Compare commits

..

21 Commits

Author SHA1 Message Date
Naman Verma
95b7331e42 Merge branch 'main' into nv/schema-changes 2026-06-18 21:09:40 +05:30
Naman Verma
3531ed86f1 fix: reject single-element list default when allowMultiple is false in list variables 2026-06-18 21:08:36 +05:30
Naman Verma
805543cd0d Merge branch 'main' into nv/schema-changes 2026-06-18 20:54:49 +05:30
Naman Verma
9efa0f757d fix: add back enum values 2026-06-18 20:54:16 +05:30
Ashwin Bhatkal
01897f7869 chore: fix variable sort type errors 2026-06-18 19:50:30 +05:30
Naman Verma
d1248ab5a3 chore: remove unsupported enum values (causing errors right now) 2026-06-18 18:00:56 +05:30
Naman Verma
ddf2f3ec53 chore: replicate variable.sort into signoz 2026-06-18 17:39:06 +05:30
Naman Verma
ad4e5b8b89 Merge branch 'main' into nv/schema-changes 2026-06-18 15:40:34 +05:30
Naman Verma
8be973ac14 Merge branch 'main' into nv/schema-changes 2026-06-17 22:00:28 +05:30
Naman Verma
f2422c1fdd fix: replicate text variable spec in signoz to make name required 2026-06-17 22:00:11 +05:30
Naman Verma
6334f26e7d fix: add validations to list variable that text variable has 2026-06-17 20:51:54 +05:30
Naman Verma
563bd2b004 fix: add additional error info on patch application error 2026-06-17 17:42:23 +05:30
Naman Verma
477be8073d fix: reject dashbaords that have vars with the same name 2026-06-17 15:42:22 +05:30
Naman Verma
4e36b9a96a test: add test for missing spec prefix in layout 2026-06-17 14:11:40 +05:30
Naman Verma
7c59e379bd chore: extract out validate panels method 2026-06-17 13:42:29 +05:30
Naman Verma
eec3472e08 fix: check that panels referred in layouts exist 2026-06-17 13:39:33 +05:30
Naman Verma
b0a065591f Merge branch 'main' into nv/schema-changes 2026-06-17 07:29:11 +05:30
Naman Verma
c0e0ebab4a Merge branch 'main' into nv/schema-changes 2026-06-16 20:20:28 +05:30
Naman Verma
9e8464f3eb Merge branch 'main' into nv/schema-changes 2026-06-16 11:40:23 +05:30
Naman Verma
845c88ec45 Merge branch 'main' into nv/schema-changes 2026-06-15 16:55:36 +05:30
Naman Verma
9b53561c31 fix: change schema properties based on UI integration review 2026-06-15 15:37:29 +05:30
163 changed files with 4564 additions and 10684 deletions

7
.github/CODEOWNERS vendored
View File

@@ -189,13 +189,6 @@ go.mod @therealpandey
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
/frontend/src/pages/ChannelsNew/ @SigNoz/pulse-frontend
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv

View File

@@ -647,12 +647,8 @@ components:
type: string
name:
type: string
transactionGroups:
$ref: '#/components/schemas/AuthtypesTransactionGroups'
required:
- name
- description
- transactionGroups
type: object
AuthtypesPostableRotateToken:
properties:
@@ -707,34 +703,6 @@ components:
useRoleAttribute:
type: boolean
type: object
AuthtypesRoleWithTransactionGroups:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
transactionGroups:
$ref: '#/components/schemas/AuthtypesTransactionGroups'
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
- transactionGroups
type: object
AuthtypesSamlConfig:
properties:
attributeMapping:
@@ -768,35 +736,11 @@ components:
- relation
- object
type: object
AuthtypesTransactionGroup:
properties:
objectGroup:
$ref: '#/components/schemas/CoretypesObjectGroup'
relation:
$ref: '#/components/schemas/AuthtypesRelation'
required:
- relation
- objectGroup
type: object
AuthtypesTransactionGroups:
items:
$ref: '#/components/schemas/AuthtypesTransactionGroup'
type: array
AuthtypesUpdatableAuthDomain:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
type: object
AuthtypesUpdatableRole:
properties:
description:
type: string
transactionGroups:
$ref: '#/components/schemas/AuthtypesTransactionGroups'
required:
- description
- transactionGroups
type: object
AuthtypesUserRole:
properties:
createdAt:
@@ -2493,17 +2437,6 @@ components:
url:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
type: boolean
display:
$ref: '#/components/schemas/VariableDisplay'
name:
type: string
value:
type: string
type: object
DashboardtypesAxes:
properties:
isLogScale:
@@ -2868,9 +2801,15 @@ components:
type: string
nullable: true
type: object
mode:
$ref: '#/components/schemas/DashboardtypesLegendMode'
position:
$ref: '#/components/schemas/DashboardtypesLegendPosition'
type: object
DashboardtypesLegendMode:
enum:
- list
type: string
DashboardtypesLegendPosition:
enum:
- bottom
@@ -2921,15 +2860,25 @@ components:
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
minLength: 1
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesVariablePlugin'
sort:
nullable: true
type: string
$ref: '#/components/schemas/DashboardtypesListVariableSpecSort'
required:
- display
- name
type: object
DashboardtypesListVariableSpecSort:
enum:
- none
- alphabetical-asc
- alphabetical-desc
- numerical-asc
- numerical-desc
- alphabetical-ci-asc
- alphabetical-ci-desc
type: string
DashboardtypesListableDashboardForUserV2:
properties:
dashboards:
@@ -3439,8 +3388,13 @@ components:
DashboardtypesSpanGaps:
properties:
fillLessThan:
description: The maximum gap size to connect when fillOnlyBelow is true.
Gaps larger than this duration are left disconnected.
type: string
fillOnlyBelow:
description: Controls whether lines connect across null values. When false
(default), all gaps are connected. When true, only gaps smaller than fillLessThan
are connected.
type: boolean
type: object
DashboardtypesStorableDashboardData:
@@ -3488,6 +3442,20 @@ components:
- color
- columnName
type: object
DashboardtypesTextVariableSpec:
properties:
constant:
type: boolean
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
minLength: 1
type: string
value:
type: string
required:
- name
type: object
DashboardtypesThresholdFormat:
enum:
- text
@@ -3507,7 +3475,6 @@ components:
required:
- value
- color
- label
type: object
DashboardtypesTimePreference:
enum:
@@ -3592,23 +3559,11 @@ components:
discriminator:
mapping:
ListVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
properties:
kind:
enum:
- TextVariable
type: string
spec:
$ref: '#/components/schemas/DashboardTextVariableSpec'
required:
- kind
- spec
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec:
properties:
@@ -3622,6 +3577,18 @@ components:
- kind
- spec
type: object
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec:
properties:
kind:
enum:
- TextVariable
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTextVariableSpec'
required:
- kind
- spec
type: object
DashboardtypesVariablePlugin:
discriminator:
mapping:
@@ -7707,15 +7674,6 @@ components:
type: object
VariableDefaultValue:
type: object
VariableDisplay:
properties:
description:
type: string
hidden:
type: boolean
name:
type: string
type: object
ZeustypesGettableHost:
properties:
hosts:
@@ -10309,15 +10267,6 @@ paths:
name: limit
schema:
type: integer
- in: query
name: q
schema:
type: string
- in: query
name: isOverride
schema:
nullable: true
type: boolean
responses:
"200":
content:
@@ -11123,7 +11072,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
$ref: '#/components/schemas/AuthtypesRole'
status:
type: string
required:
@@ -11158,7 +11107,7 @@ paths:
tags:
- role
patch:
deprecated: true
deprecated: false
description: This endpoint patches a role
operationId: PatchRole
parameters:
@@ -11219,68 +11168,6 @@ paths:
summary: Patch role
tags:
- role
put:
deprecated: false
description: This endpoint updates a role
operationId: UpdateRole
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableRole'
responses:
"204":
description: No Content
"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
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:update
- tokenizer:
- role:update
summary: Update role
tags:
- role
/api/v1/roles/{id}/relations/{relation}/objects:
get:
deprecated: false
@@ -11360,7 +11247,7 @@ paths:
tags:
- role
patch:
deprecated: true
deprecated: false
description: Patches the objects connected to the specified role via a given
relation type
operationId: PatchObjects

View File

@@ -179,36 +179,13 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Write(ctx, tuples, nil)
}
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) error {
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := provider.GetByOrgIDAndName(ctx, orgID, role.Name)
if err != nil && !errors.Asc(err, authtypes.ErrCodeRoleNotFound) {
return err
}
if existingRole != nil {
return errors.Newf(errors.TypeAlreadyExists, authtypes.ErrCodeRoleAlreadyExists, "role with name: %s already exists", existingRole.Name)
}
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
if err != nil {
return err
}
err = provider.Write(ctx, tuples, nil)
if err != nil {
return err
}
if err := provider.store.Create(ctx, role.Role); err != nil {
return err
}
return nil
return provider.store.Create(ctx, role)
}
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
@@ -236,26 +213,6 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
return role, nil
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
if err != nil {
return nil, err
}
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
@@ -290,36 +247,6 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
return objects, nil
}
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
if err != nil {
return err
}
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
if err != nil {
return err
}
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {
return err
}
return provider.store.Update(ctx, orgID, updatedRole.Role)
}
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
@@ -359,7 +286,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
role, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return err
}
@@ -375,12 +302,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
}
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
if err != nil {
return err
}
if err := provider.Write(ctx, nil, tuples); err != nil {
if err := provider.deleteTuples(ctx, role.Name, orgID); err != nil {
return errors.WithAdditionalf(err, "failed to delete tuples for the role: %s", role.Name)
}
@@ -439,7 +361,7 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
return tuples
}
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
func (provider *provider) deleteTuples(ctx context.Context, roleName string, orgID valuer.UUID) error {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
@@ -449,10 +371,26 @@ func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName str
Object: objectType.StringValue() + ":",
})
if err != nil {
return nil, err
return err
}
tuples = append(tuples, typeTuples...)
}
return tuples, nil
if len(tuples) == 0 {
return nil
}
for idx := 0; idx < len(tuples); idx += provider.config.OpenFGA.MaxTuplesPerWrite {
end := idx + provider.config.OpenFGA.MaxTuplesPerWrite
if end > len(tuples) {
end = len(tuples)
}
err := provider.Write(ctx, nil, tuples[idx:end])
if err != nil {
return err
}
}
return nil
}

View File

@@ -48,14 +48,13 @@ const config: Config.InitialOptions = {
],
'^.+\\.(js|jsx)$': 'babel-jest',
},
// TODO: https://github.com/SigNoz/engineering-pod/issues/5334
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -43,7 +43,7 @@
"@dnd-kit/modifiers": "7.0.0",
"@dnd-kit/sortable": "8.0.0",
"@dnd-kit/utilities": "3.2.2",
"@grafana/data": "^11.6.15",
"@grafana/data": "^11.6.14",
"@monaco-editor/react": "^4.7.0",
"@sentry/react": "10.57.0",
"@sentry/vite-plugin": "5.3.0",
@@ -79,7 +79,7 @@
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "4.10.1",
"http-proxy-middleware": "4.1.1",
"http-proxy-middleware": "4.0.0",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
"i18next-browser-languagedetector": "^6.1.3",
@@ -231,17 +231,16 @@
"xml2js": "0.5.0",
"phin": "^3.7.1",
"body-parser": "1.20.3",
"http-proxy-middleware": "4.1.1",
"http-proxy-middleware": "4.0.0",
"cross-spawn": "7.0.5",
"cookie": "^0.7.1",
"serialize-javascript": "6.0.2",
"prismjs": "1.30.0",
"got": "11.8.5",
"form-data": "4.0.6",
"form-data": "4.0.4",
"brace-expansion": "^2.0.2",
"on-headers": "^1.1.0",
"js-cookie": "^3.0.7",
"tmp": "0.2.7",
"tmp": "0.2.4",
"vite": "npm:rolldown-vite@7.3.1"
}
}

View File

