Compare commits

...

24 Commits

Author SHA1 Message Date
SagarRajput-7
d647b1c01d feat: new service_account page with crud and listing 2026-03-07 22:29:33 +05:30
SagarRajput-7
90a2702c77 Merge branch 'members-page' into ssopage-cleanup 2026-03-07 01:53:47 +05:30
SagarRajput-7
af406f806f feat: code refactor and added todos 2026-03-07 01:51:26 +05:30
SagarRajput-7
9a6976e50e Merge branch 'members-page' into ssopage-cleanup 2026-03-07 01:27:19 +05:30
SagarRajput-7
717c44dc26 feat: updated test case 2026-03-07 01:23:35 +05:30
SagarRajput-7
8a4c2c4be4 Merge branch 'main' into members-page 2026-03-07 01:19:58 +05:30
SagarRajput-7
ae315c600a feat: refactored and addressed the comments 2026-03-07 01:18:33 +05:30
Ashwin Bhatkal
08713cbf7d chore: support for merge queues (#10513)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-03-06 14:22:17 +00:00
Ashwin Bhatkal
235dacf4a3 chore(frontend): separate out columnWidths from ResizeTable (#10510)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore(frontend): separate out columnWidths from ResizeTable

* chore: fix tests
2026-03-06 19:20:17 +05:30
Nityananda Gohain
3a82085eb4 chore: enrich clickhouse log_comment (#10446)
* chore: enchance clickhouse log_comment

* chore: enrich all clickhouse queries

* fix: remove is_meatadata

* fix: use the new func

* fix: use semconv keys

* fix: add more details

* fix: minor changes

* fix: address comments

* fix: address cursor comments

* fix: minor changes

* fix: addressed comments

* fix: address comments and move to module functions where possible

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-03-06 12:57:15 +00:00
SagarRajput-7
0f5cab9d68 feat: removed members and invited user tables from sso page 2026-03-06 16:25:18 +05:30
SagarRajput-7
bc311660ba feat: added updatedAt as the current give that in the response 2026-03-06 16:06:54 +05:30
SagarRajput-7
6d1e9bb1bf feat: code refactor 2026-03-06 16:06:54 +05:30
SagarRajput-7
3c6535a33a feat: added test cases for the members feature 2026-03-06 16:06:54 +05:30
SagarRajput-7
a0c516ccda feat: refactored and used semantic token for edit member drawer 2026-03-06 16:06:54 +05:30
SagarRajput-7
61f83707cd feat: edit member drawer refactor 2026-03-06 16:06:54 +05:30
SagarRajput-7
f1de57d0d3 feat: code refactoring and use of semantic tokens 2026-03-06 16:06:54 +05:30
SagarRajput-7
c9f1b4cd0f feat: rebased with settings nav changes 2026-03-06 16:06:54 +05:30
SagarRajput-7
9a69f86a9e feat: added password modal and other functionality 2026-03-06 16:06:54 +05:30
SagarRajput-7
c6d9c83790 feat: added components and styles 2026-03-06 16:06:54 +05:30
SagarRajput-7
9c530d17fc feat: added members page and listing and edit view 2026-03-06 16:06:54 +05:30
SagarRajput-7
7691f3451a feat: revamped the settings nav and setting dropdown (#10494)
* feat: revamped the settings nav and setting dropdown

* feat: settings nav categorisation

* feat: added test cases

* feat: refactored code

* feat: updated test case

* feat: added keyboard shortcuts back into the settings nav at the bottom section
2026-03-06 10:32:25 +00:00
primus-bot[bot]
ded51c4379 chore(release): bump to v0.114.1 (#10515)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-03-06 12:23:42 +05:30
Yunus M
ef8790b708 chore: remove search nav item from sidenav (#10512) 2026-03-06 06:13:30 +00:00
130 changed files with 8198 additions and 1126 deletions

View File

@@ -7,10 +7,14 @@ on:
pull_request_target:
types:
- labeled
merge_group:
types:
- checks_requested
jobs:
refcheck:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest

View File

@@ -7,10 +7,14 @@ on:
pull_request_target:
types:
- labeled
merge_group:
types:
- checks_requested
jobs:
test:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
uses: signoz/primus.workflows/.github/workflows/go-test.yaml@main
@@ -21,6 +25,7 @@ jobs:
GO_VERSION: 1.24
fmt:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
uses: signoz/primus.workflows/.github/workflows/go-fmt.yaml@main
@@ -30,6 +35,7 @@ jobs:
GO_VERSION: 1.24
lint:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
uses: signoz/primus.workflows/.github/workflows/go-lint.yaml@main
@@ -39,6 +45,7 @@ jobs:
GO_VERSION: 1.24
deps:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
uses: signoz/primus.workflows/.github/workflows/go-deps.yaml@main
@@ -48,6 +55,7 @@ jobs:
GO_VERSION: 1.24
build:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
@@ -79,6 +87,7 @@ jobs:
make docker-build-enterprise
openapi:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest

View File

@@ -7,10 +7,14 @@ on:
pull_request_target:
types:
- labeled
merge_group:
types:
- checks_requested
jobs:
tsc:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
@@ -27,6 +31,7 @@ jobs:
run: cd frontend && yarn tsc
tsc2:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
uses: signoz/primus.workflows/.github/workflows/js-tsc.yaml@main
@@ -36,6 +41,7 @@ jobs:
JS_SRC: frontend
test:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
uses: signoz/primus.workflows/.github/workflows/js-test.yaml@main
@@ -45,6 +51,7 @@ jobs:
JS_SRC: frontend
fmt:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
uses: signoz/primus.workflows/.github/workflows/js-fmt.yaml@main
@@ -54,6 +61,7 @@ jobs:
JS_SRC: frontend
lint:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
uses: signoz/primus.workflows/.github/workflows/js-lint.yaml@main
@@ -63,6 +71,7 @@ jobs:
JS_SRC: frontend
md-languages:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
@@ -73,6 +82,7 @@ jobs:
run: bash frontend/scripts/validate-md-languages.sh
authz:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.114.0
image: signoz/signoz:v0.114.1
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.114.0
image: signoz/signoz:v0.114.1
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.114.0}
image: signoz/signoz:${VERSION:-v0.114.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.114.0}
image: signoz/signoz:${VERSION:-v0.114.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -8,6 +8,8 @@ import (
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
@@ -67,6 +69,10 @@ func (p *BaseSeasonalProvider) toTSResults(ctx context.Context, resp *qbtypes.Qu
}
func (p *BaseSeasonalProvider) getResults(ctx context.Context, orgID valuer.UUID, params *anomalyQueryParams) (*anomalyQueryResults, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "anomaly",
instrumentationtypes.CodeFunctionName: "getResults",
})
// TODO(srikanthccv): parallelize this?
p.logger.InfoContext(ctx, "fetching results for current period", "anomaly_current_period_query", params.CurrentPeriodQuery)
currentPeriodResults, err := p.querier.QueryRange(ctx, orgID, &params.CurrentPeriodQuery)

View File

@@ -15,7 +15,9 @@ import (
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -105,6 +107,10 @@ func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id
}
func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.UUID, widgetIdx, startTime, endTime uint64) (*querybuildertypesv5.QueryRangeResponse, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "dashboard",
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRange",
})
dashboard, err := module.GetDashboardByPublicID(ctx, id)
if err != nil {
return nil, err

View File

@@ -10,6 +10,8 @@ import (
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"go.uber.org/zap"
)
@@ -61,6 +63,10 @@ func (p *BaseSeasonalProvider) getQueryParams(req *GetAnomaliesRequest) *anomaly
}
func (p *BaseSeasonalProvider) getResults(ctx context.Context, orgID valuer.UUID, params *anomalyQueryParams) (*anomalyQueryResults, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "anomaly",
instrumentationtypes.CodeFunctionName: "getResults",
})
zap.L().Info("fetching results for current period", zap.Any("currentPeriodQuery", params.CurrentPeriodQuery))
currentPeriodResults, _, err := p.querierV2.QueryRange(ctx, orgID, params.CurrentPeriodQuery)
if err != nil {

View File

@@ -23,29 +23,7 @@ const config: Config.InitialOptions = {
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
'^react-syntax-highlighter/dist/esm/(.*)$':
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',
'^@signozhq/sonner$':
'<rootDir>/node_modules/@signozhq/sonner/dist/sonner.js',
'^@signozhq/button$':
'<rootDir>/node_modules/@signozhq/button/dist/button.js',
'^@signozhq/calendar$':
'<rootDir>/node_modules/@signozhq/calendar/dist/calendar.js',
'^@signozhq/badge': '<rootDir>/node_modules/@signozhq/badge/dist/badge.js',
'^@signozhq/checkbox':
'<rootDir>/node_modules/@signozhq/checkbox/dist/checkbox.js',
'^@signozhq/switch': '<rootDir>/node_modules/@signozhq/switch/dist/switch.js',
'^@signozhq/callout':
'<rootDir>/node_modules/@signozhq/callout/dist/callout.js',
'^@signozhq/combobox':
'<rootDir>/node_modules/@signozhq/combobox/dist/combobox.js',
'^@signozhq/input': '<rootDir>/node_modules/@signozhq/input/dist/input.js',
'^@signozhq/command':
'<rootDir>/node_modules/@signozhq/command/dist/command.js',
'^@signozhq/radio-group':
'<rootDir>/node_modules/@signozhq/radio-group/dist/radio-group.js',
'^@signozhq/toggle-group$':
'<rootDir>/node_modules/@signozhq/toggle-group/dist/toggle-group.js',
'^@signozhq/dialog$':
'<rootDir>/node_modules/@signozhq/dialog/dist/dialog.js',
'^@signozhq/([^/]+)$': '<rootDir>/node_modules/@signozhq/$1/dist/$1.js',
},
extensionsToTreatAsEsm: ['.ts'],
testMatch: ['<rootDir>/src/**/*?(*.)(test).(ts|js)?(x)'],

View File

@@ -7,9 +7,10 @@
*/
import '@testing-library/jest-dom';
import 'jest-styled-components';
import './src/styles.scss';
import { server } from './src/mocks-server/server';
import './src/styles.scss';
// Establish API mocking before all tests.
// Mock window.matchMedia

View File

@@ -55,6 +55,7 @@
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "2.1.1",
"@signozhq/dialog": "^0.0.2",
"@signozhq/drawer": "0.0.4",
"@signozhq/icons": "0.1.0",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",

View File

@@ -14,5 +14,6 @@
"archives": "Archives",
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles",
"role_details": "Role Details"
"role_details": "Role Details",
"members": "Members"
}

View File

@@ -14,5 +14,6 @@
"archives": "Archives",
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles",
"role_details": "Role Details"
"role_details": "Role Details",
"members": "Members"
}

View File

@@ -74,5 +74,6 @@
"METER_EXPLORER": "SigNoz | Meter Explorer",
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles"
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members"
}

View File

@@ -19,6 +19,7 @@ import '@signozhq/combobox';
import '@signozhq/command';
import '@signozhq/design-tokens';
import '@signozhq/dialog';
import '@signozhq/drawer';
import '@signozhq/icons';
import '@signozhq/input';
import '@signozhq/popover';

View File

@@ -0,0 +1,142 @@
.create-sa-modal {
max-width: 530px;
background: var(--popover);
border: 1px solid var(--secondary);
border-radius: 4px;
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
[data-slot='dialog-header'] {
padding: var(--padding-4);
border-bottom: 1px solid var(--secondary);
flex-shrink: 0;
background: transparent;
margin: 0;
}
[data-slot='dialog-title'] {
font-family: Inter, sans-serif;
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--label-base-400-line-height);
letter-spacing: -0.065px;
color: var(--bg-base-white);
margin: 0;
}
[data-slot='dialog-description'] {
padding: 0;
.create-sa-modal__content {
padding: var(--padding-4);
}
}
}
.create-sa-form {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
.ant-form-item {
margin-bottom: var(--spacing-4);
}
.ant-form-item-label > label {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: -0.07px;
}
&__input {
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--border);
font-size: var(--paragraph-base-400-font-size);
border-radius: 2px;
width: 100%;
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--primary);
box-shadow: none;
}
}
&__select {
width: 100%;
.ant-select-selector {
min-height: 32px;
border-radius: 2px;
background-color: var(--l2-background) !important;
border: 1px solid var(--border) !important;
padding: 0 var(--padding-2) !important;
.ant-select-selection-placeholder {
color: var(--l3-foreground);
opacity: 0.4;
font-size: var(--paragraph-base-400-font-size);
letter-spacing: -0.07px;
line-height: 32px;
}
.ant-select-selection-item {
font-size: var(--paragraph-base-400-font-size);
letter-spacing: -0.07px;
color: var(--bg-base-white);
}
}
.ant-select-arrow {
color: var(--foreground);
}
&.ant-select-focused .ant-select-selector,
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--primary);
}
}
&__helper {
font-size: var(--paragraph-small-400-font-size);
color: var(--l3-foreground);
margin: calc(var(--spacing-2) * -1) 0 var(--spacing-4) 0;
line-height: var(--paragraph-small-400-line-height);
}
}
.create-sa-modal__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
padding: 0 var(--padding-4);
height: 56px;
min-height: 56px;
border-top: 1px solid var(--secondary);
gap: var(--spacing-4);
flex-shrink: 0;
}
.lightMode {
.create-sa-modal {
[data-slot='dialog-title'] {
color: var(--bg-base-black);
}
}
.create-sa-form {
&__select {
.ant-select-selector {
.ant-select-selection-item {
color: var(--bg-base-black);
}
}
}
}
}

View File

@@ -0,0 +1,154 @@
import { useCallback, useState } from 'react';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { ChevronDown, X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { Form, Input, Select } from 'antd';
import { useCreateServiceAccount } from 'api/generated/services/serviceaccount';
import './CreateServiceAccountModal.styles.scss';
interface CreateServiceAccountModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
interface FormValues {
name: string;
email?: string;
roles: string[];
}
function CreateServiceAccountModal({
open,
onClose,
onSuccess,
}: CreateServiceAccountModalProps): JSX.Element {
const [form] = Form.useForm<FormValues>();
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createServiceAccount } = useCreateServiceAccount();
const handleClose = useCallback((): void => {
form.resetFields();
onClose();
}, [form, onClose]);
const handleSubmit = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
setIsSubmitting(true);
await createServiceAccount({
data: {
name: values.name.trim(),
email: values.email?.trim() ?? '',
roles: values.roles ?? [],
},
});
toast.success('Service account created successfully', { richColors: true });
form.resetFields();
onSuccess();
onClose();
} catch (err: unknown) {
// If it's a form validation error (no message property typical of AntD), skip
if (err && typeof err === 'object' && 'errorFields' in err) {
return;
}
const errorMessage =
err instanceof Error ? err.message : 'An error occurred';
toast.error(`Failed to create service account: ${errorMessage}`, {
richColors: true,
});
} finally {
setIsSubmitting(false);
}
}, [form, createServiceAccount, onSuccess, onClose]);
return (
<DialogWrapper
title="New Service Account"
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
showCloseButton
width="narrow"
className="create-sa-modal"
disableOutsideClick={false}
>
<div className="create-sa-modal__content">
<Form form={form} layout="vertical" className="create-sa-form">
<Form.Item
name="name"
label="Name"
rules={[{ required: true, message: 'Name is required' }]}
className="create-sa-form__item"
>
<Input placeholder="Enter a name" className="create-sa-form__input" />
</Form.Item>
<Form.Item
name="email"
label="Email Address"
className="create-sa-form__item"
>
<Input
type="email"
placeholder="email@example.com"
className="create-sa-form__input"
/>
</Form.Item>
<p className="create-sa-form__helper">
Used only for notifications about this service account. It is not used for
authentication.
</p>
<Form.Item name="roles" label="Roles" className="create-sa-form__item">
<Select
mode="multiple"
placeholder="Select roles"
suffixIcon={<ChevronDown size={14} />}
className="create-sa-form__select"
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.create-sa-modal') as HTMLElement) ||
document.body
}
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
</Form.Item>
</Form>
</div>
<DialogFooter className="create-sa-modal__footer">
<Button
type="button"
variant="solid"
color="secondary"
size="sm"
onClick={handleClose}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? 'Creating...' : 'Create Service Account'}
</Button>
</DialogFooter>
</DialogWrapper>
);
}
export default CreateServiceAccountModal;

View File

@@ -0,0 +1,304 @@
.edit-member-drawer {
&__layout {
display: flex;
flex-direction: column;
height: calc(100vh - 48px);
}
&__body {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-5) var(--padding-4);
}
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
cursor: default;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__input-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
&--disabled {
cursor: not-allowed;
opacity: 0.8;
}
}
&__email-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&__lock-icon {
color: var(--foreground);
flex-shrink: 0;
margin-left: 6px;
opacity: 0.6;
}
&__role-select {
width: 100%;
height: 32px;
.ant-select-selector {
background-color: var(--l2-background) !important;
border-color: var(--border) !important;
border-radius: 2px;
padding: 0 var(--padding-2) !important;
display: flex;
align-items: center;
}
.ant-select-selection-item {
font-size: var(--font-size-sm);
color: var(--l1-foreground);
line-height: 32px;
letter-spacing: -0.07px;
}
.ant-select-arrow {
color: var(--foreground);
}
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--foreground);
}
}
&__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
margin-top: var(--margin-1);
}
&__meta-item {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
[data-slot='badge'] {
padding: var(--padding-1) var(--padding-2);
align-items: center;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
line-height: 100%;
letter-spacing: 0.44px;
text-transform: uppercase;
}
}
&__meta-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 56px;
padding: 0 var(--padding-4);
border-top: 1px solid var(--border);
flex-shrink: 0;
background: var(--card);
}
&__footer-left {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
&__footer-divider {
width: 1px;
height: 21px;
background: var(--border);
flex-shrink: 0;
}
&__footer-btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
padding: 0;
background: transparent;
border: none;
cursor: pointer;
font-family: Inter, sans-serif;
font-size: var(--label-small-400-font-size);
font-weight: var(--label-small-400-font-weight);
line-height: var(--label-small-400-line-height);
letter-spacing: var(--label-small-400-letter-spacing);
transition: opacity 0.15s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:not(:disabled):hover {
opacity: 0.8;
}
&--danger {
color: var(--destructive);
}
&--warning {
color: var(--accent-amber);
}
}
}
.delete-dialog {
background: var(--l2-background);
border: 1px solid var(--l2-border);
[data-slot='dialog-title'] {
color: var(--l1-foreground);
}
&__body {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l2-foreground);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.065px;
margin: 0;
strong {
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
}
&__footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-4);
margin-top: var(--margin-6);
}
}
.reset-link-dialog {
background: var(--l2-background);
border: 1px solid var(--l2-border);
[data-slot='dialog-header'] {
border-color: var(--l2-border);
color: var(--l1-foreground);
}
[data-slot='dialog-description'] {
width: 510px;
}
&__content {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
&__description {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l2-foreground);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.065px;
margin: 0;
white-space: normal;
word-break: break-word;
}
&__link-row {
display: flex;
align-items: center;
height: 32px;
overflow: hidden;
background: var(--l2-background);
border: 1px solid var(--border);
border-radius: 2px;
}
&__link-text-wrap {
flex: 1;
min-width: 0;
overflow: hidden;
}
&__link-text {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 var(--padding-2);
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l2-foreground);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
}
&__copy-btn {
flex-shrink: 0;
height: 32px;
border-radius: 0 2px 2px 0;
border-top: none;
border-right: none;
border-bottom: none;
border-left: 1px solid var(--border);
min-width: 64px;
}
}

View File

@@ -0,0 +1,510 @@
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { DrawerWrapper } from '@signozhq/drawer';
import {
Check,
ChevronDown,
Copy,
Link,
LockKeyhole,
RefreshCw,
Trash2,
X,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { Select } from 'antd';
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
import sendInvite from 'api/v1/invite/create';
import cancelInvite from 'api/v1/invite/id/delete';
import deleteUser from 'api/v1/user/id/delete';
import update from 'api/v1/user/id/update';
import { MemberRow } from 'components/MembersTable/MembersTable';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { INVITE_PREFIX, MemberStatus } from 'container/MembersSettings/utils';
import { capitalize } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import './EditMemberDrawer.styles.scss';
export interface EditMemberDrawerProps {
member: MemberRow | null;
open: boolean;
onClose: () => void;
onComplete: () => void;
onRefetch?: () => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function EditMemberDrawer({
member,
open,
onClose,
onComplete,
onRefetch,
}: EditMemberDrawerProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [displayName, setDisplayName] = useState('');
const [selectedRole, setSelectedRole] = useState<ROLES>('VIEWER');
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [resetLink, setResetLink] = useState<string | null>(null);
const [showResetLinkDialog, setShowResetLinkDialog] = useState(false);
const [hasCopiedResetLink, setHasCopiedResetLink] = useState(false);
const isInvited = member?.status === MemberStatus.Invited;
// Invited member IDs are prefixed with 'invite-'; strip it to get the real invite ID
const inviteId =
isInvited && member ? member.id.slice(INVITE_PREFIX.length) : null;
useEffect(() => {
if (member) {
setDisplayName(member.name ?? '');
setSelectedRole(member.role);
}
}, [member]);
const isDirty =
member !== null &&
(displayName !== member.name || selectedRole !== member.role);
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[formatTimezoneAdjustedTimestamp],
);
const saveInvitedMember = useCallback(async (): Promise<void> => {
if (!member || !inviteId) {
return;
}
await cancelInvite({ id: inviteId });
try {
await sendInvite({
email: member.email,
name: displayName,
role: selectedRole,
frontendBaseUrl: window.location.origin,
});
toast.success('Invite updated successfully', { richColors: true });
onComplete();
onClose();
} catch {
onRefetch?.();
onClose();
toast.error(
'Failed to send the updated invite. Please re-invite this member.',
{ richColors: true },
);
}
}, [
member,
inviteId,
displayName,
selectedRole,
onComplete,
onClose,
onRefetch,
]);
const saveActiveMember = useCallback(async (): Promise<void> => {
if (!member) {
return;
}
await update({
userId: member.id,
displayName,
role: selectedRole,
});
toast.success('Member details updated successfully', { richColors: true });
onComplete();
onClose();
}, [member, displayName, selectedRole, onComplete, onClose]);
const handleSave = useCallback(async (): Promise<void> => {
if (!member || !isDirty) {
return;
}
setIsSaving(true);
try {
if (isInvited && inviteId) {
await saveInvitedMember();
} else {
await saveActiveMember();
}
} catch {
toast.error(
isInvited ? 'Failed to update invite' : 'Failed to update member details',
{ richColors: true },
);
} finally {
setIsSaving(false);
}
}, [
member,
isDirty,
isInvited,
inviteId,
saveInvitedMember,
saveActiveMember,
]);
const handleDelete = useCallback(async (): Promise<void> => {
if (!member) {
return;
}
setIsDeleting(true);
try {
if (isInvited && inviteId) {
await cancelInvite({ id: inviteId });
toast.success('Invitation cancelled successfully', { richColors: true });
} else {
await deleteUser({ userId: member.id });
toast.success('Member deleted successfully', { richColors: true });
}
setShowDeleteConfirm(false);
onComplete();
onClose();
} catch {
toast.error(
isInvited ? 'Failed to cancel invitation' : 'Failed to delete member',
{ richColors: true },
);
} finally {
setIsDeleting(false);
}
}, [member, isInvited, inviteId, onComplete, onClose]);
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
if (!member) {
return;
}
setIsGeneratingLink(true);
try {
const response = await getResetPasswordToken({ userId: member.id });
if (response?.data?.token) {
const link = `${window.location.origin}/password-reset?token=${response.data.token}`;
setResetLink(link);
setHasCopiedResetLink(false);
setShowResetLinkDialog(true);
onClose();
} else {
toast.error('Failed to generate password reset link', {
richColors: true,
position: 'top-right',
});
}
} catch {
toast.error('Failed to generate password reset link', {
richColors: true,
position: 'top-right',
});
} finally {
setIsGeneratingLink(false);
}
}, [member, onClose]);
const handleCopyResetLink = useCallback(async (): Promise<void> => {
if (!resetLink) {
return;
}
try {
await navigator.clipboard.writeText(resetLink);
setHasCopiedResetLink(true);
setTimeout(() => setHasCopiedResetLink(false), 2000);
toast.success('Reset link copied to clipboard', { richColors: true });
} catch {
toast.error('Failed to copy link', {
richColors: true,
});
}
}, [resetLink]);
const handleCopyInviteLink = useCallback(async (): Promise<void> => {
if (!member?.token) {
toast.error('Invite link is not available', {
richColors: true,
position: 'top-right',
});
return;
}
const inviteLink = `${window.location.origin}${ROUTES.SIGN_UP}?token=${member.token}`;
try {
await navigator.clipboard.writeText(inviteLink);
toast.success('Invite link copied to clipboard', {
richColors: true,
position: 'top-right',
});
} catch {
toast.error('Failed to copy invite link', {
richColors: true,
position: 'top-right',
});
}
}, [member]);
const handleClose = useCallback((): void => {
setShowDeleteConfirm(false);
onClose();
}, [onClose]);
const joinedOnLabel = isInvited ? 'Invited On' : 'Joined On';
const drawerContent = (
<div className="edit-member-drawer__layout">
<div className="edit-member-drawer__body">
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-name">
Name
</label>
<Input
id="member-name"
value={displayName}
onChange={(e): void => setDisplayName(e.target.value)}
className="edit-member-drawer__input"
placeholder="Enter name"
/>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-email">
Email Address
</label>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<span className="edit-member-drawer__email-text">
{member?.email || '—'}
</span>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
<Select
id="member-role"
value={selectedRole}
onChange={(role): void => setSelectedRole(role as ROLES)}
className="edit-member-drawer__role-select"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.edit-member-drawer') as HTMLElement) ||
document.body
}
>
<Select.Option value="ADMIN">{capitalize('ADMIN')}</Select.Option>
<Select.Option value="EDITOR">{capitalize('EDITOR')}</Select.Option>
<Select.Option value="VIEWER">{capitalize('VIEWER')}</Select.Option>
</Select>
</div>
<div className="edit-member-drawer__meta">
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Status</span>
{member?.status === MemberStatus.Active ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
</Badge>
)}
</div>
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
</div>
{!isInvited && (
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Last Modified</span>
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
</div>
)}
</div>
</div>
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
>
<Trash2 size={12} />
{isInvited ? 'Cancel Invite' : 'Delete Member'}
</Button>
<div className="edit-member-drawer__footer-divider" />
{isInvited ? (
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleCopyInviteLink}
disabled={!member?.token}
>
<Link size={12} />
Copy Invite Link
</Button>
) : (
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink}
>
<RefreshCw size={12} />
{isGeneratingLink ? 'Generating...' : 'Generate Password Reset Link'}
</Button>
)}
</div>
<div className="edit-member-drawer__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</div>
</div>
</div>
);
const deleteDialogTitle = isInvited ? 'Cancel Invitation' : 'Delete Member';
const deleteDialogBody = isInvited ? (
<>
Are you sure you want to cancel the invitation for{' '}
<strong>{member?.email}</strong>? They will no longer be able to join the
workspace using this invite.
</>
) : (
<>
Are you sure you want to delete{' '}
<strong>{member?.name || member?.email}</strong>? This will permanently
remove their access to the workspace.
</>
);
const deleteConfirmLabel = isInvited ? 'Cancel Invite' : 'Delete Member';
return (
<>
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
direction="right"
type="panel"
showCloseButton
showOverlay={false}
allowOutsideClick
header={{ title: 'Member Details' }}
content={drawerContent}
className="edit-member-drawer"
/>
<DialogWrapper
open={showResetLinkDialog}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowResetLinkDialog(false);
}
}}
title="Password Reset Link"
showCloseButton
width="base"
className="reset-link-dialog"
>
<div className="reset-link-dialog__content">
<p className="reset-link-dialog__description">
This creates a one-time link the team member can use to set a new password
for their SigNoz account.
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={handleCopyResetLink}
prefixIcon={
hasCopiedResetLink ? <Check size={12} /> : <Copy size={12} />
}
className="reset-link-dialog__copy-btn"
>
{hasCopiedResetLink ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
</DialogWrapper>
<DialogWrapper
open={showDeleteConfirm}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowDeleteConfirm(false);
}
}}
title={deleteDialogTitle}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">{deleteDialogBody}</p>
<DialogFooter className="delete-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setShowDeleteConfirm(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDeleting}
onClick={handleDelete}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : deleteConfirmLabel}
</Button>
</DialogFooter>
</DialogWrapper>
</>
);
}
export default EditMemberDrawer;

