Compare commits

..

3 Commits

Author SHA1 Message Date
Srikanth Chekuri
ef2ba2a46a Merge branch 'main' into issue-5388 2026-06-25 22:21:01 +05:30
srikanthccv
b91e664e14 chore: address lint 2026-06-17 21:47:17 +05:30
srikanthccv
04ec681eda fix(alerts): surface individual validation errors in API response 2026-06-17 16:41:28 +05:30
235 changed files with 4560 additions and 13104 deletions

View File

@@ -15,8 +15,6 @@
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles",
"role_details": "Role Details",
"role_edit": "Edit Role",
"role_create": "Create Role",
"members": "Members",
"service_accounts": "Service Accounts",
"mcp_server": "MCP Server"

View File

@@ -82,8 +82,6 @@
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
"ROLE_DETAILS": "SigNoz | Role Details",
"ROLE_CREATE": "SigNoz | Create Role",
"ROLE_EDIT": "SigNoz | Edit Role",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"

View File

@@ -21,8 +21,6 @@ interface ErrorInPlaceProps {
width?: string | number;
/** Custom content instead of ErrorContent */
children?: ReactNode;
/** Test ID for testing */
'data-testid'?: string;
}
/**
@@ -46,7 +44,6 @@ function ErrorInPlace({
height = '100%',
width = '100%',
children,
'data-testid': dataTestId,
}: ErrorInPlaceProps): JSX.Element {
const containerStyle: React.CSSProperties = {
display: 'flex',
@@ -62,11 +59,7 @@ function ErrorInPlace({
};
return (
<div
className={`error-in-place ${className}`.trim()}
style={containerStyle}
data-testid={dataTestId}
>
<div className={`error-in-place ${className}`.trim()} style={containerStyle}>
{children || <ErrorContent error={error} />}
</div>
);

View File

@@ -5,9 +5,13 @@ describe('PermissionDeniedFullPage', () => {
it('renders the title and subtitle with the permissionName interpolated', () => {
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
expect(screen.getByText('Uh-oh! You are not authorized')).toBeInTheDocument();
expect(
screen.getByText("Uh-oh! You don't have permission to view this page."),
).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
expect(
screen.getByText(/Please ask your SigNoz administrator to grant access/),
).toBeInTheDocument();
});
it('renders with a different permissionName', () => {

View File

@@ -2,7 +2,6 @@ import { CircleSlash2 } from '@signozhq/icons';
import styles from './PermissionDeniedFullPage.module.scss';
import { Style } from '@signozhq/design-tokens';
import { useAppContext } from 'providers/App/App';
interface PermissionDeniedFullPageProps {
permissionName: string;
@@ -11,18 +10,18 @@ interface PermissionDeniedFullPageProps {
function PermissionDeniedFullPage({
permissionName,
}: PermissionDeniedFullPageProps): JSX.Element {
const { user } = useAppContext();
return (
<div className={styles.container}>
<div className={styles.content}>
<span className={styles.icon}>
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
</span>
<p className={styles.title}>Uh-oh! You are not authorized</p>
<p className={styles.title}>
Uh-oh! You don&apos;t have permission to view this page.
</p>
<p className={styles.subtitle}>
<code className={styles.permission}>user/{user.id}</code> is not authorized
to perform <code className={styles.permission}>{permissionName}</code>
You need <code className={styles.permission}>{permissionName}</code> to
view this page. Please ask your SigNoz administrator to grant access.
</p>
</div>
</div>

View File

@@ -1,9 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Check, Copy, LockKeyhole } from '@signozhq/icons';
import { useCallback } from 'react';
import { LockKeyhole } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { useCopyToClipboard } from 'react-use';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import RolesSelect from 'components/RolesSelect';
@@ -48,23 +46,6 @@ function OverviewTab({
saveErrors = [],
}: OverviewTabProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [, copyToClipboard] = useCopyToClipboard();
const [hasCopiedId, setHasCopiedId] = useState(false);
const handleCopyId = useCallback((): void => {
if (account.id) {
copyToClipboard(account.id);
setHasCopiedId(true);
}
}, [account.id, copyToClipboard]);
useEffect(() => {
if (hasCopiedId) {
const timer = setTimeout(() => setHasCopiedId(false), 2000);
return (): void => clearTimeout(timer);
}
return undefined;
}, [hasCopiedId]);
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
@@ -112,17 +93,6 @@ function OverviewTab({
</label>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{account.id || '—'}</span>
{account.id && (
<Button
variant="link"
color="secondary"
onClick={handleCopyId}
className="sa-drawer__copy-btn"
data-testid="copy-id-btn"
>
{hasCopiedId ? <Check size={14} /> : <Copy size={14} />}
</Button>
)}
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</div>

View File

@@ -203,19 +203,6 @@
opacity: 0.6;
}
&__copy-btn {
flex-shrink: 0;
padding: 0;
height: auto;
min-height: auto;
color: var(--foreground);
opacity: 0.6;
&:hover {
opacity: 1;
}
}
&__disabled-roles {
display: flex;
flex-wrap: wrap;

View File

@@ -16,6 +16,7 @@ import {
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { GuardAuthZ } from 'components/GuardAuthZ/GuardAuthZ';
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
import { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
@@ -476,9 +477,15 @@ function ServiceAccountDrawer({
!isAccountLoading &&
!isAccountError &&
selectedAccountId && (
<>
{activeTab === ServiceAccountDrawerTab.Overview &&
(canRead && account ? (
<GuardAuthZ
relation="read"
object={`serviceaccount:${selectedAccountId}`}
fallbackOnNoPermissions={(): JSX.Element => (
<PermissionDeniedCallout permissionName="serviceaccount:read" />
)}
>
<>
{activeTab === ServiceAccountDrawerTab.Overview && account && (
<OverviewTab
account={account}
localName={localName}
@@ -497,24 +504,23 @@ function ServiceAccountDrawer({
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
) : (
<PermissionDeniedCallout permissionName="serviceaccount:read" />
))}
{activeTab === ServiceAccountDrawerTab.Keys &&
(canListKeys ? (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
canUpdate={canUpdate}
accountId={selectedAccountId}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
) : (
<PermissionDeniedCallout permissionName="factor-api-key:list" />
))}
</>
)}
{activeTab === ServiceAccountDrawerTab.Keys &&
(canListKeys ? (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
canUpdate={canUpdate}
accountId={selectedAccountId}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
) : (
<PermissionDeniedCallout permissionName="factor-api-key:list" />
))}
</>
</GuardAuthZ>
)}
</div>
</div>

View File

@@ -22,7 +22,6 @@ jest.mock('providers/Timezone', () => ({
},
updateTimezone: jest.fn(),
formatTimezoneAdjustedTimestamp: jest.fn(() => 'mock-date'),
formatTimezoneAdjustedTimestampOptional: jest.fn(() => 'mock-date'),
isAdaptationEnabled: true,
setIsAdaptationEnabled: jest.fn(),
}),

View File

@@ -56,9 +56,7 @@ const ROUTES = {
TRACE_EXPLORER: '/trace-explorer',
BILLING: '/settings/billing',
ROLES_SETTINGS: '/settings/roles',
ROLE_CREATE: '/settings/roles/new',
ROLE_DETAILS: '/settings/roles/:roleId',
ROLE_EDIT: '/settings/roles/:roleId/edit',
MEMBERS_SETTINGS: '/settings/members',
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',

View File

@@ -1,12 +1,10 @@
.members-settings-page {
.members-settings {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
height: 100%;
}
.members-settings {
&__header {
display: flex;
flex-direction: column;

View File

@@ -160,7 +160,7 @@ function MembersSettings(): JSX.Element {
}, [refetchUsers]);
return (
<div className="members-settings-page">
<>
<div className="members-settings">
<div className="members-settings__header">
<h1 className="members-settings__title">Members</h1>
@@ -231,7 +231,7 @@ function MembersSettings(): JSX.Element {
onClose={handleDrawerClose}
onComplete={handleMemberEditComplete}
/>
</div>
</>
);
}

View File

@@ -1,107 +0,0 @@
.createEditRolePage {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
width: 100%;
max-width: 1400px;
margin: 0 auto;
height: 100%;
min-height: 0;
}
.createEditRolePageHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
}
.createEditRolePageHeaderLeft {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.backButton {
--button-padding: var(--spacing-3);
}
.createEditRolePageActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedIndicator {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin-right: var(--spacing-4);
}
.unsavedDot {
display: block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
flex-shrink: 0;
}
.unsavedText {
color: var(--primary);
}
.createEditRolePageContent {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
flex: 1;
min-height: 0;
}
.createEditRolePageForm {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.formRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-8);
}
.formField {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-disabled-background: var(--l2-background);
input::placeholder {
color: var(--l3-foreground);
}
}
.formLabel {
font-family: Inter;
font-size: 12px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-20);
letter-spacing: 0.48px;
text-transform: uppercase;
color: var(--l2-foreground);
}
.createEditRolePageDivider {
width: 100%;
height: 1px;
background: var(--l1-border);
}

View File

@@ -1,291 +0,0 @@
import { useCallback, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { ArrowLeft, SolidAlertTriangle } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import ROUTES from 'constants/routes';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import useUrlQuery from 'hooks/useUrlQuery';
import APIError from 'types/api/error';
import PermissionEditor from './components/PermissionEditor';
import { useCreateEditRolePageActions } from './useCreateEditRolePageActions';
import { useNavigationBlocker } from '../../../hooks/useNavigationBlocker';
import styles from './CreateEditRolePage.module.scss';
function CreateEditRolePage(): JSX.Element {
const history = useHistory();
const { pathname } = useLocation();
const urlQuery = useUrlQuery();
const match = matchPath<{ roleId: string }>(pathname, {
path: ROUTES.ROLE_DETAILS,
});
const roleId = match?.params?.roleId ?? 'new';
const roleName = urlQuery.get('name') ?? '';
const [hasJsonError, setHasJsonError] = useState(false);
const { isRolesEnabled, isLoading: isFeatureGateLoading } =
useRolesFeatureGate();
const {
formData,
editorMode,
setEditorMode,
resources,
setResources,
isLoading,
isSaving,
hasUnsavedChanges,
handleSave,
handleCancel,
handleFormChange,
saveError,
validationErrors,
isCreateMode,
hasRequiredPermission,
isAuthZLoading,
deniedPermission,
loadError,
} = useCreateEditRolePageActions(roleId, roleName);
const { isBlocked, confirmNavigation, cancelNavigation, allowNextNavigation } =
useNavigationBlocker(hasUnsavedChanges);
const handleSaveAndNavigate = useCallback(async (): Promise<void> => {
if (hasJsonError) {
return;
}
const success = await handleSave();
if (success) {
allowNextNavigation();
if (isCreateMode) {
history.push(ROUTES.ROLES_SETTINGS);
} else {
const viewUrl = `${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}?name=${encodeURIComponent(roleName)}`;
history.push(viewUrl);
}
}
}, [
handleSave,
allowNextNavigation,
history,
hasJsonError,
isCreateMode,
roleId,
roleName,
]);
if (!hasRequiredPermission && !isAuthZLoading) {
return <PermissionDeniedFullPage permissionName={deniedPermission} />;
}
if (!isRolesEnabled && !isFeatureGateLoading) {
return (
<div
className={styles.createEditRolePage}
data-testid="create-edit-role-page"
>
<div className={styles.createEditRolePageHeader}>
<div className={styles.createEditRolePageHeaderLeft}>
<Button
variant="ghost"
color="secondary"
onClick={handleCancel}
data-testid="cancel-button"
className={styles.backButton}
>
<ArrowLeft size={16} />
</Button>
<Typography.Title level={3}>
{isCreateMode ? 'Create Role' : 'Edit Role'}
</Typography.Title>
</div>
</div>
<ErrorInPlace
error={
new APIError({
httpStatusCode: 403,
error: {
code: 'FEATURE_DISABLED',
message:
'Custom roles feature is not available. Please check your license or feature configuration.',
url: '',
errors: [],
},
})
}
data-testid="feature-gate-error-banner"
/>
</div>
);
}
if (isAuthZLoading || (isLoading && !isCreateMode) || isFeatureGateLoading) {
return (
<div className={styles.createEditRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
if (loadError) {
return (
<div
className={styles.createEditRolePage}
data-testid="create-edit-role-page"
>
<div className={styles.createEditRolePageHeader}>
<div className={styles.createEditRolePageHeaderLeft}>
<Button
variant="ghost"
color="secondary"
onClick={handleCancel}
disabled={isSaving}
data-testid="cancel-button"
className={styles.backButton}
>
<ArrowLeft size={16} />
</Button>
<Typography.Title level={3}>Failed to load role</Typography.Title>
</div>
</div>
<ErrorInPlace error={loadError} data-testid="role-load-error-banner" />
</div>
);
}
return (
<div
className={styles.createEditRolePage}
data-testid="create-edit-role-page"
>
<div className={styles.createEditRolePageHeader}>
<div className={styles.createEditRolePageHeaderLeft}>
<Button
variant="ghost"
color="secondary"
onClick={handleCancel}
disabled={isSaving}
data-testid="cancel-button"
className={styles.backButton}
>
<ArrowLeft size={16} />
</Button>
<Typography.Title level={3}>
{isCreateMode
? 'Create Role'
: `Role - ${formData.name || 'Loading role...'}`}
</Typography.Title>
</div>
<div className={styles.createEditRolePageActions}>
{hasUnsavedChanges && (
<div className={styles.unsavedIndicator}>
<span className={styles.unsavedDot} />
<Typography as="span" size="base" className={styles.unsavedText}>
Unsaved changes
</Typography>
</div>
)}
<Button
variant="solid"
color="primary"
onClick={handleSaveAndNavigate}
loading={isSaving}
disabled={!hasUnsavedChanges || hasJsonError}
data-testid="save-button"
>
{isCreateMode ? 'Create role' : 'Save changes'}
</Button>
</div>
</div>
{saveError && (
<ErrorInPlace
error={saveError}
height="auto"
bordered
data-testid="save-error-banner"
/>
)}
<div className={styles.createEditRolePageContent}>
<div className={styles.createEditRolePageForm}>
<div className={styles.formRow}>
{isCreateMode ? (
<div className={styles.formField}>
<label htmlFor="role-name" className={styles.formLabel}>
Name
</label>
<Input
id="role-name"
value={formData.name}
onChange={(e): void => handleFormChange('name', e.target.value)}
placeholder="my-custom-role"
data-testid="role-name-input"
/>
</div>
) : null}
<div className={styles.formField}>
<label htmlFor="role-description" className={styles.formLabel}>
Description
</label>
<Input
id="role-description"
value={formData.description}
onChange={(e): void => handleFormChange('description', e.target.value)}
placeholder="Custom role for the support team"
data-testid="role-description-input"
/>
</div>
</div>
</div>
<div className={styles.createEditRolePageDivider} />
<PermissionEditor
resources={resources}
mode={editorMode}
onModeChange={setEditorMode}
onResourceChange={setResources}
onJsonValidityChange={setHasJsonError}
isLoading={isLoading}
validationErrors={validationErrors}
/>
</div>
<ConfirmDialog
open={isBlocked}
onOpenChange={(next): void => {
if (!next) {
cancelNavigation();
}
}}
title="Discard unsaved changes?"
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
confirmText="Discard"
confirmColor="destructive"
cancelText="Keep editing"
onConfirm={confirmNavigation}
onCancel={cancelNavigation}
data-testid="discard-changes-dialog"
>
<Typography>
{isCreateMode
? 'This new role will not be created.'
: 'Your unsaved changes will be lost.'}
</Typography>
</ConfirmDialog>
</div>
);
}
export default CreateEditRolePage;

View File

@@ -1,139 +0,0 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { FeatureKeys } from 'constants/features';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.clearAllMocks();
});
function renderCreatePage(
appContextOverrides?: Record<string, unknown>,
): ReturnType<typeof render> {
return render(
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_CREATE}>
<CreateEditRolePage />
</Route>
</Switch>,
undefined,
{ initialRoute: '/settings/roles/new', appContextOverrides },
);
}
function renderEditPage(
roleId: string,
roleName: string,
appContextOverrides?: Record<string, unknown>,
): ReturnType<typeof render> {
return render(
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_EDIT}>
<CreateEditRolePage />
</Route>
</Switch>,
undefined,
{
initialRoute: `/settings/roles/${roleId}/edit?name=${encodeURIComponent(roleName)}`,
appContextOverrides,
},
);
}
describe('CreateEditRolePage - Feature Gate', () => {
describe('create mode - feature disabled', () => {
it('shows error when fine-grained authz flag is inactive', async () => {
renderCreatePage({
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
});
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows error when license is invalid', async () => {
renderCreatePage({ activeLicense: invalidLicense });
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows Create Role title when feature disabled in create mode', async () => {
renderCreatePage({ activeLicense: invalidLicense });
await expect(screen.findByText('Create Role')).resolves.toBeInTheDocument();
});
it('shows back button when feature disabled', () => {
renderCreatePage({ activeLicense: invalidLicense });
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('back button is enabled when feature disabled', () => {
renderCreatePage({ activeLicense: invalidLicense });
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
});
});
describe('edit mode - feature disabled', () => {
const ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
const ROLE_NAME = 'test-role';
it('shows error when fine-grained authz flag is inactive', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
});
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows error when license is invalid', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows Edit Role title when feature disabled in edit mode', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });
await expect(screen.findByText('Edit Role')).resolves.toBeInTheDocument();
});
});
});

View File

@@ -1,59 +0,0 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { render, screen } from 'tests/test-utils';
import { mockUseAuthZDenyAll } from 'tests/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
afterEach(() => {
jest.clearAllMocks();
});
function renderCreatePage(): ReturnType<typeof render> {
return render(
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_CREATE}>
<CreateEditRolePage />
</Route>
</Switch>,
undefined,
{ initialRoute: '/settings/roles/new' },
);
}
describe('CreateRolePage - AuthZ', () => {
describe('permission denied', () => {
it('shows PermissionDeniedFullPage when create permission denied', async () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderCreatePage();
await expect(
screen.findByText(/You are not authorized/i),
).resolves.toBeInTheDocument();
});
});
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
mockUseAuthZ.mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
refetchPermissions: jest.fn(),
});
renderCreatePage();
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
});

View File

@@ -1,284 +0,0 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const rolesApiBase = '*/api/v1/roles';
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
function renderCreatePage(): ReturnType<typeof render> {
return render(
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_CREATE}>
<CreateEditRolePage />
</Route>
</Switch>,
undefined,
{ initialRoute: '/settings/roles/new' },
);
}
describe('CreateRolePage', () => {
describe('initial render', () => {
it('renders create role page with testId', () => {
renderCreatePage();
expect(screen.getByTestId('create-edit-role-page')).toBeInTheDocument();
});
it('shows breadcrumb with "Create role" as current page', () => {
renderCreatePage();
const page = screen.getByTestId('create-edit-role-page');
const breadcrumbs = within(page).getAllByText('Create role');
expect(breadcrumbs.length).toBeGreaterThanOrEqual(1);
});
it('renders empty name input', () => {
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
expect(nameInput).toHaveValue('');
});
it('renders empty description input', () => {
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
expect(descInput).toHaveValue('');
});
it('name input is enabled in create mode', () => {
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
expect(nameInput).not.toBeDisabled();
});
it('save button shows "Create role" text', () => {
renderCreatePage();
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).toHaveTextContent('Create role');
});
it('save button is disabled when no changes', () => {
renderCreatePage();
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).toBeDisabled();
});
it('does not show unsaved indicator initially', () => {
renderCreatePage();
expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
});
});
describe('form interactions', () => {
it('enables save button when name is entered', async () => {
const user = userEvent.setup();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'test-role');
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).not.toBeDisabled();
});
it('shows unsaved indicator when form modified', async () => {
const user = userEvent.setup();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'my-role');
await expect(
screen.findByText('Unsaved changes'),
).resolves.toBeInTheDocument();
});
it('enables save button when description is entered', async () => {
const user = userEvent.setup();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Some description');
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).not.toBeDisabled();
});
});
describe('cancel action', () => {
it('navigates to roles list on cancel', async () => {
const user = userEvent.setup();
renderCreatePage();
const cancelBtn = screen.getByTestId('cancel-button');
await user.click(cancelBtn);
await expect(
screen.findByTestId('roles-list-redirect'),
).resolves.toBeInTheDocument();
});
});
describe('create success flow', () => {
it('calls create API with form data and redirects', async () => {
const createSpy = jest.fn();
server.use(
rest.post(rolesApiBase, async (req, res, ctx) => {
createSpy(await req.json());
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: { id: 'new-role-id', name: 'my-custom-role' },
}),
);
}),
);
const user = userEvent.setup();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'my-custom-role');
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Role for testing');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await waitFor(() => {
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'my-custom-role',
description: 'Role for testing',
}),
);
});
await expect(
screen.findByTestId('roles-list-redirect'),
).resolves.toBeInTheDocument();
});
});
describe('create error flows', () => {
it('does not call API when name is empty', async () => {
const createSpy = jest.fn();
server.use(
rest.post(rolesApiBase, async (req, res, ctx) => {
createSpy();
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await waitFor(
() => {
expect(createSpy).not.toHaveBeenCalled();
},
{ timeout: 500 },
);
});
it('shows error banner when API fails', async () => {
server.use(
rest.post(rolesApiBase, (_req, res, ctx) =>
res(
ctx.status(400),
ctx.json({
error: { message: 'Role name already exists' },
}),
),
),
);
const user = userEvent.setup();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'duplicate-role');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Role name already exists'),
).resolves.toBeInTheDocument();
});
});
describe('validation errors', () => {
it('shows validation error when Only Selected has no items', async () => {
const user = userEvent.setup();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
const apiKeysCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeysCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const readToggle = within(apiKeysCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const onlySelectedBtn = await within(readToggle).findByText('Only selected');
await user.click(onlySelectedBtn);
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(
'Please add at least one selector for each "Only selected" permission.',
),
).resolves.toBeInTheDocument();
});
});
});

View File

@@ -1,88 +0,0 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { render, screen } from 'tests/test-utils';
import {
mockUseAuthZDenyAll,
mockUseAuthZGrantByPrefix,
} from 'tests/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const EDIT_ROLE_ID = 'test-role-123';
const EDIT_ROLE_NAME = 'test-role';
afterEach(() => {
jest.clearAllMocks();
});
function renderEditPage(): ReturnType<typeof render> {
return render(
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_DETAILS}>
<CreateEditRolePage />
</Route>
</Switch>,
undefined,
{ initialRoute: `/settings/roles/${EDIT_ROLE_ID}?name=${EDIT_ROLE_NAME}` },
);
}
describe('EditRolePage - AuthZ', () => {
describe('permission denied', () => {
it('shows PermissionDeniedFullPage when read permission denied', async () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderEditPage();
await expect(
screen.findByText(/You are not authorized/i),
).resolves.toBeInTheDocument();
});
it('shows PermissionDeniedFullPage when update permission denied but read granted', async () => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantByPrefix('read'));
renderEditPage();
await expect(
screen.findByText(/You are not authorized/i),
).resolves.toBeInTheDocument();
});
it('checks both read and update permissions for edit mode', () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderEditPage();
expect(mockUseAuthZ).toHaveBeenCalledWith(
expect.arrayContaining([
expect.stringContaining('read'),
expect.stringContaining('update'),
]),
);
});
});
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
mockUseAuthZ.mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
refetchPermissions: jest.fn(),
});
renderEditPage();
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
});

View File

@@ -1,398 +0,0 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
const rolesApiBase = '*/api/v1/roles';
const roleWithTransactionGroups = {
status: 'success',
data: {
...customRoleResponse.data,
transactionGroups: [
{
objectGroup: {
resource: { kind: 'role', type: 'role' },
selectors: ['*'],
},
relation: 'read',
},
],
},
};
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(roleWithTransactionGroups)),
),
);
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
function renderEditPage(roleId = CUSTOM_ROLE_ID): ReturnType<typeof render> {
return render(
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_DETAILS} exact>
<div data-testid="role-details-redirect" />
</Route>
<Route path={ROUTES.ROLE_EDIT}>
<CreateEditRolePage />
</Route>
</Switch>,
undefined,
{ initialRoute: `/settings/roles/${roleId}/edit?name=Custom%20Role` },
);
}
describe('EditRolePage', () => {
describe('loading state', () => {
it('shows skeleton while fetching role data', () => {
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.delay(200), ctx.status(200), ctx.json(roleWithTransactionGroups)),
),
);
renderEditPage();
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
describe('load error state', () => {
it('shows error banner when role load fails', async () => {
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ error: { message: 'Server error' } })),
),
);
renderEditPage();
await waitFor(() => {
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
});
it('shows Failed to load role title on load error', async () => {
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(404), ctx.json({ error: { message: 'Not found' } })),
),
);
renderEditPage();
await expect(
screen.findByText('Failed to load role'),
).resolves.toBeInTheDocument();
});
it('shows back button on load error', async () => {
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ error: { message: 'Server error' } })),
),
);
renderEditPage();
await waitFor(() => {
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
});
it('navigates to view page when cancel clicked in error state', async () => {
const user = userEvent.setup();
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ error: { message: 'Server error' } })),
),
);
renderEditPage();
await waitFor(async () => {
const cancelButton = await screen.findByTestId('cancel-button');
await user.click(cancelButton);
});
await expect(
screen.findByTestId('role-details-redirect'),
).resolves.toBeInTheDocument();
});
});
describe('initial render with loaded data', () => {
it('shows role name in page title', async () => {
renderEditPage();
await expect(
screen.findByText('Role - billing-manager'),
).resolves.toBeInTheDocument();
});
it('name input is not shown in edit mode', async () => {
renderEditPage();
await waitFor(() => {
expect(screen.queryByTestId('role-name-input')).not.toBeInTheDocument();
});
});
it('populates description input with existing value', async () => {
renderEditPage();
await waitFor(async () => {
const descInput = await screen.findByTestId('role-description-input');
expect(descInput).toHaveValue(
'Custom role for managing billing and invoices.',
);
});
});
it('description input is enabled in edit mode', async () => {
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
expect(descInput).not.toBeDisabled();
});
it('save button shows "Save changes" text', async () => {
renderEditPage();
const saveBtn = await screen.findByTestId('save-button');
expect(saveBtn).toHaveTextContent('Save changes');
});
it('save button is disabled when no unsaved changes', async () => {
renderEditPage();
await waitFor(async () => {
const descInput = await screen.findByTestId('role-description-input');
expect(descInput).toHaveValue(
'Custom role for managing billing and invoices.',
);
});
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).toBeDisabled();
});
});
describe('form interactions', () => {
it('enables save button when description is modified', async () => {
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.clear(descInput);
await user.type(descInput, 'New description');
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).not.toBeDisabled();
});
it('shows unsaved indicator when description modified', async () => {
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.type(descInput, ' updated');
await expect(
screen.findByText('Unsaved changes'),
).resolves.toBeInTheDocument();
});
it('disables save when changes reverted to original', async () => {
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
const originalValue = 'Custom role for managing billing and invoices.';
await user.clear(descInput);
await user.type(descInput, 'Temporary change');
expect(screen.getByTestId('save-button')).not.toBeDisabled();
await user.clear(descInput);
await user.type(descInput, originalValue);
await waitFor(() => {
expect(screen.getByTestId('save-button')).toBeDisabled();
});
});
});
describe('cancel action', () => {
it('navigates to view role page on cancel', async () => {
const user = userEvent.setup();
renderEditPage();
await screen.findByTestId('role-description-input');
const cancelBtn = screen.getByTestId('cancel-button');
await user.click(cancelBtn);
await expect(
screen.findByTestId('role-details-redirect'),
).resolves.toBeInTheDocument();
});
});
describe('update success flow', () => {
it('redirects to view page after successful update', async () => {
server.use(
rest.put(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
);
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.clear(descInput);
await user.type(descInput, 'Updated description');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('role-details-redirect'),
).resolves.toBeInTheDocument();
});
it('calls update API when save clicked', async () => {
const updateSpy = jest.fn();
server.use(
rest.put(`${rolesApiBase}/:id`, async (req, res, ctx) => {
updateSpy();
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.type(descInput, ' edited');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await waitFor(() => {
expect(updateSpy).toHaveBeenCalled();
});
});
});
describe('update error flow', () => {
it('shows error banner when update fails with 500', async () => {
server.use(
rest.put(`${rolesApiBase}/:id`, (_req, res, ctx) => res(ctx.status(500))),
);
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.type(descInput, ' changed');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
});
it('shows error banner when update fails with 403', async () => {
server.use(
rest.put(`${rolesApiBase}/:id`, (_req, res, ctx) => res(ctx.status(403))),
);
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.type(descInput, ' test');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
});
it('shows error banner when update fails with 400', async () => {
server.use(
rest.put(`${rolesApiBase}/:id`, (_req, res, ctx) => res(ctx.status(400))),
);
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.type(descInput, ' x');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
});
});
describe('permission changes', () => {
it('detects permission change as unsaved', async () => {
const user = userEvent.setup();
renderEditPage();
await screen.findByTestId('permission-editor');
const apiKeysCard = await screen.findByTestId(
'resource-card-factor-api-key',
);
const header = within(apiKeysCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const createToggle = within(apiKeysCard).getByTestId(
'action-toggle-factor-api-key-create',
);
const allBtn = await within(createToggle).findByText('All');
await user.click(allBtn);
await expect(
screen.findByText('Unsaved changes'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('save-button')).not.toBeDisabled();
});
});
});

View File

@@ -1,218 +0,0 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { render, screen, userEvent, within } from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
import { TooltipProvider } from '@signozhq/ui/tooltip';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.clearAllMocks();
});
function renderPage(): ReturnType<typeof render> {
return render(
<TooltipProvider>
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_CREATE}>
<CreateEditRolePage />
</Route>
</Switch>
</TooltipProvider>,
undefined,
{ initialRoute: '/settings/roles/new' },
);
}
async function switchToJsonMode(): Promise<void> {
const user = userEvent.setup();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
}
async function switchToInteractiveMode(): Promise<void> {
const user = userEvent.setup();
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
);
await user.click(interactiveRadio);
}
describe('JsonEditor', () => {
describe('initial render', () => {
it('renders JSON editor when JSON mode selected', async () => {
renderPage();
await switchToJsonMode();
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
});
it('renders JSON editor container div', async () => {
renderPage();
await switchToJsonMode();
const jsonEditor = screen.getByTestId('json-editor');
expect(jsonEditor.querySelector('div')).toBeInTheDocument();
});
});
describe('sync with interactive mode', () => {
it('syncs changes from interactive mode when switching to JSON', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByTestId('permission-editor');
await switchToInteractiveMode();
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-create',
);
await user.click(await within(createToggle).findByText('All'));
await switchToJsonMode();
const jsonEditor = screen.getByTestId('json-editor');
expect(jsonEditor).toBeInTheDocument();
});
it('preserves changes when switching back to interactive', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByTestId('permission-editor');
await switchToInteractiveMode();
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-create',
);
await user.click(await within(createToggle).findByText('All'));
await switchToJsonMode();
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
);
await user.click(interactiveRadio);
const scopeToggle = within(
screen.getByTestId('action-toggle-factor-api-key-create'),
).getByTestId('action-toggle-scope-factor-api-key-create');
expect(
within(scopeToggle).getByRole('radio', { name: 'All' }),
).toBeChecked();
});
});
describe('error handling', () => {
it('no error shown initially with valid JSON', async () => {
renderPage();
await switchToJsonMode();
expect(screen.queryByTestId('json-editor-error')).not.toBeInTheDocument();
});
});
describe('JSON structure', () => {
it('produces valid transactionGroups format', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByTestId('permission-editor');
await switchToInteractiveMode();
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(readToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'test-key-123{enter}');
await switchToJsonMode();
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
});
it('handles wildcard selector for All scope', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByTestId('permission-editor');
await switchToInteractiveMode();
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-create',
);
await user.click(await within(createToggle).findByText('All'));
await switchToJsonMode();
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
});
});
describe('mode switching', () => {
it('reinitializes JSON buffer on switch from interactive to JSON', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByTestId('permission-editor');
await switchToJsonMode();
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
);
await user.click(interactiveRadio);
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(readToggle).findByText('All'));
await switchToJsonMode();
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
});
});
});

View File

