Compare commits

...

22 Commits

Author SHA1 Message Date
Tushar Vats
616481d426 fix: fixed table names and clickhouse queries 2025-10-17 17:46:28 +05:30
Tushar Vats
6765f049a2 feat: updated integration tests 2025-10-17 17:46:28 +05:30
Tushar Vats
206a896f93 feat: fix table names 2025-10-17 17:45:28 +05:30
Tushar Vats
406cad5be5 feat: added custom retension for attributes 2025-10-17 17:45:28 +05:30
Tushar Vats
6d416061f2 fix: legacy logs ttl (#9345)
This pull request introduces support for setting TTL (Time-To-Live) on logs tables in ClickHouse, including both backend logic and integration tests. The main changes add back method for setting TTL on logs tables, update the TTL API to handle logs, and provide robust test coverage for legacy and new TTL flows.
2025-10-17 17:18:15 +05:30
Amlan Kumar Nandy
b8cf0a3041 chore: fix legends representation in non-metric non-qb queries (#9351) 2025-10-17 05:42:13 +00:00
Vibhu Pandey
1793026c78 fix(tokenizer): fix cache invalidation on delete (#9352)
fix cache invalidation on delete
2025-10-16 17:36:52 +00:00
Vibhu Pandey
c122bc09b4 feat(tokenizer|sso): add tokenizer for session management and oidc sso support (#9183)
## 📄 Summary

- Instead of relying on JWT for session management, we are adding another token system: opaque. This gives the benefits of expiration and revocation.

- We are now ensuring that emails are regex checked throughout the backend.

- Support has been added for OIDC protocol
2025-10-16 18:00:38 +05:30
Shaheer Kochai
d22039b1a1 fix: show list options even in traces explorer empty / error states (#9339) 2025-10-16 05:47:42 +00:00
Aditya Singh
457b6970b1 fix: prevent queryKey overide by options (#9344) 2025-10-15 20:05:37 +05:30
Vishal Sharma
1267f9ad6e chore: add feedback button text (#9335) 2025-10-14 16:49:35 +00:00
Ekansh Gupta
ecd9498970 feat: add bidirectional keys for http.url and net.peer.name (#9208)
* feat: add bidirectional keys for http.url and net.peer.name

* feat: added bidirectional key maps

* feat: addressed comments regarding redundancy and batchKeys

* feat: addressed comments regarding redundancy and batchKeys

* feat: addressed comments regarding redundancy and batchKeys

* feat: added test cases for bidirectional key mapping

* feat: addressed comments for tests
2025-10-14 16:29:25 +00:00
Vikrant Gupta
bac8f8b211 Revert "feat(sql): swap mattn/sqlite with modernc.org/sqlite (#9325)" (#9338)
This reverts commit c62d41edf0.
2025-10-14 15:01:51 +00:00
Vikrant Gupta
800a34e625 Revert "feat(sql): increase busy timeout (#9336)" (#9337)
This reverts commit 934c09b36b.
2025-10-14 20:21:51 +05:30
Vikrant Gupta
934c09b36b feat(sql): increase busy timeout (#9336) 2025-10-14 13:23:57 +00:00
Vikrant Gupta
c62d41edf0 feat(sql): swap mattn/sqlite with modernc.org/sqlite (#9325)
* feat(sql): swap mattn/sqlite with modernc.org/sqlite

* feat(sql): revert the dashboard testing changes

* feat(sql): enable WAL mode for sqlite

* feat(sql): revert enable WAL mode for sqlite

* feat(sql): use sensible defaults for busy_timeout

* feat(sql): add ldflags
2025-10-14 15:28:19 +05:30
Abhi kumar
264af06ca0 fix: added fix for changelog paragraph font weight (#9331) 2025-10-14 12:13:39 +05:30
Nageshbansal
dcc902fb27 chore(statsreporter): fix vultr platform detection in statsreporter (#9326)
* chore(statsreporter): fix vultr platform detection in statsreporter

* chore(statsreporter): adds comment for Vultr Detection order
2025-10-13 20:18:19 +00:00
Vikrant Gupta
a4f24a231b feat(meter): better defaults in cost meter and user improvement (#9328) 2025-10-13 20:49:14 +05:30
Srikanth Chekuri
416e8d2a5e fix: panic from label set conversion (#9316) 2025-10-12 18:14:16 +05:30
Niladri Adhikary
43a6c7dcd6 feat: add abs value function in formula (#9315)
Signed-off-by: “niladrix719” <niladrix719@gmail.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-10-12 11:15:59 +00:00
Amlan Kumar Nandy
5005cae2ad fix: edit alerts crash (#9310) 2025-10-12 15:55:42 +05:30
258 changed files with 11479 additions and 9731 deletions

View File

@@ -15,7 +15,8 @@ jobs:
matrix:
src:
- bootstrap
- auth
- passwordauthn
- callbackauthn
- querier
- ttl
sqlstore-provider:
@@ -24,7 +25,7 @@ jobs:
clickhouse-version:
- 25.5.6
schema-migrator-version:
- v0.129.6
- v0.129.7
postgres-version:
- 15
if: |
@@ -43,6 +44,20 @@ jobs:
python -m pip install poetry==2.1.2
python -m poetry config virtualenvs.in-project true
cd tests/integration && poetry install --no-root
- name: webdriver
run: |
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
sudo apt-get update -qqy
sudo apt-get -qqy install google-chrome-stable
CHROME_VERSION=$(google-chrome-stable --version)
CHROME_FULL_VERSION=${CHROME_VERSION%%.*}
CHROME_MAJOR_VERSION=${CHROME_FULL_VERSION//[!0-9]}
sudo rm /etc/apt/sources.list.d/google-chrome.list
export CHROMEDRIVER_VERSION=`curl -s https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_${CHROME_MAJOR_VERSION%%.*}`
curl -L -O "https://storage.googleapis.com/chrome-for-testing-public/${CHROMEDRIVER_VERSION}/linux64/chromedriver-linux64.zip"
unzip chromedriver-linux64.zip && chmod +x chromedriver && sudo mv chromedriver /usr/local/bin
chromedriver -version
- name: run
run: |
cd tests/integration && \

View File

@@ -3,11 +3,11 @@ package main
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
@@ -56,12 +56,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return err
}
jwt := authtypes.NewJWT(cmd.NewJWTSecret(ctx, logger), 30*time.Minute, 30*24*time.Hour)
signoz, err := signoz.New(
ctx,
config,
jwt,
zeus.Config{},
noopzeus.NewProviderFactory(),
licensing.Config{},
@@ -76,13 +73,16 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
},
signoz.NewSQLStoreProviderFactories(),
signoz.NewTelemetryStoreProviderFactories(),
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)
return err
}
server, err := app.NewServer(config, signoz, jwt)
server, err := app.NewServer(config, signoz)
if err != nil {
logger.ErrorContext(ctx, "failed to create server", "error", err)
return err

View File

@@ -3,7 +3,6 @@ package cmd
import (
"context"
"log/slog"
"os"
"github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/config/envprovider"
@@ -30,12 +29,3 @@ func NewSigNozConfig(ctx context.Context, logger *slog.Logger, flags signoz.Depr
return config, nil
}
func NewJWTSecret(ctx context.Context, logger *slog.Logger) string {
jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET")
if len(jwtSecret) == 0 {
logger.ErrorContext(ctx, "🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!", "error", "SIGNOZ_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application. Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access. Please set the SIGNOZ_JWT_SECRET environment variable immediately. For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.")
}
return jwtSecret
}

View File

@@ -6,6 +6,8 @@ import (
"time"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
@@ -14,6 +16,7 @@ import (
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/organization"
@@ -54,17 +57,14 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
// add enterprise sqlstore factories to the community sqlstore factories
sqlstoreFactories := signoz.NewSQLStoreProviderFactories()
if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil {
if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory(), sqlstorehook.NewInstrumentationFactory())); err != nil {
logger.ErrorContext(ctx, "failed to add postgressqlstore factory", "error", err)
return err
}
jwt := authtypes.NewJWT(cmd.NewJWTSecret(ctx, logger), 30*time.Minute, 30*24*time.Hour)
signoz, err := signoz.New(
ctx,
config,
jwt,
enterprisezeus.Config(),
httpzeus.NewProviderFactory(),
enterpriselicensing.Config(24*time.Hour, 3),
@@ -84,13 +84,34 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
},
sqlstoreFactories,
signoz.NewTelemetryStoreProviderFactories(),
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing)
if err != nil {
return nil, err
}
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings)
if err != nil {
return nil, err
}
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing)
if err != nil {
return nil, err
}
authNs[authtypes.AuthNProviderSAML] = samlCallbackAuthN
authNs[authtypes.AuthNProviderOIDC] = oidcCallbackAuthN
return authNs, nil
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)
return err
}
server, err := enterpriseapp.NewServer(config, signoz, jwt)
server, err := enterpriseapp.NewServer(config, signoz)
if err != nil {
logger.ErrorContext(ctx, "failed to create server", "error", err)
return err

View File

@@ -243,3 +243,28 @@ statsreporter:
gateway:
# The URL of the gateway's api.
url: http://localhost:8080
##################### Tokenizer #####################
tokenizer:
# Specifies the tokenizer provider to use.
provider: jwt
lifetime:
# The duration for which a user can be idle before being required to authenticate.
idle: 168h
# The duration for which a user can remain logged in before being asked to login.
max: 720h
rotation:
# The interval to rotate tokens in.
interval: 30m
# The duration for which the previous token pair remains valid after a token pair is rotated.
duration: 60s
jwt:
# The secret to sign the JWT tokens.
secret: secret
opaque:
gc:
# The interval to perform garbage collection.
interval: 1h
token:
# The maximum number of tokens a user can have. This limits the number of concurrent sessions a user can have.
max_per_user: 5

View File

@@ -0,0 +1,191 @@
package oidccallbackauthn
import (
"context"
"net/url"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
const (
redirectPath string = "/api/v1/complete/oidc"
)
var (
scopes []string = []string{"email", oidc.ScopeOpenID}
)
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
store authtypes.AuthNStore
licensing licensing.Licensing
httpClient *client.Client
}
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings) (*AuthN, error) {
httpClient, err := client.New(providerSettings.Logger, providerSettings.TracerProvider, providerSettings.MeterProvider)
if err != nil {
return nil, err
}
return &AuthN{
store: store,
licensing: licensing,
httpClient: httpClient,
}, nil
}
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc")
}
_, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, siteURL, authDomain)
if err != nil {
return "", err
}
return oauth2Config.AuthCodeURL(authtypes.NewState(siteURL, authDomain.StorableAuthDomain().ID).URL.String()), nil
}
func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtypes.CallbackIdentity, error) {
if err := query.Get("error"); err != "" {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "oidc: error while authenticating").WithAdditional(query.Get("error_description"))
}
state, err := authtypes.NewStateFromString(query.Get("state"))
if err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeInvalidState, "oidc: invalid state").WithAdditional(err.Error())
}
authDomain, err := a.store.GetAuthDomainFromID(ctx, state.DomainID)
if err != nil {
return nil, err
}
_, err = a.licensing.GetActive(ctx, authDomain.StorableAuthDomain().OrgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
oidcProvider, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, state.URL, authDomain)
if err != nil {
return nil, err
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, a.httpClient.Client())
token, err := oauth2Config.Exchange(ctx, query.Get("code"))
if err != nil {
var retrieveError *oauth2.RetrieveError
if errors.As(err, &retrieveError) {
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to get token").WithAdditional(retrieveError.ErrorDescription).WithAdditional(string(retrieveError.Body))
}
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "oidc: failed to get token").WithAdditional(err.Error())
}
claims, err := a.claimsFromIDToken(ctx, authDomain, oidcProvider, token)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if claims == nil && authDomain.AuthDomainConfig().OIDC.GetUserInfo {
claims, err = a.claimsFromUserInfo(ctx, oidcProvider, token)
if err != nil {
return nil, err
}
}
emailClaim, ok := claims[authDomain.AuthDomainConfig().OIDC.ClaimMapping.Email].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims")
}
email, err := valuer.NewEmail(emailClaim)
if err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to parse email").WithAdditional(err.Error())
}
if !authDomain.AuthDomainConfig().OIDC.InsecureSkipEmailVerified {
emailVerifiedClaim, ok := claims["email_verified"].(bool)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email_verified in claims")
}
if !emailVerifiedClaim {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "oidc: email is not verified")
}
}
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
}
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)
}
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer)
if err != nil {
return nil, nil, err
}
return oidcProvider, &oauth2.Config{
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,
Endpoint: oidcProvider.Endpoint(),
Scopes: scopes,
RedirectURL: (&url.URL{
Scheme: siteURL.Scheme,
Host: siteURL.Host,
Path: redirectPath,
}).String(),
}, nil
}
func (a *AuthN) claimsFromIDToken(ctx context.Context, authDomain *authtypes.AuthDomain, provider *oidc.Provider, token *oauth2.Token) (map[string]any, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "oidc: no id_token in token response")
}
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().OIDC.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to verify token").WithAdditional(err.Error())
}
var claims map[string]any
if err := idToken.Claims(&claims); err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to decode claims").WithAdditional(err.Error())
}
return claims, nil
}
func (a *AuthN) claimsFromUserInfo(ctx context.Context, provider *oidc.Provider, token *oauth2.Token) (map[string]any, error) {
var claims map[string]any
userInfo, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: token.AccessToken,
TokenType: "Bearer", // The UserInfo endpoint requires a bearer token as per RFC6750
}))
if err != nil {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "oidc: failed to get user info").WithAdditional(err.Error())
}
if err := userInfo.Claims(&claims); err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to decode claims").WithAdditional(err.Error())
}
return claims, nil
}

View File

@@ -0,0 +1,155 @@
package samlcallbackauthn
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"net/url"
"strings"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
saml2 "github.com/russellhaering/gosaml2"
dsig "github.com/russellhaering/goxmldsig"
)
const (
redirectPath string = "/api/v1/complete/saml"
)
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
store authtypes.AuthNStore
licensing licensing.Licensing
}
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing) (*AuthN, error) {
return &AuthN{
store: store,
licensing: licensing,
}, nil
}
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml")
}
sp, err := a.serviceProvider(siteURL, authDomain)
if err != nil {
return "", err
}
url, err := sp.BuildAuthURL(authtypes.NewState(siteURL, authDomain.StorableAuthDomain().ID).URL.String())
if err != nil {
return "", err
}
return url, nil
}
func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*authtypes.CallbackIdentity, error) {
state, err := authtypes.NewStateFromString(formValues.Get("RelayState"))
if err != nil {
return nil, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeInvalidState, "saml: invalid state").WithAdditional(err.Error())
}
authDomain, err := a.store.GetAuthDomainFromID(ctx, state.DomainID)
if err != nil {
return nil, err
}
_, err = a.licensing.GetActive(ctx, authDomain.StorableAuthDomain().OrgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
sp, err := a.serviceProvider(state.URL, authDomain)
if err != nil {
return nil, err
}
assertionInfo, err := sp.RetrieveAssertionInfo(formValues.Get("SAMLResponse"))
if err != nil {
if errors.As(err, &saml2.ErrVerification{}) {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, err.Error())
}
if errors.As(err, &saml2.ErrMissingElement{}) {
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, err.Error())
}
return nil, err
}
if assertionInfo.WarningInfo.InvalidTime {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "saml: expired saml response")
}
email, err := valuer.NewEmail(assertionInfo.NameID)
if err != nil {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "saml: invalid email").WithAdditional("The nameID assertion is used to retrieve the email address, please check your IDP configuration and try again.")
}
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
}
func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDomain) (*saml2.SAMLServiceProvider, error) {
certStore, err := a.getCertificateStore(authDomain)
if err != nil {
return nil, err
}
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: redirectPath}
// Note:
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.
// For AWSSSO, this is the value of Application SAML audience.
return &saml2.SAMLServiceProvider{
IdentityProviderSSOURL: authDomain.AuthDomainConfig().SAML.SamlIdp,
IdentityProviderIssuer: authDomain.AuthDomainConfig().SAML.SamlEntity,
ServiceProviderIssuer: siteURL.Host,
AssertionConsumerServiceURL: acsURL.String(),
SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
AllowMissingAttributes: true,
IDPCertificateStore: certStore,
SPKeyStore: dsig.RandomKeyStoreForTest(),
}, nil
}
func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509CertificateStore, error) {
certStore := &dsig.MemoryX509CertificateStore{
Roots: []*x509.Certificate{},
}
var certBytes []byte
if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert))
if block == nil {
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no valid pem cert found")
}
certBytes = block.Bytes
} else {
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().SAML.SamlCert)
if err != nil {
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to read certificate: %s", err.Error())
}
certBytes = certData
}
idpCert, err := x509.ParseCertificate(certBytes)
if err != nil {
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to prepare saml request, invalid cert: %s", err.Error())
}
certStore.Roots = append(certStore.Roots, idpCert)
return certStore, nil
}

View File

@@ -20,7 +20,6 @@ import (
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/gorilla/mux"
)
@@ -35,10 +34,7 @@ type APIHandlerOptions struct {
Gateway *httputil.ReverseProxy
GatewayUrl string
// Querier Influx Interval
FluxInterval time.Duration
UseLogsNewSchema bool
UseTraceNewSchema bool
JWT *authtypes.JWT
FluxInterval time.Duration
}
type APIHandler struct {
@@ -93,7 +89,8 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
// paid plans specific routes
router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.receiveSAML)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionBySAMLCallback)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/complete/oidc", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionByOIDCCallback)).Methods(http.MethodGet)
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)

View File

@@ -1,107 +0,0 @@
package api
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"go.uber.org/zap"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/valuer"
)
func handleSsoError(w http.ResponseWriter, r *http.Request, redirectURL string) {
ssoError := []byte("Login failed. Please contact your system administrator")
dst := make([]byte, base64.StdEncoding.EncodedLen(len(ssoError)))
base64.StdEncoding.Encode(dst, ssoError)
http.Redirect(w, r, fmt.Sprintf("%s?ssoerror=%s", redirectURL, string(dst)), http.StatusSeeOther)
}
// receiveSAML completes a SAML request and gets user logged in
func (ah *APIHandler) receiveSAML(w http.ResponseWriter, r *http.Request) {
// this is the source url that initiated the login request
redirectUri := constants.GetDefaultSiteURL()
ctx := context.Background()
err := r.ParseForm()
if err != nil {
zap.L().Error("[receiveSAML] failed to process response - invalid response from IDP", zap.Error(err), zap.Any("request", r))
handleSsoError(w, r, redirectUri)
return
}
// the relay state is sent when a login request is submitted to
// Idp.
relayState := r.FormValue("RelayState")
zap.L().Debug("[receiveML] relay state", zap.String("relayState", relayState))
parsedState, err := url.Parse(relayState)
if err != nil || relayState == "" {
zap.L().Error("[receiveSAML] failed to process response - invalid response from IDP", zap.Error(err), zap.Any("request", r))
handleSsoError(w, r, redirectUri)
return
}
// upgrade redirect url from the relay state for better accuracy
redirectUri = fmt.Sprintf("%s://%s%s", parsedState.Scheme, parsedState.Host, "/login")
// fetch domain by parsing relay state.
domain, err := ah.Signoz.Modules.User.GetDomainFromSsoResponse(ctx, parsedState)
if err != nil {
handleSsoError(w, r, redirectUri)
return
}
orgID, err := valuer.NewUUID(domain.OrgID)
if err != nil {
handleSsoError(w, r, redirectUri)
return
}
_, err = ah.Signoz.Licensing.GetActive(ctx, orgID)
if err != nil {
zap.L().Error("[receiveSAML] sso requested but feature unavailable in org domain")
http.Redirect(w, r, fmt.Sprintf("%s?ssoerror=%s", redirectUri, "feature unavailable, please upgrade your billing plan to access this feature"), http.StatusMovedPermanently)
return
}
sp, err := domain.PrepareSamlRequest(parsedState)
if err != nil {
zap.L().Error("[receiveSAML] failed to prepare saml request for domain", zap.String("domain", domain.String()), zap.Error(err))
handleSsoError(w, r, redirectUri)
return
}
assertionInfo, err := sp.RetrieveAssertionInfo(r.FormValue("SAMLResponse"))
if err != nil {
zap.L().Error("[receiveSAML] failed to retrieve assertion info from saml response", zap.String("domain", domain.String()), zap.Error(err))
handleSsoError(w, r, redirectUri)
return
}
if assertionInfo.WarningInfo.InvalidTime {
zap.L().Error("[receiveSAML] expired saml response", zap.String("domain", domain.String()), zap.Error(err))
handleSsoError(w, r, redirectUri)
return
}
email := assertionInfo.NameID
if email == "" {
zap.L().Error("[receiveSAML] invalid email in the SSO response", zap.String("domain", domain.String()))
handleSsoError(w, r, redirectUri)
return
}
nextPage, err := ah.Signoz.Modules.User.PrepareSsoRedirect(ctx, redirectUri, email)
if err != nil {
zap.L().Error("[receiveSAML] failed to generate redirect URI after successful login ", zap.String("domain", domain.String()), zap.Error(err))
handleSsoError(w, r, redirectUri)
return
}
http.Redirect(w, r, nextPage, http.StatusSeeOther)
}