View File

@@ -0,0 +1,277 @@
import type { ReactNode } from 'react';
import { toast } from '@signozhq/sonner';
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
import cancelInvite from 'api/v1/invite/id/delete';
import deleteUser from 'api/v1/user/id/delete';
import update from 'api/v1/user/id/update';
import { MemberStatus } from 'container/MembersSettings/utils';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { ROLES } from 'types/roles';
import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
jest.mock('@signozhq/drawer', () => ({
DrawerWrapper: ({
content,
open,
}: {
content?: ReactNode;
open: boolean;
}): JSX.Element | null => (open ? <div>{content}</div> : null),
}));
jest.mock('@signozhq/dialog', () => ({
DialogWrapper: ({
children,
open,
title,
}: {
children?: ReactNode;
open: boolean;
title?: string;
}): JSX.Element | null =>
open ? (
<div role="dialog" aria-label={title}>
{children}
</div>
) : null,
DialogFooter: ({ children }: { children?: ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
jest.mock('api/v1/user/id/update');
jest.mock('api/v1/user/id/delete');
jest.mock('api/v1/invite/id/delete');
jest.mock('api/v1/invite/create');
jest.mock('api/v1/factor_password/getResetPasswordToken');
jest.mock('@signozhq/sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const mockUpdate = jest.mocked(update);
const mockDeleteUser = jest.mocked(deleteUser);
const mockCancelInvite = jest.mocked(cancelInvite);
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
const activeMember = {
id: 'user-1',
name: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN' as ROLES,
status: MemberStatus.Active,
joinedOn: '1700000000000',
updatedAt: '1710000000000',
};
const invitedMember = {
id: 'invite-abc123',
name: '',
email: 'bob@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Invited,
joinedOn: '1700000000000',
token: 'tok-xyz',
};
function renderDrawer(
props: Partial<EditMemberDrawerProps> = {},
): ReturnType<typeof render> {
return render(
<EditMemberDrawer
member={activeMember}
open
onClose={jest.fn()}
onComplete={jest.fn()}
{...props}
/>,
);
}
describe('EditMemberDrawer', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUpdate.mockResolvedValue({ httpStatusCode: 200, data: null });
mockDeleteUser.mockResolvedValue({ httpStatusCode: 200, data: null });
mockCancelInvite.mockResolvedValue({ httpStatusCode: 200, data: null });
});
it('renders active member details and disables Save when form is not dirty', () => {
renderDrawer();
expect(screen.getByDisplayValue('Alice Smith')).toBeInTheDocument();
expect(screen.getByText('alice@signoz.io')).toBeInTheDocument();
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /save member details/i }),
).toBeDisabled();
});
it('enables Save after editing name and calls update API on confirm', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer({ onComplete });
const nameInput = screen.getByDisplayValue('Alice Smith');
await user.clear(nameInput);
await user.type(nameInput, 'Alice Updated');
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
displayName: 'Alice Updated',
}),
);
expect(onComplete).toHaveBeenCalled();
});
});
it('shows delete confirm dialog and calls deleteUser for active members', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer({ onComplete });
await user.click(screen.getByRole('button', { name: /delete member/i }));
expect(
await screen.findByText(/are you sure you want to delete/i),
).toBeInTheDocument();
const confirmBtns = screen.getAllByRole('button', { name: /delete member/i });
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(mockDeleteUser).toHaveBeenCalledWith({ userId: 'user-1' });
expect(onComplete).toHaveBeenCalled();
});
});
it('shows Cancel Invite and Copy Invite Link for invited members; hides Last Modified', () => {
renderDrawer({ member: invitedMember });
expect(
screen.getByRole('button', { name: /cancel invite/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /copy invite link/i }),
).toBeInTheDocument();
expect(screen.getByText('Invited On')).toBeInTheDocument();
expect(screen.queryByText('Last Modified')).not.toBeInTheDocument();
});
it('calls cancelInvite after confirming Cancel Invite for invited members', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer({ member: invitedMember, onComplete });
await user.click(screen.getByRole('button', { name: /cancel invite/i }));
expect(
await screen.findByText(/are you sure you want to cancel the invitation/i),
).toBeInTheDocument();
const confirmBtns = screen.getAllByRole('button', { name: /cancel invite/i });
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(mockCancelInvite).toHaveBeenCalledWith({ id: 'abc123' });
expect(onComplete).toHaveBeenCalled();
});
});
describe('Generate Password Reset Link', () => {
const mockWriteText = jest.fn().mockResolvedValue(undefined);
let clipboardSpy: jest.SpyInstance | undefined;
beforeAll(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: (): Promise<void> => Promise.resolve() },
configurable: true,
writable: true,
});
});
beforeEach(() => {
mockWriteText.mockClear();
clipboardSpy = jest
.spyOn(navigator.clipboard, 'writeText')
.mockImplementation(mockWriteText);
mockGetResetPasswordToken.mockResolvedValue({
httpStatusCode: 200,
data: { token: 'reset-tok-abc', userId: 'user-1' },
});
});
afterEach(() => {
clipboardSpy?.mockRestore();
});
it('calls getResetPasswordToken and opens the reset link dialog with the generated link', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await user.click(
screen.getByRole('button', { name: /generate password reset link/i }),
);
const dialog = await screen.findByRole('dialog', {
name: /password reset link/i,
});
expect(mockGetResetPasswordToken).toHaveBeenCalledWith({
userId: 'user-1',
});
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveTextContent('reset-tok-abc');
});
it('copies the link to clipboard and shows "Copied!" on the button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
renderDrawer();
await user.click(
screen.getByRole('button', { name: /generate password reset link/i }),
);
const dialog = await screen.findByRole('dialog', {
name: /password reset link/i,
});
expect(dialog).toHaveTextContent('reset-tok-abc');
fireEvent.click(screen.getByRole('button', { name: /^copy$/i }));
// Verify success path: writeText called with the correct link
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalledWith(
'Reset link copied to clipboard',
expect.anything(),
);
});
expect(mockWriteText).toHaveBeenCalledWith(
expect.stringContaining('reset-tok-abc'),
);
expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,264 @@
.invite-members-modal {
max-width: 700px;
background: var(--popover);
border: 1px solid var(--secondary);
border-radius: 4px;
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
[data-slot='dialog-header'] {
padding: var(--padding-4);
border-bottom: 1px solid var(--secondary);
flex-shrink: 0;
background: transparent;
margin: 0;
}
[data-slot='dialog-title'] {
font-family: Inter, sans-serif;
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--label-base-400-line-height);
letter-spacing: -0.065px;
color: var(--bg-base-white);
margin: 0;
}
[data-slot='dialog-description'] {
padding: 0;
.invite-members-modal__content {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4);
}
}
}
.invite-members-modal__table {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.invite-members-modal__table-header {
display: flex;
align-items: center;
gap: var(--spacing-8);
width: 100%;
.email-header {
flex: 0 0 240px;
}
.role-header {
flex: 1 0 0;
min-width: 0;
}
.action-header {
flex: 0 0 32px;
}
.table-header-cell {
font-family: Inter, sans-serif;
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.07px;
color: var(--foreground);
}
}
.invite-members-modal__container {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 100%;
}
.team-member-row {
display: flex;
align-items: flex-start;
gap: var(--spacing-8);
width: 100%;
> .email-cell {
flex: 0 0 240px;
}
> .role-cell {
flex: 1 0 0;
min-width: 0;
}
> .action-cell {
flex: 0 0 32px;
}
}
.team-member-cell {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
&.action-cell {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
}
}
.team-member-email-input {
width: 100%;
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--border);
font-size: var(--paragraph-base-400-font-size);
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--primary);
box-shadow: none;
}
}
.team-member-role-select {
width: 100%;
.ant-select-selector {
height: 32px;
border-radius: 2px;
background-color: var(--l2-background) !important;
border: 1px solid var(--border) !important;
padding: 0 var(--padding-2) !important;
.ant-select-selection-placeholder {
color: var(--l3-foreground);
opacity: 0.4;
font-size: var(--paragraph-base-400-font-size);
letter-spacing: -0.07px;
line-height: 32px;
}
.ant-select-selection-item {
font-size: var(--paragraph-base-400-font-size);
letter-spacing: -0.07px;
color: var(--bg-base-white);
line-height: 32px;
}
}
.ant-select-arrow {
color: var(--foreground);
}
&.ant-select-focused .ant-select-selector,
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--primary);
}
}
.remove-team-member-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
border: none;
border-radius: 2px;
background: transparent;
color: var(--destructive);
opacity: 0.6;
padding: 0;
transition: background-color 0.2s, opacity 0.2s;
box-shadow: none;
&:hover {
background: rgba(229, 72, 77, 0.1);
opacity: 0.9;
}
}
.email-error-message {
display: block;
font-family: Inter, sans-serif;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-18);
color: var(--destructive);
}
.invite-team-members-error-callout {
background: rgba(229, 72, 77, 0.1);
border: 1px solid rgba(229, 72, 77, 0.2);
border-radius: 4px;
animation: horizontal-shaking 300ms ease-out;
}
@keyframes horizontal-shaking {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}
.invite-members-modal__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 var(--padding-4);
height: 56px;
min-height: 56px;
border-top: 1px solid var(--secondary);
gap: 0;
flex-shrink: 0;
}
.invite-members-modal__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.add-another-member-button {
&:hover {
border-color: var(--primary);
border-style: dashed;
color: var(--l1-foreground);
}
}
.lightMode {
.invite-members-modal {
[data-slot='dialog-title'] {
color: var(--bg-base-black);
}
}
.team-member-role-select {
.ant-select-selector {
.ant-select-selection-item {
color: var(--bg-base-black);
}
}
}
}

View File

@@ -0,0 +1,349 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@signozhq/button';
import { Callout } from '@signozhq/callout';
import { Style } from '@signozhq/design-tokens';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { ChevronDown, CircleAlert, Plus, Trash2, X } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { Select } from 'antd';
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { cloneDeep, debounce } from 'lodash-es';
import APIError from 'types/api/error';
import { ROLES } from 'types/roles';
import { EMAIL_REGEX } from 'utils/app';
import { v4 as uuid } from 'uuid';
import './InviteMembersModal.styles.scss';
interface InviteRow {
id: string;
email: string;
role: ROLES | '';
}
export interface InviteMembersModalProps {
open: boolean;
onClose: () => void;
onComplete?: () => void;
}
const EMPTY_ROW = (): InviteRow => ({ id: uuid(), email: '', role: '' });
const isRowTouched = (row: InviteRow): boolean =>
row.email.trim() !== '' || Boolean(row.role && row.role.trim() !== '');
function InviteMembersModal({
open,
onClose,
onComplete,
}: InviteMembersModalProps): JSX.Element {
const [rows, setRows] = useState<InviteRow[]>(() => [
EMPTY_ROW(),
EMPTY_ROW(),
EMPTY_ROW(),
]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
const resetAndClose = useCallback((): void => {
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
setEmailValidity({});
setHasInvalidEmails(false);
setHasInvalidRoles(false);
onClose();
}, [onClose]);
useEffect(() => {
if (open) {
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
}
}, [open]);
const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for team members';
}
if (hasInvalidEmails) {
return 'Please enter valid emails for team members';
}
return 'Please select roles for team members';
};
const validateAllUsers = useCallback((): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedEmailValidity: Record<string, boolean> = {};
const touchedRows = rows.filter(isRowTouched);
touchedRows.forEach((row) => {
const emailValid = EMAIL_REGEX.test(row.email);
const roleValid = Boolean(row.role && row.role.trim() !== '');
if (!emailValid || !row.email) {
isValid = false;
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
if (row.id) {
updatedEmailValidity[row.id] = emailValid;
}
});
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
return isValid;
}, [rows]);
const debouncedValidateEmail = useMemo(
() =>
debounce((email: string, rowId: string) => {
const isValid = EMAIL_REGEX.test(email);
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
}, 500),
[],
);
useEffect(() => {
if (!open) {
debouncedValidateEmail.cancel();
}
return (): void => {
debouncedValidateEmail.cancel();
};
}, [open, debouncedValidateEmail]);
const updateEmail = (id: string, email: string): void => {
const updatedRows = cloneDeep(rows);
const rowToUpdate = updatedRows.find((r) => r.id === id);
if (rowToUpdate) {
rowToUpdate.email = email;
setRows(updatedRows);
if (hasInvalidEmails) {
setHasInvalidEmails(false);
}
if (emailValidity[id] === false) {
setEmailValidity((prev) => ({ ...prev, [id]: true }));
}
debouncedValidateEmail(email, id);
}
};
const updateRole = (id: string, role: ROLES): void => {
const updatedRows = cloneDeep(rows);
const rowToUpdate = updatedRows.find((r) => r.id === id);
if (rowToUpdate) {
rowToUpdate.role = role;
setRows(updatedRows);
if (hasInvalidRoles) {
setHasInvalidRoles(false);
}
}
};
const addRow = (): void => {
setRows((prev) => [...prev, EMPTY_ROW()]);
};
const removeRow = (id: string): void => {
setRows((prev) => prev.filter((r) => r.id !== id));
};
const handleSubmit = useCallback(async (): Promise<void> => {
if (!validateAllUsers()) {
return;
}
const touchedRows = rows.filter(isRowTouched);
if (touchedRows.length === 0) {
return;
}
setIsSubmitting(true);
try {
if (touchedRows.length === 1) {
const row = touchedRows[0];
await sendInvite({
email: row.email.trim(),
name: '',
role: row.role as ROLES,
frontendBaseUrl: window.location.origin,
});
} else {
await inviteUsers({
invites: touchedRows.map((row) => ({
email: row.email.trim(),
name: '',
role: row.role,
frontendBaseUrl: window.location.origin,
})),
});
}
toast.success('Invites sent successfully', { richColors: true });
resetAndClose();
onComplete?.();
} catch (err) {
const apiErr = err as APIError;
if (apiErr?.getHttpStatusCode() === 409) {
toast.error(
touchedRows.length === 1
? `${touchedRows[0].email} is already a member`
: 'Invite for one or more users already exists',
{ richColors: true },
);
} else {
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(`Failed to send invites: ${errorMessage}`, {
richColors: true,
});
}
} finally {
setIsSubmitting(false);
}
}, [rows, onComplete, resetAndClose, validateAllUsers]);
const touchedRows = rows.filter(isRowTouched);
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
return (
<DialogWrapper
title="Invite Team Members"
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
resetAndClose();
}
}}
showCloseButton
width="wide"
className="invite-members-modal"
disableOutsideClick={false}
>
<div className="invite-members-modal__content">
<div className="invite-members-modal__table">
<div className="invite-members-modal__table-header">
<div className="table-header-cell email-header">Email address</div>
<div className="table-header-cell role-header">Roles</div>
<div className="table-header-cell action-header" />
</div>
<div className="invite-members-modal__container">
{rows.map(
(row): JSX.Element => (
<div key={row.id} className="team-member-row">
<div className="team-member-cell email-cell">
<Input
type="email"
placeholder="john@signoz.io"
value={row.email}
onChange={(e): void => updateEmail(row.id, e.target.value)}
className="team-member-email-input"
/>
{emailValidity[row.id] === false && row.email.trim() !== '' && (
<span className="email-error-message">Invalid email address</span>
)}
</div>
<div className="team-member-cell role-cell">
<Select
value={row.role || undefined}
onChange={(role): void => updateRole(row.id, role as ROLES)}
className="team-member-role-select"
placeholder="Select roles"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.invite-members-modal') as HTMLElement) ||
document.body
}
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
</div>
<div className="team-member-cell action-cell">
{rows.length > 1 && (
<Button
variant="ghost"
color="destructive"
className="remove-team-member-button"
onClick={(): void => removeRow(row.id)}
aria-label="Remove row"
>
<Trash2 size={12} />
</Button>
)}
</div>
</div>
),
)}
</div>
</div>
{(hasInvalidEmails || hasInvalidRoles) && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className="invite-team-members-error-callout"
description={getValidationErrorMessage()}
/>
)}
</div>
<DialogFooter className="invite-members-modal__footer">
<Button
variant="dashed"
color="secondary"
size="sm"
className="add-another-member-button"
prefixIcon={<Plus size={12} color={Style.L1_FOREGROUND} />}
onClick={addRow}
>
Add another
</Button>
<div className="invite-members-modal__footer-right">
<Button
type="button"
variant="solid"
color="secondary"
size="sm"
onClick={resetAndClose}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
onClick={handleSubmit}
disabled={isSubmitDisabled}
>
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
</Button>
</div>
</DialogFooter>
</DialogWrapper>
);
}
export default InviteMembersModal;

View File

@@ -0,0 +1,177 @@
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembersModal from '../InviteMembersModal';
jest.mock('api/v1/invite/create');
jest.mock('api/v1/invite/bulk/create');
jest.mock('@signozhq/sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const mockSendInvite = jest.mocked(sendInvite);
const mockInviteUsers = jest.mocked(inviteUsers);
const defaultProps = {
open: true,
onClose: jest.fn(),
onComplete: jest.fn(),
};
describe('InviteMembersModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSendInvite.mockResolvedValue({
httpStatusCode: 200,
data: { data: 'test', status: 'success' },
});
mockInviteUsers.mockResolvedValue({ httpStatusCode: 200, data: null });
});
it('renders 3 initial empty rows and disables the submit button', () => {
render(<InviteMembersModal {...defaultProps} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
expect(emailInputs).toHaveLength(3);
expect(
screen.getByRole('button', { name: /invite team members/i }),
).toBeDisabled();
});
it('adds a row when "Add another" is clicked and removes a row via trash button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembersModal {...defaultProps} />);
await user.click(screen.getByRole('button', { name: /add another/i }));
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(4);
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(3);
});
describe('validation callout messages', () => {
it('shows combined message when email is invalid and role is missing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'not-an-email',
);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
expect(
await screen.findByText(
'Please enter valid emails and select roles for team members',
),
).toBeInTheDocument();
});
it('shows email-only message when email is invalid but role is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembersModal {...defaultProps} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'not-an-email');
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
expect(
await screen.findByText('Please enter valid emails for team members'),
).toBeInTheDocument();
});
it('shows role-only message when email is valid but role is missing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'valid@signoz.io',
);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
expect(
await screen.findByText('Please select roles for team members'),
).toBeInTheDocument();
});
});
it('uses sendInvite (single) when only one row is filled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onComplete = jest.fn();
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'single@signoz.io');
const roleSelects = screen.getAllByText('Select roles');
await user.click(roleSelects[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(mockSendInvite).toHaveBeenCalledWith(
expect.objectContaining({ email: 'single@signoz.io', role: 'VIEWER' }),
);
expect(mockInviteUsers).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
it('uses inviteUsers (bulk) when multiple rows are filled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onComplete = jest.fn();
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(mockInviteUsers).toHaveBeenCalledWith({
invites: expect.arrayContaining([
expect.objectContaining({ email: 'alice@signoz.io', role: 'VIEWER' }),
expect.objectContaining({ email: 'bob@signoz.io', role: 'EDITOR' }),
]),
});
expect(mockSendInvite).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,216 @@
.members-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 4px;
}
.members-table {
.ant-table {
background: transparent;
font-size: 13px;
}
.ant-table-container {
border-radius: 0 !important;
border: none !important;
}
.ant-table-thead {
> tr > th,
> tr > td {
background: var(--background);
font-size: var(--paragraph-small-600-font-size);
font-weight: var(--paragraph-small-600-font-weight);
line-height: var(--paragraph-small-600-line-height);
letter-spacing: 0.44px;
text-transform: uppercase;
color: var(--foreground);
padding: var(--padding-2) var(--padding-4);
border-bottom: none !important;
border-top: none !important;
&::before {
display: none !important;
}
.ant-table-column-sorters {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
width: auto;
}
.ant-table-column-title {
flex: unset;
}
.ant-table-column-sorter {
color: var(--foreground);
opacity: 0.6;
}
.ant-table-column-sorter-up.active,
.ant-table-column-sorter-down.active {
color: var(--bg-base-white);
opacity: 1;
}
}
}
.ant-table-tbody {
> tr > td {
border-bottom: none !important;
padding: var(--padding-2) var(--padding-4);
background: transparent;
transition: none;
}
> tr.members-table-row--tinted > td {
background: rgba(171, 189, 255, 0.02);
}
> tr:hover > td {
background: rgba(171, 189, 255, 0.04) !important;
}
}
.ant-table-wrapper,
.ant-table-container,
.ant-spin-nested-loading,
.ant-spin-container {
border: none !important;
box-shadow: none !important;
}
.member-status-cell {
[data-slot='badge'] {
padding: var(--padding-1) var(--padding-2);
align-items: center;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
line-height: 100%;
letter-spacing: 0.44px;
text-transform: uppercase;
}
}
}
.member-name-email-cell {
display: flex;
align-items: center;
gap: var(--spacing-2);
height: 22px;
overflow: hidden;
.member-name {
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
color: var(--foreground);
line-height: var(--paragraph-base-500-line-height);
letter-spacing: -0.07px;
white-space: nowrap;
flex-shrink: 0;
}
.member-email {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l3-foreground-hover);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.07px;
flex: 1 0 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.member-joined-date {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
white-space: nowrap;
}
.member-joined-dash {
font-size: var(--paragraph-base-400-font-size);
color: var(--l3-foreground-hover);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
}
.members-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--padding-12) var(--padding-4);
gap: var(--spacing-4);
color: var(--foreground);
&__emoji {
font-size: var(--font-size-2xl);
line-height: 1;
}
&__text {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
margin: 0;
line-height: var(--paragraph-base-400-font-height);
strong {
font-weight: var(--font-weight-medium);
color: var(--bg-base-white);
}
}
}
.members-table-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--padding-2) var(--padding-4);
.ant-pagination-total-text {
margin-right: auto;
}
.members-pagination-range {
font-size: var(--font-size-xs);
color: var(--foreground);
}
.members-pagination-total {
font-size: var(--font-size-xs);
color: var(--foreground);
opacity: 0.5;
}
}
.lightMode {
.members-table {
.ant-table-tbody {
> tr.members-table-row--tinted > td {
background: rgba(0, 0, 0, 0.015);
}
> tr:hover > td {
background: rgba(0, 0, 0, 0.03) !important;
}
}
}
.members-empty-state {
&__text {
strong {
color: var(--bg-base-black);
}
}
}
}