@@ -12,17 +12,16 @@ overrides:
xml2js: 0.5.0
phin: ^3.7.1
body-parser: 1.20.3
http-proxy-middleware: 4.1.1
http-proxy-middleware: 4.0.0
cross-spawn: 7.0.5
cookie: ^0.7.1
serialize-javascript: 6.0.2
prismjs: 1.30.0
got: 11.8.5
form-data: 4.0.6
form-data: 4.0.4
brace-expansion: ^2.0.2
on-headers: ^1.1.0
js-cookie: ^3.0.7
tmp: 0.2.7
tmp: 0.2.4
vite: npm:rolldown-vite@7.3.1
importers:
@@ -57,8 +56,8 @@ importers:
specifier: 3.2.2
version: 3.2.2(react@18.2.0)
'@grafana/data':
specifier: ^11.6.15
version: 11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
specifier: ^11.6.14
version: 11.6.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -165,8 +164,8 @@ importers:
specifier: 4.10.1
version: 4.10.1
http-proxy-middleware:
specifier: 4.1.1
version: 4.1.1
specifier: 4.0.0
version: 4.0.0
http-status-codes:
specifier: 2.3.0
version: 2.3.0
@@ -1637,14 +1636,14 @@ packages:
'@gerrit0/mini-shiki@3.23.0':
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
'@grafana/data@11.6.15':
resolution: {integrity: sha512-q2Zbjr0N9iEGY/zKHm4Z4X5x64806E17W58y7mnvwc0MlbyGPPVulcp/rWA2Nd190mZeafZQPer9u+MaO+0HUQ==}
'@grafana/data@11.6.14':
resolution: {integrity: sha512-Nsjq1A9m6LbsKsKvOgvAk9Wq7RGjy0V4N9d5YsSnzMwCiw/ov2wblR2bcDpy95uF8KaDTIR2Gf40nJaOYksPMA==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
'@grafana/schema@11.6.15':
resolution: {integrity: sha512-MPIvGAp9uzkswnH6e+Fmzu+WBTqWMgbv93/8iu56gb+sjCB2LciZLz4KvrPFdw32bWCGSMAGqsML9mgmeJZtGQ==}
'@grafana/schema@11.6.14':
resolution: {integrity: sha512-YTqgYekb7kiu5NEoQxKF8czJ6QIARmMkCi9cNcynHqYpcDLOv5pg5Q0QtKgiiqHjlYoEeCV6iejdB4hXxzB+VA==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
@@ -5168,8 +5167,8 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
format@0.2.2:
@@ -5382,10 +5381,6 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hast-util-from-parse5@8.0.1:
resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==}
@@ -5461,8 +5456,8 @@ packages:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
http-proxy-middleware@4.1.1:
resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==}
http-proxy-middleware@4.0.0:
resolution: {integrity: sha512-wuHwaUtmC0XzJNHqRp41zXtt5ojpHbusXGhq6781VvnjWUYPu7opmOF3eomGNujT07kEOnHWZyV9UZzKimVCKA==}
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
http-status-codes@2.3.0:
@@ -5472,8 +5467,8 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
httpxy@0.5.3:
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
httpxy@0.5.1:
resolution: {integrity: sha512-JPhqYiixe1A1I+MXDewWDZqeudBGU8Q9jCHYN8ML+779RQzLjTi78HBvWz4jMxUD6h2/vUL12g4q/mFM0OUw1A==}
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
@@ -6046,8 +6041,8 @@ packages:
js-base64@3.7.5:
resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==}
js-cookie@3.0.8:
resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==}
js-cookie@2.2.1:
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
@@ -8399,8 +8394,8 @@ packages:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
tmp@0.2.7:
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
tmp@0.2.4:
resolution: {integrity: sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==}
engines: {node: '>=14.14'}
tmpl@1.0.5:
@@ -10323,10 +10318,10 @@ snapshots:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@grafana/data@11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
'@grafana/data@11.6.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@braintree/sanitize-url': 7.0.1
'@grafana/schema': 11.6.15
'@grafana/schema': 11.6.14
'@types/d3-interpolate': 3.0.1
'@types/string-hash': 1.1.3
d3-interpolate: 3.0.1
@@ -10352,7 +10347,7 @@ snapshots:
uplot: 1.6.31
xss: 1.0.14
'@grafana/schema@11.6.15':
'@grafana/schema@11.6.14':
dependencies:
tslib: 2.8.1
@@ -12891,7 +12886,7 @@ snapshots:
axios@1.16.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.6
form-data: 4.0.4
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
@@ -13838,7 +13833,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
hasown: 2.0.2
es-toolkit@1.46.1: {}
@@ -14036,7 +14031,7 @@ snapshots:
dependencies:
chardet: 0.7.0
iconv-lite: 0.4.24
tmp: 0.2.7
tmp: 0.2.4
fast-deep-equal@3.1.3: {}
@@ -14169,12 +14164,12 @@ snapshots:
cross-spawn: 7.0.5
signal-exit: 4.1.0
form-data@4.0.6:
form-data@4.0.4:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
hasown: 2.0.2
mime-types: 2.1.35
format@0.2.2: {}
@@ -14253,7 +14248,7 @@ snapshots:
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.4
hasown: 2.0.2
math-intrinsics: 1.1.0
get-nonce@1.0.1: {}
@@ -14391,10 +14386,6 @@ snapshots:
dependencies:
function-bind: 1.1.2
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
hast-util-from-parse5@8.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -14515,10 +14506,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
http-proxy-middleware@4.1.1:
http-proxy-middleware@4.0.0:
dependencies:
debug: 4.3.4(supports-color@5.5.0)
httpxy: 0.5.3
httpxy: 0.5.1
is-glob: 4.0.3
is-plain-obj: 4.1.0
micromatch: 4.0.8
@@ -14534,7 +14525,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
httpxy@0.5.3: {}
httpxy@0.5.1: {}
human-signals@2.1.0: {}
@@ -15348,7 +15339,7 @@ snapshots:
js-base64@3.7.5: {}
js-cookie@3.0.8: {}
js-cookie@2.2.1: {}
js-levenshtein@1.1.6: {}
@@ -15376,7 +15367,7 @@ snapshots:
decimal.js: 10.6.0
domexception: 4.0.0
escodegen: 2.1.0
form-data: 4.0.6
form-data: 4.0.4
html-encoding-sniffer: 3.0.0
http-proxy-agent: 5.0.0
https-proxy-agent: 5.0.1
@@ -17345,7 +17336,7 @@ snapshots:
copy-to-clipboard: 3.3.3
fast-deep-equal: 3.1.3
fast-shallow-equal: 1.0.0
js-cookie: 3.0.8
js-cookie: 2.2.1
nano-css: 5.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
@@ -17364,7 +17355,7 @@ snapshots:
copy-to-clipboard: 3.3.3
fast-deep-equal: 3.1.3
fast-shallow-equal: 1.0.0
js-cookie: 3.0.8
js-cookie: 2.2.1
nano-css: 5.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
@@ -18112,7 +18103,7 @@ snapshots:
tinypool@2.1.0: {}
tmp@0.2.7: {}
tmp@0.2.4: {}
tmpl@1.0.5: {}

View File

