mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-25 09:30:31 +01:00
Compare commits
5 Commits
feat/revam
...
feat/docs/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67418bb132 | ||
|
|
2e2517449d | ||
|
|
0150c55361 | ||
|
|
a609a4044c | ||
|
|
f78d98ea71 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -191,9 +191,6 @@ function TimeSeries({
|
||||
if (metrics[0] && yAxisUnit) {
|
||||
updateMetricMetadata(
|
||||
{
|
||||
pathParams: {
|
||||
metricName: metricNames[0],
|
||||
},
|
||||
data: buildUpdateMetricYAxisUnitPayload(
|
||||
metricNames[0],
|
||||
metrics[0],
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -237,9 +237,6 @@ function Metadata({
|
||||
const handleSave = useCallback(() => {
|
||||
updateMetricMetadata(
|
||||
{
|
||||
pathParams: {
|
||||
metricName,
|
||||
},
|
||||
data: transformUpdateMetricMetadataRequest(metricName, metricMetadataState),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,7 +56,7 @@ function MetricDetails({
|
||||
);
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (!metricMetadataResponse?.data) {
|
||||
if (!metricMetadataResponse) {
|
||||
return null;
|
||||
}
|
||||
const { type, description, unit, temporality, isMonotonic } =
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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't worry, this doesn't update this role yet, it only confirms
|
||||
that you want to clear the items.
|
||||
</Typography>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionToggle;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './CreateEditRolePage';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.errorCallout {
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { default } from './PermissionSidePanel';
|
||||
export type {
|
||||
PermissionConfig,
|
||||
PermissionSidePanelProps,
|
||||
ResourceConfig,
|
||||
ResourceDefinition,
|
||||
ResourceOption,
|
||||
ScopeType,
|
||||
} from './PermissionSidePanel.types';
|
||||
export { PermissionScope } from './PermissionSidePanel.types';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './RoleDetailsPage';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
345
frontend/src/container/RolesSettings/RolesSettings.styles.scss
Normal file
345
frontend/src/container/RolesSettings/RolesSettings.styles.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './ViewRolePage';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user