Compare commits

...

3 Commits

Author SHA1 Message Date
Claude
6cdc5dbb74 docs(frontend): add frontend-testing skill for unit/integration tests
Adds a repo skill at .claude/skills/frontend-testing that codifies how
this codebase writes and optimizes frontend Jest + React Testing Library
tests, so generated/updated tests match existing conventions instead of
drifting.

It covers the unit-vs-integration split, the custom tests/test-utils
render and its provider overrides, MSW mocking via mocks-server, the nuqs
testing adapter, renderHook, data-testid-first querying, and the concrete
speed/flake do's and don'ts (userEvent delay:null, findBy over fixed
setTimeout sleeps, awaiting portals by role), plus the pnpm run/verify
commands. Complements the existing Playwright E2E agents in .claude/agents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvj24L5mcezPkxoBYew4xa
2026-07-31 18:27:40 +00:00
Claude
b66317d025 test(frontend): remove dead print-only test in generateColorPair
The "prints the base→dark table for visual inspection" test only emitted
console.log output (a debug/diagnostic dump) with a throwaway sentinel
assertion. It exercised no behaviour the sibling assertions don't already
cover, so it was dead weight in the suite. The real coverage — deterministic
pairing, palette bounds, darken uniqueness — remains intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvj24L5mcezPkxoBYew4xa
2026-07-31 18:27:29 +00:00
Vinicius Lourenço
159354a511 refactor(authz): ensure roles/service-accounts follow the guidelines (#12348)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(authz): add withPortal prop for button/tooltip

To be able to fix the z-index issue when
using drawer/modals of our lib

Without this, the tooltip is positioned on
the back of the modal

* fix(authz-tooltip): always forward the testId

* fix(create-service-account): ensure this modal follows the authz guideline

* fix(service-account-drawer): ensure tooltips has withPortal false

Otherwise, the tooltip can appear behind the modal/drawer

* fix(service-account-drawer): ensure it follows the authz guideline

* fix(roles-listing): use correct component to guard content

* fix(create-edit-role): ensure edit follows the authz guidelines

Should show header when no permission to read

* fix(view-role): ensure edit follows the authz guidelines

Should show header when no permission to read

* fix(pr): address comments

* fix(view-header): have a better name for this component
2026-07-31 15:13:01 +00:00
26 changed files with 1224 additions and 448 deletions

View File

@@ -0,0 +1,212 @@
---
name: frontend-testing
description: Guide for writing and updating SigNoz frontend unit and integration (component) tests with Jest + React Testing Library. Use whenever you are creating a `*.test.ts`/`*.test.tsx` file, adding coverage for a component, hook, util, or provider under `frontend/src`, mocking an API for a component test, or fixing/optimizing an existing frontend test. Covers the custom `tests/test-utils` render, the MSW mock server, the nuqs testing adapter, `renderHook`, data-testid conventions, and the speed/flake pitfalls specific to this codebase.
---
# SigNoz frontend testing
Write frontend tests the way this repo already does. **Match the nearest existing test** in the same folder before inventing a pattern — consistency beats cleverness. This guide encodes the conventions so a new test looks like it was always there.
Everything below is scoped to `frontend/` (package manager is **pnpm**; the E2E Playwright suite under `tests/e2e/` is a separate stack — see `docs/contributing/tests/e2e.md`, not this skill).
## The two kinds of test in this repo
| | **Unit** | **Integration (component)** |
|---|---|---|
| Target | pure functions, hooks, reducers, selectors | a component / container rendered with its providers |
| Render | none, or `renderHook` | the custom `render` from `tests/test-utils` |
| API | not involved | **MSW** mock server (`mocks-server/`) |
| Assert on | return value / state | rendered DOM + user interactions |
Both live beside the code they test, never in a separate top-level tree.
## Decision checklist (read before writing)
1. **Is there a sibling test?** `ls` the folder for `__tests__/`, `__test__/`, or `tests/` and a `*.test.tsx`. If one exists, copy its imports and structure. Folder naming is inconsistent across the repo — follow the folder you are in, don't rename it.
2. **Unit or integration?** Use the table above.
3. **Does it hit an API?** If yes, you need MSW (see [API mocking](#api-mocking-msw)). Never let a component fire a real request.
4. **Does it read/write the URL (`nuqs`)?** If yes, use the nuqs testing adapter (see [URL state](#url-state-nuqs)).
5. **What's the query?** Prefer `data-testid` (see [Queries](#queries--assertions)). If the component lacks one on a critical control, add it to the component — `frontend/CLAUDE.md` requires testids on inputs/buttons.
## File placement & naming
- Co-locate: `Foo.tsx``Foo.test.tsx` next to it, or under a `__tests__/` folder beside it. Match whatever the sibling files do.
- `testMatch` is `src/**/*(*.)test.(ts|js)?(x)` — the file **must** end in `.test.ts`/`.test.tsx`.
- Name integration specs that render a subtree `*.integration.test.tsx` when the folder already uses that suffix (e.g. `NewSelect/__test__/VariableItem.integration.test.tsx`); otherwise plain `.test.tsx`.
- Shared per-suite render/setup goes in a `_helpers.tsx` or `*.testUtils.tsx` in the same folder — not exported app-wide.
## Integration tests: the custom `render`
Import `render` (and `screen`, `userEvent`, matchers) from **`tests/test-utils`**, not from `@testing-library/react` directly. The custom render wraps the component in every provider the app needs — `MemoryRouter`, `NuqsAdapter`, react-query `QueryClientProvider`, a redux mock store, `AppContext`, `Timezone`, `QueryBuilderProvider`, etc. — so a container renders exactly as it does in the app.
```tsx
import { render, screen, userEvent } from 'tests/test-utils';
import MyPanel from '../MyPanel';
describe('MyPanel', () => {
it('renders the empty state when there is no data', () => {
render(<MyPanel />);
expect(screen.getByTestId('my-panel-empty')).toBeInTheDocument();
});
});
```
### Provider overrides (third argument)
`render(ui, options?, providerProps?)`. The third arg drives the providers:
```tsx
render(<RolesSettings />, undefined, {
role: 'VIEWER', // 'ADMIN' (default) | 'EDITOR' | 'VIEWER'
initialRoute: '/settings/roles?tab=members', // seeds MemoryRouter + URL
appContextOverrides: { // deep-merge over getAppContextMock(role)
featureFlags: [{ name: FeatureKeys.GATEWAY, active: false, usage: 0, usage_limit: -1, route: '' }],
},
queryBuilderOverrides: { /* Partial<QueryBuilderContextType> */ },
});
```
- Use `role` to test permission gating (`hasEditPermission` is derived: ADMIN/EDITOR ⇒ true).
- Use `appContextOverrides` for licence state, feature flags, user/org, preferences — see `getAppContextMock` in `tests/test-utils.tsx` for the full shape. Import `getAppContextMock` directly when you build a bespoke wrapper (as `ListAlertRules/__tests__/_helpers.tsx` does).
- Don't hand-roll a provider stack unless a component needs something the custom render can't express (e.g. `VirtuosoMockContext` for a virtualised list, or a bespoke provider). When you do, model it on `_helpers.tsx` and still reuse `getAppContextMock`.
### What's already handled for you (don't re-mock)
The global `jest.setup.ts` and `test-utils` already provide these — reusing them keeps tests fast and consistent:
- **`react-i18next`** — `t` returns the key verbatim. Assert on keys, not translated copy.
- **MSW server** — `server.listen()` / `resetHandlers()` / `close()` run around every test automatically.
- **`ResizeObserver`, `IntersectionObserver`, `matchMedia`, `scrollIntoView`, Pointer Capture** — stubbed for jsdom + Radix/Ant.
- **System time** pinned to `2023-10-20` (`test-utils` sets it in `beforeEach`). Override per-suite with `jest.setSystemTime(new Date('...'))` when a test needs a specific clock.
- Module mocks in `frontend/__mocks__/` and `src/__tests__/` (`safeNavigateMock`, `logEventMock`, icons, css, uplot, motion). Import the shared mock rather than writing your own:
```ts
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { logEventMock } from '__tests__/logEventMock';
```
## API mocking (MSW)
APIs are mocked with **MSW**, not by hand-mocking `axios` or react-query. The server is started globally; default handlers live in `src/mocks-server/handlers.ts` with fixtures in `src/mocks-server/__mockdata__/`.
**Prefer overriding a handler per-test** over `jest.mock`-ing the api module — it exercises the real react-query + api layer:
```tsx
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen } from 'tests/test-utils';
it('shows an error when the rules API 500s', async () => {
server.use(
rest.get('*/api/v1/rules', (_req, res, ctx) => res(ctx.status(500))),
);
render(<ListAlertRules />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
```
- Add a reusable happy-path handler + fixture to `handlers.ts` / `__mockdata__/` when several tests need it; use `server.use(...)` for the one-off/error case. `resetHandlers()` after each test wipes per-test overrides automatically.
- For an API call the component makes, prefer an MSW handler over mocking the generated hook. Reach for `jest.mock('api/...')` only when the module has no HTTP surface to intercept (pure client-side helper) or the sibling tests already mock it that way.
## URL state (nuqs)
Components built on `nuqs` must use the **testing adapter**, not the real one (which throttles writes to `window.history`). Use the helpers in `tests/nuqs-helpers.ts`:
- `resetNuqsState(initialSearch)` in setup, `onNuqsUrlUpdate` as the adapter's `onUrlUpdate`, and assert with `getCurrentNuqsQueryString()` / `getCurrentNuqsSearchParams()`.
- `window.location.search` is **not** authoritative in tests — the adapter keeps state in memory. Assert via the helpers.
- The default `render` already installs `NuqsAdapter`; when you build a custom wrapper, use `NuqsTestingAdapter` with `rateLimitFactor={0}` and `hasMemory` (see `_helpers.tsx`).
## Hook & util tests
```tsx
import { act, renderHook } from '@testing-library/react';
import { useThing } from '../useThing';
it('increments', () => {
const { result } = renderHook(() => useThing());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
```
- A hook that needs context/providers takes a `wrapper` — build a small local `createWrapper(props)` returning the minimal provider tree (see `QuerySearchV2` context tests). Don't pull in the full app render for a focused hook test.
- Pure utils: no render at all — call the function and assert. Keep these fast and exhaustive (edge cases, empty/undefined, boundaries).
## Queries & assertions
Locator priority (matches `frontend/CLAUDE.md`):
1. `getByTestId('...')` / `findByTestId` — **preferred**. If a critical control has no testid, add one to the component.
2. `getByRole('button', { name: /save/i })`
3. `getByLabelText`, `getByPlaceholderText`
4. `getByText` — last resort; brittle against copy edits (and i18n returns keys here).
Rules:
- **`findBy*` for anything async** (after a click that fires a request, or portal content). `getBy*` throws immediately — only for content already in the DOM.
- **`queryBy*` for absence**: `expect(screen.queryByRole('menu')).not.toBeInTheDocument()`.
- Radix/Ant menus and dialogs render in a **portal** — after opening, `await screen.findByRole('menu')`/`'dialog'`, then query items inside it.
- Use `@testing-library/jest-dom` matchers (`toBeInTheDocument`, `toBeDisabled`, `toHaveTextContent`) — they're globally available.
## Interactions
Prefer `userEvent` over `fireEvent` for real user flows. **Set it up with `delay: null`** so tests don't wait real time between keystrokes:
```tsx
const user = userEvent.setup({ delay: null });
await user.click(screen.getByTestId('open-menu'));
await user.type(screen.getByRole('textbox'), 'cpu');
```
`fireEvent` is fine for a single low-level event where `userEvent` is overkill.
## Performance & flake — do / don't
**Do**
- `userEvent.setup({ delay: null })` — the single biggest per-test speedup.
- `await expect(locator).toBeVisible()` / `await screen.findBy...` to wait on state.
- Reuse the shared render, MSW server, and `__mocks__/` — re-mocking heavy modules per file is slow.
- Keep pure logic in utils and unit-test it directly instead of asserting it through a full component render.
- Scope MSW overrides tightly and rely on the automatic `resetHandlers()`.
**Don't**
- ❌ `await new Promise(r => setTimeout(r, 500))` or `waitForTimeout` — poll with `findBy`/`waitFor` instead.
- ❌ `it.only` / `describe.only` / `fit` / `fdescribe` — CI and lint forbid `.only`; a stray one silently drops the rest of the suite.
- ❌ Leaving `it.skip`/`xit` behind — either fix and enable, or delete it and open an issue. A permanently-skipped test is dead coverage that reads as green.
- ❌ `console.log` in committed tests.
- ❌ Asserting on translated strings (i18n mock returns the key) or on real timers/dates (clock is pinned).
- ❌ Real network — every request must be an MSW handler.
## Running & verifying
From `frontend/`:
```bash
pnpm jest path/to/File.test.tsx # one file
pnpm jest -t "renders the empty state" # by test name
pnpm jest:watch # watch mode while iterating
pnpm test:changedsince # only tests changed vs main, with coverage
pnpm jest:coverage # full coverage run
```
Before calling a test task done (per `frontend/CLAUDE.md`):
```bash
pnpm tsgo --noEmit # types
pnpm oxlint <files> # lint the files you touched, fix all warnings
pnpm jest <files> # the tests pass
```
## Author checklist
- [ ] File ends in `.test.tsx`, co-located, matching the sibling folder's convention.
- [ ] Imports `render`/`screen`/`userEvent` from `tests/test-utils` (integration) or uses `renderHook` (hook).
- [ ] Every API call is an MSW handler; no real network, no ad-hoc axios mock.
- [ ] nuqs components use the testing adapter; URL asserted via `getCurrentNuqs*`.
- [ ] Queries use `data-testid` first; async waits use `findBy*`; portals awaited by role.
- [ ] `userEvent.setup({ delay: null })`; no `setTimeout`/`waitForTimeout`.
- [ ] No `.only`, no stray `.skip`, no `console.log`.
- [ ] Assertions cover success **and** the failure/empty/permission path.
- [ ] `pnpm tsgo --noEmit`, `pnpm oxlint`, and `pnpm jest` all green on the touched files.

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
@@ -20,6 +21,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import './CreateServiceAccountModal.styles.scss';
import { Skeleton } from 'antd';
interface FormValues {
name: string;
@@ -95,33 +97,39 @@ function CreateServiceAccountModal(): JSX.Element {
testId="create-service-account-modal"
>
<div className="create-sa-modal__content">
<form
id="create-sa-form"
className="create-sa-form"
onSubmit={handleSubmit(handleCreate)}
<AuthZGuardContent
checks={[SACreatePermission]}
fallbackOnLoading={<Skeleton active paragraph={{ rows: 1 }} />}
>
<div className="create-sa-form__item">
<label htmlFor="sa-name">Name</label>
<Controller
name="name"
control={control}
rules={{ required: 'Name is required' }}
render={({ field }): JSX.Element => (
<Input
id="sa-name"
placeholder="Enter a name"
className="create-sa-form__input"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
<form
id="create-sa-form"
className="create-sa-form"
onSubmit={handleSubmit(handleCreate)}
>
<div className="create-sa-form__item">
<label htmlFor="sa-name">Name</label>
<Controller
name="name"
control={control}
rules={{ required: 'Name is required' }}
render={({ field }): JSX.Element => (
<Input
id="sa-name"
placeholder="Enter a name"
className="create-sa-form__input"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
data-testid="create-sa-name-input"
/>
)}
/>
{errors.name && (
<p className="create-sa-form__error">{errors.name.message}</p>
)}
/>
{errors.name && (
<p className="create-sa-form__error">{errors.name.message}</p>
)}
</div>
</form>
</div>
</form>
</AuthZGuardContent>
</div>
<DialogFooter className="create-sa-modal__footer">
@@ -130,6 +138,7 @@ function CreateServiceAccountModal(): JSX.Element {
variant="solid"
color="secondary"
onClick={handleClose}
data-testid="create-sa-cancel-btn"
>
<X size={12} />
Cancel
@@ -137,12 +146,14 @@ function CreateServiceAccountModal(): JSX.Element {
<AuthZButton
checks={[SACreatePermission]}
withPortal={false}
type="submit"
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
data-testid="create-sa-submit-btn"
>
Create Service Account
</AuthZButton>

View File

@@ -1,19 +1,14 @@
import { toast } from '@signozhq/ui/sonner';
import {
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CreateServiceAccountModal from '../CreateServiceAccountModal';
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,
}: {
children: React.ReactElement;
}): React.ReactElement => children,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
@@ -45,6 +40,7 @@ describe('CreateServiceAccountModal', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
setupAuthzAdmin(),
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: {} })),
),
@@ -55,23 +51,41 @@ describe('CreateServiceAccountModal', () => {
server.resetHandlers();
});
it('submit button is disabled when form is empty', () => {
it('submit button is disabled while the form is empty', async () => {
renderModal();
expect(
screen.getByRole('button', { name: /Create Service Account/i }),
).toBeDisabled();
// The form only renders once the create check resolves, and the name field
// registers its `required` rule on mount, so the empty-form invalid state
// settles a tick later.
await screen.findByTestId('create-sa-name-input');
await waitFor(() =>
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled(),
);
});
it('submit button becomes disabled after clearing the name field', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = await screen.findByTestId('create-sa-name-input');
const submitBtn = await screen.findByTestId('create-sa-submit-btn');
await user.type(nameInput, 'test');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.clear(nameInput);
await waitFor(() => expect(submitBtn).toBeDisabled());
});
it('successful submit shows toast.success and closes modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await user.type(screen.getByPlaceholderText('Enter a name'), 'Deploy Bot');
const nameInput = await screen.findByTestId('create-sa-name-input');
await user.type(nameInput, 'Deploy Bot');
const submitBtn = screen.getByRole('button', {
name: /Create Service Account/i,
});
const submitBtn = screen.getByTestId('create-sa-submit-btn');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
@@ -102,11 +116,10 @@ describe('CreateServiceAccountModal', () => {
renderModal();
await user.type(screen.getByPlaceholderText('Enter a name'), 'Dupe Bot');
const nameInput = await screen.findByTestId('create-sa-name-input');
await user.type(nameInput, 'Dupe Bot');
const submitBtn = screen.getByRole('button', {
name: /Create Service Account/i,
});
const submitBtn = screen.getByTestId('create-sa-submit-btn');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
@@ -131,8 +144,45 @@ describe('CreateServiceAccountModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByTestId('create-service-account-modal');
await user.click(screen.getByRole('button', { name: /Cancel/i }));
const cancelBtn = await screen.findByTestId('create-sa-cancel-btn');
await user.click(cancelBtn);
await waitFor(() => {
expect(
screen.queryByTestId('create-service-account-modal'),
).not.toBeInTheDocument();
});
});
it('shows inline permission denial and hides the form when create permission is denied', async () => {
server.use(setupAuthzDenyAll());
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('create-sa-name-input')).not.toBeInTheDocument();
expect(
screen.getByTestId('create-service-account-modal'),
).toBeInTheDocument();
});
it('keeps the footer usable when create permission is denied', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(setupAuthzDenyAll());
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
// The footer lives outside the guard: submit is gated, Cancel still works.
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled();
await user.click(screen.getByTestId('create-sa-cancel-btn'));
await waitFor(() => {
expect(
@@ -145,7 +195,7 @@ describe('CreateServiceAccountModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = screen.getByPlaceholderText('Enter a name');
const nameInput = await screen.findByTestId('create-sa-name-input');
await user.type(nameInput, 'Bot');
await user.clear(nameInput);

View File

@@ -5,6 +5,7 @@ import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import {
APIKeyCreatePermission,
buildSAAttachPermission,
@@ -36,91 +37,104 @@ function KeyFormPhase({
onClose,
accountId,
}: KeyFormPhaseProps): JSX.Element {
const checks = accountId
? [APIKeyCreatePermission, buildSAAttachPermission(accountId)]
: [];
return (
<>
<form id={FORM_ID} className="add-key-modal__form" onSubmit={onSubmit}>
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="key-name">
Name <span style={{ color: 'var(--destructive)' }}>*</span>
</label>
<Input
id="key-name"
placeholder="Enter key name e.g.: Service Owner"
className="add-key-modal__input"
{...register('keyName', {
required: true,
validate: (v) => !!v.trim(),
})}
/>
</div>
<div className="add-key-modal__field">
<span className="add-key-modal__label">Expiration</span>
<Controller
name="expiryMode"
control={control}
render={({ field }): JSX.Element => (
<ToggleGroupSimple
type="single"
value={field.value}
onChange={(val: string): void => {
if (val) {
field.onChange(val);
}
}}
size="sm"
className="add-key-modal__expiry-toggle"
items={[
{ value: ExpiryMode.NONE, label: 'No Expiration' },
{ value: ExpiryMode.DATE, label: 'Set Expiration Date' },
]}
<AuthZGuardContent checks={checks}>
<>
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="key-name">
Name <span style={{ color: 'var(--destructive)' }}>*</span>
</label>
<Input
id="key-name"
placeholder="Enter key name e.g.: Service Owner"
className="add-key-modal__input"
testId="add-key-name-input"
{...register('keyName', {
required: true,
validate: (v) => !!v.trim(),
})}
/>
)}
/>
</div>
</div>
{expiryMode === ExpiryMode.DATE && (
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="expiry-date">
Expiration Date
</label>
<div className="add-key-modal__datepicker">
<div className="add-key-modal__field">
<span className="add-key-modal__label">Expiration</span>
<Controller
name="expiryDate"
name="expiryMode"
control={control}
render={({ field }): JSX.Element => (
<DatePicker
id="expiry-date"
<ToggleGroupSimple
type="single"
value={field.value}
onChange={field.onChange}
popupClassName="add-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
onChange={(val: string): void => {
if (val) {
field.onChange(val);
}
}}
size="sm"
className="add-key-modal__expiry-toggle"
items={[
{ value: ExpiryMode.NONE, label: 'No Expiration' },
{ value: ExpiryMode.DATE, label: 'Set Expiration Date' },
]}
/>
)}
/>
</div>
</div>
)}
{expiryMode === ExpiryMode.DATE && (
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="expiry-date">
Expiration Date
</label>
<div className="add-key-modal__datepicker">
<Controller
name="expiryDate"
control={control}
render={({ field }): JSX.Element => (
<DatePicker
id="expiry-date"
value={field.value}
onChange={field.onChange}
popupClassName="add-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
/>
)}
/>
</div>
</div>
)}
</>
</AuthZGuardContent>
</form>
<div className="add-key-modal__footer">
<div className="add-key-modal__footer-right">
<Button variant="solid" color="secondary" onClick={onClose}>
<Button
variant="solid"
color="secondary"
onClick={onClose}
testId="add-key-cancel-btn"
>
Cancel
</Button>
<AuthZButton
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(accountId ?? ''),
]}
checks={checks}
authZEnabled={!!accountId}
withPortal={false}
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
testId="add-key-submit-btn"
>
Create Key
</AuthZButton>

View File

@@ -92,6 +92,7 @@ function DeleteAccountModal(): JSX.Element {
loading={isDeleting}
onClick={handleConfirm}
data-testid="confirm-delete-btn"
withPortal={false}
>
<Trash2 size={12} />
Delete

View File

@@ -60,6 +60,7 @@ function EditKeyForm({
<AuthZTooltip
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
enabled={!!keyItem?.id}
withPortal={false}
>
<div className="edit-key-modal__key-display">
<span className="edit-key-modal__id-text">{keyItem?.name || '—'}</span>
@@ -168,6 +169,7 @@ function EditKeyForm({
variant="link"
color="destructive"
onClick={onRevokeClick}
withPortal={false}
>
<Trash2 size={12} />
Revoke Key
@@ -186,6 +188,7 @@ function EditKeyForm({
color="primary"
loading={isSaving}
disabled={!isDirty}
withPortal={false}
>
Save Changes
</AuthZButton>

View File

@@ -114,13 +114,14 @@ function buildColumns({
render: (_, record): JSX.Element => {
const tooltipTitle = isDisabled ? 'Service account disabled' : 'Revoke Key';
return (
<Tooltip title={tooltipTitle}>
<Tooltip title={tooltipTitle} placement="bottom">
<AuthZButton
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
authZEnabled={!isDisabled && !!accountId}
withPortal={false}
variant="ghost"
size="sm"
color="destructive"
@@ -214,6 +215,7 @@ function KeysTab({
<AuthZButton
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
authZEnabled={!isDisabled && !!accountId}
withPortal={false}
variant="link"
color="primary"
onClick={async (): Promise<void> => {

View File

@@ -90,14 +90,20 @@ function OverviewTab({
Name
</label>
{isDisabled ? (
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
<AuthZTooltip
checks={[buildSAUpdatePermission(account.id)]}
withPortal={false}
>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</AuthZTooltip>
) : (
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
<AuthZTooltip
checks={[buildSAUpdatePermission(account.id)]}
withPortal={false}
>
<Input
id="sa-name"
value={localName}

View File

@@ -55,6 +55,7 @@ export function RevokeKeyFooter({
color="destructive"
loading={isRevoking}
onClick={onConfirm}
withPortal={false}
>
<Trash2 size={12} />
Revoke Key

View File

@@ -38,6 +38,7 @@ import {
APIKeyCreatePermission,
buildSAAttachPermission,
buildSADeletePermission,
buildSAReadPermission,
buildSAUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import {
@@ -376,6 +377,7 @@ function ServiceAccountDrawer({
<AuthZButton
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
authZEnabled={!!selectedAccountId}
withPortal={false}
variant="link"
color="destructive"
onClick={(): void => {
@@ -391,8 +393,12 @@ function ServiceAccountDrawer({
Cancel
</Button>
<AuthZButton
checks={[buildSAUpdatePermission(selectedAccountId ?? '')]}
checks={[
buildSAReadPermission(selectedAccountId ?? ''),
buildSAUpdatePermission(selectedAccountId ?? ''),
]}
authZEnabled={!!selectedAccountId}
withPortal={false}
variant="solid"
color="primary"
loading={isSaving}
@@ -465,6 +471,7 @@ function ServiceAccountDrawer({
buildSAAttachPermission(selectedAccountId ?? ''),
]}
authZEnabled={!isDeleted && !!selectedAccountId}
withPortal={false}
variant="outlined"
size="sm"
color="secondary"

View File

@@ -1,5 +1,10 @@
import { toast } from '@signozhq/ui/sonner';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { buildSAAttachPermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import {
setupAuthzAdmin,
setupAuthzDeny,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -66,28 +71,29 @@ describe('AddKeyModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
expect(screen.getByRole('button', { name: /Create Key/i })).toBeDisabled();
// The form only renders once the checks resolve, so waiting for it also
// guarantees the button is no longer in its authz-loading state.
const nameInput = await screen.findByTestId('add-key-name-input');
const createBtn = await screen.findByTestId('add-key-submit-btn');
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'My Key');
expect(createBtn).toBeDisabled();
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
await user.type(nameInput, 'My Key');
await waitFor(() => expect(createBtn).not.toBeDisabled());
await user.clear(nameInput);
await waitFor(() => expect(createBtn).toBeDisabled());
});
it('successful creation transitions to phase 2 with key displayed and security callout', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
await user.click(screen.getByRole('button', { name: /Create Key/i }));
const nameInput = await screen.findByTestId('add-key-name-input');
const submitBtn = await screen.findByTestId('add-key-submit-btn');
await user.type(nameInput, 'Deploy Key');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
await screen.findByText('snz_abc123xyz456secret');
expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument();
@@ -99,13 +105,11 @@ describe('AddKeyModal', () => {
renderModal();
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
await user.click(screen.getByRole('button', { name: /Create Key/i }));
const nameInput = await screen.findByTestId('add-key-name-input');
const submitBtn = await screen.findByTestId('add-key-submit-btn');
await user.type(nameInput, 'Deploy Key');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
await screen.findByText('snz_abc123xyz456secret');
@@ -123,12 +127,57 @@ describe('AddKeyModal', () => {
});
});
it('shows inline permission denial and hides the form when key create is denied', async () => {
server.use(setupAuthzDenyAll());
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('add-key-name-input')).not.toBeInTheDocument();
expect(screen.getByTestId('add-key-modal')).toBeInTheDocument();
});
it('keeps the footer usable when key create is denied', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(setupAuthzDenyAll());
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
// The footer lives outside the guard: submit is gated, Cancel still works.
expect(screen.getByTestId('add-key-submit-btn')).toBeDisabled();
await user.click(screen.getByTestId('add-key-cancel-btn'));
await waitFor(() => {
expect(screen.queryByTestId('add-key-modal')).not.toBeInTheDocument();
});
});
it('shows inline permission denial when attach on the service account is denied', async () => {
server.use(setupAuthzDeny(buildSAAttachPermission('sa-1')));
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('add-key-name-input')).not.toBeInTheDocument();
});
it('Cancel button closes the modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByTestId('add-key-modal');
await user.click(screen.getByRole('button', { name: /Cancel/i }));
const cancelBtn = await screen.findByTestId('add-key-cancel-btn');
await user.click(cancelBtn);
await waitFor(() => {
expect(screen.queryByTestId('add-key-modal')).not.toBeInTheDocument();

View File

@@ -26,6 +26,7 @@ import { useCreateEditRolePageActions } from './useCreateEditRolePageActions';
import styles from './CreateEditRolePage.module.scss';
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
function authzCheckFn(
_props: object,
@@ -33,18 +34,11 @@ function authzCheckFn(
): BrandedPermission[] {
const match = router.matchPath<{ roleId: string }>(ROUTES.ROLE_DETAILS);
const roleId = match?.roleId ?? 'new';
const roleName = router.searchParams.get('name') ?? '';
const isCreateMode = roleId === 'new';
if (isCreateMode) {
return [RoleCreatePermission];
}
if (roleName) {
return [
buildRoleReadPermission(roleName),
buildRoleUpdatePermission(roleName),
];
}
return [];
}
@@ -149,7 +143,7 @@ function CreateEditRolePageContent(): JSX.Element {
);
}
if ((isLoading && !isCreateMode) || isFeatureGateLoading) {
if (isFeatureGateLoading) {
return (
<div className={styles.createEditRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
@@ -157,32 +151,18 @@ function CreateEditRolePageContent(): JSX.Element {
);
}
if (loadError) {
return (
<div
className={styles.createEditRolePage}
data-testid="create-edit-role-page"
>
<div className={styles.createEditRolePageHeader}>
<div className={styles.createEditRolePageHeaderLeft}>
<Button
variant="ghost"
color="secondary"
onClick={handleCancel}
disabled={isSaving}
data-testid="cancel-button"
className={styles.backButton}
>
<ArrowLeft size={16} />
</Button>
<Typography.Title level={3}>Failed to load role</Typography.Title>
</div>
</div>
const title = isCreateMode
? 'Create Role'
: `Role - ${
formData.name || (isLoading ? 'Loading role...' : 'Failed to load role')
}`;
<ErrorInPlace error={loadError} data-testid="role-load-error-banner" />
</div>
);
}
const canCheckRolePermissions = !isCreateMode && !!roleName;
const saveChecks = isCreateMode
? [RoleCreatePermission]
: [buildRoleReadPermission(roleName), buildRoleUpdatePermission(roleName)];
const isSaveCheckEnabled = isCreateMode || canCheckRolePermissions;
return (
<div
@@ -201,11 +181,7 @@ function CreateEditRolePageContent(): JSX.Element {
>
<ArrowLeft size={16} />
</Button>
<Typography.Title level={3}>
{isCreateMode
? 'Create Role'
: `Role - ${formData.name || 'Loading role...'}`}
</Typography.Title>
<Typography.Title level={3}>{title}</Typography.Title>
</div>
<div className={styles.createEditRolePageActions}>
@@ -218,11 +194,8 @@ function CreateEditRolePageContent(): JSX.Element {
</div>
)}
<AuthZButton
checks={
isCreateMode
? [RoleCreatePermission]
: [buildRoleUpdatePermission(roleName)]
}
checks={saveChecks}
authZEnabled={isSaveCheckEnabled}
variant="solid"
color="primary"
onClick={handleSaveAndNavigate}
@@ -235,61 +208,91 @@ function CreateEditRolePageContent(): JSX.Element {
</div>
</div>
{saveError && (
<ErrorInPlace
error={saveError}
height="auto"
data-testid="save-error-banner"
padding={0}
bordered={true}
className={styles.errorInPlaceContainer}
/>
)}
<div className={styles.createEditRolePageContent}>
<div className={styles.createEditRolePageForm}>
<div className={styles.formRow}>
{isCreateMode ? (
<div className={styles.formField}>
<label htmlFor="role-name" className={styles.formLabel}>
Name
</label>
<Input
id="role-name"
value={formData.name}
onChange={(e): void => handleFormChange('name', e.target.value)}
placeholder="my-custom-role"
data-testid="role-name-input"
/>
</div>
) : null}
<div className={styles.formField}>
<label htmlFor="role-description" className={styles.formLabel}>
Description
</label>
<Input
id="role-description"
value={formData.description}
onChange={(e): void => handleFormChange('description', e.target.value)}
placeholder="Custom role for the support team"
data-testid="role-description-input"
/>
</div>
<AuthZGuardContent
checks={canCheckRolePermissions ? [buildRoleReadPermission(roleName)] : []}
fallbackOnLoading={
<div className={styles.createEditRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
</div>
}
>
<>
{isLoading && (
<div className={styles.createEditRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
)}
<div className={styles.createEditRolePageDivider} />
{loadError && (
<ErrorInPlace
error={loadError}
height="auto"
data-testid="role-load-error-banner"
padding={0}
bordered={true}
className={styles.errorInPlaceContainer}
/>
)}
<PermissionEditor
resources={resources}
mode={editorMode}
onModeChange={setEditorMode}
onResourceChange={setResources}
onJsonValidityChange={setHasJsonError}
isLoading={isLoading}
validationErrors={validationErrors}
/>
</div>
{saveError && (
<ErrorInPlace
error={saveError}
height="auto"
data-testid="save-error-banner"
padding={0}
bordered={true}
className={styles.errorInPlaceContainer}
/>
)}
<div className={styles.createEditRolePageContent}>
<div className={styles.createEditRolePageForm}>
<div className={styles.formRow}>
{isCreateMode ? (
<div className={styles.formField}>
<label htmlFor="role-name" className={styles.formLabel}>
Name
</label>
<Input
id="role-name"
value={formData.name}
onChange={(e): void => handleFormChange('name', e.target.value)}
placeholder="my-custom-role"
data-testid="role-name-input"
/>
</div>
) : null}
<div className={styles.formField}>
<label htmlFor="role-description" className={styles.formLabel}>
Description
</label>
<Input
id="role-description"
value={formData.description}
onChange={(e): void =>
handleFormChange('description', e.target.value)
}
placeholder="Custom role for the support team"
data-testid="role-description-input"
/>
</div>
</div>
</div>
<div className={styles.createEditRolePageDivider} />
<PermissionEditor
resources={resources}
mode={editorMode}
onModeChange={setEditorMode}
onResourceChange={setResources}
onJsonValidityChange={setHasJsonError}
isLoading={isLoading}
validationErrors={validationErrors}
/>
</div>
</>
</AuthZGuardContent>
<ConfirmDialog
open={isBlocked}

View File

@@ -4,6 +4,7 @@ import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen } from 'tests/test-utils';
import {
AUTHZ_CHECK_URL,
setupAuthzAdmin,
setupAuthzDenyAll,
setupAuthzDeny,
@@ -57,24 +58,80 @@ function renderEditPage(): ReturnType<typeof render> {
describe('EditRolePage - AuthZ', () => {
describe('permission denied', () => {
it('shows PermissionDeniedFullPage when read permission denied', async () => {
it('shows PermissionDeniedCallout when read permission denied', async () => {
server.use(setupAuthzDenyAll());
renderEditPage();
await expect(
screen.findByText(/You are not authorized/i),
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(
screen.queryByText('Uh-oh! You are not authorized'),
).not.toBeInTheDocument();
});
it('shows PermissionDeniedFullPage when update permission denied but read granted', async () => {
it('keeps the header visible and hides the form when read permission denied', async () => {
server.use(setupAuthzDenyAll());
renderEditPage();
await screen.findByText(/is not authorized to perform/i);
await expect(
screen.findByText(`Role - ${EDIT_ROLE_NAME}`),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
expect(screen.getByTestId('save-button')).toBeDisabled();
expect(
screen.queryByTestId('role-description-input'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('permission-editor')).not.toBeInTheDocument();
});
it('renders page with disabled save button when update permission denied but read granted', async () => {
server.use(setupAuthzDeny(buildRoleUpdatePermission(EDIT_ROLE_NAME)));
renderEditPage();
await expect(
screen.findByText(/You are not authorized/i),
screen.findByText(`Role - ${EDIT_ROLE_NAME}`),
).resolves.toBeInTheDocument();
const saveButton = await screen.findByTestId('save-button');
expect(saveButton).toBeDisabled();
});
});
describe('route without the name query param', () => {
// `roleName` is the permission selector. When it is missing we must skip the
// check entirely — building `role:` widens to `role:*` on the wire and never
// matches back, which would deny the form even for an admin.
it('renders the form instead of denying it', async () => {
server.use(setupAuthzAdmin());
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}` },
);
await expect(
screen.findByTestId('role-description-input'),
).resolves.toBeInTheDocument();
expect(
screen.queryByText(/is not authorized to perform/i),
).not.toBeInTheDocument();
});
});
@@ -96,6 +153,23 @@ describe('EditRolePage - AuthZ', () => {
});
});
describe('permission check failure', () => {
it('renders the form when the permission check request fails', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.status(500))),
);
renderEditPage();
await expect(
screen.findByTestId('role-description-input'),
).resolves.toBeInTheDocument();
expect(
screen.queryByText(/is not authorized to perform/i),
).not.toBeInTheDocument();
});
});
describe('permission granted', () => {
it('renders edit page when both read and update permissions granted', async () => {
server.use(setupAuthzAdmin());

View File

@@ -71,6 +71,20 @@ describe('EditRolePage', () => {
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('shows loading title in header while fetching role data', async () => {
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.delay(200), ctx.status(200), ctx.json(roleWithTransactionGroups)),
),
);
renderEditPage();
await expect(
screen.findByText('Role - Loading role...'),
).resolves.toBeInTheDocument();
});
});
describe('load error state', () => {
@@ -83,12 +97,12 @@ describe('EditRolePage', () => {
renderEditPage();
await waitFor(() => {
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
await expect(
screen.findByTestId('role-load-error-banner'),
).resolves.toBeInTheDocument();
});
it('shows Failed to load role title on load error', async () => {
it('shows failed to load title on load error', async () => {
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(404), ctx.json({ error: { message: 'Not found' } })),
@@ -98,7 +112,7 @@ describe('EditRolePage', () => {
renderEditPage();
await expect(
screen.findByText('Failed to load role'),
screen.findByText('Role - Failed to load role'),
).resolves.toBeInTheDocument();
});

View File

@@ -8,7 +8,7 @@ import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ROUTES from 'constants/routes';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import useUrlQuery from 'hooks/useUrlQuery';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useTimezone } from 'providers/Timezone';
@@ -267,7 +267,7 @@ function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
);
}
export default withAuthZPage<RolesListContentProps>(RolesListContent, {
export default withAuthZContent<RolesListContentProps>(RolesListContent, {
checks: [RoleListPermission],
fallbackOnLoading: (
<div className={styles.rolesListingTable}>

View File

@@ -0,0 +1,81 @@
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
AUTHZ_CHECK_URL,
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { render, screen } from 'tests/test-utils';
import RolesListingTable from '../RolesListingTable';
const rolesApiBase = '*/api/v1/roles';
beforeEach(() => {
server.use(
rest.get(rolesApiBase, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
});
afterEach(() => {
server.resetHandlers();
});
function renderTable(): ReturnType<typeof render> {
return render(<RolesListingTable searchQuery="" />, undefined, {
initialRoute: '/settings/roles',
});
}
describe('RolesListingTable - AuthZ', () => {
describe('permission granted', () => {
it('renders the roles table when list permission granted', async () => {
server.use(setupAuthzAdmin());
renderTable();
await expect(
screen.findByText('billing-manager'),
).resolves.toBeInTheDocument();
});
});
describe('permission denied', () => {
it('shows inline permission denial instead of the table when list permission denied', async () => {
server.use(setupAuthzDenyAll());
renderTable();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.queryByText('billing-manager')).not.toBeInTheDocument();
// denial is inline, not a full page takeover
expect(
screen.queryByText('Uh-oh! You are not authorized'),
).not.toBeInTheDocument();
});
});
describe('permission check failure', () => {
it('renders the roles table when the permission check request fails', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.status(500))),
);
renderTable();
await expect(
screen.findByText('billing-manager'),
).resolves.toBeInTheDocument();
expect(
screen.queryByText(/is not authorized to perform/i),
).not.toBeInTheDocument();
});
});
});

View File

@@ -5,22 +5,15 @@ import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Tabs } from '@signozhq/ui/tabs';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { useGetRole } from 'api/generated/services/role';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { useDeleteRoleModal } from 'container/RolesSettings/DeleteRoleModal/useDeleteRoleModal';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { transformApiToRolePermissions } from 'container/RolesSettings/hooks/useRolePermissions';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { RouterContext } from 'lib/authz/components/withAuthZ/withAuthZ';
import {
buildRoleDeletePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import { buildRoleReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { RoleType } from 'types/roles';
@@ -32,43 +25,35 @@ import ReadOnlyJsonViewer from './ReadOnlyJsonViewer';
import { useViewRolePageActions } from './useViewRolePageActions';
import styles from './ViewRolePage.module.scss';
import { ViewRolePageHeaderActions } from 'container/RolesSettings/ViewRolePage/ViewRolePageHeaderActions';
function ViewRolePageContent(): JSX.Element {
interface ViewRoleContentProps {
roleId: string;
roleName: string;
viewMode: 'list' | 'json';
expandedResources: Set<string>;
setExpandedResources: (resources: Set<string>) => void;
handleModeChange: (value: string) => void;
handleTabChange: (key: string) => void;
activeTab: 'overview';
}
function ViewRoleContentInner({
roleId,
viewMode,
expandedResources,
setExpandedResources,
handleModeChange,
handleTabChange,
activeTab,
}: ViewRoleContentProps): JSX.Element {
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
const { isRolesEnabled, isLoading: isFeatureGateLoading } =
useRolesFeatureGate();
const {
roleId,
roleName,
activeTab,
viewMode,
expandedResources,
setExpandedResources,
handleRedirectToUpdate,
handleCancel,
handleModeChange,
handleTabChange,
} = useViewRolePageActions();
const { data, isLoading, error } = useGetRole(
{ id: roleId ?? '' },
{ id: roleId },
{ query: { enabled: !!roleId } },
);
const role = data?.data;
const isManaged = role?.type === RoleType.MANAGED;
const {
isDeleteModalOpen,
deleteError,
handleOpenDeleteModal,
handleCloseDeleteModal,
handleConfirmDelete,
} = useDeleteRoleModal({
roleId,
isManaged: isManaged ?? false,
onDeleteSuccess: handleCancel,
});
const tabItems = useMemo(
() => [
@@ -116,7 +101,7 @@ function ViewRolePageContent(): JSX.Element {
<div className={styles.permissionContent}>
{viewMode === 'list' ? (
<PermissionOverview
roleId={roleId ?? ''}
roleId={roleId}
expandedResources={expandedResources}
onExpandedResourcesChange={setExpandedResources}
/>
@@ -138,6 +123,109 @@ function ViewRolePageContent(): JSX.Element {
],
);
if (isLoading) {
return <Skeleton active paragraph={{ rows: 6 }} />;
}
if (error) {
return (
<ErrorInPlace
error={toAPIError(error, 'Failed to load role details')}
data-testid="role-error-banner"
/>
);
}
if (!role) {
return <></>;
}
return (
<div className={styles.viewRolePageContent}>
<div className={styles.viewRolePageForm}>
<div className={styles.formField}>
<label htmlFor="role-description" className={styles.formLabel}>
Description
</label>
<Typography>{role.description}</Typography>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<label htmlFor="role-created-at" className={styles.formLabel}>
Created At
</label>
<Badge color="secondary">
{formatTimezoneAdjustedTimestampOptional(role.createdAt)}
</Badge>
</div>
<div className={styles.formField}>
<label htmlFor="role-modified-at" className={styles.formLabel}>
Last Modified At
</label>
<Badge color="secondary">
{formatTimezoneAdjustedTimestampOptional(role.updatedAt)}
</Badge>
</div>
</div>
</div>
<Divider />
<Tabs
className={styles.roleTabs}
value={activeTab}
onChange={handleTabChange}
items={tabItems}
/>
</div>
);
}
const ViewRoleContent = withAuthZContent<ViewRoleContentProps>(
ViewRoleContentInner,
{
checks: (props: ViewRoleContentProps) =>
props.roleName ? [buildRoleReadPermission(props.roleName)] : [],
fallbackOnLoading: <Skeleton active paragraph={{ rows: 6 }} />,
},
);
function ViewRolePage(): JSX.Element {
const { isRolesEnabled, isLoading: isFeatureGateLoading } =
useRolesFeatureGate();
const {
roleId,
roleName,
activeTab,
viewMode,
expandedResources,
setExpandedResources,
handleRedirectToUpdate,
handleCancel,
handleModeChange,
handleTabChange,
} = useViewRolePageActions();
const { data, isLoading: isRoleLoading } = useGetRole(
{ id: roleId ?? '' },
{ query: { enabled: !!roleId } },
);
const role = data?.data;
const isManaged = role?.type === RoleType.MANAGED;
const {
isDeleteModalOpen,
deleteError,
handleOpenDeleteModal,
handleCloseDeleteModal,
handleConfirmDelete,
} = useDeleteRoleModal({
roleId,
isManaged: isManaged ?? false,
onDeleteSuccess: handleCancel,
});
if (!isRolesEnabled && !isFeatureGateLoading) {
return (
<div className={styles.viewRolePage} data-testid="view-role-page">
@@ -175,7 +263,7 @@ function ViewRolePageContent(): JSX.Element {
);
}
if (isLoading || isFeatureGateLoading) {
if (isFeatureGateLoading) {
return (
<div className={styles.viewRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
@@ -183,36 +271,6 @@ function ViewRolePageContent(): JSX.Element {
);
}
if (error) {
return (
<div className={styles.viewRolePage} data-testid="view-role-page">
<div className={styles.viewRolePageHeader}>
<div className={styles.viewRolePageHeaderLeft}>
<Button
variant="ghost"
color="secondary"
onClick={handleCancel}
data-testid="cancel-button"
className={styles.backButton}
>
<ArrowLeft size={16} />
</Button>
<Typography.Title level={3}>Failed to load role</Typography.Title>
</div>
</div>
<ErrorInPlace
error={toAPIError(error, 'Failed to load role details')}
data-testid="role-error-banner"
/>
</div>
);
}
if (!role) {
return <></>;
}
return (
<div className={styles.viewRolePage} data-testid="view-role-page">
<div className={styles.viewRolePageHeader}>
@@ -227,103 +285,35 @@ function ViewRolePageContent(): JSX.Element {
<ArrowLeft size={16} />
</Button>
<Typography.Title level={3}>
{'Role - ' + role.name || 'Loading role...'}
{'Role - ' + (roleName || 'Loading role...')}
</Typography.Title>
</div>
<div className={styles.viewRolePageActions}>
{isManaged ? (
<TooltipSimple title="Managed roles cannot be deleted">
<Button
variant="link"
color="destructive"
disabled
data-testid="delete-button"
className={styles.deleteButton}
>
Delete
</Button>
</TooltipSimple>
) : (
<AuthZButton
checks={[buildRoleDeletePermission(roleName)]}
variant="link"
color="destructive"
onClick={handleOpenDeleteModal}
data-testid="delete-button"
className={styles.deleteButton}
>
Delete
</AuthZButton>
)}
<Divider type="vertical" />
{isManaged ? (
<TooltipSimple title="Managed roles cannot be updated">
<Button
variant="solid"
color="primary"
disabled
data-testid="save-button"
>
Update
</Button>
</TooltipSimple>
) : (
<AuthZButton
checks={[buildRoleUpdatePermission(roleName)]}
variant="solid"
color="primary"
data-testid="save-button"
onClick={handleRedirectToUpdate}
>
Update
</AuthZButton>
)}
</div>
</div>
<div className={styles.viewRolePageContent}>
<div className={styles.viewRolePageForm}>
<div className={styles.formField}>
<label htmlFor="role-description" className={styles.formLabel}>
Description
</label>
<Typography>{role.description}</Typography>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<label htmlFor="role-created-at" className={styles.formLabel}>
Created At
</label>
<Badge color="secondary">
{formatTimezoneAdjustedTimestampOptional(role.createdAt)}
</Badge>
</div>
<div className={styles.formField}>
<label htmlFor="role-modified-at" className={styles.formLabel}>
Last Modified At
</label>
<Badge color="secondary">
{formatTimezoneAdjustedTimestampOptional(role.updatedAt)}
</Badge>
</div>
</div>
</div>
<Divider />
<Tabs
className={styles.roleTabs}
value={activeTab}
onChange={handleTabChange}
items={tabItems}
<ViewRolePageHeaderActions
isRoleLoading={isRoleLoading}
isManaged={isManaged}
roleName={roleName}
handleOpenDeleteModal={handleOpenDeleteModal}
handleRedirectToUpdate={handleRedirectToUpdate}
/>
</div>
{roleId && (
<ViewRoleContent
roleId={roleId}
roleName={roleName}
viewMode={viewMode}
expandedResources={expandedResources}
setExpandedResources={setExpandedResources}
handleModeChange={handleModeChange}
handleTabChange={handleTabChange}
activeTab={activeTab}
/>
)}
<DeleteRoleModal
isOpen={isDeleteModalOpen}
roleName={role.name}
roleName={roleName}
error={deleteError}
onCancel={handleCloseDeleteModal}
onConfirm={handleConfirmDelete}
@@ -332,14 +322,4 @@ function ViewRolePageContent(): JSX.Element {
);
}
export default withAuthZPage(ViewRolePageContent, {
checks: (_props: object, router: RouterContext) => {
const roleName = router.searchParams.get('name') ?? '';
return roleName ? [buildRoleReadPermission(roleName)] : [];
},
fallbackOnLoading: (
<div className={styles.viewRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
),
});
export default ViewRolePage;

View File

@@ -0,0 +1,114 @@
import styles from 'container/RolesSettings/ViewRolePage/ViewRolePage.module.scss';
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import {
buildRoleDeletePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { TooltipSimple } from '@signozhq/ui/tooltip';
export function ViewRolePageHeaderActions({
isRoleLoading,
isManaged,
roleName,
handleOpenDeleteModal,
handleRedirectToUpdate,
}: {
isRoleLoading: boolean;
isManaged: boolean;
roleName: string;
handleOpenDeleteModal: () => void;
handleRedirectToUpdate: () => void;
}): JSX.Element {
const renderDeleteButton = (): JSX.Element => {
if (isRoleLoading) {
return (
<Button
variant="link"
color="destructive"
disabled
data-testid="delete-button"
className={styles.deleteButton}
>
Delete
</Button>
);
}
if (isManaged) {
return (
<TooltipSimple title="Managed roles cannot be deleted">
<Button
variant="link"
color="destructive"
disabled
data-testid="delete-button"
className={styles.deleteButton}
>
Delete
</Button>
</TooltipSimple>
);
}
return (
<AuthZButton
checks={[buildRoleDeletePermission(roleName)]}
authZEnabled={!!roleName}
variant="link"
color="destructive"
onClick={handleOpenDeleteModal}
data-testid="delete-button"
className={styles.deleteButton}
>
Delete
</AuthZButton>
);
};
const renderUpdateButton = (): JSX.Element => {
if (isRoleLoading) {
return (
<Button variant="solid" color="primary" disabled data-testid="save-button">
Update
</Button>
);
}
if (isManaged) {
return (
<TooltipSimple title="Managed roles cannot be updated">
<Button variant="solid" color="primary" disabled data-testid="save-button">
Update
</Button>
</TooltipSimple>
);
}
return (
<AuthZButton
checks={[
buildRoleReadPermission(roleName),
buildRoleUpdatePermission(roleName),
]}
authZEnabled={!!roleName}
variant="solid"
color="primary"
data-testid="save-button"
onClick={handleRedirectToUpdate}
>
Update
</AuthZButton>
);
};
return (
<div className={styles.viewRolePageActions}>
{renderDeleteButton()}
<Divider type="vertical" />
{renderUpdateButton()}
</div>
);
}

View File

@@ -34,7 +34,7 @@ describe('ViewRolePage - AuthZ', () => {
});
describe('permission denied', () => {
it('shows permission denied page when read permission denied', async () => {
it('shows inline permission denial when read permission denied but keeps header visible', async () => {
server.use(setupAuthzDenyAll());
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
@@ -49,8 +49,144 @@ describe('ViewRolePage - AuthZ', () => {
});
await expect(
screen.findByText(/You are not authorized/i),
screen.findByTestId('view-role-page'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
expect(
screen.queryByText('Uh-oh! You are not authorized'),
).not.toBeInTheDocument();
});
it('hides the role content when read permission denied', async () => {
server.use(setupAuthzDenyAll());
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(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await screen.findByText(/is not authorized to perform/i);
expect(screen.queryByTestId('permission-view-mode')).not.toBeInTheDocument();
expect(screen.queryByText('Description')).not.toBeInTheDocument();
});
});
describe('route without the name query param', () => {
// `roleName` is the permission selector. When it is missing we must skip the
// check entirely — building `role:` widens to `role:*` on the wire and never
// matches back, which would deny the content even for an admin.
it('renders the role content instead of denying it', async () => {
server.use(setupAuthzAdmin());
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(<ViewRolePage />, undefined, {
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
});
await expect(
screen.findByTestId('permission-view-mode'),
).resolves.toBeInTheDocument();
expect(
screen.queryByText(/is not authorized to perform/i),
).not.toBeInTheDocument();
});
it('leaves the action buttons enabled instead of gating them on an empty selector', async () => {
server.use(setupAuthzAdmin());
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{ initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}` },
);
await waitFor(() => {
expect(screen.getByTestId('delete-button')).not.toBeDisabled();
expect(screen.getByTestId('save-button')).not.toBeDisabled();
});
});
});
describe('permission check failure', () => {
it('renders the role content when the permission check request fails', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.status(500))),
);
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(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('permission-view-mode'),
).resolves.toBeInTheDocument();
expect(
screen.queryByText(/is not authorized to perform/i),
).not.toBeInTheDocument();
});
});

View File

@@ -78,8 +78,9 @@ describe('ViewRolePage - Edge Cases', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
// Wait for content to render (not just page wrapper)
await expect(
screen.findByTestId('view-role-page'),
screen.findByTestId('permission-view-mode'),
).resolves.toBeInTheDocument();
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);
@@ -111,8 +112,9 @@ describe('ViewRolePage - Edge Cases', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
// Wait for content to render (not just page wrapper)
await expect(
screen.findByTestId('view-role-page'),
screen.findByTestId('permission-view-mode'),
).resolves.toBeInTheDocument();
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);

View File

@@ -4,7 +4,7 @@ import * as roleApi from 'api/generated/services/role';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import { render, screen } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
import ViewRolePage from '../ViewRolePage';
@@ -45,12 +45,12 @@ describe('ViewRolePage - Error State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitFor(() => {
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
await expect(
screen.findByTestId('role-error-banner'),
).resolves.toBeInTheDocument();
});
it('displays error state with title when API fails without role data', async () => {
it('displays error state when API fails without role data', async () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
@@ -62,12 +62,10 @@ describe('ViewRolePage - Error State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
// Error shows in content area after authz check passes
await expect(
screen.findByText('Failed to load role'),
screen.findByTestId('role-error-banner'),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
});
it('shows back button on error state', async () => {

View File

@@ -1,7 +1,7 @@
import * as roleApi from 'api/generated/services/role';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { render } from 'tests/test-utils';
import { render, screen } from 'tests/test-utils';
import ViewRolePage from '../ViewRolePage';
@@ -36,6 +36,24 @@ describe('ViewRolePage - Loading State', () => {
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('keeps the header visible with delete disabled while fetching role', async () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('delete-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
it('does not fetch when roleId is missing from URL', () => {
const getRole = jest.spyOn(roleApi, 'useGetRole');

View File

@@ -18,8 +18,9 @@ import {
} from './testUtils';
async function waitForPageReady(): Promise<void> {
// Wait for content to render (after authz check passes)
await expect(
screen.findByTestId('view-role-page'),
screen.findByTestId('permission-view-mode'),
).resolves.toBeInTheDocument();
}

View File

@@ -15,12 +15,18 @@ export type AuthZButtonProps = ButtonProps & {
* Gate the permission check itself. When false, renders a plain button.
*/
authZEnabled?: boolean;
/**
* Set this false when this button is used inside a modal/drawer of signozhq/ui,
* otherwise the tooltip will not have the correct z-index
*/
withPortal?: false;
};
function AuthZButton({
checks,
tooltipMessage,
authZEnabled = true,
withPortal,
...buttonProps
}: AuthZButtonProps): JSX.Element {
return (
@@ -28,6 +34,7 @@ function AuthZButton({
checks={checks}
enabled={authZEnabled}
tooltipMessage={tooltipMessage}
withPortal={withPortal}
>
<Button {...buttonProps} />
</AuthZTooltip>

View File

@@ -1,8 +1,8 @@
import { CSSProperties, ReactElement, cloneElement, useMemo } from 'react';
import { cloneElement, CSSProperties, ReactElement, useMemo } from 'react';
import {
TooltipRoot,
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
@@ -23,6 +23,11 @@ interface AuthZTooltipProps {
children: ReactElement;
enabled?: boolean;
tooltipMessage?: string;
/**
* Set this false when this button is used inside a modal/drawer of signozhq/ui,
* otherwise the tooltip will not have the correct z-index
*/
withPortal?: false;
}
function formatDeniedMessage(
@@ -42,6 +47,7 @@ function AuthZTooltip({
children,
enabled = true,
tooltipMessage,
withPortal,
}: AuthZTooltipProps): JSX.Element {
const { user } = useAppContext();
const shouldCheck = enabled && checks.length > 0;
@@ -69,10 +75,12 @@ function AuthZTooltip({
return children;
}
const childTestId = (children.props as { testId?: string }).testId;
return (
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
<TooltipTrigger asChild testId={childTestId}>
{cloneElement(children, {
disabled: true,
style: DISABLED_STYLE,
@@ -82,7 +90,7 @@ function AuthZTooltip({
'data-denied-permissions': deniedPermissions.join(','),
})}
</TooltipTrigger>
<TooltipContent className={styles.errorContent}>
<TooltipContent className={styles.errorContent} withPortal={withPortal}>
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
</TooltipContent>
</TooltipRoot>

View File

@@ -107,20 +107,4 @@ describe('PALETTE_V3 darken-pair table', () => {
const unique = new Set(darks);
expect(unique.size).toBe(PALETTE_V3.length);
});
it('prints the base→dark table for visual inspection', () => {
// eslint-disable-next-line no-console
console.log('\nPALETTE_V3 base → darkenHex(0.22) pairs:');
// eslint-disable-next-line no-console
console.log('idx name base dark');
PALETTE_V3.forEach((hex, i) => {
const dark = darkenHex(hex, 0.22);
const name = (PALETTE_NAMES[i] ?? '').padEnd(13);
const idx = String(i).padStart(2, ' ');
// eslint-disable-next-line no-console
console.log(`${idx} ${name} ${hex} ${dark}`);
});
// Sentinel assertion so the test is not flagged as having none.
expect(PALETTE_V3).toHaveLength(28);
});
});