@@ -55,6 +55,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
),
[pathname],
);
const isOldRoute = oldRoutes.indexOf(pathname) > -1;
const currentRoute = mapRoutes.get('current');
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
@@ -82,36 +83,12 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
}, [usersData?.data]);
// Handle old routes - redirect to new routes
const isOldRoute = oldRoutes.indexOf(pathname) > -1;
if (isOldRoute) {
const redirectUrl = oldNewRoutesMapping[pathname];
// TODO(H4ad): Remove this after https://github.com/SigNoz/engineering-pod/issues/5322
// A mapped target may itself carry a query string (e.g. `/alerts?tab=Channels`).
// react-router does not re-parse a `?` embedded in the `pathname` field, so split
// it out and merge with the incoming search params.
const [redirectPath, redirectSearch = ''] = redirectUrl.split('?');
const mergedParams = new URLSearchParams(location.search);
new URLSearchParams(redirectSearch).forEach((value, name) => {
mergedParams.set(name, value);
});
const search = mergedParams.toString();
return (
<Redirect
to={{
pathname: redirectPath,
search: search ? `?${search}` : '',
hash: location.hash,
}}
/>
);
}
if (pathname.startsWith('/settings/channels/edit/')) {
const channelId = pathname.replace('/settings/channels/edit/', '');
return (
<Redirect
to={{
pathname: `/alerts/channels/edit/${channelId}`,
pathname: redirectUrl,
search: location.search,
hash: location.hash,
}}

View File

@@ -73,13 +73,7 @@ const queryClient = new QueryClient({
// Component to capture current location for assertions
function LocationDisplay(): ReactElement {
const location = useLocation();
return (
<>
<div data-testid="location-display">{location.pathname}</div>
<div data-testid="location-search">{location.search}</div>
<div data-testid="location-hash">{location.hash}</div>
</>
);
return <div data-testid="location-display">{location.pathname}</div>;
}
// Helper to create mock user
@@ -1481,10 +1475,12 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should redirect VIEWER from /alerts/channels/new (ADMIN only)', async () => {
// After moving channels under /alerts, CHANNELS_NEW ('/alerts/channels/new')
// is an exact, ADMIN-only route with no overlapping non-exact ALL_CHANNELS
// route to match last, so a VIEWER is now correctly redirected.
it('should not redirect VIEWER from /settings/channels/new due to route matching order (ALL_CHANNELS matches last)', () => {
// Note: This tests the ACTUAL behavior of Private.tsx route matching
// CHANNELS_NEW has path '/settings/channels/new' with permission ['ADMIN']
// ALL_CHANNELS has path '/settings/channels' with permission ['ADMIN', 'EDITOR', 'VIEWER']
// Due to non-exact matching and array order, ALL_CHANNELS matches LAST for '/settings/channels/new'
// This is a known limitation - actual permission enforcement happens in the page component
renderPrivateRoute({
initialRoute: ROUTES.CHANNELS_NEW,
appContext: {
@@ -1493,7 +1489,8 @@ describe('PrivateRoute', () => {
},
});
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
assertRendersChildren();
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
});
it('should allow EDITOR to access /get-started route', () => {
@@ -1551,60 +1548,4 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
});
describe('Old channel route redirects', () => {
it.each([
['/settings/channels', '/alerts', 'tab=Channels'],
['/settings/channels/new', '/alerts/channels/new', ''],
])(
'should redirect %s to %s',
async (oldRoute, expectedPath, expectedSearch) => {
renderPrivateRoute({
initialRoute: oldRoute,
appContext: { isLoggedIn: true },
});
await waitFor(() => {
expect(screen.getByTestId('location-display')).toHaveTextContent(
expectedPath,
);
});
if (expectedSearch) {
const search = screen.getByTestId('location-search').textContent ?? '';
const params = new URLSearchParams(search);
new URLSearchParams(expectedSearch).forEach((value, name) => {
expect(params.get(name)).toBe(value);
});
} else {
expect(screen.getByTestId('location-search')).toHaveTextContent('');
}
},
);
it('should redirect dynamic channel edit route preserving the channel id', async () => {
renderPrivateRoute({
initialRoute: '/settings/channels/edit/abc123',
appContext: { isLoggedIn: true },
});
await assertRedirectsTo('/alerts/channels/edit/abc123');
});
it('should merge incoming query params with the embedded query of the target', async () => {
renderPrivateRoute({
initialRoute: '/settings/channels?foo=bar',
appContext: { isLoggedIn: true },
});
await waitFor(() => {
expect(screen.getByTestId('location-display')).toHaveTextContent('/alerts');
});
const search = screen.getByTestId('location-search').textContent ?? '';
const params = new URLSearchParams(search);
expect(params.get('tab')).toBe('Channels');
expect(params.get('foo')).toBe('bar');
});
});
});

View File

@@ -142,12 +142,12 @@ export const AlertOverview = Loadable(
() => import(/* webpackChunkName: "Alert Overview" */ 'pages/AlertList'),
);
export const ChannelsNew = Loadable(
() => import(/* webpackChunkName: "Create Channels" */ 'pages/AlertList'),
export const CreateAlertChannelAlerts = Loadable(
() => import(/* webpackChunkName: "Create Channels" */ 'pages/Settings'),
);
export const ChannelsEdit = Loadable(
() => import(/* webpackChunkName: "Edit Channels" */ 'pages/AlertList'),
export const AllAlertChannels = Loadable(
() => import(/* webpackChunkName: "All Channels" */ 'pages/Settings'),
);
export const AllErrors = Loadable(

View File

@@ -5,10 +5,10 @@ import {
AIAssistantPage,
AlertHistory,
AlertOverview,
AllAlertChannels,
AllErrors,
ApiMonitoring,
ChannelsEdit,
ChannelsNew,
CreateAlertChannelAlerts,
CreateNewAlerts,
DashboardPage,
DashboardsListPage,
@@ -269,16 +269,16 @@ const routes: AppRoutes[] = [
{
path: ROUTES.CHANNELS_NEW,
exact: true,
component: ChannelsNew,
component: CreateAlertChannelAlerts,
isPrivate: true,
key: 'CHANNELS_NEW',
},
{
path: ROUTES.CHANNELS_EDIT,
path: ROUTES.ALL_CHANNELS,
exact: true,
component: ChannelsEdit,
component: AllAlertChannels,
isPrivate: true,
key: 'CHANNELS_EDIT',
key: 'ALL_CHANNELS',
},
{
path: ROUTES.ALL_ERROR,
@@ -534,9 +534,6 @@ export const oldNewRoutesMapping: Record<string, string> = {
'/messaging-queues': '/messaging-queues/overview',
'/alerts/edit': '/alerts/overview',
'/alerts/type-selection': '/alerts/new',
// TODO(H4ad): Update this after https://github.com/SigNoz/engineering-pod/issues/5322
'/settings/channels': '/alerts?tab=Channels',
'/settings/channels/new': '/alerts/channels/new',
};
export const oldRoutes = Object.keys(oldNewRoutesMapping);

View File

@@ -20,7 +20,6 @@ import type {
import type {
AuthtypesPatchableRoleDTO,
AuthtypesPostableRoleDTO,
AuthtypesUpdatableRoleDTO,
CoretypesPatchableObjectsDTO,
CreateRole201,
DeleteRolePathParameters,
@@ -32,7 +31,6 @@ import type {
PatchObjectsPathParameters,
PatchRolePathParameters,
RenderErrorResponseDTO,
UpdateRolePathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -367,7 +365,6 @@ export const invalidateGetRole = async (
/**
* This endpoint patches a role
* @deprecated
* @summary Patch role
*/
export const patchRole = (
@@ -439,7 +436,6 @@ export type PatchRoleMutationBody =
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch role
*/
export const usePatchRole = <
@@ -466,105 +462,6 @@ export const usePatchRole = <
> => {
return useMutation(getPatchRoleMutationOptions(options));
};
/**
* This endpoint updates a role
* @summary Update role
*/
export const updateRole = (
{ id }: UpdateRolePathParameters,
authtypesUpdatableRoleDTO?: BodyType<AuthtypesUpdatableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdatableRoleDTO,
signal,
});
};
export const getUpdateRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
> => {
const mutationKey = ['updateRole'];
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 updateRole>>,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateRole(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRole>>
>;
export type UpdateRoleMutationBody =
| BodyType<AuthtypesUpdatableRoleDTO>
| undefined;
export type UpdateRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update role
*/
export const useUpdateRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
> => {
return useMutation(getUpdateRoleMutationOptions(options));
};
/**
* Gets all objects connected to the specified role via a given relation type
* @summary Get objects for a role by relation
@@ -668,7 +565,6 @@ export const invalidateGetObjects = async (
/**
* Patches the objects connected to the specified role via a given relation type
* @deprecated
* @summary Patch objects for a role by relation
*/
export const patchObjects = (
@@ -740,7 +636,6 @@ export type PatchObjectsMutationBody =
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch objects for a role by relation
*/
export const usePatchObjects = <

View File

@@ -2224,31 +2224,15 @@ export interface AuthtypesPostableEmailPasswordSessionDTO {
password?: string;
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**
* @type array
*/
selectors: string[];
}
export interface AuthtypesTransactionGroupDTO {
objectGroup: CoretypesObjectGroupDTO;
relation: AuthtypesRelationDTO;
}
export type AuthtypesTransactionGroupsDTO = AuthtypesTransactionGroupDTO[];
export interface AuthtypesPostableRoleDTO {
/**
* @type string
*/
description: string;
description?: string;
/**
* @type string
*/
name: string;
transactionGroups: AuthtypesTransactionGroupsDTO;
}
export interface AuthtypesPostableRotateTokenDTO {
@@ -2291,40 +2275,6 @@ export interface AuthtypesRoleDTO {
updatedAt?: string;
}
export interface AuthtypesRoleWithTransactionGroupsDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
transactionGroups: AuthtypesTransactionGroupsDTO;
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface AuthtypesSessionContextDTO {
/**
* @type boolean
@@ -2345,14 +2295,6 @@ export interface AuthtypesUpdatableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
}
export interface AuthtypesUpdatableRoleDTO {
/**
* @type string
*/
description: string;
transactionGroups: AuthtypesTransactionGroupsDTO;
}
export interface AuthtypesUserRoleDTO {
/**
* @type string
@@ -3123,6 +3065,14 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**
* @type array
*/
selectors: string[];
}
export interface CoretypesPatchableObjectsDTO {
/**
* @type array,null
@@ -3204,37 +3154,6 @@ export interface DashboardLinkDTO {
url?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type boolean
*/
hidden?: boolean;
/**
* @type string
*/
name?: string;
}
export interface DashboardTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display?: VariableDisplayDTO;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
value?: string;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
@@ -3266,6 +3185,9 @@ export interface DashboardtypesPanelFormattingDTO {
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
@@ -3285,6 +3207,7 @@ export interface DashboardtypesLegendDTO {
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
@@ -3296,7 +3219,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
label: string;
label?: string;
/**
* @type string
*/
@@ -3961,10 +3884,12 @@ export enum DashboardtypesLineStyleDTO {
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
@@ -4596,6 +4521,15 @@ export type DashboardtypesVariablePluginDTO =
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTO
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTO;
export enum DashboardtypesListVariableSpecSortDTO {
none = 'none',
'alphabetical-asc' = 'alphabetical-asc',
'alphabetical-desc' = 'alphabetical-desc',
'numerical-asc' = 'numerical-asc',
'numerical-desc' = 'numerical-desc',
'alphabetical-ci-asc' = 'alphabetical-ci-asc',
'alphabetical-ci-desc' = 'alphabetical-ci-desc',
}
export interface DashboardtypesListVariableSpecDTO {
/**
* @type boolean
@@ -4614,16 +4548,14 @@ export interface DashboardtypesListVariableSpecDTO {
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
display: DashboardtypesDisplayDTO;
display?: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name?: string;
name: string;
plugin?: DashboardtypesVariablePluginDTO;
/**
* @type string,null
*/
sort?: string | null;
sort?: DashboardtypesListVariableSpecSortDTO;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO {
@@ -4635,21 +4567,38 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
spec: DashboardtypesListVariableSpecDTO;
}
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind {
TextVariable = 'TextVariable',
}
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
export interface DashboardtypesTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display?: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name: string;
/**
* @type string
*/
value?: string;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO {
/**
* @enum TextVariable
* @type string
*/
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
spec: DashboardTextVariableSpecDTO;
kind: DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind;
spec: DashboardtypesTextVariableSpecDTO;
}
export type DashboardtypesVariableDTO =
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
export interface DashboardtypesDashboardSpecDTO {
/**
@@ -9500,16 +9449,6 @@ export type ListLLMPricingRulesParams = {
* @description undefined
*/
limit?: number;
/**
* @type string
* @description undefined
*/
q?: string;
/**
* @type boolean,null
* @description undefined
*/
isOverride?: boolean | null;
};
export type ListLLMPricingRules200 = {
@@ -9619,7 +9558,7 @@ export type GetRolePathParameters = {
id: string;
};
export type GetRole200 = {
data: AuthtypesRoleWithTransactionGroupsDTO;
data: AuthtypesRoleDTO;
/**
* @type string
*/
@@ -9629,9 +9568,6 @@ export type GetRole200 = {
export type PatchRolePathParameters = {
id: string;
};
export type UpdateRolePathParameters = {
id: string;
};
export type GetObjectsPathParameters = {
id: string;
relation: string;

View File

@@ -274,110 +274,4 @@ describe('convertV5ResponseToLegacy', () => {
},
});
});
it('clickhouse_sql scalar keeps each value column distinct (regression: all-"A" collapse)', () => {
const scalar: ScalarData = {
columns: [
{
name: 'service.name',
queryName: 'A',
aggregationIndex: 0,
columnType: 'group',
} as unknown as ScalarData['columns'][number],
{
name: 'current_availability',
queryName: 'A',
aggregationIndex: 0,
columnType: 'aggregation',
} as unknown as ScalarData['columns'][number],
{
name: 'error_budget_remaining',
queryName: 'A',
aggregationIndex: 1,
columnType: 'aggregation',
} as unknown as ScalarData['columns'][number],
{
name: 'budget_status',
queryName: 'A',
aggregationIndex: 2,
columnType: 'group',
} as unknown as ScalarData['columns'][number],
{
name: 'total_requests',
queryName: 'A',
aggregationIndex: 4,
columnType: 'aggregation',
} as unknown as ScalarData['columns'][number],
],
data: [['kuja-api_gateway-service', 99.985, 0.985, 'Healthy ✅', 2181216]],
};
const v5Data: QueryRangeResponseV5 = {
type: 'scalar',
data: { results: [scalar] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
};
// A clickhouse_sql envelope contributes no aggregation metadata.
const params = makeBaseParams('scalar', [
{
type: 'clickhouse_sql',
spec: {
name: 'A',
query: 'SELECT ...',
disabled: false,
},
} as unknown as QueryRangeRequestV5['compositeQuery']['queries'][number],
]);
const input: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5> =
makeBaseSuccess({ data: v5Data }, params);
// formatForWeb=true is the table-panel path.
const result = convertV5ResponseToLegacy(input, { A: '' }, true);
const [tableEntry] = result.payload.data.result;
// Headers keep their real names instead of collapsing to "A".
expect(tableEntry.table?.columns).toStrictEqual([
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{
name: 'current_availability',
queryName: 'A',
isValueColumn: true,
id: 'current_availability',
},
{
name: 'error_budget_remaining',
queryName: 'A',
isValueColumn: true,
id: 'error_budget_remaining',
},
{
name: 'budget_status',
queryName: 'A',
isValueColumn: false,
id: 'budget_status',
},
{
name: 'total_requests',
queryName: 'A',
isValueColumn: true,
id: 'total_requests',
},
]);
// Ids are unique, so value columns don't overwrite each other in the row.
expect(tableEntry.table?.rows?.[0]).toStrictEqual({
data: {
'service.name': 'kuja-api_gateway-service',
current_availability: 99.985,
error_budget_remaining: 0.985,
budget_status: 'Healthy ✅',
total_requests: 2181216,
},
});
});
});

View File

@@ -15,7 +15,6 @@ function getColName(
col: ScalarData['columns'][number],
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): string {
if (col.columnType === 'group') {
return col.name;
@@ -40,32 +39,16 @@ function getColName(
return alias || expression || col.queryName;
}
// clickhouse_sql value columns carry their real SQL alias in col.name — use
// it so each value column keeps its own header instead of collapsing onto
// the query name. Formulas/promql use placeholder names, so they fall back
// to legend || queryName.
if (clickhouseQueryNames.has(col.queryName)) {
return col.name;
}
return legend || col.queryName;
}
function getColId(
col: ScalarData['columns'][number],
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): string {
if (col.columnType === 'group') {
return col.name;
}
// clickhouse_sql value columns are keyed by their real SQL alias so multiple
// value columns stay unique instead of all collapsing onto the query name
// (which would overwrite every cell in the row with the last column's value).
if (clickhouseQueryNames.has(col.queryName)) {
return col.name;
}
const aggregation =
aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex];
const expression = aggregation?.expression || '';
@@ -158,7 +141,6 @@ function convertScalarDataArrayToTable(
scalarDataArray: ScalarData[],
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): QueryDataV3[] {
// If no scalar data, return empty structure
@@ -184,10 +166,10 @@ function convertScalarDataArrayToTable(
// Collect columns for this specific query
const columns = scalarData?.columns?.map((col) => ({
name: getColName(col, legendMap, aggregationPerQuery, clickhouseQueryNames),
name: getColName(col, legendMap, aggregationPerQuery),
queryName: col.queryName,
isValueColumn: col.columnType === 'aggregation',
id: getColId(col, aggregationPerQuery, clickhouseQueryNames),
id: getColId(col, aggregationPerQuery),
}));
// Process rows for this specific query
@@ -195,13 +177,8 @@ function convertScalarDataArrayToTable(
const rowData: Record<string, any> = {};
scalarData?.columns?.forEach((col, colIndex) => {
const columnName = getColName(
col,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
const columnId = getColId(col, aggregationPerQuery, clickhouseQueryNames);
const columnName = getColName(col, legendMap, aggregationPerQuery);
const columnId = getColId(col, aggregationPerQuery);
rowData[columnId || columnName] = dataRow[colIndex];
});
@@ -225,7 +202,6 @@ function convertScalarWithFormatForWeb(
scalarDataArray: ScalarData[],
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): QueryDataV3[] {
if (!scalarDataArray || scalarDataArray.length === 0) {
return [];
@@ -234,18 +210,13 @@ function convertScalarWithFormatForWeb(
return scalarDataArray.map((scalarData) => {
const columns =
scalarData.columns?.map((col) => {
const colName = getColName(
col,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
const colName = getColName(col, legendMap, aggregationPerQuery);
return {
name: colName,
queryName: col.queryName,
isValueColumn: col.columnType === 'aggregation',
id: getColId(col, aggregationPerQuery, clickhouseQueryNames),
id: getColId(col, aggregationPerQuery),
};
}) || [];
@@ -318,7 +289,6 @@ function convertV5DataByType(
v5Data: any,
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): MetricRangePayloadV3['data'] {
switch (v5Data?.type) {
case 'time_series': {
@@ -337,7 +307,6 @@ function convertV5DataByType(
scalarData,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
return {
resultType: 'scalar',
@@ -404,15 +373,6 @@ export function convertV5ResponseToLegacy(
{} as Record<string, any>,
) || {};
// clickhouse_sql queries have no aggregation metadata; their value columns
// are named/keyed by the real SQL alias the response carries (see getColId).
const clickhouseQueryNames = new Set<string>(
(params?.compositeQuery?.queries ?? [])
.filter((query) => query.type === 'clickhouse_sql')
.map((query) => (query.spec as { name?: string })?.name)
.filter((name): name is string => !!name),
);
// If formatForWeb is true, return as-is (like existing logic)
if (formatForWeb && v5Data?.type === 'scalar') {
const scalarData = v5Data.data.results as ScalarData[];
@@ -420,7 +380,6 @@ export function convertV5ResponseToLegacy(
scalarData,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
return {
@@ -443,7 +402,6 @@ export function convertV5ResponseToLegacy(
v5Data,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
// Create legacy-compatible response structure

View File

@@ -29,10 +29,9 @@ const ROUTES = {
ALERTS_NEW: '/alerts/new',
ALERT_HISTORY: '/alerts/history',
ALERT_OVERVIEW: '/alerts/overview',
// TODO(H4ad): Add test to forbidden ? in this map after https://github.com/SigNoz/engineering-pod/issues/5322
ALL_CHANNELS: '/alerts?tab=Channels',
CHANNELS_NEW: '/alerts/channels/new',
CHANNELS_EDIT: '/alerts/channels/edit/:channelId',
ALL_CHANNELS: '/settings/channels',
CHANNELS_NEW: '/settings/channels/new',
CHANNELS_EDIT: '/settings/channels/edit/:channelId',
ALL_ERROR: '/exceptions',
ERROR_DETAIL: '/error-detail',
VERSION: '/status',
@@ -55,7 +54,6 @@ const ROUTES = {
TRACE_EXPLORER: '/trace-explorer',
BILLING: '/settings/billing',
ROLES_SETTINGS: '/settings/roles',
ROLE_CREATE: '/settings/roles/new',
ROLE_DETAILS: '/settings/roles/:roleId',
MEMBERS_SETTINGS: '/settings/members',
SUPPORT: '/support',

View File

@@ -94,7 +94,7 @@ describe('resourceRoute', () => {
it('routes channels to the edit page', () => {
expect(resourceRoute(ResourceType.channel, 'channel-uuid-1')).toBe(
'/alerts/channels/edit/channel-uuid-1',
'/settings/channels/edit/channel-uuid-1',
);
});
});

View File

@@ -1,4 +1,4 @@
.alert-channels-container {
width: 100%;
padding: 0 var(--spacing-8);
width: 90%;
margin: 12px auto;
}

View File

@@ -1,5 +1,7 @@
.create-alert-channels-container {
width: 100%;
width: 90%;
margin: 12px auto;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 3px;

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,139 +0,0 @@
.createEditRolePage {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
width: 100%;
max-width: 60vw;
margin: 0 auto;
height: 100%;
min-height: 0;
}
.createEditRolePageHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
}
.createEditRolePageActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedIndicator {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin-right: var(--spacing-4);
}
.unsavedDot {
display: block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
flex-shrink: 0;
}
.unsavedText {
font-family: Inter;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
color: var(--primary);
}
.createEditRolePageContent {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
flex: 1;
min-height: 0;
}
.createEditRolePageForm {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.formRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-8);
}
.formField {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-disabled-background: var(--l2-background);
input::placeholder {
color: var(--l3-foreground);
}
}
.formLabel {
font-family: Inter;
font-size: 12px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-20);
letter-spacing: 0.48px;
text-transform: uppercase;
color: var(--l2-foreground);
}
.createEditRolePageDivider {
width: 100%;
height: 1px;
background: var(--l1-border);
}
.errorBanner {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-5);
background: color-mix(in srgb, var(--bg-cherry-500) 15%, transparent);
border: 1px solid var(--bg-cherry-500);
border-radius: var(--border-radius-m);
}
.errorBannerMessage {
font-family: Inter;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
color: var(--text-cherry-500);
word-break: break-word;
}
.errorBannerDismiss {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: var(--spacing-1);
background: none;
border: none;
color: var(--text-cherry-500);
cursor: pointer;
border-radius: var(--border-radius-s);
transition: background 0.15s ease;
&:hover {
background: color-mix(in srgb, var(--bg-cherry-500) 20%, transparent);
}
}

View File

@@ -1,172 +0,0 @@
import { Link, matchPath, useLocation } from 'react-router-dom';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { BreadcrumbSimple } from '@signozhq/ui/breadcrumb';
import { Skeleton } from 'antd';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import PermissionEditor from './components/PermissionEditor';
import { useCreateEditRolePageCallbacks } from './useCreateEditRolePageCallbacks';
import styles from './CreateEditRolePage.module.scss';
function CreateEditRolePage(): JSX.Element {
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 {
formData,
editorMode,
setEditorMode,
resources,
setResources,
isLoading,
isSaving,
hasUnsavedChanges,
handleSave,
handleCancel,
handleFormChange,
saveError,
clearSaveError,
validationErrors,
isCreateMode,
hasRequiredPermission,
isAuthZLoading,
deniedPermission,
} = useCreateEditRolePageCallbacks(roleId, roleName);
if (!hasRequiredPermission && !isAuthZLoading) {
return <PermissionDeniedFullPage permissionName={deniedPermission} />;
}
if (isAuthZLoading || (isLoading && !isCreateMode)) {
return (
<div className={styles.createEditRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
return (
<div
className={styles.createEditRolePage}
data-testid="create-edit-role-page"
>
<div className={styles.createEditRolePageHeader}>
<BreadcrumbSimple
items={[
{
title: 'Roles',
path: ROUTES.ROLES_SETTINGS,
},
{
title: isCreateMode ? 'Create role' : 'Edit role',
},
]}
itemRender={(route, _, routes) => {
const isLast = route === routes[routes.length - 1];
return isLast ? (
<span>{route.title}</span>
) : (
<Link to={route.path!}>{route.title}</Link>
);
}}
/>
<div className={styles.createEditRolePageActions}>
{hasUnsavedChanges && (
<div className={styles.unsavedIndicator}>
<span className={styles.unsavedDot} />
<span className={styles.unsavedText}>Unsaved changes</span>
</div>
)}
<Button
variant="solid"
color="secondary"
onClick={handleCancel}
disabled={isSaving}
data-testid="cancel-button"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={handleSave}
loading={isSaving}
disabled={!hasUnsavedChanges}
data-testid="save-button"
>
{isCreateMode ? 'Create role' : 'Save changes'}
</Button>
</div>
</div>
{saveError && (
<div className={styles.errorBanner} data-testid="save-error-banner">
<span className={styles.errorBannerMessage}>{saveError}</span>
<button
type="button"
className={styles.errorBannerDismiss}
onClick={clearSaveError}
aria-label="Dismiss error"
>
<X size={14} />
</button>
</div>
)}
<div className={styles.createEditRolePageContent}>
<div className={styles.createEditRolePageForm}>
<div className={styles.formRow}>
<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"
disabled={!isCreateMode}
data-testid="role-name-input"
/>
</div>
<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}
isLoading={isLoading}
validationErrors={validationErrors}
/>
</div>
</div>
);
}
export default CreateEditRolePage;

View File

@@ -1,69 +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 type { BrandedPermission, UseAuthZResult } from 'hooks/useAuthZ/types';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
afterEach(() => {
jest.clearAllMocks();
});
function renderCreatePage(): ReturnType<typeof render> {
return render(
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_CREATE}>
<CreateEditRolePage />
</Route>
</Switch>,
undefined,
{ initialRoute: '/settings/roles/new' },
);
}
describe('CreateRolePage - AuthZ', () => {
describe('permission denied', () => {
it('shows PermissionDeniedFullPage when create permission denied', () => {
mockUseAuthZ.mockImplementation(
(permissions: BrandedPermission[]): UseAuthZResult => ({
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => [p, { isGranted: false }]),
) as UseAuthZResult['permissions'],
refetchPermissions: jest.fn(),
}),
);
renderCreatePage();
expect(
screen.getByText(/don't have permission to view this page/i),
).toBeInTheDocument();
});
});
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
mockUseAuthZ.mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
refetchPermissions: jest.fn(),
});
renderCreatePage();
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
});

View File

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

View File

@@ -1,118 +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 type { BrandedPermission, UseAuthZResult } from 'hooks/useAuthZ/types';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const EDIT_ROLE_ID = 'test-role-123';
const EDIT_ROLE_NAME = 'test-role';
afterEach(() => {
jest.clearAllMocks();
});
function renderEditPage(): ReturnType<typeof render> {
return render(
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_DETAILS}>
<CreateEditRolePage />
</Route>
</Switch>,
undefined,
{ initialRoute: `/settings/roles/${EDIT_ROLE_ID}?name=${EDIT_ROLE_NAME}` },
);
}
describe('EditRolePage - AuthZ', () => {
describe('permission denied', () => {
it('shows PermissionDeniedFullPage when read permission denied', () => {
mockUseAuthZ.mockImplementation(
(permissions: BrandedPermission[]): UseAuthZResult => ({
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => [p, { isGranted: false }]),
) as UseAuthZResult['permissions'],
refetchPermissions: jest.fn(),
}),
);
renderEditPage();
expect(
screen.getByText(/don't have permission to view this page/i),
).toBeInTheDocument();
});
it('shows PermissionDeniedFullPage when update permission denied but read granted', () => {
mockUseAuthZ.mockImplementation(
(permissions: BrandedPermission[]): UseAuthZResult => ({
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => {
const isReadPerm = p.includes(':read:');
return [p, { isGranted: isReadPerm }];
}),
) as UseAuthZResult['permissions'],
refetchPermissions: jest.fn(),
}),
);
renderEditPage();
expect(
screen.getByText(/don't have permission to view this page/i),
).toBeInTheDocument();
});
it('checks both read and update permissions for edit mode', () => {
mockUseAuthZ.mockImplementation(
(permissions: BrandedPermission[]): UseAuthZResult => ({
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => [p, { isGranted: false }]),
) as UseAuthZResult['permissions'],
refetchPermissions: jest.fn(),
}),
);
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,327 +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 = 'http://localhost/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}>
<CreateEditRolePage />
</Route>
</Switch>,
undefined,
{ initialRoute: `/settings/roles/${roleId}` },
);
}
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('initial render with loaded data', () => {
it('shows breadcrumb with "Edit role" as current page', async () => {
renderEditPage();
await expect(screen.findByText('Edit role')).resolves.toBeInTheDocument();
});
it('populates name input with existing role name', async () => {
renderEditPage();
await waitFor(async () => {
const nameInput = await screen.findByTestId('role-name-input');
expect(nameInput).toHaveValue('billing-manager');
});
});
it('name input is disabled in edit mode', async () => {
renderEditPage();
await waitFor(async () => {
const nameInput = await screen.findByTestId('role-name-input');
expect(nameInput).toBeDisabled();
});
});
it('populates description input with existing value', async () => {
renderEditPage();
await waitFor(async () => {
const descInput = await screen.findByTestId('role-description-input');
expect(descInput).toHaveValue(
'Custom role for managing billing and invoices.',
);
});
});
it('description input is enabled in edit mode', async () => {
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
expect(descInput).not.toBeDisabled();
});
it('save button shows "Save changes" text', async () => {
renderEditPage();
const saveBtn = await screen.findByTestId('save-button');
expect(saveBtn).toHaveTextContent('Save changes');
});
it('save button is disabled when no unsaved changes', async () => {
renderEditPage();
await waitFor(async () => {
const descInput = await screen.findByTestId('role-description-input');
expect(descInput).toHaveValue(
'Custom role for managing billing and invoices.',
);
});
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).toBeDisabled();
});
});
describe('form interactions', () => {
it('enables save button when description is modified', async () => {
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.clear(descInput);
await user.type(descInput, 'New description');
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).not.toBeDisabled();
});
it('shows unsaved indicator when description modified', async () => {
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
await user.type(descInput, ' updated');
expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
});
it('disables save when changes reverted to original', async () => {
const user = userEvent.setup();
renderEditPage();
const descInput = await screen.findByTestId('role-description-input');
const originalValue = 'Custom role for managing billing and invoices.';
await user.clear(descInput);
await user.type(descInput, 'Temporary change');
expect(screen.getByTestId('save-button')).not.toBeDisabled();
await user.clear(descInput);
await user.type(descInput, originalValue);
await waitFor(() => {
expect(screen.getByTestId('save-button')).toBeDisabled();
});
});
});
describe('cancel action', () => {
it('navigates to roles list on cancel', async () => {
const user = userEvent.setup();
renderEditPage();
await screen.findByTestId('role-name-input');
const cancelBtn = screen.getByTestId('cancel-button');
await user.click(cancelBtn);
await expect(
screen.findByTestId('roles-list-redirect'),
).resolves.toBeInTheDocument();
});
});
describe('update success flow', () => {
it('redirects to roles list 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('roles-list-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 = screen.getByTestId('resource-card-factor-api-key');
const createToggle = within(apiKeysCard).getByTestId(
'action-toggle-factor-api-key-create',
);
const allBtn = within(createToggle).getByText('All');
await user.click(allBtn);
expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
expect(screen.getByTestId('save-button')).not.toBeDisabled();
});
});
});

View File

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

View File

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

View File

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

View File

@@ -1,157 +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 { ACTION_LABELS, 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';
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 = ACTION_LABELS[action] || action;
const scopeItems: Array<{ value: PermissionScope; label: string }> =
useMemo(() => {
const items = [
{ value: PermissionScope.NONE, label: SCOPE_LABELS[PermissionScope.NONE] },
{ value: PermissionScope.ALL, label: SCOPE_LABELS[PermissionScope.ALL] },
];
if (canSelectIndividually) {
items.push({
value: PermissionScope.ONLY_SELECTED,
label: SCOPE_LABELS[PermissionScope.ONLY_SELECTED],
});
}
return items;
}, [canSelectIndividually]);
const handleToggleChange = useCallback(
(value: string): void => {
if (!value) {
return;
}
const isLeavingOnlySelected =
scope === PermissionScope.ONLY_SELECTED &&
value !== PermissionScope.ONLY_SELECTED;
const hasSelectedItems = selectedIds.length > 0;
if (isLeavingOnlySelected && hasSelectedItems) {
setPendingScope(value as PermissionScope);
setConfirmDialogOpen(true);
return;
}
onScopeChange(value as PermissionScope);
},
[scope, selectedIds.length, onScopeChange],
);
const handleConfirmScopeChange = useCallback((): void => {
if (pendingScope) {
onSelectedIdsChange([]);
onScopeChange(pendingScope);
}
setConfirmDialogOpen(false);
setPendingScope(null);
}, [pendingScope, onSelectedIdsChange, onScopeChange]);
const handleCancelScopeChange = useCallback((): void => {
setConfirmDialogOpen(false);
setPendingScope(null);
}, []);
return (
<>
<div
className={styles.actionToggle}
data-testid={`action-toggle-${resource}-${action}`}
>
<div className={styles.actionToggleHeader}>
<span className={styles.actionToggleLabel}>{displayLabel}</span>
<ToggleGroupSimple
type="single"
size="sm"
value={scope}
onChange={handleToggleChange}
items={scopeItems}
className={styles.actionToggleScopeToggle}
testId={`action-toggle-scope-${resource}-${action}`}
/>
</div>
{scope === PermissionScope.ONLY_SELECTED && (
<div className={styles.actionToggleSelectorWrapper}>
<Divider />
<ItemInputSelector
placeholder={getResourcePanel(resource).selectorPlaceholder}
selectedIds={selectedIds}
onChange={onSelectedIdsChange}
docsAnchor={getResourcePanel(resource).docsAnchor}
hasError={hasError}
/>
</div>
)}
</div>
<ConfirmDialog
open={confirmDialogOpen}
onOpenChange={(next): void => {
if (!next) {
handleCancelScopeChange();
}
}}
title="Change permission scope?"
confirmText="Change scope"
cancelText="Cancel"
onConfirm={handleConfirmScopeChange}
onCancel={handleCancelScopeChange}
>
<Typography>
You have {selectedIds.length} item{selectedIds.length > 1 ? 's' : ''}{' '}
selected. Changing the scope will clear your current items.
<br />
<br />
Don&apos;t worry, this doesn&apos;t update this role yet, it only confirms
that you want to clear the items.
</Typography>
</ConfirmDialog>
</>
);
}
export default ActionToggle;

View File

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

View File

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

View File

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

View File

@@ -1,139 +0,0 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import MEditor, { Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import type { AuthtypesTransactionGroupDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
transformResourcePermissionsToTransactionGroups,
transformTransactionGroupsToResourcePermissions,
} from '../../useRolePermissions';
import {
registerCompletionProvider,
registerJsonSchema,
ROLE_PERMISSIONS_MODEL_PATH,
} from './jsonSchema.config';
import styles from './JsonEditor.module.scss';
import { JsonEditorProps } from './JsonEditor.types';
function JsonEditor({
resources,
mode,
onChange,
}: JsonEditorProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [parseError, setParseError] = useState<string | null>(null);
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);
// 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 => {
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,
},
});
registerJsonSchema(monaco);
completionDisposableRef.current = registerCompletionProvider(monaco);
}, []);
useEffect(
() => (): void => {
completionDisposableRef.current?.dispose();
},
[],
);
const editorOptions = useMemo(
() => ({
automaticLayout: true,
wordWrap: 'on' as const,
minimap: {
enabled: false,
},
fontFamily: 'Geist Mono',
fontSize: 13,
lineHeight: 20,
scrollBeyondLastLine: false,
folding: true,
tabSize: 2,
fixedOverflowWidgets: true,
}),
[],
);
return (
<div className={styles.jsonEditor} data-testid="json-editor">
<div className={styles.jsonEditorContainer}>
<MEditor
value={jsonBuffer}
language="json"
path={ROLE_PERMISSIONS_MODEL_PATH}
options={editorOptions}
onChange={handleEditorChange}
height="100%"
theme={isDarkMode ? 'json-theme-dark' : 'light'}
beforeMount={configureMonaco}
/>
</div>
<div className={styles.jsonEditorErrorWrapper}>
{parseError && (
<div className={styles.jsonEditorError} data-testid="json-editor-error">
<span className={styles.jsonEditorErrorLabel}>Error:</span>
<span className={styles.jsonEditorErrorMessage}>{parseError}</span>
</div>
)}
</div>
</div>
);
}
export default JsonEditor;

View File

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

View File

@@ -1,167 +0,0 @@
.permissionEditor {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
flex: 1;
min-height: 0;
}
.permissionEditorHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.permissionEditorTitle {
font-family: Inter;
font-size: 12px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-20);
letter-spacing: 0.48px;
text-transform: uppercase;
color: var(--l2-foreground);
}
.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: center;
padding-top: var(--spacing-4);
}
.permissionEditorCollapseButton {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-4);
background: transparent;
border: none;
cursor: pointer;
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);
transition: color 0.15s ease;
&:hover {
color: var(--l1-foreground);
}
}

View File

@@ -1,133 +0,0 @@
import { useCallback } from 'react';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Skeleton } from 'antd';
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import { PermissionScope, ResourcePermissions } from '../../types';
import type { EditorMode } 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;
isLoading?: boolean;
validationErrors?: Set<string>;
}
function PermissionEditor({
resources,
mode,
onModeChange,
onResourceChange,
isLoading = false,
validationErrors,
}: PermissionEditorProps): JSX.Element {
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 => {
onModeChange(value as EditorMode);
},
[onModeChange],
);
if (isLoading) {
return (
<div className={styles.permissionEditor}>
<Skeleton active paragraph={{ rows: 6 }} />
</div>
);
}
return (
<div className={styles.permissionEditor} data-testid="permission-editor">
<div className={styles.permissionEditorHeader}>
<span className={styles.permissionEditorTitle}>Permissions</span>
<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.permissionEditorResourceList}>
{resources.map((resource) => (
<ResourceCard
key={resource.resourceId}
resource={resource}
onActionChange={handleActionChange}
defaultExpanded={true}
validationErrors={validationErrors}
/>
))}
</div>
) : (
<JsonEditor
resources={resources}
mode={mode}
onChange={handleJsonChange}
/>
)}
</div>
</div>
);
}
export default PermissionEditor;

View File

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

View File

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

View File

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

View File

@@ -1,262 +0,0 @@
import type { Monaco } from '@monaco-editor/react';
import permissionsType from 'hooks/useAuthZ/permissions.type';
/**
* @deprecated Waiting for Vikrant to provide this as generated file
*/
function buildTransactionGroupSchema(): Record<string, unknown> {
const { resources, relations } = permissionsType.data;
const resourceKinds = resources.map((r) => r.kind);
const resourceTypes = [...new Set(resources.map((r) => r.type))];
const allVerbs = Object.keys(relations);
return {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'array',
items: {
type: 'object',
required: ['objectGroup', 'relation'],
properties: {
objectGroup: {
type: 'object',
required: ['resource', 'selectors'],
properties: {
resource: {
type: 'object',
required: ['kind', 'type'],
properties: {
kind: {
type: 'string',
enum: resourceKinds,
description:
'Resource kind (e.g., role, serviceaccount, factor-api-key)',
},
type: {
type: 'string',
enum: resourceTypes,
description: 'Resource type category',
},
},
},
selectors: {
type: 'array',
items: { type: 'string' },
description:
'Selectors for resource instances. Use "*" for all, or specific IDs.',
},
},
},
relation: {
type: 'string',
enum: allVerbs,
description: 'Action/verb to allow on the resource',
},
},
},
};
}
export const TRANSACTION_GROUP_SCHEMA = buildTransactionGroupSchema();
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,207 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import ROUTES from 'constants/routes';
import type { ResourcePermissions } from '../types';
import type { EditorMode } from './components/JsonEditor.types';
import {
createEmptyRolePermissions,
useCreateRolePermissions,
useRolePermissions,
useUpdateRolePermissions,
} from '../useRolePermissions';
import { useRoleAuthZ } from './useRoleAuthZ';
import {
useRoleUnsavedChanges,
type RoleFormData,
} from './useRoleUnsavedChanges';
import { useRoleFormValidation } from './useRoleFormValidation';
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: () => void;
handleCancel: () => void;
handleFormChange: (field: keyof RoleFormData, value: string) => void;
isCreateMode: boolean;
saveError: string | null;
clearSaveError: () => void;
validationErrors: Set<string>;
hasRequiredPermission: boolean;
isAuthZLoading: boolean;
deniedPermission: string;
}
export function useCreateEditRolePageCallbacks(
roleId: string,
roleName: string,
): UseCreateEditRolePageCallbacksResult {
const history = useHistory();
const isCreateMode = roleId === 'new';
const { hasRequiredPermission, isAuthZLoading, deniedPermission } =
useRoleAuthZ(isCreateMode, roleName);
const [formData, setFormData] = useState<RoleFormData>({
name: '',
description: '',
});
const [editorMode, setEditorMode] = useState<EditorMode>('interactive');
const emptyResources = useMemo(() => createEmptyRolePermissions(), []);
const [localResources, setLocalResources] = useState<ResourcePermissions[]>(
() => (isCreateMode ? createEmptyRolePermissions() : []),
);
const [isInitialized, setIsInitialized] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { validationErrors, validateResources, clearValidationErrors } =
useRoleFormValidation();
const { data: rolePermissionsData, isLoading: isLoadingPermissions } =
useRolePermissions(roleId, {
enabled: !isCreateMode && hasRequiredPermission,
});
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 => {
setEditorMode(mode);
}, []);
const handleResourcesChange = useCallback(
(resources: ResourcePermissions[]): void => {
setLocalResources(resources);
},
[],
);
const hasUnsavedChanges = useRoleUnsavedChanges(
isCreateMode,
formData,
localResources,
rolePermissionsData,
emptyResources,
);
const handleSave = useCallback(async (): Promise<void> => {
if (!formData.name.trim()) {
toast.error('Role name is required', { position: 'bottom-center' });
return;
}
const validationError = validateResources(localResources);
if (validationError) {
setSaveError(validationError);
return;
}
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' },
);
history.push(ROUTES.ROLES_SETTINGS);
} catch (error) {
const axiosError = error as {
response?: { data?: { error?: { message?: string }; message?: string } };
message?: string;
};
const errorMessage =
axiosError?.response?.data?.error?.message ||
axiosError?.response?.data?.message ||
axiosError?.message ||
'Failed to save role';
setSaveError(errorMessage);
}
}, [
formData.name,
formData.description,
isCreateMode,
roleId,
localResources,
createRole,
updateRole,
history,
validateResources,
clearValidationErrors,
]);
const clearSaveError = useCallback((): void => {
setSaveError(null);
}, []);
const handleCancel = useCallback((): void => {
history.push(ROUTES.ROLES_SETTINGS);
}, [history]);
return {
formData,
setFormData,
editorMode,
setEditorMode: handleModeChange,
resources: localResources,
setResources: handleResourcesChange,
isLoading: isLoadingPermissions,
isSaving,
hasUnsavedChanges,
handleSave,
handleCancel,
handleFormChange,
isCreateMode,
saveError,
clearSaveError,
validationErrors,
hasRequiredPermission,
isAuthZLoading,
deniedPermission,
};
}

View File

@@ -1,68 +0,0 @@
import { useMemo } from 'react';
import {
RoleCreatePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
} from 'hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
interface UseRoleAuthZResult {
hasRequiredPermission: boolean;
isAuthZLoading: boolean;
deniedPermission: string;
}
export function useRoleAuthZ(
isCreateMode: boolean,
roleName: string,
): UseRoleAuthZResult {
const permissionsToCheck = useMemo(() => {
if (isCreateMode) {
return [RoleCreatePermission];
}
if (!roleName) {
return [];
}
return [
buildRoleReadPermission(roleName),
buildRoleUpdatePermission(roleName),
];
}, [isCreateMode, roleName]);
const { permissions, isLoading: isAuthZLoading } =
useAuthZ(permissionsToCheck);
const hasRequiredPermission = useMemo(() => {
if (permissions === null) {
return false;
}
if (isCreateMode) {
return permissions[RoleCreatePermission]?.isGranted ?? false;
}
if (!roleName) {
return true;
}
const readPerm = buildRoleReadPermission(roleName);
const updatePerm = buildRoleUpdatePermission(roleName);
return (
(permissions[readPerm]?.isGranted ?? false) &&
(permissions[updatePerm]?.isGranted ?? false)
);
}, [permissions, isCreateMode, roleName]);
const deniedPermission = useMemo(() => {
if (isCreateMode) {
return 'role:create';
}
if (roleName) {
return `role:${roleName}:update`;
}
return `role:<missing-rule-name>:update`;
}, [isCreateMode, roleName]);
return {
hasRequiredPermission,
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

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

@@ -1,74 +0,0 @@
.drawerDescription {
overflow-y: auto;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.label {
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-medium);
color: var(--foreground);
text-transform: uppercase;
letter-spacing: 0.44px;
}
.description {
font-size: var(--periscope-font-size-base);
color: var(--l1-foreground);
line-height: var(--line-height-20);
margin: 0;
}
.metaRow {
display: flex;
gap: var(--spacing-12);
}
.metaItem {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.permissionsSection {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-top: var(--spacing-4);
}
.permissionsHeader {
display: flex;
align-items: center;
padding-bottom: var(--spacing-2);
border-bottom: 1px solid var(--l1-border);
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
--button-disabled-pointer-events: auto;
width: 100%;
}
.footerLeft {
display: flex;
gap: var(--spacing-6);
}
.footerRight {
display: flex;
gap: var(--spacing-6);
}

View File

@@ -1,222 +0,0 @@
import { useCallback, useMemo } from 'react';
import { Trash2, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Skeleton, Tooltip } from 'antd';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { useTimezone } from 'providers/Timezone';
import { toAPIError } from 'utils/errorUtils';
import DeleteRoleModal from '../RolesComponents/DeleteRoleModal';
import PermissionOverview from './components/PermissionOverview';
import type { RoleDetailsDrawerProps } from './RoleDetailsDrawer.types';
import { useDeleteRoleModal } from './useDeleteRoleModal';
import { useRoleDetailsDrawerCallbacks } from './useRoleDetailsDrawerCallbacks';
import styles from './RoleDetailsDrawer.module.scss';
function RoleDetailsDrawer({
roleId,
roleName,
onClose,
}: RoleDetailsDrawerProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const {
role,
isLoading,
isAuthZLoading,
isError,
error,
isManaged,
hasReadPermission,
hasDeletePermission,
isEditDisabled,
editDisabledReason,
handleViewDetails,
permissions,
} = useRoleDetailsDrawerCallbacks({ roleId, roleName });
const {
isDeleteModalOpen,
isDeleteDisabled,
deleteDisabledReason,
handleOpenDeleteModal,
handleCloseDeleteModal,
handleConfirmDelete,
} = useDeleteRoleModal({
roleId,
isManaged,
hasDeletePermission,
onDeleteSuccess: onClose,
});
const handleEditRole = useCallback((): void => {
onClose();
handleViewDetails();
}, [onClose, handleViewDetails]);
const formatTimestamp = useCallback(
(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,
);
},
[formatTimezoneAdjustedTimestamp],
);
const typeBadgeColor = useMemo(() => {
if (isManaged) {
return 'robin';
}
return 'vanilla';
}, [isManaged]);
const deleteButton = (
<Button
variant="link"
color="destructive"
onClick={handleOpenDeleteModal}
disabled={isDeleteDisabled}
data-testid="role-drawer-delete-btn"
prefix={<Trash2 />}
>
Delete role
</Button>
);
const editButton = (
<Button
variant="solid"
color="primary"
onClick={handleEditRole}
disabled={isEditDisabled}
data-testid="role-drawer-edit-btn"
>
Edit role
</Button>
);
return (
<>
<DrawerWrapper
open={!!roleId}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
direction="right"
showCloseButton
showOverlay={false}
title={role?.name ?? 'Role Details'}
footer={
<div className={styles.footer}>
<div className={styles.footerLeft}>
{isDeleteDisabled ? (
<Tooltip title={deleteDisabledReason}>{deleteButton}</Tooltip>
) : (
deleteButton
)}
</div>
<div className={styles.footerRight}>
<Button
variant="outlined"
color="secondary"
onClick={onClose}
data-testid="role-drawer-close-btn"
>
<X size={14} />
Close
</Button>
{isEditDisabled ? (
<Tooltip title={editDisabledReason}>{editButton}</Tooltip>
) : (
editButton
)}
</div>
</div>
}
width="wide"
drawerDescriptionProps={{
className: styles.drawerDescription,
}}
>
{isAuthZLoading || isLoading ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : !hasReadPermission && permissions !== null ? (
<Callout type="error" showIcon title="Permission Denied">
You do not have permission to view this role.
</Callout>
) : isError && error ? (
<ErrorInPlace
error={toAPIError(
error,
'An unexpected error occurred while fetching role details.',
)}
/>
) : (
<div className={styles.content}>
<div className={styles.field}>
<span className={styles.label}>Type</span>
<Badge color={typeBadgeColor} variant="outline">
{isManaged ? 'Managed' : 'Custom'}
</Badge>
</div>
<div className={styles.field}>
<span className={styles.label}>Description</span>
<p className={styles.description}>{role?.description || '—'}</p>
</div>
<div className={styles.metaRow}>
<div className={styles.metaItem}>
<span className={styles.label}>Created At</span>
<Badge color="vanilla">{formatTimestamp(role?.createdAt)}</Badge>
</div>
<div className={styles.metaItem}>
<span className={styles.label}>Updated At</span>
<Badge color="vanilla">{formatTimestamp(role?.updatedAt)}</Badge>
</div>
</div>
{isManaged && (
<Callout
type="warning"
showIcon
title="This is a managed role. Permissions are view-only and cannot be modified."
/>
)}
<div className={styles.permissionsSection}>
<div className={styles.permissionsHeader}>
<span className={styles.label}>Permissions</span>
</div>
{roleId && <PermissionOverview roleId={roleId} />}
</div>
</div>
)}
</DrawerWrapper>
<DeleteRoleModal
isOpen={isDeleteModalOpen}
roleName={role?.name ?? ''}
onCancel={handleCloseDeleteModal}
onConfirm={handleConfirmDelete}
/>
</>
);
}
export default RoleDetailsDrawer;

View File

@@ -1,5 +0,0 @@
export interface RoleDetailsDrawerProps {
roleId: string | null;
roleName: string | null;
onClose: () => void;
}

View File

@@ -1,150 +0,0 @@
import { useState } from 'react';
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 RoleDetailsDrawer from '../RoleDetailsDrawer';
import {
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
mockHooksForCustomRole,
} from './testUtils';
describe('RoleDetailsDrawer - Actions', () => {
beforeEach(() => {
mockHooksForCustomRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('calls onClose when Close button clicked', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={onClose}
/>,
);
const closeBtn = screen.getByTestId('role-drawer-close-btn');
await user.click(closeBtn);
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('navigates to edit page when Edit clicked', async () => {
const user = userEvent.setup();
render(
<Switch>
<Route path="/settings/roles/:roleId">
<div data-testid="edit-page-target" />
</Route>
<Route path="/">
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>
</Route>
</Switch>,
undefined,
{ initialRoute: '/' },
);
const editBtn = screen.getByTestId('role-drawer-edit-btn');
await user.click(editBtn);
await expect(
screen.findByTestId('edit-page-target'),
).resolves.toBeInTheDocument();
});
it('opens delete modal when Delete clicked', async () => {
const user = userEvent.setup();
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
);
const deleteBtn = screen.getByTestId('role-drawer-delete-btn');
await user.click(deleteBtn);
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete the role/),
).toBeInTheDocument();
});
});
it('calls delete API with captured roleId even if drawer closes before confirm', async () => {
const user = userEvent.setup();
const mockDeleteRole = jest.fn().mockResolvedValue({});
jest.spyOn(roleApi, 'useDeleteRole').mockReturnValue({
mutateAsync: mockDeleteRole,
} as unknown as ReturnType<typeof roleApi.useDeleteRole>);
let clearRoleId: (() => void) | undefined;
function TestWrapper(): JSX.Element {
const [roleId, setRoleId] = useState<string | null>(CUSTOM_ROLE_ID);
const [roleName, setRoleName] = useState<string | null>(CUSTOM_ROLE_NAME);
clearRoleId = (): void => {
setRoleId(null);
setRoleName(null);
};
return (
<RoleDetailsDrawer
roleId={roleId}
roleName={roleName}
onClose={jest.fn()}
/>
);
}
render(<TestWrapper />);
await user.click(screen.getByTestId('role-drawer-delete-btn'));
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete the role/),
).toBeInTheDocument();
});
clearRoleId?.();
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete the role/),
).toBeInTheDocument();
});
const modal = screen.getByRole('dialog');
const modalConfirmBtn = within(modal).getByRole('button', {
name: /Delete Role/i,
});
await user.click(modalConfirmBtn);
await waitFor(() => {
expect(mockDeleteRole).toHaveBeenCalledWith({
pathParams: { id: CUSTOM_ROLE_ID },
});
});
});
});

View File

@@ -1,230 +0,0 @@
import * as roleApi from 'api/generated/services/role';
import type { BrandedPermission, UseAuthZResult } from 'hooks/useAuthZ/types';
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 '../../useRolePermissions';
import RoleDetailsDrawer from '../RoleDetailsDrawer';
import {
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
mockPermissionsData,
} from './testUtils';
describe('RoleDetailsDrawer - AuthZ', () => {
afterEach(() => {
jest.restoreAllMocks();
});
describe('permission denied', () => {
it('shows permission denied callout when read permission denied', () => {
jest.spyOn(useAuthZModule, 'useAuthZ').mockImplementation(
(permissions: BrandedPermission[]): UseAuthZResult => ({
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => [p, { isGranted: false }]),
) as UseAuthZResult['permissions'],
refetchPermissions: jest.fn(),
}),
);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByText(/Permission Denied/i)).toBeInTheDocument();
expect(
screen.getByText(/You do not have permission to view this role/i),
).toBeInTheDocument();
});
});
describe('edit button visibility', () => {
it('disables Edit button when update permission denied', () => {
jest.spyOn(useAuthZModule, 'useAuthZ').mockImplementation(
(permissions: BrandedPermission[]): UseAuthZResult => ({
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => {
const isReadPerm = p.includes(':read:');
return [p, { isGranted: isReadPerm }];
}),
) as UseAuthZResult['permissions'],
refetchPermissions: jest.fn(),
}),
);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByTestId('role-drawer-edit-btn')).toBeDisabled();
});
it('shows Edit 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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByTestId('role-drawer-edit-btn')).toBeInTheDocument();
});
});
describe('delete button visibility', () => {
it('disables Delete button when delete permission denied', () => {
jest.spyOn(useAuthZModule, 'useAuthZ').mockImplementation(
(permissions: BrandedPermission[]): UseAuthZResult => ({
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => {
const isReadPerm = p.includes(':read:');
return [p, { isGranted: isReadPerm }];
}),
) as UseAuthZResult['permissions'],
refetchPermissions: jest.fn(),
}),
);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByTestId('role-drawer-delete-btn')).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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByTestId('role-drawer-delete-btn')).not.toBeDisabled();
});
});
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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
});