View File

@@ -168,38 +168,22 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
func (ah *APIHandler) getOrCreateCloudIntegrationUser(
ctx context.Context, orgId string, cloudProvider string,
) (*types.User, *basemodel.ApiError) {
cloudIntegrationUser := fmt.Sprintf("%s-integration", cloudProvider)
email := fmt.Sprintf("%s@signoz.io", cloudIntegrationUser)
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
integrationUserResult, err := ah.Signoz.Modules.User.GetUserByEmailInOrg(ctx, orgId, email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, basemodel.NotFoundError(fmt.Errorf("couldn't look for integration user: %w", err))
}
if integrationUserResult != nil {
return &integrationUserResult.User, nil
}
zap.L().Info(
"cloud integration user not found. Attempting to create the user",
zap.String("cloudProvider", cloudProvider),
)
newUser, err := types.NewUser(cloudIntegrationUser, email, types.RoleViewer.String(), orgId)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration user: %w", err,
))
}
password := types.MustGenerateFactorPassword(newUser.ID.StringValue())
err = ah.Signoz.Modules.User.CreateUser(ctx, newUser, user.WithFactorPassword(password))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId))
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
}
return newUser, nil
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
cloudIntegrationUser, err = ah.Signoz.Modules.User.GetOrCreateUser(ctx, cloudIntegrationUser, user.WithFactorPassword(password))
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't look for integration user: %w", err))
}
return cloudIntegrationUser, nil
}
func getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (

View File

@@ -7,8 +7,11 @@ import (
"net"
"net/http"
_ "net/http/pprof" // http profiler
"slices"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation"
"github.com/gorilla/handlers"
@@ -25,7 +28,6 @@ import (
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/web"
"github.com/rs/cors"
"github.com/soheilhy/cmux"
@@ -50,7 +52,6 @@ import (
type Server struct {
config signoz.Config
signoz *signoz.SigNoz
jwt *authtypes.JWT
ruleManager *baserules.Manager
// public http router
@@ -67,7 +68,7 @@ type Server struct {
}
// NewServer creates and initializes Server
func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT) (*Server, error) {
func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
gatewayProxy, err := gateway.NewProxy(config.Gateway.URL.String(), gateway.RoutePrefix)
if err != nil {
return nil, err
@@ -153,7 +154,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT)
FluxInterval: config.Querier.FluxInterval,
Gateway: gatewayProxy,
GatewayUrl: config.Gateway.URL.String(),
JWT: jwt,
}
apiHandler, err := api.NewAPIHandler(apiOpts, signoz)
@@ -164,7 +164,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT)
s := &Server{
config: config,
signoz: signoz,
jwt: jwt,
ruleManager: rm,
httpHostPort: baseconst.HTTPHostPort,
unavailableChannel: make(chan healthcheck.Status),
@@ -195,7 +194,17 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
r := baseapp.NewRouter()
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger())
r.Use(middleware.NewAuth(s.jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(otelmux.Middleware(
"apiserver",
otelmux.WithMeterProvider(s.signoz.Instrumentation.MeterProvider()),
otelmux.WithTracerProvider(s.signoz.Instrumentation.TracerProvider()),
otelmux.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{})),
otelmux.WithFilter(func(r *http.Request) bool {
return !slices.Contains([]string{"/api/v1/health"}, r.URL.Path)
}),
otelmux.WithPublicEndpoint(),
))
r.Use(middleware.NewAuthN([]string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Tokenizer, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAPIKey(s.signoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.signoz.Instrumentation.Logger(), s.signoz.Sharder).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
s.config.APIServer.Timeout.ExcludedRoutes,

View File

@@ -4,10 +4,6 @@ import (
"os"
)
const (
DefaultSiteURL = "https://localhost:8080"
)
var LicenseSignozIo = "https://license.signoz.io/api/v1"
var LicenseAPIKey = GetOrDefaultEnv("SIGNOZ_LICENSE_API_KEY", "")
var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "")
@@ -27,13 +23,6 @@ func GetOrDefaultEnv(key string, fallback string) string {
// constant functions that override env vars
// GetDefaultSiteURL returns default site url, primarily
// used to send saml request and allowing backend to
// handle http redirect
func GetDefaultSiteURL() string {
return GetOrDefaultEnv("SIGNOZ_SITE_URL", DefaultSiteURL)
}
const DotMetricsEnabled = "DOT_METRICS_ENABLED"
var IsDotMetricsEnabled = false

View File

@@ -2,14 +2,12 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
const afterLogin = (
userId: string,
authToken: string,
refreshToken: string,
interceptorRejected?: boolean,
): void => {
setLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN, authToken);
setLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN, refreshToken);
setLocalStorageApi(LOCALSTORAGE.USER_ID, userId);
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
if (!interceptorRejected) {
@@ -18,7 +16,6 @@ const afterLogin = (
detail: {
accessJWT: authToken,
refreshJWT: refreshToken,
id: userId,
},
}),
);

View File

@@ -1,62 +0,0 @@
import getLocalStorageApi from 'api/browser/localstorage/get';
import { ENVIRONMENT } from 'constants/env';
import { LOCALSTORAGE } from 'constants/localStorage';
import { isEmpty } from 'lodash-es';
export interface WsDataEvent {
read_rows: number;
read_bytes: number;
elapsed_ms: number;
}
interface GetQueryStatsProps {
queryId: string;
setData: React.Dispatch<React.SetStateAction<WsDataEvent | undefined>>;
}
function getURL(baseURL: string, queryId: string): URL | string {
if (baseURL && !isEmpty(baseURL)) {
return `${baseURL}/ws/query_progress?q=${queryId}`;
}
const url = new URL(`/ws/query_progress?q=${queryId}`, window.location.href);
if (window.location.protocol === 'http:') {
url.protocol = 'ws';
} else {
url.protocol = 'wss';
}
return url;
}
export function getQueryStats(props: GetQueryStatsProps): void {
const { queryId, setData } = props;
const token = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || '';
// https://github.com/whatwg/websockets/issues/20 reason for not using the relative URLs
const url = getURL(ENVIRONMENT.wsURL, queryId);
const socket = new WebSocket(url, token);
socket.addEventListener('message', (event) => {
try {
const parsedData = JSON.parse(event?.data);
setData(parsedData);
} catch {
setData(event?.data);
}
});
socket.addEventListener('error', (event) => {
console.error(event);
});
socket.addEventListener('close', (event) => {
// 1000 is a normal closure status code
if (event.code !== 1000) {
console.error('WebSocket closed with error:', event);
} else {
console.error('WebSocket closed normally.');
}
});
}

View File

@@ -2,7 +2,7 @@
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/no-explicit-any */
import getLocalStorageApi from 'api/browser/localstorage/get';
import loginApi from 'api/v1/login/login';
import post from 'api/v2/sessions/rotate/post';
import afterLogin from 'AppRoutes/utils';
import axios, {
AxiosError,
@@ -12,6 +12,7 @@ import axios, {
import { ENVIRONMENT } from 'constants/env';
import { Events } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryClient } from 'react-query';
import { eventEmitter } from 'utils/getEventEmitter';
import apiV1, {
@@ -26,6 +27,14 @@ import apiV1, {
import { Logout } from './utils';
const RESPONSE_TIMEOUT_THRESHOLD = 5000; // 5 seconds
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
},
},
});
const interceptorsResponse = (
value: AxiosResponse<any>,
@@ -74,19 +83,24 @@ const interceptorRejected = async (
try {
if (axios.isAxiosError(value) && value.response) {
const { response } = value;
// reject the refresh token error
if (response.status === 401 && response.config.url !== '/login') {
if (
response.status === 401 &&
// if the session rotate call errors out with 401 or the delete sessions call returns 401 then we do not retry!
response.config.url !== '/sessions/rotate' &&
!(
response.config.url === '/sessions' && response.config.method === 'delete'
)
) {
try {
const response = await loginApi({
refreshToken: getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN) || '',
const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN);
const refreshToken = getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN);
const response = await queryClient.fetchQuery({
queryFn: () => post({ refreshToken: refreshToken || '' }),
queryKey: ['/api/v2/sessions/rotate', accessToken, refreshToken],
});
afterLogin(
response.data.userId,
response.data.accessJwt,
response.data.refreshJwt,
true,
);
afterLogin(response.data.accessToken, response.data.refreshToken, true);
try {
const reResponse = await axios(
@@ -95,7 +109,7 @@ const interceptorRejected = async (
method: value.config.method,
headers: {
...value.config.headers,
Authorization: `Bearer ${response.data.accessJwt}`,
Authorization: `Bearer ${response.data.accessToken}`,
},
data: {
...JSON.parse(value.config.data || '{}'),
@@ -113,8 +127,8 @@ const interceptorRejected = async (
Logout();
}
}
// when refresh token is expired
if (response.status === 401 && response.config.url === '/login') {
if (response.status === 401 && response.config.url === '/sessions/rotate') {
Logout();
}
}

View File

@@ -3,7 +3,15 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import history from 'lib/history';
export const Logout = (): void => {
import deleteSession from './v2/sessions/delete';
export const Logout = async (): Promise<void> => {
try {
await deleteSession();
} catch (error) {
console.error(error);
}
deleteLocalStorageKey(LOCALSTORAGE.AUTH_TOKEN);
deleteLocalStorageKey(LOCALSTORAGE.IS_LOGGED_IN);
deleteLocalStorageKey(LOCALSTORAGE.IS_IDENTIFIED_USER);
@@ -14,7 +22,6 @@ export const Logout = (): void => {
deleteLocalStorageKey(LOCALSTORAGE.USER_ID);
deleteLocalStorageKey(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT);
window.dispatchEvent(new CustomEvent('LOGOUT'));
history.push(ROUTES.LOGIN);
};

View File

@@ -2,11 +2,10 @@ 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/SAML/deleteDomain';
const deleteDomain = async (props: Props): Promise<SuccessResponseV2<null>> => {
const deleteDomain = async (id: string): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete<PayloadProps>(`/domains/${props.id}`);
const response = await axios.delete<null>(`/domains/${id}`);
return {
httpStatusCode: response.status,

View File

@@ -0,0 +1,25 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { UpdatableAuthDomain } from 'types/api/v1/domains/put';
const put = async (
props: UpdatableAuthDomain,
): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put<RawSuccessResponse<null>>(
`/domains/${props.id}`,
{ config: props.config },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default put;

View File

@@ -1,12 +1,16 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { AuthDomain, PayloadProps } from 'types/api/SAML/listDomain';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
const listAllDomain = async (): Promise<SuccessResponseV2<AuthDomain[]>> => {
const listAllDomain = async (): Promise<
SuccessResponseV2<GettableAuthDomain[]>
> => {
try {
const response = await axios.get<PayloadProps>(`/domains`);
const response = await axios.get<RawSuccessResponse<GettableAuthDomain[]>>(
`/domains`,
);
return {
httpStatusCode: response.status,

View File

@@ -0,0 +1,26 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
import { PostableAuthDomain } from 'types/api/v1/domains/post';
const post = async (
props: PostableAuthDomain,
): Promise<SuccessResponseV2<GettableAuthDomain>> => {
try {
const response = await axios.post<RawSuccessResponse<GettableAuthDomain>>(
`/domains`,
props,
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default post;

View File

@@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { AuthDomain } from 'types/api/SAML/listDomain';
import { PayloadProps, Props } from 'types/api/SAML/updateDomain';
const updateDomain = async (
props: Props,
): Promise<SuccessResponseV2<AuthDomain>> => {
try {
const response = await axios.put<PayloadProps>(`/domains/${props.id}`, props);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updateDomain;

View File

@@ -2,15 +2,12 @@ import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
LoginPrecheckResponse,
PayloadProps,
Props,
} from 'types/api/user/accept';
import { PayloadProps, Props } from 'types/api/user/accept';
import { UserResponse } from 'types/api/user/getUser';
const accept = async (
props: Props,
): Promise<SuccessResponseV2<LoginPrecheckResponse>> => {
): Promise<SuccessResponseV2<UserResponse>> => {
try {
const response = await axios.post<PayloadProps>(`/invite/accept`, props);
return {

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { Props, Signup as PayloadProps } from 'types/api/user/loginPrecheck';
const loginPrecheck = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(
`/loginPrecheck?email=${encodeURIComponent(
props.email,
)}&ref=${encodeURIComponent(window.location.href)}`,
);
return {
statusCode: 200,
error: null,
message: response.statusText,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default loginPrecheck;

View File

@@ -0,0 +1,27 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { Props } from 'types/api/user/signup';
import { SignupResponse } from 'types/api/v1/register/post';
const post = async (
props: Props,
): Promise<SuccessResponseV2<SignupResponse>> => {
try {
const response = await axios.post<RawSuccessResponse<SignupResponse>>(
`/register`,
{
...props,
},
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default post;

View File

@@ -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, Signup } from 'types/api/user/loginPrecheck';
import { Props } from 'types/api/user/signup';
const signup = async (props: Props): Promise<SuccessResponseV2<Signup>> => {
try {
const response = await axios.post<PayloadProps>(`/register`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default signup;

View File

@@ -2,15 +2,11 @@ import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props, UserLoginResponse } from 'types/api/user/login';
import { PayloadProps, UserResponse } from 'types/api/user/getUser';
const login = async (
props: Props,
): Promise<SuccessResponseV2<UserLoginResponse>> => {
const get = async (): Promise<SuccessResponseV2<UserResponse>> => {
try {
const response = await axios.post<PayloadProps>(`/login`, {
...props,
});
const response = await axios.get<PayloadProps>(`/user/me`);
return {
httpStatusCode: response.status,
@@ -21,4 +17,4 @@ const login = async (
}
};
export default login;
export default get;

View File

@@ -2,20 +2,19 @@ import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { AuthDomain } from 'types/api/SAML/listDomain';
import { PayloadProps, Props } from 'types/api/SAML/postDomain';
import { Info } from 'types/api/v1/version/get';
const create = async (props: Props): Promise<SuccessResponseV2<AuthDomain>> => {
const get = async (): Promise<SuccessResponseV2<Info>> => {
try {
const response = await axios.post<PayloadProps>(`/domains`, props);
const response = await axios.get<Info>(`/version`);
return {
httpStatusCode: response.status,
data: response.data.data,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default create;
export default get;

View File

@@ -1,25 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { getVersion } from 'constants/api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/user/getVersion';
const getVersionApi = async (): Promise<
SuccessResponse<PayloadProps> | ErrorResponse
> => {
try {
const response = await axios.get(`/${getVersion}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getVersionApi;

View File

@@ -0,0 +1,27 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { Props, SessionsContext } from 'types/api/v2/sessions/context/get';
const get = async (
props: Props,
): Promise<SuccessResponseV2<SessionsContext>> => {
try {
const response = await axios.get<RawSuccessResponse<SessionsContext>>(
'/sessions/context',
{
params: props,
},
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default get;

View File

@@ -0,0 +1,19 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
const deleteSession = async (): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete<RawSuccessResponse<null>>('/sessions');
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default deleteSession;

View File

@@ -0,0 +1,23 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { Props, Token } from 'types/api/v2/sessions/email_password/post';
const post = async (props: Props): Promise<SuccessResponseV2<Token>> => {
try {
const response = await axios.post<RawSuccessResponse<Token>>(
'/sessions/email_password',
props,
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default post;

View File

@@ -0,0 +1,23 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { Props, Token } from 'types/api/v2/sessions/rotate/post';
const post = async (props: Props): Promise<SuccessResponseV2<Token>> => {
try {
const response = await axios.post<RawSuccessResponse<Token>>(
'/sessions/rotate',
props,
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default post;

View File

@@ -99,7 +99,7 @@
}
}
& :is(h1, h2, h3, h4, h5, h6, p, &-section-title) {
& :is(h1, h2, h3, h4, h5, h6, &-section-title) {
font-weight: 600;
color: var(--text-vanilla-100, #fff);
}

View File

@@ -86,7 +86,9 @@ function HeaderRightSection({
className="share-feedback-btn periscope-btn ghost"
icon={<SquarePen size={14} />}
onClick={handleOpenFeedbackModal}
/>
>
Feedback
</Button>
</Popover>
)}

View File

@@ -1,5 +1,3 @@
const SOMETHING_WENT_WRONG = 'Something went wrong';
const getVersion = 'version';
export { getVersion, SOMETHING_WENT_WRONG };
export { SOMETHING_WENT_WRONG };

View File

@@ -35,4 +35,5 @@ export enum LOCALSTORAGE {
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
}

View File

@@ -22,6 +22,7 @@ import EndPointDetails from '../Explorer/Domains/DomainDetails/EndPointDetails';
// Mock dependencies
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: jest.fn(),
}));

View File

@@ -37,6 +37,7 @@ jest.mock(
// Mock dependencies
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: jest.fn(),
}));

View File

@@ -12,8 +12,8 @@ import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
import logEvent from 'api/common/logEvent';
import manageCreditCardApi from 'api/v1/portal/create';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import getUserVersion from 'api/v1/version/get';
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
import getUserVersion from 'api/v1/version/getVersion';
import { AxiosError } from 'axios';
import cx from 'classnames';
import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
@@ -317,14 +317,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
getUserVersionResponse.isFetched &&
getUserVersionResponse.isSuccess &&
getUserVersionResponse.data &&
getUserVersionResponse.data.payload
getUserVersionResponse.data.data
) {
dispatch({
type: UPDATE_CURRENT_VERSION,
payload: {
currentVersion: getUserVersionResponse.data.payload.version,
ee: getUserVersionResponse.data.payload.ee,
setupCompleted: getUserVersionResponse.data.payload.setupCompleted,
currentVersion: getUserVersionResponse.data.data.version,
ee: getUserVersionResponse.data.data.ee,
setupCompleted: getUserVersionResponse.data.data.setupCompleted,
},
});
}

View File

@@ -25,8 +25,8 @@ function QuerySection(): JSX.Element {
const alertDef = buildAlertDefForChartPreview({ alertType, thresholdState });
const onQueryCategoryChange = (val: EQueryType): void => {
const query: Query = { ...currentQuery, queryType: val };
const onQueryCategoryChange = (queryType: EQueryType): void => {
const query: Query = { ...currentQuery, queryType };
redirectWithQueryBuilderData(query);
};

View File

@@ -2,16 +2,28 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryParams } from 'constants/query';
import {
initialClickHouseData,
initialQueryPromQLData,
} from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { CreateAlertProvider } from '../../context';
import QuerySection from '../QuerySection';
jest.mock('uuid', () => ({
v4: (): string => 'test-uuid-12345',
}));
const MOCK_UUID = 'test-uuid-12345';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
@@ -48,12 +60,27 @@ jest.mock(
queryCategory,
alertType,
panelType,
setQueryCategory,
}: any): JSX.Element {
return (
<div data-testid="query-section-component">
<div data-testid="query-category">{queryCategory}</div>
<div data-testid="alert-type">{alertType}</div>
<div data-testid="panel-type">{panelType}</div>
<button
type="button"
data-testid="change-to-promql"
onClick={(): void => setQueryCategory(EQueryType.PROM)}
>
Change to PromQL
</button>
<button
type="button"
data-testid="change-to-query-builder"
onClick={(): void => setQueryCategory(EQueryType.QUERY_BUILDER)}
>
Change to Query Builder
</button>
</div>
);
},
@@ -240,17 +267,6 @@ describe('QuerySection', () => {
expect(screen.getByTestId('panel-type')).toHaveTextContent('graph');
});
it('has correct CSS classes for tab styling', () => {
renderQuerySection();
const tabs = screen.getAllByRole('button');
tabs.forEach((tab) => {
expect(tab).toHaveClass('list-view-tab');
expect(tab).toHaveClass('explorer-view-option');
});
});
it('renders with correct container structure', () => {
renderQuerySection();
@@ -307,4 +323,172 @@ describe('QuerySection', () => {
expect(metricsButton).toHaveClass(ACTIVE_TAB_CLASS);
expect(logsButton).not.toHaveClass(ACTIVE_TAB_CLASS);
});
it('updates the query data when the alert type changes', async () => {
const user = userEvent.setup();
renderQuerySection();
const metricsTab = screen.getByText(METRICS_TEXT);
await user.click(metricsTab);
const result = mockUseQueryBuilder.redirectWithQueryBuilderData.mock.calls[0];
expect(result[0]).toEqual({
id: MOCK_UUID,
queryType: EQueryType.QUERY_BUILDER,
unit: undefined,
builder: {
queryData: [
expect.objectContaining({
dataSource: DataSource.METRICS,
queryName: 'A',
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [initialQueryPromQLData],
clickhouse_sql: [initialClickHouseData],
});
expect(result[1]).toEqual({
[QueryParams.alertType]: AlertTypes.METRICS_BASED_ALERT,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
});
});
it('updates the query data when the query type changes from query_builder to promql', async () => {
const user = userEvent.setup();
renderQuerySection();
const changeToPromQLButton = screen.getByTestId('change-to-promql');
await user.click(changeToPromQLButton);
expect(
mockUseQueryBuilder.redirectWithQueryBuilderData,
).toHaveBeenCalledTimes(1);
const [
queryArg,
] = mockUseQueryBuilder.redirectWithQueryBuilderData.mock.calls[0];
expect(queryArg).toEqual({
...mockUseQueryBuilder.currentQuery,
queryType: EQueryType.PROM,
});
expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith(
queryArg,
);
});
it('updates the query data when switching from promql to query_builder for logs', async () => {
const user = userEvent.setup();
const mockCurrentQueryWithPromQL = {
...mockUseQueryBuilder.currentQuery,
queryType: EQueryType.PROM,
builder: {
queryData: [
{
dataSource: DataSource.LOGS,
},
],
},
};
useQueryBuilder.mockReturnValue({
...mockUseQueryBuilder,
currentQuery: mockCurrentQueryWithPromQL,
});
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CreateAlertProvider initialAlertType={AlertTypes.LOGS_BASED_ALERT}>
<QuerySection />
</CreateAlertProvider>
</MemoryRouter>
</QueryClientProvider>
</Provider>,
);
const changeToQueryBuilderButton = screen.getByTestId(
'change-to-query-builder',
);
await user.click(changeToQueryBuilderButton);
expect(
mockUseQueryBuilder.redirectWithQueryBuilderData,
).toHaveBeenCalledTimes(1);
const [
queryArg,
] = mockUseQueryBuilder.redirectWithQueryBuilderData.mock.calls[0];
expect(queryArg).toEqual({
...mockCurrentQueryWithPromQL,
queryType: EQueryType.QUERY_BUILDER,
});
expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith(
queryArg,
);
});
it('updates the query data when switching from clickhouse_sql to query_builder for traces', async () => {
const user = userEvent.setup();
const mockCurrentQueryWithClickhouseSQL = {
...mockUseQueryBuilder.currentQuery,
queryType: EQueryType.CLICKHOUSE,
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
},
],
},
};
useQueryBuilder.mockReturnValue({
...mockUseQueryBuilder,
currentQuery: mockCurrentQueryWithClickhouseSQL,
});
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CreateAlertProvider initialAlertType={AlertTypes.TRACES_BASED_ALERT}>
<QuerySection />
</CreateAlertProvider>
</MemoryRouter>
</QueryClientProvider>
</Provider>,
);
const changeToQueryBuilderButton = screen.getByTestId(
'change-to-query-builder',
);
await user.click(changeToQueryBuilderButton);
expect(
mockUseQueryBuilder.redirectWithQueryBuilderData,
).toHaveBeenCalledTimes(1);
const [
queryArg,
] = mockUseQueryBuilder.redirectWithQueryBuilderData.mock.calls[0];
expect(queryArg).toEqual({
...mockCurrentQueryWithClickhouseSQL,
queryType: EQueryType.QUERY_BUILDER,
});
expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith(
queryArg,
);
});
});

View File

@@ -0,0 +1,678 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { initialQueriesMap } from 'constants/queryBuilder';
import {
alertDefaults,
anamolyAlertDefaults,
exceptionAlertDefaults,
logAlertDefaults,
traceAlertDefaults,
} from 'container/CreateAlertRule/defaults';
import dayjs from 'dayjs';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from '../constants';
import {
AdvancedOptionsState,
AlertState,
AlertThresholdMatchType,
AlertThresholdOperator,
AlertThresholdState,
Algorithm,
EvaluationWindowState,
NotificationSettingsState,
Seasonality,
TimeDuration,
} from '../types';
import {
advancedOptionsReducer,
alertCreationReducer,
alertThresholdReducer,
buildInitialAlertDef,
evaluationWindowReducer,
getInitialAlertType,
getInitialAlertTypeFromURL,
notificationSettingsReducer,
} from '../utils';
const UNKNOWN_ACTION_TYPE = 'UNKNOWN_ACTION_TYPE';
const TEST_RESET_TO_INITIAL_STATE = 'should reset to initial state';
const TEST_SET_INITIAL_STATE_FROM_PAYLOAD =
'should set initial state from payload';
const TEST_RETURN_STATE_FOR_UNKNOWN_ACTION =
'should return current state for unknown action';
describe('CreateAlertV2 Context Utils', () => {
describe('alertCreationReducer', () => {
it('should set alert name', () => {
const result = alertCreationReducer(INITIAL_ALERT_STATE, {
type: 'SET_ALERT_NAME',
payload: 'Test Alert',
});
expect(result).toEqual({
...INITIAL_ALERT_STATE,
name: 'Test Alert',
});
});
it('should set alert labels', () => {
const labels = { severity: 'critical', team: 'backend' };
const result = alertCreationReducer(INITIAL_ALERT_STATE, {
type: 'SET_ALERT_LABELS',
payload: labels,
});
expect(result).toEqual({
...INITIAL_ALERT_STATE,
labels,
});
});
it('should set y-axis unit', () => {
const result = alertCreationReducer(INITIAL_ALERT_STATE, {
type: 'SET_Y_AXIS_UNIT',
payload: 'ms',
});
expect(result).toEqual({
...INITIAL_ALERT_STATE,
yAxisUnit: 'ms',
});
});
it(TEST_RESET_TO_INITIAL_STATE, () => {
const modifiedState: AlertState = {
name: 'Modified',
labels: { test: 'value' },
yAxisUnit: 'ms',
};
const result = alertCreationReducer(modifiedState, { type: 'RESET' });
expect(result).toEqual(INITIAL_ALERT_STATE);
});
it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => {
const newState: AlertState = {
name: 'Custom Alert',
labels: { env: 'production' },
yAxisUnit: 'bytes',
};
const result = alertCreationReducer(INITIAL_ALERT_STATE, {
type: 'SET_INITIAL_STATE',
payload: newState,
});
expect(result).toEqual(newState);
});
it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => {
const result = alertCreationReducer(
INITIAL_ALERT_STATE,
{ type: UNKNOWN_ACTION_TYPE } as any,
);
expect(result).toEqual(INITIAL_ALERT_STATE);
});
});
describe('getInitialAlertType', () => {
it('should return METRICS_BASED_ALERT for metrics data source', () => {
const result = getInitialAlertType(initialQueriesMap.metrics);
expect(result).toBe(AlertTypes.METRICS_BASED_ALERT);
});
it('should return LOGS_BASED_ALERT for logs data source', () => {
const result = getInitialAlertType(initialQueriesMap.logs);
expect(result).toBe(AlertTypes.LOGS_BASED_ALERT);
});
it('should return TRACES_BASED_ALERT for traces data source', () => {
const result = getInitialAlertType(initialQueriesMap.traces);
expect(result).toBe(AlertTypes.TRACES_BASED_ALERT);
});
it('should return METRICS_BASED_ALERT for unknown data source', () => {
const queryWithUnknownDataSource = {
...initialQueriesMap.metrics,
builder: {
...initialQueriesMap.metrics.builder,
queryData: [],
},
};
const result = getInitialAlertType(queryWithUnknownDataSource);
expect(result).toBe(AlertTypes.METRICS_BASED_ALERT);
});
});
describe('buildInitialAlertDef', () => {
it('should return logAlertDefaults for LOGS_BASED_ALERT', () => {
const result = buildInitialAlertDef(AlertTypes.LOGS_BASED_ALERT);
expect(result).toBe(logAlertDefaults);
});
it('should return traceAlertDefaults for TRACES_BASED_ALERT', () => {
const result = buildInitialAlertDef(AlertTypes.TRACES_BASED_ALERT);
expect(result).toBe(traceAlertDefaults);
});
it('should return exceptionAlertDefaults for EXCEPTIONS_BASED_ALERT', () => {
const result = buildInitialAlertDef(AlertTypes.EXCEPTIONS_BASED_ALERT);
expect(result).toBe(exceptionAlertDefaults);
});
it('should return anamolyAlertDefaults for ANOMALY_BASED_ALERT', () => {
const result = buildInitialAlertDef(AlertTypes.ANOMALY_BASED_ALERT);
expect(result).toBe(anamolyAlertDefaults);
});
it('should return alertDefaults for METRICS_BASED_ALERT', () => {
const result = buildInitialAlertDef(AlertTypes.METRICS_BASED_ALERT);
expect(result).toBe(alertDefaults);
});
it('should return alertDefaults for unknown alert type', () => {
const result = buildInitialAlertDef('UNKNOWN' as AlertTypes);
expect(result).toBe(alertDefaults);
});
});
describe('getInitialAlertTypeFromURL', () => {
it('should return ANOMALY_BASED_ALERT when ruleType is anomaly_rule', () => {
const params = new URLSearchParams('?ruleType=anomaly_rule');
const result = getInitialAlertTypeFromURL(params, initialQueriesMap.metrics);
expect(result).toBe(AlertTypes.ANOMALY_BASED_ALERT);
});
it('should return alert type from alertType param', () => {
const params = new URLSearchParams('?alertType=LOGS_BASED_ALERT');
const result = getInitialAlertTypeFromURL(params, initialQueriesMap.metrics);
expect(result).toBe(AlertTypes.LOGS_BASED_ALERT);
});
it('should prioritize ruleType over alertType', () => {
const params = new URLSearchParams(
'?ruleType=anomaly_rule&alertType=LOGS_BASED_ALERT',
);
const result = getInitialAlertTypeFromURL(params, initialQueriesMap.metrics);
expect(result).toBe(AlertTypes.ANOMALY_BASED_ALERT);
});
it('should fall back to query data source when no URL params', () => {
const params = new URLSearchParams('');
const result = getInitialAlertTypeFromURL(params, initialQueriesMap.traces);
expect(result).toBe(AlertTypes.TRACES_BASED_ALERT);
});
});
describe('alertThresholdReducer', () => {
it('should set selected query', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_SELECTED_QUERY',
payload: 'B',
});
expect(result).toEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
selectedQuery: 'B',
});
});
it('should set operator', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_OPERATOR',
payload: AlertThresholdOperator.IS_BELOW,
});
expect(result).toEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
operator: AlertThresholdOperator.IS_BELOW,
});
});
it('should set match type', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_MATCH_TYPE',
payload: AlertThresholdMatchType.ALL_THE_TIME,
});
expect(result).toEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
matchType: AlertThresholdMatchType.ALL_THE_TIME,
});
});
it('should set thresholds', () => {
const newThresholds = [
{
id: '1',
label: 'critical',
thresholdValue: 100,
recoveryThresholdValue: 90,
unit: 'ms',
channels: ['channel1'],
color: '#FF0000',
},
];
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_THRESHOLDS',
payload: newThresholds,
});
expect(result).toEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
thresholds: newThresholds,
});
});
it(TEST_RESET_TO_INITIAL_STATE, () => {
const modifiedState: AlertThresholdState = {
selectedQuery: 'B',
operator: AlertThresholdOperator.IS_BELOW,
matchType: AlertThresholdMatchType.ALL_THE_TIME,
evaluationWindow: TimeDuration.TEN_MINUTES,
algorithm: Algorithm.STANDARD,
seasonality: Seasonality.DAILY,
thresholds: [],
};
const result = alertThresholdReducer(modifiedState, { type: 'RESET' });
expect(result).toEqual(INITIAL_ALERT_THRESHOLD_STATE);
});
it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => {
const newState: AlertThresholdState = {
selectedQuery: 'C',
operator: AlertThresholdOperator.IS_EQUAL_TO,
matchType: AlertThresholdMatchType.ON_AVERAGE,
evaluationWindow: TimeDuration.ONE_HOUR,
algorithm: Algorithm.STANDARD,
seasonality: Seasonality.WEEKLY,
thresholds: [],
};
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_INITIAL_STATE',
payload: newState,
});
expect(result).toEqual(newState);
});
it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => {
const result = alertThresholdReducer(
INITIAL_ALERT_THRESHOLD_STATE,
{ type: UNKNOWN_ACTION_TYPE } as any,
);
expect(result).toEqual(INITIAL_ALERT_THRESHOLD_STATE);
});
});
describe('advancedOptionsReducer', () => {
it('should set send notification if data is missing', () => {
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: { toleranceLimit: 21, timeUnit: UniversalYAxisUnit.HOURS },
});
expect(result).toEqual({
...INITIAL_ADVANCED_OPTIONS_STATE,
sendNotificationIfDataIsMissing: {
...INITIAL_ADVANCED_OPTIONS_STATE.sendNotificationIfDataIsMissing,
toleranceLimit: 21,
timeUnit: UniversalYAxisUnit.HOURS,
},
});
});
it('should toggle send notification if data is missing', () => {
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: true,
});
expect(result).toEqual({
...INITIAL_ADVANCED_OPTIONS_STATE,
sendNotificationIfDataIsMissing: {
...INITIAL_ADVANCED_OPTIONS_STATE.sendNotificationIfDataIsMissing,
enabled: true,
},
});
});
it('should set enforce minimum datapoints', () => {
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
payload: { minimumDatapoints: 10 },
});
expect(result).toEqual({
...INITIAL_ADVANCED_OPTIONS_STATE,
enforceMinimumDatapoints: {
...INITIAL_ADVANCED_OPTIONS_STATE.enforceMinimumDatapoints,
minimumDatapoints: 10,
},
});
});
it('should toggle enforce minimum datapoints', () => {
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS',
payload: true,
});
expect(result).toEqual({
...INITIAL_ADVANCED_OPTIONS_STATE,
enforceMinimumDatapoints: {
...INITIAL_ADVANCED_OPTIONS_STATE.enforceMinimumDatapoints,
enabled: true,
},
});
});
it('should set delay evaluation', () => {
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
type: 'SET_DELAY_EVALUATION',
payload: { delay: 10, timeUnit: UniversalYAxisUnit.HOURS },
});
expect(result).toEqual({
...INITIAL_ADVANCED_OPTIONS_STATE,
delayEvaluation: { delay: 10, timeUnit: UniversalYAxisUnit.HOURS },
});
});
it('should set evaluation cadence', () => {
const newCadence = {
default: { value: 5, timeUnit: UniversalYAxisUnit.HOURS },
custom: {
repeatEvery: 'week',
startAt: '12:00:00',
timezone: 'America/New_York',
occurence: ['Monday', 'Friday'],
},
rrule: { date: dayjs(), startAt: '10:00:00', rrule: 'test-rrule' },
};
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
type: 'SET_EVALUATION_CADENCE',
payload: newCadence,
});
expect(result).toEqual({
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
...newCadence,
},
});
});
it('should set evaluation cadence mode', () => {
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
type: 'SET_EVALUATION_CADENCE_MODE',
payload: 'custom',
});
expect(result).toEqual({
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
},
});
});
it(TEST_RESET_TO_INITIAL_STATE, () => {
const modifiedState: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
delayEvaluation: { delay: 10, timeUnit: UniversalYAxisUnit.HOURS },
};
const result = advancedOptionsReducer(modifiedState, { type: 'RESET' });
expect(result).toEqual(INITIAL_ADVANCED_OPTIONS_STATE);
});
it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => {
const newState: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
sendNotificationIfDataIsMissing: {
toleranceLimit: 45,
timeUnit: UniversalYAxisUnit.SECONDS,
enabled: true,
},
};
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
type: 'SET_INITIAL_STATE',
payload: newState,
});
expect(result).toEqual(newState);
});
it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => {
const result = advancedOptionsReducer(
INITIAL_ADVANCED_OPTIONS_STATE,
{ type: UNKNOWN_ACTION_TYPE } as any,
);
expect(result).toEqual(INITIAL_ADVANCED_OPTIONS_STATE);
});
});
describe('evaluationWindowReducer', () => {
it('should set window type to rolling and reset timeframe', () => {
const modifiedState: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'cumulative',
timeframe: 'currentHour',
};
const result = evaluationWindowReducer(modifiedState, {
type: 'SET_WINDOW_TYPE',
payload: 'rolling',
});
expect(result).toEqual({
windowType: 'rolling',
timeframe: INITIAL_EVALUATION_WINDOW_STATE.timeframe,
startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt,
});
});
it('should set window type to cumulative and set timeframe to currentHour', () => {
const result = evaluationWindowReducer(INITIAL_EVALUATION_WINDOW_STATE, {
type: 'SET_WINDOW_TYPE',
payload: 'cumulative',
});
expect(result).toEqual({
windowType: 'cumulative',
timeframe: 'currentHour',
startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt,
});
});
it('should set timeframe', () => {
const result = evaluationWindowReducer(INITIAL_EVALUATION_WINDOW_STATE, {
type: 'SET_TIMEFRAME',
payload: '10m0s',
});
expect(result).toEqual({
...INITIAL_EVALUATION_WINDOW_STATE,
timeframe: '10m0s',
});
});
it('should set starting at', () => {
const newStartingAt = {
time: '14:30:00',
number: '5',
timezone: 'Europe/London',
unit: UniversalYAxisUnit.HOURS,
};
const result = evaluationWindowReducer(INITIAL_EVALUATION_WINDOW_STATE, {
type: 'SET_STARTING_AT',
payload: newStartingAt,
});
expect(result).toEqual({
...INITIAL_EVALUATION_WINDOW_STATE,
startingAt: newStartingAt,
});
});
it(TEST_RESET_TO_INITIAL_STATE, () => {
const modifiedState: EvaluationWindowState = {
windowType: 'cumulative',
timeframe: 'currentHour',
startingAt: {
time: '12:00:00',
number: '2',
timezone: 'America/New_York',
unit: UniversalYAxisUnit.HOURS,
},
};
const result = evaluationWindowReducer(modifiedState, { type: 'RESET' });
expect(result).toEqual(INITIAL_EVALUATION_WINDOW_STATE);
});
it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => {
const newState: EvaluationWindowState = {
windowType: 'cumulative',
timeframe: 'currentDay',
startingAt: {
time: '09:00:00',
number: '3',
timezone: 'Asia/Tokyo',
unit: UniversalYAxisUnit.HOURS,
},
};
const result = evaluationWindowReducer(INITIAL_EVALUATION_WINDOW_STATE, {
type: 'SET_INITIAL_STATE',
payload: newState,
});
expect(result).toEqual(newState);
});
it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => {
const result = evaluationWindowReducer(
INITIAL_EVALUATION_WINDOW_STATE,
{ type: UNKNOWN_ACTION_TYPE } as any,
);
expect(result).toEqual(INITIAL_EVALUATION_WINDOW_STATE);
});
});
describe('notificationSettingsReducer', () => {
it('should set multiple notifications', () => {
const notifications = ['channel1', 'channel2', 'channel3'];
const result = notificationSettingsReducer(
INITIAL_NOTIFICATION_SETTINGS_STATE,
{
type: 'SET_MULTIPLE_NOTIFICATIONS',
payload: notifications,
},
);
expect(result).toEqual({
...INITIAL_NOTIFICATION_SETTINGS_STATE,
multipleNotifications: notifications,
});
});
it('should set multiple notifications to null', () => {
const modifiedState = {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
multipleNotifications: ['channel1', 'channel2'],
};
const result = notificationSettingsReducer(modifiedState, {
type: 'SET_MULTIPLE_NOTIFICATIONS',
payload: null,
});
expect(result).toEqual({
...modifiedState,
multipleNotifications: null,
});
});
it('should set re-notification', () => {
const reNotification = {
enabled: true,
value: 60,
unit: UniversalYAxisUnit.HOURS,
conditions: ['firing' as const, 'nodata' as const],
};
const result = notificationSettingsReducer(
INITIAL_NOTIFICATION_SETTINGS_STATE,
{
type: 'SET_RE_NOTIFICATION',
payload: reNotification,
},
);
expect(result).toEqual({
...INITIAL_NOTIFICATION_SETTINGS_STATE,
reNotification,
});
});
it('should set description', () => {
const description = 'Custom alert description with {{$value}}';
const result = notificationSettingsReducer(
INITIAL_NOTIFICATION_SETTINGS_STATE,
{
type: 'SET_DESCRIPTION',
payload: description,
},
);
expect(result).toEqual({
...INITIAL_NOTIFICATION_SETTINGS_STATE,
description,
});
});
it('should set routing policies', () => {
const result = notificationSettingsReducer(
INITIAL_NOTIFICATION_SETTINGS_STATE,
{
type: 'SET_ROUTING_POLICIES',
payload: true,
},
);
expect(result).toEqual({
...INITIAL_NOTIFICATION_SETTINGS_STATE,
routingPolicies: true,
});
});
it(TEST_RESET_TO_INITIAL_STATE, () => {
const modifiedState: NotificationSettingsState = {
multipleNotifications: ['channel1'],
reNotification: {
enabled: true,
value: 120,
unit: UniversalYAxisUnit.HOURS,
conditions: ['firing'],
},
description: 'Modified description',
routingPolicies: true,
};
const result = notificationSettingsReducer(modifiedState, {
type: 'RESET',
});
expect(result).toEqual(INITIAL_NOTIFICATION_SETTINGS_STATE);
});
it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => {
const newState: NotificationSettingsState = {
multipleNotifications: ['channel4', 'channel5'],
reNotification: {
enabled: true,
value: 90,
unit: UniversalYAxisUnit.MINUTES,
conditions: ['nodata'],
},
description: 'New description',
routingPolicies: true,
};
const result = notificationSettingsReducer(
INITIAL_NOTIFICATION_SETTINGS_STATE,
{
type: 'SET_INITIAL_STATE',
payload: newState,
},
);
expect(result).toEqual(newState);
});
it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => {
const result = notificationSettingsReducer(
INITIAL_NOTIFICATION_SETTINGS_STATE,
{ type: UNKNOWN_ACTION_TYPE } as any,
);
expect(result).toEqual(INITIAL_NOTIFICATION_SETTINGS_STATE);
});
});
});

View File

@@ -51,7 +51,13 @@ export const useCreateAlertState = (): ICreateAlertContextProps => {
export function CreateAlertProvider(
props: ICreateAlertProviderProps,
): JSX.Element {
const { children, initialAlertState, isEditMode, ruleId } = props;
const {
children,
initialAlertState,
isEditMode,
ruleId,
initialAlertType,
} = props;
const [alertState, setAlertState] = useReducer(
alertCreationReducer,
@@ -62,9 +68,12 @@ export function CreateAlertProvider(
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const [alertType, setAlertType] = useState<AlertTypes>(() =>
getInitialAlertTypeFromURL(queryParams, currentQuery),
);
const [alertType, setAlertType] = useState<AlertTypes>(() => {
if (isEditMode) {
return initialAlertType;
}
return getInitialAlertTypeFromURL(queryParams, currentQuery);
});
const handleAlertTypeChange = useCallback(
(value: AlertTypes): void => {

View File

@@ -62,7 +62,7 @@ export const alertCreationReducer = (
export function getInitialAlertType(currentQuery: Query): AlertTypes {
const dataSource =
currentQuery.builder.queryData[0].dataSource || DataSource.METRICS;
currentQuery.builder.queryData?.[0]?.dataSource || DataSource.METRICS;
switch (dataSource) {
case DataSource.METRICS:
return AlertTypes.METRICS_BASED_ALERT;

View File

@@ -0,0 +1,16 @@
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export function sanitizeDefaultAlertQuery(
query: Query,
alertType: AlertTypes,
): Query {
// If there are no queries, add a default one based on the alert type
if (query.builder.queryData.length === 0) {
const dataSource = ALERTS_DATA_SOURCE_MAP[alertType];
query.builder.queryData.push(initialQueryBuilderFormValuesMap[dataSource]);
}
return query;
}

View File

@@ -23,6 +23,7 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
const mockUseQuery = jest.fn();
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQuery: (queryKey: any, queryFn: any, options: any): any =>
mockUseQuery(queryKey, queryFn, options),
}));

View File

@@ -52,6 +52,7 @@ jest.mock('container/InfraMonitoringK8s/commonUtils', () => ({
const mockUseQueries = jest.fn();
const mockUseQuery = jest.fn();
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: (queryConfigs: any[]): any[] => mockUseQueries(queryConfigs),
useQuery: (config: any): any => mockUseQuery(config),
}));

View File

@@ -22,6 +22,7 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
const mockUseQuery = jest.fn();
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQuery: (queryKey: any, queryFn: any, options: any): any =>
mockUseQuery(queryKey, queryFn, options),
}));

View File

@@ -23,6 +23,7 @@ import LabelColumn from 'components/TableRenderer/LabelColumn';
import TextToolTip from 'components/TextToolTip';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
import useSortableTable from 'hooks/ResizeTable/useSortableTable';
import useComponentPermission from 'hooks/useComponentPermission';
import useDebouncedFn from 'hooks/useDebouncedFunction';
@@ -36,6 +37,7 @@ import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UseQueryResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { GettableAlert } from 'types/api/alerts/get';
import DeleteAlert from './DeleteAlert';
@@ -141,7 +143,10 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
];
const onEditHandler = (record: GettableAlert, openInNewTab: boolean): void => {
const compositeQuery = mapQueryDataFromApi(record.condition.compositeQuery);
const compositeQuery = sanitizeDefaultAlertQuery(
mapQueryDataFromApi(record.condition.compositeQuery),
record.alertType as AlertTypes,
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),

View File

@@ -1,118 +1,830 @@
import Login from 'container/Login';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
/* eslint-disable sonarjs/no-identical-functions */
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { ErrorV2 } from 'types/api';
import { Info } from 'types/api/v1/version/get';
import { SessionsContext } from 'types/api/v2/sessions/context/get';
import { Token } from 'types/api/v2/sessions/email_password/post';
const errorNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
import Login from '../index';
const VERSION_ENDPOINT = '*/api/v1/version';
const SESSIONS_CONTEXT_ENDPOINT = '*/api/v2/sessions/context';
const CALLBACK_AUTHN_ORG = 'callback_authn_org';
const CALLBACK_AUTHN_URL = 'https://sso.example.com/auth';
const PASSWORD_AUTHN_ORG = 'password_authn_org';
const PASSWORD_AUTHN_EMAIL = 'jest.test@signoz.io';
jest.mock('lib/history', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
error: errorNotification,
default: {
push: jest.fn(),
location: {
search: '',
},
})),
},
}));
describe('Login Flow', () => {
test('Login form is rendered correctly', async () => {
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="" />);
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push
>;
// Check for the main description
expect(
screen.getByText(
'Sign in to monitor, trace, and troubleshoot your applications effortlessly.',
),
).toBeInTheDocument();
// Mock data
const mockVersionSetupCompleted: Info = {
setupCompleted: true,
ee: 'Y',
version: '0.25.0',
};
// Email input
const emailInput = screen.getByTestId('email');
expect(emailInput).toBeInTheDocument();
expect(emailInput).toHaveAttribute('type', 'email');
const mockVersionSetupIncomplete: Info = {
setupCompleted: false,
ee: 'Y',
version: '0.25.0',
};
// Next button
const nextButton = screen.getByRole('button', { name: /next/i });
expect(nextButton).toBeInTheDocument();
const mockSingleOrgPasswordAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Test Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
],
};
// No account prompt (default: canSelfRegister is false)
expect(
screen.getByText(
"Don't have an account? Contact your admin to send you an invite link.",
),
).toBeInTheDocument();
});
const mockSingleOrgCallbackAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Test Organization',
authNSupport: {
password: [],
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
},
},
],
};
test('Display error if email is not provided', async () => {
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="" />);
const mockMultiOrgMixedAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: PASSWORD_AUTHN_ORG,
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
{
id: 'org-2',
name: CALLBACK_AUTHN_ORG,
authNSupport: {
password: [],
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
},
},
],
};
const nextButton = screen.getByRole('button', { name: /next/i });
fireEvent.click(nextButton);
const mockOrgWithWarning: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Warning Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
warning: {
code: 'ORG_WARNING',
message: 'Organization has limited access',
url: 'https://example.com/warning',
errors: [{ message: 'Contact admin for full access' }],
} as ErrorV2,
},
],
};
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Please enter a valid email address',
}),
);
});
const mockEmailPasswordResponse: Token = {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
};
test('Display error if invalid email is provided and next clicked', async () => {
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="" />);
describe('Login Component', () => {
beforeEach(() => {
jest.clearAllMocks();
const emailInput = screen.getByTestId('email');
fireEvent.change(emailInput, {
target: { value: 'failEmail@signoz.io' },
});
const nextButton = screen.getByRole('button', { name: /next/i });
fireEvent.click(nextButton);
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message:
'Invalid configuration detected, please contact your administrator',
}),
);
});
test('providing shaheer@signoz.io as email and pressing next, should make the Login with SSO button visible', async () => {
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="" />);
act(() => {
fireEvent.change(screen.getByTestId('email'), {
target: { value: 'shaheer@signoz.io' },
});
fireEvent.click(screen.getByTestId('initiate_login'));
});
await waitFor(() => {
expect(screen.getByText(/login with sso/i)).toBeInTheDocument();
});
});
test('Display email, password, forgot password if password=Y', () => {
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="Y" />);
const emailInput = screen.getByTestId('email');
expect(emailInput).toBeInTheDocument();
const passwordInput = screen.getByTestId('password');
expect(passwordInput).toBeInTheDocument();
const forgotPasswordLink = screen.getByText('Forgot password?');
expect(forgotPasswordLink).toBeInTheDocument();
});
test('Display tooltip with correct message if forgot password is hovered while password=Y', async () => {
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="Y" />);
const forgotPasswordLink = screen.getByText('Forgot password?');
act(() => {
fireEvent.mouseOver(forgotPasswordLink);
});
await waitFor(() => {
// Tooltip text is static in the new UI
expect(
screen.getByText(
'Ask your admin to reset your password and send you a new invite link',
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
describe('Initial Render', () => {
it('renders login form with email input and next button', () => {
const { getByTestId, getByPlaceholderText } = render(<Login />);
expect(
screen.getByText(/sign in to monitor, trace, and troubleshoot/i),
).toBeInTheDocument();
expect(getByTestId('email')).toBeInTheDocument();
expect(getByTestId('initiate_login')).toBeInTheDocument();
expect(getByPlaceholderText('name@yourcompany.com')).toBeInTheDocument();
});
it('shows loading state when version data is being fetched', () => {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.delay(100),
ctx.status(200),
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
),
),
);
const { getByTestId } = render(<Login />);
expect(getByTestId('initiate_login')).toBeDisabled();
});
});
describe('Setup Check', () => {
it('redirects to signup when setup is not completed', async () => {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: mockVersionSetupIncomplete, status: 'success' }),
),
),
);
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.SIGN_UP);
});
});
it('stays on login page when setup is completed', async () => {
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).not.toHaveBeenCalled();
});
});
it('handles version API error gracefully', async () => {
server.use(
rest.get(VERSION_ENDPOINT, (req, res, ctx) =>
res(ctx.status(500), ctx.json({ error: 'Server error' })),
),
);
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).not.toHaveBeenCalled();
});
});
});
describe('Session Context Fetching', () => {
it('fetches session context on next button click and enables password', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgPasswordAuth }),
),
),
);
const { getByTestId } = render(<Login />);
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
expect(getByTestId('password')).toBeInTheDocument();
});
});
it('handles session context API errors', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(500),
ctx.json({
error: {
code: 'internal_server',
message: 'couldnt fetch the sessions context',
url: '',
},
}),
),
),
);
const { getByTestId, getByText } = render(<Login />);
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
expect(getByText('couldnt fetch the sessions context')).toBeInTheDocument();
});
});
it('auto-selects organization when only one exists', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgPasswordAuth }),
),
),
);
const { getByTestId } = render(<Login />);
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
// Should show password field directly (no org selection needed)
expect(getByTestId('password')).toBeInTheDocument();
expect(screen.queryByText(/organization name/i)).not.toBeInTheDocument();
});
});
});
describe('Organization Selection', () => {
it('shows organization dropdown when multiple orgs exist', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockMultiOrgMixedAuth }),
),
),
);
const { getByTestId, getByText } = render(<Login />);
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
expect(getByText('Organization Name')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
// Click on the dropdown to reveal the options
await user.click(screen.getByRole('combobox'));
await waitFor(() => {
expect(screen.getByText(PASSWORD_AUTHN_ORG)).toBeInTheDocument();
expect(screen.getByText(CALLBACK_AUTHN_ORG)).toBeInTheDocument();
});
});
it('updates selected organization on dropdown change', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
),
);
render(<Login />);
const emailInput = screen.getByTestId('email');
const nextButton = screen.getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
// Select CALLBACK_AUTHN_ORG
await user.click(screen.getByRole('combobox'));
await user.click(screen.getByText(CALLBACK_AUTHN_ORG));
await waitFor(() => {
expect(
screen.getByRole('button', { name: /login with callback/i }),
).toBeInTheDocument();
});
});
});
describe('Password Authentication', () => {
it('shows password field when password auth is supported', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgPasswordAuth }),
),
),
);
const { getByTestId, getByText } = render(<Login />);
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
expect(getByTestId('password')).toBeInTheDocument();
expect(getByText(/forgot password/i)).toBeInTheDocument();
expect(getByTestId('password_authn_submit')).toBeInTheDocument();
});
});
it('enables password auth when URL parameter password=Y', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgCallbackAuth }),
),
),
);
const { getByTestId } = render(<Login />, undefined, {
initialRoute: '/login?password=Y',
});
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
// Should show password field even for SSO org due to password=Y override
expect(getByTestId('password')).toBeInTheDocument();
});
});
});
describe('Callback Authentication', () => {
it('shows callback login button when callback auth is supported', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgCallbackAuth }),
),
),
);
const { getByTestId, queryByTestId } = render(<Login />);
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
expect(getByTestId('callback_authn_submit')).toBeInTheDocument();
expect(queryByTestId('password')).not.toBeInTheDocument();
});
});
it('redirects to callback URL on button click', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Mock window.location.href
const mockLocation = {
href: 'http://localhost/',
};
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
});
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgCallbackAuth }),
),
),
);
const { getByTestId, queryByTestId } = render(<Login />);
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
expect(getByTestId('callback_authn_submit')).toBeInTheDocument();
expect(queryByTestId('password')).not.toBeInTheDocument();
});
const callbackButton = getByTestId('callback_authn_submit');
await user.click(callbackButton);
// Check that window.location.href was set to the callback URL
await waitFor(() => {
expect(window.location.href).toBe(CALLBACK_AUTHN_URL);
});
});
});
describe('Password Authentication Execution', () => {
it('calls email/password API with correct parameters', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgPasswordAuth }),
),
),
rest.post('*/api/v2/sessions/email_password', async (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockEmailPasswordResponse }),
),
),
);
const { getByTestId } = render(<Login />);
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
expect(getByTestId('password')).toBeInTheDocument();
});
const passwordInput = getByTestId('password');
const loginButton = getByTestId('password_authn_submit');
await user.type(passwordInput, 'testpassword');
await user.click(loginButton);
// do not test for the request paramters here. Reference: https://mswjs.io/docs/best-practices/avoid-request-assertions
// rather test for the effects of the request
await waitFor(() => {
expect(localStorage.getItem('AUTH_TOKEN')).toBe('mock-access-token');
});
});
it('shows error modal on authentication failure', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgPasswordAuth }),
),
),
rest.post('*/api/v2/sessions/email_password', (_, res, ctx) =>
res(
ctx.status(401),
ctx.json({
error: {
code: 'invalid_input',
message: 'invalid password',
url: '',
},
}),
),
),
);
const { getByTestId, getByText } = render(<Login />);
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
expect(getByTestId('password')).toBeInTheDocument();
});
const passwordInput = getByTestId('password');
const loginButton = getByTestId('password_authn_submit');
await user.type(passwordInput, 'wrongpassword');
await user.click(loginButton);
await waitFor(() => {
expect(getByText('invalid password')).toBeInTheDocument();
});
});
});
describe('URL Parameter Handling', () => {
it('calls afterLogin when accessToken and refreshToken are in URL', async () => {
render(<Login />, undefined, {
initialRoute: '/login?accessToken=test-token&refreshToken=test-refresh',
});
await waitFor(() => {
expect(localStorage.getItem('AUTH_TOKEN')).toBe('test-token');
expect(localStorage.getItem('REFRESH_AUTH_TOKEN')).toBe('test-refresh');
});
});
it('shows error modal when callbackauthnerr parameter exists', async () => {
const { getByText } = render(<Login />, undefined, {
initialRoute:
'/login?callbackauthnerr=true&code=AUTH_ERROR&message=Authentication failed&url=https://example.com/error&errors=[{"message":"Invalid token"}]',
});
await waitFor(() => {
expect(getByText('AUTH_ERROR')).toBeInTheDocument();
});
});
it('handles malformed error JSON gracefully', async () => {
const { queryByText, getByText } = render(<Login />, undefined, {
initialRoute:
'/login?callbackauthnerr=true&code=AUTH_ERROR&message=Authentication failed&errors=invalid-json',
});
await waitFor(() => {
expect(queryByText('invalid-json')).not.toBeInTheDocument();
expect(getByText('AUTH_ERROR')).toBeInTheDocument();
});
});
});
describe('Session Organization Warnings', () => {
it('shows warning modal when org has warning', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockOrgWithWarning }),
),
),
);
render(<Login />);
const emailInput = screen.getByTestId('email');
const nextButton = screen.getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
expect(
screen.getByText(/organization has limited access/i),
).toBeInTheDocument();
});
});
it('shows warning modal when a warning org is selected among multiple orgs', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Mock multiple orgs including one with a warning
const mockMultiOrgWithWarning = {
orgs: [
{ id: 'org1', name: 'Org 1' },
{
id: 'org2',
name: 'Org 2',
warning: {
code: 'ORG_WARNING',
message: 'Organization has limited access',
url: 'https://example.com/warning',
errors: [{ message: 'Contact admin for full access' }],
} as ErrorV2,
},
],
};
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockMultiOrgWithWarning }),
),
),
);
const { getByTestId } = render(<Login />);
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
// Select the organization with a warning
await user.click(screen.getByRole('combobox'));
await user.click(screen.getByText('Org 2'));
await waitFor(() => {
expect(
screen.getByText(/organization has limited access/i),
).toBeInTheDocument();
});
});
});
describe('Form State Management', () => {
it('disables form fields during loading states', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(
ctx.delay(100),
ctx.status(200),
ctx.json({ data: mockSingleOrgPasswordAuth }),
),
),
);
render(<Login />);
const emailInput = screen.getByTestId('email');
const nextButton = screen.getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
// Button should be disabled during API call
expect(nextButton).toBeDisabled();
});
it('shows correct button text for each auth method', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
);
render(<Login />);
// Initially shows "Next" button
expect(screen.getByTestId('initiate_login')).toBeInTheDocument();
const emailInput = screen.getByTestId('email');
const nextButton = screen.getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
// Should show "Login" button for password auth
expect(screen.getByTestId('password_authn_submit')).toBeInTheDocument();
expect(screen.queryByTestId('initiate_login')).not.toBeInTheDocument();
});
});
});
describe('Edge Cases', () => {
it('handles user with no organizations', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockNoOrgs: SessionsContext = {
exists: false,
orgs: [],
};
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockNoOrgs })),
),
);
render(<Login />);
const emailInput = screen.getByTestId('email');
const nextButton = screen.getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
// Should not show any auth method buttons
expect(
screen.queryByTestId('password_authn_submit'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('callback_authn_submit'),
).not.toBeInTheDocument();
});
});
it('handles organization with no auth support', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockNoAuthSupport: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'No Auth Organization',
authNSupport: {
password: [],
callback: [],
},
},
],
};
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockNoAuthSupport })),
),
);
render(<Login />);
const emailInput = screen.getByTestId('email');
const nextButton = screen.getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
await user.click(nextButton);
await waitFor(() => {
// Should not show any auth method buttons
expect(
screen.queryByTestId('password_authn_submit'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('callback_authn_submit'),
).not.toBeInTheDocument();
});
});
});
});

View File

@@ -1,213 +1,255 @@
import './Login.styles.scss';
import { Button, Form, Input, Space, Tooltip, Typography } from 'antd';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import loginApi from 'api/v1/login/login';
import loginPrecheckApi from 'api/v1/login/loginPrecheck';
import getUserVersion from 'api/v1/version/getVersion';
import { Button, Form, Input, Select, Space, Tooltip, Typography } from 'antd';
import getVersion from 'api/v1/version/get';
import get from 'api/v2/sessions/context/get';
import post from 'api/v2/sessions/email_password/post';
import afterLogin from 'AppRoutes/utils';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { ArrowRight } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useEffect, useState } from 'react';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { ErrorV2 } from 'types/api';
import APIError from 'types/api/error';
import { Signup as PrecheckResultType } from 'types/api/user/loginPrecheck';
import { SessionsContext } from 'types/api/v2/sessions/context/get';
import { FormContainer, Label, ParentContainer } from './styles';
interface LoginProps {
jwt: string;
refreshjwt: string;
userId: string;
ssoerror: string;
withPassword: string;
function parseErrors(errors: string): { message: string }[] {
try {
const parsedErrors = JSON.parse(errors);
return parsedErrors.map((error: { message: string }) => ({
message: error.message,
}));
} catch (e) {
console.error('Failed to parse errors:', e);
return [];
}
}
type FormValues = { email: string; password: string };
type FormValues = {
email: string;
password: string;
orgId: string;
url: string;
};
function Login({
jwt,
refreshjwt,
userId,
ssoerror = '',
withPassword = '0',
}: LoginProps): JSX.Element {
const [isLoading, setIsLoading] = useState<boolean>(false);
const { user } = useAppContext();
function Login(): JSX.Element {
const urlQueryParams = useUrlQuery();
// override for callbackAuthN in case of some misconfiguration
const isPasswordAuthNEnabled = (urlQueryParams.get('password') || 'N') === 'Y';
const [precheckResult, setPrecheckResult] = useState<PrecheckResultType>({
sso: false,
ssoUrl: '',
canSelfRegister: false,
isUser: true,
});
// callbackAuthN handling
const accessToken = urlQueryParams.get('accessToken') || '';
const refreshToken = urlQueryParams.get('refreshToken') || '';
const [precheckInProcess, setPrecheckInProcess] = useState(false);
const [precheckComplete, setPrecheckComplete] = useState(false);
// callbackAuthN error handling
const callbackAuthError = urlQueryParams.get('callbackauthnerr') || '';
const callbackAuthErrorCode = urlQueryParams.get('code') || '';
const callbackAuthErrorMessage = urlQueryParams.get('message') || '';
const callbackAuthErrorURL = urlQueryParams.get('url') || '';
const callbackAuthErrorAdditional = urlQueryParams.get('errors') || '';
const { notifications } = useNotifications();
const [sessionsContext, setSessionsContext] = useState<SessionsContext>();
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [sessionsOrgId, setSessionsOrgId] = useState<string>('');
const [
sessionsContextLoading,
setIsLoadingSessionsContext,
] = useState<boolean>(false);
const [form] = Form.useForm<FormValues>();
const { showErrorModal } = useErrorModal();
const getUserVersionResponse = useQuery({
queryFn: getUserVersion,
queryKey: ['getUserVersion', user?.accessJwt],
// setupCompleted information to route to signup page in case setup is incomplete
const {
data: versionData,
isLoading: versionLoading,
error: versionError,
} = useQuery({
queryFn: getVersion,
queryKey: ['api/v1/version/get'],
enabled: true,
});
// in case of error do not route to signup page as it may lead to double registration
useEffect(() => {
if (
getUserVersionResponse.isFetched &&
getUserVersionResponse.data &&
getUserVersionResponse.data.payload
versionData &&
!versionLoading &&
!versionError &&
!versionData.data.setupCompleted
) {
const { setupCompleted } = getUserVersionResponse.data.payload;
if (!setupCompleted) {
// no org account registered yet, re-route user to sign up first
history.push(ROUTES.SIGN_UP);
}
history.push(ROUTES.SIGN_UP);
}
}, [getUserVersionResponse]);
const [form] = Form.useForm<FormValues>();
useEffect(() => {
if (withPassword === 'Y') {
setPrecheckComplete(true);
}
}, [withPassword]);
useEffect(() => {
async function processJwt(): Promise<void> {
if (jwt && jwt !== '') {
setIsLoading(true);
await afterLogin(userId, jwt, refreshjwt);
setIsLoading(false);
const fromPathname = getLocalStorageApi(
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
);
if (fromPathname) {
history.push(fromPathname);
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
} else {
history.push(ROUTES.APPLICATION);
}
}
}
processJwt();
}, [jwt, refreshjwt, userId]);
useEffect(() => {
if (ssoerror !== '') {
notifications.error({
message: 'sorry, failed to login',
});
}
}, [ssoerror, notifications]);
}, [versionData, versionLoading, versionError]);
// fetch the sessions context post user entering the email
const onNextHandler = async (): Promise<void> => {
const email = form.getFieldValue('email');
if (!email) {
notifications.error({
message: 'Please enter a valid email address',
});
return;
}
setPrecheckInProcess(true);
setIsLoadingSessionsContext(true);
try {
const response = await loginPrecheckApi({
const sessionsContextResponse = await get({
email,
ref: window.location.href,
});
if (response.statusCode === 200) {
setPrecheckResult({ ...precheckResult, ...response.payload });
const { isUser } = response.payload;
if (isUser) {
setPrecheckComplete(true);
} else {
notifications.error({
message:
'This account does not exist. To create a new account, contact your admin to get an invite link',
});
}
} else {
notifications.error({
message:
'Invalid configuration detected, please contact your administrator',
});
setSessionsContext(sessionsContextResponse.data);
if (sessionsContextResponse.data.orgs.length === 1) {
setSessionsOrgId(sessionsContextResponse.data.orgs[0].id);
}
} catch (e) {
console.log('failed to call precheck Api', e);
notifications.error({ message: 'Sorry, something went wrong' });
}
setPrecheckInProcess(false);
};
const { sso, canSelfRegister } = precheckResult;
const onSubmitHandler: () => Promise<void> = async () => {
try {
const { email, password } = form.getFieldsValue();
if (!precheckComplete) {
onNextHandler();
return;
}
if (precheckComplete && sso) {
window.location.href = precheckResult.ssoUrl || '';
return;
}
setIsLoading(true);
const response = await loginApi({
email,
password,
});
afterLogin(
response.data.userId,
response.data.accessJwt,
response.data.refreshJwt,
);
setIsLoading(false);
} catch (error) {
setIsLoading(false);
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
showErrorModal(error as APIError);
}
setIsLoadingSessionsContext(false);
};
const renderSAMLAction = (): JSX.Element => (
<Button
type="primary"
loading={isLoading}
disabled={isLoading}
href={precheckResult.ssoUrl}
>
Login with SSO
</Button>
);
// post selection of email and session org decide on the authN mechanism to use
const isPasswordAuthN = useMemo((): boolean => {
if (!sessionsContext) {
return false;
}
const renderOnSsoError = (): JSX.Element | null => {
if (!ssoerror) {
if (!sessionsOrgId) {
return false;
}
let isPasswordAuthN = false;
sessionsContext.orgs.forEach((orgSession) => {
if (
orgSession.id === sessionsOrgId &&
orgSession.authNSupport?.password?.length > 0
) {
isPasswordAuthN = true;
}
});
return isPasswordAuthN || isPasswordAuthNEnabled;
}, [sessionsContext, sessionsOrgId, isPasswordAuthNEnabled]);
const isCallbackAuthN = useMemo((): boolean => {
if (!sessionsContext) {
return false;
}
if (!sessionsOrgId) {
return false;
}
let isCallbackAuthN = false;
sessionsContext.orgs.forEach((orgSession) => {
if (
orgSession.id === sessionsOrgId &&
orgSession.authNSupport?.callback?.length > 0
) {
isCallbackAuthN = true;
form.setFieldValue('url', orgSession.authNSupport.callback[0].url);
}
});
return isCallbackAuthN && !isPasswordAuthNEnabled;
}, [sessionsContext, sessionsOrgId, isPasswordAuthNEnabled, form]);
const sessionsOrgWarning = useMemo((): ErrorV2 | null => {
if (!sessionsContext) {
return null;
}
return (
<Typography.Paragraph italic style={{ color: '#ACACAC' }}>
Are you trying to resolve SSO configuration issue?{' '}
<a href="/login?password=Y">Login with password</a>.
</Typography.Paragraph>
);
if (!sessionsOrgId) {
return null;
}
let sessionsOrgWarning;
sessionsContext.orgs.forEach((orgSession) => {
if (orgSession.id === sessionsOrgId && orgSession.warning) {
sessionsOrgWarning = orgSession.warning;
}
});
return sessionsOrgWarning || null;
}, [sessionsContext, sessionsOrgId]);
// once the callback authN redirects to the login screen with access_token and refresh_token navigate them to homepage
useEffect(() => {
if (accessToken && refreshToken) {
afterLogin(accessToken, refreshToken);
}
}, [accessToken, refreshToken]);
const onSubmitHandler: () => Promise<void> = async () => {
setIsSubmitting(true);
try {
if (isPasswordAuthN) {
const email = form.getFieldValue('email');
const password = form.getFieldValue('password');
const createSessionEmailPasswordResponse = await post({
email,
password,
orgId: sessionsOrgId,
});
afterLogin(
createSessionEmailPasswordResponse.data.accessToken,
createSessionEmailPasswordResponse.data.refreshToken,
);
}
if (isCallbackAuthN) {
const url = form.getFieldValue('url');
window.location.href = url;
}
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSubmitting(false);
}
};
useEffect(() => {
if (callbackAuthError) {
showErrorModal(
new APIError({
httpStatusCode: 500,
error: {
code: callbackAuthErrorCode,
message: callbackAuthErrorMessage,
url: callbackAuthErrorURL,
errors: parseErrors(callbackAuthErrorAdditional),
},
}),
);
}
}, [
callbackAuthError,
callbackAuthErrorAdditional,
callbackAuthErrorCode,
callbackAuthErrorMessage,
callbackAuthErrorURL,
showErrorModal,
]);
useEffect(() => {
if (sessionsOrgWarning) {
showErrorModal(
new APIError({
error: {
code: sessionsOrgWarning.code,
message: sessionsOrgWarning.message,
url: sessionsOrgWarning.url,
errors: sessionsOrgWarning.errors,
},
httpStatusCode: 400,
}),
);
}
}, [sessionsOrgWarning, showErrorModal]);
return (
<div className="login-form-container">
<FormContainer form={form} onFinish={onSubmitHandler}>
@@ -225,17 +267,39 @@ function Login({
<FormContainer.Item name="email">
<Input
type="email"
id="loginEmail"
id="email"
data-testid="email"
required
placeholder="name@yourcompany.com"
autoFocus
disabled={isLoading}
disabled={versionLoading}
className="login-form-input"
/>
</FormContainer.Item>
</ParentContainer>
{precheckComplete && !sso && (
{sessionsContext && sessionsContext.orgs.length > 1 && (
<ParentContainer>
<Label htmlFor="orgId">Organization Name</Label>
<FormContainer.Item name="orgId">
<Select
id="orgId"
data-testid="orgId"
className="login-form-input"
placeholder="Select your organization"
options={sessionsContext.orgs.map((org) => ({
value: org.id,
label: org.name || 'default',
}))}
onChange={(value: string): void => {
setSessionsOrgId(value);
}}
/>
</FormContainer.Item>
</ParentContainer>
)}
{sessionsContext && isPasswordAuthN && (
<ParentContainer>
<Label htmlFor="Password">Password</Label>
<FormContainer.Item name="password">
@@ -243,7 +307,7 @@ function Login({
required
id="currentPassword"
data-testid="password"
disabled={isLoading}
disabled={isSubmitting}
className="login-form-input"
/>
</FormContainer.Item>
@@ -255,16 +319,16 @@ function Login({
</div>
</ParentContainer>
)}
<Space
style={{ marginTop: 16 }}
align="start"
direction="vertical"
size={20}
>
{!precheckComplete && (
{!sessionsContext && (
<Button
disabled={precheckInProcess}
loading={precheckInProcess}
disabled={versionLoading || sessionsContextLoading}
type="primary"
onClick={onNextHandler}
data-testid="initiate_login"
@@ -274,12 +338,27 @@ function Login({
Next
</Button>
)}
{precheckComplete && !sso && (
{sessionsContext && isCallbackAuthN && (
<Button
disabled={isLoading}
loading={isLoading}
disabled={isSubmitting}
type="primary"
htmlType="submit"
data-testid="callback_authn_submit"
data-attr="signup"
className="periscope-btn primary next-btn"
icon={<ArrowRight size={12} />}
>
Login With Callback
</Button>
)}
{sessionsContext && isPasswordAuthN && (
<Button
disabled={isSubmitting}
type="primary"
data-testid="password_authn_submit"
htmlType="submit"
data-attr="signup"
className="periscope-btn primary next-btn"
icon={<ArrowRight size={12} />}
@@ -287,30 +366,6 @@ function Login({
Login
</Button>
)}
{precheckComplete && sso && renderSAMLAction()}
{!precheckComplete && ssoerror && renderOnSsoError()}
{!canSelfRegister && (
<Typography.Paragraph className="no-acccount">
Don&apos;t have an account? Contact your admin to send you an invite
link.
</Typography.Paragraph>
)}
{canSelfRegister && (
<Typography.Paragraph italic style={{ color: '#ACACAC' }}>
If you are admin,{' '}
<Typography.Link
onClick={(): void => {
history.push(ROUTES.SIGN_UP);
}}
style={{ fontWeight: 700 }}
>
Create an account
</Typography.Link>
</Typography.Paragraph>
)}
</Space>
</FormContainer>
</div>

View File

@@ -1,6 +1,4 @@
import { Switch, Typography } from 'antd';
import { WsDataEvent } from 'api/common/getQueryStats';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import LogsDownloadOptionsMenu from 'components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
@@ -14,7 +12,6 @@ import QueryStatus from './QueryStatus';
function LogsActionsContainer({
listQuery,
queryStats,
selectedPanelType,
showFrequencyChart,
handleToggleFrequencyChart,
@@ -37,7 +34,6 @@ function LogsActionsContainer({
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
queryStats: WsDataEvent | undefined;
minTime: number;
maxTime: number;
}): JSX.Element {
@@ -126,22 +122,6 @@ function LogsActionsContainer({
error={isError}
success={isSuccess}
/>
{queryStats?.read_rows && (
<Typography.Text className="rows">
{getYAxisFormattedValue(queryStats.read_rows?.toString(), 'short')}{' '}
rows
</Typography.Text>
)}
{queryStats?.elapsed_ms && (
<>
<div className="divider" />
<Typography.Text className="time">
{getYAxisFormattedValue(queryStats?.elapsed_ms?.toString(), 'ms')}
</Typography.Text>
</>
)}
</div>
)}
</div>

View File

@@ -3,7 +3,6 @@ import './LogsExplorerViews.styles.scss';
import getFromLocalstorage from 'api/browser/localstorage/get';
import setToLocalstorage from 'api/browser/localstorage/set';
import { getQueryStats, WsDataEvent } from 'api/common/getQueryStats';
import logEvent from 'api/common/logEvent';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -132,7 +131,6 @@ function LogsExplorerViewsContainer({
const [logs, setLogs] = useState<ILog[]>([]);
const [requestData, setRequestData] = useState<Query | null>(null);
const [queryId, setQueryId] = useState<string>(v4());
const [queryStats, setQueryStats] = useState<WsDataEvent>();
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
@@ -409,19 +407,6 @@ function LogsExplorerViewsContainer({
setQueryId(v4());
}, [data]);
useEffect(() => {
if (
!isEmpty(queryId) &&
(isLoading || isFetching) &&
selectedPanelType !== PANEL_TYPES.LIST
) {
setQueryStats(undefined);
setTimeout(() => {
getQueryStats({ queryId, setData: setQueryStats });
}, 500);
}
}, [queryId, isLoading, isFetching, selectedPanelType]);
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {
@@ -632,7 +617,6 @@ function LogsExplorerViewsContainer({
{!showLiveLogs && (
<LogsActionsContainer
listQuery={listQuery}
queryStats={queryStats}
selectedPanelType={selectedPanelType}
showFrequencyChart={showFrequencyChart}
handleToggleFrequencyChart={handleToggleFrequencyChart}

View File

@@ -52,10 +52,6 @@ jest.mock(
},
);
jest.mock('api/common/getQueryStats', () => ({
getQueryStats: jest.fn(),
}));
jest.mock('constants/panelTypes', () => ({
AVAILABLE_EXPORT_PANEL_TYPES: ['graph', 'table'],
}));

View File

@@ -1,19 +1,25 @@
import './BreakDown.styles.scss';
import { Alert, Typography } from 'antd';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import GridCard from 'container/GridCardLayout/GridCard';
import { Card, CardContainer } from 'container/GridCardLayout/styles';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import useUrlQuery from 'hooks/useUrlQuery';
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuid } from 'uuid';
import {
@@ -108,27 +114,66 @@ function Section(section: MetricSection): JSX.Element {
function BreakDown(): JSX.Element {
const { isCloudUser } = useGetTenantLicense();
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const showInfo =
getLocalStorageApi(LOCALSTORAGE.DISSMISSED_COST_METER_INFO) !== 'true';
const isDateBeforeAugust22nd2025 = (minTime: number): boolean => {
const august22nd2025UTC = dayjs.utc('2025-08-22T00:00:00Z');
return dayjs(minTime / 1e6).isBefore(august22nd2025UTC);
};
const showShortRangeWarning = (maxTime - minTime) / 1e6 < 61 * 60 * 1000;
return (
<div className="meter-explorer-breakdown">
<section className="meter-explorer-date-time">
<DateTimeSelectionV2 showAutoRefresh={false} />
</section>
<section className="meter-explorer-graphs">
<section className="info">
{showInfo && (
<Alert
type="info"
showIcon
closable
onClose={(): void => {
setLocalStorageApi(LOCALSTORAGE.DISSMISSED_COST_METER_INFO, 'true');
}}
message="Billing is calculated in UTC. To match your meter data with billing, select full-day ranges in UTC time (00:00 23:59 UTC).
For example, if youre in IST, for the billing of Jan 1, select your time range as Jan 1, 5:30 AM Jan 2, 5:29 AM IST."
For example, if youre in PT, for the billing of Jan 1, select your time range as Dec 31, 4:00 PM Jan 1, 3:59 PM PT."
/>
{isCloudUser && (
<Alert
type="warning"
showIcon
message="Meter module data is accurate only from 22nd August 2025, 00:00 UTC onwards. Data before this time was collected during the beta phase and may be inaccurate."
/>
)}
</section>
)}
{isCloudUser && isDateBeforeAugust22nd2025(minTime) && (
<Alert
type="warning"
showIcon
message="Meter module data is accurate only from 22nd August 2025, 00:00 UTC onwards. Data before this time was collected during the beta phase and may be inaccurate."
/>
)}
{showShortRangeWarning && (
<Alert
type="warning"
showIcon
closable
message={
<>
Meter metrics data is aggregated over 1 hour period. Please select time
range accordingly.&nbsp;
<a
href="https://signoz.io/docs/cost-meter/overview/#accessing-cost-meter"
rel="noopener noreferrer"
target="_blank"
style={{ textDecoration: 'underline' }}
>
Learn more
</a>
.
</>
}
/>
)}
<section className="total">
<Section
id={sections[0].id}

View File

@@ -1,154 +0,0 @@
import { Button, Card, Space, Typography } from 'antd';
import changeMyPassword from 'api/v1/factor_password/changeMyPassword';
import { useNotifications } from 'hooks/useNotifications';
import { Save } from 'lucide-react';
import { isPasswordNotValidMessage, isPasswordValid } from 'pages/SignUp/utils';
import { useAppContext } from 'providers/App/App';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import APIError from 'types/api/error';
import { Password } from '../styles';
function PasswordContainer(): JSX.Element {
const [currentPassword, setCurrentPassword] = useState<string>('');
const [updatePassword, setUpdatePassword] = useState<string>('');
const { t } = useTranslation(['routes', 'settings', 'common']);
const { user } = useAppContext();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isPasswordPolicyError, setIsPasswordPolicyError] = useState<boolean>(
false,
);
const defaultPlaceHolder = '*************';
const { notifications } = useNotifications();
useEffect(() => {
if (currentPassword && !isPasswordValid(currentPassword)) {
setIsPasswordPolicyError(true);
} else {
setIsPasswordPolicyError(false);
}
}, [currentPassword]);
if (!user) {
return <div />;
}
const onChangePasswordClickHandler = async (): Promise<void> => {
try {
setIsLoading(true);
if (!isPasswordValid(currentPassword)) {
setIsPasswordPolicyError(true);
setIsLoading(false);
return;
}
await changeMyPassword({
newPassword: updatePassword,
oldPassword: currentPassword,
userId: user.id,
});
notifications.success({
message: t('success', {
ns: 'common',
}),
});
setIsLoading(false);
} catch (error) {
setIsLoading(false);
notifications.error({
message: (error as APIError).error.error.code,
description: (error as APIError).error.error.message,
});
}
};
const isDisabled =
isLoading ||
currentPassword.length === 0 ||
updatePassword.length === 0 ||
isPasswordPolicyError ||
currentPassword === updatePassword;
return (
<Card className="reset-password-card">
<Space direction="vertical" size="small">
<Typography.Title
level={4}
style={{ marginTop: 0 }}
data-testid="change-password-header"
>
{t('change_password', {
ns: 'settings',
})}
</Typography.Title>
<Space direction="vertical">
<Typography data-testid="current-password-label">
{t('current_password', {
ns: 'settings',
})}
</Typography>
<Password
data-testid="current-password-textbox"
disabled={isLoading}
placeholder={defaultPlaceHolder}
onChange={(event): void => {
setCurrentPassword(event.target.value);
}}
value={currentPassword}
/>
</Space>
<Space direction="vertical">
<Typography data-testid="new-password-label">
{t('new_password', {
ns: 'settings',
})}
</Typography>
<Password
data-testid="new-password-textbox"
disabled={isLoading}
placeholder={defaultPlaceHolder}
onChange={(event): void => {
const updatedValue = event.target.value;
setUpdatePassword(updatedValue);
}}
value={updatePassword}
/>
</Space>
<Space>
{isPasswordPolicyError && (
<Typography.Paragraph
data-testid="validation-message"
style={{
color: '#D89614',
marginTop: '0.50rem',
}}
>
{isPasswordNotValidMessage}
</Typography.Paragraph>
)}
</Space>
<Button
disabled={isDisabled}
loading={isLoading}
onClick={onChangePasswordClickHandler}
type="primary"
data-testid="update-password-button"
>
<Save
size={12}
style={{ marginRight: '8px' }}
data-testid="update-password-icon"
/>{' '}
{t('change_password', {
ns: 'settings',
})}
</Button>
</Space>
</Card>
);
}
export default PasswordContainer;

View File

@@ -7,9 +7,8 @@ import changeMyPassword from 'api/v1/factor_password/changeMyPassword';
import editUser from 'api/v1/user/id/update';
import { useNotifications } from 'hooks/useNotifications';
import { Check, FileTerminal, MailIcon, UserIcon } from 'lucide-react';
import { isPasswordValid } from 'pages/SignUp/utils';
import { useAppContext } from 'providers/App/App';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import APIError from 'types/api/error';
@@ -22,9 +21,6 @@ function UserInfo(): JSX.Element {
const [currentPassword, setCurrentPassword] = useState<string>('');
const [updatePassword, setUpdatePassword] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isPasswordPolicyError, setIsPasswordPolicyError] = useState<boolean>(
false,
);
const [changedName, setChangedName] = useState<string>(
user?.displayName || '',
@@ -40,14 +36,6 @@ function UserInfo(): JSX.Element {
const defaultPlaceHolder = '*************';
useEffect(() => {
if (currentPassword && !isPasswordValid(currentPassword)) {
setIsPasswordPolicyError(true);
} else {
setIsPasswordPolicyError(false);
}
}, [currentPassword]);
if (!user) {
return <div />;
}
@@ -64,11 +52,6 @@ function UserInfo(): JSX.Element {
try {
setIsLoading(true);
if (!isPasswordValid(currentPassword)) {
setIsPasswordPolicyError(true);
setIsLoading(false);
return;
}
await changeMyPassword({
newPassword: updatePassword,
oldPassword: currentPassword,
@@ -94,7 +77,6 @@ function UserInfo(): JSX.Element {
isLoading ||
currentPassword.length === 0 ||
updatePassword.length === 0 ||
isPasswordPolicyError ||
currentPassword === updatePassword;
const onSaveHandler = async (): Promise<void> => {

View File

@@ -408,6 +408,7 @@ export default function Onboarding(): JSX.Element {
form={form}
isInviteTeamMemberModalOpen={isInviteTeamMemberModalOpen}
toggleModal={toggleModal}
onClose={(): void => {}}
/>
</div>
);

View File

@@ -0,0 +1,19 @@
.auth-domain {
display: flex;
flex-direction: column;
gap: 8px;
.auth-domain-header {
display: flex;
align-items: center;
justify-content: space-between;
}
}
.auth-domain-list {
.auth-domain-list-column-action {
display: flex;
flex-direction: row;
gap: 24px;
}
}

View File

@@ -0,0 +1,90 @@
import './CreateEdit.styles.scss';
import { GoogleSquareFilled, KeyOutlined } from '@ant-design/icons';
import { Button, Typography } from 'antd';
interface AuthNProvider {
key: string;
title: string;
description: string;
icon: JSX.Element;
enabled: boolean;
}
function getAuthNProviders(samlEnabled: boolean): AuthNProvider[] {
return [
{
key: 'google_auth',
title: 'Google Apps Authentication',
description: 'Let members sign-in with a Google workspace account',
icon: <GoogleSquareFilled style={{ fontSize: '37px' }} />,
enabled: true,
},
{
key: 'saml',
title: 'SAML Authentication',
description:
'Azure, Active Directory, Okta or your custom SAML 2.0 solution',
icon: <KeyOutlined style={{ fontSize: '37px' }} />,
enabled: samlEnabled,
},
{
key: 'oidc',
title: 'OIDC Authentication',
description:
'Authenticate using OpenID Connect providers like Azure, Active Directory, Okta, or other OIDC compliant solutions',
icon: <KeyOutlined style={{ fontSize: '37px' }} />,
enabled: samlEnabled,
},
];
}
function AuthnProviderSelector({
setAuthnProvider,
samlEnabled,
}: {
setAuthnProvider: React.Dispatch<React.SetStateAction<string>>;
samlEnabled: boolean;
}): JSX.Element {
const authnProviders = getAuthNProviders(samlEnabled);
return (
<div className="authn-provider-selector">
<section className="header">
<Typography.Title level={4}>
Configure Authentication Method
</Typography.Title>
<Typography.Paragraph italic>
SigNoz supports the following single sign-on services (SSO). Get started
with setting your projects SSO below
</Typography.Paragraph>
</section>
<section className="selector">
{authnProviders.map((provider) => {
if (provider.enabled) {
return (
<section key={provider.key} className="provider">
<span className="icon">{provider.icon}</span>
<div className="title-description">
<Typography.Text className="title">{provider.title}</Typography.Text>
<Typography.Paragraph className="description">
{provider.description}
</Typography.Paragraph>
</div>
<Button
onClick={(): void => setAuthnProvider(provider.key)}
type="primary"
>
Configure
</Button>
</section>
);
}
return <div key={provider.key} />;
})}
</section>
</div>
);
}
export default AuthnProviderSelector;

View File

@@ -0,0 +1,39 @@
.authn-provider-selector {
display: flex;
flex-direction: column;
.header {
display: flex;
flex-direction: column;
}
.selector {
display: flex;
flex-direction: column;
gap: 16px;
.provider {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
.title-description {
.title {
font-weight: bold;
}
}
}
}
}
.ant-modal-body {
.auth-domain-configure {
.action-buttons {
display: flex;
justify-content: end;
gap: 8px;
margin-top: 16px;
}
}
}

View File

@@ -0,0 +1,134 @@
import './CreateEdit.styles.scss';
import { Button, Form, Modal } from 'antd';
import put from 'api/v1/domains/id/put';
import post from 'api/v1/domains/post';
import { FeatureKeys } from 'constants/features';
import { defaultTo } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useState } from 'react';
import APIError from 'types/api/error';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
import { PostableAuthDomain } from 'types/api/v1/domains/post';
import AuthnProviderSelector from './AuthnProviderSelector';
import ConfigureGoogleAuthAuthnProvider from './Providers/AuthnGoogleAuth';
import ConfigureOIDCAuthnProvider from './Providers/AuthnOIDC';
import ConfigureSAMLAuthnProvider from './Providers/AuthnSAML';
interface CreateOrEditProps {
isCreate: boolean;
onClose: () => void;
record?: GettableAuthDomain;
}
function configureAuthnProvider(
authnProvider: string,
isCreate: boolean,
): JSX.Element {
switch (authnProvider) {
case 'saml':
return <ConfigureSAMLAuthnProvider isCreate={isCreate} />;
case 'google_auth':
return <ConfigureGoogleAuthAuthnProvider isCreate={isCreate} />;
case 'oidc':
return <ConfigureOIDCAuthnProvider isCreate={isCreate} />;
default:
return <ConfigureGoogleAuthAuthnProvider isCreate={isCreate} />;
}
}
function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const { isCreate, record, onClose } = props;
const [form] = Form.useForm<PostableAuthDomain>();
const [authnProvider, setAuthnProvider] = useState<string>(
record?.ssoType || '',
);
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();
const samlEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false;
const onSubmitHandler = async (): Promise<void> => {
const name = form.getFieldValue('name');
const googleAuthConfig = form.getFieldValue('googleAuthConfig');
const samlConfig = form.getFieldValue('samlConfig');
const oidcConfig = form.getFieldValue('oidcConfig');
try {
if (isCreate) {
await post({
name,
config: {
ssoEnabled: true,
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
},
});
} else {
await put({
id: record?.id || '',
config: {
ssoEnabled: form.getFieldValue('ssoEnabled'),
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
},
});
}
onClose();
} catch (error) {
showErrorModal(error as APIError);
}
};
const onBackHandler = (): void => {
setAuthnProvider('');
};
return (
<Modal open footer={null} onCancel={onClose}>
<Form
name="auth-domain"
initialValues={defaultTo(record, {
name: '',
ssoEnabled: false,
ssoType: '',
})}
form={form}
layout="vertical"
>
{isCreate && authnProvider === '' && (
<AuthnProviderSelector
setAuthnProvider={setAuthnProvider}
samlEnabled={samlEnabled}
/>
)}
{authnProvider !== '' && (
<div className="auth-domain-configure">
{configureAuthnProvider(authnProvider, isCreate)}
<section className="action-buttons">
{isCreate && <Button onClick={onBackHandler}>Back</Button>}
{!isCreate && <Button onClick={onClose}>Cancel</Button>}
<Button onClick={onSubmitHandler} type="primary">
Save Changes
</Button>
</section>
</div>
)}
</Form>
</Modal>
);
}
CreateOrEdit.defaultProps = {
record: null,
};
export default CreateOrEdit;

View File

@@ -0,0 +1,68 @@
import './Providers.styles.scss';
import { Callout } from '@signozhq/callout';
import { Form, Input, Typography } from 'antd';
function ConfigureGoogleAuthAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
return (
<div className="google-auth">
<section className="header">
<Typography.Text className="title">
Edit Google Authentication
</Typography.Text>
<Typography.Paragraph className="description">
Enter OAuth 2.0 credentials obtained from the Google API Console below.
Read the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
target="_blank"
rel="noreferrer"
>
docs
</a>{' '}
for more information.
</Typography.Paragraph>
</section>
<Form.Item label="Domain" name="name" className="field">
<Input disabled={!isCreate} />
</Form.Item>
<Form.Item
label="Client ID"
name={['googleAuthConfig', 'clientId']}
className="field"
tooltip={{
title: `ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.`,
}}
>
<Input />
</Form.Item>
<Form.Item
label="Client Secret"
name={['googleAuthConfig', 'clientSecret']}
className="field"
tooltip={{
title: `It is the application's secret.`,
}}
>
<Input />
</Form.Item>
<Callout
type="warning"
size="small"
showIcon
description="Google OAuth2 wont be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
);
}
export default ConfigureGoogleAuthAuthnProvider;

View File

@@ -0,0 +1,105 @@
import './Providers.styles.scss';
import { Callout } from '@signozhq/callout';
import { Checkbox, Form, Input, Typography } from 'antd';
function ConfigureOIDCAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
return (
<div className="saml">
<section className="header">
<Typography.Text className="title">
Edit OIDC Authentication
</Typography.Text>
</section>
<Form.Item label="Domain" name="name">
<Input disabled={!isCreate} />
</Form.Item>
<Form.Item
label="Issuer URL"
name={['oidcConfig', 'issuer']}
tooltip={{
title: `It is the URL identifier for the service. For example: "https://accounts.google.com" or "https://login.salesforce.com".`,
}}
>
<Input />
</Form.Item>
<Form.Item
label="Issuer Alias"
name={['oidcConfig', 'issuerAlias']}
tooltip={{
title: `Some offspec providers like Azure, Oracle IDCS have oidc discovery url different from issuer url which causes issuerValidation to fail.
This provides a way to override the Issuer url from the .well-known/openid-configuration issuer`,
}}
>
<Input />
</Form.Item>
<Form.Item
label="Client ID"
name={['oidcConfig', 'clientId']}
tooltip={{ title: `It is the application's ID.` }}
>
<Input />
</Form.Item>
<Form.Item
label="Client Secret"
name={['oidcConfig', 'clientSecret']}
tooltip={{ title: `It is the application's secret.` }}
>
<Input />
</Form.Item>
<Form.Item
label="Email Claim Mapping"
name={['oidcConfig', 'claimMapping', 'email']}
tooltip={{
title: `Mapping of email claims to the corresponding email field in the token.`,
}}
>
<Input />
</Form.Item>
<Form.Item
label="Skip Email Verification"
name={['oidcConfig', 'insecureSkipEmailVerified']}
valuePropName="checked"
className="field"
tooltip={{
title: `Whether to skip email verification. Defaults to "false"`,
}}
>
<Checkbox />
</Form.Item>
<Form.Item
label="Get User Info"
name={['oidcConfig', 'getUserInfo']}
valuePropName="checked"
className="field"
tooltip={{
title: `Uses the userinfo endpoint to get additional claims for the token. This is especially useful where upstreams return "thin" id tokens`,
}}
>
<Checkbox />
</Form.Item>
<Callout
type="warning"
size="small"
showIcon
description="OIDC wont be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
);
}
export default ConfigureOIDCAuthnProvider;

View File

@@ -0,0 +1,77 @@
import './Providers.styles.scss';
import { Callout } from '@signozhq/callout';
import { Checkbox, Form, Input, Typography } from 'antd';
function ConfigureSAMLAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
return (
<div className="saml">
<section className="header">
<Typography.Text className="title">
Edit SAML Authentication
</Typography.Text>
</section>
<Form.Item label="Domain" name="name">
<Input disabled={!isCreate} />
</Form.Item>
<Form.Item
label="SAML ACS URL"
name={['samlConfig', 'samlIdp']}
tooltip={{
title: `The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">`,
}}
>
<Input />
</Form.Item>
<Form.Item
label="SAML Entity ID"
name={['samlConfig', 'samlEntity']}
tooltip={{
title: `The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>`,
}}
>
<Input />
</Form.Item>
<Form.Item
label="SAML X.509 Certificate"
name={['samlConfig', 'samlCert']}
tooltip={{
title: `The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata of the identity provider. Example: <ds:X509Certificate><ds:X509Certificate>{samlCert}</ds:X509Certificate></ds:X509Certificate>`,
}}
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item
label="Skip Signing AuthN Requests"
name={['samlConfig', 'insecureSkipAuthNRequestsSigned']}
valuePropName="checked"
className="field"
tooltip={{
title: `Whether to skip signing the SAML requests. It can typically be found in the WantAuthnRequestsSigned attribute of the IDPSSODescriptor element in the SAML metadata of the identity provider. Example: <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
For providers like jumpcloud, this should be set to true.Note: This is the reverse of WantAuthnRequestsSigned. If WantAuthnRequestsSigned is false, then InsecureSkipAuthNRequestsSigned should be true.`,
}}
>
<Checkbox />
</Form.Item>
<Callout
type="warning"
size="small"
showIcon
description="SAML wont be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
);
}
export default ConfigureSAMLAuthnProvider;

View File

@@ -0,0 +1,67 @@
.google-auth {
display: flex;
flex-direction: column;
.ant-form-item {
margin-bottom: 12px !important;
}
.header {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
.title {
font-weight: bold;
}
.description {
margin-bottom: 0px !important;
}
}
.callout {
margin-top: 16px;
}
}
.saml {
display: flex;
flex-direction: column;
.ant-form-item {
margin-bottom: 12px !important;
}
.header {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
.title {
font-weight: bold;
}
}
.field {
.ant-row {
display: flex;
flex-direction: row;
align-items: center;
flex-flow: nowrap;
.ant-col {
display: flex;
flex-grow: 1;
width: 100%;
padding: 0px;
}
}
}
.callout {
margin-top: 16px;
}
}

View File

@@ -0,0 +1,45 @@
import { Switch } from 'antd';
import put from 'api/v1/domains/id/put';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useState } from 'react';
import APIError from 'types/api/error';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
function Toggle({ isDefaultChecked, record }: ToggleProps): JSX.Element {
const [isChecked, setIsChecked] = useState<boolean>(isDefaultChecked);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { showErrorModal } = useErrorModal();
const onChangeHandler = async (checked: boolean): Promise<void> => {
setIsLoading(true);
try {
await put({
id: record.id,
config: {
ssoEnabled: checked,
ssoType: record.ssoType,
googleAuthConfig: record.googleAuthConfig,
oidcConfig: record.oidcConfig,
samlConfig: record.samlConfig,
},
});
setIsChecked(checked);
} catch (error) {
showErrorModal(error as APIError);
}
setIsLoading(false);
};
return (
<Switch loading={isLoading} checked={isChecked} onChange={onChangeHandler} />
);
}
interface ToggleProps {
isDefaultChecked: boolean;
record: GettableAuthDomain;
}
export default Toggle;

View File

@@ -0,0 +1,148 @@
import './AuthDomain.styles.scss';
import { PlusOutlined } from '@ant-design/icons';
import { Button, Table, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import deleteDomain from 'api/v1/domains/id/delete';
import listAllDomain from 'api/v1/domains/list';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useState } from 'react';
import { useQuery } from 'react-query';
import APIError from 'types/api/error';
import { GettableAuthDomain, SSOType } from 'types/api/v1/domains/list';
import CreateEdit from './CreateEdit/CreateEdit';
import Toggle from './Toggle';
const columns: ColumnsType<GettableAuthDomain> = [
{
title: 'Domain',
dataIndex: 'name',
key: 'name',
width: 100,
render: (val): JSX.Element => <Typography.Text>{val}</Typography.Text>,
},
{
title: 'Enforce SSO',
dataIndex: 'ssoEnabled',
key: 'ssoEnabled',
width: 80,
render: (value: boolean, record: GettableAuthDomain): JSX.Element => (
<Toggle isDefaultChecked={value} record={record} />
),
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
width: 100,
render: (_, record: GettableAuthDomain): JSX.Element => (
<section className="auth-domain-list-column-action">
<Typography.Link data-column-action="configure">
Configure {SSOType.get(record.ssoType)}
</Typography.Link>
<Typography.Link type="danger" data-column-action="delete">
Delete
</Typography.Link>
</section>
),
},
];
async function deleteDomainById(
id: string,
showErrorModal: (error: APIError) => void,
refetchAuthDomainListResponse: () => void,
): Promise<void> {
try {
await deleteDomain(id);
refetchAuthDomainListResponse();
} catch (error) {
showErrorModal(error as APIError);
}
}
function AuthDomain(): JSX.Element {
const [record, setRecord] = useState<GettableAuthDomain>();
const [addDomain, setAddDomain] = useState<boolean>(false);
const { showErrorModal } = useErrorModal();
const {
data: authDomainListResponse,
isLoading: isLoadingAuthDomainListResponse,
isFetching: isFetchingAuthDomainListResponse,
error: errorFetchingAuthDomainListResponse,
refetch: refetchAuthDomainListResponse,
} = useQuery({
queryFn: listAllDomain,
queryKey: ['/api/v1/domains', 'list'],
enabled: true,
});
return (
<div className="auth-domain">
<section className="auth-domain-header">
<Typography.Title level={3}>Authenticated Domains</Typography.Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={(): void => {
setAddDomain(true);
}}
className="button"
>
Add Domain
</Button>
</section>
{(errorFetchingAuthDomainListResponse as APIError) && (
<ErrorContent error={errorFetchingAuthDomainListResponse as APIError} />
)}
{!(errorFetchingAuthDomainListResponse as APIError) && (
<Table
columns={columns}
dataSource={authDomainListResponse?.data}
onRow={(record): any => ({
onClick: (
event: React.SyntheticEvent<HTMLLinkElement, MouseEvent>,
): void => {
const target = event.target as HTMLLinkElement;
const { columnAction } = target.dataset;
switch (columnAction) {
case 'configure':
setRecord(record);
break;
case 'delete':
deleteDomainById(
record.id,
showErrorModal,
refetchAuthDomainListResponse,
);
break;
default:
console.error('Unknown action:', columnAction);
}
},
})}
loading={
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
}
className="auth-domain-list"
/>
)}
{(addDomain || record) && (
<CreateEdit
isCreate={!record}
record={record}
onClose={(): void => {
setAddDomain(false);
setRecord(undefined);
refetchAuthDomainListResponse();
}}
/>
)}
</div>
);
}
export default AuthDomain;

View File

@@ -1,101 +0,0 @@
/* eslint-disable prefer-regex-literals */
import { PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input, Modal, Typography } from 'antd';
import { useForm } from 'antd/es/form/Form';
import createDomainApi from 'api/v1/domains/create';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import APIError from 'types/api/error';
function AddDomain({ refetch }: Props): JSX.Element {
const { t } = useTranslation(['common', 'organizationsettings']);
const [isAddDomains, setIsDomain] = useState(false);
const [form] = useForm<FormProps>();
const { org } = useAppContext();
const { notifications } = useNotifications();
const onCreateHandler = async (): Promise<void> => {
try {
await createDomainApi({
name: form.getFieldValue('domain'),
orgId: (org || [])[0].id,
});
notifications.success({
message: 'Your domain has been added successfully.',
duration: 15,
});
setIsDomain(false);
refetch();
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
}
};
return (
<>
<div className="auth-domains-title-container">
<Typography.Title level={3}>
{t('authenticated_domains', {
ns: 'organizationsettings',
})}
</Typography.Title>
<Button
onClick={(): void => setIsDomain(true)}
type="primary"
icon={<PlusOutlined />}
>
{t('add_domain', { ns: 'organizationsettings' })}
</Button>
</div>
<Modal
centered
title="Add Domain"
className="add-domain-modal"
footer={null}
open={isAddDomains}
destroyOnClose
onCancel={(): void => setIsDomain(false)}
>
<Form form={form} onFinish={onCreateHandler} requiredMark>
<Form.Item
required
name={['domain']}
rules={[
{
message: 'Please enter a valid domain',
required: true,
pattern: new RegExp(
'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$',
),
},
]}
>
<Input placeholder="signoz.io" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
Add Domain
</Button>
</Form.Item>
</Form>
</Modal>
</>
);
}
interface FormProps {
domain: string;
}
interface Props {
refetch: () => void;
}
export default AddDomain;

View File

@@ -1,39 +0,0 @@
import { Button, Space, Typography } from 'antd';
import { ReactNode } from 'react';
import { IconContainer, TitleContainer, TitleText } from './styles';
function Row({
onClickHandler,
Icon,
buttonText,
subTitle,
title,
isDisabled,
}: RowProps): JSX.Element {
return (
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<IconContainer>{Icon}</IconContainer>
<TitleContainer>
<TitleText>{title}</TitleText>
<Typography.Text>{subTitle}</Typography.Text>
</TitleContainer>
<Button disabled={isDisabled} onClick={onClickHandler} type="primary">
{buttonText}
</Button>
</Space>
);
}
export interface RowProps {
onClickHandler: VoidFunction;
Icon: ReactNode;
title: string;
subTitle: ReactNode;
buttonText: string;
isDisabled: boolean;
}
export default Row;

View File

@@ -1,16 +0,0 @@
import { Typography } from 'antd';
import styled from 'styled-components';
export const TitleContainer = styled.div`
display: flex;
flex-direction: column;
gap: 0.25rem;
`;
export const IconContainer = styled.div`
min-width: 70px;
`;
export const TitleText = styled(Typography)`
font-weight: bold;
`;

View File

@@ -1,118 +0,0 @@
import { GoogleSquareFilled, KeyOutlined } from '@ant-design/icons';
import { Typography } from 'antd';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import { useCallback, useMemo } from 'react';
import { AuthDomain, GOOGLE_AUTH, SAML } from 'types/api/SAML/listDomain';
import Row, { RowProps } from './Row';
import { RowContainer, RowSpace } from './styles';
function Create({
ssoMethod,
assignSsoMethod,
setIsSettingsOpen,
setIsEditModalOpen,
}: CreateProps): JSX.Element {
const { featureFlags } = useAppContext();
const SSOFlag =
featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false;
const onGoogleAuthClickHandler = useCallback(() => {
assignSsoMethod(GOOGLE_AUTH);
setIsSettingsOpen(false);
setIsEditModalOpen(true);
}, [assignSsoMethod, setIsSettingsOpen, setIsEditModalOpen]);
const onEditSAMLHandler = useCallback(() => {
assignSsoMethod(SAML);
setIsSettingsOpen(false);
setIsEditModalOpen(true);
}, [assignSsoMethod, setIsSettingsOpen, setIsEditModalOpen]);
const ConfigureButtonText = useMemo(() => {
switch (ssoMethod) {
case GOOGLE_AUTH:
return 'Edit Google Auth';
case SAML:
return 'Edit SAML';
default:
return 'Get Started';
}
}, [ssoMethod]);
const data: RowProps[] = SSOFlag
? [
{
buttonText: ConfigureButtonText,
Icon: <GoogleSquareFilled style={{ fontSize: '37px' }} />,
title: 'Google Apps Authentication',
subTitle: 'Let members sign-in with a Google workspace account',
onClickHandler: onGoogleAuthClickHandler,
isDisabled: false,
},
{
buttonText: ConfigureButtonText,
Icon: <KeyOutlined style={{ fontSize: '37px' }} />,
onClickHandler: onEditSAMLHandler,
subTitle: (
<>
Azure, Active Directory, Okta or your custom SAML 2.0 solution{' '}
<a
href="https://github.com/SigNoz/signoz/issues/8647"
target="_blank"
rel="noopener noreferrer"
>
(Unsupported SAMLs)
</a>
</>
),
title: 'SAML Authentication',
isDisabled: false,
},
]
: [
{
buttonText: ConfigureButtonText,
Icon: <GoogleSquareFilled style={{ fontSize: '37px' }} />,
title: 'Google Apps Authentication',
subTitle: 'Let members sign-in with a Google account',
onClickHandler: onGoogleAuthClickHandler,
isDisabled: false,
},
];
return (
<div>
<Typography.Text italic>
SigNoz supports the following single sign-on services (SSO). Get started
with setting your projects SSO below
</Typography.Text>
<RowContainer>
<RowSpace direction="vertical">
{data.map((rowData) => (
<Row
Icon={rowData.Icon}
buttonText={rowData.buttonText}
onClickHandler={rowData.onClickHandler}
subTitle={rowData.subTitle}
title={rowData.title}
key={rowData.title}
isDisabled={rowData.isDisabled}
/>
))}
</RowSpace>
</RowContainer>
</div>
);
}
interface CreateProps {
ssoMethod: AuthDomain['ssoType'];
assignSsoMethod: (value: AuthDomain['ssoType']) => void;
setIsSettingsOpen: (value: boolean) => void;
setIsEditModalOpen: (value: boolean) => void;
}
export default Create;

View File

@@ -1,14 +0,0 @@
import { Space } from 'antd';
import styled from 'styled-components';
export const RowContainer = styled.div`
display: flex;
flex-direction: column;
margin-top: 1rem;
`;
export const RowSpace = styled(Space)`
&&& {
row-gap: 1.5rem !important;
}
`;

View File

@@ -1,49 +0,0 @@
import { InfoCircleFilled } from '@ant-design/icons';
import { Card, Form, Input, Space, Typography } from 'antd';
function EditGoogleAuth(): JSX.Element {
return (
<>
<Typography.Paragraph>
Enter OAuth 2.0 credentials obtained from the Google API Console below. Read
the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
target="_blank"
rel="noreferrer"
>
docs
</a>{' '}
for more information.
</Typography.Paragraph>
<Form.Item
label="Client ID"
name={['googleAuthConfig', 'clientId']}
rules={[{ required: true, message: 'Please input Google Auth Client ID!' }]}
>
<Input />
</Form.Item>
<Form.Item
label="Client Secret"
name={['googleAuthConfig', 'clientSecret']}
rules={[
{ required: true, message: 'Please input Google Auth Client Secret!' },
]}
>
<Input />
</Form.Item>
<Card style={{ marginBottom: '1rem' }}>
<Space>
<InfoCircleFilled />
<Typography>
Google OAuth2 wont be enabled unless you enter all the attributes above
</Typography>
</Space>
</Card>
</>
);
}
export default EditGoogleAuth;

View File

@@ -1,43 +0,0 @@
import { InfoCircleFilled } from '@ant-design/icons';
import { Card, Form, Input, Space, Typography } from 'antd';
function EditSAML(): JSX.Element {
return (
<>
<Form.Item
label="SAML ACS URL"
name={['samlConfig', 'samlIdp']}
rules={[{ required: true, message: 'Please input your ACS URL!' }]}
>
<Input />
</Form.Item>
<Form.Item
label="SAML Entity ID"
name={['samlConfig', 'samlEntity']}
rules={[{ required: true, message: 'Please input your Entity Id!' }]}
>
<Input />
</Form.Item>
<Form.Item
rules={[{ required: true, message: 'Please input your Certificate!' }]}
label="SAML X.509 Certificate"
name={['samlConfig', 'samlCert']}
>
<Input.TextArea rows={4} />
</Form.Item>
<Card style={{ marginBottom: '1rem' }}>
<Space>
<InfoCircleFilled />
<Typography>
SAML wont be enabled unless you enter all the attributes above
</Typography>
</Space>
</Card>
</>
);
}
export default EditSAML;

View File

@@ -1,40 +0,0 @@
import {
AuthDomain,
GOOGLE_AUTH,
GoogleAuthConfig,
isGoogleAuthConfig,
isSAMLConfig,
SAML,
SAMLConfig,
} from 'types/api/SAML/listDomain';
export function parseSamlForm(
current: AuthDomain,
formValues: AuthDomain,
): SAMLConfig | undefined {
if (current?.ssoType === SAML && isSAMLConfig(formValues?.samlConfig)) {
return {
...current.samlConfig,
...formValues?.samlConfig,
};
}
return current.samlConfig;
}
export function parseGoogleAuthForm(
current: AuthDomain,
formValues: AuthDomain,
): GoogleAuthConfig | undefined {
if (
current?.ssoType === GOOGLE_AUTH &&
isGoogleAuthConfig(formValues?.googleAuthConfig)
) {
return {
...current.googleAuthConfig,
...formValues?.googleAuthConfig,
};
}
return current.googleAuthConfig;
}

View File

@@ -1,102 +0,0 @@
import { Button, Form, Space } from 'antd';
import { useForm } from 'antd/lib/form/Form';
import { useNotifications } from 'hooks/useNotifications';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { AuthDomain, GOOGLE_AUTH, SAML } from 'types/api/SAML/listDomain';
import EditGoogleAuth from './EditGoogleAuth';
import EditSAML from './EditSAML';
import { parseGoogleAuthForm, parseSamlForm } from './helpers';
// renderFormInputs selectively renders form fields depending upon
// sso type
const renderFormInputs = (
record: AuthDomain | undefined,
): JSX.Element | undefined => {
switch (record?.ssoType) {
case GOOGLE_AUTH:
return <EditGoogleAuth />;
case SAML:
default:
return <EditSAML />;
}
};
function EditSSO({
onRecordUpdateHandler,
record,
setEditModalOpen,
}: EditFormProps): JSX.Element {
const [form] = useForm<AuthDomain>();
const { t } = useTranslation(['common']);
const { notifications } = useNotifications();
const onFinishHandler = useCallback(() => {
form
.validateFields()
.then(async (values) => {
await onRecordUpdateHandler({
...record,
ssoEnabled: true,
ssoType: record.ssoType,
samlConfig: parseSamlForm(record, values),
googleAuthConfig: parseGoogleAuthForm(record, values),
});
})
.catch(() => {
notifications.error({
message: t('something_went_wrong', { ns: 'common' }),
});
});
}, [form, onRecordUpdateHandler, record, t, notifications]);
const onResetHandler = useCallback(() => {
form.resetFields();
setEditModalOpen(false);
}, [setEditModalOpen, form]);
return (
<Form
name="basic"
initialValues={record}
onFinishFailed={(error): void => {
error.errorFields.forEach(({ errors }) => {
notifications.error({
message:
errors[0].toString() || t('something_went_wrong', { ns: 'common' }),
});
});
form.resetFields();
}}
layout="vertical"
onFinish={onFinishHandler}
autoComplete="off"
form={form}
>
{renderFormInputs(record)}
<Space
style={{ width: '100%', justifyContent: 'flex-end' }}
align="end"
direction="horizontal"
>
<Button htmlType="button" onClick={onResetHandler}>
Cancel
</Button>
<Button type="primary" htmlType="submit">
Save Settings
</Button>
</Space>
</Form>
);
}
interface EditFormProps {
onRecordUpdateHandler: (record: AuthDomain) => Promise<boolean>;
record: AuthDomain;
setEditModalOpen: (open: boolean) => void;
}
export default EditSSO;

View File

@@ -1,46 +0,0 @@
import { Switch } from 'antd';
import { useMemo, useState } from 'react';
import { AuthDomain } from 'types/api/SAML/listDomain';
import { isSSOConfigValid } from '../helpers';
function SwitchComponent({
isDefaultChecked,
onRecordUpdateHandler,
record,
}: SwitchComponentProps): JSX.Element {
const [isChecked, setIsChecked] = useState<boolean>(isDefaultChecked);
const [isLoading, setIsLoading] = useState<boolean>(false);
const onChangeHandler = async (checked: boolean): Promise<void> => {
setIsLoading(true);
const response = await onRecordUpdateHandler({
...record,
ssoEnabled: checked,
});
if (response) {
setIsChecked(checked);
}
setIsLoading(false);
};
const isInValidVerificate = useMemo(() => !isSSOConfigValid(record), [record]);
return (
<Switch
loading={isLoading}
disabled={isInValidVerificate}
checked={isChecked}
onChange={onChangeHandler}
/>
);
}
interface SwitchComponentProps {
isDefaultChecked: boolean;
onRecordUpdateHandler: (record: AuthDomain) => Promise<boolean>;
record: AuthDomain;
}
export default SwitchComponent;

View File

@@ -1,74 +0,0 @@
import { AuthDomain, SAML } from 'types/api/SAML/listDomain';
import { isSSOConfigValid } from './helpers';
const inValidCase: AuthDomain['samlConfig'][] = [
{
samlCert: '',
samlEntity: '',
samlIdp: '',
},
{
samlCert: '',
samlEntity: '',
samlIdp: 'asd',
},
{
samlCert: 'sample certificate',
samlEntity: '',
samlIdp: '',
},
{
samlCert: 'sample cert',
samlEntity: 'sample entity',
samlIdp: '',
},
];
const validCase: AuthDomain['samlConfig'][] = [
{
samlCert: 'sample cert',
samlEntity: 'sample entity',
samlIdp: 'sample idp',
},
];
describe('Utils', () => {
inValidCase.forEach((config) => {
it('should return invalid saml config', () => {
expect(
isSSOConfigValid({
id: 'test-0',
name: 'test',
orgId: '32ed234',
ssoEnabled: true,
ssoType: SAML,
samlConfig: {
samlCert: config?.samlCert || '',
samlEntity: config?.samlEntity || '',
samlIdp: config?.samlIdp || '',
},
}),
).toBe(false);
});
});
validCase.forEach((config) => {
it('should return invalid saml config', () => {
expect(
isSSOConfigValid({
id: 'test-0',
name: 'test',
orgId: '32ed234',
ssoEnabled: true,
ssoType: SAML,
samlConfig: {
samlCert: config?.samlCert || '',
samlEntity: config?.samlEntity || '',
samlIdp: config?.samlIdp || '',
},
}),
).toBe(true);
});
});
});

View File

@@ -1,45 +0,0 @@
import { AuthDomain, GOOGLE_AUTH, SAML } from 'types/api/SAML/listDomain';
export const ConfigureSsoButtonText = (
ssoType: AuthDomain['ssoType'],
): string => {
switch (ssoType) {
case SAML:
return 'Edit SAML';
case GOOGLE_AUTH:
return 'Edit Google Auth';
default:
return 'Configure SSO';
}
};
export const EditModalTitleText = (
ssoType: AuthDomain['ssoType'] | undefined,
): string => {
switch (ssoType) {
case SAML:
return 'Edit SAML Configuration';
case GOOGLE_AUTH:
return 'Edit Google Authentication';
default:
return 'Configure SSO';
}
};
export const isSSOConfigValid = (domain: AuthDomain): boolean => {
switch (domain.ssoType) {
case SAML:
return (
domain.samlConfig?.samlCert?.length !== 0 &&
domain.samlConfig?.samlEntity?.length !== 0 &&
domain.samlConfig?.samlIdp?.length !== 0
);
case GOOGLE_AUTH:
return (
domain.googleAuthConfig?.clientId?.length !== 0 &&
domain.googleAuthConfig?.clientSecret?.length !== 0
);
default:
return false;
}
};

View File

@@ -1,263 +0,0 @@
import { Button, Modal, Space, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import deleteDomain from 'api/v1/domains/delete';
import listAllDomain from 'api/v1/domains/list';
import updateDomain from 'api/v1/domains/update';
import { ResizeTable } from 'components/ResizeTable';
import TextToolTip from 'components/TextToolTip';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import APIError from 'types/api/error';
import { AuthDomain } from 'types/api/SAML/listDomain';
import { v4 } from 'uuid';
import AddDomain from './AddDomain';
import Create from './Create';
import EditSSO from './Edit';
import { ConfigureSsoButtonText, EditModalTitleText } from './helpers';
import { ColumnWithTooltip } from './styles';
import SwitchComponent from './Switch';
function AuthDomains(): JSX.Element {
const { t } = useTranslation(['common', 'organizationsettings']);
const [isSettingsOpen, setIsSettingsOpen] = useState<boolean>(false);
const { org } = useAppContext();
const [currentDomain, setCurrentDomain] = useState<AuthDomain>();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const { data, isLoading, refetch } = useQuery(['saml'], {
queryFn: () => listAllDomain(),
enabled: org !== null,
});
const { notifications } = useNotifications();
const assignSsoMethod = useCallback(
(typ: AuthDomain['ssoType']): void => {
setCurrentDomain({ ...currentDomain, ssoType: typ } as AuthDomain);
},
[currentDomain, setCurrentDomain],
);
const onCloseHandler = useCallback(
(func: Dispatch<SetStateAction<boolean>>) => (): void => {
func(false);
},
[],
);
const onRecordUpdateHandler = useCallback(
async (record: AuthDomain): Promise<boolean> => {
try {
await updateDomain(record);
notifications.success({
message: t('saml_settings', {
ns: 'organizationsettings',
}),
});
refetch();
onCloseHandler(setIsEditModalOpen)();
return true;
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return false;
}
},
[refetch, t, onCloseHandler, notifications],
);
const onOpenHandler = useCallback(
(func: Dispatch<SetStateAction<boolean>>) => (): void => {
func(true);
},
[],
);
const onEditHandler = useCallback(
(record: AuthDomain) => (): void => {
if (!record.ssoType) {
onOpenHandler(setIsSettingsOpen)();
} else {
onOpenHandler(setIsEditModalOpen)();
}
setCurrentDomain(record);
},
[onOpenHandler],
);
const onDeleteHandler = useCallback(
(record: AuthDomain) => (): void => {
Modal.confirm({
centered: true,
title: t('delete_domain', {
ns: 'organizationsettings',
}),
content: t('delete_domain_message', {
ns: 'organizationsettings',
}),
onOk: async () => {
try {
await deleteDomain({
...record,
});
notifications.success({
message: t('common:success'),
});
refetch();
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
}
},
});
},
[refetch, t, notifications],
);
const columns: ColumnsType<AuthDomain> = [
{
title: 'Domain',
dataIndex: 'name',
key: 'name',
width: 100,
},
{
title: (
<ColumnWithTooltip>
<Typography>Enforce SSO</Typography>
<TextToolTip
{...{
text: `When enabled, this option restricts users to SSO based authentication. For more information, click `,
url: 'https://signoz.io/docs/userguide/sso-authentication/',
}}
/>{' '}
</ColumnWithTooltip>
),
dataIndex: 'ssoEnabled',
key: 'ssoEnabled',
width: 80,
render: (value: boolean, record: AuthDomain): JSX.Element => (
<SwitchComponent
onRecordUpdateHandler={onRecordUpdateHandler}
isDefaultChecked={value}
record={record}
/>
),
},
{
title: '',
dataIndex: 'description',
key: 'description',
width: 100,
render: (_, record: AuthDomain): JSX.Element => (
<Button type="link" onClick={onEditHandler(record)}>
{ConfigureSsoButtonText(record.ssoType)}
</Button>
),
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
width: 50,
render: (_, record): JSX.Element => (
<Button onClick={onDeleteHandler(record)} danger type="link">
Delete
</Button>
),
},
];
if (!isLoading && data?.data?.length === 0) {
return (
<Space direction="vertical" size="middle">
<AddDomain refetch={refetch} />
<Modal
centered
title="Configure Authentication Method"
onCancel={onCloseHandler(setIsSettingsOpen)}
destroyOnClose
open={isSettingsOpen}
footer={null}
>
<Create
ssoMethod={currentDomain?.ssoType as AuthDomain['ssoType']}
assignSsoMethod={assignSsoMethod}
setIsEditModalOpen={setIsEditModalOpen}
setIsSettingsOpen={setIsSettingsOpen}
/>
</Modal>
<ResizeTable
columns={columns}
rowKey={(record: AuthDomain): string => record.name + v4()}
dataSource={[]}
tableLayout="fixed"
bordered
/>
</Space>
);
}
const tableData = data?.data || [];
return (
<>
<Modal
centered
title="Configure Authentication Method"
onCancel={onCloseHandler(setIsSettingsOpen)}
destroyOnClose
open={isSettingsOpen}
footer={null}
>
<Create
ssoMethod={currentDomain?.ssoType as AuthDomain['ssoType']}
assignSsoMethod={assignSsoMethod}
setIsSettingsOpen={setIsSettingsOpen}
setIsEditModalOpen={setIsEditModalOpen}
/>
</Modal>
<Modal
open={isEditModalOpen}
centered
title={EditModalTitleText(currentDomain?.ssoType)}
onCancel={onCloseHandler(setIsEditModalOpen)}
destroyOnClose
style={{ minWidth: '600px' }}
footer={null}
>
<EditSSO
onRecordUpdateHandler={onRecordUpdateHandler}
record={currentDomain as AuthDomain}
setEditModalOpen={setIsEditModalOpen}
/>
</Modal>
<div className="auth-domains-container">
<AddDomain refetch={refetch} />
<ResizeTable
columns={columns}
dataSource={tableData}
loading={isLoading}
tableLayout="fixed"
rowKey={(record: AuthDomain): string => record.name + v4()}
bordered
/>
</div>
</>
);
}
export default AuthDomains;

View File

@@ -1,15 +0,0 @@
import { Row } from 'antd';
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
`;
export const ColumnWithTooltip = styled(Row)`
&&& > article {
margin-right: 0.5rem;
}
`;

View File

@@ -1,23 +1,10 @@
import { Button, Form, Modal } from 'antd';
import { FormInstance } from 'antd/lib';
import sendInvite from 'api/v1/invite/create';
import get from 'api/v1/invite/get';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from 'react';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { PendingInvite } from 'types/api/user/getPendingInvites';
import { ROLES } from 'types/roles';
import InviteTeamMembers from '../InviteTeamMembers';
import { InviteMemberFormValues } from '../PendingInvitesContainer';
@@ -26,17 +13,7 @@ export interface InviteUserModalProps {
isInviteTeamMemberModalOpen: boolean;
toggleModal: (value: boolean) => void;
form: FormInstance<InviteMemberFormValues>;
setDataSource?: Dispatch<SetStateAction<DataProps[]>>;
shouldCallApi?: boolean;
}
interface DataProps {
key: number;
name: string;
id: string;
email: string;
accessLevel: ROLES;
inviteLink: string;
onClose: () => void;
}
function InviteUserModal(props: InviteUserModalProps): JSX.Element {
@@ -44,54 +21,15 @@ function InviteUserModal(props: InviteUserModalProps): JSX.Element {
isInviteTeamMemberModalOpen,
toggleModal,
form,
setDataSource,
shouldCallApi = false,
onClose,
} = props;
const { notifications } = useNotifications();
const { t } = useTranslation(['organizationsettings', 'common']);
const { user } = useAppContext();
const [isInvitingMembers, setIsInvitingMembers] = useState<boolean>(false);
const [modalForm] = Form.useForm<InviteMemberFormValues>(form);
const getPendingInvitesResponse = useQuery<
SuccessResponseV2<PendingInvite[]>,
APIError
>({
queryFn: get,
queryKey: ['getPendingInvites', user?.accessJwt],
enabled: shouldCallApi,
});
const getParsedInviteData = useCallback(
(payload: PendingInvite[] = []) =>
payload?.map((data) => ({
key: data.createdAt,
name: data?.name,
id: data.id,
email: data.email,
accessLevel: data.role,
inviteLink: `${window.location.origin}${ROUTES.SIGN_UP}?token=${data.token}`,
})),
[],
);
useEffect(() => {
if (
getPendingInvitesResponse.status === 'success' &&
getPendingInvitesResponse?.data?.data
) {
const data = getParsedInviteData(
getPendingInvitesResponse?.data?.data || [],
);
setDataSource?.(data);
}
}, [
getParsedInviteData,
getPendingInvitesResponse?.data?.data,
getPendingInvitesResponse.status,
setDataSource,
]);
const onInviteClickHandler = useCallback(
async (values: InviteMemberFormValues): Promise<void> => {
try {
@@ -119,10 +57,7 @@ function InviteUserModal(props: InviteUserModalProps): JSX.Element {
);
setTimeout(async () => {
const { data, status } = await getPendingInvitesResponse.refetch();
if (status === 'success' && data.data) {
setDataSource?.(getParsedInviteData(data?.data || []));
}
onClose();
setIsInvitingMembers?.(false);
toggleModal(false);
}, 2000);
@@ -134,15 +69,7 @@ function InviteUserModal(props: InviteUserModalProps): JSX.Element {
});
}
},
[
getParsedInviteData,
getPendingInvitesResponse,
notifications,
setDataSource,
setIsInvitingMembers,
t,
toggleModal,
],
[notifications, onClose, t, toggleModal],
);
return (
@@ -177,9 +104,4 @@ function InviteUserModal(props: InviteUserModalProps): JSX.Element {
);
}
InviteUserModal.defaultProps = {
setDataSource: (): void => {},
shouldCallApi: false,
};
export default InviteUserModal;

View File

@@ -3,6 +3,7 @@ import { ColumnsType } from 'antd/lib/table';
import getAll from 'api/v1/user/get';
import deleteUser from 'api/v1/user/id/delete';
import update from 'api/v1/user/id/update';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { ResizeTable } from 'components/ResizeTable';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
@@ -11,9 +12,7 @@ import { useAppContext } from 'providers/App/App';
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { UserResponse } from 'types/api/user/getUsers';
import { ROLES } from 'types/roles';
import DeleteMembersDetails from '../DeleteMembersDetails';
@@ -210,10 +209,8 @@ function UserFunction({
function Members(): JSX.Element {
const { org } = useAppContext();
const { status, data, isLoading } = useQuery<
SuccessResponseV2<UserResponse[]>,
APIError
>({
const { data, isLoading, error } = useQuery({
queryFn: () => getAll(),
queryKey: ['getOrgUser', org?.[0].id],
});
@@ -221,7 +218,7 @@ function Members(): JSX.Element {
const [dataSource, setDataSource] = useState<DataType[]>([]);
useEffect(() => {
if (status === 'success' && data?.data && Array.isArray(data.data)) {
if (data?.data && Array.isArray(data.data)) {
const updatedData: DataType[] = data?.data?.map((e) => ({
accessLevel: e.role,
email: e.email,
@@ -231,7 +228,7 @@ function Members(): JSX.Element {
}));
setDataSource(updatedData);
}
}, [data?.data, status]);
}, [data]);
const columns: ColumnsType<DataType> = [
{
@@ -293,14 +290,17 @@ function Members(): JSX.Element {
<div className="members-count"> ({dataSource.length}) </div>
)}
</Typography.Title>
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={dataSource}
pagination={false}
loading={status === 'loading'}
bordered
/>
{!(error as APIError) && (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={dataSource}
pagination={false}
loading={isLoading}
bordered
/>
)}
{(error as APIError) && <ErrorContent error={error as APIError} />}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { Button, Form, Space, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import get from 'api/v1/invite/get';
import deleteInvite from 'api/v1/invite/id/delete';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { ResizeTable } from 'components/ResizeTable';
import { INVITE_MEMBERS_HASH } from 'constants/app';
import ROUTES from 'constants/routes';
@@ -13,7 +14,6 @@ import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { PendingInvite } from 'types/api/user/getPendingInvites';
import { ROLES } from 'types/roles';
@@ -48,10 +48,7 @@ function PendingInvitesContainer(): JSX.Element {
}
}, [state.error, state.value, t, notifications]);
const getPendingInvitesResponse = useQuery<
SuccessResponseV2<PendingInvite[]>,
APIError
>({
const { data, isLoading, error, isError, refetch } = useQuery({
queryFn: get,
queryKey: ['getPendingInvites', user?.accessJwt],
});
@@ -90,20 +87,11 @@ function PendingInvitesContainer(): JSX.Element {
}, [hash, toggleModal]);
useEffect(() => {
if (
getPendingInvitesResponse.status === 'success' &&
getPendingInvitesResponse?.data?.data
) {
const data = getParsedInviteData(
getPendingInvitesResponse?.data?.data || [],
);
setDataSource(data);
if (data?.data) {
const parsedData = getParsedInviteData(data?.data || []);
setDataSource(parsedData);
}
}, [
getParsedInviteData,
getPendingInvitesResponse?.data?.data,
getPendingInvitesResponse.status,
]);
}, [data, getParsedInviteData]);
const onRevokeHandler = async (id: string): Promise<void> => {
try {
@@ -184,16 +172,15 @@ function PendingInvitesContainer(): JSX.Element {
<InviteUserModal
form={form}
isInviteTeamMemberModalOpen={isInviteTeamMemberModalOpen}
setDataSource={setDataSource}
toggleModal={toggleModal}
shouldCallApi
onClose={refetch}
/>
<div className="pending-invites-container">
<TitleWrapper>
<Typography.Title level={3}>
{t('pending_invites')}
{getPendingInvitesResponse.status !== 'loading' && dataSource && (
{dataSource && (
<div className="members-count"> ({dataSource.length})</div>
)}
</Typography.Title>
@@ -210,14 +197,17 @@ function PendingInvitesContainer(): JSX.Element {
</Button>
</Space>
</TitleWrapper>
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={dataSource}
pagination={false}
loading={getPendingInvitesResponse.status === 'loading'}
bordered
/>
{!isError && (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={dataSource}
pagination={false}
loading={isLoading}
bordered
/>
)}
{isError && <ErrorContent error={error as APIError} />}
</div>
</div>
);

View File

@@ -3,7 +3,7 @@ import './OrganizationSettings.styles.scss';
import { Space } from 'antd';
import { useAppContext } from 'providers/App/App';
import AuthDomains from './AuthDomains';
import AuthDomain from './AuthDomain';
import DisplayName from './DisplayName';
import Members from './Members';
import PendingInvitesContainer from './PendingInvitesContainer';
@@ -26,7 +26,7 @@ function OrganizationSettings(): JSX.Element {
<PendingInvitesContainer />
<Members />
<AuthDomains />
<AuthDomain />
</div>
);
}

View File

@@ -1,4 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { initialQueriesMap } from 'constants/queryBuilder';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { LegendPosition } from 'types/api/dashboard/getAll';
@@ -62,6 +63,7 @@ const baseOptions = {
legendPosition: LegendPosition.BOTTOM,
softMin: null,
softMax: null,
query: initialQueriesMap.metrics,
};
describe('Legend Scroll Position Preservation', () => {

View File

@@ -53,7 +53,6 @@ const getRoute = (key: string): string => {
const useBaseAggregateOptions = ({
query,
onClose,
subMenu,
setSubMenu,
aggregateData,
contextLinks,
@@ -69,8 +68,6 @@ const useBaseAggregateOptions = ({
} = useUpdatedQuery();
const { selectedDashboard } = useDashboard();
console.log('>>V subMenu', subMenu);
useEffect(() => {
if (!aggregateData) return;
const resolveQuery = async (): Promise<void> => {

View File

@@ -162,6 +162,10 @@ export const getDefaultOption = (route: string): Time => {
if (route === ROUTES.APPLICATION) {
return Options[2].value;
}
if (route === ROUTES.METER) {
return Options[5].value;
}
return Options[2].value;
};

View File

@@ -0,0 +1,188 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import ListView from './index';
// Helper to create error response
const createErrorResponse = (
status: number,
code: string,
message: string,
): {
httpStatusCode: number;
error: {
code: string;
message: string;
url: string;
errors: unknown[];
};
} => ({
httpStatusCode: status,
error: {
code,
message,
url: '',
errors: [],
},
});
// Helper to create MSW error handler
const createErrorHandler = (
status: number,
code: string,
message: string,
): ReturnType<typeof rest.post> =>
rest.post(/query-range/, (_req, res, ctx) =>
res(ctx.status(status), ctx.json(createErrorResponse(status, code, message))),
);
// Helper to render with required props
const renderListView = (
props: Record<string, unknown> = {},
): ReturnType<typeof render> => {
const setWarning = jest.fn();
const setIsLoadingQueries = jest.fn();
return render(
<ListView
isFilterApplied={false}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>,
undefined,
{ initialRoute: '/traces-explorer' },
);
};
// Helper to verify all controls are visible
const verifyControlsVisibility = (): void => {
// Order by controls
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
// Pagination controls
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
// Items per page selector (there are multiple comboboxes, so we check for at least 2)
const comboboxes = screen.getAllByRole('combobox');
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
// Options menu (settings button) - check for translation key or actual text
expect(screen.getByText(/options_menu.options|options/i)).toBeInTheDocument();
};
describe('Traces ListView - Error and Empty States', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('Empty State', () => {
it('shows all controls and empty state when filters are applied but no results', async () => {
// MSW default returns empty result
renderListView({ isFilterApplied: true });
// All controls should be visible
verifyControlsVisibility();
// Empty state with filter message should be visible
await waitFor(() => {
expect(screen.getByText(/This query had no results/i)).toBeInTheDocument();
});
});
});
describe('Error States', () => {
it('shows all controls when API returns 400 error', async () => {
server.use(createErrorHandler(400, 'BadRequestError', 'Bad Request'));
renderListView();
// All controls should be visible even when there's an error
verifyControlsVisibility();
// Wait for the component to render
await waitFor(() => {
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
});
});
it('shows all controls when API returns 500 error', async () => {
server.use(
createErrorHandler(500, 'InternalServerError', 'Internal Server Error'),
);
renderListView();
// All controls should be visible even when there's an error
verifyControlsVisibility();
// Wait for the component to render
await waitFor(() => {
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
});
});
it('shows all controls when API returns network error', async () => {
server.use(
rest.post(/query-range/, (_req, res) =>
res.networkError('Failed to connect'),
),
);
renderListView();
// All controls should be visible even when there's an error
verifyControlsVisibility();
// Wait for the component to render
await waitFor(() => {
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
});
});
});
describe('Controls Functionality', () => {
it('allows interaction with controls in error state', async () => {
server.use(createErrorHandler(400, 'BadRequestError', 'Bad Request'));
renderListView();
// Wait for component to render
await waitFor(() => {
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
});
// Order by controls should be interactive
const comboboxes = screen.getAllByRole('combobox');
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
// Pagination controls should be present
const previousButton = screen.getByRole('button', { name: /previous/i });
const nextButton = screen.getByRole('button', { name: /next/i });
expect(previousButton).toBeInTheDocument();
expect(nextButton).toBeInTheDocument();
// Options menu should be clickable
const optionsButton = screen.getByText(/options_menu.options|options/i);
expect(optionsButton).toBeInTheDocument();
});
it('allows interaction with controls in empty state', async () => {
renderListView();
// Wait for empty state
await waitFor(() => {
expect(screen.getByText(/No traces yet/i)).toBeInTheDocument();
});
// All controls should be interactive
const comboboxes = screen.getAllByRole('combobox');
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
// Options menu should be clickable
const optionsButton = screen.getByText(/options_menu.options|options/i);
expect(optionsButton).toBeInTheDocument();
});
});
});

View File

@@ -242,28 +242,26 @@ function ListView({
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
return (
<Container>
{transformedQueryTableData.length !== 0 && (
<div className="trace-explorer-controls">
<div className="order-by-container">
<div className="order-by-label">
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
<ListViewOrderBy
value={orderBy}
onChange={handleOrderChange}
dataSource={DataSource.TRACES}
/>
<div className="trace-explorer-controls">
<div className="order-by-container">
<div className="order-by-label">
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
<TraceExplorerControls
isLoading={isFetching}
totalCount={totalCount}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
<ListViewOrderBy
value={orderBy}
onChange={handleOrderChange}
dataSource={DataSource.TRACES}
/>
</div>
)}
<TraceExplorerControls
isLoading={isFetching}
totalCount={totalCount}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
{isError && error && <ErrorInPlace error={error as APIError} />}

View File

@@ -42,8 +42,8 @@ export const useGetAggregateKeys: UseGetAttributeKeys = (
}, [options?.queryKey, requestData, isInfraMonitoring, infraMonitoringEntity]);
return useQuery<SuccessResponse<IQueryAutocompleteResponse> | ErrorResponse>({
queryKey,
queryFn: () => getAggregateKeys(requestData),
...options,
queryKey,
});
};

View File

@@ -55,7 +55,6 @@ export const useGetQueryKeySuggestions: UseGetQueryKeySuggestions = (
signalSource,
]);
return useQuery<AxiosResponse<QueryKeySuggestionsResponseProps>, AxiosError>({
queryKey,
queryFn: () =>
getKeySuggestions({
signal,
@@ -66,5 +65,6 @@ export const useGetQueryKeySuggestions: UseGetQueryKeySuggestions = (
signalSource,
}),
...options,
queryKey,
});
};

View File

@@ -10,6 +10,7 @@ const useActiveLicenseV3 = (isLoggedIn: boolean): UseLicense =>
queryFn: getActive,
queryKey: [REACT_QUERY_KEY.GET_ACTIVE_LICENSE_V3],
enabled: !!isLoggedIn,
retry: false,
});
type UseLicense = UseQueryResult<SuccessResponseV2<LicenseResModel>, APIError>;

View File

@@ -11,6 +11,7 @@ import { HelmetProvider } from 'react-helmet-async';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import store from 'store';
import APIError from 'types/api/error';
const queryClient = new QueryClient({
defaultOptions: {
@@ -19,9 +20,13 @@ const queryClient = new QueryClient({
retry(failureCount, error): boolean {
if (
// in case of manually throwing errors please make sure to send error.response.status
error instanceof AxiosError &&
error.response?.status &&
(error.response?.status >= 400 || error.response?.status <= 499)
(error instanceof AxiosError &&
error.response?.status &&
error.response?.status >= 400 &&
error.response?.status <= 499) ||
(error instanceof APIError &&
error.getHttpStatusCode() >= 400 &&
error.getHttpStatusCode() <= 499)
) {
return false;
}

View File

@@ -28,6 +28,7 @@ import { prepareQueryRangePayload } from './prepareQueryRangePayload';
import { QueryData } from 'types/api/widgets/getQuery';
import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { EQueryType } from 'types/common/dashboard';
/**
* Validates if metric name is available for METRICS data source
@@ -142,6 +143,11 @@ export const getLegend = (
payloadQuery: Query,
labelName: string,
) => {
// For non-query builder queries, return the label name directly
if (payloadQuery.queryType !== EQueryType.QUERY_BUILDER) {
return labelName;
}
const aggregationPerQuery = payloadQuery?.builder?.queryData.reduce(
(acc, query) => {
if (query.queryName === queryData.queryName) {

View File

@@ -1,4 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { GetUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
export const inputPropsTimeSeries = {
@@ -204,6 +204,7 @@ export const inputPropsTimeSeries = {
softMax: null,
softMin: null,
panelType: PANEL_TYPES.TIME_SERIES,
query: initialQueriesMap.metrics,
} as GetUPlotChartOptions;
export const inputPropsBar = {

View File

@@ -117,6 +117,11 @@ function AlertDetails(): JSX.Element {
}
};
// Show spinner until we have alert data loaded
if (isLoading && !alertRuleDetails) {
return <Spinner />;
}
return (
<CreateAlertProvider
ruleId={ruleId || ''}

View File

@@ -1,16 +1,8 @@
import './Login.styles.scss';
import LoginContainer from 'container/Login';
import useURLQuery from 'hooks/useUrlQuery';
function Login(): JSX.Element {
const urlQueryParams = useURLQuery();
const jwt = urlQueryParams.get('jwt') || '';
const refreshJwt = urlQueryParams.get('refreshjwt') || '';
const userId = urlQueryParams.get('usr') || '';
const ssoerror = urlQueryParams.get('ssoerror') || '';
const withPassword = urlQueryParams.get('password') || '';
return (
<div className="login-page-container">
<div className="perilin-bg" />
@@ -25,13 +17,7 @@ function Login(): JSX.Element {
<div className="brand-title">SigNoz</div>
</div>
<LoginContainer
ssoerror={ssoerror}
jwt={jwt}
refreshjwt={refreshJwt}
userId={userId}
withPassword={withPassword}
/>
<LoginContainer />
</div>
</div>
);

View File

@@ -1,45 +1,33 @@
import { Typography } from 'antd';
import getUserVersion from 'api/v1/version/getVersion';
import getUserVersion from 'api/v1/version/get';
import Spinner from 'components/Spinner';
import ResetPasswordContainer from 'container/ResetPassword';
import { useAppContext } from 'providers/App/App';
import { useTranslation } from 'react-i18next';
import { useQueries } from 'react-query';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useEffect } from 'react';
import { useQuery } from 'react-query';
import APIError from 'types/api/error';
function ResetPassword(): JSX.Element {
const { t } = useTranslation('common');
const { user, isLoggedIn } = useAppContext();
const { showErrorModal } = useErrorModal();
const [versionResponse] = useQueries([
{
queryFn: getUserVersion,
queryKey: ['getUserVersion', user?.accessJwt],
enabled: !isLoggedIn,
},
]);
const { data, isLoading, error } = useQuery({
queryFn: getUserVersion,
queryKey: ['getUserVersion', user?.accessJwt],
enabled: !isLoggedIn,
});
if (
versionResponse.status === 'error' ||
(versionResponse.status === 'success' &&
versionResponse.data?.statusCode !== 200)
) {
return (
<Typography>
{versionResponse.data?.error || t('something_went_wrong')}
</Typography>
);
}
useEffect(() => {
if (error) {
showErrorModal(error as APIError);
}
}, [error, showErrorModal]);
if (
versionResponse.status === 'loading' ||
!(versionResponse.data && versionResponse.data.payload)
) {
if (isLoading) {
return <Spinner tip="Loading..." />;
}
const { version } = versionResponse.data.payload;
return <ResetPasswordContainer version={version} />;
return <ResetPasswordContainer version={data?.data.version || ''} />;
}
export default ResetPassword;

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