diff --git a/.github/workflows/integrationci.yaml b/.github/workflows/integrationci.yaml index 2f89a4f09e..5046aa796f 100644 --- a/.github/workflows/integrationci.yaml +++ b/.github/workflows/integrationci.yaml @@ -29,6 +29,7 @@ jobs: - name: fmt run: | make py-fmt + git diff --exit-code -- tests/integration/ - name: lint run: | make py-lint @@ -49,6 +50,7 @@ jobs: - ttl - alerts - ingestionkeys + - rootuser sqlstore-provider: - postgres - sqlite diff --git a/cmd/community/server.go b/cmd/community/server.go index c49649d7c4..b0b7425ee2 100644 --- a/cmd/community/server.go +++ b/cmd/community/server.go @@ -32,14 +32,14 @@ import ( ) func registerServer(parentCmd *cobra.Command, logger *slog.Logger) { - var flags signoz.DeprecatedFlags + var configFiles []string serverCmd := &cobra.Command{ Use: "server", Short: "Run the SigNoz server", FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, RunE: func(currCmd *cobra.Command, args []string) error { - config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags) + config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, configFiles) if err != nil { return err } @@ -48,7 +48,7 @@ func registerServer(parentCmd *cobra.Command, logger *slog.Logger) { }, } - flags.RegisterFlags(serverCmd) + serverCmd.Flags().StringArrayVar(&configFiles, "config", nil, "path to a YAML configuration file (can be specified multiple times, later files override earlier ones)") parentCmd.AddCommand(serverCmd) } diff --git a/cmd/config.go b/cmd/config.go index 85524aba62..51650b0b1b 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -10,18 +10,23 @@ import ( "github.com/SigNoz/signoz/pkg/signoz" ) -func NewSigNozConfig(ctx context.Context, logger *slog.Logger, flags signoz.DeprecatedFlags) (signoz.Config, error) { +func NewSigNozConfig(ctx context.Context, logger *slog.Logger, configFiles []string) (signoz.Config, error) { + uris := make([]string, 0, len(configFiles)+1) + for _, f := range configFiles { + uris = append(uris, "file:"+f) + } + uris = append(uris, "env:") + config, err := signoz.NewConfig( ctx, logger, config.ResolverConfig{ - Uris: []string{"env:"}, + Uris: uris, ProviderFactories: []config.ProviderFactory{ envprovider.NewFactory(), fileprovider.NewFactory(), }, }, - flags, ) if err != nil { return signoz.Config{}, err diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000000..11fca158d1 --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSigNozConfig_NoConfigFiles(t *testing.T) { + logger := slog.New(slog.DiscardHandler) + config, err := NewSigNozConfig(context.Background(), logger, nil) + require.NoError(t, err) + assert.NotZero(t, config) +} + +func TestNewSigNozConfig_SingleConfigFile(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + err := os.WriteFile(configPath, []byte(` +cache: + provider: "redis" +`), 0644) + require.NoError(t, err) + + logger := slog.New(slog.DiscardHandler) + config, err := NewSigNozConfig(context.Background(), logger, []string{configPath}) + require.NoError(t, err) + assert.Equal(t, "redis", config.Cache.Provider) +} + +func TestNewSigNozConfig_MultipleConfigFiles_LaterOverridesEarlier(t *testing.T) { + dir := t.TempDir() + + basePath := filepath.Join(dir, "base.yaml") + err := os.WriteFile(basePath, []byte(` +cache: + provider: "memory" +sqlstore: + provider: "sqlite" +`), 0644) + require.NoError(t, err) + + overridePath := filepath.Join(dir, "override.yaml") + err = os.WriteFile(overridePath, []byte(` +cache: + provider: "redis" +`), 0644) + require.NoError(t, err) + + logger := slog.New(slog.DiscardHandler) + config, err := NewSigNozConfig(context.Background(), logger, []string{basePath, overridePath}) + require.NoError(t, err) + // Later file overrides earlier + assert.Equal(t, "redis", config.Cache.Provider) + // Value from base file that wasn't overridden persists + assert.Equal(t, "sqlite", config.SQLStore.Provider) +} + +func TestNewSigNozConfig_EnvOverridesConfigFile(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + err := os.WriteFile(configPath, []byte(` +cache: + provider: "fromfile" +`), 0644) + require.NoError(t, err) + + t.Setenv("SIGNOZ_CACHE_PROVIDER", "fromenv") + + logger := slog.New(slog.DiscardHandler) + config, err := NewSigNozConfig(context.Background(), logger, []string{configPath}) + require.NoError(t, err) + // Env should override file + assert.Equal(t, "fromenv", config.Cache.Provider) +} + +func TestNewSigNozConfig_NonexistentFile(t *testing.T) { + logger := slog.New(slog.DiscardHandler) + _, err := NewSigNozConfig(context.Background(), logger, []string{"/nonexistent/config.yaml"}) + assert.Error(t, err) +} diff --git a/cmd/enterprise/server.go b/cmd/enterprise/server.go index 11afa99ea7..d0e28da307 100644 --- a/cmd/enterprise/server.go +++ b/cmd/enterprise/server.go @@ -42,14 +42,14 @@ import ( ) func registerServer(parentCmd *cobra.Command, logger *slog.Logger) { - var flags signoz.DeprecatedFlags + var configFiles []string serverCmd := &cobra.Command{ Use: "server", Short: "Run the SigNoz server", FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, RunE: func(currCmd *cobra.Command, args []string) error { - config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags) + config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, configFiles) if err != nil { return err } @@ -58,7 +58,7 @@ func registerServer(parentCmd *cobra.Command, logger *slog.Logger) { }, } - flags.RegisterFlags(serverCmd) + serverCmd.Flags().StringArrayVar(&configFiles, "config", nil, "path to a YAML configuration file (can be specified multiple times, later files override earlier ones)") parentCmd.AddCommand(serverCmd) } diff --git a/conf/example.yaml b/conf/example.yaml index 60ce89b6cd..4bce8dc3e6 100644 --- a/conf/example.yaml +++ b/conf/example.yaml @@ -328,15 +328,18 @@ user: ##################### IdentN ##################### identn: tokenizer: - # toggle the identN resolver + # toggle tokenizer identN enabled: true # headers to use for tokenizer identN resolver headers: - Authorization - Sec-WebSocket-Protocol apikey: - # toggle the identN resolver + # toggle apikey identN enabled: true # headers to use for apikey identN resolver headers: - SIGNOZ-API-KEY + impersonation: + # toggle impersonation identN, when enabled, all requests will impersonate the root user + enabled: false diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index cd95a20617..ca8e219a22 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -598,6 +598,39 @@ components: required: - config type: object + GlobaltypesAPIKeyConfig: + properties: + enabled: + type: boolean + type: object + GlobaltypesConfig: + properties: + external_url: + type: string + identN: + $ref: '#/components/schemas/GlobaltypesIdentNConfig' + ingestion_url: + type: string + type: object + GlobaltypesIdentNConfig: + properties: + apikey: + $ref: '#/components/schemas/GlobaltypesAPIKeyConfig' + impersonation: + $ref: '#/components/schemas/GlobaltypesImpersonationConfig' + tokenizer: + $ref: '#/components/schemas/GlobaltypesTokenizerConfig' + type: object + GlobaltypesImpersonationConfig: + properties: + enabled: + type: boolean + type: object + GlobaltypesTokenizerConfig: + properties: + enabled: + type: boolean + type: object MetricsexplorertypesListMetric: properties: description: @@ -2030,13 +2063,6 @@ components: required: - id type: object - TypesGettableGlobalConfig: - properties: - external_url: - type: string - ingestion_url: - type: string - type: object TypesIdentifiable: properties: id: @@ -2101,17 +2127,6 @@ components: role: type: string type: object - TypesPostableAcceptInvite: - properties: - displayName: - type: string - password: - type: string - sourceUrl: - type: string - token: - type: string - type: object TypesPostableBulkInviteRequest: properties: invites: @@ -3255,7 +3270,7 @@ paths: schema: properties: data: - $ref: '#/components/schemas/TypesGettableGlobalConfig' + $ref: '#/components/schemas/GlobaltypesConfig' status: type: string required: @@ -3263,80 +3278,16 @@ paths: - data type: object description: OK - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Unauthorized - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Forbidden "500": content: application/json: schema: $ref: '#/components/schemas/RenderErrorResponse' description: Internal Server Error - security: - - api_key: - - EDITOR - - tokenizer: - - EDITOR summary: Get global config tags: - global /api/v1/invite: - get: - deprecated: false - description: This endpoint lists all invites - operationId: ListInvite - responses: - "200": - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/TypesInvite' - type: array - status: - type: string - required: - - status - - data - type: object - description: OK - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Unauthorized - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Forbidden - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Internal Server Error - security: - - api_key: - - ADMIN - - tokenizer: - - ADMIN - summary: List invites - tags: - - users post: deprecated: false description: This endpoint creates an invite for a user @@ -3399,151 +3350,6 @@ paths: summary: Create invite tags: - users - /api/v1/invite/{id}: - delete: - deprecated: false - description: This endpoint deletes an invite by id - operationId: DeleteInvite - parameters: - - in: path - name: id - required: true - schema: - type: string - responses: - "204": - description: No Content - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Bad Request - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Unauthorized - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Forbidden - "404": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Not Found - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Internal Server Error - security: - - api_key: - - ADMIN - - tokenizer: - - ADMIN - summary: Delete invite - tags: - - users - /api/v1/invite/{token}: - get: - deprecated: false - description: This endpoint gets an invite by token - operationId: GetInvite - parameters: - - in: path - name: token - required: true - schema: - type: string - responses: - "200": - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/TypesInvite' - status: - type: string - required: - - status - - data - type: object - description: OK - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Bad Request - "404": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Not Found - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Internal Server Error - summary: Get invite - tags: - - users - /api/v1/invite/accept: - post: - deprecated: false - description: This endpoint accepts an invite by token - operationId: AcceptInvite - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/TypesPostableAcceptInvite' - responses: - "201": - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/TypesUser' - status: - type: string - required: - - status - - data - type: object - description: Created - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Bad Request - "404": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Not Found - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Internal Server Error - summary: Accept invite - tags: - - users /api/v1/invite/bulk: post: deprecated: false @@ -5814,9 +5620,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - EDITOR - tokenizer: - - ADMIN + - EDITOR summary: Get ingestion keys for workspace tags: - gateway @@ -5864,9 +5670,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - EDITOR - tokenizer: - - ADMIN + - EDITOR summary: Create ingestion key for workspace tags: - gateway @@ -5904,9 +5710,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - EDITOR - tokenizer: - - ADMIN + - EDITOR summary: Delete ingestion key for workspace tags: - gateway @@ -5948,9 +5754,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - EDITOR - tokenizer: - - ADMIN + - EDITOR summary: Update ingestion key for workspace tags: - gateway @@ -6005,9 +5811,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - EDITOR - tokenizer: - - ADMIN + - EDITOR summary: Create limit for the ingestion key tags: - gateway @@ -6045,9 +5851,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - EDITOR - tokenizer: - - ADMIN + - EDITOR summary: Delete limit for the ingestion key tags: - gateway @@ -6089,9 +5895,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - EDITOR - tokenizer: - - ADMIN + - EDITOR summary: Update limit for the ingestion key tags: - gateway @@ -6149,9 +5955,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - EDITOR - tokenizer: - - ADMIN + - EDITOR summary: Search ingestion keys for workspace tags: - gateway diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 7eb7ba320b..2c8d0fab18 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -193,6 +193,16 @@ module.exports = { ], }, ], + 'no-restricted-syntax': [ + 'error', + { + selector: + // TODO: Make this generic on removal of redux + "CallExpression[callee.property.name='getState'][callee.object.name=/^use/]", + message: + 'Avoid calling .getState() directly. Export a standalone action from the store instead.', + }, + ], }, overrides: [ { @@ -217,5 +227,13 @@ module.exports = { '@typescript-eslint/no-unused-vars': 'warn', }, }, + { + // Store definition files are the only place .getState() is permitted — + // they are the canonical source for standalone action exports. + files: ['**/*Store.{ts,tsx}'], + rules: { + 'no-restricted-syntax': 'off', + }, + }, ], }; diff --git a/frontend/package.json b/frontend/package.json index caeb483649..10a561bd28 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -137,6 +137,7 @@ "react-full-screen": "1.1.1", "react-grid-layout": "^1.3.4", "react-helmet-async": "1.3.0", + "react-hook-form": "7.71.2", "react-i18next": "^11.16.1", "react-lottie": "1.2.10", "react-markdown": "8.0.7", diff --git a/frontend/public/locales/en-GB/routes.json b/frontend/public/locales/en-GB/routes.json index 1d17b8b0c1..ada230119d 100644 --- a/frontend/public/locales/en-GB/routes.json +++ b/frontend/public/locales/en-GB/routes.json @@ -15,5 +15,6 @@ "logs_to_metrics": "Logs To Metrics", "roles": "Roles", "role_details": "Role Details", - "members": "Members" + "members": "Members", + "service_accounts": "Service Accounts" } diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index 6e41568697..11edb571ca 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -50,5 +50,8 @@ "INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring", "METER_EXPLORER": "SigNoz | Meter Explorer", "METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views", - "METER": "SigNoz | Meter" + "METER": "SigNoz | Meter", + "ROLES_SETTINGS": "SigNoz | Roles", + "MEMBERS_SETTINGS": "SigNoz | Members", + "SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts" } diff --git a/frontend/public/locales/en/routes.json b/frontend/public/locales/en/routes.json index 1d17b8b0c1..ada230119d 100644 --- a/frontend/public/locales/en/routes.json +++ b/frontend/public/locales/en/routes.json @@ -15,5 +15,6 @@ "logs_to_metrics": "Logs To Metrics", "roles": "Roles", "role_details": "Role Details", - "members": "Members" + "members": "Members", + "service_accounts": "Service Accounts" } diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 92cef52359..7bbae4276d 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -75,5 +75,6 @@ "METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views", "METER": "SigNoz | Meter", "ROLES_SETTINGS": "SigNoz | Roles", - "MEMBERS_SETTINGS": "SigNoz | Members" + "MEMBERS_SETTINGS": "SigNoz | Members", + "SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts" } diff --git a/frontend/src/api/generated/services/sigNoz.schemas.ts b/frontend/src/api/generated/services/sigNoz.schemas.ts index 78af678cd6..d51900606b 100644 --- a/frontend/src/api/generated/services/sigNoz.schemas.ts +++ b/frontend/src/api/generated/services/sigNoz.schemas.ts @@ -776,6 +776,45 @@ export interface GatewaytypesUpdatableIngestionKeyLimitDTO { tags?: string[] | null; } +export interface GlobaltypesAPIKeyConfigDTO { + /** + * @type boolean + */ + enabled?: boolean; +} + +export interface GlobaltypesConfigDTO { + /** + * @type string + */ + external_url?: string; + identN?: GlobaltypesIdentNConfigDTO; + /** + * @type string + */ + ingestion_url?: string; +} + +export interface GlobaltypesIdentNConfigDTO { + apikey?: GlobaltypesAPIKeyConfigDTO; + impersonation?: GlobaltypesImpersonationConfigDTO; + tokenizer?: GlobaltypesTokenizerConfigDTO; +} + +export interface GlobaltypesImpersonationConfigDTO { + /** + * @type boolean + */ + enabled?: boolean; +} + +export interface GlobaltypesTokenizerConfigDTO { + /** + * @type boolean + */ + enabled?: boolean; +} + export interface MetricsexplorertypesListMetricDTO { /** * @type string @@ -2402,17 +2441,6 @@ export interface TypesGettableAPIKeyDTO { userId?: string; } -export interface TypesGettableGlobalConfigDTO { - /** - * @type string - */ - external_url?: string; - /** - * @type string - */ - ingestion_url?: string; -} - export interface TypesIdentifiableDTO { /** * @type string @@ -2511,25 +2539,6 @@ export interface TypesPostableAPIKeyDTO { role?: string; } -export interface TypesPostableAcceptInviteDTO { - /** - * @type string - */ - displayName?: string; - /** - * @type string - */ - password?: string; - /** - * @type string - */ - sourceUrl?: string; - /** - * @type string - */ - token?: string; -} - export interface TypesPostableBulkInviteRequestDTO { /** * @type array @@ -3026,18 +3035,7 @@ export type GetResetPasswordToken200 = { }; export type GetGlobalConfig200 = { - data: TypesGettableGlobalConfigDTO; - /** - * @type string - */ - status: string; -}; - -export type ListInvite200 = { - /** - * @type array - */ - data: TypesInviteDTO[]; + data: GlobaltypesConfigDTO; /** * @type string */ @@ -3052,28 +3050,6 @@ export type CreateInvite201 = { status: string; }; -export type DeleteInvitePathParameters = { - id: string; -}; -export type GetInvitePathParameters = { - token: string; -}; -export type GetInvite200 = { - data: TypesInviteDTO; - /** - * @type string - */ - status: string; -}; - -export type AcceptInvite201 = { - data: TypesUserDTO; - /** - * @type string - */ - status: string; -}; - export type ListPromotedAndIndexedPaths200 = { /** * @type array diff --git a/frontend/src/api/generated/services/users/index.ts b/frontend/src/api/generated/services/users/index.ts index d9f3ed9f4e..7fcccb7e62 100644 --- a/frontend/src/api/generated/services/users/index.ts +++ b/frontend/src/api/generated/services/users/index.ts @@ -20,26 +20,20 @@ import { useMutation, useQuery } from 'react-query'; import type { BodyType, ErrorType } from '../../../generatedAPIInstance'; import { GeneratedAPIInstance } from '../../../generatedAPIInstance'; import type { - AcceptInvite201, ChangePasswordPathParameters, CreateAPIKey201, CreateInvite201, - DeleteInvitePathParameters, DeleteUserPathParameters, - GetInvite200, - GetInvitePathParameters, GetMyUser200, GetResetPasswordToken200, GetResetPasswordTokenPathParameters, GetUser200, GetUserPathParameters, ListAPIKeys200, - ListInvite200, ListUsers200, RenderErrorResponseDTO, RevokeAPIKeyPathParameters, TypesChangePasswordRequestDTO, - TypesPostableAcceptInviteDTO, TypesPostableAPIKeyDTO, TypesPostableBulkInviteRequestDTO, TypesPostableForgotPasswordDTO, @@ -255,84 +249,6 @@ export const invalidateGetResetPasswordToken = async ( return queryClient; }; -/** - * This endpoint lists all invites - * @summary List invites - */ -export const listInvite = (signal?: AbortSignal) => { - return GeneratedAPIInstance({ - url: `/api/v1/invite`, - method: 'GET', - signal, - }); -}; - -export const getListInviteQueryKey = () => { - return [`/api/v1/invite`] as const; -}; - -export const getListInviteQueryOptions = < - TData = Awaited>, - TError = ErrorType ->(options?: { - query?: UseQueryOptions>, TError, TData>; -}) => { - const { query: queryOptions } = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getListInviteQueryKey(); - - const queryFn: QueryFunction>> = ({ - signal, - }) => listInvite(signal); - - return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< - Awaited>, - TError, - TData - > & { queryKey: QueryKey }; -}; - -export type ListInviteQueryResult = NonNullable< - Awaited> ->; -export type ListInviteQueryError = ErrorType; - -/** - * @summary List invites - */ - -export function useListInvite< - TData = Awaited>, - TError = ErrorType ->(options?: { - query?: UseQueryOptions>, TError, TData>; -}): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getListInviteQueryOptions(options); - - const query = useQuery(queryOptions) as UseQueryResult & { - queryKey: QueryKey; - }; - - query.queryKey = queryOptions.queryKey; - - return query; -} - -/** - * @summary List invites - */ -export const invalidateListInvite = async ( - queryClient: QueryClient, - options?: InvalidateOptions, -): Promise => { - await queryClient.invalidateQueries( - { queryKey: getListInviteQueryKey() }, - options, - ); - - return queryClient; -}; - /** * This endpoint creates an invite for a user * @summary Create invite @@ -416,257 +332,6 @@ export const useCreateInvite = < return useMutation(mutationOptions); }; -/** - * This endpoint deletes an invite by id - * @summary Delete invite - */ -export const deleteInvite = ({ id }: DeleteInvitePathParameters) => { - return GeneratedAPIInstance({ - url: `/api/v1/invite/${id}`, - method: 'DELETE', - }); -}; - -export const getDeleteInviteMutationOptions = < - TError = ErrorType, - TContext = unknown ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { pathParams: DeleteInvitePathParameters }, - TContext - >; -}): UseMutationOptions< - Awaited>, - TError, - { pathParams: DeleteInvitePathParameters }, - TContext -> => { - const mutationKey = ['deleteInvite']; - const { mutation: mutationOptions } = options - ? options.mutation && - 'mutationKey' in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey } }; - - const mutationFn: MutationFunction< - Awaited>, - { pathParams: DeleteInvitePathParameters } - > = (props) => { - const { pathParams } = props ?? {}; - - return deleteInvite(pathParams); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type DeleteInviteMutationResult = NonNullable< - Awaited> ->; - -export type DeleteInviteMutationError = ErrorType; - -/** - * @summary Delete invite - */ -export const useDeleteInvite = < - TError = ErrorType, - TContext = unknown ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { pathParams: DeleteInvitePathParameters }, - TContext - >; -}): UseMutationResult< - Awaited>, - TError, - { pathParams: DeleteInvitePathParameters }, - TContext -> => { - const mutationOptions = getDeleteInviteMutationOptions(options); - - return useMutation(mutationOptions); -}; -/** - * This endpoint gets an invite by token - * @summary Get invite - */ -export const getInvite = ( - { token }: GetInvitePathParameters, - signal?: AbortSignal, -) => { - return GeneratedAPIInstance({ - url: `/api/v1/invite/${token}`, - method: 'GET', - signal, - }); -}; - -export const getGetInviteQueryKey = ({ token }: GetInvitePathParameters) => { - return [`/api/v1/invite/${token}`] as const; -}; - -export const getGetInviteQueryOptions = < - TData = Awaited>, - TError = ErrorType ->( - { token }: GetInvitePathParameters, - options?: { - query?: UseQueryOptions>, TError, TData>; - }, -) => { - const { query: queryOptions } = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetInviteQueryKey({ token }); - - const queryFn: QueryFunction>> = ({ - signal, - }) => getInvite({ token }, signal); - - return { - queryKey, - queryFn, - enabled: !!token, - ...queryOptions, - } as UseQueryOptions>, TError, TData> & { - queryKey: QueryKey; - }; -}; - -export type GetInviteQueryResult = NonNullable< - Awaited> ->; -export type GetInviteQueryError = ErrorType; - -/** - * @summary Get invite - */ - -export function useGetInvite< - TData = Awaited>, - TError = ErrorType ->( - { token }: GetInvitePathParameters, - options?: { - query?: UseQueryOptions>, TError, TData>; - }, -): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getGetInviteQueryOptions({ token }, options); - - const query = useQuery(queryOptions) as UseQueryResult & { - queryKey: QueryKey; - }; - - query.queryKey = queryOptions.queryKey; - - return query; -} - -/** - * @summary Get invite - */ -export const invalidateGetInvite = async ( - queryClient: QueryClient, - { token }: GetInvitePathParameters, - options?: InvalidateOptions, -): Promise => { - await queryClient.invalidateQueries( - { queryKey: getGetInviteQueryKey({ token }) }, - options, - ); - - return queryClient; -}; - -/** - * This endpoint accepts an invite by token - * @summary Accept invite - */ -export const acceptInvite = ( - typesPostableAcceptInviteDTO: BodyType, - signal?: AbortSignal, -) => { - return GeneratedAPIInstance({ - url: `/api/v1/invite/accept`, - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - data: typesPostableAcceptInviteDTO, - signal, - }); -}; - -export const getAcceptInviteMutationOptions = < - TError = ErrorType, - TContext = unknown ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { data: BodyType }, - TContext - >; -}): UseMutationOptions< - Awaited>, - TError, - { data: BodyType }, - TContext -> => { - const mutationKey = ['acceptInvite']; - const { mutation: mutationOptions } = options - ? options.mutation && - 'mutationKey' in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey } }; - - const mutationFn: MutationFunction< - Awaited>, - { data: BodyType } - > = (props) => { - const { data } = props ?? {}; - - return acceptInvite(data); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type AcceptInviteMutationResult = NonNullable< - Awaited> ->; -export type AcceptInviteMutationBody = BodyType; -export type AcceptInviteMutationError = ErrorType; - -/** - * @summary Accept invite - */ -export const useAcceptInvite = < - TError = ErrorType, - TContext = unknown ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { data: BodyType }, - TContext - >; -}): UseMutationResult< - Awaited>, - TError, - { data: BodyType }, - TContext -> => { - const mutationOptions = getAcceptInviteMutationOptions(options); - - return useMutation(mutationOptions); -}; /** * This endpoint creates a bulk invite for a user * @summary Create bulk invite diff --git a/frontend/src/api/v1/invite/get.ts b/frontend/src/api/v1/invite/get.ts deleted file mode 100644 index 00d769054f..0000000000 --- a/frontend/src/api/v1/invite/get.ts +++ /dev/null @@ -1,19 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; -import { AxiosError } from 'axios'; -import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { PayloadProps, PendingInvite } from 'types/api/user/getPendingInvites'; - -const get = async (): Promise> => { - try { - const response = await axios.get(`/invite`); - return { - httpStatusCode: response.status, - data: response.data.data, - }; - } catch (error) { - ErrorResponseHandlerV2(error as AxiosError); - } -}; - -export default get; diff --git a/frontend/src/api/v1/invite/id/accept.ts b/frontend/src/api/v1/invite/id/accept.ts deleted file mode 100644 index 68d17a080d..0000000000 --- a/frontend/src/api/v1/invite/id/accept.ts +++ /dev/null @@ -1,22 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; -import { AxiosError } from 'axios'; -import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { PayloadProps, Props } from 'types/api/user/accept'; -import { UserResponse } from 'types/api/user/getUser'; - -const accept = async ( - props: Props, -): Promise> => { - try { - const response = await axios.post(`/invite/accept`, props); - return { - httpStatusCode: response.status, - data: response.data.data, - }; - } catch (error) { - ErrorResponseHandlerV2(error as AxiosError); - } -}; - -export default accept; diff --git a/frontend/src/api/v1/invite/id/delete.ts b/frontend/src/api/v1/invite/id/delete.ts deleted file mode 100644 index ec7c6f7460..0000000000 --- a/frontend/src/api/v1/invite/id/delete.ts +++ /dev/null @@ -1,20 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; -import { AxiosError } from 'axios'; -import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { Props } from 'types/api/user/deleteInvite'; - -const del = async (props: Props): Promise> => { - try { - const response = await axios.delete(`/invite/${props.id}`); - - return { - httpStatusCode: response.status, - data: null, - }; - } catch (error) { - ErrorResponseHandlerV2(error as AxiosError); - } -}; - -export default del; diff --git a/frontend/src/api/v1/invite/id/get.ts b/frontend/src/api/v1/invite/id/get.ts deleted file mode 100644 index 6c08333492..0000000000 --- a/frontend/src/api/v1/invite/id/get.ts +++ /dev/null @@ -1,28 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; -import { AxiosError } from 'axios'; -import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { - InviteDetails, - PayloadProps, - Props, -} from 'types/api/user/getInviteDetails'; - -const getInviteDetails = async ( - props: Props, -): Promise> => { - try { - const response = await axios.get( - `/invite/${props.inviteId}?ref=${window.location.href}`, - ); - - return { - httpStatusCode: response.status, - data: response.data.data, - }; - } catch (error) { - ErrorResponseHandlerV2(error as AxiosError); - } -}; - -export default getInviteDetails; diff --git a/frontend/src/api/v1/user/id/delete.ts b/frontend/src/api/v1/user/id/delete.ts deleted file mode 100644 index 5474ad4b2f..0000000000 --- a/frontend/src/api/v1/user/id/delete.ts +++ /dev/null @@ -1,20 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; -import { AxiosError } from 'axios'; -import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { Props } from 'types/api/user/deleteUser'; - -const deleteUser = async (props: Props): Promise> => { - try { - const response = await axios.delete(`/user/${props.userId}`); - - return { - httpStatusCode: response.status, - data: null, - }; - } catch (error) { - ErrorResponseHandlerV2(error as AxiosError); - } -}; - -export default deleteUser; diff --git a/frontend/src/components/AnnouncementBanner/AnnouncementBanner.styles.scss b/frontend/src/components/AnnouncementBanner/AnnouncementBanner.styles.scss new file mode 100644 index 0000000000..a3f95c2234 --- /dev/null +++ b/frontend/src/components/AnnouncementBanner/AnnouncementBanner.styles.scss @@ -0,0 +1,97 @@ +.announcement-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-4); + padding: var(--padding-2) var(--padding-4); + height: 40px; + font-family: var(--font-sans), sans-serif; + font-size: var(--label-base-500-font-size); + line-height: var(--label-base-500-line-height); + font-weight: var(--label-base-500-font-weight); + letter-spacing: -0.065px; + + &--warning { + background-color: var(--callout-warning-background); + color: var(--callout-warning-description); + .announcement-banner__action, + .announcement-banner__dismiss { + background: var(--callout-warning-border); + } + } + + &--info { + background-color: var(--callout-primary-background); + color: var(--callout-primary-description); + .announcement-banner__action, + .announcement-banner__dismiss { + background: var(--callout-primary-border); + } + } + + &--error { + background-color: var(--callout-error-background); + color: var(--callout-error-description); + .announcement-banner__action, + .announcement-banner__dismiss { + background: var(--callout-error-border); + } + } + + &--success { + background-color: var(--callout-success-background); + color: var(--callout-success-description); + .announcement-banner__action, + .announcement-banner__dismiss { + background: var(--callout-success-border); + } + } + + &__body { + display: flex; + align-items: center; + gap: var(--spacing-4); + flex: 1; + min-width: 0; + } + + &__icon { + display: flex; + align-items: center; + flex-shrink: 0; + } + + &__message { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: var(--line-height-normal); + + strong { + font-weight: var(--font-weight-semibold); + } + } + + &__action { + height: 24px; + font-size: var(--label-small-500-font-size); + color: currentColor; + + &:hover { + opacity: 0.8; + } + } + + &__dismiss { + width: 24px; + height: 24px; + padding: 0; + color: currentColor; + + &:hover { + opacity: 0.8; + } + } +} diff --git a/frontend/src/components/AnnouncementBanner/AnnouncementBanner.test.tsx b/frontend/src/components/AnnouncementBanner/AnnouncementBanner.test.tsx new file mode 100644 index 0000000000..f960a30cdb --- /dev/null +++ b/frontend/src/components/AnnouncementBanner/AnnouncementBanner.test.tsx @@ -0,0 +1,89 @@ +import { render, screen, userEvent } from 'tests/test-utils'; + +import { + AnnouncementBanner, + AnnouncementBannerProps, + PersistedAnnouncementBanner, +} from './index'; + +const STORAGE_KEY = 'test-banner-dismissed'; + +function renderBanner(props: Partial = {}): void { + render(); +} + +afterEach(() => { + localStorage.removeItem(STORAGE_KEY); +}); + +describe('AnnouncementBanner', () => { + it('renders message and default warning variant', () => { + renderBanner({ message: Heads up }); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveClass('announcement-banner--warning'); + expect(alert).toHaveTextContent('Heads up'); + }); + + it.each(['warning', 'info', 'success', 'error'] as const)( + 'renders %s variant correctly', + (type) => { + renderBanner({ type, message: 'Test message' }); + const alert = screen.getByRole('alert'); + expect(alert).toHaveClass(`announcement-banner--${type}`); + }, + ); + + it('calls action onClick when action button is clicked', async () => { + const onClick = jest.fn() as jest.MockedFunction<() => void>; + renderBanner({ action: { label: 'Go to Settings', onClick } }); + + const user = userEvent.setup({ pointerEventsCheck: 0 }); + await user.click(screen.getByRole('button', { name: /go to settings/i })); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('hides dismiss button when onClose is not provided and hides icon when icon is null', () => { + renderBanner({ onClose: undefined, icon: null }); + + expect( + screen.queryByRole('button', { name: /dismiss/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('alert')?.querySelector('.announcement-banner__icon'), + ).not.toBeInTheDocument(); + }); +}); + +describe('PersistedAnnouncementBanner', () => { + it('dismisses on click, calls onDismiss, and persists to localStorage', async () => { + const onDismiss = jest.fn() as jest.MockedFunction<() => void>; + render( + , + ); + + const user = userEvent.setup({ pointerEventsCheck: 0 }); + await user.click(screen.getByRole('button', { name: /dismiss/i })); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(STORAGE_KEY)).toBe('true'); + }); + + it('does not render when storageKey is already set in localStorage', () => { + localStorage.setItem(STORAGE_KEY, 'true'); + render( + , + ); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/AnnouncementBanner/AnnouncementBanner.tsx b/frontend/src/components/AnnouncementBanner/AnnouncementBanner.tsx new file mode 100644 index 0000000000..5047116189 --- /dev/null +++ b/frontend/src/components/AnnouncementBanner/AnnouncementBanner.tsx @@ -0,0 +1,84 @@ +import { ReactNode } from 'react'; +import { Button } from '@signozhq/button'; +import { + CircleAlert, + CircleCheckBig, + Info, + TriangleAlert, + X, +} from '@signozhq/icons'; +import cx from 'classnames'; + +import './AnnouncementBanner.styles.scss'; + +export type AnnouncementBannerType = 'warning' | 'info' | 'error' | 'success'; + +export interface AnnouncementBannerAction { + label: string; + onClick: () => void; +} + +export interface AnnouncementBannerProps { + message: ReactNode; + type?: AnnouncementBannerType; + icon?: ReactNode | null; + action?: AnnouncementBannerAction; + onClose?: () => void; + className?: string; +} + +const DEFAULT_ICONS: Record = { + warning: , + info: , + error: , + success: , +}; + +export default function AnnouncementBanner({ + message, + type = 'warning', + icon, + action, + onClose, + className, +}: AnnouncementBannerProps): JSX.Element { + const resolvedIcon = icon === null ? null : icon ?? DEFAULT_ICONS[type]; + + return ( +
+
+ {resolvedIcon && ( + {resolvedIcon} + )} + {message} + {action && ( + + )} +
+ + {onClose && ( + + )} +
+ ); +} diff --git a/frontend/src/components/AnnouncementBanner/PersistedAnnouncementBanner.tsx b/frontend/src/components/AnnouncementBanner/PersistedAnnouncementBanner.tsx new file mode 100644 index 0000000000..d9302b0e9a --- /dev/null +++ b/frontend/src/components/AnnouncementBanner/PersistedAnnouncementBanner.tsx @@ -0,0 +1,34 @@ +import { useState } from 'react'; + +import AnnouncementBanner, { + AnnouncementBannerProps, +} from './AnnouncementBanner'; + +interface PersistedAnnouncementBannerProps extends AnnouncementBannerProps { + storageKey: string; + onDismiss?: () => void; +} + +function isDismissed(storageKey: string): boolean { + return localStorage.getItem(storageKey) === 'true'; +} + +export default function PersistedAnnouncementBanner({ + storageKey, + onDismiss, + ...props +}: PersistedAnnouncementBannerProps): JSX.Element | null { + const [visible, setVisible] = useState(() => !isDismissed(storageKey)); + + if (!visible) { + return null; + } + + const handleClose = (): void => { + localStorage.setItem(storageKey, 'true'); + setVisible(false); + onDismiss?.(); + }; + + return ; +} diff --git a/frontend/src/components/AnnouncementBanner/index.ts b/frontend/src/components/AnnouncementBanner/index.ts new file mode 100644 index 0000000000..94e1989188 --- /dev/null +++ b/frontend/src/components/AnnouncementBanner/index.ts @@ -0,0 +1,12 @@ +import AnnouncementBanner from './AnnouncementBanner'; +import PersistedAnnouncementBanner from './PersistedAnnouncementBanner'; + +export type { + AnnouncementBannerAction, + AnnouncementBannerProps, + AnnouncementBannerType, +} from './AnnouncementBanner'; + +export { AnnouncementBanner, PersistedAnnouncementBanner }; + +export default AnnouncementBanner; diff --git a/frontend/src/components/CreateServiceAccountModal/CreateServiceAccountModal.styles.scss b/frontend/src/components/CreateServiceAccountModal/CreateServiceAccountModal.styles.scss new file mode 100644 index 0000000000..1d88185762 --- /dev/null +++ b/frontend/src/components/CreateServiceAccountModal/CreateServiceAccountModal.styles.scss @@ -0,0 +1,106 @@ +.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-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); + + &__item { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + margin-bottom: var(--spacing-4); + + > 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; + } + } + + &__error { + font-size: var(--paragraph-small-400-font-size); + color: var(--destructive); + line-height: var(--paragraph-small-400-line-height); + margin: 0; + } + + &__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); + } + } +} diff --git a/frontend/src/components/CreateServiceAccountModal/CreateServiceAccountModal.tsx b/frontend/src/components/CreateServiceAccountModal/CreateServiceAccountModal.tsx new file mode 100644 index 0000000000..1b8c6c78c4 --- /dev/null +++ b/frontend/src/components/CreateServiceAccountModal/CreateServiceAccountModal.tsx @@ -0,0 +1,230 @@ +import { Controller, useForm } from 'react-hook-form'; +import { useQueryClient } from 'react-query'; +import { Button } from '@signozhq/button'; +import { DialogFooter, DialogWrapper } from '@signozhq/dialog'; +import { X } from '@signozhq/icons'; +import { Input } from '@signozhq/input'; +import { toast } from '@signozhq/sonner'; +import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs'; +import { + invalidateListServiceAccounts, + useCreateServiceAccount, +} from 'api/generated/services/serviceaccount'; +import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas'; +import { AxiosError } from 'axios'; +import RolesSelect, { useRoles } from 'components/RolesSelect'; +import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants'; +import { parseAsBoolean, useQueryState } from 'nuqs'; +import { EMAIL_REGEX } from 'utils/app'; + +import './CreateServiceAccountModal.styles.scss'; + +interface FormValues { + name: string; + email: string; + roles: string[]; +} + +function CreateServiceAccountModal(): JSX.Element { + const queryClient = useQueryClient(); + const [isOpen, setIsOpen] = useQueryState( + SA_QUERY_PARAMS.CREATE_SA, + parseAsBoolean.withDefault(false), + ); + + const { + control, + handleSubmit, + reset, + formState: { isValid, errors }, + } = useForm({ + mode: 'onChange', + defaultValues: { + name: '', + email: '', + roles: [], + }, + }); + + const { + mutate: createServiceAccount, + isLoading: isSubmitting, + } = useCreateServiceAccount({ + mutation: { + onSuccess: async () => { + toast.success('Service account created successfully', { + richColors: true, + }); + reset(); + await setIsOpen(null); + await invalidateListServiceAccounts(queryClient); + }, + onError: (err) => { + const errMessage = + convertToApiError( + err as AxiosError | null, + )?.getErrorMessage() || 'An error occurred'; + toast.error(`Failed to create service account: ${errMessage}`, { + richColors: true, + }); + }, + }, + }); + const { + roles, + isLoading: rolesLoading, + isError: rolesError, + error: rolesErrorObj, + refetch: refetchRoles, + } = useRoles(); + + function handleClose(): void { + reset(); + setIsOpen(null); + } + + function handleCreate(values: FormValues): void { + createServiceAccount({ + data: { + name: values.name.trim(), + email: values.email.trim(), + roles: values.roles, + }, + }); + } + + return ( + { + if (!open) { + handleClose(); + } + }} + showCloseButton + width="narrow" + className="create-sa-modal" + disableOutsideClick={false} + > +
+
+
+ + ( + + )} + /> + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + ( + + )} + /> + {errors.email && ( +

{errors.email.message}

+ )} +
+

+ Used only for notifications about this service account. It is not used for + authentication. +

+ +
+ + + value.length > 0 || 'At least one role is required', + }} + render={({ field }): JSX.Element => ( + + )} + /> + {errors.roles && ( +

{errors.roles.message}

+ )} +
+
+
+ + + + + + +
+ ); +} + +export default CreateServiceAccountModal; diff --git a/frontend/src/components/CreateServiceAccountModal/__tests__/CreateServiceAccountModal.test.tsx b/frontend/src/components/CreateServiceAccountModal/__tests__/CreateServiceAccountModal.test.tsx new file mode 100644 index 0000000000..8dea4e2d3c --- /dev/null +++ b/frontend/src/components/CreateServiceAccountModal/__tests__/CreateServiceAccountModal.test.tsx @@ -0,0 +1,179 @@ +import { toast } from '@signozhq/sonner'; +import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles'; +import { rest, server } from 'mocks-server/server'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; + +import CreateServiceAccountModal from '../CreateServiceAccountModal'; + +jest.mock('@signozhq/sonner', () => ({ + toast: { success: jest.fn(), error: jest.fn() }, +})); + +const mockToast = jest.mocked(toast); + +const ROLES_ENDPOINT = '*/api/v1/roles'; +const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts'; + +function renderModal(): ReturnType { + return render( + + + , + ); +} + +describe('CreateServiceAccountModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + server.use( + rest.get(ROLES_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json(listRolesSuccessResponse)), + ), + rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) => + res(ctx.status(201), ctx.json({ status: 'success', data: {} })), + ), + ); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + it('submit button is disabled when form is empty', () => { + renderModal(); + + expect( + screen.getByRole('button', { name: /Create Service Account/i }), + ).toBeDisabled(); + }); + + it('submit button remains disabled when email is invalid', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + await user.type(screen.getByPlaceholderText('Enter a name'), 'My Bot'); + await user.type( + screen.getByPlaceholderText('email@example.com'), + 'not-an-email', + ); + + await user.click(screen.getByText('Select roles')); + await user.click(await screen.findByTitle('signoz-admin')); + + await waitFor(() => + expect( + screen.getByRole('button', { name: /Create Service Account/i }), + ).toBeDisabled(), + ); + }); + + it('successful submit shows toast.success and closes modal', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + await user.type(screen.getByPlaceholderText('Enter a name'), 'Deploy Bot'); + await user.type( + screen.getByPlaceholderText('email@example.com'), + 'deploy@acme.io', + ); + + await user.click(screen.getByText('Select roles')); + await user.click(await screen.findByTitle('signoz-admin')); + + const submitBtn = screen.getByRole('button', { + name: /Create Service Account/i, + }); + await waitFor(() => expect(submitBtn).not.toBeDisabled()); + await user.click(submitBtn); + + await waitFor(() => { + expect(mockToast.success).toHaveBeenCalledWith( + 'Service account created successfully', + expect.anything(), + ); + }); + + await waitFor(() => { + expect( + screen.queryByRole('dialog', { name: /New Service Account/i }), + ).not.toBeInTheDocument(); + }); + }); + + it('shows toast.error on API error and keeps modal open', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + server.use( + rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) => + res( + ctx.status(500), + ctx.json({ status: 'error', error: 'Internal Server Error' }), + ), + ), + ); + + renderModal(); + + await user.type(screen.getByPlaceholderText('Enter a name'), 'Dupe Bot'); + await user.type( + screen.getByPlaceholderText('email@example.com'), + 'dupe@acme.io', + ); + + await user.click(screen.getByText('Select roles')); + await user.click(await screen.findByTitle('signoz-admin')); + + const submitBtn = screen.getByRole('button', { + name: /Create Service Account/i, + }); + await waitFor(() => expect(submitBtn).not.toBeDisabled()); + await user.click(submitBtn); + + await waitFor(() => { + expect(mockToast.error).toHaveBeenCalledWith( + expect.stringMatching(/Failed to create service account/i), + expect.anything(), + ); + }); + + expect( + screen.getByRole('dialog', { name: /New Service Account/i }), + ).toBeInTheDocument(); + }); + + it('Cancel button closes modal without submitting', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + await screen.findByRole('dialog', { name: /New Service Account/i }); + await user.click(screen.getByRole('button', { name: /Cancel/i })); + + expect( + screen.queryByRole('dialog', { name: /New Service Account/i }), + ).not.toBeInTheDocument(); + }); + + it('shows "Name is required" after clearing the name field', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + const nameInput = screen.getByPlaceholderText('Enter a name'); + await user.type(nameInput, 'Bot'); + await user.clear(nameInput); + + await screen.findByText('Name is required'); + }); + + it('shows "Please enter a valid email address" for a malformed email', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + await user.type( + screen.getByPlaceholderText('email@example.com'), + 'not-an-email', + ); + + await screen.findByText('Please enter a valid email address'); + }); +}); diff --git a/frontend/src/components/EditMemberDrawer/EditMemberDrawer.tsx b/frontend/src/components/EditMemberDrawer/EditMemberDrawer.tsx index d3bc9690c9..e4d3c6fe92 100644 --- a/frontend/src/components/EditMemberDrawer/EditMemberDrawer.tsx +++ b/frontend/src/components/EditMemberDrawer/EditMemberDrawer.tsx @@ -7,7 +7,6 @@ import { Check, ChevronDown, Copy, - Link, LockKeyhole, RefreshCw, Trash2, @@ -16,18 +15,21 @@ import { 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 { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs'; +import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas'; +import { + getResetPasswordToken, + useDeleteUser, + useUpdateUser, +} from 'api/generated/services/users'; +import { AxiosError } from 'axios'; 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 { MemberStatus } from 'container/MembersSettings/utils'; import { capitalize } from 'lodash-es'; import { useTimezone } from 'providers/Timezone'; import { ROLES } from 'types/roles'; +import { popupContainer } from 'utils/selectPopupContainer'; import './EditMemberDrawer.styles.scss'; @@ -36,7 +38,6 @@ export interface EditMemberDrawerProps { open: boolean; onClose: () => void; onComplete: () => void; - onRefetch?: () => void; } // eslint-disable-next-line sonarjs/cognitive-complexity @@ -45,24 +46,62 @@ function EditMemberDrawer({ open, onClose, onComplete, - onRefetch, }: EditMemberDrawerProps): JSX.Element { const { formatTimezoneAdjustedTimestamp } = useTimezone(); const [displayName, setDisplayName] = useState(''); const [selectedRole, setSelectedRole] = useState('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(null); const [showResetLinkDialog, setShowResetLinkDialog] = useState(false); const [hasCopiedResetLink, setHasCopiedResetLink] = useState(false); + const [linkType, setLinkType] = useState<'invite' | 'reset' | null>(null); 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; + + const { mutate: updateUser, isLoading: isSaving } = useUpdateUser({ + mutation: { + onSuccess: (): void => { + toast.success('Member details updated successfully', { richColors: true }); + onComplete(); + onClose(); + }, + onError: (err): void => { + const errMessage = + convertToApiError( + err as AxiosError | null, + )?.getErrorMessage() || 'An error occurred'; + toast.error(`Failed to update member details: ${errMessage}`, { + richColors: true, + }); + }, + }, + }); + + const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUser({ + mutation: { + onSuccess: (): void => { + toast.success( + isInvited ? 'Invite revoked successfully' : 'Member deleted successfully', + { richColors: true }, + ); + setShowDeleteConfirm(false); + onComplete(); + onClose(); + }, + onError: (err): void => { + const errMessage = + convertToApiError( + err as AxiosError | null, + )?.getErrorMessage() || 'An error occurred'; + const prefix = isInvited + ? 'Failed to revoke invite' + : 'Failed to delete member'; + toast.error(`${prefix}: ${errMessage}`, { richColors: true }); + }, + }, + }); useEffect(() => { if (member) { @@ -73,7 +112,7 @@ function EditMemberDrawer({ const isDirty = member !== null && - (displayName !== member.name || selectedRole !== member.role); + (displayName !== (member.name ?? '') || selectedRole !== member.role); const formatTimestamp = useCallback( (ts: string | null | undefined): string => { @@ -89,106 +128,24 @@ function EditMemberDrawer({ [formatTimezoneAdjustedTimestamp], ); - const saveInvitedMember = useCallback(async (): Promise => { - 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 => { - 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 => { + const handleSave = useCallback((): 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, - ]); + updateUser({ + pathParams: { id: member.id }, + data: { id: member.id, displayName, role: selectedRole }, + }); + }, [member, isDirty, displayName, selectedRole, updateUser]); - const handleDelete = useCallback(async (): Promise => { + const handleDelete = useCallback((): 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]); + deleteUser({ + pathParams: { id: member.id }, + }); + }, [member, deleteUser]); const handleGenerateResetLink = useCallback(async (): Promise => { if (!member) { @@ -196,11 +153,12 @@ function EditMemberDrawer({ } setIsGeneratingLink(true); try { - const response = await getResetPasswordToken({ userId: member.id }); + const response = await getResetPasswordToken({ id: member.id }); if (response?.data?.token) { const link = `${window.location.origin}/password-reset?token=${response.data.token}`; setResetLink(link); setHasCopiedResetLink(false); + setLinkType(isInvited ? 'invite' : 'reset'); setShowResetLinkDialog(true); onClose(); } else { @@ -217,7 +175,7 @@ function EditMemberDrawer({ } finally { setIsGeneratingLink(false); } - }, [member, onClose]); + }, [member, isInvited, setLinkType, onClose]); const handleCopyResetLink = useCallback(async (): Promise => { if (!resetLink) { @@ -227,36 +185,18 @@ function EditMemberDrawer({ await navigator.clipboard.writeText(resetLink); setHasCopiedResetLink(true); setTimeout(() => setHasCopiedResetLink(false), 2000); - toast.success('Reset link copied to clipboard', { richColors: true }); + toast.success( + linkType === 'invite' + ? 'Invite link copied to clipboard' + : 'Reset link copied to clipboard', + { richColors: true }, + ); } catch { toast.error('Failed to copy link', { richColors: true, }); } - }, [resetLink]); - - const handleCopyInviteLink = useCallback(async (): Promise => { - 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]); + }, [resetLink, linkType]); const handleClose = useCallback((): void => { setShowDeleteConfirm(false); @@ -303,10 +243,7 @@ function EditMemberDrawer({ onChange={(role): void => setSelectedRole(role as ROLES)} className="edit-member-drawer__role-select" suffixIcon={} - getPopupContainer={(triggerNode): HTMLElement => - (triggerNode?.closest('.edit-member-drawer') as HTMLElement) || - document.body - } + getPopupContainer={popupContainer} > {capitalize('ADMIN')} {capitalize('EDITOR')} @@ -348,30 +285,22 @@ function EditMemberDrawer({ onClick={(): void => setShowDeleteConfirm(true)} > - {isInvited ? 'Cancel Invite' : 'Delete Member'} + {isInvited ? 'Revoke Invite' : 'Delete Member'}
- - {isInvited ? ( - - ) : ( - - )} +
@@ -394,21 +323,21 @@ function EditMemberDrawer({
); - const deleteDialogTitle = isInvited ? 'Cancel Invitation' : 'Delete Member'; + const deleteDialogTitle = isInvited ? 'Revoke Invite' : 'Delete Member'; const deleteDialogBody = isInvited ? ( <> - Are you sure you want to cancel the invitation for{' '} + Are you sure you want to revoke the invite for{' '} {member?.email}? They will no longer be able to join the workspace using this invite. ) : ( <> Are you sure you want to delete{' '} - {member?.name || member?.email}? This will permanently - remove their access to the workspace. + {member?.name || member?.email}? This will remove their + access to the workspace. ); - const deleteConfirmLabel = isInvited ? 'Cancel Invite' : 'Delete Member'; + const deleteConfirmLabel = isInvited ? 'Revoke Invite' : 'Delete Member'; return ( <> @@ -434,17 +363,19 @@ function EditMemberDrawer({ onOpenChange={(isOpen): void => { if (!isOpen) { setShowResetLinkDialog(false); + setLinkType(null); } }} - title="Password Reset Link" + title={linkType === 'invite' ? 'Invite Link' : 'Password Reset Link'} showCloseButton width="base" className="reset-link-dialog" >

- This creates a one-time link the team member can use to set a new password - for their SigNoz account. + {linkType === 'invite' + ? 'Share this one-time link with the team member to complete their account setup.' + : 'This creates a one-time link the team member can use to set a new password for their SigNoz account.'}

diff --git a/frontend/src/components/EditMemberDrawer/__tests__/EditMemberDrawer.test.tsx b/frontend/src/components/EditMemberDrawer/__tests__/EditMemberDrawer.test.tsx index 52bcf22295..9742f61031 100644 --- a/frontend/src/components/EditMemberDrawer/__tests__/EditMemberDrawer.test.tsx +++ b/frontend/src/components/EditMemberDrawer/__tests__/EditMemberDrawer.test.tsx @@ -1,9 +1,11 @@ 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 { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs'; +import { + getResetPasswordToken, + useDeleteUser, + useUpdateUser, +} from 'api/generated/services/users'; import { MemberStatus } from 'container/MembersSettings/utils'; import { fireEvent, @@ -46,11 +48,16 @@ jest.mock('@signozhq/dialog', () => ({ ), })); -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('api/generated/services/users', () => ({ + useDeleteUser: jest.fn(), + useUpdateUser: jest.fn(), + getResetPasswordToken: jest.fn(), +})); + +jest.mock('api/ErrorResponseHandlerForGeneratedAPIs', () => ({ + convertToApiError: jest.fn(), +})); + jest.mock('@signozhq/sonner', () => ({ toast: { success: jest.fn(), @@ -58,9 +65,8 @@ jest.mock('@signozhq/sonner', () => ({ }, })); -const mockUpdate = jest.mocked(update); -const mockDeleteUser = jest.mocked(deleteUser); -const mockCancelInvite = jest.mocked(cancelInvite); +const mockUpdateMutate = jest.fn(); +const mockDeleteMutate = jest.fn(); const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken); const activeMember = { @@ -74,13 +80,12 @@ const activeMember = { }; const invitedMember = { - id: 'invite-abc123', + id: 'abc123', name: '', email: 'bob@signoz.io', role: 'VIEWER' as ROLES, status: MemberStatus.Invited, joinedOn: '1700000000000', - token: 'tok-xyz', }; function renderDrawer( @@ -100,9 +105,14 @@ function renderDrawer( describe('EditMemberDrawer', () => { beforeEach(() => { jest.clearAllMocks(); - mockUpdate.mockResolvedValue({ httpStatusCode: 200, data: null }); - mockDeleteUser.mockResolvedValue({ httpStatusCode: 200, data: null }); - mockCancelInvite.mockResolvedValue({ httpStatusCode: 200, data: null }); + (useUpdateUser as jest.Mock).mockReturnValue({ + mutate: mockUpdateMutate, + isLoading: false, + }); + (useDeleteUser as jest.Mock).mockReturnValue({ + mutate: mockDeleteMutate, + isLoading: false, + }); }); it('renders active member details and disables Save when form is not dirty', () => { @@ -120,6 +130,13 @@ describe('EditMemberDrawer', () => { const onComplete = jest.fn(); const user = userEvent.setup({ pointerEventsCheck: 0 }); + (useUpdateUser as jest.Mock).mockImplementation((options) => ({ + mutate: mockUpdateMutate.mockImplementation(() => { + options?.mutation?.onSuccess?.(); + }), + isLoading: false, + })); + renderDrawer({ onComplete }); const nameInput = screen.getByDisplayValue('Alice Smith'); @@ -132,10 +149,10 @@ describe('EditMemberDrawer', () => { await user.click(saveBtn); await waitFor(() => { - expect(mockUpdate).toHaveBeenCalledWith( + expect(mockUpdateMutate).toHaveBeenCalledWith( expect.objectContaining({ - userId: 'user-1', - displayName: 'Alice Updated', + pathParams: { id: 'user-1' }, + data: expect.objectContaining({ displayName: 'Alice Updated' }), }), ); expect(onComplete).toHaveBeenCalled(); @@ -146,6 +163,13 @@ describe('EditMemberDrawer', () => { const onComplete = jest.fn(); const user = userEvent.setup({ pointerEventsCheck: 0 }); + (useDeleteUser as jest.Mock).mockImplementation((options) => ({ + mutate: mockDeleteMutate.mockImplementation(() => { + options?.mutation?.onSuccess?.(); + }), + isLoading: false, + })); + renderDrawer({ onComplete }); await user.click(screen.getByRole('button', { name: /delete member/i })); @@ -158,45 +182,184 @@ describe('EditMemberDrawer', () => { await user.click(confirmBtns[confirmBtns.length - 1]); await waitFor(() => { - expect(mockDeleteUser).toHaveBeenCalledWith({ userId: 'user-1' }); + expect(mockDeleteMutate).toHaveBeenCalledWith({ + pathParams: { id: 'user-1' }, + }); expect(onComplete).toHaveBeenCalled(); }); }); - it('shows Cancel Invite and Copy Invite Link for invited members; hides Last Modified', () => { + it('shows revoke invite and copy invite link for invited members; hides Last Modified', () => { renderDrawer({ member: invitedMember }); expect( - screen.getByRole('button', { name: /cancel invite/i }), + screen.getByRole('button', { name: /revoke invite/i }), ).toBeInTheDocument(); expect( screen.getByRole('button', { name: /copy invite link/i }), ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /generate password reset link/i }), + ).not.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 () => { + it('calls deleteUser after confirming revoke invite for invited members', async () => { const onComplete = jest.fn(); const user = userEvent.setup({ pointerEventsCheck: 0 }); + (useDeleteUser as jest.Mock).mockImplementation((options) => ({ + mutate: mockDeleteMutate.mockImplementation(() => { + options?.mutation?.onSuccess?.(); + }), + isLoading: false, + })); + renderDrawer({ member: invitedMember, onComplete }); - await user.click(screen.getByRole('button', { name: /cancel invite/i })); + await user.click(screen.getByRole('button', { name: /revoke invite/i })); expect( - await screen.findByText(/are you sure you want to cancel the invitation/i), + await screen.findByText(/Are you sure you want to revoke the invite/i), ).toBeInTheDocument(); - const confirmBtns = screen.getAllByRole('button', { name: /cancel invite/i }); + const confirmBtns = screen.getAllByRole('button', { name: /revoke invite/i }); await user.click(confirmBtns[confirmBtns.length - 1]); await waitFor(() => { - expect(mockCancelInvite).toHaveBeenCalledWith({ id: 'abc123' }); + expect(mockDeleteMutate).toHaveBeenCalledWith({ + pathParams: { id: 'abc123' }, + }); expect(onComplete).toHaveBeenCalled(); }); }); + it('calls update API when saving changes for an invited member', async () => { + const onComplete = jest.fn(); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + (useUpdateUser as jest.Mock).mockImplementation((options) => ({ + mutate: mockUpdateMutate.mockImplementation(() => { + options?.mutation?.onSuccess?.(); + }), + isLoading: false, + })); + + renderDrawer({ member: { ...invitedMember, name: 'Bob' }, onComplete }); + + const nameInput = screen.getByDisplayValue('Bob'); + await user.clear(nameInput); + await user.type(nameInput, 'Bob Updated'); + + const saveBtn = screen.getByRole('button', { name: /save member details/i }); + await waitFor(() => expect(saveBtn).not.toBeDisabled()); + await user.click(saveBtn); + + await waitFor(() => { + expect(mockUpdateMutate).toHaveBeenCalledWith( + expect.objectContaining({ + pathParams: { id: 'abc123' }, + data: expect.objectContaining({ displayName: 'Bob Updated' }), + }), + ); + expect(onComplete).toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + const mockConvertToApiError = jest.mocked(convertToApiError); + + beforeEach(() => { + mockConvertToApiError.mockReturnValue({ + getErrorMessage: (): string => 'Something went wrong on server', + } as ReturnType); + }); + + it('shows API error message when updateUser fails', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const mockToast = jest.mocked(toast); + + (useUpdateUser as jest.Mock).mockImplementation((options) => ({ + mutate: mockUpdateMutate.mockImplementation(() => { + options?.mutation?.onError?.({}); + }), + isLoading: false, + })); + + renderDrawer(); + + 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(mockToast.error).toHaveBeenCalledWith( + 'Failed to update member details: Something went wrong on server', + expect.anything(), + ); + }); + }); + + it('shows API error message when deleteUser fails for active member', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const mockToast = jest.mocked(toast); + + (useDeleteUser as jest.Mock).mockImplementation((options) => ({ + mutate: mockDeleteMutate.mockImplementation(() => { + options?.mutation?.onError?.({}); + }), + isLoading: false, + })); + + renderDrawer(); + + await user.click(screen.getByRole('button', { name: /delete member/i })); + const confirmBtns = screen.getAllByRole('button', { + name: /delete member/i, + }); + await user.click(confirmBtns[confirmBtns.length - 1]); + + await waitFor(() => { + expect(mockToast.error).toHaveBeenCalledWith( + 'Failed to delete member: Something went wrong on server', + expect.anything(), + ); + }); + }); + + it('shows API error message when deleteUser fails for invited member', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const mockToast = jest.mocked(toast); + + (useDeleteUser as jest.Mock).mockImplementation((options) => ({ + mutate: mockDeleteMutate.mockImplementation(() => { + options?.mutation?.onError?.({}); + }), + isLoading: false, + })); + + renderDrawer({ member: invitedMember }); + + await user.click(screen.getByRole('button', { name: /revoke invite/i })); + const confirmBtns = screen.getAllByRole('button', { + name: /revoke invite/i, + }); + await user.click(confirmBtns[confirmBtns.length - 1]); + + await waitFor(() => { + expect(mockToast.error).toHaveBeenCalledWith( + 'Failed to revoke invite: Something went wrong on server', + expect.anything(), + ); + }); + }); + }); + describe('Generate Password Reset Link', () => { const mockWriteText = jest.fn().mockResolvedValue(undefined); let clipboardSpy: jest.SpyInstance | undefined; @@ -215,8 +378,8 @@ describe('EditMemberDrawer', () => { .spyOn(navigator.clipboard, 'writeText') .mockImplementation(mockWriteText); mockGetResetPasswordToken.mockResolvedValue({ - httpStatusCode: 200, - data: { token: 'reset-tok-abc', userId: 'user-1' }, + status: 'success', + data: { token: 'reset-tok-abc', id: 'user-1' }, }); }); @@ -237,7 +400,7 @@ describe('EditMemberDrawer', () => { name: /password reset link/i, }); expect(mockGetResetPasswordToken).toHaveBeenCalledWith({ - userId: 'user-1', + id: 'user-1', }); expect(dialog).toBeInTheDocument(); expect(dialog).toHaveTextContent('reset-tok-abc'); @@ -260,7 +423,6 @@ describe('EditMemberDrawer', () => { 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', diff --git a/frontend/src/components/InviteMembersModal/InviteMembersModal.tsx b/frontend/src/components/InviteMembersModal/InviteMembersModal.tsx index 6bc5f4e92b..acd1ebc12c 100644 --- a/frontend/src/components/InviteMembersModal/InviteMembersModal.tsx +++ b/frontend/src/components/InviteMembersModal/InviteMembersModal.tsx @@ -13,6 +13,7 @@ import { cloneDeep, debounce } from 'lodash-es'; import APIError from 'types/api/error'; import { ROLES } from 'types/roles'; import { EMAIL_REGEX } from 'utils/app'; +import { popupContainer } from 'utils/selectPopupContainer'; import { v4 as uuid } from 'uuid'; import './InviteMembersModal.styles.scss'; @@ -254,6 +255,8 @@ function InviteMembersModal({ value={row.email} onChange={(e): void => updateEmail(row.id, e.target.value)} className="team-member-email-input" + name={`invite-email-${row.id}`} + autoComplete="email" /> {emailValidity[row.id] === false && row.email.trim() !== '' && ( Invalid email address @@ -266,10 +269,7 @@ function InviteMembersModal({ className="team-member-role-select" placeholder="Select roles" suffixIcon={} - getPopupContainer={(triggerNode): HTMLElement => - (triggerNode?.closest('.invite-members-modal') as HTMLElement) || - document.body - } + getPopupContainer={popupContainer} > Viewer Editor diff --git a/frontend/src/components/MembersTable/MembersTable.styles.scss b/frontend/src/components/MembersTable/MembersTable.styles.scss index d57f4c075f..3cd5c51faf 100644 --- a/frontend/src/components/MembersTable/MembersTable.styles.scss +++ b/frontend/src/components/MembersTable/MembersTable.styles.scss @@ -162,7 +162,7 @@ font-weight: var(--paragraph-base-400-font-weight); color: var(--foreground); margin: 0; - line-height: var(--paragraph-base-400-font-height); + line-height: var(--paragraph-base-400-line-height); strong { font-weight: var(--font-weight-medium); diff --git a/frontend/src/components/MembersTable/MembersTable.tsx b/frontend/src/components/MembersTable/MembersTable.tsx index 7f19d65a43..0a58942dcc 100644 --- a/frontend/src/components/MembersTable/MembersTable.tsx +++ b/frontend/src/components/MembersTable/MembersTable.tsx @@ -1,6 +1,6 @@ import type React from 'react'; import { Badge } from '@signozhq/badge'; -import { Pagination, Table, Tooltip } from 'antd'; +import { 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'; @@ -18,7 +18,6 @@ export interface MemberRow { status: MemberStatus; joinedOn: string | null; updatedAt?: string | null; - token?: string | null; } interface MembersTableProps { @@ -64,11 +63,23 @@ function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element { ); } - return ( - - INVITED - - ); + if (status === MemberStatus.Deleted) { + return ( + + DELETED + + ); + } + + if (status === MemberStatus.Invited) { + return ( + + INVITED + + ); + } + + return ; } function MembersEmptyState({ @@ -199,14 +210,30 @@ function MembersTable({ dataSource={data} rowKey="id" loading={loading} - pagination={false} + pagination={{ + current: currentPage, + pageSize, + total, + showTotal: showPaginationTotal, + showSizeChanger: false, + onChange: onPageChange, + className: 'members-table-pagination', + hideOnSinglePage: true, + }} rowClassName={(_, index): string => index % 2 === 0 ? 'members-table-row--tinted' : '' } - onRow={(record): React.HTMLAttributes => ({ - onClick: (): void => onRowClick?.(record), - style: onRowClick ? { cursor: 'pointer' } : undefined, - })} + onRow={(record): React.HTMLAttributes => { + const isClickable = onRowClick && record.status !== MemberStatus.Deleted; + return { + onClick: (): void => { + if (isClickable) { + onRowClick(record); + } + }, + style: isClickable ? { cursor: 'pointer' } : undefined, + }; + }} onChange={(_, __, sorter): void => { if (onSortChange) { onSortChange( @@ -220,17 +247,6 @@ function MembersTable({ }} className="members-table" /> - {total > pageSize && ( - - )}
); } diff --git a/frontend/src/components/MembersTable/__tests__/MembersTable.test.tsx b/frontend/src/components/MembersTable/__tests__/MembersTable.test.tsx index 3536e83bb5..8aa962d65d 100644 --- a/frontend/src/components/MembersTable/__tests__/MembersTable.test.tsx +++ b/frontend/src/components/MembersTable/__tests__/MembersTable.test.tsx @@ -24,13 +24,12 @@ const mockActiveMembers: MemberRow[] = [ ]; const mockInvitedMember: MemberRow = { - id: 'invite-abc', + id: 'inv-abc', name: '', email: 'charlie@signoz.io', role: 'EDITOR' as ROLES, status: MemberStatus.Invited, joinedOn: null, - token: 'tok-123', }; const defaultProps = { @@ -93,6 +92,34 @@ describe('MembersTable', () => { ); }); + it('renders DELETED badge and does not call onRowClick when a deleted member row is clicked', async () => { + const onRowClick = jest.fn(); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const deletedMember: MemberRow = { + id: 'user-del', + name: 'Dave Deleted', + email: 'dave@signoz.io', + role: 'VIEWER' as ROLES, + status: MemberStatus.Deleted, + joinedOn: null, + }; + + render( + , + ); + + expect(screen.getByText('DELETED')).toBeInTheDocument(); + await user.click(screen.getByText('Dave Deleted')); + expect(onRowClick).not.toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-del' }), + ); + }); + it('shows "No members found" empty state when no data and no search query', () => { render(); diff --git a/frontend/src/components/RolesSelect/RolesSelect.styles.scss b/frontend/src/components/RolesSelect/RolesSelect.styles.scss new file mode 100644 index 0000000000..df4e61ed27 --- /dev/null +++ b/frontend/src/components/RolesSelect/RolesSelect.styles.scss @@ -0,0 +1,90 @@ +.roles-select { + width: 100%; + + // todo: styles should easeup once upgrade to select from periscope + .ant-select-selector { + min-height: 32px; + background-color: var(--l2-background) !important; + border: 1px solid var(--border) !important; + border-radius: 2px; + padding: 2px var(--padding-2) !important; + display: flex; + align-items: center; + flex-wrap: wrap; + } + + .ant-select-selection-overflow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-1); + 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 var(--padding-1) 0 6px; + line-height: var(--line-height-20); + 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); + } + + &.ant-select-focused .ant-select-selector, + &:not(.ant-select-disabled):hover .ant-select-selector { + border-color: var(--primary) !important; + } +} + +.roles-select-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-3); + padding: var(--padding-1) var(--padding-2); + color: var(--destructive); + font-size: var(--font-size-xs); + + &__msg { + display: flex; + align-items: center; + gap: var(--spacing-3); + } + + &__retry-btn { + display: flex; + align-items: center; + background: none; + border: none; + cursor: pointer; + padding: 2px; + color: var(--destructive); + } +} diff --git a/frontend/src/components/RolesSelect/RolesSelect.tsx b/frontend/src/components/RolesSelect/RolesSelect.tsx new file mode 100644 index 0000000000..b0f8c4c6e0 --- /dev/null +++ b/frontend/src/components/RolesSelect/RolesSelect.tsx @@ -0,0 +1,173 @@ +import { CircleAlert, RefreshCw } from '@signozhq/icons'; +import { Checkbox, Select } from 'antd'; +import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs'; +import { useListRoles } from 'api/generated/services/role'; +import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas'; +import cx from 'classnames'; +import APIError from 'types/api/error'; +import { popupContainer } from 'utils/selectPopupContainer'; + +import './RolesSelect.styles.scss'; + +export interface RoleOption { + label: string; + value: string; +} + +export function useRoles(): { + roles: AuthtypesRoleDTO[]; + isLoading: boolean; + isError: boolean; + error: APIError | undefined; + refetch: () => void; +} { + const { data, isLoading, isError, error, refetch } = useListRoles(); + return { + roles: data?.data ?? [], + isLoading, + isError, + error: convertToApiError(error), + refetch, + }; +} + +export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] { + return roles.map((role) => ({ + label: role.name ?? '', + value: role.name ?? '', + })); +} + +function ErrorContent({ + error, + onRefetch, +}: { + error?: APIError; + onRefetch?: () => void; +}): JSX.Element { + const errorMessage = error?.message || 'Failed to load roles'; + + return ( +
+ + + {errorMessage} + + {onRefetch && ( + + )} +
+ ); +} + +interface BaseProps { + id?: string; + placeholder?: string; + className?: string; + getPopupContainer?: (trigger: HTMLElement) => HTMLElement; + roles?: AuthtypesRoleDTO[]; + loading?: boolean; + isError?: boolean; + error?: APIError; + onRefetch?: () => void; +} + +interface SingleProps extends BaseProps { + mode?: 'single'; + value?: string; + onChange?: (role: string) => void; +} + +interface MultipleProps extends BaseProps { + mode: 'multiple'; + value?: string[]; + onChange?: (roles: string[]) => void; +} + +export type RolesSelectProps = SingleProps | MultipleProps; + +function RolesSelect(props: RolesSelectProps): JSX.Element { + const externalRoles = props.roles; + + const { + data, + isLoading: internalLoading, + isError: internalError, + error: internalErrorObj, + refetch: internalRefetch, + } = useListRoles({ + query: { enabled: externalRoles === undefined }, + }); + + const roles = externalRoles ?? data?.data ?? []; + const options = getRoleOptions(roles); + + const { + mode, + id, + placeholder = 'Select role', + className, + getPopupContainer = popupContainer, + loading = internalLoading, + isError = internalError, + error = convertToApiError(internalErrorObj), + onRefetch = externalRoles === undefined ? internalRefetch : undefined, + } = props; + + const notFoundContent = isError ? ( + + ) : undefined; + + if (mode === 'multiple') { + const { value = [], onChange } = props as MultipleProps; + return ( + + ); +} + +export default RolesSelect; diff --git a/frontend/src/components/RolesSelect/index.ts b/frontend/src/components/RolesSelect/index.ts new file mode 100644 index 0000000000..4bb99d4be9 --- /dev/null +++ b/frontend/src/components/RolesSelect/index.ts @@ -0,0 +1,2 @@ +export type { RoleOption, RolesSelectProps } from './RolesSelect'; +export { default, getRoleOptions, useRoles } from './RolesSelect'; diff --git a/frontend/src/components/ServiceAccountDrawer/AddKeyModal/AddKeyModal.styles.scss b/frontend/src/components/ServiceAccountDrawer/AddKeyModal/AddKeyModal.styles.scss new file mode 100644 index 0000000000..263ba79e5b --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/AddKeyModal/AddKeyModal.styles.scss @@ -0,0 +1,179 @@ +.add-key-modal { + [data-slot='dialog-description'] { + padding: 0; + } + + &__form { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: 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; + } + + &__input { + height: 32px; + background: var(--l2-background); + border-color: var(--border); + color: var(--l1-foreground); + box-shadow: none; + + &::placeholder { + color: var(--l3-foreground); + } + } + + &__expiry-toggle { + width: 60%; + display: flex; + border: 1px solid var(--border); + border-radius: 2px; + overflow: hidden; + padding: 0; + gap: 0; + + [data-slot='toggle-group'] { + width: 100%; + display: flex; + } + + &-btn { + flex: 1; + height: 32px; + border-radius: 0; + font-size: var(--label-small-400-font-size); + font-weight: var(--label-small-400-font-weight); + line-height: var(--label-small-400-line-height); + 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); + } + } + + .add-key-modal-datepicker-popup { + border-radius: 4px; + border: 1px solid var(--secondary); + background: var(--popover); + box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2); + } + } + + &__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; + } + + &__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: flex-end; + padding: var(--padding-4); + border-top: 1px solid var(--secondary); + } + + &__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; + } + } +} diff --git a/frontend/src/components/ServiceAccountDrawer/AddKeyModal/KeyCreatedPhase.tsx b/frontend/src/components/ServiceAccountDrawer/AddKeyModal/KeyCreatedPhase.tsx new file mode 100644 index 0000000000..dec6310262 --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/AddKeyModal/KeyCreatedPhase.tsx @@ -0,0 +1,52 @@ +import { Badge } from '@signozhq/badge'; +import { Button } from '@signozhq/button'; +import { Callout } from '@signozhq/callout'; +import { Check, Copy } from '@signozhq/icons'; +import type { ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO } from 'api/generated/services/sigNoz.schemas'; + +export interface KeyCreatedPhaseProps { + createdKey: ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO; + hasCopied: boolean; + expiryLabel: string; + onCopy: () => void; +} + +function KeyCreatedPhase({ + createdKey, + hasCopied, + expiryLabel, + onCopy, +}: KeyCreatedPhaseProps): JSX.Element { + return ( +
+
+ Key +
+ {createdKey.key} + +
+
+ +
+ Expiration + {expiryLabel} +
+ + +
+ ); +} + +export default KeyCreatedPhase; diff --git a/frontend/src/components/ServiceAccountDrawer/AddKeyModal/KeyFormPhase.tsx b/frontend/src/components/ServiceAccountDrawer/AddKeyModal/KeyFormPhase.tsx new file mode 100644 index 0000000000..cb91d32c86 --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/AddKeyModal/KeyFormPhase.tsx @@ -0,0 +1,130 @@ +import type { Control, UseFormRegister } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; +import { Button } from '@signozhq/button'; +import { Input } from '@signozhq/input'; +import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group'; +import { DatePicker } from 'antd'; +import { popupContainer } from 'utils/selectPopupContainer'; + +import { disabledDate } from '../utils'; +import type { FormValues } from './types'; +import { ExpiryMode, FORM_ID } from './types'; + +export interface KeyFormPhaseProps { + register: UseFormRegister; + control: Control; + expiryMode: ExpiryMode; + isSubmitting: boolean; + isValid: boolean; + onSubmit: () => void; + onClose: () => void; +} + +function KeyFormPhase({ + register, + control, + expiryMode, + isSubmitting, + isValid, + onSubmit, + onClose, +}: KeyFormPhaseProps): JSX.Element { + return ( + <> +
+
+ + !!v.trim(), + })} + /> +
+ +
+ Expiration + ( + { + if (val) { + field.onChange(val); + } + }} + className="add-key-modal__expiry-toggle" + > + + No Expiration + + + Set Expiration Date + + + )} + /> +
+ + {expiryMode === ExpiryMode.DATE && ( +
+ +
+ ( + + )} + /> +
+
+ )} +
+ +
+
+ + +
+
+ + ); +} + +export default KeyFormPhase; diff --git a/frontend/src/components/ServiceAccountDrawer/AddKeyModal/index.tsx b/frontend/src/components/ServiceAccountDrawer/AddKeyModal/index.tsx new file mode 100644 index 0000000000..fe4dded3d8 --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/AddKeyModal/index.tsx @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useQueryClient } from 'react-query'; +import { DialogWrapper } from '@signozhq/dialog'; +import { toast } from '@signozhq/sonner'; +import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs'; +import { + invalidateListServiceAccountKeys, + useCreateServiceAccountKey, +} from 'api/generated/services/serviceaccount'; +import type { + RenderErrorResponseDTO, + ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO, +} from 'api/generated/services/sigNoz.schemas'; +import { AxiosError } from 'axios'; +import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; +import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants'; +import { parseAsBoolean, useQueryState } from 'nuqs'; + +import KeyCreatedPhase from './KeyCreatedPhase'; +import KeyFormPhase from './KeyFormPhase'; +import type { FormValues } from './types'; +import { DEFAULT_FORM_VALUES, ExpiryMode, Phase, PHASE_TITLES } from './types'; + +import './AddKeyModal.styles.scss'; + +function AddKeyModal(): JSX.Element { + const queryClient = useQueryClient(); + const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT); + const [isAddKeyOpen, setIsAddKeyOpen] = useQueryState( + SA_QUERY_PARAMS.ADD_KEY, + parseAsBoolean.withDefault(false), + ); + const open = isAddKeyOpen && !!accountId; + + const [phase, setPhase] = useState(Phase.FORM); + const [ + createdKey, + setCreatedKey, + ] = useState(null); + const [hasCopied, setHasCopied] = useState(false); + + const { + control, + register, + handleSubmit, + reset, + watch, + formState: { isValid }, + } = useForm({ + mode: 'onChange', + defaultValues: DEFAULT_FORM_VALUES, + }); + + const expiryMode = watch('expiryMode'); + const expiryDate = watch('expiryDate'); + + useEffect(() => { + if (open) { + setPhase(Phase.FORM); + setCreatedKey(null); + setHasCopied(false); + reset(); + } + }, [open, reset]); + + const { + mutate: createKey, + isLoading: isSubmitting, + } = useCreateServiceAccountKey({ + mutation: { + onSuccess: async (response) => { + const keyData = response?.data; + if (keyData) { + setCreatedKey(keyData); + setPhase(Phase.CREATED); + if (accountId) { + await invalidateListServiceAccountKeys(queryClient, { id: accountId }); + } + } + }, + onError: (error) => { + const errMessage = + convertToApiError( + error as AxiosError | null, + )?.getErrorMessage() || 'Failed to create key'; + toast.error(errMessage, { richColors: true }); + }, + }, + }); + + function handleCreate({ + keyName, + expiryMode: mode, + expiryDate: date, + }: FormValues): void { + if (!accountId) { + return; + } + const expiresAt = + mode === ExpiryMode.DATE && date ? date.endOf('day').unix() : 0; + createKey({ + pathParams: { id: accountId }, + data: { name: keyName.trim(), expiresAt }, + }); + } + + const handleCopy = useCallback(async (): Promise => { + 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 => { + setIsAddKeyOpen(null); + }, [setIsAddKeyOpen]); + + function getExpiryLabel(): string { + if (expiryMode === ExpiryMode.NONE || !expiryDate) { + return 'Never'; + } + try { + return expiryDate.format(DATE_TIME_FORMATS.MONTH_DATE); + } catch { + return 'Never'; + } + } + + return ( + { + if (!isOpen) { + handleClose(); + } + }} + title={PHASE_TITLES[phase]} + width="base" + className="add-key-modal" + showCloseButton + disableOutsideClick={false} + > + {phase === Phase.FORM && ( + + )} + + {phase === Phase.CREATED && createdKey && ( + + )} + + ); +} + +export default AddKeyModal; diff --git a/frontend/src/components/ServiceAccountDrawer/AddKeyModal/types.ts b/frontend/src/components/ServiceAccountDrawer/AddKeyModal/types.ts new file mode 100644 index 0000000000..a037d75a19 --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/AddKeyModal/types.ts @@ -0,0 +1,30 @@ +import type { Dayjs } from 'dayjs'; + +export const enum Phase { + FORM = 'form', + CREATED = 'created', +} + +export const enum ExpiryMode { + NONE = 'none', + DATE = 'date', +} + +export const FORM_ID = 'add-key-form'; + +export const PHASE_TITLES: Record = { + [Phase.FORM]: 'Add a New Key', + [Phase.CREATED]: 'Key Created Successfully', +}; + +export interface FormValues { + keyName: string; + expiryMode: ExpiryMode; + expiryDate: Dayjs | null; +} + +export const DEFAULT_FORM_VALUES: FormValues = { + keyName: '', + expiryMode: ExpiryMode.NONE, + expiryDate: null, +}; diff --git a/frontend/src/components/ServiceAccountDrawer/DisableAccountModal.tsx b/frontend/src/components/ServiceAccountDrawer/DisableAccountModal.tsx new file mode 100644 index 0000000000..32deed1068 --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/DisableAccountModal.tsx @@ -0,0 +1,109 @@ +import { useQueryClient } from 'react-query'; +import { Button } from '@signozhq/button'; +import { DialogFooter, DialogWrapper } from '@signozhq/dialog'; +import { PowerOff, X } from '@signozhq/icons'; +import { toast } from '@signozhq/sonner'; +import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs'; +import { + getGetServiceAccountQueryKey, + invalidateListServiceAccounts, + useUpdateServiceAccountStatus, +} from 'api/generated/services/serviceaccount'; +import type { + RenderErrorResponseDTO, + ServiceaccounttypesServiceAccountDTO, +} from 'api/generated/services/sigNoz.schemas'; +import { AxiosError } from 'axios'; +import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants'; +import { parseAsBoolean, useQueryState } from 'nuqs'; + +function DisableAccountModal(): JSX.Element { + const queryClient = useQueryClient(); + const [accountId, setAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT); + const [isDisableOpen, setIsDisableOpen] = useQueryState( + SA_QUERY_PARAMS.DISABLE_SA, + parseAsBoolean.withDefault(false), + ); + const open = !!isDisableOpen && !!accountId; + + const cachedAccount = accountId + ? queryClient.getQueryData<{ + data: ServiceaccounttypesServiceAccountDTO; + }>(getGetServiceAccountQueryKey({ id: accountId })) + : null; + const accountName = cachedAccount?.data?.name; + + const { + mutate: updateStatus, + isLoading: isDisabling, + } = useUpdateServiceAccountStatus({ + mutation: { + onSuccess: async () => { + toast.success('Service account disabled', { richColors: true }); + await setIsDisableOpen(null); + await setAccountId(null); + await invalidateListServiceAccounts(queryClient); + }, + onError: (error) => { + const errMessage = + convertToApiError( + error as AxiosError | null, + )?.getErrorMessage() || 'Failed to disable service account'; + toast.error(errMessage, { richColors: true }); + }, + }, + }); + + function handleConfirm(): void { + if (!accountId) { + return; + } + updateStatus({ + pathParams: { id: accountId }, + data: { status: 'DISABLED' }, + }); + } + + function handleCancel(): void { + setIsDisableOpen(null); + } + + return ( + { + if (!isOpen) { + handleCancel(); + } + }} + title={`Disable service account ${accountName ?? ''}?`} + width="narrow" + className="alert-dialog sa-disable-dialog" + showCloseButton={false} + disableOutsideClick={false} + > +

+ Disabling this service account will revoke access for all its keys. Any + systems using this account will lose access immediately. +

+ + + + +
+ ); +} + +export default DisableAccountModal; diff --git a/frontend/src/components/ServiceAccountDrawer/EditKeyModal/EditKeyForm.tsx b/frontend/src/components/ServiceAccountDrawer/EditKeyModal/EditKeyForm.tsx new file mode 100644 index 0000000000..a05277b8de --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/EditKeyModal/EditKeyForm.tsx @@ -0,0 +1,165 @@ +import type { Control, UseFormRegister } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; +import { Badge } from '@signozhq/badge'; +import { Button } from '@signozhq/button'; +import { LockKeyhole, Trash2, X } from '@signozhq/icons'; +import { Input } from '@signozhq/input'; +import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group'; +import { DatePicker } from 'antd'; +import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas'; +import { popupContainer } from 'utils/selectPopupContainer'; + +import { disabledDate, formatLastObservedAt } from '../utils'; +import type { FormValues } from './types'; +import { ExpiryMode, FORM_ID } from './types'; + +export interface EditKeyFormProps { + register: UseFormRegister; + control: Control; + expiryMode: ExpiryMode; + keyItem: ServiceaccounttypesFactorAPIKeyDTO | null; + isSaving: boolean; + isDirty: boolean; + onSubmit: () => void; + onClose: () => void; + onRevokeClick: () => void; + formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string; +} + +function EditKeyForm({ + register, + control, + expiryMode, + keyItem, + isSaving, + isDirty, + onSubmit, + onClose, + onRevokeClick, + formatTimezoneAdjustedTimestamp, +}: EditKeyFormProps): JSX.Element { + return ( + <> +
+
+ + +
+ +
+ +
+ ******************** + +
+
+ +
+ Expiration + ( + { + if (val) { + field.onChange(val); + } + }} + className="edit-key-modal__expiry-toggle" + > + + No Expiration + + + Set Expiration Date + + + )} + /> +
+ + {expiryMode === ExpiryMode.DATE && ( +
+ +
+ ( + + )} + /> +
+
+ )} + +
+ Last Observed At + + {formatLastObservedAt( + keyItem?.lastObservedAt ?? null, + formatTimezoneAdjustedTimestamp, + )} + +
+
+ +
+ +
+ + +
+
+ + ); +} + +export default EditKeyForm; diff --git a/frontend/src/components/ServiceAccountDrawer/EditKeyModal/EditKeyModal.styles.scss b/frontend/src/components/ServiceAccountDrawer/EditKeyModal/EditKeyModal.styles.scss new file mode 100644 index 0000000000..af5395a97e --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/EditKeyModal/EditKeyModal.styles.scss @@ -0,0 +1,188 @@ +.edit-key-modal { + [data-slot='dialog-description'] { + padding: 0; + } + + &__form { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--padding-4); + } + + &__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + } + + &__label { + font-size: 13px; + 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: 13px; + font-family: monospace; + color: var(--foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + letter-spacing: 2px; + } + + &__lock-icon { + color: var(--foreground); + flex-shrink: 0; + margin-left: 6px; + opacity: 0.6; + } + + &__expiry-toggle { + width: 60%; + display: flex; + border: 1px solid var(--border); + border-radius: 2px; + overflow: hidden; + padding: 0; + gap: 0; + + [data-slot='toggle-group'] { + width: 100%; + display: flex; + } + + &-btn { + flex: 1; + height: 32px; + border-radius: 0; + font-size: var(--label-small-400-font-size); + font-weight: var(--label-small-400-font-weight); + line-height: var(--label-small-400-line-height); + justify-content: center; + background: transparent; + border: none; + border-right: 1px solid var(--border); + color: var(--foreground); + white-space: nowrap; + + &: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: 13px; + } + + .ant-picker-suffix { + color: var(--foreground); + } + } + + .edit-key-modal-datepicker-popup { + border-radius: 4px; + border: 1px solid var(--secondary); + background: var(--popover); + box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2); + } + } + + &__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; + padding: var(--padding-4); + border-top: 1px solid var(--secondary); + } + + &__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; + } + } +} diff --git a/frontend/src/components/ServiceAccountDrawer/EditKeyModal/index.tsx b/frontend/src/components/ServiceAccountDrawer/EditKeyModal/index.tsx new file mode 100644 index 0000000000..e03c6fb421 --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/EditKeyModal/index.tsx @@ -0,0 +1,189 @@ +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useQueryClient } from 'react-query'; +import { DialogWrapper } from '@signozhq/dialog'; +import { toast } from '@signozhq/sonner'; +import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs'; +import { + invalidateListServiceAccountKeys, + useRevokeServiceAccountKey, + useUpdateServiceAccountKey, +} from 'api/generated/services/serviceaccount'; +import type { + RenderErrorResponseDTO, + ServiceaccounttypesFactorAPIKeyDTO, +} from 'api/generated/services/sigNoz.schemas'; +import { AxiosError } from 'axios'; +import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants'; +import dayjs from 'dayjs'; +import { parseAsString, useQueryState } from 'nuqs'; +import { useTimezone } from 'providers/Timezone'; + +import { RevokeKeyContent } from '../RevokeKeyModal'; +import EditKeyForm from './EditKeyForm'; +import type { FormValues } from './types'; +import { DEFAULT_FORM_VALUES, ExpiryMode } from './types'; + +import './EditKeyModal.styles.scss'; + +export interface EditKeyModalProps { + keyItem: ServiceaccounttypesFactorAPIKeyDTO | null; +} + +function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element { + const queryClient = useQueryClient(); + const [selectedAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT); + const [editKeyId, setEditKeyId] = useQueryState( + SA_QUERY_PARAMS.EDIT_KEY, + parseAsString.withDefault(''), + ); + + const open = !!editKeyId && !!selectedAccountId; + + const { formatTimezoneAdjustedTimestamp } = useTimezone(); + const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false); + + const { + register, + control, + reset, + watch, + formState: { isDirty }, + handleSubmit, + } = useForm({ + defaultValues: DEFAULT_FORM_VALUES, + }); + + useEffect(() => { + if (keyItem) { + reset({ + name: keyItem.name ?? '', + expiryMode: keyItem.expiresAt === 0 ? ExpiryMode.NONE : ExpiryMode.DATE, + expiresAt: keyItem.expiresAt === 0 ? null : dayjs.unix(keyItem.expiresAt), + }); + } + }, [keyItem?.id, reset]); // eslint-disable-line react-hooks/exhaustive-deps + + const expiryMode = watch('expiryMode'); + + const { mutate: updateKey, isLoading: isSaving } = useUpdateServiceAccountKey({ + mutation: { + onSuccess: async () => { + toast.success('Key updated successfully', { richColors: true }); + await setEditKeyId(null); + if (selectedAccountId) { + await invalidateListServiceAccountKeys(queryClient, { + id: selectedAccountId, + }); + } + }, + onError: (error) => { + const errMessage = + convertToApiError( + error as AxiosError | null, + )?.getErrorMessage() || 'Failed to update key'; + toast.error(errMessage, { richColors: true }); + }, + }, + }); + + const { + mutate: revokeKey, + isLoading: isRevoking, + } = useRevokeServiceAccountKey({ + mutation: { + onSuccess: async () => { + toast.success('Key revoked successfully', { richColors: true }); + setIsRevokeConfirmOpen(false); + await setEditKeyId(null); + if (selectedAccountId) { + await invalidateListServiceAccountKeys(queryClient, { + id: selectedAccountId, + }); + } + }, + onError: (error) => { + const errMessage = + convertToApiError( + error as AxiosError | null, + )?.getErrorMessage() || 'Failed to revoke key'; + toast.error(errMessage, { richColors: true }); + }, + }, + }); + + function handleClose(): void { + setEditKeyId(null); + setIsRevokeConfirmOpen(false); + } + + const onSubmit = handleSubmit( + ({ name, expiryMode: mode, expiresAt }): void => { + if (!keyItem || !selectedAccountId) { + return; + } + const currentExpiresAt = + mode === ExpiryMode.NONE || !expiresAt ? 0 : expiresAt.endOf('day').unix(); + updateKey({ + pathParams: { id: selectedAccountId, fid: keyItem.id }, + data: { name, expiresAt: currentExpiresAt }, + }); + }, + ); + + function handleRevoke(): void { + if (!keyItem || !selectedAccountId) { + return; + } + revokeKey({ pathParams: { id: selectedAccountId, fid: keyItem.id } }); + } + + return ( + { + if (!isOpen) { + if (isRevokeConfirmOpen) { + setIsRevokeConfirmOpen(false); + } else { + handleClose(); + } + } + }} + title={ + isRevokeConfirmOpen + ? `Revoke ${keyItem?.name ?? 'key'}?` + : 'Edit Key Details' + } + width={isRevokeConfirmOpen ? 'narrow' : 'base'} + className={ + isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal' + } + showCloseButton={!isRevokeConfirmOpen} + disableOutsideClick={false} + > + {isRevokeConfirmOpen ? ( + setIsRevokeConfirmOpen(false)} + onConfirm={handleRevoke} + /> + ) : ( + setIsRevokeConfirmOpen(true)} + formatTimezoneAdjustedTimestamp={formatTimezoneAdjustedTimestamp} + /> + )} + + ); +} + +export default EditKeyModal; diff --git a/frontend/src/components/ServiceAccountDrawer/EditKeyModal/types.ts b/frontend/src/components/ServiceAccountDrawer/EditKeyModal/types.ts new file mode 100644 index 0000000000..003dd80118 --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/EditKeyModal/types.ts @@ -0,0 +1,20 @@ +import type { Dayjs } from 'dayjs'; + +export const enum ExpiryMode { + NONE = 'none', + DATE = 'date', +} + +export const FORM_ID = 'edit-key-form'; + +export interface FormValues { + name: string; + expiryMode: ExpiryMode; + expiresAt: Dayjs | null; +} + +export const DEFAULT_FORM_VALUES: FormValues = { + name: '', + expiryMode: ExpiryMode.NONE, + expiresAt: null, +}; diff --git a/frontend/src/components/ServiceAccountDrawer/KeysTab.tsx b/frontend/src/components/ServiceAccountDrawer/KeysTab.tsx new file mode 100644 index 0000000000..f33966f72b --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/KeysTab.tsx @@ -0,0 +1,237 @@ +import { useCallback, useMemo } from 'react'; +import { Button } from '@signozhq/button'; +import { KeyRound, X } from '@signozhq/icons'; +import { Skeleton, Table, Tooltip } from 'antd'; +import type { ColumnsType } from 'antd/es/table/interface'; +import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas'; +import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'; +import { useTimezone } from 'providers/Timezone'; + +import EditKeyModal from './EditKeyModal'; +import RevokeKeyModal from './RevokeKeyModal'; +import { formatLastObservedAt } from './utils'; + +interface KeysTabProps { + keys: ServiceaccounttypesFactorAPIKeyDTO[]; + isLoading: boolean; + isDisabled?: boolean; + currentPage: number; + pageSize: number; +} + +interface BuildColumnsParams { + isDisabled: boolean; + onRevokeClick: (keyId: string) => void; + handleformatLastObservedAt: ( + lastObservedAt: Date | null | undefined, + ) => string; +} + +function formatExpiry(expiresAt: number): JSX.Element { + if (expiresAt === 0) { + return Never; + } + const expiryDate = dayjs.unix(expiresAt); + if (expiryDate.isBefore(dayjs())) { + return Expired; + } + return {expiryDate.format(DATE_TIME_FORMATS.MONTH_DATE)}; +} + +function buildColumns({ + isDisabled, + onRevokeClick, + handleformatLastObservedAt, +}: BuildColumnsParams): ColumnsType { + return [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + className: 'keys-tab__name-column', + sorter: (a, b): number => (a.name ?? '').localeCompare(b.name ?? ''), + render: (_, record): JSX.Element => ( + {record.name ?? '—'} + ), + }, + { + title: 'Expiry', + dataIndex: 'expiresAt', + key: 'expiry', + width: 160, + align: 'right' as const, + sorter: (a, b): number => { + const aVal = a.expiresAt === 0 ? Infinity : a.expiresAt; + const bVal = b.expiresAt === 0 ? Infinity : b.expiresAt; + return aVal - bVal; + }, + render: (expiresAt: number): JSX.Element => formatExpiry(expiresAt), + }, + { + title: 'Last Observed At', + dataIndex: 'lastObservedAt', + key: 'lastObservedAt', + width: 220, + align: 'right' as const, + sorter: (a, b): number => { + const aVal = a.lastObservedAt + ? new Date(a.lastObservedAt).getTime() + : -Infinity; + const bVal = b.lastObservedAt + ? new Date(b.lastObservedAt).getTime() + : -Infinity; + return aVal - bVal; + }, + render: (lastObservedAt: Date | null | undefined): string => + handleformatLastObservedAt(lastObservedAt), + }, + { + title: '', + key: 'action', + width: 48, + align: 'right' as const, + render: (_, record): JSX.Element => ( + + + + ), + }, + ]; +} + +function KeysTab({ + keys, + isLoading, + isDisabled = false, + currentPage, + pageSize, +}: KeysTabProps): JSX.Element { + const [, setIsAddKeyOpen] = useQueryState( + 'add-key', + parseAsBoolean.withDefault(false), + ); + const { formatTimezoneAdjustedTimestamp } = useTimezone(); + const [editKeyId, setEditKeyId] = useQueryState( + 'edit-key', + parseAsString.withDefault(''), + ); + const [, setRevokeKeyId] = useQueryState( + 'revoke-key', + parseAsString.withDefault(''), + ); + const editKey = keys.find((k) => k.id === editKeyId) ?? null; + + const handleformatLastObservedAt = useCallback( + (lastObservedAt: Date | null | undefined): string => + formatLastObservedAt(lastObservedAt, formatTimezoneAdjustedTimestamp), + [formatTimezoneAdjustedTimestamp], + ); + + const onRevokeClick = useCallback( + (keyId: string): void => { + setRevokeKeyId(keyId); + }, + [setRevokeKeyId], + ); + + const columns = useMemo( + () => buildColumns({ isDisabled, onRevokeClick, handleformatLastObservedAt }), + [isDisabled, onRevokeClick, handleformatLastObservedAt], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (keys.length === 0) { + return ( +
+ +

No keys. Start by creating one.

+ +
+ ); + } + + return ( + <> + {/* Todo: use new table component from periscope when ready */} + + columns={columns} + dataSource={keys} + rowKey="id" + pagination={{ + style: { display: 'none' }, + current: currentPage, + pageSize, + }} + showSorterTooltip={false} + className={`keys-tab__table${ + isDisabled ? ' keys-tab__table--disabled' : '' + }`} + rowClassName={(_, index): string => + index % 2 === 0 ? 'keys-tab__table-row--alt' : '' + } + onRow={( + record, + ): { + onClick: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + role: string; + tabIndex: number; + 'aria-label': string; + } => ({ + onClick: async (): Promise => { + if (!isDisabled) { + await setEditKeyId(record.id); + } + }, + onKeyDown: async (e: React.KeyboardEvent): Promise => { + if ((e.key === 'Enter' || e.key === ' ') && !isDisabled) { + if (e.key === ' ') { + e.preventDefault(); + } + await setEditKeyId(record.id); + } + }, + role: 'button', + tabIndex: 0, + 'aria-label': `Edit key ${record.name || 'options'}`, + })} + /> + + + + + + ); +} + +export default KeysTab; diff --git a/frontend/src/components/ServiceAccountDrawer/OverviewTab.tsx b/frontend/src/components/ServiceAccountDrawer/OverviewTab.tsx new file mode 100644 index 0000000000..98b3e968f5 --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/OverviewTab.tsx @@ -0,0 +1,150 @@ +import { useCallback } from 'react'; +import { Badge } from '@signozhq/badge'; +import { LockKeyhole } from '@signozhq/icons'; +import { Input } from '@signozhq/input'; +import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas'; +import RolesSelect from 'components/RolesSelect'; +import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; +import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils'; +import { useTimezone } from 'providers/Timezone'; +import APIError from 'types/api/error'; + +interface OverviewTabProps { + account: ServiceAccountRow; + localName: string; + onNameChange: (v: string) => void; + localRoles: string[]; + onRolesChange: (v: string[]) => void; + isDisabled: boolean; + availableRoles: AuthtypesRoleDTO[]; + rolesLoading?: boolean; + rolesError?: boolean; + rolesErrorObj?: APIError | undefined; + onRefetchRoles?: () => void; +} + +function OverviewTab({ + account, + localName, + onNameChange, + localRoles, + onRolesChange, + isDisabled, + availableRoles, + rolesLoading, + rolesError, + rolesErrorObj, + onRefetchRoles, +}: 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 ( + <> +
+ + {isDisabled ? ( +
+ {localName || '—'} + +
+ ) : ( + onNameChange(e.target.value)} + className="sa-drawer__input" + placeholder="Enter name" + /> + )} +
+ +
+ +
+ {account.email || '—'} + +
+
+ +
+ + {isDisabled ? ( +
+
+ {localRoles.length > 0 ? ( + localRoles.map((r) => ( + + {r} + + )) + ) : ( + + )} +
+ +
+ ) : ( + + )} +
+ +
+
+ Status + {account.status?.toUpperCase() === 'ACTIVE' ? ( + + ACTIVE + + ) : ( + + DISABLED + + )} +
+ +
+ Created At + {formatTimestamp(account.createdAt)} +
+ +
+ Updated At + {formatTimestamp(account.updatedAt)} +
+
+ + ); +} + +export default OverviewTab; diff --git a/frontend/src/components/ServiceAccountDrawer/RevokeKeyModal.tsx b/frontend/src/components/ServiceAccountDrawer/RevokeKeyModal.tsx new file mode 100644 index 0000000000..40ad15baca --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/RevokeKeyModal.tsx @@ -0,0 +1,129 @@ +import { useQueryClient } from 'react-query'; +import { Button } from '@signozhq/button'; +import { DialogFooter, DialogWrapper } from '@signozhq/dialog'; +import { Trash2, X } from '@signozhq/icons'; +import { toast } from '@signozhq/sonner'; +import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs'; +import { + getListServiceAccountKeysQueryKey, + invalidateListServiceAccountKeys, + useRevokeServiceAccountKey, +} from 'api/generated/services/serviceaccount'; +import type { + RenderErrorResponseDTO, + ServiceaccounttypesFactorAPIKeyDTO, +} from 'api/generated/services/sigNoz.schemas'; +import { AxiosError } from 'axios'; +import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants'; +import { parseAsString, useQueryState } from 'nuqs'; + +export interface RevokeKeyContentProps { + isRevoking: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +export function RevokeKeyContent({ + isRevoking, + onCancel, + onConfirm, +}: RevokeKeyContentProps): JSX.Element { + return ( + <> +

+ Revoking this key will permanently invalidate it. Any systems using this key + will lose access immediately. +

+ + + + + + ); +} + +function RevokeKeyModal(): JSX.Element { + const queryClient = useQueryClient(); + const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT); + const [revokeKeyId, setRevokeKeyId] = useQueryState( + SA_QUERY_PARAMS.REVOKE_KEY, + parseAsString.withDefault(''), + ); + const open = !!revokeKeyId && !!accountId; + + const cachedKeys = accountId + ? queryClient.getQueryData<{ data: ServiceaccounttypesFactorAPIKeyDTO[] }>( + getListServiceAccountKeysQueryKey({ id: accountId }), + ) + : null; + const keyName = cachedKeys?.data?.find((k) => k.id === revokeKeyId)?.name; + + const { + mutate: revokeKey, + isLoading: isRevoking, + } = useRevokeServiceAccountKey({ + mutation: { + onSuccess: async () => { + toast.success('Key revoked successfully', { richColors: true }); + await setRevokeKeyId(null); + if (accountId) { + await invalidateListServiceAccountKeys(queryClient, { id: accountId }); + } + }, + onError: (error) => { + const errMessage = + convertToApiError( + error as AxiosError | null, + )?.getErrorMessage() || 'Failed to revoke key'; + toast.error(errMessage, { richColors: true }); + }, + }, + }); + + function handleConfirm(): void { + if (!revokeKeyId || !accountId) { + return; + } + revokeKey({ pathParams: { id: accountId, fid: revokeKeyId } }); + } + + function handleCancel(): void { + setRevokeKeyId(null); + } + + return ( + { + if (!isOpen) { + handleCancel(); + } + }} + title={`Revoke ${keyName ?? 'key'}?`} + width="narrow" + className="alert-dialog delete-dialog" + showCloseButton={false} + disableOutsideClick={false} + > + + + ); +} + +export default RevokeKeyModal; diff --git a/frontend/src/components/ServiceAccountDrawer/ServiceAccountDrawer.styles.scss b/frontend/src/components/ServiceAccountDrawer/ServiceAccountDrawer.styles.scss new file mode 100644 index 0000000000..a6804dffee --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/ServiceAccountDrawer.styles.scss @@ -0,0 +1,460 @@ +.sa-drawer { + [data-slot='drawer-close'] + div { + border-left: 1px solid var(--l1-border); + padding-left: var(--padding-4); + margin-left: var(--margin-2); + } + + &__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) var(--padding-2) var(--padding-4); + flex-shrink: 0; + } + + &__tab-group { + [data-slot='toggle-group'] { + height: 32px; + border-radius: 2px; + border: 1px solid var(--l1-border); + background: var(--l2-background); + gap: 0; + } + + [data-slot='toggle-group-item'] { + height: 32px; + border-radius: 0; + border-left: 1px solid var(--l1-border); + background: transparent; + color: var(--l2-foreground); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-normal); + font-family: Inter, sans-serif; + padding: 0 var(--padding-7); + gap: var(--spacing-3); + box-shadow: none; + + &:first-child { + border-left: none; + border-radius: 2px 0 0 2px; + } + + &:last-child { + border-radius: 0 2px 2px 0; + } + + &:hover { + background: rgba(171, 189, 255, 0.04); + color: var(--l1-foreground); + } + + &[data-state='on'] { + background: var(--l1-border); + color: var(--l1-foreground); + } + } + } + + &__tab { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + font-size: var(--font-size-xs); + 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: var(--code-small-400-font-size); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-20); + 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(--secondary); + background: var(--card); + } + + &__keys-pagination { + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; + padding: var(--padding-2) 0; + + .ant-pagination-total-text { + margin-right: auto; + } + } + + &__pagination-range { + font-size: var(--font-size-xs); + color: var(--foreground); + font-weight: var(--font-weight-normal); + } + + &__pagination-total { + font-size: var(--font-size-xs); + color: var(--foreground); + opacity: 0.5; + } + + &__footer-btn { + padding-left: 0; + padding-right: 0; + } + + &__footer-right { + display: flex; + align-items: center; + gap: var(--spacing-6); + } + + &__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; + } + + &__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 { + &__loading { + display: flex; + align-items: center; + justify-content: center; + padding: var(--padding-8) var(--padding-4); + } + + &__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; + height: 80%; + } + + &__empty-icon { + color: var(--l2-foreground); + } + + &__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; + } + } + + // todo: styles should easeup by upgrading the component with table component from persiscope + &__table { + .ant-table { + background: transparent; + border: 1px solid var(--l1-border); + border-radius: 4px; + overflow: hidden; + } + + .ant-table-thead > tr > th, + .ant-table-thead > tr > td { + height: 38px; + padding: 0 var(--padding-4); + background: transparent !important; + border-bottom: 1px solid var(--l1-border) !important; + font-size: var(--uppercase-small-500-font-size); + font-weight: var(--uppercase-small-500-font-weight); + color: var(--l2-foreground); + letter-spacing: 0.44px; + text-transform: uppercase; + + &::before { + display: none !important; + } + + .ant-table-column-sorter { + color: var(--l2-foreground); + opacity: 0.5; + } + + &.ant-table-column-sort { + background: transparent !important; + color: var(--l1-foreground); + + .ant-table-column-sorter { + color: var(--l1-foreground); + opacity: 1; + } + } + + &:hover { + background: transparent !important; + color: var(--l1-foreground); + + .ant-table-column-sorter { + opacity: 1; + } + } + } + + .ant-table-tbody > tr > td { + height: 38px; + padding: 0 var(--padding-4); + border-bottom: 1px solid var(--l1-border); + font-size: 13px; + font-weight: var(--font-weight-normal); + color: var(--l2-foreground); + letter-spacing: -0.07px; + cursor: pointer; + background: transparent; + transition: none; + font-variant-numeric: lining-nums tabular-nums stacked-fractions ordinal + slashed-zero; + + &.ant-table-column-sort { + background: transparent; + } + } + + .ant-table-tbody > tr:last-child > td { + border-bottom: none; + } + + .ant-table-tbody > tr { + background: transparent; + + &:hover > td { + background: rgba(171, 189, 255, 0.06) !important; + } + + &.keys-tab__table-row--alt > td { + background: rgba(171, 189, 255, 0.02); + + &:hover { + background: rgba(171, 189, 255, 0.06) !important; + } + } + } + + &--disabled { + .ant-table-tbody > tr { + cursor: not-allowed; + opacity: 0.6; + + &:hover > td { + background: transparent !important; + } + } + } + + .ant-table-cell-row-hover { + background: transparent !important; + } + } + + &__name-column { + .ant-table-column-sorters { + justify-content: flex-start; + gap: var(--spacing-2); + } + + .ant-table-column-title { + flex: none; + } + } + + &__name-text { + font-size: 13px; + font-weight: var(--font-weight-normal); + color: var(--l2-foreground); + letter-spacing: -0.07px; + text-transform: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__expiry--never { + color: var(--l2-foreground); + } + + &__expiry--expired { + color: var(--l3-foreground); + } + + &__revoke-btn { + width: 32px; + height: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + } +} + +.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); + } +} diff --git a/frontend/src/components/ServiceAccountDrawer/ServiceAccountDrawer.tsx b/frontend/src/components/ServiceAccountDrawer/ServiceAccountDrawer.tsx new file mode 100644 index 0000000000..a801e4df54 --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/ServiceAccountDrawer.tsx @@ -0,0 +1,369 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button } from '@signozhq/button'; +import { DrawerWrapper } from '@signozhq/drawer'; +import { Key, LayoutGrid, Plus, PowerOff, X } from '@signozhq/icons'; +import { toast } from '@signozhq/sonner'; +import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group'; +import { Pagination, Skeleton } from 'antd'; +import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs'; +import { + useGetServiceAccount, + useListServiceAccountKeys, + useUpdateServiceAccount, +} from 'api/generated/services/serviceaccount'; +import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas'; +import { AxiosError } from 'axios'; +import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace'; +import { useRoles } from 'components/RolesSelect'; +import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants'; +import { + ServiceAccountRow, + toServiceAccountRow, +} from 'container/ServiceAccountsSettings/utils'; +import { + parseAsBoolean, + parseAsInteger, + parseAsString, + parseAsStringEnum, + useQueryState, +} from 'nuqs'; +import { toAPIError } from 'utils/errorUtils'; + +import AddKeyModal from './AddKeyModal'; +import DisableAccountModal from './DisableAccountModal'; +import KeysTab from './KeysTab'; +import OverviewTab from './OverviewTab'; +import { ServiceAccountDrawerTab } from './utils'; + +import './ServiceAccountDrawer.styles.scss'; + +export interface ServiceAccountDrawerProps { + onSuccess: (options?: { closeDrawer?: boolean }) => void; +} + +const PAGE_SIZE = 15; + +// eslint-disable-next-line sonarjs/cognitive-complexity +function ServiceAccountDrawer({ + onSuccess, +}: ServiceAccountDrawerProps): JSX.Element { + const [selectedAccountId, setSelectedAccountId] = useQueryState( + SA_QUERY_PARAMS.ACCOUNT, + ); + const open = !!selectedAccountId; + const [activeTab, setActiveTab] = useQueryState( + SA_QUERY_PARAMS.TAB, + parseAsStringEnum( + Object.values(ServiceAccountDrawerTab), + ).withDefault(ServiceAccountDrawerTab.Overview), + ); + const [keysPage, setKeysPage] = useQueryState( + SA_QUERY_PARAMS.KEYS_PAGE, + parseAsInteger.withDefault(1), + ); + const [, setEditKeyId] = useQueryState( + SA_QUERY_PARAMS.EDIT_KEY, + parseAsString.withDefault(''), + ); + const [, setIsAddKeyOpen] = useQueryState( + SA_QUERY_PARAMS.ADD_KEY, + parseAsBoolean.withDefault(false), + ); + const [, setIsDisableOpen] = useQueryState( + SA_QUERY_PARAMS.DISABLE_SA, + parseAsBoolean.withDefault(false), + ); + const [localName, setLocalName] = useState(''); + const [localRoles, setLocalRoles] = useState([]); + + const { + data: accountData, + isLoading: isAccountLoading, + isError: isAccountError, + error: accountError, + refetch: refetchAccount, + } = useGetServiceAccount( + { id: selectedAccountId ?? '' }, + { query: { enabled: !!selectedAccountId } }, + ); + + const account = useMemo( + (): ServiceAccountRow | null => + accountData?.data ? toServiceAccountRow(accountData.data) : null, + [accountData], + ); + + useEffect(() => { + if (account) { + setLocalName(account.name ?? ''); + setLocalRoles(account.roles ?? []); + setKeysPage(1); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [account?.id]); + + const isDisabled = account?.status?.toUpperCase() !== 'ACTIVE'; + + const isDirty = + account !== null && + (localName !== (account.name ?? '') || + JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? [])); + + const { + roles: availableRoles, + isLoading: rolesLoading, + isError: rolesError, + error: rolesErrorObj, + refetch: refetchRoles, + } = useRoles(); + + const { data: keysData, isLoading: keysLoading } = useListServiceAccountKeys( + { id: selectedAccountId ?? '' }, + { query: { enabled: !!selectedAccountId } }, + ); + const keys = keysData?.data ?? []; + + useEffect(() => { + if (keysLoading) { + return; + } + const maxPage = Math.max(1, Math.ceil(keys.length / PAGE_SIZE)); + if (keysPage > maxPage) { + setKeysPage(maxPage); + } + }, [keysLoading, keys.length, keysPage, setKeysPage]); + + const { mutate: updateAccount, isLoading: isSaving } = useUpdateServiceAccount( + { + mutation: { + onSuccess: () => { + toast.success('Service account updated successfully', { + richColors: true, + }); + refetchAccount(); + onSuccess({ closeDrawer: false }); + }, + onError: (error) => { + const errMessage = + convertToApiError( + error as AxiosError | null, + )?.getErrorMessage() || 'Failed to update service account'; + toast.error(errMessage, { richColors: true }); + }, + }, + }, + ); + + function handleSave(): void { + if (!account || !isDirty) { + return; + } + updateAccount({ + pathParams: { id: account.id }, + data: { name: localName, email: account.email, roles: localRoles }, + }); + } + + const handleClose = useCallback((): void => { + setIsDisableOpen(null); + setIsAddKeyOpen(null); + setSelectedAccountId(null); + setActiveTab(null); + setKeysPage(null); + setEditKeyId(null); + }, [ + setSelectedAccountId, + setActiveTab, + setKeysPage, + setEditKeyId, + setIsAddKeyOpen, + setIsDisableOpen, + ]); + + const drawerContent = ( +
+
+ { + if (val) { + setActiveTab(val as ServiceAccountDrawerTab); + if (val !== ServiceAccountDrawerTab.Keys) { + setKeysPage(null); + setEditKeyId(null); + } + } + }} + className="sa-drawer__tab-group" + > + + + Overview + + + + Keys + {keys.length > 0 && ( + {keys.length} + )} + + + {activeTab === ServiceAccountDrawerTab.Keys && ( + + )} +
+ +
+ {isAccountLoading && } + {isAccountError && ( + + )} + {!isAccountLoading && !isAccountError && ( + <> + {activeTab === ServiceAccountDrawerTab.Overview && account && ( + + )} + {activeTab === ServiceAccountDrawerTab.Keys && ( + + )} + + )} +
+ +
+ {activeTab === ServiceAccountDrawerTab.Keys ? ( + ( + <> + + {range[0]} — {range[1]} + + of {total} + + )} + showSizeChanger={false} + hideOnSinglePage + onChange={(page): void => { + void setKeysPage(page); + }} + className="sa-drawer__keys-pagination" + /> + ) : ( + <> + {!isDisabled && ( + + )} + {!isDisabled && ( +
+ + +
+ )} + + )} +
+
+ ); + + return ( + <> + { + if (!isOpen) { + handleClose(); + } + }} + direction="right" + type="panel" + showCloseButton + showOverlay={false} + allowOutsideClick + header={{ title: 'Service Account Details' }} + content={drawerContent} + className="sa-drawer" + /> + + + + + + ); +} + +export default ServiceAccountDrawer; diff --git a/frontend/src/components/ServiceAccountDrawer/__tests__/AddKeyModal.test.tsx b/frontend/src/components/ServiceAccountDrawer/__tests__/AddKeyModal.test.tsx new file mode 100644 index 0000000000..9e1bb377ee --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/__tests__/AddKeyModal.test.tsx @@ -0,0 +1,139 @@ +import { toast } from '@signozhq/sonner'; +import { rest, server } from 'mocks-server/server'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; + +import AddKeyModal from '../AddKeyModal'; + +jest.mock('@signozhq/sonner', () => ({ + toast: { success: jest.fn(), error: jest.fn() }, +})); + +const mockToast = jest.mocked(toast); + +const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys'; + +const createdKeyResponse = { + data: { + id: 'key-1', + name: 'Deploy Key', + key: 'snz_abc123xyz456secret', + expiresAt: 0, + lastObservedAt: null, + }, +}; + +function renderModal(): ReturnType { + return render( + + + , + ); +} + +describe('AddKeyModal', () => { + beforeAll(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: jest.fn().mockResolvedValue(undefined) }, + configurable: true, + writable: true, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + server.use( + rest.post(SA_KEYS_ENDPOINT, (_, res, ctx) => + res(ctx.status(201), ctx.json(createdKeyResponse)), + ), + ); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + it('"Create Key" is disabled when name is empty; enabled after typing a name', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + expect(screen.getByRole('button', { name: /Create Key/i })).toBeDisabled(); + + await user.type(screen.getByPlaceholderText(/Enter key name/i), 'My Key'); + + await waitFor(() => + expect( + screen.getByRole('button', { name: /Create Key/i }), + ).not.toBeDisabled(), + ); + }); + + it('successful creation transitions to phase 2 with key displayed and security callout', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key'); + await waitFor(() => + expect( + screen.getByRole('button', { name: /Create Key/i }), + ).not.toBeDisabled(), + ); + await user.click(screen.getByRole('button', { name: /Create Key/i })); + + await screen.findByText('snz_abc123xyz456secret'); + expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument(); + await screen.findByRole('dialog', { name: /Key Created Successfully/i }); + }); + + it('copy button writes key to clipboard and shows toast.success', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const writeTextSpy = jest + .spyOn(navigator.clipboard, 'writeText') + .mockResolvedValue(undefined); + + renderModal(); + + await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key'); + await waitFor(() => + expect( + screen.getByRole('button', { name: /Create Key/i }), + ).not.toBeDisabled(), + ); + await user.click(screen.getByRole('button', { name: /Create Key/i })); + + await screen.findByText('snz_abc123xyz456secret'); + + const copyBtn = screen + .getAllByRole('button') + .find((btn) => btn.querySelector('svg')); + if (!copyBtn) { + throw new Error('Copy button not found'); + } + await user.click(copyBtn); + + await waitFor(() => { + expect(writeTextSpy).toHaveBeenCalledWith('snz_abc123xyz456secret'); + expect(mockToast.success).toHaveBeenCalledWith( + 'Key copied to clipboard', + expect.anything(), + ); + }); + + writeTextSpy.mockRestore(); + }); + + it('Cancel button closes the modal', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + await screen.findByRole('dialog', { name: /Add a New Key/i }); + await user.click(screen.getByRole('button', { name: /Cancel/i })); + + expect( + screen.queryByRole('dialog', { name: /Add a New Key/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ServiceAccountDrawer/__tests__/EditKeyModal.test.tsx b/frontend/src/components/ServiceAccountDrawer/__tests__/EditKeyModal.test.tsx new file mode 100644 index 0000000000..00887da0ed --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/__tests__/EditKeyModal.test.tsx @@ -0,0 +1,151 @@ +import { toast } from '@signozhq/sonner'; +import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas'; +import { rest, server } from 'mocks-server/server'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; + +import EditKeyModal from '../EditKeyModal'; + +jest.mock('@signozhq/sonner', () => ({ + toast: { success: jest.fn(), error: jest.fn() }, +})); + +const mockToast = jest.mocked(toast); + +const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/key-1'; + +const mockKey: ServiceaccounttypesFactorAPIKeyDTO = { + id: 'key-1', + name: 'Original Key Name', + expiresAt: 0, + lastObservedAt: null as any, + key: 'snz_abc123', + serviceAccountId: 'sa-1', +}; + +function renderModal( + keyItem: ServiceaccounttypesFactorAPIKeyDTO | null = mockKey, + searchParams: Record = { + account: 'sa-1', + 'edit-key': 'key-1', + }, +): ReturnType { + return render( + + + , + ); +} + +describe('EditKeyModal (URL-controlled)', () => { + beforeEach(() => { + jest.clearAllMocks(); + server.use( + rest.put(SA_KEY_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json({ status: 'success', data: {} })), + ), + rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json({ status: 'success', data: {} })), + ), + ); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + it('renders nothing when edit-key param is absent', () => { + renderModal(null, { account: 'sa-1' }); + + expect( + screen.queryByRole('dialog', { name: /Edit Key Details/i }), + ).not.toBeInTheDocument(); + }); + + it('renders key data from prop when edit-key param is set', async () => { + renderModal(); + + expect( + await screen.findByDisplayValue('Original Key Name'), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled(); + }); + + it('save calls update API, shows toast, and closes modal', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + const nameInput = await screen.findByPlaceholderText(/Enter key name/i); + await user.clear(nameInput); + await user.type(nameInput, 'Updated Key Name'); + + await user.click(screen.getByRole('button', { name: /Save Changes/i })); + + await waitFor(() => { + expect(mockToast.success).toHaveBeenCalledWith( + 'Key updated successfully', + expect.anything(), + ); + }); + + await waitFor(() => { + expect( + screen.queryByRole('dialog', { name: /Edit Key Details/i }), + ).not.toBeInTheDocument(); + }); + }); + + it('cancel clears edit-key param and closes modal', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + await screen.findByDisplayValue('Original Key Name'); + await user.click(screen.getByRole('button', { name: /Cancel/i })); + + expect( + screen.queryByRole('dialog', { name: /Edit Key Details/i }), + ).not.toBeInTheDocument(); + }); + + it('revoke flow: clicking Revoke Key shows confirmation inside same dialog', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + await screen.findByDisplayValue('Original Key Name'); + await user.click(screen.getByRole('button', { name: /Revoke Key/i })); + + // Same dialog, now showing revoke confirmation + expect( + await screen.findByRole('dialog', { name: /Revoke Original Key Name/i }), + ).toBeInTheDocument(); + expect( + screen.getByText(/Revoking this key will permanently invalidate it/i), + ).toBeInTheDocument(); + }); + + it('revoke flow: confirming revoke shows toast and closes modal', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + renderModal(); + + await screen.findByDisplayValue('Original Key Name'); + await user.click(screen.getByRole('button', { name: /Revoke Key/i })); + + const confirmBtn = await screen.findByRole('button', { + name: /^Revoke Key$/i, + }); + await user.click(confirmBtn); + + await waitFor(() => { + expect(mockToast.success).toHaveBeenCalledWith( + 'Key revoked successfully', + expect.anything(), + ); + }); + + await waitFor(() => { + expect( + screen.queryByRole('dialog', { name: /Edit Key Details/i }), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/ServiceAccountDrawer/__tests__/KeysTab.test.tsx b/frontend/src/components/ServiceAccountDrawer/__tests__/KeysTab.test.tsx new file mode 100644 index 0000000000..ecd8c625b8 --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/__tests__/KeysTab.test.tsx @@ -0,0 +1,183 @@ +import { toast } from '@signozhq/sonner'; +import { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas'; +import { rest, server } from 'mocks-server/server'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; + +import KeysTab from '../KeysTab'; + +jest.mock('@signozhq/sonner', () => ({ + toast: { success: jest.fn(), error: jest.fn() }, +})); + +const mockToast = jest.mocked(toast); + +const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/:fid'; + +const keys: ServiceaccounttypesFactorAPIKeyDTO[] = [ + { + id: 'key-1', + name: 'Production Key', + expiresAt: 0, + lastObservedAt: null as any, + key: 'snz_prod_123', + serviceAccountId: 'sa-1', + }, + { + id: 'key-2', + name: 'Staging Key', + expiresAt: 1924905600, // 2030-12-31 + lastObservedAt: new Date('2026-03-10T10:00:00Z'), + key: 'snz_stag_456', + serviceAccountId: 'sa-1', + }, +]; + +const defaultProps = { + keys, + isLoading: false, + isDisabled: false, + currentPage: 1, + pageSize: 10, +}; + +function renderKeysTab( + props: Partial = {}, + searchParams: Record = { account: 'sa-1' }, +): ReturnType { + return render( + + + , + ); +} + +describe('KeysTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + server.use( + rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json({ status: 'success', data: {} })), + ), + ); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + it('renders loading state', () => { + renderKeysTab({ isLoading: true }); + expect(document.querySelector('.ant-skeleton')).toBeInTheDocument(); + }); + + it('renders empty state when no keys and clicking add sets add-key param', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const onUrlUpdate = jest.fn(); + render( + + + , + ); + + expect( + screen.getByText(/No keys. Start by creating one./i), + ).toBeInTheDocument(); + const addBtn = screen.getByRole('button', { name: /\+ Add your first key/i }); + await user.click(addBtn); + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining('add-key=true'), + }), + ); + }); + + it('renders table with keys', () => { + renderKeysTab(); + + expect(screen.getByText('Production Key')).toBeInTheDocument(); + expect(screen.getByText('Staging Key')).toBeInTheDocument(); + expect(screen.getByText('Never')).toBeInTheDocument(); + expect(screen.getByText('Dec 31, 2030')).toBeInTheDocument(); + }); + + it('clicking a row sets the edit-key URL param', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const onUrlUpdate = jest.fn(); + + render( + + + , + ); + + const row = screen.getByText('Production Key').closest('tr'); + if (!row) { + throw new Error('Row not found'); + } + + await user.click(row); + + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining('edit-key=key-1'), + }), + ); + }); + + it('clicking revoke icon sets revoke-key URL param', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const onUrlUpdate = jest.fn(); + + render( + + + , + ); + + const revokeBtns = screen + .getAllByRole('button') + .filter((btn) => btn.className.includes('keys-tab__revoke-btn')); + await user.click(revokeBtns[0]); + + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining('revoke-key=key-1'), + }), + ); + }); + + it('handles successful key revocation via RevokeKeyModal', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + renderKeysTab(); + + // Seed the keys cache so RevokeKeyModal can read the key name + const revokeBtns = screen + .getAllByRole('button') + .filter((btn) => btn.className.includes('keys-tab__revoke-btn')); + await user.click(revokeBtns[0]); + + const confirmBtn = await screen.findByRole('button', { name: /Revoke Key/i }); + await user.click(confirmBtn); + + await waitFor(() => { + expect(mockToast.success).toHaveBeenCalledWith( + 'Key revoked successfully', + expect.anything(), + ); + }); + }); + + it('disables actions when isDisabled is true', () => { + renderKeysTab({ isDisabled: true }); + + const revokeBtns = screen + .getAllByRole('button') + .filter((btn) => btn.className.includes('keys-tab__revoke-btn')); + revokeBtns.forEach((btn) => expect(btn).toBeDisabled()); + }); +}); diff --git a/frontend/src/components/ServiceAccountDrawer/__tests__/ServiceAccountDrawer.test.tsx b/frontend/src/components/ServiceAccountDrawer/__tests__/ServiceAccountDrawer.test.tsx new file mode 100644 index 0000000000..e99961ceae --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/__tests__/ServiceAccountDrawer.test.tsx @@ -0,0 +1,250 @@ +import type { ReactNode } from 'react'; +import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles'; +import { rest, server } from 'mocks-server/server'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; + +import ServiceAccountDrawer from '../ServiceAccountDrawer'; + +jest.mock('@signozhq/drawer', () => ({ + DrawerWrapper: ({ + content, + open, + }: { + content?: ReactNode; + open: boolean; + }): JSX.Element | null => (open ?
{content}
: null), +})); + +jest.mock('@signozhq/sonner', () => ({ + toast: { success: jest.fn(), error: jest.fn() }, +})); + +const ROLES_ENDPOINT = '*/api/v1/roles'; +const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys'; +const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1'; +const SA_STATUS_ENDPOINT = '*/api/v1/service_accounts/sa-1/status'; + +const activeAccountResponse = { + id: 'sa-1', + name: 'CI Bot', + email: 'ci-bot@signoz.io', + roles: ['signoz-admin'], + status: 'ACTIVE', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', +}; + +const disabledAccountResponse = { + ...activeAccountResponse, + id: 'sa-2', + status: 'DISABLED', +}; + +function renderDrawer( + searchParams: Record = { account: 'sa-1' }, +): ReturnType { + return render( + + + , + ); +} + +describe('ServiceAccountDrawer', () => { + beforeEach(() => { + jest.clearAllMocks(); + server.use( + rest.get(ROLES_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json(listRolesSuccessResponse)), + ), + rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json({ data: [] })), + ), + rest.get(SA_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json({ data: activeAccountResponse })), + ), + rest.put(SA_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json({ status: 'success', data: {} })), + ), + rest.put(SA_STATUS_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json({ status: 'success', data: {} })), + ), + ); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + it('renders Overview tab by default: editable name input, locked email, Save disabled when not dirty', async () => { + renderDrawer(); + + expect(await screen.findByDisplayValue('CI Bot')).toBeInTheDocument(); + expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled(); + }); + + it('editing name enables Save; clicking Save sends correct payload and calls onSuccess', async () => { + const onSuccess = jest.fn(); + const updateSpy = jest.fn(); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + server.use( + rest.put(SA_ENDPOINT, async (req, res, ctx) => { + updateSpy(await req.json()); + return res(ctx.status(200), ctx.json({ status: 'success', data: {} })); + }), + ); + + render( + + + , + ); + + const nameInput = await screen.findByDisplayValue('CI Bot'); + await user.clear(nameInput); + await user.type(nameInput, 'CI Bot Updated'); + + const saveBtn = screen.getByRole('button', { name: /Save Changes/i }); + await waitFor(() => expect(saveBtn).not.toBeDisabled()); + await user.click(saveBtn); + + await waitFor(() => { + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'CI Bot Updated', + email: 'ci-bot@signoz.io', + roles: ['signoz-admin'], + }), + ); + expect(onSuccess).toHaveBeenCalledWith({ closeDrawer: false }); + }); + }); + + it('changing roles enables Save; clicking Save sends updated roles in payload', async () => { + const updateSpy = jest.fn(); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + server.use( + rest.put(SA_ENDPOINT, async (req, res, ctx) => { + updateSpy(await req.json()); + return res(ctx.status(200), ctx.json({ status: 'success', data: {} })); + }), + ); + + renderDrawer(); + + await screen.findByDisplayValue('CI Bot'); + + await user.click(screen.getByLabelText('Roles')); + await user.click(await screen.findByTitle('signoz-viewer')); + + const saveBtn = screen.getByRole('button', { name: /Save Changes/i }); + await waitFor(() => expect(saveBtn).not.toBeDisabled()); + await user.click(saveBtn); + + await waitFor(() => { + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + roles: expect.arrayContaining(['signoz-admin', 'signoz-viewer']), + }), + ); + }); + }); + + it('"Disable Service Account" opens confirm dialog; confirming sends correct status payload', async () => { + const statusSpy = jest.fn(); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + server.use( + rest.put(SA_STATUS_ENDPOINT, async (req, res, ctx) => { + statusSpy(await req.json()); + return res(ctx.status(200), ctx.json({ status: 'success', data: {} })); + }), + ); + + renderDrawer(); + + await screen.findByDisplayValue('CI Bot'); + + await user.click( + screen.getByRole('button', { name: /Disable Service Account/i }), + ); + + const dialog = await screen.findByRole('dialog', { + name: /Disable service account CI Bot/i, + }); + expect(dialog).toBeInTheDocument(); + + const confirmBtns = screen.getAllByRole('button', { name: /^Disable$/i }); + await user.click(confirmBtns[confirmBtns.length - 1]); + + await waitFor(() => { + expect(statusSpy).toHaveBeenCalledWith({ status: 'DISABLED' }); + }); + + await waitFor(() => { + expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument(); + }); + }); + + it('disabled account shows read-only name, no Save button, no Disable button', async () => { + server.use( + rest.get('*/api/v1/service_accounts/sa-2', (_, res, ctx) => + res(ctx.status(200), ctx.json({ data: disabledAccountResponse })), + ), + rest.get('*/api/v1/service_accounts/sa-2/keys', (_, res, ctx) => + res(ctx.status(200), ctx.json({ data: [] })), + ), + ); + + renderDrawer({ account: 'sa-2' }); + + await screen.findByText('CI Bot'); + + expect( + screen.queryByRole('button', { name: /Save Changes/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Disable Service Account/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument(); + }); + + it('switching to Keys tab shows "No keys" empty state', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + renderDrawer(); + + await screen.findByDisplayValue('CI Bot'); + + await user.click(screen.getByRole('radio', { name: /Keys/i })); + + await screen.findByText(/No keys/i); + }); + + it('shows skeleton while loading account data', () => { + renderDrawer(); + + // Skeleton renders while the fetch is in-flight + expect(document.querySelector('.ant-skeleton')).toBeInTheDocument(); + }); + + it('shows error state when account fetch fails', async () => { + server.use( + rest.get(SA_ENDPOINT, (_, res, ctx) => + res(ctx.status(500), ctx.json({ message: 'Server error' })), + ), + ); + + renderDrawer(); + + expect( + await screen.findByText( + /An unexpected error occurred while fetching service account details/i, + ), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ServiceAccountDrawer/utils.ts b/frontend/src/components/ServiceAccountDrawer/utils.ts new file mode 100644 index 0000000000..547a4976e0 --- /dev/null +++ b/frontend/src/components/ServiceAccountDrawer/utils.ts @@ -0,0 +1,33 @@ +import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; +import type { Dayjs } from 'dayjs'; +import dayjs from 'dayjs'; + +export enum ServiceAccountDrawerTab { + Overview = 'overview', + Keys = 'keys', +} + +export function formatLastObservedAt( + lastObservedAt: string | Date | null | undefined, + formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string, +): string { + if (!lastObservedAt) { + return '—'; + } + const str = + typeof lastObservedAt === 'string' + ? lastObservedAt + : lastObservedAt.toISOString(); + // Go zero time means the key has never been used + if (str.startsWith('0001-01-01')) { + return '—'; + } + const d = new Date(str); + if (Number.isNaN(d.getTime())) { + return '—'; + } + return formatTimezoneAdjustedTimestamp(str, DATE_TIME_FORMATS.DASH_DATETIME); +} + +export const disabledDate = (current: Dayjs): boolean => + !!current && current < dayjs().startOf('day'); diff --git a/frontend/src/components/ServiceAccountsTable/ServiceAccountsTable.styles.scss b/frontend/src/components/ServiceAccountsTable/ServiceAccountsTable.styles.scss new file mode 100644 index 0000000000..1d5b67968b --- /dev/null +++ b/frontend/src/components/ServiceAccountsTable/ServiceAccountsTable.styles.scss @@ -0,0 +1,217 @@ +.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-name-column { + .ant-table-column-sorters { + justify-content: flex-start; + gap: var(--spacing-2); + } + + .ant-table-column-title { + flex: none; + } + } + + .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); + + &__icon { + color: var(--l2-foreground); + } + + &__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-line-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; + } +} + +.sa-tooltip { + .ant-tooltip-inner { + background-color: var(--bg-slate-500); + color: var(--foreground); + font-size: var(--font-size-xs); + line-height: normal; + padding: var(--padding-2) var(--padding-3); + border-radius: 4px; + text-align: left; + } + + .ant-tooltip-arrow-content { + background-color: var(--bg-slate-500); + } +} + +.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); + } + } + } +} diff --git a/frontend/src/components/ServiceAccountsTable/ServiceAccountsTable.tsx b/frontend/src/components/ServiceAccountsTable/ServiceAccountsTable.tsx new file mode 100644 index 0000000000..459d398c7d --- /dev/null +++ b/frontend/src/components/ServiceAccountsTable/ServiceAccountsTable.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { Table } from 'antd'; +import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants'; +import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils'; +import { parseAsInteger, parseAsString, useQueryState } from 'nuqs'; + +import { + columns, + ServiceAccountsEmptyState, + showPaginationTotal, +} from './utils'; + +import './ServiceAccountsTable.styles.scss'; + +export const PAGE_SIZE = 20; + +interface ServiceAccountsTableProps { + data: ServiceAccountRow[]; + loading: boolean; + onRowClick?: (row: ServiceAccountRow) => void; +} + +function ServiceAccountsTable({ + data, + loading, + onRowClick, +}: ServiceAccountsTableProps): JSX.Element { + const [currentPage, setPage] = useQueryState( + SA_QUERY_PARAMS.PAGE, + parseAsInteger.withDefault(1), + ); + const [searchQuery] = useQueryState( + SA_QUERY_PARAMS.SEARCH, + parseAsString.withDefault(''), + ); + + return ( +
+ {/* Todo: use new table component from periscope when ready */} + + columns={columns} + dataSource={data} + rowKey="id" + loading={loading} + pagination={ + data.length > PAGE_SIZE + ? { + current: currentPage, + pageSize: PAGE_SIZE, + total: data.length, + showTotal: showPaginationTotal, + showSizeChanger: false, + onChange: (page: number): void => void setPage(page), + className: 'sa-table-pagination', + } + : false + } + rowClassName={(_, index): string => + index % 2 === 0 ? 'sa-table-row--tinted' : '' + } + showSorterTooltip={false} + locale={{ + emptyText: , + }} + className="sa-table" + onRow={( + record, + ): { + onClick?: () => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + style?: React.CSSProperties; + tabIndex?: number; + role?: string; + 'aria-label'?: string; + } => { + if (!onRowClick) { + return {}; + } + + return { + onClick: (): void => onRowClick(record), + onKeyDown: (e: React.KeyboardEvent): void => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onRowClick(record); + } + }, + style: { cursor: 'pointer' }, + tabIndex: 0, + role: 'button', + 'aria-label': `View service account ${record.name || record.email}`, + }; + }} + /> +
+ ); +} + +export default ServiceAccountsTable; diff --git a/frontend/src/components/ServiceAccountsTable/__tests__/ServiceAccountsTable.test.tsx b/frontend/src/components/ServiceAccountsTable/__tests__/ServiceAccountsTable.test.tsx new file mode 100644 index 0000000000..89912cd04d --- /dev/null +++ b/frontend/src/components/ServiceAccountsTable/__tests__/ServiceAccountsTable.test.tsx @@ -0,0 +1,96 @@ +import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import { render, screen, userEvent } from 'tests/test-utils'; + +import ServiceAccountsTable from '../ServiceAccountsTable'; + +const mockActiveAccount: ServiceAccountRow = { + id: 'sa-1', + name: 'CI Bot', + email: 'ci-bot@signoz.io', + roles: ['signoz-admin'], + status: 'ACTIVE', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', +}; + +const mockDisabledAccount: ServiceAccountRow = { + id: 'sa-2', + name: 'Legacy Bot', + email: 'legacy@signoz.io', + roles: ['signoz-viewer', 'signoz-editor', 'billing-manager'], + status: 'DISABLED', + createdAt: '2025-06-01T00:00:00Z', + updatedAt: '2025-12-01T00:00:00Z', +}; + +const defaultProps = { + loading: false, + onRowClick: jest.fn(), +}; + +describe('ServiceAccountsTable', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders name, email, role badge, and ACTIVE status badge', () => { + render(); + + expect(screen.getByText('CI Bot')).toBeInTheDocument(); + expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument(); + expect(screen.getByText('signoz-admin')).toBeInTheDocument(); + expect(screen.getByText('ACTIVE')).toBeInTheDocument(); + }); + + it('shows DISABLED badge and +2 overflow badge for multi-role accounts', () => { + render( + , + ); + + expect(screen.getByText('DISABLED')).toBeInTheDocument(); + expect(screen.getByText('signoz-viewer')).toBeInTheDocument(); + expect(screen.getByText('+2')).toBeInTheDocument(); + }); + + it('calls onRowClick with the correct account when a row is clicked', async () => { + const onRowClick = jest.fn() as jest.MockedFunction< + (row: ServiceAccountRow) => void + >; + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + render( + , + ); + + await user.click( + screen.getByRole('button', { name: /View service account CI Bot/i }), + ); + + expect(onRowClick).toHaveBeenCalledTimes(1); + expect(onRowClick).toHaveBeenCalledWith( + expect.objectContaining({ id: 'sa-1', email: 'ci-bot@signoz.io' }), + ); + }); + + it('shows "No service accounts" empty state when data is empty and no search query', () => { + render(); + + expect(screen.getByText(/No service accounts/i)).toBeInTheDocument(); + }); + + it('shows "No results for {query}" empty state when search is active', () => { + render( + + + , + ); + + expect(screen.getByText(/No results for/i)).toBeInTheDocument(); + expect(screen.getByText('ghost')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ServiceAccountsTable/utils.tsx b/frontend/src/components/ServiceAccountsTable/utils.tsx new file mode 100644 index 0000000000..2168b4e56f --- /dev/null +++ b/frontend/src/components/ServiceAccountsTable/utils.tsx @@ -0,0 +1,132 @@ +import { Badge } from '@signozhq/badge'; +import { ScanSearch } from '@signozhq/icons'; +import { Tooltip } from 'antd'; +import type { ColumnsType } from 'antd/es/table/interface'; +import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils'; + +export function NameEmailCell({ + name, + email, +}: { + name: string; + email: string; +}): JSX.Element { + return ( +
+ {name && ( + + {name} + + )} + + {email} + +
+ ); +} + +export function RolesCell({ roles }: { roles: string[] }): JSX.Element { + if (!roles || roles.length === 0) { + return ; + } + const first = roles[0]; + const overflow = roles.length - 1; + const tooltipContent = roles.slice(1).join(', '); + + return ( +
+ {first} + {overflow > 0 && ( + + + +{overflow} + + + )} +
+ ); +} + +export function StatusBadge({ status }: { status: string }): JSX.Element { + if (status?.toUpperCase() === 'ACTIVE') { + return ( + + ACTIVE + + ); + } + return ( + + DISABLED + + ); +} + +export function ServiceAccountsEmptyState({ + searchQuery, +}: { + searchQuery: string; +}): JSX.Element { + return ( +
+ + {searchQuery ? ( +

+ No results for {searchQuery} +

+ ) : ( +

+ No service accounts. Start by creating one to manage keys. +

+ )} +
+ ); +} + +export const columns: ColumnsType = [ + { + title: 'Name / Email', + dataIndex: 'name', + key: 'name', + className: 'sa-name-column', + sorter: (a, b): number => a.email.localeCompare(b.email), + render: (_, record): JSX.Element => ( + + ), + }, + { + title: 'Roles', + dataIndex: 'roles', + key: 'roles', + width: 420, + render: (roles: string[]): JSX.Element => , + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + width: 120, + align: 'right' as const, + className: 'sa-status-cell', + sorter: (a, b): number => + (a.status?.toUpperCase() === 'ACTIVE' ? 0 : 1) - + (b.status?.toUpperCase() === 'ACTIVE' ? 0 : 1), + render: (status: string): JSX.Element => , + }, +]; + +export const showPaginationTotal = ( + _total: number, + range: number[], +): JSX.Element => ( + <> + + {range[0]} — {range[1]} + + of {_total} + +); diff --git a/frontend/src/constants/dateTimeFormats.ts b/frontend/src/constants/dateTimeFormats.ts index 0afccc3ff9..98fa0af41a 100644 --- a/frontend/src/constants/dateTimeFormats.ts +++ b/frontend/src/constants/dateTimeFormats.ts @@ -33,6 +33,7 @@ export const DATE_TIME_FORMATS = { SPAN_POPOVER_DATE: 'D/M/YY - HH:mm:ss', // Month name formats + MONTH_DATE: 'MMM D, YYYY', MONTH_DATE_FULL: 'MMMM DD, YYYY', MONTH_DATE_SHORT: 'DD MMM YYYY', MONTH_DATETIME_SHORT: 'DD MMM YYYY HH:mm', diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index 46250d9dbc..89634059af 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -35,4 +35,5 @@ export enum LOCALSTORAGE { LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES', SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART', DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO', + DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER', } diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 6c8f9b8bc1..49523f82bf 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -86,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; diff --git a/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper.tsx b/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper.tsx index bd005e9fdf..b5e79fcef2 100644 --- a/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper.tsx +++ b/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper.tsx @@ -5,7 +5,7 @@ import { LegendPosition, TooltipRenderArgs, } from 'lib/uPlotV2/components/types'; -import UPlotChart from 'lib/uPlotV2/components/UPlotChart'; +import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart'; import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext'; import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin'; import noop from 'lodash-es/noop'; diff --git a/frontend/src/container/DashboardContainer/visualization/panels/TimeSeriesPanel/utils.ts b/frontend/src/container/DashboardContainer/visualization/panels/TimeSeriesPanel/utils.ts index 2261b49998..8bd0fe1e39 100644 --- a/frontend/src/container/DashboardContainer/visualization/panels/TimeSeriesPanel/utils.ts +++ b/frontend/src/container/DashboardContainer/visualization/panels/TimeSeriesPanel/utils.ts @@ -123,7 +123,7 @@ export const prepareUPlotConfig = ({ drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line, label: label, colorMapping: widget.customLegendColors ?? {}, - spanGaps: true, + spanGaps: widget.spanGaps ?? true, lineStyle: widget.lineStyle || LineStyle.Solid, lineInterpolation: widget.lineInterpolation || LineInterpolation.Spline, showPoints: diff --git a/frontend/src/container/Home/Home.tsx b/frontend/src/container/Home/Home.tsx index 05423b5479..9b570e9cc7 100644 --- a/frontend/src/container/Home/Home.tsx +++ b/frontend/src/container/Home/Home.tsx @@ -6,13 +6,16 @@ import { Button, Popover } from 'antd'; import logEvent from 'api/common/logEvent'; import listUserPreferences from 'api/v1/user/preferences/list'; import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update'; +import { PersistedAnnouncementBanner } from 'components/AnnouncementBanner'; import Header from 'components/Header/Header'; import { ENTITY_VERSION_V5 } from 'constants/app'; +import { LOCALSTORAGE } from 'constants/localStorage'; import { ORG_PREFERENCES } from 'constants/orgPreferences'; import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import ROUTES from 'constants/routes'; import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils'; +import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config'; import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList'; import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange'; import { useIsDarkMode } from 'hooks/useDarkMode'; @@ -291,6 +294,24 @@ export default function Home(): JSX.Element { return (
+ {IS_SERVICE_ACCOUNTS_ENABLED && ( + + API Keys have been deprecated and replaced by{' '} + Service Accounts. Please migrate to Service Accounts for + programmatic API access. + + } + action={{ + label: 'Go to Service Accounts', + onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS), + }} + /> + )} +
(null); - const { - data: usersData, - isLoading: isUsersLoading, - refetch: refetchUsers, - } = useQuery({ + const { data: usersData, isLoading, 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 allMembers = useMemo( + (): MemberRow[] => + (usersData?.data ?? []).map((user) => ({ + id: user.id, + name: user.displayName, + email: user.email, + role: user.role, + status: toMemberStatus(user.status ?? ''), + joinedOn: toISOString(user.createdAt), + updatedAt: toISOString(user?.updatedAt), + })), + [usersData], + ); const filteredMembers = useMemo((): MemberRow[] => { let result = allMembers; @@ -100,11 +73,6 @@ function MembersSettings(): JSX.Element { 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 => { @@ -122,9 +90,14 @@ function MembersSettings(): JSX.Element { if (currentPage > maxPage) { setPage(maxPage); } + if (currentPage < 1) { + setPage(1); + } }, [filteredMembers.length, currentPage, setPage]); - const pendingCount = invitesData?.data?.length ?? 0; + const pendingCount = allMembers.filter( + (m) => m.status === MemberStatus.Invited, + ).length; const totalCount = allMembers.length; const filterMenuItems: MenuProps['items'] = [ @@ -163,8 +136,7 @@ function MembersSettings(): JSX.Element { const handleInviteComplete = useCallback((): void => { refetchUsers(); - refetchInvites(); - }, [refetchUsers, refetchInvites]); + }, [refetchUsers]); const handleRowClick = useCallback((member: MemberRow): void => { setSelectedMember(member); @@ -176,9 +148,8 @@ function MembersSettings(): JSX.Element { const handleMemberEditComplete = useCallback((): void => { refetchUsers(); - refetchInvites(); setSelectedMember(null); - }, [refetchUsers, refetchInvites]); + }, [refetchUsers]); return ( <> @@ -209,6 +180,7 @@ function MembersSettings(): JSX.Element {
{ @@ -217,6 +189,7 @@ function MembersSettings(): JSX.Element { }} className="members-search-input" color="secondary" + name="members-search" />
@@ -232,7 +205,7 @@ function MembersSettings(): JSX.Element {
); diff --git a/frontend/src/container/MembersSettings/__tests__/MembersSettings.integration.test.tsx b/frontend/src/container/MembersSettings/__tests__/MembersSettings.integration.test.tsx index 52489bf9c4..0a29fe646f 100644 --- a/frontend/src/container/MembersSettings/__tests__/MembersSettings.integration.test.tsx +++ b/frontend/src/container/MembersSettings/__tests__/MembersSettings.integration.test.tsx @@ -1,6 +1,5 @@ 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'; @@ -13,7 +12,6 @@ jest.mock('@signozhq/sonner', () => ({ })); const USERS_ENDPOINT = '*/api/v1/user'; -const INVITES_ENDPOINT = '*/api/v1/invite'; const mockUsers: UserResponse[] = [ { @@ -21,7 +19,8 @@ const mockUsers: UserResponse[] = [ displayName: 'Alice Smith', email: 'alice@signoz.io', role: 'ADMIN', - createdAt: 1700000000, + status: 'active', + createdAt: '2024-01-01T00:00:00.000Z', organization: 'TestOrg', orgId: 'org-1', }, @@ -30,20 +29,30 @@ const mockUsers: UserResponse[] = [ displayName: 'Bob Jones', email: 'bob@signoz.io', role: 'VIEWER', - createdAt: 1700000001, + status: 'active', + createdAt: '2024-01-02T00:00:00.000Z', organization: 'TestOrg', orgId: 'org-1', }, -]; - -const mockInvites: PendingInvite[] = [ { id: 'inv-1', + displayName: '', email: 'charlie@signoz.io', - name: 'Charlie', role: 'EDITOR', - createdAt: 1700000002, - token: 'tok-abc', + status: 'pending_invite', + createdAt: '2024-01-03T00:00:00.000Z', + organization: 'TestOrg', + orgId: 'org-1', + }, + { + id: 'user-3', + displayName: 'Dave Deleted', + email: 'dave@signoz.io', + role: 'VIEWER', + status: 'deleted', + createdAt: '2024-01-04T00:00:00.000Z', + organization: 'TestOrg', + orgId: 'org-1', }, ]; @@ -54,9 +63,6 @@ describe('MembersSettings (integration)', () => { 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 })), - ), ); }); @@ -64,14 +70,16 @@ describe('MembersSettings (integration)', () => { server.resetHandlers(); }); - it('loads and displays active users and pending invites', async () => { + it('loads and displays active users, pending invites, and deleted members', async () => { render(); await screen.findByText('Alice Smith'); expect(screen.getByText('Bob Jones')).toBeInTheDocument(); expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument(); + expect(screen.getByText('Dave Deleted')).toBeInTheDocument(); expect(screen.getAllByText('ACTIVE')).toHaveLength(2); expect(screen.getByText('INVITED')).toBeInTheDocument(); + expect(screen.getByText('DELETED')).toBeInTheDocument(); }); it('filters to pending invites via the filter dropdown', async () => { @@ -107,7 +115,7 @@ describe('MembersSettings (integration)', () => { expect(screen.queryByText('charlie@signoz.io')).not.toBeInTheDocument(); }); - it('opens EditMemberDrawer when a member row is clicked', async () => { + it('opens EditMemberDrawer when an active member row is clicked', async () => { const user = userEvent.setup({ pointerEventsCheck: 0 }); render(); @@ -117,6 +125,16 @@ describe('MembersSettings (integration)', () => { await screen.findByText('Member Details'); }); + it('does not open EditMemberDrawer when a deleted member row is clicked', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + render(); + + await user.click(await screen.findByText('Dave Deleted')); + + expect(screen.queryByText('Member Details')).not.toBeInTheDocument(); + }); + it('opens InviteMembersModal when "Invite member" button is clicked', async () => { const user = userEvent.setup({ pointerEventsCheck: 0 }); diff --git a/frontend/src/container/MembersSettings/utils.ts b/frontend/src/container/MembersSettings/utils.ts index 06fa317276..87d9a06392 100644 --- a/frontend/src/container/MembersSettings/utils.ts +++ b/frontend/src/container/MembersSettings/utils.ts @@ -1,5 +1,3 @@ -export const INVITE_PREFIX = 'invite-'; - export enum FilterMode { All = 'all', Invited = 'invited', @@ -8,4 +6,25 @@ export enum FilterMode { export enum MemberStatus { Active = 'Active', Invited = 'Invited', + Deleted = 'Deleted', + Anonymous = 'Anonymous', +} + +export enum UserApiStatus { + Active = 'active', + PendingInvite = 'pending_invite', + Deleted = 'deleted', +} + +export function toMemberStatus(apiStatus: string): MemberStatus { + switch (apiStatus) { + case UserApiStatus.PendingInvite: + return MemberStatus.Invited; + case UserApiStatus.Deleted: + return MemberStatus.Deleted; + case UserApiStatus.Active: + return MemberStatus.Active; + default: + return MemberStatus.Anonymous; + } } diff --git a/frontend/src/container/NewWidget/RightContainer/FillModeSelector.styles.scss b/frontend/src/container/NewWidget/RightContainer/FillModeSelector.styles.scss deleted file mode 100644 index 23c8fee05b..0000000000 --- a/frontend/src/container/NewWidget/RightContainer/FillModeSelector.styles.scss +++ /dev/null @@ -1,21 +0,0 @@ -.fill-mode-selector { - .fill-mode-icon { - width: 24px; - height: 24px; - } - - .fill-mode-label { - text-transform: uppercase; - font-size: 12px; - font-weight: 500; - color: var(--bg-vanilla-400); - } -} - -.lightMode { - .fill-mode-selector { - .fill-mode-label { - color: var(--bg-ink-400); - } - } -} diff --git a/frontend/src/container/NewWidget/RightContainer/FillModeSelector.tsx b/frontend/src/container/NewWidget/RightContainer/FillModeSelector.tsx deleted file mode 100644 index 831a27fcb9..0000000000 --- a/frontend/src/container/NewWidget/RightContainer/FillModeSelector.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group'; -import { Typography } from 'antd'; -import { FillMode } from 'lib/uPlotV2/config/types'; - -import './FillModeSelector.styles.scss'; - -interface FillModeSelectorProps { - value: FillMode; - onChange: (value: FillMode) => void; -} - -export function FillModeSelector({ - value, - onChange, -}: FillModeSelectorProps): JSX.Element { - return ( -
- Fill mode - { - if (newValue) { - onChange(newValue as FillMode); - } - }} - > - - - - - None - - - - - - Solid - - - - - - - - - - - - - Gradient - - - -
- ); -} diff --git a/frontend/src/container/NewWidget/RightContainer/LineInterpolationSelector.styles.scss b/frontend/src/container/NewWidget/RightContainer/LineInterpolationSelector.styles.scss deleted file mode 100644 index dfd9740238..0000000000 --- a/frontend/src/container/NewWidget/RightContainer/LineInterpolationSelector.styles.scss +++ /dev/null @@ -1,21 +0,0 @@ -.line-interpolation-selector { - .line-interpolation-icon { - width: 24px; - height: 24px; - } - - .line-interpolation-label { - text-transform: uppercase; - font-size: 12px; - font-weight: 500; - color: var(--bg-vanilla-400); - } -} - -.lightMode { - .line-interpolation-selector { - .line-interpolation-label { - color: var(--bg-ink-400); - } - } -} diff --git a/frontend/src/container/NewWidget/RightContainer/LineInterpolationSelector.tsx b/frontend/src/container/NewWidget/RightContainer/LineInterpolationSelector.tsx deleted file mode 100644 index 76736d358a..0000000000 --- a/frontend/src/container/NewWidget/RightContainer/LineInterpolationSelector.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group'; -import { Typography } from 'antd'; -import { LineInterpolation } from 'lib/uPlotV2/config/types'; - -import './LineInterpolationSelector.styles.scss'; - -interface LineInterpolationSelectorProps { - value: LineInterpolation; - onChange: (value: LineInterpolation) => void; -} - -export function LineInterpolationSelector({ - value, - onChange, -}: LineInterpolationSelectorProps): JSX.Element { - return ( -
- - Line interpolation - - { - if (newValue) { - onChange(newValue as LineInterpolation); - } - }} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ); -} diff --git a/frontend/src/container/NewWidget/RightContainer/LineStyleSelector.styles.scss b/frontend/src/container/NewWidget/RightContainer/LineStyleSelector.styles.scss deleted file mode 100644 index 89e67ac4d7..0000000000 --- a/frontend/src/container/NewWidget/RightContainer/LineStyleSelector.styles.scss +++ /dev/null @@ -1,21 +0,0 @@ -.line-style-selector { - .line-style-icon { - width: 24px; - height: 24px; - } - - .line-style-label { - text-transform: uppercase; - font-size: 12px; - font-weight: 500; - color: var(--bg-vanilla-400); - } -} - -.lightMode { - .line-style-selector { - .line-style-label { - color: var(--bg-ink-400); - } - } -} diff --git a/frontend/src/container/NewWidget/RightContainer/LineStyleSelector.tsx b/frontend/src/container/NewWidget/RightContainer/LineStyleSelector.tsx deleted file mode 100644 index fd7b8dd67b..0000000000 --- a/frontend/src/container/NewWidget/RightContainer/LineStyleSelector.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group'; -import { Typography } from 'antd'; -import { LineStyle } from 'lib/uPlotV2/config/types'; - -import './LineStyleSelector.styles.scss'; - -interface LineStyleSelectorProps { - value: LineStyle; - onChange: (value: LineStyle) => void; -} - -export function LineStyleSelector({ - value, - onChange, -}: LineStyleSelectorProps): JSX.Element { - return ( -
- Line style - { - if (newValue) { - onChange(newValue as LineStyle); - } - }} - > - - - - - Solid - - - - - - Dashed - - -
- ); -} diff --git a/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss b/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss index 10e66f7109..7dc438e7e5 100644 --- a/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss +++ b/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss @@ -4,6 +4,10 @@ font-family: 'Space Mono'; padding-bottom: 48px; + .panel-type-select { + width: 100%; + } + .section-heading { font-family: 'Space Mono'; color: var(--bg-vanilla-400); @@ -26,10 +30,6 @@ letter-spacing: 0.48px; } - .panel-type-select { - width: 100%; - } - .header { display: flex; padding: 14px 14px 14px 12px; @@ -192,6 +192,16 @@ justify-content: space-between; } } + + .context-links { + padding: 12px 12px 16px 12px; + border-bottom: 1px solid var(--bg-slate-500); + } + + .thresholds-section { + padding: 12px 12px 16px 12px; + border-top: 1px solid var(--bg-slate-500); + } } .select-option { @@ -216,7 +226,8 @@ .lightMode { .right-container { background-color: var(--bg-vanilla-100); - .section-heading { + .section-heading, + .section-heading-small { color: var(--bg-ink-400); } .header { diff --git a/frontend/src/container/NewWidget/RightContainer/SettingSections/ChartAppearanceSection/ChartAppearanceSection.tsx b/frontend/src/container/NewWidget/RightContainer/SettingSections/ChartAppearanceSection/ChartAppearanceSection.tsx index 4df781ea27..68a412d53f 100644 --- a/frontend/src/container/NewWidget/RightContainer/SettingSections/ChartAppearanceSection/ChartAppearanceSection.tsx +++ b/frontend/src/container/NewWidget/RightContainer/SettingSections/ChartAppearanceSection/ChartAppearanceSection.tsx @@ -7,9 +7,10 @@ import { } from 'lib/uPlotV2/config/types'; import { Paintbrush } from 'lucide-react'; -import { FillModeSelector } from '../../components/FillModeSelector/FillModeSelector'; -import { LineInterpolationSelector } from '../../components/LineInterpolationSelector/LineInterpolationSelector'; -import { LineStyleSelector } from '../../components/LineStyleSelector/LineStyleSelector'; +import DisconnectValuesSelector from '../../components/DisconnectValuesSelector/DisconnectValuesSelector'; +import FillModeSelector from '../../components/FillModeSelector/FillModeSelector'; +import LineInterpolationSelector from '../../components/LineInterpolationSelector/LineInterpolationSelector'; +import LineStyleSelector from '../../components/LineStyleSelector/LineStyleSelector'; import SettingsSection from '../../components/SettingsSection/SettingsSection'; interface ChartAppearanceSectionProps { @@ -21,10 +22,14 @@ interface ChartAppearanceSectionProps { setLineInterpolation: Dispatch>; showPoints: boolean; setShowPoints: Dispatch>; + spanGaps: boolean | number; + setSpanGaps: Dispatch>; allowFillMode: boolean; allowLineStyle: boolean; allowLineInterpolation: boolean; allowShowPoints: boolean; + allowSpanGaps: boolean; + stepInterval: number; } export default function ChartAppearanceSection({ @@ -36,10 +41,14 @@ export default function ChartAppearanceSection({ setLineInterpolation, showPoints, setShowPoints, + spanGaps, + setSpanGaps, allowFillMode, allowLineStyle, allowLineInterpolation, allowShowPoints, + allowSpanGaps, + stepInterval, }: ChartAppearanceSectionProps): JSX.Element { return ( }> @@ -66,6 +75,13 @@ export default function ChartAppearanceSection({ )} + {allowSpanGaps && ( + + )} ); } diff --git a/frontend/src/container/NewWidget/RightContainer/__tests__/RightContainer.test.tsx b/frontend/src/container/NewWidget/RightContainer/__tests__/RightContainer.test.tsx index b12e30bbde..a6881a5638 100644 --- a/frontend/src/container/NewWidget/RightContainer/__tests__/RightContainer.test.tsx +++ b/frontend/src/container/NewWidget/RightContainer/__tests__/RightContainer.test.tsx @@ -178,6 +178,8 @@ describe('RightContainer - Alerts Section', () => { setLineStyle: jest.fn(), showPoints: false, setShowPoints: jest.fn(), + spanGaps: false, + setSpanGaps: jest.fn(), }; beforeEach(() => { diff --git a/frontend/src/container/NewWidget/RightContainer/components/DisconnectValuesSelector/DisconnectValuesModeToggle.tsx b/frontend/src/container/NewWidget/RightContainer/components/DisconnectValuesSelector/DisconnectValuesModeToggle.tsx new file mode 100644 index 0000000000..c5ed204d7d --- /dev/null +++ b/frontend/src/container/NewWidget/RightContainer/components/DisconnectValuesSelector/DisconnectValuesModeToggle.tsx @@ -0,0 +1,38 @@ +import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group'; +import { Typography } from 'antd'; +import { DisconnectedValuesMode } from 'lib/uPlotV2/config/types'; + +interface DisconnectValuesModeToggleProps { + value: DisconnectedValuesMode; + onChange: (value: DisconnectedValuesMode) => void; +} + +export default function DisconnectValuesModeToggle({ + value, + onChange, +}: DisconnectValuesModeToggleProps): JSX.Element { + return ( + { + if (newValue) { + onChange(newValue as DisconnectedValuesMode); + } + }} + > + + Never + + + + Threshold + + + + ); +} diff --git a/frontend/src/container/NewWidget/RightContainer/components/DisconnectValuesSelector/DisconnectValuesSelector.styles.scss b/frontend/src/container/NewWidget/RightContainer/components/DisconnectValuesSelector/DisconnectValuesSelector.styles.scss new file mode 100644 index 0000000000..c540a90dbd --- /dev/null +++ b/frontend/src/container/NewWidget/RightContainer/components/DisconnectValuesSelector/DisconnectValuesSelector.styles.scss @@ -0,0 +1,21 @@ +.disconnect-values-selector { + .disconnect-values-input-wrapper { + display: flex; + flex-direction: column; + gap: 16px; + + .disconnect-values-threshold-wrapper { + display: flex; + flex-direction: column; + gap: 8px; + .disconnect-values-threshold-input { + max-width: 160px; + height: auto; + .disconnect-values-threshold-prefix { + padding: 0 8px; + font-size: 20px; + } + } + } + } +} diff --git a/frontend/src/container/NewWidget/RightContainer/components/DisconnectValuesSelector/DisconnectValuesSelector.tsx b/frontend/src/container/NewWidget/RightContainer/components/DisconnectValuesSelector/DisconnectValuesSelector.tsx new file mode 100644 index 0000000000..fc72e5b6fc --- /dev/null +++ b/frontend/src/container/NewWidget/RightContainer/components/DisconnectValuesSelector/DisconnectValuesSelector.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react'; +import { Typography } from 'antd'; +import { DisconnectedValuesMode } from 'lib/uPlotV2/config/types'; + +import DisconnectValuesModeToggle from './DisconnectValuesModeToggle'; +import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput'; + +import './DisconnectValuesSelector.styles.scss'; + +const DEFAULT_THRESHOLD_SECONDS = 60; + +interface DisconnectValuesSelectorProps { + value: boolean | number; + minValue: number; + onChange: (value: boolean | number) => void; +} + +export default function DisconnectValuesSelector({ + value, + minValue, + onChange, +}: DisconnectValuesSelectorProps): JSX.Element { + const [mode, setMode] = useState(() => { + if (typeof value === 'number') { + return DisconnectedValuesMode.Threshold; + } + return DisconnectedValuesMode.Never; + }); + const [thresholdSeconds, setThresholdSeconds] = useState( + typeof value === 'number' ? value : minValue ?? DEFAULT_THRESHOLD_SECONDS, + ); + + useEffect(() => { + if (typeof value === 'boolean') { + setMode(DisconnectedValuesMode.Never); + } else if (typeof value === 'number') { + setMode(DisconnectedValuesMode.Threshold); + setThresholdSeconds(value); + } + }, [value]); + + useEffect(() => { + if (minValue !== undefined) { + setThresholdSeconds(minValue); + if (mode === DisconnectedValuesMode.Threshold) { + onChange(minValue); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [minValue]); + + const handleModeChange = (newMode: DisconnectedValuesMode): void => { + setMode(newMode); + switch (newMode) { + case DisconnectedValuesMode.Never: + onChange(true); + break; + case DisconnectedValuesMode.Threshold: + onChange(thresholdSeconds); + break; + } + }; + + const handleThresholdChange = (seconds: number): void => { + setThresholdSeconds(seconds); + onChange(seconds); + }; + + return ( +
+ + Disconnect values + +
+ + {mode === DisconnectedValuesMode.Threshold && ( +
+ + Threshold Value + + +
+ )} +
+
+ ); +} diff --git a/frontend/src/container/NewWidget/RightContainer/components/DisconnectValuesSelector/DisconnectValuesThresholdInput.tsx b/frontend/src/container/NewWidget/RightContainer/components/DisconnectValuesSelector/DisconnectValuesThresholdInput.tsx new file mode 100644 index 0000000000..6e4ae57098 --- /dev/null +++ b/frontend/src/container/NewWidget/RightContainer/components/DisconnectValuesSelector/DisconnectValuesThresholdInput.tsx @@ -0,0 +1,92 @@ +import { ChangeEvent, useEffect, useState } from 'react'; +import { rangeUtil } from '@grafana/data'; +import { Callout, Input } from '@signozhq/ui'; +interface DisconnectValuesThresholdInputProps { + value: number; + onChange: (seconds: number) => void; + minValue: number; +} + +export default function DisconnectValuesThresholdInput({ + value, + onChange, + minValue, +}: DisconnectValuesThresholdInputProps): JSX.Element { + const [inputValue, setInputValue] = useState( + rangeUtil.secondsToHms(value), + ); + const [error, setError] = useState(null); + + useEffect(() => { + setInputValue(rangeUtil.secondsToHms(value)); + setError(null); + }, [value]); + + const updateValue = (txt: string): void => { + if (!txt) { + return; + } + try { + let seconds: number; + if (rangeUtil.isValidTimeSpan(txt)) { + seconds = rangeUtil.intervalToSeconds(txt); + } else { + const parsed = Number(txt); + if (Number.isNaN(parsed) || parsed <= 0) { + setError('Enter a valid duration (e.g. 1h, 10m, 1d)'); + return; + } + seconds = parsed; + } + if (minValue !== undefined && seconds < minValue) { + setError(`Threshold should be > ${rangeUtil.secondsToHms(minValue)}`); + return; + } + setError(null); + setInputValue(txt); + onChange(seconds); + } catch { + setError('Invalid threshold value'); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter') { + updateValue(e.currentTarget.value); + } + }; + + const handleBlur = (e: React.FocusEvent): void => { + updateValue(e.currentTarget.value); + }; + + const handleChange = (e: ChangeEvent): void => { + setInputValue(e.currentTarget.value); + if (error) { + setError(null); + } + }; + + return ( +
+ >} + value={inputValue} + onChange={handleChange} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + autoFocus={true} + aria-invalid={!!error} + aria-describedby={error ? 'threshold-error' : undefined} + /> + {error && ( + + {error} + + )} +
+ ); +} diff --git a/frontend/src/container/NewWidget/RightContainer/components/FillModeSelector/FillModeSelector.tsx b/frontend/src/container/NewWidget/RightContainer/components/FillModeSelector/FillModeSelector.tsx index 831a27fcb9..51e7af9824 100644 --- a/frontend/src/container/NewWidget/RightContainer/components/FillModeSelector/FillModeSelector.tsx +++ b/frontend/src/container/NewWidget/RightContainer/components/FillModeSelector/FillModeSelector.tsx @@ -9,7 +9,7 @@ interface FillModeSelectorProps { onChange: (value: FillMode) => void; } -export function FillModeSelector({ +export default function FillModeSelector({ value, onChange, }: FillModeSelectorProps): JSX.Element { diff --git a/frontend/src/container/NewWidget/RightContainer/components/LineInterpolationSelector/LineInterpolationSelector.tsx b/frontend/src/container/NewWidget/RightContainer/components/LineInterpolationSelector/LineInterpolationSelector.tsx index 76736d358a..cb55997aac 100644 --- a/frontend/src/container/NewWidget/RightContainer/components/LineInterpolationSelector/LineInterpolationSelector.tsx +++ b/frontend/src/container/NewWidget/RightContainer/components/LineInterpolationSelector/LineInterpolationSelector.tsx @@ -9,7 +9,7 @@ interface LineInterpolationSelectorProps { onChange: (value: LineInterpolation) => void; } -export function LineInterpolationSelector({ +export default function LineInterpolationSelector({ value, onChange, }: LineInterpolationSelectorProps): JSX.Element { diff --git a/frontend/src/container/NewWidget/RightContainer/components/LineStyleSelector/LineStyleSelector.tsx b/frontend/src/container/NewWidget/RightContainer/components/LineStyleSelector/LineStyleSelector.tsx index fd7b8dd67b..840a6c236c 100644 --- a/frontend/src/container/NewWidget/RightContainer/components/LineStyleSelector/LineStyleSelector.tsx +++ b/frontend/src/container/NewWidget/RightContainer/components/LineStyleSelector/LineStyleSelector.tsx @@ -9,7 +9,7 @@ interface LineStyleSelectorProps { onChange: (value: LineStyle) => void; } -export function LineStyleSelector({ +export default function LineStyleSelector({ value, onChange, }: LineStyleSelectorProps): JSX.Element { diff --git a/frontend/src/container/NewWidget/RightContainer/constants.ts b/frontend/src/container/NewWidget/RightContainer/constants.ts index d62ff01c94..bbc97d763b 100644 --- a/frontend/src/container/NewWidget/RightContainer/constants.ts +++ b/frontend/src/container/NewWidget/RightContainer/constants.ts @@ -262,3 +262,17 @@ export const panelTypeVsShowPoints: { [PANEL_TYPES.TRACE]: false, [PANEL_TYPES.EMPTY_WIDGET]: false, } as const; + +export const panelTypeVsSpanGaps: { + [key in PANEL_TYPES]: boolean; +} = { + [PANEL_TYPES.TIME_SERIES]: true, + [PANEL_TYPES.VALUE]: false, + [PANEL_TYPES.TABLE]: false, + [PANEL_TYPES.LIST]: false, + [PANEL_TYPES.PIE]: false, + [PANEL_TYPES.BAR]: false, + [PANEL_TYPES.HISTOGRAM]: false, + [PANEL_TYPES.TRACE]: false, + [PANEL_TYPES.EMPTY_WIDGET]: false, +} as const; diff --git a/frontend/src/container/NewWidget/RightContainer/index.tsx b/frontend/src/container/NewWidget/RightContainer/index.tsx index d4053a2c4e..700c7f01cd 100644 --- a/frontend/src/container/NewWidget/RightContainer/index.tsx +++ b/frontend/src/container/NewWidget/RightContainer/index.tsx @@ -1,6 +1,7 @@ import { Dispatch, SetStateAction, useMemo } from 'react'; import { UseQueryResult } from 'react-query'; import { Typography } from 'antd'; +import { ExecStats } from 'api/v5/v5'; import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types'; import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder'; import { PanelTypesWithData } from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems'; @@ -11,6 +12,7 @@ import { LineInterpolation, LineStyle, } from 'lib/uPlotV2/config/types'; +import get from 'lodash-es/get'; import { SuccessResponse } from 'types/api'; import { ColumnUnit, @@ -36,6 +38,7 @@ import { panelTypeVsPanelTimePreferences, panelTypeVsShowPoints, panelTypeVsSoftMinMax, + panelTypeVsSpanGaps, panelTypeVsStackingChartPreferences, panelTypeVsThreshold, panelTypeVsYAxisUnit, @@ -68,6 +71,8 @@ function RightContainer({ setLineStyle, showPoints, setShowPoints, + spanGaps, + setSpanGaps, bucketCount, bucketWidth, stackedBarChart, @@ -138,6 +143,7 @@ function RightContainer({ const allowLineStyle = panelTypeVsLineStyle[selectedGraph]; const allowFillMode = panelTypeVsFillMode[selectedGraph]; const allowShowPoints = panelTypeVsShowPoints[selectedGraph]; + const allowSpanGaps = panelTypeVsSpanGaps[selectedGraph]; const decimapPrecisionOptions = useMemo( () => [ @@ -176,10 +182,26 @@ function RightContainer({ (allowFillMode || allowLineStyle || allowLineInterpolation || - allowShowPoints), - [allowFillMode, allowLineStyle, allowLineInterpolation, allowShowPoints], + allowShowPoints || + allowSpanGaps), + [ + allowFillMode, + allowLineStyle, + allowLineInterpolation, + allowShowPoints, + allowSpanGaps, + ], ); + const stepInterval = useMemo(() => { + const stepIntervals: ExecStats['stepIntervals'] = get( + queryResponse, + 'data.payload.data.newResult.meta.stepIntervals', + {}, + ); + return Math.min(...Object.values(stepIntervals)); + }, [queryResponse]); + return (
@@ -237,10 +259,14 @@ function RightContainer({ setLineInterpolation={setLineInterpolation} showPoints={showPoints} setShowPoints={setShowPoints} + spanGaps={spanGaps} + setSpanGaps={setSpanGaps} allowFillMode={allowFillMode} allowLineStyle={allowLineStyle} allowLineInterpolation={allowLineInterpolation} allowShowPoints={allowShowPoints} + allowSpanGaps={allowSpanGaps} + stepInterval={stepInterval} /> )} @@ -364,6 +390,8 @@ export interface RightContainerProps { setLineStyle: Dispatch>; showPoints: boolean; setShowPoints: Dispatch>; + spanGaps: boolean | number; + setSpanGaps: Dispatch>; } RightContainer.defaultProps = { diff --git a/frontend/src/container/NewWidget/__test__/NewWidget.test.tsx b/frontend/src/container/NewWidget/__test__/NewWidget.test.tsx index 6425db00b5..9451d63880 100644 --- a/frontend/src/container/NewWidget/__test__/NewWidget.test.tsx +++ b/frontend/src/container/NewWidget/__test__/NewWidget.test.tsx @@ -14,7 +14,6 @@ import { DashboardProvider } from 'providers/Dashboard/Dashboard'; import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider'; import i18n from 'ReactI18'; import { - fireEvent, getByText as getByTextUtil, render, userEvent, @@ -342,9 +341,8 @@ describe('Stacking bar in new panel', () => { const STACKING_STATE_ATTR = 'data-stacking-state'; describe('when switching to BAR panel type', () => { - jest.setTimeout(10000); - beforeEach(() => { + jest.useFakeTimers(); jest.clearAllMocks(); // Mock useSearchParams to return the expected values @@ -354,7 +352,15 @@ describe('when switching to BAR panel type', () => { ]); }); + afterEach(() => { + jest.useRealTimers(); + }); + it('should preserve saved stacking value of true', async () => { + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime.bind(jest), + }); + const { getByTestId, getByText, container } = render( { 'true', ); - await userEvent.click(getByText('Bar')); // Panel Type Selected + await user.click(getByText('Bar')); // Panel Type Selected // find dropdown with - .ant-select-dropdown const panelDropdown = document.querySelector( @@ -380,7 +386,7 @@ describe('when switching to BAR panel type', () => { // Select TimeSeries from dropdown const option = within(panelDropdown).getByText('Time Series'); - fireEvent.click(option); + await user.click(option); expect(getByTestId('panel-change-select')).toHaveAttribute( STACKING_STATE_ATTR, @@ -395,7 +401,7 @@ describe('when switching to BAR panel type', () => { expect(panelTypeDropdown2).toBeInTheDocument(); expect(getByTextUtil(panelTypeDropdown2, 'Time Series')).toBeInTheDocument(); - fireEvent.click(getByTextUtil(panelTypeDropdown2, 'Time Series')); + await user.click(getByTextUtil(panelTypeDropdown2, 'Time Series')); // find dropdown with - .ant-select-dropdown const panelDropdown2 = document.querySelector( @@ -403,7 +409,7 @@ describe('when switching to BAR panel type', () => { ) as HTMLElement; // // Select BAR from dropdown const BarOption = within(panelDropdown2).getByText('Bar'); - fireEvent.click(BarOption); + await user.click(BarOption); // Stack series should be true checkStackSeriesState(container, true); diff --git a/frontend/src/container/NewWidget/index.tsx b/frontend/src/container/NewWidget/index.tsx index 3f0ffc82a0..1f1f855fc1 100644 --- a/frontend/src/container/NewWidget/index.tsx +++ b/frontend/src/container/NewWidget/index.tsx @@ -220,6 +220,9 @@ function NewWidget({ const [showPoints, setShowPoints] = useState( selectedWidget?.showPoints ?? false, ); + const [spanGaps, setSpanGaps] = useState( + selectedWidget?.spanGaps ?? true, + ); const [customLegendColors, setCustomLegendColors] = useState< Record >(selectedWidget?.customLegendColors || {}); @@ -289,6 +292,7 @@ function NewWidget({ fillMode, lineStyle, showPoints, + spanGaps, columnUnits, bucketCount, stackedBarChart, @@ -328,6 +332,7 @@ function NewWidget({ fillMode, lineStyle, showPoints, + spanGaps, customLegendColors, contextLinks, selectedWidget.columnWidths, @@ -541,6 +546,7 @@ function NewWidget({ softMin: selectedWidget?.softMin || 0, softMax: selectedWidget?.softMax || 0, fillSpans: selectedWidget?.fillSpans, + spanGaps: selectedWidget?.spanGaps ?? true, isLogScale: selectedWidget?.isLogScale || false, bucketWidth: selectedWidget?.bucketWidth || 0, bucketCount: selectedWidget?.bucketCount || 0, @@ -572,6 +578,7 @@ function NewWidget({ softMin: selectedWidget?.softMin || 0, softMax: selectedWidget?.softMax || 0, fillSpans: selectedWidget?.fillSpans, + spanGaps: selectedWidget?.spanGaps ?? true, isLogScale: selectedWidget?.isLogScale || false, bucketWidth: selectedWidget?.bucketWidth || 0, bucketCount: selectedWidget?.bucketCount || 0, @@ -889,6 +896,8 @@ function NewWidget({ setLineStyle={setLineStyle} showPoints={showPoints} setShowPoints={setShowPoints} + spanGaps={spanGaps} + setSpanGaps={setSpanGaps} opacity={opacity} yAxisUnit={yAxisUnit} columnUnits={columnUnits} diff --git a/frontend/src/container/ServiceAccountsSettings/ServiceAccountsSettings.styles.scss b/frontend/src/container/ServiceAccountsSettings/ServiceAccountsSettings.styles.scss new file mode 100644 index 0000000000..faf08e8dc3 --- /dev/null +++ b/frontend/src/container/ServiceAccountsSettings/ServiceAccountsSettings.styles.scss @@ -0,0 +1,134 @@ +.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-status-badge { + color: var(--l3-foreground); + border-color: var(--border); +} + +.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); + } + } +} diff --git a/frontend/src/container/ServiceAccountsSettings/ServiceAccountsSettings.tsx b/frontend/src/container/ServiceAccountsSettings/ServiceAccountsSettings.tsx new file mode 100644 index 0000000000..d0f037b179 --- /dev/null +++ b/frontend/src/container/ServiceAccountsSettings/ServiceAccountsSettings.tsx @@ -0,0 +1,307 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useQueryClient } from 'react-query'; +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 { + getGetServiceAccountQueryKey, + useListServiceAccounts, +} from 'api/generated/services/serviceaccount'; +import type { + GetServiceAccount200, + ListServiceAccounts200, +} from 'api/generated/services/sigNoz.schemas'; +import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal'; +import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace'; +import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer'; +import ServiceAccountsTable, { + PAGE_SIZE, +} from 'components/ServiceAccountsTable/ServiceAccountsTable'; +import { + parseAsBoolean, + parseAsInteger, + parseAsString, + parseAsStringEnum, + useQueryState, +} from 'nuqs'; +import { toAPIError } from 'utils/errorUtils'; + +import { SA_QUERY_PARAMS } from './constants'; +import { + FilterMode, + ServiceAccountRow, + ServiceAccountStatus, + toServiceAccountRow, +} from './utils'; + +import './ServiceAccountsSettings.styles.scss'; + +function ServiceAccountsSettings(): JSX.Element { + const [currentPage, setPage] = useQueryState( + SA_QUERY_PARAMS.PAGE, + parseAsInteger.withDefault(1), + ); + const [searchQuery, setSearchQuery] = useQueryState( + SA_QUERY_PARAMS.SEARCH, + parseAsString.withDefault(''), + ); + const [filterMode, setFilterMode] = useQueryState( + SA_QUERY_PARAMS.FILTER, + parseAsStringEnum(Object.values(FilterMode)).withDefault( + FilterMode.All, + ), + ); + const [, setSelectedAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT); + const [, setIsCreateModalOpen] = useQueryState( + SA_QUERY_PARAMS.CREATE_SA, + parseAsBoolean.withDefault(false), + ); + + const queryClient = useQueryClient(); + + const seedAccountCache = useCallback( + (data: ListServiceAccounts200) => { + data.data.forEach((account) => { + queryClient.setQueryData( + getGetServiceAccountQueryKey({ id: account.id }), + (old) => old ?? { data: account, status: data.status }, + ); + }); + }, + [queryClient], + ); + + const { + data: serviceAccountsData, + isLoading, + isError, + error, + refetch: handleCreateSuccess, + } = useListServiceAccounts({ + query: { onSuccess: seedAccountCache }, + }); + + const allAccounts = useMemo( + (): ServiceAccountRow[] => + (serviceAccountsData?.data ?? []).map(toServiceAccountRow), + [serviceAccountsData], + ); + + const activeCount = useMemo( + () => + allAccounts.filter( + (a) => a.status?.toUpperCase() === ServiceAccountStatus.Active, + ).length, + [allAccounts], + ); + + const disabledCount = useMemo( + () => + allAccounts.filter( + (a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active, + ).length, + [allAccounts], + ); + + const filteredAccounts = useMemo((): ServiceAccountRow[] => { + let result = allAccounts; + + if (filterMode === FilterMode.Active) { + result = result.filter( + (a) => a.status?.toUpperCase() === ServiceAccountStatus.Active, + ); + } else if (filterMode === FilterMode.Disabled) { + result = result.filter( + (a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active, + ); + } + + if (searchQuery.trim()) { + const q = searchQuery.trim().toLowerCase(); + result = result.filter( + (a) => + a.name?.toLowerCase().includes(q) || + a.email?.toLowerCase().includes(q) || + a.roles?.some((role: string) => role.toLowerCase().includes(q)), + ); + } + + return result; + }, [allAccounts, filterMode, searchQuery]); + + useEffect(() => { + if (filteredAccounts.length === 0) { + return; + } + + const maxPage = Math.max(1, Math.ceil(filteredAccounts.length / PAGE_SIZE)); + if (currentPage > maxPage) { + setPage(maxPage); + } else if (currentPage < 1) { + setPage(1); + } + }, [filteredAccounts.length, currentPage, setPage]); + + const totalCount = allAccounts.length; + + const filterMenuItems: MenuProps['items'] = [ + { + key: FilterMode.All, + label: ( +
+ All accounts ⎯ {totalCount} + {filterMode === FilterMode.All && } +
+ ), + onClick: (): void => { + setFilterMode(FilterMode.All); + setPage(1); + }, + }, + { + key: FilterMode.Active, + label: ( +
+ Active ⎯ {activeCount} + {filterMode === FilterMode.Active && } +
+ ), + onClick: (): void => { + setFilterMode(FilterMode.Active); + setPage(1); + }, + }, + { + key: FilterMode.Disabled, + label: ( +
+ Disabled ⎯ {disabledCount} + {filterMode === FilterMode.Disabled && } +
+ ), + onClick: (): void => { + setFilterMode(FilterMode.Disabled); + setPage(1); + }, + }, + ]; + + function getFilterLabel(): string { + switch (filterMode) { + case FilterMode.Active: + return `Active ⎯ ${activeCount}`; + case FilterMode.Disabled: + return `Disabled ⎯ ${disabledCount}`; + default: + return `All accounts ⎯ ${totalCount}`; + } + } + const filterLabel = getFilterLabel(); + + const handleRowClick = useCallback( + (row: ServiceAccountRow): void => { + setSelectedAccountId(row.id); + }, + [setSelectedAccountId], + ); + + const handleDrawerSuccess = useCallback( + (options?: { closeDrawer?: boolean }): void => { + if (options?.closeDrawer) { + setSelectedAccountId(null); + } + handleCreateSuccess(); + }, + [handleCreateSuccess, setSelectedAccountId], + ); + + return ( + <> +
+
+

Service Accounts

+

+ Overview of service accounts added to this workspace.{' '} + {/* Todo: to add doc links */} + {/* + Learn more + */} +

+
+ +
+ + + + +
+ { + setSearchQuery(e.target.value); + setPage(1); + }} + className="sa-settings-search-input" + color="secondary" + /> +
+ + +
+
+ + {isError ? ( + + ) : ( + + )} + + + + + + ); +} + +export default ServiceAccountsSettings; diff --git a/frontend/src/container/ServiceAccountsSettings/__tests__/ServiceAccountsSettings.integration.test.tsx b/frontend/src/container/ServiceAccountsSettings/__tests__/ServiceAccountsSettings.integration.test.tsx new file mode 100644 index 0000000000..5070b4dc7a --- /dev/null +++ b/frontend/src/container/ServiceAccountsSettings/__tests__/ServiceAccountsSettings.integration.test.tsx @@ -0,0 +1,231 @@ +import type { ReactNode } from 'react'; +import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles'; +import { rest, server } from 'mocks-server/server'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import { render, screen, userEvent } from 'tests/test-utils'; + +import ServiceAccountsSettings from '../ServiceAccountsSettings'; + +const SA_LIST_ENDPOINT = '*/api/v1/service_accounts'; +const SA_ENDPOINT = '*/api/v1/service_accounts/:id'; +const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys'; +const ROLES_ENDPOINT = '*/api/v1/roles'; + +jest.mock('@signozhq/drawer', () => ({ + DrawerWrapper: ({ + content, + open, + }: { + content?: ReactNode; + open: boolean; + }): JSX.Element | null => (open ?
{content}
: null), +})); + +const mockServiceAccountsAPI = [ + { + id: 'sa-1', + name: 'CI Bot', + email: 'ci-bot@signoz.io', + roles: ['signoz-admin'], + status: 'ACTIVE', + createdAt: 1700000000, + updatedAt: 1700000001, + }, + { + id: 'sa-2', + name: 'Monitoring Agent', + email: 'monitor@signoz.io', + roles: ['signoz-viewer'], + status: 'ACTIVE', + createdAt: 1700000002, + updatedAt: 1700000003, + }, + { + id: 'sa-3', + name: 'Legacy Bot', + email: 'legacy@signoz.io', + roles: ['signoz-editor'], + status: 'DISABLED', + createdAt: 1700000004, + updatedAt: 1700000005, + }, +]; + +describe('ServiceAccountsSettings (integration)', () => { + beforeEach(() => { + jest.clearAllMocks(); + server.use( + rest.get(SA_LIST_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI })), + ), + rest.get(SA_ENDPOINT, (req, res, ctx) => { + const { id } = req.params as { id: string }; + const account = mockServiceAccountsAPI.find((a) => a.id === id); + return account + ? res(ctx.status(200), ctx.json({ data: account })) + : res(ctx.status(404), ctx.json({ message: 'Not found' })); + }), + rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json({ data: [] })), + ), + rest.get(ROLES_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json(listRolesSuccessResponse)), + ), + ); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + it('loads and displays all accounts with correct ACTIVE and DISABLED badges', async () => { + render( + + + , + ); + + await screen.findByText('CI Bot'); + expect(screen.getByText('Monitoring Agent')).toBeInTheDocument(); + expect(screen.getByText('legacy@signoz.io')).toBeInTheDocument(); + expect(screen.getAllByText('ACTIVE')).toHaveLength(2); + expect(screen.getByText('DISABLED')).toBeInTheDocument(); + }); + + it('filter dropdown to "Active" hides DISABLED accounts', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + render( + + + , + ); + + await screen.findByText('CI Bot'); + + await user.click(screen.getByRole('button', { name: /All accounts/i })); + + const activeOption = await screen.findByText(/Active ⎯/i); + await user.click(activeOption); + + await screen.findByText('CI Bot'); + expect(screen.queryByText('Legacy Bot')).not.toBeInTheDocument(); + }); + + it('search by name filters accounts in real-time', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + render( + + + , + ); + + await screen.findByText('CI Bot'); + + await user.type( + screen.getByPlaceholderText(/Search by name or email/i), + 'legacy', + ); + + await screen.findByText('Legacy Bot'); + expect(screen.queryByText('CI Bot')).not.toBeInTheDocument(); + expect(screen.queryByText('Monitoring Agent')).not.toBeInTheDocument(); + }); + + it('clicking a row opens the drawer with account details visible', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + render( + + + , + ); + + await user.click( + await screen.findByRole('button', { + name: /View service account CI Bot/i, + }), + ); + + expect( + await screen.findByRole('button', { name: /Disable Service Account/i }), + ).toBeInTheDocument(); + }); + + it('saving changes in the drawer refetches the list', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const listRefetchSpy = jest.fn(); + + server.use( + rest.get(SA_LIST_ENDPOINT, (_, res, ctx) => { + listRefetchSpy(); + return res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI })); + }), + rest.put(SA_ENDPOINT, (_, res, ctx) => + res(ctx.status(200), ctx.json({ status: 'success', data: {} })), + ), + ); + + render( + + + , + ); + + await screen.findByText('CI Bot'); + listRefetchSpy.mockClear(); + + await user.click( + await screen.findByRole('button', { name: /View service account CI Bot/i }), + ); + + const nameInput = await screen.findByDisplayValue('CI Bot'); + await user.clear(nameInput); + await user.type(nameInput, 'CI Bot Updated'); + + await user.click(screen.getByRole('button', { name: /Save Changes/i })); + + await screen.findByDisplayValue('CI Bot Updated'); + expect(listRefetchSpy).toHaveBeenCalled(); + }); + + it('"New Service Account" button opens the Create Service Account modal', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + render( + + + , + ); + + await screen.findByText('CI Bot'); + + await user.click( + screen.getByRole('button', { name: /New Service Account/i }), + ); + + await screen.findByRole('dialog', { name: /New Service Account/i }); + expect(screen.getByPlaceholderText('Enter a name')).toBeInTheDocument(); + }); + + it('shows error state when API fails', async () => { + server.use( + rest.get(SA_LIST_ENDPOINT, (_, res, ctx) => + res(ctx.status(500), ctx.json({ message: 'Internal Server Error' })), + ), + ); + + render( + + + , + ); + + expect( + await screen.findByText( + /An unexpected error occurred while fetching service accounts/i, + ), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/ServiceAccountsSettings/config.ts b/frontend/src/container/ServiceAccountsSettings/config.ts new file mode 100644 index 0000000000..f80be90597 --- /dev/null +++ b/frontend/src/container/ServiceAccountsSettings/config.ts @@ -0,0 +1 @@ +export const IS_SERVICE_ACCOUNTS_ENABLED = false; diff --git a/frontend/src/container/ServiceAccountsSettings/constants.ts b/frontend/src/container/ServiceAccountsSettings/constants.ts new file mode 100644 index 0000000000..a7c9014307 --- /dev/null +++ b/frontend/src/container/ServiceAccountsSettings/constants.ts @@ -0,0 +1,13 @@ +export const SA_QUERY_PARAMS = { + CREATE_SA: 'create-sa', + ACCOUNT: 'account', + PAGE: 'page', + SEARCH: 'search', + FILTER: 'filter', + TAB: 'tab', + KEYS_PAGE: 'keysPage', + ADD_KEY: 'add-key', + EDIT_KEY: 'edit-key', + REVOKE_KEY: 'revoke-key', + DISABLE_SA: 'disable-sa', +} as const; diff --git a/frontend/src/container/ServiceAccountsSettings/utils.ts b/frontend/src/container/ServiceAccountsSettings/utils.ts new file mode 100644 index 0000000000..4e87ee264d --- /dev/null +++ b/frontend/src/container/ServiceAccountsSettings/utils.ts @@ -0,0 +1,37 @@ +import type { ServiceaccounttypesServiceAccountDTO } from 'api/generated/services/sigNoz.schemas'; +import { toISOString } from 'utils/app'; + +export function toServiceAccountRow( + sa: ServiceaccounttypesServiceAccountDTO, +): ServiceAccountRow { + return { + id: sa.id, + name: sa.name, + email: sa.email, + roles: sa.roles, + status: sa.status, + createdAt: toISOString(sa.createdAt), + updatedAt: toISOString(sa.updatedAt), + }; +} + +export enum FilterMode { + All = 'all', + Active = 'active', + Disabled = 'disabled', +} + +export enum ServiceAccountStatus { + Active = 'ACTIVE', + Disabled = 'DISABLED', +} + +export interface ServiceAccountRow { + id: string; + name: string; + email: string; + roles: string[]; + status: string; + createdAt: string | null; + updatedAt: string | null; +} diff --git a/frontend/src/container/SideNav/menuItems.tsx b/frontend/src/container/SideNav/menuItems.tsx index f428563328..fefb99f896 100644 --- a/frontend/src/container/SideNav/menuItems.tsx +++ b/frontend/src/container/SideNav/menuItems.tsx @@ -8,6 +8,7 @@ import { BellDot, Binoculars, Book, + Bot, Boxes, BugIcon, Building2, @@ -358,6 +359,13 @@ export const settingsNavSections: SettingsNavSection[] = [ isEnabled: false, itemKey: 'members', }, + { + key: ROUTES.SERVICE_ACCOUNTS_SETTINGS, + label: 'Service Accounts', + icon: , + isEnabled: false, + itemKey: 'service-accounts', + }, { key: ROUTES.API_KEYS, label: 'API Keys', diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/constants.ts b/frontend/src/container/TopNav/DateTimeSelectionV2/constants.ts index 253d2140b5..724e185a89 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/constants.ts +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/constants.ts @@ -154,6 +154,7 @@ export const routesToSkip = [ ROUTES.ALL_DASHBOARD, ROUTES.ORG_SETTINGS, ROUTES.MEMBERS_SETTINGS, + ROUTES.SERVICE_ACCOUNTS_SETTINGS, ROUTES.INGESTION_SETTINGS, ROUTES.API_KEYS, ROUTES.ERROR_DETAIL, diff --git a/frontend/src/lib/uPlotV2/components/UPlotChart.tsx b/frontend/src/lib/uPlotV2/components/UPlotChart/UPlotChart.tsx similarity index 91% rename from frontend/src/lib/uPlotV2/components/UPlotChart.tsx rename to frontend/src/lib/uPlotV2/components/UPlotChart/UPlotChart.tsx index 7e6320ea5f..1588317f09 100644 --- a/frontend/src/lib/uPlotV2/components/UPlotChart.tsx +++ b/frontend/src/lib/uPlotV2/components/UPlotChart/UPlotChart.tsx @@ -6,8 +6,9 @@ import { LineChart } from 'lucide-react'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import uPlot, { AlignedData, Options } from 'uplot'; -import { usePlotContext } from '../context/PlotContext'; -import { UPlotChartProps } from './types'; +import { usePlotContext } from '../../context/PlotContext'; +import { UPlotChartProps } from '../types'; +import { prepareAlignedData } from './utils'; /** * Check if dimensions have changed @@ -83,8 +84,11 @@ export default function UPlotChart({ ...configOptions, } as Options; + // prepare final AlignedData + const preparedData = prepareAlignedData({ data, config }); + // Create new plot instance - const plot = new uPlot(plotConfig, data as AlignedData, containerRef.current); + const plot = new uPlot(plotConfig, preparedData, containerRef.current); if (plotRef) { plotRef(plot); @@ -162,7 +166,8 @@ export default function UPlotChart({ } // Update data if only data changed else if (!sameData(prevProps, currentProps) && plotInstanceRef.current) { - plotInstanceRef.current.setData(data as AlignedData); + const preparedData = prepareAlignedData({ data, config }); + plotInstanceRef.current.setData(preparedData as AlignedData); } prevPropsRef.current = currentProps; diff --git a/frontend/src/lib/uPlotV2/components/UPlotChart/utils.ts b/frontend/src/lib/uPlotV2/components/UPlotChart/utils.ts new file mode 100644 index 0000000000..25c997d7ec --- /dev/null +++ b/frontend/src/lib/uPlotV2/components/UPlotChart/utils.ts @@ -0,0 +1,16 @@ +import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder'; +import { applySpanGapsToAlignedData } from 'lib/uPlotV2/utils/dataUtils'; +import { AlignedData } from 'uplot'; + +export function prepareAlignedData({ + data, + config, +}: { + data: AlignedData; + config: UPlotConfigBuilder; +}): AlignedData { + const seriesSpanGaps = config.getSeriesSpanGapsOptions(); + return seriesSpanGaps.length > 0 + ? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps) + : (data as AlignedData); +} diff --git a/frontend/src/lib/uPlotV2/components/__tests__/UPlotChart.test.tsx b/frontend/src/lib/uPlotV2/components/__tests__/UPlotChart.test.tsx index c0908a323a..0f01d641e8 100644 --- a/frontend/src/lib/uPlotV2/components/__tests__/UPlotChart.test.tsx +++ b/frontend/src/lib/uPlotV2/components/__tests__/UPlotChart.test.tsx @@ -4,7 +4,7 @@ import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder'; import type { AlignedData } from 'uplot'; import { PlotContextProvider } from '../../context/PlotContext'; -import UPlotChart from '../UPlotChart'; +import UPlotChart from '../UPlotChart/UPlotChart'; // --------------------------------------------------------------------------- // Mocks @@ -86,6 +86,7 @@ const createMockConfig = (): UPlotConfigBuilder => { }), getId: jest.fn().mockReturnValue(undefined), getShouldSaveSelectionPreference: jest.fn().mockReturnValue(false), + getSeriesSpanGapsOptions: jest.fn().mockReturnValue([]), } as unknown) as UPlotConfigBuilder; }; @@ -328,6 +329,78 @@ describe('UPlotChart', () => { }); }); + describe('spanGaps data transformation', () => { + it('inserts null break points before passing data to uPlot when a gap exceeds the numeric threshold', () => { + const config = createMockConfig(); + // gap 0→100 = 100 > threshold 50 → null inserted at midpoint x=50 + (config.getSeriesSpanGapsOptions as jest.Mock).mockReturnValue([ + { spanGaps: 50 }, + ]); + const data: AlignedData = [ + [0, 100], + [1, 2], + ]; + + render(, { + wrapper: Wrapper, + }); + + const [, receivedData] = mockUPlotConstructor.mock.calls[0]; + expect(receivedData[0]).toEqual([0, 50, 100]); + expect(receivedData[1]).toEqual([1, null, 2]); + }); + + it('passes data through unchanged when no gap exceeds the numeric threshold', () => { + const config = createMockConfig(); + // all gaps = 10, threshold = 50 → no insertions, same reference returned + (config.getSeriesSpanGapsOptions as jest.Mock).mockReturnValue([ + { spanGaps: 50 }, + ]); + const data: AlignedData = [ + [0, 10, 20], + [1, 2, 3], + ]; + + render(, { + wrapper: Wrapper, + }); + + const [, receivedData] = mockUPlotConstructor.mock.calls[0]; + expect(receivedData).toBe(data); + }); + + it('transforms data passed to setData when data updates and a new gap exceeds the threshold', () => { + const config = createMockConfig(); + (config.getSeriesSpanGapsOptions as jest.Mock).mockReturnValue([ + { spanGaps: 50 }, + ]); + + // initial render: gap 10 < 50, no transformation + const initialData: AlignedData = [ + [0, 10], + [1, 2], + ]; + // updated data: gap 100 > 50 → null inserted at midpoint x=50 + const newData: AlignedData = [ + [0, 100], + [3, 4], + ]; + + const { rerender } = render( + , + { wrapper: Wrapper }, + ); + + rerender( + , + ); + + const receivedData = instances[0].setData.mock.calls[0][0]; + expect(receivedData[0]).toEqual([0, 50, 100]); + expect(receivedData[1]).toEqual([3, null, 4]); + }); + }); + describe('prop updates', () => { it('calls setData without recreating the plot when only data changes', () => { const config = createMockConfig(); diff --git a/frontend/src/lib/uPlotV2/config/UPlotConfigBuilder.ts b/frontend/src/lib/uPlotV2/config/UPlotConfigBuilder.ts index ed8f36efdd..aa74efe818 100644 --- a/frontend/src/lib/uPlotV2/config/UPlotConfigBuilder.ts +++ b/frontend/src/lib/uPlotV2/config/UPlotConfigBuilder.ts @@ -14,6 +14,7 @@ import { STEP_INTERVAL_MULTIPLIER, } from '../constants'; import { calculateWidthBasedOnStepInterval } from '../utils'; +import { SeriesSpanGapsOption } from '../utils/dataUtils'; import { ConfigBuilder, ConfigBuilderProps, @@ -161,6 +162,13 @@ export class UPlotConfigBuilder extends ConfigBuilder< this.series.push(new UPlotSeriesBuilder(props)); } + getSeriesSpanGapsOptions(): SeriesSpanGapsOption[] { + return this.series.map((s) => { + const { spanGaps } = s.props; + return { spanGaps }; + }); + } + /** * Add a hook for extensibility */ diff --git a/frontend/src/lib/uPlotV2/config/UPlotSeriesBuilder.ts b/frontend/src/lib/uPlotV2/config/UPlotSeriesBuilder.ts index 30d05e1279..5cd76cb9d9 100644 --- a/frontend/src/lib/uPlotV2/config/UPlotSeriesBuilder.ts +++ b/frontend/src/lib/uPlotV2/config/UPlotSeriesBuilder.ts @@ -4,6 +4,7 @@ import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils'; import uPlot, { Series } from 'uplot'; import { generateGradientFill } from '../utils/generateGradientFill'; +import { isolatedPointFilter } from '../utils/seriesPointsFilter'; import { BarAlignment, ConfigBuilder, @@ -146,20 +147,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder { }: { resolvedLineColor: string; }): Partial { - const { - lineWidth, - pointSize, - pointsBuilder, - pointsFilter, - drawStyle, - showPoints, - } = this.props; + const { lineWidth, pointSize, pointsFilter } = this.props; - /** - * If pointSize is not provided, use the lineWidth * POINT_SIZE_FACTOR - * to determine the point size. - * POINT_SIZE_FACTOR is 2, so the point size will be 2x the line width. - */ const resolvedPointSize = pointSize ?? (lineWidth ?? DEFAULT_LINE_WIDTH) * POINT_SIZE_FACTOR; @@ -168,19 +157,39 @@ export class UPlotSeriesBuilder extends ConfigBuilder { fill: resolvedLineColor, size: resolvedPointSize, filter: pointsFilter || undefined, + show: this.resolvePointsShow(), }; - if (pointsBuilder) { - pointsConfig.show = pointsBuilder; - } else if (drawStyle === DrawStyle.Points) { - pointsConfig.show = true; - } else { - pointsConfig.show = !!showPoints; + // When spanGaps is in threshold (numeric) mode, points hidden by default + // become invisible when isolated by injected gap-nulls (no line connects + // to them). Use a gap-based filter to show only those isolated points as + // dots. Do NOT set show=true here — the filter is called with show=false + // and returns specific indices to render; setting show=true would cause + // uPlot to call filter with show=true which short-circuits the logic and + // renders all points. + if (this.shouldApplyIsolatedPointFilter(pointsConfig.show)) { + pointsConfig.filter = isolatedPointFilter; } return pointsConfig; } + private resolvePointsShow(): Series.Points['show'] { + const { pointsBuilder, drawStyle, showPoints } = this.props; + if (pointsBuilder) { + return pointsBuilder; + } + if (drawStyle === DrawStyle.Points) { + return true; + } + return !!showPoints; + } + + private shouldApplyIsolatedPointFilter(show: Series.Points['show']): boolean { + const { drawStyle, pointsFilter } = this.props; + return drawStyle === DrawStyle.Line && !pointsFilter && !show; + } + private getLineColor(): string { const { colorMapping, label, lineColor, isDarkMode } = this.props; if (!label) { @@ -212,7 +221,12 @@ export class UPlotSeriesBuilder extends ConfigBuilder { return { scale: scaleKey, label, - spanGaps: typeof spanGaps === 'boolean' ? spanGaps : false, + // When spanGaps is numeric, we always disable uPlot's internal + // spanGaps behavior and rely on data-prep to implement the + // threshold-based null handling. When spanGaps is boolean we + // map it directly. When spanGaps is undefined we fall back to + // the default of true. + spanGaps: typeof spanGaps === 'number' ? false : spanGaps ?? true, value: (): string => '', pxAlign: true, show, diff --git a/frontend/src/lib/uPlotV2/config/__tests__/UPlotSeriesBuilder.test.ts b/frontend/src/lib/uPlotV2/config/__tests__/UPlotSeriesBuilder.test.ts index f13cf1ef7d..e8caba0b49 100644 --- a/frontend/src/lib/uPlotV2/config/__tests__/UPlotSeriesBuilder.test.ts +++ b/frontend/src/lib/uPlotV2/config/__tests__/UPlotSeriesBuilder.test.ts @@ -1,6 +1,7 @@ import { themeColors } from 'constants/theme'; import uPlot from 'uplot'; +import { isolatedPointFilter } from '../../utils/seriesPointsFilter'; import type { SeriesProps } from '../types'; import { DrawStyle, LineInterpolation, LineStyle } from '../types'; import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder'; @@ -40,6 +41,37 @@ describe('UPlotSeriesBuilder', () => { expect(typeof config.value).toBe('function'); }); + it('maps boolean spanGaps directly to uPlot spanGaps', () => { + const trueBuilder = new UPlotSeriesBuilder( + createBaseProps({ + spanGaps: true, + }), + ); + const falseBuilder = new UPlotSeriesBuilder( + createBaseProps({ + spanGaps: false, + }), + ); + + const trueConfig = trueBuilder.getConfig(); + const falseConfig = falseBuilder.getConfig(); + + expect(trueConfig.spanGaps).toBe(true); + expect(falseConfig.spanGaps).toBe(false); + }); + + it('disables uPlot spanGaps when spanGaps is a number', () => { + const builder = new UPlotSeriesBuilder( + createBaseProps({ + spanGaps: 10000, + }), + ); + + const config = builder.getConfig(); + + expect(config.spanGaps).toBe(false); + }); + it('uses explicit lineColor when provided, regardless of mapping', () => { const builder = new UPlotSeriesBuilder( createBaseProps({ @@ -284,4 +316,50 @@ describe('UPlotSeriesBuilder', () => { expect(config.points?.filter).toBe(pointsFilter); }); + + it('assigns isolatedPointFilter and does not force show=true when spanGaps is numeric and no custom filter', () => { + const builder = new UPlotSeriesBuilder( + createBaseProps({ + drawStyle: DrawStyle.Line, + spanGaps: 10_000, + showPoints: false, + }), + ); + + const config = builder.getConfig(); + + expect(config.points?.filter).toBe(isolatedPointFilter); + expect(config.points?.show).toBe(false); + }); + + it('does not assign isolatedPointFilter when a custom pointsFilter is provided alongside numeric spanGaps', () => { + const customFilter: uPlot.Series.Points.Filter = jest.fn(() => null); + + const builder = new UPlotSeriesBuilder( + createBaseProps({ + drawStyle: DrawStyle.Line, + spanGaps: 10_000, + pointsFilter: customFilter, + }), + ); + + const config = builder.getConfig(); + + expect(config.points?.filter).toBe(customFilter); + }); + + it('does not assign isolatedPointFilter when showPoints is true even with numeric spanGaps', () => { + const builder = new UPlotSeriesBuilder( + createBaseProps({ + drawStyle: DrawStyle.Line, + spanGaps: 10_000, + showPoints: true, + }), + ); + + const config = builder.getConfig(); + + expect(config.points?.filter).toBeUndefined(); + expect(config.points?.show).toBe(true); + }); }); diff --git a/frontend/src/lib/uPlotV2/config/types.ts b/frontend/src/lib/uPlotV2/config/types.ts index 664c310e14..808260ff60 100644 --- a/frontend/src/lib/uPlotV2/config/types.ts +++ b/frontend/src/lib/uPlotV2/config/types.ts @@ -99,6 +99,11 @@ export interface ScaleProps { distribution?: DistributionType; } +export enum DisconnectedValuesMode { + Never = 'never', + Threshold = 'threshold', +} + /** * Props for configuring a series */ @@ -175,7 +180,16 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig { pointsFilter?: Series.Points.Filter; pointsBuilder?: Series.Points.Show; show?: boolean; - spanGaps?: boolean; + /** + * Controls how nulls are treated for this series. + * + * - boolean: mapped directly to uPlot's spanGaps behavior + * - number: interpreted as an X-axis threshold (same unit as ref values), + * where gaps smaller than this threshold are spanned by + * converting short null runs to undefined during data prep + * while uPlot's internal spanGaps is kept disabled. + */ + spanGaps?: boolean | number; fillColor?: string; fillMode?: FillMode; isDarkMode?: boolean; diff --git a/frontend/src/lib/uPlotV2/utils/__tests__/dataUtils.test.ts b/frontend/src/lib/uPlotV2/utils/__tests__/dataUtils.test.ts index f869c1d28c..e2ac93899a 100644 --- a/frontend/src/lib/uPlotV2/utils/__tests__/dataUtils.test.ts +++ b/frontend/src/lib/uPlotV2/utils/__tests__/dataUtils.test.ts @@ -1,4 +1,12 @@ -import { isInvalidPlotValue, normalizePlotValue } from '../dataUtils'; +import uPlot from 'uplot'; + +import { + applySpanGapsToAlignedData, + insertLargeGapNullsIntoAlignedData, + isInvalidPlotValue, + normalizePlotValue, + SeriesSpanGapsOption, +} from '../dataUtils'; describe('dataUtils', () => { describe('isInvalidPlotValue', () => { @@ -59,4 +67,217 @@ describe('dataUtils', () => { expect(normalizePlotValue(42.5)).toBe(42.5); }); }); + + describe('insertLargeGapNullsIntoAlignedData', () => { + it('returns original data unchanged when no gap exceeds the threshold', () => { + // all gaps = 10, threshold = 25 → no insertions + const data: uPlot.AlignedData = [ + [0, 10, 20, 30], + [1, 2, 3, 4], + ]; + const options: SeriesSpanGapsOption[] = [{ spanGaps: 25 }]; + + const result = insertLargeGapNullsIntoAlignedData(data, options); + + expect(result).toBe(data); + }); + + it('does not insert when the gap equals the threshold exactly', () => { + // gap = 50, threshold = 50 → condition is gap > threshold, not >= + const data: uPlot.AlignedData = [ + [0, 50], + [1, 2], + ]; + const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }]; + + const result = insertLargeGapNullsIntoAlignedData(data, options); + + expect(result).toBe(data); + }); + + it('inserts a null at the midpoint when a single gap exceeds the threshold', () => { + // gap 0→100 = 100 > 50 → insert null at x=50 + const data: uPlot.AlignedData = [ + [0, 100], + [1, 2], + ]; + const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }]; + + const result = insertLargeGapNullsIntoAlignedData(data, options); + + expect(result[0]).toEqual([0, 50, 100]); + expect(result[1]).toEqual([1, null, 2]); + }); + + it('inserts nulls at every gap that exceeds the threshold', () => { + // gaps: 0→100=100, 100→110=10, 110→210=100; threshold=50 + // → insert at 0→100 and 110→210 + const data: uPlot.AlignedData = [ + [0, 100, 110, 210], + [1, 2, 3, 4], + ]; + const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }]; + + const result = insertLargeGapNullsIntoAlignedData(data, options); + + expect(result[0]).toEqual([0, 50, 100, 110, 160, 210]); + expect(result[1]).toEqual([1, null, 2, 3, null, 4]); + }); + + it('inserts null for all series at a gap triggered by any one series', () => { + // series 0: threshold=50, gap=100 → triggers insertion + // series 1: threshold=200, gap=100 → would not trigger alone + // result: both series get null at the inserted x because the x-axis is shared + const data: uPlot.AlignedData = [ + [0, 100], + [1, 2], + [3, 4], + ]; + const options: SeriesSpanGapsOption[] = [ + { spanGaps: 50 }, + { spanGaps: 200 }, + ]; + + const result = insertLargeGapNullsIntoAlignedData(data, options); + + expect(result[0]).toEqual([0, 50, 100]); + expect(result[1]).toEqual([1, null, 2]); + expect(result[2]).toEqual([3, null, 4]); + }); + + it('ignores boolean spanGaps options (only numeric values trigger insertion)', () => { + const data: uPlot.AlignedData = [ + [0, 100], + [1, 2], + ]; + const options: SeriesSpanGapsOption[] = [{ spanGaps: true }]; + + const result = insertLargeGapNullsIntoAlignedData(data, options); + + expect(result).toBe(data); + }); + + it('returns original data when series options array is empty', () => { + const data: uPlot.AlignedData = [ + [0, 100], + [1, 2], + ]; + + const result = insertLargeGapNullsIntoAlignedData(data, []); + + expect(result).toBe(data); + }); + + it('returns original data when there is only one x point', () => { + const data: uPlot.AlignedData = [[0], [1]]; + const options: SeriesSpanGapsOption[] = [{ spanGaps: 10 }]; + + const result = insertLargeGapNullsIntoAlignedData(data, options); + + expect(result).toBe(data); + }); + + it('preserves existing null values in the series alongside inserted ones', () => { + // original series already has a null; gap 0→100 also triggers insertion + const data: uPlot.AlignedData = [ + [0, 100, 110], + [1, null, 2], + ]; + const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }]; + + const result = insertLargeGapNullsIntoAlignedData(data, options); + + expect(result[0]).toEqual([0, 50, 100, 110]); + expect(result[1]).toEqual([1, null, null, 2]); + }); + }); + + describe('applySpanGapsToAlignedData', () => { + const xs: uPlot.AlignedData[0] = [0, 10, 20, 30]; + + it('returns original data when there are no series', () => { + const data: uPlot.AlignedData = [xs]; + const result = applySpanGapsToAlignedData(data, []); + + expect(result).toBe(data); + }); + + it('leaves data unchanged when spanGaps is undefined', () => { + const ys = [1, null, 2, null]; + const data: uPlot.AlignedData = [xs, ys]; + const options: SeriesSpanGapsOption[] = [{}]; + + const result = applySpanGapsToAlignedData(data, options); + + expect(result[1]).toEqual(ys); + }); + + it('converts nulls to undefined when spanGaps is true', () => { + const ys = [1, null, 2, null]; + const data: uPlot.AlignedData = [xs, ys]; + const options: SeriesSpanGapsOption[] = [{ spanGaps: true }]; + + const result = applySpanGapsToAlignedData(data, options); + + expect(result[1]).toEqual([1, undefined, 2, undefined]); + }); + + it('leaves data unchanged when spanGaps is false', () => { + const ys = [1, null, 2, null]; + const data: uPlot.AlignedData = [xs, ys]; + const options: SeriesSpanGapsOption[] = [{ spanGaps: false }]; + + const result = applySpanGapsToAlignedData(data, options); + + expect(result[1]).toEqual(ys); + }); + + it('inserts a null break point when a gap exceeds the numeric threshold', () => { + // gap 0→100 = 100 > 50 → null inserted at midpoint x=50 + const data: uPlot.AlignedData = [ + [0, 100, 110], + [1, 2, 3], + ]; + const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }]; + + const result = applySpanGapsToAlignedData(data, options); + + expect(result[0]).toEqual([0, 50, 100, 110]); + expect(result[1]).toEqual([1, null, 2, 3]); + }); + + it('returns original data when no gap exceeds the numeric threshold', () => { + // all gaps = 10, threshold = 25 → no insertions + const data: uPlot.AlignedData = [xs, [1, 2, 3, 4]]; + const options: SeriesSpanGapsOption[] = [{ spanGaps: 25 }]; + + const result = applySpanGapsToAlignedData(data, options); + + expect(result).toBe(data); + }); + + it('applies both numeric gap insertion and boolean null-to-undefined in one pass', () => { + // series 0: spanGaps: 50 → gap 0→100 triggers a null break at midpoint x=50 + // series 1: spanGaps: true → the inserted null at x=50 becomes undefined, + // so the line spans over it rather than breaking + const data: uPlot.AlignedData = [ + [0, 100], + [1, 2], + [3, 4], + ]; + const options: SeriesSpanGapsOption[] = [ + { spanGaps: 50 }, + { spanGaps: true }, + ]; + + const result = applySpanGapsToAlignedData(data, options); + + // x-axis extended with the inserted midpoint + expect(result[0]).toEqual([0, 50, 100]); + // series 0: null at midpoint breaks the line + expect(result[1]).toEqual([1, null, 2]); + // series 1: null at midpoint converted to undefined → line spans over it + expect(result[2]).toEqual([3, undefined, 4]); + }); + }); }); diff --git a/frontend/src/lib/uPlotV2/utils/__tests__/seriesPointsFilter.test.ts b/frontend/src/lib/uPlotV2/utils/__tests__/seriesPointsFilter.test.ts new file mode 100644 index 0000000000..04d98ba231 --- /dev/null +++ b/frontend/src/lib/uPlotV2/utils/__tests__/seriesPointsFilter.test.ts @@ -0,0 +1,251 @@ +import type uPlot from 'uplot'; + +import { + findNearestNonNull, + findSandwichedIndices, + isolatedPointFilter, +} from '../seriesPointsFilter'; + +// --------------------------------------------------------------------------- +// Minimal uPlot stub — only the surface used by seriesPointsFilter +// --------------------------------------------------------------------------- + +function makeUPlot({ + xData, + yData, + idxs, + valToPosFn, + posToIdxFn, +}: { + xData: number[]; + yData: (number | null | undefined)[]; + idxs?: [number, number]; + valToPosFn?: (val: number) => number; + posToIdxFn?: (pos: number) => number; +}): uPlot { + return ({ + data: [xData, yData], + series: [{}, { idxs: idxs ?? [0, yData.length - 1] }], + valToPos: jest.fn((val: number) => (valToPosFn ? valToPosFn(val) : val)), + posToIdx: jest.fn((pos: number) => + posToIdxFn ? posToIdxFn(pos) : Math.round(pos), + ), + } as unknown) as uPlot; +} + +// --------------------------------------------------------------------------- +// findNearestNonNull +// --------------------------------------------------------------------------- + +describe('findNearestNonNull', () => { + it('returns the right neighbor when left side is null', () => { + const yData = [null, null, 42, null]; + expect(findNearestNonNull(yData, 1)).toBe(2); + }); + + it('returns the left neighbor when right side is null', () => { + const yData = [null, 42, null, null]; + expect(findNearestNonNull(yData, 2)).toBe(1); + }); + + it('prefers the right neighbor over the left when both exist at the same distance', () => { + const yData = [10, null, 20]; + // j=1: right (idx 3) is out of bounds (undefined == null), left (idx 1) is null + // Actually right (idx 2) exists at j=1 + expect(findNearestNonNull(yData, 1)).toBe(2); + }); + + it('returns approxIdx unchanged when no non-null value is found within 100 steps', () => { + const yData: (number | null)[] = Array(5).fill(null); + expect(findNearestNonNull(yData, 2)).toBe(2); + }); + + it('handles undefined values the same as null', () => { + const yData: (number | null | undefined)[] = [undefined, undefined, 99]; + expect(findNearestNonNull(yData, 0)).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// findSandwichedIndices +// --------------------------------------------------------------------------- + +describe('findSandwichedIndices', () => { + it('returns empty array when no consecutive gaps share a pixel boundary', () => { + const gaps = [ + [0, 10], + [20, 30], + ]; + const yData = [1, null, null, 2]; + const u = makeUPlot({ xData: [0, 1, 2, 3], yData }); + expect(findSandwichedIndices(gaps, yData, u)).toEqual([]); + }); + + it('returns the index between two gaps that share a pixel boundary', () => { + // gaps[0] ends at 10, gaps[1] starts at 10 → sandwiched point at pixel 10 + const gaps = [ + [0, 10], + [10, 20], + ]; + // posToIdx(10) → 2 + const yData = [null, null, 5, null, null]; + const u = makeUPlot({ xData: [0, 1, 2, 3, 4], yData, posToIdxFn: () => 2 }); + expect(findSandwichedIndices(gaps, yData, u)).toEqual([2]); + }); + + it('scans to nearest non-null when posToIdx lands on a null', () => { + // posToIdx returns 2 which is null; nearest non-null is index 3 + const gaps = [ + [0, 10], + [10, 20], + ]; + const yData = [null, null, null, 7, null]; + const u = makeUPlot({ xData: [0, 1, 2, 3, 4], yData, posToIdxFn: () => 2 }); + expect(findSandwichedIndices(gaps, yData, u)).toEqual([3]); + }); + + it('returns multiple indices when several gap pairs share boundaries', () => { + // Three consecutive gaps: [0,10], [10,20], [20,30] + // → two sandwiched points: between gaps 0-1 at px 10, between gaps 1-2 at px 20 + const gaps = [ + [0, 10], + [10, 20], + [20, 30], + ]; + const yData = [null, 1, null, 2, null]; + const u = makeUPlot({ + xData: [0, 1, 2, 3, 4], + yData, + posToIdxFn: (pos) => (pos === 10 ? 1 : 3), + }); + expect(findSandwichedIndices(gaps, yData, u)).toEqual([1, 3]); + }); +}); + +// --------------------------------------------------------------------------- +// isolatedPointFilter +// --------------------------------------------------------------------------- + +describe('isolatedPointFilter', () => { + it('returns null when show is true (normal point rendering active)', () => { + const u = makeUPlot({ xData: [0, 1], yData: [1, null] }); + expect(isolatedPointFilter(u, 1, true, [[0, 10]])).toBeNull(); + }); + + it('returns null when gaps is null', () => { + const u = makeUPlot({ xData: [0, 1], yData: [1, null] }); + expect(isolatedPointFilter(u, 1, false, null)).toBeNull(); + }); + + it('returns null when gaps is empty', () => { + const u = makeUPlot({ xData: [0, 1], yData: [1, null] }); + expect(isolatedPointFilter(u, 1, false, [])).toBeNull(); + }); + + it('returns null when series idxs is undefined', () => { + const u = ({ + data: [ + [0, 1], + [1, null], + ], + series: [{}, { idxs: undefined }], + valToPos: jest.fn(() => 0), + posToIdx: jest.fn(() => 0), + } as unknown) as uPlot; + expect(isolatedPointFilter(u, 1, false, [[0, 10]])).toBeNull(); + }); + + it('includes firstIdx when the first gap starts at the first data point pixel', () => { + // xData[firstIdx=0] → valToPos → 5; gaps[0][0] === 5 → isolated leading point + const xData = [0, 1, 2, 3, 4]; + const yData = [10, null, null, null, 20]; + const u = makeUPlot({ + xData, + yData, + idxs: [0, 4], + valToPosFn: (val) => (val === 0 ? 5 : 40), // firstPos=5, lastPos=40 + }); + // gaps[0][0] === 5 (firstPos), gaps last end !== 40 + const result = isolatedPointFilter(u, 1, false, [ + [5, 15], + [20, 30], + ]); + expect(result).toContain(0); // firstIdx + }); + + it('includes lastIdx when the last gap ends at the last data point pixel', () => { + const xData = [0, 1, 2, 3, 4]; + const yData = [10, null, null, null, 20]; + const u = makeUPlot({ + xData, + yData, + idxs: [0, 4], + valToPosFn: (val) => (val === 0 ? 5 : 40), // firstPos=5, lastPos=40 + }); + // gaps last end === 40 (lastPos), gaps[0][0] !== 5 + const result = isolatedPointFilter(u, 1, false, [ + [10, 20], + [30, 40], + ]); + expect(result).toContain(4); // lastIdx + }); + + it('includes sandwiched index between two gaps sharing a pixel boundary', () => { + const xData = [0, 1, 2, 3, 4]; + const yData = [null, null, 5, null, null]; + const u = makeUPlot({ + xData, + yData, + idxs: [0, 4], + valToPosFn: () => 99, // firstPos/lastPos won't match gap boundaries + posToIdxFn: () => 2, + }); + const result = isolatedPointFilter(u, 1, false, [ + [0, 50], + [50, 100], + ]); + expect(result).toContain(2); + }); + + it('returns null when no isolated points are found', () => { + const xData = [0, 1, 2]; + const yData = [1, 2, 3]; + const u = makeUPlot({ + xData, + yData, + idxs: [0, 2], + // firstPos = 10, lastPos = 30 — neither matches any gap boundary + valToPosFn: (val) => (val === 0 ? 10 : 30), + }); + // gaps don't share boundaries and don't touch firstPos/lastPos + const result = isolatedPointFilter(u, 1, false, [ + [0, 5], + [15, 20], + ]); + expect(result).toBeNull(); + }); + + it('returns all three kinds of isolated points in one pass', () => { + // Leading (firstPos=0 === gaps[0][0]), sandwiched (gaps[1] and gaps[2] share 50), + // trailing (lastPos=100 === gaps last end) + const xData = [0, 1, 2, 3, 4]; + const yData = [1, null, 2, null, 3]; + const u = makeUPlot({ + xData, + yData, + idxs: [0, 4], + valToPosFn: (val) => (val === 0 ? 0 : 100), + posToIdxFn: () => 2, // sandwiched point at idx 2 + }); + const gaps = [ + [0, 20], + [40, 50], + [50, 80], + [90, 100], + ]; + const result = isolatedPointFilter(u, 1, false, gaps); + expect(result).toContain(0); // leading + expect(result).toContain(2); // sandwiched + expect(result).toContain(4); // trailing + }); +}); diff --git a/frontend/src/lib/uPlotV2/utils/dataUtils.ts b/frontend/src/lib/uPlotV2/utils/dataUtils.ts index 4aea802e37..74dc6d2d26 100644 --- a/frontend/src/lib/uPlotV2/utils/dataUtils.ts +++ b/frontend/src/lib/uPlotV2/utils/dataUtils.ts @@ -24,10 +24,10 @@ export function isInvalidPlotValue(value: unknown): boolean { } // Try to parse the string as a number - const numValue = parseFloat(value); + const parsedNumber = parseFloat(value); // If parsing failed or resulted in a non-finite number, it's invalid - if (Number.isNaN(numValue) || !Number.isFinite(numValue)) { + if (Number.isNaN(parsedNumber) || !Number.isFinite(parsedNumber)) { return true; } } @@ -51,3 +51,178 @@ export function normalizePlotValue( // Already a valid number return value as number; } + +export interface SeriesSpanGapsOption { + spanGaps?: boolean | number; +} + +// Internal type alias: a series value array that may contain nulls/undefineds. +// uPlot uses null to draw a visible gap and undefined to represent "no sample" +// (the line continues across undefined points but breaks at null ones). +type SeriesArray = Array; + +/** + * Returns true if the given gap size exceeds the numeric spanGaps threshold + * of at least one series. Used to decide whether to insert a null break point. + */ +function gapExceedsThreshold( + gapSize: number, + seriesOptions: SeriesSpanGapsOption[], +): boolean { + return seriesOptions.some( + ({ spanGaps }) => + typeof spanGaps === 'number' && spanGaps > 0 && gapSize > spanGaps, + ); +} + +/** + * For each series with a numeric spanGaps threshold, insert a null data point + * between consecutive x timestamps whose gap exceeds the threshold. + * + * Why: uPlot draws a continuous line between all non-null points. When the + * time gap between two consecutive samples is larger than the configured + * spanGaps value, we inject a synthetic null at the midpoint so uPlot renders + * a visible break instead of a misleading straight line across the gap. + * + * Because uPlot's AlignedData shares a single x-axis across all series, a null + * is inserted for every series at each position where any series needs a break. + * + * Two-pass approach for performance: + * Pass 1 — count how many nulls will be inserted (no allocations). + * Pass 2 — fill pre-allocated output arrays by index (no push/reallocation). + */ +export function insertLargeGapNullsIntoAlignedData( + data: uPlot.AlignedData, + seriesOptions: SeriesSpanGapsOption[], +): uPlot.AlignedData { + const [xValues, ...seriesValues] = data; + + if ( + !Array.isArray(xValues) || + xValues.length < 2 || + seriesValues.length === 0 + ) { + return data; + } + + const timestamps = xValues as number[]; + const totalPoints = timestamps.length; + + // Pass 1: count insertions needed so we know the exact output length. + // This lets us pre-allocate arrays rather than growing them dynamically. + let insertionCount = 0; + for (let i = 0; i < totalPoints - 1; i += 1) { + if (gapExceedsThreshold(timestamps[i + 1] - timestamps[i], seriesOptions)) { + insertionCount += 1; + } + } + + // No gaps exceed any threshold — return the original data unchanged. + if (insertionCount === 0) { + return data; + } + + // Pass 2: build output arrays of exact size and fill them. + // `writeIndex` is the write cursor into the output arrays. + const outputLen = totalPoints + insertionCount; + const newX = new Array(outputLen); + const newSeries: SeriesArray[] = seriesValues.map( + () => new Array(outputLen), + ); + + let writeIndex = 0; + for (let i = 0; i < totalPoints; i += 1) { + // Copy the real data point at position i + newX[writeIndex] = timestamps[i]; + for ( + let seriesIndex = 0; + seriesIndex < seriesValues.length; + seriesIndex += 1 + ) { + newSeries[seriesIndex][writeIndex] = (seriesValues[ + seriesIndex + ] as SeriesArray)[i]; + } + writeIndex += 1; + + // If the gap to the next x timestamp exceeds the threshold, insert a + // synthetic null at the midpoint. The midpoint x is placed halfway + // between timestamps[i] and timestamps[i+1] (minimum 1 unit past timestamps[i] to stay unique). + if ( + i < totalPoints - 1 && + gapExceedsThreshold(timestamps[i + 1] - timestamps[i], seriesOptions) + ) { + newX[writeIndex] = + timestamps[i] + + Math.max(1, Math.floor((timestamps[i + 1] - timestamps[i]) / 2)); + for ( + let seriesIndex = 0; + seriesIndex < seriesValues.length; + seriesIndex += 1 + ) { + newSeries[seriesIndex][writeIndex] = null; // null tells uPlot to break the line here + } + writeIndex += 1; + } + } + + return [newX, ...newSeries] as uPlot.AlignedData; +} + +/** + * Apply per-series spanGaps (boolean | number) handling to an aligned dataset. + * + * spanGaps controls how uPlot handles gaps in a series: + * - boolean true → convert null → undefined so uPlot spans over every gap + * (draws a continuous line, skipping missing samples) + * - boolean false → no change; nulls render as visible breaks (default) + * - number → insert a null break point between any two consecutive + * timestamps whose difference exceeds the threshold; + * gaps smaller than the threshold are left as-is + * + * The input data is expected to be of the form: + * [xValues, series1Values, series2Values, ...] + */ +export function applySpanGapsToAlignedData( + data: uPlot.AlignedData, + seriesOptions: SeriesSpanGapsOption[], +): uPlot.AlignedData { + const [xValues, ...seriesValues] = data; + + if (!Array.isArray(xValues) || seriesValues.length === 0) { + return data; + } + + // Numeric spanGaps: operates on the whole dataset at once because inserting + // null break points requires modifying the shared x-axis. + const hasNumericSpanGaps = seriesOptions.some( + ({ spanGaps }) => typeof spanGaps === 'number', + ); + const gapProcessed = hasNumericSpanGaps + ? insertLargeGapNullsIntoAlignedData(data, seriesOptions) + : data; + + // Boolean spanGaps === true: convert null → undefined per series so uPlot + // draws a continuous line across missing samples instead of breaking it. + // Skip this pass entirely if no series uses spanGaps: true. + const hasBooleanTrue = seriesOptions.some(({ spanGaps }) => spanGaps === true); + if (!hasBooleanTrue) { + return gapProcessed; + } + + const [newX, ...newSeries] = gapProcessed; + const transformedSeries = newSeries.map((yValues, seriesIndex) => { + const { spanGaps } = seriesOptions[seriesIndex] ?? {}; + if (spanGaps !== true) { + // This series doesn't use spanGaps: true — leave it unchanged. + return yValues; + } + // Replace null with undefined: uPlot skips undefined points without + // breaking the line, effectively spanning over the gap. + return (yValues as SeriesArray).map((pointValue) => + pointValue === null ? undefined : pointValue, + ) as uPlot.AlignedData[0]; + }); + + return [newX, ...transformedSeries] as uPlot.AlignedData; +} diff --git a/frontend/src/lib/uPlotV2/utils/seriesPointsFilter.ts b/frontend/src/lib/uPlotV2/utils/seriesPointsFilter.ts new file mode 100644 index 0000000000..a723d8bfee --- /dev/null +++ b/frontend/src/lib/uPlotV2/utils/seriesPointsFilter.ts @@ -0,0 +1,93 @@ +import uPlot from 'uplot'; + +/** + * Scans outward from approxIdx to find the nearest non-null data index. + * posToIdx can land on a null when pixel density exceeds 1 point-per-pixel. + */ +export function findNearestNonNull( + yData: (number | null | undefined)[], + approxIdx: number, +): number { + for (let j = 1; j < 100; j++) { + if (yData[approxIdx + j] != null) { + return approxIdx + j; + } + if (yData[approxIdx - j] != null) { + return approxIdx - j; + } + } + return approxIdx; +} + +/** + * Returns data indices of points sandwiched between two consecutive gaps that + * share a pixel boundary — meaning a point (or cluster) is isolated between them. + */ +export function findSandwichedIndices( + gaps: number[][], + yData: (number | null | undefined)[], + uPlotInstance: uPlot, +): number[] { + const indices: number[] = []; + for (let i = 0; i < gaps.length; i++) { + const nextGap = gaps[i + 1]; + if (nextGap && gaps[i][1] === nextGap[0]) { + const approxIdx = uPlotInstance.posToIdx(gaps[i][1], true); + indices.push( + yData[approxIdx] == null ? findNearestNonNull(yData, approxIdx) : approxIdx, + ); + } + } + return indices; +} + +/** + * Points filter that shows data points isolated by gap-nulls (no connecting line). + * Used when spanGaps threshold mode injects nulls around gaps — without this, + * lone points become invisible because no line connects to them. + * + * Uses uPlot's gap pixel array rather than checking raw null neighbors in the + * data array. Returns an array of data indices (not a bitmask); null = no points. + * + + */ +// eslint-disable-next-line max-params +export function isolatedPointFilter( + uPlotInstance: uPlot, + seriesIdx: number, + show: boolean, + gaps?: null | number[][], +): number[] | null { + if (show || !gaps || gaps.length === 0) { + return null; + } + + const idxs = uPlotInstance.series[seriesIdx].idxs; + if (!idxs) { + return null; + } + + const [firstIdx, lastIdx] = idxs; + const xData = uPlotInstance.data[0] as number[]; + const yData = uPlotInstance.data[seriesIdx] as (number | null | undefined)[]; + + // valToPos with canvas=true matches the pixel space used by the gaps array. + const firstPos = Math.round( + uPlotInstance.valToPos(xData[firstIdx], 'x', true), + ); + const lastPos = Math.round(uPlotInstance.valToPos(xData[lastIdx], 'x', true)); + + const filtered: number[] = []; + + if (gaps[0][0] === firstPos) { + filtered.push(firstIdx); + } + + filtered.push(...findSandwichedIndices(gaps, yData, uPlotInstance)); + + if (gaps[gaps.length - 1][1] === lastPos) { + filtered.push(lastIdx); + } + + return filtered.length ? filtered : null; +} diff --git a/frontend/src/mocks-server/__mockdata__/invite_user.ts b/frontend/src/mocks-server/__mockdata__/invite_user.ts deleted file mode 100644 index 3b736df977..0000000000 --- a/frontend/src/mocks-server/__mockdata__/invite_user.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const inviteUser = { - status: 'success', - data: { - statusCode: 200, - error: null, - payload: [ - { - email: 'jane@doe.com', - name: 'Jane', - token: 'testtoken', - createdAt: 1715741587, - role: 'VIEWER', - organization: 'test', - }, - { - email: 'test+in@singoz.io', - name: '', - token: 'testtoken1', - createdAt: 1720095913, - role: 'VIEWER', - organization: 'test', - }, - ], - }, -}; diff --git a/frontend/src/mocks-server/handlers.ts b/frontend/src/mocks-server/handlers.ts index abb3fc1aec..371389ccf7 100644 --- a/frontend/src/mocks-server/handlers.ts +++ b/frontend/src/mocks-server/handlers.ts @@ -9,7 +9,6 @@ import { getDashboardById, } from './__mockdata__/dashboards'; import { explorerView } from './__mockdata__/explorer_views'; -import { inviteUser } from './__mockdata__/invite_user'; import { licensesSuccessResponse } from './__mockdata__/licenses'; import { membersResponse } from './__mockdata__/members'; import { queryRangeSuccessResponse } from './__mockdata__/query_range'; @@ -175,11 +174,14 @@ export const handlers = [ res(ctx.status(200), ctx.json(getDashboardById)), ), - rest.get('http://localhost/api/v1/invite', (_, res, ctx) => - res(ctx.status(200), ctx.json(inviteUser)), - ), rest.post('http://localhost/api/v1/invite', (_, res, ctx) => - res(ctx.status(200), ctx.json(inviteUser)), + res( + ctx.status(200), + ctx.json({ + status: 'success', + data: 'invite sent successfully', + }), + ), ), rest.put('http://localhost/api/v1/user/:id', (_, res, ctx) => res( diff --git a/frontend/src/pages/ServiceAccountsSettings/index.tsx b/frontend/src/pages/ServiceAccountsSettings/index.tsx new file mode 100644 index 0000000000..9ad5a953c3 --- /dev/null +++ b/frontend/src/pages/ServiceAccountsSettings/index.tsx @@ -0,0 +1 @@ +export { default } from 'container/ServiceAccountsSettings/ServiceAccountsSettings'; diff --git a/frontend/src/pages/Settings/Settings.tsx b/frontend/src/pages/Settings/Settings.tsx index 918d23bcdc..9541670380 100644 --- a/frontend/src/pages/Settings/Settings.tsx +++ b/frontend/src/pages/Settings/Settings.tsx @@ -5,6 +5,7 @@ import logEvent from 'api/common/logEvent'; import RouteTab from 'components/RouteTab'; import { FeatureKeys } from 'constants/features'; import ROUTES from 'constants/routes'; +import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config'; import { routeConfig } from 'container/SideNav/config'; import { getQueryString } from 'container/SideNav/helper'; import { settingsNavSections } from 'container/SideNav/menuItems'; @@ -85,6 +86,8 @@ function SettingsPage(): JSX.Element { item.key === ROUTES.INGESTION_SETTINGS || item.key === ROUTES.ORG_SETTINGS || item.key === ROUTES.MEMBERS_SETTINGS || + (IS_SERVICE_ACCOUNTS_ENABLED && + item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS) || item.key === ROUTES.SHORTCUTS ? true : item.isEnabled, @@ -116,6 +119,8 @@ function SettingsPage(): JSX.Element { item.key === ROUTES.API_KEYS || item.key === ROUTES.ORG_SETTINGS || item.key === ROUTES.MEMBERS_SETTINGS || + (IS_SERVICE_ACCOUNTS_ENABLED && + item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS) || item.key === ROUTES.INGESTION_SETTINGS ? true : item.isEnabled, @@ -141,7 +146,9 @@ function SettingsPage(): JSX.Element { isEnabled: item.key === ROUTES.API_KEYS || item.key === ROUTES.ORG_SETTINGS || - item.key === ROUTES.MEMBERS_SETTINGS + item.key === ROUTES.MEMBERS_SETTINGS || + (IS_SERVICE_ACCOUNTS_ENABLED && + item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS) ? true : item.isEnabled, })); diff --git a/frontend/src/pages/Settings/config.tsx b/frontend/src/pages/Settings/config.tsx index 4b9a7fd6d6..3754cec44f 100644 --- a/frontend/src/pages/Settings/config.tsx +++ b/frontend/src/pages/Settings/config.tsx @@ -17,6 +17,7 @@ import { TFunction } from 'i18next'; import { Backpack, BellDot, + Bot, Building, Cpu, CreditCard, @@ -30,6 +31,7 @@ import { } 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'] => [ @@ -203,6 +205,21 @@ export const mySettings = (t: TFunction): RouteTabProps['routes'] => [ }, ]; +export const serviceAccountsSettings = ( + t: TFunction, +): RouteTabProps['routes'] => [ + { + Component: ServiceAccountsSettings, + name: ( +
+ {t('routes:service_accounts').toString()} +
+ ), + route: ROUTES.SERVICE_ACCOUNTS_SETTINGS, + key: ROUTES.SERVICE_ACCOUNTS_SETTINGS, + }, +]; + export const createAlertChannels = (t: TFunction): RouteTabProps['routes'] => [ { Component: (): JSX.Element => ( diff --git a/frontend/src/pages/Settings/utils.ts b/frontend/src/pages/Settings/utils.ts index 3270352a05..82d2499c19 100644 --- a/frontend/src/pages/Settings/utils.ts +++ b/frontend/src/pages/Settings/utils.ts @@ -1,4 +1,5 @@ import { RouteTabProps } from 'components/RouteTab/types'; +import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config'; import { TFunction } from 'i18next'; import { ROLES, USER_ROLES } from 'types/roles'; @@ -17,6 +18,7 @@ import { organizationSettings, roleDetails, rolesSettings, + serviceAccountsSettings, } from './config'; export const getRoutes = ( @@ -63,6 +65,10 @@ export const getRoutes = ( if (isAdmin) { settings.push(...apiKeys(t), ...membersSettings(t)); + + if (IS_SERVICE_ACCOUNTS_ENABLED) { + settings.push(...serviceAccountsSettings(t)); + } } // todo: Sagar - check the condition for role list and details page, to whom we want to serve diff --git a/frontend/src/pages/SignUp/SignUp.tsx b/frontend/src/pages/SignUp/SignUp.tsx index fecf560454..19a9f08d47 100644 --- a/frontend/src/pages/SignUp/SignUp.tsx +++ b/frontend/src/pages/SignUp/SignUp.tsx @@ -1,13 +1,9 @@ -import { useEffect, useMemo, useState } from 'react'; -import { useQuery } from 'react-query'; -import { useLocation } from 'react-router-dom'; +import { useMemo, useState } from 'react'; import { Button } from '@signozhq/button'; import { Callout } from '@signozhq/callout'; import { Input } from '@signozhq/input'; import { Form, Input as AntdInput, Typography } from 'antd'; import logEvent from 'api/common/logEvent'; -import accept from 'api/v1/invite/id/accept'; -import getInviteDetails from 'api/v1/invite/id/get'; import signUpApi from 'api/v1/register/post'; import passwordAuthNContext from 'api/v2/sessions/email_password/post'; import afterLogin from 'AppRoutes/utils'; @@ -15,9 +11,7 @@ import AuthError from 'components/AuthError/AuthError'; import AuthPageContainer from 'components/AuthPageContainer'; import { useNotifications } from 'hooks/useNotifications'; import { ArrowRight, CircleAlert } from 'lucide-react'; -import { SuccessResponseV2 } from 'types/api'; import APIError from 'types/api/error'; -import { InviteDetails } from 'types/api/user/getInviteDetails'; import { FormContainer, Label } from './styles'; @@ -39,22 +33,6 @@ function SignUp(): JSX.Element { false, ); const [formError, setFormError] = useState(); - const { search } = useLocation(); - const params = new URLSearchParams(search); - const token = params.get('token'); - const [isDetailsDisable, setIsDetailsDisable] = useState(false); - - const getInviteDetailsResponse = useQuery< - SuccessResponseV2, - APIError - >({ - queryFn: () => - getInviteDetails({ - inviteId: token || '', - }), - queryKey: ['getInviteDetails', token], - enabled: token !== null, - }); const { notifications } = useNotifications(); const [form] = Form.useForm(); @@ -64,49 +42,6 @@ function SignUp(): JSX.Element { const password = Form.useWatch('password', form); const confirmPassword = Form.useWatch('confirmPassword', form); - useEffect(() => { - if ( - getInviteDetailsResponse.status === 'success' && - getInviteDetailsResponse.data.data - ) { - const responseDetails = getInviteDetailsResponse.data.data; - form.setFieldValue('email', responseDetails.email); - form.setFieldValue('organizationName', responseDetails.organization); - setIsDetailsDisable(true); - - logEvent('Account Creation Page Visited', { - email: responseDetails.email, - name: responseDetails.name, - company_name: responseDetails.organization, - source: 'SigNoz Cloud', - }); - } - }, [ - getInviteDetailsResponse.data?.data, - form, - getInviteDetailsResponse.status, - ]); - - useEffect(() => { - if ( - getInviteDetailsResponse.status === 'success' && - getInviteDetailsResponse?.error - ) { - const { error } = getInviteDetailsResponse; - notifications.error({ - message: (error as APIError).getErrorCode(), - description: (error as APIError).getErrorMessage(), - }); - } - }, [ - getInviteDetailsResponse, - getInviteDetailsResponse.data, - getInviteDetailsResponse.status, - notifications, - ]); - - const isSignUp = token === null; - const signUp = async (values: FormValues): Promise => { try { const { organizationName, password, email } = values; @@ -114,7 +49,6 @@ function SignUp(): JSX.Element { email, orgDisplayName: organizationName, password, - token: params.get('token') || undefined, }); const token = await passwordAuthNContext({ @@ -129,25 +63,6 @@ function SignUp(): JSX.Element { } }; - const acceptInvite = async (values: FormValues): Promise => { - try { - const { password, email } = values; - const user = await accept({ - password, - token: params.get('token') || '', - }); - const token = await passwordAuthNContext({ - email, - password, - orgId: user.data.orgId, - }); - - await afterLogin(token.data.accessToken, token.data.refreshToken); - } catch (error) { - setFormError(error as APIError); - } - }; - const handleSubmit = (): void => { (async (): Promise => { try { @@ -155,14 +70,10 @@ function SignUp(): JSX.Element { setLoading(true); setFormError(null); - if (isSignUp) { - await signUp(values); - logEvent('Account Created Successfully', { - email: values.email, - }); - } else { - await acceptInvite(values); - } + await signUp(values); + logEvent('Account Created Successfully', { + email: values.email, + }); setLoading(false); } catch (error) { @@ -247,7 +158,6 @@ function SignUp(): JSX.Element { autoFocus required id="signupEmail" - disabled={isDetailsDisable} className="signup-form-input" /> @@ -291,15 +201,13 @@ function SignUp(): JSX.Element {
- {isSignUp && ( - - )} + {confirmPasswordError && ( ({ const REGISTER_ENDPOINT = '*/api/v1/register'; const EMAIL_PASSWORD_ENDPOINT = '*/api/v2/sessions/email_password'; -const INVITE_DETAILS_ENDPOINT = '*/api/v1/invite/*'; -const ACCEPT_INVITE_ENDPOINT = '*/api/v1/invite/accept'; -interface MockSignupResponse extends SignupResponse { - orgId: string; -} - -const mockSignupResponse: MockSignupResponse = { +const mockSignupResponse: SignupResponse = { orgId: 'test-org-id', createdAt: Date.now(), email: 'test@signoz.io', @@ -53,15 +46,6 @@ const mockTokenResponse: Token = { refreshToken: 'mock-refresh-token', }; -const mockInviteDetails: InviteDetails = { - email: 'invited@signoz.io', - name: 'Invited User', - organization: 'Test Org', - createdAt: Date.now(), - role: 'ADMIN', - token: 'invite-token-123', -}; - describe('SignUp Component - Regular Signup', () => { beforeEach(() => { jest.clearAllMocks(); @@ -288,242 +272,3 @@ describe('SignUp Component - Regular Signup', () => { }); }); }); - -describe('SignUp Component - Accept Invite', () => { - beforeEach(() => { - jest.clearAllMocks(); - window.history.pushState({}, '', '/signup?token=invite-token-123'); - }); - - afterEach(() => { - server.resetHandlers(); - }); - - describe('Initial Render with Invite', () => { - it('pre-fills form fields from invite details', async () => { - server.use( - rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) => - res( - ctx.status(200), - ctx.json({ - data: mockInviteDetails, - status: 'success', - }), - ), - ), - ); - - render(, undefined, { - initialRoute: '/signup?token=invite-token-123', - }); - - const emailInput = await screen.findByLabelText(/email address/i); - - await waitFor(() => { - expect(emailInput).toHaveValue('invited@signoz.io'); - }); - }); - - it('disables email field when invite details are loaded', async () => { - server.use( - rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) => - res( - ctx.status(200), - ctx.json({ - data: mockInviteDetails, - status: 'success', - }), - ), - ), - ); - - render(, undefined, { - initialRoute: '/signup?token=invite-token-123', - }); - - const emailInput = await screen.findByLabelText(/email address/i); - - await waitFor(() => { - expect(emailInput).toBeDisabled(); - }); - }); - - it('does not show admin account info callout for invite flow', async () => { - server.use( - rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) => - res( - ctx.status(200), - ctx.json({ - data: mockInviteDetails, - status: 'success', - }), - ), - ), - ); - - render(, undefined, { - initialRoute: '/signup?token=invite-token-123', - }); - - await waitFor(() => { - expect( - screen.queryByText(/this will create an admin account/i), - ).not.toBeInTheDocument(); - }); - }); - }); - - describe('Successful Invite Acceptance', () => { - it('successfully accepts invite and logs in user', async () => { - const user = userEvent.setup({ pointerEventsCheck: 0 }); - - server.use( - rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) => - res( - ctx.status(200), - ctx.json({ - data: mockInviteDetails, - status: 'success', - }), - ), - ), - rest.post(ACCEPT_INVITE_ENDPOINT, (_req, res, ctx) => - res( - ctx.status(200), - ctx.json({ - data: mockSignupResponse, - status: 'success', - }), - ), - ), - rest.post(EMAIL_PASSWORD_ENDPOINT, (_req, res, ctx) => - res( - ctx.status(200), - ctx.json({ - data: mockTokenResponse, - status: 'success', - }), - ), - ), - ); - - render(, undefined, { - initialRoute: '/signup?token=invite-token-123', - }); - - const emailInput = await screen.findByLabelText(/email address/i); - await waitFor(() => { - expect(emailInput).toHaveValue('invited@signoz.io'); - }); - - const passwordInput = screen.getByPlaceholderText(/enter new password/i); - const confirmPasswordInput = screen.getByPlaceholderText( - /confirm your new password/i, - ); - const submitButton = screen.getByRole('button', { - name: /access my workspace/i, - }); - - await user.type(passwordInput, 'password123'); - await user.type(confirmPasswordInput, 'password123'); - - await waitFor(() => { - expect(submitButton).not.toBeDisabled(); - }); - - await user.click(submitButton); - - await waitFor(() => { - expect(mockAfterLogin).toHaveBeenCalledWith( - 'mock-access-token', - 'mock-refresh-token', - ); - }); - }); - }); - - describe('Error Handling for Invite', () => { - it('displays error when invite details fetch fails', async () => { - server.use( - rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) => - res( - ctx.status(404), - ctx.json({ - error: { - code: 'INVITE_NOT_FOUND', - message: 'Invite not found', - }, - }), - ), - ), - ); - - render(, undefined, { - initialRoute: '/signup?token=invalid-token', - }); - - // Verify form is still accessible and fields are enabled - const emailInput = await screen.findByLabelText(/email address/i); - - expect(emailInput).toBeInTheDocument(); - expect(emailInput).not.toBeDisabled(); - }); - - it('displays error when accept invite API fails', async () => { - const user = userEvent.setup({ pointerEventsCheck: 0 }); - - server.use( - rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) => - res( - ctx.status(200), - ctx.json({ - data: mockInviteDetails, - status: 'success', - }), - ), - ), - rest.post(ACCEPT_INVITE_ENDPOINT, (_req, res, ctx) => - res( - ctx.status(400), - ctx.json({ - error: { - code: 'INVALID_TOKEN', - message: 'Invalid or expired invite token', - }, - }), - ), - ), - ); - - render(, undefined, { - initialRoute: '/signup?token=expired-token', - }); - - const emailInput = await screen.findByLabelText(/email address/i); - await waitFor(() => { - expect(emailInput).toHaveValue('invited@signoz.io'); - }); - - const passwordInput = screen.getByPlaceholderText(/enter new password/i); - const confirmPasswordInput = screen.getByPlaceholderText( - /confirm your new password/i, - ); - const submitButton = screen.getByRole('button', { - name: /access my workspace/i, - }); - - await user.type(passwordInput, 'password123'); - await user.type(confirmPasswordInput, 'password123'); - - await waitFor(() => { - expect(submitButton).not.toBeDisabled(); - }); - - await user.click(submitButton); - - expect( - await screen.findByText(/invalid or expired invite token/i), - ).toBeInTheDocument(); - }); - }); -}); diff --git a/frontend/src/types/api/dashboard/getAll.ts b/frontend/src/types/api/dashboard/getAll.ts index c55dd2a31c..4110f03481 100644 --- a/frontend/src/types/api/dashboard/getAll.ts +++ b/frontend/src/types/api/dashboard/getAll.ts @@ -141,6 +141,7 @@ export interface IBaseWidget { showPoints?: boolean; lineStyle?: LineStyle; fillMode?: FillMode; + spanGaps?: boolean | number; } export interface Widgets extends IBaseWidget { query: Query; diff --git a/frontend/src/types/api/user/accept.ts b/frontend/src/types/api/user/accept.ts deleted file mode 100644 index 71da965ad9..0000000000 --- a/frontend/src/types/api/user/accept.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { UserResponse } from './getUser'; - -export interface Props { - token: string; - password: string; - displayName?: string; - sourceUrl?: string; -} - -export interface LoginPrecheckResponse { - sso: boolean; - ssoUrl?: string; - canSelfRegister?: boolean; - isUser: boolean; -} - -export interface PayloadProps { - data: UserResponse; - status: string; -} diff --git a/frontend/src/types/api/user/deleteInvite.ts b/frontend/src/types/api/user/deleteInvite.ts deleted file mode 100644 index 7d3136189c..0000000000 --- a/frontend/src/types/api/user/deleteInvite.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface Props { - id: string; -} - -export interface PayloadProps { - data: string; -} diff --git a/frontend/src/types/api/user/getInviteDetails.ts b/frontend/src/types/api/user/getInviteDetails.ts deleted file mode 100644 index 1d84f59812..0000000000 --- a/frontend/src/types/api/user/getInviteDetails.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { User } from 'types/reducer/app'; -import { ROLES } from 'types/roles'; - -import { Organization } from './getOrganization'; - -export interface Props { - inviteId: string; -} - -export interface PayloadProps { - data: InviteDetails; - status: string; -} - -export interface InviteDetails { - createdAt: number; - email: User['email']; - name: User['displayName']; - role: ROLES; - token: string; - organization: Organization['displayName']; -} diff --git a/frontend/src/types/api/user/getPendingInvites.ts b/frontend/src/types/api/user/getPendingInvites.ts deleted file mode 100644 index f3f09a77d4..0000000000 --- a/frontend/src/types/api/user/getPendingInvites.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { User } from 'types/reducer/app'; -import { ROLES } from 'types/roles'; - -export interface PendingInvite { - createdAt: number; - email: User['email']; - name: User['displayName']; - role: ROLES; - id: string; - token: string; -} - -export type PayloadProps = { - data: PendingInvite[]; - status: string; -}; diff --git a/frontend/src/types/api/user/getUser.ts b/frontend/src/types/api/user/getUser.ts index de15453565..71fd659252 100644 --- a/frontend/src/types/api/user/getUser.ts +++ b/frontend/src/types/api/user/getUser.ts @@ -7,7 +7,7 @@ export interface Props { } export interface UserResponse { - createdAt: number; + createdAt: number | string; email: string; id: string; displayName: string; @@ -17,7 +17,9 @@ export interface UserResponse { * @deprecated This will be removed in the future releases in favor of new AuthZ framework */ role: ROLES; - updatedAt?: number; + updatedAt?: number | string; + isRoot?: boolean; + status?: 'active' | 'pending_invite' | 'deleted'; } export interface PayloadProps { data: UserResponse; diff --git a/frontend/src/utils/app.ts b/frontend/src/utils/app.ts index 3aebb9f8db..a1c74aea08 100644 --- a/frontend/src/utils/app.ts +++ b/frontend/src/utils/app.ts @@ -1,6 +1,7 @@ import getLocalStorage from 'api/browser/localstorage/get'; import { FeatureKeys } from 'constants/features'; import { SKIP_ONBOARDING } from 'constants/onboarding'; +import dayjs from 'dayjs'; import { get } from 'lodash-es'; import { getLocation } from 'utils/getLocation'; @@ -73,3 +74,19 @@ export function buildAbsolutePath({ } export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function toISOString( + date: Date | string | number | null | undefined, +): string | null { + if (date == null) { + return null; + } + + const d = dayjs(date); + + if (!d.isValid()) { + return null; + } + + return d.toISOString(); +} diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts index ed7a3828f4..9c7ae2b96d 100644 --- a/frontend/src/utils/permission/index.ts +++ b/frontend/src/utils/permission/index.ts @@ -100,6 +100,7 @@ export const routePermission: Record = { ROLES_SETTINGS: ['ADMIN'], ROLE_DETAILS: ['ADMIN'], MEMBERS_SETTINGS: ['ADMIN'], + SERVICE_ACCOUNTS_SETTINGS: ['ADMIN'], BILLING: ['ADMIN'], SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'], SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'], diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 161d774a7f..6937c75d6b 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -16952,6 +16952,11 @@ react-helmet-async@*, react-helmet-async@1.3.0: react-fast-compare "^3.2.0" shallowequal "^1.1.0" +react-hook-form@7.71.2: + version "7.71.2" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.71.2.tgz#a5f1d2b855be9ecf1af6e74df9b80f54beae7e35" + integrity sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA== + react-hooks-testing-library@0.6.0: version "0.6.0" resolved "https://registry.npmjs.org/react-hooks-testing-library/-/react-hooks-testing-library-0.6.0.tgz" diff --git a/pkg/apiserver/signozapiserver/gateway.go b/pkg/apiserver/signozapiserver/gateway.go index 339d877577..e34c253b4b 100644 --- a/pkg/apiserver/signozapiserver/gateway.go +++ b/pkg/apiserver/signozapiserver/gateway.go @@ -10,7 +10,7 @@ import ( ) func (provider *provider) addGatewayRoutes(router *mux.Router) error { - if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.GetIngestionKeys), handler.OpenAPIDef{ + if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.GetIngestionKeys), handler.OpenAPIDef{ ID: "GetIngestionKeys", Tags: []string{"gateway"}, Summary: "Get ingestion keys for workspace", @@ -23,12 +23,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error { SuccessStatusCode: http.StatusOK, ErrorStatusCodes: []int{}, Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), + SecuritySchemes: newSecuritySchemes(types.RoleEditor), })).Methods(http.MethodGet).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/gateway/ingestion_keys/search", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.SearchIngestionKeys), handler.OpenAPIDef{ + if err := router.Handle("/api/v2/gateway/ingestion_keys/search", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.SearchIngestionKeys), handler.OpenAPIDef{ ID: "SearchIngestionKeys", Tags: []string{"gateway"}, Summary: "Search ingestion keys for workspace", @@ -41,12 +41,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error { SuccessStatusCode: http.StatusOK, ErrorStatusCodes: []int{}, Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), + SecuritySchemes: newSecuritySchemes(types.RoleEditor), })).Methods(http.MethodGet).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.CreateIngestionKey), handler.OpenAPIDef{ + if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.CreateIngestionKey), handler.OpenAPIDef{ ID: "CreateIngestionKey", Tags: []string{"gateway"}, Summary: "Create ingestion key for workspace", @@ -58,12 +58,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error { SuccessStatusCode: http.StatusCreated, ErrorStatusCodes: []int{}, Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), + SecuritySchemes: newSecuritySchemes(types.RoleEditor), })).Methods(http.MethodPost).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.UpdateIngestionKey), handler.OpenAPIDef{ + if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.UpdateIngestionKey), handler.OpenAPIDef{ ID: "UpdateIngestionKey", Tags: []string{"gateway"}, Summary: "Update ingestion key for workspace", @@ -75,12 +75,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error { SuccessStatusCode: http.StatusNoContent, ErrorStatusCodes: []int{}, Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), + SecuritySchemes: newSecuritySchemes(types.RoleEditor), })).Methods(http.MethodPatch).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.DeleteIngestionKey), handler.OpenAPIDef{ + if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.DeleteIngestionKey), handler.OpenAPIDef{ ID: "DeleteIngestionKey", Tags: []string{"gateway"}, Summary: "Delete ingestion key for workspace", @@ -92,12 +92,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error { SuccessStatusCode: http.StatusNoContent, ErrorStatusCodes: []int{}, Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), + SecuritySchemes: newSecuritySchemes(types.RoleEditor), })).Methods(http.MethodDelete).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}/limits", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.CreateIngestionKeyLimit), handler.OpenAPIDef{ + if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}/limits", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.CreateIngestionKeyLimit), handler.OpenAPIDef{ ID: "CreateIngestionKeyLimit", Tags: []string{"gateway"}, Summary: "Create limit for the ingestion key", @@ -109,12 +109,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error { SuccessStatusCode: http.StatusCreated, ErrorStatusCodes: []int{}, Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), + SecuritySchemes: newSecuritySchemes(types.RoleEditor), })).Methods(http.MethodPost).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.UpdateIngestionKeyLimit), handler.OpenAPIDef{ + if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.UpdateIngestionKeyLimit), handler.OpenAPIDef{ ID: "UpdateIngestionKeyLimit", Tags: []string{"gateway"}, Summary: "Update limit for the ingestion key", @@ -126,12 +126,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error { SuccessStatusCode: http.StatusNoContent, ErrorStatusCodes: []int{}, Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), + SecuritySchemes: newSecuritySchemes(types.RoleEditor), })).Methods(http.MethodPatch).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.DeleteIngestionKeyLimit), handler.OpenAPIDef{ + if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.DeleteIngestionKeyLimit), handler.OpenAPIDef{ ID: "DeleteIngestionKeyLimit", Tags: []string{"gateway"}, Summary: "Delete limit for the ingestion key", @@ -143,7 +143,7 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error { SuccessStatusCode: http.StatusNoContent, ErrorStatusCodes: []int{}, Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), + SecuritySchemes: newSecuritySchemes(types.RoleEditor), })).Methods(http.MethodDelete).GetError(); err != nil { return err } diff --git a/pkg/apiserver/signozapiserver/global.go b/pkg/apiserver/signozapiserver/global.go index 23db345212..964c5cd726 100644 --- a/pkg/apiserver/signozapiserver/global.go +++ b/pkg/apiserver/signozapiserver/global.go @@ -4,24 +4,24 @@ import ( "net/http" "github.com/SigNoz/signoz/pkg/http/handler" - "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/globaltypes" "github.com/gorilla/mux" ) func (provider *provider) addGlobalRoutes(router *mux.Router) error { - if err := router.Handle("/api/v1/global/config", handler.New(provider.authZ.EditAccess(provider.globalHandler.GetConfig), handler.OpenAPIDef{ + if err := router.Handle("/api/v1/global/config", handler.New(provider.authZ.OpenAccess(provider.globalHandler.GetConfig), handler.OpenAPIDef{ ID: "GetGlobalConfig", Tags: []string{"global"}, Summary: "Get global config", Description: "This endpoint returns global config", Request: nil, RequestContentType: "", - Response: new(types.GettableGlobalConfig), + Response: new(globaltypes.Config), ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK, ErrorStatusCodes: []int{}, Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleEditor), + SecuritySchemes: nil, })).Methods(http.MethodGet).GetError(); err != nil { return err } diff --git a/pkg/apiserver/signozapiserver/provider.go b/pkg/apiserver/signozapiserver/provider.go index c48301f8e0..4451e7b4db 100644 --- a/pkg/apiserver/signozapiserver/provider.go +++ b/pkg/apiserver/signozapiserver/provider.go @@ -241,7 +241,7 @@ func (provider *provider) AddToRouter(router *mux.Router) error { func newSecuritySchemes(role types.Role) []handler.OpenAPISecurityScheme { return []handler.OpenAPISecurityScheme{ - {Name: authtypes.IdentNProviderAPIkey.StringValue(), Scopes: []string{role.String()}}, + {Name: authtypes.IdentNProviderAPIKey.StringValue(), Scopes: []string{role.String()}}, {Name: authtypes.IdentNProviderTokenizer.StringValue(), Scopes: []string{role.String()}}, } } diff --git a/pkg/apiserver/signozapiserver/user.go b/pkg/apiserver/signozapiserver/user.go index f533fa16ff..0e24c5a468 100644 --- a/pkg/apiserver/signozapiserver/user.go +++ b/pkg/apiserver/signozapiserver/user.go @@ -43,74 +43,6 @@ func (provider *provider) addUserRoutes(router *mux.Router) error { return err } - if err := router.Handle("/api/v1/invite/{token}", handler.New(provider.authZ.OpenAccess(provider.userHandler.GetInvite), handler.OpenAPIDef{ - ID: "GetInvite", - Tags: []string{"users"}, - Summary: "Get invite", - Description: "This endpoint gets an invite by token", - Request: nil, - RequestContentType: "", - Response: new(types.Invite), - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusOK, - ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, - Deprecated: false, - SecuritySchemes: []handler.OpenAPISecurityScheme{}, - })).Methods(http.MethodGet).GetError(); err != nil { - return err - } - - if err := router.Handle("/api/v1/invite/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.DeleteInvite), handler.OpenAPIDef{ - ID: "DeleteInvite", - Tags: []string{"users"}, - Summary: "Delete invite", - Description: "This endpoint deletes an invite by id", - Request: nil, - RequestContentType: "", - Response: nil, - ResponseContentType: "", - SuccessStatusCode: http.StatusNoContent, - ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), - })).Methods(http.MethodDelete).GetError(); err != nil { - return err - } - - if err := router.Handle("/api/v1/invite", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListInvite), handler.OpenAPIDef{ - ID: "ListInvite", - Tags: []string{"users"}, - Summary: "List invites", - Description: "This endpoint lists all invites", - Request: nil, - RequestContentType: "", - Response: make([]*types.Invite, 0), - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusOK, - ErrorStatusCodes: []int{}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), - })).Methods(http.MethodGet).GetError(); err != nil { - return err - } - - if err := router.Handle("/api/v1/invite/accept", handler.New(provider.authZ.OpenAccess(provider.userHandler.AcceptInvite), handler.OpenAPIDef{ - ID: "AcceptInvite", - Tags: []string{"users"}, - Summary: "Accept invite", - Description: "This endpoint accepts an invite by token", - Request: new(types.PostableAcceptInvite), - RequestContentType: "application/json", - Response: new(types.User), - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusCreated, - ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, - Deprecated: false, - SecuritySchemes: []handler.OpenAPISecurityScheme{}, - })).Methods(http.MethodPost).GetError(); err != nil { - return err - } - if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateAPIKey), handler.OpenAPIDef{ ID: "CreateAPIKey", Tags: []string{"users"}, diff --git a/pkg/global/global.go b/pkg/global/global.go index e78f2ecd03..1a06b700e4 100644 --- a/pkg/global/global.go +++ b/pkg/global/global.go @@ -1,9 +1,14 @@ package global -import "net/http" +import ( + "context" + "net/http" + + "github.com/SigNoz/signoz/pkg/types/globaltypes" +) type Global interface { - GetConfig() Config + GetConfig(context.Context) *globaltypes.Config } type Handler interface { diff --git a/pkg/global/signozglobal/handler.go b/pkg/global/signozglobal/handler.go index 2c930ec0e7..b5d6c6aec8 100644 --- a/pkg/global/signozglobal/handler.go +++ b/pkg/global/signozglobal/handler.go @@ -1,11 +1,12 @@ package signozglobal import ( + "context" "net/http" + "time" "github.com/SigNoz/signoz/pkg/global" "github.com/SigNoz/signoz/pkg/http/render" - "github.com/SigNoz/signoz/pkg/types" ) type handler struct { @@ -17,7 +18,10 @@ func NewHandler(global global.Global) global.Handler { } func (handler *handler) GetConfig(rw http.ResponseWriter, r *http.Request) { - cfg := handler.global.GetConfig() + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() - render.Success(rw, http.StatusOK, types.NewGettableGlobalConfig(cfg.ExternalURL, cfg.IngestionURL)) + cfg := handler.global.GetConfig(ctx) + + render.Success(rw, http.StatusOK, cfg) } diff --git a/pkg/global/signozglobal/provider.go b/pkg/global/signozglobal/provider.go index cb9878da88..50382a66c9 100644 --- a/pkg/global/signozglobal/provider.go +++ b/pkg/global/signozglobal/provider.go @@ -5,27 +5,38 @@ import ( "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/global" + "github.com/SigNoz/signoz/pkg/identn" + "github.com/SigNoz/signoz/pkg/types/globaltypes" ) type provider struct { - config global.Config - settings factory.ScopedProviderSettings + config global.Config + identNConfig identn.Config + settings factory.ScopedProviderSettings } -func NewFactory() factory.ProviderFactory[global.Global, global.Config] { +func NewFactory(identNConfig identn.Config) factory.ProviderFactory[global.Global, global.Config] { return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config global.Config) (global.Global, error) { - return newProvider(ctx, providerSettings, config) + return newProvider(ctx, providerSettings, config, identNConfig) }) } -func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config global.Config) (global.Global, error) { +func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config global.Config, identNConfig identn.Config) (global.Global, error) { settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/global/signozglobal") return &provider{ - config: config, - settings: settings, + config: config, + identNConfig: identNConfig, + settings: settings, }, nil } -func (provider *provider) GetConfig() global.Config { - return provider.config +func (provider *provider) GetConfig(context.Context) *globaltypes.Config { + return globaltypes.NewConfig( + globaltypes.NewEndpoint(provider.config.ExternalURL.String(), provider.config.IngestionURL.String()), + globaltypes.NewIdentNConfig( + globaltypes.TokenizerConfig{Enabled: provider.identNConfig.Tokenizer.Enabled}, + globaltypes.APIKeyConfig{Enabled: provider.identNConfig.APIKeyConfig.Enabled}, + globaltypes.ImpersonationConfig{Enabled: provider.identNConfig.Impersonation.Enabled}, + ), + ) } diff --git a/pkg/http/middleware/authz.go b/pkg/http/middleware/authz.go index 731f4fe5d1..d6afc1ba53 100644 --- a/pkg/http/middleware/authz.go +++ b/pkg/http/middleware/authz.go @@ -40,7 +40,7 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc { return } - if claims.IdentNProvider == authtypes.IdentNProviderAPIkey.StringValue() { + if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() { if err := claims.IsViewer(); err != nil { middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims) render.Error(rw, err) @@ -90,7 +90,7 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc { return } - if claims.IdentNProvider == authtypes.IdentNProviderAPIkey.StringValue() { + if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() { if err := claims.IsEditor(); err != nil { middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims) render.Error(rw, err) @@ -139,7 +139,7 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc { return } - if claims.IdentNProvider == authtypes.IdentNProviderAPIkey.StringValue() { + if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() { if err := claims.IsAdmin(); err != nil { middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims) render.Error(rw, err) diff --git a/pkg/identn/apikeyidentn/identn.go b/pkg/identn/apikeyidentn/provider.go similarity index 94% rename from pkg/identn/apikeyidentn/identn.go rename to pkg/identn/apikeyidentn/provider.go index c23fc3a557..28642e2a4c 100644 --- a/pkg/identn/apikeyidentn/identn.go +++ b/pkg/identn/apikeyidentn/provider.go @@ -25,7 +25,7 @@ type provider struct { } func NewFactory(store sqlstore.SQLStore) factory.ProviderFactory[identn.IdentN, identn.Config] { - return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderAPIkey.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) { + return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderAPIKey.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) { return New(providerSettings, store, config) }) } @@ -40,7 +40,7 @@ func New(providerSettings factory.ProviderSettings, store sqlstore.SQLStore, con } func (provider *provider) Name() authtypes.IdentNProvider { - return authtypes.IdentNProviderAPIkey + return authtypes.IdentNProviderAPIKey } func (provider *provider) Test(req *http.Request) bool { @@ -52,10 +52,6 @@ func (provider *provider) Test(req *http.Request) bool { return false } -func (provider *provider) Enabled() bool { - return provider.config.APIKeyConfig.Enabled -} - func (provider *provider) Pre(req *http.Request) *http.Request { token := provider.extractToken(req) if token == "" { diff --git a/pkg/identn/config.go b/pkg/identn/config.go index 85023c5527..ad01df7100 100644 --- a/pkg/identn/config.go +++ b/pkg/identn/config.go @@ -1,6 +1,7 @@ package identn import ( + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/factory" ) @@ -10,11 +11,20 @@ type Config struct { // Config for apikey identN resolver APIKeyConfig APIKeyConfig `mapstructure:"apikey"` + + // Config for impersonation identN resolver + Impersonation ImpersonationConfig `mapstructure:"impersonation"` +} + +type ImpersonationConfig struct { + // Toggles the identN resolver + Enabled bool `mapstructure:"enabled"` } type TokenizerConfig struct { // Toggles the identN resolver Enabled bool `mapstructure:"enabled"` + // Headers to extract from incoming requests Headers []string `mapstructure:"headers"` } @@ -22,6 +32,7 @@ type TokenizerConfig struct { type APIKeyConfig struct { // Toggles the identN resolver Enabled bool `mapstructure:"enabled"` + // Headers to extract from incoming requests Headers []string `mapstructure:"headers"` } @@ -40,9 +51,22 @@ func newConfig() factory.Config { Enabled: true, Headers: []string{"SIGNOZ-API-KEY"}, }, + Impersonation: ImpersonationConfig{ + Enabled: false, + }, } } func (c Config) Validate() error { + if c.Impersonation.Enabled { + if c.Tokenizer.Enabled { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "identn::impersonation cannot be enabled if identn::tokenizer is enabled") + } + + if c.APIKeyConfig.Enabled { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "identn::impersonation cannot be enabled if identn::apikey is enabled") + } + } + return nil } diff --git a/pkg/identn/identn.go b/pkg/identn/identn.go index 473953fb51..0ffec463a4 100644 --- a/pkg/identn/identn.go +++ b/pkg/identn/identn.go @@ -23,8 +23,6 @@ type IdentN interface { GetIdentity(r *http.Request) (*authtypes.Identity, error) Name() authtypes.IdentNProvider - - Enabled() bool } // IdentNWithPreHook is optionally implemented by resolvers that need to diff --git a/pkg/identn/impersonationidentn/provider.go b/pkg/identn/impersonationidentn/provider.go new file mode 100644 index 0000000000..9fc73a1517 --- /dev/null +++ b/pkg/identn/impersonationidentn/provider.go @@ -0,0 +1,96 @@ +package impersonationidentn + +import ( + "context" + "net/http" + "sync" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/identn" + "github.com/SigNoz/signoz/pkg/modules/organization" + "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/types/authtypes" +) + +type provider struct { + config identn.Config + settings factory.ScopedProviderSettings + orgGetter organization.Getter + userGetter user.Getter + userConfig user.Config + + mu sync.RWMutex + identity *authtypes.Identity +} + +func NewFactory(orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) factory.ProviderFactory[identn.IdentN, identn.Config] { + return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderImpersonation.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) { + return New(ctx, providerSettings, config, orgGetter, userGetter, userConfig) + }) +} + +func New(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config, orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) (identn.IdentN, error) { + settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/impersonationidentn") + + settings.Logger().WarnContext(ctx, "impersonation identity provider is enabled, all requests will impersonate the root user") + + if !userConfig.Root.Enabled { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "root user is not enabled, impersonation identity provider will not be able to resolve any identity") + } + + return &provider{ + config: config, + settings: settings, + orgGetter: orgGetter, + userGetter: userGetter, + userConfig: userConfig, + }, nil +} + +func (provider *provider) Name() authtypes.IdentNProvider { + return authtypes.IdentNProviderImpersonation +} + +func (provider *provider) Test(_ *http.Request) bool { + return true +} + +func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, error) { + ctx := req.Context() + + provider.mu.RLock() + if provider.identity != nil { + provider.mu.RUnlock() + return provider.identity, nil + } + provider.mu.RUnlock() + + provider.mu.Lock() + defer provider.mu.Unlock() + + // Re-check after acquiring write lock; another goroutine may have resolved it. + if provider.identity != nil { + return provider.identity, nil + } + + org, _, err := provider.orgGetter.GetByIDOrName(ctx, provider.userConfig.Root.Org.ID, provider.userConfig.Root.Org.Name) + if err != nil { + return nil, err + } + + rootUser, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID) + if err != nil { + return nil, err + } + + provider.identity = authtypes.NewIdentity( + rootUser.ID, + rootUser.OrgID, + rootUser.Email, + rootUser.Role, + authtypes.IdentNProviderImpersonation, + ) + + return provider.identity, nil +} diff --git a/pkg/identn/resolver.go b/pkg/identn/resolver.go index 0e16704231..2e22e38c7a 100644 --- a/pkg/identn/resolver.go +++ b/pkg/identn/resolver.go @@ -1,9 +1,11 @@ package identn import ( + "context" "net/http" "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/types/authtypes" ) type identNResolver struct { @@ -11,19 +13,55 @@ type identNResolver struct { settings factory.ScopedProviderSettings } -func NewIdentNResolver(providerSettings factory.ProviderSettings, identNs ...IdentN) IdentNResolver { - enabledIdentNs := []IdentN{} +func NewIdentNResolver(ctx context.Context, providerSettings factory.ProviderSettings, identNConfig Config, identNFactories factory.NamedMap[factory.ProviderFactory[IdentN, Config]]) (IdentNResolver, error) { + identNs := []IdentN{} - for _, identN := range identNs { - if identN.Enabled() { - enabledIdentNs = append(enabledIdentNs, identN) + if identNConfig.Impersonation.Enabled { + identNFactory, err := identNFactories.Get(authtypes.IdentNProviderImpersonation.StringValue()) + if err != nil { + return nil, err } + + identN, err := identNFactory.New(ctx, providerSettings, identNConfig) + if err != nil { + return nil, err + } + + identNs = append(identNs, identN) + } + + if identNConfig.Tokenizer.Enabled { + identNFactory, err := identNFactories.Get(authtypes.IdentNProviderTokenizer.StringValue()) + if err != nil { + return nil, err + } + + identN, err := identNFactory.New(ctx, providerSettings, identNConfig) + if err != nil { + return nil, err + } + + identNs = append(identNs, identN) + } + + if identNConfig.APIKeyConfig.Enabled { + identNFactory, err := identNFactories.Get(authtypes.IdentNProviderAPIKey.StringValue()) + if err != nil { + return nil, err + } + + identN, err := identNFactory.New(ctx, providerSettings, identNConfig) + if err != nil { + return nil, err + } + + identNs = append(identNs, identN) } return &identNResolver{ - identNs: enabledIdentNs, + identNs: identNs, settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn"), - } + }, nil } // GetIdentN returns the first IdentN whose Test() returns true. diff --git a/pkg/identn/tokenizeridentn/identn.go b/pkg/identn/tokenizeridentn/provider.go similarity index 97% rename from pkg/identn/tokenizeridentn/identn.go rename to pkg/identn/tokenizeridentn/provider.go index 9624b5002e..d17cd6bda9 100644 --- a/pkg/identn/tokenizeridentn/identn.go +++ b/pkg/identn/tokenizeridentn/provider.go @@ -48,10 +48,6 @@ func (provider *provider) Test(req *http.Request) bool { return false } -func (provider *provider) Enabled() bool { - return provider.config.Tokenizer.Enabled -} - func (provider *provider) Pre(req *http.Request) *http.Request { accessToken := provider.extractToken(req) if accessToken == "" { diff --git a/pkg/modules/organization/implorganization/getter.go b/pkg/modules/organization/implorganization/getter.go index 9667d106ca..d1a92047cd 100644 --- a/pkg/modules/organization/implorganization/getter.go +++ b/pkg/modules/organization/implorganization/getter.go @@ -3,6 +3,7 @@ package implorganization import ( "context" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/modules/organization" "github.com/SigNoz/signoz/pkg/sharder" "github.com/SigNoz/signoz/pkg/types" @@ -22,6 +23,33 @@ func (module *getter) Get(ctx context.Context, id valuer.UUID) (*types.Organizat return module.store.Get(ctx, id) } +func (module *getter) GetByIDOrName(ctx context.Context, id valuer.UUID, name string) (*types.Organization, bool, error) { + if id.IsZero() { + org, err := module.store.GetByName(ctx, name) + if err != nil { + return nil, false, err + } + + return org, true, nil + } + + org, err := module.store.Get(ctx, id) + if err == nil { + return org, false, nil + } + + if !errors.Ast(err, errors.TypeNotFound) { + return nil, false, err + } + + org, err = module.store.GetByName(ctx, name) + if err != nil { + return nil, false, err + } + + return org, true, nil +} + func (module *getter) ListByOwnedKeyRange(ctx context.Context) ([]*types.Organization, error) { start, end, err := module.sharder.GetMyOwnedKeyRange(ctx) if err != nil { diff --git a/pkg/modules/organization/organization.go b/pkg/modules/organization/organization.go index 43f8292788..92d2f5831a 100644 --- a/pkg/modules/organization/organization.go +++ b/pkg/modules/organization/organization.go @@ -12,6 +12,10 @@ type Getter interface { // Get gets the organization based on the given id Get(context.Context, valuer.UUID) (*types.Organization, error) + // GetByIDOrName gets the organization by id, falling back to name on not found. + // The boolean is true when the name fallback path was used. + GetByIDOrName(context.Context, valuer.UUID, string) (*types.Organization, bool, error) + // ListByOwnedKeyRange gets all the organizations owned by the instance ListByOwnedKeyRange(context.Context) ([]*types.Organization, error) diff --git a/pkg/modules/user/impluser/handler.go b/pkg/modules/user/impluser/handler.go index 17b4bff20c..a17c7d8563 100644 --- a/pkg/modules/user/impluser/handler.go +++ b/pkg/modules/user/impluser/handler.go @@ -27,25 +27,6 @@ func NewHandler(module root.Module, getter root.Getter) root.Handler { return &handler{module: module, getter: getter} } -func (h *handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - req := new(types.PostableAcceptInvite) - if err := binding.JSON.BindBody(r.Body, req); err != nil { - render.Error(w, err) - return - } - - user, err := h.module.AcceptInvite(ctx, req.InviteToken, req.Password) - if err != nil { - render.Error(w, err) - return - } - - render.Success(w, http.StatusCreated, user) -} - func (h *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() @@ -104,59 +85,6 @@ func (h *handler) CreateBulkInvite(rw http.ResponseWriter, r *http.Request) { render.Success(rw, http.StatusCreated, nil) } -func (h *handler) GetInvite(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - token := mux.Vars(r)["token"] - invite, err := h.module.GetInviteByToken(ctx, token) - if err != nil { - render.Error(w, err) - return - } - - render.Success(w, http.StatusOK, invite) -} - -func (h *handler) ListInvite(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - render.Error(w, err) - return - } - - invites, err := h.module.ListInvite(ctx, claims.OrgID) - if err != nil { - render.Error(w, err) - return - } - - render.Success(w, http.StatusOK, invites) -} - -func (h *handler) DeleteInvite(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - id := mux.Vars(r)["id"] - - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - render.Error(w, err) - return - } - - if err := h.module.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.UserID); err != nil { - render.Error(w, err) - return - } - - render.Success(w, http.StatusNoContent, nil) -} - func (h *handler) GetUser(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() @@ -213,9 +141,6 @@ func (h *handler) ListUsers(w http.ResponseWriter, r *http.Request) { return } - // temp code - show only active users - users = slices.DeleteFunc(users, func(user *types.User) bool { return user.Status != types.UserStatusActive }) - render.Success(w, http.StatusOK, users) } diff --git a/pkg/modules/user/impluser/module.go b/pkg/modules/user/impluser/module.go index 41ec1fe8c2..d4093106e0 100644 --- a/pkg/modules/user/impluser/module.go +++ b/pkg/modules/user/impluser/module.go @@ -49,54 +49,6 @@ func NewModule(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em } } -func (m *Module) AcceptInvite(ctx context.Context, token string, password string) (*types.User, error) { - // get the user by reset password token - user, err := m.store.GetUserByResetPasswordToken(ctx, token) - if err != nil { - return nil, err - } - - // update the password and delete the token - err = m.UpdatePasswordByResetPasswordToken(ctx, token, password) - if err != nil { - return nil, err - } - - // query the user again - user, err = m.store.GetByOrgIDAndID(ctx, user.OrgID, user.ID) - if err != nil { - return nil, err - } - - return user, nil -} - -func (m *Module) GetInviteByToken(ctx context.Context, token string) (*types.Invite, error) { - // get the user - user, err := m.store.GetUserByResetPasswordToken(ctx, token) - if err != nil { - return nil, err - } - - // create a dummy invite obj for backward compatibility - invite := &types.Invite{ - Identifiable: types.Identifiable{ - ID: user.ID, - }, - Name: user.DisplayName, - Email: user.Email, - Token: token, - Role: user.Role, - OrgID: user.OrgID, - TimeAuditable: types.TimeAuditable{ - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - }, - } - - return invite, nil -} - // CreateBulk implements invite.Module. func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error) { creator, err := m.store.GetUser(ctx, userID) @@ -218,46 +170,6 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID return invites, nil } -func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) { - // find all the users with pending_invite status - users, err := m.store.ListUsersByOrgID(ctx, valuer.MustNewUUID(orgID)) - if err != nil { - return nil, err - } - - pendingUsers := slices.DeleteFunc(users, func(user *types.User) bool { return user.Status != types.UserStatusPendingInvite }) - - var invites []*types.Invite - - for _, pUser := range pendingUsers { - // get the reset password token - resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, pUser.ID) - if err != nil { - return nil, err - } - - // create a dummy invite obj for backward compatibility - invite := &types.Invite{ - Identifiable: types.Identifiable{ - ID: pUser.ID, - }, - Name: pUser.DisplayName, - Email: pUser.Email, - Token: resetPasswordToken.Token, - Role: pUser.Role, - OrgID: pUser.OrgID, - TimeAuditable: types.TimeAuditable{ - CreatedAt: pUser.CreatedAt, - UpdatedAt: pUser.UpdatedAt, // dummy - }, - } - - invites = append(invites, invite) - } - - return invites, nil -} - func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error { createUserOpts := root.NewCreateUserOptions(opts...) @@ -304,10 +216,6 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u return nil, errors.WithAdditionalf(err, "cannot update deleted user") } - if err := existingUser.ErrIfPending(); err != nil { - return nil, errors.WithAdditionalf(err, "cannot update pending user") - } - requestor, err := m.store.GetUser(ctx, valuer.MustNewUUID(updatedBy)) if err != nil { return nil, err diff --git a/pkg/modules/user/impluser/service.go b/pkg/modules/user/impluser/service.go index 50e50a7501..f48b66a691 100644 --- a/pkg/modules/user/impluser/service.go +++ b/pkg/modules/user/impluser/service.go @@ -77,54 +77,28 @@ func (s *service) Stop(ctx context.Context) error { } func (s *service) reconcile(ctx context.Context) error { - if !s.config.Org.ID.IsZero() { - return s.reconcileWithOrgID(ctx) - } - - return s.reconcileByName(ctx) -} - -func (s *service) reconcileWithOrgID(ctx context.Context) error { - org, err := s.orgGetter.Get(ctx, s.config.Org.ID) + org, resolvedByName, err := s.orgGetter.GetByIDOrName(ctx, s.config.Org.ID, s.config.Org.Name) if err != nil { if !errors.Ast(err, errors.TypeNotFound) { return err // something really went wrong } - // org was not found using id check if we can find an org using name - - existingOrgByName, nameErr := s.orgGetter.GetByName(ctx, s.config.Org.Name) - if nameErr != nil && !errors.Ast(nameErr, errors.TypeNotFound) { - return nameErr // something really went wrong - } - - // we found an org using name - if existingOrgByName != nil { - // the existing org has the same name as config but org id is different inform user with actionable message - return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "organization with name %q already exists with a different ID %s (expected %s)", s.config.Org.Name, existingOrgByName.ID.StringValue(), s.config.Org.ID.StringValue()) - } - - // default - we did not found any org using id and name both - create a new org - newOrg := types.NewOrganizationWithID(s.config.Org.ID, s.config.Org.Name, s.config.Org.Name) - _, err = s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password) - return err - } - - return s.reconcileRootUser(ctx, org.ID) -} - -func (s *service) reconcileByName(ctx context.Context) error { - org, err := s.orgGetter.GetByName(ctx, s.config.Org.Name) - if err != nil { - if errors.Ast(err, errors.TypeNotFound) { + if s.config.Org.ID.IsZero() { newOrg := types.NewOrganization(s.config.Org.Name, s.config.Org.Name) _, err := s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password) return err } + newOrg := types.NewOrganizationWithID(s.config.Org.ID, s.config.Org.Name, s.config.Org.Name) + _, err = s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password) return err } + if !s.config.Org.ID.IsZero() && resolvedByName { + // the existing org has the same name as config but org id is different; inform user with actionable message + return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "organization with name %q already exists with a different ID %s (expected %s)", s.config.Org.Name, org.ID.StringValue(), s.config.Org.ID.StringValue()) + } + return s.reconcileRootUser(ctx, org.ID) } diff --git a/pkg/modules/user/user.go b/pkg/modules/user/user.go index 820b7fd319..cd26bef56a 100644 --- a/pkg/modules/user/user.go +++ b/pkg/modules/user/user.go @@ -41,9 +41,6 @@ type Module interface { // invite CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error) - ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) - AcceptInvite(ctx context.Context, token string, password string) (*types.User, error) - GetInviteByToken(ctx context.Context, token string) (*types.Invite, error) // API KEY CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error @@ -89,10 +86,6 @@ type Getter interface { type Handler interface { // invite CreateInvite(http.ResponseWriter, *http.Request) - AcceptInvite(http.ResponseWriter, *http.Request) - GetInvite(http.ResponseWriter, *http.Request) // public function - ListInvite(http.ResponseWriter, *http.Request) - DeleteInvite(http.ResponseWriter, *http.Request) CreateBulkInvite(http.ResponseWriter, *http.Request) ListUsers(http.ResponseWriter, *http.Request) diff --git a/pkg/query-service/constants/constants.go b/pkg/query-service/constants/constants.go index b644d3a4bd..e19e70f1a1 100644 --- a/pkg/query-service/constants/constants.go +++ b/pkg/query-service/constants/constants.go @@ -28,9 +28,6 @@ const SpanSearchScopeRoot = "isroot" const SpanSearchScopeEntryPoint = "isentrypoint" const OrderBySpanCount = "span_count" -// Deprecated: Use the new emailing service instead -var InviteEmailTemplate = GetOrDefaultEnv("INVITE_EMAIL_TEMPLATE", "/root/templates/invitation_email.gotmpl") - var MetricsExplorerClickhouseThreads = GetOrDefaultEnvInt("METRICS_EXPLORER_CLICKHOUSE_THREADS", 8) var UpdatedMetricsMetadataCachePrefix = GetOrDefaultEnv("METRICS_UPDATED_METADATA_CACHE_KEY", "UPDATED_METRICS_METADATA") diff --git a/pkg/signoz/config.go b/pkg/signoz/config.go index 3370a9c0ad..c175bb0499 100644 --- a/pkg/signoz/config.go +++ b/pkg/signoz/config.go @@ -3,7 +3,6 @@ package signoz import ( "context" "log/slog" - "net/url" "os" "path" "reflect" @@ -38,7 +37,6 @@ import ( "github.com/SigNoz/signoz/pkg/valuer" "github.com/SigNoz/signoz/pkg/version" "github.com/SigNoz/signoz/pkg/web" - "github.com/spf13/cobra" ) // Config defines the entire input configuration of signoz. @@ -119,43 +117,7 @@ type Config struct { IdentN identn.Config `mapstructure:"identn"` } -// DeprecatedFlags are the flags that are deprecated and scheduled for removal. -// These flags are used to ensure backward compatibility with the old flags. -type DeprecatedFlags struct { - MaxIdleConns int - MaxOpenConns int - DialTimeout time.Duration - Config string - FluxInterval string - FluxIntervalForTraceDetail string - PreferSpanMetrics bool - Cluster string - GatewayUrl string -} - -func (df *DeprecatedFlags) RegisterFlags(cmd *cobra.Command) { - cmd.Flags().IntVar(&df.MaxIdleConns, "max-idle-conns", 50, "max idle connections to the database") - cmd.Flags().IntVar(&df.MaxOpenConns, "max-open-conns", 100, "max open connections to the database") - cmd.Flags().DurationVar(&df.DialTimeout, "dial-timeout", 5*time.Second, "dial timeout for the database") - cmd.Flags().StringVar(&df.Config, "config", "./config/prometheus.yml", "(prometheus config to read metrics)") - cmd.Flags().StringVar(&df.FluxInterval, "flux-interval", "5m", "flux interval") - cmd.Flags().StringVar(&df.FluxIntervalForTraceDetail, "flux-interval-for-trace-detail", "2m", "flux interval for trace detail") - cmd.Flags().BoolVar(&df.PreferSpanMetrics, "prefer-span-metrics", false, "(prefer span metrics for service level metrics)") - cmd.Flags().StringVar(&df.Cluster, "cluster", "cluster", "(cluster name - defaults to 'cluster')") - cmd.Flags().StringVar(&df.GatewayUrl, "gateway-url", "", "(url to the gateway)") - - _ = cmd.Flags().MarkDeprecated("max-idle-conns", "use SIGNOZ_TELEMETRYSTORE_MAX__IDLE__CONNS instead") - _ = cmd.Flags().MarkDeprecated("max-open-conns", "use SIGNOZ_TELEMETRYSTORE_MAX__OPEN__CONNS instead") - _ = cmd.Flags().MarkDeprecated("dial-timeout", "use SIGNOZ_TELEMETRYSTORE_DIAL__TIMEOUT instead") - _ = cmd.Flags().MarkDeprecated("config", "use SIGNOZ_PROMETHEUS_CONFIG instead") - _ = cmd.Flags().MarkDeprecated("flux-interval", "use SIGNOZ_QUERIER_FLUX__INTERVAL instead") - _ = cmd.Flags().MarkDeprecated("flux-interval-for-trace-detail", "use SIGNOZ_QUERIER_FLUX__INTERVAL instead") - _ = cmd.Flags().MarkDeprecated("cluster", "use SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER instead") - _ = cmd.Flags().MarkDeprecated("prefer-span-metrics", "use SIGNOZ_FLAGGER_CONFIG_BOOLEAN_USE__SPAN__METRICS instead") - _ = cmd.Flags().MarkDeprecated("gateway-url", "use SIGNOZ_GATEWAY_URL instead") -} - -func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.ResolverConfig, deprecatedFlags DeprecatedFlags) (Config, error) { +func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.ResolverConfig) (Config, error) { configFactories := []factory.ConfigFactory{ global.NewConfigFactory(), version.NewConfigFactory(), @@ -193,7 +155,7 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R return Config{}, err } - mergeAndEnsureBackwardCompatibility(ctx, logger, &config, deprecatedFlags) + mergeAndEnsureBackwardCompatibility(ctx, logger, &config) if err := validateConfig(config); err != nil { return Config{}, err @@ -218,7 +180,7 @@ func validateConfig(config Config) error { return nil } -func mergeAndEnsureBackwardCompatibility(ctx context.Context, logger *slog.Logger, config *Config, deprecatedFlags DeprecatedFlags) { +func mergeAndEnsureBackwardCompatibility(ctx context.Context, logger *slog.Logger, config *Config) { if os.Getenv("SIGNOZ_LOCAL_DB_PATH") != "" { logger.WarnContext(ctx, "[Deprecated] env SIGNOZ_LOCAL_DB_PATH is deprecated and scheduled for removal. Please use SIGNOZ_SQLSTORE_SQLITE_PATH instead.") config.SQLStore.Sqlite.Path = os.Getenv("SIGNOZ_LOCAL_DB_PATH") @@ -255,25 +217,6 @@ func mergeAndEnsureBackwardCompatibility(ctx context.Context, logger *slog.Logge config.TelemetryStore.Clickhouse.DSN = os.Getenv("ClickHouseUrl") } - if deprecatedFlags.MaxIdleConns != 50 { - logger.WarnContext(ctx, "[Deprecated] flag --max-idle-conns is deprecated and scheduled for removal. Please use SIGNOZ_TELEMETRYSTORE_MAX__IDLE__CONNS instead.") - config.TelemetryStore.Connection.MaxIdleConns = deprecatedFlags.MaxIdleConns - } - - if deprecatedFlags.MaxOpenConns != 100 { - logger.WarnContext(ctx, "[Deprecated] flag --max-open-conns is deprecated and scheduled for removal. Please use SIGNOZ_TELEMETRYSTORE_MAX__OPEN__CONNS instead.") - config.TelemetryStore.Connection.MaxOpenConns = deprecatedFlags.MaxOpenConns - } - - if deprecatedFlags.DialTimeout != 5*time.Second { - logger.WarnContext(ctx, "[Deprecated] flag --dial-timeout is deprecated and scheduled for removal. Please use SIGNOZ_TELEMETRYSTORE_DIAL__TIMEOUT instead.") - config.TelemetryStore.Connection.DialTimeout = deprecatedFlags.DialTimeout - } - - if deprecatedFlags.Config != "" { - logger.WarnContext(ctx, "[Deprecated] flag --config is deprecated for passing prometheus config. The flag will be used for passing the entire SigNoz config. More details can be found at https://github.com/SigNoz/signoz/issues/6805.") - } - if os.Getenv("INVITE_EMAIL_TEMPLATE") != "" { logger.WarnContext(ctx, "[Deprecated] env INVITE_EMAIL_TEMPLATE is deprecated and scheduled for removal. Please use SIGNOZ_EMAILING_TEMPLATES_DIRECTORY instead.") config.Emailing.Templates.Directory = path.Dir(os.Getenv("INVITE_EMAIL_TEMPLATE")) @@ -322,33 +265,6 @@ func mergeAndEnsureBackwardCompatibility(ctx context.Context, logger *slog.Logge config.Analytics.Enabled = os.Getenv("TELEMETRY_ENABLED") == "true" } - if deprecatedFlags.FluxInterval != "" { - logger.WarnContext(ctx, "[Deprecated] flag --flux-interval is deprecated and scheduled for removal. Please use SIGNOZ_QUERIER_FLUX__INTERVAL instead.") - fluxInterval, err := time.ParseDuration(deprecatedFlags.FluxInterval) - if err != nil { - logger.WarnContext(ctx, "Error parsing --flux-interval, using default value.") - } else { - config.Querier.FluxInterval = fluxInterval - } - } - - if deprecatedFlags.FluxIntervalForTraceDetail != "" { - logger.WarnContext(ctx, "[Deprecated] flag --flux-interval-for-trace-detail is deprecated and scheduled for complete removal. Please use SIGNOZ_QUERIER_FLUX__INTERVAL instead.") - } - - if deprecatedFlags.Cluster != "" { - logger.WarnContext(ctx, "[Deprecated] flag --cluster is deprecated and scheduled for removal. Please use SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER instead.") - config.TelemetryStore.Clickhouse.Cluster = deprecatedFlags.Cluster - } - - if deprecatedFlags.PreferSpanMetrics { - logger.WarnContext(ctx, "[Deprecated] flag --prefer-span-metrics is deprecated and scheduled for removal. Please use SIGNOZ_FLAGGER_CONFIG_BOOLEAN_USE__SPAN__METRICS instead.") - if config.Flagger.Config.Boolean == nil { - config.Flagger.Config.Boolean = make(map[string]bool) - } - config.Flagger.Config.Boolean[flagger.FeatureUseSpanMetrics.String()] = deprecatedFlags.PreferSpanMetrics - } - if os.Getenv("USE_SPAN_METRICS") != "" { logger.WarnContext(ctx, "[Deprecated] env USE_SPAN_METRICS is deprecated and scheduled for removal. Please use SIGNOZ_FLAGGER_CONFIG_BOOLEAN_USE__SPAN__METRICS instead.") if config.Flagger.Config.Boolean == nil { @@ -357,16 +273,6 @@ func mergeAndEnsureBackwardCompatibility(ctx context.Context, logger *slog.Logge config.Flagger.Config.Boolean[flagger.FeatureUseSpanMetrics.String()] = os.Getenv("USE_SPAN_METRICS") == "true" } - if deprecatedFlags.GatewayUrl != "" { - logger.WarnContext(ctx, "[Deprecated] flag --gateway-url is deprecated and scheduled for removal. Please use SIGNOZ_GATEWAY_URL instead.") - u, err := url.Parse(deprecatedFlags.GatewayUrl) - if err != nil { - logger.WarnContext(ctx, "Error parsing --gateway-url, using default value.") - } else { - config.Gateway.URL = u - } - } - if os.Getenv("SIGNOZ_JWT_SECRET") != "" { logger.WarnContext(ctx, "[Deprecated] env SIGNOZ_JWT_SECRET is deprecated and scheduled for removal. Please use SIGNOZ_TOKENIZER_JWT_SECRET instead.") config.Tokenizer.JWT.Secret = os.Getenv("SIGNOZ_JWT_SECRET") diff --git a/pkg/signoz/config_test.go b/pkg/signoz/config_test.go index 9318d9243f..dddc0b863d 100644 --- a/pkg/signoz/config_test.go +++ b/pkg/signoz/config_test.go @@ -13,6 +13,6 @@ import ( // their default values. func TestValidateConfig(t *testing.T) { logger := slog.New(slog.DiscardHandler) - _, err := NewConfig(context.Background(), logger, configtest.NewResolverConfig(), DeprecatedFlags{}) + _, err := NewConfig(context.Background(), logger, configtest.NewResolverConfig()) assert.NoError(t, err) } diff --git a/pkg/signoz/openapi.go b/pkg/signoz/openapi.go index 1f8da98859..cc73701bf2 100644 --- a/pkg/signoz/openapi.go +++ b/pkg/signoz/openapi.go @@ -82,7 +82,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta reflector.SpecSchema().SetTitle("SigNoz") reflector.SpecSchema().SetDescription("OpenTelemetry-Native Logs, Metrics and Traces in a single pane") - reflector.SpecSchema().SetAPIKeySecurity(authtypes.IdentNProviderAPIkey.StringValue(), "SigNoz-Api-Key", openapi.InHeader, "API Keys") + reflector.SpecSchema().SetAPIKeySecurity(authtypes.IdentNProviderAPIKey.StringValue(), "SigNoz-Api-Key", openapi.InHeader, "API Keys") reflector.SpecSchema().SetHTTPBearerTokenSecurity(authtypes.IdentNProviderTokenizer.StringValue(), "Tokenizer", "Tokens generated by the tokenizer") collector := handler.NewOpenAPICollector(reflector) diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index f7b7b84c2b..0f9066fdf0 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -24,6 +24,7 @@ import ( "github.com/SigNoz/signoz/pkg/global/signozglobal" "github.com/SigNoz/signoz/pkg/identn" "github.com/SigNoz/signoz/pkg/identn/apikeyidentn" + "github.com/SigNoz/signoz/pkg/identn/impersonationidentn" "github.com/SigNoz/signoz/pkg/identn/tokenizeridentn" "github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain" "github.com/SigNoz/signoz/pkg/modules/organization" @@ -242,7 +243,7 @@ func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, p ) } -func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, global global.Global, modules Modules, handlers Handlers) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] { +func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] { return factory.MustNewNamedMap( signozapiserver.NewFactory( orgGetter, @@ -252,7 +253,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au implsession.NewHandler(modules.Session), implauthdomain.NewHandler(modules.AuthDomain), implpreference.NewHandler(modules.Preference), - signozglobal.NewHandler(global), + handlers.Global, implpromote.NewHandler(modules.Promote), handlers.FlaggerHandler, modules.Dashboard, @@ -276,16 +277,17 @@ func NewTokenizerProviderFactories(cache cache.Cache, sqlstore sqlstore.SQLStore ) } -func NewIdentNProviderFactories(sqlstore sqlstore.SQLStore, tokenizer tokenizer.Tokenizer) factory.NamedMap[factory.ProviderFactory[identn.IdentN, identn.Config]] { +func NewIdentNProviderFactories(sqlstore sqlstore.SQLStore, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) factory.NamedMap[factory.ProviderFactory[identn.IdentN, identn.Config]] { return factory.MustNewNamedMap( + impersonationidentn.NewFactory(orgGetter, userGetter, userConfig), tokenizeridentn.NewFactory(tokenizer), apikeyidentn.NewFactory(sqlstore), ) } -func NewGlobalProviderFactories() factory.NamedMap[factory.ProviderFactory[global.Global, global.Config]] { +func NewGlobalProviderFactories(identNConfig identn.Config) factory.NamedMap[factory.ProviderFactory[global.Global, global.Config]] { return factory.MustNewNamedMap( - signozglobal.NewFactory(), + signozglobal.NewFactory(identNConfig), ) } diff --git a/pkg/signoz/provider_test.go b/pkg/signoz/provider_test.go index 29c955c4a2..12b88f0f99 100644 --- a/pkg/signoz/provider_test.go +++ b/pkg/signoz/provider_test.go @@ -92,7 +92,6 @@ func TestNewProviderFactories(t *testing.T) { NewAPIServerProviderFactories( implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil), nil, - nil, Modules{}, Handlers{}, ) diff --git a/pkg/signoz/signoz.go b/pkg/signoz/signoz.go index c439a0f2b5..82f1f659bd 100644 --- a/pkg/signoz/signoz.go +++ b/pkg/signoz/signoz.go @@ -382,7 +382,7 @@ func New( ctx, providerSettings, config.Global, - NewGlobalProviderFactories(), + NewGlobalProviderFactories(config.IdentN), "signoz", ) if err != nil { @@ -393,16 +393,11 @@ func New( modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter) // Initialize identN resolver - identNFactories := NewIdentNProviderFactories(sqlstore, tokenizer) - identNs := []identn.IdentN{} - for _, identNFactory := range identNFactories.GetInOrder() { - identN, err := identNFactory.New(ctx, providerSettings, config.IdentN) - if err != nil { - return nil, err - } - identNs = append(identNs, identN) + identNFactories := NewIdentNProviderFactories(sqlstore, tokenizer, orgGetter, userGetter, config.User) + identNResolver, err := identn.NewIdentNResolver(ctx, providerSettings, config.IdentN, identNFactories) + if err != nil { + return nil, err } - identNResolver := identn.NewIdentNResolver(providerSettings, identNs...) userService := impluser.NewService(providerSettings, impluser.NewStore(sqlstore, providerSettings), modules.User, orgGetter, authz, config.User.Root) @@ -417,7 +412,7 @@ func New( ctx, providerSettings, config.APIServer, - NewAPIServerProviderFactories(orgGetter, authz, global, modules, handlers), + NewAPIServerProviderFactories(orgGetter, authz, modules, handlers), "signoz", ) if err != nil { diff --git a/pkg/types/authtypes/identn.go b/pkg/types/authtypes/identn.go index 4db0e4c4ea..b61baf641a 100644 --- a/pkg/types/authtypes/identn.go +++ b/pkg/types/authtypes/identn.go @@ -3,10 +3,11 @@ package authtypes import "github.com/SigNoz/signoz/pkg/valuer" var ( - IdentNProviderTokenizer = IdentNProvider{valuer.NewString("tokenizer")} - IdentNProviderAPIkey = IdentNProvider{valuer.NewString("api_key")} - IdentNProviderAnonymous = IdentNProvider{valuer.NewString("anonymous")} - IdentNProviderInternal = IdentNProvider{valuer.NewString("internal")} + IdentNProviderTokenizer = IdentNProvider{valuer.NewString("tokenizer")} + IdentNProviderAPIKey = IdentNProvider{valuer.NewString("api_key")} + IdentNProviderAnonymous = IdentNProvider{valuer.NewString("anonymous")} + IdentNProviderInternal = IdentNProvider{valuer.NewString("internal")} + IdentNProviderImpersonation = IdentNProvider{valuer.NewString("impersonation")} ) type IdentNProvider struct{ valuer.String } diff --git a/pkg/types/global.go b/pkg/types/global.go deleted file mode 100644 index 3278725549..0000000000 --- a/pkg/types/global.go +++ /dev/null @@ -1,15 +0,0 @@ -package types - -import "net/url" - -type GettableGlobalConfig struct { - ExternalURL string `json:"external_url"` - IngestionURL string `json:"ingestion_url"` -} - -func NewGettableGlobalConfig(externalURL, ingestionURL *url.URL) *GettableGlobalConfig { - return &GettableGlobalConfig{ - ExternalURL: externalURL.String(), - IngestionURL: ingestionURL.String(), - } -} diff --git a/pkg/types/globaltypes/config.go b/pkg/types/globaltypes/config.go new file mode 100644 index 0000000000..73a8579cbc --- /dev/null +++ b/pkg/types/globaltypes/config.go @@ -0,0 +1,13 @@ +package globaltypes + +type Config struct { + Endpoint + IdentN IdentNConfig `json:"identN"` +} + +func NewConfig(endpoint Endpoint, identN IdentNConfig) *Config { + return &Config{ + Endpoint: endpoint, + IdentN: identN, + } +} diff --git a/pkg/types/globaltypes/endpoint.go b/pkg/types/globaltypes/endpoint.go new file mode 100644 index 0000000000..ed3b139481 --- /dev/null +++ b/pkg/types/globaltypes/endpoint.go @@ -0,0 +1,13 @@ +package globaltypes + +type Endpoint struct { + ExternalURL string `json:"external_url"` + IngestionURL string `json:"ingestion_url"` +} + +func NewEndpoint(externalURL, ingestionURL string) Endpoint { + return Endpoint{ + ExternalURL: externalURL, + IngestionURL: ingestionURL, + } +} diff --git a/pkg/types/globaltypes/identn.go b/pkg/types/globaltypes/identn.go new file mode 100644 index 0000000000..3be382749f --- /dev/null +++ b/pkg/types/globaltypes/identn.go @@ -0,0 +1,27 @@ +package globaltypes + +type IdentNConfig struct { + Tokenizer TokenizerConfig `json:"tokenizer"` + APIKey APIKeyConfig `json:"apikey"` + Impersonation ImpersonationConfig `json:"impersonation"` +} + +type TokenizerConfig struct { + Enabled bool `json:"enabled"` +} + +type APIKeyConfig struct { + Enabled bool `json:"enabled"` +} + +type ImpersonationConfig struct { + Enabled bool `json:"enabled"` +} + +func NewIdentNConfig(tokenizer TokenizerConfig, apiKey APIKeyConfig, impersonation ImpersonationConfig) IdentNConfig { + return IdentNConfig{ + Tokenizer: tokenizer, + APIKey: apiKey, + Impersonation: impersonation, + } +} diff --git a/pkg/types/invite.go b/pkg/types/invite.go index ce4179aa71..7d69c21116 100644 --- a/pkg/types/invite.go +++ b/pkg/types/invite.go @@ -30,22 +30,6 @@ type Invite struct { InviteLink string `bun:"-" json:"inviteLink"` } -type InviteEmailData struct { - CustomerName string - InviterName string - InviterEmail string - Link string -} - -type PostableAcceptInvite struct { - DisplayName string `json:"displayName"` - InviteToken string `json:"token"` - Password string `json:"password"` - - // reference URL to track where the register request is coming from - SourceURL string `json:"sourceUrl"` -} - type PostableInvite struct { Name string `json:"name"` Email valuer.Email `json:"email"` @@ -79,10 +63,6 @@ func (request *PostableBulkInviteRequest) UnmarshalJSON(data []byte) error { return nil } -type GettableCreateInviteResponse struct { - InviteToken string `json:"token"` -} - func NewInvite(name string, role Role, orgID valuer.UUID, email valuer.Email) (*Invite, error) { invite := &Invite{ Identifiable: Identifiable{ @@ -101,23 +81,3 @@ func NewInvite(name string, role Role, orgID valuer.UUID, email valuer.Email) (* return invite, nil } - -func (request *PostableAcceptInvite) UnmarshalJSON(data []byte) error { - type Alias PostableAcceptInvite - - var temp Alias - if err := json.Unmarshal(data, &temp); err != nil { - return err - } - - if temp.InviteToken == "" { - return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invite token is required") - } - - if !IsPasswordValid(temp.Password) { - return ErrInvalidPassword - } - - *request = PostableAcceptInvite(temp) - return nil -} diff --git a/tests/integration/fixtures/clickhouse.py b/tests/integration/fixtures/clickhouse.py index 4f8f0b6cb5..80f02dd9f8 100644 --- a/tests/integration/fixtures/clickhouse.py +++ b/tests/integration/fixtures/clickhouse.py @@ -172,7 +172,7 @@ def clickhouse( ( 'version="v0.0.1" && ' 'node_os=$(uname -s | tr "[:upper:]" "[:lower:]") && ' - 'node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && ' + "node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && " "cd /tmp && " 'wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F${version}/histogram-quantile_${node_os}_${node_arch}.tar.gz" && ' "tar -xzf histogram-quantile.tar.gz && " diff --git a/tests/integration/fixtures/signoz.py b/tests/integration/fixtures/signoz.py index bec1117093..2d8242bdde 100644 --- a/tests/integration/fixtures/signoz.py +++ b/tests/integration/fixtures/signoz.py @@ -2,6 +2,7 @@ import platform import time from http import HTTPStatus from os import path +from typing import Optional import docker import docker.errors @@ -16,8 +17,7 @@ from fixtures.logger import setup_logger logger = setup_logger(__name__) -@pytest.fixture(name="signoz", scope="package") -def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments +def create_signoz( network: Network, zeus: types.TestContainerDocker, gateway: types.TestContainerDocker, @@ -25,9 +25,12 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments clickhouse: types.TestContainerClickhouse, request: pytest.FixtureRequest, pytestconfig: pytest.Config, + cache_key: str = "signoz", + env_overrides: Optional[dict] = None, ) -> types.SigNoz: """ - Package-scoped fixture for setting up SigNoz. + Factory function for creating a SigNoz container. + Accepts optional env_overrides to customize the container environment. """ def create() -> types.SigNoz: @@ -81,6 +84,9 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments if with_web: env["SIGNOZ_WEB_ENABLED"] = True + if env_overrides: + env = env | env_overrides + container = DockerContainer("signoz:integration") for k, v in env.items(): container.with_env(k, v) @@ -169,7 +175,7 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments return dev.wrap( request, pytestconfig, - "signoz", + cache_key, empty=lambda: types.SigNoz( self=types.TestContainerDocker( id="", @@ -185,3 +191,27 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments delete=delete, restore=restore, ) + + +@pytest.fixture(name="signoz", scope="package") +def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments + network: Network, + zeus: types.TestContainerDocker, + gateway: types.TestContainerDocker, + sqlstore: types.TestContainerSQL, + clickhouse: types.TestContainerClickhouse, + request: pytest.FixtureRequest, + pytestconfig: pytest.Config, +) -> types.SigNoz: + """ + Package-scoped fixture for setting up SigNoz. + """ + return create_signoz( + network=network, + zeus=zeus, + gateway=gateway, + sqlstore=sqlstore, + clickhouse=clickhouse, + request=request, + pytestconfig=pytestconfig, + ) diff --git a/tests/integration/src/callbackauthn/02_saml.py b/tests/integration/src/callbackauthn/02_saml.py index 49706696cf..eb746698bb 100644 --- a/tests/integration/src/callbackauthn/02_saml.py +++ b/tests/integration/src/callbackauthn/02_saml.py @@ -664,7 +664,9 @@ def test_saml_sso_deleted_user_gets_new_user_on_login( # --- Step 3: SSO login should be blocked for deleted user --- create_user_idp(email, "password", True, "SAML", "Lifecycle") - perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password") + perform_saml_login( + signoz, driver, get_session_context, idp_login, email, "password" + ) # Verify user is NOT reactivated — check via DB since API may filter deleted users with signoz.sqlstore.conn.connect() as conn: @@ -683,7 +685,11 @@ def test_saml_sso_deleted_user_gets_new_user_on_login( headers={"Authorization": f"Bearer {admin_token}"}, ) found_user = next( - (user for user in response.json()["data"] if user["email"] == email and user["id"] != user_id), + ( + user + for user in response.json()["data"] + if user["email"] == email and user["id"] != user_id + ), None, ) assert found_user is not None diff --git a/tests/integration/src/callbackauthn/03_oidc.py b/tests/integration/src/callbackauthn/03_oidc.py index 6821c3d550..75ed4a5aee 100644 --- a/tests/integration/src/callbackauthn/03_oidc.py +++ b/tests/integration/src/callbackauthn/03_oidc.py @@ -4,7 +4,6 @@ from urllib.parse import urlparse import requests from selenium import webdriver -from sqlalchemy import sql from wiremock.resources.mappings import Mapping from fixtures.auth import ( diff --git a/tests/integration/src/cloudintegrations/02_generate_connection_url.py b/tests/integration/src/cloudintegrations/02_generate_connection_url.py index 95af1877fe..e16dd75270 100644 --- a/tests/integration/src/cloudintegrations/02_generate_connection_url.py +++ b/tests/integration/src/cloudintegrations/02_generate_connection_url.py @@ -6,7 +6,6 @@ import requests from fixtures import types from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD -from fixtures.cloudintegrations import create_cloud_integration_account from fixtures.cloudintegrationsutils import simulate_agent_checkin from fixtures.logger import setup_logger @@ -168,14 +167,14 @@ def test_duplicate_cloud_account_checkins( assert account1_id != account2_id, "Two accounts should have different internal IDs" -# First check-in succeeds: account1 claims cloud_account_id + # First check-in succeeds: account1 claims cloud_account_id response = simulate_agent_checkin( signoz, admin_token, cloud_provider, account1_id, same_cloud_account_id ) assert ( response.status_code == HTTPStatus.OK ), f"Expected 200 for first check-in, got {response.status_code}: {response.text}" -# + # # Second check-in should fail: account2 tries to use the same cloud_account_id response = simulate_agent_checkin( signoz, admin_token, cloud_provider, account2_id, same_cloud_account_id diff --git a/tests/integration/src/ingestionkeys/01_ingestion_keys.py b/tests/integration/src/ingestionkeys/01_ingestion_keys.py index a5a86c7911..09d3f0d873 100644 --- a/tests/integration/src/ingestionkeys/01_ingestion_keys.py +++ b/tests/integration/src/ingestionkeys/01_ingestion_keys.py @@ -21,6 +21,9 @@ from fixtures.logger import setup_logger logger = setup_logger(__name__) +GATEWAY_APIS_EDITOR_EMAIL = "gatewayapiseditor@integration.test" +GATEWAY_APIS_EDITOR_PASSWORD = "password123Z$" + def test_apply_license( signoz: types.SigNoz, @@ -32,6 +35,31 @@ def test_apply_license( add_license(signoz, make_http_mocks, get_token) +def test_create_editor_user( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +) -> None: + """Invite and register an editor user for gateway API tests.""" + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + invite_response = requests.post( + signoz.self.host_configs["8080"].get("/api/v1/invite"), + json={"email": GATEWAY_APIS_EDITOR_EMAIL, "role": "EDITOR"}, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert invite_response.status_code == HTTPStatus.CREATED + reset_token = invite_response.json()["data"]["token"] + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v1/resetPassword"), + json={"password": GATEWAY_APIS_EDITOR_PASSWORD, "token": reset_token}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT + + # --------------------------------------------------------------------------- # Ingestion key CRUD # --------------------------------------------------------------------------- @@ -44,7 +72,7 @@ def test_create_ingestion_key( get_token: Callable[[str, str], str], ) -> None: """POST /api/v2/gateway/ingestion_keys creates a key via the gateway.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) make_http_mocks( signoz.gateway, @@ -77,7 +105,7 @@ def test_create_ingestion_key( "tags": ["env:test", "team:platform"], "expires_at": "2030-01-01T00:00:00Z", }, - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) @@ -103,7 +131,7 @@ def test_get_ingestion_keys( get_token: Callable[[str, str], str], ) -> None: """GET /api/v2/gateway/ingestion_keys lists keys via the gateway.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) # Default page=1, per_page=10 → gateway gets ?page=1&per_page=10 make_http_mocks( @@ -146,7 +174,7 @@ def test_get_ingestion_keys( response = requests.get( signoz.self.host_configs["8080"].get("/api/v2/gateway/ingestion_keys"), - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) @@ -168,7 +196,7 @@ def test_get_ingestion_keys_custom_pagination( get_token: Callable[[str, str], str], ) -> None: """GET /api/v2/gateway/ingestion_keys with custom pagination params.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) make_http_mocks( signoz.gateway, @@ -200,7 +228,7 @@ def test_get_ingestion_keys_custom_pagination( signoz.self.host_configs["8080"].get( "/api/v2/gateway/ingestion_keys?page=2&per_page=5" ), - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) @@ -221,7 +249,7 @@ def test_search_ingestion_keys( get_token: Callable[[str, str], str], ) -> None: """GET /api/v2/gateway/ingestion_keys/search searches keys by name.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) # name, page, per_page are sorted alphabetically by Go url.Values.Encode() make_http_mocks( @@ -266,7 +294,7 @@ def test_search_ingestion_keys( signoz.self.host_configs["8080"].get( "/api/v2/gateway/ingestion_keys/search?name=my-test" ), - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) @@ -286,7 +314,7 @@ def test_search_ingestion_keys_empty( get_token: Callable[[str, str], str], ) -> None: """Search returns an empty list when no keys match.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) make_http_mocks( signoz.gateway, @@ -318,7 +346,7 @@ def test_search_ingestion_keys_empty( signoz.self.host_configs["8080"].get( "/api/v2/gateway/ingestion_keys/search?name=nonexistent" ), - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) @@ -338,7 +366,7 @@ def test_update_ingestion_key( get_token: Callable[[str, str], str], ) -> None: """PATCH /api/v2/gateway/ingestion_keys/{keyId} updates a key via the gateway.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}" @@ -366,7 +394,7 @@ def test_update_ingestion_key( "tags": ["env:prod"], "expires_at": "2031-06-15T00:00:00Z", }, - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) @@ -388,7 +416,7 @@ def test_delete_ingestion_key( get_token: Callable[[str, str], str], ) -> None: """DELETE /api/v2/gateway/ingestion_keys/{keyId} deletes a key via the gateway.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}" @@ -411,7 +439,7 @@ def test_delete_ingestion_key( signoz.self.host_configs["8080"].get( f"/api/v2/gateway/ingestion_keys/{TEST_KEY_ID}" ), - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) diff --git a/tests/integration/src/ingestionkeys/02_ingestion_keys_limits.py b/tests/integration/src/ingestionkeys/02_ingestion_keys_limits.py index e3531004f3..73ef546660 100644 --- a/tests/integration/src/ingestionkeys/02_ingestion_keys_limits.py +++ b/tests/integration/src/ingestionkeys/02_ingestion_keys_limits.py @@ -10,7 +10,7 @@ from wiremock.client import ( ) from fixtures import types -from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license +from fixtures.auth import add_license from fixtures.gatewayutils import ( TEST_KEY_ID, TEST_LIMIT_ID, @@ -22,6 +22,9 @@ from fixtures.logger import setup_logger logger = setup_logger(__name__) +GATEWAY_APIS_EDITOR_EMAIL = "gatewayapiseditor@integration.test" +GATEWAY_APIS_EDITOR_PASSWORD = "password123Z$" + def test_apply_license( signoz: types.SigNoz, @@ -45,7 +48,7 @@ def test_create_ingestion_key_limit_only_size( get_token: Callable[[str, str], str], ) -> None: """Creating a limit with only size omits count from the gateway payload.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}/limits" @@ -79,7 +82,7 @@ def test_create_ingestion_key_limit_only_size( "config": {"day": {"size": 1000}}, "tags": ["test"], }, - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) @@ -105,7 +108,7 @@ def test_create_ingestion_key_limit_only_count( get_token: Callable[[str, str], str], ) -> None: """Creating a limit with only count omits size from the gateway payload.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}/limits" @@ -139,7 +142,7 @@ def test_create_ingestion_key_limit_only_count( "config": {"day": {"count": 500}}, "tags": ["test"], }, - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) @@ -162,7 +165,7 @@ def test_create_ingestion_key_limit_both_size_and_count( get_token: Callable[[str, str], str], ) -> None: """Creating a limit with both size and count includes both in the gateway payload.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}/limits" @@ -199,7 +202,7 @@ def test_create_ingestion_key_limit_both_size_and_count( }, "tags": ["test"], }, - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) @@ -229,7 +232,7 @@ def test_update_ingestion_key_limit_only_size( get_token: Callable[[str, str], str], ) -> None: """Updating a limit with only size omits count from the gateway payload.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) gateway_url = f"/v1/workspaces/me/limits/{TEST_LIMIT_ID}" @@ -256,7 +259,7 @@ def test_update_ingestion_key_limit_only_size( "config": {"day": {"size": 2000}}, "tags": ["test"], }, - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) @@ -279,7 +282,7 @@ def test_update_ingestion_key_limit_only_count( get_token: Callable[[str, str], str], ) -> None: """Updating a limit with only count omits size from the gateway payload.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) gateway_url = f"/v1/workspaces/me/limits/{TEST_LIMIT_ID}" @@ -306,7 +309,7 @@ def test_update_ingestion_key_limit_only_count( "config": {"day": {"count": 750}}, "tags": ["test"], }, - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) @@ -328,7 +331,7 @@ def test_update_ingestion_key_limit_both_size_and_count( get_token: Callable[[str, str], str], ) -> None: """Updating a limit with both size and count includes both in the gateway payload.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) gateway_url = f"/v1/workspaces/me/limits/{TEST_LIMIT_ID}" @@ -355,7 +358,7 @@ def test_update_ingestion_key_limit_both_size_and_count( "config": {"day": {"size": 1000, "count": 500}}, "tags": ["test"], }, - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) @@ -382,7 +385,7 @@ def test_delete_ingestion_key_limit( get_token: Callable[[str, str], str], ) -> None: """DELETE /api/v2/gateway/ingestion_keys/limits/{limitId} deletes a limit.""" - admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD) gateway_url = f"/v1/workspaces/me/limits/{TEST_LIMIT_ID}" @@ -405,7 +408,7 @@ def test_delete_ingestion_key_limit( signoz.self.host_configs["8080"].get( f"/api/v2/gateway/ingestion_keys/limits/{TEST_LIMIT_ID}" ), - headers={"Authorization": f"Bearer {admin_token}"}, + headers={"Authorization": f"Bearer {editor_token}"}, timeout=10, ) diff --git a/tests/integration/src/passwordauthn/01_register.py b/tests/integration/src/passwordauthn/01_register.py index e3d118b225..60659cbf63 100644 --- a/tests/integration/src/passwordauthn/01_register.py +++ b/tests/integration/src/passwordauthn/01_register.py @@ -110,9 +110,7 @@ def test_invite_and_register( signoz.self.host_configs["8080"].get("/api/v1/invite"), json={"email": "editor@integration.test", "role": "EDITOR", "name": "editor"}, timeout=2, - headers={ - "Authorization": f"Bearer {admin_token}" - }, + headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == HTTPStatus.CREATED @@ -121,6 +119,23 @@ def test_invite_and_register( assert invited_user["email"] == "editor@integration.test" assert invited_user["role"] == "EDITOR" + # Verify the user user appears in the users list but as pending_invite status + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v1/user"), + timeout=2, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == HTTPStatus.OK + + user_response = response.json()["data"] + found_user = next( + (user for user in user_response if user["email"] == "editor@integration.test"), + None, + ) + assert found_user is not None + assert found_user["status"] == "pending_invite" + assert found_user["role"] == "EDITOR" + reset_token = invited_user["token"] # Reset the password to complete the invite flow (activates the user and also grants authz) @@ -139,9 +154,7 @@ def test_invite_and_register( response = requests.get( signoz.self.host_configs["8080"].get("/api/v1/user"), timeout=2, - headers={ - "Authorization": f"Bearer {editor_token}" - }, + headers={"Authorization": f"Bearer {editor_token}"}, ) assert response.status_code == HTTPStatus.FORBIDDEN @@ -194,7 +207,6 @@ def test_revoke_invite_and_register( ) assert response.status_code == HTTPStatus.NO_CONTENT - # Try to use the reset token — should fail (user deleted) response = requests.post( signoz.self.host_configs["8080"].get("/api/v1/resetPassword"), @@ -231,85 +243,3 @@ def test_self_access( assert response.status_code == HTTPStatus.OK assert response.json()["data"]["role"] == "EDITOR" - - -def test_old_invite_flow(signoz: types.SigNoz, get_token: Callable[[str, str], str]): - admin_token = get_token("admin@integration.test", "password123Z$") - - # invite a new user - response = requests.post( - signoz.self.host_configs["8080"].get("/api/v1/invite"), - json={"email": "oldinviteflow@integration.test", "role": "VIEWER", "name": "old invite flow"}, - timeout=2, - headers={"Authorization": f"Bearer {admin_token}"}, - ) - assert response.status_code == HTTPStatus.CREATED - - # get the invite token using get api - response = requests.get( - signoz.self.host_configs["8080"].get("/api/v1/invite"), - timeout=2, - headers={ - "Authorization": f"Bearer {admin_token}" - }, - ) - - invite_response = response.json()["data"] - found_invite = next( - ( - invite - for invite in invite_response - if invite["email"] == "oldinviteflow@integration.test" - ), - None, - ) - - # accept the invite - response = requests.post( - signoz.self.host_configs["8080"].get("/api/v1/invite/accept"), - json={ - "password": "password123Z$", - "displayName": "old invite flow", - "token": f"{found_invite['token']}", - }, - timeout=2, - ) - assert response.status_code == HTTPStatus.CREATED - - # verify the invite token has been deleted - response = requests.get( - signoz.self.host_configs["8080"].get(f"/api/v1/invite/{found_invite['token']}"), - timeout=2, - ) - assert response.status_code in (HTTPStatus.NOT_FOUND, HTTPStatus.BAD_REQUEST) - - # verify that admin endpoints cannot be called - response = requests.get( - signoz.self.host_configs["8080"].get("/api/v1/user"), - timeout=2, - headers={ - "Authorization": f"Bearer {get_token("oldinviteflow@integration.test", "password123Z$")}" - }, - ) - assert response.status_code == HTTPStatus.FORBIDDEN - - # verify the user has been created - response = requests.get( - signoz.self.host_configs["8080"].get("/api/v1/user"), - timeout=2, - headers={ - "Authorization": f"Bearer {admin_token}" - }, - ) - assert response.status_code == HTTPStatus.OK - - user_response = response.json()["data"] - found_user = next( - (user for user in user_response if user["email"] == "oldinviteflow@integration.test"), - None, - ) - - assert found_user is not None - assert found_user["role"] == "VIEWER" - assert found_user["displayName"] == "old invite flow" - assert found_user["email"] == "oldinviteflow@integration.test" diff --git a/tests/integration/src/passwordauthn/03_apikey.py b/tests/integration/src/passwordauthn/03_apikey.py index 4a76c41bc4..c5473f3ed4 100644 --- a/tests/integration/src/passwordauthn/03_apikey.py +++ b/tests/integration/src/passwordauthn/03_apikey.py @@ -63,7 +63,9 @@ def test_api_key(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> assert found_pat["role"] == "ADMIN" -def test_api_key_role(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None: +def test_api_key_role( + signoz: types.SigNoz, get_token: Callable[[str, str], str] +) -> None: admin_token = get_token("admin@integration.test", "password123Z$") response = requests.post( diff --git a/tests/integration/src/passwordauthn/07_invite_status.py b/tests/integration/src/passwordauthn/07_invite_status.py index 5906728b9b..35edc7a21c 100644 --- a/tests/integration/src/passwordauthn/07_invite_status.py +++ b/tests/integration/src/passwordauthn/07_invite_status.py @@ -6,8 +6,6 @@ import requests from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD from fixtures.types import SigNoz -from sqlalchemy import sql - def test_reinvite_deleted_user( signoz: SigNoz, @@ -31,7 +29,11 @@ def test_reinvite_deleted_user( # invite the user response = requests.post( signoz.self.host_configs["8080"].get("/api/v1/invite"), - json={"email": reinvite_user_email, "role": reinvite_user_role, "name": reinvite_user_name}, + json={ + "email": reinvite_user_email, + "role": reinvite_user_role, + "name": reinvite_user_name, + }, headers={"Authorization": f"Bearer {admin_token}"}, timeout=2, ) @@ -58,21 +60,27 @@ def test_reinvite_deleted_user( # Re-invite the same email — should succeed response = requests.post( signoz.self.host_configs["8080"].get("/api/v1/invite"), - json={"email": reinvite_user_email, "role": "VIEWER", "name": "reinvite user v2"}, + json={ + "email": reinvite_user_email, + "role": "VIEWER", + "name": "reinvite user v2", + }, headers={"Authorization": f"Bearer {admin_token}"}, timeout=2, ) assert response.status_code == HTTPStatus.CREATED reinvited_user = response.json()["data"] - assert reinvited_user["role"] == "VIEWER" - assert reinvited_user["id"] != invited_user["id"] # confirms a new user was created + assert reinvited_user["role"] == "VIEWER" + assert reinvited_user["id"] != invited_user["id"] # confirms a new user was created reinvited_user_reset_password_token = reinvited_user["token"] - response = requests.post( signoz.self.host_configs["8080"].get("/api/v1/resetPassword"), - json={"password": "newPassword123Z$", "token": reinvited_user_reset_password_token}, + json={ + "password": "newPassword123Z$", + "token": reinvited_user_reset_password_token, + }, timeout=2, ) assert response.status_code == HTTPStatus.NO_CONTENT @@ -95,8 +103,16 @@ def test_bulk_invite( signoz.self.host_configs["8080"].get("/api/v1/invite/bulk"), json={ "invites": [ - {"email": "bulk1@integration.test", "role": "EDITOR", "name": "bulk user 1"}, - {"email": "bulk2@integration.test", "role": "VIEWER", "name": "bulk user 2"}, + { + "email": "bulk1@integration.test", + "role": "EDITOR", + "name": "bulk user 1", + }, + { + "email": "bulk2@integration.test", + "role": "VIEWER", + "name": "bulk user 2", + }, ] }, headers={"Authorization": f"Bearer {admin_token}"}, diff --git a/tests/integration/src/passwordauthn/08_user_unique_index.py b/tests/integration/src/passwordauthn/08_user_unique_index.py index 0adcfc557b..5c40190151 100644 --- a/tests/integration/src/passwordauthn/08_user_unique_index.py +++ b/tests/integration/src/passwordauthn/08_user_unique_index.py @@ -42,7 +42,11 @@ def test_unique_index_allows_multiple_deleted_rows( # Step 1: invite and delete the first user resp = requests.post( signoz.self.host_configs["8080"].get("/api/v1/invite"), - json={"email": UNIQUE_INDEX_USER_EMAIL, "role": "EDITOR", "name": "unique index user v1"}, + json={ + "email": UNIQUE_INDEX_USER_EMAIL, + "role": "EDITOR", + "name": "unique index user v1", + }, headers={"Authorization": f"Bearer {admin_token}"}, timeout=2, ) @@ -59,7 +63,11 @@ def test_unique_index_allows_multiple_deleted_rows( # Step 2: re-invite and delete the same email (second deleted row) resp = requests.post( signoz.self.host_configs["8080"].get("/api/v1/invite"), - json={"email": UNIQUE_INDEX_USER_EMAIL, "role": "EDITOR", "name": "unique index user v2"}, + json={ + "email": UNIQUE_INDEX_USER_EMAIL, + "role": "EDITOR", + "name": "unique index user v2", + }, headers={"Authorization": f"Bearer {admin_token}"}, timeout=2, ) @@ -85,9 +93,9 @@ def test_unique_index_allows_multiple_deleted_rows( ) deleted_rows = result.fetchall() - assert len(deleted_rows) == 2, ( - f"expected 2 deleted rows for {UNIQUE_INDEX_USER_EMAIL}, got {len(deleted_rows)}" - ) + assert ( + len(deleted_rows) == 2 + ), f"expected 2 deleted rows for {UNIQUE_INDEX_USER_EMAIL}, got {len(deleted_rows)}" deleted_ids = {row[0] for row in deleted_rows} assert first_user_id in deleted_ids assert second_user_id in deleted_ids diff --git a/tests/integration/src/querier/03_metrics.py b/tests/integration/src/querier/03_metrics.py index 9b9f4c1e82..baf593e0b0 100644 --- a/tests/integration/src/querier/03_metrics.py +++ b/tests/integration/src/querier/03_metrics.py @@ -585,13 +585,14 @@ def test_metrics_fill_formula_with_group_by( context=f"metrics/{fill_mode}/F1/{group}", ) + def test_histogram_p90_returns_404_outside_data_window( signoz: types.SigNoz, create_user_admin: None, # pylint: disable=unused-argument get_token: Callable[[str, str], str], insert_metrics: Callable[[List[Metrics]], None], ) -> None: - + now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0) metric_name = "test_p90_last_seen_bucket" diff --git a/tests/integration/src/querier/08_metrics_histogram.py b/tests/integration/src/querier/08_metrics_histogram.py index 36da313374..e6eeac316d 100644 --- a/tests/integration/src/querier/08_metrics_histogram.py +++ b/tests/integration/src/querier/08_metrics_histogram.py @@ -373,6 +373,7 @@ def test_histogram_count_no_param( ) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02 assert values[-1]["value"] == last_values[le] + @pytest.mark.parametrize( "space_agg, zeroth_value, first_value, last_value", [ @@ -423,6 +424,7 @@ def test_histogram_percentile_for_all_services( assert result_values[1]["value"] == first_value assert result_values[-1]["value"] == last_value + @pytest.mark.parametrize( "space_agg, first_value, last_value", [ @@ -472,6 +474,7 @@ def test_histogram_percentile_for_cumulative_service( assert result_values[0]["value"] == first_value assert result_values[-1]["value"] == last_value + @pytest.mark.parametrize( "space_agg, zeroth_value, first_value, last_value", [ @@ -521,4 +524,4 @@ def test_histogram_percentile_for_delta_service( assert len(result_values) == 60 assert result_values[0]["value"] == zeroth_value assert result_values[1]["value"] == first_value - assert result_values[-1]["value"] == last_value \ No newline at end of file + assert result_values[-1]["value"] == last_value diff --git a/tests/integration/src/rootuser/01_rootuser.py b/tests/integration/src/rootuser/01_rootuser.py new file mode 100644 index 0000000000..3dbaf4bc7f --- /dev/null +++ b/tests/integration/src/rootuser/01_rootuser.py @@ -0,0 +1,54 @@ +import time +from http import HTTPStatus + +import requests + +from fixtures import types +from fixtures.logger import setup_logger + +logger = setup_logger(__name__) + + +def test_root_user_created(signoz: types.SigNoz) -> None: + """ + The root user service reconciles asynchronously after startup. + + Phase 1: Poll /api/v1/version until setupCompleted=true. + Phase 2: Poll /api/v1/user until it returns 200, confirming the root + user actually exists and the impersonation provider works. + """ + # Phase 1: wait for setupCompleted + for attempt in range(15): + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v1/version"), + timeout=2, + ) + assert response.status_code == HTTPStatus.OK + if response.json().get("setupCompleted") is True: + break + logger.info( + "Attempt %s: setupCompleted is not yet true, retrying ...", + attempt + 1, + ) + time.sleep(2) + else: + raise AssertionError( + "setupCompleted did not become true within the expected time" + ) + + # Phase 2: wait for root user to be fully resolved + for attempt in range(15): + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v1/user"), + timeout=2, + ) + if response.status_code == HTTPStatus.OK: + return + logger.info( + "Attempt %s: /api/v1/user returned %s, retrying ...", + attempt + 1, + response.status_code, + ) + time.sleep(2) + + raise AssertionError("root user was not created within the expected time") diff --git a/tests/integration/src/rootuser/02_impersonation.py b/tests/integration/src/rootuser/02_impersonation.py new file mode 100644 index 0000000000..a9127f4aae --- /dev/null +++ b/tests/integration/src/rootuser/02_impersonation.py @@ -0,0 +1,49 @@ +from http import HTTPStatus + +import requests + +from fixtures import types +from fixtures.logger import setup_logger + +logger = setup_logger(__name__) + + +def test_global_config_returns_impersonation_enabled(signoz: types.SigNoz) -> None: + """ + GET /api/v1/global/config without any auth header should return 200 + and report impersonation as enabled. + """ + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v1/global/config"), + timeout=2, + ) + + assert response.status_code == HTTPStatus.OK + + data = response.json()["data"] + assert data["identN"]["impersonation"]["enabled"] is True + assert data["identN"]["tokenizer"]["enabled"] is False + assert data["identN"]["apikey"]["enabled"] is False + + +def test_impersonated_user_is_admin(signoz: types.SigNoz) -> None: + """ + The impersonated identity should have admin privileges. + Listing users is an admin-only endpoint. + """ + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v1/user"), + timeout=2, + ) + + assert response.status_code == HTTPStatus.OK + + users = response.json()["data"] + assert len(users) >= 1 + + root_user = next( + (u for u in users if u.get("isRoot") is True), + None, + ) + assert root_user is not None + assert root_user["role"] == "ADMIN" diff --git a/tests/integration/src/rootuser/__init__.py b/tests/integration/src/rootuser/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/src/rootuser/conftest.py b/tests/integration/src/rootuser/conftest.py new file mode 100644 index 0000000000..4ca2db3c24 --- /dev/null +++ b/tests/integration/src/rootuser/conftest.py @@ -0,0 +1,41 @@ +import pytest +from testcontainers.core.container import Network + +from fixtures import types +from fixtures.signoz import create_signoz + +ROOT_USER_EMAIL = "rootuser@integration.test" +ROOT_USER_PASSWORD = "password123Z$" + + +@pytest.fixture(name="signoz", scope="package") +def signoz_rootuser( + network: Network, + zeus: types.TestContainerDocker, + gateway: types.TestContainerDocker, + sqlstore: types.TestContainerSQL, + clickhouse: types.TestContainerClickhouse, + request: pytest.FixtureRequest, + pytestconfig: pytest.Config, +) -> types.SigNoz: + """ + Package-scoped fixture for SigNoz with root user and impersonation enabled. + """ + return create_signoz( + network=network, + zeus=zeus, + gateway=gateway, + sqlstore=sqlstore, + clickhouse=clickhouse, + request=request, + pytestconfig=pytestconfig, + cache_key="signoz-rootuser", + env_overrides={ + "SIGNOZ_IDENTN_IMPERSONATION_ENABLED": True, + "SIGNOZ_IDENTN_TOKENIZER_ENABLED": False, + "SIGNOZ_IDENTN_APIKEY_ENABLED": False, + "SIGNOZ_USER_ROOT_ENABLED": True, + "SIGNOZ_USER_ROOT_EMAIL": ROOT_USER_EMAIL, + "SIGNOZ_USER_ROOT_PASSWORD": ROOT_USER_PASSWORD, + }, + )