View File

@@ -1,118 +0,0 @@
import { render, screen } from 'tests/test-utils';
import RoleDetailsDrawer from '../RoleDetailsDrawer';
import {
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
mockHooksForCustomRole,
} from './testUtils';
describe('RoleDetailsDrawer - Custom Role', () => {
beforeEach(() => {
mockHooksForCustomRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders role name in drawer title', () => {
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(screen.getByText('billing-manager')).toBeInTheDocument();
});
it('displays Custom badge for custom roles', () => {
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(screen.getByText('Custom')).toBeInTheDocument();
});
it('shows role description', () => {
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(
screen.getByText('Custom role for managing billing and invoices.'),
).toBeInTheDocument();
});
it('shows Edit button for custom roles', () => {
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(screen.getByTestId('role-drawer-edit-btn')).toBeInTheDocument();
});
it('shows Close button', () => {
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(screen.getByTestId('role-drawer-close-btn')).toBeInTheDocument();
});
it('renders created/updated timestamps labels', () => {
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(screen.getByText('Created At')).toBeInTheDocument();
expect(screen.getByText('Updated At')).toBeInTheDocument();
});
});

View File

@@ -1,139 +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 '../../useRolePermissions';
import RoleDetailsDrawer from '../RoleDetailsDrawer';
import {
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
mockPermissionsData,
} from './testUtils';
describe('RoleDetailsDrawer - Edge Cases', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('shows fallback for missing description', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
data: {
...customRoleResponse.data,
description: '',
},
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(1);
});
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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -1,44 +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 RoleDetailsDrawer from '../RoleDetailsDrawer';
import { CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME } from './testUtils';
describe('RoleDetailsDrawer - Error State', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('displays error component when API fails', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Failed to fetch'),
} as ReturnType<typeof roleApi.useGetRole>);
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
const errorContainer = document.querySelector('.error-in-place');
expect(errorContainer).toBeInTheDocument();
});
});

