feat(tokenizer|sso): add tokenizer for session management and oidc sso support (#9183)

## 📄 Summary

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

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

- Support has been added for OIDC protocol
This commit is contained in:
Vibhu Pandey
2025-10-16 18:00:38 +05:30
committed by GitHub
parent d22039b1a1
commit c122bc09b4
225 changed files with 9291 additions and 9503 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,10 @@ import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/SAML/deleteDomain';
const deleteDomain = async (props: Props): Promise<SuccessResponseV2<null>> => {
const deleteDomain = async (id: string): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete<PayloadProps>(`/domains/${props.id}`);
const response = await axios.delete<null>(`/domains/${id}`);
return {
httpStatusCode: response.status,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,45 +1,33 @@
import { Typography } from 'antd';
import getUserVersion from 'api/v1/version/getVersion';
import getUserVersion from 'api/v1/version/get';
import Spinner from 'components/Spinner';
import ResetPasswordContainer from 'container/ResetPassword';
import { useAppContext } from 'providers/App/App';
import { useTranslation } from 'react-i18next';
import { useQueries } from 'react-query';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useEffect } from 'react';
import { useQuery } from 'react-query';
import APIError from 'types/api/error';
function ResetPassword(): JSX.Element {
const { t } = useTranslation('common');
const { user, isLoggedIn } = useAppContext();
const { showErrorModal } = useErrorModal();
const [versionResponse] = useQueries([
{
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;

View File

@@ -4,12 +4,10 @@ import { Button, Form, Input, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import accept from 'api/v1/invite/id/accept';
import getInviteDetails from 'api/v1/invite/id/get';
import loginApi from 'api/v1/login/login';
import signUpApi from 'api/v1/register/signup';
import signUpApi from 'api/v1/register/post';
import passwordAuthNContext from 'api/v2/sessions/email_password/post';
import afterLogin from 'AppRoutes/utils';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useEffect, useState } from 'react';
import { useQuery } from 'react-query';
@@ -17,10 +15,8 @@ import { useLocation } from 'react-router-dom';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { InviteDetails } from 'types/api/user/getInviteDetails';
import { Signup as LoginPrecheckPayloadProps } from 'types/api/user/loginPrecheck';
import { FormContainer, Label } from './styles';
import { isPasswordNotValidMessage, isPasswordValid } from './utils';
type FormValues = {
email: string;
@@ -34,17 +30,9 @@ type FormValues = {
function SignUp(): JSX.Element {
const [loading, setLoading] = useState(false);
const [precheck, setPrecheck] = useState<LoginPrecheckPayloadProps>({
sso: false,
isUser: false,
});
const [confirmPasswordError, setConfirmPasswordError] = useState<boolean>(
false,
);
const [isPasswordPolicyError, setIsPasswordPolicyError] = useState<boolean>(
false,
);
const { search } = useLocation();
const params = new URLSearchParams(search);
const token = params.get('token');
@@ -71,7 +59,6 @@ function SignUp(): JSX.Element {
getInviteDetailsResponse.data.data
) {
const responseDetails = getInviteDetailsResponse.data.data;
if (responseDetails.precheck) setPrecheck(responseDetails.precheck);
form.setFieldValue('firstName', responseDetails.name);
form.setFieldValue('email', responseDetails.email);
form.setFieldValue('organizationName', responseDetails.organization);
@@ -115,20 +102,20 @@ function SignUp(): JSX.Element {
const signUp = async (values: FormValues): Promise<void> => {
try {
const { organizationName, password, email } = values;
await signUpApi({
const user = await signUpApi({
email,
orgDisplayName: organizationName,
password,
token: params.get('token') || undefined,
});
const loginResponse = await loginApi({
const token = await passwordAuthNContext({
email,
password,
orgId: user.data.orgId,
});
const { data } = loginResponse;
await afterLogin(data.userId, data.accessJwt, data.refreshJwt);
await afterLogin(token.data.accessToken, token.data.refreshToken);
} catch (error) {
showErrorModal(error as APIError);
}
@@ -137,16 +124,17 @@ function SignUp(): JSX.Element {
const acceptInvite = async (values: FormValues): Promise<void> => {
try {
const { password, email } = values;
await accept({
const user = await accept({
password,
token: params.get('token') || '',
});
const loginResponse = await loginApi({
const token = await passwordAuthNContext({
email,
password,
orgId: user.data.orgId,
});
const { data } = loginResponse;
await afterLogin(data.userId, data.accessJwt, data.refreshJwt);
await afterLogin(token.data.accessToken, token.data.refreshToken);
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
@@ -155,42 +143,6 @@ function SignUp(): JSX.Element {
}
};
const handleSubmitSSO = async (): Promise<void> => {
if (!params.get('token')) {
notifications.error({
message:
'Invite token is required for signup, please request one from your admin',
});
return;
}
setLoading(true);
try {
const response = await accept({
password: '',
token: params.get('token') || '',
sourceUrl: encodeURIComponent(window.location.href),
});
if (response.data?.sso) {
if (response.data?.ssoUrl) {
window.location.href = response.data?.ssoUrl;
} else {
notifications.error({
message: 'Signup completed but failed to initiate login',
});
// take user to login page as there is nothing to do here
history.push(ROUTES.LOGIN);
}
}
} catch (error) {
notifications.error({
message: 'Something went wrong',
});
}
setLoading(false);
};
// eslint-disable-next-line sonarjs/cognitive-complexity
const handleSubmit = (): void => {
(async (): Promise<void> => {
@@ -198,15 +150,6 @@ function SignUp(): JSX.Element {
const values = form.getFieldsValue();
setLoading(true);
if (!isPasswordValid(values.password)) {
logEvent('Account Creation Page - Invalid Password', {
email: values.email,
});
setIsPasswordPolicyError(true);
setLoading(false);
return;
}
if (isSignUp) {
await signUp(values);
logEvent('Account Created Successfully', {
@@ -232,9 +175,6 @@ function SignUp(): JSX.Element {
if ('password' in changedValues || 'confirmPassword' in changedValues) {
const { password, confirmPassword } = form.getFieldsValue();
const isInvalidPassword = !isPasswordValid(password) && password.length > 0;
setIsPasswordPolicyError(isInvalidPassword);
const isSamePassword = password === confirmPassword;
setConfirmPasswordError(!isSamePassword);
}
@@ -245,9 +185,9 @@ function SignUp(): JSX.Element {
return (
loading ||
!values.email ||
(!precheck.sso && (!values.password || !values.confirmPassword)) ||
confirmPasswordError ||
isPasswordPolicyError
!values.password ||
!values.confirmPassword ||
confirmPasswordError
);
};
@@ -266,7 +206,7 @@ function SignUp(): JSX.Element {
</div>
<FormContainer
onFinish={!precheck.sso ? handleSubmit : handleSubmitSSO}
onFinish={handleSubmit}
onValuesChange={handleValuesChange}
form={form}
className="signup-form"
@@ -292,8 +232,6 @@ function SignUp(): JSX.Element {
</FormContainer.Item>
</div>
{!precheck.sso && (
<>
<div className="password-container">
<Label htmlFor="currentPassword">Password</Label>
<FormContainer.Item noStyle name="password">
@@ -307,8 +245,6 @@ function SignUp(): JSX.Element {
<Input.Password required id="confirmPassword" />
</FormContainer.Item>
</div>
</>
)}
<div className="password-error-container">
{confirmPasswordError && (
@@ -319,12 +255,6 @@ function SignUp(): JSX.Element {
Passwords dont match. Please try again
</Typography.Paragraph>
)}
{isPasswordPolicyError && (
<Typography.Paragraph className="password-error-message">
{isPasswordNotValidMessage}
</Typography.Paragraph>
)}
</div>
{isSignUp && (

View File

@@ -1,13 +0,0 @@
/**
* @function
* @description to check whether password is valid or not
* @reference stackoverflow.com/a/69807687
* @returns Boolean
*/
export const isPasswordValid = (value: string): boolean => {
// eslint-disable-next-line prefer-regex-literals
const pattern = new RegExp('^.{8,}$');
return pattern.test(value);
};
export const isPasswordNotValidMessage = `Password must a have minimum of 8 characters`;

View File

@@ -1,14 +1,13 @@
import getLocalStorageApi from 'api/browser/localstorage/get';
import { Logout } from 'api/utils';
import listOrgPreferences from 'api/v1/org/preferences/list';
import get from 'api/v1/user/me/get';
import listUserPreferences from 'api/v1/user/preferences/list';
import getUserVersion from 'api/v1/version/getVersion';
import getUserVersion from 'api/v1/version/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import dayjs from 'dayjs';
import useActiveLicenseV3 from 'hooks/useActiveLicenseV3/useActiveLicenseV3';
import { useGetFeatureFlag } from 'hooks/useGetFeatureFlag';
import { useGlobalEventListener } from 'hooks/useGlobalEventListener';
import useGetUser from 'hooks/user/useGetUser';
import {
createContext,
PropsWithChildren,
@@ -40,7 +39,7 @@ import { getUserDefaults } from './utils';
export const AppContext = createContext<IAppContext | undefined>(undefined);
export function AppProvider({ children }: PropsWithChildren): JSX.Element {
// on load of the provider set the user defaults with access jwt , refresh jwt and user id from local storage
// on load of the provider set the user defaults with access token , refresh token from local storage
const [user, setUser] = useState<IUser>(() => getUserDefaults());
const [activeLicense, setActiveLicense] = useState<LicenseResModel | null>(
null,
@@ -63,13 +62,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
const [showChangelogModal, setShowChangelogModal] = useState<boolean>(false);
// if the user.id is not present, for migration older cases then we need to logout only for current logged in users!
useEffect(() => {
if (!user.id && isLoggedIn) {
Logout();
}
}, [isLoggedIn, user]);
// fetcher for user
// user will only be fetched if the user id and token is present
// if logged out and trying to hit any route none of these calls will trigger
@@ -77,7 +69,12 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
data: userData,
isFetching: isFetchingUser,
error: userFetchError,
} = useGetUser(user.id, isLoggedIn);
} = useQuery({
queryFn: get,
queryKey: ['/api/v1/user/me'],
enabled: isLoggedIn,
});
useEffect(() => {
if (!isFetchingUser && userData && userData.data) {
setUser((prev) => ({
@@ -320,7 +317,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
updateOrg,
updateChangelog,
toggleChangelogModal,
versionData: versionData?.payload || null,
versionData: versionData?.data || null,
hasEditPermission:
user?.role === USER_ROLES.ADMIN || user?.role === USER_ROLES.EDITOR,
}),

View File

@@ -8,7 +8,7 @@ import {
} from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { UserResponse as User } from 'types/api/user/getUser';
import { PayloadProps } from 'types/api/user/getVersion';
import { Info } from 'types/api/v1/version/get';
export interface IAppContext {
user: IUser;
@@ -36,7 +36,7 @@ export interface IAppContext {
updateOrg(orgId: string, updatedOrgName: string): void;
updateChangelog(payload: ChangelogSchema): void;
toggleChangelogModal(): void;
versionData: PayloadProps | null;
versionData: Info | null;
hasEditPermission: boolean;
}

View File

@@ -10,12 +10,11 @@ function getUserDefaults(): IUser {
getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN),
'',
);
const userId = defaultTo(getLocalStorageApi(LOCALSTORAGE.USER_ID), '');
return {
accessJwt,
refreshJwt,
id: userId,
id: '',
email: '',
displayName: '',
createdAt: 0,

View File

@@ -1,7 +1,7 @@
import { apiV3 } from 'api/apiV1';
import getLocalStorageApi from 'api/browser/localstorage/get';
import { Logout } from 'api/utils';
import loginApi from 'api/v1/login/login';
import post from 'api/v2/sessions/rotate/post';
import afterLogin from 'AppRoutes/utils';
import { ENVIRONMENT } from 'constants/env';
import { LIVE_TAIL_HEARTBEAT_TIMEOUT } from 'constants/liveTail';
@@ -18,6 +18,7 @@ import {
useRef,
useState,
} from 'react';
import { useQueryClient } from 'react-query';
import APIError from 'types/api/error';
interface IEventSourceContext {
@@ -58,6 +59,7 @@ export function EventSourceProvider({
const eventSourceRef = useRef<EventSourcePolyfill | null>(null);
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const handleSetInitialLoading = useCallback((value: boolean) => {
setInitialLoading(value);
@@ -75,15 +77,15 @@ export function EventSourceProvider({
setInitialLoading(false);
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);
// If token refresh was successful, we'll let the component
// handle reconnection through the reconnectDueToError state
setReconnectDueToError(true);
@@ -101,7 +103,7 @@ export function EventSourceProvider({
eventSourceRef.current.close();
Logout();
}
}, [notifications]);
}, [notifications, queryClient]);
const destroyEventSourceSession = useCallback(() => {
if (!eventSourceRef.current) return;

View File

@@ -55,3 +55,8 @@ export interface Warning {
url: string;
warnings: AdditionalWarnings[];
}
export interface RawSuccessResponse<T> {
status: string;
data: T;
}

View File

@@ -1,3 +1,5 @@
import { UserResponse } from './getUser';
export interface Props {
token: string;
password: string;
@@ -13,6 +15,6 @@ export interface LoginPrecheckResponse {
}
export interface PayloadProps {
data: LoginPrecheckResponse;
data: UserResponse;
status: string;
}

View File

@@ -2,7 +2,6 @@ import { User } from 'types/reducer/app';
import { ROLES } from 'types/roles';
import { Organization } from './getOrganization';
import { Signup as LoginPrecheckPayloadProps } from './loginPrecheck';
export interface Props {
inviteId: string;
@@ -20,5 +19,4 @@ export interface InviteDetails {
role: ROLES;
token: string;
organization: Organization['displayName'];
precheck?: LoginPrecheckPayloadProps;
}

View File

@@ -1,18 +0,0 @@
export interface PayloadProps {
data: UserLoginResponse;
status: string;
}
export interface Props {
email?: string;
password?: string;
refreshToken?: UserLoginResponse['refreshJwt'];
}
export interface UserLoginResponse {
accessJwt: string;
accessJwtExpiry: number;
refreshJwt: string;
refreshJwtExpiry: number;
userId: string;
}

View File

@@ -1,16 +0,0 @@
export interface PayloadProps {
data: Signup;
status: string;
}
export interface Signup {
sso: boolean;
ssoUrl?: string;
canSelfRegister?: boolean;
isUser: boolean;
}
export interface Props {
email: string;
path?: string;
}

View File

@@ -0,0 +1,44 @@
export const SSOType = new Map<string, string>([
['google_auth', 'Google Auth'],
['saml', 'SAML'],
['email_password', 'Email Password'],
['oidc', 'OIDC'],
]);
export interface GettableAuthDomain {
id: string;
name: string;
orgId: string;
ssoEnabled: boolean;
ssoType: string;
samlConfig?: SAMLConfig;
googleAuthConfig?: GoogleAuthConfig;
oidcConfig?: OIDCConfig;
}
export interface SAMLConfig {
samlEntity: string;
samlIdp: string;
samlCert: string;
insecureSkipAuthNRequestsSigned: boolean;
}
export interface GoogleAuthConfig {
clientId: string;
clientSecret: string;
redirectURI: string;
}
export interface OIDCConfig {
issuer: string;
issuerAlias: string;
clientId: string;
clientSecret: string;
claimMapping: ClaimMapping;
insecureSkipEmailVerified: boolean;
getUserInfo: boolean;
}
export interface ClaimMapping {
email: string;
}

View File

@@ -0,0 +1,39 @@
export interface PostableAuthDomain {
name: string;
config: Config;
}
export interface Config {
ssoEnabled: boolean;
ssoType: string;
samlConfig?: SAMLConfig;
googleAuthConfig?: GoogleAuthConfig;
oidcConfig?: OIDCConfig;
}
export interface SAMLConfig {
samlEntity: string;
samlIdp: string;
samlCert: string;
insecureSkipAuthNRequestsSigned: boolean;
}
export interface GoogleAuthConfig {
clientId: string;
clientSecret: string;
redirectURI: string;
}
export interface OIDCConfig {
issuer: string;
issuerAlias: string;
clientId: string;
clientSecret: string;
claimMapping: ClaimMapping;
insecureSkipEmailVerified: boolean;
getUserInfo: boolean;
}
export interface ClaimMapping {
email: string;
}

View File

@@ -0,0 +1,37 @@
export interface UpdatableAuthDomain {
config: {
ssoEnabled: boolean;
ssoType: string;
samlConfig?: SAMLConfig;
googleAuthConfig?: GoogleAuthConfig;
oidcConfig?: OIDCConfig;
};
id: string;
}
export interface SAMLConfig {
samlEntity: string;
samlIdp: string;
samlCert: string;
insecureSkipAuthNRequestsSigned: boolean;
}
export interface GoogleAuthConfig {
clientId: string;
clientSecret: string;
redirectURI: string;
}
export interface OIDCConfig {
issuer: string;
issuerAlias: string;
clientId: string;
clientSecret: string;
claimMapping: ClaimMapping;
insecureSkipEmailVerified: boolean;
getUserInfo: boolean;
}
export interface ClaimMapping {
email: string;
}

View File

@@ -0,0 +1,10 @@
import { ROLES } from 'types/roles';
export interface SignupResponse {
createdAt: number;
email: string;
id: string;
displayName: string;
orgId: string;
role: ROLES;
}

View File

@@ -1,4 +1,4 @@
export interface PayloadProps {
export interface Info {
version: string;
ee: 'Y' | 'N';
setupCompleted: boolean;

View File

@@ -0,0 +1,32 @@
import { ErrorV2 } from 'types/api';
export interface Props {
email: string;
ref: string;
}
export interface PasswordAuthN {
provider: string;
}
export interface CallbackAuthN {
provider: string;
url: string;
}
export interface AuthNSupport {
password: PasswordAuthN[];
callback: CallbackAuthN[];
}
export interface OrgSessionContext {
id: string;
name: string;
authNSupport: AuthNSupport;
warning?: ErrorV2;
}
export interface SessionsContext {
exists: boolean;
orgs: OrgSessionContext[];
}

View File

@@ -0,0 +1,10 @@
export interface Props {
email: string;
password: string;
orgId: string;
}
export interface Token {
accessToken: string;
refreshToken: string;
}

View File

@@ -0,0 +1,8 @@
export interface Props {
refreshToken: string;
}
export interface Token {
accessToken: string;
refreshToken: string;
}

14
go.mod
View File

@@ -9,6 +9,7 @@ require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.129.4
github.com/allegro/bigcache/v3 v3.1.0
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/cespare/xxhash/v2 v2.3.0
@@ -17,8 +18,7 @@ require (
github.com/go-co-op/gocron v1.30.1
github.com/go-openapi/runtime v0.28.0
github.com/go-openapi/strfmt v0.23.0
github.com/go-redis/redis/v8 v8.11.5
github.com/go-redis/redismock/v8 v8.11.5
github.com/go-redis/redismock/v9 v9.2.0
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/gojek/heimdall/v7 v7.0.3
github.com/golang-jwt/jwt/v5 v5.3.0
@@ -44,6 +44,8 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.66.1
github.com/prometheus/prometheus v0.304.1
github.com/redis/go-redis/extra/redisotel/v9 v9.15.1
github.com/redis/go-redis/v9 v9.15.1
github.com/rs/cors v1.11.1
github.com/russellhaering/gosaml2 v0.9.0
github.com/russellhaering/goxmldsig v1.2.0
@@ -59,10 +61,12 @@ require (
github.com/uptrace/bun v1.2.9
github.com/uptrace/bun/dialect/pgdialect v1.2.9
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9
github.com/uptrace/bun/extra/bunotel v1.2.9
go.opentelemetry.io/collector/confmap v1.34.0
go.opentelemetry.io/collector/otelcol v0.128.0
go.opentelemetry.io/collector/pdata v1.34.0
go.opentelemetry.io/contrib/config v0.10.0
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.63.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/metric v1.38.0
@@ -85,7 +89,9 @@ require (
require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
@@ -297,7 +303,7 @@ require (
go.opentelemetry.io/collector/receiver/receiverhelper v0.128.0 // indirect
go.opentelemetry.io/collector/receiver/receivertest v0.128.0 // indirect
go.opentelemetry.io/collector/receiver/xreceiver v0.128.0 // indirect
go.opentelemetry.io/collector/semconv v0.128.0 // indirect
go.opentelemetry.io/collector/semconv v0.128.0
go.opentelemetry.io/collector/service v0.128.0 // indirect
go.opentelemetry.io/collector/service/hostcapabilities v0.128.0 // indirect
go.opentelemetry.io/contrib/bridges/otelzap v0.11.0 // indirect
@@ -314,7 +320,7 @@ require (
go.opentelemetry.io/otel/exporters/prometheus v0.58.0
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.12.2 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 // indirect
go.opentelemetry.io/otel/log v0.12.2 // indirect
go.opentelemetry.io/otel/sdk/log v0.12.2 // indirect
go.opentelemetry.io/otel/sdk/metric v1.38.0

53
go.sum
View File

@@ -118,6 +118,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
github.com/allegro/bigcache/v3 v3.1.0 h1:H2Vp8VOvxcrB91o86fUSVJFqeuz8kpyyB02eH3bSzwk=
github.com/allegro/bigcache/v3 v3.1.0/go.mod h1:aPyh7jEvrog9zAwx5N7+JUQX5dZTSGpxF1LAR4dr35I=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
@@ -158,6 +160,10 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cactus/go-statsd-client/statsd v0.0.0-20200423205355-cb0885a1018c/go.mod h1:l/bIBLeOl9eX+wxJAzxS4TveKRtAqlyDpHjhkfO0MEI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
@@ -166,7 +172,6 @@ github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -267,7 +272,6 @@ github.com/foxboron/go-tpm-keyfiles v0.0.0-20250323135004-b31fac66206e/go.mod h1
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -325,16 +329,13 @@ github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvSc
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-redis/redismock/v8 v8.11.5 h1:RJFIiua58hrBrSpXhnGX3on79AU3S271H4ZhRI1wyVo=
github.com/go-redis/redismock/v8 v8.11.5/go.mod h1:UaAU9dEe1C+eGr+FHV5prCWIt0hafyPWbGMEWE0UWdA=
github.com/go-redis/redismock/v9 v9.2.0 h1:ZrMYQeKPECZPjOj5u9eyOjg8Nnb0BS9lkVIZ6IpsKLw=
github.com/go-redis/redismock/v9 v9.2.0/go.mod h1:18KHfGDK4Y6c2R0H38EUGWAdc7ZQS9gfYxc94k7rWT0=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
@@ -439,7 +440,6 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
@@ -562,7 +562,6 @@ github.com/hetznercloud/hcloud-go/v2 v2.21.0 h1:wUpQT+fgAxIcdMtFvuCJ78ziqc/VARub
github.com/hetznercloud/hcloud-go/v2 v2.21.0/go.mod h1:WSM7w+9tT86sJTNcF8a/oHljC3HUmQfcLxYsgx6PpSc=
github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs=
github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs=
github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs=
github.com/huandu/go-sqlbuilder v1.35.0 h1:ESvxFHN8vxCTudY1Vq63zYpU5yJBESn19sf6k4v2T5Q=
@@ -736,7 +735,6 @@ github.com/natefinch/wrap v0.2.0/go.mod h1:6gMHlAl12DwYEfKP3TkuykYUfLSEAvHw67itm
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
@@ -746,16 +744,8 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/open-telemetry/opamp-go v0.19.0 h1:8LvQKDwqi+BU3Yy159SU31e2XB0vgnk+PN45pnKilPs=
@@ -869,8 +859,12 @@ github.com/prometheus/sigv4 v0.1.2/go.mod h1:GF9fwrvLgkQwDdQ5BXeV9XUSCH/IPNqzvAo
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.9.0 h1:URbPQ4xVQSQhZ27WMQVmZSo3uT3pL+4IdHVcYq2nVfM=
github.com/redis/go-redis/v9 v9.9.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 h1:G3pzZlMvMX9VX9TBB8zr03CAkeyMtbyW2D59PdyaGkM=
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1/go.mod h1:JiJ4f0bngycE8LQqzY/4TB23witBbFnlUS6hPvHn6Zc=
github.com/redis/go-redis/extra/redisotel/v9 v9.15.1 h1:gvNK57rhjwIjAiGTSZH2+XO37mcLyYCsJC1qlNUnBjs=
github.com/redis/go-redis/extra/redisotel/v9 v9.15.1/go.mod h1:O41kV1OVBXIT0Tipo902iT8+rbqF0zL5v5paLxp5/7s=
github.com/redis/go-redis/v9 v9.15.1 h1:BVn5z3pdIKIr5WI4Yv1MRXslB616gqBLBgVmhykiHIw=
github.com/redis/go-redis/v9 v9.15.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
@@ -1005,6 +999,10 @@ github.com/uptrace/bun/dialect/pgdialect v1.2.9 h1:caf5uFbOGiXvadV6pA5gn87k0awFF
github.com/uptrace/bun/dialect/pgdialect v1.2.9/go.mod h1:m7L9JtOp/Lt8HccET70ULxplMweE/u0S9lNUSxz2duo=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9 h1:HLzGWXBh07sT8zhVPy6veYbbGrAtYq0KzyRHXBj+GjA=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9/go.mod h1:dUR+ecoCWA0FIa9vhQVRnGtYYPpuCLJoEEtX9E1aiBU=
github.com/uptrace/bun/extra/bunotel v1.2.9 h1:BGGrBga+iVL78SGiMpLt2N9MAKvrG3f8wLk8zCLwFJg=
github.com/uptrace/bun/extra/bunotel v1.2.9/go.mod h1:6dVl5Ko6xOhuoqUPWHpfFrntBDwmOnq0OMiR/SGwAC8=
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 h1:ZjUj9BLYf9PEqBn8W/OapxhPjVRdC6CsXTdULHsyk5c=
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2/go.mod h1:O8bHQfyinKwTXKkiKNGmLQS7vRsqRxIQTFZpYpHK3IQ=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/vjeantet/grok v1.0.1 h1:2rhIR7J4gThTgcZ1m2JY4TrJZNgjn985U28kT2wQrJ4=
@@ -1164,6 +1162,8 @@ go.opentelemetry.io/contrib/bridges/otelzap v0.11.0 h1:u2E32P7j1a/gRgZDWhIXC+Shd
go.opentelemetry.io/contrib/bridges/otelzap v0.11.0/go.mod h1:pJPCLM8gzX4ASqLlyAXjHBEYxgbOQJ/9bidWxD6PEPQ=
go.opentelemetry.io/contrib/config v0.10.0 h1:2JknAzMaYjxrHkTnZh3eOme/Y2P5eHE2SWfhfV6Xd6c=
go.opentelemetry.io/contrib/config v0.10.0/go.mod h1:aND2M6/KfNkntI5cyvHriR/zvZgPf8j9yETdSmvpfmc=
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.63.0 h1:rATLgFjv0P9qyXQR/aChJ6JVbMtXOQjt49GgT36cBbk=
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.63.0/go.mod h1:34csimR1lUhdT5HH4Rii9aKPrvBcnFRwxLwcevsU+Kk=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 h1:0tY123n7CdWMem7MOVdKOt0YfshufLCwfE5Bob+hQuM=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0/go.mod h1:CosX/aS4eHnG9D7nESYpV753l4j9q5j3SL/PUYd2lR8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
@@ -1196,8 +1196,8 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.12.2 h1:12vMqzLLNZtXuXbJh
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.12.2/go.mod h1:ZccPZoPOoq8x3Trik/fCsba7DEYDUnN6yX79pgp2BUQ=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0 h1:G8Xec/SgZQricwWBJF/mHZc7A02YHedfFDENwJEdRA0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0/go.mod h1:PD57idA/AiFD5aqoxGxCvT/ILJPeHy3MjqU/NS7KogY=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE=
go.opentelemetry.io/otel/log v0.12.2 h1:yob9JVHn2ZY24byZeaXpTVoPS6l+UrrxmxmPKohXTwc=
go.opentelemetry.io/otel/log v0.12.2/go.mod h1:ShIItIxSYxufUMt+1H5a2wbckGli3/iCfuEbVZi/98E=
go.opentelemetry.io/otel/log/logtest v0.0.0-20250526142609-aa5bd0e64989 h1:4JF7oY9CcHrPGfBLijDcXZyCzGckVEyOjuat5ktmQRg=
@@ -1291,7 +1291,6 @@ golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1315,7 +1314,6 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
@@ -1331,7 +1329,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
@@ -1381,7 +1378,6 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1395,7 +1391,6 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1403,7 +1398,6 @@ golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1430,7 +1424,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1539,7 +1532,6 @@ golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82u
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
@@ -1752,7 +1744,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=

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