Compare commits

..

9 Commits

Author SHA1 Message Date
nityanandagohain
9604438723 Merge remote-tracking branch 'origin/main' into issue_5267 2026-06-25 17:23:58 +05:30
nityanandagohain
e0a66b7a10 fix: more cleanup 2026-06-25 17:20:48 +05:30
nityanandagohain
b01be1f31c fix: update openapi 2026-06-25 11:19:08 +05:30
nityanandagohain
c8a007cd8d fix: cleanup go.mod 2026-06-25 10:55:54 +05:30
nityanandagohain
205b23b830 fix: lint 2026-06-25 10:52:27 +05:30
nityanandagohain
68b166f75e fix: more cleanup and integration test 2026-06-25 10:50:52 +05:30
nityanandagohain
20d8ca6d7d Merge remote-tracking branch 'origin/main' into issue_5267 2026-06-25 10:06:52 +05:30
nityanandagohain
1e1be670f1 feat: integrate with collector 2026-06-23 12:07:16 +05:30
nityanandagohain
d1682f2ab6 feat: span mapper test endpoint 2026-06-19 18:31:31 +05:30
162 changed files with 4619 additions and 12421 deletions

View File

@@ -38,6 +38,7 @@ jobs:
fail-fast: false
matrix:
suite:
- spanmapper
- alerts
- basepath
- callbackauthn

View File