View File

@@ -1,60 +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 RoleDetailsDrawer from '../RoleDetailsDrawer';
import { CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME } from './testUtils';
describe('RoleDetailsDrawer - 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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('does not fetch when roleId is null', () => {
const getRole = jest.spyOn(roleApi, 'useGetRole');
render(
<RoleDetailsDrawer roleId={null} roleName={null} onClose={jest.fn()} />,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(getRole).toHaveBeenCalledWith(
{ id: '' },
expect.objectContaining({ query: { enabled: false } }),
);
});
});

View File

@@ -1,103 +0,0 @@
import { render, screen } from 'tests/test-utils';
import RoleDetailsDrawer from '../RoleDetailsDrawer';
import {
MANAGED_ROLE_ID,
MANAGED_ROLE_NAME,
mockHooksForManagedRole,
} from './testUtils';
describe('RoleDetailsDrawer - Managed Role', () => {
beforeEach(() => {
mockHooksForManagedRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('displays Managed badge for managed roles', () => {
render(
<RoleDetailsDrawer
roleId={MANAGED_ROLE_ID}
roleName={MANAGED_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(screen.getByText('Managed')).toBeInTheDocument();
});
it('shows warning callout for managed roles', () => {
render(
<RoleDetailsDrawer
roleId={MANAGED_ROLE_ID}
roleName={MANAGED_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(
screen.getByText(
'This is a managed role. Permissions are view-only and cannot be modified.',
),
).toBeInTheDocument();
});
it('disables Edit button for managed roles', () => {
render(
<RoleDetailsDrawer
roleId={MANAGED_ROLE_ID}
roleName={MANAGED_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(screen.getByTestId('role-drawer-edit-btn')).toBeDisabled();
});
it('disables Delete button for managed roles', () => {
render(
<RoleDetailsDrawer
roleId={MANAGED_ROLE_ID}
roleName={MANAGED_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(screen.getByTestId('role-drawer-delete-btn')).toBeDisabled();
});
it('still shows Close button for managed roles', () => {
render(
<RoleDetailsDrawer
roleId={MANAGED_ROLE_ID}
roleName={MANAGED_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{
initialRoute: '/settings/roles',
},
);
expect(screen.getByTestId('role-drawer-close-btn')).toBeInTheDocument();
});
});

View File

@@ -1,692 +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 '../../useRolePermissions';
import RoleDetailsDrawer from '../RoleDetailsDrawer';
import {
CUSTOM_ROLE_ID,
CUSTOM_ROLE_NAME,
mockHooksForCustomRole,
mockHooksWithPermissions,
mockPermissionsData,
} from './testUtils';
describe('RoleDetailsDrawer - Permission Overview', () => {
beforeEach(() => {
mockHooksForCustomRole();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders Permissions section label', () => {
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByText('Permissions')).toBeInTheDocument();
});
it('renders permission overview container', () => {
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
});
it('shows resource permission cards', () => {
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(
screen.getByTestId('granted-count-factor-api-key'),
).toBeInTheDocument();
});
});
describe('RoleDetailsDrawer - 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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByTestId('permission-overview-loading')).toBeInTheDocument();
});
});
describe('RoleDetailsDrawer - 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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByTestId('permission-overview-error')).toBeInTheDocument();
});
});
describe('RoleDetailsDrawer - Scope: ALL permissions', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('shows "All" badge for actions with ALL scope', () => {
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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByTestId('granted-count-role')).toHaveTextContent(
'3 / 3 granted',
);
});
});
describe('RoleDetailsDrawer - Scope: NONE permissions', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('shows "None" badge for actions with NONE scope', () => {
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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
'0 / 4 granted',
);
});
});
describe('RoleDetailsDrawer - Scope: ONLY_SELECTED permissions', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('shows "Only selected" badge with count', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'role',
resourceKind: 'role',
resourceType: 'role',
resourceLabel: 'Roles',
actions: {
read: {
scope: 'only_selected',
selectedIds: ['admin', 'editor', 'viewer'],
},
},
availableActions: ['read'],
},
],
});
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByTestId('scope-badge-read')).toHaveTextContent(
'Only selected · 3',
);
});
it('displays selected IDs as expandable chips', () => {
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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByText('key-abc-123')).toBeInTheDocument();
expect(screen.getByText('key-def-456')).toBeInTheDocument();
});
it('counts ONLY_SELECTED as granted in count', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'serviceaccount',
resourceKind: 'serviceaccount',
resourceType: 'serviceaccount',
resourceLabel: 'Service Accounts',
actions: {
read: { scope: 'only_selected', selectedIds: ['sa-1'] },
create: { scope: 'none', selectedIds: [] },
},
availableActions: ['read', 'create'],
},
],
});
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByText('editor-role')).toBeInTheDocument();
const toggle = screen.getByTestId('toggle-items-update');
await user.click(toggle);
expect(screen.queryByText('editor-role')).not.toBeInTheDocument();
await user.click(toggle);
expect(screen.getByText('editor-role')).toBeInTheDocument();
});
});
describe('RoleDetailsDrawer - Mixed permission scopes', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('renders all three scope types in single resource card', () => {
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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
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(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
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('RoleDetailsDrawer - Unknown resources', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('renders unknown resource with fallback label', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'future-resource',
resourceKind: 'future-resource',
resourceType: 'metaresource',
resourceLabel: 'future-resource',
actions: {
read: { scope: 'all', selectedIds: [] },
},
availableActions: ['read'],
},
],
});
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(
screen.getByTestId('resource-section-future-resource'),
).toBeInTheDocument();
expect(screen.getByText('future-resource')).toBeInTheDocument();
});
it('shows raw verb name when no label mapping exists', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'test-resource',
resourceKind: 'test-resource',
resourceType: 'metaresource',
resourceLabel: 'Test Resource',
actions: {
unknown_action: { scope: 'all', selectedIds: [] },
},
availableActions: ['unknown_action'],
},
],
});
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(screen.getByText('unknown_action')).toBeInTheDocument();
});
it('handles resource with empty actions', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
{
resourceId: 'empty-resource',
resourceKind: 'empty-resource',
resourceType: 'metaresource',
resourceLabel: 'Empty Resource',
actions: {},
availableActions: [],
},
],
});
render(
<RoleDetailsDrawer
roleId={CUSTOM_ROLE_ID}
roleName={CUSTOM_ROLE_NAME}
onClose={jest.fn()}
/>,
undefined,
{ initialRoute: '/settings/roles' },
);
expect(
screen.getByTestId('resource-section-empty-resource'),
).toBeInTheDocument();
expect(screen.getByTestId('granted-count-empty-resource')).toHaveTextContent(
'0 / 0 granted',
);
});
});