View File

@@ -0,0 +1,238 @@
import type React from 'react';
import { Badge } from '@signozhq/badge';
import { Pagination, Table, Tooltip } from 'antd';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { MemberStatus } from 'container/MembersSettings/utils';
import { capitalize } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import './MembersTable.styles.scss';
export interface MemberRow {
id: string;
name?: string;
email: string;
role: ROLES;
status: MemberStatus;
joinedOn: string | null;
updatedAt?: string | null;
token?: string | null;
}
interface MembersTableProps {
data: MemberRow[];
loading: boolean;
total: number;
currentPage: number;
pageSize: number;
searchQuery: string;
onPageChange: (page: number) => void;
onRowClick?: (member: MemberRow) => void;
onSortChange?: (
sorter: SorterResult<MemberRow> | SorterResult<MemberRow>[],
) => void;
}
function NameEmailCell({
name,
email,
}: {
name?: string;
email: string;
}): JSX.Element {
return (
<div className="member-name-email-cell">
{name && (
<span className="member-name" title={name}>
{name}
</span>
)}
<Tooltip title={email} overlayClassName="member-tooltip">
<span className="member-email">{email}</span>
</Tooltip>
</div>
);
}
function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element {
if (status === MemberStatus.Active) {
return (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
);
}
return (
<Badge color="amber" variant="outline">
INVITED
</Badge>
);
}
function MembersEmptyState({
searchQuery,
}: {
searchQuery: string;
}): JSX.Element {
return (
<div className="members-empty-state">
<span
className="members-empty-state__emoji"
role="img"
aria-label="monocle face"
>
🧐
</span>
{searchQuery ? (
<p className="members-empty-state__text">
No results for <strong>{searchQuery}</strong>
</p>
) : (
<p className="members-empty-state__text">No members found</p>
)}
</div>
);
}
function MembersTable({
data,
loading,
total,
currentPage,
pageSize,
searchQuery,
onPageChange,
onRowClick,
onSortChange,
}: MembersTableProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formatJoinedOn = (date: string | null): string => {
if (!date) {
return '—';
}
const d = new Date(date);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
};
const columns: ColumnsType<MemberRow> = [
{
title: 'Name / Email',
dataIndex: 'name',
key: 'name',
sorter: (a, b): number => a.email.localeCompare(b.email),
render: (_, record): JSX.Element => (
<NameEmailCell name={record.name} email={record.email} />
),
},
{
title: 'Roles',
dataIndex: 'role',
key: 'role',
width: 180,
sorter: (a, b): number => a.role.localeCompare(b.role),
render: (role: ROLES): JSX.Element => (
<Badge color="vanilla">{capitalize(role)}</Badge>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'right' as const,
className: 'member-status-cell',
sorter: (a, b): number => a.status.localeCompare(b.status),
render: (status: MemberRow['status']): JSX.Element => (
<StatusBadge status={status} />
),
},
{
title: 'Joined On',
dataIndex: 'joinedOn',
key: 'joinedOn',
width: 250,
align: 'right' as const,
sorter: (a, b): number => {
if (!a.joinedOn && !b.joinedOn) {
return 0;
}
if (!a.joinedOn) {
return 1;
}
if (!b.joinedOn) {
return -1;
}
return new Date(a.joinedOn).getTime() - new Date(b.joinedOn).getTime();
},
render: (joinedOn: string | null): JSX.Element => {
const formatted = formatJoinedOn(joinedOn);
const isDash = formatted === '—';
return (
<span className={isDash ? 'member-joined-dash' : 'member-joined-date'}>
{formatted}
</span>
);
},
},
];
const showPaginationTotal = (_total: number, range: number[]): JSX.Element => (
<>
<span className="members-pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="members-pagination-total"> of {_total}</span>
</>
);
return (
<div className="members-table-wrapper">
<Table<MemberRow>
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={false}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'members-table-row--tinted' : ''
}
onRow={(record): React.HTMLAttributes<HTMLElement> => ({
onClick: (): void => onRowClick?.(record),
style: onRowClick ? { cursor: 'pointer' } : undefined,
})}
onChange={(_, __, sorter): void => {
if (onSortChange) {
onSortChange(
sorter as SorterResult<MemberRow> | SorterResult<MemberRow>[],
);
}
}}
showSorterTooltip={false}
locale={{
emptyText: <MembersEmptyState searchQuery={searchQuery} />,
}}
className="members-table"
/>
{total > pageSize && (
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
showTotal={showPaginationTotal}
showSizeChanger={false}
onChange={onPageChange}
className="members-table-pagination"
/>
)}
</div>
);
}
export default MembersTable;

View File

@@ -0,0 +1,143 @@
import { MemberStatus } from 'container/MembersSettings/utils';
import { render, screen, userEvent } from 'tests/test-utils';
import { ROLES } from 'types/roles';
import MembersTable, { MemberRow } from '../MembersTable';
const mockActiveMembers: MemberRow[] = [
{
id: 'user-1',
name: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN' as ROLES,
status: MemberStatus.Active,
joinedOn: '1700000000000',
},
{
id: 'user-2',
name: 'Bob Jones',
email: 'bob@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Active,
joinedOn: null,
},
];
const mockInvitedMember: MemberRow = {
id: 'invite-abc',
name: '',
email: 'charlie@signoz.io',
role: 'EDITOR' as ROLES,
status: MemberStatus.Invited,
joinedOn: null,
token: 'tok-123',
};
const defaultProps = {
loading: false,
total: 2,
currentPage: 1,
pageSize: 20,
searchQuery: '',
onPageChange: jest.fn(),
onRowClick: jest.fn(),
};
describe('MembersTable', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders member rows with name, email, role badge, and ACTIVE status', () => {
render(<MembersTable {...defaultProps} data={mockActiveMembers} />);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
expect(screen.getByText('alice@signoz.io')).toBeInTheDocument();
expect(screen.getByText('Admin')).toBeInTheDocument();
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
});
it('renders INVITED badge for pending invite members', () => {
render(
<MembersTable
{...defaultProps}
data={[...mockActiveMembers, mockInvitedMember]}
total={3}
/>,
);
expect(screen.getByText('INVITED')).toBeInTheDocument();
expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument();
expect(screen.getByText('Editor')).toBeInTheDocument();
});
it('calls onRowClick with the member data when a row is clicked', async () => {
const onRowClick = jest.fn() as jest.MockedFunction<
(member: MemberRow) => void
>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<MembersTable
{...defaultProps}
data={mockActiveMembers}
onRowClick={onRowClick}
/>,
);
await user.click(screen.getByText('Alice Smith'));
expect(onRowClick).toHaveBeenCalledTimes(1);
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user-1', email: 'alice@signoz.io' }),
);
});
it('shows "No members found" empty state when no data and no search query', () => {
render(<MembersTable {...defaultProps} data={[]} total={0} searchQuery="" />);
expect(screen.getByText('No members found')).toBeInTheDocument();
});
it('shows "No results for X" when no data and a search query is set', () => {
render(
<MembersTable {...defaultProps} data={[]} total={0} searchQuery="unknown" />,
);
expect(screen.getByText(/No results for/i)).toBeInTheDocument();
expect(screen.getByText('unknown')).toBeInTheDocument();
});
it('hides pagination when total does not exceed pageSize', () => {
const { container } = render(
<MembersTable
{...defaultProps}
data={mockActiveMembers}
total={2}
pageSize={20}
/>,
);
expect(
container.querySelector('.members-table-pagination'),
).not.toBeInTheDocument();
});
it('shows pagination when total exceeds pageSize', () => {
const { container } = render(
<MembersTable
{...defaultProps}
data={mockActiveMembers}
total={25}
pageSize={20}
/>,
);
expect(
container.querySelector('.members-table-pagination'),
).toBeInTheDocument();
expect(
container.querySelector('.members-pagination-total'),
).toBeInTheDocument();
});
});

View File