@@ -1,76 +1,48 @@
# 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.
# Migrating from the install script to Foundry
> [!IMPORTANT]
> 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].
> The install script is now deprecated and will no longer receive updates.
## How it works
This guide walks you through migrating an existing SigNoz deployment running via
Docker Compose to [Foundry](https://signoz.io/docs/install/docker/).
Foundry splits a deployment into two commands:
> [!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.
- `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.
## Overview
To stay up to date on new installation platforms and patterns, please refer to [Foundry](https://github.com/SigNoz/foundry).
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).
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).
## Prerequisites
- [ ] Install Foundry - `curl -fsSL https://signoz.io/foundry.sh | bash`
- An existing SigNoz deployment from `install.sh` or `deploy/` (Compose or
Swarm).
- `foundryctl` (installed in step 1).
## 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.
## Migrate
1. Make a note of the volume names used by your existing deployment for the following components:
- ClickHouse
- SigNoz
- ZooKeeper
### 1. Install Foundry
> If you used the docker compose file we provided, the volumes will be `signoz-clickhouse`, `signoz-sqlite`, and `signoz-zookeeper-1`.
```bash
curl -fsSL https://signoz.io/foundry.sh | bash
```
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`.
### 2. Keep your rollback path
Foundry has a [Docker Compose example](https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose). Please use it as a reference.
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.
> [!WARNING]
> If your deployment had more than 1 shard or replica, you will need to adjust your manifest volumes accordingly.
> [!IMPORTANT]
> 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>
> 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.
```yaml
# casting.yaml
apiVersion: v1alpha1
kind: Installation
metadata:
@@ -89,8 +61,8 @@ spec:
data:
config-0-0.yaml: |
macros:
replica: "example01-01-1" # replace with your replica macro
shard: "01" # replace with your shard macro
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)
patches:
- target: "deployment/compose.yaml"
operations:
@@ -108,163 +80,50 @@ spec:
value: root
```
</details>
> [!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>
<summary><b>Docker Swarm</b> casting.yaml</summary>
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.
```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
```
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.
</details>
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.
> [!NOTE]
> 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.
> This will generate downtime so please plan accordingly.
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].
5. Validate the SigNoz containers are down with `docker ps -a`. Multiple containers cannot bind the same volume.
### 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
```
6. Run `foundryctl cast -f casting.yaml`. This will recreate the containers based on the spec. This process will download new container images.
> [!NOTE]
> This causes downtime, so plan accordingly.
> When `cast` is run, the migration container will execute its migrations.
Confirm nothing is still bound to the volumes before continuing:
## 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.
```bash
docker ps -a
```
## 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:
### 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.
- 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.
## Troubleshooting
If the migration runs into trouble, reach out on [Slack][slack] or open a
[Foundry issue][foundry-issues].
- Please reach out to our community on [Slack](https://signoz.io/slack).
## References
- [Foundry][foundry]
- [Casting file reference][casting-ref]
- [Custom config files][custom-config]
- [Patches][patches]
- [SigNoz documentation][signoz-docs]
[foundry]: https://github.com/SigNoz/foundry
[install-docs]: https://signoz.io/docs/install/
[compose-example]: https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose
[patches]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md
[custom-config]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files
[env-vars]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec
[casting-ref]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md
[ch-config]: https://clickhouse.com/docs/operations/configuration-files
[upgrade-path]: https://signoz.io/docs/operate/upgrade/
[slack]: https://signoz.io/slack
[foundry-issues]: https://github.com/SigNoz/foundry/issues
[signoz-docs]: https://signoz.io/docs
- [SigNoz Docker installation docs](https://signoz.io/docs/install/docker/)
- [SigNoz documentation](https://signoz.io/docs)
- [Foundry](https://github.com/SigNoz/foundry)

View File

@@ -4011,94 +4011,6 @@ components:
enabled:
type: boolean
type: object
InframonitoringtypesAssociatedComponent:
properties:
name:
type: string
type:
$ref: '#/components/schemas/InframonitoringtypesCheckComponentType'
required:
- type
- name
type: object
InframonitoringtypesAttributesComponentEntry:
properties:
associatedComponent:
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
attributes:
items:
type: string
nullable: true
type: array
required:
- attributes
- associatedComponent
type: object
InframonitoringtypesCheckComponentType:
enum:
- receiver
- processor
type: string
InframonitoringtypesCheckType:
enum:
- hosts
- processes
- pods
- nodes
- deployments
- daemonsets
- statefulsets
- jobs
- namespaces
- clusters
- volumes
type: string
InframonitoringtypesChecks:
properties:
missingDefaultEnabledMetrics:
items:
$ref: '#/components/schemas/InframonitoringtypesMissingMetricsComponentEntry'
nullable: true
type: array
missingOptionalMetrics:
items:
$ref: '#/components/schemas/InframonitoringtypesMissingMetricsComponentEntry'
nullable: true
type: array
missingRequiredAttributes:
items:
$ref: '#/components/schemas/InframonitoringtypesMissingAttributesComponentEntry'
nullable: true
type: array
presentDefaultEnabledMetrics:
items:
$ref: '#/components/schemas/InframonitoringtypesMetricsComponentEntry'
nullable: true
type: array
presentOptionalMetrics:
items:
$ref: '#/components/schemas/InframonitoringtypesMetricsComponentEntry'
nullable: true
type: array
presentRequiredAttributes:
items:
$ref: '#/components/schemas/InframonitoringtypesAttributesComponentEntry'
nullable: true
type: array
ready:
type: boolean
type:
$ref: '#/components/schemas/InframonitoringtypesCheckType'
required:
- type
- ready
- presentDefaultEnabledMetrics
- presentOptionalMetrics
- presentRequiredAttributes
- missingDefaultEnabledMetrics
- missingOptionalMetrics
- missingRequiredAttributes
type: object
InframonitoringtypesClusterRecord:
properties:
clusterCPU:
@@ -4433,57 +4345,6 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesMetricsComponentEntry:
properties:
associatedComponent:
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
metrics:
items:
type: string
nullable: true
type: array
required:
- metrics
- associatedComponent
type: object
InframonitoringtypesMissingAttributesComponentEntry:
properties:
associatedComponent:
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
attributes:
items:
type: string
nullable: true
type: array
documentationLink:
type: string
message:
type: string
required:
- attributes
- associatedComponent
- message
- documentationLink
type: object
InframonitoringtypesMissingMetricsComponentEntry:
properties:
associatedComponent:
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
documentationLink:
type: string
message:
type: string
metrics:
items:
type: string
nullable: true
type: array
required:
- metrics
- associatedComponent
- message
- documentationLink
type: object
InframonitoringtypesNamespaceRecord:
properties:
meta:
@@ -7207,6 +7068,19 @@ components:
required:
- items
type: object
SpantypesGettableSpanMapperTest:
properties:
collectorLogs:
items:
type: string
nullable: true
type: array
spans:
items:
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
nullable: true
type: array
type: object
SpantypesGettableTraceAggregations:
properties:
aggregations:
@@ -7294,6 +7168,39 @@ components:
- name
- condition
type: object
SpantypesPostableSpanMapperTest:
properties:
groups:
items:
$ref: '#/components/schemas/SpantypesPostableSpanMapperTestGroup'
nullable: true
type: array
spans:
items:
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
nullable: true
type: array
required:
- spans
- groups
type: object
SpantypesPostableSpanMapperTestGroup:
properties:
condition:
$ref: '#/components/schemas/SpantypesSpanMapperGroupCondition'
enabled:
type: boolean
mappers:
items:
$ref: '#/components/schemas/SpantypesPostableSpanMapper'
nullable: true
type: array
name:
type: string
required:
- name
- condition
type: object
SpantypesPostableTraceAggregations:
properties:
aggregations:
@@ -7455,6 +7362,17 @@ components:
- operation
- priority
type: object
SpantypesSpanMapperTestSpan:
properties:
attributes:
additionalProperties: {}
nullable: true
type: object
resource:
additionalProperties: {}
nullable: true
type: object
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -13083,6 +13001,69 @@ paths:
summary: Update a span mapper
tags:
- spanmapper
/api/v1/span_mapper_groups/test:
post:
deprecated: false
description: Tests how span mappers would transform sample spans
operationId: TestSpanMappers
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableSpanMapperTest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableSpanMapperTest'
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
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Test span mappers against sample spans
tags:
- spanmapper
/api/v1/stats:
get:
deprecated: false
@@ -14967,72 +14948,6 @@ paths:
summary: Health check
tags:
- health
/api/v2/infra_monitoring/checks:
get:
deprecated: false
description: 'Checks whether the metrics and attributes required to power the
infra-monitoring section selected by the ''type'' query parameter (hosts,
processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces,
clusters, volumes) are being received. For each collector receiver or processor
that contributes required metrics or attributes, lists what is present and
what is missing, with a prebuilt user-facing message and a docs link per missing
component. Default-enabled metrics are those expected as soon as the receiver
is configured; optional metrics require ''enabled: true'' in receiver config.
''ready'' is true only when every missing list is empty.'
operationId: GetChecks
parameters:
- in: query
name: type
required: true
schema:
$ref: '#/components/schemas/InframonitoringtypesCheckType'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesChecks'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Run Infra Monitoring Setup Checks
tags:
- inframonitoring
/api/v2/infra_monitoring/clusters:
post:
deprecated: false

View File

@@ -370,7 +370,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
for _, cb := range provider.onBeforeRoleDelete {
if err := cb(ctx, orgID, id, role.Name); err != nil {
if err := cb(ctx, orgID, id); err != nil {
return err
}
}

View File

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

View File

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

View File

@@ -4,22 +4,14 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
GetChecks200,
GetChecksParams,
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableDaemonSetsDTO,
InframonitoringtypesPostableDeploymentsDTO,
@@ -46,93 +38,6 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Checks whether the metrics and attributes required to power the infra-monitoring section selected by the 'type' query parameter (hosts, processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces, clusters, volumes) are being received. For each collector receiver or processor that contributes required metrics or attributes, lists what is present and what is missing, with a prebuilt user-facing message and a docs link per missing component. Default-enabled metrics are those expected as soon as the receiver is configured; optional metrics require 'enabled: true' in receiver config. 'ready' is true only when every missing list is empty.
* @summary Run Infra Monitoring Setup Checks
*/
export const getChecks = (params: GetChecksParams, signal?: AbortSignal) => {
return GeneratedAPIInstance<GetChecks200>({
url: `/api/v2/infra_monitoring/checks`,
method: 'GET',
params,
signal,
});
};
export const getGetChecksQueryKey = (params?: GetChecksParams) => {
return [
`/api/v2/infra_monitoring/checks`,
...(params ? [params] : []),
] as const;
};
export const getGetChecksQueryOptions = <
TData = Awaited<ReturnType<typeof getChecks>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetChecksParams,
options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetChecksQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getChecks>>> = ({
signal,
}) => getChecks(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getChecks>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetChecksQueryResult = NonNullable<
Awaited<ReturnType<typeof getChecks>>
>;
export type GetChecksQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Run Infra Monitoring Setup Checks
*/
export function useGetChecks<
TData = Awaited<ReturnType<typeof getChecks>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetChecksParams,
options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetChecksQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Run Infra Monitoring Setup Checks
*/
export const invalidateGetChecks = async (
queryClient: QueryClient,
params: GetChecksParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetChecksQueryKey(params) },
options,
);
return queryClient;
};
/**
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* @summary List Clusters for Infra Monitoring

View File

@@ -5458,121 +5458,6 @@ export interface GlobaltypesConfigDTO {
mcp_url: string | null;
}
export enum InframonitoringtypesCheckComponentTypeDTO {
receiver = 'receiver',
processor = 'processor',
}
export interface InframonitoringtypesAssociatedComponentDTO {
/**
* @type string
*/
name: string;
type: InframonitoringtypesCheckComponentTypeDTO;
}
export interface InframonitoringtypesAttributesComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
/**
* @type array,null
*/
attributes: string[] | null;
}
export enum InframonitoringtypesCheckTypeDTO {
hosts = 'hosts',
processes = 'processes',
pods = 'pods',
nodes = 'nodes',
deployments = 'deployments',
daemonsets = 'daemonsets',
statefulsets = 'statefulsets',
jobs = 'jobs',
namespaces = 'namespaces',
clusters = 'clusters',
volumes = 'volumes',
}
export interface InframonitoringtypesMissingMetricsComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
/**
* @type string
*/
documentationLink: string;
/**
* @type string
*/
message: string;
/**
* @type array,null
*/
metrics: string[] | null;
}
export interface InframonitoringtypesMissingAttributesComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
/**
* @type array,null
*/
attributes: string[] | null;
/**
* @type string
*/
documentationLink: string;
/**
* @type string
*/
message: string;
}
export interface InframonitoringtypesMetricsComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
/**
* @type array,null
*/
metrics: string[] | null;
}
export interface InframonitoringtypesChecksDTO {
/**
* @type array,null
*/
missingDefaultEnabledMetrics:
| InframonitoringtypesMissingMetricsComponentEntryDTO[]
| null;
/**
* @type array,null
*/
missingOptionalMetrics:
| InframonitoringtypesMissingMetricsComponentEntryDTO[]
| null;
/**
* @type array,null
*/
missingRequiredAttributes:
| InframonitoringtypesMissingAttributesComponentEntryDTO[]
| null;
/**
* @type array,null
*/
presentDefaultEnabledMetrics:
| InframonitoringtypesMetricsComponentEntryDTO[]
| null;
/**
* @type array,null
*/
presentOptionalMetrics: InframonitoringtypesMetricsComponentEntryDTO[] | null;
/**
* @type array,null
*/
presentRequiredAttributes:
| InframonitoringtypesAttributesComponentEntryDTO[]
| null;
/**
* @type boolean
*/
ready: boolean;
type: InframonitoringtypesCheckTypeDTO;
}
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -8345,6 +8230,48 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOAttributes =
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOResource =
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
export interface SpantypesSpanMapperTestSpanDTO {
/**
* @type object,null
*/
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
/**
* @type object,null
*/
resource?: SpantypesSpanMapperTestSpanDTOResource;
}
export interface SpantypesGettableSpanMapperTestDTO {
/**
* @type array,null
*/
collectorLogs?: string[] | null;
/**
* @type array,null
*/
spans?: SpantypesSpanMapperTestSpanDTO[] | null;
}
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
@@ -8640,6 +8567,33 @@ export interface SpantypesPostableSpanMapperGroupDTO {
name: string;
}
export interface SpantypesPostableSpanMapperTestGroupDTO {
condition: SpantypesSpanMapperGroupConditionDTO | null;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type array,null
*/
mappers?: SpantypesPostableSpanMapperDTO[] | null;
/**
* @type string
*/
name: string;
}
export interface SpantypesPostableSpanMapperTestDTO {
/**
* @type array,null
*/
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export interface SpantypesSpanAggregationDTO {
aggregation: SpantypesSpanAggregationTypeDTO;
field: TelemetrytypesTelemetryFieldKeyDTO;
@@ -10021,6 +9975,14 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type TestSpanMappers200 = {
data: SpantypesGettableSpanMapperTestDTO;
/**
* @type string
*/
status: string;
};
export type GetStats200Data = { [key: string]: unknown };
export type GetStats200 = {
@@ -10344,21 +10306,6 @@ export type Healthz503 = {
status: string;
};
export type GetChecksParams = {
/**
* @description undefined
*/
type: InframonitoringtypesCheckTypeDTO;
};
export type GetChecks200 = {
data: InframonitoringtypesChecksDTO;
/**
* @type string
*/
status: string;
};
export type ListClusters200 = {
data: InframonitoringtypesClustersDTO;
/**

View File

@@ -30,8 +30,10 @@ import type {
RenderErrorResponseDTO,
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesPostableSpanMapperTestDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
TestSpanMappers200,
UpdateSpanMapperGroupPathParameters,
UpdateSpanMapperPathParameters,
} from '../sigNoz.schemas';
@@ -780,3 +782,86 @@ export const useUpdateSpanMapper = <
> => {
return useMutation(getUpdateSpanMapperMutationOptions(options));
};
/**
* Tests how span mappers would transform sample spans
* @summary Test span mappers against sample spans
*/
export const testSpanMappers = (
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<TestSpanMappers200>({
url: `/api/v1/span_mapper_groups/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableSpanMapperTestDTO,
signal,
});
};
export const getTestSpanMappersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
const mutationKey = ['testSpanMappers'];
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 testSpanMappers>>,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
> = (props) => {
const { data } = props ?? {};
return testSpanMappers(data);
};
return { mutationFn, ...mutationOptions };
};
export type TestSpanMappersMutationResult = NonNullable<
Awaited<ReturnType<typeof testSpanMappers>>
>;
export type TestSpanMappersMutationBody =
| BodyType<SpantypesPostableSpanMapperTestDTO>
| undefined;
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Test span mappers against sample spans
*/
export const useTestSpanMappers = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
return useMutation(getTestSpanMappersMutationOptions(options));
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,107 +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 {
color: var(--primary);
}
.createEditRolePageContent {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
flex: 1;
min-height: 0;
}
.createEditRolePageForm {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.formRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-8);
}
.formField {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-disabled-background: var(--l2-background);
input::placeholder {
color: var(--l3-foreground);
}
}
.formLabel {
font-family: Inter;
font-size: 12px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-20);
letter-spacing: 0.48px;
text-transform: uppercase;
color: var(--l2-foreground);
}
.createEditRolePageDivider {
width: 100%;
height: 1px;
background: var(--l1-border);
}

View File

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

View File

@@ -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', async () => {
renderCreatePage({
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
});
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows error when license is invalid', async () => {
renderCreatePage({ activeLicense: invalidLicense });
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows Create Role title when feature disabled in create mode', async () => {
renderCreatePage({ activeLicense: invalidLicense });
await expect(screen.findByText('Create Role')).resolves.toBeInTheDocument();
});
it('shows back button when feature disabled', () => {
renderCreatePage({ activeLicense: invalidLicense });
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('back button is enabled when feature disabled', () => {
renderCreatePage({ activeLicense: invalidLicense });
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
});
});
describe('edit mode - feature disabled', () => {
const ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
const ROLE_NAME = 'test-role';
it('shows error when fine-grained authz flag is inactive', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
});
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows error when license is invalid', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows Edit Role title when feature disabled in edit mode', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });
await expect(screen.findByText('Edit Role')).resolves.toBeInTheDocument();
});
});
});

View File

@@ -1,59 +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', async () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderCreatePage();
await expect(
screen.findByText(/You are not authorized/i),
).resolves.toBeInTheDocument();
});
});
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
mockUseAuthZ.mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
refetchPermissions: jest.fn(),
});
renderCreatePage();
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
});

View File

@@ -1,284 +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');
await expect(
screen.findByText('Unsaved changes'),
).resolves.toBeInTheDocument();
});
it('enables save button when description is entered', async () => {
const user = userEvent.setup();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Some description');
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).not.toBeDisabled();
});
});
describe('cancel action', () => {
it('navigates to roles list on cancel', async () => {
const user = userEvent.setup();
renderCreatePage();
const cancelBtn = screen.getByTestId('cancel-button');
await user.click(cancelBtn);
await expect(
screen.findByTestId('roles-list-redirect'),
).resolves.toBeInTheDocument();
});
});
describe('create success flow', () => {
it('calls create API with form data and redirects', async () => {
const createSpy = jest.fn();
server.use(
rest.post(rolesApiBase, async (req, res, ctx) => {
createSpy(await req.json());
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: { id: 'new-role-id', name: 'my-custom-role' },
}),
);
}),
);
const user = userEvent.setup();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'my-custom-role');
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Role for testing');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await waitFor(() => {
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'my-custom-role',
description: 'Role for testing',
}),
);
});
await expect(
screen.findByTestId('roles-list-redirect'),
).resolves.toBeInTheDocument();
});
});
describe('create error flows', () => {
it('does not call API when name is empty', async () => {
const createSpy = jest.fn();
server.use(
rest.post(rolesApiBase, async (req, res, ctx) => {
createSpy();
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await waitFor(
() => {
expect(createSpy).not.toHaveBeenCalled();
},
{ timeout: 500 },
);
});
it('shows error banner when API fails', async () => {
server.use(
rest.post(rolesApiBase, (_req, res, ctx) =>
res(
ctx.status(400),
ctx.json({
error: { message: 'Role name already exists' },
}),
),
),
);
const user = userEvent.setup();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'duplicate-role');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Role name already exists'),
).resolves.toBeInTheDocument();
});
});
describe('validation errors', () => {
it('shows validation error when Only Selected has no items', async () => {
const user = userEvent.setup();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
const apiKeysCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeysCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const readToggle = within(apiKeysCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const onlySelectedBtn = await within(readToggle).findByText('Only selected');
await user.click(onlySelectedBtn);
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(
'Please add at least one selector for each "Only selected" permission.',
),
).resolves.toBeInTheDocument();
});
});
});

View File

@@ -1,88 +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', async () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderEditPage();
await expect(
screen.findByText(/You are not authorized/i),
).resolves.toBeInTheDocument();
});
it('shows PermissionDeniedFullPage when update permission denied but read granted', async () => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantByPrefix('read'));
renderEditPage();
await expect(
screen.findByText(/You are not authorized/i),
).resolves.toBeInTheDocument();
});
it('checks both read and update permissions for edit mode', () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderEditPage();
expect(mockUseAuthZ).toHaveBeenCalledWith(
expect.arrayContaining([
expect.stringContaining('read'),
expect.stringContaining('update'),
]),
);
});
});
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
mockUseAuthZ.mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
refetchPermissions: jest.fn(),
});
renderEditPage();
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
});

View File

@@ -1,398 +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');
await expect(
screen.findByText('Unsaved changes'),
).resolves.toBeInTheDocument();
});
it('disables save when changes reverted to original', async () => {
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
const originalValue = 'Custom role for managing billing and invoices.';
await user.clear(descInput);
await user.type(descInput, 'Temporary change');
expect(screen.getByTestId('save-button')).not.toBeDisabled();
await user.clear(descInput);
await user.type(descInput, originalValue);
await waitFor(() => {
expect(screen.getByTestId('save-button')).toBeDisabled();
});
});
});
describe('cancel action', () => {
it('navigates to view role page on cancel', async () => {
const user = userEvent.setup();
renderEditPage();
await screen.findByTestId('role-description-input');
const cancelBtn = screen.getByTestId('cancel-button');
await user.click(cancelBtn);
await expect(
screen.findByTestId('role-details-redirect'),
).resolves.toBeInTheDocument();
});
});
describe('update success flow', () => {
it('redirects to view page after successful update', async () => {
server.use(
rest.put(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
);
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.clear(descInput);
await user.type(descInput, 'Updated description');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('role-details-redirect'),
).resolves.toBeInTheDocument();
});
it('calls update API when save clicked', async () => {
const updateSpy = jest.fn();
server.use(
rest.put(`${rolesApiBase}/:id`, async (req, res, ctx) => {
updateSpy();
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.type(descInput, ' edited');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await waitFor(() => {
expect(updateSpy).toHaveBeenCalled();
});
});
});
describe('update error flow', () => {
it('shows error banner when update fails with 500', async () => {
server.use(
rest.put(`${rolesApiBase}/:id`, (_req, res, ctx) => res(ctx.status(500))),
);
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.type(descInput, ' changed');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
});
it('shows error banner when update fails with 403', async () => {
server.use(
rest.put(`${rolesApiBase}/:id`, (_req, res, ctx) => res(ctx.status(403))),
);
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.type(descInput, ' test');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
});
it('shows error banner when update fails with 400', async () => {
server.use(
rest.put(`${rolesApiBase}/:id`, (_req, res, ctx) => res(ctx.status(400))),
);
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.type(descInput, ' x');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
});
});
describe('permission changes', () => {
it('detects permission change as unsaved', async () => {
const user = userEvent.setup();
renderEditPage();
await screen.findByTestId('permission-editor');
const apiKeysCard = await screen.findByTestId(
'resource-card-factor-api-key',
);
const header = within(apiKeysCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const createToggle = within(apiKeysCard).getByTestId(
'action-toggle-factor-api-key-create',
);
const allBtn = await within(createToggle).findByText('All');
await user.click(allBtn);
await expect(
screen.findByText('Unsaved changes'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('save-button')).not.toBeDisabled();
});
});
});

View File

@@ -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(await within(createToggle).findByText('All'));
await switchToJsonMode();
const jsonEditor = screen.getByTestId('json-editor');
expect(jsonEditor).toBeInTheDocument();
});
it('preserves changes when switching back to interactive', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByTestId('permission-editor');
await switchToInteractiveMode();
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-create',
);
await user.click(await within(createToggle).findByText('All'));
await switchToJsonMode();
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
);
await user.click(interactiveRadio);
const scopeToggle = within(
screen.getByTestId('action-toggle-factor-api-key-create'),
).getByTestId('action-toggle-scope-factor-api-key-create');
expect(
within(scopeToggle).getByRole('radio', { name: 'All' }),
).toBeChecked();
});
});
describe('error handling', () => {
it('no error shown initially with valid JSON', async () => {
renderPage();
await switchToJsonMode();
expect(screen.queryByTestId('json-editor-error')).not.toBeInTheDocument();
});
});
describe('JSON structure', () => {
it('produces valid transactionGroups format', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByTestId('permission-editor');
await switchToInteractiveMode();
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(readToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'test-key-123{enter}');
await switchToJsonMode();
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
});
it('handles wildcard selector for All scope', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByTestId('permission-editor');
await switchToInteractiveMode();
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-create',
);
await user.click(await within(createToggle).findByText('All'));
await switchToJsonMode();
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
});
});
describe('mode switching', () => {
it('reinitializes JSON buffer on switch from interactive to JSON', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByTestId('permission-editor');
await switchToJsonMode();
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
);
await user.click(interactiveRadio);
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(readToggle).findByText('All'));
await switchToJsonMode();
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
});
});
});

View File

@@ -1,523 +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', async () => {
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
await expect(
within(apiKeyCard).findByText(/0 \/ \d+ granted/),
).resolves.toBeInTheDocument();
});
});
describe('action toggles', () => {
it('renders action toggles for each available action', async () => {
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
expect(
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-read'),
).toBeInTheDocument();
expect(
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-read'),
).toBeInTheDocument();
expect(
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-update'),
).toBeInTheDocument();
expect(
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-delete'),
).toBeInTheDocument();
});
it('defaults all actions to None scope', async () => {
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const scopeToggle = within(createToggle).getByTestId(
'action-toggle-scope-factor-api-key-read',
);
expect(
within(scopeToggle).getByRole('radio', { name: 'None' }),
).toBeChecked();
});
it('changes scope to All when clicked', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const allBtn = await within(createToggle).findByText('All');
await user.click(allBtn);
const scopeToggle = within(createToggle).getByTestId(
'action-toggle-scope-factor-api-key-read',
);
expect(
within(scopeToggle).getByRole('radio', { name: 'All' }),
).toBeChecked();
});
it('updates granted count when scope changed', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('All'));
await expect(
within(apiKeyCard).findByText(/1 \/ \d+ granted/),
).resolves.toBeInTheDocument();
});
});
describe('Only Selected scope', () => {
it('shows item input selector when Only Selected is chosen', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const onlySelectedBtn =
await within(createToggle).findByText('Only selected');
await user.click(onlySelectedBtn);
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
});
it('adds item when typed and Enter pressed', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'api-key-001{enter}');
await expect(screen.findByText('api-key-001')).resolves.toBeInTheDocument();
});
it('adds item when Add button clicked', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'api-key-002');
const addBtn = screen.getByTestId('item-input-selector-add-btn');
await user.click(addBtn);
await expect(screen.findByText('api-key-002')).resolves.toBeInTheDocument();
});
it('adds multiple items separated by comma', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'key-a, key-b, key-c{enter}');
await expect(screen.findByText('key-a')).resolves.toBeInTheDocument();
await expect(screen.findByText('key-b')).resolves.toBeInTheDocument();
await expect(screen.findByText('key-c')).resolves.toBeInTheDocument();
});
it('adds multiple items separated by space', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'key-x key-y key-z{enter}');
await expect(screen.findByText('key-x')).resolves.toBeInTheDocument();
await expect(screen.findByText('key-y')).resolves.toBeInTheDocument();
await expect(screen.findByText('key-z')).resolves.toBeInTheDocument();
});
it('does not add duplicate items', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'same-key{enter}');
await user.type(input, 'same-key{enter}');
const badges = screen.getAllByText('same-key');
expect(badges).toHaveLength(1);
});
it('removes item when X clicked', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'removable-key{enter}');
const removeBtn = screen.getByRole('button', {
name: /remove removable-key/i,
});
await user.click(removeBtn);
expect(screen.queryByText('removable-key')).not.toBeInTheDocument();
});
it('shows Add button disabled when input is empty', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const addBtn = screen.getByTestId('item-input-selector-add-btn');
expect(addBtn).toBeDisabled();
});
});
describe('scope change confirmation dialog', () => {
it('shows confirm dialog when leaving Only Selected with items', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'will-be-cleared{enter}');
await user.click(await within(createToggle).findByText('All'));
await expect(
screen.findByText('Change permission scope?'),
).resolves.toBeInTheDocument();
});
it('clears items when confirmed', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'to-be-cleared{enter}');
await user.click(await within(createToggle).findByText('All'));
const dialog = await screen.findByRole('dialog');
await user.click(
within(dialog).getByRole('button', { name: /change scope/i }),
);
await waitFor(() => {
expect(screen.queryByText('to-be-cleared')).not.toBeInTheDocument();
});
});
it('keeps items when cancelled', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'preserved-key{enter}');
await user.click(await within(createToggle).findByText('None'));
const dialog = await screen.findByRole('dialog');
await user.click(within(dialog).getByRole('button', { name: /cancel/i }));
await expect(
screen.findByText('preserved-key'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
});
it('does not show dialog when leaving Only Selected with no items', async () => {
const user = userEvent.setup();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(createToggle).findByText('Only selected'));
await user.click(await within(createToggle).findByText('All'));
expect(
screen.queryByText('Change permission scope?'),
).not.toBeInTheDocument();
});
});
describe('verbs without Only Selected option', () => {
it('does not show Only Selected for list verb', async () => {
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const listToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-list',
);
expect(
within(listToggle).queryByText('Only selected'),
).not.toBeInTheDocument();
await expect(
within(listToggle).findByText('None'),
).resolves.toBeInTheDocument();
await expect(
within(listToggle).findByText('All'),
).resolves.toBeInTheDocument();
});
});
describe('collapse/expand all resources', () => {
it('shows expand/collapse toggle group', () => {
renderPage();
expect(screen.getByTestId('toggle-all-group')).toBeInTheDocument();
expect(screen.getByTestId('expand-all-button')).toBeInTheDocument();
expect(screen.getByTestId('collapse-all-button')).toBeInTheDocument();
});
it('expands all cards when expand button clicked', async () => {
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
expect(header).toHaveAttribute('aria-expanded', 'true');
});
});
});

View File

@@ -1,65 +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);
}
.actionToggleScopeToggle {
flex-shrink: 0;
width: fit-content;
--toggle-group-item-size: 1.4rem;
--toggle-group-item-padding-right: 0.4rem;
--toggle-group-item-border-style: none;
--toggle-group-secondary-active-bg: var(--bg-robin-800);
--toggle-group-item-align-items: baseline;
gap: var(--spacing-2);
padding: var(--spacing-2);
button {
min-width: 46px;
font-weight: bold;
&[data-state='on'] {
color: var(--bg-robin-200);
}
&:focus-visible {
outline: 2px solid var(--bg-robin-500);
outline-offset: 2px;
border-radius: var(--radius-2);
}
}
}
.actionToggleSelectorWrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
--divider-color: var(--l2-border);
}

View File

@@ -1,160 +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}>
<Typography as="span" size="base">
{displayLabel}
</Typography>
<ToggleGroupSimple
type="single"
size="sm"
value={scope}
onChange={handleToggleChange}
items={scopeItems}
className={styles.actionToggleScopeToggle}
testId={`action-toggle-scope-${resource}-${action}`}
/>
</div>
{scope === PermissionScope.ONLY_SELECTED && (
<div className={styles.actionToggleSelectorWrapper}>
<Divider />
<ItemInputSelector
placeholder={getResourcePanel(resource).selectorPlaceholder}
selectedIds={selectedIds}
onChange={onSelectedIdsChange}
docsAnchor={getResourcePanel(resource).docsAnchor}
hasError={hasError}
/>
</div>
)}
</div>
<ConfirmDialog
open={confirmDialogOpen}
onOpenChange={(next): void => {
if (!next) {
handleCancelScopeChange();
}
}}
title="Change permission scope?"
confirmText="Change scope"
cancelText="Cancel"
onConfirm={handleConfirmScopeChange}
onCancel={handleCancelScopeChange}
>
<Typography>
You have {selectedIds.length} item{selectedIds.length > 1 ? 's' : ''}{' '}
selected. Changing the scope will clear your current items.
<br />
<br />
Don&apos;t worry, this doesn&apos;t update this role yet, it only confirms
that you want to clear the items.
</Typography>
</ConfirmDialog>
</>
);
}
export default ActionToggle;

View File

@@ -1,105 +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;
}
.itemInputSelectorBadgeLabel {
flex: 1;
min-width: 0;
}
.itemInputSelectorBadgeRemove {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
background: transparent;
border: none;
border-radius: 2px;
color: var(--l2-foreground);
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--l1-background);
color: var(--l1-foreground);
}
}
.itemInputSelectorHint {
margin: 0;
color: var(--l2-foreground);
a {
color: var(--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -1,206 +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}>
<Typography
as="span"
size="small"
truncate={1}
className={styles.itemInputSelectorBadgeLabel}
>
{id}
</Typography>
<button
type="button"
className={styles.itemInputSelectorBadgeRemove}
onClick={(): void => handleRemove(id)}
onKeyDown={(e): void => handleBadgeKeyDown(e, id, index)}
aria-label={`Remove ${id}`}
>
<X size={10} />
</button>
</span>
))}
</div>
<TooltipSimple
title={
<Typography align="left">
Still not sure on how to add selectors? <br />
<Typography.Link
href={`${BASE_DOCS_URL}#${docsAnchor}`}
target="_blank"
rel="noopener noreferrer"
>
Check the docs
</Typography.Link>{' '}
to understand selectors for this resource.
</Typography>
}
>
<Info size={16} className={styles.itemInputSelectorInfoIcon} />
</TooltipSimple>
</div>
) : (
<Typography className={styles.itemInputSelectorHint}>
Not sure what to type here?{' '}
<Typography.Link
href={`${BASE_DOCS_URL}#${docsAnchor}`}
target="_blank"
rel="noopener noreferrer"
>
Check the docs
</Typography.Link>{' '}
to understand selectors for this resource.
</Typography>
)}
</div>
);
}
export default ItemInputSelector;

View File

@@ -1,44 +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;
}
.jsonEditorErrorMessage {
font-family:
Geist Mono,
monospace;
word-break: break-word;
}

View File

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

View File

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

View File

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

View File

@@ -1,252 +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}>
<Typography
as="span"
size="small"
weight="medium"
color="muted"
className={styles.permissionEditorTitle}
>
Transaction Groups
</Typography>
<hr className={styles.permissionEditorDivider} />
<RadioGroup
className={styles.permissionEditorModeToggle}
value={mode}
onChange={handleModeChange}
testId="permission-editor-mode"
>
<RadioGroupItem
value="interactive"
containerClassName={styles.permissionEditorModeItem}
className={styles.permissionEditorModeInput}
testId="permission-editor-mode-interactive"
>
Interactive
</RadioGroupItem>
<RadioGroupItem
value="json"
containerClassName={styles.permissionEditorModeItem}
className={styles.permissionEditorModeInput}
testId="permission-editor-mode-json"
>
JSON
</RadioGroupItem>
</RadioGroup>
</div>
<div className={styles.permissionEditorContent}>
{mode === 'interactive' ? (
<>
<div className={styles.permissionEditorCollapseAction}>
<ButtonGroup
variant="outlined"
color="secondary"
size="sm"
testId="toggle-all-group"
>
<Button onClick={handleExpandAll} data-testid="expand-all-button">
Expand all
</Button>
<Button onClick={handleCollapseAll} data-testid="collapse-all-button">
Collapse all
</Button>
</ButtonGroup>
</div>
<div className={styles.permissionEditorResourceList}>
{resources.map((resource) => (
<ResourceCard
key={resource.resourceId}
resource={resource}
onActionChange={handleActionChange}
isExpanded={expandedResources.has(resource.resourceId)}
onExpandChange={handleExpandChange(resource.resourceId)}
validationErrors={validationErrors}
/>
))}
</div>
</>
) : (
<JsonEditor
ref={jsonEditorRef}
resources={resources}
mode={mode}
onChange={handleJsonChange}
onValidityChange={handleJsonValidityChange}
/>
)}
</div>
<ConfirmDialog
open={showDiscardConfirm}
onOpenChange={(next): void => {
if (!next) {
handleDiscardCancel();
}
}}
title="Discard JSON changes?"
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
confirmText="Discard"
confirmColor="destructive"
cancelText="Stay in JSON"
onConfirm={handleDiscardConfirm}
onCancel={handleDiscardCancel}
>
<Typography>
The JSON contains errors and cannot be parsed. Switching to Interactive
mode will discard your changes.
</Typography>
</ConfirmDialog>
</div>
);
}
export default PermissionEditor;

