mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-23 00:30:30 +01:00
Compare commits
30 Commits
main
...
feat/revam
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffec7d96ee | ||
|
|
87cf4c23d9 | ||
|
|
3f955cb75d | ||
|
|
c26e11ecb1 | ||
|
|
7eeda4ee4a | ||
|
|
a62e6f7638 | ||
|
|
f9e04de467 | ||
|
|
9d2068bfd1 | ||
|
|
881e87cf7a | ||
|
|
70f128139b | ||
|
|
ee3615ed45 | ||
|
|
0e5cdc9a19 | ||
|
|
c783712b6d | ||
|
|
72faac5300 | ||
|
|
0311da3cff | ||
|
|
90a91da645 | ||
|
|
193c332597 | ||
|
|
94556706e0 | ||
|
|
86ca6e4472 | ||
|
|
a1109363b1 | ||
|
|
bfb089eabd | ||
|
|
0f8dfd008d | ||
|
|
8f90c7222e | ||
|
|
f572287fa6 | ||
|
|
5ebc95615c | ||
|
|
4e5adb7102 | ||
|
|
8c5a8fa275 | ||
|
|
1532b24fba | ||
|
|
85cf432103 | ||
|
|
c27a6bdbc2 |
@@ -55,6 +55,7 @@ const ROUTES = {
|
||||
TRACE_EXPLORER: '/trace-explorer',
|
||||
BILLING: '/settings/billing',
|
||||
ROLES_SETTINGS: '/settings/roles',
|
||||
ROLE_CREATE: '/settings/roles/new',
|
||||
ROLE_DETAILS: '/settings/roles/:roleId',
|
||||
MEMBERS_SETTINGS: '/settings/members',
|
||||
SUPPORT: '/support',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
.members-settings {
|
||||
.members-settings-page {
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
.createEditRolePage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
width: 100%;
|
||||
max-width: 60vw;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.createEditRolePageHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.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 {
|
||||
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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.errorBanner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-5);
|
||||
background: color-mix(in srgb, var(--bg-cherry-500) 15%, transparent);
|
||||
border: 1px solid var(--bg-cherry-500);
|
||||
border-radius: var(--border-radius-m);
|
||||
}
|
||||
|
||||
.errorBannerMessage {
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--text-cherry-500);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.errorBannerDismiss {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-1);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-cherry-500);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-s);
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-cherry-500) 20%, transparent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Link, matchPath, useLocation } from 'react-router-dom';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { BreadcrumbSimple } from '@signozhq/ui/breadcrumb';
|
||||
import { Skeleton } from 'antd';
|
||||
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import PermissionEditor from './components/PermissionEditor';
|
||||
import { useCreateEditRolePageCallbacks } from './useCreateEditRolePageCallbacks';
|
||||
|
||||
import styles from './CreateEditRolePage.module.scss';
|
||||
|
||||
function CreateEditRolePage(): JSX.Element {
|
||||
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 {
|
||||
formData,
|
||||
editorMode,
|
||||
setEditorMode,
|
||||
resources,
|
||||
setResources,
|
||||
isLoading,
|
||||
isSaving,
|
||||
hasUnsavedChanges,
|
||||
handleSave,
|
||||
handleCancel,
|
||||
handleFormChange,
|
||||
saveError,
|
||||
clearSaveError,
|
||||
validationErrors,
|
||||
isCreateMode,
|
||||
hasRequiredPermission,
|
||||
isAuthZLoading,
|
||||
deniedPermission,
|
||||
} = useCreateEditRolePageCallbacks(roleId, roleName);
|
||||
|
||||
if (!hasRequiredPermission && !isAuthZLoading) {
|
||||
return <PermissionDeniedFullPage permissionName={deniedPermission} />;
|
||||
}
|
||||
|
||||
if (isAuthZLoading || (isLoading && !isCreateMode)) {
|
||||
return (
|
||||
<div className={styles.createEditRolePage}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.createEditRolePage}
|
||||
data-testid="create-edit-role-page"
|
||||
>
|
||||
<div className={styles.createEditRolePageHeader}>
|
||||
<BreadcrumbSimple
|
||||
items={[
|
||||
{
|
||||
title: 'Roles',
|
||||
path: ROUTES.ROLES_SETTINGS,
|
||||
},
|
||||
{
|
||||
title: isCreateMode ? 'Create role' : 'Edit role',
|
||||
},
|
||||
]}
|
||||
itemRender={(route, _, routes) => {
|
||||
const isLast = route === routes[routes.length - 1];
|
||||
return isLast ? (
|
||||
<span>{route.title}</span>
|
||||
) : (
|
||||
<Link to={route.path!}>{route.title}</Link>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className={styles.createEditRolePageActions}>
|
||||
{hasUnsavedChanges && (
|
||||
<div className={styles.unsavedIndicator}>
|
||||
<span className={styles.unsavedDot} />
|
||||
<span className={styles.unsavedText}>Unsaved changes</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
disabled={!hasUnsavedChanges}
|
||||
data-testid="save-button"
|
||||
>
|
||||
{isCreateMode ? 'Create role' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className={styles.errorBanner} data-testid="save-error-banner">
|
||||
<span className={styles.errorBannerMessage}>{saveError}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.errorBannerDismiss}
|
||||
onClick={clearSaveError}
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.createEditRolePageContent}>
|
||||
<div className={styles.createEditRolePageForm}>
|
||||
<div className={styles.formRow}>
|
||||
<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"
|
||||
disabled={!isCreateMode}
|
||||
data-testid="role-name-input"
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
isLoading={isLoading}
|
||||
validationErrors={validationErrors}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateEditRolePage;
|
||||
@@ -0,0 +1,69 @@
|
||||
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 type { BrandedPermission, UseAuthZResult } from 'hooks/useAuthZ/types';
|
||||
|
||||
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', () => {
|
||||
mockUseAuthZ.mockImplementation(
|
||||
(permissions: BrandedPermission[]): UseAuthZResult => ({
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => [p, { isGranted: false }]),
|
||||
) as UseAuthZResult['permissions'],
|
||||
refetchPermissions: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
renderCreatePage();
|
||||
|
||||
expect(
|
||||
screen.getByText(/don't have permission to view this page/i),
|
||||
).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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
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 = 'http://localhost/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');
|
||||
|
||||
expect(screen.getByText('Unsaved changes')).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();
|
||||
|
||||
expect(screen.getByText('Role name already exists')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismisses error banner when X clicked', async () => {
|
||||
server.use(
|
||||
rest.post(rolesApiBase, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
error: { message: 'Some error' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'test');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
const errorBanner = await screen.findByTestId('save-error-banner');
|
||||
const dismissBtn = within(errorBanner).getByRole('button', {
|
||||
name: /dismiss/i,
|
||||
});
|
||||
await user.click(dismissBtn);
|
||||
|
||||
expect(screen.queryByTestId('save-error-banner')).not.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 createToggle = within(apiKeysCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
const onlySelectedBtn = within(createToggle).getByText('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();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Please add at least one selector for each "Only selected" permission.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
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 type { BrandedPermission, UseAuthZResult } from 'hooks/useAuthZ/types';
|
||||
|
||||
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', () => {
|
||||
mockUseAuthZ.mockImplementation(
|
||||
(permissions: BrandedPermission[]): UseAuthZResult => ({
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => [p, { isGranted: false }]),
|
||||
) as UseAuthZResult['permissions'],
|
||||
refetchPermissions: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
renderEditPage();
|
||||
|
||||
expect(
|
||||
screen.getByText(/don't have permission to view this page/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows PermissionDeniedFullPage when update permission denied but read granted', () => {
|
||||
mockUseAuthZ.mockImplementation(
|
||||
(permissions: BrandedPermission[]): UseAuthZResult => ({
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => {
|
||||
const isReadPerm = p.includes(':read:');
|
||||
return [p, { isGranted: isReadPerm }];
|
||||
}),
|
||||
) as UseAuthZResult['permissions'],
|
||||
refetchPermissions: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
renderEditPage();
|
||||
|
||||
expect(
|
||||
screen.getByText(/don't have permission to view this page/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks both read and update permissions for edit mode', () => {
|
||||
mockUseAuthZ.mockImplementation(
|
||||
(permissions: BrandedPermission[]): UseAuthZResult => ({
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => [p, { isGranted: false }]),
|
||||
) as UseAuthZResult['permissions'],
|
||||
refetchPermissions: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
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 = 'http://localhost/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}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{ initialRoute: `/settings/roles/${roleId}` },
|
||||
);
|
||||
}
|
||||
|
||||
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('initial render with loaded data', () => {
|
||||
it('shows breadcrumb with "Edit role" as current page', async () => {
|
||||
renderEditPage();
|
||||
|
||||
await expect(screen.findByText('Edit role')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates name input with existing role name', async () => {
|
||||
renderEditPage();
|
||||
|
||||
await waitFor(async () => {
|
||||
const nameInput = await screen.findByTestId('role-name-input');
|
||||
expect(nameInput).toHaveValue('billing-manager');
|
||||
});
|
||||
});
|
||||
|
||||
it('name input is disabled in edit mode', async () => {
|
||||
renderEditPage();
|
||||
|
||||
await waitFor(async () => {
|
||||
const nameInput = await screen.findByTestId('role-name-input');
|
||||
expect(nameInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
expect(screen.getByText('Unsaved changes')).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 roles list on cancel', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderEditPage();
|
||||
|
||||
await screen.findByTestId('role-name-input');
|
||||
|
||||
const cancelBtn = screen.getByTestId('cancel-button');
|
||||
await user.click(cancelBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('roles-list-redirect'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update success flow', () => {
|
||||
it('redirects to roles list 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('roles-list-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 = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeysCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
const allBtn = within(createToggle).getByText('All');
|
||||
await user.click(allBtn);
|
||||
|
||||
expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('save-button')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
await user.click(within(createToggle).getByText('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();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
await user.click(within(createToggle).getByText('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();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
await user.click(within(createToggle).getByText('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();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
await user.click(within(createToggle).getByText('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 switchToJsonMode();
|
||||
|
||||
const interactiveRadio = screen.getByTestId(
|
||||
'permission-editor-mode-interactive',
|
||||
);
|
||||
await user.click(interactiveRadio);
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
await user.click(within(readToggle).getByText('All'));
|
||||
|
||||
await switchToJsonMode();
|
||||
|
||||
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,478 @@
|
||||
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>;
|
||||
|
||||
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 expanded 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', 'true');
|
||||
});
|
||||
|
||||
it('collapses 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', 'false');
|
||||
});
|
||||
|
||||
it('expands collapsed 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);
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('shows granted count in resource card header', () => {
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
expect(within(apiKeyCard).getByText(/0 \/ \d+ granted/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('action toggles', () => {
|
||||
it('renders action toggles for each available action', () => {
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
expect(
|
||||
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-create'),
|
||||
).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', () => {
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
const scopeToggle = within(createToggle).getByTestId(
|
||||
'action-toggle-scope-factor-api-key-create',
|
||||
);
|
||||
expect(
|
||||
within(scopeToggle).getByRole('radio', { name: 'None' }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it('changes scope to All when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
const allBtn = within(createToggle).getByText('All');
|
||||
await user.click(allBtn);
|
||||
|
||||
const scopeToggle = within(createToggle).getByTestId(
|
||||
'action-toggle-scope-factor-api-key-create',
|
||||
);
|
||||
expect(
|
||||
within(scopeToggle).getByRole('radio', { name: 'All' }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it('updates granted count when scope changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
await user.click(within(createToggle).getByText('All'));
|
||||
|
||||
expect(within(apiKeyCard).getByText(/1 \/ \d+ granted/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Only Selected scope', () => {
|
||||
it('shows item input selector when Only Selected is chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
const onlySelectedBtn = within(createToggle).getByText('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();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
await user.click(within(createToggle).getByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'api-key-001{enter}');
|
||||
|
||||
expect(screen.getByText('api-key-001')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds item when Add button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
await user.click(within(createToggle).getByText('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);
|
||||
|
||||
expect(screen.getByText('api-key-002')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds multiple items separated by comma', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
await user.click(within(createToggle).getByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'key-a, key-b, key-c{enter}');
|
||||
|
||||
expect(screen.getByText('key-a')).toBeInTheDocument();
|
||||
expect(screen.getByText('key-b')).toBeInTheDocument();
|
||||
expect(screen.getByText('key-c')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds multiple items separated by space', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
await user.click(within(createToggle).getByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'key-x key-y key-z{enter}');
|
||||
|
||||
expect(screen.getByText('key-x')).toBeInTheDocument();
|
||||
expect(screen.getByText('key-y')).toBeInTheDocument();
|
||||
expect(screen.getByText('key-z')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add duplicate items', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
await user.click(within(createToggle).getByText('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();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
await user.click(within(createToggle).getByText('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();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
await user.click(within(createToggle).getByText('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();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
await user.click(within(createToggle).getByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'will-be-cleared{enter}');
|
||||
|
||||
await user.click(within(createToggle).getByText('All'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Change permission scope?'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears items when confirmed', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
await user.click(within(createToggle).getByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'to-be-cleared{enter}');
|
||||
|
||||
await user.click(within(createToggle).getByText('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();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
await user.click(within(createToggle).getByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'preserved-key{enter}');
|
||||
|
||||
await user.click(within(createToggle).getByText('None'));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
await user.click(within(dialog).getByRole('button', { name: /cancel/i }));
|
||||
|
||||
expect(screen.getByText('preserved-key')).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();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
|
||||
await user.click(within(createToggle).getByText('Only selected'));
|
||||
await user.click(within(createToggle).getByText('All'));
|
||||
|
||||
expect(
|
||||
screen.queryByText('Change permission scope?'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verbs without Only Selected option', () => {
|
||||
it('does not show Only Selected for list verb', () => {
|
||||
renderPage();
|
||||
|
||||
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();
|
||||
expect(within(listToggle).getByText('None')).toBeInTheDocument();
|
||||
expect(within(listToggle).getByText('All')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapse/expand all resources', () => {
|
||||
it('does not show collapse button when 3 or fewer resources expanded', () => {
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('collapse-resources-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
.actionToggleLabel {
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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 { ACTION_LABELS, 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';
|
||||
|
||||
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 = ACTION_LABELS[action] || 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}>
|
||||
<span className={styles.actionToggleLabel}>{displayLabel}</span>
|
||||
<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't worry, this doesn't update this role yet, it only confirms
|
||||
that you want to clear the items.
|
||||
</Typography>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionToggle;
|
||||
@@ -0,0 +1,113 @@
|
||||
.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;
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-16);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.itemInputSelectorBadgeLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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}>
|
||||
<span className={styles.itemInputSelectorBadgeLabel}>{id}</span>
|
||||
<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={
|
||||
<span>
|
||||
Still not sure on how to add selectors?{' '}
|
||||
<Typography.Link
|
||||
href={`${BASE_DOCS_URL}#${docsAnchor}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Check the docs
|
||||
</Typography.Link>{' '}
|
||||
to understand selectors for this resource.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<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;
|
||||
@@ -0,0 +1,49 @@
|
||||
.jsonEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.jsonEditorContainer {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.jsonEditorErrorLabel {
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l1-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jsonEditorErrorMessage {
|
||||
font-family:
|
||||
Geist Mono,
|
||||
monospace;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import MEditor, { Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import type { AuthtypesTransactionGroupDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import {
|
||||
transformResourcePermissionsToTransactionGroups,
|
||||
transformTransactionGroupsToResourcePermissions,
|
||||
} from '../../useRolePermissions';
|
||||
import {
|
||||
registerCompletionProvider,
|
||||
registerJsonSchema,
|
||||
ROLE_PERMISSIONS_MODEL_PATH,
|
||||
} from './jsonSchema.config';
|
||||
|
||||
import styles from './JsonEditor.module.scss';
|
||||
import { JsonEditorProps } from './JsonEditor.types';
|
||||
|
||||
function JsonEditor({
|
||||
resources,
|
||||
mode,
|
||||
onChange,
|
||||
}: JsonEditorProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
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);
|
||||
|
||||
// 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 => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
registerJsonSchema(monaco);
|
||||
completionDisposableRef.current = registerCompletionProvider(monaco);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
completionDisposableRef.current?.dispose();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const editorOptions = useMemo(
|
||||
() => ({
|
||||
automaticLayout: true,
|
||||
wordWrap: 'on' as const,
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
fontFamily: 'Geist Mono',
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
scrollBeyondLastLine: false,
|
||||
folding: true,
|
||||
tabSize: 2,
|
||||
fixedOverflowWidgets: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.jsonEditor} data-testid="json-editor">
|
||||
<div className={styles.jsonEditorContainer}>
|
||||
<MEditor
|
||||
value={jsonBuffer}
|
||||
language="json"
|
||||
path={ROLE_PERMISSIONS_MODEL_PATH}
|
||||
options={editorOptions}
|
||||
onChange={handleEditorChange}
|
||||
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">
|
||||
<span className={styles.jsonEditorErrorLabel}>Error:</span>
|
||||
<span className={styles.jsonEditorErrorMessage}>{parseError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonEditor;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ResourcePermissions } from '../../types';
|
||||
|
||||
export type EditorMode = 'interactive' | 'json';
|
||||
|
||||
export interface JsonEditorProps {
|
||||
resources: ResourcePermissions[];
|
||||
mode: EditorMode;
|
||||
onChange: (resources: ResourcePermissions[]) => void;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
.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 {
|
||||
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);
|
||||
}
|
||||
|
||||
.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: center;
|
||||
padding-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.permissionEditorCollapseButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
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);
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback } from 'react';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Skeleton } from 'antd';
|
||||
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
|
||||
import { PermissionScope, ResourcePermissions } from '../../types';
|
||||
import type { EditorMode } 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;
|
||||
isLoading?: boolean;
|
||||
validationErrors?: Set<string>;
|
||||
}
|
||||
|
||||
function PermissionEditor({
|
||||
resources,
|
||||
mode,
|
||||
onModeChange,
|
||||
onResourceChange,
|
||||
isLoading = false,
|
||||
validationErrors,
|
||||
}: PermissionEditorProps): JSX.Element {
|
||||
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 => {
|
||||
onModeChange(value as EditorMode);
|
||||
},
|
||||
[onModeChange],
|
||||
);
|
||||
|
||||
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}>
|
||||
<span className={styles.permissionEditorTitle}>Permissions</span>
|
||||
<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.permissionEditorResourceList}>
|
||||
{resources.map((resource) => (
|
||||
<ResourceCard
|
||||
key={resource.resourceId}
|
||||
resource={resource}
|
||||
onActionChange={handleActionChange}
|
||||
defaultExpanded={true}
|
||||
validationErrors={validationErrors}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<JsonEditor
|
||||
resources={resources}
|
||||
mode={mode}
|
||||
onChange={handleJsonChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionEditor;
|
||||
@@ -0,0 +1,80 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.resourceCardLabel {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.resourceCardHeaderRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resourceCardGrantedCount {
|
||||
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);
|
||||
}
|
||||
|
||||
.resourceCardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
height: 1px;
|
||||
margin: 0 var(--spacing-8);
|
||||
background: var(--l2-border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
|
||||
import { supportsOnlySelected } from '../../permissions.config';
|
||||
import ActionToggle from './ActionToggle';
|
||||
|
||||
import styles from './ResourceCard.module.scss';
|
||||
import {
|
||||
PermissionScope,
|
||||
ResourcePermissions,
|
||||
} from 'container/RolesSettings/types';
|
||||
|
||||
interface ResourceCardProps {
|
||||
resource: ResourcePermissions;
|
||||
onActionChange: (
|
||||
resourceId: AuthZResource,
|
||||
action: AuthZVerb,
|
||||
scope: PermissionScope,
|
||||
selectedIds: string[],
|
||||
) => void;
|
||||
defaultExpanded?: boolean;
|
||||
validationErrors?: Set<string>;
|
||||
}
|
||||
|
||||
function ResourceCard({
|
||||
resource,
|
||||
onActionChange,
|
||||
defaultExpanded = false,
|
||||
validationErrors,
|
||||
}: ResourceCardProps): JSX.Element {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
const handleToggleExpand = useCallback((): void => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
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 = useMemo(() => {
|
||||
return Object.values(resource.actions).filter(
|
||||
(config) => !!config && config.scope !== PermissionScope.NONE,
|
||||
).length;
|
||||
}, [resource.actions]);
|
||||
|
||||
const totalCount = resource.availableActions.length;
|
||||
|
||||
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>
|
||||
<span className={styles.resourceCardLabel}>{resource.resourceLabel}</span>
|
||||
</div>
|
||||
<div className={styles.resourceCardHeaderRight}>
|
||||
<span className={styles.resourceCardGrantedCount}>
|
||||
{grantedCount} / {totalCount} granted
|
||||
</span>
|
||||
</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;
|
||||
@@ -0,0 +1,97 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { Monaco } from '@monaco-editor/react';
|
||||
import permissionsType from 'hooks/useAuthZ/permissions.type';
|
||||
|
||||
/**
|
||||
* @deprecated Waiting for Vikrant to provide this as generated file
|
||||
*/
|
||||
function buildTransactionGroupSchema(): Record<string, unknown> {
|
||||
const { resources, relations } = permissionsType.data;
|
||||
|
||||
const resourceKinds = resources.map((r) => r.kind);
|
||||
const resourceTypes = [...new Set(resources.map((r) => r.type))];
|
||||
const allVerbs = Object.keys(relations);
|
||||
|
||||
return {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['objectGroup', 'relation'],
|
||||
properties: {
|
||||
objectGroup: {
|
||||
type: 'object',
|
||||
required: ['resource', 'selectors'],
|
||||
properties: {
|
||||
resource: {
|
||||
type: 'object',
|
||||
required: ['kind', 'type'],
|
||||
properties: {
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: resourceKinds,
|
||||
description:
|
||||
'Resource kind (e.g., role, serviceaccount, factor-api-key)',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: resourceTypes,
|
||||
description: 'Resource type category',
|
||||
},
|
||||
},
|
||||
},
|
||||
selectors: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description:
|
||||
'Selectors for resource instances. Use "*" for all, or specific IDs.',
|
||||
},
|
||||
},
|
||||
},
|
||||
relation: {
|
||||
type: 'string',
|
||||
enum: allVerbs,
|
||||
description: 'Action/verb to allow on the resource',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const TRANSACTION_GROUP_SCHEMA = buildTransactionGroupSchema();
|
||||
|
||||
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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './CreateEditRolePage';
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import type { ResourcePermissions } from '../types';
|
||||
import type { EditorMode } from './components/JsonEditor.types';
|
||||
import {
|
||||
createEmptyRolePermissions,
|
||||
useCreateRolePermissions,
|
||||
useRolePermissions,
|
||||
useUpdateRolePermissions,
|
||||
} from '../useRolePermissions';
|
||||
import { useRoleAuthZ } from './useRoleAuthZ';
|
||||
import {
|
||||
useRoleUnsavedChanges,
|
||||
type RoleFormData,
|
||||
} from './useRoleUnsavedChanges';
|
||||
import { useRoleFormValidation } from './useRoleFormValidation';
|
||||
|
||||
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: () => void;
|
||||
handleCancel: () => void;
|
||||
handleFormChange: (field: keyof RoleFormData, value: string) => void;
|
||||
isCreateMode: boolean;
|
||||
saveError: string | null;
|
||||
clearSaveError: () => void;
|
||||
validationErrors: Set<string>;
|
||||
hasRequiredPermission: boolean;
|
||||
isAuthZLoading: boolean;
|
||||
deniedPermission: string;
|
||||
}
|
||||
|
||||
export function useCreateEditRolePageCallbacks(
|
||||
roleId: string,
|
||||
roleName: string,
|
||||
): UseCreateEditRolePageCallbacksResult {
|
||||
const history = useHistory();
|
||||
const isCreateMode = roleId === 'new';
|
||||
|
||||
const { hasRequiredPermission, isAuthZLoading, deniedPermission } =
|
||||
useRoleAuthZ(isCreateMode, roleName);
|
||||
|
||||
const [formData, setFormData] = useState<RoleFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
});
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>('interactive');
|
||||
const emptyResources = useMemo(() => createEmptyRolePermissions(), []);
|
||||
const [localResources, setLocalResources] = useState<ResourcePermissions[]>(
|
||||
() => (isCreateMode ? createEmptyRolePermissions() : []),
|
||||
);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { validationErrors, validateResources, clearValidationErrors } =
|
||||
useRoleFormValidation();
|
||||
|
||||
const { data: rolePermissionsData, isLoading: isLoadingPermissions } =
|
||||
useRolePermissions(roleId, {
|
||||
enabled: !isCreateMode && hasRequiredPermission,
|
||||
});
|
||||
|
||||
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 => {
|
||||
setEditorMode(mode);
|
||||
}, []);
|
||||
|
||||
const handleResourcesChange = useCallback(
|
||||
(resources: ResourcePermissions[]): void => {
|
||||
setLocalResources(resources);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const hasUnsavedChanges = useRoleUnsavedChanges(
|
||||
isCreateMode,
|
||||
formData,
|
||||
localResources,
|
||||
rolePermissionsData,
|
||||
emptyResources,
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Role name is required', { position: 'bottom-center' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateResources(localResources);
|
||||
if (validationError) {
|
||||
setSaveError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
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' },
|
||||
);
|
||||
history.push(ROUTES.ROLES_SETTINGS);
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { error?: { message?: string }; message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError?.response?.data?.error?.message ||
|
||||
axiosError?.response?.data?.message ||
|
||||
axiosError?.message ||
|
||||
'Failed to save role';
|
||||
setSaveError(errorMessage);
|
||||
}
|
||||
}, [
|
||||
formData.name,
|
||||
formData.description,
|
||||
isCreateMode,
|
||||
roleId,
|
||||
localResources,
|
||||
createRole,
|
||||
updateRole,
|
||||
history,
|
||||
validateResources,
|
||||
clearValidationErrors,
|
||||
]);
|
||||
|
||||
const clearSaveError = useCallback((): void => {
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback((): void => {
|
||||
history.push(ROUTES.ROLES_SETTINGS);
|
||||
}, [history]);
|
||||
|
||||
return {
|
||||
formData,
|
||||
setFormData,
|
||||
editorMode,
|
||||
setEditorMode: handleModeChange,
|
||||
resources: localResources,
|
||||
setResources: handleResourcesChange,
|
||||
isLoading: isLoadingPermissions,
|
||||
isSaving,
|
||||
hasUnsavedChanges,
|
||||
handleSave,
|
||||
handleCancel,
|
||||
handleFormChange,
|
||||
isCreateMode,
|
||||
saveError,
|
||||
clearSaveError,
|
||||
validationErrors,
|
||||
hasRequiredPermission,
|
||||
isAuthZLoading,
|
||||
deniedPermission,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
RoleCreatePermission,
|
||||
buildRoleReadPermission,
|
||||
buildRoleUpdatePermission,
|
||||
} from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
|
||||
interface UseRoleAuthZResult {
|
||||
hasRequiredPermission: boolean;
|
||||
isAuthZLoading: boolean;
|
||||
deniedPermission: string;
|
||||
}
|
||||
|
||||
export function useRoleAuthZ(
|
||||
isCreateMode: boolean,
|
||||
roleName: string,
|
||||
): UseRoleAuthZResult {
|
||||
const permissionsToCheck = useMemo(() => {
|
||||
if (isCreateMode) {
|
||||
return [RoleCreatePermission];
|
||||
}
|
||||
if (!roleName) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
buildRoleReadPermission(roleName),
|
||||
buildRoleUpdatePermission(roleName),
|
||||
];
|
||||
}, [isCreateMode, roleName]);
|
||||
|
||||
const { permissions, isLoading: isAuthZLoading } =
|
||||
useAuthZ(permissionsToCheck);
|
||||
|
||||
const hasRequiredPermission = useMemo(() => {
|
||||
if (permissions === null) {
|
||||
return false;
|
||||
}
|
||||
if (isCreateMode) {
|
||||
return permissions[RoleCreatePermission]?.isGranted ?? false;
|
||||
}
|
||||
if (!roleName) {
|
||||
return true;
|
||||
}
|
||||
const readPerm = buildRoleReadPermission(roleName);
|
||||
const updatePerm = buildRoleUpdatePermission(roleName);
|
||||
return (
|
||||
(permissions[readPerm]?.isGranted ?? false) &&
|
||||
(permissions[updatePerm]?.isGranted ?? false)
|
||||
);
|
||||
}, [permissions, isCreateMode, roleName]);
|
||||
|
||||
const deniedPermission = useMemo(() => {
|
||||
if (isCreateMode) {
|
||||
return 'role:create';
|
||||
}
|
||||
if (roleName) {
|
||||
return `role:${roleName}:update`;
|
||||
}
|
||||
return `role:<missing-rule-name>:update`;
|
||||
}, [isCreateMode, roleName]);
|
||||
|
||||
return {
|
||||
hasRequiredPermission,
|
||||
isAuthZLoading,
|
||||
deniedPermission,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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,
|
||||
]);
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
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;
|
||||
@@ -1,40 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export { default } from './PermissionSidePanel';
|
||||
export type {
|
||||
PermissionConfig,
|
||||
PermissionSidePanelProps,
|
||||
ResourceConfig,
|
||||
ResourceDefinition,
|
||||
ResourceOption,
|
||||
ScopeType,
|
||||
} from './PermissionSidePanel.types';
|
||||
export { PermissionScope } from './PermissionSidePanel.types';
|
||||
@@ -1,325 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
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;
|
||||
@@ -1,536 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
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;
|
||||
@@ -1,87 +0,0 @@
|
||||
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;
|
||||
@@ -1,54 +0,0 @@
|
||||
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;
|
||||
@@ -1,22 +0,0 @@
|
||||
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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './RoleDetailsPage';
|
||||
@@ -0,0 +1,74 @@
|
||||
.drawerDescription {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.44px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l1-foreground);
|
||||
line-height: var(--line-height-20);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.permissionsSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.permissionsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: var(--spacing-2);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
--button-disabled-pointer-events: auto;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footerLeft {
|
||||
display: flex;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.footerRight {
|
||||
display: flex;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import DeleteRoleModal from '../RolesComponents/DeleteRoleModal';
|
||||
|
||||
import PermissionOverview from './components/PermissionOverview';
|
||||
import type { RoleDetailsDrawerProps } from './RoleDetailsDrawer.types';
|
||||
import { useDeleteRoleModal } from './useDeleteRoleModal';
|
||||
import { useRoleDetailsDrawerCallbacks } from './useRoleDetailsDrawerCallbacks';
|
||||
|
||||
import styles from './RoleDetailsDrawer.module.scss';
|
||||
|
||||
function RoleDetailsDrawer({
|
||||
roleId,
|
||||
roleName,
|
||||
onClose,
|
||||
}: RoleDetailsDrawerProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const {
|
||||
role,
|
||||
isLoading,
|
||||
isAuthZLoading,
|
||||
isError,
|
||||
error,
|
||||
isManaged,
|
||||
hasReadPermission,
|
||||
hasDeletePermission,
|
||||
isEditDisabled,
|
||||
editDisabledReason,
|
||||
handleViewDetails,
|
||||
permissions,
|
||||
} = useRoleDetailsDrawerCallbacks({ roleId, roleName });
|
||||
|
||||
const {
|
||||
isDeleteModalOpen,
|
||||
isDeleteDisabled,
|
||||
deleteDisabledReason,
|
||||
handleOpenDeleteModal,
|
||||
handleCloseDeleteModal,
|
||||
handleConfirmDelete,
|
||||
} = useDeleteRoleModal({
|
||||
roleId,
|
||||
isManaged,
|
||||
hasDeletePermission,
|
||||
onDeleteSuccess: onClose,
|
||||
});
|
||||
|
||||
const handleEditRole = useCallback((): void => {
|
||||
onClose();
|
||||
handleViewDetails();
|
||||
}, [onClose, handleViewDetails]);
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(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,
|
||||
);
|
||||
},
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const typeBadgeColor = useMemo(() => {
|
||||
if (isManaged) {
|
||||
return 'robin';
|
||||
}
|
||||
return 'vanilla';
|
||||
}, [isManaged]);
|
||||
|
||||
const deleteButton = (
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={handleOpenDeleteModal}
|
||||
disabled={isDeleteDisabled}
|
||||
data-testid="role-drawer-delete-btn"
|
||||
prefix={<Trash2 />}
|
||||
>
|
||||
Delete role
|
||||
</Button>
|
||||
);
|
||||
|
||||
const editButton = (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleEditRole}
|
||||
disabled={isEditDisabled}
|
||||
data-testid="role-drawer-edit-btn"
|
||||
>
|
||||
Edit role
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerWrapper
|
||||
open={!!roleId}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
showOverlay={false}
|
||||
title={role?.name ?? 'Role Details'}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.footerLeft}>
|
||||
{isDeleteDisabled ? (
|
||||
<Tooltip title={deleteDisabledReason}>{deleteButton}</Tooltip>
|
||||
) : (
|
||||
deleteButton
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.footerRight}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
data-testid="role-drawer-close-btn"
|
||||
>
|
||||
<X size={14} />
|
||||
Close
|
||||
</Button>
|
||||
{isEditDisabled ? (
|
||||
<Tooltip title={editDisabledReason}>{editButton}</Tooltip>
|
||||
) : (
|
||||
editButton
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
width="wide"
|
||||
drawerDescriptionProps={{
|
||||
className: styles.drawerDescription,
|
||||
}}
|
||||
>
|
||||
{isAuthZLoading || isLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
) : !hasReadPermission && permissions !== null ? (
|
||||
<Callout type="error" showIcon title="Permission Denied">
|
||||
You do not have permission to view this role.
|
||||
</Callout>
|
||||
) : isError && error ? (
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
error,
|
||||
'An unexpected error occurred while fetching role details.',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Type</span>
|
||||
<Badge color={typeBadgeColor} variant="outline">
|
||||
{isManaged ? 'Managed' : 'Custom'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Description</span>
|
||||
<p className={styles.description}>{role?.description || '—'}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.metaRow}>
|
||||
<div className={styles.metaItem}>
|
||||
<span className={styles.label}>Created At</span>
|
||||
<Badge color="vanilla">{formatTimestamp(role?.createdAt)}</Badge>
|
||||
</div>
|
||||
<div className={styles.metaItem}>
|
||||
<span className={styles.label}>Updated At</span>
|
||||
<Badge color="vanilla">{formatTimestamp(role?.updatedAt)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isManaged && (
|
||||
<Callout
|
||||
type="warning"
|
||||
showIcon
|
||||
title="This is a managed role. Permissions are view-only and cannot be modified."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.permissionsSection}>
|
||||
<div className={styles.permissionsHeader}>
|
||||
<span className={styles.label}>Permissions</span>
|
||||
</div>
|
||||
{roleId && <PermissionOverview roleId={roleId} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DrawerWrapper>
|
||||
<DeleteRoleModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
roleName={role?.name ?? ''}
|
||||
onCancel={handleCloseDeleteModal}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleDetailsDrawer;
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface RoleDetailsDrawerProps {
|
||||
roleId: string | null;
|
||||
roleName: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react';
|
||||
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 RoleDetailsDrawer from '../RoleDetailsDrawer';
|
||||
|
||||
import {
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
mockHooksForCustomRole,
|
||||
} from './testUtils';
|
||||
|
||||
describe('RoleDetailsDrawer - Actions', () => {
|
||||
beforeEach(() => {
|
||||
mockHooksForCustomRole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls onClose when Close button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const closeBtn = screen.getByTestId('role-drawer-close-btn');
|
||||
await user.click(closeBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to edit page when Edit clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Switch>
|
||||
<Route path="/settings/roles/:roleId">
|
||||
<div data-testid="edit-page-target" />
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{ initialRoute: '/' },
|
||||
);
|
||||
|
||||
const editBtn = screen.getByTestId('role-drawer-edit-btn');
|
||||
await user.click(editBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('edit-page-target'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens delete modal when Delete clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const deleteBtn = screen.getByTestId('role-drawer-delete-btn');
|
||||
await user.click(deleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Are you sure you want to delete the role/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls delete API with captured roleId even if drawer closes before confirm', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const mockDeleteRole = jest.fn().mockResolvedValue({});
|
||||
jest.spyOn(roleApi, 'useDeleteRole').mockReturnValue({
|
||||
mutateAsync: mockDeleteRole,
|
||||
} as unknown as ReturnType<typeof roleApi.useDeleteRole>);
|
||||
|
||||
let clearRoleId: (() => void) | undefined;
|
||||
|
||||
function TestWrapper(): JSX.Element {
|
||||
const [roleId, setRoleId] = useState<string | null>(CUSTOM_ROLE_ID);
|
||||
const [roleName, setRoleName] = useState<string | null>(CUSTOM_ROLE_NAME);
|
||||
|
||||
clearRoleId = (): void => {
|
||||
setRoleId(null);
|
||||
setRoleName(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<RoleDetailsDrawer
|
||||
roleId={roleId}
|
||||
roleName={roleName}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByTestId('role-drawer-delete-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Are you sure you want to delete the role/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
clearRoleId?.();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Are you sure you want to delete the role/),
|
||||
).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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import type { BrandedPermission, UseAuthZResult } from 'hooks/useAuthZ/types';
|
||||
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 '../../useRolePermissions';
|
||||
import RoleDetailsDrawer from '../RoleDetailsDrawer';
|
||||
|
||||
import {
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
mockPermissionsData,
|
||||
} from './testUtils';
|
||||
|
||||
describe('RoleDetailsDrawer - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
it('shows permission denied callout when read permission denied', () => {
|
||||
jest.spyOn(useAuthZModule, 'useAuthZ').mockImplementation(
|
||||
(permissions: BrandedPermission[]): UseAuthZResult => ({
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => [p, { isGranted: false }]),
|
||||
) as UseAuthZResult['permissions'],
|
||||
refetchPermissions: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Permission Denied/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/You do not have permission to view this role/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit button visibility', () => {
|
||||
it('disables Edit button when update permission denied', () => {
|
||||
jest.spyOn(useAuthZModule, 'useAuthZ').mockImplementation(
|
||||
(permissions: BrandedPermission[]): UseAuthZResult => ({
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => {
|
||||
const isReadPerm = p.includes(':read:');
|
||||
return [p, { isGranted: isReadPerm }];
|
||||
}),
|
||||
) as UseAuthZResult['permissions'],
|
||||
refetchPermissions: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('role-drawer-edit-btn')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows Edit 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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('role-drawer-edit-btn')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete button visibility', () => {
|
||||
it('disables Delete button when delete permission denied', () => {
|
||||
jest.spyOn(useAuthZModule, 'useAuthZ').mockImplementation(
|
||||
(permissions: BrandedPermission[]): UseAuthZResult => ({
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => {
|
||||
const isReadPerm = p.includes(':read:');
|
||||
return [p, { isGranted: isReadPerm }];
|
||||
}),
|
||||
) as UseAuthZResult['permissions'],
|
||||
refetchPermissions: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('role-drawer-delete-btn')).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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('role-drawer-delete-btn')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import RoleDetailsDrawer from '../RoleDetailsDrawer';
|
||||
|
||||
import {
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
mockHooksForCustomRole,
|
||||
} from './testUtils';
|
||||
|
||||
describe('RoleDetailsDrawer - Custom Role', () => {
|
||||
beforeEach(() => {
|
||||
mockHooksForCustomRole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders role name in drawer title', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('billing-manager')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays Custom badge for custom roles', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Custom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows role description', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Custom role for managing billing and invoices.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Edit button for custom roles', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('role-drawer-edit-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Close button', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('role-drawer-close-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders created/updated timestamps labels', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Created At')).toBeInTheDocument();
|
||||
expect(screen.getByText('Updated At')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
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 '../../useRolePermissions';
|
||||
import RoleDetailsDrawer from '../RoleDetailsDrawer';
|
||||
|
||||
import {
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
mockPermissionsData,
|
||||
} from './testUtils';
|
||||
|
||||
describe('RoleDetailsDrawer - Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows fallback for missing description', () => {
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
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 RoleDetailsDrawer from '../RoleDetailsDrawer';
|
||||
|
||||
import { CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME } from './testUtils';
|
||||
|
||||
describe('RoleDetailsDrawer - Error State', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('displays error component when API fails', () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error('Failed to fetch'),
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
const errorContainer = document.querySelector('.error-in-place');
|
||||
expect(errorContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
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 RoleDetailsDrawer from '../RoleDetailsDrawer';
|
||||
|
||||
import { CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME } from './testUtils';
|
||||
|
||||
describe('RoleDetailsDrawer - 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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not fetch when roleId is null', () => {
|
||||
const getRole = jest.spyOn(roleApi, 'useGetRole');
|
||||
|
||||
render(
|
||||
<RoleDetailsDrawer roleId={null} roleName={null} onClose={jest.fn()} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(getRole).toHaveBeenCalledWith(
|
||||
{ id: '' },
|
||||
expect.objectContaining({ query: { enabled: false } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import RoleDetailsDrawer from '../RoleDetailsDrawer';
|
||||
|
||||
import {
|
||||
MANAGED_ROLE_ID,
|
||||
MANAGED_ROLE_NAME,
|
||||
mockHooksForManagedRole,
|
||||
} from './testUtils';
|
||||
|
||||
describe('RoleDetailsDrawer - Managed Role', () => {
|
||||
beforeEach(() => {
|
||||
mockHooksForManagedRole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('displays Managed badge for managed roles', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={MANAGED_ROLE_ID}
|
||||
roleName={MANAGED_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Managed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning callout for managed roles', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={MANAGED_ROLE_ID}
|
||||
roleName={MANAGED_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This is a managed role. Permissions are view-only and cannot be modified.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Edit button for managed roles', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={MANAGED_ROLE_ID}
|
||||
roleName={MANAGED_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('role-drawer-edit-btn')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Delete button for managed roles', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={MANAGED_ROLE_ID}
|
||||
roleName={MANAGED_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('role-drawer-delete-btn')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('still shows Close button for managed roles', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={MANAGED_ROLE_ID}
|
||||
roleName={MANAGED_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/settings/roles',
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('role-drawer-close-btn')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,692 @@
|
||||
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 '../../useRolePermissions';
|
||||
import RoleDetailsDrawer from '../RoleDetailsDrawer';
|
||||
|
||||
import {
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
mockHooksForCustomRole,
|
||||
mockHooksWithPermissions,
|
||||
mockPermissionsData,
|
||||
} from './testUtils';
|
||||
|
||||
describe('RoleDetailsDrawer - Permission Overview', () => {
|
||||
beforeEach(() => {
|
||||
mockHooksForCustomRole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders Permissions section label', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByText('Permissions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders permission overview container', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows resource permission cards', () => {
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('granted-count-factor-api-key'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RoleDetailsDrawer - 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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('permission-overview-loading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RoleDetailsDrawer - 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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('permission-overview-error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RoleDetailsDrawer - Scope: ALL permissions', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows "All" badge for actions with ALL scope', () => {
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('granted-count-role')).toHaveTextContent(
|
||||
'3 / 3 granted',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RoleDetailsDrawer - Scope: NONE permissions', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows "None" badge for actions with NONE scope', () => {
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
|
||||
'0 / 4 granted',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RoleDetailsDrawer - Scope: ONLY_SELECTED permissions', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows "Only selected" badge with count', () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'role',
|
||||
resourceKind: 'role',
|
||||
resourceType: 'role',
|
||||
resourceLabel: 'Roles',
|
||||
actions: {
|
||||
read: {
|
||||
scope: 'only_selected',
|
||||
selectedIds: ['admin', 'editor', 'viewer'],
|
||||
},
|
||||
},
|
||||
availableActions: ['read'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('scope-badge-read')).toHaveTextContent(
|
||||
'Only selected · 3',
|
||||
);
|
||||
});
|
||||
|
||||
it('displays selected IDs as expandable chips', () => {
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByText('key-abc-123')).toBeInTheDocument();
|
||||
expect(screen.getByText('key-def-456')).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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByText('editor-role')).toBeInTheDocument();
|
||||
|
||||
const toggle = screen.getByTestId('toggle-items-update');
|
||||
await user.click(toggle);
|
||||
|
||||
expect(screen.queryByText('editor-role')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(toggle);
|
||||
expect(screen.getByText('editor-role')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RoleDetailsDrawer - Mixed permission scopes', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders all three scope types in single resource card', () => {
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
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(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
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('RoleDetailsDrawer - Unknown resources', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders unknown resource with fallback label', () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'future-resource',
|
||||
resourceKind: 'future-resource',
|
||||
resourceType: 'metaresource',
|
||||
resourceLabel: 'future-resource',
|
||||
actions: {
|
||||
read: { scope: 'all', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['read'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('resource-section-future-resource'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('future-resource')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows raw verb name when no label mapping exists', () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'test-resource',
|
||||
resourceKind: 'test-resource',
|
||||
resourceType: 'metaresource',
|
||||
resourceLabel: 'Test Resource',
|
||||
actions: {
|
||||
unknown_action: { scope: 'all', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['unknown_action'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(screen.getByText('unknown_action')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles resource with empty actions', () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'empty-resource',
|
||||
resourceKind: 'empty-resource',
|
||||
resourceType: 'metaresource',
|
||||
resourceLabel: 'Empty Resource',
|
||||
actions: {},
|
||||
availableActions: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<RoleDetailsDrawer
|
||||
roleId={CUSTOM_ROLE_ID}
|
||||
roleName={CUSTOM_ROLE_NAME}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles' },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('resource-section-empty-resource'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('granted-count-empty-resource')).toHaveTextContent(
|
||||
'0 / 0 granted',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
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 '../../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 const mockPermissionsData = {
|
||||
roleId: CUSTOM_ROLE_ID,
|
||||
roleName: 'billing-manager',
|
||||
roleDescription: 'Custom role for managing billing and invoices.',
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'factor-api-key',
|
||||
resourceKind: 'factor-api-key',
|
||||
resourceType: 'metaresource',
|
||||
resourceLabel: 'API Keys',
|
||||
actions: {
|
||||
create: { scope: 'none', selectedIds: [] },
|
||||
read: { scope: 'all', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['create', 'read', 'update', 'delete', 'list'],
|
||||
},
|
||||
{
|
||||
resourceId: 'role',
|
||||
resourceKind: 'role',
|
||||
resourceType: 'role',
|
||||
resourceLabel: 'Roles',
|
||||
actions: {
|
||||
create: { scope: 'none', selectedIds: [] },
|
||||
read: { scope: 'none', selectedIds: [] },
|
||||
},
|
||||
availableActions: [
|
||||
'create',
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
'list',
|
||||
'attach',
|
||||
'detach',
|
||||
],
|
||||
},
|
||||
{
|
||||
resourceId: 'serviceaccount',
|
||||
resourceKind: 'serviceaccount',
|
||||
resourceType: '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>);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-18);
|
||||
border-radius: var(--spacing-2);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.all {
|
||||
color: var(--bg-robin-300);
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 16%, transparent);
|
||||
}
|
||||
|
||||
.none {
|
||||
color: var(--l3-foreground);
|
||||
background: color-mix(in srgb, var(--l3-foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: var(--bg-sienna-400);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 16%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
import type { ScopeBadgeVariant } from './permissionDisplay.utils';
|
||||
import { getScopeBadge } 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> = {
|
||||
all: styles.all,
|
||||
none: styles.none,
|
||||
selected: styles.selected,
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
<span className={styles.label}>{actionLabel}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`${styles.badge} ${BADGE_VARIANT_CLASS[badge.variant]}`}
|
||||
data-testid={`scope-badge-${actionName}`}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && isExpandable && (
|
||||
<SelectedItemsChips
|
||||
ids={selectedIds}
|
||||
testId={`selected-items-${actionName}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionRow;
|
||||
@@ -0,0 +1,18 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--cherry-foreground);
|
||||
margin: 0;
|
||||
padding: var(--spacing-4);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Skeleton } from 'antd';
|
||||
|
||||
import { useRolePermissions } from '../../useRolePermissions';
|
||||
|
||||
import ResourcePermissionCard from './ResourcePermissionCard';
|
||||
|
||||
import styles from './PermissionOverview.module.scss';
|
||||
|
||||
export interface PermissionOverviewProps {
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
function PermissionOverview({ roleId }: PermissionOverviewProps): JSX.Element {
|
||||
const { data: permissions, isLoading, isError } = useRolePermissions(roleId);
|
||||
|
||||
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">
|
||||
<p className={styles.errorText}>Failed to load permissions</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { resources } = permissions;
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-testid="permission-overview">
|
||||
<div className={styles.grid}>
|
||||
{resources.map((resource) => (
|
||||
<ResourcePermissionCard key={resource.resourceId} resource={resource} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionOverview;
|
||||
@@ -0,0 +1,70 @@
|
||||
.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);
|
||||
padding: var(--spacing-6) var(--spacing-7);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l1-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.grantedCount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-1) var(--spacing-4);
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-18);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getResourcePanel } from '../../permissions.config';
|
||||
import { PermissionScope, ResourcePermissions } from '../../types';
|
||||
|
||||
import ActionRow from './ActionRow';
|
||||
import { getActionLabel } from './permissionDisplay.utils';
|
||||
|
||||
import styles from './ResourcePermissionCard.module.scss';
|
||||
|
||||
export interface ResourcePermissionCardProps {
|
||||
resource: ResourcePermissions;
|
||||
}
|
||||
|
||||
function ResourcePermissionCard({
|
||||
resource,
|
||||
}: ResourcePermissionCardProps): JSX.Element {
|
||||
const { resourceLabel, resourceKind, actions, availableActions } = resource;
|
||||
|
||||
const panel = getResourcePanel(resourceKind);
|
||||
const Icon = panel.icon;
|
||||
|
||||
const grantedCount = availableActions.filter((actionName) => {
|
||||
const config = actions[actionName];
|
||||
return !!config && config.scope !== PermissionScope.NONE;
|
||||
}).length;
|
||||
const totalCount = availableActions.length;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={styles.card}
|
||||
data-testid={`resource-section-${resourceKind}`}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<span className={styles.icon}>
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<h4 className={styles.title}>{resourceLabel}</h4>
|
||||
</div>
|
||||
<span
|
||||
className={styles.grantedCount}
|
||||
data-testid={`granted-count-${resourceKind}`}
|
||||
>
|
||||
{grantedCount} / {totalCount} granted
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<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;
|
||||
@@ -0,0 +1,34 @@
|
||||
.chips {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 calc(14px + var(--spacing-3));
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-1) var(--spacing-4);
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--spacing-2);
|
||||
}
|
||||
|
||||
.chipDot {
|
||||
width: var(--spacing-2);
|
||||
height: var(--spacing-2);
|
||||
border-radius: 50%;
|
||||
background: var(--bg-robin-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chipLabel {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-18);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import styles from './SelectedItemsChips.module.scss';
|
||||
|
||||
export interface SelectedItemsChipsProps {
|
||||
ids: string[];
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function SelectedItemsChips({
|
||||
ids,
|
||||
testId,
|
||||
}: SelectedItemsChipsProps): JSX.Element {
|
||||
return (
|
||||
<ul className={styles.chips} data-testid={testId}>
|
||||
{ids.map((id) => (
|
||||
<li key={id} className={styles.chip}>
|
||||
<span className={styles.chipDot} />
|
||||
<span className={styles.chipLabel}>{id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectedItemsChips;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ACTION_LABELS, PermissionScope } from '../../types';
|
||||
|
||||
export type ScopeBadgeVariant = 'all' | 'none' | 'selected';
|
||||
|
||||
export interface ScopeBadge {
|
||||
label: string;
|
||||
variant: ScopeBadgeVariant;
|
||||
}
|
||||
|
||||
export function getActionLabel(actionName: string): string {
|
||||
return ACTION_LABELS[actionName] ?? actionName;
|
||||
}
|
||||
|
||||
export function getScopeBadge(
|
||||
scope: PermissionScope,
|
||||
selectedCount: number,
|
||||
): ScopeBadge {
|
||||
switch (scope) {
|
||||
case PermissionScope.ALL:
|
||||
return { label: 'All', variant: 'all' };
|
||||
case PermissionScope.ONLY_SELECTED:
|
||||
return { label: `Only selected · ${selectedCount}`, variant: 'selected' };
|
||||
case PermissionScope.NONE:
|
||||
default:
|
||||
return { label: 'None', variant: 'none' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './RoleDetailsDrawer';
|
||||
export type { RoleDetailsDrawerProps } from './RoleDetailsDrawer.types';
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
invalidateGetRole,
|
||||
invalidateListRoles,
|
||||
useDeleteRole,
|
||||
} from 'api/generated/services/role';
|
||||
|
||||
interface UseDeleteRoleModalProps {
|
||||
roleId?: string | null;
|
||||
isManaged: boolean;
|
||||
hasDeletePermission: boolean;
|
||||
onDeleteSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface UseDeleteRoleModalResult {
|
||||
isDeleteModalOpen: boolean;
|
||||
isDeleteDisabled: boolean;
|
||||
deleteDisabledReason: string;
|
||||
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 { mutateAsync: deleteRole } = useDeleteRole();
|
||||
|
||||
const handleOpenDeleteModal = useCallback((): void => {
|
||||
setDeleteTargetRoleId(roleId ?? null);
|
||||
}, [roleId]);
|
||||
|
||||
const handleCloseDeleteModal = useCallback((): void => {
|
||||
setDeleteTargetRoleId(null);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async (): Promise<boolean> => {
|
||||
if (!deleteTargetRoleId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await deleteRole({ pathParams: { id: deleteTargetRoleId } });
|
||||
await invalidateListRoles(queryClient);
|
||||
await invalidateGetRole(queryClient, { id: deleteTargetRoleId });
|
||||
setDeleteTargetRoleId(null);
|
||||
onDeleteSuccess?.();
|
||||
return true;
|
||||
}, [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,
|
||||
handleOpenDeleteModal,
|
||||
handleCloseDeleteModal,
|
||||
handleConfirmDelete,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useGetRole } from 'api/generated/services/role';
|
||||
import type {
|
||||
AuthtypesRoleWithTransactionGroupsDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
buildRoleDeletePermission,
|
||||
buildRoleReadPermission,
|
||||
buildRoleUpdatePermission,
|
||||
} from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { RoleType } from 'types/roles';
|
||||
|
||||
import type { RoleDetailsDrawerProps } from './RoleDetailsDrawer.types';
|
||||
|
||||
interface UseRoleDetailsDrawerCallbacksResult {
|
||||
role: AuthtypesRoleWithTransactionGroupsDTO | undefined;
|
||||
isLoading: boolean;
|
||||
isAuthZLoading: boolean;
|
||||
isError: boolean;
|
||||
error: ErrorType<RenderErrorResponseDTO> | null;
|
||||
isManaged: boolean;
|
||||
hasReadPermission: boolean;
|
||||
hasUpdatePermission: boolean;
|
||||
hasDeletePermission: boolean;
|
||||
isEditDisabled: boolean;
|
||||
editDisabledReason: string;
|
||||
handleViewDetails: () => void;
|
||||
permissions: ReturnType<typeof useAuthZ>['permissions'];
|
||||
}
|
||||
|
||||
export function useRoleDetailsDrawerCallbacks(
|
||||
props: Pick<RoleDetailsDrawerProps, 'roleId' | 'roleName'>,
|
||||
): UseRoleDetailsDrawerCallbacksResult {
|
||||
const { roleId, roleName } = props;
|
||||
const history = useHistory();
|
||||
|
||||
const permissionsToCheck = useMemo(() => {
|
||||
if (!roleName) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
buildRoleReadPermission(roleName),
|
||||
buildRoleUpdatePermission(roleName),
|
||||
buildRoleDeletePermission(roleName),
|
||||
];
|
||||
}, [roleName]);
|
||||
|
||||
const { permissions, isLoading: isAuthZLoading } =
|
||||
useAuthZ(permissionsToCheck);
|
||||
|
||||
const hasReadPermission = useMemo(() => {
|
||||
if (!roleName || permissions === null) {
|
||||
return false;
|
||||
}
|
||||
return permissions[buildRoleReadPermission(roleName)]?.isGranted ?? false;
|
||||
}, [permissions, roleName]);
|
||||
|
||||
const hasUpdatePermission = useMemo(() => {
|
||||
if (!roleName || permissions === null) {
|
||||
return false;
|
||||
}
|
||||
return permissions[buildRoleUpdatePermission(roleName)]?.isGranted ?? false;
|
||||
}, [permissions, roleName]);
|
||||
|
||||
const hasDeletePermission = useMemo(() => {
|
||||
if (!roleName || permissions === null) {
|
||||
return false;
|
||||
}
|
||||
return permissions[buildRoleDeletePermission(roleName)]?.isGranted ?? false;
|
||||
}, [permissions, roleName]);
|
||||
|
||||
const { data, isLoading, isError, error } = useGetRole(
|
||||
{ id: roleId ?? '' },
|
||||
{ query: { enabled: !!roleId && hasReadPermission } },
|
||||
);
|
||||
|
||||
const role = data?.data;
|
||||
const isManaged = role?.type === RoleType.MANAGED;
|
||||
|
||||
const handleViewDetails = useCallback((): void => {
|
||||
if (roleId && role?.name) {
|
||||
const search = `?name=${encodeURIComponent(role.name)}`;
|
||||
history.push(`${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}${search}`);
|
||||
}
|
||||
}, [history, roleId, role?.name]);
|
||||
|
||||
const isEditDisabled = isManaged || !hasUpdatePermission;
|
||||
const editDisabledReason = isManaged
|
||||
? 'Managed roles cannot be edited'
|
||||
: 'You do not have permission to edit this role';
|
||||
|
||||
return {
|
||||
role,
|
||||
isLoading,
|
||||
isAuthZLoading,
|
||||
isError,
|
||||
error,
|
||||
isManaged,
|
||||
hasReadPermission,
|
||||
hasUpdatePermission,
|
||||
hasDeletePermission,
|
||||
isEditDisabled,
|
||||
editDisabledReason,
|
||||
handleViewDetails,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
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;
|
||||
@@ -1,59 +1,42 @@
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Modal } from 'antd';
|
||||
import { Trash2 } from '@signozhq/icons';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface DeleteRoleModalProps {
|
||||
isOpen: boolean;
|
||||
roleName: string;
|
||||
isDeleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
onConfirm: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
function DeleteRoleModal({
|
||||
isOpen,
|
||||
roleName,
|
||||
isDeleting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteRoleModalProps): JSX.Element {
|
||||
return (
|
||||
<Modal
|
||||
<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}
|
||||
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"
|
||||
disableOutsideClick
|
||||
>
|
||||
<p className="delete-text">
|
||||
<Typography>
|
||||
Are you sure you want to delete the role <strong>{roleName}</strong>? This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
</Modal>
|
||||
</Typography>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
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';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { RoleType } from 'types/roles';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import '../RolesSettings.styles.scss';
|
||||
import RoleDetailsDrawer from '../RoleDetailsDrawer';
|
||||
|
||||
import styles from './RolesListingTable.module.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -33,6 +36,15 @@ function RolesListingTable({
|
||||
}: RolesListingTableProps): JSX.Element {
|
||||
const { isRolesEnabled } = useRolesFeatureGate();
|
||||
|
||||
const [selectedRoleId, setSelectedRoleId] = useQueryState(
|
||||
'selectedRoleId',
|
||||
parseAsString,
|
||||
);
|
||||
const [selectedRoleName, setSelectedRoleName] = useQueryState(
|
||||
'selectedRoleName',
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
|
||||
RoleListPermission,
|
||||
]);
|
||||
@@ -95,7 +107,6 @@ function RolesListingTable({
|
||||
[filteredRoles],
|
||||
);
|
||||
|
||||
// Combine managed + custom into a flat display list for pagination
|
||||
const displayList = useMemo((): DisplayItem[] => {
|
||||
const result: DisplayItem[] = [];
|
||||
|
||||
@@ -116,7 +127,6 @@ 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;
|
||||
@@ -127,7 +137,6 @@ 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;
|
||||
@@ -140,7 +149,6 @@ 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;
|
||||
@@ -153,6 +161,21 @@ function RolesListingTable({
|
||||
return result;
|
||||
}, [displayList, currentPage]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(roleId: string, roleName: string): void => {
|
||||
if (isRolesEnabled) {
|
||||
void setSelectedRoleId(roleId);
|
||||
void setSelectedRoleName(roleName);
|
||||
}
|
||||
},
|
||||
[isRolesEnabled, setSelectedRoleId, setSelectedRoleName],
|
||||
);
|
||||
|
||||
const handleDrawerClose = useCallback((): void => {
|
||||
void setSelectedRoleId(null);
|
||||
void setSelectedRoleName(null);
|
||||
}, [setSelectedRoleId, setSelectedRoleName]);
|
||||
|
||||
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="numbers">
|
||||
@@ -168,7 +191,7 @@ function RolesListingTable({
|
||||
|
||||
if (isAuthZLoading || isLoading) {
|
||||
return (
|
||||
<div className="roles-listing-table">
|
||||
<div className={styles.rolesListingTable}>
|
||||
<Skeleton active paragraph={{ rows: 5 }} />
|
||||
</div>
|
||||
);
|
||||
@@ -176,7 +199,7 @@ function RolesListingTable({
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="roles-listing-table">
|
||||
<div className={styles.rolesListingTable}>
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
error,
|
||||
@@ -189,31 +212,34 @@ function RolesListingTable({
|
||||
|
||||
if (filteredRoles.length === 0) {
|
||||
return (
|
||||
<div className="roles-listing-table">
|
||||
<div className="roles-table-empty">
|
||||
{searchQuery ? 'No roles match your search.' : 'No roles found.'}
|
||||
<>
|
||||
<div className={styles.rolesListingTable}>
|
||||
<div className={styles.emptyState}>
|
||||
{searchQuery ? 'No roles match your search.' : 'No roles found.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RoleDetailsDrawer
|
||||
roleId={selectedRoleId}
|
||||
roleName={selectedRoleName}
|
||||
onClose={handleDrawerClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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={`roles-table-row${isRolesEnabled ? ' roles-table-row--clickable' : ''}`}
|
||||
className={cx(styles.tableRow, {
|
||||
[styles.tableRowClickable]: isRolesEnabled,
|
||||
})}
|
||||
role={isRolesEnabled ? 'button' : undefined}
|
||||
tabIndex={isRolesEnabled ? 0 : undefined}
|
||||
onClick={
|
||||
isRolesEnabled
|
||||
? (): void => {
|
||||
if (role.id) {
|
||||
navigateToRole(role.id, role.name);
|
||||
if (role.id && role.name) {
|
||||
handleRowClick(role.id, role.name);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
@@ -221,76 +247,81 @@ function RolesListingTable({
|
||||
onKeyDown={
|
||||
isRolesEnabled
|
||||
? (e): void => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && role.id) {
|
||||
navigateToRole(role.id, role.name);
|
||||
if ((e.key === 'Enter' || e.key === ' ') && role.id && role.name) {
|
||||
handleRowClick(role.id, role.name);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="roles-table-cell roles-table-cell--name">
|
||||
<div className={cx(styles.tableCell, styles.tableCellName)}>
|
||||
{role.name ?? '—'}
|
||||
</div>
|
||||
<div className="roles-table-cell roles-table-cell--description">
|
||||
<div className={cx(styles.tableCell, styles.tableCellDescription)}>
|
||||
<LineClampedText
|
||||
text={role.description ?? '—'}
|
||||
tooltipProps={{ overlayClassName: 'roles-description-tooltip' }}
|
||||
tooltipProps={{ overlayClassName: styles.descriptionTooltip }}
|
||||
/>
|
||||
</div>
|
||||
<div className="roles-table-cell roles-table-cell--updated-at">
|
||||
<div className={cx(styles.tableCell, styles.tableCellUpdatedAt)}>
|
||||
{formatTimestamp(role.updatedAt)}
|
||||
</div>
|
||||
<div className="roles-table-cell roles-table-cell--created-at">
|
||||
<div className={cx(styles.tableCell, styles.tableCellCreatedAt)}>
|
||||
{formatTimestamp(role.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="roles-listing-table">
|
||||
<div className="roles-table-scroll-container">
|
||||
<div className="roles-table-inner">
|
||||
<div className="roles-table-header">
|
||||
<div className="roles-table-header-cell roles-table-header-cell--name">
|
||||
Name
|
||||
</div>
|
||||
<div className="roles-table-header-cell roles-table-header-cell--description">
|
||||
Description
|
||||
</div>
|
||||
<div className="roles-table-header-cell roles-table-header-cell--updated-at">
|
||||
Updated At
|
||||
</div>
|
||||
<div className="roles-table-header-cell roles-table-header-cell--created-at">
|
||||
Created At
|
||||
<>
|
||||
<div 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)}>
|
||||
Description
|
||||
</div>
|
||||
<div className={cx(styles.headerCell, styles.headerCellUpdatedAt)}>
|
||||
Updated At
|
||||
</div>
|
||||
<div className={cx(styles.headerCell, styles.headerCellCreatedAt)}>
|
||||
Created At
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{paginatedItems.map((item) =>
|
||||
item.type === 'section' ? (
|
||||
<h3 key={`section-${item.label}`} className={styles.sectionHeader}>
|
||||
{item.label}
|
||||
{item.count !== undefined && (
|
||||
<span className={styles.sectionHeaderCount}>{item.count}</span>
|
||||
)}
|
||||
</h3>
|
||||
) : (
|
||||
renderRow(item.role)
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{paginatedItems.map((item) =>
|
||||
item.type === 'section' ? (
|
||||
<h3 key={`section-${item.label}`} className="roles-table-section-header">
|
||||
{item.label}
|
||||
{item.count !== undefined && (
|
||||
<span className="roles-table-section-header__count">{item.count}</span>
|
||||
)}
|
||||
</h3>
|
||||
) : (
|
||||
renderRow(item.role)
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={totalRoleCount}
|
||||
showTotal={showPaginationItem}
|
||||
showSizeChanger={false}
|
||||
hideOnSinglePage
|
||||
onChange={(page): void => setCurrentPage(page)}
|
||||
className="roles-table-pagination"
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={totalRoleCount}
|
||||
showTotal={showPaginationItem}
|
||||
showSizeChanger={false}
|
||||
hideOnSinglePage
|
||||
onChange={(page): void => setCurrentPage(page)}
|
||||
className={styles.pagination}
|
||||
/>
|
||||
</div>
|
||||
<RoleDetailsDrawer
|
||||
roleId={selectedRoleId}
|
||||
roleName={selectedRoleName}
|
||||
onClose={handleDrawerClose}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
229
frontend/src/container/RolesSettings/RolesSettings.module.scss
Normal file
229
frontend/src/container/RolesSettings/RolesSettings.module.scss
Normal file
@@ -0,0 +1,229 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,40 @@
|
||||
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 './RolesSettings.styles.scss';
|
||||
import styles from './RolesSettings.module.scss';
|
||||
|
||||
function RolesSettings(): JSX.Element {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const history = useHistory();
|
||||
const { isRolesEnabled } = useRolesFeatureGate();
|
||||
|
||||
return (
|
||||
<div className="roles-settings" data-testid="roles-settings">
|
||||
<div className="roles-settings-header">
|
||||
<h3 className="roles-settings-header-title">Roles</h3>
|
||||
<p className="roles-settings-header-description">
|
||||
<div data-testid="roles-settings">
|
||||
<div className={styles.rolesSettingsHeader}>
|
||||
<h3 className={styles.rolesSettingsHeaderTitle}>Roles</h3>
|
||||
<p className={styles.rolesSettingsHeaderDescription}>
|
||||
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="roles-settings-header-learn-more"
|
||||
className={styles.rolesSettingsHeaderLearnMore}
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="roles-settings-content">
|
||||
<div className="roles-settings-toolbar">
|
||||
<div className={styles.rolesSettingsContent}>
|
||||
<div className={styles.rolesSettingsToolbar}>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search for roles..."
|
||||
@@ -45,8 +46,8 @@ function RolesSettings(): JSX.Element {
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="role-settings-toolbar-button"
|
||||
onClick={(): void => setIsCreateModalOpen(true)}
|
||||
className={styles.roleSettingsToolbarButton}
|
||||
onClick={(): void => history.push(ROUTES.ROLE_CREATE)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Custom role
|
||||
@@ -56,10 +57,6 @@ function RolesSettings(): JSX.Element {
|
||||
</div>
|
||||
<RolesListingTable searchQuery={searchQuery} />
|
||||
</div>
|
||||
<CreateRoleModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={(): void => setIsCreateModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -77,13 +77,14 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('filters roles by search query on name', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'billing' },
|
||||
});
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, 'billing');
|
||||
|
||||
await expect(
|
||||
screen.findByText('billing-manager'),
|
||||
@@ -94,13 +95,14 @@ 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();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'read-only' },
|
||||
});
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, 'read-only');
|
||||
|
||||
await expect(screen.findByText('signoz-viewer')).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
|
||||
@@ -108,13 +110,14 @@ 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();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'nonexistentrole' },
|
||||
});
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, 'nonexistentrole');
|
||||
|
||||
await expect(
|
||||
screen.findByText('No roles match your search.'),
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
import type {
|
||||
AuthtypesRelationDTO,
|
||||
AuthtypesTransactionGroupDTO,
|
||||
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';
|
||||
|
||||
jest.mock('../permissions.config', () => ({
|
||||
RESOURCE_ORDER: ['factor-api-key', 'role', 'serviceaccount'] as const,
|
||||
getResourceVerbs: (resource: string): string[] => {
|
||||
const verbMap: Record<string, string[]> = {
|
||||
'factor-api-key': ['create', 'read', 'update', 'delete'],
|
||||
role: ['create', 'read', 'update', 'delete'],
|
||||
serviceaccount: ['create', 'read', 'update', 'delete'],
|
||||
};
|
||||
return verbMap[resource] ?? [];
|
||||
},
|
||||
getResourceType: (resource: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
'factor-api-key': 'metaresource',
|
||||
role: 'role',
|
||||
serviceaccount: 'metaresource',
|
||||
};
|
||||
return typeMap[resource] ?? 'metaresource';
|
||||
},
|
||||
getResourcePanel: (resource: string): { label: string } => {
|
||||
const labelMap: Record<string, string> = {
|
||||
'factor-api-key': 'API Keys',
|
||||
role: 'Roles',
|
||||
serviceaccount: 'Service Accounts',
|
||||
};
|
||||
return { label: labelMap[resource] ?? resource };
|
||||
},
|
||||
}));
|
||||
|
||||
const ID_A = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
const ID_B = 'bbbbbbbb-0000-0000-0000-000000000002';
|
||||
|
||||
function createResourcePermissions(
|
||||
resourceKind: AuthZResource,
|
||||
resourceType: string,
|
||||
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(
|
||||
'factor-api-key' as AuthZResource,
|
||||
'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(
|
||||
'factor-api-key' as AuthZResource,
|
||||
'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: 'factor-api-key',
|
||||
type: 'metaresource',
|
||||
},
|
||||
selectors: ['*'],
|
||||
},
|
||||
relation: 'create',
|
||||
});
|
||||
});
|
||||
|
||||
it('transforms ONLY_SELECTED scope to specific selectors', () => {
|
||||
const resources: ResourcePermissions[] = [
|
||||
createResourcePermissions(
|
||||
'role' as AuthZResource,
|
||||
'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: 'role',
|
||||
type: 'role',
|
||||
},
|
||||
selectors: [ID_A, ID_B],
|
||||
},
|
||||
relation: 'read',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates separate transaction groups per verb', () => {
|
||||
const resources: ResourcePermissions[] = [
|
||||
createResourcePermissions(
|
||||
'serviceaccount' as AuthZResource,
|
||||
'metaresource',
|
||||
'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 === 'create')).toStrictEqual({
|
||||
objectGroup: {
|
||||
resource: { kind: 'serviceaccount', type: 'metaresource' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
relation: 'create',
|
||||
});
|
||||
expect(result.find((t) => t.relation === 'read')).toStrictEqual({
|
||||
objectGroup: {
|
||||
resource: { kind: 'serviceaccount', type: 'metaresource' },
|
||||
selectors: [ID_A],
|
||||
},
|
||||
relation: 'read',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles multiple resources with different configurations', () => {
|
||||
const resources: ResourcePermissions[] = [
|
||||
createResourcePermissions(
|
||||
'factor-api-key' as AuthZResource,
|
||||
'metaresource',
|
||||
'API Keys',
|
||||
{
|
||||
delete: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
},
|
||||
['delete'] as AuthZVerb[],
|
||||
),
|
||||
createResourcePermissions(
|
||||
'role' as AuthZResource,
|
||||
'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: 'factor-api-key', type: 'metaresource' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
relation: 'delete',
|
||||
});
|
||||
expect(result).toContainEqual({
|
||||
objectGroup: {
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
selectors: [ID_B],
|
||||
},
|
||||
relation: 'update',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when all actions are NONE', () => {
|
||||
const resources: ResourcePermissions[] = [
|
||||
createResourcePermissions(
|
||||
'factor-api-key' as AuthZResource,
|
||||
'metaresource',
|
||||
'API Keys',
|
||||
{
|
||||
create: { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
read: { scope: PermissionScope.NONE, selectedIds: [] },
|
||||
},
|
||||
['create', 'read'] as AuthZVerb[],
|
||||
),
|
||||
createResourcePermissions(
|
||||
'role' as AuthZResource,
|
||||
'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: 'factor-api-key',
|
||||
type: 'metaresource' as CoretypesTypeDTO,
|
||||
},
|
||||
selectors: ['*'],
|
||||
},
|
||||
relation: 'read' as AuthtypesRelationDTO,
|
||||
},
|
||||
];
|
||||
|
||||
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: 'role', type: 'role' as CoretypesTypeDTO },
|
||||
selectors: [ID_A, ID_B],
|
||||
},
|
||||
relation: 'update' as AuthtypesRelationDTO,
|
||||
},
|
||||
];
|
||||
|
||||
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: 'factor-api-key',
|
||||
type: 'metaresource' as CoretypesTypeDTO,
|
||||
},
|
||||
selectors: ['*'],
|
||||
},
|
||||
relation: 'create' as AuthtypesRelationDTO,
|
||||
},
|
||||
];
|
||||
|
||||
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.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: 'metaresource',
|
||||
resourceLabel: 'API Keys',
|
||||
availableActions: ['create', 'read', 'update', 'delete'],
|
||||
});
|
||||
|
||||
const roleResource = result.find((r) => r.resourceKind === 'role');
|
||||
expect(roleResource).toMatchObject({
|
||||
resourceId: 'role',
|
||||
resourceKind: 'role',
|
||||
resourceType: 'role',
|
||||
resourceLabel: 'Roles',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles multiple transaction groups for same resource', () => {
|
||||
const transactionGroups: AuthtypesTransactionGroupDTO[] = [
|
||||
{
|
||||
objectGroup: {
|
||||
resource: { kind: 'role', type: 'role' as CoretypesTypeDTO },
|
||||
selectors: ['*'],
|
||||
},
|
||||
relation: 'read' as AuthtypesRelationDTO,
|
||||
},
|
||||
{
|
||||
objectGroup: {
|
||||
resource: { kind: 'role', type: 'role' as CoretypesTypeDTO },
|
||||
selectors: [ID_A],
|
||||
},
|
||||
relation: 'update' as AuthtypesRelationDTO,
|
||||
},
|
||||
];
|
||||
|
||||
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: 'metaresource',
|
||||
resourceLabel: 'API Keys',
|
||||
availableActions: ['create', 'read', 'update', 'delete'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip transformation', () => {
|
||||
it('transforming to transaction groups and back preserves data', () => {
|
||||
const original: ResourcePermissions[] = [
|
||||
createResourcePermissions(
|
||||
'factor-api-key' as AuthZResource,
|
||||
'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(
|
||||
'role' as AuthZResource,
|
||||
'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(
|
||||
'serviceaccount' as AuthZResource,
|
||||
'metaresource',
|
||||
'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],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,613 +0,0 @@
|
||||
import type {
|
||||
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',
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
101
frontend/src/container/RolesSettings/permissions.config.ts
Normal file
101
frontend/src/container/RolesSettings/permissions.config.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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';
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
export const RESOURCE_PANELS: Partial<
|
||||
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: 'serviceaccount',
|
||||
},
|
||||
};
|
||||
|
||||
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): string {
|
||||
const match = permissionsType.data.resources.find(
|
||||
(entry) => entry.kind === resource,
|
||||
);
|
||||
return match ? match.type : 'metaresource';
|
||||
}
|
||||
|
||||
export function supportsOnlySelected(
|
||||
verb: AuthZVerb,
|
||||
): verb is ObjectScopedVerb {
|
||||
return OBJECT_SCOPED_VERB_SET.has(verb);
|
||||
}
|
||||
52
frontend/src/container/RolesSettings/types.ts
Normal file
52
frontend/src/container/RolesSettings/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
|
||||
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;
|
||||
/** Resource type for API (e.g. 'metaresource', 'role', 'serviceaccount'). */
|
||||
resourceType: string;
|
||||
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 const ACTION_LABELS: Record<string, string> = {
|
||||
read: 'Read',
|
||||
create: 'Create',
|
||||
update: 'Edit',
|
||||
delete: 'Delete',
|
||||
attach: 'Attach',
|
||||
detach: 'Detach',
|
||||
list: 'List',
|
||||
assignee: 'Assign',
|
||||
};
|
||||
|
||||
export interface ResourceItemsResult {
|
||||
items: SelectableItem[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
308
frontend/src/container/RolesSettings/useRolePermissions.ts
Normal file
308
frontend/src/container/RolesSettings/useRolePermissions.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import {
|
||||
AuthtypesRelationDTO,
|
||||
CoretypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
AuthtypesPostableRoleDTO,
|
||||
AuthtypesRoleWithTransactionGroupsDTO,
|
||||
AuthtypesTransactionGroupDTO,
|
||||
AuthtypesUpdatableRoleDTO,
|
||||
} 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,
|
||||
type: resource.resourceType as CoretypesTypeDTO,
|
||||
},
|
||||
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],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function transformApiToRolePermissions(
|
||||
role: AuthtypesRoleWithTransactionGroupsDTO,
|
||||
): RolePermissionsData {
|
||||
const transactionsByResource = new Map<
|
||||
string,
|
||||
Map<AuthZVerb, { selectors: string[] }>
|
||||
>();
|
||||
|
||||
for (const txnGroup of role.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 });
|
||||
}
|
||||
|
||||
const resources: ResourcePermissions[] = 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],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
roleId: role.id,
|
||||
roleName: role.name,
|
||||
roleDescription: role.description,
|
||||
resources,
|
||||
};
|
||||
}
|
||||
|
||||
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: Error | 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: error as Error | null,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import type {
|
||||
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,
|
||||
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]));
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
.sa-settings {
|
||||
.sa-settings-page {
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { buildPermission } from './utils';
|
||||
|
||||
// TODO(H4ad): Remove frontend/src/container/RolesSettings/CreateEditRolePage/permissions.config.ts once this is removed
|
||||
|
||||
export const IsAdminPermission = buildPermission(
|
||||
'assignee',
|
||||
'role:signoz-admin',
|
||||
|
||||
@@ -6,6 +6,7 @@ type PermissionsData = typeof permissionsType.data;
|
||||
export type Resource = PermissionsData['resources'][number];
|
||||
export type ResourceName = Resource['kind'];
|
||||
export type ResourceType = Resource['type'];
|
||||
export type AuthZVerb = Resource['allowedVerbs'][number];
|
||||
|
||||
type RelationsByType = PermissionsData['relations'];
|
||||
|
||||
@@ -41,6 +42,26 @@ export type AuthZRelation = AllRelations;
|
||||
export type AuthZResource = ResourceName;
|
||||
export type AuthZObject<R extends AuthZRelation> = RelationToObject<R>;
|
||||
|
||||
/**
|
||||
* Verbs that can support selector.
|
||||
*
|
||||
* Create is here because we can create a role with permission create:serviceaccount:<id>
|
||||
*/
|
||||
export type ObjectScopedVerb = Extract<
|
||||
AuthZVerb,
|
||||
'create' | 'read' | 'update' | 'delete' | 'assignee' | 'attach' | 'detach'
|
||||
>;
|
||||
|
||||
export const OBJECT_SCOPED_VERBS: readonly ObjectScopedVerb[] = [
|
||||
'create',
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
'assignee',
|
||||
'attach',
|
||||
'detach',
|
||||
];
|
||||
|
||||
export type BrandedPermission = string & { __brandedPermission: true };
|
||||
|
||||
export type AuthZCheckResponse = Record<
|
||||
|
||||
@@ -79,9 +79,12 @@
|
||||
|
||||
.settings-page-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--l1-background);
|
||||
padding: 10px 8px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
@@ -99,6 +102,34 @@
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.ant-tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane-active {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ function SettingsPage(): JSX.Element {
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_CREATE ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
? true
|
||||
@@ -126,6 +127,7 @@ function SettingsPage(): JSX.Element {
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_CREATE ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
? true
|
||||
@@ -173,6 +175,7 @@ function SettingsPage(): JSX.Element {
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_CREATE ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
? true
|
||||
|
||||
@@ -9,7 +9,7 @@ import MCPServerSettings from 'container/MCPServerSettings/MCPServerSettings';
|
||||
import MySettings from 'container/MySettings';
|
||||
import OrganizationSettings from 'container/OrganizationSettings';
|
||||
import RolesSettings from 'container/RolesSettings';
|
||||
import RoleDetailsPage from 'container/RolesSettings/RoleDetails';
|
||||
import CreateEditRolePage from 'container/RolesSettings/CreateEditRolePage';
|
||||
import { TFunction } from 'i18next';
|
||||
import {
|
||||
Backpack,
|
||||
@@ -135,7 +135,7 @@ export const rolesSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
|
||||
export const roleDetails = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: RoleDetailsPage,
|
||||
Component: CreateEditRolePage,
|
||||
name: (
|
||||
<div className="periscope-tab">
|
||||
<Shield size={16} /> {t('routes:role_details').toString()}
|
||||
@@ -146,6 +146,19 @@ export const roleDetails = (t: TFunction): RouteTabProps['routes'] => [
|
||||
},
|
||||
];
|
||||
|
||||
export const roleCreate = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: CreateEditRolePage,
|
||||
name: (
|
||||
<div className="periscope-tab">
|
||||
<Shield size={16} /> {t('routes:role_create').toString()}
|
||||
</div>
|
||||
),
|
||||
route: ROUTES.ROLE_CREATE,
|
||||
key: ROUTES.ROLE_CREATE,
|
||||
},
|
||||
];
|
||||
|
||||
export const keyboardShortcuts = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: Shortcuts,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
multiIngestionSettings,
|
||||
mySettings,
|
||||
organizationSettings,
|
||||
roleCreate,
|
||||
roleDetails,
|
||||
rolesSettings,
|
||||
serviceAccountsSettings,
|
||||
@@ -61,6 +62,7 @@ export const getRoutes = (
|
||||
settings.push(
|
||||
...serviceAccountsSettings(t),
|
||||
...rolesSettings(t),
|
||||
...roleCreate(t),
|
||||
...roleDetails(t),
|
||||
);
|
||||
|
||||
|
||||
@@ -833,3 +833,7 @@ body.ai-assistant-panel-open {
|
||||
--dropdown-menu-content-z-index: 1050;
|
||||
--dropdown-menu-sub-content-z-index: 1050;
|
||||
}
|
||||
|
||||
div[data-slot='callout'] {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLE_CREATE: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
MEMBERS_SETTINGS: ['ADMIN'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
|
||||
Reference in New Issue
Block a user