Compare commits

...

29 Commits

Author SHA1 Message Date
Abhi Kumar
b1e9b935c5 chore(dashboards-v2): tidy panel-editor query helpers
- useLegendSeries: drop redundant optional chaining on panel.spec.
- usePanelTypeSwitch: default null queries to [] for getBuilderQueries.
- Remove the unused getPanelKindLabel util.
2026-06-26 13:58:41 +05:30
Abhi Kumar
d4849984da refactor(dashboards-v2): rename Formatting section to "Formatting & Units"
Serialize section header test ids (lowercase, spaces → dashes) so a
multi-word title doesn't break the data-testid, and update the test.
2026-06-26 13:58:00 +05:30
Abhi Kumar
1e841949ab feat(dashboards-v2): redesign span-gaps as a "Disconnect values" control
Replace the raw seconds input with a Never/Threshold toggle plus a
duration "Threshold value" field. The threshold is stored verbatim as a
duration string ("10m", "5s") — the wire format the backend expects — and
parsed back to seconds only for rendering and validation. Threads the
query step interval through the config pane to seed/floor the threshold,
and rejects invalid or below-step-interval entries inline (V1 parity).
2026-06-26 13:54:13 +05:30
Abhi Kumar
478b4b3cd0 fix(dashboards-v2): allow clearing the threshold value input
The threshold "Value" field was a controlled numeric input, so an
emptied field snapped back to 0 (Number("") is 0, not NaN) and the
seeded 0 could never be removed. Hold a local string so the field can
be cleared and edited; shared by all threshold row variants.
2026-06-26 13:45:55 +05:30
Abhi Kumar
811c7bba90 feat(dashboards-v2): add "Move out of section" panel action
The "Move to section" submenu only listed titled sections, so a panel
in a titled section couldn't be moved back to the untitled root. Add a
direct "Move out of section" action, shown when the panel sits in a
titled section and an untitled root section exists to receive it.
2026-06-26 13:45:41 +05:30
Abhi Kumar
5a5c10b333 fix(dashboards-v2): place toolbar-created panels in the root section
The top-right "New Panel" button creates a panel with no section context,
which createPanelOps resolved to the LAST section instead of the root.
Fall back to the first (root) section when no valid index is given; still
create an untitled section when the dashboard has none.
2026-06-26 13:45:26 +05:30
Abhi Kumar
ed8c8046f4 fix(dashboards-v2): disable panel types unsupported by the datasource
A new panel's builder is seeded with the kind's default signal, but
`spec.queries` stays empty until the query is modified — so the type
switcher saw an undefined datasource and never disabled incompatible
types (e.g. List on a metrics panel, which then breaks rendering).
Resolve the signal with a fallback to the kind's default signal so
compatibility is enforced from the first render.
2026-06-26 13:44:56 +05:30
Abhi Kumar
ec88a1560a refactor(dashboards-v2): let consumers control panel-type icon rendering
Store icon component references in PANEL_TYPES instead of pre-rendered
elements, so each consumer (selection modal, type switcher) decides the
icon size and other props at the render site. Renames constants.tsx ->
constants.ts now that it holds no JSX.
2026-06-26 03:58:53 +05:30
Abhi Kumar
27b73064c2 feat(dashboards-v2): reuse findFreeSlot for panel clone placement
Clone now places the copy beside the last row when it fits, else wraps
to a fresh row at the section bottom — matching the new-panel save path
instead of always starting a new row.
2026-06-26 03:44:26 +05:30
Abhi Kumar
55569c5986 feat(dashboards-v2): switch panel visualization type in the editor
Wire the panel-type switcher into the config pane: every kind now declares a
`visualization` section (with `switchPanelKind`) so the switcher is reachable
from any panel, VisualizationSection renders it, and ConfigPane threads the
current kind/signal and an onChange down through SectionSlot. The editor drives
the in-place kind switch via usePanelTypeSwitch, so switching is reversible
within an edit session and a first visit rebuilds the query + resets the spec
(carrying unit/decimals).
2026-06-26 01:41:45 +05:30
Abhi Kumar
9d9d6c9b93 feat(dashboards-v2): add the PanelTypeSwitcher control
A presentational visualization-type selector (built on ConfigSelect) that lists
the panel kinds and disables types whose supported signals exclude the panel's
current datasource (V1 parity — e.g. List needs logs/traces, not metrics). Rename
the Value option label to Number so the switcher (and the type-selection modal)
read consistently. Not yet rendered anywhere.
2026-06-26 01:41:45 +05:30
Abhi Kumar
2fe2744340 feat(dashboards-v2): add panel-type switch logic
Two building blocks for switching a panel's kind in place:

- getSwitchedPluginSpec: on a first-time switch to a new kind, rebuild the
  plugin spec from the kind's defaults, carrying over unit/decimals (and seeding
  List columns from the signal).
- usePanelTypeSwitch: a per-kind session cache that makes switches reversible
  (Table -> List -> Table restores the original query + spec) and rebuilds the
  query for the new panel type on a first visit.

Both are standalone and not yet wired into the editor.
2026-06-26 01:41:45 +05:30
Abhi Kumar
c512fbafc9 refactor(dashboards-v2): extend ConfigSelect for the type switcher
Add per-option `disabled` and let `icon` be an arbitrary `ReactNode` (not just
a `SegmentIconName`) so the upcoming panel-type switcher can disable unsupported
types and render panel-type glyphs. Also widen the ConfigSegmented group to full
width so segmented controls fill the config pane.
2026-06-26 01:41:45 +05:30
Abhi Kumar
aabf1fb18c chore: pr review changes 2026-06-26 01:41:45 +05:30
Abhi Kumar
757da5cd57 refactor(dashboards-v2): type panel signals as TelemetrytypesSignalDTO
Replace the query-builder `DataSource` enum with `TelemetrytypesSignalDTO`
across the panel signal plumbing — the panel definitions' `supportedSignals`,
the List columns editor/suggestions/defaults, and the editor query-sync and
column-seeding hooks — and make the signal non-optional where a panel always
resolves one. Drops the now-dead `DataSource` imports.
2026-06-26 01:41:42 +05:30
Abhi Kumar
b4fc02f18e refactor(dashboards-v2): address PR review feedback
- getPanelDefinition: make PanelRegistry total over PanelKind so it never
  returns undefined (a missing kind is now a compile error)
- toQueryEnvelopes/hasRunnableQueries: accept `... | null` and drop the
  `?? []` at call sites
- consolidate panel loading to a single `isFetching` signal (Panel,
  PreviewPane, PanelEditor)
- rename defaultDataSource -> signal; use TelemetrytypesSignalDTO enum for
  the List default columns
- useCreatePanel now owns the panel-type picker state, deduping the three
  add-panel triggers; rename pluginKind -> panelKind
- LabelThresholdRow: extract onSave into a callback; ThresholdsSection test
  uses userEvent
- PreviewPane: drop the optional chaining on the required display field
- trim over-verbose comments
2026-06-26 01:41:27 +05:30
Abhi Kumar
9295c384a8 docs(dashboards-v2): trim verbose comments to minimal-why style
Condense multi-paragraph block comments and drop comments that restate
the code across the new-panel editor surface, keeping only the non-obvious
"why" and concise JSDoc on functions and type fields. Comment-only — no
code, types, or test assertions changed.
2026-06-26 01:28:55 +05:30
Abhi Kumar
9ea086e6d8 feat(dashboards-v2): create new panels from the editor
Add a draft-first create flow: picking a panel type opens the editor at
`/panel/new?panelKind=…&layoutIndex=…` on an in-memory default panel, and
nothing is persisted until save — cancelling leaves the dashboard
untouched (V1 parity). On save a new panel is minted (uuid) and added to
the target section via `createPanelOps` (resolves the section, or creates
one when the dashboard has none).

All "Add panel" triggers (section header, empty section, empty dashboard,
toolbar) route through a single useCreatePanel hook + the V2 type picker;
the leftover V1 global modal and useAddPanelToSection are removed.

New panels seed sensible defaults from the kind's supported signals: the
query datasource (e.g. List → logs, not the unsupported metrics default)
and, for List, the signal's default columns — so the Columns control
isn't empty on first open. New panels always re-serialize their query for
the kind on save, so the persisted query is valid even if untouched.