View File

@@ -1,135 +0,0 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import {
customRoleResponse,
managedRoleResponse,
} from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import * as useRolePermissionsModule from '../../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 const mockPermissionsData = {
roleId: CUSTOM_ROLE_ID,
roleName: 'billing-manager',
roleDescription: 'Custom role for managing billing and invoices.',
resources: [
{
resourceId: 'factor-api-key',
resourceKind: 'factor-api-key',
resourceType: 'metaresource',
resourceLabel: 'API Keys',
actions: {
create: { scope: 'none', selectedIds: [] },
read: { scope: 'all', selectedIds: [] },
},
availableActions: ['create', 'read', 'update', 'delete', 'list'],
},
{
resourceId: 'role',
resourceKind: 'role',
resourceType: 'role',
resourceLabel: 'Roles',
actions: {
create: { scope: 'none', selectedIds: [] },
read: { scope: 'none', selectedIds: [] },
},
availableActions: [
'create',
'read',
'update',
'delete',
'list',
'attach',
'detach',
],
},
{
resourceId: 'serviceaccount',
resourceKind: 'serviceaccount',
resourceType: 'serviceaccount',
resourceLabel: 'Service Accounts',
actions: {
create: { scope: 'none', selectedIds: [] },
read: { scope: 'none', selectedIds: [] },
},
availableActions: [
'create',
'read',
'update',
'delete',
'list',
'attach',
'detach',
],
},
],
};
export function mockHooksForCustomRole(): void {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
}
export function mockHooksWithPermissions(permissions: unknown): void {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: permissions,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
}
export function mockHooksForManagedRole(): void {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: {
...mockPermissionsData,
roleId: MANAGED_ROLE_ID,
roleName: 'signoz-admin',
},
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
}