@@ -1,523 +0,0 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
const expandButton = screen.getByTestId('expand-all-button');
await user.click(expandButton);
}
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.clearAllMocks();
});
function renderPage(): ReturnType<typeof render> {
return render(
<TooltipProvider>
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_CREATE}>
<CreateEditRolePage />
</Route>
</Switch>
</TooltipProvider>,
undefined,
{ initialRoute: '/settings/roles/new' },
);
}
describe('PermissionEditor', () => {
describe('mode toggle', () => {
it('renders permission editor with testId', () => {
renderPage();
expect(screen.getByTestId('permission-editor')).toBeInTheDocument();
});
it('defaults to interactive mode', () => {
renderPage();
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
);
expect(interactiveRadio).toBeChecked();
});
it('switches to JSON mode when clicked', async () => {
const user = userEvent.setup();
renderPage();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
expect(jsonRadio).toBeChecked();
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
});
it('switches back to interactive mode', async () => {
const user = userEvent.setup();
renderPage();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
);
await user.click(interactiveRadio);
expect(interactiveRadio).toBeChecked();
expect(screen.queryByTestId('json-editor')).not.toBeInTheDocument();
});
});
describe('resource cards', () => {
it('renders all resource cards', () => {
renderPage();
expect(
screen.getByTestId('resource-card-factor-api-key'),
).toBeInTheDocument();
expect(screen.getByTestId('resource-card-role')).toBeInTheDocument();
expect(
screen.getByTestId('resource-card-serviceaccount'),
).toBeInTheDocument();
});
it('resource cards are collapsed by default', () => {
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
expect(header).toHaveAttribute('aria-expanded', 'false');
});
it('expands resource card when header clicked', async () => {
const user = userEvent.setup();
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
expect(header).toHaveAttribute('aria-expanded', 'true');
});
it('collapses expanded resource card when header clicked again', async () => {
const user = userEvent.setup();
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
await user.click(header);
expect(header).toHaveAttribute('aria-expanded', 'false');
});
it('shows granted count in resource card header', async () => {
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
await expect(
within(apiKeyCard).findByText(/0 \/ \d+ granted/),
).resolves.toBeInTheDocument();
});
});
describe('action toggles', () => {
it('renders action toggles for each available action', async () => {
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
expect(
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-read'),
).toBeInTheDocument();
expect(
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-read'),
).toBeInTheDocument();
expect(
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-update'),
).toBeInTheDocument();
expect(
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-delete'),
).toBeInTheDocument();
});
it('defaults all actions to None scope', async () => {
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const scopeToggle = within(createToggle).getByTestId(
'action-toggle-scope-factor-api-key-read',
);
expect(
within(scopeToggle).getByRole('radio', { name: 'None' }),
).toBeChecked();
});
it('changes scope to All when clicked', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const allBtn = await within(createToggle).findByText('All');
await user.click(allBtn);
const scopeToggle = within(createToggle).getByTestId(
'action-toggle-scope-factor-api-key-read',
);
expect(
within(scopeToggle).getByRole('radio', { name: 'All' }),
).toBeChecked();
});
it('updates granted count when scope changed', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('All'));
await expect(
within(apiKeyCard).findByText(/1 \/ \d+ granted/),
).resolves.toBeInTheDocument();
});
});
describe('Only Selected scope', () => {
it('shows item input selector when Only Selected is chosen', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const onlySelectedBtn =
await within(createToggle).findByText('Only selected');
await user.click(onlySelectedBtn);
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
});
it('adds item when typed and Enter pressed', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'api-key-001{enter}');
await expect(screen.findByText('api-key-001')).resolves.toBeInTheDocument();
});
it('adds item when Add button clicked', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'api-key-002');
const addBtn = screen.getByTestId('item-input-selector-add-btn');
await user.click(addBtn);
await expect(screen.findByText('api-key-002')).resolves.toBeInTheDocument();
});
it('adds multiple items separated by comma', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'key-a, key-b, key-c{enter}');
await expect(screen.findByText('key-a')).resolves.toBeInTheDocument();
await expect(screen.findByText('key-b')).resolves.toBeInTheDocument();
await expect(screen.findByText('key-c')).resolves.toBeInTheDocument();
});
it('adds multiple items separated by space', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'key-x key-y key-z{enter}');
await expect(screen.findByText('key-x')).resolves.toBeInTheDocument();
await expect(screen.findByText('key-y')).resolves.toBeInTheDocument();
await expect(screen.findByText('key-z')).resolves.toBeInTheDocument();
});
it('does not add duplicate items', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'same-key{enter}');
await user.type(input, 'same-key{enter}');
const badges = screen.getAllByText('same-key');
expect(badges).toHaveLength(1);
});
it('removes item when X clicked', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'removable-key{enter}');
const removeBtn = screen.getByRole('button', {
name: /remove removable-key/i,
});
await user.click(removeBtn);
expect(screen.queryByText('removable-key')).not.toBeInTheDocument();
});
it('shows Add button disabled when input is empty', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const addBtn = screen.getByTestId('item-input-selector-add-btn');
expect(addBtn).toBeDisabled();
});
});
describe('scope change confirmation dialog', () => {
it('shows confirm dialog when leaving Only Selected with items', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'will-be-cleared{enter}');
await user.click(await within(createToggle).findByText('All'));
await expect(
screen.findByText('Change permission scope?'),
).resolves.toBeInTheDocument();
});
it('clears items when confirmed', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'to-be-cleared{enter}');
await user.click(await within(createToggle).findByText('All'));
const dialog = await screen.findByRole('dialog');
await user.click(
within(dialog).getByRole('button', { name: /change scope/i }),
);
await waitFor(() => {
expect(screen.queryByText('to-be-cleared')).not.toBeInTheDocument();
});
});
it('keeps items when cancelled', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'preserved-key{enter}');
await user.click(await within(createToggle).findByText('None'));
const dialog = await screen.findByRole('dialog');
await user.click(within(dialog).getByRole('button', { name: /cancel/i }));
await expect(
screen.findByText('preserved-key'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
});
it('does not show dialog when leaving Only Selected with no items', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
await user.click(await within(createToggle).findByText('All'));
expect(
screen.queryByText('Change permission scope?'),
).not.toBeInTheDocument();
});
});
describe('verbs without Only Selected option', () => {
it('does not show Only Selected for list verb', async () => {
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const listToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-list',
);
expect(
within(listToggle).queryByText('Only selected'),
).not.toBeInTheDocument();
await expect(
within(listToggle).findByText('None'),
).resolves.toBeInTheDocument();
await expect(
within(listToggle).findByText('All'),
).resolves.toBeInTheDocument();
});
});
describe('collapse/expand all resources', () => {
it('shows expand/collapse toggle group', () => {
renderPage();
expect(screen.getByTestId('toggle-all-group')).toBeInTheDocument();
expect(screen.getByTestId('expand-all-button')).toBeInTheDocument();
expect(screen.getByTestId('collapse-all-button')).toBeInTheDocument();
});
it('expands all cards when expand button clicked', async () => {
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
expect(header).toHaveAttribute('aria-expanded', 'true');
});
});
});

View File

@@ -1,65 +0,0 @@
.actionToggle {
position: relative;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-6) var(--spacing-8);
&::after {
content: '';
position: absolute;
right: var(--spacing-8);
bottom: 0;
left: var(--spacing-8);
height: 1px;
background: var(--l2-border);
}
&:last-child::after {
display: none;
}
}
.actionToggleHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
}
.actionToggleScopeToggle {
flex-shrink: 0;
width: fit-content;
--toggle-group-item-size: 1.4rem;
--toggle-group-item-padding-right: 0.4rem;
--toggle-group-item-border-style: none;
--toggle-group-secondary-active-bg: var(--bg-robin-800);
--toggle-group-item-align-items: baseline;
gap: var(--spacing-2);
padding: var(--spacing-2);
button {
min-width: 46px;
font-weight: bold;
&[data-state='on'] {
color: var(--bg-robin-200);
}
&:focus-visible {
outline: 2px solid var(--bg-robin-500);
outline-offset: 2px;
border-radius: var(--radius-2);
}
}
}
.actionToggleSelectorWrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
--divider-color: var(--l2-border);
}

View File

@@ -1,160 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Divider } from '@signozhq/ui/divider';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import { PermissionScope } from '../../types';
import { getResourcePanel } from '../../permissions.config';
import ItemInputSelector from './ItemInputSelector';
import styles from './ActionToggle.module.scss';
import { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import { getActionLabel } from 'container/RolesSettings/ViewRolePage/components/permissionDisplay.utils';
const SCOPE_LABELS: Record<PermissionScope, string> = {
[PermissionScope.NONE]: 'None',
[PermissionScope.ALL]: 'All',
[PermissionScope.ONLY_SELECTED]: 'Only selected',
};
interface ActionToggleProps {
action: AuthZVerb;
scope: string;
selectedIds: string[];
resource: AuthZResource;
canSelectIndividually: boolean;
onScopeChange: (scope: PermissionScope) => void;
onSelectedIdsChange: (ids: string[]) => void;
hasError?: boolean;
}
function ActionToggle({
action,
scope,
selectedIds,
resource,
canSelectIndividually,
onScopeChange,
onSelectedIdsChange,
hasError = false,
}: ActionToggleProps): JSX.Element {
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [pendingScope, setPendingScope] = useState<PermissionScope | null>(null);
const displayLabel = getActionLabel(action);
const scopeItems: Array<{ value: PermissionScope; label: string }> =
useMemo(() => {
const items = [
{ value: PermissionScope.NONE, label: SCOPE_LABELS[PermissionScope.NONE] },
{ value: PermissionScope.ALL, label: SCOPE_LABELS[PermissionScope.ALL] },
];
if (canSelectIndividually) {
items.push({
value: PermissionScope.ONLY_SELECTED,
label: SCOPE_LABELS[PermissionScope.ONLY_SELECTED],
});
}
return items;
}, [canSelectIndividually]);
const handleToggleChange = useCallback(
(value: string): void => {
if (!value) {
return;
}
const isLeavingOnlySelected =
scope === PermissionScope.ONLY_SELECTED &&
value !== PermissionScope.ONLY_SELECTED;
const hasSelectedItems = selectedIds.length > 0;
if (isLeavingOnlySelected && hasSelectedItems) {
setPendingScope(value as PermissionScope);
setConfirmDialogOpen(true);
return;
}
onScopeChange(value as PermissionScope);
},
[scope, selectedIds.length, onScopeChange],
);
const handleConfirmScopeChange = useCallback((): void => {
if (pendingScope) {
onSelectedIdsChange([]);
onScopeChange(pendingScope);
}
setConfirmDialogOpen(false);
setPendingScope(null);
}, [pendingScope, onSelectedIdsChange, onScopeChange]);
const handleCancelScopeChange = useCallback((): void => {
setConfirmDialogOpen(false);
setPendingScope(null);
}, []);
return (
<>
<div
className={styles.actionToggle}
data-testid={`action-toggle-${resource}-${action}`}
>
<div className={styles.actionToggleHeader}>
<Typography as="span" size="base">
{displayLabel}
</Typography>
<ToggleGroupSimple
type="single"
size="sm"
value={scope}
onChange={handleToggleChange}
items={scopeItems}
className={styles.actionToggleScopeToggle}
testId={`action-toggle-scope-${resource}-${action}`}
/>
</div>
{scope === PermissionScope.ONLY_SELECTED && (
<div className={styles.actionToggleSelectorWrapper}>
<Divider />
<ItemInputSelector
placeholder={getResourcePanel(resource).selectorPlaceholder}
selectedIds={selectedIds}
onChange={onSelectedIdsChange}
docsAnchor={getResourcePanel(resource).docsAnchor}
hasError={hasError}
/>
</div>
)}
</div>
<ConfirmDialog
open={confirmDialogOpen}
onOpenChange={(next): void => {
if (!next) {
handleCancelScopeChange();
}
}}
title="Change permission scope?"
confirmText="Change scope"
cancelText="Cancel"
onConfirm={handleConfirmScopeChange}
onCancel={handleCancelScopeChange}
>
<Typography>
You have {selectedIds.length} item{selectedIds.length > 1 ? 's' : ''}{' '}
selected. Changing the scope will clear your current items.
<br />
<br />
Don&apos;t worry, this doesn&apos;t update this role yet, it only confirms
that you want to clear the items.
</Typography>
</ConfirmDialog>
</>
);
}
export default ActionToggle;

View File

@@ -1,105 +0,0 @@
.itemInputSelector {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
background: var(--l1-background);
border: 1px solid var(--l1-border);
border-radius: 4px;
padding: var(--spacing-4);
--input-suffix-padding: var(--spacing-2);
}
.itemInputSelectorError {
border-color: var(--destructive);
}
.itemInputSelectorFooter {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--spacing-3);
padding-top: var(--spacing-3);
&::before {
content: '';
position: absolute;
top: 0;
right: 0;
left: 0;
height: 1px;
background: var(--l1-border);
}
}
.itemInputSelectorBadges {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
max-height: 60px;
overflow-y: auto;
}
.itemInputSelectorInfoIcon {
flex-shrink: 0;
color: var(--l2-foreground);
cursor: pointer;
&:hover {
color: var(--l1-foreground);
}
}
.itemInputSelectorBadge {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 2px 4px 2px 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
}
.itemInputSelectorBadgeLabel {
flex: 1;
min-width: 0;
}
.itemInputSelectorBadgeRemove {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
background: transparent;
border: none;
border-radius: 2px;
color: var(--l2-foreground);
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--l1-background);
color: var(--l1-foreground);
}
}
.itemInputSelectorHint {
margin: 0;
color: var(--l2-foreground);
a {
color: var(--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -1,206 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { Info, Plus, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import styles from './ItemInputSelector.module.scss';
const BASE_DOCS_URL =
'https://signoz.io/docs/manage/administrator-guide/iam/permissions/';
export interface ItemInputSelectorProps {
placeholder: string;
selectedIds: string[];
onChange: (ids: string[]) => void;
docsAnchor?: string;
hasError?: boolean;
}
function parseInputValues(input: string): string[] {
return input
.split(/[\s,]+/)
.map((v) => v.trim())
.filter(Boolean);
}
function ItemInputSelector({
placeholder,
selectedIds,
onChange,
docsAnchor = 'role',
hasError = false,
}: ItemInputSelectorProps): JSX.Element {
const [inputValue, setInputValue] = useState('');
const footerRef = useRef<HTMLDivElement>(null);
const addValues = useCallback(
(input: string): void => {
const values = parseInputValues(input);
if (values.length === 0) {
return;
}
const existingSet = new Set(selectedIds);
const newIds = values.filter((v) => !existingSet.has(v));
if (newIds.length > 0) {
onChange([...selectedIds, ...newIds]);
}
setInputValue('');
},
[selectedIds, onChange],
);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
setInputValue(e.target.value);
},
[],
);
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
e.preventDefault();
addValues(inputValue);
}
},
[inputValue, addValues],
);
const handleInputBlur = useCallback((): void => {
addValues(inputValue);
}, [inputValue, addValues]);
const handleAddClick = useCallback((): void => {
addValues(inputValue);
}, [inputValue, addValues]);
const handleRemove = useCallback(
(itemId: string): void => {
onChange(selectedIds.filter((id) => id !== itemId));
},
[selectedIds, onChange],
);
const handleBadgeKeyDown = useCallback(
(
e: React.KeyboardEvent<HTMLButtonElement>,
itemId: string,
index: number,
): void => {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
e.preventDefault();
handleRemove(itemId);
const targetIndex = index > 0 ? index - 1 : 0;
requestAnimationFrame(() => {
const buttons = footerRef.current?.querySelectorAll('button');
const targetButton = buttons?.[targetIndex] as
| HTMLButtonElement
| undefined;
targetButton?.focus();
});
},
[handleRemove],
);
const showError = hasError && selectedIds.length === 0;
return (
<div
className={cx(
styles.itemInputSelector,
showError ? styles.itemInputSelectorError : '',
)}
data-testid="item-input-selector"
>
<Input
placeholder={placeholder}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
data-testid="item-input-selector-input"
suffix={
<Button
variant="solid"
size="sm"
onClick={handleAddClick}
disabled={!inputValue.trim()}
data-testid="item-input-selector-add-btn"
>
<Plus size={14} />
Add
</Button>
}
/>
{selectedIds.length > 0 ? (
<div ref={footerRef} className={styles.itemInputSelectorFooter}>
<div className={styles.itemInputSelectorBadges}>
{selectedIds.map((id, index) => (
<span key={id} className={styles.itemInputSelectorBadge} title={id}>
<Typography
as="span"
size="small"
truncate={1}
className={styles.itemInputSelectorBadgeLabel}
>
{id}
</Typography>
<button
type="button"
className={styles.itemInputSelectorBadgeRemove}
onClick={(): void => handleRemove(id)}
onKeyDown={(e): void => handleBadgeKeyDown(e, id, index)}
aria-label={`Remove ${id}`}
>
<X size={10} />
</button>
</span>
))}
</div>
<TooltipSimple
title={
<Typography align="left">
Still not sure on how to add selectors? <br />
<Typography.Link
href={`${BASE_DOCS_URL}#${docsAnchor}`}
target="_blank"
rel="noopener noreferrer"
>
Check the docs
</Typography.Link>{' '}
to understand selectors for this resource.
</Typography>
}
>
<Info size={16} className={styles.itemInputSelectorInfoIcon} />
</TooltipSimple>
</div>
) : (
<Typography className={styles.itemInputSelectorHint}>
Not sure what to type here?{' '}
<Typography.Link
href={`${BASE_DOCS_URL}#${docsAnchor}`}
target="_blank"
rel="noopener noreferrer"
>
Check the docs
</Typography.Link>{' '}
to understand selectors for this resource.
</Typography>
)}
</div>
);
}
export default ItemInputSelector;

View File

@@ -1,44 +0,0 @@
.jsonEditor {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
flex: 1;
min-height: 0;
}
.jsonEditorContainer {
position: relative;
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
flex: 1;
min-height: 200px;
}
.copyButton {
position: absolute;
top: 8px;
right: 24px;
z-index: 10;
}
.jsonEditorErrorWrapper {
min-height: 52px;
}
.jsonEditorError {
display: flex;
align-items: flex-start;
gap: var(--spacing-2);
padding: var(--spacing-4) var(--spacing-6);
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
border: 1px solid var(--danger-background);
border-radius: 4px;
}
.jsonEditorErrorMessage {
font-family:
Geist Mono,
monospace;
word-break: break-word;
}

View File

@@ -1,234 +0,0 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useState,
useRef,
} from 'react';
import { useCopyToClipboard } from 'react-use';
import MEditor, { Monaco, OnMount } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Check, Copy } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import type { AuthtypesTransactionGroupDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
defineJsonTheme,
EDITABLE_EDITOR_OPTIONS,
JSON_THEME_DARK,
} from '../../monaco.config';
import {
transformResourcePermissionsToTransactionGroups,
transformTransactionGroupsToResourcePermissions,
} from '../../hooks/useRolePermissions';
import {
registerCompletionProvider,
registerJsonSchema,
ROLE_PERMISSIONS_MODEL_PATH,
} from './jsonSchema.config';
import styles from './JsonEditor.module.scss';
import { JsonEditorProps, JsonEditorRef } from './JsonEditor.types';
type MonacoEditor = Parameters<OnMount>[0];
const JsonEditor = forwardRef<JsonEditorRef, JsonEditorProps>(
function JsonEditor({ resources, mode, onChange, onValidityChange }, ref) {
const isDarkMode = useIsDarkMode();
const [copyState, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
const [schemaErrors, setSchemaErrors] = useState<string[]>([]);
const [jsonBuffer, setJsonBuffer] = useState<string>(() => {
const transactionGroups =
transformResourcePermissionsToTransactionGroups(resources);
return JSON.stringify(transactionGroups, null, 2);
});
const prevModeRef = useRef(mode);
const completionDisposableRef = useRef<{ dispose(): void } | null>(null);
const editorRef = useRef<MonacoEditor | null>(null);
const markersListenerRef = useRef<{ dispose(): void } | null>(null);
const hasError = parseError !== null || schemaErrors.length > 0;
useImperativeHandle(ref, () => ({
hasParseError: (): boolean => hasError,
}));
useEffect(() => {
onValidityChange?.(hasError);
}, [hasError, onValidityChange]);
// Reinitialize buffer when switching from interactive to json mode
useEffect(() => {
const wasInteractive = prevModeRef.current === 'interactive';
const isNowJson = mode === 'json';
if (wasInteractive && isNowJson) {
const transactionGroups =
transformResourcePermissionsToTransactionGroups(resources);
setJsonBuffer(JSON.stringify(transactionGroups, null, 2));
setParseError(null);
}
prevModeRef.current = mode;
}, [mode, resources]);
const handleEditorChange = useCallback(
(value: string | undefined): void => {
if (!value) {
return;
}
setJsonBuffer(value);
try {
const parsed = JSON.parse(value) as AuthtypesTransactionGroupDTO[];
const resourcePermissions =
transformTransactionGroupsToResourcePermissions(parsed);
setParseError(null);
onChange(resourcePermissions);
} catch (err) {
setParseError(err instanceof Error ? err.message : 'Invalid JSON format');
}
},
[onChange],
);
const configureMonaco = useCallback((monaco: Monaco): void => {
defineJsonTheme(monaco);
registerJsonSchema(monaco);
completionDisposableRef.current = registerCompletionProvider(monaco);
}, []);
const handleEditorMount: OnMount = useCallback((editorInstance, monaco) => {
editorRef.current = editorInstance;
type MonacoMarker = ReturnType<typeof monaco.editor.getModelMarkers>[number];
markersListenerRef.current = monaco.editor.onDidChangeMarkers(
(uris: readonly Parameters<typeof monaco.Uri.parse>[0][]) => {
const model = editorInstance.getModel();
if (!model) {
return;
}
const modelUri = model.uri.toString();
const hasRelevantChange = uris.some((uri) => uri.toString() === modelUri);
if (!hasRelevantChange) {
return;
}
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
const errors = markers
.filter(
(marker: MonacoMarker) =>
marker.severity === monaco.MarkerSeverity.Error,
)
.map(
(marker: MonacoMarker) =>
`Line ${marker.startLineNumber}: ${marker.message}`,
);
setSchemaErrors(errors);
},
);
}, []);
useEffect(
() => (): void => {
completionDisposableRef.current?.dispose();
markersListenerRef.current?.dispose();
},
[],
);
useEffect(() => {
if (copyState.value) {
setCopied(true);
const timer = setTimeout(() => setCopied(false), 1500);
return (): void => clearTimeout(timer);
}
return undefined;
}, [copyState]);
const handleCopy = useCallback((): void => {
copyToClipboard(jsonBuffer);
}, [copyToClipboard, jsonBuffer]);
return (
<div className={styles.jsonEditor} data-testid="json-editor">
<div className={styles.jsonEditorContainer}>
<TooltipSimple title={copied ? 'Copied!' : 'Copy JSON'}>
<Button
variant="ghost"
size="sm"
className={styles.copyButton}
onClick={handleCopy}
>
{copied ? (
<Check size={14} color={Color.BG_FOREST_400} />
) : (
<Copy
size={14}
color={isDarkMode ? Color.BG_VANILLA_400 : Color.TEXT_INK_400}
/>
)}
</Button>
</TooltipSimple>
<MEditor
value={jsonBuffer}
language="json"
path={ROLE_PERMISSIONS_MODEL_PATH}
options={EDITABLE_EDITOR_OPTIONS}
onChange={handleEditorChange}
onMount={handleEditorMount}
height="100%"
theme={isDarkMode ? JSON_THEME_DARK : 'light'}
beforeMount={configureMonaco}
/>
</div>
<div className={styles.jsonEditorErrorWrapper}>
{parseError && (
<div className={styles.jsonEditorError} data-testid="json-editor-error">
<Typography as="span" size="base" weight="medium">
Parse Error:
</Typography>
<Typography
as="span"
size="base"
className={styles.jsonEditorErrorMessage}
>
{parseError}
</Typography>
</div>
)}
{!parseError && schemaErrors.length > 0 && (
<div
className={styles.jsonEditorError}
data-testid="json-editor-schema-error"
>
<Typography as="span" size="base" weight="medium">
Schema Error:
</Typography>
<Typography
as="span"
size="base"
className={styles.jsonEditorErrorMessage}
>
{schemaErrors[0]}
{schemaErrors.length > 1 && ` (+${schemaErrors.length - 1} more)`}
</Typography>
</div>
)}
</div>
</div>
);
},
);
export default JsonEditor;

View File

@@ -1,14 +0,0 @@
import { ResourcePermissions } from '../../types';
export type EditorMode = 'interactive' | 'json';
export interface JsonEditorProps {
resources: ResourcePermissions[];
mode: EditorMode;
onChange: (resources: ResourcePermissions[]) => void;
onValidityChange?: (hasError: boolean) => void;
}
export interface JsonEditorRef {
hasParseError: () => boolean;
}

View File

@@ -1,155 +0,0 @@
.permissionEditor {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
flex: 1;
min-height: 0;
}
.permissionEditorHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.permissionEditorTitle {
letter-spacing: 0.48px;
text-transform: uppercase;
position: relative;
}
.permissionEditorDivider {
height: 7px;
flex: 1 1 0%;
border-width: medium medium;
border-style: none none;
border-color: currentcolor currentcolor;
border-image: initial;
border-top: 2px dotted var(--l1-border);
border-bottom: 2px dotted var(--l1-border);
margin: 0px var(--spacing-4);
}
.permissionEditorModeToggle {
display: inline-flex;
grid-auto-flow: column;
gap: 0;
flex-shrink: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
}
.permissionEditorModeItem {
position: relative;
display: flex;
align-items: center;
&:not(:last-child) {
border-right: 1px solid var(--l2-border);
}
label {
display: flex;
align-items: center;
min-height: 24px;
padding: var(--spacing-3) var(--spacing-6);
font-family: Inter;
font-size: var(--periscope-font-size-base);
line-height: var(--line-height-20);
color: var(--l2-foreground);
white-space: nowrap;
cursor: pointer;
user-select: none;
}
}
.permissionEditorModeInput {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
* {
display: none;
}
&[data-state='checked'] + label {
background: var(--l3-background);
color: var(--l1-foreground);
font-weight: var(--font-weight-medium);
}
}
.permissionEditorContent {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.permissionEditorResourceList {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding-bottom: var(--spacing-8);
}
.permissionEditorCollapsedSection {
display: flex;
flex-direction: column;
}
.permissionEditorCollapsedHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
width: 100%;
padding: var(--spacing-4) var(--spacing-6);
background: var(--l3-background);
border: 1px dashed var(--l2-border);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s ease;
text-align: left;
&:hover {
background: var(--l2-background);
}
&:focus {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
}
.permissionEditorCollapsedLabel {
font-family: Inter;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
color: var(--l2-foreground);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.permissionEditorCollapsedCount {
font-family: Inter;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-20);
color: var(--primary);
flex-shrink: 0;
}
.permissionEditorCollapseAction {
display: flex;
justify-content: flex-start;
padding-bottom: var(--spacing-4);
}

View File

@@ -1,252 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { SolidAlertTriangle } from '@signozhq/icons';
import { Button, ButtonGroup } from '@signozhq/ui/button';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import { PermissionScope, ResourcePermissions } from '../../types';
import type { EditorMode, JsonEditorRef } from './JsonEditor.types';
import JsonEditor from './JsonEditor';
import ResourceCard from './ResourceCard';
import styles from './PermissionEditor.module.scss';
interface PermissionEditorProps {
resources: ResourcePermissions[];
mode: EditorMode;
onModeChange: (mode: EditorMode) => void;
onResourceChange: (resources: ResourcePermissions[]) => void;
onJsonValidityChange?: (hasError: boolean) => void;
isLoading?: boolean;
validationErrors?: Set<string>;
}
function PermissionEditor({
resources,
mode,
onModeChange,
onResourceChange,
onJsonValidityChange,
isLoading = false,
validationErrors,
}: PermissionEditorProps): JSX.Element {
const jsonEditorRef = useRef<JsonEditorRef>(null);
const handleJsonValidityChange = useCallback(
(hasError: boolean): void => {
onJsonValidityChange?.(mode === 'json' && hasError);
},
[mode, onJsonValidityChange],
);
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
const [expandedResources, setExpandedResources] = useState<Set<string>>(
new Set(),
);
const handleExpandAll = useCallback((): void => {
setExpandedResources(new Set(resources.map((r) => r.resourceId)));
}, [resources]);
const handleCollapseAll = useCallback((): void => {
setExpandedResources(new Set());
}, []);
const handleExpandChange = useCallback(
(resourceId: string) =>
(expanded: boolean): void => {
setExpandedResources((prev) => {
const next = new Set(prev);
if (expanded) {
next.add(resourceId);
} else {
next.delete(resourceId);
}
return next;
});
},
[],
);
const handleActionChange = useCallback(
(
resourceId: AuthZResource,
action: AuthZVerb,
scope: PermissionScope,
selectedIds: string[],
): void => {
const updatedResources = resources.map((r) => {
if (r.resourceId !== resourceId) {
return r;
}
return {
...r,
actions: {
...r.actions,
[action]: {
scope: scope,
selectedIds,
},
},
};
});
onResourceChange(updatedResources);
},
[resources, onResourceChange],
);
const handleJsonChange = useCallback(
(updatedResources: ResourcePermissions[]): void => {
onResourceChange(updatedResources);
},
[onResourceChange],
);
const handleModeChange = useCallback(
(value: string): void => {
const newMode = value as EditorMode;
if (
newMode === 'interactive' &&
mode === 'json' &&
jsonEditorRef.current?.hasParseError()
) {
setShowDiscardConfirm(true);
return;
}
if (newMode === 'interactive') {
onJsonValidityChange?.(false);
}
onModeChange(newMode);
},
[mode, onModeChange, onJsonValidityChange],
);
const handleDiscardConfirm = useCallback(async (): Promise<boolean> => {
onJsonValidityChange?.(false);
onModeChange('interactive');
setShowDiscardConfirm(false);
return true;
}, [onModeChange, onJsonValidityChange]);
const handleDiscardCancel = useCallback((): void => {
setShowDiscardConfirm(false);
}, []);
if (isLoading) {
return (
<div className={styles.permissionEditor}>
<Skeleton active paragraph={{ rows: 6 }} />
</div>
);
}
return (
<div className={styles.permissionEditor} data-testid="permission-editor">
<div className={styles.permissionEditorHeader}>
<Typography
as="span"
size="small"
weight="medium"
color="muted"
className={styles.permissionEditorTitle}
>
Transaction Groups
</Typography>
<hr className={styles.permissionEditorDivider} />
<RadioGroup
className={styles.permissionEditorModeToggle}
value={mode}
onChange={handleModeChange}
testId="permission-editor-mode"
>
<RadioGroupItem
value="interactive"
containerClassName={styles.permissionEditorModeItem}
className={styles.permissionEditorModeInput}
testId="permission-editor-mode-interactive"
>
Interactive
</RadioGroupItem>
<RadioGroupItem
value="json"
containerClassName={styles.permissionEditorModeItem}
className={styles.permissionEditorModeInput}
testId="permission-editor-mode-json"
>
JSON
</RadioGroupItem>
</RadioGroup>
</div>
<div className={styles.permissionEditorContent}>
{mode === 'interactive' ? (
<>
<div className={styles.permissionEditorCollapseAction}>
<ButtonGroup
variant="outlined"
color="secondary"
size="sm"
testId="toggle-all-group"
>
<Button onClick={handleExpandAll} data-testid="expand-all-button">
Expand all
</Button>
<Button onClick={handleCollapseAll} data-testid="collapse-all-button">
Collapse all
</Button>
</ButtonGroup>
</div>
<div className={styles.permissionEditorResourceList}>
{resources.map((resource) => (
<ResourceCard
key={resource.resourceId}
resource={resource}
onActionChange={handleActionChange}
isExpanded={expandedResources.has(resource.resourceId)}
onExpandChange={handleExpandChange(resource.resourceId)}
validationErrors={validationErrors}
/>
))}
</div>
</>
) : (
<JsonEditor
ref={jsonEditorRef}
resources={resources}
mode={mode}
onChange={handleJsonChange}
onValidityChange={handleJsonValidityChange}
/>
)}
</div>
<ConfirmDialog
open={showDiscardConfirm}
onOpenChange={(next): void => {
if (!next) {
handleDiscardCancel();
}
}}
title="Discard JSON changes?"
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
confirmText="Discard"
confirmColor="destructive"
cancelText="Stay in JSON"
onConfirm={handleDiscardConfirm}
onCancel={handleDiscardCancel}
>
<Typography>
The JSON contains errors and cannot be parsed. Switching to Interactive
mode will discard your changes.
</Typography>
</ConfirmDialog>
</div>
);
}
export default PermissionEditor;

View File

@@ -1,64 +0,0 @@
.resourceCard {
display: flex;
flex-direction: column;
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
background: var(--l2-background);
transition: border-color 0.15s ease;
}
.resourceCardHeader {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--spacing-6) var(--spacing-8);
border: none;
background: transparent;
cursor: pointer;
transition: background 0.15s ease;
text-align: left;
&:hover {
background: var(--l1-background-hover);
}
&:focus {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
}
.resourceCardHeaderLeft {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.resourceCardChevron {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--l2-foreground);
flex-shrink: 0;
}
.resourceCardHeaderRight {
display: flex;
align-items: center;
}
.resourceCardBody {
display: flex;
flex-direction: column;
&::before {
content: '';
height: 1px;
margin: 0 var(--spacing-8);
background: var(--l2-border);
}
}

View File

@@ -1,132 +0,0 @@
import { useCallback, useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import { Typography } from '@signozhq/ui/typography';
import { useRoleGrantedCount } from '../../hooks/useRoleGrantedCount';
import { supportsOnlySelected } from '../../permissions.config';
import ActionToggle from './ActionToggle';
import styles from './ResourceCard.module.scss';
import { PermissionScope, ResourcePermissions } from '../../types';
interface ResourceCardProps {
resource: ResourcePermissions;
onActionChange: (
resourceId: AuthZResource,
action: AuthZVerb,
scope: PermissionScope,
selectedIds: string[],
) => void;
defaultExpanded?: boolean;
isExpanded?: boolean;
onExpandChange?: (expanded: boolean) => void;
validationErrors?: Set<string>;
}
function ResourceCard({
resource,
onActionChange,
defaultExpanded = false,
isExpanded: controlledExpanded,
onExpandChange,
validationErrors,
}: ResourceCardProps): JSX.Element {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const isControlled = controlledExpanded !== undefined;
const isExpanded = isControlled ? controlledExpanded : internalExpanded;
const handleToggleExpand = useCallback((): void => {
if (isControlled) {
onExpandChange?.(!controlledExpanded);
} else {
setInternalExpanded((prev) => !prev);
}
}, [isControlled, controlledExpanded, onExpandChange]);
const handleScopeChange = useCallback(
(action: AuthZVerb) =>
(scope: PermissionScope): void => {
const currentConfig = resource.actions[action];
const selectedIds =
scope === PermissionScope.ONLY_SELECTED
? (currentConfig?.selectedIds ?? [])
: [];
onActionChange(resource.resourceId, action, scope, selectedIds);
},
[resource.resourceId, resource.actions, onActionChange],
);
const handleSelectedIdsChange = useCallback(
(action: AuthZVerb) =>
(ids: string[]): void => {
const currentConfig = resource.actions[action];
onActionChange(
resource.resourceId,
action,
currentConfig?.scope ?? PermissionScope.ONLY_SELECTED,
ids,
);
},
[resource.resourceId, resource.actions, onActionChange],
);
const [grantedCount, totalCount] = useRoleGrantedCount(resource);
return (
<div
className={styles.resourceCard}
data-testid={`resource-card-${resource.resourceId}`}
>
<button
type="button"
className={styles.resourceCardHeader}
onClick={handleToggleExpand}
aria-expanded={isExpanded}
aria-label={`${resource.resourceLabel}: ${grantedCount} of ${totalCount} permissions granted`}
data-testid={`resource-card-header-${resource.resourceId}`}
>
<div className={styles.resourceCardHeaderLeft}>
<span className={styles.resourceCardChevron}>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
<Typography as="span" size="base" weight="medium">
{resource.resourceLabel}
</Typography>
</div>
<div className={styles.resourceCardHeaderRight}>
<Typography as="span" size="base" color="muted">
{grantedCount} / {totalCount} granted
</Typography>
</div>
</button>
{isExpanded && (
<div className={styles.resourceCardBody}>
{resource.availableActions.map((action) => {
const actionConfig = resource.actions[action] ?? {
scope: PermissionScope.NONE,
selectedIds: [],
};
return (
<ActionToggle
key={action}
action={action}
scope={actionConfig.scope}
selectedIds={actionConfig.selectedIds}
resource={resource.resourceId}
canSelectIndividually={supportsOnlySelected(action)}
onScopeChange={handleScopeChange(action)}
onSelectedIdsChange={handleSelectedIdsChange(action)}
hasError={validationErrors?.has(`${resource.resourceId}:${action}`)}
/>
);
})}
</div>
)}
</div>
);
}
export default ResourceCard;

View File

@@ -1,97 +0,0 @@
import {
ROLE_PERMISSIONS_MODEL_PATH,
shouldProvideCompletions,
} from '../jsonSchema.config';
describe('shouldProvideCompletions', () => {
const validPath = `/some/path/${ROLE_PERMISSIONS_MODEL_PATH}`;
const invalidPath = '/some/other/file.json';
describe('model path validation', () => {
it('returns false when model path does not end with ROLE_PERMISSIONS_MODEL_PATH', () => {
expect(shouldProvideCompletions(invalidPath, '[')).toBe(false);
});
it('returns true when model path ends with ROLE_PERMISSIONS_MODEL_PATH and at array position', () => {
expect(shouldProvideCompletions(validPath, '[')).toBe(true);
});
});
describe('cursor position validation', () => {
it('returns true when cursor is after opening bracket', () => {
expect(shouldProvideCompletions(validPath, '[')).toBe(true);
});
it('returns true when cursor is after comma at root level', () => {
expect(shouldProvideCompletions(validPath, '[{},\n')).toBe(true);
});
it('returns true with whitespace before bracket', () => {
expect(shouldProvideCompletions(validPath, ' \n [')).toBe(true);
});
it('returns true with whitespace before comma', () => {
expect(shouldProvideCompletions(validPath, '[{} , ')).toBe(true);
});
it('returns false when cursor is in middle of text', () => {
expect(shouldProvideCompletions(validPath, '[{"foo"')).toBe(false);
});
it('returns false when cursor is after closing bracket', () => {
expect(shouldProvideCompletions(validPath, '[]')).toBe(false);
});
it('returns false when cursor is after colon', () => {
expect(shouldProvideCompletions(validPath, '[{"key":')).toBe(false);
});
});
describe('brace depth validation', () => {
it('returns false when cursor is inside an object', () => {
expect(shouldProvideCompletions(validPath, '[{')).toBe(false);
});
it('returns false when cursor is inside nested object', () => {
const text = '[{"objectGroup": {"resource": {';
expect(shouldProvideCompletions(validPath, text)).toBe(false);
});
it('returns true when all objects are closed and at comma', () => {
const text = '[{"objectGroup": {"resource": {}}}],';
expect(shouldProvideCompletions(validPath, text)).toBe(true);
});
it('returns true after complete object with comma', () => {
const text = `[{
"objectGroup": {
"resource": { "kind": "dashboard", "type": "object" },
"selectors": ["*"]
},
"relation": "read"
},`;
expect(shouldProvideCompletions(validPath, text)).toBe(true);
});
it('returns false inside partial object after comma', () => {
const text = `[{
"objectGroup": {
"resource": { "kind": "dashboard", "type": "object" },`;
expect(shouldProvideCompletions(validPath, text)).toBe(false);
});
});
describe('edge cases', () => {
it('handles empty string', () => {
expect(shouldProvideCompletions(validPath, '')).toBe(false);
});
it('handles only whitespace', () => {
expect(shouldProvideCompletions(validPath, ' \n\t ')).toBe(false);
});
it('handles unbalanced braces (more closing) - no completions for malformed JSON', () => {
expect(shouldProvideCompletions(validPath, '[{}}},')).toBe(false);
});
});
});

View File

@@ -1,207 +0,0 @@
import type { Monaco } from '@monaco-editor/react';
import permissionsType from 'hooks/useAuthZ/permissions.type';
import transactionGroupSchema from 'schemas/generated/transactionGroups.schema.json';
export const TRANSACTION_GROUP_SCHEMA = transactionGroupSchema;
const SCHEMA_URI = 'inmemory://model/transaction-groups-schema.json';
export const ROLE_PERMISSIONS_MODEL_PATH = 'role-permissions.json';
export function registerJsonSchema(monaco: Monaco): void {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemaValidation: 'error',
schemas: [
{
uri: SCHEMA_URI,
fileMatch: [ROLE_PERMISSIONS_MODEL_PATH],
schema: TRANSACTION_GROUP_SCHEMA,
},
],
});
}
interface SnippetDef {
label: string;
insertText: string;
documentation: string;
}
type BasePermissionTypeDataResourcesType =
(typeof permissionsType.data)['resources'][number];
function createGrantAllPermissionSnippet(
kind: BasePermissionTypeDataResourcesType['kind'],
allowedVerbs: BasePermissionTypeDataResourcesType['allowedVerbs'],
type: BasePermissionTypeDataResourcesType['type'],
): SnippetDef {
return {
label: `${kind}:all`,
insertText: allowedVerbs
.map(
(verb) => `{
"objectGroup": {
"resource": { "kind": "${kind}", "type": "${type}" },
"selectors": ["*"]
},
"relation": "${verb}"
}`,
)
.join(',\n'),
documentation: `Grant all permissions (${allowedVerbs.join(', ')}) on ${kind}`,
};
}
function createGrantPermissionToVerbAndKind(
kind: BasePermissionTypeDataResourcesType['kind'],
verb: string,
type: BasePermissionTypeDataResourcesType['type'],
): SnippetDef {
return {
label: `${kind}:${verb}`,
insertText: `{
"objectGroup": {
"resource": { "kind": "${kind}", "type": "${type}" },
"selectors": ["*"]
},
"relation": "${verb}"
}`,
documentation: `${verb} permission on ${kind}`,
};
}
function createGrantPermissionAsReadonly(
resources: (typeof permissionsType.data)['resources'],
): SnippetDef {
return {
label: 'readonly',
insertText: resources
.filter(
(r) => r.allowedVerbs.includes('read') || r.allowedVerbs.includes('list'),
)
.flatMap((r) => {
const verbs = r.allowedVerbs.filter((v) => v === 'read' || v === 'list');
return verbs.map(
(verb) => `{
"objectGroup": {
"resource": { "kind": "${r.kind}", "type": "${r.type}" },
"selectors": ["*"]
},
"relation": "${verb}"
}`,
);
})
.join(',\n'),
documentation: 'Read-only access to all resources (read + list)',
};
}
function buildResourceSnippets(): SnippetDef[] {
const { resources } = permissionsType.data;
const snippets: SnippetDef[] = [];
for (const resource of resources) {
const { kind, type, allowedVerbs } = resource;
snippets.push(createGrantAllPermissionSnippet(kind, allowedVerbs, type));
for (const verb of allowedVerbs) {
snippets.push(createGrantPermissionToVerbAndKind(kind, verb, type));
}
}
snippets.push(createGrantPermissionAsReadonly(resources));
return snippets;
}
const SNIPPETS = buildResourceSnippets();
type MonacoModel = Parameters<
Parameters<
Monaco['languages']['registerCompletionItemProvider']
>[1]['provideCompletionItems']
>[0];
type MonacoPosition = Parameters<
Parameters<
Monaco['languages']['registerCompletionItemProvider']
>[1]['provideCompletionItems']
>[1];
interface Disposable {
dispose(): void;
}
/**
* Check if completions should be provided based on model path and cursor position.
* Pure function for testability.
*/
export function shouldProvideCompletions(
modelPath: string,
textBeforeCursor: string,
): boolean {
if (!modelPath.endsWith(ROLE_PERMISSIONS_MODEL_PATH)) {
return false;
}
const trimmed = textBeforeCursor.trim();
const endsAtArrayPosition = trimmed.endsWith('[') || trimmed.endsWith(',');
if (!endsAtArrayPosition) {
return false;
}
let braceDepth = 0;
for (const char of textBeforeCursor) {
if (char === '{') {
braceDepth++;
} else if (char === '}') {
braceDepth--;
}
}
return braceDepth === 0;
}
/**
* Register completion provider for smart snippets.
* Returns disposable to clean up on unmount.
*/
export function registerCompletionProvider(monaco: Monaco): Disposable {
return monaco.languages.registerCompletionItemProvider('json', {
triggerCharacters: ['"', '{', '['],
provideCompletionItems(model: MonacoModel, position: MonacoPosition) {
const textBeforeCursor = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});
if (!shouldProvideCompletions(model.uri.path, textBeforeCursor)) {
return { suggestions: [] };
}
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const suggestions = SNIPPETS.map((snippet, index) => ({
label: snippet.label,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: snippet.insertText,
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: snippet.documentation,
range,
sortText: String(index).padStart(3, '0'),
}));
return { suggestions };
},
});
}