View File

@@ -1,64 +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;
}
.resourceCardHeaderRight {
display: flex;
align-items: center;
}
.resourceCardBody {
display: flex;
flex-direction: column;
&::before {
content: '';
height: 1px;
margin: 0 var(--spacing-8);
background: var(--l2-border);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,210 +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 {
letter-spacing: 0.48px;
text-transform: uppercase;
position: relative;
}
.permissionDivider {
height: 7px;
flex: 1 1 0%;
border-width: medium medium;
border-style: none none;
border-color: currentcolor currentcolor;
border-image: initial;
border-top: 2px dotted var(--l1-border);
border-bottom: 2px dotted var(--l1-border);
margin: 0px var(--spacing-4);
}
.permissionModeToggle {
display: inline-flex;
grid-auto-flow: column;
gap: 0;
flex-shrink: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
}
.permissionModeItem {
position: relative;
display: flex;
align-items: center;
&:not(:last-child) {
border-right: 1px solid var(--l2-border);
}
label {
display: flex;
align-items: center;
min-height: 24px;
padding: var(--spacing-3) var(--spacing-6);
font-family: Inter;
font-size: var(--periscope-font-size-base);
line-height: var(--line-height-20);
color: var(--l2-foreground);
white-space: nowrap;
cursor: pointer;
user-select: none;
}
}
.permissionModeInput {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
* {
display: none;
}
&[data-state='checked'] + label {
background: var(--l3-background);
color: var(--l1-foreground);
font-weight: var(--font-weight-medium);
}
}
.permissionContent {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}

View File

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

View File

@@ -1,130 +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 expect(
screen.findByText(/Are you sure you want to delete the role/),
).resolves.toBeInTheDocument();
});
it('calls delete API and redirects on successful delete', async () => {
const user = userEvent.setup();
const mockDeleteRole = jest.fn().mockResolvedValue({});
jest.spyOn(roleApi, 'useDeleteRole').mockReturnValue({
mutateAsync: mockDeleteRole,
} as unknown as ReturnType<typeof roleApi.useDeleteRole>);
render(
<Switch>
<Route path="/settings/roles/:roleId">
<ViewRolePage />
</Route>
<Route path="/settings/roles">
<div data-testid="roles-list-target" />
</Route>
</Switch>,
undefined,
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
await user.click(screen.getByTestId('delete-button'));
await expect(
screen.findByText(/Are you sure you want to delete the role/),
).resolves.toBeInTheDocument();
const modal = screen.getByRole('dialog');
const modalConfirmBtn = within(modal).getByRole('button', {
name: /Delete Role/i,
});
await user.click(modalConfirmBtn);
await waitFor(() => {
expect(mockDeleteRole).toHaveBeenCalledWith({
pathParams: { id: CUSTOM_ROLE_ID },
});
});
await expect(
screen.findByTestId('roles-list-target'),
).resolves.toBeInTheDocument();
});
});