@@ -14,8 +14,6 @@ import cx from 'classnames';
import { dragColumnParams } from 'hooks/useDragColumns/configs';
import { getColumnWidth, RowData } from 'lib/query/createTableColumnsFromQuery';
import { debounce, set } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { Widgets } from 'types/api/dashboard/getAll';
import ResizableHeader from './ResizableHeader';
import { DragSpanStyle } from './styles';
@@ -26,31 +24,26 @@ function ResizeTable({
columns,
onDragColumn,
pagination,
widgetId,
shouldPersistColumnWidths = false,
columnWidths,
onColumnWidthsChange,
...restProps
}: ResizeTableProps): JSX.Element {
const [columnsData, setColumns] = useState<ColumnsType>([]);
const { setColumnWidths, selectedDashboard } = useDashboard();
const columnWidths = shouldPersistColumnWidths
? (selectedDashboard?.data?.widgets?.find(
(widget) => widget.id === widgetId,
) as Widgets)?.columnWidths
: undefined;
const onColumnWidthsChangeRef = useRef(onColumnWidthsChange);
const updateAllColumnWidths = useRef(
debounce((widthsConfig: Record<string, number>) => {
if (!widgetId || !shouldPersistColumnWidths) {
if (!onColumnWidthsChangeRef.current) {
return;
}
setColumnWidths?.((prev) => ({
...prev,
[widgetId]: widthsConfig,
}));
onColumnWidthsChangeRef.current(widthsConfig);
}, 1000),
).current;
useEffect(() => {
onColumnWidthsChangeRef.current = onColumnWidthsChange;
}, [onColumnWidthsChange]);
const handleResize = useCallback(
(index: number) => (
e: SyntheticEvent<Element>,
@@ -75,7 +68,7 @@ function ResizeTable({
...col,
...(onDragColumn && {
title: (
<DragSpanStyle className="dragHandler">
<DragSpanStyle className="dragHandler" data-testid="drag-column-title">
{col?.title?.toString() || ''}
</DragSpanStyle>
),
@@ -106,31 +99,31 @@ function ResizeTable({
}, [mergedColumns, pagination, restProps]);
useEffect(() => {
if (columns) {
// Apply stored column widths from widget configuration
const columnsWithStoredWidths = columns.map((col) => {
const dataIndex = (col as RowData).dataIndex as string;
if (dataIndex && columnWidths) {
const width = getColumnWidth(dataIndex, columnWidths);
if (width) {
return {
...col,
width, // Apply stored width
};
}
}
return col;
});
setColumns(columnsWithStoredWidths);
}
}, [columns, columnWidths]);
useEffect(() => {
if (!shouldPersistColumnWidths) {
if (!columns) {
return;
}
// Collect all column widths in a single object
const columnsWithStoredWidths = columns.map((col) => {
const dataIndex = (col as RowData).dataIndex as string;
if (dataIndex && columnWidths) {
const width = getColumnWidth(dataIndex, columnWidths);
if (width) {
return { ...col, width };
}
}
return col;
});
setColumns(columnsWithStoredWidths);
}, [columns, columnWidths]);
const lastReportedWidthsRef = useRef<Record<string, number>>({});
useEffect(() => {
if (!onColumnWidthsChange) {
return;
}
const newColumnWidths: Record<string, number> = {};
mergedColumns.forEach((col) => {
@@ -140,11 +133,20 @@ function ResizeTable({
}
});
// Only update if there are actual widths to set
if (Object.keys(newColumnWidths).length > 0) {
if (Object.keys(newColumnWidths).length === 0) {
return;
}
const last = lastReportedWidthsRef.current;
const hasChange =
Object.keys(newColumnWidths).length !== Object.keys(last).length ||
Object.keys(newColumnWidths).some((k) => newColumnWidths[k] !== last[k]);
if (hasChange) {
lastReportedWidthsRef.current = newColumnWidths;
updateAllColumnWidths(newColumnWidths);
}
}, [mergedColumns, updateAllColumnWidths, shouldPersistColumnWidths]);
}, [mergedColumns, updateAllColumnWidths, onColumnWidthsChange]);
return onDragColumn ? (
<ReactDragListView.DragColumn {...dragColumnParams} onDragEnd={onDragColumn}>

View File

@@ -0,0 +1,244 @@
import { act } from '@testing-library/react';
import { render, screen, userEvent } from 'tests/test-utils';
import ResizeTable from '../ResizeTable';
jest.mock('react-resizable', () => ({
Resizable: ({
children,
onResize,
width,
}: {
children: React.ReactNode;
onResize: (
e: React.SyntheticEvent,
data: { size: { width: number } },
) => void;
width: number;
}): JSX.Element => (
<div>
{children}
<button
data-testid="resize-trigger"
type="button"
onClick={(e): void => onResize(e, { size: { width: width + 50 } })}
/>
</div>
),
}));
// Make debounce synchronous so onColumnWidthsChange fires immediately
jest.mock('lodash-es', () => ({
...jest.requireActual('lodash-es'),
debounce: (fn: (...args: any[]) => any): ((...args: any[]) => any) => fn,
}));
const baseColumns = [
{ dataIndex: 'name', title: 'Name', width: 100 },
{ dataIndex: 'value', title: 'Value', width: 100 },
];
const baseDataSource = [
{ key: '1', name: 'Alice', value: 42 },
{ key: '2', name: 'Bob', value: 99 },
];
describe('ResizeTable', () => {
it('renders column headers and data rows', () => {
render(
<ResizeTable
columns={baseColumns}
dataSource={baseDataSource}
rowKey="key"
/>,
);
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Value')).toBeInTheDocument();
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
it('overrides column widths from columnWidths prop and reports them via onColumnWidthsChange', () => {
const onColumnWidthsChange = jest.fn();
act(() => {
render(
<ResizeTable
columns={baseColumns}
dataSource={baseDataSource}
rowKey="key"
columnWidths={{ name: 250, value: 180 }}
onColumnWidthsChange={onColumnWidthsChange}
/>,
);
});
expect(onColumnWidthsChange).toHaveBeenCalledWith(
expect.objectContaining({ name: 250, value: 180 }),
);
});
it('reports original column widths via onColumnWidthsChange when columnWidths prop is not provided', () => {
const onColumnWidthsChange = jest.fn();
act(() => {
render(
<ResizeTable
columns={baseColumns}
dataSource={baseDataSource}
rowKey="key"
onColumnWidthsChange={onColumnWidthsChange}
/>,
);
});
expect(onColumnWidthsChange).toHaveBeenCalledWith(
expect.objectContaining({ name: 100, value: 100 }),
);
});
it('does not call onColumnWidthsChange when it is not provided', () => {
// Should render without errors and without attempting to call an undefined callback
expect(() => {
render(
<ResizeTable
columns={baseColumns}
dataSource={baseDataSource}
rowKey="key"
/>,
);
}).not.toThrow();
});
it('only overrides the column that has a stored width, leaving others at their original width', () => {
const onColumnWidthsChange = jest.fn();
act(() => {
render(
<ResizeTable
columns={baseColumns}
dataSource={baseDataSource}
rowKey="key"
columnWidths={{ name: 250 }}
onColumnWidthsChange={onColumnWidthsChange}
/>,
);
});
expect(onColumnWidthsChange).toHaveBeenCalledWith(
expect.objectContaining({ name: 250, value: 100 }),
);
});
it('does not call onColumnWidthsChange on re-render when widths have not changed', () => {
const onColumnWidthsChange = jest.fn();
const { rerender } = render(
<ResizeTable
columns={baseColumns}
dataSource={baseDataSource}
rowKey="key"
onColumnWidthsChange={onColumnWidthsChange}
/>,
);
expect(onColumnWidthsChange).toHaveBeenCalledTimes(1);
onColumnWidthsChange.mockClear();
rerender(
<ResizeTable
columns={baseColumns}
dataSource={baseDataSource}
rowKey="key"
onColumnWidthsChange={onColumnWidthsChange}
/>,
);
expect(onColumnWidthsChange).not.toHaveBeenCalled();
});
it('does not call onColumnWidthsChange when no column has a defined width', () => {
const onColumnWidthsChange = jest.fn();
render(
<ResizeTable
columns={[
{ dataIndex: 'name', title: 'Name' },
{ dataIndex: 'value', title: 'Value' },
]}
dataSource={baseDataSource}
rowKey="key"
onColumnWidthsChange={onColumnWidthsChange}
/>,
);
expect(onColumnWidthsChange).not.toHaveBeenCalled();
});
it('calls onColumnWidthsChange with the new width after a column is resized', async () => {
const user = userEvent.setup();
const onColumnWidthsChange = jest.fn();
render(
<ResizeTable
columns={baseColumns}
dataSource={baseDataSource}
rowKey="key"
onColumnWidthsChange={onColumnWidthsChange}
/>,
);
onColumnWidthsChange.mockClear();
// Click the first column's resize trigger — mock adds 50px to the current width (100 → 150)
const [firstResizeTrigger] = screen.getAllByTestId('resize-trigger');
await user.click(firstResizeTrigger);
expect(onColumnWidthsChange).toHaveBeenCalledWith(
expect.objectContaining({ name: 150, value: 100 }),
);
});
it('does not affect other columns when only one column is resized', async () => {
const user = userEvent.setup();
const onColumnWidthsChange = jest.fn();
render(
<ResizeTable
columns={baseColumns}
dataSource={baseDataSource}
rowKey="key"
onColumnWidthsChange={onColumnWidthsChange}
/>,
);
onColumnWidthsChange.mockClear();
// Resize only the second column (value: 100 → 150), name should stay at 100
const resizeTriggers = screen.getAllByTestId('resize-trigger');
await user.click(resizeTriggers[1]);
expect(onColumnWidthsChange).toHaveBeenCalledWith(
expect.objectContaining({ name: 100, value: 150 }),
);
});
it('wraps column titles in drag handler spans when onDragColumn is provided', () => {
const onDragColumn = jest.fn();
render(
<ResizeTable
columns={baseColumns}
dataSource={baseDataSource}
rowKey="key"
onDragColumn={onDragColumn}
/>,
);
const dragTitles = screen.getAllByTestId('drag-column-title');
expect(dragTitles).toHaveLength(baseColumns.length);
expect(dragTitles[0]).toHaveTextContent('Name');
expect(dragTitles[1]).toHaveTextContent('Value');
});
});

View File

@@ -6,10 +6,25 @@ import { LaunchChatSupportProps } from 'components/LaunchChatSupport/LaunchChatS
import { TableDataSource } from './contants';
type ColumnWidths = Record<string, number>;
export interface ResizeTableProps extends TableProps<any> {
onDragColumn?: (fromIndex: number, toIndex: number) => void;
widgetId?: string;
shouldPersistColumnWidths?: boolean;
/**
* Pre-resolved column widths for this table, keyed by column dataIndex.
* Use this to apply persisted widths on mount (e.g. from widget.columnWidths).
* Do NOT pass a value that updates reactively on every resize — that creates a
* feedback loop. Pass only stable / persisted values.
*/
columnWidths?: ColumnWidths;
/**
* Called (debounced) whenever the user finishes resizing a column.
* The widths object contains all current column widths keyed by dataIndex.
* Intended for persisting widths to an external store (e.g. dashboard context
* staging buffer). The caller owns the storage; ResizeTable does not read back
* whatever is written here.
*/
onColumnWidthsChange?: (widths: ColumnWidths) => void;
}
export interface DynamicColumnTableProps extends TableProps<any> {
tablesource: typeof TableDataSource[keyof typeof TableDataSource];

View File

@@ -0,0 +1,185 @@
.add-key-modal {
&__form {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
// Expiry segmented toggle using ToggleGroup
&__expiry-toggle {
display: flex;
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
padding: 0;
gap: 0;
&-btn {
flex: 1;
height: 32px;
border-radius: 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
justify-content: center;
background: transparent;
border: none;
border-right: 1px solid var(--border);
color: var(--foreground);
&:last-child {
border-right: none;
}
&[data-state='on'] {
background: var(--l2-background);
color: var(--l1-foreground);
}
}
}
&__datepicker {
width: 100%;
height: 32px;
.ant-picker {
background: var(--l2-background);
border-color: var(--border);
border-radius: 2px;
width: 100%;
height: 32px;
input {
color: var(--l1-foreground);
font-size: var(--font-size-sm);
}
.ant-picker-suffix {
color: var(--foreground);
}
&:hover {
border-color: var(--foreground);
}
}
}
&__key-display {
display: flex;
align-items: center;
height: 32px;
background: var(--l2-background);
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
}
&__key-text {
flex: 1;
min-width: 0;
padding: 0 var(--padding-2);
font-size: var(--font-size-sm);
color: var(--l1-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: monospace;
}
&__copy-btn {
flex-shrink: 0;
height: 32px;
border-radius: 0 2px 2px 0;
border-top: none;
border-right: none;
border-bottom: none;
border-left: 1px solid var(--border);
min-width: 40px;
}
&__callout {
display: flex;
align-items: flex-start;
gap: var(--spacing-3);
background: rgba(78, 116, 248, 0.1);
border: 1px solid rgba(78, 116, 248, 0.2);
border-radius: 4px;
padding: var(--padding-3) var(--padding-4);
font-size: var(--font-size-sm);
color: var(--foreground);
line-height: 1.5;
}
&__callout-icon {
color: var(--primary);
flex-shrink: 0;
margin-top: 2px;
}
&__expiry-meta {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__expiry-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--padding-4);
border-top: 1px solid var(--border);
margin-top: var(--margin-2);
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__learn-more {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
color: var(--primary);
font-size: var(--font-size-sm);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -0,0 +1,271 @@
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogWrapper } from '@signozhq/dialog';
import { ArrowUpRight, Check, Copy, Info } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { DatePicker } from 'antd';
import { useCreateServiceAccountKey } from 'api/generated/services/serviceaccount';
import type { ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { format } from 'date-fns';
import type { Dayjs } from 'dayjs';
import './AddKeyModal.styles.scss';
interface AddKeyModalProps {
open: boolean;
accountId: string;
onClose: () => void;
onSuccess: () => void;
}
type Phase = 'form' | 'created';
type ExpiryMode = 'none' | 'date';
function AddKeyModal({
open,
accountId,
onClose,
onSuccess,
}: AddKeyModalProps): JSX.Element {
const [phase, setPhase] = useState<Phase>('form');
const [keyName, setKeyName] = useState('');
const [expiryMode, setExpiryMode] = useState<ExpiryMode>('none');
const [expiryDate, setExpiryDate] = useState<Dayjs | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [
createdKey,
setCreatedKey,
] = useState<ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO | null>(null);
const [hasCopied, setHasCopied] = useState(false);
useEffect(() => {
if (open) {
setPhase('form');
setKeyName('');
setExpiryMode('none');
setExpiryDate(null);
setIsSubmitting(false);
setCreatedKey(null);
setHasCopied(false);
}
}, [open]);
const { mutateAsync: createKey } = useCreateServiceAccountKey();
const handleCreate = useCallback(async (): Promise<void> => {
if (!keyName.trim()) {
return;
}
setIsSubmitting(true);
try {
const expiresAt =
expiryMode === 'date' && expiryDate ? expiryDate.unix() : 0;
const response = await createKey({
pathParams: { id: accountId },
data: { name: keyName.trim(), expires_at: expiresAt },
});
const keyData = response?.data;
if (keyData) {
setCreatedKey(keyData);
setPhase('created');
}
} catch {
toast.error('Failed to create key', { richColors: true });
} finally {
setIsSubmitting(false);
}
}, [keyName, expiryMode, expiryDate, accountId, createKey]);
const handleCopy = useCallback(async (): Promise<void> => {
if (!createdKey?.key) {
return;
}
try {
await navigator.clipboard.writeText(createdKey.key);
setHasCopied(true);
setTimeout(() => setHasCopied(false), 2000);
toast.success('Key copied to clipboard', { richColors: true });
} catch {
toast.error('Failed to copy key', { richColors: true });
}
}, [createdKey]);
const handleClose = useCallback((): void => {
if (phase === 'created') {
onSuccess();
}
onClose();
}, [phase, onSuccess, onClose]);
const expiryLabel = (): string => {
if (expiryMode === 'none' || !expiryDate) {
return 'Never';
}
try {
return format(expiryDate.toDate(), 'MMM d, yyyy');
} catch {
return 'Never';
}
};
const title = phase === 'form' ? 'Add a New Key' : 'Key Created Successfully';
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
title={title}
width="base"
className="add-key-modal"
showCloseButton
disableOutsideClick={false}
>
{phase === 'form' && (
<div className="add-key-modal__form">
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="key-name">
Name <span style={{ color: 'var(--destructive)' }}>*</span>
</label>
<Input
id="key-name"
value={keyName}
onChange={(e): void => setKeyName(e.target.value)}
placeholder="Enter key name e.g.: Service Owner"
className="add-key-modal__input"
/>
</div>
<div className="add-key-modal__field">
<label className="add-key-modal__label">Expiration</label>
<ToggleGroup
type="single"
value={expiryMode}
onValueChange={(val): void => {
if (val) {
setExpiryMode(val as ExpiryMode);
if (val === 'none') {
setExpiryDate(null);
}
}
}}
className="add-key-modal__expiry-toggle"
>
<ToggleGroupItem
value="none"
className="add-key-modal__expiry-toggle-btn"
>
No Expiration
</ToggleGroupItem>
<ToggleGroupItem
value="date"
className="add-key-modal__expiry-toggle-btn"
>
Set Expiration Date
</ToggleGroupItem>
</ToggleGroup>
</div>
{expiryMode === 'date' && (
<div className="add-key-modal__field">
<label className="add-key-modal__label">Expiration Date</label>
<div className="add-key-modal__datepicker">
<DatePicker
value={expiryDate}
onChange={(date): void => setExpiryDate(date)}
style={{ width: '100%', height: 32 }}
popupClassName="add-key-modal__datepicker-popup"
/>
</div>
</div>
)}
<div className="add-key-modal__footer">
<a
href="https://signoz.io/docs/service-accounts"
target="_blank"
rel="noopener noreferrer"
className="add-key-modal__learn-more"
>
Learn more about Service Account Keys
<ArrowUpRight size={12} />
</a>
<div className="add-key-modal__footer-right">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={handleClose}
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!keyName.trim() || isSubmitting}
onClick={handleCreate}
>
{isSubmitting ? 'Creating...' : 'Create Key'}
</Button>
</div>
</div>
</div>
)}
{phase === 'created' && createdKey && (
<div className="add-key-modal__form">
<div className="add-key-modal__field">
<label className="add-key-modal__label">API Key</label>
<div className="add-key-modal__key-display">
<span className="add-key-modal__key-text">{createdKey.key}</span>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={handleCopy}
className="add-key-modal__copy-btn"
>
{hasCopied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</div>
</div>
<div className="add-key-modal__expiry-meta">
<span className="add-key-modal__expiry-label">Expiration</span>
<Badge color="vanilla">{expiryLabel()}</Badge>
</div>
<div className="add-key-modal__callout">
<Info size={12} className="add-key-modal__callout-icon" />
<span>
Store the key securely. This is the only time it will be displayed.
</span>
</div>
<div className="add-key-modal__footer">
<span />
<div className="add-key-modal__footer-right">
<Button
variant="solid"
color="primary"
size="sm"
onClick={handleClose}
>
Done
</Button>
</div>
</div>
</div>
)}
</DialogWrapper>
);
}
export default AddKeyModal;

View File

@@ -0,0 +1,192 @@
.edit-key-modal {
.ant-modal-content {
background: var(--popover);
border: 1px solid var(--secondary);
border-radius: 4px;
padding: 0;
}
.ant-modal-header {
background: transparent;
border-bottom: 1px solid var(--secondary);
padding: var(--padding-4) var(--padding-5);
margin: 0;
}
.ant-modal-title {
font-size: var(--label-large-500-font-size);
font-weight: var(--label-large-500-font-weight);
color: var(--l1-foreground);
}
.ant-modal-close {
color: var(--foreground);
&:hover {
color: var(--l1-foreground);
}
}
.ant-modal-body {
padding: var(--padding-5);
}
.ant-modal-footer {
display: none;
}
&__form {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__key-display {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
cursor: not-allowed;
opacity: 0.8;
}
&__key-text {
font-size: var(--font-size-sm);
font-family: monospace;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&__lock-icon {
color: var(--foreground);
flex-shrink: 0;
margin-left: 6px;
opacity: 0.6;
}
&__expiry-toggle {
display: flex;
gap: var(--spacing-2);
&-btn {
flex: 1;
height: 32px;
border-radius: 2px;
font-size: var(--font-size-sm);
&--active {
background: var(--l2-background);
border: 1px solid var(--border);
}
}
}
&__datepicker {
.ant-picker {
background: var(--l2-background);
border-color: var(--border);
border-radius: 2px;
width: 100%;
height: 32px;
input {
color: var(--l1-foreground);
font-size: var(--font-size-sm);
}
.ant-picker-suffix {
color: var(--foreground);
}
&:hover {
border-color: var(--foreground);
}
}
}
&__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__meta-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
border-top: 1px solid var(--secondary);
margin-top: var(--margin-6);
padding-top: var(--padding-4);
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__footer-danger {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: 0;
background: transparent;
border: none;
cursor: pointer;
color: var(--destructive);
font-size: var(--label-small-400-font-size);
font-weight: var(--label-small-400-font-weight);
transition: opacity 0.15s ease;
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}

View File

@@ -0,0 +1,296 @@
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { LockKeyhole, Trash2, X } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { DatePicker, Modal } from 'antd';
import {
useRevokeServiceAccountKey,
useUpdateServiceAccountKey,
} from 'api/generated/services/serviceaccount';
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { format } from 'date-fns';
import dayjs, { type Dayjs } from 'dayjs';
import { useTimezone } from 'providers/Timezone';
import './EditKeyModal.styles.scss';
interface EditKeyModalProps {
open: boolean;
accountId: string;
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
onClose: () => void;
onSuccess: () => void;
}
type ExpiryMode = 'none' | 'date';
function EditKeyModal({
open,
accountId,
keyItem,
onClose,
onSuccess,
}: EditKeyModalProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [localName, setLocalName] = useState('');
const [expiryMode, setExpiryMode] = useState<ExpiryMode>('none');
const [localDate, setLocalDate] = useState<Dayjs | null>(null);
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isRevoking, setIsRevoking] = useState(false);
useEffect(() => {
if (keyItem) {
setLocalName(keyItem.name ?? '');
if (keyItem.expires_at === 0) {
setExpiryMode('none');
setLocalDate(null);
} else {
setExpiryMode('date');
setLocalDate(dayjs.unix(keyItem.expires_at));
}
}
}, [keyItem]);
const originalExpiresAt = keyItem?.expires_at ?? 0;
const currentExpiresAt =
expiryMode === 'none' || !localDate ? 0 : localDate.unix();
const isDirty =
keyItem !== null &&
(localName !== (keyItem.name ?? '') ||
currentExpiresAt !== originalExpiresAt);
const { mutateAsync: updateKey } = useUpdateServiceAccountKey();
const { mutateAsync: revokeKey } = useRevokeServiceAccountKey();
const handleSave = useCallback(async (): Promise<void> => {
if (!keyItem || !isDirty) {return;}
setIsSaving(true);
try {
await updateKey({
pathParams: { id: accountId, fid: keyItem.id },
data: { name: localName, expires_at: currentExpiresAt },
});
toast.success('Key updated successfully', { richColors: true });
onSuccess();
} catch {
toast.error('Failed to update key', { richColors: true });
} finally {
setIsSaving(false);
}
}, [keyItem, isDirty, localName, currentExpiresAt, accountId, updateKey, onSuccess]);
const handleRevoke = useCallback(async (): Promise<void> => {
if (!keyItem) {return;}
setIsRevoking(true);
try {
await revokeKey({
pathParams: { id: accountId, fid: keyItem.id },
});
toast.success('Key revoked successfully', { richColors: true });
setIsRevokeConfirmOpen(false);
onSuccess();
} catch {
toast.error('Failed to revoke key', { richColors: true });
} finally {
setIsRevoking(false);
}
}, [keyItem, accountId, revokeKey, onSuccess]);
const formatLastUsed = useCallback(
(lastUsed: Date | null | undefined): string => {
if (!lastUsed) {return '—';}
try {
return formatTimezoneAdjustedTimestamp(
String(lastUsed),
DATE_TIME_FORMATS.DASH_DATETIME,
);
} catch {
return '—';
}
},
[formatTimezoneAdjustedTimestamp],
);
const expiryDisplayLabel = (): string => {
if (expiryMode === 'none' || !localDate) {return 'Never';}
try {
return format(localDate.toDate(), 'MMM d, yyyy');
} catch {
return 'Never';
}
};
return (
<>
<Modal
open={open}
onCancel={onClose}
title="Edit Key"
width={530}
footer={null}
className="edit-key-modal"
destroyOnClose
>
<div className="edit-key-modal__form">
{/* Name */}
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-name">
Name
</label>
<Input
id="edit-key-name"
value={localName}
onChange={(e): void => setLocalName(e.target.value)}
className="edit-key-modal__input"
placeholder="Enter key name"
/>
</div>
{/* Key (read-only masked) */}
<div className="edit-key-modal__field">
<label className="edit-key-modal__label">Key</label>
<div className="edit-key-modal__key-display">
<span className="edit-key-modal__key-text">
{keyItem?.key ?? '—'}
</span>
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
</div>
</div>
{/* Expiration toggle */}
<div className="edit-key-modal__field">
<label className="edit-key-modal__label">Expiration</label>
<div className="edit-key-modal__expiry-toggle">
<Button
variant={expiryMode === 'none' ? 'solid' : 'ghost'}
color="secondary"
size="sm"
className={`edit-key-modal__expiry-toggle-btn${expiryMode === 'none' ? ' edit-key-modal__expiry-toggle-btn--active' : ''}`}
onClick={(): void => {
setExpiryMode('none');
setLocalDate(null);
}}
>
No Expiration
</Button>
<Button
variant={expiryMode === 'date' ? 'solid' : 'ghost'}
color="secondary"
size="sm"
className={`edit-key-modal__expiry-toggle-btn${expiryMode === 'date' ? ' edit-key-modal__expiry-toggle-btn--active' : ''}`}
onClick={(): void => setExpiryMode('date')}
>
Set Expiration Date
</Button>
</div>
</div>
{expiryMode === 'date' && (
<div className="edit-key-modal__field">
<label className="edit-key-modal__label">Expiration Date</label>
<div className="edit-key-modal__datepicker">
<DatePicker
value={localDate}
onChange={(date): void => setLocalDate(date)}
style={{ width: '100%', height: 32 }}
/>
</div>
</div>
)}
{/* Expiry meta */}
<div className="edit-key-modal__meta">
<span className="edit-key-modal__meta-label">Expiry</span>
<Badge color="vanilla">{expiryDisplayLabel()}</Badge>
</div>
{/* Last used meta */}
<div className="edit-key-modal__meta">
<span className="edit-key-modal__meta-label">Last Used</span>
<Badge color="vanilla">
{formatLastUsed(keyItem?.last_used ?? null)}
</Badge>
</div>
{/* Footer */}
<div className="edit-key-modal__footer">
<button
type="button"
className="edit-key-modal__footer-danger"
onClick={(): void => setIsRevokeConfirmOpen(true)}
>
<Trash2 size={12} />
Revoke Key
</button>
<div className="edit-key-modal__footer-right">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={onClose}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</div>
</Modal>
{/* Revoke confirm dialog */}
<DialogWrapper
open={isRevokeConfirmOpen}
onOpenChange={(isOpen): void => {
if (!isOpen) {setIsRevokeConfirmOpen(false);}
}}
title={`Revoke ${keyItem?.name ?? 'key'}?`}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">
Revoking this key will permanently invalidate it. Any systems using this
key will lose access immediately.
</p>
<DialogFooter className="delete-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setIsRevokeConfirmOpen(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isRevoking}
onClick={handleRevoke}
>
<Trash2 size={12} />
{isRevoking ? 'Revoking...' : 'Revoke Key'}
</Button>
</DialogFooter>
</DialogWrapper>
</>
);
}
export default EditKeyModal;

View File

@@ -0,0 +1,241 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { Trash2, X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { Tooltip } from 'antd';
import {
useListServiceAccountKeys,
useRevokeServiceAccountKey,
} from 'api/generated/services/serviceaccount';
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { format } from 'date-fns';
import { useTimezone } from 'providers/Timezone';
import EditKeyModal from './EditKeyModal';
interface KeysTabProps {
accountId: string;
onKeyCountChange: (n: number) => void;
onAddKeyClick: () => void;
}
function formatExpiry(expiresAt: number): JSX.Element {
if (expiresAt === 0) {
return <span className="keys-tab__expiry--never">Never</span>;
}
const expiryDate = new Date(expiresAt * 1000);
if (expiryDate < new Date()) {
return (
<span className="keys-tab__expiry--expired">
{format(expiryDate, 'MMM d, yyyy')}
</span>
);
}
return <span>{format(expiryDate, 'MMM d, yyyy')}</span>;
}
function KeysTab({
accountId,
onKeyCountChange,
onAddKeyClick,
}: KeysTabProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [
editKey,
setEditKey,
] = useState<ServiceaccounttypesFactorAPIKeyDTO | null>(null);
const [
revokeTarget,
setRevokeTarget,
] = useState<ServiceaccounttypesFactorAPIKeyDTO | null>(null);
const [isRevoking, setIsRevoking] = useState(false);
const { data: keysData, refetch } = useListServiceAccountKeys({
id: accountId,
});
const keys = keysData?.data ?? [];
useEffect(() => {
onKeyCountChange(keys.length);
}, [keys.length, onKeyCountChange]);
const { mutateAsync: revokeKey } = useRevokeServiceAccountKey();
const handleRevoke = useCallback(async (): Promise<void> => {
if (!revokeTarget) {
return;
}
setIsRevoking(true);
try {
await revokeKey({
pathParams: { id: accountId, fid: revokeTarget.id },
});
toast.success('Key revoked successfully', { richColors: true });
setRevokeTarget(null);
refetch();
} catch {
toast.error('Failed to revoke key', { richColors: true });
} finally {
setIsRevoking(false);
}
}, [revokeTarget, revokeKey, accountId, refetch]);
const handleKeySuccess = useCallback((): void => {
setEditKey(null);
refetch();
}, [refetch]);
const formatLastUsed = useCallback(
(lastUsed: Date | null | undefined): string => {
if (!lastUsed) {
return '—';
}
try {
return formatTimezoneAdjustedTimestamp(
String(lastUsed),
DATE_TIME_FORMATS.DASH_DATETIME,
);
} catch {
return '—';
}
},
[formatTimezoneAdjustedTimestamp],
);
if (keys.length === 0) {
return (
<div className="keys-tab__empty">
<span className="keys-tab__empty-emoji" role="img" aria-label="searching">
🧐
</span>
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
<button
type="button"
className="keys-tab__learn-more"
onClick={onAddKeyClick}
>
+ Add your first key
</button>
</div>
);
}
return (
<>
<div className="keys-tab__table-wrap">
{/* Header row */}
<div className="keys-tab__table-header">
<span className="keys-tab__col-name">Name</span>
<span className="keys-tab__col-expiry">Expiry</span>
<span className="keys-tab__col-last-used">Last Used</span>
<span className="keys-tab__col-action" />
</div>
{/* Data rows */}
{keys.map((keyItem, idx) => (
<div
key={keyItem.id}
className={`keys-tab__table-row${
idx % 2 === 1 ? ' keys-tab__table-row--alt' : ''
}`}
onClick={(): void => setEditKey(keyItem)}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
setEditKey(keyItem);
}
}}
>
<span className="keys-tab__col-name keys-tab__name-text">
{keyItem.name ?? '—'}
</span>
<span className="keys-tab__col-expiry">
{formatExpiry(keyItem.expires_at)}
</span>
<span className="keys-tab__col-last-used">
{formatLastUsed(keyItem.last_used ?? null)}
</span>
<span className="keys-tab__col-action">
<Tooltip title="Revoke Key">
<Button
variant="ghost"
size="xs"
color="destructive"
onClick={(e): void => {
e.stopPropagation();
setRevokeTarget(keyItem);
}}
className="keys-tab__revoke-btn"
>
<X size={14} />
</Button>
</Tooltip>
</span>
</div>
))}
{/* Count footer */}
<div className="keys-tab__table-footer">
<span className="keys-tab__count">
1 {keys.length} of {keys.length}
</span>
</div>
</div>
{/* Revoke confirm dialog */}
<DialogWrapper
open={revokeTarget !== null}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setRevokeTarget(null);
}
}}
title={`Revoke ${revokeTarget?.name ?? 'key'}?`}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">
Revoking this key will permanently invalidate it. Any systems using
this key will lose access immediately.
</p>
<DialogFooter className="delete-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setRevokeTarget(null)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isRevoking}
onClick={handleRevoke}
>
<Trash2 size={12} />
{isRevoking ? 'Revoking...' : 'Revoke Key'}
</Button>
</DialogFooter>
</DialogWrapper>
<EditKeyModal
open={editKey !== null}
accountId={accountId}
keyItem={editKey}
onClose={(): void => setEditKey(null)}
onSuccess={handleKeySuccess}
/>
</>
);
}
export default KeysTab;

View File

@@ -0,0 +1,147 @@
import { useCallback } from 'react';
import { Badge } from '@signozhq/badge';
import { ChevronDown, LockKeyhole } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Select } from 'antd';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { capitalize } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
interface OverviewTabProps {
account: ServiceAccountRow;
localName: string;
onNameChange: (v: string) => void;
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
}
function OverviewTab({
account,
localName,
onNameChange,
localRoles,
onRolesChange,
isDisabled,
}: OverviewTabProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[formatTimezoneAdjustedTimestamp],
);
return (
<>
{/* Name */}
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-name">
Name
</label>
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
) : (
<Input
id="sa-name"
value={localName}
onChange={(e): void => onNameChange(e.target.value)}
className="sa-drawer__input"
placeholder="Enter name"
/>
)}
</div>
{/* Email (always locked) */}
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-email">
Email Address
</label>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{account.email || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</div>
{/* Roles */}
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-roles">
Roles
</label>
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<div className="sa-drawer__disabled-roles">
{localRoles.length > 0 ? (
localRoles.map((r) => (
<Badge key={r} color="vanilla">
{capitalize(r)}
</Badge>
))
) : (
<span className="sa-drawer__input-text"></span>
)}
</div>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
) : (
<Select
id="sa-roles"
mode="multiple"
value={localRoles}
onChange={(roles): void => onRolesChange(roles as string[])}
className="sa-drawer__role-select"
suffixIcon={<ChevronDown size={14} />}
placeholder="Select roles"
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.sa-drawer') as HTMLElement) || document.body
}
>
<Select.Option value="ADMIN">{capitalize('ADMIN')}</Select.Option>
<Select.Option value="EDITOR">{capitalize('EDITOR')}</Select.Option>
<Select.Option value="VIEWER">{capitalize('VIEWER')}</Select.Option>
</Select>
)}
</div>
{/* Meta */}
<div className="sa-drawer__meta">
<div className="sa-drawer__meta-item">
<span className="sa-drawer__meta-label">Status</span>
{account.status?.toUpperCase() === 'ACTIVE' ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="vanilla" variant="outline">
DISABLED
</Badge>
)}
</div>
<div className="sa-drawer__meta-item">
<span className="sa-drawer__meta-label">Created At</span>
<Badge color="vanilla">{formatTimestamp(account.createdAt)}</Badge>
</div>
<div className="sa-drawer__meta-item">
<span className="sa-drawer__meta-label">Updated At</span>
<Badge color="vanilla">{formatTimestamp(account.updatedAt)}</Badge>
</div>
</div>
</>
);
}
export default OverviewTab;

View File

@@ -0,0 +1,466 @@
.sa-drawer {
&__layout {
display: flex;
flex-direction: column;
height: calc(100vh - 48px);
}
&__tabs {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--padding-3) var(--padding-4);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
&__tab-group {
// ToggleGroup container — let it inherit default styles
}
&__tab {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
}
&__tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
padding: 0 6px;
border-radius: 50px;
background: var(--secondary);
font-size: 11px;
font-weight: var(--font-weight-normal);
line-height: 20px;
color: var(--foreground);
letter-spacing: -0.06px;
}
&__body {
flex: 1;
overflow-y: auto;
padding: var(--padding-5) var(--padding-4);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
&__footer {
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--padding-4);
border-top: 1px solid var(--border);
background: var(--card);
}
&__footer-btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: 0;
background: transparent;
border: none;
cursor: pointer;
font-family: Inter, sans-serif;
font-size: var(--label-small-400-font-size);
font-weight: var(--label-small-400-font-weight);
line-height: var(--label-small-400-line-height);
letter-spacing: var(--label-small-400-letter-spacing);
transition: opacity 0.15s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:not(:disabled):hover {
opacity: 0.8;
}
&--danger {
color: var(--destructive);
}
&--primary {
color: var(--primary);
}
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
// Field styles (mirrors EditMemberDrawer)
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
cursor: default;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__input-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
&--disabled {
cursor: not-allowed;
opacity: 0.8;
}
}
&__input-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&__lock-icon {
color: var(--foreground);
flex-shrink: 0;
margin-left: 6px;
opacity: 0.6;
}
&__role-select {
width: 100%;
min-height: 32px;
.ant-select-selector {
background-color: var(--l2-background) !important;
border-color: var(--border) !important;
border-radius: 2px;
padding: 2px var(--padding-2) !important;
display: flex;
align-items: center;
flex-wrap: wrap;
min-height: 32px;
}
.ant-select-selection-overflow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 2px 0;
}
.ant-select-selection-overflow-item {
display: flex;
align-items: center;
}
.ant-select-selection-item {
display: flex;
align-items: center;
height: 22px;
font-size: var(--font-size-sm);
color: var(--l1-foreground);
background: var(--l3-background);
border: 1px solid var(--border);
border-radius: 2px;
padding: 0 4px 0 6px;
line-height: 20px;
letter-spacing: -0.07px;
margin: 0;
}
.ant-select-selection-item-remove {
display: flex;
align-items: center;
color: var(--foreground);
margin-left: 2px;
}
.ant-select-selection-placeholder {
font-size: var(--font-size-sm);
color: var(--l3-foreground);
}
.ant-select-arrow {
color: var(--foreground);
}
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--foreground);
}
}
&__disabled-roles {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
&__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
margin-top: var(--margin-1);
}
&__meta-item {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
[data-slot='badge'] {
padding: var(--padding-1) var(--padding-2);
align-items: center;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
line-height: 100%;
letter-spacing: 0.44px;
text-transform: uppercase;
}
}
&__meta-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: 0.48px;
text-transform: uppercase;
}
}
// Keys tab styles
.keys-tab {
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--padding-8) var(--padding-4);
gap: var(--spacing-4);
text-align: center;
}
&__empty-emoji {
font-size: 32px;
}
&__empty-text {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
margin: 0;
}
&__learn-more {
background: transparent;
border: none;
color: var(--primary);
font-size: var(--font-size-sm);
cursor: pointer;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
&__table-wrap {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
&__table-header {
display: flex;
align-items: center;
height: 32px;
padding: 0 var(--padding-4);
border-bottom: 1px solid var(--border);
background: transparent;
}
&__table-row {
display: flex;
align-items: center;
height: 38px;
padding: 0 var(--padding-4);
border-bottom: 1px solid var(--border);
cursor: pointer;
&:last-of-type {
border-bottom: none;
}
&:hover {
background: rgba(171, 189, 255, 0.04);
}
&--alt {
background: rgba(171, 189, 255, 0.02);
&:hover {
background: rgba(171, 189, 255, 0.04);
}
}
}
&__col-name {
flex: 1;
min-width: 0;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__col-expiry {
width: 100px;
text-align: right;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
flex-shrink: 0;
}
&__col-last-used {
width: 180px;
text-align: right;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
flex-shrink: 0;
}
&__col-action {
width: 40px;
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
&__name-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--l1-foreground);
letter-spacing: normal;
text-transform: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Override col styles in data rows for non-header cells
&__table-row &__col-expiry,
&__table-row &__col-last-used {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
letter-spacing: normal;
text-transform: none;
}
&__expiry--never {
color: var(--l2-foreground);
}
&__expiry--expired {
color: var(--l3-foreground);
}
&__revoke-btn {
width: 28px;
height: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
&__table-footer {
display: flex;
align-items: center;
height: 36px;
padding: 0 var(--padding-4);
border-top: 1px solid var(--border);
}
&__count {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
}
// Activate/disable confirm dialogs
.sa-activate-dialog,
.sa-disable-dialog {
background: var(--l2-background);
border: 1px solid var(--l2-border);
[data-slot='dialog-title'] {
color: var(--l1-foreground);
}
&__body {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l2-foreground);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.065px;
margin: 0;
strong {
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
}
&__footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-4);
margin-top: var(--margin-6);
}
}

View File

@@ -0,0 +1,357 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { DrawerWrapper } from '@signozhq/drawer';
import {
Check,
Key,
LayoutGrid,
Plus,
PowerOff,
Trash2,
X,
} from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import {
useUpdateServiceAccount,
useUpdateServiceAccountStatus,
} from 'api/generated/services/serviceaccount';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import AddKeyModal from './AddKeyModal';
import KeysTab from './KeysTab';
import OverviewTab from './OverviewTab';
import './ServiceAccountDrawer.styles.scss';
export interface ServiceAccountDrawerProps {
account: ServiceAccountRow | null;
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function ServiceAccountDrawer({
account,
open,
onClose,
onSuccess,
}: ServiceAccountDrawerProps): JSX.Element {
const [activeTab, setActiveTab] = useState<'overview' | 'keys'>('overview');
const [keyCount, setKeyCount] = useState(0);
const [isActivateConfirmOpen, setIsActivateConfirmOpen] = useState(false);
const [isDisableConfirmOpen, setIsDisableConfirmOpen] = useState(false);
const [localName, setLocalName] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const [isAddKeyOpen, setIsAddKeyOpen] = useState(false);
useEffect(() => {
if (account) {
setLocalName(account.name ?? '');
setLocalRoles(account.roles ?? []);
setActiveTab('overview');
}
}, [account]);
const isDisabled = account?.status?.toUpperCase() !== 'ACTIVE';
const isDirty =
account !== null &&
(localName !== (account.name ?? '') ||
JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? []));
const { mutateAsync: updateAccount } = useUpdateServiceAccount();
const { mutateAsync: updateStatus } = useUpdateServiceAccountStatus();
const handleSave = useCallback(async (): Promise<void> => {
if (!account || !isDirty) {
return;
}
setIsSaving(true);
try {
await updateAccount({
pathParams: { id: account.id },
data: { name: localName, email: account.email, roles: localRoles },
});
toast.success('Service account updated successfully', { richColors: true });
onSuccess();
} catch {
toast.error('Failed to update service account', { richColors: true });
} finally {
setIsSaving(false);
}
}, [account, isDirty, localName, localRoles, updateAccount, onSuccess]);
const handleDisable = useCallback(async (): Promise<void> => {
if (!account) {
return;
}
setIsDisabling(true);
try {
await updateStatus({
pathParams: { id: account.id },
data: { status: 'DISABLED' },
});
toast.success('Service account disabled', { richColors: true });
onSuccess();
} catch {
toast.error('Failed to disable service account', { richColors: true });
} finally {
setIsDisabling(false);
}
}, [account, updateStatus, onSuccess]);
const handleActivate = useCallback(async (): Promise<void> => {
if (!account) {
return;
}
setIsActivating(true);
try {
await updateStatus({
pathParams: { id: account.id },
data: { status: 'ACTIVE' },
});
toast.success('Service account activated', { richColors: true });
setIsActivateConfirmOpen(false);
onSuccess();
} catch {
toast.error('Failed to activate service account', { richColors: true });
} finally {
setIsActivating(false);
}
}, [account, updateStatus, onSuccess]);
const handleClose = useCallback((): void => {
setIsActivateConfirmOpen(false);
setIsDisableConfirmOpen(false);
setIsAddKeyOpen(false);
onClose();
}, [onClose]);
const handleKeySuccess = useCallback((): void => {
// Keys tab will refetch internally; just update count if needed
}, []);
const drawerContent = (
<div className="sa-drawer__layout">
<div className="sa-drawer__tabs">
<ToggleGroup
type="single"
value={activeTab}
onValueChange={(val): void => {
if (val) {
setActiveTab(val as 'overview' | 'keys');
}
}}
className="sa-drawer__tab-group"
>
<ToggleGroupItem value="overview" className="sa-drawer__tab">
<LayoutGrid size={14} />
Overview
</ToggleGroupItem>
<ToggleGroupItem value="keys" className="sa-drawer__tab">
<Key size={14} />
Keys
{keyCount > 0 && <span className="sa-drawer__tab-count">{keyCount}</span>}
</ToggleGroupItem>
</ToggleGroup>
{activeTab === 'keys' && (
<Button
variant="solid"
size="sm"
color="primary"
onClick={(): void => setIsAddKeyOpen(true)}
>
<Plus size={12} />
Add Key
</Button>
)}
</div>
<div className="sa-drawer__body">
{activeTab === 'overview' && account && (
<OverviewTab
account={account}
localName={localName}
onNameChange={setLocalName}
localRoles={localRoles}
onRolesChange={setLocalRoles}
isDisabled={isDisabled}
/>
)}
{activeTab === 'keys' && account && (
<KeysTab
accountId={account.id}
onKeyCountChange={setKeyCount}
onAddKeyClick={(): void => setIsAddKeyOpen(true)}
/>
)}
</div>
<div className="sa-drawer__footer">
{isDisabled ? (
<button
type="button"
className="sa-drawer__footer-btn sa-drawer__footer-btn--primary"
onClick={(): void => setIsActivateConfirmOpen(true)}
>
<Check size={12} />
Activate Service Account
</button>
) : (
<>
<button
type="button"
className="sa-drawer__footer-btn sa-drawer__footer-btn--danger"
onClick={(): void => setIsDisableConfirmOpen(true)}
>
<PowerOff size={12} />
Disable Service Account
</button>
{activeTab === 'overview' && (
<div className="sa-drawer__footer-right">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={handleClose}
>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
)}
</>
)}
</div>
</div>
);
return (
<>
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
direction="right"
type="panel"
showCloseButton
showOverlay={false}
allowOutsideClick
header={{ title: 'Service Account Details' }}
content={drawerContent}
className="sa-drawer"
/>
{/* Activate confirm dialog */}
<DialogWrapper
open={isActivateConfirmOpen}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setIsActivateConfirmOpen(false);
}
}}
title={`Activate service account ${account?.name ?? ''}?`}
width="narrow"
className="alert-dialog sa-activate-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="sa-activate-dialog__body">
Reactivating this service account will restore access for all its keys and
any systems using them.
</p>
<DialogFooter className="sa-activate-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setIsActivateConfirmOpen(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={isActivating}
onClick={handleActivate}
>
<Check size={12} />
{isActivating ? 'Activating...' : 'Activate'}
</Button>
</DialogFooter>
</DialogWrapper>
{/* Disable confirm dialog */}
<DialogWrapper
open={isDisableConfirmOpen}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setIsDisableConfirmOpen(false);
}
}}
title={`Disable service account ${account?.name ?? ''}?`}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">
Disabling this service account will revoke access for all its keys. Any
systems using this account will lose access immediately.
</p>
<DialogFooter className="delete-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setIsDisableConfirmOpen(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDisabling}
onClick={handleDisable}
>
<Trash2 size={12} />
{isDisabling ? 'Disabling...' : 'Disable'}
</Button>
</DialogFooter>
</DialogWrapper>
{account && (
<AddKeyModal
open={isAddKeyOpen}
accountId={account.id}
onClose={(): void => setIsAddKeyOpen(false)}
onSuccess={handleKeySuccess}
/>
)}
</>
);
}
export default ServiceAccountDrawer;