View File

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

View File

@@ -1,256 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import type { ResourcePermissions } from '../types';
import type { EditorMode } from './components/JsonEditor.types';
import {
createEmptyRolePermissions,
useCreateRolePermissions,
useRolePermissions,
useUpdateRolePermissions,
} from '../hooks/useRolePermissions';
import { useRoleAuthZ } from '../hooks/useRoleAuthZ';
import {
useRoleUnsavedChanges,
type RoleFormData,
} from './useRoleUnsavedChanges';
import { useRoleFormValidation } from './useRoleFormValidation';
const EDITOR_MODES: EditorMode[] = ['interactive', 'json'];
interface UseCreateEditRolePageCallbacksResult {
formData: RoleFormData;
setFormData: React.Dispatch<React.SetStateAction<RoleFormData>>;
editorMode: EditorMode;
setEditorMode: (mode: EditorMode) => void;
resources: ResourcePermissions[];
setResources: (resources: ResourcePermissions[]) => void;
isLoading: boolean;
isSaving: boolean;
hasUnsavedChanges: boolean;
handleSave: () => Promise<boolean>;
handleCancel: () => void;
handleFormChange: (field: keyof RoleFormData, value: string) => void;
isCreateMode: boolean;
loadError: APIError | null;
saveError: APIError | null;
clearSaveError: () => void;
validationErrors: Set<string>;
hasRequiredPermission: boolean;
isAuthZLoading: boolean;
deniedPermission: string;
}
export function useCreateEditRolePageActions(
roleId: string,
roleName: string,
): UseCreateEditRolePageCallbacksResult {
const history = useHistory();
const isCreateMode = roleId === 'new';
const {
hasCreatePermission,
hasReadPermission,
hasUpdatePermission,
isAuthZLoading,
} = useRoleAuthZ(roleName);
const deniedPermission = useMemo(() => {
if (isCreateMode) {
return 'role:create';
}
if (roleName) {
return `role:${roleName}:update`;
}
return `role:<missing-rule-name>:update`;
}, [isCreateMode, roleName]);
const [formData, setFormData] = useState<RoleFormData>({
name: '',
description: '',
});
const [editorMode, setEditorMode] = useQueryState(
'viewMode',
parseAsStringLiteral(EDITOR_MODES).withDefault('interactive'),
);
const emptyResources = useMemo(() => createEmptyRolePermissions(), []);
const [localResources, setLocalResources] = useState<ResourcePermissions[]>(
() => (isCreateMode ? createEmptyRolePermissions() : []),
);
const [isInitialized, setIsInitialized] = useState(false);
const [saveError, setSaveError] = useState<APIError | null>(null);
const { validationErrors, validateResources, clearValidationErrors } =
useRoleFormValidation();
const {
data: rolePermissionsData,
isLoading: isLoadingPermissions,
error: rolePermissionsError,
} = useRolePermissions(roleId, {
enabled: !isCreateMode,
});
const loadError = rolePermissionsError
? toAPIError(rolePermissionsError, 'Failed to load role')
: null;
const { mutateAsync: createRole, isLoading: isCreating } =
useCreateRolePermissions();
const { mutateAsync: updateRole, isLoading: isUpdating } =
useUpdateRolePermissions();
const isSaving = isCreating || isUpdating;
useEffect(() => {
if (rolePermissionsData && !isInitialized) {
setFormData({
name: rolePermissionsData.roleName,
description: rolePermissionsData.roleDescription,
});
setLocalResources(JSON.parse(JSON.stringify(rolePermissionsData.resources)));
setIsInitialized(true);
}
}, [rolePermissionsData, isInitialized]);
const handleFormChange = useCallback(
(field: keyof RoleFormData, value: string): void => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
},
[],
);
const handleModeChange = useCallback(
(mode: EditorMode): void => {
void setEditorMode(mode);
},
[setEditorMode],
);
const handleResourcesChange = useCallback(
(resources: ResourcePermissions[]): void => {
setLocalResources(resources);
},
[],
);
const hasUnsavedChanges = useRoleUnsavedChanges(
isCreateMode,
formData,
localResources,
rolePermissionsData,
emptyResources,
);
const handleSave = useCallback(async (): Promise<boolean> => {
if (!formData.name.trim()) {
toast.error('Role name is required', { position: 'bottom-center' });
return false;
}
const validationError = validateResources(localResources);
if (validationError) {
setSaveError(
new APIError({
httpStatusCode: 400,
error: {
code: 'VALIDATION_ERROR',
message: validationError,
url: '',
errors: [],
},
}),
);
return false;
}
clearValidationErrors();
setSaveError(null);
try {
if (isCreateMode) {
await createRole({
name: formData.name,
description: formData.description,
resources: localResources,
});
} else {
await updateRole({
roleId,
description: formData.description,
resources: localResources,
});
}
toast.success(
isCreateMode ? 'Role created successfully' : 'Role updated successfully',
{ position: 'bottom-center' },
);
return true;
} catch (error) {
setSaveError(
toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Failed to save role',
),
);
return false;
}
}, [
formData.name,
formData.description,
isCreateMode,
roleId,
localResources,
createRole,
updateRole,
validateResources,
clearValidationErrors,
]);
const clearSaveError = useCallback((): void => {
setSaveError(null);
}, []);
const handleCancel = useCallback((): void => {
if (isCreateMode) {
history.push(ROUTES.ROLES_SETTINGS);
} else {
const viewUrl = `${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}?name=${encodeURIComponent(roleName)}`;
history.push(viewUrl);
}
}, [history, isCreateMode, roleId, roleName]);
return {
formData,
setFormData,
editorMode,
setEditorMode: handleModeChange,
resources: localResources,
setResources: handleResourcesChange,
isLoading: isLoadingPermissions,
isSaving,
hasUnsavedChanges,
handleSave,
handleCancel,
handleFormChange,
isCreateMode,
loadError,
saveError,
clearSaveError,
validationErrors,
hasRequiredPermission: isCreateMode
? hasCreatePermission
: hasReadPermission && hasUpdatePermission,
isAuthZLoading,
deniedPermission,
};
}

View File

@@ -1,50 +0,0 @@
import { useCallback, useState } from 'react';
import { PermissionScope, ResourcePermissions } from '../types';
interface UseRoleFormValidationResult {
validationErrors: Set<string>;
validateResources: (resources: ResourcePermissions[]) => string | null;
clearValidationErrors: () => void;
}
export function useRoleFormValidation(): UseRoleFormValidationResult {
const [validationErrors, setValidationErrors] = useState<Set<string>>(
() => new Set(),
);
const validateResources = useCallback(
(resources: ResourcePermissions[]): string | null => {
const errors = new Set<string>();
for (const resource of resources) {
for (const [action, config] of Object.entries(resource.actions)) {
if (
config?.scope === PermissionScope.ONLY_SELECTED &&
config.selectedIds.length === 0
) {
errors.add(`${resource.resourceId}:${action}`);
}
}
}
if (errors.size > 0) {
setValidationErrors(errors);
return 'Please add at least one selector for each "Only selected" permission.';
}
setValidationErrors(new Set());
return null;
},
[],
);
const clearValidationErrors = useCallback((): void => {
setValidationErrors(new Set());
}, []);
return {
validationErrors,
validateResources,
clearValidationErrors,
};
}

View File

@@ -1,50 +0,0 @@
import { useMemo } from 'react';
import type { ResourcePermissions } from '../types';
export interface RoleFormData {
name: string;
description: string;
}
interface RolePermissionsData {
roleName: string;
roleDescription: string;
resources: ResourcePermissions[];
}
export function useRoleUnsavedChanges(
isCreateMode: boolean,
formData: RoleFormData,
localResources: ResourcePermissions[],
rolePermissionsData: RolePermissionsData | undefined,
emptyResources: ResourcePermissions[],
): boolean {
return useMemo(() => {
if (isCreateMode) {
return (
formData.name.trim() !== '' ||
formData.description.trim() !== '' ||
JSON.stringify(localResources) !== JSON.stringify(emptyResources)
);
}
if (!rolePermissionsData) {
return false;
}
const nameChanged = formData.name !== rolePermissionsData.roleName;
const descriptionChanged =
formData.description !== rolePermissionsData.roleDescription;
const resourcesChanged =
JSON.stringify(localResources) !==
JSON.stringify(rolePermissionsData.resources);
return nameChanged || descriptionChanged || resourcesChanged;
}, [
isCreateMode,
formData,
localResources,
rolePermissionsData,
emptyResources,
]);
}

View File

@@ -1,3 +0,0 @@
.errorCallout {
margin-top: var(--spacing-4);
}

View File

@@ -1,58 +0,0 @@
import { Trash2 } from '@signozhq/icons';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import APIError from 'types/api/error';
import styles from './DeleteRoleModal.module.scss';
import { Callout } from '@signozhq/ui/callout';
interface DeleteRoleModalProps {
isOpen: boolean;
roleName: string;
error: APIError | null;
onCancel: () => void;
onConfirm: () => Promise<boolean>;
}
function DeleteRoleModal({
isOpen,
roleName,
error,
onCancel,
onConfirm,
}: DeleteRoleModalProps): JSX.Element {
return (
<ConfirmDialog
open={isOpen}
onOpenChange={(next): void => {
if (!next) {
onCancel();
}
}}
title="Delete Role"
titleIcon={<Trash2 size={14} />}
confirmText="Delete Role"
confirmColor="destructive"
cancelText="Cancel"
onConfirm={onConfirm}
onCancel={onCancel}
disableOutsideClick
>
<Typography>
Are you sure you want to delete the role <strong>{roleName}</strong>? This
action cannot be undone.
</Typography>
{error && (
<Callout
title="Failed to delete role"
color="cherry"
className={styles.errorCallout}
>
<ErrorInPlace error={error} height="auto" padding={0} />
</Callout>
)}
</ConfirmDialog>
);
}
export default DeleteRoleModal;

View File

@@ -1,97 +0,0 @@
import { useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import {
invalidateGetRole,
invalidateListRoles,
useDeleteRole,
} from 'api/generated/services/role';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import APIError from 'types/api/error';
interface UseDeleteRoleModalProps {
roleId?: string | null;
isManaged: boolean;
hasDeletePermission: boolean;
onDeleteSuccess?: () => void;
}
interface UseDeleteRoleModalResult {
isDeleteModalOpen: boolean;
isDeleteDisabled: boolean;
deleteDisabledReason: string;
isDeleting: boolean;
deleteError: APIError | null;
handleOpenDeleteModal: () => void;
handleCloseDeleteModal: () => void;
handleConfirmDelete: () => Promise<boolean>;
}
export function useDeleteRoleModal(
props: UseDeleteRoleModalProps,
): UseDeleteRoleModalResult {
const { roleId, isManaged, hasDeletePermission, onDeleteSuccess } = props;
const queryClient = useQueryClient();
const [deleteTargetRoleId, setDeleteTargetRoleId] = useState<string | null>(
null,
);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<APIError | null>(null);
const { mutateAsync: deleteRole } = useDeleteRole();
const handleOpenDeleteModal = useCallback((): void => {
setDeleteTargetRoleId(roleId ?? null);
}, [roleId]);
const handleCloseDeleteModal = useCallback((): void => {
setDeleteTargetRoleId(null);
setDeleteError(null);
}, []);
const handleConfirmDelete = useCallback(async (): Promise<boolean> => {
if (!deleteTargetRoleId) {
return false;
}
setIsDeleting(true);
setDeleteError(null);
try {
await deleteRole({ pathParams: { id: deleteTargetRoleId } });
await invalidateListRoles(queryClient);
await invalidateGetRole(queryClient, { id: deleteTargetRoleId });
setDeleteTargetRoleId(null);
onDeleteSuccess?.();
return true;
} catch (error) {
const apiError = convertToApiError(
error as AxiosError<RenderErrorResponseDTO>,
);
setDeleteError(apiError ?? null);
return false;
} finally {
setIsDeleting(false);
}
}, [deleteRole, deleteTargetRoleId, queryClient, onDeleteSuccess]);
const isDeleteModalOpen = deleteTargetRoleId !== null;
const isDeleteDisabled = isManaged || !hasDeletePermission;
const deleteDisabledReason = isManaged
? 'Managed roles cannot be deleted'
: 'You do not have permission to delete this role';
return {
isDeleteModalOpen,
isDeleteDisabled,
deleteDisabledReason,
isDeleting,
deleteError,
handleOpenDeleteModal,
handleCloseDeleteModal,
handleConfirmDelete,
};
}

View File

@@ -0,0 +1,271 @@
.permission-side-panel-backdrop {
position: fixed;
inset: 0;
z-index: 100;
background: transparent;
}
.permission-side-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 101;
width: 720px;
display: flex;
flex-direction: column;
background: var(--l2-background);
border-left: 1px solid var(--l1-border);
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
&__header {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
height: 48px;
padding: 0 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
}
&__header-divider {
display: block;
width: 1px;
height: 16px;
background: var(--l1-border);
flex-shrink: 0;
}
&__title {
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--foreground);
}
&__content {
flex: 1;
overflow-y: auto;
padding: 12px 15px;
}
&__resource-list {
display: flex;
flex-direction: column;
border: 1px solid var(--l1-border);
border-radius: 4px;
overflow: hidden;
}
&__footer {
display: flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
height: 56px;
padding: 0 16px;
gap: 12px;
background: var(--l2-background);
border-top: 1px solid var(--l1-border);
}
&__unsaved {
display: flex;
align-items: center;
gap: 8px;
margin-right: auto;
}
&__unsaved-dot {
display: block;
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
flex-shrink: 0;
}
&__unsaved-text {
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
color: var(--primary);
}
&__footer-actions {
display: flex;
align-items: center;
gap: 12px;
}
}
.psp-resource {
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--l1-border);
&:last-child {
border-bottom: none;
}
&__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
cursor: pointer;
transition: background 0.15s ease;
&--expanded {
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
}
&:hover {
background: color-mix(in srgb, var(--bg-robin-200) 3%, transparent);
}
}
&__left {
display: flex;
align-items: center;
gap: 16px;
}
&__chevron {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
color: var(--foreground);
flex-shrink: 0;
}
&__label {
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
}
&__body {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0 8px 44px;
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
}
&__radio-group {
display: flex;
flex-direction: column;
gap: 2px;
}
&__radio-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
label {
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
cursor: pointer;
}
}
&__select-wrapper {
padding: 6px 16px 4px 24px;
}
&__select {
width: 100%;
// todo: https://github.com/SigNoz/components/issues/116
.ant-select-selector {
background: var(--l2-background) !important;
border: 1px solid var(--border) !important;
border-radius: 2px !important;
padding: 4px 6px !important;
min-height: 32px !important;
box-shadow: none !important;
&:hover,
&:focus-within {
border-color: var(--input) !important;
box-shadow: none !important;
}
}
.ant-select-selection-placeholder {
font-family: Inter;
font-size: 14px;
font-weight: 400;
color: var(--foreground);
opacity: 0.4;
}
.ant-select-selection-item {
background: var(--input) !important;
border: none !important;
border-radius: 2px !important;
padding: 0 6px !important;
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: var(--l1-foreground) !important;
height: auto !important;
}
.ant-select-selection-item-remove {
color: var(--foreground) !important;
display: flex;
align-items: center;
}
.ant-select-arrow {
color: var(--foreground);
}
}
&__select-popup {
.ant-select-item {
font-family: Inter;
font-size: 14px;
font-weight: 400;
color: var(--foreground);
background: var(--l2-background);
&-option-selected {
background: var(--border) !important;
color: var(--l1-foreground) !important;
}
&-option-active {
background: var(--l2-background-hover) !important;
}
}
.ant-select-dropdown {
background: var(--l2-background);
border: 1px solid var(--l1-border);
border-radius: 2px;
padding: 4px 0;
}
}
}

View File

