Compare commits
22 Commits
v0.97.0-51
...
tvats-cust
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
616481d426 | ||
|
|
6765f049a2 | ||
|
|
206a896f93 | ||
|
|
406cad5be5 | ||
|
|
6d416061f2 | ||
|
|
b8cf0a3041 | ||
|
|
1793026c78 | ||
|
|
c122bc09b4 | ||
|
|
d22039b1a1 | ||
|
|
457b6970b1 | ||
|
|
1267f9ad6e | ||
|
|
ecd9498970 | ||
|
|
bac8f8b211 | ||
|
|
800a34e625 | ||
|
|
934c09b36b | ||
|
|
c62d41edf0 | ||
|
|
264af06ca0 | ||
|
|
dcc902fb27 | ||
|
|
a4f24a231b | ||
|
|
416e8d2a5e | ||
|
|
43a6c7dcd6 | ||
|
|
5005cae2ad |
19
.github/workflows/integrationci.yaml
vendored
19
.github/workflows/integrationci.yaml
vendored
@@ -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 && \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
191
ee/authn/callbackauthn/oidccallbackauthn/authn.go
Normal file
191
ee/authn/callbackauthn/oidccallbackauthn/authn.go
Normal 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
|
||||
}
|
||||
155
ee/authn/callbackauthn/samlcallbackauthn/authn.go
Normal file
155
ee/authn/callbackauthn/samlcallbackauthn/authn.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
25
frontend/src/api/v1/domains/id/put.ts
Normal file
25
frontend/src/api/v1/domains/id/put.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
26
frontend/src/api/v1/domains/post.ts
Normal file
26
frontend/src/api/v1/domains/post.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
27
frontend/src/api/v1/register/post.ts
Normal file
27
frontend/src/api/v1/register/post.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
27
frontend/src/api/v2/sessions/context/get.ts
Normal file
27
frontend/src/api/v2/sessions/context/get.ts
Normal 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;
|
||||
19
frontend/src/api/v2/sessions/delete.ts
Normal file
19
frontend/src/api/v2/sessions/delete.ts
Normal 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;
|
||||
23
frontend/src/api/v2/sessions/email_password/post.ts
Normal file
23
frontend/src/api/v2/sessions/email_password/post.ts
Normal 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;
|
||||
23
frontend/src/api/v2/sessions/rotate/post.ts
Normal file
23
frontend/src/api/v2/sessions/rotate/post.ts
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,9 @@ function HeaderRightSection({
|
||||
className="share-feedback-btn periscope-btn ghost"
|
||||
icon={<SquarePen size={14} />}
|
||||
onClick={handleOpenFeedbackModal}
|
||||
/>
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
const SOMETHING_WENT_WRONG = 'Something went wrong';
|
||||
|
||||
const getVersion = 'version';
|
||||
|
||||
export { getVersion, SOMETHING_WENT_WRONG };
|
||||
export { SOMETHING_WENT_WRONG };
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import EndPointDetails from '../Explorer/Domains/DomainDetails/EndPointDetails';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ jest.mock(
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
frontend/src/container/EditAlertV2/utils.tsx
Normal file
16
frontend/src/container/EditAlertV2/utils.tsx
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -52,10 +52,6 @@ jest.mock(
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('api/common/getQueryStats', () => ({
|
||||
getQueryStats: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('constants/panelTypes', () => ({
|
||||
AVAILABLE_EXPORT_PANEL_TYPES: ['graph', 'table'],
|
||||
}));
|
||||
|
||||
@@ -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 you’re 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 you’re 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.
|
||||
<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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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> => {
|
||||
|
||||
@@ -408,6 +408,7 @@ export default function Onboarding(): JSX.Element {
|
||||
form={form}
|
||||
isInviteTeamMemberModalOpen={isInviteTeamMemberModalOpen}
|
||||
toggleModal={toggleModal}
|
||||
onClose={(): void => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 project’s 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigureGoogleAuthAuthnProvider;
|
||||
@@ -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 won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigureOIDCAuthnProvider;
|
||||
@@ -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 won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigureSAMLAuthnProvider;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
148
frontend/src/container/OrganizationSettings/AuthDomain/index.tsx
Normal file
148
frontend/src/container/OrganizationSettings/AuthDomain/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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 project’s 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;
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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 won’t be enabled unless you enter all the attributes above
|
||||
</Typography>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditGoogleAuth;
|
||||
@@ -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 won’t be enabled unless you enter all the attributes above
|
||||
</Typography>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditSAML;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
188
frontend/src/container/TracesExplorer/ListView/index.test.tsx
Normal file
188
frontend/src/container/TracesExplorer/ListView/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />}
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@ export const useGetAggregateKeys: UseGetAttributeKeys = (
|
||||
}, [options?.queryKey, requestData, isInfraMonitoring, infraMonitoringEntity]);
|
||||
|
||||
return useQuery<SuccessResponse<IQueryAutocompleteResponse> | ErrorResponse>({
|
||||
queryKey,
|
||||
queryFn: () => getAggregateKeys(requestData),
|
||||
...options,
|
||||
queryKey,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 || ''}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user