echo "--- working tree after commits (should be only dev hack) ---"; git status --short
2026-06-26 01:12:07 +05:30
Abhi Kumar
a5c8f413ff style(dashboards-v2): format list panel header with oxfmt 2026-06-26 01:03:54 +05:30
Abhi Kumar
46c7f2c285 feat(dashboards-v2): move list columns editor below the query builder
Replace the config-pane Columns section with a dnd-kit reorderable editor
rendered beneath the query builder (V1 parity); sanitize selectFields to the
field-key DTO so saved columns drop non-contract keys (isIndexed).
2026-06-26 01:03:52 +05:30
Abhi Kumar
c6330182f2 feat(dashboards-v2): list columns editor with datasource column switch 2026-06-26 01:03:38 +05:30
Abhi Kumar
afe643647a feat(dashboards-v2): list panel kind (logs/traces) with row detail 2026-06-26 01:02:40 +05:30
Abhi kumar
853397a79e feat(dashboards-v2): create new panels from the editor (#11777)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(dashboards-v2): list panel kind (logs/traces) with row detail

* feat(dashboards-v2): list columns editor with datasource column switch

* feat(dashboards-v2): move list columns editor below the query builder

Replace the config-pane Columns section with a dnd-kit reorderable editor
rendered beneath the query builder (V1 parity); sanitize selectFields to the
field-key DTO so saved columns drop non-contract keys (isIndexed).

* style(dashboards-v2): format list panel header with oxfmt

* refactor(dashboards-v2): drop the unsupported-panel fallback

Every panel kind now has a renderer, so the "not yet supported in V2"
body is dead. Remove UnsupportedPanelBody and render the panel body
behind a plain `panelDefinition &&` guard. Also clean up the panel-kind
typing in Panel.tsx: `spec.plugin.kind` is non-optional and already a
PanelKind, so the `as unknown as` cast and `?.` chains go away.

* refactor(dashboards-v2): tighten the getPanelDefinition cast

Drop the dead `if (!kind)` guard (a partial registry already returns
undefined for absent keys) and reduce the `as unknown as` double-cast to
a single `as` — comparability clears it, so the unknown laundering was
unnecessary.

* refactor(dashboards-v2): narrow renderer props to the panel kind

PanelRendererProps<K> now carries a `panel` narrowed to kind K (via a
PluginOfKind helper that picks the variant from the generated plugin
union), so each renderer reads `panel.spec.plugin.spec` as its exact spec
DTO. Removes the per-renderer `as <Kind>SpecDTO` cast (and the now-dead
`?? {}` fallbacks) across all seven renderers; the single unavoidable
widening stays in getPanelDefinition.

* feat(dashboards-v2): create new panels from the editor

Add a draft-first create flow: picking a panel type opens the editor at
`/panel/new?panelKind=…&layoutIndex=…` on an in-memory default panel, and
nothing is persisted until save — cancelling leaves the dashboard
untouched (V1 parity). On save a new panel is minted (uuid) and added to
the target section via `createPanelOps` (resolves the section, or creates
one when the dashboard has none).

All "Add panel" triggers (section header, empty section, empty dashboard,
toolbar) route through a single useCreatePanel hook + the V2 type picker;
the leftover V1 global modal and useAddPanelToSection are removed.

New panels seed sensible defaults from the kind's supported signals: the
query datasource (e.g. List → logs, not the unsupported metrics default)
and, for List, the signal's default columns — so the Columns control
isn't empty on first open. New panels always re-serialize their query for
the kind on save, so the persisted query is valid even if untouched.

echo "--- working tree after commits (should be only dev hack) ---"; git status --short

* fix(dashboards-v2): place a new panel in the last row's free space

createPanelOps placed every new panel at x:0 on a fresh row, so a half-full
last row left its right half empty. Find the first free slot in the section's
last row (right of its panels) and only wrap to a new row when it can't fit.

* fix(uplot): center a single-item chart legend

isSingleRow read the container width from a ref inside useMemo, so it computed
once at mount (ref still null → width 0 → false) and only recovered if the
component happened to re-render. The uPlot legend re-renders on plot sync so it
recovered by luck; the Pie legend doesn't, leaving a single item clipped in a
narrow grid track. Measure the container with useResizeObserver instead.

* feat(dashboards-v2): unified panel empty, no-data and no-query states

Add a shared PanelMessage component (icon + title + description + optional
action) backing every non-chart panel state. PanelBody now shows a 'Nothing to
visualize yet' state for panels with no runnable query and a polished error
state with Retry. NoData becomes a 'No data in this time range' affordance with
an optional Retry, wired through a new optional refetch on the renderer props.

* fix(dashboards-v2): derive the preview "Plotted with" tag from the panel query

The editor preview hardcoded EQueryType.QUERY_BUILDER, so PromQL/ClickHouse
panels were mislabelled and list panels still showed the tag. Add a V2 PlotTag
(mirroring V1: hidden for list panels and before a query exists) fed by a new
getPanelQueryType util that reads the panel's V5 envelopes.

* feat(dashboards-v2): panel header title with description tooltip

Replace PanelHeader's ReactNode title with explicit name + description props,
rendering the description as an info-icon tooltip in the header instead of a
wrapper around the title. Panel passes them directly (no headerTitle memo).

* refactor(dashboards-v2): let consumers own the refetch loader signal

usePanelQuery returns the raw react-query isLoading; the dashboard panel and the
editor preview now compute isLoading || isFetching themselves, so each surface
decides whether a background refetch shows the full loader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboards-v2): default a threshold's label to an empty string on save

* feat(dashboards-v2): show the panel header in the editor preview

Reuse PanelHeader in PreviewPane so the editor preview shows the panel
title, description tooltip, refetch spinner, and error/warning indicators,
with the body rendered flush beneath it like the dashboard grid panel.

Add a hideActions flag to PanelHeader to suppress the actions menu in the
editor: View/Edit/Clone/Delete don't apply there, and omitting panelActions
alone isn't enough since View/Edit/Download survive their own gates. Wire the
table/list header search through the preview as the grid does, and thread the
raw isFetching for the header's refetch spinner.

* feat(dashboards-v2): seed new panels with per-kind config defaults

A new panel was seeded with an empty plugin spec, so the config pane's
dropdowns and segmented controls (time scope, legend position, line style /
interpolation, fill mode) opened with nothing selected.

Derive a default plugin spec from each kind's declared sections via a new pure
buildDefaultPluginSpec helper and seed it in createDefaultPanel. Each default
equals the matching renderer fallback, so only the config-pane display changes,
not the rendered output. Controls whose empty state already reads as the chart
default (unit/decimals "auto", switches, numeric "Auto" inputs) are left unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboards-v2): preserve list columns across datasource round-trips

The List panel stores a single selectFields list, so switching the query
signal replaced the columns with the new datasource's defaults — going
logs -> traces -> logs discarded a customized logs selection.

Remember each signal's columns and restore them when switching back;
defaults still seed a signal the first time it's seen.

* docs(dashboards-v2): trim verbose comments to minimal-why style

Condense multi-paragraph block comments and drop comments that restate
the code across the new-panel editor surface, keeping only the non-obvious
"why" and concise JSDoc on functions and type fields. Comment-only — no
code, types, or test assertions changed.

* fix(dashboards-v2): show resource attributes like service.name in trace list panels

The List renderer only flattened nested resource/attribute maps for logs, so a
selected resource field such as service.name (nested under resources_string)
rendered N/A for traces even though the same data shows in V1. Flatten traces too.

* feat(dashboards-v2): open new List panels on a runnable logs query (V1 parity)

A new List panel opened blank: its seed query had an empty orderBy and lived only
in the builder, so the preview didn't run on open. Seed spec.queries at creation
with the V1 list logs query (orderBy timestamp desc) so the panel opens on logs,
pre-sorted, and the preview runs immediately; wire up useSeedNewListColumns so its
default columns (timestamp, body) are populated too. Switching to traces is handled
by the builder's list-view path.

* fix(dashboards-v2): drop all config-pane sections from the List panel

List columns are edited below the query builder, and Context Links isn't wanted in
the config pane, so the List panel now declares no config-pane sections.

* refactor(dashboards-v2): address PR review feedback

- getPanelDefinition: make PanelRegistry total over PanelKind so it never
  returns undefined (a missing kind is now a compile error)
- toQueryEnvelopes/hasRunnableQueries: accept `... | null` and drop the
  `?? []` at call sites
- consolidate panel loading to a single `isFetching` signal (Panel,
  PreviewPane, PanelEditor)
- rename defaultDataSource -> signal; use TelemetrytypesSignalDTO enum for
  the List default columns
- useCreatePanel now owns the panel-type picker state, deduping the three
  add-panel triggers; rename pluginKind -> panelKind
- LabelThresholdRow: extract onSave into a callback; ThresholdsSection test
  uses userEvent
- PreviewPane: drop the optional chaining on the required display field
- trim over-verbose comments

* refactor(dashboards-v2): type panel signals as TelemetrytypesSignalDTO

Replace the query-builder `DataSource` enum with `TelemetrytypesSignalDTO`
across the panel signal plumbing — the panel definitions' `supportedSignals`,
the List columns editor/suggestions/defaults, and the editor query-sync and
column-seeding hooks — and make the signal non-optional where a panel always
resolves one. Drops the now-dead `DataSource` imports.

* chore: pr review changes