View File

@@ -0,0 +1,191 @@
.sa-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 4px;
}
.sa-table {
.ant-table {
background: transparent;
font-size: 13px;
}
.ant-table-container {
border-radius: 0 !important;
border: none !important;
}
.ant-table-thead {
> tr > th,
> tr > td {
background: var(--background);
font-size: var(--paragraph-small-600-font-size);
font-weight: var(--paragraph-small-600-font-weight);
line-height: var(--paragraph-small-600-line-height);
letter-spacing: 0.44px;
text-transform: uppercase;
color: var(--foreground);
padding: var(--padding-2) var(--padding-4);
border-bottom: none !important;
border-top: none !important;
&::before {
display: none !important;
}
}
}
.ant-table-tbody {
> tr > td {
border-bottom: none !important;
padding: var(--padding-2) var(--padding-4);
background: transparent;
transition: none;
}
> tr.sa-table-row--tinted > td {
background: rgba(171, 189, 255, 0.02);
}
> tr:hover > td {
background: rgba(171, 189, 255, 0.04) !important;
}
}
.ant-table-wrapper,
.ant-table-container,
.ant-spin-nested-loading,
.ant-spin-container {
border: none !important;
box-shadow: none !important;
}
.sa-status-cell {
[data-slot='badge'] {
padding: var(--padding-1) var(--padding-2);
align-items: center;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
line-height: 100%;
letter-spacing: 0.44px;
text-transform: uppercase;
}
}
}
.sa-name-email-cell {
display: flex;
align-items: center;
gap: var(--spacing-2);
height: 22px;
overflow: hidden;
.sa-name {
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
color: var(--foreground);
line-height: var(--paragraph-base-500-line-height);
letter-spacing: -0.07px;
white-space: nowrap;
flex-shrink: 0;
}
.sa-email {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l3-foreground-hover);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.07px;
flex: 1 0 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.sa-roles-cell {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.sa-dash {
font-size: var(--paragraph-base-400-font-size);
color: var(--l3-foreground-hover);
}
.sa-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--padding-12) var(--padding-4);
gap: var(--spacing-4);
color: var(--foreground);
&__emoji {
font-size: var(--font-size-2xl);
line-height: 1;
}
&__text {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
margin: 0;
line-height: var(--paragraph-base-400-font-height);
strong {
font-weight: var(--font-weight-medium);
color: var(--bg-base-white);
}
}
}
.sa-table-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--padding-2) var(--padding-4);
.ant-pagination-total-text {
margin-right: auto;
}
.sa-pagination-range {
font-size: var(--font-size-xs);
color: var(--foreground);
}
.sa-pagination-total {
font-size: var(--font-size-xs);
color: var(--foreground);
opacity: 0.5;
}
}
.lightMode {
.sa-table {
.ant-table-tbody {
> tr.sa-table-row--tinted > td {
background: rgba(0, 0, 0, 0.015);
}
> tr:hover > td {
background: rgba(0, 0, 0, 0.03) !important;
}
}
}
.sa-empty-state {
&__text {
strong {
color: var(--bg-base-black);
}
}
}
}

View File

@@ -0,0 +1,210 @@
import React from 'react';
import { Badge } from '@signozhq/badge';
import { Pagination, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { useTimezone } from 'providers/Timezone';
import './ServiceAccountsTable.styles.scss';
interface ServiceAccountsTableProps {
data: ServiceAccountRow[];
loading: boolean;
total: number;
currentPage: number;
pageSize: number;
searchQuery: string;
onPageChange: (page: number) => void;
onRowClick?: (row: ServiceAccountRow) => void;
}
function NameEmailCell({
name,
email,
}: {
name: string;
email: string;
}): JSX.Element {
return (
<div className="sa-name-email-cell">
{name && (
<span className="sa-name" title={name}>
{name}
</span>
)}
<Tooltip title={email} overlayClassName="sa-tooltip">
<span className="sa-email">{email}</span>
</Tooltip>
</div>
);
}
function RolesCell({ roles }: { roles: string[] }): JSX.Element {
if (!roles || roles.length === 0) {
return <span className="sa-dash"></span>;
}
const first = roles[0];
const overflow = roles.length - 1;
return (
<div className="sa-roles-cell">
<Badge color="vanilla">{first}</Badge>
{overflow > 0 && (
<Badge color="vanilla" variant="outline">
+{overflow}
</Badge>
)}
</div>
);
}
function StatusBadge({ status }: { status: string }): JSX.Element {
if (status?.toUpperCase() === 'ACTIVE') {
return (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
);
}
return (
<Badge color="vanilla" variant="outline">
DISABLED
</Badge>
);
}
function ServiceAccountsEmptyState({
searchQuery,
}: {
searchQuery: string;
}): JSX.Element {
return (
<div className="sa-empty-state">
<span className="sa-empty-state__emoji" role="img" aria-label="robot">
🤖
</span>
{searchQuery ? (
<p className="sa-empty-state__text">
No results for <strong>{searchQuery}</strong>
</p>
) : (
<p className="sa-empty-state__text">
No service accounts. Start by creating one to manage keys.
</p>
)}
</div>
);
}
function ServiceAccountsTable({
data,
loading,
total,
currentPage,
pageSize,
searchQuery,
onPageChange,
onRowClick,
}: ServiceAccountsTableProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formatCreatedAt = (date: string | null): string => {
if (!date) {
return '—';
}
const d = new Date(date);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
};
const columns: ColumnsType<ServiceAccountRow> = [
{
title: 'Name / Email',
dataIndex: 'name',
key: 'name',
render: (_, record): JSX.Element => (
<NameEmailCell name={record.name} email={record.email} />
),
},
{
title: 'Roles',
dataIndex: 'roles',
key: 'roles',
width: 200,
render: (roles: string[]): JSX.Element => <RolesCell roles={roles} />,
},
{
title: 'Permissions',
key: 'permissions',
width: 240,
render: (): JSX.Element => <span className="sa-dash"></span>,
},
{
title: 'Keys',
key: 'keys',
width: 96,
align: 'right' as const,
render: (): JSX.Element => <span className="sa-dash"></span>,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 96,
align: 'right' as const,
className: 'sa-status-cell',
render: (status: string): JSX.Element => <StatusBadge status={status} />,
},
];
const showPaginationTotal = (_total: number, range: number[]): JSX.Element => (
<>
<span className="sa-pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="sa-pagination-total"> of {_total}</span>
</>
);
// Silence unused import warning — formatCreatedAt used by future columns
void formatCreatedAt;
return (
<div className="sa-table-wrapper">
<Table<ServiceAccountRow>
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={false}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'sa-table-row--tinted' : ''
}
showSorterTooltip={false}
locale={{
emptyText: <ServiceAccountsEmptyState searchQuery={searchQuery} />,
}}
className="sa-table"
onRow={(record): { onClick: () => void; style?: React.CSSProperties } => ({
onClick: (): void => onRowClick?.(record),
style: onRowClick ? { cursor: 'pointer' } : undefined,
})}
/>
{total > pageSize && (
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
showTotal={showPaginationTotal}
showSizeChanger={false}
onChange={onPageChange}
className="sa-table-pagination"
/>
)}
</div>
);
}
export default ServiceAccountsTable;

View File

