Compare commits

..

5 Commits

Author SHA1 Message Date
Nagesh Bansal
67418bb132 docs: fix the signoz docs troubleshooting guide url 2026-06-25 07:42:57 +05:30
Nagesh Bansal
2e2517449d docs(deploy): title migration guide for both install script and deploy 2026-06-25 06:52:36 +05:30
Nagesh Bansal
0150c55361 docs(deploy): restructure migration guide and add Docker Swarm 2026-06-25 06:38:48 +05:30
Abir Roy
a609a4044c fix(ui): resolve monaco find widget clipping and flickering (#11826)
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
2026-06-24 20:11:11 +00:00
Nikhil Mantri
f78d98ea71 feat(metrics-explorer): move metric_name from path param to query param (#11745)
* chore: metricName to post body for POST /api/v2/metrics/{metric_name}/metadata

* chore: metricName to query param for GET /api/v2/metrics/{metric_name}/metadata

* chore: added metricName in api get metric attributes

* chore: highlights api modified

* chore: alerts api modified

* chore: dashboards api modified

* chore: description added for metric_name query params

* feat(metrics-explorer): integrate metricName query/body API change in frontend (#11818)

* feat(metrics-explorer): integrate metricName query/body API change in frontend

The metrics-explorer endpoints moved metric_name off the URL path: the
five GETs (attributes, metadata, highlights, alerts, dashboards) now take
a required `metricName` query param, and POST /metadata reads metricName
from the request body.

- Regenerate the orval client from the updated openapi spec, so the GET
  helpers build `/api/v2/metrics/<op>?metricName=...` (URL-encoded, so
  slashed cloud metric names work) and updateMetricMetadata posts to
  `/api/v2/metrics/metadata` with metricName in the body.
- Collapse the useGetMetricAttributes call to the single merged params
  object (metricName + start/end).
- Drop the now-removed pathParams wrapper from both updateMetricMetadata
  call sites; the payload builders already include metricName in the body.
- Update the Metadata test to assert metricName inside the request body.

* revert(metrics-explorer): drop slashed-metric-name band-aid guards

These two defensive guards were added as temporary workarounds for the
metric_name-with-slash bug (SigNoz/signoz#11527, #11528), which returned
200 + HTML instead of JSON. The root cause is fixed by moving metricName
to a query/body param, so the band-aids are no longer needed and revert
to the original intended code.

- MetricDetails.tsx: `!metricMetadataResponse?.data` -> `!metricMetadataResponse`
- AllAttributes.tsx: `?.data?.attributes` -> `?.data.attributes`

* chore: added description for metricName query params

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-24 19:40:09 +00:00
124 changed files with 4279 additions and 10350 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,165 @@ 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, see
[Troubleshooting Foundry][troubleshooting] for how to capture what we need to
help (the `--debug` output, the exit code, and your `casting.yaml`), then reach
out on [Slack][slack].
## 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/
[troubleshooting]: https://signoz.io/docs/setup/foundry/troubleshooting/faq/
[slack]: https://signoz.io/slack
[signoz-docs]: https://signoz.io/docs

View File

@@ -15700,16 +15700,20 @@ paths:
summary: List metric names
tags:
- metrics
/api/v2/metrics/{metric_name}/alerts:
/api/v2/metrics/alerts:
get:
deprecated: false
description: This endpoint returns associated alerts for a specified metric
operationId: GetMetricAlerts
parameters:
- in: path
name: metric_name
- description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
in: query
name: metricName
required: true
schema:
description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
type: string
responses:
"200":
@@ -15764,28 +15768,36 @@ paths:
summary: Get metric alerts
tags:
- metrics
/api/v2/metrics/{metric_name}/attributes:
/api/v2/metrics/attributes:
get:
deprecated: false
description: This endpoint returns attribute keys and their unique values for
a specified metric
operationId: GetMetricAttributes
parameters:
- in: query
name: start
schema:
nullable: true
type: integer
- in: query
name: end
schema:
nullable: true
type: integer
- in: path
name: metric_name
- description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
in: query
name: metricName
required: true
schema:
description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
type: string
- description: Start of the time range as a Unix timestamp in milliseconds.
in: query
name: start
schema:
description: Start of the time range as a Unix timestamp in milliseconds.
nullable: true
type: integer
- description: End of the time range as a Unix timestamp in milliseconds.
in: query
name: end
schema:
description: End of the time range as a Unix timestamp in milliseconds.
nullable: true
type: integer
responses:
"200":
content:
@@ -15839,16 +15851,20 @@ paths:
summary: Get metric attributes
tags:
- metrics
/api/v2/metrics/{metric_name}/dashboards:
/api/v2/metrics/dashboards:
get:
deprecated: false
description: This endpoint returns associated dashboards for a specified metric
operationId: GetMetricDashboards
parameters:
- in: path
name: metric_name
- description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
in: query
name: metricName
required: true
schema:
description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
type: string
responses:
"200":
@@ -15903,17 +15919,21 @@ paths:
summary: Get metric dashboards
tags:
- metrics
/api/v2/metrics/{metric_name}/highlights:
/api/v2/metrics/highlights:
get:
deprecated: false
description: This endpoint returns highlights like number of datapoints, totaltimeseries,
active time series, last received time for a specified metric
operationId: GetMetricHighlights
parameters:
- in: path
name: metric_name
- description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
in: query
name: metricName
required: true
schema:
description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
type: string
responses:
"200":
@@ -15968,17 +15988,79 @@ paths:
summary: Get metric highlights
tags:
- metrics
/api/v2/metrics/{metric_name}/metadata:
/api/v2/metrics/inspect:
post:
deprecated: false
description: Returns raw time series data points for a metric within a time
range (max 30 minutes). Each series includes labels and timestamp/value pairs.
operationId: InspectMetrics
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsResponse'
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: Inspect raw metric data points
tags:
- metrics
/api/v2/metrics/metadata:
get:
deprecated: false
description: This endpoint returns metadata information like metric description,
unit, type, temporality, monotonicity for a specified metric
operationId: GetMetricMetadata
parameters:
- in: path
name: metric_name
- description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
in: query
name: metricName
required: true
schema:
description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
type: string
responses:
"200":
@@ -16038,12 +16120,6 @@ paths:
description: This endpoint helps to update metadata information like metric
description, unit, type, temporality, monotonicity for a specified metric
operationId: UpdateMetricMetadata
parameters:
- in: path
name: metric_name
required: true
schema:
type: string
requestBody:
content:
application/json:
@@ -16084,64 +16160,6 @@ paths:
summary: Update metric metadata
tags:
- metrics
/api/v2/metrics/inspect:
post:
deprecated: false
description: Returns raw time series data points for a metric within a time
range (max 30 minutes). Each series includes labels and timestamp/value pairs.
operationId: InspectMetrics
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsResponse'
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: Inspect raw metric data points
tags:
- metrics
/api/v2/metrics/onboarding:
get:
deprecated: false

View File

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

View File

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

View File

@@ -19,16 +19,15 @@ import type {
import type {
GetMetricAlerts200,
GetMetricAlertsPathParameters,
GetMetricAlertsParams,
GetMetricAttributes200,
GetMetricAttributesParams,
GetMetricAttributesPathParameters,
GetMetricDashboards200,
GetMetricDashboardsPathParameters,
GetMetricDashboardsParams,
GetMetricHighlights200,
GetMetricHighlightsPathParameters,
GetMetricHighlightsParams,
GetMetricMetadata200,
GetMetricMetadataPathParameters,
GetMetricMetadataParams,
GetMetricsOnboardingStatus200,
GetMetricsStats200,
GetMetricsTreemap200,
@@ -40,7 +39,6 @@ import type {
MetricsexplorertypesTreemapRequestDTO,
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
RenderErrorResponseDTO,
UpdateMetricMetadataPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -146,27 +144,26 @@ export const invalidateListMetrics = async (
* @summary Get metric alerts
*/
export const getMetricAlerts = (
{ metricName }: GetMetricAlertsPathParameters,
params: GetMetricAlertsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAlerts200>({
url: `/api/v2/metrics/${metricName}/alerts`,
url: `/api/v2/metrics/alerts`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricAlertsQueryKey = ({
metricName,
}: GetMetricAlertsPathParameters) => {
return [`/api/v2/metrics/${metricName}/alerts`] as const;
export const getGetMetricAlertsQueryKey = (params?: GetMetricAlertsParams) => {
return [`/api/v2/metrics/alerts`, ...(params ? [params] : [])] as const;
};
export const getGetMetricAlertsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricAlertsPathParameters,
params: GetMetricAlertsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -177,19 +174,13 @@ export const getGetMetricAlertsQueryOptions = <
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricAlertsQueryKey({ metricName });
const queryKey = queryOptions?.queryKey ?? getGetMetricAlertsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMetricAlerts>>> = ({
signal,
}) => getMetricAlerts({ metricName }, signal);
}) => getMetricAlerts(params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
TError,
TData
@@ -209,7 +200,7 @@ export function useGetMetricAlerts<
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricAlertsPathParameters,
params: GetMetricAlertsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -218,7 +209,7 @@ export function useGetMetricAlerts<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricAlertsQueryOptions({ metricName }, options);
const queryOptions = getGetMetricAlertsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -232,11 +223,11 @@ export function useGetMetricAlerts<
*/
export const invalidateGetMetricAlerts = async (
queryClient: QueryClient,
{ metricName }: GetMetricAlertsPathParameters,
params: GetMetricAlertsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricAlertsQueryKey({ metricName }) },
{ queryKey: getGetMetricAlertsQueryKey(params) },
options,
);
@@ -248,12 +239,11 @@ export const invalidateGetMetricAlerts = async (
* @summary Get metric attributes
*/
export const getMetricAttributes = (
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
params: GetMetricAttributesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAttributes200>({
url: `/api/v2/metrics/${metricName}/attributes`,
url: `/api/v2/metrics/attributes`,
method: 'GET',
params,
signal,
@@ -261,21 +251,16 @@ export const getMetricAttributes = (
};
export const getGetMetricAttributesQueryKey = (
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
) => {
return [
`/api/v2/metrics/${metricName}/attributes`,
...(params ? [params] : []),
] as const;
return [`/api/v2/metrics/attributes`, ...(params ? [params] : [])] as const;
};
export const getGetMetricAttributesQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
params: GetMetricAttributesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
@@ -287,19 +272,13 @@ export const getGetMetricAttributesQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetMetricAttributesQueryKey({ metricName }, params);
queryOptions?.queryKey ?? getGetMetricAttributesQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricAttributes>>
> = ({ signal }) => getMetricAttributes({ metricName }, params, signal);
> = ({ signal }) => getMetricAttributes(params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
TData
@@ -319,8 +298,7 @@ export function useGetMetricAttributes<
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
params: GetMetricAttributesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
@@ -329,11 +307,7 @@ export function useGetMetricAttributes<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricAttributesQueryOptions(
{ metricName },
params,
options,
);
const queryOptions = getGetMetricAttributesQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -347,12 +321,11 @@ export function useGetMetricAttributes<
*/
export const invalidateGetMetricAttributes = async (
queryClient: QueryClient,
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
params: GetMetricAttributesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricAttributesQueryKey({ metricName }, params) },
{ queryKey: getGetMetricAttributesQueryKey(params) },
options,
);
@@ -364,27 +337,28 @@ export const invalidateGetMetricAttributes = async (
* @summary Get metric dashboards
*/
export const getMetricDashboards = (
{ metricName }: GetMetricDashboardsPathParameters,
params: GetMetricDashboardsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboards200>({
url: `/api/v2/metrics/${metricName}/dashboards`,
url: `/api/v2/metrics/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricDashboardsQueryKey = ({
metricName,
}: GetMetricDashboardsPathParameters) => {
return [`/api/v2/metrics/${metricName}/dashboards`] as const;
export const getGetMetricDashboardsQueryKey = (
params?: GetMetricDashboardsParams,
) => {
return [`/api/v2/metrics/dashboards`, ...(params ? [params] : [])] as const;
};
export const getGetMetricDashboardsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricDashboardsPathParameters,
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -396,18 +370,13 @@ export const getGetMetricDashboardsQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey({ metricName });
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricDashboards>>
> = ({ signal }) => getMetricDashboards({ metricName }, signal);
> = ({ signal }) => getMetricDashboards(params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
TError,
TData
@@ -427,7 +396,7 @@ export function useGetMetricDashboards<
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricDashboardsPathParameters,
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -436,10 +405,7 @@ export function useGetMetricDashboards<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricDashboardsQueryOptions(
{ metricName },
options,
);
const queryOptions = getGetMetricDashboardsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -453,11 +419,11 @@ export function useGetMetricDashboards<
*/
export const invalidateGetMetricDashboards = async (
queryClient: QueryClient,
{ metricName }: GetMetricDashboardsPathParameters,
params: GetMetricDashboardsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricDashboardsQueryKey({ metricName }) },
{ queryKey: getGetMetricDashboardsQueryKey(params) },
options,
);
@@ -469,27 +435,28 @@ export const invalidateGetMetricDashboards = async (
* @summary Get metric highlights
*/
export const getMetricHighlights = (
{ metricName }: GetMetricHighlightsPathParameters,
params: GetMetricHighlightsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricHighlights200>({
url: `/api/v2/metrics/${metricName}/highlights`,
url: `/api/v2/metrics/highlights`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricHighlightsQueryKey = ({
metricName,
}: GetMetricHighlightsPathParameters) => {
return [`/api/v2/metrics/${metricName}/highlights`] as const;
export const getGetMetricHighlightsQueryKey = (
params?: GetMetricHighlightsParams,
) => {
return [`/api/v2/metrics/highlights`, ...(params ? [params] : [])] as const;
};
export const getGetMetricHighlightsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricHighlightsPathParameters,
params: GetMetricHighlightsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -501,18 +468,13 @@ export const getGetMetricHighlightsQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey({ metricName });
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricHighlights>>
> = ({ signal }) => getMetricHighlights({ metricName }, signal);
> = ({ signal }) => getMetricHighlights(params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
TError,
TData
@@ -532,7 +494,7 @@ export function useGetMetricHighlights<
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricHighlightsPathParameters,
params: GetMetricHighlightsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -541,10 +503,7 @@ export function useGetMetricHighlights<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricHighlightsQueryOptions(
{ metricName },
options,
);
const queryOptions = getGetMetricHighlightsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -558,219 +517,17 @@ export function useGetMetricHighlights<
*/
export const invalidateGetMetricHighlights = async (
queryClient: QueryClient,
{ metricName }: GetMetricHighlightsPathParameters,
params: GetMetricHighlightsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricHighlightsQueryKey({ metricName }) },
{ queryKey: getGetMetricHighlightsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Get metric metadata
*/
export const getMetricMetadata = (
{ metricName }: GetMetricMetadataPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
url: `/api/v2/metrics/${metricName}/metadata`,
method: 'GET',
signal,
});
};
export const getGetMetricMetadataQueryKey = ({
metricName,
}: GetMetricMetadataPathParameters) => {
return [`/api/v2/metrics/${metricName}/metadata`] as const;
};
export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey({ metricName });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricMetadata>>
> = ({ signal }) => getMetricMetadata({ metricName }, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricMetadata>>
>;
export type GetMetricMetadataQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get metric metadata
*/
export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricMetadataQueryOptions({ metricName }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get metric metadata
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
{ metricName }: GetMetricMetadataPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricMetadataQueryKey({ metricName }) },
options,
);
return queryClient;
};
/**
* This endpoint helps to update metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Update metric metadata
*/
export const updateMetricMetadata = (
{ metricName }: UpdateMetricMetadataPathParameters,
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/metrics/${metricName}/metadata`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricsexplorertypesUpdateMetricMetadataRequestDTO,
signal,
});
};
export const getUpdateMetricMetadataMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{
pathParams: UpdateMetricMetadataPathParameters;
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{
pathParams: UpdateMetricMetadataPathParameters;
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
},
TContext
> => {
const mutationKey = ['updateMetricMetadata'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateMetricMetadata>>,
{
pathParams: UpdateMetricMetadataPathParameters;
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateMetricMetadata(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateMetricMetadataMutationResult = NonNullable<
Awaited<ReturnType<typeof updateMetricMetadata>>
>;
export type UpdateMetricMetadataMutationBody =
| BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>
| undefined;
export type UpdateMetricMetadataMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update metric metadata
*/
export const useUpdateMetricMetadata = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{
pathParams: UpdateMetricMetadataPathParameters;
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{
pathParams: UpdateMetricMetadataPathParameters;
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
},
TContext
> => {
return useMutation(getUpdateMetricMetadataMutationOptions(options));
};
/**
* Returns raw time series data points for a metric within a time range (max 30 minutes). Each series includes labels and timestamp/value pairs.
* @summary Inspect raw metric data points
@@ -854,6 +611,188 @@ export const useInspectMetrics = <
> => {
return useMutation(getInspectMetricsMutationOptions(options));
};
/**
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Get metric metadata
*/
export const getMetricMetadata = (
params: GetMetricMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
url: `/api/v2/metrics/metadata`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricMetadataQueryKey = (
params?: GetMetricMetadataParams,
) => {
return [`/api/v2/metrics/metadata`, ...(params ? [params] : [])] as const;
};
export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricMetadata>>
> = ({ signal }) => getMetricMetadata(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricMetadata>>
>;
export type GetMetricMetadataQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get metric metadata
*/
export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricMetadataQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get metric metadata
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
params: GetMetricMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricMetadataQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint helps to update metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Update metric metadata
*/
export const updateMetricMetadata = (
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/metrics/metadata`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricsexplorertypesUpdateMetricMetadataRequestDTO,
signal,
});
};
export const getUpdateMetricMetadataMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
TContext
> => {
const mutationKey = ['updateMetricMetadata'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateMetricMetadata>>,
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> }
> = (props) => {
const { data } = props ?? {};
return updateMetricMetadata(data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateMetricMetadataMutationResult = NonNullable<
Awaited<ReturnType<typeof updateMetricMetadata>>
>;
export type UpdateMetricMetadataMutationBody =
| BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>
| undefined;
export type UpdateMetricMetadataMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update metric metadata
*/
export const useUpdateMetricMetadata = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
TContext
> => {
return useMutation(getUpdateMetricMetadataMutationOptions(options));
};
/**
* Lightweight endpoint that checks if any non-SigNoz metrics have been ingested, used for onboarding status detection
* @summary Check if non-SigNoz metrics have been received

View File

@@ -10370,9 +10370,14 @@ export type ListMetrics200 = {
status: string;
};
export type GetMetricAlertsPathParameters = {
export type GetMetricAlertsParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricAlerts200 = {
data: MetricsexplorertypesMetricAlertsResponseDTO;
/**
@@ -10381,18 +10386,20 @@ export type GetMetricAlerts200 = {
status: string;
};
export type GetMetricAttributesPathParameters = {
metricName: string;
};
export type GetMetricAttributesParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
/**
* @type integer,null
* @description undefined
* @description Start of the time range as a Unix timestamp in milliseconds.
*/
start?: number | null;
/**
* @type integer,null
* @description undefined
* @description End of the time range as a Unix timestamp in milliseconds.
*/
end?: number | null;
};
@@ -10405,9 +10412,14 @@ export type GetMetricAttributes200 = {
status: string;
};
export type GetMetricDashboardsPathParameters = {
export type GetMetricDashboardsParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricDashboards200 = {
data: MetricsexplorertypesMetricDashboardsResponseDTO;
/**
@@ -10416,9 +10428,14 @@ export type GetMetricDashboards200 = {
status: string;
};
export type GetMetricHighlightsPathParameters = {
export type GetMetricHighlightsParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricHighlights200 = {
data: MetricsexplorertypesMetricHighlightsResponseDTO;
/**
@@ -10427,22 +10444,24 @@ export type GetMetricHighlights200 = {
status: string;
};
export type GetMetricMetadataPathParameters = {
metricName: string;
};
export type GetMetricMetadata200 = {
data: MetricsexplorertypesMetricMetadataDTO;
export type InspectMetrics200 = {
data: MetricsexplorertypesInspectMetricsResponseDTO;
/**
* @type string
*/
status: string;
};
export type UpdateMetricMetadataPathParameters = {
export type GetMetricMetadataParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type InspectMetrics200 = {
data: MetricsexplorertypesInspectMetricsResponseDTO;
export type GetMetricMetadata200 = {
data: MetricsexplorertypesMetricMetadataDTO;
/**
* @type string
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -191,9 +191,6 @@ function TimeSeries({
if (metrics[0] && yAxisUnit) {
updateMetricMetadata(
{
pathParams: {
metricName: metricNames[0],
},
data: buildUpdateMetricYAxisUnitPayload(
metricNames[0],
metrics[0],

View File

@@ -48,18 +48,14 @@ function AllAttributes({
isLoading: isLoadingAttributes,
isError: isErrorAttributes,
refetch: refetchAttributes,
} = useGetMetricAttributes(
{
metricName,
},
{
start: minTime ? Math.floor(minTime / 1000000) : undefined,
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
},
);
} = useGetMetricAttributes({
metricName,
start: minTime ? Math.floor(minTime / 1000000) : undefined,
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
});
const attributes = useMemo(
() => attributesData?.data?.attributes ?? [],
() => attributesData?.data.attributes ?? [],
[attributesData],
);

View File

@@ -237,9 +237,6 @@ function Metadata({
const handleSave = useCallback(() => {
updateMetricMetadata(
{
pathParams: {
metricName,
},
data: transformUpdateMetricMetadataRequest(metricName, metricMetadataState),
},
{

View File

@@ -56,7 +56,7 @@ function MetricDetails({
);
const metadata = useMemo(() => {
if (!metricMetadataResponse?.data) {
if (!metricMetadataResponse) {
return null;
}
const { type, description, unit, temporality, isMonotonic } =

View File

@@ -195,14 +195,12 @@ describe('Metadata', () => {
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
metricName: MOCK_METRIC_NAME,
type: MetrictypesTypeDTO.sum,
temporality: MetrictypesTemporalityDTO.cumulative,
unit: 'By',
isMonotonic: true,
}),
pathParams: {
metricName: MOCK_METRIC_NAME,
},
}),
expect.objectContaining({
onSuccess: expect.any(Function),

View File

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

View File

@@ -1,289 +0,0 @@
import { useCallback, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { ArrowLeft, SolidAlertTriangle } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import ROUTES from 'constants/routes';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import useUrlQuery from 'hooks/useUrlQuery';
import APIError from 'types/api/error';
import PermissionEditor from './components/PermissionEditor';
import { useCreateEditRolePageCallbacks } from './useCreateEditRolePageCallbacks';
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,
} = useCreateEditRolePageCallbacks(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} />
<span className={styles.unsavedText}>Unsaved changes</span>
</div>
)}
<Button
variant="solid"
color="primary"
onClick={handleSaveAndNavigate}
loading={isSaving}
disabled={!hasUnsavedChanges || hasJsonError}
data-testid="save-button"
>
{isCreateMode ? 'Create role' : 'Save changes'}
</Button>
</div>
</div>
{saveError && (
<ErrorInPlace
error={saveError}
height="auto"
bordered
data-testid="save-error-banner"
/>
)}
<div className={styles.createEditRolePageContent}>
<div className={styles.createEditRolePageForm}>
<div className={styles.formRow}>
{isCreateMode ? (
<div className={styles.formField}>
<label htmlFor="role-name" className={styles.formLabel}>
Name
</label>
<Input
id="role-name"
value={formData.name}
onChange={(e): void => handleFormChange('name', e.target.value)}
placeholder="my-custom-role"
data-testid="role-name-input"
/>
</div>
) : null}
<div className={styles.formField}>
<label htmlFor="role-description" className={styles.formLabel}>
Description
</label>
<Input
id="role-description"
value={formData.description}
onChange={(e): void => handleFormChange('description', e.target.value)}
placeholder="Custom role for the support team"
data-testid="role-description-input"
/>
</div>
</div>
</div>
<div className={styles.createEditRolePageDivider} />
<PermissionEditor
resources={resources}
mode={editorMode}
onModeChange={setEditorMode}
onResourceChange={setResources}
onJsonValidityChange={setHasJsonError}
isLoading={isLoading}
validationErrors={validationErrors}
/>
</div>
<ConfirmDialog
open={isBlocked}
onOpenChange={(next): void => {
if (!next) {
cancelNavigation();
}
}}
title="Discard unsaved changes?"
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
confirmText="Discard"
confirmColor="destructive"
cancelText="Keep editing"
onConfirm={confirmNavigation}
onCancel={cancelNavigation}
data-testid="discard-changes-dialog"
>
<Typography>
{isCreateMode
? 'This new role will not be created.'
: 'Your unsaved changes will be lost.'}
</Typography>
</ConfirmDialog>
</div>
);
}
export default CreateEditRolePage;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,219 +0,0 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useState,
useRef,
} from 'react';
import { useCopyToClipboard } from 'react-use';
import MEditor, { Monaco, OnMount } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Check, Copy } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import 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">
<span className={styles.jsonEditorErrorLabel}>Parse Error:</span>
<span className={styles.jsonEditorErrorMessage}>{parseError}</span>
</div>
)}
{!parseError && schemaErrors.length > 0 && (
<div
className={styles.jsonEditorError}
data-testid="json-editor-schema-error"
>
<span className={styles.jsonEditorErrorLabel}>Schema Error:</span>
<span className={styles.jsonEditorErrorMessage}>
{schemaErrors[0]}
{schemaErrors.length > 1 && ` (+${schemaErrors.length - 1} more)`}
</span>
</div>
)}
</div>
</div>
);
},
);
export default JsonEditor;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,136 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import { supportsOnlySelected } from '../../permissions.config';
import ActionToggle from './ActionToggle';
import styles from './ResourceCard.module.scss';
import {
PermissionScope,
ResourcePermissions,
} from 'container/RolesSettings/types';
interface ResourceCardProps {
resource: ResourcePermissions;
onActionChange: (
resourceId: AuthZResource,
action: AuthZVerb,
scope: PermissionScope,
selectedIds: string[],
) => void;
defaultExpanded?: boolean;
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 = useMemo(() => {
return Object.values(resource.actions).filter(
(config) => !!config && config.scope !== PermissionScope.NONE,
).length;
}, [resource.actions]);
const totalCount = resource.availableActions.length;
return (
<div
className={styles.resourceCard}
data-testid={`resource-card-${resource.resourceId}`}
>
<button
type="button"
className={styles.resourceCardHeader}
onClick={handleToggleExpand}
aria-expanded={isExpanded}
aria-label={`${resource.resourceLabel}: ${grantedCount} of ${totalCount} permissions granted`}
data-testid={`resource-card-header-${resource.resourceId}`}
>
<div className={styles.resourceCardHeaderLeft}>
<span className={styles.resourceCardChevron}>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
<span className={styles.resourceCardLabel}>{resource.resourceLabel}</span>
</div>
<div className={styles.resourceCardHeaderRight}>
<span className={styles.resourceCardGrantedCount}>
{grantedCount} / {totalCount} granted
</span>
</div>
</button>
{isExpanded && (
<div className={styles.resourceCardBody}>
{resource.availableActions.map((action) => {
const actionConfig = resource.actions[action] ?? {
scope: PermissionScope.NONE,
selectedIds: [],
};
return (
<ActionToggle
key={action}
action={action}
scope={actionConfig.scope}
selectedIds={actionConfig.selectedIds}
resource={resource.resourceId}
canSelectIndividually={supportsOnlySelected(action)}
onScopeChange={handleScopeChange(action)}
onSelectedIdsChange={handleSelectedIdsChange(action)}
hasError={validationErrors?.has(`${resource.resourceId}:${action}`)}
/>
);
})}
</div>
)}
</div>
);
}
export default ResourceCard;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
import { Trash2 } from '@signozhq/icons';
import { Callout } from '@signozhq/ui/callout';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import styles from './DeleteRoleModal.module.scss';
interface DeleteRoleModalProps {
isOpen: boolean;
roleName: string;
errorMessage: string | null;
onCancel: () => void;
onConfirm: () => Promise<boolean>;
}
function DeleteRoleModal({
isOpen,
roleName,
errorMessage,
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>
{errorMessage && (
<>
<Callout title="Error" color="cherry" className={styles.errorCallout}>
{errorMessage}
</Callout>
</>
)}
</ConfirmDialog>
);
}
export default DeleteRoleModal;

View File

@@ -1,99 +0,0 @@
import { useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import {
invalidateGetRole,
invalidateListRoles,
useDeleteRole,
} from 'api/generated/services/role';
interface UseDeleteRoleModalProps {
roleId?: string | null;
isManaged: boolean;
hasDeletePermission: boolean;
onDeleteSuccess?: () => void;
}
interface UseDeleteRoleModalResult {
isDeleteModalOpen: boolean;
isDeleteDisabled: boolean;
deleteDisabledReason: string;
isDeleting: boolean;
deleteErrorMessage: string | 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 [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(
null,
);
const { mutateAsync: deleteRole } = useDeleteRole();
const handleOpenDeleteModal = useCallback((): void => {
setDeleteTargetRoleId(roleId ?? null);
}, [roleId]);
const handleCloseDeleteModal = useCallback((): void => {
setDeleteTargetRoleId(null);
setDeleteErrorMessage(null);
}, []);
const handleConfirmDelete = useCallback(async (): Promise<boolean> => {
if (!deleteTargetRoleId) {
return false;
}
setIsDeleting(true);
setDeleteErrorMessage(null);
try {
await deleteRole({ pathParams: { id: deleteTargetRoleId } });
await invalidateListRoles(queryClient);
await invalidateGetRole(queryClient, { id: deleteTargetRoleId });
setDeleteTargetRoleId(null);
onDeleteSuccess?.();
return true;
} catch (error) {
const axiosError = error as {
response?: { data?: { error?: { message?: unknown } } };
};
const message =
axiosError?.response?.data?.error?.message ||
(error instanceof Error ? error.message : null) ||
'Failed to delete role';
setDeleteErrorMessage(String(message));
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,
deleteErrorMessage,
handleOpenDeleteModal,
handleCloseDeleteModal,
handleConfirmDelete,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,331 +0,0 @@
import { useMemo } from 'react';
import { ArrowLeft } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Tabs } from '@signozhq/ui/tabs';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { useGetRole } from 'api/generated/services/role';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import { useDeleteRoleModal } from 'container/RolesSettings/DeleteRoleModal/useDeleteRoleModal';
import { useRoleAuthZ } from 'container/RolesSettings/hooks/useRoleAuthZ';
import { transformApiToRolePermissions } from 'container/RolesSettings/hooks/useRolePermissions';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { RoleType } from 'types/roles';
import { toAPIError } from 'utils/errorUtils';
import DeleteRoleModal from '../DeleteRoleModal/DeleteRoleModal';
import PermissionOverview from './components/PermissionOverview';
import ReadOnlyJsonViewer from './ReadOnlyJsonViewer';
import { useViewRolePageCallbacks } from './useViewRolePageCallbacks';
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,
} = useViewRolePageCallbacks();
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,
deleteErrorMessage,
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}>
<span className={styles.permissionTitle}>Transaction Groups</span>
<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}
errorMessage={deleteErrorMessage}
onCancel={handleCloseDeleteModal}
onConfirm={handleConfirmDelete}
/>
</div>
);
}
export default ViewRolePage;

View File

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

View File

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

View File

@@ -1,71 +0,0 @@
import { render, screen } from 'tests/test-utils';
import ViewRolePage from '../ViewRolePage';
import {
buildViewRoleRoute,
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
mockHooksForCustomRole,
} from './testUtils';
describe('ViewRolePage - Custom Role', () => {
beforeEach(() => {
mockHooksForCustomRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders role name in page title', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByText('Role - billing-manager')).toBeInTheDocument();
});
it('shows role description', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(
screen.getByText('Custom role for managing billing and invoices.'),
).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', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByText('Created At')).toBeInTheDocument();
expect(screen.getByText('Last Modified At')).toBeInTheDocument();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,673 +0,0 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import userEvent from '@testing-library/user-event';
import { render, screen, within } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
import ViewRolePage from '../ViewRolePage';
import {
buildViewRoleRoute,
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
mockHooksForCustomRole,
mockHooksWithPermissions,
mockPermissionsData,
} from './testUtils';
async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
const expandButton = screen.getByTestId('expand-all-button');
await user.click(expandButton);
}
describe('ViewRolePage - Permission Overview', () => {
beforeEach(() => {
mockHooksForCustomRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders Transaction Groups section label', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByText('Transaction Groups')).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();
expect(screen.getByText('key-abc-123')).toBeInTheDocument();
expect(screen.getByText('key-def-456')).toBeInTheDocument();
});
it('counts ONLY_SELECTED as granted in count', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'serviceaccount',
resourceKind: 'serviceaccount',
resourceType: 'serviceaccount',
resourceLabel: 'Service Accounts',
actions: {
read: { scope: 'only_selected', selectedIds: ['sa-1'] },
create: { scope: 'none', selectedIds: [] },
},
availableActions: ['read', 'create'],
},
],
});
render(<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();
expect(screen.getByText('editor-role')).toBeInTheDocument();
const toggle = screen.getByTestId('toggle-items-update');
await user.click(toggle);
expect(screen.queryByText('editor-role')).not.toBeInTheDocument();
await user.click(toggle);
expect(screen.getByText('editor-role')).toBeInTheDocument();
});
});
describe('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', () => {
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();
expect(screen.getByText('future-resource')).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();
expect(screen.getByText('Unknown_action')).toBeInTheDocument();
});
it('handles resource with empty actions', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'empty-resource',
resourceKind: 'empty-resource',
resourceType: 'metaresource',
resourceLabel: 'Empty Resource',
actions: {},
availableActions: [],
},
],
});
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(
screen.getByTestId('resource-section-empty-resource'),
).toBeInTheDocument();
expect(screen.getByTestId('granted-count-empty-resource')).toHaveTextContent(
'0 / 0 granted',
);
});
});
describe('ViewRolePage - View mode toggle', () => {
beforeEach(() => {
mockHooksForCustomRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders Interactive/JSON toggle', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('permission-view-mode-list')).toBeInTheDocument();
expect(screen.getByTestId('permission-view-mode-json')).toBeInTheDocument();
});
it('switches to JSON view when JSON toggle clicked', async () => {
const user = userEvent.setup();
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
const jsonToggle = screen.getByTestId('permission-view-mode-json');
await user.click(jsonToggle);
expect(screen.queryByTestId('permission-overview')).not.toBeInTheDocument();
});
});
describe('ViewRolePage - JSON Viewer Copy Button', () => {
beforeEach(() => {
mockHooksForCustomRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders copy button in JSON view', async () => {
const user = userEvent.setup();
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
const jsonToggle = screen.getByTestId('permission-view-mode-json');
await user.click(jsonToggle);
expect(
screen.getByTestId('read-only-json-viewer-copy-button'),
).toBeInTheDocument();
});
it('copy button is clickable', async () => {
const user = userEvent.setup();
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
const jsonToggle = screen.getByTestId('permission-view-mode-json');
await user.click(jsonToggle);
const copyButton = screen.getByTestId('read-only-json-viewer-copy-button');
expect(copyButton).not.toBeDisabled();
await user.click(copyButton);
});
});

View File

@@ -1,139 +0,0 @@
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: 'factor-api-key',
resourceType: 'metaresource',
resourceLabel: 'API Keys',
actions: {
create: { scope: 'none', selectedIds: [] },
read: { scope: 'all', selectedIds: [] },
},
availableActions: ['create', 'read', 'update', 'delete', 'list'],
},
{
resourceId: 'role',
resourceKind: 'role',
resourceType: 'role',
resourceLabel: 'Roles',
actions: {
create: { scope: 'none', selectedIds: [] },
read: { scope: 'none', selectedIds: [] },
},
availableActions: [
'create',
'read',
'update',
'delete',
'list',
'attach',
'detach',
],
},
{
resourceId: 'serviceaccount',
resourceKind: 'serviceaccount',
resourceType: 'serviceaccount',
resourceLabel: 'Service Accounts',
actions: {
create: { scope: 'none', selectedIds: [] },
read: { scope: 'none', selectedIds: [] },
},
availableActions: [
'create',
'read',
'update',
'delete',
'list',
'attach',
'detach',
],
},
],
};
export function mockHooksForCustomRole(): void {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
}
export function mockHooksWithPermissions(permissions: unknown): void {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: permissions,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
}
export function mockHooksForManagedRole(): void {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: {
...mockPermissionsData,
roleId: MANAGED_ROLE_ID,
roleName: 'signoz-admin',
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
}

View File

@@ -1,80 +0,0 @@
.row {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-5) 0;
}
.rowHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-5);
}
.rowLeft {
display: flex;
align-items: center;
gap: var(--spacing-3);
min-width: 0;
}
.chevron {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
background: transparent;
border: none;
color: var(--l3-foreground);
cursor: pointer;
flex-shrink: 0;
&:hover {
color: var(--l2-foreground);
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
border-radius: var(--spacing-1);
}
}
.label {
font-family: Inter;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
color: var(--l1-foreground);
}
.badge {
display: inline-flex;
align-items: center;
padding: var(--spacing-1) var(--spacing-3);
font-family: Inter;
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-18);
border-radius: var(--spacing-2);
white-space: nowrap;
flex-shrink: 0;
}
.all {
color: var(--bg-robin-300);
background: color-mix(in srgb, var(--bg-robin-500) 16%, transparent);
}
.none {
color: var(--l3-foreground);
background: color-mix(in srgb, var(--l3-foreground) 10%, transparent);
}
.selected {
color: var(--bg-sienna-400);
background: color-mix(in srgb, var(--bg-sienna-500) 16%, transparent);
}

View File

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

View File

@@ -1,25 +0,0 @@
.container {
display: flex;
flex-direction: column;
padding-bottom: var(--spacing-8);
}
.collapseAction {
display: flex;
justify-content: flex-start;
padding-bottom: var(--spacing-4);
}
.grid {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
}
.errorText {
font-size: var(--periscope-font-size-base);
color: var(--cherry-foreground);
margin: 0;
padding: var(--spacing-4);
text-align: center;
}

View File

@@ -1,117 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
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">
<p className={styles.errorText}>Failed to load permissions</p>
</div>
);
}
return (
<div className={styles.container} data-testid="permission-overview">
<div className={styles.collapseAction}>
<ButtonGroup
variant="outlined"
color="secondary"
size="sm"
testId="toggle-all-group"
>
<Button onClick={handleExpandAll} data-testid="expand-all-button">
Expand all
</Button>
<Button onClick={handleCollapseAll} data-testid="collapse-all-button">
Collapse all
</Button>
</ButtonGroup>
</div>
<div className={styles.grid}>
{resources.map((resource) => (
<ResourcePermissionCard
key={resource.resourceId}
resource={resource}
isExpanded={expandedResources.has(resource.resourceId)}
onExpandChange={handleExpandChange(resource.resourceId)}
/>
))}
</div>
</div>
);
}
export default PermissionOverview;

View File

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

View File

@@ -1,106 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { getResourcePanel } from '../../permissions.config';
import { PermissionScope, ResourcePermissions } from '../../types';
import ActionRow from './ActionRow';
import { getActionLabel } from './permissionDisplay.utils';
import styles from './ResourcePermissionCard.module.scss';
export interface ResourcePermissionCardProps {
resource: ResourcePermissions;
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 = useMemo(() => {
return availableActions.filter((actionName) => {
const config = actions[actionName];
return !!config && config.scope !== PermissionScope.NONE;
}).length;
}, [availableActions, actions]);
const totalCount = availableActions.length;
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>
<h4 className={styles.title}>{resourceLabel}</h4>
</div>
<span
className={styles.grantedCount}
data-testid={`granted-count-${resourceKind}`}
>
{grantedCount} / {totalCount} granted
</span>
</button>
{isExpanded && (
<div className={styles.rows}>
{availableActions.map((actionName) => {
const config = actions[actionName];
if (!config) {
return null;
}
const selectedIds =
config.scope === PermissionScope.ONLY_SELECTED ? config.selectedIds : [];
return (
<ActionRow
key={actionName}
actionName={actionName}
actionLabel={getActionLabel(actionName)}
scope={config.scope}
selectedIds={selectedIds}
/>
);
})}
</div>
)}
</section>
);
}
export default ResourcePermissionCard;

View File

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

View File

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

View File

@@ -1,31 +0,0 @@
import { PermissionScope } from '../../types';
export type ScopeBadgeVariant = 'all' | 'none' | '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: 'all' };
case PermissionScope.ONLY_SELECTED:
return { label: `Only selected · ${selectedCount}`, variant: 'selected' };
case PermissionScope.NONE:
default:
return { label: 'None', variant: 'none' };
}
}

View File

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

View File

@@ -1,88 +0,0 @@
import { useCallback, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
export type ViewMode = 'list' | 'json';
export type TabKey = 'overview';
const VIEW_MODES: ViewMode[] = ['list', 'json'];
const TAB_KEYS: TabKey[] = ['overview'];
interface UseViewRolePageCallbacksResult {
roleId: string | undefined;
roleName: string;
activeTab: TabKey;
viewMode: ViewMode;
expandedResources: Set<string>;
setExpandedResources: (resources: Set<string>) => void;
handleRedirectToUpdate: () => void;
handleCancel: () => void;
handleModeChange: (value: string) => void;
handleTabChange: (key: string) => void;
}
export function useViewRolePageCallbacks(): 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';
@@ -77,14 +77,13 @@ describe('RolesSettings', () => {
});
it('filters roles by search query on name', async () => {
const user = userEvent.setup();
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.clear(searchInput);
await user.type(searchInput, 'billing');
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
target: { value: 'billing' },
});
await expect(
screen.findByText('billing-manager'),
@@ -95,14 +94,13 @@ describe('RolesSettings', () => {
});
it('filters roles by search query on description', async () => {
const user = userEvent.setup();
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.clear(searchInput);
await user.type(searchInput, 'read-only');
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
target: { value: 'read-only' },
});
await expect(screen.findByText('signoz-viewer')).resolves.toBeInTheDocument();
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
@@ -110,14 +108,13 @@ describe('RolesSettings', () => {
});
it('shows empty state when search matches nothing', async () => {
const user = userEvent.setup();
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.clear(searchInput);
await user.type(searchInput, 'nonexistentrole');
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
target: { value: 'nonexistentrole' },
});
await expect(
screen.findByText('No roles match your search.'),

View File

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

View File

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

View File

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

View File

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

View File

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

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