@@ -0,0 +1,300 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
RadioGroup,
RadioGroupItem,
RadioGroupLabel,
} from '@signozhq/ui/radio-group';
import { Select, Skeleton } from 'antd';
import {
buildConfig,
configsEqual,
DEFAULT_RESOURCE_CONFIG,
isResourceConfigEqual,
} from '../utils';
import type {
PermissionConfig,
PermissionSidePanelProps,
ResourceConfig,
ResourceDefinition,
ScopeType,
} from './PermissionSidePanel.types';
import { PermissionScope } from './PermissionSidePanel.types';
import './PermissionSidePanel.styles.scss';
const RELATIONS_ALL_ONLY = new Set(['list', 'create']);
interface ResourceRowProps {
resource: ResourceDefinition;
config: ResourceConfig;
isExpanded: boolean;
relation: string;
onToggleExpand: (id: string) => void;
onScopeChange: (id: string, scope: ScopeType) => void;
onSelectedIdsChange: (id: string, ids: string[]) => void;
}
function ResourceRow({
resource,
config,
isExpanded,
relation,
onToggleExpand,
onScopeChange,
onSelectedIdsChange,
}: ResourceRowProps): JSX.Element {
const showOnlySelected = !RELATIONS_ALL_ONLY.has(relation);
return (
<div className="psp-resource">
<div
className={`psp-resource__row${
isExpanded ? ' psp-resource__row--expanded' : ''
}`}
role="button"
tabIndex={0}
onClick={(): void => onToggleExpand(resource.id)}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
onToggleExpand(resource.id);
}
}}
>
<div className="psp-resource__left">
<span className="psp-resource__chevron">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className="psp-resource__label">{resource.label}</span>
</div>
</div>
{isExpanded && (
<div className="psp-resource__body">
<RadioGroup
value={config.scope}
onChange={(val): void => onScopeChange(resource.id, val as ScopeType)}
color="robin"
className="psp-resource__radio-group"
>
<div className="psp-resource__radio-item">
<RadioGroupItem value={PermissionScope.ALL} id={`${resource.id}-all`} />
<RadioGroupLabel htmlFor={`${resource.id}-all`}>All</RadioGroupLabel>
</div>
{showOnlySelected && (
<div className="psp-resource__radio-item">
<RadioGroupItem
value={PermissionScope.ONLY_SELECTED}
id={`${resource.id}-only-selected`}
/>
<RadioGroupLabel htmlFor={`${resource.id}-only-selected`}>
Only selected
</RadioGroupLabel>
</div>
)}
<div className="psp-resource__radio-item">
<RadioGroupItem
value={PermissionScope.NONE}
id={`${resource.id}-none`}
/>
<RadioGroupLabel htmlFor={`${resource.id}-none`}>None</RadioGroupLabel>
</div>
</RadioGroup>
{config.scope === PermissionScope.ONLY_SELECTED && showOnlySelected && (
<div className="psp-resource__select-wrapper">
<Select
mode="tags"
open={false}
allowClear
suffixIcon={null}
value={config.selectedIds}
onChange={(vals: string[]): void =>
onSelectedIdsChange(resource.id, vals)
}
placeholder="Type and press Enter to add..."
className="psp-resource__select"
/>
</div>
)}
</div>
)}
</div>
);
}
function PermissionSidePanel({
open,
onClose,
permissionLabel,
relation,
resources,
initialConfig,
isLoading = false,
isSaving = false,
canEdit = true,
onSave,
}: PermissionSidePanelProps): JSX.Element | null {
const [config, setConfig] = useState<PermissionConfig>(() =>
buildConfig(resources, initialConfig),
);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
useEffect(() => {
if (open) {
setConfig(buildConfig(resources, initialConfig));
setExpandedIds(new Set());
}
}, [open, resources, initialConfig]);
const savedConfig = useMemo(
() => buildConfig(resources, initialConfig),
[resources, initialConfig],
);
const unsavedCount = useMemo(() => {
if (configsEqual(config, savedConfig)) {
return 0;
}
return Object.keys(config).filter(
(id) => !isResourceConfigEqual(config[id], savedConfig[id]),
).length;
}, [config, savedConfig]);
const updateResource = useCallback(
(id: string, patch: Partial<ResourceConfig>): void => {
setConfig((prev) => ({
...prev,
[id]: { ...prev[id], ...patch },
}));
},
[],
);
const handleToggleExpand = useCallback((id: string): void => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const handleScopeChange = useCallback(
(id: string, scope: ScopeType): void => {
updateResource(id, { scope, selectedIds: [] });
},
[updateResource],
);
const handleSelectedIdsChange = useCallback(
(id: string, ids: string[]): void => {
updateResource(id, { selectedIds: ids });
},
[updateResource],
);
const handleSave = useCallback((): void => {
onSave(config);
}, [config, onSave]);
const handleDiscard = useCallback((): void => {
setConfig(buildConfig(resources, initialConfig));
setExpandedIds(new Set());
}, [resources, initialConfig]);
if (!open) {
return null;
}
return (
<>
<div
className="permission-side-panel-backdrop"
role="presentation"
onClick={onClose}
/>
<div className="permission-side-panel">
<div className="permission-side-panel__header">
<Button
variant="link"
color="secondary"
size="icon"
onClick={onClose}
aria-label="Close panel"
>
<X size={14} />
</Button>
<span className="permission-side-panel__header-divider" />
<span className="permission-side-panel__title">
Edit {permissionLabel} Permissions
</span>
</div>
<div className="permission-side-panel__content">
{isLoading ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : (
<div className="permission-side-panel__resource-list">
{resources.map((resource) => (
<ResourceRow
key={resource.id}
resource={resource}
config={config[resource.id] ?? DEFAULT_RESOURCE_CONFIG}
isExpanded={expandedIds.has(resource.id)}
relation={relation}
onToggleExpand={handleToggleExpand}
onScopeChange={handleScopeChange}
onSelectedIdsChange={handleSelectedIdsChange}
/>
))}
</div>
)}
</div>
<div className="permission-side-panel__footer">
{unsavedCount > 0 && (
<div className="permission-side-panel__unsaved">
<span className="permission-side-panel__unsaved-dot" />
<span className="permission-side-panel__unsaved-text">
{unsavedCount} unsaved change{unsavedCount !== 1 ? 's' : ''}
</span>
</div>
)}
<div className="permission-side-panel__footer-actions">
<Button
variant="solid"
color="secondary"
prefix={<X size={14} />}
onClick={unsavedCount > 0 ? handleDiscard : onClose}
size="sm"
disabled={isSaving}
>
{unsavedCount > 0 ? 'Discard' : 'Cancel'}
</Button>
<Button
variant="solid"
color="primary"
size="sm"
onClick={handleSave}
loading={isSaving}
disabled={isLoading || unsavedCount === 0 || !canEdit}
>
Save Changes
</Button>
</div>
</div>
</div>
</>
);
}
export default PermissionSidePanel;

View File

@@ -0,0 +1,40 @@
export interface ResourceOption {
value: string;
label: string;
}
export interface ResourceDefinition {
id: string;
kind: string;
type: string;
label: string;
options?: ResourceOption[];
}
export enum PermissionScope {
ALL = 'all',
ONLY_SELECTED = 'only_selected',
NONE = 'none',
}
export type ScopeType = PermissionScope;
export interface ResourceConfig {
scope: ScopeType;
selectedIds: string[];
}
export type PermissionConfig = Record<string, ResourceConfig>;
export interface PermissionSidePanelProps {
open: boolean;
onClose: () => void;
permissionLabel: string;
relation: string;
resources: ResourceDefinition[];
initialConfig?: PermissionConfig;
isLoading?: boolean;
isSaving?: boolean;
canEdit?: boolean;
onSave: (config: PermissionConfig) => void;
}

View File

@@ -0,0 +1,10 @@
export { default } from './PermissionSidePanel';
export type {
PermissionConfig,
PermissionSidePanelProps,
ResourceConfig,
ResourceDefinition,
ResourceOption,
ScopeType,
} from './PermissionSidePanel.types';
export { PermissionScope } from './PermissionSidePanel.types';

View File

@@ -0,0 +1,325 @@
.role-details-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
width: 100%;
max-width: 60vw;
margin: 0 auto;
.role-details-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.role-details-title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 18px;
font-weight: 500;
line-height: 28px;
letter-spacing: -0.09px;
}
.role-details-permission-item--readonly {
cursor: default !important;
pointer-events: none;
opacity: 0.55;
}
.role-details-actions {
display: flex;
align-items: center;
gap: 12px;
}
.role-details-overview {
display: flex;
flex-direction: column;
gap: 16px;
}
.role-details-meta {
display: flex;
flex-direction: column;
gap: 16px;
}
.role-details-section-label {
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.48px;
text-transform: uppercase;
color: var(--foreground);
}
.role-details-description-text {
font-family: Inter;
font-size: 13px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--foreground);
}
.role-details-info-row {
display: flex;
gap: 16px;
align-items: flex-start;
}
.role-details-info-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.role-details-info-value {
display: flex;
align-items: center;
gap: 8px;
}
.role-details-info-name {
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--foreground);
}
.role-details-permissions {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 8px;
}
.role-details-permissions-header {
display: flex;
align-items: center;
gap: 16px;
height: 20px;
}
.role-details-permissions-divider {
flex: 1;
border: none;
border-top: 2px dotted var(--l1-border);
border-bottom: 2px dotted var(--l1-border);
height: 7px;
margin: 0;
}
.role-details-permissions-learn-more {
color: var(--primary);
font-size: var(--font-size-xs);
text-decoration: none;
white-space: nowrap;
&:hover {
text-decoration: underline;
}
}
.role-details-permission-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.role-details-permission-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 12px;
background: color-mix(in srgb, var(--bg-robin-200) 8%, transparent);
border: 1px solid var(--secondary);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: color-mix(in srgb, var(--bg-robin-200) 12%, transparent);
}
}
.role-details-permission-item-left {
display: flex;
align-items: center;
gap: 8px;
}
.role-details-permission-item-label {
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
}
.role-details-members {
display: flex;
flex-direction: column;
gap: 16px;
}
.role-details-members-search {
display: flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 6px 6px 6px 8px;
background: var(--l2-background);
border: 1px solid var(--secondary);
border-radius: 2px;
.role-details-members-search-icon {
flex-shrink: 0;
color: var(--foreground);
opacity: 0.5;
}
.role-details-members-search-input {
flex: 1;
height: 100%;
background: transparent;
border: none;
outline: none;
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--foreground);
&::placeholder {
color: var(--foreground);
opacity: 0.4;
}
}
}
.role-details-members-content {
display: flex;
flex-direction: column;
min-height: 420px;
border: 1px dashed var(--secondary);
border-radius: 3px;
margin-top: -1px;
}
.role-details-members-empty-state {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 48px 0;
flex-grow: 1;
.role-details-members-empty-emoji {
font-size: 32px;
line-height: 1;
}
.role-details-members-empty-text {
font-family: Inter;
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
&--bold {
font-weight: 500;
color: var(--l1-foreground);
}
&--muted {
font-weight: 400;
color: var(--foreground);
}
}
}
.role-details-skeleton {
padding: 16px 0;
}
}
.role-details-delete-modal {
width: calc(100% - 30px) !important;
max-width: 384px;
.ant-modal-content {
padding: 0;
border-radius: 4px;
border: 1px solid var(--secondary);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
.ant-modal-header {
padding: 16px;
background: var(--l2-background);
margin-bottom: 0;
}
.ant-modal-body {
padding: 0 16px 28px 16px;
}
.ant-modal-footer {
display: flex;
justify-content: flex-end;
padding: 16px;
margin: 0;
.cancel-btn {
display: flex;
align-items: center;
border: none;
border-radius: 2px;
}
.delete-btn {
display: flex;
align-items: center;
border: none;
border-radius: 2px;
margin-left: 12px;
}
}
}
.title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
}
.delete-text {
color: var(--foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
strong {
font-weight: 600;
color: var(--l1-foreground);
}
}
}

View File

@@ -0,0 +1,309 @@
import { useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Redirect, useHistory, useLocation } from 'react-router-dom';
import { Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import { Skeleton } from 'antd';
import {
getGetObjectsQueryKey,
useDeleteRole,
useGetObjects,
useGetRole,
usePatchObjects,
} from 'api/generated/services/role';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import permissionsType from 'hooks/useAuthZ/permissions.type';
import {
buildRoleDeletePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
} from 'hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import type { AuthzResources } from '../utils';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ROUTES from 'constants/routes';
import { capitalize } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { RoleType } from 'types/roles';
import { handleApiError, toAPIError } from 'utils/errorUtils';
import type { PermissionConfig } from '../PermissionSidePanel';
import PermissionSidePanel from '../PermissionSidePanel';
import CreateRoleModal from '../RolesComponents/CreateRoleModal';
import DeleteRoleModal from '../RolesComponents/DeleteRoleModal';
import {
buildPatchPayload,
derivePermissionTypes,
deriveResourcesForRelation,
objectsToPermissionConfig,
} from '../utils';
import OverviewTab from './components/OverviewTab';
import { ROLE_ID_REGEX } from './constants';
import './RoleDetailsPage.styles.scss';
// eslint-disable-next-line sonarjs/cognitive-complexity
function RoleDetailsPage(): JSX.Element {
const { pathname, search } = useLocation();
const history = useHistory();
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const { isRolesEnabled, isLoading: isRolesGateLoading } =
useRolesFeatureGate();
const authzResources: AuthzResources = permissionsType.data;
// Extract roleId from URL pathname since useParams doesn't work in nested routing
const roleIdMatch = pathname.match(ROLE_ID_REGEX);
const roleId = roleIdMatch ? roleIdMatch[1] : '';
// Role name passed as query param by the listing page — used to check read permission
// before the role details API resolves. Absent when navigating directly (e.g. deep link),
// in which case we skip the FGA check and fall back to the BE guard.
const nameFromQuery = useMemo(
() => new URLSearchParams(search).get('name') ?? '',
[search],
);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [activePermission, setActivePermission] = useState<string | null>(null);
const { data, isLoading, isFetching, isError, error } = useGetRole(
{ id: roleId },
{ query: { enabled: !!roleId } },
);
const role = data?.data;
const isTransitioning = isFetching && role?.id !== roleId;
const isManaged = role?.type === RoleType.MANAGED;
const roleName = role?.name ?? '';
// Read check — fires immediately using the name query param so we can gate the page
// before the role details API resolves. Skipped when name is absent.
const { permissions: readPerms, isLoading: isReadAuthZLoading } = useAuthZ(
nameFromQuery ? [buildRoleReadPermission(nameFromQuery)] : [],
{ enabled: !!nameFromQuery },
);
const hasReadPermission = nameFromQuery
? (readPerms?.[buildRoleReadPermission(nameFromQuery)]?.isGranted ?? true)
: true;
// Update check uses role name once loaded
const { permissions: updatePerms, isLoading: isAuthZLoading } = useAuthZ(
roleName && !isManaged ? [buildRoleUpdatePermission(roleName)] : [],
{ enabled: !!roleName && !isManaged },
);
const hasUpdatePermission = isAuthZLoading
? false
: (updatePerms?.[buildRoleUpdatePermission(roleName)]?.isGranted ?? false);
const permissionTypes = useMemo(
() => derivePermissionTypes(authzResources?.relations ?? null),
[authzResources],
);
const resourcesForActivePermission = useMemo(
() =>
activePermission
? deriveResourcesForRelation(authzResources ?? null, activePermission)
: [],
[authzResources, activePermission],
);
const { data: objectsData, isLoading: isLoadingObjects } = useGetObjects(
{ id: roleId, relation: activePermission ?? '' },
{
query: {
enabled: !!activePermission && !!roleId && !isManaged,
},
},
);
const initialConfig = useMemo(() => {
if (!objectsData?.data || !activePermission) {
return;
}
return objectsToPermissionConfig(
objectsData.data,
resourcesForActivePermission,
);
}, [objectsData, activePermission, resourcesForActivePermission]);
const handleSaveSuccess = (): void => {
toast.success('Permissions saved successfully');
if (activePermission) {
queryClient.removeQueries(
getGetObjectsQueryKey({ id: roleId, relation: activePermission }),
);
}
};
const { mutate: patchObjects, isLoading: isSaving } = usePatchObjects({
mutation: {
onSuccess: handleSaveSuccess,
onError: (err) => handleApiError(err, showErrorModal),
},
});
const { mutate: deleteRole, isLoading: isDeleting } = useDeleteRole({
mutation: {
onSuccess: (): void => {
toast.success('Role deleted successfully');
history.push(ROUTES.ROLES_SETTINGS);
},
onError: (err) => handleApiError(err, showErrorModal),
},
});
if (isRolesGateLoading) {
return (
<div className="role-details-page">
<Skeleton
active
paragraph={{ rows: 8 }}
className="role-details-skeleton"
/>
</div>
);
}
if (!isRolesEnabled) {
return <Redirect to={ROUTES.ROLES_SETTINGS} />;
}
if (!hasReadPermission && readPerms !== null) {
return <PermissionDeniedFullPage permissionName="role:read" />;
}
if (isLoading || isTransitioning || (!!nameFromQuery && isReadAuthZLoading)) {
return (
<div className="role-details-page">
<Skeleton
active
paragraph={{ rows: 8 }}
className="role-details-skeleton"
/>
</div>
);
}
if (isError) {
return (
<div className="role-details-page">
<ErrorInPlace
error={toAPIError(
error,
'An unexpected error occurred while fetching role details.',
)}
/>
</div>
);
}
if (!role) {
return (
<div className="role-details-page">
<Skeleton
active
paragraph={{ rows: 8 }}
className="role-details-skeleton"
/>
</div>
);
}
const handleSave = (config: PermissionConfig): void => {
if (!activePermission || !authzResources) {
return;
}
patchObjects({
pathParams: { id: roleId, relation: activePermission },
data: buildPatchPayload({
newConfig: config,
initialConfig: initialConfig ?? {},
resources: resourcesForActivePermission,
authzRes: authzResources,
}),
});
};
return (
<div className="role-details-page">
<div className="role-details-header">
<h2 className="role-details-title">Role {role.name}</h2>
{!isManaged && (
<div className="role-details-actions">
<AuthZTooltip checks={[buildRoleDeletePermission(role.name)]}>
<Button
variant="link"
color="destructive"
onClick={(): void => setIsDeleteModalOpen(true)}
aria-label="Delete role"
>
<Trash2 size={12} />
</Button>
</AuthZTooltip>
<AuthZTooltip checks={[buildRoleUpdatePermission(role.name)]}>
<Button
variant="solid"
color="secondary"
onClick={(): void => setIsEditModalOpen(true)}
>
Edit Role Details
</Button>
</AuthZTooltip>
</div>
)}
</div>
<OverviewTab
role={role || null}
isManaged={isManaged}
permissionTypes={permissionTypes}
onPermissionClick={(key): void => setActivePermission(key)}
/>
{!isManaged && (
<>
<PermissionSidePanel
open={activePermission !== null}
onClose={(): void => setActivePermission(null)}
permissionLabel={activePermission ? capitalize(activePermission) : ''}
relation={activePermission ?? ''}
resources={resourcesForActivePermission}
initialConfig={initialConfig}
isLoading={isLoadingObjects}
isSaving={isSaving}
canEdit={hasUpdatePermission}
onSave={handleSave}
/>
<CreateRoleModal
isOpen={isEditModalOpen}
onClose={(): void => setIsEditModalOpen(false)}
initialData={{
id: roleId,
name: role.name || '',
description: role.description || '',
}}
/>
</>
)}
<DeleteRoleModal
isOpen={isDeleteModalOpen}
roleName={role.name || ''}
isDeleting={isDeleting}
onCancel={(): void => setIsDeleteModalOpen(false)}
onConfirm={(): void => deleteRole({ pathParams: { id: roleId } })}
/>
</div>
);
}
export default RoleDetailsPage;

View File

@@ -0,0 +1,536 @@
import * as roleApi from 'api/generated/services/role';
import {
customRoleResponse,
managedRoleResponse,
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { Route, Switch } from 'react-router-dom';
import {
defaultFeatureFlags,
fireEvent,
render,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import { FeatureKeys } from 'constants/features';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import {
invalidLicense,
mockUseAuthZDenyAll,
mockUseAuthZGrantAll,
} from 'tests/authz-test-utils';
import RoleDetailsPage from '../RoleDetailsPage';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
const MANAGED_ROLE_ID = '019c24aa-2248-756f-9833-984f1ab63819';
const rolesApiBase = 'http://localhost/api/v1/roles';
const emptyObjectsResponse = { status: 'success', data: [] };
const allScopeObjectsResponse = {
status: 'success',
data: [
{
resource: { kind: 'role', type: 'role' },
selectors: ['*'],
},
],
};
function setupDefaultHandlers(roleId = CUSTOM_ROLE_ID): void {
const roleResponse =
roleId === MANAGED_ROLE_ID ? managedRoleResponse : customRoleResponse;
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(roleResponse)),
),
);
}
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
describe('RoleDetailsPage', () => {
it('renders custom role header, tabs, description, permissions, and action buttons', async () => {
setupDefaultHandlers();
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
await expect(
screen.findByText('Role — billing-manager'),
).resolves.toBeInTheDocument();
expect(
screen.getByText('Custom role for managing billing and invoices.'),
).toBeInTheDocument();
expect(screen.getByText('Create')).toBeInTheDocument();
expect(screen.getByText('Read')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /edit role details/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /delete role/i }),
).toBeInTheDocument();
});
it('shows managed-role warning callout and hides edit/delete buttons', async () => {
setupDefaultHandlers(MANAGED_ROLE_ID);
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${MANAGED_ROLE_ID}`,
});
await expect(
screen.findByText(/Role — signoz-admin/),
).resolves.toBeInTheDocument();
expect(
screen.getByText(
'This is a managed role. Permissions and settings are view-only and cannot be modified.',
),
).toBeInTheDocument();
expect(screen.queryByText('Edit Role Details')).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /delete role/i }),
).not.toBeInTheDocument();
});
it('edit flow: modal opens pre-filled and calls PATCH on save', async () => {
const patchSpy = jest.fn();
let description = customRoleResponse.data.description;
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
...customRoleResponse,
data: { ...customRoleResponse.data, description },
}),
),
),
rest.patch(`${rolesApiBase}/:id`, async (req, res, ctx) => {
const body = await req.json();
patchSpy(body);
description = body.description;
return res(
ctx.status(200),
ctx.json({
...customRoleResponse,
data: { ...customRoleResponse.data, description },
}),
);
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
await screen.findByText('Role — billing-manager');
await user.click(screen.getByRole('button', { name: /edit role details/i }));
await expect(
screen.findByText('Edit Role Details', { selector: '.ant-modal-title' }),
).resolves.toBeInTheDocument();
const nameInput = screen.getByPlaceholderText(
'Enter role name e.g. : Service Owner',
);
expect(nameInput).toBeDisabled();
const descField = screen.getByPlaceholderText(
'A helpful description of the role',
);
await user.clear(descField);
await user.type(descField, 'Updated description');
await user.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() =>
expect(patchSpy).toHaveBeenCalledWith({
description: 'Updated description',
}),
);
await waitFor(() =>
expect(
screen.queryByText('Edit Role Details', { selector: '.ant-modal-title' }),
).not.toBeInTheDocument(),
);
await expect(
screen.findByText('Updated description'),
).resolves.toBeInTheDocument();
});
it('delete flow: modal shows role name, DELETE called on confirm', async () => {
const deleteSpy = jest.fn();
setupDefaultHandlers();
server.use(
rest.delete(`${rolesApiBase}/:id`, (_req, res, ctx) => {
deleteSpy();
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
await screen.findByText('Role — billing-manager');
await user.click(screen.getByRole('button', { name: /delete role/i }));
await expect(
screen.findByText(/Are you sure you want to delete the role/),
).resolves.toBeInTheDocument();
const dialog = await screen.findByRole('dialog');
await user.click(
within(dialog).getByRole('button', { name: /delete role/i }),
);
await waitFor(() => expect(deleteSpy).toHaveBeenCalled());
await waitFor(() =>
expect(
screen.queryByText(/Are you sure you want to delete the role/),
).not.toBeInTheDocument(),
);
});
it('shows PermissionDeniedFullPage when read permission is denied via query param', async () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}?name=billing-manager`,
});
await expect(
screen.findByText(/you don't have permission to view this page/i),
).resolves.toBeInTheDocument();
});
it('redirects to the roles list when license is not valid', async () => {
render(
<Switch>
<Route path="/settings/roles/:roleId">
<RoleDetailsPage />
</Route>
<Route path="/settings/roles" exact>
<div data-testid="roles-list-redirect-target" />
</Route>
</Switch>,
undefined,
{
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
appContextOverrides: { activeLicense: invalidLicense },
},
);
await expect(
screen.findByTestId('roles-list-redirect-target'),
).resolves.toBeInTheDocument();
});
it('redirects to the roles list when fine-grained authz flag is inactive', async () => {
render(
<Switch>
<Route path="/settings/roles/:roleId">
<RoleDetailsPage />
</Route>
<Route path="/settings/roles" exact>
<div data-testid="roles-list-redirect-target" />
</Route>
</Switch>,
undefined,
{
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
appContextOverrides: {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
},
},
);
await expect(
screen.findByTestId('roles-list-redirect-target'),
).resolves.toBeInTheDocument();
});
describe('permission side panel', () => {
beforeEach(() => {
// Both hooks mocked so data renders synchronously — no React Query scheduler or MSW round-trip.
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isFetching: false,
isError: false,
error: null,
} as any);
jest
.spyOn(roleApi, 'useGetObjects')
.mockReturnValue({ data: emptyObjectsResponse, isLoading: false } as any);
});
afterEach(() => {
jest.restoreAllMocks();
});
async function openCreatePanel(): Promise<HTMLElement> {
await screen.findByText('Role — billing-manager');
fireEvent.click(screen.getByText('Create'));
await screen.findByText('Edit Create Permissions');
const panel = document.querySelector(
'.permission-side-panel',
) as HTMLElement;
await within(panel).findByRole('button', { name: 'role' });
return panel;
}
async function openReadPanel(): Promise<HTMLElement> {
await screen.findByText('Role — billing-manager');
fireEvent.click(screen.getByText('Read'));
await screen.findByText('Edit Read Permissions');
const panel = document.querySelector(
'.permission-side-panel',
) as HTMLElement;
await within(panel).findByRole('button', { name: 'role' });
return panel;
}
it('Save Changes is disabled until a resource scope is changed', async () => {
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
const panel = await openCreatePanel();
expect(
within(panel).getByRole('button', { name: /save changes/i }),
).toBeDisabled();
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
fireEvent.click(screen.getByText('All'));
expect(
within(panel).getByRole('button', { name: /save changes/i }),
).not.toBeDisabled();
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
});
it('set scope to All → patchObjects additions: ["*"], deletions: null', async () => {
const patchSpy = jest.fn();
server.use(
rest.patch(
`${rolesApiBase}/:id/relations/:relation/objects`,
async (req, res, ctx) => {
patchSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
},
),
);
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
const panel = await openCreatePanel();
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
fireEvent.click(screen.getByText('All'));
fireEvent.click(
within(panel).getByRole('button', { name: /save changes/i }),
);
await waitFor(() =>
expect(patchSpy).toHaveBeenCalledWith({
additions: [
{
resource: { kind: 'role', type: 'role' },
selectors: ['*'],
},
],
deletions: null,
}),
);
});
it('set scope to Only selected with IDs → patchObjects additions contain those IDs', async () => {
const patchSpy = jest.fn();
server.use(
rest.patch(
`${rolesApiBase}/:id/relations/:relation/objects`,
async (req, res, ctx) => {
patchSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
},
),
);
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
const panel = await openReadPanel();
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
// Default is NONE, so switch to Only selected first to reveal the combobox
fireEvent.click(screen.getByText('Only selected'));
const combobox = within(panel).getByRole('combobox');
fireEvent.change(combobox, { target: { value: 'role-001' } });
fireEvent.keyDown(combobox, { key: 'Enter', keyCode: 13 });
fireEvent.click(
within(panel).getByRole('button', { name: /save changes/i }),
);
await waitFor(() =>
expect(patchSpy).toHaveBeenCalledWith({
additions: [
{
resource: { kind: 'role', type: 'role' },
selectors: ['role-001'],
},
],
deletions: null,
}),
);
});
it('set scope to None on create panel (existing All) → patchObjects deletions: ["*"], additions: null', async () => {
const patchSpy = jest.fn();
jest.spyOn(roleApi, 'useGetObjects').mockReturnValue({
data: allScopeObjectsResponse,
isLoading: false,
} as any);
server.use(
rest.patch(
`${rolesApiBase}/:id/relations/:relation/objects`,
async (req, res, ctx) => {
patchSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
},
),
);
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
const panel = await openCreatePanel();
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
fireEvent.click(screen.getByText('None'));
fireEvent.click(
within(panel).getByRole('button', { name: /save changes/i }),
);
await waitFor(() =>
expect(patchSpy).toHaveBeenCalledWith({
additions: null,
deletions: [
{
resource: { kind: 'role', type: 'role' },
selectors: ['*'],
},
],
}),
);
});
it('existing All scope changed to Only selected (empty) → patchObjects deletions: ["*"], additions: null', async () => {
const patchSpy = jest.fn();
jest.spyOn(roleApi, 'useGetObjects').mockReturnValue({
data: allScopeObjectsResponse,
isLoading: false,
} as any);
server.use(
rest.patch(
`${rolesApiBase}/:id/relations/:relation/objects`,
async (req, res, ctx) => {
patchSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
},
),
);
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
const panel = await openReadPanel();
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
fireEvent.click(screen.getByText('Only selected'));
fireEvent.click(
within(panel).getByRole('button', { name: /save changes/i }),
);
await waitFor(() =>
expect(patchSpy).toHaveBeenCalledWith({
additions: null,
deletions: [
{
resource: { kind: 'role', type: 'role' },
selectors: ['*'],
},
],
}),
);
});
it('unsaved changes counter shown on scope change, Discard resets it', async () => {
render(<RoleDetailsPage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
const panel = await openCreatePanel();
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
fireEvent.click(screen.getByText('All'));
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
fireEvent.click(within(panel).getByRole('button', { name: /discard/i }));
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
expect(
within(panel).getByRole('button', { name: /save changes/i }),
).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,44 @@
import { useState } from 'react';
import { Search } from '@signozhq/icons';
function MembersTab(): JSX.Element {
const [searchQuery, setSearchQuery] = useState('');
return (
<div className="role-details-members">
<div className="role-details-members-search">
<Search size={12} className="role-details-members-search-icon" />
<input
type="text"
className="role-details-members-search-input"
placeholder="Search and add members..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
</div>
{/* Todo: Right now we are only adding the empty state in this cut */}
<div className="role-details-members-content">
<div className="role-details-members-empty-state">
<span
className="role-details-members-empty-emoji"
role="img"
aria-label="monocle face"
>
🧐
</span>
<p className="role-details-members-empty-text">
<span className="role-details-members-empty-text--bold">
No members added.
</span>{' '}
<span className="role-details-members-empty-text--muted">
Start adding members to this role.
</span>
</p>
</div>
</div>
</div>
);
}
export default MembersTab;

View File

@@ -0,0 +1,87 @@
import { Callout } from '@signozhq/ui/callout';
import { PermissionType, TimestampBadge } from '../../utils';
import PermissionItem from './PermissionItem';
import { AuthtypesRelationDTO } from 'api/generated/services/sigNoz.schemas';
interface OverviewTabProps {
role: {
description?: string;
createdAt?: Date | string;
updatedAt?: Date | string;
} | null;
isManaged: boolean;
permissionTypes: PermissionType[];
onPermissionClick: (relationKey: string) => void;
}
function OverviewTab({
role,
isManaged,
permissionTypes,
onPermissionClick,
}: OverviewTabProps): JSX.Element {
return (
<div className="role-details-overview">
{isManaged && (
<Callout
type="warning"
showIcon
title="This is a managed role. Permissions and settings are view-only and cannot be modified."
/>
)}
<div className="role-details-meta">
<div>
<p className="role-details-section-label">Description</p>
<p className="role-details-description-text">{role?.description || '—'}</p>
</div>
<div className="role-details-info-row">
<div className="role-details-info-col">
<p className="role-details-section-label">Created At</p>
<div className="role-details-info-value">
<TimestampBadge date={role?.createdAt} />
</div>
</div>
<div className="role-details-info-col">
<p className="role-details-section-label">Last Modified At</p>
<div className="role-details-info-value">
<TimestampBadge date={role?.updatedAt} />
</div>
</div>
</div>
</div>
<div className="role-details-permissions">
<div className="role-details-permissions-header">
<span className="role-details-section-label">Permissions</span>
<a
href="https://signoz.io/docs/manage/administrator-guide/iam/permissions/"
target="_blank"
rel="noopener noreferrer"
className="role-details-permissions-learn-more"
>
Learn more
</a>
<hr className="role-details-permissions-divider" />
</div>
<div className="role-details-permission-list">
{permissionTypes
.filter((p) => p.key !== AuthtypesRelationDTO.assignee)
.map((permissionType) => (
<PermissionItem
key={permissionType.key}
permissionType={permissionType}
isManaged={isManaged}
onPermissionClick={onPermissionClick}
/>
))}
</div>
</div>
</div>
);
}
export default OverviewTab;

View File

@@ -0,0 +1,54 @@
import { ChevronRight } from '@signozhq/icons';
import { PermissionType } from '../../utils';
interface PermissionItemProps {
permissionType: PermissionType;
isManaged: boolean;
onPermissionClick: (key: string) => void;
}
function PermissionItem({
permissionType,
isManaged,
onPermissionClick,
}: PermissionItemProps): JSX.Element {
const { key, label, icon } = permissionType;
if (isManaged) {
return (
<div
key={key}
className="role-details-permission-item role-details-permission-item--readonly"
>
<div className="role-details-permission-item-left">
{icon}
<span className="role-details-permission-item-label">{label}</span>
</div>
</div>
);
}
return (
<div
key={key}
className="role-details-permission-item"
role="button"
tabIndex={0}
onClick={(): void => onPermissionClick(key)}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
onPermissionClick(key);
}
}}
>
<div className="role-details-permission-item-left">
{icon}
<span className="role-details-permission-item-label">{label}</span>
</div>
<ChevronRight size={14} color="var(--foreground)" />
</div>
);
}
export default PermissionItem;

View File

@@ -0,0 +1,22 @@
import {
BadgePlus,
Eye,
LayoutList,
PencilRuler,
Settings,
Trash2,
} from '@signozhq/icons';
export const ROLE_ID_REGEX = /\/settings\/roles\/([^/]+)/;
export type IconComponent = React.ComponentType<any>;
export const PERMISSION_ICON_MAP: Record<string, IconComponent> = {
create: BadgePlus,
list: LayoutList,
read: Eye,
update: PencilRuler,
delete: Trash2,
};
export const FALLBACK_PERMISSION_ICON: IconComponent = Settings;

View File

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

View File