@@ -56,6 +56,7 @@ const ROUTES = {
BILLING: '/settings/billing',
ROLES_SETTINGS: '/settings/roles',
ROLE_DETAILS: '/settings/roles/:roleId',
MEMBERS_SETTINGS: '/settings/members',
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',
TRACES_SAVE_VIEWS: '/traces/saved-views',
@@ -85,6 +86,7 @@ const ROUTES = {
METER_EXPLORER_VIEWS: '/meter/explorer/views',
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
} as const;
export default ROUTES;

View File

@@ -674,7 +674,7 @@ function GeneralSettings({
return (
<div className="general-settings-page">
<div className="general-settings-header">
<span className="general-settings-title">General</span>
<span className="general-settings-title">Workspace</span>
<span className="general-settings-subtitle">
Manage your workspace settings.
</span>

View File

@@ -81,7 +81,18 @@ function FullView({
setCurrentGraphRef(fullViewRef);
}, [setCurrentGraphRef]);
const { selectedDashboard, isDashboardLocked } = useDashboard();
const {
selectedDashboard,
isDashboardLocked,
setColumnWidths,
} = useDashboard();
const onColumnWidthsChange = useCallback(
(widths: Record<string, number>) => {
setColumnWidths((prev) => ({ ...prev, [widget.id]: widths }));
},
[setColumnWidths, widget.id],
);
const { dashboardVariables } = useDashboardVariables();
const { user } = useAppContext();
@@ -381,6 +392,7 @@ function FullView({
onClickHandler={onClickHandler}
enableDrillDown={enableDrillDown}
selectedGraph={selectedPanelType}
onColumnWidthsChange={onColumnWidthsChange}
/>
</GraphContainer>
</div>

View File

@@ -168,6 +168,9 @@ jest.mock('providers/Dashboard/Dashboard', () => ({
variables: [],
},
},
setLayouts: jest.fn(),
setSelectedDashboard: jest.fn(),
setColumnWidths: jest.fn(),
}),
}));

View File

@@ -101,7 +101,19 @@ function WidgetGraphComponent({
const navigateToExplorerPages = useNavigateToExplorerPages();
const { setLayouts, selectedDashboard, setSelectedDashboard } = useDashboard();
const {
setLayouts,
selectedDashboard,
setSelectedDashboard,
setColumnWidths,
} = useDashboard();
const onColumnWidthsChange = useCallback(
(widths: Record<string, number>) => {
setColumnWidths((prev) => ({ ...prev, [widget.id]: widths }));
},
[setColumnWidths, widget.id],
);
const onToggleModal = useCallback(
(func: Dispatch<SetStateAction<boolean>>) => {
@@ -424,6 +436,7 @@ function WidgetGraphComponent({
customSeries={customSeries}
customOnRowClick={customOnRowClick}
enableDrillDown={enableDrillDown}
onColumnWidthsChange={onColumnWidthsChange}
/>
</div>
)}

View File

@@ -45,6 +45,8 @@ function GridTableComponent({
onOpenTraceBtnClick,
customOnRowClick,
widgetId,
columnWidths,
onColumnWidthsChange,
panelType,
queryRangeRequest,
decimalPrecision,
@@ -284,6 +286,8 @@ function GridTableComponent({
dataSource={dataSource}
sticky={sticky}
widgetId={widgetId}
columnWidths={columnWidths}
onColumnWidthsChange={onColumnWidthsChange}
panelType={panelType}
queryRangeRequest={queryRangeRequest}
onRow={

View File

@@ -24,6 +24,8 @@ export type GridTableComponentProps = {
onOpenTraceBtnClick?: (record: RowData) => void;
customOnRowClick?: (record: RowData) => void;
widgetId?: string;
columnWidths?: Record<string, number>;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
renderColumnCell?: QueryTableProps['renderColumnCell'];
customColTitles?: Record<string, string>;
enableDrillDown?: boolean;

View File

@@ -33,6 +33,7 @@ function LogsPanelComponent({
widget,
setRequestData,
queryResponse,
onColumnWidthsChange,
}: LogsPanelComponentProps): JSX.Element {
const [pageSize, setPageSize] = useState<number>(10);
const [offset, setOffset] = useState<number>(0);
@@ -145,8 +146,8 @@ function LogsPanelComponent({
columns={columns}
onRow={handleRow}
rowKey={(record): string => record.id}
widgetId={widget.id}
shouldPersistColumnWidths
columnWidths={widget.columnWidths}
onColumnWidthsChange={onColumnWidthsChange}
/>
</OverlayScrollbar>
</div>
@@ -189,6 +190,7 @@ export type LogsPanelComponentProps = {
Error
>;
widget: Widgets;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
};
export default LogsPanelComponent;

View File

@@ -0,0 +1,120 @@
.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%;
&__header {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__title {
font-size: var(--label-large-500-font-size);
font-weight: var(--label-large-500-font-weight);
color: var(--text-base-white);
letter-spacing: -0.09px;
line-height: var(--line-height-normal);
margin: 0;
}
&__subtitle {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: -0.07px;
line-height: var(--paragraph-base-400-line-height);
margin: 0;
}
&__controls {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__search {
flex: 1;
min-width: 0;
}
}
.members-filter-trigger {
display: flex;
align-items: center;
gap: var(--spacing-2);
border: 1px solid var(--border);
border-radius: 2px;
background-color: var(--l2-background);
> span {
color: var(--foreground);
}
&__chevron {
flex-shrink: 0;
color: var(--foreground);
}
}
.members-filter-dropdown {
.ant-dropdown-menu {
padding: var(--padding-3) 14px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--l2-background);
backdrop-filter: blur(20px);
}
.ant-dropdown-menu-item {
background: transparent !important;
padding: var(--padding-1) 0 !important;
&:hover {
background: transparent !important;
}
}
}
.members-filter-option {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: 0.14px;
min-width: 170px;
&:hover {
color: var(--card-foreground);
background: transparent;
}
}
.members-search-input {
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--border);
&::placeholder {
color: var(--l3-foreground);
}
}
.lightMode {
.members-settings {
&__title {
color: var(--text-base-black);
}
}
.members-filter-option {
&:hover {
color: var(--bg-neutral-light-100);
}
}
}

View File

@@ -0,0 +1,262 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/button';
import { Check, ChevronDown, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import getPendingInvites from 'api/v1/invite/get';
import getAll from 'api/v1/user/get';
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { useAppContext } from 'providers/App/App';
import { FilterMode, INVITE_PREFIX, MemberStatus } from './utils';
import './MembersSettings.styles.scss';
const PAGE_SIZE = 20;
function MembersSettings(): JSX.Element {
const { org } = useAppContext();
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
// TODO(nuqs): Replace with nuqs once the nuqs setup and integration is done - for search
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<FilterMode>(FilterMode.All);
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
const {
data: usersData,
isLoading: isUsersLoading,
refetch: refetchUsers,
} = useQuery({
queryFn: getAll,
queryKey: ['getOrgUser', org?.[0]?.id],
});
const {
data: invitesData,
isLoading: isInvitesLoading,
refetch: refetchInvites,
} = useQuery({
queryFn: getPendingInvites,
queryKey: ['getPendingInvites'],
});
const isLoading = isUsersLoading || isInvitesLoading;
const allMembers = useMemo((): MemberRow[] => {
const activeMembers: MemberRow[] = (usersData?.data ?? []).map((user) => ({
id: user.id,
name: user.displayName,
email: user.email,
role: user.role,
status: MemberStatus.Active,
joinedOn: user.createdAt ? String(user.createdAt) : null,
updatedAt: user?.updatedAt ? String(user.updatedAt) : null,
}));
const pendingInvites: MemberRow[] = (invitesData?.data ?? []).map(
(invite) => ({
id: `${INVITE_PREFIX}${invite.id}`,
name: invite.name ?? '',
email: invite.email,
role: invite.role,
status: MemberStatus.Invited,
joinedOn: invite.createdAt ? String(invite.createdAt) : null,
token: invite.token ?? null,
}),
);
return [...activeMembers, ...pendingInvites];
}, [usersData, invitesData]);
const filteredMembers = useMemo((): MemberRow[] => {
let result = allMembers;
if (filterMode === FilterMode.Invited) {
result = result.filter((m) => m.status === MemberStatus.Invited);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(m) =>
m?.name?.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q) ||
m.role.toLowerCase().includes(q),
);
}
return result;
}, [allMembers, filterMode, searchQuery]);
const paginatedMembers = useMemo((): MemberRow[] => {
const start = (currentPage - 1) * PAGE_SIZE;
return filteredMembers.slice(start, start + PAGE_SIZE);
}, [filteredMembers, currentPage]);
// TODO(nuqs): Replace with nuqs once the nuqs setup and integration is done
const setPage = useCallback(
(page: number): void => {
urlQuery.set('page', String(page));
history.replace({ search: urlQuery.toString() });
},
[history, urlQuery],
);
useEffect(() => {
if (filteredMembers.length === 0) {
return;
}
const maxPage = Math.ceil(filteredMembers.length / PAGE_SIZE);
if (currentPage > maxPage) {
setPage(maxPage);
}
}, [filteredMembers.length, currentPage, setPage]);
const pendingCount = invitesData?.data?.length ?? 0;
const totalCount = allMembers.length;
const filterMenuItems: MenuProps['items'] = [
{
key: FilterMode.All,
label: (
<div className="members-filter-option">
<span>All members {totalCount}</span>
{filterMode === FilterMode.All && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.All);
setPage(1);
},
},
{
key: FilterMode.Invited,
label: (
<div className="members-filter-option">
<span>Pending invites {pendingCount}</span>
{filterMode === FilterMode.Invited && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Invited);
setPage(1);
},
},
];
const filterLabel =
filterMode === FilterMode.All
? `All members ⎯ ${totalCount}`
: `Pending invites ⎯ ${pendingCount}`;
const handleInviteComplete = useCallback((): void => {
refetchUsers();
refetchInvites();
}, [refetchUsers, refetchInvites]);
const handleRowClick = useCallback((member: MemberRow): void => {
setSelectedMember(member);
}, []);
const handleDrawerClose = useCallback((): void => {
setSelectedMember(null);
}, []);
const handleMemberEditComplete = useCallback((): void => {
refetchUsers();
refetchInvites();
setSelectedMember(null);
}, [refetchUsers, refetchInvites]);
return (
<>
<div className="members-settings">
<div className="members-settings__header">
<h1 className="members-settings__title">Members</h1>
<p className="members-settings__subtitle">
Overview of people added to this workspace.
</p>
</div>
<div className="members-settings__controls">
<Dropdown
menu={{ items: filterMenuItems }}
trigger={['click']}
overlayClassName="members-filter-dropdown"
>
<Button
variant="solid"
size="sm"
color="secondary"
className="members-filter-trigger"
>
<span>{filterLabel}</span>
<ChevronDown size={12} className="members-filter-trigger__chevron" />
</Button>
</Dropdown>
<div className="members-settings__search">
<Input
placeholder="Search by name, email, or role..."
value={searchQuery}
onChange={(e): void => {
setSearchQuery(e.target.value);
setPage(1);
}}
className="members-search-input"
color="secondary"
/>
</div>
<Button
variant="solid"
size="sm"
color="primary"
onClick={(): void => setIsInviteModalOpen(true)}
>
<Plus size={12} />
Invite member
</Button>
</div>
</div>
<MembersTable
data={paginatedMembers}
loading={isLoading}
total={filteredMembers.length}
currentPage={currentPage}
pageSize={PAGE_SIZE}
searchQuery={searchQuery}
onPageChange={setPage}
onRowClick={handleRowClick}
/>
<InviteMembersModal
open={isInviteModalOpen}
onClose={(): void => setIsInviteModalOpen(false)}
onComplete={handleInviteComplete}
/>
<EditMemberDrawer
member={selectedMember}
open={selectedMember !== null}
onClose={handleDrawerClose}
onComplete={handleMemberEditComplete}
onRefetch={handleInviteComplete}
/>
</>
);
}
export default MembersSettings;

View File

@@ -0,0 +1,131 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import { PendingInvite } from 'types/api/user/getPendingInvites';
import { UserResponse } from 'types/api/user/getUser';
import MembersSettings from '../MembersSettings';
jest.mock('@signozhq/sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const USERS_ENDPOINT = '*/api/v1/user';
const INVITES_ENDPOINT = '*/api/v1/invite';
const mockUsers: UserResponse[] = [
{
id: 'user-1',
displayName: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN',
createdAt: 1700000000,
organization: 'TestOrg',
orgId: 'org-1',
},
{
id: 'user-2',
displayName: 'Bob Jones',
email: 'bob@signoz.io',
role: 'VIEWER',
createdAt: 1700000001,
organization: 'TestOrg',
orgId: 'org-1',
},
];
const mockInvites: PendingInvite[] = [
{
id: 'inv-1',
email: 'charlie@signoz.io',
name: 'Charlie',
role: 'EDITOR',
createdAt: 1700000002,
token: 'tok-abc',
},
];
describe('MembersSettings (integration)', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(USERS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockUsers })),
),
rest.get(INVITES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockInvites })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('loads and displays active users and pending invites', async () => {
render(<MembersSettings />);
await screen.findByText('Alice Smith');
expect(screen.getByText('Bob Jones')).toBeInTheDocument();
expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument();
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
expect(screen.getByText('INVITED')).toBeInTheDocument();
});
it('filters to pending invites via the filter dropdown', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MembersSettings />);
await screen.findByText('Alice Smith');
await user.click(screen.getByRole('button', { name: /all members/i }));
const pendingOption = await screen.findByText(/pending invites/i);
await user.click(pendingOption);
await screen.findByText('charlie@signoz.io');
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
});
it('filters members by name using the search input', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MembersSettings />);
await screen.findByText('Alice Smith');
await user.type(
screen.getByPlaceholderText(/Search by name, email, or role/i),
'bob',
);
await screen.findByText('Bob Jones');
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
expect(screen.queryByText('charlie@signoz.io')).not.toBeInTheDocument();
});
it('opens EditMemberDrawer when a member row is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MembersSettings />);
await user.click(await screen.findByText('Alice Smith'));
await screen.findByText('Member Details');
});
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MembersSettings />);
await user.click(screen.getByRole('button', { name: /invite member/i }));
expect(await screen.findAllByPlaceholderText('john@signoz.io')).toHaveLength(
3,
);
});
});

View File

@@ -0,0 +1,11 @@
export const INVITE_PREFIX = 'invite-';
export enum FilterMode {
All = 'all',
Invited = 'invited',
}
export enum MemberStatus {
Active = 'Active',
Invited = 'Invited',
}

View File

@@ -161,7 +161,7 @@ function MySettings(): JSX.Element {
<div className="my-settings-container">
<div className="user-info-section">
<div className="user-info-section-header">
<div className="user-info-section-title">General </div>
<div className="user-info-section-title">Account </div>
<div className="user-info-section-subtitle">
Manage your account settings.

View File

@@ -11,7 +11,7 @@ import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader';
import InviteUserModal from 'container/OrganizationSettings/InviteUserModal/InviteUserModal';
import { InviteMemberFormValues } from 'container/OrganizationSettings/PendingInvitesContainer';
import { InviteMemberFormValues } from 'container/OrganizationSettings/utils';
import history from 'lib/history';
import { UserPlus } from 'lucide-react';
import { useAppContext } from 'providers/App/App';

View File

@@ -12,7 +12,7 @@ import { SOMETHING_WENT_WRONG } from 'constants/api';
import { FeatureKeys } from 'constants/features';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { InviteTeamMembersProps } from 'container/OrganizationSettings/PendingInvitesContainer';
import { InviteTeamMembersProps } from 'container/OrganizationSettings/utils';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';

View File

@@ -1,32 +0,0 @@
import { gold } from '@ant-design/colors';
import { ExclamationCircleTwoTone } from '@ant-design/icons';
import { Space, Typography } from 'antd';
function DeleteMembersDetails({
name,
}: DeleteMembersDetailsProps): JSX.Element {
return (
<div>
<Space direction="horizontal" size="middle" align="start">
<ExclamationCircleTwoTone
twoToneColor={[gold[6], '#1f1f1f']}
style={{
fontSize: '1.4rem',
}}
/>
<Space direction="vertical">
<Typography>Are you sure you want to delete {name}</Typography>
<Typography>
This will remove all access from dashboards and other features in SigNoz
</Typography>
</Space>
</Space>
</div>
);
}
interface DeleteMembersDetailsProps {
name: string;
}
export default DeleteMembersDetails;

View File

@@ -1,167 +0,0 @@
import {
ChangeEventHandler,
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import { CopyOutlined } from '@ant-design/icons';
import { Button, Input, Select, Space, Tooltip } from 'antd';
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import APIError from 'types/api/error';
import { ROLES } from 'types/roles';
import { InputGroup, SelectDrawer, Title } from './styles';
const { Option } = Select;
function EditMembersDetails({
emailAddress,
name,
role,
setEmailAddress,
setName,
setRole,
id,
}: EditMembersDetailsProps): JSX.Element {
const [passwordLink, setPasswordLink] = useState<string>('');
const { t } = useTranslation(['common']);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [state, copyToClipboard] = useCopyToClipboard();
const getPasswordLink = (token: string): string =>
`${window.location.origin}${ROUTES.PASSWORD_RESET}?token=${token}`;
const onChangeHandler = useCallback(
(setFunc: Dispatch<SetStateAction<string>>, value: string) => {
setFunc(value);
},
[],
);
const { notifications } = useNotifications();
useEffect(() => {
if (state.error) {
notifications.error({
message: t('something_went_wrong'),
});
}
if (state.value) {
notifications.success({
message: t('success'),
});
}
}, [state.error, state.value, t, notifications]);
const onPasswordChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
setPasswordLink(event.target.value);
},
[],
);
const onGeneratePasswordHandler = async (): Promise<void> => {
try {
setIsLoading(true);
const response = await getResetPasswordToken({
userId: id || '',
});
setPasswordLink(getPasswordLink(response.data.token));
setIsLoading(false);
} catch (error) {
setIsLoading(false);
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
}
};
return (
<Space direction="vertical" size="large">
<Space direction="horizontal">
<Title>Email address</Title>
<Input
placeholder="john@signoz.io"
readOnly
onChange={(event): void =>
onChangeHandler(setEmailAddress, event.target.value)
}
disabled={isLoading}
value={emailAddress}
/>
</Space>
<Space direction="horizontal">
<Title>Name (optional)</Title>
<Input
placeholder="John"
onChange={(event): void => onChangeHandler(setName, event.target.value)}
value={name}
disabled={isLoading}
/>
</Space>
<Space direction="horizontal">
<Title>Role</Title>
<SelectDrawer
value={role}
onSelect={(value: unknown): void => {
if (typeof value === 'string') {
setRole(value as ROLES);
}
}}
disabled={isLoading}
>
<Option value="ADMIN">ADMIN</Option>
<Option value="VIEWER">VIEWER</Option>
<Option value="EDITOR">EDITOR</Option>
</SelectDrawer>
</Space>
<Button
loading={isLoading}
disabled={isLoading}
onClick={onGeneratePasswordHandler}
type="primary"
>
Generate Reset Password link
</Button>
{passwordLink && (
<InputGroup>
<Input
style={{ width: '100%' }}
defaultValue="git@github.com:ant-design/ant-design.git"
onChange={onPasswordChangeHandler}
value={passwordLink}
disabled={isLoading}
/>
<Tooltip title="COPY LINK">
<Button
icon={<CopyOutlined />}
onClick={(): void => copyToClipboard(passwordLink)}
/>
</Tooltip>
</InputGroup>
)}
</Space>
);
}
interface EditMembersDetailsProps {
emailAddress: string;
name: string;
role: ROLES;
setEmailAddress: Dispatch<SetStateAction<string>>;
setName: Dispatch<SetStateAction<string>>;
setRole: Dispatch<SetStateAction<ROLES>>;
id: string;
}
export default EditMembersDetails;

View File

@@ -1,16 +0,0 @@
import { Select, Typography } from 'antd';
import styled from 'styled-components';
export const SelectDrawer = styled(Select)`
width: 120px;
`;
export const Title = styled(Typography)`
width: 7rem;
`;
export const InputGroup = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`;

View File

@@ -11,7 +11,7 @@ import {
} from 'antd';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { InviteMemberFormValues } from '../PendingInvitesContainer/index';
import { InviteMemberFormValues } from '../utils';
import { SelectDrawer, SpaceContainer, TitleWrapper } from './styles';
function InviteTeamMembers({ form, onFinish }: Props): JSX.Element {

View File

@@ -6,7 +6,7 @@ import { useNotifications } from 'hooks/useNotifications';
import APIError from 'types/api/error';
import InviteTeamMembers from '../InviteTeamMembers';
import { InviteMemberFormValues } from '../PendingInvitesContainer';
import { InviteMemberFormValues } from '../utils';
export interface InviteUserModalProps {
isInviteTeamMemberModalOpen: boolean;

View File

@@ -1,324 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import {
Button,
Modal,
Space,
TableColumnsType as ColumnsType,
Typography,
} from 'antd';
import getAll from 'api/v1/user/get';
import deleteUser from 'api/v1/user/id/delete';
import update from 'api/v1/user/id/update';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { ResizeTable } from 'components/ResizeTable';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import APIError from 'types/api/error';
import { ROLES } from 'types/roles';
import DeleteMembersDetails from '../DeleteMembersDetails';
import EditMembersDetails from '../EditMembersDetails';
function UserFunction({
setDataSource,
accessLevel,
name,
email,
id,
}: UserFunctionProps): JSX.Element {
const [isModalVisible, setIsModalVisible] = useState(false);
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const onModalToggleHandler = (
func: Dispatch<SetStateAction<boolean>>,
value: boolean,
): void => {
func(value);
};
const [emailAddress, setEmailAddress] = useState(email);
const [updatedName, setUpdatedName] = useState(name);
const [role, setRole] = useState<ROLES>(accessLevel);
const { t } = useTranslation(['common']);
const [isDeleteLoading, setIsDeleteLoading] = useState<boolean>(false);
const [isUpdateLoading, setIsUpdateLoading] = useState<boolean>(false);
const { notifications } = useNotifications();
const onUpdateDetailsHandler = (): void => {
setDataSource((data) => {
const index = data.findIndex((e) => e.id === id);
if (index !== -1) {
const current = data[index];
const updatedData: DataType[] = [
...data.slice(0, index),
{
...current,
name: updatedName,
accessLevel: role,
email: emailAddress,
},
...data.slice(index + 1, data.length),
];
return updatedData;
}
return data;
});
};
const onDelete = (): void => {
setDataSource((source) => {
const index = source.findIndex((e) => e.id === id);
if (index !== -1) {
const updatedData: DataType[] = [
...source.slice(0, index),
...source.slice(index + 1, source.length),
];
return updatedData;
}
return source;
});
};
const onDeleteHandler = async (): Promise<void> => {
try {
setIsDeleteLoading(true);
await deleteUser({
userId: id,
});
onDelete();
notifications.success({
message: t('success', {
ns: 'common',
}),
});
setIsDeleteModalVisible(false);
setIsDeleteLoading(false);
} catch (error) {
setIsDeleteLoading(false);
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
}
};
const onEditMemberDetails = async (): Promise<void> => {
try {
setIsUpdateLoading(true);
await update({
userId: id,
displayName: updatedName,
role,
});
onUpdateDetailsHandler();
if (role !== accessLevel) {
notifications.success({
message: 'User details updated successfully',
description: 'The user details have been updated successfully.',
});
} else {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
}
setIsUpdateLoading(false);
setIsModalVisible(false);
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
setIsUpdateLoading(false);
}
};
return (
<>
<Space direction="horizontal">
<Typography.Link
onClick={(): void => onModalToggleHandler(setIsModalVisible, true)}
>
Edit
</Typography.Link>
<Typography.Link
onClick={(): void => onModalToggleHandler(setIsDeleteModalVisible, true)}
>
Delete
</Typography.Link>
</Space>
<Modal
title="Edit member details"
className="edit-member-details-modal"
open={isModalVisible}
onOk={(): void => onModalToggleHandler(setIsModalVisible, false)}
onCancel={(): void => onModalToggleHandler(setIsModalVisible, false)}
centered
destroyOnClose
footer={[
<Button
key="back"
onClick={(): void => onModalToggleHandler(setIsModalVisible, false)}
type="default"
>
Cancel
</Button>,
<Button
key="Invite_team_members"
onClick={onEditMemberDetails}
type="primary"
disabled={isUpdateLoading}
loading={isUpdateLoading}
>
Update Details
</Button>,
]}
>
<EditMembersDetails
{...{
emailAddress,
name: updatedName,
role,
setEmailAddress,
setName: setUpdatedName,
setRole,
id,
}}
/>
</Modal>
<Modal
title="Edit member details"
open={isDeleteModalVisible}
onOk={onDeleteHandler}
onCancel={(): void => onModalToggleHandler(setIsDeleteModalVisible, false)}
centered
confirmLoading={isDeleteLoading}
>
<DeleteMembersDetails name={name} />
</Modal>
</>
);
}
function Members(): JSX.Element {
const { org } = useAppContext();
const { data, isLoading, error } = useQuery({
queryFn: () => getAll(),
queryKey: ['getOrgUser', org?.[0].id],
});
const [dataSource, setDataSource] = useState<DataType[]>([]);
useEffect(() => {
if (data?.data && Array.isArray(data.data)) {
const updatedData: DataType[] = data?.data?.map((e) => ({
accessLevel: e.role,
email: e.email,
id: String(e.id),
joinedOn: String(e.createdAt),
name: e.displayName,
}));
setDataSource(updatedData);
}
}, [data]);
const columns: ColumnsType<DataType> = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 100,
},
{
title: 'Emails',
dataIndex: 'email',
key: 'email',
width: 100,
},
{
title: 'Access Level',
dataIndex: 'accessLevel',
key: 'accessLevel',
width: 50,
},
{
title: 'Joined On',
dataIndex: 'joinedOn',
key: 'joinedOn',
width: 60,
render: (_, record): JSX.Element => {
const { joinedOn } = record;
return (
<Typography>
{dayjs(joinedOn).format(DATE_TIME_FORMATS.MONTH_DATE_FULL)}
</Typography>
);
},
},
{
title: 'Action',
dataIndex: 'action',
width: 80,
render: (_, record): JSX.Element => (
<UserFunction
{...{
accessLevel: record.accessLevel,
email: record.email,
joinedOn: record.joinedOn,
name: record.name,
id: record.id,
setDataSource,
}}
/>
),
},
];
return (
<div className="members-container">
<Typography.Title level={3}>
Members{' '}
{!isLoading && dataSource && (
<div className="members-count"> ({dataSource.length}) </div>
)}
</Typography.Title>
{!(error as APIError) && (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={dataSource}
pagination={false}
loading={isLoading}
bordered
/>
)}
{(error as APIError) && <ErrorContent error={error as APIError} />}
</div>
);
}
interface DataType {
id: string;
name: string;
email: string;
accessLevel: ROLES;
joinedOn: string;
}
interface UserFunctionProps extends DataType {
setDataSource: Dispatch<SetStateAction<DataType[]>>;
}
export default Members;

View File

@@ -1,248 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { PlusOutlined } from '@ant-design/icons';
import {
Button,
Form,
Space,
TableColumnsType as ColumnsType,
Typography,
} from 'antd';
import get from 'api/v1/invite/get';
import deleteInvite from 'api/v1/invite/id/delete';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { ResizeTable } from 'components/ResizeTable';
import { INVITE_MEMBERS_HASH } from 'constants/app';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import APIError from 'types/api/error';
import { PendingInvite } from 'types/api/user/getPendingInvites';
import { ROLES } from 'types/roles';
import InviteUserModal from '../InviteUserModal/InviteUserModal';
import { TitleWrapper } from './styles';
function PendingInvitesContainer(): JSX.Element {
const [
isInviteTeamMemberModalOpen,
setIsInviteTeamMemberModalOpen,
] = useState<boolean>(false);
const [form] = Form.useForm<InviteMemberFormValues>();
const { t } = useTranslation(['organizationsettings', 'common']);
const [state, setText] = useCopyToClipboard();
const { notifications } = useNotifications();
const { user } = useAppContext();
useEffect(() => {
if (state.error) {
notifications.error({
message: state.error.message,
});
}
if (state.value) {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
}
}, [state.error, state.value, t, notifications]);
const { data, isLoading, error, isError, refetch } = useQuery({
queryFn: get,
queryKey: ['getPendingInvites', user?.accessJwt],
});
const [dataSource, setDataSource] = useState<DataProps[]>([]);
const toggleModal = useCallback(
(value: boolean): void => {
setIsInviteTeamMemberModalOpen(value);
if (!value) {
form.resetFields();
}
},
[form],
);
const { hash } = useLocation();
const getParsedInviteData = useCallback(
(payload: PendingInvite[] = []) =>
payload?.map((data) => ({
key: data.createdAt,
name: data.name,
id: data.id,
email: data.email,
accessLevel: data.role,
inviteLink: `${window.location.origin}${ROUTES.SIGN_UP}?token=${data.token}`,
})),
[],
);
useEffect(() => {
if (hash === INVITE_MEMBERS_HASH) {
toggleModal(true);
}
}, [hash, toggleModal]);
useEffect(() => {
if (data?.data) {
const parsedData = getParsedInviteData(data?.data || []);
setDataSource(parsedData);
}
}, [data, getParsedInviteData]);
const onRevokeHandler = async (id: string): Promise<void> => {
try {
await deleteInvite({
id,
});
// remove from the client data
const index = dataSource.findIndex((e) => e.id === id);
if (index !== -1) {
setDataSource([
...dataSource.slice(0, index),
...dataSource.slice(index + 1, dataSource.length),
]);
}
notifications.success({
message: t('success', {
ns: 'common',
}),
});
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
}
};
const columns: ColumnsType<DataProps> = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 100,
},
{
title: 'Emails',
dataIndex: 'email',
key: 'email',
width: 80,
},
{
title: 'Access Level',
dataIndex: 'accessLevel',
key: 'accessLevel',
width: 50,
},
{
title: 'Invite Link',
dataIndex: 'inviteLink',
key: 'Invite Link',
ellipsis: true,
width: 100,
},
{
title: 'Action',
dataIndex: 'action',
width: 80,
key: 'Action',
render: (_, record): JSX.Element => (
<Space direction="horizontal">
<Typography.Link onClick={(): Promise<void> => onRevokeHandler(record.id)}>
Revoke
</Typography.Link>
<Typography.Link
onClick={(): void => {
setText(record.inviteLink);
}}
>
Copy Invite Link
</Typography.Link>
</Space>
),
},
];
return (
<div className="pending-invites-container-wrapper">
<InviteUserModal
form={form}
isInviteTeamMemberModalOpen={isInviteTeamMemberModalOpen}
toggleModal={toggleModal}
onClose={refetch}
/>
<div className="pending-invites-container">
<TitleWrapper>
<Typography.Title level={3}>
{t('pending_invites')}
{dataSource && (
<div className="members-count"> ({dataSource.length})</div>
)}
</Typography.Title>
<Space>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={(): void => {
toggleModal(true);
}}
>
{t('invite_members')}
</Button>
</Space>
</TitleWrapper>
{!isError && (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={dataSource}
pagination={false}
loading={isLoading}
bordered
/>
)}
{isError && <ErrorContent error={error as APIError} />}
</div>
</div>
);
}
export interface InviteTeamMembersProps {
email: string;
name: string;
role: string;
id: string;
frontendBaseUrl: string;
}
interface DataProps {
key: number;
name: string;
id: string;
email: string;
accessLevel: ROLES;
inviteLink: string;
}
type Role = 'ADMIN' | 'VIEWER' | 'EDITOR';
export interface InviteMemberFormValues {
members: {
email: string;
name: string;
role: Role;
}[];
}
export default PendingInvitesContainer;

View File

@@ -1,8 +0,0 @@
import styled from 'styled-components';
export const TitleWrapper = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
`;

View File

@@ -3,8 +3,6 @@ import { useAppContext } from 'providers/App/App';
import AuthDomain from './AuthDomain';
import DisplayName from './DisplayName';
import Members from './Members';
import PendingInvitesContainer from './PendingInvitesContainer';
import './OrganizationSettings.styles.scss';
@@ -23,9 +21,6 @@ function OrganizationSettings(): JSX.Element {
))}
</Space>
<PendingInvitesContainer />
<Members />
<AuthDomain />
</div>
);

View File