View File

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

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
import { Skeleton } from 'antd';
import { useRolePermissions } from '../../useRolePermissions';
import ResourcePermissionCard from './ResourcePermissionCard';
import styles from './PermissionOverview.module.scss';
export interface PermissionOverviewProps {
roleId: string;
}
function PermissionOverview({ roleId }: PermissionOverviewProps): JSX.Element {
const { data: permissions, isLoading, isError } = useRolePermissions(roleId);
if (isLoading) {
return (
<div className={styles.container} data-testid="permission-overview-loading">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
if (isError || !permissions) {
return (
<div className={styles.container} data-testid="permission-overview-error">
<p className={styles.errorText}>Failed to load permissions</p>
</div>
);
}
const { resources } = permissions;
return (
<div className={styles.container} data-testid="permission-overview">
<div className={styles.grid}>
{resources.map((resource) => (
<ResourcePermissionCard key={resource.resourceId} resource={resource} />
))}
</div>
</div>
);
}
export default PermissionOverview;

View File

@@ -1,70 +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);
padding: var(--spacing-6) var(--spacing-7);
border-bottom: 1px solid var(--l1-border);
}
.headerLeft {
display: flex;
align-items: center;
gap: var(--spacing-4);
min-width: 0;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--l2-foreground);
flex-shrink: 0;
}
.title {
margin: 0;
font-family: Inter;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-20);
color: var(--l1-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.grantedCount {
display: inline-flex;
align-items: center;
padding: var(--spacing-1) var(--spacing-4);
font-family: Inter;
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-18);
color: var(--bg-robin-300);
background: color-mix(in srgb, var(--bg-robin-500) 14%, transparent);
border-radius: 16px;
white-space: nowrap;
flex-shrink: 0;
}
.rows {
display: flex;
flex-direction: column;
padding: 0 var(--spacing-7);
// A subtle divider between consecutive action rows.
> * + * {
border-top: 1px solid var(--l1-border);
}
}

View File

@@ -1,72 +0,0 @@
import { getResourcePanel } from '../../permissions.config';
import { PermissionScope, ResourcePermissions } from '../../types';
import ActionRow from './ActionRow';
import { getActionLabel } from './permissionDisplay.utils';
import styles from './ResourcePermissionCard.module.scss';
export interface ResourcePermissionCardProps {
resource: ResourcePermissions;
}
function ResourcePermissionCard({
resource,
}: ResourcePermissionCardProps): JSX.Element {
const { resourceLabel, resourceKind, actions, availableActions } = resource;
const panel = getResourcePanel(resourceKind);
const Icon = panel.icon;
const grantedCount = availableActions.filter((actionName) => {
const config = actions[actionName];
return !!config && config.scope !== PermissionScope.NONE;
}).length;
const totalCount = availableActions.length;
return (
<section
className={styles.card}
data-testid={`resource-section-${resourceKind}`}
>
<header className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.icon}>
<Icon size={16} />
</span>
<h4 className={styles.title}>{resourceLabel}</h4>
</div>
<span
className={styles.grantedCount}
data-testid={`granted-count-${resourceKind}`}
>
{grantedCount} / {totalCount} granted
</span>
</header>
<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,34 +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);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-1) var(--spacing-4);
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: var(--spacing-2);
}
.chipDot {
width: var(--spacing-2);
height: var(--spacing-2);
border-radius: 50%;
background: var(--bg-robin-400);
flex-shrink: 0;
}
.chipLabel {
font-family: 'Geist Mono', monospace;
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-18);
color: var(--l2-foreground);
}

View File

@@ -1,24 +0,0 @@
import styles from './SelectedItemsChips.module.scss';
export interface SelectedItemsChipsProps {
ids: string[];
testId?: string;
}
function SelectedItemsChips({
ids,
testId,
}: SelectedItemsChipsProps): JSX.Element {
return (
<ul className={styles.chips} data-testid={testId}>
{ids.map((id) => (
<li key={id} className={styles.chip}>
<span className={styles.chipDot} />
<span className={styles.chipLabel}>{id}</span>
</li>
))}
</ul>
);
}
export default SelectedItemsChips;

View File

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

View File

@@ -1,2 +0,0 @@
export { default } from './RoleDetailsDrawer';
export type { RoleDetailsDrawerProps } from './RoleDetailsDrawer.types';

View File