@@ -0,0 +1,189 @@
import { useCallback, useEffect, useRef } from 'react';
import { useQueryClient } from 'react-query';
import { generatePath, useHistory } from 'react-router-dom';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import { Form, Modal } from 'antd';
import {
invalidateGetRole,
invalidateListRoles,
useCreateRole,
usePatchRole,
} from 'api/generated/services/role';
import {
AuthtypesPostableRoleDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { handleApiError } from 'utils/errorUtils';
import '../RolesSettings.styles.scss';
export interface CreateRoleModalInitialData {
id: string;
name: string;
description?: string;
}
interface CreateRoleModalProps {
isOpen: boolean;
onClose: () => void;
initialData?: CreateRoleModalInitialData;
}
interface CreateRoleFormValues {
name: string;
description?: string;
}
function CreateRoleModal({
isOpen,
onClose,
initialData,
}: CreateRoleModalProps): JSX.Element {
const [form] = Form.useForm<CreateRoleFormValues>();
const queryClient = useQueryClient();
const history = useHistory();
const { showErrorModal } = useErrorModal();
const isEditMode = !!initialData?.id;
const prevIsOpen = useRef(isOpen);
useEffect(() => {
if (isOpen && !prevIsOpen.current) {
if (isEditMode && initialData) {
form.setFieldsValue({
name: initialData.name,
description: initialData.description || '',
});
} else {
form.resetFields();
}
}
prevIsOpen.current = isOpen;
}, [isOpen, isEditMode, initialData, form]);
const handleSuccess = async (
message: string,
redirectPath?: string,
): Promise<void> => {
await invalidateListRoles(queryClient);
if (isEditMode && initialData?.id) {
await invalidateGetRole(queryClient, { id: initialData.id });
}
toast.success(message);
form.resetFields();
onClose();
if (redirectPath) {
history.push(redirectPath);
}
};
const handleError = (error: ErrorType<RenderErrorResponseDTO>): void => {
handleApiError(error, showErrorModal);
};
const { mutate: createRole, isLoading: isCreating } = useCreateRole({
mutation: {
onSuccess: (res) =>
handleSuccess(
'Role created successfully',
generatePath(ROUTES.ROLE_DETAILS, { roleId: res.data.id }),
),
onError: handleError,
},
});
const { mutate: patchRole, isLoading: isPatching } = usePatchRole({
mutation: {
onSuccess: () => handleSuccess('Role updated successfully'),
onError: handleError,
},
});
const onSubmit = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
if (isEditMode && initialData?.id) {
patchRole({
pathParams: { id: initialData.id },
data: { description: values.description || '' },
});
} else {
const data: AuthtypesPostableRoleDTO = {
name: values.name,
description: values.description || '',
transactionGroups: [],
};
createRole({ data });
}
} catch {
// form validation failed; antd handles inline error display
}
}, [form, createRole, patchRole, isEditMode, initialData]);
const onCancel = useCallback((): void => {
form.resetFields();
onClose();
}, [form, onClose]);
const isLoading = isCreating || isPatching;
return (
<Modal
open={isOpen}
onCancel={onCancel}
title={isEditMode ? 'Edit Role Details' : 'Create a New Role'}
footer={[
<Button
key="cancel"
variant="solid"
color="secondary"
onClick={onCancel}
size="sm"
>
<X size={14} />
Cancel
</Button>,
<Button
key="submit"
variant="solid"
color="primary"
onClick={onSubmit}
loading={isLoading}
size="sm"
>
{isEditMode ? 'Save Changes' : 'Create Role'}
</Button>,
]}
destroyOnClose
className="create-role-modal"
width={530}
>
<Form form={form} layout="vertical" className="create-role-form">
<Form.Item
name="name"
label="Name"
rules={[{ required: true, message: 'Role name is required' }]}
>
<Input
disabled={isEditMode}
placeholder="Enter role name e.g. : Service Owner"
/>
</Form.Item>
<Form.Item name="description" label="Description">
<textarea
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder="A helpful description of the role"
/>
</Form.Item>
</Form>
</Modal>
);
}
export default CreateRoleModal;

View File

@@ -0,0 +1,60 @@
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Modal } from 'antd';
interface DeleteRoleModalProps {
isOpen: boolean;
roleName: string;
isDeleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
function DeleteRoleModal({
isOpen,
roleName,
isDeleting,
onCancel,
onConfirm,
}: DeleteRoleModalProps): JSX.Element {
return (
<Modal
open={isOpen}
onCancel={onCancel}
title={<span className="title">Delete Role</span>}
closable
footer={[
<Button
key="cancel"
className="cancel-btn"
prefix={<X size={14} />}
onClick={onCancel}
variant="solid"
color="secondary"
>
Cancel
</Button>,
<Button
key="delete"
className="delete-btn"
prefix={<Trash2 size={14} />}
onClick={onConfirm}
loading={isDeleting}
variant="solid"
color="destructive"
>
Delete Role
</Button>,
]}
destroyOnClose
className="role-details-delete-modal"
>
<p className="delete-text">
Are you sure you want to delete the role <strong>{roleName}</strong>? This
action cannot be undone.
</p>
</Modal>
);
}
export default DeleteRoleModal;

View File

@@ -1,167 +0,0 @@
.rolesListingTable {
margin-top: 12px;
border-radius: 4px;
overflow: hidden;
}
.scrollContainer {
overflow-x: auto;
}
.tableInner {
min-width: 850px;
}
.tableHeader {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 24px;
}
.headerCell {
font-family: Inter;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.44px;
color: var(--foreground);
line-height: 16px;
}
.headerCellName {
flex: 0 0 180px;
}
.headerCellDescription {
flex: 1;
min-width: 0;
}
.headerCellCreatedAt {
flex: 0 0 180px;
text-align: right;
}
.headerCellUpdatedAt {
flex: 0 0 180px;
text-align: right;
}
.sectionHeader {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 16px;
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
border-top: 1px solid var(--secondary);
border-bottom: 1px solid var(--secondary);
font-family: Inter;
font-size: 12px;
font-weight: 400;
color: var(--foreground);
line-height: 16px;
margin: 0;
}
.sectionHeaderCount {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: 9px;
background: var(--l3-background);
font-size: 12px;
font-weight: 500;
color: var(--foreground);
line-height: 1;
}
.tableRow {
display: flex;
align-items: center;
padding: 8px 16px;
background: color-mix(in srgb, var(--bg-robin-200) 2%, transparent);
border-bottom: 1px solid var(--secondary);
gap: 24px;
}
.tableRowClickable {
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--bg-robin-200) 5%, transparent);
}
}
.tableCell {
font-family: Inter;
font-size: 14px;
font-weight: 400;
color: var(--foreground);
line-height: 20px;
}
.tableCellName {
flex: 0 0 180px;
font-weight: 500;
}
.tableCellDescription {
flex: 1;
min-width: 0;
overflow: hidden;
}
.tableCellCreatedAt {
flex: 0 0 180px;
text-align: right;
white-space: nowrap;
}
.tableCellUpdatedAt {
flex: 0 0 180px;
text-align: right;
white-space: nowrap;
}
.emptyState {
padding: 24px 16px;
text-align: center;
color: var(--foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
.pagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 16px;
:global(.ant-pagination-total-text) {
margin-right: auto;
.numbers {
font-family: Inter;
font-size: 12px;
color: var(--foreground);
}
.total {
font-family: Inter;
font-size: 12px;
color: var(--foreground);
opacity: 0.5;
}
}
}
.descriptionTooltip {
max-height: none;
overflow-y: visible;
}

View File

@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import cx from 'classnames';
import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { RoleListPermission } from 'hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
@@ -16,7 +16,7 @@ import { useTimezone } from 'providers/Timezone';
import { RoleType } from 'types/roles';
import { toAPIError } from 'utils/errorUtils';
import styles from './RolesListingTable.module.scss';
import '../RolesSettings.styles.scss';
const PAGE_SIZE = 20;
@@ -41,7 +41,7 @@ function RolesListingTable({
const { data, isLoading, isError, error } = useListRoles({
query: { enabled: hasListPermission },
});
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
@@ -57,6 +57,19 @@ function RolesListingTable({
const roles = useMemo(() => data?.data ?? [], [data]);
const formatTimestamp = (date?: Date | string): string => {
if (!date) {
return '—';
}
const d = new Date(date);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
};
const filteredRoles = useMemo(() => {
if (!searchQuery.trim()) {
return roles;
@@ -82,6 +95,7 @@ function RolesListingTable({
[filteredRoles],
);
// Combine managed + custom into a flat display list for pagination
const displayList = useMemo((): DisplayItem[] => {
const result: DisplayItem[] = [];
@@ -102,6 +116,7 @@ function RolesListingTable({
const totalRoleCount = managedRoles.length + customRoles.length;
// Ensure current page is valid; if out of bounds, redirect to last available page
useEffect(() => {
if (isLoading || totalRoleCount === 0) {
return;
@@ -112,6 +127,7 @@ function RolesListingTable({
}
}, [isLoading, totalRoleCount, currentPage, setCurrentPage]);
// Paginate: count only role items, but include section headers contextually
const paginatedItems = useMemo((): DisplayItem[] => {
const startRole = (currentPage - 1) * PAGE_SIZE;
const endRole = startRole + PAGE_SIZE;
@@ -124,6 +140,7 @@ function RolesListingTable({
lastSection = item;
} else {
if (roleIndex >= startRole && roleIndex < endRole) {
// Insert section header before first role in that section on this page
if (lastSection) {
result.push(lastSection);
lastSection = null;
@@ -136,16 +153,6 @@ function RolesListingTable({
return result;
}, [displayList, currentPage]);
const handleRowClick = useCallback(
(roleId: string, roleName: string): void => {
if (isRolesEnabled) {
const url = `${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}?name=${encodeURIComponent(roleName)}`;
history.push(url);
}
},
[isRolesEnabled, history],
);
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
<>
<span className="numbers">
@@ -161,7 +168,7 @@ function RolesListingTable({
if (isAuthZLoading || isLoading) {
return (
<div className={styles.rolesListingTable}>
<div className="roles-listing-table">
<Skeleton active paragraph={{ rows: 5 }} />
</div>
);
@@ -169,7 +176,7 @@ function RolesListingTable({
if (isError) {
return (
<div className={styles.rolesListingTable}>
<div className="roles-listing-table">
<ErrorInPlace
error={toAPIError(
error,
@@ -182,27 +189,31 @@ function RolesListingTable({
if (filteredRoles.length === 0) {
return (
<div className={styles.rolesListingTable}>
<div className={styles.emptyState}>
<div className="roles-listing-table">
<div className="roles-table-empty">
{searchQuery ? 'No roles match your search.' : 'No roles found.'}
</div>
</div>
);
}
const navigateToRole = (roleId: string, roleName?: string): void => {
const search = roleName ? `?name=${encodeURIComponent(roleName)}` : '';
history.push(`${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}${search}`);
};
// todo: use table from periscope when its available for consumption
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
<div
key={role.id}
className={cx(styles.tableRow, {
[styles.tableRowClickable]: isRolesEnabled,
})}
className={`roles-table-row${isRolesEnabled ? ' roles-table-row--clickable' : ''}`}
role={isRolesEnabled ? 'button' : undefined}
tabIndex={isRolesEnabled ? 0 : undefined}
onClick={
isRolesEnabled
? (): void => {
if (role.id && role.name) {
handleRowClick(role.id, role.name);
if (role.id) {
navigateToRole(role.id, role.name);
}
}
: undefined
@@ -210,54 +221,56 @@ function RolesListingTable({
onKeyDown={
isRolesEnabled
? (e): void => {
if ((e.key === 'Enter' || e.key === ' ') && role.id && role.name) {
handleRowClick(role.id, role.name);
if ((e.key === 'Enter' || e.key === ' ') && role.id) {
navigateToRole(role.id, role.name);
}
}
: undefined
}
>
<div className={cx(styles.tableCell, styles.tableCellName)}>
<div className="roles-table-cell roles-table-cell--name">
{role.name ?? '—'}
</div>
<div className={cx(styles.tableCell, styles.tableCellDescription)}>
<div className="roles-table-cell roles-table-cell--description">
<LineClampedText
text={role.description ?? '—'}
tooltipProps={{ overlayClassName: styles.descriptionTooltip }}
tooltipProps={{ overlayClassName: 'roles-description-tooltip' }}
/>
</div>
<div className={cx(styles.tableCell, styles.tableCellUpdatedAt)}>
{formatTimezoneAdjustedTimestampOptional(role.updatedAt)}
<div className="roles-table-cell roles-table-cell--updated-at">
{formatTimestamp(role.updatedAt)}
</div>
<div className={cx(styles.tableCell, styles.tableCellCreatedAt)}>
{formatTimezoneAdjustedTimestampOptional(role.createdAt)}
<div className="roles-table-cell roles-table-cell--created-at">
{formatTimestamp(role.createdAt)}
</div>
</div>
);
return (
<div className={styles.rolesListingTable}>
<div className={styles.scrollContainer}>
<div className={styles.tableInner}>
<div className={styles.tableHeader}>
<div className={cx(styles.headerCell, styles.headerCellName)}>Name</div>
<div className={cx(styles.headerCell, styles.headerCellDescription)}>
<div className="roles-listing-table">
<div className="roles-table-scroll-container">
<div className="roles-table-inner">
<div className="roles-table-header">
<div className="roles-table-header-cell roles-table-header-cell--name">
Name
</div>
<div className="roles-table-header-cell roles-table-header-cell--description">
Description
</div>
<div className={cx(styles.headerCell, styles.headerCellUpdatedAt)}>
<div className="roles-table-header-cell roles-table-header-cell--updated-at">
Updated At
</div>
<div className={cx(styles.headerCell, styles.headerCellCreatedAt)}>
<div className="roles-table-header-cell roles-table-header-cell--created-at">
Created At
</div>
</div>
{paginatedItems.map((item) =>
item.type === 'section' ? (
<h3 key={`section-${item.label}`} className={styles.sectionHeader}>
<h3 key={`section-${item.label}`} className="roles-table-section-header">
{item.label}
{item.count !== undefined && (
<span className={styles.sectionHeaderCount}>{item.count}</span>
<span className="roles-table-section-header__count">{item.count}</span>
)}
</h3>
) : (
@@ -275,7 +288,7 @@ function RolesListingTable({
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => setCurrentPage(page)}
className={styles.pagination}
className="roles-table-pagination"
/>
</div>
);

View File

@@ -1,229 +0,0 @@
.rolesSettingsHeader {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 100%;
padding: 16px;
}
.rolesSettingsHeaderTitle {
color: var(--l1-foreground);
font-family: Inter;
font-style: normal;
font-size: 18px;
font-weight: 500;
line-height: 28px;
letter-spacing: -0.09px;
margin: 0;
}
.rolesSettingsHeaderDescription {
color: var(--foreground);
font-family: Inter;
font-style: normal;
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: 20px;
letter-spacing: -0.07px;
margin: 0;
}
.rolesSettingsHeaderLearnMore {
color: var(--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.rolesSettingsContent {
padding: 0 16px;
}
.rolesSettingsToolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.roleSettingsToolbarButton {
display: flex;
width: 156px;
height: 32px;
padding: 6px 12px;
justify-content: center;
align-items: center;
gap: 6px;
border-radius: 2px;
}
.rolesDescriptionTooltip {
max-height: none;
overflow-y: visible;
}
.rolesListingTable {
margin-top: 12px;
border-radius: 4px;
overflow: hidden;
}
.rolesTableScrollContainer {
overflow-x: auto;
}
.rolesTableInner {
min-width: 850px;
}
.rolesTableHeader {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 24px;
}
.rolesTableHeaderCell {
font-family: Inter;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.44px;
color: var(--foreground);
line-height: 16px;
}
.rolesTableHeaderCellName {
flex: 0 0 180px;
}
.rolesTableHeaderCellDescription {
flex: 1;
min-width: 0;
}
.rolesTableHeaderCellCreatedAt {
flex: 0 0 180px;
text-align: right;
}
.rolesTableHeaderCellUpdatedAt {
flex: 0 0 180px;
text-align: right;
}
.rolesTableSectionHeader {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 16px;
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
border-top: 1px solid var(--secondary);
border-bottom: 1px solid var(--secondary);
font-family: Inter;
font-size: 12px;
font-weight: 400;
color: var(--foreground);
line-height: 16px;
margin: 0;
}
.rolesTableSectionHeaderCount {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: 9px;
background: var(--l3-background);
font-size: 12px;
font-weight: 500;
color: var(--foreground);
line-height: 1;
}
.rolesTableRow {
display: flex;
align-items: center;
padding: 8px 16px;
background: color-mix(in srgb, var(--bg-robin-200) 2%, transparent);
border-bottom: 1px solid var(--secondary);
gap: 24px;
}
.rolesTableRowClickable {
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--bg-robin-200) 5%, transparent);
}
}
.rolesTableCell {
font-family: Inter;
font-size: 14px;
font-weight: 400;
color: var(--foreground);
line-height: 20px;
}
.rolesTableCellName {
flex: 0 0 180px;
font-weight: 500;
}
.rolesTableCellDescription {
flex: 1;
min-width: 0;
overflow: hidden;
}
.rolesTableCellCreatedAt {
flex: 0 0 180px;
text-align: right;
white-space: nowrap;
}
.rolesTableCellUpdatedAt {
flex: 0 0 180px;
text-align: right;
white-space: nowrap;
}
.rolesTableEmpty {
padding: 24px 16px;
text-align: center;
color: var(--foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
.rolesTablePagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 16px;
:global(.ant-pagination-total-text) {
margin-right: auto;
}
:global(.ant-pagination-total-text .numbers) {
font-family: Inter;
font-size: 12px;
color: var(--foreground);
}
:global(.ant-pagination-total-text .total) {
font-family: Inter;
font-size: 12px;
color: var(--foreground);
opacity: 0.5;
}
}

View File

@@ -0,0 +1,345 @@
.roles-settings {
.roles-settings-header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 100%;
padding: 16px;
.roles-settings-header-title {
color: var(--l1-foreground);
font-family: Inter;
font-style: normal;
font-size: 18px;
font-weight: 500;
line-height: 28px;
letter-spacing: -0.09px;
margin: 0;
}
.roles-settings-header-description {
color: var(--foreground);
font-family: Inter;
font-style: normal;
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: 20px;
letter-spacing: -0.07px;
margin: 0;
}
.roles-settings-header-learn-more {
color: var(--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.roles-settings-content {
padding: 0 16px;
}
.roles-settings-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
.role-settings-toolbar-button {
display: flex;
width: 156px;
height: 32px;
padding: 6px 12px;
justify-content: center;
align-items: center;
gap: 6px;
border-radius: 2px;
}
}
}
.roles-description-tooltip {
max-height: none;
overflow-y: visible;
}
.roles-listing-table {
margin-top: 12px;
border-radius: 4px;
overflow: hidden;
}
.roles-table-scroll-container {
overflow-x: auto;
}
.roles-table-inner {
min-width: 850px;
}
.roles-table-header {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 24px;
}
.roles-table-header-cell {
font-family: Inter;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.44px;
color: var(--foreground);
line-height: 16px;
&--name {
flex: 0 0 180px;
}
&--description {
flex: 1;
min-width: 0;
}
&--created-at {
flex: 0 0 180px;
text-align: right;
}
&--updated-at {
flex: 0 0 180px;
text-align: right;
}
}
.roles-table-section-header {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 16px;
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
border-top: 1px solid var(--secondary);
border-bottom: 1px solid var(--secondary);
font-family: Inter;
font-size: 12px;
font-weight: 400;
color: var(--foreground);
line-height: 16px;
margin: 0;
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: 9px;
background: var(--l3-background);
font-size: 12px;
font-weight: 500;
color: var(--foreground);
line-height: 1;
}
}
.roles-table-row {
display: flex;
align-items: center;
padding: 8px 16px;
background: color-mix(in srgb, var(--bg-robin-200) 2%, transparent);
border-bottom: 1px solid var(--secondary);
gap: 24px;
&--clickable {
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--bg-robin-200) 5%, transparent);
}
}
}
.roles-table-cell {
font-family: Inter;
font-size: 14px;
font-weight: 400;
color: var(--foreground);
line-height: 20px;
&--name {
flex: 0 0 180px;
font-weight: 500;
}
&--description {
flex: 1;
min-width: 0;
overflow: hidden;
}
&--created-at {
flex: 0 0 180px;
text-align: right;
white-space: nowrap;
}
&--updated-at {
flex: 0 0 180px;
text-align: right;
white-space: nowrap;
}
}
.roles-table-empty {
padding: 24px 16px;
text-align: center;
color: var(--foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
.roles-table-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 16px;
.ant-pagination-total-text {
margin-right: auto;
.numbers {
font-family: Inter;
font-size: 12px;
color: var(--foreground);
}
.total {
font-family: Inter;
font-size: 12px;
color: var(--foreground);
opacity: 0.5;
}
}
}
.create-role-modal {
.ant-modal-content {
padding: 0;
background: var(--l2-background);
border: 1px solid var(--secondary);
border-radius: 4px;
}
.ant-modal-header {
background: var(--l2-background);
border-bottom: 1px solid var(--secondary);
padding: 16px;
margin-bottom: 0;
}
.ant-modal-close {
top: 14px;
inset-inline-end: 16px;
width: 14px;
height: 14px;
color: var(--foreground);
.ant-modal-close-x {
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
}
.ant-modal-title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
}
.ant-modal-body {
padding: 16px;
}
.create-role-form {
display: flex;
flex-direction: column;
gap: 16px;
.ant-form-item {
margin-bottom: 0;
}
.ant-form-item-label {
padding-bottom: 8px;
label {
color: var(--foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
}
input {
&::placeholder {
opacity: 0.4;
}
}
textarea {
width: 100%;
box-sizing: border-box;
min-height: 100px;
resize: vertical;
background: var(--input-background, transparent);
border: 1px solid var(--border);
border-radius: 2px;
padding: 6px 8px;
font-family: Inter;
font-size: var(--font-size-xs);
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
outline: none;
box-shadow: none;
&::placeholder {
color: var(--muted-foreground);
opacity: 0.4;
}
&:focus,
&:hover {
border-color: var(--input);
box-shadow: none;
}
}
}
.ant-modal-footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin: 0;
padding: 0 16px;
height: 56px;
border-top: 1px solid var(--secondary);
}
}

View File

@@ -1,42 +1,39 @@
import { useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import ROUTES from 'constants/routes';
import { RoleCreatePermission } from 'hooks/useAuthZ/permissions/role.permissions';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import CreateRoleModal from './RolesComponents/CreateRoleModal';
import RolesListingTable from './RolesComponents/RolesListingTable';
import styles from './RolesSettings.module.scss';
import './RolesSettings.styles.scss';
function RolesSettings(): JSX.Element {
const [searchQuery, setSearchQuery] = useState('');
const history = useHistory();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { isRolesEnabled } = useRolesFeatureGate();
return (
<div data-testid="roles-settings">
<div className={styles.rolesSettingsHeader}>
<h3 className={styles.rolesSettingsHeaderTitle}>Roles</h3>
<p className={styles.rolesSettingsHeaderDescription}>
{isRolesEnabled
? 'Create and manage custom roles for your team. '
: 'The built-in roles of this instance.'}{' '}
<div className="roles-settings" data-testid="roles-settings">
<div className="roles-settings-header">
<h3 className="roles-settings-header-title">Roles</h3>
<p className="roles-settings-header-description">
Create and manage custom roles for your team.{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/iam/roles/"
target="_blank"
rel="noopener noreferrer"
className={styles.rolesSettingsHeaderLearnMore}
className="roles-settings-header-learn-more"
>
Learn more
</a>
</p>
</div>
<div className={styles.rolesSettingsContent}>
<div className={styles.rolesSettingsToolbar}>
<div className="roles-settings-content">
<div className="roles-settings-toolbar">
<Input
type="search"
placeholder="Search for roles..."
@@ -48,8 +45,8 @@ function RolesSettings(): JSX.Element {
<Button
variant="solid"
color="primary"
className={styles.roleSettingsToolbarButton}
onClick={(): void => history.push(ROUTES.ROLE_CREATE)}
className="role-settings-toolbar-button"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={14} />
Custom role
@@ -59,6 +56,10 @@ function RolesSettings(): JSX.Element {
</div>
<RolesListingTable searchQuery={searchQuery} />
</div>
<CreateRoleModal
isOpen={isCreateModalOpen}
onClose={(): void => setIsCreateModalOpen(false)}
/>
</div>
);
}

View File

@@ -1,22 +0,0 @@
.readOnlyJsonViewer {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.editorContainer {
position: relative;
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
flex: 1;
min-height: 300px;
}
.copyButton {
position: absolute;
top: 8px;
right: 24px;
z-index: 10;
}

View File

@@ -1,91 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import MEditor from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Check, Copy } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
defineJsonTheme,
JSON_THEME_DARK,
READONLY_EDITOR_OPTIONS,
} from '../monaco.config';
import { RolePermissionsData } from '../types';
import { transformResourcePermissionsToTransactionGroups } from '../hooks/useRolePermissions';
import styles from './ReadOnlyJsonViewer.module.scss';
export interface ReadOnlyJsonViewerProps {
permissions: RolePermissionsData;
}
function ReadOnlyJsonViewer({
permissions,
}: ReadOnlyJsonViewerProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [copyState, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState(false);
const jsonContent = useMemo(() => {
if (!permissions.resources) {
return '[]';
}
const transactionGroups = transformResourcePermissionsToTransactionGroups(
permissions.resources,
);
return JSON.stringify(transactionGroups, null, 2);
}, [permissions.resources]);
useEffect(() => {
if (copyState.value) {
setCopied(true);
const timer = setTimeout(() => setCopied(false), 1500);
return (): void => clearTimeout(timer);
}
return undefined;
}, [copyState]);
const handleCopy = useCallback((): void => {
copyToClipboard(jsonContent);
}, [copyToClipboard, jsonContent]);
return (
<div
className={styles.readOnlyJsonViewer}
data-testid="read-only-json-viewer"
>
<div className={styles.editorContainer}>
<TooltipSimple title={copied ? 'Copied!' : 'Copy JSON'}>
<Button
variant="ghost"
size="sm"
className={styles.copyButton}
onClick={handleCopy}
data-testid="read-only-json-viewer-copy-button"
>
{copied ? (
<Check size={14} color={Color.BG_FOREST_400} />
) : (
<Copy
size={14}
color={isDarkMode ? Color.BG_VANILLA_400 : Color.TEXT_INK_400}
/>
)}
</Button>
</TooltipSimple>
<MEditor
value={jsonContent}
language="json"
options={READONLY_EDITOR_OPTIONS}
height="100%"
theme={isDarkMode ? JSON_THEME_DARK : 'light'}
beforeMount={defineJsonTheme}
/>
</div>
</div>
);
}
export default ReadOnlyJsonViewer;

View File

@@ -1,210 +0,0 @@
.viewRolePage {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
width: 100%;
max-width: 1400px;
margin: 0 auto;
height: 100%;
min-height: 0;
}
.viewRolePageHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
}
.viewRolePageHeaderLeft {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.backButton {
--button-padding: var(--spacing-3);
}
.viewRolePageActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.deleteButton {
margin-right: 0px;
&:disabled {
pointer-events: auto;
}
}
.unsavedText {
font-family: Inter;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
color: var(--primary);
}
.viewRolePageContent {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
flex: 1;
min-height: 0;
}
.viewRolePageForm {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.formRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-8);
}
.formField {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-disabled-background: var(--l2-background);
input::placeholder {
color: var(--l3-foreground);
}
}
.formLabel {
font-family: Inter;
font-size: 12px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-20);
letter-spacing: 0.48px;
text-transform: uppercase;
color: var(--l2-foreground);
}
.viewRolePageDivider {
width: 100%;
height: 1px;
background: var(--l1-border);
}
.roleTabs {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
--tab-content-padding: 0px;
[role='tabpanel'] {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
}
.permissionSection {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
flex: 1;
min-height: 0;
}
.permissionHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.permissionTitle {
letter-spacing: 0.48px;
text-transform: uppercase;
position: relative;
}
.permissionDivider {
height: 7px;
flex: 1 1 0%;
border-width: medium medium;
border-style: none none;
border-color: currentcolor currentcolor;
border-image: initial;
border-top: 2px dotted var(--l1-border);
border-bottom: 2px dotted var(--l1-border);
margin: 0px var(--spacing-4);
}
.permissionModeToggle {
display: inline-flex;
grid-auto-flow: column;
gap: 0;
flex-shrink: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
}
.permissionModeItem {
position: relative;
display: flex;
align-items: center;
&:not(:last-child) {
border-right: 1px solid var(--l2-border);
}
label {
display: flex;
align-items: center;
min-height: 24px;
padding: var(--spacing-3) var(--spacing-6);
font-family: Inter;
font-size: var(--periscope-font-size-base);
line-height: var(--line-height-20);
color: var(--l2-foreground);
white-space: nowrap;
cursor: pointer;
user-select: none;
}
}
.permissionModeInput {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
* {
display: none;
}
&[data-state='checked'] + label {
background: var(--l3-background);
color: var(--l1-foreground);
font-weight: var(--font-weight-medium);
}
}
.permissionContent {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}

View File

@@ -1,339 +0,0 @@
import { useMemo } from 'react';
import { ArrowLeft } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Tabs } from '@signozhq/ui/tabs';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { useGetRole } from 'api/generated/services/role';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import { useDeleteRoleModal } from 'container/RolesSettings/DeleteRoleModal/useDeleteRoleModal';
import { useRoleAuthZ } from 'container/RolesSettings/hooks/useRoleAuthZ';
import { transformApiToRolePermissions } from 'container/RolesSettings/hooks/useRolePermissions';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { RoleType } from 'types/roles';
import { toAPIError } from 'utils/errorUtils';
import DeleteRoleModal from '../DeleteRoleModal/DeleteRoleModal';
import PermissionOverview from './components/PermissionOverview';
import ReadOnlyJsonViewer from './ReadOnlyJsonViewer';
import { useViewRolePageActions } from './useViewRolePageActions';
import styles from './ViewRolePage.module.scss';
function ViewRolePage(): JSX.Element {
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
const { isRolesEnabled, isLoading: isFeatureGateLoading } =
useRolesFeatureGate();
const {
roleId,
roleName,
activeTab,
viewMode,
expandedResources,
setExpandedResources,
handleRedirectToUpdate,
handleCancel,
handleModeChange,
handleTabChange,
} = useViewRolePageActions();
const {
hasReadPermission,
readRolePermission,
hasUpdatePermission,
updateRolePermission,
hasDeletePermission,
isAuthZLoading,
} = useRoleAuthZ(roleName);
const { data, isLoading, error } = useGetRole(
{ id: roleId ?? '' },
{ query: { enabled: !!roleId && hasReadPermission } },
);
const role = data?.data;
const isManaged = role?.type === RoleType.MANAGED;
const {
isDeleteModalOpen,
isDeleteDisabled,
deleteDisabledReason,
deleteError,
handleOpenDeleteModal,
handleCloseDeleteModal,
handleConfirmDelete,
} = useDeleteRoleModal({
roleId,
isManaged: isManaged ?? false,
hasDeletePermission,
onDeleteSuccess: handleCancel,
});
const tabItems = useMemo(
() => [
{
key: 'overview' as const,
label: 'Overview',
children: (
<div className={styles.permissionSection}>
<div className={styles.permissionHeader}>
<Typography
as="span"
size="small"
weight="medium"
color="muted"
className={styles.permissionTitle}
>
Transaction Groups
</Typography>
<hr className={styles.permissionDivider} />
<RadioGroup
className={styles.permissionModeToggle}
value={viewMode}
onChange={handleModeChange}
testId="permission-view-mode"
>
<RadioGroupItem
value="list"
containerClassName={styles.permissionModeItem}
className={styles.permissionModeInput}
testId="permission-view-mode-list"
>
List
</RadioGroupItem>
<RadioGroupItem
value="json"
containerClassName={styles.permissionModeItem}
className={styles.permissionModeInput}
testId="permission-view-mode-json"
>
JSON
</RadioGroupItem>
</RadioGroup>
</div>
<div className={styles.permissionContent}>
{viewMode === 'list' ? (
<PermissionOverview
roleId={roleId ?? ''}
expandedResources={expandedResources}
onExpandedResourcesChange={setExpandedResources}
/>
) : role ? (
<ReadOnlyJsonViewer permissions={transformApiToRolePermissions(role)} />
) : null}
</div>
</div>
),
},
],
[
viewMode,
handleModeChange,
roleId,
role,
expandedResources,
setExpandedResources,
],
);
if (!hasReadPermission && !isAuthZLoading) {
return (
<PermissionDeniedFullPage permissionName={readRolePermission.object} />
);
}
if (!isRolesEnabled && !isFeatureGateLoading) {
return (
<div className={styles.viewRolePage} data-testid="view-role-page">
<div className={styles.viewRolePageHeader}>
<div className={styles.viewRolePageHeaderLeft}>
<Button
variant="ghost"
color="secondary"
onClick={handleCancel}
data-testid="cancel-button"
className={styles.backButton}
>
<ArrowLeft size={16} />
</Button>
<Typography.Title level={3}>View Role</Typography.Title>
</div>
</div>
<ErrorInPlace
error={
new APIError({
httpStatusCode: 403,
error: {
code: 'FEATURE_DISABLED',
message:
'Custom roles feature is not available. Please check your license or feature configuration.',
url: '',
errors: [],
},
})
}
data-testid="feature-gate-error-banner"
/>
</div>
);
}
if (isAuthZLoading || isLoading || isFeatureGateLoading) {
return (
<div className={styles.viewRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
if (error) {
return (
<div className={styles.viewRolePage} data-testid="view-role-page">
<div className={styles.viewRolePageHeader}>
<div className={styles.viewRolePageHeaderLeft}>
<Button
variant="ghost"
color="secondary"
onClick={handleCancel}
data-testid="cancel-button"
className={styles.backButton}
>
<ArrowLeft size={16} />
</Button>
<Typography.Title level={3}>Failed to load role</Typography.Title>
</div>
</div>
<ErrorInPlace
error={toAPIError(error, 'Failed to load role details')}
data-testid="role-error-banner"
/>
</div>
);
}
if (!role) {
return <></>;
}
return (
<div className={styles.viewRolePage} data-testid="view-role-page">
<div className={styles.viewRolePageHeader}>
<div className={styles.viewRolePageHeaderLeft}>
<Button
variant="ghost"
color="secondary"
onClick={handleCancel}
data-testid="cancel-button"
className={styles.backButton}
>
<ArrowLeft size={16} />
</Button>
<Typography.Title level={3}>
{'Role - ' + role.name || 'Loading role...'}
</Typography.Title>
</div>
<div className={styles.viewRolePageActions}>
<TooltipSimple
title={isDeleteDisabled ? deleteDisabledReason : 'Open delete modal'}
>
<Button
variant="link"
color="destructive"
onClick={handleOpenDeleteModal}
disabled={isDeleteDisabled}
data-testid="delete-button"
className={styles.deleteButton}
>
Delete
</Button>
</TooltipSimple>
<Divider type="vertical" />
<TooltipSimple
title={
isManaged
? 'Managed roles cannot be updated'
: hasUpdatePermission
? 'Open update page'
: `You are not authorized to perform ${updateRolePermission.object}`
}
>
<Button
variant="solid"
color="primary"
data-testid="save-button"
disabled={isManaged || !hasUpdatePermission}
onClick={handleRedirectToUpdate}
style={
isManaged || !hasUpdatePermission
? { pointerEvents: 'auto' }
: undefined
}
>
Update
</Button>
</TooltipSimple>
</div>
</div>
<div className={styles.viewRolePageContent}>
<div className={styles.viewRolePageForm}>
<div className={styles.formField}>
<label htmlFor="role-description" className={styles.formLabel}>
Description
</label>
<Typography>{role.description}</Typography>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<label htmlFor="role-created-at" className={styles.formLabel}>
Created At
</label>
<Badge color="secondary">
{formatTimezoneAdjustedTimestampOptional(role.createdAt)}
</Badge>
</div>
<div className={styles.formField}>
<label htmlFor="role-modified-at" className={styles.formLabel}>
Last Modified At
</label>
<Badge color="secondary">
{formatTimezoneAdjustedTimestampOptional(role.updatedAt)}
</Badge>
</div>
</div>
</div>
<Divider />
<Tabs
className={styles.roleTabs}
value={activeTab}
onChange={handleTabChange}
items={tabItems}
/>
</div>
<DeleteRoleModal
isOpen={isDeleteModalOpen}
roleName={role.name}
error={deleteError}
onCancel={handleCloseDeleteModal}
onConfirm={handleConfirmDelete}
/>
</div>
);
}
export default ViewRolePage;

View File

@@ -1,130 +0,0 @@
import { Route, Switch } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import * as roleApi from 'api/generated/services/role';
import { render, screen, waitFor, within } from 'tests/test-utils';
import ViewRolePage from '../ViewRolePage';
import {
buildViewRoleRoute,
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
mockHooksForCustomRole,
} from './testUtils';
describe('ViewRolePage - Actions', () => {
beforeEach(() => {
mockHooksForCustomRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('navigates to roles list when Cancel clicked', async () => {
const user = userEvent.setup();
render(
<Switch>
<Route path="/settings/roles/:roleId">
<ViewRolePage />
</Route>
<Route path="/settings/roles">
<div data-testid="roles-list-target" />
</Route>
</Switch>,
undefined,
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const cancelBtn = screen.getByTestId('cancel-button');
await user.click(cancelBtn);
await expect(
screen.findByTestId('roles-list-target'),
).resolves.toBeInTheDocument();
});
it('navigates to edit page when Update clicked', async () => {
const user = userEvent.setup();
render(
<Switch>
<Route path="/settings/roles/:roleId/edit">
<div data-testid="edit-page-target" />
</Route>
<Route path="/settings/roles/:roleId">
<ViewRolePage />
</Route>
</Switch>,
undefined,
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const updateBtn = screen.getByTestId('save-button');
await user.click(updateBtn);
await expect(
screen.findByTestId('edit-page-target'),
).resolves.toBeInTheDocument();
});
it('opens delete modal when Delete clicked', async () => {
const user = userEvent.setup();
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
const deleteBtn = screen.getByTestId('delete-button');
await user.click(deleteBtn);
await expect(
screen.findByText(/Are you sure you want to delete the role/),
).resolves.toBeInTheDocument();
});
it('calls delete API and redirects on successful delete', async () => {
const user = userEvent.setup();
const mockDeleteRole = jest.fn().mockResolvedValue({});
jest.spyOn(roleApi, 'useDeleteRole').mockReturnValue({
mutateAsync: mockDeleteRole,
} as unknown as ReturnType<typeof roleApi.useDeleteRole>);
render(
<Switch>
<Route path="/settings/roles/:roleId">
<ViewRolePage />
</Route>
<Route path="/settings/roles">
<div data-testid="roles-list-target" />
</Route>
</Switch>,
undefined,
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
await user.click(screen.getByTestId('delete-button'));
await expect(
screen.findByText(/Are you sure you want to delete the role/),
).resolves.toBeInTheDocument();
const modal = screen.getByRole('dialog');
const modalConfirmBtn = within(modal).getByRole('button', {
name: /Delete Role/i,
});
await user.click(modalConfirmBtn);
await waitFor(() => {
expect(mockDeleteRole).toHaveBeenCalledWith({
pathParams: { id: CUSTOM_ROLE_ID },
});
});
await expect(
screen.findByTestId('roles-list-target'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -1,429 +0,0 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import userEvent from '@testing-library/user-event';
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import {
customRoleResponse,
managedRoleResponse,
} from 'mocks-server/__mockdata__/roles';
import {
mockUseAuthZDenyAll,
mockUseAuthZGrantAll,
mockUseAuthZGrantByPrefix,
} from 'tests/authz-test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
import ViewRolePage from '../ViewRolePage';
import {
buildViewRoleRoute,
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
MANAGED_ROLE_ID,
MANAGED_ROLE_NAME,
mockPermissionsData,
} from './testUtils';
const mockUseAuthZGrantReadDeleteDenied = mockUseAuthZGrantByPrefix(
'read',
'update',
);
const mockUseAuthZGrantReadUpdateDenied = mockUseAuthZGrantByPrefix(
'read',
'delete',
);
describe('ViewRolePage - AuthZ', () => {
afterEach(() => {
jest.restoreAllMocks();
});
describe('permission denied', () => {
it('shows permission denied page when read permission denied', async () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZDenyAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByText(/You are not authorized/i),
).resolves.toBeInTheDocument();
});
});
describe('update button visibility', () => {
it('enables Update button when update permission granted', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
},
);
expect(screen.getByTestId('save-button')).toBeInTheDocument();
});
it('disables Update button when update permission denied', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadUpdateDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
},
);
expect(screen.getByTestId('save-button')).toBeDisabled();
});
it('disables Update button when role is managed', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: {
...mockPermissionsData,
roleId: MANAGED_ROLE_ID,
roleName: MANAGED_ROLE_NAME,
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(MANAGED_ROLE_ID, MANAGED_ROLE_NAME),
},
);
expect(screen.getByTestId('save-button')).toBeDisabled();
});
it('shows managed role tooltip when update button hovered on managed role', async () => {
const user = userEvent.setup();
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: {
...mockPermissionsData,
roleId: MANAGED_ROLE_ID,
roleName: MANAGED_ROLE_NAME,
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(MANAGED_ROLE_ID, MANAGED_ROLE_NAME),
},
);
const updateButton = screen.getByTestId('save-button');
await user.hover(updateButton);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
'Managed roles cannot be updated',
);
});
});
it('shows authorization tooltip when update permission denied', async () => {
const user = userEvent.setup();
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadUpdateDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
},
);
const updateButton = screen.getByTestId('save-button');
await user.hover(updateButton);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
/You are not authorized to perform/,
);
});
});
});
describe('delete button visibility', () => {
it('disables Delete button when delete permission denied', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadDeleteDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
},
);
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
it('enables Delete button when delete permission granted', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
},
);
expect(screen.getByTestId('delete-button')).not.toBeDisabled();
});
it('shows permission denied tooltip when delete permission denied', async () => {
const user = userEvent.setup();
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadDeleteDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
},
);
const deleteButton = screen.getByTestId('delete-button');
await user.hover(deleteButton);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
'You do not have permission to delete this role',
);
});
});
it('shows managed role tooltip when role is managed', async () => {
const user = userEvent.setup();
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: {
...mockPermissionsData,
roleId: MANAGED_ROLE_ID,
roleName: MANAGED_ROLE_NAME,
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(MANAGED_ROLE_ID, MANAGED_ROLE_NAME),
},
);
const deleteButton = screen.getByTestId('delete-button');
await user.hover(deleteButton);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
'Managed roles cannot be deleted',
);
});
});
});
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
jest.spyOn(useAuthZModule, 'useAuthZ').mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
refetchPermissions: jest.fn(),
});
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
});

View File

@@ -1,75 +0,0 @@
import { render, screen } from 'tests/test-utils';
import ViewRolePage from '../ViewRolePage';
import {
buildViewRoleRoute,
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
mockHooksForCustomRole,
} from './testUtils';
describe('ViewRolePage - Custom Role', () => {
beforeEach(() => {
mockHooksForCustomRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders role name in page title', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByText('Role - billing-manager'),
).resolves.toBeInTheDocument();
});
it('shows role description', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByText('Custom role for managing billing and invoices.'),
).resolves.toBeInTheDocument();
});
it('shows Update button for custom roles', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('save-button')).toBeInTheDocument();
});
it('shows Cancel button', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('shows Delete button', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
});
it('renders created/updated timestamps labels', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(screen.findByText('Created At')).resolves.toBeInTheDocument();
await expect(
screen.findByText('Last Modified At'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -1,115 +0,0 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { render, screen } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
import ViewRolePage from '../ViewRolePage';
import {
buildViewRoleRoute,
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
mockPermissionsData,
} from './testUtils';
describe('ViewRolePage - Edge Cases', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('shows fallback for missing description', async () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
data: {
...customRoleResponse.data,
description: '',
},
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(screen.findByText('Description')).resolves.toBeInTheDocument();
});
it('shows fallback for invalid timestamps', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
data: {
...customRoleResponse.data,
createdAt: 'invalid-date',
updatedAt: 'also-invalid',
},
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
it('shows fallback for undefined timestamps', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
data: {
...customRoleResponse.data,
createdAt: undefined,
updatedAt: undefined,
},
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -1,115 +0,0 @@
import { Route, Switch } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { render, screen } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
import ViewRolePage from '../ViewRolePage';
import {
buildViewRoleRoute,
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
mockPermissionsData,
} from './testUtils';
describe('ViewRolePage - Error State', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('displays error component when API has error but role data exists', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: true,
error: new Error('Failed to fetch'),
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
it('displays error state with title when API fails without role data', async () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Failed to fetch role'),
} as ReturnType<typeof roleApi.useGetRole>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByText('Failed to load role'),
).resolves.toBeInTheDocument();
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
it('shows back button on error state', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Failed to fetch role'),
} as ReturnType<typeof roleApi.useGetRole>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('navigates to roles list when back button clicked on error state', async () => {
const user = userEvent.setup();
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Failed to fetch role'),
} as ReturnType<typeof roleApi.useGetRole>);
render(
<Switch>
<Route path="/settings/roles/:roleId">
<ViewRolePage />
</Route>
<Route path="/settings/roles">
<div data-testid="roles-list-target" />
</Route>
</Switch>,
undefined,
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const cancelButton = screen.getByTestId('cancel-button');
await user.click(cancelButton);
await expect(
screen.findByTestId('roles-list-target'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -1,82 +0,0 @@
import * as roleApi from 'api/generated/services/role';
import { FeatureKeys } from 'constants/features';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import ViewRolePage from '../ViewRolePage';
import {
buildViewRoleRoute,
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
} from './testUtils';
describe('ViewRolePage - Feature Gate', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('feature disabled', () => {
it('shows error when fine-grained authz flag is inactive', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
},
});
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows error when license is invalid', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: { activeLicense: invalidLicense },
});
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows back button when feature disabled', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: { activeLicense: invalidLicense },
});
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('back button is enabled when feature disabled', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: { activeLicense: invalidLicense },
});
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
});
});
});

View File

@@ -1,52 +0,0 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { render } from 'tests/test-utils';
import ViewRolePage from '../ViewRolePage';
import {
buildViewRoleRoute,
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
} from './testUtils';
describe('ViewRolePage - Loading State', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('shows skeleton while fetching role', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('does not fetch when roleId is missing from URL', () => {
const getRole = jest.spyOn(roleApi, 'useGetRole');
render(<ViewRolePage />, undefined, {
initialRoute: '/settings/roles',
});
expect(getRole).toHaveBeenCalledWith(
{ id: '' },
expect.objectContaining({ query: { enabled: false } }),
);
});
});

View File

@@ -1,63 +0,0 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from 'tests/test-utils';
import ViewRolePage from '../ViewRolePage';
import {
buildViewRoleRoute,
MANAGED_ROLE_ID,
MANAGED_ROLE_NAME,
mockHooksForManagedRole,
} from './testUtils';
describe('ViewRolePage - Managed Role', () => {
beforeEach(() => {
mockHooksForManagedRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('disables Delete button for managed roles', () => {
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(MANAGED_ROLE_ID, MANAGED_ROLE_NAME),
},
);
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
it('disables Update button for managed roles', () => {
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(MANAGED_ROLE_ID, MANAGED_ROLE_NAME),
},
);
expect(screen.getByTestId('save-button')).toBeDisabled();
});
it('still shows Cancel button for managed roles', () => {
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(MANAGED_ROLE_ID, MANAGED_ROLE_NAME),
},
);
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
});

View File

@@ -1,679 +0,0 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import userEvent from '@testing-library/user-event';
import { render, screen, within } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
import ViewRolePage from '../ViewRolePage';
import {
buildViewRoleRoute,
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
mockHooksForCustomRole,
mockHooksWithPermissions,
mockPermissionsData,
} from './testUtils';
async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
const expandButton = screen.getByTestId('expand-all-button');
await user.click(expandButton);
}
describe('ViewRolePage - Permission Overview', () => {
beforeEach(() => {
mockHooksForCustomRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders Transaction Groups section label', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByText('Transaction Groups'),
).resolves.toBeInTheDocument();
});
it('renders permission overview container', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
});
it('shows resource permission cards', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(
screen.getByTestId('resource-section-factor-api-key'),
).toBeInTheDocument();
expect(screen.getByTestId('resource-section-role')).toBeInTheDocument();
expect(
screen.getByTestId('resource-section-serviceaccount'),
).toBeInTheDocument();
});
it('displays granted count for each resource', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(
screen.getByTestId('granted-count-factor-api-key'),
).toBeInTheDocument();
});
});
describe('ViewRolePage - Permission Overview Loading State', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('shows skeleton when permissions are loading', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('permission-overview-loading')).toBeInTheDocument();
});
});
describe('ViewRolePage - Permission Overview Error State', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('shows error when permissions fail to load', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Failed'),
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('permission-overview-error')).toBeInTheDocument();
});
});
describe('ViewRolePage - Scope: ALL permissions', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('shows "All" badge for actions with ALL scope', async () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'factor-api-key',
resourceKind: 'factor-api-key',
resourceType: 'metaresource',
resourceLabel: 'API Keys',
actions: {
read: { scope: 'all', selectedIds: [] },
create: { scope: 'all', selectedIds: [] },
},
availableActions: ['read', 'create'],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expandAllCards();
expect(screen.getByTestId('scope-badge-read')).toHaveTextContent('All');
expect(screen.getByTestId('scope-badge-create')).toHaveTextContent('All');
});
it('shows full granted count when all actions are ALL', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'role',
resourceKind: 'role',
resourceType: 'role',
resourceLabel: 'Roles',
actions: {
read: { scope: 'all', selectedIds: [] },
create: { scope: 'all', selectedIds: [] },
update: { scope: 'all', selectedIds: [] },
},
availableActions: ['read', 'create', 'update'],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('granted-count-role')).toHaveTextContent(
'3 / 3 granted',
);
});
});
describe('ViewRolePage - Scope: NONE permissions', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('shows "None" badge for actions with NONE scope', async () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'serviceaccount',
resourceKind: 'serviceaccount',
resourceType: 'serviceaccount',
resourceLabel: 'Service Accounts',
actions: {
read: { scope: 'none', selectedIds: [] },
delete: { scope: 'none', selectedIds: [] },
},
availableActions: ['read', 'delete'],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expandAllCards();
expect(screen.getByTestId('scope-badge-read')).toHaveTextContent('None');
expect(screen.getByTestId('scope-badge-delete')).toHaveTextContent('None');
});
it('shows zero granted count when all actions are NONE', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'factor-api-key',
resourceKind: 'factor-api-key',
resourceType: 'metaresource',
resourceLabel: 'API Keys',
actions: {
read: { scope: 'none', selectedIds: [] },
create: { scope: 'none', selectedIds: [] },
update: { scope: 'none', selectedIds: [] },
delete: { scope: 'none', selectedIds: [] },
},
availableActions: ['read', 'create', 'update', 'delete'],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
'0 / 4 granted',
);
});
});
describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('shows "Only selected" badge with count', async () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'role',
resourceKind: 'role',
resourceType: 'role',
resourceLabel: 'Roles',
actions: {
read: {
scope: 'only_selected',
selectedIds: ['admin', 'editor', 'viewer'],
},
},
availableActions: ['read'],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expandAllCards();
expect(screen.getByTestId('scope-badge-read')).toHaveTextContent(
'Only selected · 3',
);
});
it('displays selected IDs as expandable chips', async () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'factor-api-key',
resourceKind: 'factor-api-key',
resourceType: 'metaresource',
resourceLabel: 'API Keys',
actions: {
read: {
scope: 'only_selected',
selectedIds: ['key-abc-123', 'key-def-456'],
},
},
availableActions: ['read'],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expandAllCards();
await expect(screen.findByText('key-abc-123')).resolves.toBeInTheDocument();
await expect(screen.findByText('key-def-456')).resolves.toBeInTheDocument();
});
it('counts ONLY_SELECTED as granted in count', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'serviceaccount',
resourceKind: 'serviceaccount',
resourceType: 'serviceaccount',
resourceLabel: 'Service Accounts',
actions: {
read: { scope: 'only_selected', selectedIds: ['sa-1'] },
create: { scope: 'none', selectedIds: [] },
},
availableActions: ['read', 'create'],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('granted-count-serviceaccount')).toHaveTextContent(
'1 / 2 granted',
);
});
it('can collapse and expand selected items list', async () => {
const user = userEvent.setup();
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'role',
resourceKind: 'role',
resourceType: 'role',
resourceLabel: 'Roles',
actions: {
update: {
scope: 'only_selected',
selectedIds: ['editor-role'],
},
},
availableActions: ['update'],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expandAllCards();
await expect(screen.findByText('editor-role')).resolves.toBeInTheDocument();
const toggle = screen.getByTestId('toggle-items-update');
await user.click(toggle);
expect(screen.queryByText('editor-role')).not.toBeInTheDocument();
await user.click(toggle);
await expect(screen.findByText('editor-role')).resolves.toBeInTheDocument();
});
});
describe('ViewRolePage - Mixed permission scopes', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('renders all three scope types in single resource card', async () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'factor-api-key',
resourceKind: 'factor-api-key',
resourceType: 'metaresource',
resourceLabel: 'API Keys',
actions: {
read: { scope: 'all', selectedIds: [] },
create: { scope: 'none', selectedIds: [] },
update: { scope: 'only_selected', selectedIds: ['key-1', 'key-2'] },
delete: { scope: 'none', selectedIds: [] },
},
availableActions: ['read', 'create', 'update', 'delete'],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expandAllCards();
const section = screen.getByTestId('resource-section-factor-api-key');
expect(within(section).getByTestId('scope-badge-read')).toHaveTextContent(
'All',
);
expect(within(section).getByTestId('scope-badge-create')).toHaveTextContent(
'None',
);
expect(within(section).getByTestId('scope-badge-update')).toHaveTextContent(
'Only selected · 2',
);
expect(within(section).getByTestId('scope-badge-delete')).toHaveTextContent(
'None',
);
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
'2 / 4 granted',
);
});
it('renders multiple resources with different scope combinations', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'factor-api-key',
resourceKind: 'factor-api-key',
resourceType: 'metaresource',
resourceLabel: 'API Keys',
actions: {
read: { scope: 'all', selectedIds: [] },
create: { scope: 'all', selectedIds: [] },
},
availableActions: ['read', 'create'],
},
{
resourceId: 'role',
resourceKind: 'role',
resourceType: 'role',
resourceLabel: 'Roles',
actions: {
read: { scope: 'none', selectedIds: [] },
create: { scope: 'none', selectedIds: [] },
},
availableActions: ['read', 'create'],
},
{
resourceId: 'serviceaccount',
resourceKind: 'serviceaccount',
resourceType: 'serviceaccount',
resourceLabel: 'Service Accounts',
actions: {
read: { scope: 'only_selected', selectedIds: ['sa-1'] },
create: { scope: 'all', selectedIds: [] },
},
availableActions: ['read', 'create'],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
'2 / 2 granted',
);
expect(screen.getByTestId('granted-count-role')).toHaveTextContent(
'0 / 2 granted',
);
expect(screen.getByTestId('granted-count-serviceaccount')).toHaveTextContent(
'2 / 2 granted',
);
});
});
describe('ViewRolePage - Unknown resources', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('renders unknown resource with fallback label', async () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'future-resource',
resourceKind: 'future-resource',
resourceType: 'metaresource',
resourceLabel: 'future-resource',
actions: {
read: { scope: 'all', selectedIds: [] },
},
availableActions: ['read'],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(
screen.getByTestId('resource-section-future-resource'),
).toBeInTheDocument();
await expect(
screen.findByText('future-resource'),
).resolves.toBeInTheDocument();
});
it('shows raw verb name when no label mapping exists', async () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'test-resource',
resourceKind: 'test-resource',
resourceType: 'metaresource',
resourceLabel: 'Test Resource',
actions: {
unknown_action: { scope: 'all', selectedIds: [] },
},
availableActions: ['unknown_action'],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expandAllCards();
await expect(
screen.findByText('Unknown_action'),
).resolves.toBeInTheDocument();
});
it('handles resource with empty actions', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'empty-resource',
resourceKind: 'empty-resource',
resourceType: 'metaresource',
resourceLabel: 'Empty Resource',
actions: {},
availableActions: [],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(
screen.getByTestId('resource-section-empty-resource'),
).toBeInTheDocument();
expect(screen.getByTestId('granted-count-empty-resource')).toHaveTextContent(
'0 / 0 granted',
);
});
});
describe('ViewRolePage - View mode toggle', () => {
beforeEach(() => {
mockHooksForCustomRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders Interactive/JSON toggle', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('permission-view-mode-list')).toBeInTheDocument();
expect(screen.getByTestId('permission-view-mode-json')).toBeInTheDocument();
});
it('switches to JSON view when JSON toggle clicked', async () => {
const user = userEvent.setup();
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
const jsonToggle = screen.getByTestId('permission-view-mode-json');
await user.click(jsonToggle);
expect(screen.queryByTestId('permission-overview')).not.toBeInTheDocument();
});
});
describe('ViewRolePage - JSON Viewer Copy Button', () => {
beforeEach(() => {
mockHooksForCustomRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders copy button in JSON view', async () => {
const user = userEvent.setup();
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
const jsonToggle = screen.getByTestId('permission-view-mode-json');
await user.click(jsonToggle);
expect(
screen.getByTestId('read-only-json-viewer-copy-button'),
).toBeInTheDocument();
});
it('copy button is clickable', async () => {
const user = userEvent.setup();
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
const jsonToggle = screen.getByTestId('permission-view-mode-json');
await user.click(jsonToggle);
const copyButton = screen.getByTestId('read-only-json-viewer-copy-button');
expect(copyButton).not.toBeDisabled();
await user.click(copyButton);
});
});