* chore: pr review changes

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:48:20 +00:00
Vinicius Lourenço
bd526df11d feat(roles): rework the create/update pages (#11815)
* fix(styles): little fix for callout

I will upstream this fix eventually

* fix(settings): ensure tab content can use height: 100%

We will need this fix for the create/edit role page later during json mode

* refactor(authz): define the verbs that supports selectorId

* refactor(roles): drop old components

We will rewrite them

* refactor(roles): use css modules on role settings

* refactor(roles): rewrite delete role modal to use dialog component

* feat(roles): add initial type files

* feat(error-in-place): add testId to allow assert on future tests

* refactor(permission-denied-full-page): change the wording a little bit to match the API

This change was requested by Vikrant

* feat(service-account-drawer): add copy button for ID

Suggested by https://github.com/SigNoz/platform-pod/issues/2529#issuecomment-4776975323

* fix(service-account-drawer): not validating the permissions correctly for read/update

Found at https://github.com/SigNoz/platform-pod/issues/2529#issuecomment-4776975323

* feat(timezone): add little helper to allow format optional dates

* refactor(authz): export parse permission to get objectId when needed

* test(authz): add helper to grant by prefix

* test(test-utils): include tooltip provider

* feat(hooks): add hook to block navigation via history

* feat(roles): add hook to map API interface to own interface for easier integration

* feat(roles): add hook to fetch role permissions

* feat(roles): add config object around how to define each section for each resource

* feat(roles): add config for monaco when showing json

* feat(view-roles): add selected ids component

* feat(view-roles): add component row to render selected roles

* feat(view-roles): add card component to render each resource selected

* feat(view-roles): add overview component to view roles

* feat(view-roles): add readonly json viewer

* feat(view-roles): add view role page

* test(view-roles): add tests for view roles

* feat(create-edit-roles): add hook for form validation

* feat(create-edit-roles): add hook for unsaved changes

* feat(create-edit-roles): add hook for generic logic for create-edit

* feat(create-edit-roles): add component to render json

* feat(create-edit-roles): add component to add new selectors for a permission

* feat(create-edit-roles): add component to select scopes of a permission

* feat(create-edit-roles): add component render resources to be selected

* feat(create-edit-roles): add component render permission editor

* feat(create-edit-roles): add page to create/edit roles

* test(create-edit-roles): add tests for page and subcomponents

* feat(list-roles): refactor to css modules & add row click to open view role page

* fix(list-roles): change description when feature gate is disabled

* refactor(roles): removed action labels and use function instead

* refactor(authz): add todo about permission.config

* feat(roles): add routes for create/edit/view

* test(service-account): update missing tests

* fix(pr): address comments

* fix(pr): rename suffix of hooks from callbacks to actions

* fix(pr): remove all usages of getByText

* test(use-navigation-blocker): fix test
2026-06-25 17:24:16 +00:00
Nikhil Mantri
8ac07d3d37 feat(infra-monitoring): v2 onboarding/checks api (#11104)
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
* chore: baseline setup

* chore: endpoint detail update

* chore: added logic for hosts v3 api

* fix: bug fix

* chore: disk usage

* chore: added validate function

* chore: added some unit tests

* chore: return status as a string

* chore: yarn generate api

* chore: removed isSendingK8sAgentsMetricsCode

* chore: moved funcs

* chore: added validation on order by

* chore: added pods list logic

* chore: updated openapi yml

* chore: updated spec

* chore: pods api meta start time

* chore: nil pointer check

* chore: nil pointer dereference fix in req.Filter

* chore: added temporalities of metrics

* chore: added pods metrics temporality

* chore: unified composite key function

* chore: code improvements

* chore: added pods list api updates

* chore: hostStatusNone added for clarity that this field can be left empty as well in payload

* chore: yarn generate api

* chore: return errors from getMetadata and lint fix

* chore: return errors from getMetadata and lint fix

* chore: added hostName logic

* chore: modified getMetadata query

* chore: add type for response and files rearrange

* chore: warnings added passing from queryResponse warning to host lists response struct

* chore: added better metrics existence check

* chore: added a TODO remark

* chore: added required metrics check

* chore: distributed samples table to local table change for get metadata

* chore: frontend fix

* chore: endpoint correction

* chore: endpoint modification openapi

* chore: escape backtick to prevent sql injection

* chore: rearrage

* chore: improvements

* chore: validate order by to validate function

* chore: improved description

* chore: added TODOs and made filterByStatus a part of filter struct

* chore: ignore empty string hosts in get active hosts

* feat(infra-monitoring): v2 hosts list - return counts of active & inactive hosts for custom group by attributes (#10956)

* chore: add functionality for showing active and inactive counts in custom group by

* chore: bug fix

* chore: added subquery for active and total count

* chore: ignore empty string hosts in get active hosts

* fix: sinceUnixMilli for determining active hosts compute once per request

* chore: refactor code

* chore: rename HostsList -> ListHosts

* chore: rearrangement

* chore: inframonitoring types renaming

* chore: added types package

* chore: file structure further breakdown for clarity

* chore: comments correction

* chore: removed temporalities

* chore: pods code restructuring

* chore: comments resolve

* chore: added json tag required: true

* chore: removed pod metric temporalities

* chore: removed internal server error

* chore: added status unauthorized

* chore: remove a defensive nil map check, the function ensure non-nil map when err nil

* chore: cleanup and rename

* chore: make sort stable in case of tiebreaker by comparing composite group by keys

* chore: added types and constants

* chore: added specs for all component types

* chore: added attrs presence check function

* chore: added onboarding splits

* chore: regen api client for inframonitoring

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: added required tags

* chore: added support for pod phase unknown

* chore: removed pods - order by phase

* chore: improved api description to document -1 as no data in numeric fields

* fix: rebase fixes

* chore: added onboarding api

* chore: renamed method

* chore: get onboarding spec

* chore: simplify

* chore: readability improvement

* chore: added a note from otel

* chore: added a note from otel

* feat(infra-monitoring): v2 pods list apis - phase counts when custom grouping (#11088)

* chore: added phase counts feature

* chore: added queries for pod phase counts in custom group by

* chore: added unknown phase count

* fix: isPodUIDInGroupBy in buildPodRecords

* chore: 3 cte --> 2 cte

* chore: pod phase with local table of time series as counts

* chore: comment correction

* chore: corrected comment

* chore: value column for samples table added

* chore: removed query G for phase counts

* chore: rename variable

* chore: added PodPhaseNum constants to types

* chore: updated comment

* chore: onboarding specs updated to match v2 infra-monitoring apis

* chore: integration tests added

* chore: documentation future links added

* chore: added new metrics existence function + modified return types

* chore: not required parameter removal

* chore: reformatted integration tests

* chore: renamed onboarding -> checks

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-06-25 13:57:50 +00:00
Nageshbansal
9bab8e0ae2 docs(deploy): restructure migration guide and add Docker Swarm (#11851)
* docs(deploy): restructure migration guide and add Docker Swarm

* docs(deploy): title migration guide for both install script and deploy

* docs: fix the signoz docs troubleshooting guide url

* docs(deploy): point troubleshooting at Slack and Foundry issues
2026-06-25 11:48:37 +00:00
Nityananda Gohain
8040f222ad fix: enable opamp for llmpricing/spanmmapper and remove unit from config (#11285)
* fix: enable opamp for llmpricing

* fix: test files

* chore: cleanups

* fix: don't deploy to opamp if ff is disabled

* fix: enable spanmapper behild feature flag
2026-06-25 11:38:53 +00:00
Nikhil Mantri
a45212cb79 feat(infra-monitoring): remove requiredMetricsCheck from v2 APIs + integration tests for new querier warnings behaviour. (#11792)
* chore: metric_name required checks removed

* refactor(inframonitoring): drop required-metrics test, add test hosts_warnings

* chore: updated integration tests for pods

* chore: updated integration tests for volumes

* chore: updated integration tests for nodes

* chore: updated integration tests for clusters and deployments

* chore: updated statefulsets integration tests

* chore: updated integration tests for jobs and daemonsets

* chore: removed unwanted provisional comments

* test(inframonitoring): dedupe metric-availability warning fixtures, reuse existing datasets

* test(inframonitoring): isolate hosts metric-key-pair warning fixture

Scenario-2 shared host acc-h1 + the accuracy fixture with
test_hosts_value_accuracy, causing a deterministic CI failure (empty
warnings). Restore the dedicated kp-h1 fixture + deployment.environment
groupBy so the warning assertion never depends on data shared with
another test.

* chore: removed unimportant tests for same thing

* test(inframonitoring): drop obsolete metric-key-pair warning scenario

PR #11835 removed the "key X not found on metric" warning from the metric

statement builder (lexer-derived check can't tell a key from a value, so

$variables got false-flagged). The metric_key_pair_not_seen scenario asserted

that now-removed warning, so it fails deterministically after merging main.

Remove the scenario and its orphaned fixture; never-seen-metric coverage

("never been received", still emitted) stays.
2026-06-25 10:26:50 +00:00
Abir Roy
a609a4044c fix(ui): resolve monaco find widget clipping and flickering (#11826)
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
2026-06-24 20:11:11 +00:00
287 changed files with 16084 additions and 5181 deletions

View File

@@ -1,48 +1,76 @@
# Migrating from the install script to Foundry
# Migrating from the install script and `deploy/` to Foundry
The install script (`install.sh`) and the bundled Compose and Swarm files
under `deploy/` are deprecated in favor of [Foundry][foundry], the supported
way to install and manage SigNoz. This guide moves an existing Docker Compose
or Docker Swarm deployment to Foundry and reattaches your existing volumes, so
your data is preserved.
> [!IMPORTANT]
> The install script is now deprecated and will no longer receive updates.
> This guide is only for **existing** `install.sh` / `deploy/` deployments.
> Setting up SigNoz for the first time? Skip migration and install Foundry
> directly: [SigNoz install docs][install-docs].
This guide walks you through migrating an existing SigNoz deployment running via
Docker Compose to [Foundry](https://signoz.io/docs/install/docker/).
## How it works
> [!NOTE]
> Setting up SigNoz for the first time? You don't need this guide — follow the [SigNoz installation docs](https://signoz.io/docs/install/) instead.
Foundry splits a deployment into two commands:
## Overview
To stay up to date on new installation platforms and patterns, please refer to [Foundry](https://github.com/SigNoz/foundry).
- `foundryctl forge` generates the deployment manifests from a `casting.yaml`.
It never touches running containers, so it is safe to re-run while you
iterate.
- `foundryctl cast` applies those manifests: it (re)creates the containers and
reuses the volumes you point it at.
Two `foundryctl` commands are used throughout this guide:
- **`forge`** — generates deployment manifests from your `casting.yaml`. It does not touch running containers, so it is safe to re-run while you iterate.
- **`cast`** — applies the generated manifests: it creates and starts the containers (and pulls new images).
You write one `casting.yaml`, point a few patches at your existing data
volumes, then cast. The steps below are the same for Compose and Swarm; they
differ only in the casting (step 3) and how you stop the old stack (step 5).
## Prerequisites
- [ ] Install Foundry - `curl -fsSL https://signoz.io/foundry.sh | bash`
## Migration Steps
> [!WARNING]
> **Before proceeding, back up both:**
> - **Your docker volumes** — these hold your data.
> - **Your existing `docker-compose.yaml` (and any config it references)** — keep a copy somewhere safe. The compose manifests are no longer distributed by SigNoz, so this backup is your only way to roll back to your previous setup.
- An existing SigNoz deployment from `install.sh` or `deploy/` (Compose or
Swarm).
- `foundryctl` (installed in step 1).
1. Make a note of the volume names used by your existing deployment for the following components:
- ClickHouse
- SigNoz
- ZooKeeper
## Migrate
> If you used the docker compose file we provided, the volumes will be `signoz-clickhouse`, `signoz-sqlite`, and `signoz-zookeeper-1`.
### 1. Install Foundry
2. Generate your `casting.yaml`. Based on internal testing, the following casting should generate the manifests that mimic the legacy docker compose setup (compare against your backed-up `docker-compose.yaml`). Once created, run `foundryctl forge -f casting.yaml`.
```bash
curl -fsSL https://signoz.io/foundry.sh | bash
```
Foundry has a [Docker Compose example](https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose). Please use it as a reference.
### 2. Keep your rollback path
> [!WARNING]
> If your deployment had more than 1 shard or replica, you will need to adjust your manifest volumes accordingly.
This migration reattaches your existing volumes in place; it does not move or
delete your data. The only destructive action is passing `--volumes` / `-v`
when you stop the old stack (step 5), so avoid that flag.
> [!IMPORTANT]
> The `replica` and `shard` macros below are placeholders. Replace them with the values from your existing ClickHouse configuration (check the `macros` section of your current ClickHouse config, e.g. `config.xml`/`metrika.xml`), otherwise the generated manifests will not match your existing data.
> Keep a copy of your existing `docker-compose.yaml` / stack file (and any
> config it references). SigNoz no longer distributes these files, so this copy
> is your only way to roll back.
### 3. Write your `casting.yaml`
Use the casting for your deployment. Both reproduce the legacy single-node
setup (ClickHouse + ZooKeeper + SQLite) and reattach your existing volumes;
they differ only in `spec.deployment.flavor` and the volume-reuse patch
(Compose volumes have a `name` to replace; Swarm volumes are bare, so the whole
entry is replaced). If your deployment ran more than one shard or replica,
adjust the volume patches accordingly. The
[Docker Compose example][compose-example] is a useful reference.
> [!IMPORTANT]
> The `replica` and `shard` macros are placeholders. Replace them with the
> values from your existing ClickHouse config (the `macros` section of
> `config.xml` / `metrika.xml`), or the generated manifests will not match your
> existing data.
<details>
<summary><b>Docker Compose</b> casting.yaml</summary>
```yaml
# casting.yaml
apiVersion: v1alpha1
kind: Installation
metadata:
@@ -61,8 +89,8 @@ spec:
data:
config-0-0.yaml: |
macros:
replica: "example01-01-1" # replace with your existing ClickHouse replica macro (see legacy configuration files for reference)
shard: "01" # replace with your existing ClickHouse shard macro (see legacy configuration files for reference)
replica: "example01-01-1" # replace with your replica macro
shard: "01" # replace with your shard macro
patches:
- target: "deployment/compose.yaml"
operations:
@@ -80,50 +108,163 @@ spec:
value: root
```
> [!NOTE]
> The `user: root` patch on the ZooKeeper service is required so the container can read/write the data in your reused ZooKeeper volume, which was created with `root`-owned files by the legacy compose setup. Without it, ZooKeeper may fail to start with permission errors.
</details>
If you had custom configurations for features like SMTP or additional ingestion processors/receivers, you will need to include those in your casting file via [patches](https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md), [custom configuration](https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files) or [environment variables](https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec) based on your previous configuration.
<details>
<summary><b>Docker Swarm</b> casting.yaml</summary>
3. Review your manifests, we suggest executing the following checks on your manifests before proceeding:
- [ ] Validate the container images match what your deployment had, Foundry uses `latest` on generation by default.
- [ ] If your signoz version was older than latest, please check the [upgrade path](https://signoz.io/docs/operate/upgrade/) first.
- [ ] Check the produced manifests in `pours/deployment` match your older configurations. Extra consideration and review needs to be done on `compose.yaml` as this will be the main entry point for your new deployment.
- [ ] The configuration files for clickhouse are now in YAML so validate your custom settings are present.
```yaml
# casting.yaml
apiVersion: v1alpha1
kind: Installation
metadata:
name: signoz
spec:
deployment:
flavor: swarm
mode: docker
metastore:
kind: sqlite
telemetrykeeper:
kind: zookeeper
telemetrystore:
spec:
config:
data:
config-0-0.yaml: |
macros:
replica: "example01-01-1" # replace with your replica macro
shard: "01" # replace with your shard macro
patches:
- target: "deployment/compose.yaml"
operations:
- op: replace
path: /volumes/signoz-telemetrykeeper-0-data
value:
name: signoz-zookeeper-1
- op: replace
path: /volumes/signoz-telemetrystore-0-0-data
value:
name: signoz-clickhouse
- op: replace
path: /volumes/signoz-metastore-sqlite-0-data
value:
name: signoz-sqlite
- op: add
path: /services/signoz-telemetrykeeper-zookeeper-0/user
value: root
```
4. Execute a `docker compose down`. **Do not** include parameters such as `--volumes` (or `-v`), as it will wipe the volumes we need to maintain and reuse to avoid data loss.
</details>
> [!NOTE]
> This will generate downtime so please plan accordingly.
> The `user: root` patch on the ZooKeeper service lets the container read and
> write the data in your reused ZooKeeper volume, whose files the legacy setup
> created as `root`. Without it, ZooKeeper may fail to start with permission
> errors.
5. Validate the SigNoz containers are down with `docker ps -a`. Multiple containers cannot bind the same volume.
If you had custom configuration (SMTP, extra ingestion receivers/processors,
or custom ClickHouse settings), carry it over via [patches][patches],
[custom config files][custom-config], or [environment variables][env-vars].
6. Run `foundryctl cast -f casting.yaml`. This will recreate the containers based on the spec. This process will download new container images.
### 4. Generate and review the manifests
```bash
foundryctl forge -f casting.yaml
```
Review `pours/deployment/` before deploying:
- [ ] Container images match your current deployment. Foundry generates with
`latest` by default; if your SigNoz version was older than latest, check the
[upgrade path][upgrade-path] first.
- [ ] The generated manifests match your previous configuration, especially
`compose.yaml` (the new entry point for your deployment).
- [ ] The ClickHouse config is now YAML rather than XML; confirm your custom
settings carried over (see [ClickHouse configuration files][ch-config] for
the XML-to-YAML mapping).
### 5. Stop the old deployment
Use the command for your deployment. Do **not** pass `--volumes` / `-v`; that
would delete the data you are migrating.
```bash
docker compose down # Compose
docker stack rm signoz # Swarm
```
> [!NOTE]
> When `cast` is run, the migration container will execute its migrations.
> This causes downtime, so plan accordingly.
## Verifying the Migration
- SigNoz containers will be up and running.
- Log in to the SigNoz UI and verify that data is present.
- Signoz will run on localhost:8080
- Validate that your data ingestion is receiving data.
- Ingesters will receive data on localhost:4317(grpc) and localhost:4318(http)
- Review the logs from both ClickHouse and ZooKeeper; no errors should be present.
Confirm nothing is still bound to the volumes before continuing:
## Rolling Back
Because step 4 brought the legacy stack down *without* `-v`, your original volumes
are untouched and still hold your data. To roll back:
```bash
docker ps -a
```
- Stop and remove the containers created by Foundry (`docker compose down`, again without `-v`).
- Confirm the containers are gone with `docker ps -a` so nothing else is bound to the volumes.
- Reapply your original docker compose file (`docker compose up -d`). It will reattach to the
existing volumes and restore your prior state.
### 6. Deploy with Foundry
```bash
foundryctl cast -f casting.yaml
```
This recreates the containers against your existing volumes and pulls the
images. The migration container runs the schema migrations as part of `cast`.
**Prefer not to use `cast`?** The manifests in `pours/deployment/` are standard
Docker artifacts you can apply yourself. Run the command from that directory so
the relative config paths resolve:
```bash
cd pours/deployment
docker compose up -d # Compose
docker stack deploy -c compose.yaml signoz # Swarm
```
## Verify
- All SigNoz containers are running.
- The UI is reachable on `http://localhost:8080`, and OTLP on `4317` (gRPC)
and `4318` (HTTP), so already-instrumented apps and saved bookmarks keep
working.
- Your existing data is present in the UI, and new data is being ingested.
- ClickHouse and ZooKeeper logs show no errors.
## Roll back
Step 5 left your volumes untouched, so your data is intact. To return to the
previous setup:
1. Bring down the Foundry deployment (`docker compose down` or
`docker stack rm signoz`, again without `-v`).
2. Confirm the containers are gone with `docker ps -a`.
3. Re-apply your backed-up stack: `docker compose up -d` (Compose) or
`docker stack deploy -c docker-compose.yaml signoz` (Swarm). It reattaches
the same volumes and restores your prior state.
## Troubleshooting
- Please reach out to our community on [Slack](https://signoz.io/slack).
If the migration runs into trouble, reach out on [Slack][slack] or open a
[Foundry issue][foundry-issues].
## References
- [SigNoz Docker installation docs](https://signoz.io/docs/install/docker/)
- [SigNoz documentation](https://signoz.io/docs)
- [Foundry](https://github.com/SigNoz/foundry)
- [Foundry][foundry]
- [Casting file reference][casting-ref]
- [Custom config files][custom-config]
- [Patches][patches]
- [SigNoz documentation][signoz-docs]
[foundry]: https://github.com/SigNoz/foundry
[install-docs]: https://signoz.io/docs/install/
[compose-example]: https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose
[patches]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md
[custom-config]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files
[env-vars]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec
[casting-ref]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md
[ch-config]: https://clickhouse.com/docs/operations/configuration-files
[upgrade-path]: https://signoz.io/docs/operate/upgrade/
[slack]: https://signoz.io/slack
[foundry-issues]: https://github.com/SigNoz/foundry/issues
[signoz-docs]: https://signoz.io/docs

View File

@@ -4011,6 +4011,94 @@ components:
enabled:
type: boolean
type: object
InframonitoringtypesAssociatedComponent:
properties:
name:
type: string
type:
$ref: '#/components/schemas/InframonitoringtypesCheckComponentType'
required:
- type
- name
type: object
InframonitoringtypesAttributesComponentEntry:
properties:
associatedComponent:
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
attributes:
items:
type: string
nullable: true
type: array
required:
- attributes
- associatedComponent
type: object
InframonitoringtypesCheckComponentType:
enum:
- receiver
- processor
type: string
InframonitoringtypesCheckType:
enum:
- hosts
- processes
- pods
- nodes
- deployments
- daemonsets
- statefulsets
- jobs
- namespaces
- clusters
- volumes
type: string
InframonitoringtypesChecks:
properties:
missingDefaultEnabledMetrics:
items:
$ref: '#/components/schemas/InframonitoringtypesMissingMetricsComponentEntry'
nullable: true
type: array
missingOptionalMetrics:
items:
$ref: '#/components/schemas/InframonitoringtypesMissingMetricsComponentEntry'
nullable: true
type: array
missingRequiredAttributes:
items:
$ref: '#/components/schemas/InframonitoringtypesMissingAttributesComponentEntry'
nullable: true
type: array
presentDefaultEnabledMetrics:
items:
$ref: '#/components/schemas/InframonitoringtypesMetricsComponentEntry'
nullable: true
type: array
presentOptionalMetrics:
items:
$ref: '#/components/schemas/InframonitoringtypesMetricsComponentEntry'
nullable: true
type: array
presentRequiredAttributes:
items:
$ref: '#/components/schemas/InframonitoringtypesAttributesComponentEntry'
nullable: true
type: array
ready:
type: boolean
type:
$ref: '#/components/schemas/InframonitoringtypesCheckType'
required:
- type
- ready
- presentDefaultEnabledMetrics
- presentOptionalMetrics
- presentRequiredAttributes
- missingDefaultEnabledMetrics
- missingOptionalMetrics
- missingRequiredAttributes
type: object
InframonitoringtypesClusterRecord:
properties:
clusterCPU:
@@ -4054,8 +4142,6 @@ components:
items:
$ref: '#/components/schemas/InframonitoringtypesClusterRecord'
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
@@ -4066,7 +4152,6 @@ components:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesDaemonSetRecord:
@@ -4123,8 +4208,6 @@ components:
items:
$ref: '#/components/schemas/InframonitoringtypesDaemonSetRecord'
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
@@ -4135,7 +4218,6 @@ components:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesDeploymentRecord:
@@ -4192,8 +4274,6 @@ components:
items:
$ref: '#/components/schemas/InframonitoringtypesDeploymentRecord'
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
@@ -4204,7 +4284,6 @@ components:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesHostFilter:
@@ -4270,8 +4349,6 @@ components:
items:
$ref: '#/components/schemas/InframonitoringtypesHostRecord'
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
@@ -4282,7 +4359,6 @@ components:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesJobRecord:
@@ -4345,8 +4421,6 @@ components:
items:
$ref: '#/components/schemas/InframonitoringtypesJobRecord'
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
@@ -4357,9 +4431,59 @@ components:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesMetricsComponentEntry:
properties:
associatedComponent:
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
metrics:
items:
type: string
nullable: true
type: array
required:
- metrics
- associatedComponent
type: object
InframonitoringtypesMissingAttributesComponentEntry:
properties:
associatedComponent:
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
attributes:
items:
type: string
nullable: true
type: array
documentationLink:
type: string
message:
type: string
required:
- attributes
- associatedComponent
- message
- documentationLink
type: object
InframonitoringtypesMissingMetricsComponentEntry:
properties:
associatedComponent:
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
documentationLink:
type: string
message:
type: string
metrics:
items:
type: string
nullable: true
type: array
required:
- metrics
- associatedComponent
- message
- documentationLink
type: object
InframonitoringtypesNamespaceRecord:
properties:
meta:
@@ -4392,8 +4516,6 @@ components:
items:
$ref: '#/components/schemas/InframonitoringtypesNamespaceRecord'
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
@@ -4404,7 +4526,6 @@ components:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesNodeCondition:
@@ -4469,8 +4590,6 @@ components:
items:
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
@@ -4481,7 +4600,6 @@ components:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesPodCountsByPhase:
@@ -4567,8 +4685,6 @@ components:
items:
$ref: '#/components/schemas/InframonitoringtypesPodRecord'
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
@@ -4579,7 +4695,6 @@ components:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesPostableClusters:
@@ -4842,16 +4957,6 @@ components:
- end
- limit
type: object
InframonitoringtypesRequiredMetricsCheck:
properties:
missingMetrics:
items:
type: string
nullable: true
type: array
required:
- missingMetrics
type: object
InframonitoringtypesResponseType:
enum:
- list
@@ -4911,8 +5016,6 @@ components:
items:
$ref: '#/components/schemas/InframonitoringtypesStatefulSetRecord'
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
@@ -4923,7 +5026,6 @@ components:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesVolumeRecord:
@@ -4971,8 +5073,6 @@ components:
items:
$ref: '#/components/schemas/InframonitoringtypesVolumeRecord'
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
@@ -4983,7 +5083,6 @@ components:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
LlmpricingruletypesGettablePricingRules:
@@ -14868,6 +14967,72 @@ paths:
summary: Health check
tags:
- health
/api/v2/infra_monitoring/checks:
get:
deprecated: false
description: 'Checks whether the metrics and attributes required to power the
infra-monitoring section selected by the ''type'' query parameter (hosts,
processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces,
clusters, volumes) are being received. For each collector receiver or processor
that contributes required metrics or attributes, lists what is present and
what is missing, with a prebuilt user-facing message and a docs link per missing
component. Default-enabled metrics are those expected as soon as the receiver
is configured; optional metrics require ''enabled: true'' in receiver config.
''ready'' is true only when every missing list is empty.'
operationId: GetChecks
parameters:
- in: query
name: type
required: true
schema:
$ref: '#/components/schemas/InframonitoringtypesCheckType'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesChecks'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Run Infra Monitoring Setup Checks
tags:
- inframonitoring
/api/v2/infra_monitoring/clusters:
post:
deprecated: false
@@ -14882,10 +15047,10 @@ paths:
for custom groupBy keys; in both modes every row aggregates nodes and pods
in the group. Supports filtering via a filter expression, custom groupBy,
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
via offset/limit. Also reports missing required metrics and whether the requested
time range falls before the data retention boundary. Numeric metric fields
(clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable)
return -1 as a sentinel when no data is available for that field.'
via offset/limit. Also reports whether the requested time range falls before
the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable,
clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data
is available for that field.'
operationId: ListClusters
requestBody:
content:
@@ -14958,11 +15123,11 @@ paths:
row aggregates pods owned by daemonsets in the group. Supports filtering via
a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit
/ memory / memory_request / memory_limit / desired_nodes / current_nodes,
and pagination via offset/limit. Also reports missing required metrics and
whether the requested time range falls before the data retention boundary.
Numeric metric fields (daemonSetCPU, daemonSetCPURequest, daemonSetCPULimit,
daemonSetMemory, daemonSetMemoryRequest, daemonSetMemoryLimit, desiredNodes,
currentNodes) return -1 as a sentinel when no data is available for that field.'
and pagination via offset/limit. Also reports whether the requested time range
falls before the data retention boundary. Numeric metric fields (daemonSetCPU,
daemonSetCPURequest, daemonSetCPULimit, daemonSetMemory, daemonSetMemoryRequest,
daemonSetMemoryLimit, desiredNodes, currentNodes) return -1 as a sentinel
when no data is available for that field.'
operationId: ListDaemonSets
requestBody:
content:
@@ -15033,11 +15198,11 @@ paths:
group. Supports filtering via a filter expression, custom groupBy, ordering
by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit
/ desired_pods / available_pods, and pagination via offset/limit. Also reports
missing required metrics and whether the requested time range falls before
the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest,
deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit,
desiredPods, availablePods) return -1 as a sentinel when no data is available
for that field.'
whether the requested time range falls before the data retention boundary.
Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit,
deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods,
availablePods) return -1 as a sentinel when no data is available for that
field.'
operationId: ListDeployments
requestBody:
content:
@@ -15102,10 +15267,9 @@ paths:
custom groupBy to aggregate hosts by any attribute, ordering by any of the
five metrics, and pagination via offset/limit. The response type is ''list''
for the default host.name grouping or ''grouped_list'' for custom groupBy
keys. Also reports missing required metrics and whether the requested time
range falls before the data retention boundary. Numeric metric fields (cpu,
memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available
for that field.'
keys. Also reports whether the requested time range falls before the data
retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage)
return -1 as a sentinel when no data is available for that field.'
operationId: ListHosts
requestBody:
content:
@@ -15179,11 +15343,11 @@ paths:
jobs in the group. Supports filtering via a filter expression, custom groupBy,
ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit
/ desired_successful_pods / active_pods / failed_pods / successful_pods, and
pagination via offset/limit. Also reports missing required metrics and whether
the requested time range falls before the data retention boundary. Numeric
metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest,
jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods)
return -1 as a sentinel when no data is available for that field.'
pagination via offset/limit. Also reports whether the requested time range
falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest,
jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods,
activePods, failedPods, successfulPods) return -1 as a sentinel when no data
is available for that field.'
operationId: ListJobs
requestBody:
content:
@@ -15248,10 +15412,10 @@ paths:
type is ''list'' for the default k8s.namespace.name grouping or ''grouped_list''
for custom groupBy keys; in both modes every row aggregates pods in the group.
Supports filtering via a filter expression, custom groupBy, ordering by cpu
/ memory, and pagination via offset/limit. Also reports missing required metrics
and whether the requested time range falls before the data retention boundary.
Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel
when no data is available for that field.'
/ memory, and pagination via offset/limit. Also reports whether the requested
time range falls before the data retention boundary. Numeric metric fields
(namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available
for that field.'
operationId: ListNamespaces
requestBody:
content:
@@ -15319,10 +15483,10 @@ paths:
for custom groupBy keys (each row aggregates nodes in the group; condition
stays no_data). Supports filtering via a filter expression, custom groupBy,
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
via offset/limit. Also reports missing required metrics and whether the requested
time range falls before the data retention boundary. Numeric metric fields
(nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1
as a sentinel when no data is available for that field.'
via offset/limit. Also reports whether the requested time range falls before
the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable,
nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is
available for that field.'
operationId: ListNodes
requestBody:
content:
@@ -15391,11 +15555,10 @@ paths:
is one pod with its current phase) or ''grouped_list'' for custom groupBy
keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase:
{ pending, running, succeeded, failed, unknown } derived from each pod''s
latest phase in the window). Also reports missing required metrics and whether
the requested time range falls before the data retention boundary. Numeric
metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest,
podMemoryLimit, podAge) return -1 as a sentinel when no data is available
for that field.'
latest phase in the window). Also reports whether the requested time range
falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest,
podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1
as a sentinel when no data is available for that field.'
operationId: ListPods
requestBody:
content:
@@ -15462,11 +15625,10 @@ paths:
usage, inodes, inodes_free, inodes_used), and pagination via offset/limit.
The response type is ''list'' for the default k8s.persistentvolumeclaim.name
grouping or ''grouped_list'' for custom groupBy keys; in both modes every
row aggregates volumes in the group. Also reports missing required metrics
and whether the requested time range falls before the data retention boundary.
Numeric metric fields (volumeAvailable, volumeCapacity, volumeUsage, volumeInodes,
volumeInodesFree, volumeInodesUsed) return -1 as a sentinel when no data is
available for that field.'
row aggregates volumes in the group. Also reports whether the requested time
range falls before the data retention boundary. Numeric metric fields (volumeAvailable,
volumeCapacity, volumeUsage, volumeInodes, volumeInodesFree, volumeInodesUsed)
return -1 as a sentinel when no data is available for that field.'
operationId: ListVolumes
requestBody:
content:
@@ -15537,11 +15699,10 @@ paths:
statefulsets in the group. Supports filtering via a filter expression, custom
groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request
/ memory_limit / desired_pods / current_pods, and pagination via offset/limit.
Also reports missing required metrics and whether the requested time range
falls before the data retention boundary. Numeric metric fields (statefulSetCPU,
statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest,
statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel
when no data is available for that field.'
Also reports whether the requested time range falls before the data retention
boundary. Numeric metric fields (statefulSetCPU, statefulSetCPURequest, statefulSetCPULimit,
statefulSetMemory, statefulSetMemoryRequest, statefulSetMemoryLimit, desiredPods,
currentPods) return -1 as a sentinel when no data is available for that field.'
operationId: ListStatefulSets
requestBody:
content:

View File

@@ -90,8 +90,12 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
// initiate agent config handler
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
Store: signoz.SQLStore,
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
Store: signoz.SQLStore,
AgentFeatures: []agentConf.AgentFeature{
logParsingPipelineController,
signoz.Modules.SpanMapper,
signoz.Modules.LLMPricingRule,
},
})
if err != nil {
return nil, err

View File

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

View File

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

View File

@@ -4,14 +4,22 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
GetChecks200,
GetChecksParams,
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableDaemonSetsDTO,
InframonitoringtypesPostableDeploymentsDTO,
@@ -39,7 +47,94 @@ import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* Checks whether the metrics and attributes required to power the infra-monitoring section selected by the 'type' query parameter (hosts, processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces, clusters, volumes) are being received. For each collector receiver or processor that contributes required metrics or attributes, lists what is present and what is missing, with a prebuilt user-facing message and a docs link per missing component. Default-enabled metrics are those expected as soon as the receiver is configured; optional metrics require 'enabled: true' in receiver config. 'ready' is true only when every missing list is empty.
* @summary Run Infra Monitoring Setup Checks
*/
export const getChecks = (params: GetChecksParams, signal?: AbortSignal) => {
return GeneratedAPIInstance<GetChecks200>({
url: `/api/v2/infra_monitoring/checks`,
method: 'GET',
params,
signal,
});
};
export const getGetChecksQueryKey = (params?: GetChecksParams) => {
return [
`/api/v2/infra_monitoring/checks`,
...(params ? [params] : []),
] as const;
};
export const getGetChecksQueryOptions = <
TData = Awaited<ReturnType<typeof getChecks>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetChecksParams,
options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetChecksQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getChecks>>> = ({
signal,
}) => getChecks(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getChecks>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetChecksQueryResult = NonNullable<
Awaited<ReturnType<typeof getChecks>>
>;
export type GetChecksQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Run Infra Monitoring Setup Checks
*/
export function useGetChecks<
TData = Awaited<ReturnType<typeof getChecks>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetChecksParams,
options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetChecksQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Run Infra Monitoring Setup Checks
*/
export const invalidateGetChecks = async (
queryClient: QueryClient,
params: GetChecksParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetChecksQueryKey(params) },
options,
);
return queryClient;
};
/**
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* @summary List Clusters for Infra Monitoring
*/
export const listClusters = (
@@ -122,7 +217,7 @@ export const useListClusters = <
return useMutation(getListClustersMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes DaemonSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the daemonset, plus average CPU/memory request and limit utilization (daemonSetCPURequest, daemonSetCPULimit, daemonSetMemoryRequest, daemonSetMemoryLimit). Each row also reports the latest known node-level counters from kube-state-metrics: desiredNodes (k8s.daemonset.desired_scheduled_nodes, the number of nodes the daemonset wants to run on) and currentNodes (k8s.daemonset.current_scheduled_nodes, the number of nodes the daemonset currently runs on) — note these are node counts, not pod counts. It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each daemonset includes metadata attributes (k8s.daemonset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.daemonset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by daemonsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_nodes / current_nodes, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (daemonSetCPU, daemonSetCPURequest, daemonSetCPULimit, daemonSetMemory, daemonSetMemoryRequest, daemonSetMemoryLimit, desiredNodes, currentNodes) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes DaemonSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the daemonset, plus average CPU/memory request and limit utilization (daemonSetCPURequest, daemonSetCPULimit, daemonSetMemoryRequest, daemonSetMemoryLimit). Each row also reports the latest known node-level counters from kube-state-metrics: desiredNodes (k8s.daemonset.desired_scheduled_nodes, the number of nodes the daemonset wants to run on) and currentNodes (k8s.daemonset.current_scheduled_nodes, the number of nodes the daemonset currently runs on) — note these are node counts, not pod counts. It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each daemonset includes metadata attributes (k8s.daemonset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.daemonset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by daemonsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_nodes / current_nodes, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (daemonSetCPU, daemonSetCPURequest, daemonSetCPULimit, daemonSetMemory, daemonSetMemoryRequest, daemonSetMemoryLimit, desiredNodes, currentNodes) return -1 as a sentinel when no data is available for that field.
* @summary List DaemonSets for Infra Monitoring
*/
export const listDaemonSets = (
@@ -205,7 +300,7 @@ export const useListDaemonSets = <
return useMutation(getListDaemonSetsMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes Deployments with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest, deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each row also reports the latest known desiredPods (k8s.deployment.desired) and availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.deployment.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by deployments in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / available_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods, availablePods) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes Deployments with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest, deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each row also reports the latest known desiredPods (k8s.deployment.desired) and availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.deployment.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by deployments in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / available_pods, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods, availablePods) return -1 as a sentinel when no data is available for that field.
* @summary List Deployments for Infra Monitoring
*/
export const listDeployments = (
@@ -288,7 +383,7 @@ export const useListDeployments = <
return useMutation(getListDeploymentsMutationOptions(options));
};
/**
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
* @summary List Hosts for Infra Monitoring
*/
export const listHosts = (
@@ -371,7 +466,7 @@ export const useListHosts = <
return useMutation(getListHostsMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes Jobs with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the job, plus average CPU/memory request and limit utilization (jobCPURequest, jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the latest known job-level counters from kube-state-metrics: desiredSuccessfulPods (k8s.job.desired_successful_pods, the target completion count), activePods (k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative). It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value); note podCountsByPhase.failed (current pod-phase) is distinct from failedPods (cumulative job kube-state-metric). Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.job.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by jobs in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_successful_pods / active_pods / failed_pods / successful_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes Jobs with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the job, plus average CPU/memory request and limit utilization (jobCPURequest, jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the latest known job-level counters from kube-state-metrics: desiredSuccessfulPods (k8s.job.desired_successful_pods, the target completion count), activePods (k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative). It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value); note podCountsByPhase.failed (current pod-phase) is distinct from failedPods (cumulative job kube-state-metric). Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.job.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by jobs in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_successful_pods / active_pods / failed_pods / successful_pods, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods) return -1 as a sentinel when no data is available for that field.
* @summary List Jobs for Infra Monitoring
*/
export const listJobs = (
@@ -454,7 +549,7 @@ export const useListJobs = <
return useMutation(getListJobsMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
* @summary List Namespaces for Infra Monitoring
*/
export const listNamespaces = (
@@ -537,7 +632,7 @@ export const useListNamespaces = <
return useMutation(getListNamespacesMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* @summary List Nodes for Infra Monitoring
*/
export const listNodes = (
@@ -620,7 +715,7 @@ export const useListNodes = <
return useMutation(getListNodesMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
* @summary List Pods for Infra Monitoring
*/
export const listPods = (
@@ -703,7 +798,7 @@ export const useListPods = <
return useMutation(getListPodsMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes persistent volume claims (PVCs) with key volume metrics: available bytes, capacity bytes, usage (capacity - available), inodes, free inodes, and used inodes. Each row also includes metadata attributes (k8s.persistentvolumeclaim.name, k8s.pod.uid, k8s.pod.name, k8s.namespace.name, k8s.node.name, k8s.statefulset.name, k8s.cluster.name). Supports filtering via a filter expression, custom groupBy to aggregate volumes by any attribute, ordering by any of the six metrics (available, capacity, usage, inodes, inodes_free, inodes_used), and pagination via offset/limit. The response type is 'list' for the default k8s.persistentvolumeclaim.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates volumes in the group. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (volumeAvailable, volumeCapacity, volumeUsage, volumeInodes, volumeInodesFree, volumeInodesUsed) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes persistent volume claims (PVCs) with key volume metrics: available bytes, capacity bytes, usage (capacity - available), inodes, free inodes, and used inodes. Each row also includes metadata attributes (k8s.persistentvolumeclaim.name, k8s.pod.uid, k8s.pod.name, k8s.namespace.name, k8s.node.name, k8s.statefulset.name, k8s.cluster.name). Supports filtering via a filter expression, custom groupBy to aggregate volumes by any attribute, ordering by any of the six metrics (available, capacity, usage, inodes, inodes_free, inodes_used), and pagination via offset/limit. The response type is 'list' for the default k8s.persistentvolumeclaim.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates volumes in the group. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (volumeAvailable, volumeCapacity, volumeUsage, volumeInodes, volumeInodesFree, volumeInodesUsed) return -1 as a sentinel when no data is available for that field.
* @summary List Volumes for Infra Monitoring
*/
export const listVolumes = (
@@ -786,7 +881,7 @@ export const useListVolumes = <
return useMutation(getListVolumesMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes StatefulSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the statefulset, plus average CPU/memory request and limit utilization (statefulSetCPURequest, statefulSetCPULimit, statefulSetMemoryRequest, statefulSetMemoryLimit). Each row also reports the latest known desiredPods (k8s.statefulset.desired_pods) and currentPods (k8s.statefulset.current_pods) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each statefulset includes metadata attributes (k8s.statefulset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.statefulset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by statefulsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / current_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (statefulSetCPU, statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest, statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes StatefulSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the statefulset, plus average CPU/memory request and limit utilization (statefulSetCPURequest, statefulSetCPULimit, statefulSetMemoryRequest, statefulSetMemoryLimit). Each row also reports the latest known desiredPods (k8s.statefulset.desired_pods) and currentPods (k8s.statefulset.current_pods) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each statefulset includes metadata attributes (k8s.statefulset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.statefulset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by statefulsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / current_pods, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (statefulSetCPU, statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest, statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel when no data is available for that field.
* @summary List StatefulSets for Infra Monitoring
*/
export const listStatefulSets = (

View File

@@ -5458,6 +5458,121 @@ export interface GlobaltypesConfigDTO {
mcp_url: string | null;
}
export enum InframonitoringtypesCheckComponentTypeDTO {
receiver = 'receiver',
processor = 'processor',
}
export interface InframonitoringtypesAssociatedComponentDTO {
/**
* @type string
*/
name: string;
type: InframonitoringtypesCheckComponentTypeDTO;
}
export interface InframonitoringtypesAttributesComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
/**
* @type array,null
*/
attributes: string[] | null;
}
export enum InframonitoringtypesCheckTypeDTO {
hosts = 'hosts',
processes = 'processes',
pods = 'pods',
nodes = 'nodes',
deployments = 'deployments',
daemonsets = 'daemonsets',
statefulsets = 'statefulsets',
jobs = 'jobs',
namespaces = 'namespaces',
clusters = 'clusters',
volumes = 'volumes',
}
export interface InframonitoringtypesMissingMetricsComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
/**
* @type string
*/
documentationLink: string;
/**
* @type string
*/
message: string;
/**
* @type array,null
*/
metrics: string[] | null;
}
export interface InframonitoringtypesMissingAttributesComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
/**
* @type array,null
*/
attributes: string[] | null;
/**
* @type string
*/
documentationLink: string;
/**
* @type string
*/
message: string;
}
export interface InframonitoringtypesMetricsComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
/**
* @type array,null
*/
metrics: string[] | null;
}
export interface InframonitoringtypesChecksDTO {
/**
* @type array,null
*/
missingDefaultEnabledMetrics:
| InframonitoringtypesMissingMetricsComponentEntryDTO[]
| null;
/**
* @type array,null
*/
missingOptionalMetrics:
| InframonitoringtypesMissingMetricsComponentEntryDTO[]
| null;
/**
* @type array,null
*/
missingRequiredAttributes:
| InframonitoringtypesMissingAttributesComponentEntryDTO[]
| null;
/**
* @type array,null
*/
presentDefaultEnabledMetrics:
| InframonitoringtypesMetricsComponentEntryDTO[]
| null;
/**
* @type array,null
*/
presentOptionalMetrics: InframonitoringtypesMetricsComponentEntryDTO[] | null;
/**
* @type array,null
*/
presentRequiredAttributes:
| InframonitoringtypesAttributesComponentEntryDTO[]
| null;
/**
* @type boolean
*/
ready: boolean;
type: InframonitoringtypesCheckTypeDTO;
}
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -5535,13 +5650,6 @@ export interface InframonitoringtypesClusterRecordDTO {
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
}
export interface InframonitoringtypesRequiredMetricsCheckDTO {
/**
* @type array,null
*/
missingMetrics: string[] | null;
}
export enum InframonitoringtypesResponseTypeDTO {
list = 'list',
grouped_list = 'grouped_list',
@@ -5577,7 +5685,6 @@ export interface InframonitoringtypesClustersDTO {
* @type array
*/
records: InframonitoringtypesClusterRecordDTO[];
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
@@ -5655,7 +5762,6 @@ export interface InframonitoringtypesDaemonSetsDTO {
* @type array
*/
records: InframonitoringtypesDaemonSetRecordDTO[];
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
@@ -5733,7 +5839,6 @@ export interface InframonitoringtypesDeploymentsDTO {
* @type array
*/
records: InframonitoringtypesDeploymentRecordDTO[];
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
@@ -5819,7 +5924,6 @@ export interface InframonitoringtypesHostsDTO {
* @type array
*/
records: InframonitoringtypesHostRecordDTO[];
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
@@ -5905,7 +6009,6 @@ export interface InframonitoringtypesJobsDTO {
* @type array
*/
records: InframonitoringtypesJobRecordDTO[];
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
@@ -5955,7 +6058,6 @@ export interface InframonitoringtypesNamespacesDTO {
* @type array
*/
records: InframonitoringtypesNamespaceRecordDTO[];
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
@@ -6022,7 +6124,6 @@ export interface InframonitoringtypesNodesDTO {
* @type array
*/
records: InframonitoringtypesNodeRecordDTO[];
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
@@ -6106,7 +6207,6 @@ export interface InframonitoringtypesPodsDTO {
* @type array
*/
records: InframonitoringtypesPodRecordDTO[];
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
@@ -6454,7 +6554,6 @@ export interface InframonitoringtypesStatefulSetsDTO {
* @type array
*/
records: InframonitoringtypesStatefulSetRecordDTO[];
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
@@ -6523,7 +6622,6 @@ export interface InframonitoringtypesVolumesDTO {
* @type array
*/
records: InframonitoringtypesVolumeRecordDTO[];
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
@@ -10246,6 +10344,21 @@ export type Healthz503 = {
status: string;
};
export type GetChecksParams = {
/**
* @description undefined
*/
type: InframonitoringtypesCheckTypeDTO;
};
export type GetChecks200 = {
data: InframonitoringtypesChecksDTO;
/**
* @type string
*/
status: string;
};
export type ListClusters200 = {
data: InframonitoringtypesClustersDTO;
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
});
});
});

View File

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

View File

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

View File

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

View File

@@ -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,
};
}

View File

@@ -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,
]);
}

View File

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

View File

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

View File

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

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

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

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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();
});
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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;

View File

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

View File

@@ -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;
}

View File

@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import cx from 'classnames';
import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { RoleListPermission } from 'hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
@@ -16,7 +16,7 @@ import { useTimezone } from 'providers/Timezone';
import { RoleType } from 'types/roles';
import { toAPIError } from 'utils/errorUtils';
import '../RolesSettings.styles.scss';
import styles from './RolesListingTable.module.scss';
const PAGE_SIZE = 20;
@@ -41,7 +41,7 @@ function RolesListingTable({
const { data, isLoading, isError, error } = useListRoles({
query: { enabled: hasListPermission },
});
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
@@ -57,19 +57,6 @@ function RolesListingTable({
const roles = useMemo(() => data?.data ?? [], [data]);
const formatTimestamp = (date?: Date | string): string => {
if (!date) {
return '—';
}
const d = new Date(date);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
};
const filteredRoles = useMemo(() => {
if (!searchQuery.trim()) {
return roles;
@@ -95,7 +82,6 @@ function RolesListingTable({
[filteredRoles],
);
// Combine managed + custom into a flat display list for pagination
const displayList = useMemo((): DisplayItem[] => {
const result: DisplayItem[] = [];
@@ -116,7 +102,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 +112,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 +124,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 +136,16 @@ function RolesListingTable({
return result;
}, [displayList, currentPage]);
const handleRowClick = useCallback(
(roleId: string, roleName: string): void => {
if (isRolesEnabled) {
const url = `${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}?name=${encodeURIComponent(roleName)}`;
history.push(url);
}
},
[isRolesEnabled, history],
);
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
<>
<span className="numbers">
@@ -168,7 +161,7 @@ function RolesListingTable({
if (isAuthZLoading || isLoading) {
return (
<div className="roles-listing-table">
<div className={styles.rolesListingTable}>
<Skeleton active paragraph={{ rows: 5 }} />
</div>
);
@@ -176,7 +169,7 @@ function RolesListingTable({
if (isError) {
return (
<div className="roles-listing-table">
<div className={styles.rolesListingTable}>
<ErrorInPlace
error={toAPIError(
error,
@@ -189,31 +182,27 @@ function RolesListingTable({
if (filteredRoles.length === 0) {
return (
<div className="roles-listing-table">
<div className="roles-table-empty">
<div className={styles.rolesListingTable}>
<div className={styles.emptyState}>
{searchQuery ? 'No roles match your search.' : 'No roles found.'}
</div>
</div>
);
}
const navigateToRole = (roleId: string, roleName?: string): void => {
const search = roleName ? `?name=${encodeURIComponent(roleName)}` : '';
history.push(`${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}${search}`);
};
// todo: use table from periscope when its available for consumption
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
<div
key={role.id}
className={`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,56 +210,54 @@ 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">
{formatTimestamp(role.updatedAt)}
<div className={cx(styles.tableCell, styles.tableCellUpdatedAt)}>
{formatTimezoneAdjustedTimestampOptional(role.updatedAt)}
</div>
<div className="roles-table-cell roles-table-cell--created-at">
{formatTimestamp(role.createdAt)}
<div className={cx(styles.tableCell, styles.tableCellCreatedAt)}>
{formatTimezoneAdjustedTimestampOptional(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">
<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="roles-table-header-cell roles-table-header-cell--updated-at">
<div className={cx(styles.headerCell, styles.headerCellUpdatedAt)}>
Updated At
</div>
<div className="roles-table-header-cell roles-table-header-cell--created-at">
<div className={cx(styles.headerCell, styles.headerCellCreatedAt)}>
Created At
</div>
</div>
{paginatedItems.map((item) =>
item.type === 'section' ? (
<h3 key={`section-${item.label}`} className="roles-table-section-header">
<h3 key={`section-${item.label}`} className={styles.sectionHeader}>
{item.label}
{item.count !== undefined && (
<span className="roles-table-section-header__count">{item.count}</span>
<span className={styles.sectionHeaderCount}>{item.count}</span>
)}
</h3>
) : (
@@ -288,7 +275,7 @@ function RolesListingTable({
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => setCurrentPage(page)}
className="roles-table-pagination"
className={styles.pagination}
/>
</div>
);

View 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;
}
}

View File

@@ -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);
}
}

View File

@@ -1,39 +1,42 @@
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">
Create and manage custom roles for your team.{' '}
<div data-testid="roles-settings">
<div className={styles.rolesSettingsHeader}>
<h3 className={styles.rolesSettingsHeaderTitle}>Roles</h3>
<p className={styles.rolesSettingsHeaderDescription}>
{isRolesEnabled
? 'Create and manage custom roles for your team. '
: 'The built-in roles of this instance.'}{' '}
<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 +48,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 +59,6 @@ function RolesSettings(): JSX.Element {
</div>
<RolesListingTable searchQuery={searchQuery} />
</div>
<CreateRoleModal
isOpen={isCreateModalOpen}
onClose={(): void => setIsCreateModalOpen(false)}
/>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,9 +6,9 @@ import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
defaultFeatureFlags,
fireEvent,
render,
screen,
userEvent,
} from 'tests/test-utils';
import { FeatureKeys } from 'constants/features';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
@@ -36,13 +36,13 @@ describe('RolesSettings', () => {
server.resetHandlers();
});
it('renders the header and search input', () => {
it('renders the header and search input', async () => {
render(<RolesSettings />);
expect(screen.getByText('Roles')).toBeInTheDocument();
expect(
screen.getByText('Create and manage custom roles for your team.'),
).toBeInTheDocument();
await expect(screen.findByText('Roles')).resolves.toBeInTheDocument();
await expect(
screen.findByText('Create and manage custom roles for your team.'),
).resolves.toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search for roles...'),
).toBeInTheDocument();
@@ -54,36 +54,41 @@ describe('RolesSettings', () => {
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
// Section headers
expect(screen.getByText('Managed roles')).toBeInTheDocument();
expect(screen.getByText('Custom roles')).toBeInTheDocument();
await expect(screen.findByText('Managed roles')).resolves.toBeInTheDocument();
await expect(screen.findByText('Custom roles')).resolves.toBeInTheDocument();
// Managed roles
expect(screen.getByText('signoz-admin')).toBeInTheDocument();
expect(screen.getByText('signoz-editor')).toBeInTheDocument();
expect(screen.getByText('signoz-viewer')).toBeInTheDocument();
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
await expect(screen.findByText('signoz-editor')).resolves.toBeInTheDocument();
await expect(screen.findByText('signoz-viewer')).resolves.toBeInTheDocument();
// Custom roles
expect(screen.getByText('billing-manager')).toBeInTheDocument();
expect(screen.getByText('dashboard-creator')).toBeInTheDocument();
await expect(
screen.findByText('billing-manager'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('dashboard-creator'),
).resolves.toBeInTheDocument();
// Custom roles count badge
expect(screen.getByText('2')).toBeInTheDocument();
await expect(screen.findByText('2')).resolves.toBeInTheDocument();
// Column headers
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Updated At')).toBeInTheDocument();
expect(screen.getByText('Created At')).toBeInTheDocument();
await expect(screen.findByText('Name')).resolves.toBeInTheDocument();
await expect(screen.findByText('Description')).resolves.toBeInTheDocument();
await expect(screen.findByText('Updated At')).resolves.toBeInTheDocument();
await expect(screen.findByText('Created At')).resolves.toBeInTheDocument();
});
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 +99,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 +114,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.'),
@@ -177,7 +184,9 @@ describe('RolesSettings', () => {
for (const role of allRoles) {
if (role.description) {
expect(screen.getByText(role.description)).toBeInTheDocument();
await expect(
screen.findByText(role.description),
).resolves.toBeInTheDocument();
}
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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