@@ -1,73 +0,0 @@
import { useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import {
invalidateGetRole,
invalidateListRoles,
useDeleteRole,
} from 'api/generated/services/role';
interface UseDeleteRoleModalProps {
roleId?: string | null;
isManaged: boolean;
hasDeletePermission: boolean;
onDeleteSuccess?: () => void;
}
interface UseDeleteRoleModalResult {
isDeleteModalOpen: boolean;
isDeleteDisabled: boolean;
deleteDisabledReason: string;
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 { mutateAsync: deleteRole } = useDeleteRole();
const handleOpenDeleteModal = useCallback((): void => {
setDeleteTargetRoleId(roleId ?? null);
}, [roleId]);
const handleCloseDeleteModal = useCallback((): void => {
setDeleteTargetRoleId(null);
}, []);
const handleConfirmDelete = useCallback(async (): Promise<boolean> => {
if (!deleteTargetRoleId) {
return false;
}
await deleteRole({ pathParams: { id: deleteTargetRoleId } });
await invalidateListRoles(queryClient);
await invalidateGetRole(queryClient, { id: deleteTargetRoleId });
setDeleteTargetRoleId(null);
onDeleteSuccess?.();
return true;
}, [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,
handleOpenDeleteModal,
handleCloseDeleteModal,
handleConfirmDelete,
};
}

View File

@@ -1,112 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { useGetRole } from 'api/generated/services/role';
import type {
AuthtypesRoleWithTransactionGroupsDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import {
buildRoleDeletePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
} from 'hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { RoleType } from 'types/roles';
import type { RoleDetailsDrawerProps } from './RoleDetailsDrawer.types';
interface UseRoleDetailsDrawerCallbacksResult {
role: AuthtypesRoleWithTransactionGroupsDTO | undefined;
isLoading: boolean;
isAuthZLoading: boolean;
isError: boolean;
error: ErrorType<RenderErrorResponseDTO> | null;
isManaged: boolean;
hasReadPermission: boolean;
hasUpdatePermission: boolean;
hasDeletePermission: boolean;
isEditDisabled: boolean;
editDisabledReason: string;
handleViewDetails: () => void;
permissions: ReturnType<typeof useAuthZ>['permissions'];
}
export function useRoleDetailsDrawerCallbacks(
props: Pick<RoleDetailsDrawerProps, 'roleId' | 'roleName'>,
): UseRoleDetailsDrawerCallbacksResult {
const { roleId, roleName } = props;
const history = useHistory();
const permissionsToCheck = useMemo(() => {
if (!roleName) {
return [];
}
return [
buildRoleReadPermission(roleName),
buildRoleUpdatePermission(roleName),
buildRoleDeletePermission(roleName),
];
}, [roleName]);
const { permissions, isLoading: isAuthZLoading } =
useAuthZ(permissionsToCheck);
const hasReadPermission = useMemo(() => {
if (!roleName || permissions === null) {
return false;
}
return permissions[buildRoleReadPermission(roleName)]?.isGranted ?? false;
}, [permissions, roleName]);
const hasUpdatePermission = useMemo(() => {
if (!roleName || permissions === null) {
return false;
}
return permissions[buildRoleUpdatePermission(roleName)]?.isGranted ?? false;
}, [permissions, roleName]);
const hasDeletePermission = useMemo(() => {
if (!roleName || permissions === null) {
return false;
}
return permissions[buildRoleDeletePermission(roleName)]?.isGranted ?? false;
}, [permissions, roleName]);
const { data, isLoading, isError, error } = useGetRole(
{ id: roleId ?? '' },
{ query: { enabled: !!roleId && hasReadPermission } },
);
const role = data?.data;
const isManaged = role?.type === RoleType.MANAGED;
const handleViewDetails = useCallback((): void => {
if (roleId && role?.name) {
const search = `?name=${encodeURIComponent(role.name)}`;
history.push(`${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}${search}`);
}
}, [history, roleId, role?.name]);
const isEditDisabled = isManaged || !hasUpdatePermission;
const editDisabledReason = isManaged
? 'Managed roles cannot be edited'
: 'You do not have permission to edit this role';
return {
role,
isLoading,
isAuthZLoading,
isError,
error,
isManaged,
hasReadPermission,
hasUpdatePermission,
hasDeletePermission,
isEditDisabled,
editDisabledReason,
handleViewDetails,
permissions,
};
}

View File

@@ -0,0 +1,188 @@
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,
...(values.description ? { description: values.description } : {}),
};
createRole({ data });
}
} catch {
// form validation failed; antd handles inline error display
}
}, [form, createRole, patchRole, isEditMode, initialData]);
const onCancel = useCallback((): void => {
form.resetFields();
onClose();
}, [form, onClose]);
const isLoading = isCreating || isPatching;
return (
<Modal
open={isOpen}
onCancel={onCancel}
title={isEditMode ? 'Edit Role Details' : 'Create a New Role'}
footer={[
<Button
key="cancel"
variant="solid"
color="secondary"
onClick={onCancel}
size="sm"
>
<X size={14} />
Cancel
</Button>,
<Button
key="submit"
variant="solid"
color="primary"
onClick={onSubmit}
loading={isLoading}
size="sm"
>
{isEditMode ? 'Save Changes' : 'Create Role'}
</Button>,
]}
destroyOnClose
className="create-role-modal"
width={530}
>
<Form form={form} layout="vertical" className="create-role-form">
<Form.Item
name="name"
label="Name"
rules={[{ required: true, message: 'Role name is required' }]}
>
<Input
disabled={isEditMode}
placeholder="Enter role name e.g. : Service Owner"
/>
</Form.Item>
<Form.Item name="description" label="Description">
<textarea
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder="A helpful description of the role"
/>
</Form.Item>
</Form>
</Modal>
);
}
export default CreateRoleModal;

View File

@@ -1,42 +1,59 @@
import { Trash2 } from '@signozhq/icons';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
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: () => Promise<boolean>;
onConfirm: () => void;
}
function DeleteRoleModal({
isOpen,
roleName,
isDeleting,
onCancel,
onConfirm,
}: DeleteRoleModalProps): JSX.Element {
return (
<ConfirmDialog
<Modal
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
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"
>
<Typography>
<p className="delete-text">
Are you sure you want to delete the role <strong>{roleName}</strong>? This
action cannot be undone.
</Typography>
</ConfirmDialog>
</p>
</Modal>
);
}

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,25 +1,22 @@
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';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import useUrlQuery from 'hooks/useUrlQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { parseAsString, useQueryState } from 'nuqs';
import { useTimezone } from 'providers/Timezone';
import { RoleType } from 'types/roles';
import { toAPIError } from 'utils/errorUtils';
import RoleDetailsDrawer from '../RoleDetailsDrawer';
import styles from './RolesListingTable.module.scss';
import '../RolesSettings.styles.scss';
const PAGE_SIZE = 20;
@@ -36,15 +33,6 @@ function RolesListingTable({
}: RolesListingTableProps): JSX.Element {
const { isRolesEnabled } = useRolesFeatureGate();
const [selectedRoleId, setSelectedRoleId] = useQueryState(
'selectedRoleId',
parseAsString,
);
const [selectedRoleName, setSelectedRoleName] = useQueryState(
'selectedRoleName',
parseAsString,
);
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
RoleListPermission,
]);
@@ -107,6 +95,7 @@ function RolesListingTable({
[filteredRoles],
);
// Combine managed + custom into a flat display list for pagination
const displayList = useMemo((): DisplayItem[] => {
const result: DisplayItem[] = [];
@@ -127,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;
@@ -137,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;
@@ -149,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;
@@ -161,21 +153,6 @@ function RolesListingTable({
return result;
}, [displayList, currentPage]);
const handleRowClick = useCallback(
(roleId: string, roleName: string): void => {
if (isRolesEnabled) {
void setSelectedRoleId(roleId);
void setSelectedRoleName(roleName);
}
},
[isRolesEnabled, setSelectedRoleId, setSelectedRoleName],
);
const handleDrawerClose = useCallback((): void => {
void setSelectedRoleId(null);
void setSelectedRoleName(null);
}, [setSelectedRoleId, setSelectedRoleName]);
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
<>
<span className="numbers">
@@ -191,7 +168,7 @@ function RolesListingTable({
if (isAuthZLoading || isLoading) {
return (
<div className={styles.rolesListingTable}>
<div className="roles-listing-table">
<Skeleton active paragraph={{ rows: 5 }} />
</div>
);
@@ -199,7 +176,7 @@ function RolesListingTable({
if (isError) {
return (
<div className={styles.rolesListingTable}>
<div className="roles-listing-table">
<ErrorInPlace
error={toAPIError(
error,
@@ -212,34 +189,31 @@ function RolesListingTable({
if (filteredRoles.length === 0) {
return (
<>
<div className={styles.rolesListingTable}>
<div className={styles.emptyState}>
{searchQuery ? 'No roles match your search.' : 'No roles found.'}
</div>
<div className="roles-listing-table">
<div className="roles-table-empty">
{searchQuery ? 'No roles match your search.' : 'No roles found.'}
</div>
<RoleDetailsDrawer
roleId={selectedRoleId}
roleName={selectedRoleName}
onClose={handleDrawerClose}
/>
</>
</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
@@ -247,81 +221,76 @@ 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)}>
<div className="roles-table-cell roles-table-cell--updated-at">
{formatTimestamp(role.updatedAt)}
</div>
<div className={cx(styles.tableCell, styles.tableCellCreatedAt)}>
<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)}>
Description
</div>
<div className={cx(styles.headerCell, styles.headerCellUpdatedAt)}>
Updated At
</div>
<div className={cx(styles.headerCell, styles.headerCellCreatedAt)}>
Created At
</div>
<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="roles-table-header-cell roles-table-header-cell--updated-at">
Updated At
</div>
<div className="roles-table-header-cell roles-table-header-cell--created-at">
Created At
</div>
{paginatedItems.map((item) =>
item.type === 'section' ? (
<h3 key={`section-${item.label}`} className={styles.sectionHeader}>
{item.label}
{item.count !== undefined && (
<span className={styles.sectionHeaderCount}>{item.count}</span>
)}
</h3>
) : (
renderRow(item.role)
),
)}
</div>
</div>
<Pagination
current={currentPage}
pageSize={PAGE_SIZE}
total={totalRoleCount}
showTotal={showPaginationItem}
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => setCurrentPage(page)}
className={styles.pagination}
/>
{paginatedItems.map((item) =>
item.type === 'section' ? (
<h3 key={`section-${item.label}`} className="roles-table-section-header">
{item.label}
{item.count !== undefined && (
<span className="roles-table-section-header__count">{item.count}</span>
)}
</h3>
) : (
renderRow(item.role)
),
)}
</div>
</div>
<RoleDetailsDrawer
roleId={selectedRoleId}
roleName={selectedRoleName}
onClose={handleDrawerClose}
<Pagination
current={currentPage}
pageSize={PAGE_SIZE}
total={totalRoleCount}
showTotal={showPaginationItem}
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => setCurrentPage(page)}
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,40 +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}>
<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..."
@@ -46,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
@@ -57,6 +56,10 @@ function RolesSettings(): JSX.Element {
</div>
<RolesListingTable searchQuery={searchQuery} />
</div>
<CreateRoleModal
isOpen={isCreateModalOpen}
onClose={(): void => setIsCreateModalOpen(false)}
/>
</div>
);
}

View File

@@ -6,9 +6,9 @@ import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
defaultFeatureFlags,
fireEvent,
render,
screen,
userEvent,
} from 'tests/test-utils';
import { FeatureKeys } from 'constants/features';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
@@ -77,14 +77,13 @@ describe('RolesSettings', () => {
});
it('filters roles by search query on name', async () => {
const user = userEvent.setup();
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.clear(searchInput);
await user.type(searchInput, 'billing');
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
target: { value: 'billing' },
});
await expect(
screen.findByText('billing-manager'),
@@ -95,14 +94,13 @@ describe('RolesSettings', () => {
});
it('filters roles by search query on description', async () => {
const user = userEvent.setup();
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.clear(searchInput);
await user.type(searchInput, 'read-only');
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
target: { value: 'read-only' },
});
await expect(screen.findByText('signoz-viewer')).resolves.toBeInTheDocument();
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
@@ -110,14 +108,13 @@ describe('RolesSettings', () => {
});
it('shows empty state when search matches nothing', async () => {
const user = userEvent.setup();
render(<RolesSettings />);
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.clear(searchInput);
await user.type(searchInput, 'nonexistentrole');
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
target: { value: 'nonexistentrole' },
});
await expect(
screen.findByText('No roles match your search.'),

View File

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

View File

@@ -0,0 +1,613 @@
import type {
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',
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,101 +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';
/** 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;
}
export const RESOURCE_PANELS: Partial<
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: 'serviceaccount',
},
};
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): string {
const match = permissionsType.data.resources.find(
(entry) => entry.kind === resource,
);
return match ? match.type : 'metaresource';
}
export function supportsOnlySelected(
verb: AuthZVerb,
): verb is ObjectScopedVerb {
return OBJECT_SCOPED_VERB_SET.has(verb);
}

View File

@@ -1,52 +0,0 @@
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
export enum PermissionScope {
ALL = 'all',
ONLY_SELECTED = 'only_selected',
NONE = 'none',
}
export type ActionScope = PermissionScope;
export interface ActionConfig {
scope: ActionScope;
selectedIds: string[];
}
export interface ResourcePermissions {
resourceId: AuthZResource;
resourceKind: AuthZResource;
/** Resource type for API (e.g. 'metaresource', 'role', 'serviceaccount'). */
resourceType: string;
resourceLabel: string;
actions: Partial<Record<AuthZVerb, ActionConfig>>;
availableActions: AuthZVerb[];
}
export interface RolePermissionsData {
roleId: string;
roleName: string;
roleDescription: string;
resources: ResourcePermissions[];
}
export interface SelectableItem {
id: string;
label: string;
}
export const ACTION_LABELS: Record<string, string> = {
read: 'Read',
create: 'Create',
update: 'Edit',
delete: 'Delete',
attach: 'Attach',
detach: 'Detach',
list: 'List',
assignee: 'Assign',
};
export interface ResourceItemsResult {
items: SelectableItem[];
isLoading: boolean;
}

View File

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

View File

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

View File

@@ -1,12 +1,10 @@
.sa-settings-page {
.sa-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%;
}
.sa-settings {
&__header {
display: flex;
flex-direction: column;

View File

@@ -205,7 +205,7 @@ function ServiceAccountsSettings(): JSX.Element {
);
return (
<div className="sa-settings-page">
<>
<div className="sa-settings">
<div className="sa-settings__header">
<h1 className="sa-settings__title">Service Accounts</h1>
@@ -295,7 +295,7 @@ function ServiceAccountsSettings(): JSX.Element {
<CreateServiceAccountModal />
<ServiceAccountDrawer onSuccess={handleDrawerSuccess} />
</div>
</>
);
}

View File

@@ -80,7 +80,7 @@ import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import { useCmdK } from '../../providers/cmdKProvider';
import { routeConfig } from './config';
import { buildNavUrl, getQueryString } from './helper';
import { getQueryString } from './helper';
import {
defaultMoreMenuItems,
getUserSettingsDropdownMenuItems,
@@ -486,13 +486,12 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const availableParams = routeConfig[key];
const queryString = getQueryString(availableParams || [], params);
const url = buildNavUrl(key, queryString);
if (pathname !== key) {
if (event && isModifierKeyPressed(event)) {
openInNewTab(url);
openInNewTab(`${key}?${queryString.join('&')}`);
} else {
history.push(url, {
history.push(`${key}?${queryString.join('&')}`, {
from: pathname,
});
}

View File

@@ -8,14 +8,3 @@ export const getQueryString = (
}
return '';
});
/**
* @deprecated This should be removed after https://github.com/SigNoz/engineering-pod/issues/5322 is done
*/
export const buildNavUrl = (key: string, queryString: string[]): string => {
if (key.includes('?')) {
const extra = queryString.filter(Boolean).join('&');
return extra ? `${key}&${extra}` : key;
}
return `${key}?${queryString.join('&')}`;
};

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