View File

@@ -1,143 +0,0 @@
import {
CoretypesKindDTO,
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import {
customRoleResponse,
managedRoleResponse,
} from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
export const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
export const CUSTOM_ROLE_NAME = 'billing-manager';
export const MANAGED_ROLE_ID = '019c24aa-2248-756f-9833-984f1ab63819';
export const MANAGED_ROLE_NAME = 'signoz-admin';
export function buildViewRoleRoute(roleId: string, roleName: string): string {
return `/settings/roles/${roleId}?name=${encodeURIComponent(roleName)}`;
}
export const mockPermissionsData = {
roleId: CUSTOM_ROLE_ID,
roleName: 'billing-manager',
roleDescription: 'Custom role for managing billing and invoices.',
resources: [
{
resourceId: 'factor-api-key',
resourceKind: CoretypesKindDTO['factor-api-key'],
resourceType: CoretypesTypeDTO.metaresource,
resourceLabel: 'API Keys',
actions: {
create: { scope: 'none', selectedIds: [] },
read: { scope: 'all', selectedIds: [] },
},
availableActions: ['create', 'read', 'update', 'delete', 'list'],
},
{
resourceId: 'role',
resourceKind: CoretypesKindDTO.role,
resourceType: CoretypesTypeDTO.role,
resourceLabel: 'Roles',
actions: {
create: { scope: 'none', selectedIds: [] },
read: { scope: 'none', selectedIds: [] },
},
availableActions: [
'create',
'read',
'update',
'delete',
'list',
'attach',
'detach',
],
},
{
resourceId: 'serviceaccount',
resourceKind: CoretypesKindDTO.serviceaccount,
resourceType: CoretypesTypeDTO.serviceaccount,
resourceLabel: 'Service Accounts',
actions: {
create: { scope: 'none', selectedIds: [] },
read: { scope: 'none', selectedIds: [] },
},
availableActions: [
'create',
'read',
'update',
'delete',
'list',
'attach',
'detach',
],
},
],
};
export function mockHooksForCustomRole(): void {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
}
export function mockHooksWithPermissions(permissions: unknown): void {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: permissions,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
}
export function mockHooksForManagedRole(): void {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: {
...mockPermissionsData,
roleId: MANAGED_ROLE_ID,
roleName: 'signoz-admin',
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
}

View File

@@ -1,68 +0,0 @@
.row {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-5) 0;
}
.rowHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-5);
}
.rowLeft {
display: flex;
align-items: center;
gap: var(--spacing-3);
min-width: 0;
}
.chevron {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
background: transparent;
border: none;
color: var(--l3-foreground);
cursor: pointer;
flex-shrink: 0;
&:hover {
color: var(--l2-foreground);
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
border-radius: var(--spacing-1);
}
}
.badge {
display: inline-flex;
align-items: center;
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--spacing-2);
white-space: nowrap;
flex-shrink: 0;
}
.allBadgeVariant {
color: var(--bg-robin-300);
background: color-mix(in srgb, var(--bg-robin-500) 16%, transparent);
}
.noneBadgeVariant {
color: var(--l3-foreground);
background: color-mix(in srgb, var(--l3-foreground) 10%, transparent);
}
.selectedBadgeVariant {
color: var(--bg-sienna-400);
background: color-mix(in srgb, var(--bg-sienna-500) 16%, transparent);
}

View File