@@ -1,37 +0,0 @@
import { act, render, screen, waitFor } from 'tests/test-utils';
import Members from '../Members';
describe('Organization Settings Page', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('render list of members', async () => {
act(() => {
render(<Members />);
});
const title = await screen.findByText(/Members/i);
expect(title).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('firstUser@test.io')).toBeInTheDocument(); // first item
expect(screen.getByText('lastUser@test.io')).toBeInTheDocument(); // last item
});
});
// this is required as our edit/delete logic is dependent on the index and it will break with pagination enabled
it('render list of members without pagination', async () => {
render(<Members />);
await waitFor(() => {
expect(screen.getByText('firstUser@test.io')).toBeInTheDocument(); // first item
expect(screen.getByText('lastUser@test.io')).toBeInTheDocument(); // last item
expect(
document.querySelector('.ant-table-pagination'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,17 @@
export interface InviteTeamMembersProps {
email: string;
name: string;
role: string;
id: string;
frontendBaseUrl: string;
}
type Role = 'ADMIN' | 'VIEWER' | 'EDITOR';
export interface InviteMemberFormValues {
members: {
email: string;
name: string;
role: Role;
}[];
}

View File

@@ -8,6 +8,7 @@ function ListPanelWrapper({
widget,
queryResponse,
setRequestData,
onColumnWidthsChange,
}: PanelWrapperProps): JSX.Element {
const dataSource = widget.query.builder?.queryData[0]?.dataSource;
@@ -21,6 +22,7 @@ function ListPanelWrapper({
widget={widget}
queryResponse={queryResponse}
setRequestData={setRequestData}
onColumnWidthsChange={onColumnWidthsChange}
/>
);
}
@@ -29,6 +31,7 @@ function ListPanelWrapper({
widget={widget}
queryResponse={queryResponse}
setRequestData={setRequestData}
onColumnWidthsChange={onColumnWidthsChange}
/>
);
}

View File

@@ -24,6 +24,7 @@ function PanelWrapper({
customOnRowClick,
panelMode,
enableDrillDown = false,
onColumnWidthsChange,
}: PanelWrapperProps): JSX.Element {
const Component = PanelTypeVsPanelWrapper[
selectedGraph || widget.panelTypes
@@ -58,6 +59,7 @@ function PanelWrapper({
customOnRowClick={customOnRowClick}
customSeries={customSeries}
enableDrillDown={enableDrillDown}
onColumnWidthsChange={onColumnWidthsChange}
/>
);
}

View File

@@ -14,6 +14,7 @@ function TablePanelWrapper({
onOpenTraceBtnClick,
customOnRowClick,
enableDrillDown = false,
onColumnWidthsChange,
}: PanelWrapperProps): JSX.Element {
const panelData =
(queryResponse.data?.payload?.data?.result?.[0] as any)?.table || [];
@@ -34,6 +35,8 @@ function TablePanelWrapper({
onOpenTraceBtnClick={onOpenTraceBtnClick}
customOnRowClick={customOnRowClick}
widgetId={widget.id}
columnWidths={widget.columnWidths}
onColumnWidthsChange={onColumnWidthsChange}
renderColumnCell={widget.renderColumnCell}
customColTitles={widget.customColTitles}
contextLinks={widget.contextLinks}

View File

@@ -29,6 +29,7 @@ export type PanelWrapperProps = {
customSeries?: (data: QueryData[]) => uPlot.Series[];
enableDrillDown?: boolean;
panelMode: PanelMode;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
};
export type TooltipData = {

View File

@@ -24,6 +24,8 @@ export type QueryTableProps = Omit<
sticky?: TableProps<RowData>['sticky'];
searchTerm?: string;
widgetId?: string;
columnWidths?: Record<string, number>;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
enableDrillDown?: boolean;
contextLinks?: ContextLinksData;
panelType?: PANEL_TYPES;

View File

@@ -28,6 +28,8 @@ export function QueryTable({
sticky,
searchTerm,
widgetId,
columnWidths,
onColumnWidthsChange,
panelType,
...props
}: QueryTableProps): JSX.Element {
@@ -175,8 +177,8 @@ export function QueryTable({
dataSource={filterTable === null ? newDataSource : filterTable}
scroll={{ x: 'max-content' }}
pagination={paginationConfig}
widgetId={widgetId}
shouldPersistColumnWidths
columnWidths={columnWidths}
onColumnWidthsChange={onColumnWidthsChange}
sticky={sticky}
{...props}
/>

View File

@@ -0,0 +1,129 @@
.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%;
&__header {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__title {
font-size: var(--label-large-500-font-size);
font-weight: var(--label-large-500-font-weight);
color: var(--text-base-white);
letter-spacing: -0.09px;
line-height: var(--line-height-normal);
margin: 0;
}
&__subtitle {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: -0.07px;
line-height: var(--paragraph-base-400-line-height);
margin: 0;
}
&__learn-more {
color: var(--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
&__controls {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__search {
flex: 1;
min-width: 0;
}
}
.sa-settings-filter-trigger {
display: flex;
align-items: center;
gap: var(--spacing-2);
border: 1px solid var(--border);
border-radius: 2px;
background-color: var(--l2-background);
> span {
color: var(--foreground);
}
&__chevron {
flex-shrink: 0;
color: var(--foreground);
}
}
.sa-settings-filter-dropdown {
.ant-dropdown-menu {
padding: var(--padding-3) 14px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--l2-background);
backdrop-filter: blur(20px);
}
.ant-dropdown-menu-item {
background: transparent !important;
padding: var(--padding-1) 0 !important;
&:hover {
background: transparent !important;
}
}
}
.sa-settings-filter-option {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: 0.14px;
min-width: 170px;
&:hover {
color: var(--card-foreground);
background: transparent;
}
}
.sa-settings-search-input {
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--border);
&::placeholder {
color: var(--l3-foreground);
}
}
.lightMode {
.sa-settings {
&__title {
color: var(--text-base-black);
}
}
.sa-settings-filter-option {
&:hover {
color: var(--bg-neutral-light-100);
}
}
}

View File

@@ -0,0 +1,251 @@
import { useCallback, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/button';
import { Check, ChevronDown, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
import ServiceAccountsTable from 'components/ServiceAccountsTable/ServiceAccountsTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { FilterMode, ServiceAccountRow } from './utils';
import './ServiceAccountsSettings.styles.scss';
const PAGE_SIZE = 20;
function ServiceAccountsSettings(): JSX.Element {
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<FilterMode>(FilterMode.All);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedAccount, setSelectedAccount] = useState<ServiceAccountRow | null>(null);
const {
data: serviceAccountsData,
isLoading,
refetch,
} = useListServiceAccounts();
const allAccounts = useMemo(
(): ServiceAccountRow[] =>
(serviceAccountsData?.data ?? []).map((sa) => ({
id: sa.id,
name: sa.name,
email: sa.email,
roles: sa.roles,
status: sa.status,
createdAt: sa.createdAt ? String(sa.createdAt) : null,
updatedAt: sa.updatedAt ? String(sa.updatedAt) : null,
})),
[serviceAccountsData],
);
const activeCount = useMemo(
() => allAccounts.filter((a) => a.status?.toUpperCase() === 'ACTIVE').length,
[allAccounts],
);
const disabledCount = useMemo(
() => allAccounts.filter((a) => a.status?.toUpperCase() !== 'ACTIVE').length,
[allAccounts],
);
const filteredAccounts = useMemo((): ServiceAccountRow[] => {
let result = allAccounts;
if (filterMode === FilterMode.Active) {
result = result.filter((a) => a.status?.toUpperCase() === 'ACTIVE');
} else if (filterMode === FilterMode.Disabled) {
result = result.filter((a) => a.status?.toUpperCase() !== 'ACTIVE');
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(a) =>
a.name?.toLowerCase().includes(q) || a.email?.toLowerCase().includes(q),
);
}
return result;
}, [allAccounts, filterMode, searchQuery]);
const paginatedAccounts = useMemo((): ServiceAccountRow[] => {
const start = (currentPage - 1) * PAGE_SIZE;
return filteredAccounts.slice(start, start + PAGE_SIZE);
}, [filteredAccounts, currentPage]);
const setPage = useCallback(
(page: number): void => {
urlQuery.set('page', String(page));
history.replace({ search: urlQuery.toString() });
},
[history, urlQuery],
);
const totalCount = allAccounts.length;
const filterMenuItems: MenuProps['items'] = [
{
key: FilterMode.All,
label: (
<div className="sa-settings-filter-option">
<span>All accounts {totalCount}</span>
{filterMode === FilterMode.All && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.All);
setPage(1);
},
},
{
key: FilterMode.Active,
label: (
<div className="sa-settings-filter-option">
<span>Active {activeCount}</span>
{filterMode === FilterMode.Active && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Active);
setPage(1);
},
},
{
key: FilterMode.Disabled,
label: (
<div className="sa-settings-filter-option">
<span>Disabled {disabledCount}</span>
{filterMode === FilterMode.Disabled && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Disabled);
setPage(1);
},
},
];
const filterLabel =
filterMode === FilterMode.Active
? `Active ⎯ ${activeCount}`
: filterMode === FilterMode.Disabled
? `Disabled ⎯ ${disabledCount}`
: `All accounts ⎯ ${totalCount}`;
const handleRowClick = useCallback((row: ServiceAccountRow): void => {
setSelectedAccount(row);
}, []);
const handleDrawerClose = useCallback((): void => {
setSelectedAccount(null);
}, []);
const handleDrawerSuccess = useCallback((): void => {
refetch();
setSelectedAccount(null);
}, [refetch]);
const handleCreateSuccess = useCallback((): void => {
refetch();
}, [refetch]);
return (
<>
<div className="sa-settings">
<div className="sa-settings__header">
<h1 className="sa-settings__title">Service Accounts</h1>
<p className="sa-settings__subtitle">
Service accounts are used for machine-to-machine authentication via API
keys.{' '}
<a
href="https://signoz.io/docs/service-accounts"
target="_blank"
rel="noopener noreferrer"
className="sa-settings__learn-more"
>
Learn more
</a>
</p>
</div>
<div className="sa-settings__controls">
<Dropdown
menu={{ items: filterMenuItems }}
trigger={['click']}
overlayClassName="sa-settings-filter-dropdown"
>
<Button
variant="solid"
size="sm"
color="secondary"
className="sa-settings-filter-trigger"
>
<span>{filterLabel}</span>
<ChevronDown size={12} className="sa-settings-filter-trigger__chevron" />
</Button>
</Dropdown>
<div className="sa-settings__search">
<Input
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e): void => {
setSearchQuery(e.target.value);
setPage(1);
}}
className="sa-settings-search-input"
color="secondary"
/>
</div>
<Button
variant="solid"
size="sm"
color="primary"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={12} />
New Service Account
</Button>
</div>
</div>
<ServiceAccountsTable
data={paginatedAccounts}
loading={isLoading}
total={filteredAccounts.length}
currentPage={currentPage}
pageSize={PAGE_SIZE}
searchQuery={searchQuery}
onPageChange={setPage}
onRowClick={handleRowClick}
/>
<CreateServiceAccountModal
open={isCreateModalOpen}
onClose={(): void => setIsCreateModalOpen(false)}
onSuccess={handleCreateSuccess}
/>
<ServiceAccountDrawer
account={selectedAccount}
open={selectedAccount !== null}
onClose={handleDrawerClose}
onSuccess={handleDrawerSuccess}
/>
</>
);
}
export default ServiceAccountsSettings;

View File

@@ -0,0 +1,15 @@
export enum FilterMode {
All = 'all',
Active = 'active',
Disabled = 'disabled',
}
export interface ServiceAccountRow {
id: string;
name: string;
email: string;
roles: string[];
status: string;
createdAt: string | null;
updatedAt: string | null;
}

View File

@@ -1057,21 +1057,20 @@
gap: 8px;
.user-settings-dropdown-label-text {
color: var(--bg-slate-50, #62687c);
color: var(--l3-foreground);
font-family: Inter;
font-size: 10px;
font-family: Inter;
font-weight: 600;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
font-style: normal;
line-height: 18px; /* 163.636% */
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
}
.user-settings-dropdown-label-email {
color: var(--bg-vanilla-400, #c0c1c3);
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-size: var(--font-size-xs);
font-style: normal;
line-height: normal;
letter-spacing: 0.14px;
@@ -1079,7 +1078,7 @@
}
.ant-dropdown-menu-item-divider {
background-color: var(--bg-slate-500, #161922) !important;
background-color: var(--secondary) !important;
}
.ant-dropdown-menu-item-disabled {
@@ -1095,6 +1094,14 @@
.help-support-dropdown {
.ant-dropdown-menu-item {
min-height: 32px;
.ant-dropdown-menu-title-content {
color: var(--l1-foreground) !important;
}
.user-settings-dropdown-logout-section {
color: var(--danger-background);
}
}
}
@@ -1271,7 +1278,7 @@
}
.help-support-dropdown li.ant-dropdown-menu-item-divider {
background-color: var(--bg-slate-500, #161922) !important;
background-color: var(--secondary) !important;
}
.lightMode {
@@ -1431,22 +1438,6 @@
}
}
.settings-dropdown {
.user-settings-dropdown-logged-in-section {
.user-settings-dropdown-label-text {
color: var(--bg-ink-400);
}
.user-settings-dropdown-label-email {
color: var(--bg-ink-300);
}
}
.ant-dropdown-menu-item-divider {
background-color: var(--bg-vanilla-300) !important;
}
}
.reorder-shortcut-nav-items-modal {
.ant-modal-content {
border: 1px solid var(--bg-vanilla-300);
@@ -1503,10 +1494,6 @@
color: var(--bg-ink-400);
}
}
.help-support-dropdown li.ant-dropdown-menu-item-divider {
background-color: var(--bg-vanilla-300) !important;
}
}
.version-tooltip-overlay {

View File

@@ -69,6 +69,7 @@ import { routeConfig } from './config';
import { getQueryString } from './helper';
import {
defaultMoreMenuItems,
getUserSettingsDropdownMenuItems,
helpSupportDropdownMenuItems as DefaultHelpSupportDropdownMenuItems,
helpSupportMenuItem,
primaryMenuItems,
@@ -485,48 +486,12 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const userSettingsDropdownMenuItems: MenuProps['items'] = useMemo(
() =>
[
{
key: 'label',
label: (
<div className="user-settings-dropdown-logged-in-section">
<span className="user-settings-dropdown-label-text">LOGGED IN AS</span>
<span className="user-settings-dropdown-label-email">{user.email}</span>
</div>
),
disabled: true,
dataTestId: 'logged-in-as-nav-item',
},
{ type: 'divider' as const },
{
key: 'account',
label: 'Account Settings',
dataTestId: 'account-settings-nav-item',
},
{
key: 'workspace',
label: 'Workspace Settings',
disabled: isWorkspaceBlocked,
dataTestId: 'workspace-settings-nav-item',
},
...(isEnterpriseSelfHostedUser || isCommunityEnterpriseUser
? [
{
key: 'license',
label: 'Manage License',
dataTestId: 'manage-license-nav-item',
},
]
: []),
{ type: 'divider' as const },
{
key: 'logout',
label: (
<span className="user-settings-dropdown-logout-section">Sign out</span>
),
dataTestId: 'logout-nav-item',
},
].filter(Boolean),
getUserSettingsDropdownMenuItems({
userEmail: user.email,
isWorkspaceBlocked,
isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser,
}),
[
isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser,
@@ -856,9 +821,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
});
switch (item.key) {
case ROUTES.SHORTCUTS:
history.push(ROUTES.SHORTCUTS);
break;
case 'invite-collaborators':
history.push(`${ROUTES.ORG_SETTINGS}#invite-team-members`);
break;
@@ -878,7 +840,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
};
const handleSettingsMenuItemClick = (info: SidebarItem): void => {
const item = userSettingsDropdownMenuItems.find(
const item = (userSettingsDropdownMenuItems ?? []).find(
(item) => item?.key === info.key,
);
let menuLabel = '';
@@ -904,6 +866,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
case 'license':
history.push(ROUTES.LIST_LICENSES);
break;
case 'keyboard-shortcuts':
history.push(ROUTES.SHORTCUTS);
break;
case 'logout':
Logout();
break;

View File

@@ -0,0 +1,74 @@
import { getUserSettingsDropdownMenuItems } from 'container/SideNav/menuItems';
const BASE_PARAMS = {
userEmail: 'test@signoz.io',
isWorkspaceBlocked: false,
isEnterpriseSelfHostedUser: false,
isCommunityEnterpriseUser: false,
};
describe('getUserSettingsDropdownMenuItems', () => {
it('always includes logged-in-as label, workspace, account, keyboard shortcuts, and sign out', () => {
const items = getUserSettingsDropdownMenuItems(BASE_PARAMS);
const keys = items?.map((item) => item?.key);
expect(keys).toContain('label');
expect(keys).toContain('workspace');
expect(keys).toContain('account');
expect(keys).toContain('keyboard-shortcuts');
expect(keys).toContain('logout');
// workspace item is enabled when workspace is not blocked
const workspaceItem = items?.find(
(item: any) => item.key === 'workspace',
) as any;
expect(workspaceItem?.disabled).toBe(false);
// does not include license item for regular cloud user
expect(keys).not.toContain('license');
});
it('includes manage license item for enterprise self-hosted users', () => {
const items = getUserSettingsDropdownMenuItems({
...BASE_PARAMS,
isEnterpriseSelfHostedUser: true,
});
const keys = items?.map((item) => item?.key);
expect(keys).toContain('license');
});
it('includes manage license item for community enterprise users', () => {
const items = getUserSettingsDropdownMenuItems({
...BASE_PARAMS,
isCommunityEnterpriseUser: true,
});
const keys = items?.map((item) => item?.key);
expect(keys).toContain('license');
});
it('workspace item is disabled when workspace is blocked', () => {
const items = getUserSettingsDropdownMenuItems({
...BASE_PARAMS,
isWorkspaceBlocked: true,
});
const workspaceItem = items?.find(
(item: any) => item.key === 'workspace',
) as any;
expect(workspaceItem?.disabled).toBe(true);
});
it('returns items in correct order: label, divider, workspace, account, ..., shortcuts, divider, logout', () => {
const items = getUserSettingsDropdownMenuItems(BASE_PARAMS) ?? [];
const keys = items.map((item: any) => item.key ?? item.type);
expect(keys[0]).toBe('label');
expect(keys[1]).toBe('divider');
expect(keys[2]).toBe('workspace');
expect(keys[3]).toBe('account');
expect(keys[keys.length - 1]).toBe('logout');
});
});

View File

@@ -1,4 +1,6 @@
import { RocketOutlined } from '@ant-design/icons';
import { Style } from '@signozhq/design-tokens';
import { MenuProps } from 'antd';
import ROUTES from 'constants/routes';
import {
ArrowUpRight,
@@ -6,8 +8,10 @@ import {
BellDot,
Binoculars,
Book,
Bot,
Boxes,
BugIcon,
Building2,
ChartArea,
Cloudy,
DraftingCompass,
@@ -20,21 +24,26 @@ import {
Layers2,
LayoutGrid,
ListMinus,
LogOut,
MessageSquareText,
Plus,
Receipt,
Route,
ScrollText,
Search,
Settings,
Shield,
Slack,
Unplug,
User,
UserPlus,
Users,
} from 'lucide-react';
import { SecondaryMenuItemKey, SidebarItem } from './sideNav.types';
import {
SecondaryMenuItemKey,
SettingsNavSection,
SidebarItem,
} from './sideNav.types';
export const getStartedMenuItem = {
key: ROUTES.GET_STARTED,
@@ -188,12 +197,6 @@ export const primaryMenuItems: SidebarItem[] = [
icon: <Home size={16} />,
itemKey: 'home',
},
{
key: 'quick-search',
label: 'Search',
icon: <Search size={16} />,
itemKey: 'quick-search',
},
{
key: ROUTES.LIST_ALL_ALERT,
label: 'Alerts',
@@ -296,77 +299,114 @@ export const defaultMoreMenuItems: SidebarItem[] = [
},
];
export const settingsMenuItems: SidebarItem[] = [
export const settingsNavSections: SettingsNavSection[] = [
{
key: ROUTES.SETTINGS,
label: 'General',
icon: <Settings size={16} />,
isEnabled: true,
itemKey: 'general',
},
{
key: ROUTES.BILLING,
label: 'Billing',
icon: <Receipt size={16} />,
isEnabled: false,
itemKey: 'billing',
},
{
key: ROUTES.ROLES_SETTINGS,
label: 'Roles',
icon: <Shield size={16} />,
isEnabled: false,
itemKey: 'roles',
},
{
key: ROUTES.ORG_SETTINGS,
label: 'Members & SSO',
icon: <User size={16} />,
isEnabled: false,
itemKey: 'members-sso',
key: 'general',
items: [
{
key: ROUTES.SETTINGS,
label: 'Workspace',
icon: <Settings size={16} />,
isEnabled: true,
itemKey: 'workspace',
},
{
key: ROUTES.MY_SETTINGS,
label: 'Account',
icon: <User size={16} />,
isEnabled: true,
itemKey: 'account',
},
{
key: ROUTES.ALL_CHANNELS,
label: 'Notification Channels',
icon: <FileKey2 size={16} />,
isEnabled: true,
itemKey: 'notification-channels',
},
{
key: ROUTES.BILLING,
label: 'Billing',
icon: <Receipt size={16} />,
isEnabled: false,
itemKey: 'billing',
},
{
key: ROUTES.INTEGRATIONS,
label: 'Integrations',
icon: <Unplug size={16} />,
isEnabled: false,
itemKey: 'integrations',
},
],
},
{
key: ROUTES.INTEGRATIONS,
label: 'Integrations',
icon: <Unplug size={16} />,
isEnabled: false,
itemKey: 'integrations',
key: 'identity-access',
title: 'Identity & Access',
items: [
{
key: ROUTES.ROLES_SETTINGS,
label: 'Roles',
icon: <Shield size={16} />,
isEnabled: false,
itemKey: 'roles',
},
{
key: ROUTES.MEMBERS_SETTINGS,
label: 'Members',
icon: <Users size={16} />,
isEnabled: false,
itemKey: 'members',
},
{
key: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
label: 'Service Accounts',
icon: <Bot size={16} />,
isEnabled: false,
itemKey: 'service-accounts',
},
{
key: ROUTES.API_KEYS,
label: 'API Keys',
icon: <Key size={16} />,
isEnabled: false,
itemKey: 'api-keys',
},
{
key: ROUTES.INGESTION_SETTINGS,
label: 'Ingestion',
icon: <RocketOutlined rotate={45} />,
isEnabled: false,
itemKey: 'ingestion',
},
],
},
{
key: ROUTES.ALL_CHANNELS,
label: 'Notification Channels',
icon: <FileKey2 size={16} />,
isEnabled: true,
itemKey: 'notification-channels',
key: 'authentication',
title: 'Authentication',
items: [
{
key: ROUTES.ORG_SETTINGS,
label: 'Single Sign-on',
icon: <User size={16} />,
isEnabled: false,
itemKey: 'sso',
},
],
},
{
key: ROUTES.API_KEYS,
label: 'API Keys',
icon: <Key size={16} />,
isEnabled: false,
itemKey: 'api-keys',
},
{
key: ROUTES.INGESTION_SETTINGS,
label: 'Ingestion',
icon: <RocketOutlined rotate={45} />,
isEnabled: false,
itemKey: 'ingestion',
},
{
key: ROUTES.MY_SETTINGS,
label: 'Account Settings',
icon: <User size={16} />,
isEnabled: true,
itemKey: 'account-settings',
},
{
key: ROUTES.SHORTCUTS,
label: 'Keyboard Shortcuts',
icon: <Layers2 size={16} />,
isEnabled: true,
itemKey: 'keyboard-shortcuts',
key: 'shortcuts',
hasDivider: true,
items: [
{
key: ROUTES.SHORTCUTS,
label: 'Keyboard Shortcuts',
icon: <Keyboard size={16} />,
isEnabled: true,
itemKey: 'keyboard-shortcuts',
},
],
},
];
@@ -417,12 +457,6 @@ export const helpSupportDropdownMenuItems: SidebarItem[] = [
icon: <MessageSquareText size={14} />,
itemKey: 'chat-support',
},
{
key: ROUTES.SHORTCUTS,
label: 'Keyboard Shortcuts',
icon: <Keyboard size={14} />,
itemKey: 'keyboard-shortcuts',
},
{
key: 'invite-collaborators',
label: 'Invite a Team Member',
@@ -431,6 +465,78 @@ export const helpSupportDropdownMenuItems: SidebarItem[] = [
},
];
export interface UserSettingsMenuItemsParams {
userEmail: string;
isWorkspaceBlocked: boolean;
isEnterpriseSelfHostedUser: boolean;
isCommunityEnterpriseUser: boolean;
}
export const getUserSettingsDropdownMenuItems = ({
userEmail,
isWorkspaceBlocked,
isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser,
}: UserSettingsMenuItemsParams): MenuProps['items'] =>
[
{
key: 'label',
label: (
<div className="user-settings-dropdown-logged-in-section">
<span className="user-settings-dropdown-label-text">LOGGED IN AS</span>
<span className="user-settings-dropdown-label-email">{userEmail}</span>
</div>
),
disabled: true,
dataTestId: 'logged-in-as-nav-item',
},
{ type: 'divider' as const },
{
key: 'workspace',
label: 'Workspace Settings',
icon: <Building2 size={14} color={Style.L1_FOREGROUND} />,
disabled: isWorkspaceBlocked,
dataTestId: 'workspace-settings-nav-item',
},
{
key: 'account',
label: 'Account Settings',
icon: <User size={14} color={Style.L1_FOREGROUND} />,
dataTestId: 'account-settings-nav-item',
},
...(isEnterpriseSelfHostedUser || isCommunityEnterpriseUser
? [
{
key: 'license',
label: 'Manage License',
icon: <Shield size={14} color={Style.L1_FOREGROUND} />,
dataTestId: 'manage-license-nav-item',
},
]
: []),
{
key: 'keyboard-shortcuts',
label: 'Keyboard Shortcuts',
icon: <Keyboard size={14} color={Style.L1_FOREGROUND} />,
dataTestId: 'keyboard-shortcuts-nav-item',
},
{ type: 'divider' as const },
{
key: 'logout',
label: (
<span className="user-settings-dropdown-logout-section">Sign out</span>
),
icon: (
<LogOut
size={14}
className="user-settings-dropdown-logout-section"
color={Style.DANGER_BACKGROUND}
/>
),
dataTestId: 'logout-nav-item',
},
].filter(Boolean);
/** Mapping of some newly added routes and their corresponding active sidebar menu key */
export const NEW_ROUTES_MENU_ITEM_KEY_MAP: Record<string, string> = {
[ROUTES.TRACE]: ROUTES.TRACES_EXPLORER,

View File

@@ -24,6 +24,13 @@ export interface SidebarItem {
export const CHANGELOG_LABEL = 'Full Changelog';
export interface SettingsNavSection {
title?: string;
items: SidebarItem[];
key: string;
hasDivider?: boolean;
}
export interface DropdownSeparator {
type: 'divider' | 'group';
label?: ReactNode;

View File

@@ -153,6 +153,8 @@ export const routesToSkip = [
ROUTES.VERSION,
ROUTES.ALL_DASHBOARD,
ROUTES.ORG_SETTINGS,
ROUTES.MEMBERS_SETTINGS,
ROUTES.SERVICE_ACCOUNTS_SETTINGS,
ROUTES.INGESTION_SETTINGS,
ROUTES.API_KEYS,
ROUTES.ERROR_DETAIL,

View File

@@ -35,6 +35,7 @@ function TracesTableComponent({
widget,
queryResponse,
setRequestData,
onColumnWidthsChange,
}: TracesTableComponentProps): JSX.Element {
const [pagination, setPagination] = useState<Pagination>({
offset: 0,
@@ -131,8 +132,8 @@ function TracesTableComponent({
columns={columns}
onRow={handleRow}
sticky
widgetId={widget.id}
shouldPersistColumnWidths
columnWidths={widget.columnWidths}
onColumnWidthsChange={onColumnWidthsChange}
/>
</OverlayScrollbar>
</div>
@@ -175,6 +176,7 @@ export type TracesTableComponentProps = {
>;
widget: Widgets;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
};
export default TracesTableComponent;

View File

@@ -0,0 +1,7 @@
import MembersSettingsContainer from 'container/MembersSettings/MembersSettings';
function MembersSettings(): JSX.Element {
return <MembersSettingsContainer />;
}
export default MembersSettings;

View File

@@ -0,0 +1 @@
export { default } from 'container/ServiceAccountsSettings/ServiceAccountsSettings';

View File

@@ -31,9 +31,33 @@
.settings-page-sidenav {
width: 240px;
height: calc(100vh - 48px);
border-right: 1px solid var(--Slate-500, #161922);
background: var(--Ink-500, #0b0c0e);
margin-top: 4px;
border-right: 1px solid var(--secondary);
background: var(--sidebar-primary-foreground);
padding-top: var(--padding-1);
display: flex;
flex-direction: column;
gap: var(--spacing-12);
overflow-y: auto;
.settings-nav-section {
display: flex;
flex-direction: column;
}
.settings-nav-section--with-divider {
border-top: 1px solid var(--secondary);
padding-top: var(--padding-4);
}
.settings-nav-section-title {
font-size: var(--uppercase-small-600-font-size);
font-weight: var(--uppercase-small-600-font-weight);
letter-spacing: 0.88px;
text-transform: uppercase;
color: var(--l3-foreground);
margin-bottom: var(--margin-2);
padding: var(--padding-1) var(--padding-3);
}
.nav-item {
.nav-item-data {

View File

@@ -7,7 +7,7 @@ import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import { routeConfig } from 'container/SideNav/config';
import { getQueryString } from 'container/SideNav/helper';
import { settingsMenuItems as defaultSettingsMenuItems } from 'container/SideNav/menuItems';
import { settingsNavSections } from 'container/SideNav/menuItems';
import NavItem from 'container/SideNav/NavItem/NavItem';
import { SidebarItem } from 'container/SideNav/sideNav.types';
import useComponentPermission from 'hooks/useComponentPermission';
@@ -33,7 +33,7 @@ function SettingsPage(): JSX.Element {
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const [settingsMenuItems, setSettingsMenuItems] = useState<SidebarItem[]>(
defaultSettingsMenuItems,
settingsNavSections.flatMap((section) => section.items),
);
const isAdmin = user.role === USER_ROLES.ADMIN;
@@ -83,6 +83,8 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.SHORTCUTS
? true
: item.isEnabled,
@@ -113,6 +115,8 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.INGESTION_SETTINGS
? true
: item.isEnabled,
@@ -136,7 +140,10 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.API_KEYS || item.key === ROUTES.ORG_SETTINGS
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
? true
: item.isEnabled,
}));
@@ -252,25 +259,45 @@ function SettingsPage(): JSX.Element {
<div className="settings-page-content-container">
<div className="settings-page-sidenav" data-testid="settings-page-sidenav">
{settingsMenuItems
.filter((item) => item.isEnabled)
.map((item) => (
<NavItem
key={item.key}
item={item}
isActive={isActiveNavItem(item.key as string)}
isDisabled={false}
showIcon={false}
onClick={(event): void => {
logEvent('Settings V2: Menu clicked', {
menuLabel: item.label,
menuRoute: item.key,
});
handleMenuItemClick((event as unknown) as MouseEvent, item);
}}
dataTestId={item.itemKey}
/>
))}
{settingsNavSections.map((section) => {
const enabledItems = section.items.filter((sectionItem) =>
settingsMenuItems.some(
(item) => item.key === sectionItem.key && item.isEnabled,
),
);
if (enabledItems.length === 0) {
return null;
}
return (
<div
key={section.key}
className={`settings-nav-section${
section.hasDivider ? ' settings-nav-section--with-divider' : ''
}`}
>
{section.title && (
<div className="settings-nav-section-title">{section.title}</div>
)}
{enabledItems.map((item) => (
<NavItem
key={item.key}
item={item}
isActive={isActiveNavItem(item.key as string)}
isDisabled={false}
showIcon={false}
onClick={(event): void => {
logEvent('Settings V2: Menu clicked', {
menuLabel: item.label,
menuRoute: item.key,
});
handleMenuItemClick((event as unknown) as MouseEvent, item);
}}
dataTestId={item.itemKey}
/>
))}
</div>
);
})}
</div>
<div className="settings-page-content">

View File

@@ -0,0 +1,138 @@
import React from 'react';
import SettingsPage from 'pages/Settings/Settings';
import { render, screen, within } from 'tests/test-utils';
import { LicensePlatform } from 'types/api/licensesV3/getActive';
import { USER_ROLES } from 'types/roles';
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }): React.ReactNode =>
children,
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('lib/history', () => ({
push: jest.fn(),
listen: jest.fn(() => jest.fn()),
location: { pathname: '/settings', search: '' },
}));
const getCloudAdminOverrides = (): any => ({
activeLicense: {
key: 'test-key',
platform: LicensePlatform.CLOUD,
},
});
const getSelfHostedAdminOverrides = (): any => ({
activeLicense: {
key: 'test-key',
platform: LicensePlatform.SELF_HOSTED,
},
});
describe('SettingsPage nav sections', () => {
describe('Cloud Admin', () => {
beforeEach(() => {
render(<SettingsPage />, undefined, {
role: USER_ROLES.ADMIN,
appContextOverrides: getCloudAdminOverrides(),
initialRoute: '/settings',
});
});
it.each([
'settings-page-sidenav',
'workspace',
'account',
'notification-channels',
'billing',
'roles',
'members',
'api-keys',
'sso',
'integrations',
'ingestion',
])('renders "%s" element', (id) => {
expect(screen.getByTestId(id)).toBeInTheDocument();
});
it.each(['Identity & Access', 'Authentication'])(
'renders "%s" section title',
(text) => {
expect(screen.getByText(text)).toBeInTheDocument();
},
);
});
describe('Cloud Viewer', () => {
beforeEach(() => {
render(<SettingsPage />, undefined, {
role: USER_ROLES.VIEWER,
appContextOverrides: getCloudAdminOverrides(),
initialRoute: '/settings',
});
});
it.each(['workspace', 'account'])('renders "%s" element', (id) => {
expect(screen.getByTestId(id)).toBeInTheDocument();
});
it.each(['billing', 'roles', 'api-keys'])(
'does not render "%s" element',
(id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
},
);
});
describe('Self-hosted Admin', () => {
beforeEach(() => {
render(<SettingsPage />, undefined, {
role: USER_ROLES.ADMIN,
appContextOverrides: getSelfHostedAdminOverrides(),
initialRoute: '/settings',
});
});
it.each(['roles', 'members', 'api-keys', 'integrations', 'sso', 'ingestion'])(
'renders "%s" element',
(id) => {
expect(screen.getByTestId(id)).toBeInTheDocument();
},
);
});
describe('section structure', () => {
it('renders items in correct sections for cloud admin', () => {
const { container } = render(<SettingsPage />, undefined, {
role: USER_ROLES.ADMIN,
appContextOverrides: getCloudAdminOverrides(),
initialRoute: '/settings',
});
const sidenav = within(container).getByTestId('settings-page-sidenav');
const sections = sidenav.querySelectorAll('.settings-nav-section');
// Should have at least 2 sections (general + identity-access) for cloud admin
expect(sections.length).toBeGreaterThanOrEqual(2);
});
it('hides section entirely when all items in it are disabled', () => {
// Community user has very limited access — identity section should be hidden
render(<SettingsPage />, undefined, {
role: USER_ROLES.VIEWER,
appContextOverrides: {
activeLicense: null,
},
initialRoute: '/settings',
});
expect(screen.queryByText('IDENTITY & ACCESS')).not.toBeInTheDocument();
});
});
});

View File

@@ -17,6 +17,7 @@ import { TFunction } from 'i18next';
import {
Backpack,
BellDot,
Bot,
Building,
Cpu,
CreditCard,
@@ -26,8 +27,11 @@ import {
Plus,
Shield,
User,
Users,
} from 'lucide-react';
import ChannelsEdit from 'pages/ChannelsEdit';
import MembersSettings from 'pages/MembersSettings';
import ServiceAccountsSettings from 'pages/ServiceAccountsSettings';
import Shortcuts from 'pages/Shortcuts';
export const organizationSettings = (t: TFunction): RouteTabProps['routes'] => [
@@ -136,6 +140,19 @@ export const billingSettings = (t: TFunction): RouteTabProps['routes'] => [
},
];
export const membersSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: MembersSettings,
name: (
<div className="periscope-tab">
<Users size={16} /> {t('routes:members').toString()}
</div>
),
route: ROUTES.MEMBERS_SETTINGS,
key: ROUTES.MEMBERS_SETTINGS,
},
];
export const rolesSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: RolesSettings,
@@ -188,6 +205,22 @@ export const mySettings = (t: TFunction): RouteTabProps['routes'] => [
},
];
export const serviceAccountsSettings = (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_t: TFunction,
): RouteTabProps['routes'] => [
{
Component: ServiceAccountsSettings,
name: (
<div className="periscope-tab">
<Bot size={16} /> Service Accounts
</div>
),
route: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
key: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
},
];
export const createAlertChannels = (t: TFunction): RouteTabProps['routes'] => [
{
Component: (): JSX.Element => (

View File

@@ -11,11 +11,13 @@ import {
generalSettings,
ingestionSettings,
keyboardShortcuts,
membersSettings,
multiIngestionSettings,
mySettings,
organizationSettings,
roleDetails,
rolesSettings,
serviceAccountsSettings,
} from './config';
export const getRoutes = (
@@ -60,7 +62,11 @@ export const getRoutes = (
settings.push(...alertChannels(t));
if (isAdmin) {
settings.push(...apiKeys(t));
settings.push(
...apiKeys(t),
...membersSettings(t),
...serviceAccountsSettings(t),
);
}
// todo: Sagar - check the condition for role list and details page, to whom we want to serve

View File

@@ -14,6 +14,7 @@ export interface UserResponse {
orgId: string;
organization: string;
role: ROLES;
updatedAt?: number;
}
export interface PayloadProps {
data: UserResponse;

View File

@@ -8,6 +8,7 @@ export interface UserResponse {
orgId: string;
organization: string;
role: ROLES;
updatedAt?: number;
}
export interface PayloadProps {
data: UserResponse[];

View File

@@ -71,3 +71,5 @@ export function buildAbsolutePath({
return urlQueryString ? `${absolutePath}?${urlQueryString}` : absolutePath;
}
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

View File

@@ -99,6 +99,8 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN'],
ROLE_DETAILS: ['ADMIN'],
MEMBERS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -4439,7 +4439,7 @@
aria-hidden "^1.1.1"
react-remove-scroll "2.5.4"
"@radix-ui/react-dialog@^1.1.11", "@radix-ui/react-dialog@^1.1.6":
"@radix-ui/react-dialog@^1.1.1", "@radix-ui/react-dialog@^1.1.11", "@radix-ui/react-dialog@^1.1.6":
version "1.1.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632"
integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==
@@ -5519,6 +5519,21 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/drawer@0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@signozhq/drawer/-/drawer-0.0.4.tgz#7c6e6779602113f55df8a55076e68b9cc13c7d79"
integrity sha512-m/shStl5yVPjHjrhDAh3EeKqqTtMmZUBVlgJPUGgoNV3sFsuN6JNaaAtEJI8cQBWkbEEiHLWKVkL/vhbQ7YrUg==
dependencies:
"@radix-ui/react-dialog" "^1.1.11"
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
lucide-react "^0.445.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
vaul "^1.1.2"
"@signozhq/icons@0.1.0", "@signozhq/icons@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@signozhq/icons/-/icons-0.1.0.tgz#00dfb430dbac423bfff715876f91a7b8a72509e4"
@@ -19660,6 +19675,13 @@ value-equal@^1.0.1:
resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz"
integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==
vaul@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.2.tgz#c959f8b9dc2ed4f7d99366caee433fbef91f5ba9"
integrity sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==
dependencies:
"@radix-ui/react-dialog" "^1.1.1"
vfile-location@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-4.1.0.tgz#69df82fb9ef0a38d0d02b90dd84620e120050dd0"

View File

@@ -18,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -59,6 +60,8 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
// TODO(srikanthccv): use metadata store to fetch metric metadata
func (m *module) ListMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
ctx = m.withMetricsExplorerContext(ctx, "ListMetrics")
if err := params.Validate(); err != nil {
return nil, err
}
@@ -288,6 +291,7 @@ func (m *module) GetTreemap(ctx context.Context, orgID valuer.UUID, req *metrics
}
func (m *module) GetMetricMetadataMulti(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string]*metricsexplorertypes.MetricMetadata, error) {
if len(metricNames) == 0 {
return map[string]*metricsexplorertypes.MetricMetadata{}, nil
}
@@ -476,6 +480,8 @@ func (m *module) GetMetricAttributes(ctx context.Context, orgID valuer.UUID, req
}
func (m *module) CheckMetricExists(ctx context.Context, orgID valuer.UUID, metricName string) (bool, error) {
ctx = m.withMetricsExplorerContext(ctx, "CheckMetricExists")
sb := sqlbuilder.NewSelectBuilder()
sb.Select("count(*) > 0 as metricExists")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
@@ -512,6 +518,8 @@ func (m *module) fetchMetadataFromCache(ctx context.Context, orgID valuer.UUID,
}
func (m *module) fetchUpdatedMetadata(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string]*metricsexplorertypes.MetricMetadata, error) {
ctx = m.withMetricsExplorerContext(ctx, "fetchUpdatedMetadata")
if len(metricNames) == 0 {
return map[string]*metricsexplorertypes.MetricMetadata{}, nil
}
@@ -570,6 +578,8 @@ func (m *module) fetchUpdatedMetadata(ctx context.Context, orgID valuer.UUID, me
}
func (m *module) fetchTimeseriesMetadata(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string]*metricsexplorertypes.MetricMetadata, error) {
ctx = m.withMetricsExplorerContext(ctx, "fetchTimeseriesMetadata")
if len(metricNames) == 0 {
return map[string]*metricsexplorertypes.MetricMetadata{}, nil
}
@@ -698,6 +708,8 @@ func (m *module) validateMetricLabels(ctx context.Context, req *metricsexplorert
}
func (m *module) checkForLabelInMetric(ctx context.Context, metricName string, label string) (bool, error) {
ctx = m.withMetricsExplorerContext(ctx, "checkForLabelInMetric")
sb := sqlbuilder.NewSelectBuilder()
sb.Select("count(*) > 0 AS has_label")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
@@ -719,6 +731,7 @@ func (m *module) checkForLabelInMetric(ctx context.Context, metricName string, l
}
func (m *module) insertMetricsMetadata(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.UpdateMetricMetadataRequest) error {
ctx = m.withMetricsExplorerContext(ctx, "insertMetricsMetadata")
createdAt := time.Now().UnixMilli()
ib := sqlbuilder.NewInsertBuilder()
@@ -812,6 +825,7 @@ func (m *module) fetchMetricsStatsWithSamples(
normalized bool,
orderBy *qbtypes.OrderBy,
) ([]metricsexplorertypes.Stat, uint64, error) {
ctx = m.withMetricsExplorerContext(ctx, "fetchMetricsStatsWithSamples")
start, end, distributedTsTable, localTsTable := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), nil)
samplesTable := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil)
@@ -919,6 +933,8 @@ func (m *module) fetchMetricsStatsWithSamples(
}
func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplorertypes.TreemapRequest, filterWhereClause *sqlbuilder.WhereClause) ([]metricsexplorertypes.TreemapEntry, error) {
ctx = m.withMetricsExplorerContext(ctx, "computeTimeseriesTreemap")
start, end, distributedTsTable, _ := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), nil)
totalTSBuilder := sqlbuilder.NewSelectBuilder()
@@ -983,6 +999,8 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplo
}
func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorertypes.TreemapRequest, filterWhereClause *sqlbuilder.WhereClause) ([]metricsexplorertypes.TreemapEntry, error) {
ctx = m.withMetricsExplorerContext(ctx, "computeSamplesTreemap")
start, end, distributedTsTable, localTsTable := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), nil)
samplesTable := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil)
countExp := telemetrymetrics.CountExpressionForSamplesTable(samplesTable)
@@ -1084,6 +1102,8 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
// getMetricDataPoints returns the total number of data points (samples) for a metric.
func (m *module) getMetricDataPoints(ctx context.Context, metricName string) (uint64, error) {
ctx = m.withMetricsExplorerContext(ctx, "getMetricDataPoints")
sb := sqlbuilder.NewSelectBuilder()
sb.Select("sum(count) AS data_points")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4Agg30mTableName))
@@ -1104,6 +1124,8 @@ func (m *module) getMetricDataPoints(ctx context.Context, metricName string) (ui
// getMetricLastReceived returns the last received timestamp for a metric.
func (m *module) getMetricLastReceived(ctx context.Context, metricName string) (uint64, error) {
ctx = m.withMetricsExplorerContext(ctx, "getMetricLastReceived")
sb := sqlbuilder.NewSelectBuilder()
sb.Select("MAX(last_reported_unix_milli) AS last_received_time")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
@@ -1127,6 +1149,8 @@ func (m *module) getMetricLastReceived(ctx context.Context, metricName string) (
// getTotalTimeSeriesForMetricName returns the total number of unique time series for a metric.
func (m *module) getTotalTimeSeriesForMetricName(ctx context.Context, metricName string) (uint64, error) {
ctx = m.withMetricsExplorerContext(ctx, "getTotalTimeSeriesForMetricName")
sb := sqlbuilder.NewSelectBuilder()
sb.Select("uniq(fingerprint) AS time_series_count")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV41weekTableName))
@@ -1147,6 +1171,8 @@ func (m *module) getTotalTimeSeriesForMetricName(ctx context.Context, metricName
// getActiveTimeSeriesForMetricName returns the number of active time series for a metric within the given duration.
func (m *module) getActiveTimeSeriesForMetricName(ctx context.Context, metricName string, duration time.Duration) (uint64, error) {
ctx = m.withMetricsExplorerContext(ctx, "getActiveTimeSeriesForMetricName")
milli := time.Now().Add(-duration).UnixMilli()
sb := sqlbuilder.NewSelectBuilder()
@@ -1168,6 +1194,8 @@ func (m *module) getActiveTimeSeriesForMetricName(ctx context.Context, metricNam
}
func (m *module) fetchMetricAttributes(ctx context.Context, metricName string, start, end *int64) ([]metricsexplorertypes.MetricAttribute, error) {
ctx = m.withMetricsExplorerContext(ctx, "fetchMetricAttributes")
// Build query using sqlbuilder
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
@@ -1216,3 +1244,12 @@ func (m *module) fetchMetricAttributes(ctx context.Context, metricName string, s
return attributes, nil
}
func (m *module) withMetricsExplorerContext(ctx context.Context, functionName string) context.Context {
comments := map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
instrumentationtypes.CodeNamespace: "metrics-explorer",
instrumentationtypes.CodeFunctionName: functionName,
}
return ctxtypes.NewContextWithCommentVals(ctx, comments)
}

View File

@@ -11,6 +11,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/promotetypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -105,6 +107,11 @@ func (m *module) PromotePaths(ctx context.Context, paths []string) error {
// createIndexes creates string ngram + token filter indexes on JSON path subcolumns for LIKE queries.
func (m *module) createIndexes(ctx context.Context, indexes []schemamigrator.Index) error {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
instrumentationtypes.CodeNamespace: "promote",
instrumentationtypes.CodeFunctionName: "createIndexes",
})
if len(indexes) == 0 {
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -22,6 +23,10 @@ func NewModule(querier querier.Querier) rawdataexport.Module {
}
func (m *Module) ExportRawData(ctx context.Context, orgID valuer.UUID, rangeRequest *qbtypes.QueryRangeRequest, doneChan chan any) (chan *qbtypes.RawRow, chan error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "rawdataexport",
instrumentationtypes.CodeFunctionName: "ExportRawData",
})
spec := rangeRequest.CompositeQuery.Queries[0].Spec.(qbtypes.QueryBuilderQuery[qbtypes.LogAggregation])
rowCountLimit := spec.Limit

View File

@@ -13,6 +13,8 @@ import (
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/servicetypes/servicetypesv1"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -34,6 +36,8 @@ func NewModule(q querier.Querier, ts telemetrystore.TelemetryStore) services.Mod
// FetchTopLevelOperations returns top-level operations per service using db query
func (m *module) FetchTopLevelOperations(ctx context.Context, start time.Time, services []string) (map[string][]string, error) {
ctx = m.withServicesContext(ctx, "FetchTopLevelOperations")
db := m.TelemetryStore.ClickhouseDB()
query := fmt.Sprintf("SELECT name, serviceName, max(time) as ts FROM %s.%s WHERE time >= @start", telemetrytraces.DBName, telemetrytraces.TopLevelOperationsTableName)
args := []any{clickhouse.Named("start", start)}
@@ -70,6 +74,7 @@ func (m *module) FetchTopLevelOperations(ctx context.Context, start time.Time, s
// Get implements services.Module
// Builds a QBv5 traces aggregation grouped by service.name and maps results to ResponseItem.
func (m *module) Get(ctx context.Context, orgUUID valuer.UUID, req *servicetypesv1.Request) ([]*servicetypesv1.ResponseItem, error) {
ctx = m.withServicesContext(ctx, "Get")
if req == nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
@@ -104,6 +109,7 @@ func (m *module) Get(ctx context.Context, orgUUID valuer.UUID, req *servicetypes
// GetTopOperations implements services.Module for QBV5 based top ops
func (m *module) GetTopOperations(ctx context.Context, orgUUID valuer.UUID, req *servicetypesv1.OperationsRequest) ([]servicetypesv1.OperationItem, error) {
ctx = m.withServicesContext(ctx, "GetTopOperations")
if req == nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
@@ -124,6 +130,7 @@ func (m *module) GetTopOperations(ctx context.Context, orgUUID valuer.UUID, req
// GetEntryPointOperations implements services.Module for QBV5 based entry point ops
func (m *module) GetEntryPointOperations(ctx context.Context, orgUUID valuer.UUID, req *servicetypesv1.OperationsRequest) ([]servicetypesv1.OperationItem, error) {
ctx = m.withServicesContext(ctx, "GetEntryPointOperations")
if req == nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
@@ -517,3 +524,12 @@ func (m *module) mapEntryPointOpsQueryRangeResp(resp *qbtypes.QueryRangeResponse
}
return out
}
func (m *module) withServicesContext(ctx context.Context, functionName string) context.Context {
comments := map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
instrumentationtypes.CodeNamespace: "services",
instrumentationtypes.CodeFunctionName: functionName,
}
return ctxtypes.NewContextWithCommentVals(ctx, comments)
}

View File

@@ -8,6 +8,8 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spanpercentiletypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -27,6 +29,10 @@ func NewModule(
}
func (m *module) GetSpanPercentile(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "spanpercentile",
instrumentationtypes.CodeFunctionName: "GetSpanPercentile",
})
queryRangeRequest, err := buildSpanPercentileQuery(ctx, req)
if err != nil {
return nil, err

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