View File

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

View File

@@ -1,75 +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', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByText('Role - billing-manager'),
).resolves.toBeInTheDocument();
});
it('shows role description', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByText('Custom role for managing billing and invoices.'),
).resolves.toBeInTheDocument();
});
it('shows Update button for custom roles', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('save-button')).toBeInTheDocument();
});
it('shows Cancel button', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('shows Delete button', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
});
it('renders created/updated timestamps labels', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(screen.findByText('Created At')).resolves.toBeInTheDocument();
await expect(
screen.findByText('Last Modified At'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -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', async () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
data: {
...customRoleResponse.data,
description: '',
},
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(screen.findByText('Description')).resolves.toBeInTheDocument();
});
it('shows fallback for invalid timestamps', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
data: {
...customRoleResponse.data,
createdAt: 'invalid-date',
updatedAt: 'also-invalid',
},
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
it('shows fallback for undefined timestamps', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
data: {
...customRoleResponse.data,
createdAt: undefined,
updatedAt: undefined,
},
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -1,115 +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', async () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Failed to fetch role'),
} as ReturnType<typeof roleApi.useGetRole>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByText('Failed to load role'),
).resolves.toBeInTheDocument();
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
it('shows back button on error state', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Failed to fetch role'),
} as ReturnType<typeof roleApi.useGetRole>);
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('navigates to roles list when back button clicked on error state', async () => {
const user = userEvent.setup();
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Failed to fetch role'),
} as ReturnType<typeof roleApi.useGetRole>);
render(
<Switch>
<Route path="/settings/roles/:roleId">
<ViewRolePage />
</Route>
<Route path="/settings/roles">
<div data-testid="roles-list-target" />
</Route>
</Switch>,
undefined,
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const cancelButton = screen.getByTestId('cancel-button');
await user.click(cancelButton);
await expect(
screen.findByTestId('roles-list-target'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -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', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
},
});
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows error when license is invalid', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: { activeLicense: invalidLicense },
});
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows back button when feature disabled', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: { activeLicense: invalidLicense },
});
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('back button is enabled when feature disabled', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: { activeLicense: invalidLicense },
});
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,68 +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);
}
}
.badge {
display: inline-flex;
align-items: center;
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--spacing-2);
white-space: nowrap;
flex-shrink: 0;
}
.allBadgeVariant {
color: var(--bg-robin-300);
background: color-mix(in srgb, var(--bg-robin-500) 16%, transparent);
}
.noneBadgeVariant {
color: var(--l3-foreground);
background: color-mix(in srgb, var(--l3-foreground) 10%, transparent);
}
.selectedBadgeVariant {
color: var(--bg-sienna-400);
background: color-mix(in srgb, var(--bg-sienna-500) 16%, transparent);
}

View File

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

View File

@@ -1,21 +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 {
padding: var(--spacing-4);
}

View File

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

View File

@@ -1,78 +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;
}
.grantedCount {
display: inline-flex;
align-items: center;
padding: var(--spacing-1) var(--spacing-4);
color: var(--bg-robin-300);
background: color-mix(in srgb, var(--bg-robin-500) 14%, transparent);
border-radius: 16px;
white-space: nowrap;
flex-shrink: 0;
}
.rows {
display: flex;
flex-direction: column;
padding: 0 var(--spacing-7);
// A subtle divider between consecutive action rows.
> * + * {
border-top: 1px solid var(--l1-border);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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