@@ -1,83 +0,0 @@
import { useCallback, useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { getScopeBadge, ScopeBadgeVariant } from './permissionDisplay.utils';
import SelectedItemsChips from './SelectedItemsChips';
import styles from './ActionRow.module.scss';
import { PermissionScope } from 'container/RolesSettings/types';
const BADGE_VARIANT_CLASS: Record<ScopeBadgeVariant, string> = {
[ScopeBadgeVariant.ALL]: styles.allBadgeVariant,
[ScopeBadgeVariant.NONE]: styles.noneBadgeVariant,
[ScopeBadgeVariant.SELECTED]: styles.selectedBadgeVariant,
};
export interface ActionRowProps {
actionName: string;
actionLabel: string;
scope: PermissionScope;
selectedIds?: string[];
}
function ActionRow({
actionName,
actionLabel,
scope,
selectedIds = [],
}: ActionRowProps): JSX.Element {
const isExpandable =
scope === PermissionScope.ONLY_SELECTED && selectedIds.length > 0;
const [isExpanded, setIsExpanded] = useState(isExpandable);
const handleToggle = useCallback((): void => {
setIsExpanded((prev) => !prev);
}, []);
const badge = getScopeBadge(scope, selectedIds.length);
return (
<div className={styles.row} data-testid={`permission-row-${actionName}`}>
<div className={styles.rowHeader}>
<div className={styles.rowLeft}>
{isExpandable && (
<button
type="button"
className={styles.chevron}
onClick={handleToggle}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} selected items`}
data-testid={`toggle-items-${actionName}`}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
)}
<Typography as="span" size="base">
{actionLabel}
</Typography>
</div>
<Typography
as="span"
size="small"
weight="medium"
className={`${styles.badge} ${BADGE_VARIANT_CLASS[badge.variant]}`}
testId={`scope-badge-${actionName}`}
>
{badge.label}
</Typography>
</div>
{isExpanded && isExpandable && (
<SelectedItemsChips
ids={selectedIds}
testId={`selected-items-${actionName}`}
/>
)}
</div>
);
}
export default ActionRow;

View File

@@ -1,21 +0,0 @@
.container {
display: flex;
flex-direction: column;
padding-bottom: var(--spacing-8);
}
.collapseAction {
display: flex;
justify-content: flex-start;
padding-bottom: var(--spacing-4);
}
.grid {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
}
.errorText {
padding: var(--spacing-4);
}

View File

@@ -1,120 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Button, ButtonGroup } from '@signozhq/ui/button';
import { Skeleton } from 'antd';
import { useRolePermissions } from '../../hooks/useRolePermissions';
import ResourcePermissionCard from './ResourcePermissionCard';
import styles from './PermissionOverview.module.scss';
export interface PermissionOverviewProps {
roleId: string;
expandedResources?: Set<string>;
onExpandedResourcesChange?: (expanded: Set<string>) => void;
}
function PermissionOverview({
roleId,
expandedResources: externalExpanded,
onExpandedResourcesChange,
}: PermissionOverviewProps): JSX.Element {
const { data: permissions, isLoading, isError } = useRolePermissions(roleId);
const [internalExpanded, setInternalExpanded] = useState<Set<string>>(
new Set(),
);
const isControlled = externalExpanded !== undefined;
const expandedResources = isControlled ? externalExpanded : internalExpanded;
const updateExpandedResources = useCallback(
(updater: (prev: Set<string>) => Set<string>): void => {
if (isControlled) {
onExpandedResourcesChange?.(updater(externalExpanded));
} else {
setInternalExpanded(updater);
}
},
[isControlled, externalExpanded, onExpandedResourcesChange],
);
const resources = useMemo(
() => permissions?.resources ?? [],
[permissions?.resources],
);
const handleExpandAll = useCallback((): void => {
updateExpandedResources(() => new Set(resources.map((r) => r.resourceId)));
}, [resources, updateExpandedResources]);
const handleCollapseAll = useCallback((): void => {
updateExpandedResources(() => new Set());
}, [updateExpandedResources]);
const handleExpandChange = useCallback(
(resourceId: string) =>
(expanded: boolean): void => {
updateExpandedResources((prev) => {
const next = new Set(prev);
if (expanded) {
next.add(resourceId);
} else {
next.delete(resourceId);
}
return next;
});
},
[updateExpandedResources],
);
if (isLoading) {
return (
<div className={styles.container} data-testid="permission-overview-loading">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
if (isError || !permissions) {
return (
<div className={styles.container} data-testid="permission-overview-error">
<Typography.Text className={styles.errorText} align="center" color="danger">
Failed to load permissions
</Typography.Text>
</div>
);
}
return (
<div className={styles.container} data-testid="permission-overview">
<div className={styles.collapseAction}>
<ButtonGroup
variant="outlined"
color="secondary"
size="sm"
testId="toggle-all-group"
>
<Button onClick={handleExpandAll} data-testid="expand-all-button">
Expand all
</Button>
<Button onClick={handleCollapseAll} data-testid="collapse-all-button">
Collapse all
</Button>
</ButtonGroup>
</div>
<div className={styles.grid}>
{resources.map((resource) => (
<ResourcePermissionCard
key={resource.resourceId}
resource={resource}
isExpanded={expandedResources.has(resource.resourceId)}
onExpandChange={handleExpandChange(resource.resourceId)}
/>
))}
</div>
</div>
);
}
export default PermissionOverview;

View File

@@ -1,78 +0,0 @@
.card {
display: flex;
flex-direction: column;
background: var(--l2-background);
border: 1px solid var(--l1-border);
border-radius: var(--spacing-4);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-5);
width: 100%;
padding: var(--spacing-6) var(--spacing-7);
border: none;
background: transparent;
cursor: pointer;
transition: background 0.15s ease;
text-align: left;
&:hover {
background: var(--l1-background-hover);
}
&:focus {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
}
.headerLeft {
display: flex;
align-items: center;
gap: var(--spacing-4);
min-width: 0;
}
.chevron {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--l2-foreground);
flex-shrink: 0;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--l2-foreground);
flex-shrink: 0;
}
.grantedCount {
display: inline-flex;
align-items: center;
padding: var(--spacing-1) var(--spacing-4);
color: var(--bg-robin-300);
background: color-mix(in srgb, var(--bg-robin-500) 14%, transparent);
border-radius: 16px;
white-space: nowrap;
flex-shrink: 0;
}
.rows {
display: flex;
flex-direction: column;
padding: 0 var(--spacing-7);
// A subtle divider between consecutive action rows.
> * + * {
border-top: 1px solid var(--l1-border);
}
}

View File

@@ -1,106 +0,0 @@
import { useCallback, useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { getResourcePanel } from '../../permissions.config';
import { useRoleGrantedCount } from '../../hooks/useRoleGrantedCount';
import { PermissionScope, ResourcePermissions } from '../../types';
import ActionRow from './ActionRow';
import { getActionLabel } from './permissionDisplay.utils';
import styles from './ResourcePermissionCard.module.scss';
import { Typography } from '@signozhq/ui/typography';
export interface ResourcePermissionCardProps {
resource: ResourcePermissions;
isExpanded?: boolean;
onExpandChange?: (expanded: boolean) => void;
}
function ResourcePermissionCard({
resource,
isExpanded: controlledExpanded,
onExpandChange,
}: ResourcePermissionCardProps): JSX.Element {
const [internalExpanded, setInternalExpanded] = useState(false);
const isControlled = controlledExpanded !== undefined;
const isExpanded = isControlled ? controlledExpanded : internalExpanded;
const { resourceLabel, resourceKind, actions, availableActions } = resource;
const panel = getResourcePanel(resourceKind);
const Icon = panel.icon;
const handleToggleExpand = useCallback((): void => {
if (isControlled) {
onExpandChange?.(!controlledExpanded);
} else {
setInternalExpanded((prev) => !prev);
}
}, [isControlled, controlledExpanded, onExpandChange]);
const [grantedCount, totalCount] = useRoleGrantedCount(resource);
return (
<section
className={styles.card}
data-testid={`resource-section-${resourceKind}`}
>
<button
type="button"
className={styles.header}
onClick={handleToggleExpand}
aria-expanded={isExpanded}
aria-label={`${resourceLabel}: ${grantedCount} of ${totalCount} permissions granted`}
data-testid={`resource-card-header-${resourceKind}`}
>
<div className={styles.headerLeft}>
<span className={styles.chevron}>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
<span className={styles.icon}>
<Icon size={16} />
</span>
<Typography as="h4" size="base" weight="bold">
{resourceLabel}
</Typography>
</div>
<Typography
as="span"
size="small"
weight="medium"
className={styles.grantedCount}
testId={`granted-count-${resourceKind}`}
>
{grantedCount} / {totalCount} granted
</Typography>
</button>
{isExpanded && (
<div className={styles.rows}>
{availableActions.map((actionName) => {
const config = actions[actionName];
if (!config) {
return null;
}
const selectedIds =
config.scope === PermissionScope.ONLY_SELECTED ? config.selectedIds : [];
return (
<ActionRow
key={actionName}
actionName={actionName}
actionLabel={getActionLabel(actionName)}
scope={config.scope}
selectedIds={selectedIds}
/>
);
})}
</div>
)}
</section>
);
}
export default ResourcePermissionCard;

View File

@@ -1,8 +0,0 @@
.chips {
list-style: none;
margin: 0;
padding: 0 0 0 calc(14px + var(--spacing-3));
display: flex;
flex-wrap: wrap;
gap: var(--spacing-3);
}

View File

@@ -1,31 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import styles from './SelectedItemsChips.module.scss';
import { useId } from 'react';
export interface SelectedItemsChipsProps {
ids: string[];
testId?: string;
}
function SelectedItemsChips({
ids,
testId,
}: SelectedItemsChipsProps): JSX.Element {
const componentId = useId();
return (
<ul className={styles.chips} data-testid={testId}>
{ids.map((id) => (
<Badge
key={`selector-badge-${componentId}-${id}`}
variant="outline"
color="secondary"
>
{id}
</Badge>
))}
</ul>
);
}
export default SelectedItemsChips;

View File

@@ -1,38 +0,0 @@
import { PermissionScope } from '../../types';
export enum ScopeBadgeVariant {
ALL = 'all',
NONE = 'none',
SELECTED = 'selected',
}
export interface ScopeBadge {
label: string;
variant: ScopeBadgeVariant;
}
export function getActionLabel(actionName: string): string {
if (!actionName) {
return 'Unknown';
}
return actionName[0].toUpperCase() + actionName.slice(1);
}
export function getScopeBadge(
scope: PermissionScope,
selectedCount: number,
): ScopeBadge {
switch (scope) {
case PermissionScope.ALL:
return { label: 'All', variant: ScopeBadgeVariant.ALL };
case PermissionScope.ONLY_SELECTED:
return {
label: `Only selected · ${selectedCount}`,
variant: ScopeBadgeVariant.SELECTED,
};
case PermissionScope.NONE:
default:
return { label: 'None', variant: ScopeBadgeVariant.NONE };
}
}

View File

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

View File

@@ -1,88 +0,0 @@
import { useCallback, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
export type ViewMode = 'list' | 'json';
export type TabKey = 'overview';
const VIEW_MODES: ViewMode[] = ['list', 'json'];
const TAB_KEYS: TabKey[] = ['overview'];
interface UseViewRolePageCallbacksResult {
roleId: string | undefined;
roleName: string;
activeTab: TabKey;
viewMode: ViewMode;
expandedResources: Set<string>;
setExpandedResources: (resources: Set<string>) => void;
handleRedirectToUpdate: () => void;
handleCancel: () => void;
handleModeChange: (value: string) => void;
handleTabChange: (key: string) => void;
}
export function useViewRolePageActions(): UseViewRolePageCallbacksResult {
const { pathname } = useLocation();
const history = useHistory();
const urlQuery = useUrlQuery();
const match = matchPath<{ roleId: string }>(pathname, {
path: ROUTES.ROLE_DETAILS,
});
const roleId = match?.params?.roleId;
const roleName = urlQuery.get('name') ?? '';
const [activeTab, setActiveTab] = useQueryState(
'tab',
parseAsStringLiteral(TAB_KEYS).withDefault('overview'),
);
const [viewMode, setViewMode] = useQueryState(
'viewMode',
parseAsStringLiteral(VIEW_MODES).withDefault('list'),
);
const [expandedResources, setExpandedResources] = useState<Set<string>>(
new Set(),
);
const handleRedirectToUpdate = useCallback(() => {
if (!roleId || !roleName) {
return;
}
const updateUrl = `${ROUTES.ROLE_EDIT.replace(':roleId', roleId)}?name=${roleName}`;
history.push(updateUrl);
}, [history, roleId, roleName]);
const handleCancel = useCallback((): void => {
history.push(ROUTES.ROLES_SETTINGS);
}, [history]);
const handleModeChange = useCallback(
(value: string): void => {
void setViewMode(value as ViewMode);
},
[setViewMode],
);
const handleTabChange = useCallback(
(key: string): void => {
void setActiveTab(key as TabKey);
},
[setActiveTab],
);
return {
roleId,
roleName,
activeTab,
viewMode,
expandedResources,
setExpandedResources,
handleRedirectToUpdate,
handleCancel,
handleModeChange,
handleTabChange,
};
}

View File

@@ -6,9 +6,9 @@ import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
defaultFeatureFlags,
fireEvent,
render,
screen,
userEvent,
} from 'tests/test-utils';
import { FeatureKeys } from 'constants/features';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
@@ -36,13 +36,13 @@ describe('RolesSettings', () => {
server.resetHandlers();
});
it('renders the header and search input', async () => {
it('renders the header and search input', () => {
render(<RolesSettings />);
await expect(screen.findByText('Roles')).resolves.toBeInTheDocument();
await expect(
screen.findByText('Create and manage custom roles for your team.'),
).resolves.toBeInTheDocument();
expect(screen.getByText('Roles')).toBeInTheDocument();
expect(
screen.getByText('Create and manage custom roles for your team.'),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search for roles...'),
).toBeInTheDocument();
@@ -54,41 +54,36 @@ describe('RolesSettings', () => {
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
// Section headers
await expect(screen.findByText('Managed roles')).resolves.toBeInTheDocument();
await expect(screen.findByText('Custom roles')).resolves.toBeInTheDocument();
expect(screen.getByText('Managed roles')).toBeInTheDocument();
expect(screen.getByText('Custom roles')).toBeInTheDocument();
// Managed roles
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
await expect(screen.findByText('signoz-editor')).resolves.toBeInTheDocument();
await expect(screen.findByText('signoz-viewer')).resolves.toBeInTheDocument();
expect(screen.getByText('signoz-admin')).toBeInTheDocument();
expect(screen.getByText('signoz-editor')).toBeInTheDocument();
expect(screen.getByText('signoz-viewer')).toBeInTheDocument();
// Custom roles
await expect(
screen.findByText('billing-manager'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('dashboard-creator'),
).resolves.toBeInTheDocument();
expect(screen.getByText('billing-manager')).toBeInTheDocument();
expect(screen.getByText('dashboard-creator')).toBeInTheDocument();
// Custom roles count badge
await expect(screen.findByText('2')).resolves.toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
// Column headers
await expect(screen.findByText('Name')).resolves.toBeInTheDocument();
await expect(screen.findByText('Description')).resolves.toBeInTheDocument();
await expect(screen.findByText('Updated At')).resolves.toBeInTheDocument();
await expect(screen.findByText('Created At')).resolves.toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Updated At')).toBeInTheDocument();
expect(screen.getByText('Created At')).toBeInTheDocument();
});
it('filters roles by search query on name', async () => {
const user = userEvent.setup();
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.clear(searchInput);
await user.type(searchInput, 'billing');
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
target: { value: 'billing' },
});
await expect(
screen.findByText('billing-manager'),
@@ -99,14 +94,13 @@ describe('RolesSettings', () => {
});
it('filters roles by search query on description', async () => {
const user = userEvent.setup();
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.clear(searchInput);
await user.type(searchInput, 'read-only');
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
target: { value: 'read-only' },
});
await expect(screen.findByText('signoz-viewer')).resolves.toBeInTheDocument();
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
@@ -114,14 +108,13 @@ describe('RolesSettings', () => {
});
it('shows empty state when search matches nothing', async () => {
const user = userEvent.setup();
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.clear(searchInput);
await user.type(searchInput, 'nonexistentrole');
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
target: { value: 'nonexistentrole' },
});
await expect(
screen.findByText('No roles match your search.'),
@@ -184,9 +177,7 @@ describe('RolesSettings', () => {
for (const role of allRoles) {
if (role.description) {
await expect(
screen.findByText(role.description),
).resolves.toBeInTheDocument();
expect(screen.getByText(role.description)).toBeInTheDocument();
}
}
});

View File

@@ -0,0 +1,614 @@
import type {
CoretypesKindDTO,
CoretypesObjectGroupDTO,
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
PermissionConfig,
ResourceDefinition,
} from '../PermissionSidePanel/PermissionSidePanel.types';
import type { AuthzResources } from '../utils';
import { PermissionScope } from '../PermissionSidePanel/PermissionSidePanel.types';
import {
buildConfig,
buildPatchPayload,
configsEqual,
DEFAULT_RESOURCE_CONFIG,
derivePermissionTypes,
deriveResourcesForRelation,
objectsToPermissionConfig,
} from '../utils';
jest.mock('../RoleDetails/constants', () => {
const MockIcon = (): null => null;
return {
PERMISSION_ICON_MAP: {
create: MockIcon,
list: MockIcon,
read: MockIcon,
update: MockIcon,
delete: MockIcon,
},
FALLBACK_PERMISSION_ICON: MockIcon,
ROLE_ID_REGEX: /\/settings\/roles\/([^/]+)/,
};
});
const dashboardResource: AuthzResources['resources'][number] = {
kind: 'dashboard',
type: 'metaresource',
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
};
const alertResource: AuthzResources['resources'][number] = {
kind: 'alert',
type: 'metaresource',
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
};
const baseAuthzResources: AuthzResources = {
resources: [dashboardResource, alertResource],
relations: {
create: ['metaresource'],
read: ['metaresource'],
},
};
// API payload resource refs — only kind+type, no allowedVerbs (matches CoretypesResourceRefDTO shape)
const dashboardResourceRef = {
kind: 'dashboard' as CoretypesKindDTO,
type: 'metaresource' as CoretypesTypeDTO,
};
const alertResourceRef = {
kind: 'alert',
type: 'metaresource' as CoretypesTypeDTO,
};
const resourceDefs: ResourceDefinition[] = [
{
id: 'metaresource:dashboard',
kind: 'dashboard',
type: 'metaresource',
label: 'Dashboard',
},
{
id: 'metaresource:alert',
kind: 'alert',
type: 'metaresource',
label: 'Alert',
},
];
const ID_A = 'aaaaaaaa-0000-0000-0000-000000000001';
const ID_B = 'bbbbbbbb-0000-0000-0000-000000000002';
const ID_C = 'cccccccc-0000-0000-0000-000000000003';
describe('buildPatchPayload', () => {
it('sends only the added selector as an addition', () => {
const initial: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A],
},
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const newConfig: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
},
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const result = buildPatchPayload({
newConfig,
initialConfig: initial,
resources: resourceDefs,
authzRes: baseAuthzResources,
});
expect(result.additions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: [ID_B] },
]);
expect(result.deletions).toBeNull();
});
it('sends only the removed selector as a deletion', () => {
const initial: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B, ID_C],
},
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const newConfig: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_C],
},
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const result = buildPatchPayload({
newConfig,
initialConfig: initial,
resources: resourceDefs,
authzRes: baseAuthzResources,
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: [ID_B] },
]);
expect(result.additions).toBeNull();
});
it('treats selector order as irrelevant — produces no payload when IDs are identical', () => {
const initial: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
},
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const newConfig: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_B, ID_A],
},
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const result = buildPatchPayload({
newConfig,
initialConfig: initial,
resources: resourceDefs,
authzRes: baseAuthzResources,
});
expect(result.additions).toBeNull();
expect(result.deletions).toBeNull();
});
it('replaces wildcard with specific IDs when switching all → only_selected', () => {
const initial: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const newConfig: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
},
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const result = buildPatchPayload({
newConfig,
initialConfig: initial,
resources: resourceDefs,
authzRes: baseAuthzResources,
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: ['*'] },
]);
expect(result.additions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
]);
});
it('only deletes wildcard when switching all → only_selected with empty selector list', () => {
const initial: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const newConfig: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
const result = buildPatchPayload({
newConfig,
initialConfig: initial,
resources: resourceDefs,
authzRes: baseAuthzResources,
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: ['*'] },
]);
expect(result.additions).toBeNull();
});
it('ALL → NONE: deletes wildcard, no additions', () => {
const initial: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
};
const newConfig: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.NONE, selectedIds: [] },
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
};
const result = buildPatchPayload({
newConfig,
initialConfig: initial,
resources: resourceDefs,
authzRes: baseAuthzResources,
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: ['*'] },
]);
expect(result.additions).toBeNull();
});
it('NONE → ALL: adds wildcard, no deletions', () => {
const initial: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.NONE, selectedIds: [] },
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
};
const newConfig: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
};
const result = buildPatchPayload({
newConfig,
initialConfig: initial,
resources: resourceDefs,
authzRes: baseAuthzResources,
});
expect(result.additions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: ['*'] },
]);
expect(result.deletions).toBeNull();
});
it('ONLY_SELECTED → NONE: deletes selected IDs, no additions', () => {
const initial: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
},
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
};
const newConfig: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.NONE, selectedIds: [] },
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
};
const result = buildPatchPayload({
newConfig,
initialConfig: initial,
resources: resourceDefs,
authzRes: baseAuthzResources,
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
]);
expect(result.additions).toBeNull();
});
it('NONE → ONLY_SELECTED with IDs: adds those IDs, no deletions', () => {
const initial: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.NONE, selectedIds: [] },
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
};
const newConfig: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A],
},
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
};
const result = buildPatchPayload({
newConfig,
initialConfig: initial,
resources: resourceDefs,
authzRes: baseAuthzResources,
});
expect(result.additions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: [ID_A] },
]);
expect(result.deletions).toBeNull();
});
it('NONE → NONE: no change, produces empty payload', () => {
const initial: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.NONE, selectedIds: [] },
'metaresource:alert': { scope: PermissionScope.NONE, selectedIds: [] },
};
const result = buildPatchPayload({
newConfig: { ...initial },
initialConfig: initial,
resources: resourceDefs,
authzRes: baseAuthzResources,
});
expect(result.additions).toBeNull();
expect(result.deletions).toBeNull();
});
it('only includes resources that actually changed', () => {
const initial: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A],
},
};
const newConfig: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] }, // unchanged
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
}, // added ID_B
};
const result = buildPatchPayload({
newConfig,
initialConfig: initial,
resources: resourceDefs,
authzRes: baseAuthzResources,
});
expect(result.additions).toStrictEqual([
{ resource: alertResourceRef, selectors: [ID_B] },
]);
expect(result.deletions).toBeNull();
});
});
describe('objectsToPermissionConfig', () => {
it('maps a wildcard selector to ALL scope', () => {
const objects: CoretypesObjectGroupDTO[] = [
{ resource: dashboardResourceRef, selectors: ['*'] },
];
const result = objectsToPermissionConfig(objects, resourceDefs);
expect(result['metaresource:dashboard']).toStrictEqual({
scope: PermissionScope.ALL,
selectedIds: [],
});
});
it('maps specific selectors to ONLY_SELECTED scope with the IDs', () => {
const objects: CoretypesObjectGroupDTO[] = [
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
];
const result = objectsToPermissionConfig(objects, resourceDefs);
expect(result['metaresource:dashboard']).toStrictEqual({
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
});
});
it('defaults to NONE scope when resource is absent from API response', () => {
const result = objectsToPermissionConfig([], resourceDefs);
expect(result['metaresource:dashboard']).toStrictEqual({
scope: PermissionScope.NONE,
selectedIds: [],
});
expect(result['metaresource:alert']).toStrictEqual({
scope: PermissionScope.NONE,
selectedIds: [],
});
});
});
describe('configsEqual', () => {
it('returns true for identical configs', () => {
const config: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
'metaresource:alert': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A],
},
};
expect(configsEqual(config, { ...config })).toBe(true);
});
it('returns false when configs differ', () => {
const a: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
};
const b: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [],
},
};
expect(configsEqual(a, b)).toBe(false);
const c: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_C, ID_B],
},
};
const d: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
},
};
expect(configsEqual(c, d)).toBe(false);
});
it('returns true when selectedIds are the same but in different order', () => {
const a: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
},
};
const b: PermissionConfig = {
'metaresource:dashboard': {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_B, ID_A],
},
};
expect(configsEqual(a, b)).toBe(true);
});
});
describe('buildConfig', () => {
it('uses initial values when provided and defaults for resources not in initial', () => {
const initial: PermissionConfig = {
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
};
const result = buildConfig(resourceDefs, initial);
expect(result['metaresource:dashboard']).toStrictEqual({
scope: PermissionScope.ALL,
selectedIds: [],
});
expect(result['metaresource:alert']).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
});
it('applies DEFAULT_RESOURCE_CONFIG (NONE scope) to all resources when no initial is provided', () => {
const result = buildConfig(resourceDefs);
expect(result['metaresource:dashboard']).toStrictEqual(
DEFAULT_RESOURCE_CONFIG,
);
expect(result['metaresource:alert']).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
expect(DEFAULT_RESOURCE_CONFIG.scope).toBe(PermissionScope.NONE);
});
});
describe('derivePermissionTypes', () => {
it('derives one PermissionType per relation key with correct key and capitalised label', () => {
const relations: AuthzResources['relations'] = {
create: ['metaresource'],
read: ['metaresource'],
delete: ['metaresource'],
};
const result = derivePermissionTypes(relations);
expect(result).toHaveLength(3);
expect(result.map((p) => p.key)).toStrictEqual(['create', 'read', 'delete']);
expect(result[0].label).toBe('Create');
});
it('falls back to the default set of permission types when relations is null', () => {
const result = derivePermissionTypes(null);
expect(result.map((p) => p.key)).toStrictEqual([
'create',
'list',
'read',
'update',
'delete',
]);
});
});
describe('deriveResourcesForRelation', () => {
it('returns resources whose type matches the relation', () => {
const result = deriveResourcesForRelation(baseAuthzResources, 'create');
expect(result).toHaveLength(2);
expect(result.map((r) => r.id)).toStrictEqual([
'metaresource:dashboard',
'metaresource:alert',
]);
});
it('returns an empty array when authzResources is null', () => {
expect(deriveResourcesForRelation(null, 'create')).toHaveLength(0);
});
it('returns an empty array when the relation is not defined in the map', () => {
expect(
deriveResourcesForRelation(baseAuthzResources, 'nonexistent'),
).toHaveLength(0);
});
describe('allowedVerbs filtering', () => {
it('excludes resources whose allowedVerbs does not include the relation', () => {
const authz: AuthzResources = {
resources: [
{
kind: 'dashboard',
type: 'metaresource',
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
},
{
kind: 'alert',
type: 'metaresource',
allowedVerbs: ['create', 'read', 'update', 'delete', 'list', 'attach'],
},
],
relations: { attach: ['metaresource'] },
};
const result = deriveResourcesForRelation(authz, 'attach');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('metaresource:alert');
});
it('requires both type-relation match and allowedVerbs — neither condition alone is sufficient', () => {
const authz: AuthzResources = {
resources: [
{ kind: 'dashboard', type: 'metaresource', allowedVerbs: ['read'] },
{ kind: 'role', type: 'role', allowedVerbs: ['create'] },
],
relations: { create: ['metaresource'] },
};
expect(deriveResourcesForRelation(authz, 'create')).toHaveLength(0);
});
});
});

View File

@@ -1,515 +0,0 @@
import {
AuthtypesRelationDTO,
AuthtypesTransactionGroupDTO,
CoretypesKindDTO,
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import {
ActionConfig,
PermissionScope,
ResourcePermissions,
} from '../../types';
import {
createEmptyRolePermissions,
transformResourcePermissionsToTransactionGroups,
transformTransactionGroupsToResourcePermissions,
} from '../useRolePermissions';
const ID_A = 'aaaaaaaa-0000-0000-0000-000000000001';
const ID_B = 'bbbbbbbb-0000-0000-0000-000000000002';
function createResourcePermissions(
resourceKind: AuthZResource,
resourceType: CoretypesTypeDTO,
resourceLabel: string,
actions: Partial<Record<AuthZVerb, ActionConfig>>,
availableActions: AuthZVerb[],
): ResourcePermissions {
return {
resourceId: resourceKind,
resourceKind,
resourceType,
resourceLabel,
actions,
availableActions,
};
}
describe('transformResourcePermissionsToTransactionGroups', () => {
it('skips actions with NONE scope', () => {
const resources: ResourcePermissions[] = [
createResourcePermissions(
CoretypesKindDTO['factor-api-key'],
CoretypesTypeDTO.metaresource,
'API Keys',
{
create: { scope: PermissionScope.NONE, selectedIds: [] },
read: { scope: PermissionScope.NONE, selectedIds: [] },
},
['create', 'read'] as AuthZVerb[],
),
];
const result = transformResourcePermissionsToTransactionGroups(resources);
expect(result).toHaveLength(0);
});
it('transforms ALL scope to wildcard selector', () => {
const resources: ResourcePermissions[] = [
createResourcePermissions(
CoretypesKindDTO['factor-api-key'],
CoretypesTypeDTO.metaresource,
'API Keys',
{
create: { scope: PermissionScope.ALL, selectedIds: [] },
},
['create'] as AuthZVerb[],
),
];
const result = transformResourcePermissionsToTransactionGroups(resources);
expect(result).toHaveLength(1);
expect(result[0]).toStrictEqual({
objectGroup: {
resource: {
kind: CoretypesKindDTO['factor-api-key'],
type: CoretypesTypeDTO.metaresource,
},
selectors: ['*'],
},
relation: AuthtypesRelationDTO.create,
});
});
it('transforms ONLY_SELECTED scope to specific selectors', () => {
const resources: ResourcePermissions[] = [
createResourcePermissions(
CoretypesKindDTO.role,
CoretypesTypeDTO.role,
'Roles',
{
read: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A, ID_B] },
},
['read'] as AuthZVerb[],
),
];
const result = transformResourcePermissionsToTransactionGroups(resources);
expect(result).toHaveLength(1);
expect(result[0]).toStrictEqual({
objectGroup: {
resource: {
kind: CoretypesKindDTO.role,
type: CoretypesTypeDTO.role,
},
selectors: [ID_A, ID_B],
},
relation: AuthtypesRelationDTO.read,
});
});
it('creates separate transaction groups per verb', () => {
const resources: ResourcePermissions[] = [
createResourcePermissions(
CoretypesKindDTO.serviceaccount,
CoretypesTypeDTO.serviceaccount,
'Service Accounts',
{
create: { scope: PermissionScope.ALL, selectedIds: [] },
read: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
update: { scope: PermissionScope.NONE, selectedIds: [] },
},
['create', 'read', 'update'] as AuthZVerb[],
),
];
const result = transformResourcePermissionsToTransactionGroups(resources);
expect(result).toHaveLength(2);
expect(
result.find((t) => t.relation === AuthtypesRelationDTO.create),
).toStrictEqual({
objectGroup: {
resource: {
kind: CoretypesKindDTO.serviceaccount,
type: CoretypesTypeDTO.serviceaccount,
},
selectors: ['*'],
},
relation: AuthtypesRelationDTO.create,
});
expect(
result.find((t) => t.relation === AuthtypesRelationDTO.read),
).toStrictEqual({
objectGroup: {
resource: {
kind: CoretypesKindDTO.serviceaccount,
type: CoretypesTypeDTO.serviceaccount,
},
selectors: [ID_A],
},
relation: AuthtypesRelationDTO.read,
});
});
it('handles multiple resources with different configurations', () => {
const resources: ResourcePermissions[] = [
createResourcePermissions(
CoretypesKindDTO['factor-api-key'],
CoretypesTypeDTO.metaresource,
'API Keys',
{
delete: { scope: PermissionScope.ALL, selectedIds: [] },
},
['delete'] as AuthZVerb[],
),
createResourcePermissions(
CoretypesKindDTO.role,
CoretypesTypeDTO.role,
'Roles',
{
update: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_B] },
},
['update'] as AuthZVerb[],
),
];
const result = transformResourcePermissionsToTransactionGroups(resources);
expect(result).toHaveLength(2);
expect(result).toContainEqual({
objectGroup: {
resource: {
kind: CoretypesKindDTO['factor-api-key'],
type: CoretypesTypeDTO.metaresource,
},
selectors: ['*'],
},
relation: AuthtypesRelationDTO.delete,
});
expect(result).toContainEqual({
objectGroup: {
resource: {
kind: CoretypesKindDTO.role,
type: CoretypesTypeDTO.role,
},
selectors: [ID_B],
},
relation: AuthtypesRelationDTO.update,
});
});
it('returns empty array when all actions are NONE', () => {
const resources: ResourcePermissions[] = [
createResourcePermissions(
CoretypesKindDTO['factor-api-key'],
CoretypesTypeDTO.metaresource,
'API Keys',
{
create: { scope: PermissionScope.NONE, selectedIds: [] },
read: { scope: PermissionScope.NONE, selectedIds: [] },
},
['create', 'read'] as AuthZVerb[],
),
createResourcePermissions(
CoretypesKindDTO.role,
CoretypesTypeDTO.role,
'Roles',
{
create: { scope: PermissionScope.NONE, selectedIds: [] },
},
['create'] as AuthZVerb[],
),
];
const result = transformResourcePermissionsToTransactionGroups(resources);
expect(result).toHaveLength(0);
});
});
describe('transformTransactionGroupsToResourcePermissions', () => {
it('maps wildcard selector to ALL scope', () => {
const transactionGroups: AuthtypesTransactionGroupDTO[] = [
{
objectGroup: {
resource: {
kind: CoretypesKindDTO['factor-api-key'],
type: CoretypesTypeDTO.metaresource,
},
selectors: ['*'],
},
relation: AuthtypesRelationDTO.read,
},
];
const result =
transformTransactionGroupsToResourcePermissions(transactionGroups);
const apiKeyResource = result.find(
(r) => r.resourceKind === 'factor-api-key',
);
expect(apiKeyResource?.actions.read).toStrictEqual({
scope: PermissionScope.ALL,
selectedIds: [],
});
});
it('maps specific selectors to ONLY_SELECTED scope', () => {
const transactionGroups: AuthtypesTransactionGroupDTO[] = [
{
objectGroup: {
resource: {
kind: CoretypesKindDTO.role,
type: CoretypesTypeDTO.role,
},
selectors: [ID_A, ID_B],
},
relation: AuthtypesRelationDTO.update,
},
];
const result =
transformTransactionGroupsToResourcePermissions(transactionGroups);
const roleResource = result.find((r) => r.resourceKind === 'role');
expect(roleResource?.actions.update).toStrictEqual({
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
});
});
it('defaults missing verbs to NONE scope', () => {
const transactionGroups: AuthtypesTransactionGroupDTO[] = [
{
objectGroup: {
resource: {
kind: CoretypesKindDTO['factor-api-key'],
type: CoretypesTypeDTO.metaresource,
},
selectors: ['*'],
},
relation: AuthtypesRelationDTO.create,
},
];
const result =
transformTransactionGroupsToResourcePermissions(transactionGroups);
const apiKeyResource = result.find(
(r) => r.resourceKind === 'factor-api-key',
);
expect(apiKeyResource?.actions.create).toStrictEqual({
scope: PermissionScope.ALL,
selectedIds: [],
});
expect(apiKeyResource?.actions.read).toStrictEqual({
scope: PermissionScope.NONE,
selectedIds: [],
});
expect(apiKeyResource?.actions.list).toStrictEqual({
scope: PermissionScope.NONE,
selectedIds: [],
});
expect(apiKeyResource?.actions.update).toStrictEqual({
scope: PermissionScope.NONE,
selectedIds: [],
});
expect(apiKeyResource?.actions.delete).toStrictEqual({
scope: PermissionScope.NONE,
selectedIds: [],
});
});
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(3);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'role',
'serviceaccount',
]);
});
it('sets correct resource metadata from permissions config', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
const apiKeyResource = result.find(
(r) => r.resourceKind === 'factor-api-key',
);
expect(apiKeyResource).toMatchObject({
resourceId: 'factor-api-key',
resourceKind: 'factor-api-key',
resourceType: CoretypesTypeDTO.metaresource,
resourceLabel: 'API Keys',
availableActions: ['create', 'delete', 'list', 'read', 'update'],
});
const roleResource = result.find(
(r) => r.resourceKind === CoretypesKindDTO.role,
);
expect(roleResource).toMatchObject({
resourceId: CoretypesKindDTO.role,
resourceKind: CoretypesKindDTO.role,
resourceType: CoretypesTypeDTO.role,
resourceLabel: 'Roles',
availableActions: [
'attach',
'create',
'delete',
'detach',
'list',
'read',
'update',
],
});
});
it('handles multiple transaction groups for same resource', () => {
const transactionGroups: AuthtypesTransactionGroupDTO[] = [
{
objectGroup: {
resource: {
kind: CoretypesKindDTO.role,
type: CoretypesTypeDTO.role,
},
selectors: ['*'],
},
relation: AuthtypesRelationDTO.read,
},
{
objectGroup: {
resource: {
kind: CoretypesKindDTO.role,
type: CoretypesTypeDTO.role,
},
selectors: [ID_A],
},
relation: AuthtypesRelationDTO.update,
},
];
const result =
transformTransactionGroupsToResourcePermissions(transactionGroups);
const roleResource = result.find((r) => r.resourceKind === 'role');
expect(roleResource?.actions.read).toStrictEqual({
scope: PermissionScope.ALL,
selectedIds: [],
});
expect(roleResource?.actions.update).toStrictEqual({
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A],
});
});
});
describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(3);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'role',
'serviceaccount',
]);
});
it('sets all actions to NONE scope with empty selectedIds', () => {
const result = createEmptyRolePermissions();
for (const resource of result) {
for (const verb of resource.availableActions) {
expect(resource.actions[verb]).toStrictEqual({
scope: PermissionScope.NONE,
selectedIds: [],
});
}
}
});
it('includes correct metadata from permissions config', () => {
const result = createEmptyRolePermissions();
const apiKeyResource = result.find(
(r) => r.resourceKind === 'factor-api-key',
);
expect(apiKeyResource).toMatchObject({
resourceId: 'factor-api-key',
resourceType: CoretypesTypeDTO.metaresource,
resourceLabel: 'API Keys',
availableActions: ['create', 'delete', 'list', 'read', 'update'],
});
});
});
describe('round-trip transformation', () => {
it('transforming to transaction groups and back preserves data', () => {
const original: ResourcePermissions[] = [
createResourcePermissions(
CoretypesKindDTO['factor-api-key'],
CoretypesTypeDTO.metaresource,
'API Keys',
{
create: { scope: PermissionScope.ALL, selectedIds: [] },
read: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
update: { scope: PermissionScope.NONE, selectedIds: [] },
delete: { scope: PermissionScope.NONE, selectedIds: [] },
},
['create', 'read', 'update', 'delete'] as AuthZVerb[],
),
createResourcePermissions(
CoretypesKindDTO.role,
CoretypesTypeDTO.role,
'Roles',
{
create: { scope: PermissionScope.NONE, selectedIds: [] },
read: { scope: PermissionScope.ALL, selectedIds: [] },
update: {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: [ID_A, ID_B],
},
delete: { scope: PermissionScope.NONE, selectedIds: [] },
},
['create', 'read', 'update', 'delete'] as AuthZVerb[],
),
createResourcePermissions(
CoretypesKindDTO.serviceaccount,
CoretypesTypeDTO.serviceaccount,
'Service Accounts',
{
create: { scope: PermissionScope.NONE, selectedIds: [] },
read: { scope: PermissionScope.NONE, selectedIds: [] },
update: { scope: PermissionScope.NONE, selectedIds: [] },
delete: { scope: PermissionScope.NONE, selectedIds: [] },
},
['create', 'read', 'update', 'delete'] as AuthZVerb[],
),
];
const transactionGroups =
transformResourcePermissionsToTransactionGroups(original);
const restored =
transformTransactionGroupsToResourcePermissions(transactionGroups);
for (const originalResource of original) {
const restoredResource = restored.find(
(r) => r.resourceKind === originalResource.resourceKind,
);
expect(restoredResource).toBeDefined();
for (const verb of originalResource.availableActions) {
expect(restoredResource?.actions[verb]).toStrictEqual(
originalResource.actions[verb],
);
}
}
});
});

View File

@@ -1,85 +0,0 @@
import { useMemo } from 'react';
import {
buildRoleDeletePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
RoleCreatePermission,
} from 'hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { ParsedPermissionObject, parsePermission } from 'hooks/useAuthZ/utils';
interface UseRoleAuthZResult {
readRolePermission: ParsedPermissionObject;
updateRolePermission: ParsedPermissionObject;
deleteRolePermission: ParsedPermissionObject;
hasCreatePermission: boolean;
hasReadPermission: boolean;
hasUpdatePermission: boolean;
hasDeletePermission: boolean;
isAuthZLoading: boolean;
}
export function useRoleAuthZ(roleName: string): UseRoleAuthZResult {
const readRolePermissionName = buildRoleReadPermission(roleName);
const updateRolePermissionName = buildRoleUpdatePermission(roleName);
const deleteRolePermissionName = buildRoleDeletePermission(roleName);
const permissionsToCheck = useMemo(() => {
const perms = [RoleCreatePermission];
if (roleName) {
perms.push(
readRolePermissionName,
updateRolePermissionName,
deleteRolePermissionName,
);
}
return perms;
}, [
roleName,
readRolePermissionName,
updateRolePermissionName,
deleteRolePermissionName,
]);
const { permissions, isLoading: isAuthZLoading } =
useAuthZ(permissionsToCheck);
const hasCreatePermission = useMemo(() => {
if (permissions === null) {
return false;
}
return permissions[RoleCreatePermission]?.isGranted ?? false;
}, [permissions]);
const hasReadPermission = useMemo(() => {
if (!roleName || permissions === null) {
return true;
}
return permissions[buildRoleReadPermission(roleName)]?.isGranted ?? true;
}, [permissions, roleName]);
const hasUpdatePermission = useMemo(() => {
if (!roleName || permissions === null) {
return true;
}
return permissions[buildRoleUpdatePermission(roleName)]?.isGranted ?? true;
}, [permissions, roleName]);
const hasDeletePermission = useMemo(() => {
if (!roleName || permissions === null) {
return true;
}
return permissions[buildRoleDeletePermission(roleName)]?.isGranted ?? true;
}, [permissions, roleName]);
return {
readRolePermission: parsePermission(readRolePermissionName),
updateRolePermission: parsePermission(updateRolePermissionName),
deleteRolePermission: parsePermission(deleteRolePermissionName),
hasCreatePermission,
hasReadPermission,
hasUpdatePermission,
hasDeletePermission,
isAuthZLoading,
};
}

View File

@@ -1,16 +0,0 @@
import { useMemo } from 'react';
import { PermissionScope, ResourcePermissions } from '../types';
export function useRoleGrantedCount(
resource: Pick<ResourcePermissions, 'availableActions' | 'actions'>,
): [grantedCount: number, totalCount: number] {
return useMemo(() => {
const granted = resource.availableActions.filter((actionName) => {
const config = resource.actions[actionName];
return !!config && config.scope !== PermissionScope.NONE;
});
return [granted.length, resource.availableActions.length];
}, [resource.availableActions, resource.actions]);
}

View File

@@ -1,260 +0,0 @@
import { useMutation, useQueryClient } from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import type {
AuthtypesPostableRoleDTO,
AuthtypesRoleWithTransactionGroupsDTO,
AuthtypesTransactionGroupDTO,
AuthtypesUpdatableRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
AuthtypesRelationDTO,
CoretypesKindDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
invalidateGetRole,
invalidateListRoles,
useCreateRole,
useGetRole,
useUpdateRole,
} from 'api/generated/services/role';
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import {
getResourcePanel,
getResourceType,
getResourceVerbs,
RESOURCE_ORDER,
} from '../permissions.config';
import {
ActionConfig,
PermissionScope,
ResourcePermissions,
RolePermissionsData,
} from '../types';
const WILDCARD_SELECTOR = '*';
/**
* Converts internal ResourcePermissions[] to API transactionGroups format.
*/
export function transformResourcePermissionsToTransactionGroups(
resources: ResourcePermissions[],
): AuthtypesTransactionGroupDTO[] {
const transactionGroups: AuthtypesTransactionGroupDTO[] = [];
for (const resource of resources) {
for (const [verbKey, config] of Object.entries(resource.actions)) {
const verb = verbKey as AuthZVerb;
const action = config as ActionConfig;
if (action.scope === PermissionScope.NONE) {
continue;
}
const selectors =
action.scope === PermissionScope.ALL
? [WILDCARD_SELECTOR]
: action.selectedIds;
transactionGroups.push({
objectGroup: {
resource: {
kind: resource.resourceKind as CoretypesKindDTO,
type: resource.resourceType,
},
selectors,
},
relation: verb as unknown as AuthtypesRelationDTO,
});
}
}
return transactionGroups;
}
/**
* Converts API transactionGroups format back to internal ResourcePermissions[].
*/
export function transformTransactionGroupsToResourcePermissions(
transactionGroups: AuthtypesTransactionGroupDTO[],
): ResourcePermissions[] {
const transactionsByResource = new Map<
string,
Map<AuthZVerb, { selectors: string[] }>
>();
for (const txnGroup of transactionGroups) {
const resourceKind = txnGroup.objectGroup.resource.kind as AuthZResource;
const verb = txnGroup.relation as AuthZVerb;
const selectors = txnGroup.objectGroup.selectors ?? [];
let resourceMap = transactionsByResource.get(resourceKind);
if (!resourceMap) {
resourceMap = new Map();
transactionsByResource.set(resourceKind, resourceMap);
}
resourceMap.set(verb, { selectors });
}
return RESOURCE_ORDER.map((resource) => {
const verbs = getResourceVerbs(resource);
const resourceTxns = transactionsByResource.get(resource);
const actions: Partial<Record<AuthZVerb, ActionConfig>> = {};
verbs.forEach((verb) => {
const txn = resourceTxns?.get(verb);
if (!txn) {
actions[verb] = { scope: PermissionScope.NONE, selectedIds: [] };
} else if (
txn.selectors.length === 1 &&
txn.selectors[0] === WILDCARD_SELECTOR
) {
actions[verb] = { scope: PermissionScope.ALL, selectedIds: [] };
} else {
actions[verb] = {
scope: PermissionScope.ONLY_SELECTED,
selectedIds: txn.selectors,
};
}
});
return {
resourceId: resource,
resourceKind: resource,
resourceType: getResourceType(resource),
resourceLabel: getResourcePanel(resource).label,
actions,
availableActions: [...verbs],
};
});
}
export function transformApiToRolePermissions(
role: AuthtypesRoleWithTransactionGroupsDTO,
): RolePermissionsData {
return {
roleId: role.id,
roleName: role.name,
roleDescription: role.description,
resources: transformTransactionGroupsToResourcePermissions(
role.transactionGroups ?? [],
),
};
}
export function createEmptyRolePermissions(): ResourcePermissions[] {
return RESOURCE_ORDER.map((resource) => {
const verbs = getResourceVerbs(resource);
const actions: Partial<Record<AuthZVerb, ActionConfig>> = {};
verbs.forEach((verb) => {
actions[verb] = { scope: PermissionScope.NONE, selectedIds: [] };
});
return {
resourceId: resource,
resourceKind: resource,
resourceType: getResourceType(resource),
resourceLabel: getResourcePanel(resource).label,
actions,
availableActions: [...verbs],
};
});
}
export function useRolePermissions(
roleId: string,
options?: { enabled?: boolean },
): {
data: RolePermissionsData | undefined;
isLoading: boolean;
isError: boolean;
error: ErrorType<RenderErrorResponseDTO> | null;
} {
const { data, isLoading, isError, error } = useGetRole(
{ id: roleId },
{
query: {
enabled: options?.enabled !== false && !!roleId,
select: (response) => transformApiToRolePermissions(response.data),
},
},
);
return {
data,
isLoading,
isError,
error,
};
}
export interface CreateRolePayload {
name: string;
description: string;
resources: ResourcePermissions[];
}
export function useCreateRolePermissions(): ReturnType<
typeof useMutation<void, unknown, CreateRolePayload>
> {
const queryClient = useQueryClient();
const { mutateAsync: createRoleMutation } = useCreateRole();
return useMutation(
async (payload: CreateRolePayload) => {
const apiPayload: AuthtypesPostableRoleDTO = {
name: payload.name,
description: payload.description,
transactionGroups: transformResourcePermissionsToTransactionGroups(
payload.resources,
),
};
await createRoleMutation({ data: apiPayload });
},
{
onSuccess: async () => {
await invalidateListRoles(queryClient);
},
},
);
}
export interface UpdateRolePermissionsPayload {
roleId: string;
description: string;
resources: ResourcePermissions[];
}
export function useUpdateRolePermissions(): ReturnType<
typeof useMutation<void, unknown, UpdateRolePermissionsPayload>
> {
const queryClient = useQueryClient();
const { mutateAsync: updateRoleMutation } = useUpdateRole();
return useMutation(
async (payload: UpdateRolePermissionsPayload) => {
const apiPayload: AuthtypesUpdatableRoleDTO = {
description: payload.description,
transactionGroups: transformResourcePermissionsToTransactionGroups(
payload.resources,
),
};
await updateRoleMutation({
pathParams: { id: payload.roleId },
data: apiPayload,
});
},
{
onSuccess: async (_, variables) => {
await invalidateGetRole(queryClient, { id: variables.roleId });
await invalidateListRoles(queryClient);
},
},
);
}

View File

@@ -1,43 +0,0 @@
import { Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
type EditorOptions = Parameters<Monaco['editor']['create']>[1];
export const JSON_THEME_DARK = 'json-theme-dark';
export function defineJsonTheme(monaco: Monaco): void {
monaco.editor.defineTheme(JSON_THEME_DARK, {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
],
colors: {
'editor.background': Color.BG_INK_400,
},
});
}
const BASE_EDITOR_OPTIONS: EditorOptions = {
automaticLayout: true,
wordWrap: 'on',
minimap: { enabled: false },
fontFamily: 'Geist Mono',
fontSize: 13,
lineHeight: 20,
scrollBeyondLastLine: false,
folding: true,
tabSize: 2,
};
export const READONLY_EDITOR_OPTIONS: EditorOptions = {
...BASE_EDITOR_OPTIONS,
readOnly: true,
domReadOnly: true,
};
export const EDITABLE_EDITOR_OPTIONS: EditorOptions = {
...BASE_EDITOR_OPTIONS,
fixedOverflowWidgets: true,
};

View File

@@ -1,108 +0,0 @@
import { Bot, Key, Shield } from '@signozhq/icons';
import permissionsType from 'hooks/useAuthZ/permissions.type';
import {
AuthZResource,
AuthZVerb,
OBJECT_SCOPED_VERBS,
ObjectScopedVerb,
} from 'hooks/useAuthZ/types';
import { CoretypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
/** Shared shape of the icon components exported by `@signozhq/icons`. */
type IconComponent = typeof Shield;
const OBJECT_SCOPED_VERB_SET = new Set<string>(OBJECT_SCOPED_VERBS);
export interface ResourcePanelConfig {
label: string;
description: string;
icon: IconComponent;
selectorPlaceholder: string;
docsAnchor: string;
}
/**
* Do not use CoretypesTypeDTO to represent this,
* we want to add resource panel configs for only types we actually are using,
* not all of them
*/
export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
'factor-api-key': {
label: 'API Keys',
description: 'Programmatic access tokens for the workspace.',
icon: Key,
selectorPlaceholder: 'Type API key ID, separate multiple with comma or space',
docsAnchor: 'factor-api-key',
},
role: {
label: 'Roles',
description: 'Custom and managed roles and their assignments.',
icon: Shield,
selectorPlaceholder: 'Type role name, separate multiple with comma or space',
docsAnchor: 'role',
},
serviceaccount: {
label: 'Service Accounts',
description: 'Non-human identities used by integrations.',
icon: Bot,
selectorPlaceholder:
'Type service account ID, separate multiple with comma or space',
docsAnchor: 'service-account',
},
};
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];
export function getResourcePanel(resource: AuthZResource): ResourcePanelConfig {
const panel = RESOURCE_PANELS[resource];
if (panel) {
return panel;
}
// Ideally we will have all the resources mapped by compile time, in case we forgot or we are using a backend
// that is newer than frontend, we should have this as fallback to avoid crashing the UI
return {
label: resource,
description: 'Manage permissions for this resource.',
icon: Shield,
selectorPlaceholder: 'Type ID, separate multiple with comma or space',
docsAnchor: '',
};
}
export function getResourceVerbs(
resource: AuthZResource,
): readonly AuthZVerb[] {
const match = permissionsType.data.resources.find(
(entry) => entry.kind === resource,
);
if (!match) {
return [];
}
// Role resource cannot have assignee verb
// TODO(H4ad): Remove this once we get rid of frontend/src/hooks/useAuthZ/legacy.ts
if (resource === 'role') {
return match.allowedVerbs.filter((verb) => verb !== 'assignee');
}
return match.allowedVerbs;
}
export function getResourceType(resource: AuthZResource): CoretypesTypeDTO {
const match = permissionsType.data.resources.find(
(entry) => entry.kind === resource,
);
return match
? (match.type as CoretypesTypeDTO)
: CoretypesTypeDTO.metaresource;
}
export function supportsOnlySelected(
verb: AuthZVerb,
): verb is ObjectScopedVerb {
return OBJECT_SCOPED_VERB_SET.has(verb);
}

View File

@@ -1,41 +0,0 @@
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import { CoretypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
export enum PermissionScope {
ALL = 'all',
ONLY_SELECTED = 'only_selected',
NONE = 'none',
}
export type ActionScope = PermissionScope;
export interface ActionConfig {
scope: ActionScope;
selectedIds: string[];
}
export interface ResourcePermissions {
resourceId: AuthZResource;
resourceKind: AuthZResource;
resourceType: CoretypesTypeDTO;
resourceLabel: string;
actions: Partial<Record<AuthZVerb, ActionConfig>>;
availableActions: AuthZVerb[];
}
export interface RolePermissionsData {
roleId: string;
roleName: string;
roleDescription: string;
resources: ResourcePermissions[];
}
export interface SelectableItem {
id: string;
label: string;
}
export interface ResourceItemsResult {
items: SelectableItem[];
isLoading: boolean;
}

View File

@@ -0,0 +1,264 @@
import React from 'react';
import { Badge } from '@signozhq/ui/badge';
import type {
CoretypesKindDTO,
CoretypesObjectGroupDTO,
CoretypesResourceRefDTO,
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { capitalize } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import type {
PermissionConfig,
ResourceConfig,
ResourceDefinition,
ScopeType,
} from './PermissionSidePanel/PermissionSidePanel.types';
import { PermissionScope } from './PermissionSidePanel/PermissionSidePanel.types';
import {
FALLBACK_PERMISSION_ICON,
PERMISSION_ICON_MAP,
} from './RoleDetails/constants';
export type AuthzResources = {
resources: ReadonlyArray<{
kind: string;
type: string;
allowedVerbs: readonly string[];
}>;
relations: Readonly<Record<string, ReadonlyArray<string>>>;
};
export interface PermissionType {
key: string;
label: string;
icon: JSX.Element;
}
export interface PatchPayloadOptions {
newConfig: PermissionConfig;
initialConfig: PermissionConfig;
resources: ResourceDefinition[];
authzRes: AuthzResources;
}
export function derivePermissionTypes(
relations: AuthzResources['relations'] | null,
): PermissionType[] {
const iconSize = { size: 14 };
if (!relations) {
return Object.entries(PERMISSION_ICON_MAP).map(([key, IconComp]) => ({
key,
label: capitalize(key),
icon: React.createElement(IconComp, iconSize),
}));
}
return Object.keys(relations).map((key) => {
const IconComp = PERMISSION_ICON_MAP[key] ?? FALLBACK_PERMISSION_ICON;
return {
key,
label: capitalize(key),
icon: React.createElement(IconComp, iconSize),
};
});
}
export function deriveResourcesForRelation(
authzResources: AuthzResources | null,
relation: string,
): ResourceDefinition[] {
if (!authzResources?.relations) {
return [];
}
const supportedTypes = authzResources.relations[relation] ?? [];
return authzResources.resources
.filter(
(r) => supportedTypes.includes(r.type) && r.allowedVerbs.includes(relation),
)
.map((r) => ({
id: `${r.type}:${r.kind}`,
kind: r.kind,
type: r.type,
label: r.kind,
options: [],
}));
}
export function objectsToPermissionConfig(
objects: CoretypesObjectGroupDTO[],
resources: ResourceDefinition[],
): PermissionConfig {
const config: PermissionConfig = {};
for (const res of resources) {
const obj = objects.find(
(o) => o.resource.kind === res.kind && o.resource.type === res.type,
);
if (!obj) {
config[res.id] = {
scope: PermissionScope.NONE,
selectedIds: [],
};
} else {
const isAll = obj.selectors.includes('*');
config[res.id] = {
scope: isAll ? PermissionScope.ALL : PermissionScope.ONLY_SELECTED,
selectedIds: isAll ? [] : obj.selectors,
};
}
}
return config;
}
function selectorsForScope(scope: ScopeType, selectedIds: string[]): string[] {
if (scope === PermissionScope.ALL) {
return ['*'];
}
if (scope === PermissionScope.ONLY_SELECTED) {
return selectedIds;
}
return []; // NONE
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function buildPatchPayload({
newConfig,
initialConfig,
resources,
authzRes,
}: PatchPayloadOptions): {
additions: CoretypesObjectGroupDTO[] | null;
deletions: CoretypesObjectGroupDTO[] | null;
} {
if (!authzRes) {
return { additions: null, deletions: null };
}
const additions: CoretypesObjectGroupDTO[] = [];
const deletions: CoretypesObjectGroupDTO[] = [];
for (const res of resources) {
const initial = initialConfig[res.id];
const current = newConfig[res.id];
const found = authzRes.resources.find(
(r) => r.kind === res.kind && r.type === res.type,
);
if (!found) {
continue;
}
const resourceDef: CoretypesResourceRefDTO = {
kind: found.kind as CoretypesKindDTO,
type: found.type as CoretypesTypeDTO,
};
const initialScope = initial?.scope ?? PermissionScope.NONE;
const currentScope = current?.scope ?? PermissionScope.NONE;
if (initialScope === currentScope) {
// Same scope — only diff individual selectors when both are ONLY_SELECTED
if (initialScope === PermissionScope.ONLY_SELECTED) {
const initialIds = new Set(initial?.selectedIds ?? []);
const currentIds = new Set(current?.selectedIds ?? []);
const removed = [...initialIds].filter((id) => !currentIds.has(id));
const added = [...currentIds].filter((id) => !initialIds.has(id));
if (removed.length > 0) {
deletions.push({ resource: resourceDef, selectors: removed });
}
if (added.length > 0) {
additions.push({ resource: resourceDef, selectors: added });
}
}
// Both ALL or both NONE → no change, skip
} else {
// Scope changed — replace old selectors with new ones
const initialSelectors = selectorsForScope(
initialScope,
initial?.selectedIds ?? [],
);
if (initialSelectors.length > 0) {
deletions.push({ resource: resourceDef, selectors: initialSelectors });
}
const currentSelectors = selectorsForScope(
currentScope,
current?.selectedIds ?? [],
);
if (currentSelectors.length > 0) {
additions.push({ resource: resourceDef, selectors: currentSelectors });
}
}
}
return {
additions: additions.length > 0 ? additions : null,
deletions: deletions.length > 0 ? deletions : null,
};
}
interface TimestampBadgeProps {
date?: Date | string;
}
export function TimestampBadge({ date }: TimestampBadgeProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
if (!date) {
return <Badge color="vanilla"></Badge>;
}
const d = new Date(date);
if (Number.isNaN(d.getTime())) {
return <Badge color="vanilla"></Badge>;
}
const formatted = formatTimezoneAdjustedTimestamp(
date,
DATE_TIME_FORMATS.DASH_DATETIME,
);
return <Badge color="vanilla">{formatted}</Badge>;
}
export const DEFAULT_RESOURCE_CONFIG: ResourceConfig = {
scope: PermissionScope.NONE,
selectedIds: [],
};
export function buildConfig(
resources: ResourceDefinition[],
initial?: PermissionConfig,
): PermissionConfig {
const config: PermissionConfig = {};
resources.forEach((r) => {
config[r.id] = initial?.[r.id] ?? { ...DEFAULT_RESOURCE_CONFIG };
});
return config;
}
export function isResourceConfigEqual(
ac: ResourceConfig,
bc?: ResourceConfig,
): boolean {
if (!bc) {
return false;
}
return (
ac.scope === bc.scope &&
JSON.stringify([...ac.selectedIds].sort()) ===
JSON.stringify([...bc.selectedIds].sort())
);
}
export function configsEqual(
a: PermissionConfig,
b: PermissionConfig,
): boolean {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) {
return false;
}
return keysA.every((id) => isResourceConfigEqual(a[id], b[id]));
}

View File

@@ -45,7 +45,7 @@ describe('ServiceAccountsSettings — FGA', () => {
await waitFor(() => {
expect(
screen.getByText('Uh-oh! You are not authorized'),
screen.getByText(/You don't have permission to view this page/),
).toBeInTheDocument();
});
@@ -75,7 +75,7 @@ describe('ServiceAccountsSettings — FGA', () => {
});
expect(
screen.queryByText('Uh-oh! You are not authorized'),
screen.queryByText(/You don't have permission to view this page/),
).not.toBeInTheDocument();
});

View File

@@ -1,12 +1,10 @@
.sa-settings-page {
.sa-settings {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
height: 100%;
}
.sa-settings {
&__header {
display: flex;
flex-direction: column;

View File

@@ -205,7 +205,7 @@ function ServiceAccountsSettings(): JSX.Element {
);
return (
<div className="sa-settings-page">
<>
<div className="sa-settings">
<div className="sa-settings__header">
<h1 className="sa-settings__title">Service Accounts</h1>
@@ -295,7 +295,7 @@ function ServiceAccountsSettings(): JSX.Element {
<CreateServiceAccountModal />
<ServiceAccountDrawer onSuccess={handleDrawerSuccess} />
</div>
</>
);
}

View File

@@ -165,8 +165,6 @@ export const routesToSkip = [
ROUTES.BILLING,
ROUTES.ROLES_SETTINGS,
ROUTES.ROLE_DETAILS,
ROUTES.ROLE_CREATE,
ROUTES.ROLE_EDIT,
ROUTES.SUPPORT,
ROUTES.WORKSPACE_LOCKED,
ROUTES.WORKSPACE_SUSPENDED,

View File

@@ -1,370 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { useNavigationBlocker } from '../useNavigationBlocker';
const mockUnblock = jest.fn();
const mockBlock = jest.fn().mockReturnValue(mockUnblock);
const mockPush = jest.fn();
const mockReplace = jest.fn();
const mockGoBack = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: (): object => ({
block: mockBlock,
push: mockPush,
replace: mockReplace,
goBack: mockGoBack,
}),
}));
describe('useNavigationBlocker', () => {
const mockLocation = {
pathname: '/new-route',
search: '',
hash: '',
state: null,
key: 'test-key',
};
beforeEach(() => {
jest.clearAllMocks();
mockBlock.mockReturnValue(mockUnblock);
});
describe('initial state', () => {
it('returns isBlocked false when shouldBlock is false', () => {
const { result } = renderHook(() => useNavigationBlocker(false));
expect(result.current.isBlocked).toBe(false);
expect(result.current.blockedNavigationDetails).toBeNull();
});
it('returns isBlocked false when shouldBlock is true but no navigation attempted', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
expect(result.current.isBlocked).toBe(false);
expect(result.current.blockedNavigationDetails).toBeNull();
});
});
describe('history.block behavior', () => {
it('calls history.block when shouldBlock is true', () => {
renderHook(() => useNavigationBlocker(true));
expect(mockBlock).toHaveBeenCalledTimes(1);
expect(mockBlock).toHaveBeenCalledWith(expect.any(Function));
});
it('does not call history.block when shouldBlock is false', () => {
renderHook(() => useNavigationBlocker(false));
expect(mockBlock).not.toHaveBeenCalled();
});
it('unblocks when shouldBlock changes from true to false', () => {
const { rerender } = renderHook(
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
{ initialProps: { shouldBlock: true } },
);
expect(mockBlock).toHaveBeenCalledTimes(1);
rerender({ shouldBlock: false });
expect(mockUnblock).toHaveBeenCalledTimes(1);
});
it('unblocks on unmount when blocking', () => {
const { unmount } = renderHook(() => useNavigationBlocker(true));
unmount();
expect(mockUnblock).toHaveBeenCalledTimes(1);
});
it('does not call unblock on unmount when not blocking', () => {
const { unmount } = renderHook(() => useNavigationBlocker(false));
unmount();
expect(mockUnblock).not.toHaveBeenCalled();
});
});
describe('navigation blocking', () => {
it('blocks PUSH navigation and sets blockedNavigationDetails', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
let blockResult: boolean | undefined;
act(() => {
blockResult = blockCallback(mockLocation, 'PUSH');
});
expect(blockResult).toBe(false);
expect(result.current.isBlocked).toBe(true);
expect(result.current.blockedNavigationDetails).toStrictEqual({
location: mockLocation,
action: 'PUSH',
});
});
it('blocks REPLACE navigation and sets blockedNavigationDetails', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(mockLocation, 'REPLACE');
});
expect(result.current.isBlocked).toBe(true);
expect(result.current.blockedNavigationDetails?.action).toBe('REPLACE');
});
it('blocks POP navigation and sets blockedNavigationDetails', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(mockLocation, 'POP');
});
expect(result.current.isBlocked).toBe(true);
expect(result.current.blockedNavigationDetails?.action).toBe('POP');
});
});
describe('confirmNavigation', () => {
it('does nothing when no blockedNavigationDetails', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
act(() => {
result.current.confirmNavigation();
});
expect(mockPush).not.toHaveBeenCalled();
expect(mockReplace).not.toHaveBeenCalled();
expect(mockGoBack).not.toHaveBeenCalled();
});
it('performs PUSH navigation when confirming blocked PUSH', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(mockLocation, 'PUSH');
});
act(() => {
result.current.confirmNavigation();
});
expect(mockUnblock).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith(mockLocation);
expect(result.current.isBlocked).toBe(false);
expect(result.current.blockedNavigationDetails).toBeNull();
});
it('performs REPLACE navigation when confirming blocked REPLACE', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(mockLocation, 'REPLACE');
});
act(() => {
result.current.confirmNavigation();
});
expect(mockReplace).toHaveBeenCalledWith(mockLocation);
});
it('performs goBack when confirming blocked POP', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(mockLocation, 'POP');
});
act(() => {
result.current.confirmNavigation();
});
expect(mockGoBack).toHaveBeenCalled();
});
});
describe('cancelNavigation', () => {
it('clears blockedNavigationDetails without performing navigation', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(mockLocation, 'PUSH');
});
expect(result.current.isBlocked).toBe(true);
act(() => {
result.current.cancelNavigation();
});
expect(result.current.isBlocked).toBe(false);
expect(result.current.blockedNavigationDetails).toBeNull();
expect(mockPush).not.toHaveBeenCalled();
expect(mockReplace).not.toHaveBeenCalled();
expect(mockGoBack).not.toHaveBeenCalled();
});
});
describe('allowNextNavigation', () => {
it('bypasses blocking for next navigation only', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
act(() => {
result.current.allowNextNavigation();
});
const blockCallback = mockBlock.mock.calls[0][0];
let blockResult: boolean | undefined;
act(() => {
blockResult = blockCallback(mockLocation, 'PUSH');
});
expect(blockResult).toBeUndefined();
expect(result.current.isBlocked).toBe(false);
});
it('resumes blocking after bypassed navigation', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
result.current.allowNextNavigation();
});
act(() => {
blockCallback(mockLocation, 'PUSH');
});
let blockResult: boolean | undefined;
act(() => {
blockResult = blockCallback(mockLocation, 'PUSH');
});
expect(blockResult).toBe(false);
expect(result.current.isBlocked).toBe(true);
});
it('resets bypass flag when shouldBlock changes to false before navigation', () => {
const { result, rerender } = renderHook(
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
{ initialProps: { shouldBlock: true } },
);
act(() => {
result.current.allowNextNavigation();
});
rerender({ shouldBlock: false });
rerender({ shouldBlock: true });
const blockCallback = mockBlock.mock.calls[1][0];
let blockResult: boolean | undefined;
act(() => {
blockResult = blockCallback(mockLocation, 'PUSH');
});
expect(blockResult).toBe(false);
expect(result.current.isBlocked).toBe(true);
});
});
describe('beforeunload event', () => {
let addEventListenerSpy: jest.SpyInstance;
let removeEventListenerSpy: jest.SpyInstance;
beforeEach(() => {
addEventListenerSpy = jest.spyOn(window, 'addEventListener');
removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
});
afterEach(() => {
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});
it('adds beforeunload listener when shouldBlock is true', () => {
renderHook(() => useNavigationBlocker(true));
expect(addEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
it('does not add beforeunload listener when shouldBlock is false', () => {
renderHook(() => useNavigationBlocker(false));
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
it('removes beforeunload listener on unmount', () => {
const { unmount } = renderHook(() => useNavigationBlocker(true));
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
it('removes beforeunload listener when shouldBlock changes to false', () => {
const { rerender } = renderHook(
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
{ initialProps: { shouldBlock: true } },
);
rerender({ shouldBlock: false });
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
it('beforeunload handler calls preventDefault', () => {
renderHook(() => useNavigationBlocker(true));
const beforeUnloadHandler = addEventListenerSpy.mock.calls.find(
(call) => call[0] === 'beforeunload',
)?.[1];
const mockEvent = {
preventDefault: jest.fn(),
};
const result = beforeUnloadHandler(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(result).toBeUndefined();
});
});
});

Some files were not shown because too many files have changed in this diff Show More