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:
17
.github/workflows/integrationci.yaml
vendored
17
.github/workflows/integrationci.yaml
vendored
@@ -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 && \
|
||||
|
||||
@@ -3,11 +3,11 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/cmd"
|
||||
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
|
||||
@@ -56,12 +56,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
return err
|
||||
}
|
||||
|
||||
jwt := authtypes.NewJWT(cmd.NewJWTSecret(ctx, logger), 30*time.Minute, 30*24*time.Hour)
|
||||
|
||||
signoz, err := signoz.New(
|
||||
ctx,
|
||||
config,
|
||||
jwt,
|
||||
zeus.Config{},
|
||||
noopzeus.NewProviderFactory(),
|
||||
licensing.Config{},
|
||||
@@ -76,13 +73,16 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
},
|
||||
signoz.NewSQLStoreProviderFactories(),
|
||||
signoz.NewTelemetryStoreProviderFactories(),
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create signoz", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
server, err := app.NewServer(config, signoz, jwt)
|
||||
server, err := app.NewServer(config, signoz)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create server", "error", err)
|
||||
return err
|
||||
|
||||
@@ -3,7 +3,6 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/config"
|
||||
"github.com/SigNoz/signoz/pkg/config/envprovider"
|
||||
@@ -30,12 +29,3 @@ func NewSigNozConfig(ctx context.Context, logger *slog.Logger, flags signoz.Depr
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func NewJWTSecret(ctx context.Context, logger *slog.Logger) string {
|
||||
jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET")
|
||||
if len(jwtSecret) == 0 {
|
||||
logger.ErrorContext(ctx, "🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!", "error", "SIGNOZ_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application. Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access. Please set the SIGNOZ_JWT_SECRET environment variable immediately. For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.")
|
||||
}
|
||||
|
||||
return jwtSecret
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/cmd"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
|
||||
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
|
||||
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
|
||||
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
|
||||
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
@@ -54,17 +57,14 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
// add enterprise sqlstore factories to the community sqlstore factories
|
||||
sqlstoreFactories := signoz.NewSQLStoreProviderFactories()
|
||||
if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil {
|
||||
if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory(), sqlstorehook.NewInstrumentationFactory())); err != nil {
|
||||
logger.ErrorContext(ctx, "failed to add postgressqlstore factory", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
jwt := authtypes.NewJWT(cmd.NewJWTSecret(ctx, logger), 30*time.Minute, 30*24*time.Hour)
|
||||
|
||||
signoz, err := signoz.New(
|
||||
ctx,
|
||||
config,
|
||||
jwt,
|
||||
enterprisezeus.Config(),
|
||||
httpzeus.NewProviderFactory(),
|
||||
enterpriselicensing.Config(24*time.Hour, 3),
|
||||
@@ -84,13 +84,34 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
},
|
||||
sqlstoreFactories,
|
||||
signoz.NewTelemetryStoreProviderFactories(),
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authNs[authtypes.AuthNProviderSAML] = samlCallbackAuthN
|
||||
authNs[authtypes.AuthNProviderOIDC] = oidcCallbackAuthN
|
||||
|
||||
return authNs, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create signoz", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
server, err := enterpriseapp.NewServer(config, signoz, jwt)
|
||||
server, err := enterpriseapp.NewServer(config, signoz)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create server", "error", err)
|
||||
return err
|
||||
|
||||
@@ -243,3 +243,28 @@ statsreporter:
|
||||
gateway:
|
||||
# The URL of the gateway's api.
|
||||
url: http://localhost:8080
|
||||
|
||||
##################### Tokenizer #####################
|
||||
tokenizer:
|
||||
# Specifies the tokenizer provider to use.
|
||||
provider: jwt
|
||||
lifetime:
|
||||
# The duration for which a user can be idle before being required to authenticate.
|
||||
idle: 168h
|
||||
# The duration for which a user can remain logged in before being asked to login.
|
||||
max: 720h
|
||||
rotation:
|
||||
# The interval to rotate tokens in.
|
||||
interval: 30m
|
||||
# The duration for which the previous token pair remains valid after a token pair is rotated.
|
||||
duration: 60s
|
||||
jwt:
|
||||
# The secret to sign the JWT tokens.
|
||||
secret: secret
|
||||
opaque:
|
||||
gc:
|
||||
# The interval to perform garbage collection.
|
||||
interval: 1h
|
||||
token:
|
||||
# The maximum number of tokens a user can have. This limits the number of concurrent sessions a user can have.
|
||||
max_per_user: 5
|
||||
|
||||
191
ee/authn/callbackauthn/oidccallbackauthn/authn.go
Normal file
191
ee/authn/callbackauthn/oidccallbackauthn/authn.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package oidccallbackauthn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/client"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
redirectPath string = "/api/v1/complete/oidc"
|
||||
)
|
||||
|
||||
var (
|
||||
scopes []string = []string{"email", oidc.ScopeOpenID}
|
||||
)
|
||||
|
||||
var _ authn.CallbackAuthN = (*AuthN)(nil)
|
||||
|
||||
type AuthN struct {
|
||||
store authtypes.AuthNStore
|
||||
licensing licensing.Licensing
|
||||
httpClient *client.Client
|
||||
}
|
||||
|
||||
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings) (*AuthN, error) {
|
||||
httpClient, err := client.New(providerSettings.Logger, providerSettings.TracerProvider, providerSettings.MeterProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AuthN{
|
||||
store: store,
|
||||
licensing: licensing,
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc")
|
||||
}
|
||||
|
||||
_, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, siteURL, authDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return oauth2Config.AuthCodeURL(authtypes.NewState(siteURL, authDomain.StorableAuthDomain().ID).URL.String()), nil
|
||||
}
|
||||
|
||||
func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtypes.CallbackIdentity, error) {
|
||||
if err := query.Get("error"); err != "" {
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "oidc: error while authenticating").WithAdditional(query.Get("error_description"))
|
||||
}
|
||||
|
||||
state, err := authtypes.NewStateFromString(query.Get("state"))
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeInvalidState, "oidc: invalid state").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
authDomain, err := a.store.GetAuthDomainFromID(ctx, state.DomainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = a.licensing.GetActive(ctx, authDomain.StorableAuthDomain().OrgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
oidcProvider, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, state.URL, authDomain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, a.httpClient.Client())
|
||||
token, err := oauth2Config.Exchange(ctx, query.Get("code"))
|
||||
if err != nil {
|
||||
var retrieveError *oauth2.RetrieveError
|
||||
if errors.As(err, &retrieveError) {
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to get token").WithAdditional(retrieveError.ErrorDescription).WithAdditional(string(retrieveError.Body))
|
||||
}
|
||||
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "oidc: failed to get token").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
claims, err := a.claimsFromIDToken(ctx, authDomain, oidcProvider, token)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims == nil && authDomain.AuthDomainConfig().OIDC.GetUserInfo {
|
||||
claims, err = a.claimsFromUserInfo(ctx, oidcProvider, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
emailClaim, ok := claims[authDomain.AuthDomainConfig().OIDC.ClaimMapping.Email].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims")
|
||||
}
|
||||
|
||||
email, err := valuer.NewEmail(emailClaim)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to parse email").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
if !authDomain.AuthDomainConfig().OIDC.InsecureSkipEmailVerified {
|
||||
emailVerifiedClaim, ok := claims["email_verified"].(bool)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email_verified in claims")
|
||||
}
|
||||
|
||||
if !emailVerifiedClaim {
|
||||
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "oidc: email is not verified")
|
||||
}
|
||||
}
|
||||
|
||||
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
|
||||
}
|
||||
|
||||
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
|
||||
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
|
||||
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)
|
||||
}
|
||||
|
||||
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return oidcProvider, &oauth2.Config{
|
||||
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
|
||||
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,
|
||||
Endpoint: oidcProvider.Endpoint(),
|
||||
Scopes: scopes,
|
||||
RedirectURL: (&url.URL{
|
||||
Scheme: siteURL.Scheme,
|
||||
Host: siteURL.Host,
|
||||
Path: redirectPath,
|
||||
}).String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AuthN) claimsFromIDToken(ctx context.Context, authDomain *authtypes.AuthDomain, provider *oidc.Provider, token *oauth2.Token) (map[string]any, error) {
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "oidc: no id_token in token response")
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().OIDC.ClientID})
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to verify token").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
var claims map[string]any
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to decode claims").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (a *AuthN) claimsFromUserInfo(ctx context.Context, provider *oidc.Provider, token *oauth2.Token) (map[string]any, error) {
|
||||
var claims map[string]any
|
||||
|
||||
userInfo, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(&oauth2.Token{
|
||||
AccessToken: token.AccessToken,
|
||||
TokenType: "Bearer", // The UserInfo endpoint requires a bearer token as per RFC6750
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "oidc: failed to get user info").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
if err := userInfo.Claims(&claims); err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to decode claims").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
155
ee/authn/callbackauthn/samlcallbackauthn/authn.go
Normal file
155
ee/authn/callbackauthn/samlcallbackauthn/authn.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package samlcallbackauthn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
saml2 "github.com/russellhaering/gosaml2"
|
||||
dsig "github.com/russellhaering/goxmldsig"
|
||||
)
|
||||
|
||||
const (
|
||||
redirectPath string = "/api/v1/complete/saml"
|
||||
)
|
||||
|
||||
var _ authn.CallbackAuthN = (*AuthN)(nil)
|
||||
|
||||
type AuthN struct {
|
||||
store authtypes.AuthNStore
|
||||
licensing licensing.Licensing
|
||||
}
|
||||
|
||||
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing) (*AuthN, error) {
|
||||
return &AuthN{
|
||||
store: store,
|
||||
licensing: licensing,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml")
|
||||
}
|
||||
|
||||
sp, err := a.serviceProvider(siteURL, authDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
url, err := sp.BuildAuthURL(authtypes.NewState(siteURL, authDomain.StorableAuthDomain().ID).URL.String())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*authtypes.CallbackIdentity, error) {
|
||||
state, err := authtypes.NewStateFromString(formValues.Get("RelayState"))
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeInvalidState, "saml: invalid state").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
authDomain, err := a.store.GetAuthDomainFromID(ctx, state.DomainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = a.licensing.GetActive(ctx, authDomain.StorableAuthDomain().OrgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
sp, err := a.serviceProvider(state.URL, authDomain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assertionInfo, err := sp.RetrieveAssertionInfo(formValues.Get("SAMLResponse"))
|
||||
if err != nil {
|
||||
if errors.As(err, &saml2.ErrVerification{}) {
|
||||
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, err.Error())
|
||||
}
|
||||
|
||||
if errors.As(err, &saml2.ErrMissingElement{}) {
|
||||
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, err.Error())
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if assertionInfo.WarningInfo.InvalidTime {
|
||||
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "saml: expired saml response")
|
||||
}
|
||||
|
||||
email, err := valuer.NewEmail(assertionInfo.NameID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "saml: invalid email").WithAdditional("The nameID assertion is used to retrieve the email address, please check your IDP configuration and try again.")
|
||||
}
|
||||
|
||||
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
|
||||
}
|
||||
|
||||
func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDomain) (*saml2.SAMLServiceProvider, error) {
|
||||
certStore, err := a.getCertificateStore(authDomain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: redirectPath}
|
||||
|
||||
// Note:
|
||||
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.
|
||||
// For AWSSSO, this is the value of Application SAML audience.
|
||||
return &saml2.SAMLServiceProvider{
|
||||
IdentityProviderSSOURL: authDomain.AuthDomainConfig().SAML.SamlIdp,
|
||||
IdentityProviderIssuer: authDomain.AuthDomainConfig().SAML.SamlEntity,
|
||||
ServiceProviderIssuer: siteURL.Host,
|
||||
AssertionConsumerServiceURL: acsURL.String(),
|
||||
SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
|
||||
AllowMissingAttributes: true,
|
||||
IDPCertificateStore: certStore,
|
||||
SPKeyStore: dsig.RandomKeyStoreForTest(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509CertificateStore, error) {
|
||||
certStore := &dsig.MemoryX509CertificateStore{
|
||||
Roots: []*x509.Certificate{},
|
||||
}
|
||||
|
||||
var certBytes []byte
|
||||
if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") {
|
||||
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert))
|
||||
if block == nil {
|
||||
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no valid pem cert found")
|
||||
}
|
||||
|
||||
certBytes = block.Bytes
|
||||
} else {
|
||||
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().SAML.SamlCert)
|
||||
if err != nil {
|
||||
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to read certificate: %s", err.Error())
|
||||
}
|
||||
|
||||
certBytes = certData
|
||||
}
|
||||
|
||||
idpCert, err := x509.ParseCertificate(certBytes)
|
||||
if err != nil {
|
||||
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to prepare saml request, invalid cert: %s", err.Error())
|
||||
}
|
||||
|
||||
certStore.Roots = append(certStore.Roots, idpCert)
|
||||
|
||||
return certStore, nil
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func handleSsoError(w http.ResponseWriter, r *http.Request, redirectURL string) {
|
||||
ssoError := []byte("Login failed. Please contact your system administrator")
|
||||
dst := make([]byte, base64.StdEncoding.EncodedLen(len(ssoError)))
|
||||
base64.StdEncoding.Encode(dst, ssoError)
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s?ssoerror=%s", redirectURL, string(dst)), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// receiveSAML completes a SAML request and gets user logged in
|
||||
func (ah *APIHandler) receiveSAML(w http.ResponseWriter, r *http.Request) {
|
||||
// this is the source url that initiated the login request
|
||||
redirectUri := constants.GetDefaultSiteURL()
|
||||
ctx := context.Background()
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
zap.L().Error("[receiveSAML] failed to process response - invalid response from IDP", zap.Error(err), zap.Any("request", r))
|
||||
handleSsoError(w, r, redirectUri)
|
||||
return
|
||||
}
|
||||
|
||||
// the relay state is sent when a login request is submitted to
|
||||
// Idp.
|
||||
relayState := r.FormValue("RelayState")
|
||||
zap.L().Debug("[receiveML] relay state", zap.String("relayState", relayState))
|
||||
|
||||
parsedState, err := url.Parse(relayState)
|
||||
if err != nil || relayState == "" {
|
||||
zap.L().Error("[receiveSAML] failed to process response - invalid response from IDP", zap.Error(err), zap.Any("request", r))
|
||||
handleSsoError(w, r, redirectUri)
|
||||
return
|
||||
}
|
||||
|
||||
// upgrade redirect url from the relay state for better accuracy
|
||||
redirectUri = fmt.Sprintf("%s://%s%s", parsedState.Scheme, parsedState.Host, "/login")
|
||||
|
||||
// fetch domain by parsing relay state.
|
||||
domain, err := ah.Signoz.Modules.User.GetDomainFromSsoResponse(ctx, parsedState)
|
||||
if err != nil {
|
||||
handleSsoError(w, r, redirectUri)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(domain.OrgID)
|
||||
if err != nil {
|
||||
handleSsoError(w, r, redirectUri)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = ah.Signoz.Licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
zap.L().Error("[receiveSAML] sso requested but feature unavailable in org domain")
|
||||
http.Redirect(w, r, fmt.Sprintf("%s?ssoerror=%s", redirectUri, "feature unavailable, please upgrade your billing plan to access this feature"), http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
|
||||
sp, err := domain.PrepareSamlRequest(parsedState)
|
||||
if err != nil {
|
||||
zap.L().Error("[receiveSAML] failed to prepare saml request for domain", zap.String("domain", domain.String()), zap.Error(err))
|
||||
handleSsoError(w, r, redirectUri)
|
||||
return
|
||||
}
|
||||
|
||||
assertionInfo, err := sp.RetrieveAssertionInfo(r.FormValue("SAMLResponse"))
|
||||
if err != nil {
|
||||
zap.L().Error("[receiveSAML] failed to retrieve assertion info from saml response", zap.String("domain", domain.String()), zap.Error(err))
|
||||
handleSsoError(w, r, redirectUri)
|
||||
return
|
||||
}
|
||||
|
||||
if assertionInfo.WarningInfo.InvalidTime {
|
||||
zap.L().Error("[receiveSAML] expired saml response", zap.String("domain", domain.String()), zap.Error(err))
|
||||
handleSsoError(w, r, redirectUri)
|
||||
return
|
||||
}
|
||||
|
||||
email := assertionInfo.NameID
|
||||
if email == "" {
|
||||
zap.L().Error("[receiveSAML] invalid email in the SSO response", zap.String("domain", domain.String()))
|
||||
handleSsoError(w, r, redirectUri)
|
||||
return
|
||||
}
|
||||
|
||||
nextPage, err := ah.Signoz.Modules.User.PrepareSsoRedirect(ctx, redirectUri, email)
|
||||
if err != nil {
|
||||
zap.L().Error("[receiveSAML] failed to generate redirect URI after successful login ", zap.String("domain", domain.String()), zap.Error(err))
|
||||
handleSsoError(w, r, redirectUri)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, nextPage, http.StatusSeeOther)
|
||||
}
|
||||
@@ -168,38 +168,22 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationUser(
|
||||
ctx context.Context, orgId string, cloudProvider string,
|
||||
) (*types.User, *basemodel.ApiError) {
|
||||
cloudIntegrationUser := fmt.Sprintf("%s-integration", cloudProvider)
|
||||
email := fmt.Sprintf("%s@signoz.io", cloudIntegrationUser)
|
||||
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
|
||||
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
|
||||
|
||||
integrationUserResult, err := ah.Signoz.Modules.User.GetUserByEmailInOrg(ctx, orgId, email)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, basemodel.NotFoundError(fmt.Errorf("couldn't look for integration user: %w", err))
|
||||
}
|
||||
|
||||
if integrationUserResult != nil {
|
||||
return &integrationUserResult.User, nil
|
||||
}
|
||||
|
||||
zap.L().Info(
|
||||
"cloud integration user not found. Attempting to create the user",
|
||||
zap.String("cloudProvider", cloudProvider),
|
||||
)
|
||||
|
||||
newUser, err := types.NewUser(cloudIntegrationUser, email, types.RoleViewer.String(), orgId)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't create cloud integration user: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
password := types.MustGenerateFactorPassword(newUser.ID.StringValue())
|
||||
|
||||
err = ah.Signoz.Modules.User.CreateUser(ctx, newUser, user.WithFactorPassword(password))
|
||||
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId))
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
|
||||
}
|
||||
|
||||
return newUser, nil
|
||||
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
|
||||
|
||||
cloudIntegrationUser, err = ah.Signoz.Modules.User.GetOrCreateUser(ctx, cloudIntegrationUser, user.WithFactorPassword(password))
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't look for integration user: %w", err))
|
||||
}
|
||||
|
||||
return cloudIntegrationUser, nil
|
||||
}
|
||||
|
||||
func getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
_ "net/http/pprof" // http profiler
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
@@ -25,7 +28,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
"github.com/rs/cors"
|
||||
"github.com/soheilhy/cmux"
|
||||
@@ -50,7 +52,6 @@ import (
|
||||
type Server struct {
|
||||
config signoz.Config
|
||||
signoz *signoz.SigNoz
|
||||
jwt *authtypes.JWT
|
||||
ruleManager *baserules.Manager
|
||||
|
||||
// public http router
|
||||
@@ -67,7 +68,7 @@ type Server struct {
|
||||
}
|
||||
|
||||
// NewServer creates and initializes Server
|
||||
func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT) (*Server, error) {
|
||||
func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
gatewayProxy, err := gateway.NewProxy(config.Gateway.URL.String(), gateway.RoutePrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -153,7 +154,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT)
|
||||
FluxInterval: config.Querier.FluxInterval,
|
||||
Gateway: gatewayProxy,
|
||||
GatewayUrl: config.Gateway.URL.String(),
|
||||
JWT: jwt,
|
||||
}
|
||||
|
||||
apiHandler, err := api.NewAPIHandler(apiOpts, signoz)
|
||||
@@ -164,7 +164,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT)
|
||||
s := &Server{
|
||||
config: config,
|
||||
signoz: signoz,
|
||||
jwt: jwt,
|
||||
ruleManager: rm,
|
||||
httpHostPort: baseconst.HTTPHostPort,
|
||||
unavailableChannel: make(chan healthcheck.Status),
|
||||
@@ -195,7 +194,17 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
r := baseapp.NewRouter()
|
||||
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger())
|
||||
|
||||
r.Use(middleware.NewAuth(s.jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(otelmux.Middleware(
|
||||
"apiserver",
|
||||
otelmux.WithMeterProvider(s.signoz.Instrumentation.MeterProvider()),
|
||||
otelmux.WithTracerProvider(s.signoz.Instrumentation.TracerProvider()),
|
||||
otelmux.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{})),
|
||||
otelmux.WithFilter(func(r *http.Request) bool {
|
||||
return !slices.Contains([]string{"/api/v1/health"}, r.URL.Path)
|
||||
}),
|
||||
otelmux.WithPublicEndpoint(),
|
||||
))
|
||||
r.Use(middleware.NewAuthN([]string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Tokenizer, s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(middleware.NewAPIKey(s.signoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.signoz.Instrumentation.Logger(), s.signoz.Sharder).Wrap)
|
||||
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
|
||||
s.config.APIServer.Timeout.ExcludedRoutes,
|
||||
|
||||
@@ -4,10 +4,6 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultSiteURL = "https://localhost:8080"
|
||||
)
|
||||
|
||||
var LicenseSignozIo = "https://license.signoz.io/api/v1"
|
||||
var LicenseAPIKey = GetOrDefaultEnv("SIGNOZ_LICENSE_API_KEY", "")
|
||||
var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "")
|
||||
@@ -27,13 +23,6 @@ func GetOrDefaultEnv(key string, fallback string) string {
|
||||
|
||||
// constant functions that override env vars
|
||||
|
||||
// GetDefaultSiteURL returns default site url, primarily
|
||||
// used to send saml request and allowing backend to
|
||||
// handle http redirect
|
||||
func GetDefaultSiteURL() string {
|
||||
return GetOrDefaultEnv("SIGNOZ_SITE_URL", DefaultSiteURL)
|
||||
}
|
||||
|
||||
const DotMetricsEnabled = "DOT_METRICS_ENABLED"
|
||||
|
||||
var IsDotMetricsEnabled = false
|
||||
|
||||
@@ -2,14 +2,12 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
const afterLogin = (
|
||||
userId: string,
|
||||
authToken: string,
|
||||
refreshToken: string,
|
||||
interceptorRejected?: boolean,
|
||||
): void => {
|
||||
setLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN, authToken);
|
||||
setLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN, refreshToken);
|
||||
setLocalStorageApi(LOCALSTORAGE.USER_ID, userId);
|
||||
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
|
||||
|
||||
if (!interceptorRejected) {
|
||||
@@ -18,7 +16,6 @@ const afterLogin = (
|
||||
detail: {
|
||||
accessJWT: authToken,
|
||||
refreshJWT: refreshToken,
|
||||
id: userId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
||||
export interface WsDataEvent {
|
||||
read_rows: number;
|
||||
read_bytes: number;
|
||||
elapsed_ms: number;
|
||||
}
|
||||
interface GetQueryStatsProps {
|
||||
queryId: string;
|
||||
setData: React.Dispatch<React.SetStateAction<WsDataEvent | undefined>>;
|
||||
}
|
||||
|
||||
function getURL(baseURL: string, queryId: string): URL | string {
|
||||
if (baseURL && !isEmpty(baseURL)) {
|
||||
return `${baseURL}/ws/query_progress?q=${queryId}`;
|
||||
}
|
||||
const url = new URL(`/ws/query_progress?q=${queryId}`, window.location.href);
|
||||
|
||||
if (window.location.protocol === 'http:') {
|
||||
url.protocol = 'ws';
|
||||
} else {
|
||||
url.protocol = 'wss';
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function getQueryStats(props: GetQueryStatsProps): void {
|
||||
const { queryId, setData } = props;
|
||||
|
||||
const token = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || '';
|
||||
|
||||
// https://github.com/whatwg/websockets/issues/20 reason for not using the relative URLs
|
||||
const url = getURL(ENVIRONMENT.wsURL, queryId);
|
||||
|
||||
const socket = new WebSocket(url, token);
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
try {
|
||||
const parsedData = JSON.parse(event?.data);
|
||||
setData(parsedData);
|
||||
} catch {
|
||||
setData(event?.data);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('error', (event) => {
|
||||
console.error(event);
|
||||
});
|
||||
|
||||
socket.addEventListener('close', (event) => {
|
||||
// 1000 is a normal closure status code
|
||||
if (event.code !== 1000) {
|
||||
console.error('WebSocket closed with error:', event);
|
||||
} else {
|
||||
console.error('WebSocket closed normally.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import loginApi from 'api/v1/login/login';
|
||||
import post from 'api/v2/sessions/rotate/post';
|
||||
import afterLogin from 'AppRoutes/utils';
|
||||
import axios, {
|
||||
AxiosError,
|
||||
@@ -12,6 +12,7 @@ import axios, {
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { Events } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryClient } from 'react-query';
|
||||
import { eventEmitter } from 'utils/getEventEmitter';
|
||||
|
||||
import apiV1, {
|
||||
@@ -26,6 +27,14 @@ import apiV1, {
|
||||
import { Logout } from './utils';
|
||||
|
||||
const RESPONSE_TIMEOUT_THRESHOLD = 5000; // 5 seconds
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const interceptorsResponse = (
|
||||
value: AxiosResponse<any>,
|
||||
@@ -74,19 +83,24 @@ const interceptorRejected = async (
|
||||
try {
|
||||
if (axios.isAxiosError(value) && value.response) {
|
||||
const { response } = value;
|
||||
// reject the refresh token error
|
||||
if (response.status === 401 && response.config.url !== '/login') {
|
||||
|
||||
if (
|
||||
response.status === 401 &&
|
||||
// if the session rotate call errors out with 401 or the delete sessions call returns 401 then we do not retry!
|
||||
response.config.url !== '/sessions/rotate' &&
|
||||
!(
|
||||
response.config.url === '/sessions' && response.config.method === 'delete'
|
||||
)
|
||||
) {
|
||||
try {
|
||||
const response = await loginApi({
|
||||
refreshToken: getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN) || '',
|
||||
const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN);
|
||||
const refreshToken = getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN);
|
||||
const response = await queryClient.fetchQuery({
|
||||
queryFn: () => post({ refreshToken: refreshToken || '' }),
|
||||
queryKey: ['/api/v2/sessions/rotate', accessToken, refreshToken],
|
||||
});
|
||||
|
||||
afterLogin(
|
||||
response.data.userId,
|
||||
response.data.accessJwt,
|
||||
response.data.refreshJwt,
|
||||
true,
|
||||
);
|
||||
afterLogin(response.data.accessToken, response.data.refreshToken, true);
|
||||
|
||||
try {
|
||||
const reResponse = await axios(
|
||||
@@ -95,7 +109,7 @@ const interceptorRejected = async (
|
||||
method: value.config.method,
|
||||
headers: {
|
||||
...value.config.headers,
|
||||
Authorization: `Bearer ${response.data.accessJwt}`,
|
||||
Authorization: `Bearer ${response.data.accessToken}`,
|
||||
},
|
||||
data: {
|
||||
...JSON.parse(value.config.data || '{}'),
|
||||
@@ -113,8 +127,8 @@ const interceptorRejected = async (
|
||||
Logout();
|
||||
}
|
||||
}
|
||||
// when refresh token is expired
|
||||
if (response.status === 401 && response.config.url === '/login') {
|
||||
|
||||
if (response.status === 401 && response.config.url === '/sessions/rotate') {
|
||||
Logout();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,15 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
|
||||
export const Logout = (): void => {
|
||||
import deleteSession from './v2/sessions/delete';
|
||||
|
||||
export const Logout = async (): Promise<void> => {
|
||||
try {
|
||||
await deleteSession();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
deleteLocalStorageKey(LOCALSTORAGE.AUTH_TOKEN);
|
||||
deleteLocalStorageKey(LOCALSTORAGE.IS_LOGGED_IN);
|
||||
deleteLocalStorageKey(LOCALSTORAGE.IS_IDENTIFIED_USER);
|
||||
@@ -14,7 +22,6 @@ export const Logout = (): void => {
|
||||
deleteLocalStorageKey(LOCALSTORAGE.USER_ID);
|
||||
deleteLocalStorageKey(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT);
|
||||
window.dispatchEvent(new CustomEvent('LOGOUT'));
|
||||
|
||||
history.push(ROUTES.LOGIN);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@ import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/SAML/deleteDomain';
|
||||
|
||||
const deleteDomain = async (props: Props): Promise<SuccessResponseV2<null>> => {
|
||||
const deleteDomain = async (id: string): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.delete<PayloadProps>(`/domains/${props.id}`);
|
||||
const response = await axios.delete<null>(`/domains/${id}`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
25
frontend/src/api/v1/domains/id/put.ts
Normal file
25
frontend/src/api/v1/domains/id/put.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { UpdatableAuthDomain } from 'types/api/v1/domains/put';
|
||||
|
||||
const put = async (
|
||||
props: UpdatableAuthDomain,
|
||||
): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.put<RawSuccessResponse<null>>(
|
||||
`/domains/${props.id}`,
|
||||
{ config: props.config },
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default put;
|
||||
@@ -1,12 +1,16 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { AuthDomain, PayloadProps } from 'types/api/SAML/listDomain';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { GettableAuthDomain } from 'types/api/v1/domains/list';
|
||||
|
||||
const listAllDomain = async (): Promise<SuccessResponseV2<AuthDomain[]>> => {
|
||||
const listAllDomain = async (): Promise<
|
||||
SuccessResponseV2<GettableAuthDomain[]>
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/domains`);
|
||||
const response = await axios.get<RawSuccessResponse<GettableAuthDomain[]>>(
|
||||
`/domains`,
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
|
||||
26
frontend/src/api/v1/domains/post.ts
Normal file
26
frontend/src/api/v1/domains/post.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { GettableAuthDomain } from 'types/api/v1/domains/list';
|
||||
import { PostableAuthDomain } from 'types/api/v1/domains/post';
|
||||
|
||||
const post = async (
|
||||
props: PostableAuthDomain,
|
||||
): Promise<SuccessResponseV2<GettableAuthDomain>> => {
|
||||
try {
|
||||
const response = await axios.post<RawSuccessResponse<GettableAuthDomain>>(
|
||||
`/domains`,
|
||||
props,
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default post;
|
||||
@@ -1,23 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { AuthDomain } from 'types/api/SAML/listDomain';
|
||||
import { PayloadProps, Props } from 'types/api/SAML/updateDomain';
|
||||
|
||||
const updateDomain = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<AuthDomain>> => {
|
||||
try {
|
||||
const response = await axios.put<PayloadProps>(`/domains/${props.id}`, props);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default updateDomain;
|
||||
@@ -2,15 +2,12 @@ import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
LoginPrecheckResponse,
|
||||
PayloadProps,
|
||||
Props,
|
||||
} from 'types/api/user/accept';
|
||||
import { PayloadProps, Props } from 'types/api/user/accept';
|
||||
import { UserResponse } from 'types/api/user/getUser';
|
||||
|
||||
const accept = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<LoginPrecheckResponse>> => {
|
||||
): Promise<SuccessResponseV2<UserResponse>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>(`/invite/accept`, props);
|
||||
return {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { Props, Signup as PayloadProps } from 'types/api/user/loginPrecheck';
|
||||
|
||||
const loginPrecheck = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`/loginPrecheck?email=${encodeURIComponent(
|
||||
props.email,
|
||||
)}&ref=${encodeURIComponent(window.location.href)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.statusText,
|
||||
payload: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default loginPrecheck;
|
||||
27
frontend/src/api/v1/register/post.ts
Normal file
27
frontend/src/api/v1/register/post.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { Props } from 'types/api/user/signup';
|
||||
import { SignupResponse } from 'types/api/v1/register/post';
|
||||
|
||||
const post = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<SignupResponse>> => {
|
||||
try {
|
||||
const response = await axios.post<RawSuccessResponse<SignupResponse>>(
|
||||
`/register`,
|
||||
{
|
||||
...props,
|
||||
},
|
||||
);
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default post;
|
||||
@@ -1,22 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Signup } from 'types/api/user/loginPrecheck';
|
||||
import { Props } from 'types/api/user/signup';
|
||||
|
||||
const signup = async (props: Props): Promise<SuccessResponseV2<Signup>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>(`/register`, {
|
||||
...props,
|
||||
});
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default signup;
|
||||
@@ -2,15 +2,11 @@ import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props, UserLoginResponse } from 'types/api/user/login';
|
||||
import { PayloadProps, UserResponse } from 'types/api/user/getUser';
|
||||
|
||||
const login = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<UserLoginResponse>> => {
|
||||
const get = async (): Promise<SuccessResponseV2<UserResponse>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>(`/login`, {
|
||||
...props,
|
||||
});
|
||||
const response = await axios.get<PayloadProps>(`/user/me`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
@@ -21,4 +17,4 @@ const login = async (
|
||||
}
|
||||
};
|
||||
|
||||
export default login;
|
||||
export default get;
|
||||
@@ -2,20 +2,19 @@ import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { AuthDomain } from 'types/api/SAML/listDomain';
|
||||
import { PayloadProps, Props } from 'types/api/SAML/postDomain';
|
||||
import { Info } from 'types/api/v1/version/get';
|
||||
|
||||
const create = async (props: Props): Promise<SuccessResponseV2<AuthDomain>> => {
|
||||
const get = async (): Promise<SuccessResponseV2<Info>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>(`/domains`, props);
|
||||
const response = await axios.get<Info>(`/version`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default create;
|
||||
export default get;
|
||||
@@ -1,25 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { getVersion } from 'constants/api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps } from 'types/api/user/getVersion';
|
||||
|
||||
const getVersionApi = async (): Promise<
|
||||
SuccessResponse<PayloadProps> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.get(`/${getVersion}`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getVersionApi;
|
||||
27
frontend/src/api/v2/sessions/context/get.ts
Normal file
27
frontend/src/api/v2/sessions/context/get.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { Props, SessionsContext } from 'types/api/v2/sessions/context/get';
|
||||
|
||||
const get = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<SessionsContext>> => {
|
||||
try {
|
||||
const response = await axios.get<RawSuccessResponse<SessionsContext>>(
|
||||
'/sessions/context',
|
||||
{
|
||||
params: props,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default get;
|
||||
19
frontend/src/api/v2/sessions/delete.ts
Normal file
19
frontend/src/api/v2/sessions/delete.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
|
||||
const deleteSession = async (): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.delete<RawSuccessResponse<null>>('/sessions');
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default deleteSession;
|
||||
23
frontend/src/api/v2/sessions/email_password/post.ts
Normal file
23
frontend/src/api/v2/sessions/email_password/post.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { Props, Token } from 'types/api/v2/sessions/email_password/post';
|
||||
|
||||
const post = async (props: Props): Promise<SuccessResponseV2<Token>> => {
|
||||
try {
|
||||
const response = await axios.post<RawSuccessResponse<Token>>(
|
||||
'/sessions/email_password',
|
||||
props,
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default post;
|
||||
23
frontend/src/api/v2/sessions/rotate/post.ts
Normal file
23
frontend/src/api/v2/sessions/rotate/post.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { Props, Token } from 'types/api/v2/sessions/rotate/post';
|
||||
|
||||
const post = async (props: Props): Promise<SuccessResponseV2<Token>> => {
|
||||
try {
|
||||
const response = await axios.post<RawSuccessResponse<Token>>(
|
||||
'/sessions/rotate',
|
||||
props,
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default post;
|
||||
@@ -1,5 +1,3 @@
|
||||
const SOMETHING_WENT_WRONG = 'Something went wrong';
|
||||
|
||||
const getVersion = 'version';
|
||||
|
||||
export { getVersion, SOMETHING_WENT_WRONG };
|
||||
export { SOMETHING_WENT_WRONG };
|
||||
|
||||
@@ -22,6 +22,7 @@ import EndPointDetails from '../Explorer/Domains/DomainDetails/EndPointDetails';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ jest.mock(
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import manageCreditCardApi from 'api/v1/portal/create';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import getUserVersion from 'api/v1/version/get';
|
||||
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
|
||||
import getUserVersion from 'api/v1/version/getVersion';
|
||||
import { AxiosError } from 'axios';
|
||||
import cx from 'classnames';
|
||||
import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
|
||||
@@ -317,14 +317,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
getUserVersionResponse.isFetched &&
|
||||
getUserVersionResponse.isSuccess &&
|
||||
getUserVersionResponse.data &&
|
||||
getUserVersionResponse.data.payload
|
||||
getUserVersionResponse.data.data
|
||||
) {
|
||||
dispatch({
|
||||
type: UPDATE_CURRENT_VERSION,
|
||||
payload: {
|
||||
currentVersion: getUserVersionResponse.data.payload.version,
|
||||
ee: getUserVersionResponse.data.payload.ee,
|
||||
setupCompleted: getUserVersionResponse.data.payload.setupCompleted,
|
||||
currentVersion: getUserVersionResponse.data.data.version,
|
||||
ee: getUserVersionResponse.data.data.ee,
|
||||
setupCompleted: getUserVersionResponse.data.data.setupCompleted,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
|
||||
const mockUseQuery = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQuery: (queryKey: any, queryFn: any, options: any): any =>
|
||||
mockUseQuery(queryKey, queryFn, options),
|
||||
}));
|
||||
|
||||
@@ -52,6 +52,7 @@ jest.mock('container/InfraMonitoringK8s/commonUtils', () => ({
|
||||
const mockUseQueries = jest.fn();
|
||||
const mockUseQuery = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueries: (queryConfigs: any[]): any[] => mockUseQueries(queryConfigs),
|
||||
useQuery: (config: any): any => mockUseQuery(config),
|
||||
}));
|
||||
|
||||
@@ -22,6 +22,7 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
|
||||
const mockUseQuery = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQuery: (queryKey: any, queryFn: any, options: any): any =>
|
||||
mockUseQuery(queryKey, queryFn, options),
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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't have an account? Contact your admin to send you an invite
|
||||
link.
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
|
||||
{canSelfRegister && (
|
||||
<Typography.Paragraph italic style={{ color: '#ACACAC' }}>
|
||||
If you are admin,{' '}
|
||||
<Typography.Link
|
||||
onClick={(): void => {
|
||||
history.push(ROUTES.SIGN_UP);
|
||||
}}
|
||||
style={{ fontWeight: 700 }}
|
||||
>
|
||||
Create an account
|
||||
</Typography.Link>
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</Space>
|
||||
</FormContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Switch, Typography } from 'antd';
|
||||
import { WsDataEvent } from 'api/common/getQueryStats';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import LogsDownloadOptionsMenu from 'components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
@@ -14,7 +12,6 @@ import QueryStatus from './QueryStatus';
|
||||
|
||||
function LogsActionsContainer({
|
||||
listQuery,
|
||||
queryStats,
|
||||
selectedPanelType,
|
||||
showFrequencyChart,
|
||||
handleToggleFrequencyChart,
|
||||
@@ -37,7 +34,6 @@ function LogsActionsContainer({
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isSuccess: boolean;
|
||||
queryStats: WsDataEvent | undefined;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
}): JSX.Element {
|
||||
@@ -126,22 +122,6 @@ function LogsActionsContainer({
|
||||
error={isError}
|
||||
success={isSuccess}
|
||||
/>
|
||||
|
||||
{queryStats?.read_rows && (
|
||||
<Typography.Text className="rows">
|
||||
{getYAxisFormattedValue(queryStats.read_rows?.toString(), 'short')}{' '}
|
||||
rows
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
{queryStats?.elapsed_ms && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<Typography.Text className="time">
|
||||
{getYAxisFormattedValue(queryStats?.elapsed_ms?.toString(), 'ms')}
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import './LogsExplorerViews.styles.scss';
|
||||
|
||||
import getFromLocalstorage from 'api/browser/localstorage/get';
|
||||
import setToLocalstorage from 'api/browser/localstorage/set';
|
||||
import { getQueryStats, WsDataEvent } from 'api/common/getQueryStats';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -132,7 +131,6 @@ function LogsExplorerViewsContainer({
|
||||
const [logs, setLogs] = useState<ILog[]>([]);
|
||||
const [requestData, setRequestData] = useState<Query | null>(null);
|
||||
const [queryId, setQueryId] = useState<string>(v4());
|
||||
const [queryStats, setQueryStats] = useState<WsDataEvent>();
|
||||
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
@@ -409,19 +407,6 @@ function LogsExplorerViewsContainer({
|
||||
setQueryId(v4());
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isEmpty(queryId) &&
|
||||
(isLoading || isFetching) &&
|
||||
selectedPanelType !== PANEL_TYPES.LIST
|
||||
) {
|
||||
setQueryStats(undefined);
|
||||
setTimeout(() => {
|
||||
getQueryStats({ queryId, setData: setQueryStats });
|
||||
}, 500);
|
||||
}
|
||||
}, [queryId, isLoading, isFetching, selectedPanelType]);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {
|
||||
@@ -632,7 +617,6 @@ function LogsExplorerViewsContainer({
|
||||
{!showLiveLogs && (
|
||||
<LogsActionsContainer
|
||||
listQuery={listQuery}
|
||||
queryStats={queryStats}
|
||||
selectedPanelType={selectedPanelType}
|
||||
showFrequencyChart={showFrequencyChart}
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
|
||||
@@ -52,10 +52,6 @@ jest.mock(
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('api/common/getQueryStats', () => ({
|
||||
getQueryStats: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('constants/panelTypes', () => ({
|
||||
AVAILABLE_EXPORT_PANEL_TYPES: ['graph', 'table'],
|
||||
}));
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { Button, Card, Space, Typography } from 'antd';
|
||||
import changeMyPassword from 'api/v1/factor_password/changeMyPassword';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Save } from 'lucide-react';
|
||||
import { isPasswordNotValidMessage, isPasswordValid } from 'pages/SignUp/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { Password } from '../styles';
|
||||
|
||||
function PasswordContainer(): JSX.Element {
|
||||
const [currentPassword, setCurrentPassword] = useState<string>('');
|
||||
const [updatePassword, setUpdatePassword] = useState<string>('');
|
||||
const { t } = useTranslation(['routes', 'settings', 'common']);
|
||||
const { user } = useAppContext();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isPasswordPolicyError, setIsPasswordPolicyError] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
|
||||
const defaultPlaceHolder = '*************';
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPassword && !isPasswordValid(currentPassword)) {
|
||||
setIsPasswordPolicyError(true);
|
||||
} else {
|
||||
setIsPasswordPolicyError(false);
|
||||
}
|
||||
}, [currentPassword]);
|
||||
|
||||
if (!user) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const onChangePasswordClickHandler = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (!isPasswordValid(currentPassword)) {
|
||||
setIsPasswordPolicyError(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
await changeMyPassword({
|
||||
newPassword: updatePassword,
|
||||
oldPassword: currentPassword,
|
||||
userId: user.id,
|
||||
});
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
notifications.error({
|
||||
message: (error as APIError).error.error.code,
|
||||
description: (error as APIError).error.error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled =
|
||||
isLoading ||
|
||||
currentPassword.length === 0 ||
|
||||
updatePassword.length === 0 ||
|
||||
isPasswordPolicyError ||
|
||||
currentPassword === updatePassword;
|
||||
|
||||
return (
|
||||
<Card className="reset-password-card">
|
||||
<Space direction="vertical" size="small">
|
||||
<Typography.Title
|
||||
level={4}
|
||||
style={{ marginTop: 0 }}
|
||||
data-testid="change-password-header"
|
||||
>
|
||||
{t('change_password', {
|
||||
ns: 'settings',
|
||||
})}
|
||||
</Typography.Title>
|
||||
<Space direction="vertical">
|
||||
<Typography data-testid="current-password-label">
|
||||
{t('current_password', {
|
||||
ns: 'settings',
|
||||
})}
|
||||
</Typography>
|
||||
<Password
|
||||
data-testid="current-password-textbox"
|
||||
disabled={isLoading}
|
||||
placeholder={defaultPlaceHolder}
|
||||
onChange={(event): void => {
|
||||
setCurrentPassword(event.target.value);
|
||||
}}
|
||||
value={currentPassword}
|
||||
/>
|
||||
</Space>
|
||||
<Space direction="vertical">
|
||||
<Typography data-testid="new-password-label">
|
||||
{t('new_password', {
|
||||
ns: 'settings',
|
||||
})}
|
||||
</Typography>
|
||||
<Password
|
||||
data-testid="new-password-textbox"
|
||||
disabled={isLoading}
|
||||
placeholder={defaultPlaceHolder}
|
||||
onChange={(event): void => {
|
||||
const updatedValue = event.target.value;
|
||||
setUpdatePassword(updatedValue);
|
||||
}}
|
||||
value={updatePassword}
|
||||
/>
|
||||
</Space>
|
||||
<Space>
|
||||
{isPasswordPolicyError && (
|
||||
<Typography.Paragraph
|
||||
data-testid="validation-message"
|
||||
style={{
|
||||
color: '#D89614',
|
||||
marginTop: '0.50rem',
|
||||
}}
|
||||
>
|
||||
{isPasswordNotValidMessage}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</Space>
|
||||
<Button
|
||||
disabled={isDisabled}
|
||||
loading={isLoading}
|
||||
onClick={onChangePasswordClickHandler}
|
||||
type="primary"
|
||||
data-testid="update-password-button"
|
||||
>
|
||||
<Save
|
||||
size={12}
|
||||
style={{ marginRight: '8px' }}
|
||||
data-testid="update-password-icon"
|
||||
/>{' '}
|
||||
{t('change_password', {
|
||||
ns: 'settings',
|
||||
})}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordContainer;
|
||||
@@ -7,9 +7,8 @@ import changeMyPassword from 'api/v1/factor_password/changeMyPassword';
|
||||
import editUser from 'api/v1/user/id/update';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Check, FileTerminal, MailIcon, UserIcon } from 'lucide-react';
|
||||
import { isPasswordValid } from 'pages/SignUp/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -22,9 +21,6 @@ function UserInfo(): JSX.Element {
|
||||
const [currentPassword, setCurrentPassword] = useState<string>('');
|
||||
const [updatePassword, setUpdatePassword] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isPasswordPolicyError, setIsPasswordPolicyError] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
|
||||
const [changedName, setChangedName] = useState<string>(
|
||||
user?.displayName || '',
|
||||
@@ -40,14 +36,6 @@ function UserInfo(): JSX.Element {
|
||||
|
||||
const defaultPlaceHolder = '*************';
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPassword && !isPasswordValid(currentPassword)) {
|
||||
setIsPasswordPolicyError(true);
|
||||
} else {
|
||||
setIsPasswordPolicyError(false);
|
||||
}
|
||||
}, [currentPassword]);
|
||||
|
||||
if (!user) {
|
||||
return <div />;
|
||||
}
|
||||
@@ -64,11 +52,6 @@ function UserInfo(): JSX.Element {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (!isPasswordValid(currentPassword)) {
|
||||
setIsPasswordPolicyError(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
await changeMyPassword({
|
||||
newPassword: updatePassword,
|
||||
oldPassword: currentPassword,
|
||||
@@ -94,7 +77,6 @@ function UserInfo(): JSX.Element {
|
||||
isLoading ||
|
||||
currentPassword.length === 0 ||
|
||||
updatePassword.length === 0 ||
|
||||
isPasswordPolicyError ||
|
||||
currentPassword === updatePassword;
|
||||
|
||||
const onSaveHandler = async (): Promise<void> => {
|
||||
|
||||
@@ -408,6 +408,7 @@ export default function Onboarding(): JSX.Element {
|
||||
form={form}
|
||||
isInviteTeamMemberModalOpen={isInviteTeamMemberModalOpen}
|
||||
toggleModal={toggleModal}
|
||||
onClose={(): void => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.auth-domain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.auth-domain-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-domain-list {
|
||||
.auth-domain-list-column-action {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import './CreateEdit.styles.scss';
|
||||
|
||||
import { GoogleSquareFilled, KeyOutlined } from '@ant-design/icons';
|
||||
import { Button, Typography } from 'antd';
|
||||
|
||||
interface AuthNProvider {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: JSX.Element;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function getAuthNProviders(samlEnabled: boolean): AuthNProvider[] {
|
||||
return [
|
||||
{
|
||||
key: 'google_auth',
|
||||
title: 'Google Apps Authentication',
|
||||
description: 'Let members sign-in with a Google workspace account',
|
||||
icon: <GoogleSquareFilled style={{ fontSize: '37px' }} />,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'saml',
|
||||
title: 'SAML Authentication',
|
||||
description:
|
||||
'Azure, Active Directory, Okta or your custom SAML 2.0 solution',
|
||||
icon: <KeyOutlined style={{ fontSize: '37px' }} />,
|
||||
enabled: samlEnabled,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'oidc',
|
||||
title: 'OIDC Authentication',
|
||||
description:
|
||||
'Authenticate using OpenID Connect providers like Azure, Active Directory, Okta, or other OIDC compliant solutions',
|
||||
icon: <KeyOutlined style={{ fontSize: '37px' }} />,
|
||||
enabled: samlEnabled,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function AuthnProviderSelector({
|
||||
setAuthnProvider,
|
||||
samlEnabled,
|
||||
}: {
|
||||
setAuthnProvider: React.Dispatch<React.SetStateAction<string>>;
|
||||
samlEnabled: boolean;
|
||||
}): JSX.Element {
|
||||
const authnProviders = getAuthNProviders(samlEnabled);
|
||||
return (
|
||||
<div className="authn-provider-selector">
|
||||
<section className="header">
|
||||
<Typography.Title level={4}>
|
||||
Configure Authentication Method
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph italic>
|
||||
SigNoz supports the following single sign-on services (SSO). Get started
|
||||
with setting your project’s SSO below
|
||||
</Typography.Paragraph>
|
||||
</section>
|
||||
<section className="selector">
|
||||
{authnProviders.map((provider) => {
|
||||
if (provider.enabled) {
|
||||
return (
|
||||
<section key={provider.key} className="provider">
|
||||
<span className="icon">{provider.icon}</span>
|
||||
<div className="title-description">
|
||||
<Typography.Text className="title">{provider.title}</Typography.Text>
|
||||
<Typography.Paragraph className="description">
|
||||
{provider.description}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Button
|
||||
onClick={(): void => setAuthnProvider(provider.key)}
|
||||
type="primary"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return <div key={provider.key} />;
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthnProviderSelector;
|
||||
@@ -0,0 +1,39 @@
|
||||
.authn-provider-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.provider {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.title-description {
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
.auth-domain-configure {
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import './CreateEdit.styles.scss';
|
||||
|
||||
import { Button, Form, Modal } from 'antd';
|
||||
import put from 'api/v1/domains/id/put';
|
||||
import post from 'api/v1/domains/post';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useState } from 'react';
|
||||
import APIError from 'types/api/error';
|
||||
import { GettableAuthDomain } from 'types/api/v1/domains/list';
|
||||
import { PostableAuthDomain } from 'types/api/v1/domains/post';
|
||||
|
||||
import AuthnProviderSelector from './AuthnProviderSelector';
|
||||
import ConfigureGoogleAuthAuthnProvider from './Providers/AuthnGoogleAuth';
|
||||
import ConfigureOIDCAuthnProvider from './Providers/AuthnOIDC';
|
||||
import ConfigureSAMLAuthnProvider from './Providers/AuthnSAML';
|
||||
|
||||
interface CreateOrEditProps {
|
||||
isCreate: boolean;
|
||||
onClose: () => void;
|
||||
record?: GettableAuthDomain;
|
||||
}
|
||||
|
||||
function configureAuthnProvider(
|
||||
authnProvider: string,
|
||||
isCreate: boolean,
|
||||
): JSX.Element {
|
||||
switch (authnProvider) {
|
||||
case 'saml':
|
||||
return <ConfigureSAMLAuthnProvider isCreate={isCreate} />;
|
||||
case 'google_auth':
|
||||
return <ConfigureGoogleAuthAuthnProvider isCreate={isCreate} />;
|
||||
case 'oidc':
|
||||
return <ConfigureOIDCAuthnProvider isCreate={isCreate} />;
|
||||
default:
|
||||
return <ConfigureGoogleAuthAuthnProvider isCreate={isCreate} />;
|
||||
}
|
||||
}
|
||||
|
||||
function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
const { isCreate, record, onClose } = props;
|
||||
const [form] = Form.useForm<PostableAuthDomain>();
|
||||
const [authnProvider, setAuthnProvider] = useState<string>(
|
||||
record?.ssoType || '',
|
||||
);
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { featureFlags } = useAppContext();
|
||||
const samlEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false;
|
||||
|
||||
const onSubmitHandler = async (): Promise<void> => {
|
||||
const name = form.getFieldValue('name');
|
||||
const googleAuthConfig = form.getFieldValue('googleAuthConfig');
|
||||
const samlConfig = form.getFieldValue('samlConfig');
|
||||
const oidcConfig = form.getFieldValue('oidcConfig');
|
||||
|
||||
try {
|
||||
if (isCreate) {
|
||||
await post({
|
||||
name,
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: authnProvider,
|
||||
googleAuthConfig,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await put({
|
||||
id: record?.id || '',
|
||||
config: {
|
||||
ssoEnabled: form.getFieldValue('ssoEnabled'),
|
||||
ssoType: authnProvider,
|
||||
googleAuthConfig,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
};
|
||||
|
||||
const onBackHandler = (): void => {
|
||||
setAuthnProvider('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open footer={null} onCancel={onClose}>
|
||||
<Form
|
||||
name="auth-domain"
|
||||
initialValues={defaultTo(record, {
|
||||
name: '',
|
||||
ssoEnabled: false,
|
||||
ssoType: '',
|
||||
})}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
{isCreate && authnProvider === '' && (
|
||||
<AuthnProviderSelector
|
||||
setAuthnProvider={setAuthnProvider}
|
||||
samlEnabled={samlEnabled}
|
||||
/>
|
||||
)}
|
||||
{authnProvider !== '' && (
|
||||
<div className="auth-domain-configure">
|
||||
{configureAuthnProvider(authnProvider, isCreate)}
|
||||
<section className="action-buttons">
|
||||
{isCreate && <Button onClick={onBackHandler}>Back</Button>}
|
||||
{!isCreate && <Button onClick={onClose}>Cancel</Button>}
|
||||
<Button onClick={onSubmitHandler} type="primary">
|
||||
Save Changes
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
CreateOrEdit.defaultProps = {
|
||||
record: null,
|
||||
};
|
||||
|
||||
export default CreateOrEdit;
|
||||
@@ -0,0 +1,68 @@
|
||||
import './Providers.styles.scss';
|
||||
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Form, Input, Typography } from 'antd';
|
||||
|
||||
function ConfigureGoogleAuthAuthnProvider({
|
||||
isCreate,
|
||||
}: {
|
||||
isCreate: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="google-auth">
|
||||
<section className="header">
|
||||
<Typography.Text className="title">
|
||||
Edit Google Authentication
|
||||
</Typography.Text>
|
||||
<Typography.Paragraph className="description">
|
||||
Enter OAuth 2.0 credentials obtained from the Google API Console below.
|
||||
Read the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
docs
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</Typography.Paragraph>
|
||||
</section>
|
||||
|
||||
<Form.Item label="Domain" name="name" className="field">
|
||||
<Input disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Client ID"
|
||||
name={['googleAuthConfig', 'clientId']}
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Client Secret"
|
||||
name={['googleAuthConfig', 'clientSecret']}
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `It is the application's secret.`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="Google OAuth2 won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigureGoogleAuthAuthnProvider;
|
||||
@@ -0,0 +1,105 @@
|
||||
import './Providers.styles.scss';
|
||||
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Checkbox, Form, Input, Typography } from 'antd';
|
||||
|
||||
function ConfigureOIDCAuthnProvider({
|
||||
isCreate,
|
||||
}: {
|
||||
isCreate: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="saml">
|
||||
<section className="header">
|
||||
<Typography.Text className="title">
|
||||
Edit OIDC Authentication
|
||||
</Typography.Text>
|
||||
</section>
|
||||
|
||||
<Form.Item label="Domain" name="name">
|
||||
<Input disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Issuer URL"
|
||||
name={['oidcConfig', 'issuer']}
|
||||
tooltip={{
|
||||
title: `It is the URL identifier for the service. For example: "https://accounts.google.com" or "https://login.salesforce.com".`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Issuer Alias"
|
||||
name={['oidcConfig', 'issuerAlias']}
|
||||
tooltip={{
|
||||
title: `Some offspec providers like Azure, Oracle IDCS have oidc discovery url different from issuer url which causes issuerValidation to fail.
|
||||
This provides a way to override the Issuer url from the .well-known/openid-configuration issuer`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Client ID"
|
||||
name={['oidcConfig', 'clientId']}
|
||||
tooltip={{ title: `It is the application's ID.` }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Client Secret"
|
||||
name={['oidcConfig', 'clientSecret']}
|
||||
tooltip={{ title: `It is the application's secret.` }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Email Claim Mapping"
|
||||
name={['oidcConfig', 'claimMapping', 'email']}
|
||||
tooltip={{
|
||||
title: `Mapping of email claims to the corresponding email field in the token.`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Skip Email Verification"
|
||||
name={['oidcConfig', 'insecureSkipEmailVerified']}
|
||||
valuePropName="checked"
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `Whether to skip email verification. Defaults to "false"`,
|
||||
}}
|
||||
>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Get User Info"
|
||||
name={['oidcConfig', 'getUserInfo']}
|
||||
valuePropName="checked"
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `Uses the userinfo endpoint to get additional claims for the token. This is especially useful where upstreams return "thin" id tokens`,
|
||||
}}
|
||||
>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="OIDC won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigureOIDCAuthnProvider;
|
||||
@@ -0,0 +1,77 @@
|
||||
import './Providers.styles.scss';
|
||||
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Checkbox, Form, Input, Typography } from 'antd';
|
||||
|
||||
function ConfigureSAMLAuthnProvider({
|
||||
isCreate,
|
||||
}: {
|
||||
isCreate: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="saml">
|
||||
<section className="header">
|
||||
<Typography.Text className="title">
|
||||
Edit SAML Authentication
|
||||
</Typography.Text>
|
||||
</section>
|
||||
|
||||
<Form.Item label="Domain" name="name">
|
||||
<Input disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="SAML ACS URL"
|
||||
name={['samlConfig', 'samlIdp']}
|
||||
tooltip={{
|
||||
title: `The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="SAML Entity ID"
|
||||
name={['samlConfig', 'samlEntity']}
|
||||
tooltip={{
|
||||
title: `The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="SAML X.509 Certificate"
|
||||
name={['samlConfig', 'samlCert']}
|
||||
tooltip={{
|
||||
title: `The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata of the identity provider. Example: <ds:X509Certificate><ds:X509Certificate>{samlCert}</ds:X509Certificate></ds:X509Certificate>`,
|
||||
}}
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Skip Signing AuthN Requests"
|
||||
name={['samlConfig', 'insecureSkipAuthNRequestsSigned']}
|
||||
valuePropName="checked"
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `Whether to skip signing the SAML requests. It can typically be found in the WantAuthnRequestsSigned attribute of the IDPSSODescriptor element in the SAML metadata of the identity provider. Example: <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
For providers like jumpcloud, this should be set to true.Note: This is the reverse of WantAuthnRequestsSigned. If WantAuthnRequestsSigned is false, then InsecureSkipAuthNRequestsSigned should be true.`,
|
||||
}}
|
||||
>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="SAML won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigureSAMLAuthnProvider;
|
||||
@@ -0,0 +1,67 @@
|
||||
.google-auth {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 12px !important;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.callout {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.saml {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 12px !important;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
.ant-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-flow: nowrap;
|
||||
|
||||
.ant-col {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.callout {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Switch } from 'antd';
|
||||
import put from 'api/v1/domains/id/put';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useState } from 'react';
|
||||
import APIError from 'types/api/error';
|
||||
import { GettableAuthDomain } from 'types/api/v1/domains/list';
|
||||
|
||||
function Toggle({ isDefaultChecked, record }: ToggleProps): JSX.Element {
|
||||
const [isChecked, setIsChecked] = useState<boolean>(isDefaultChecked);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onChangeHandler = async (checked: boolean): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await put({
|
||||
id: record.id,
|
||||
config: {
|
||||
ssoEnabled: checked,
|
||||
ssoType: record.ssoType,
|
||||
googleAuthConfig: record.googleAuthConfig,
|
||||
oidcConfig: record.oidcConfig,
|
||||
samlConfig: record.samlConfig,
|
||||
},
|
||||
});
|
||||
setIsChecked(checked);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch loading={isLoading} checked={isChecked} onChange={onChangeHandler} />
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleProps {
|
||||
isDefaultChecked: boolean;
|
||||
record: GettableAuthDomain;
|
||||
}
|
||||
|
||||
export default Toggle;
|
||||
148
frontend/src/container/OrganizationSettings/AuthDomain/index.tsx
Normal file
148
frontend/src/container/OrganizationSettings/AuthDomain/index.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import './AuthDomain.styles.scss';
|
||||
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Table, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import deleteDomain from 'api/v1/domains/id/delete';
|
||||
import listAllDomain from 'api/v1/domains/list';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import APIError from 'types/api/error';
|
||||
import { GettableAuthDomain, SSOType } from 'types/api/v1/domains/list';
|
||||
|
||||
import CreateEdit from './CreateEdit/CreateEdit';
|
||||
import Toggle from './Toggle';
|
||||
|
||||
const columns: ColumnsType<GettableAuthDomain> = [
|
||||
{
|
||||
title: 'Domain',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
render: (val): JSX.Element => <Typography.Text>{val}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: 'Enforce SSO',
|
||||
dataIndex: 'ssoEnabled',
|
||||
key: 'ssoEnabled',
|
||||
width: 80,
|
||||
render: (value: boolean, record: GettableAuthDomain): JSX.Element => (
|
||||
<Toggle isDefaultChecked={value} record={record} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_, record: GettableAuthDomain): JSX.Element => (
|
||||
<section className="auth-domain-list-column-action">
|
||||
<Typography.Link data-column-action="configure">
|
||||
Configure {SSOType.get(record.ssoType)}
|
||||
</Typography.Link>
|
||||
<Typography.Link type="danger" data-column-action="delete">
|
||||
Delete
|
||||
</Typography.Link>
|
||||
</section>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
async function deleteDomainById(
|
||||
id: string,
|
||||
showErrorModal: (error: APIError) => void,
|
||||
refetchAuthDomainListResponse: () => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await deleteDomain(id);
|
||||
refetchAuthDomainListResponse();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}
|
||||
|
||||
function AuthDomain(): JSX.Element {
|
||||
const [record, setRecord] = useState<GettableAuthDomain>();
|
||||
const [addDomain, setAddDomain] = useState<boolean>(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const {
|
||||
data: authDomainListResponse,
|
||||
isLoading: isLoadingAuthDomainListResponse,
|
||||
isFetching: isFetchingAuthDomainListResponse,
|
||||
error: errorFetchingAuthDomainListResponse,
|
||||
refetch: refetchAuthDomainListResponse,
|
||||
} = useQuery({
|
||||
queryFn: listAllDomain,
|
||||
queryKey: ['/api/v1/domains', 'list'],
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="auth-domain">
|
||||
<section className="auth-domain-header">
|
||||
<Typography.Title level={3}>Authenticated Domains</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={(): void => {
|
||||
setAddDomain(true);
|
||||
}}
|
||||
className="button"
|
||||
>
|
||||
Add Domain
|
||||
</Button>
|
||||
</section>
|
||||
{(errorFetchingAuthDomainListResponse as APIError) && (
|
||||
<ErrorContent error={errorFetchingAuthDomainListResponse as APIError} />
|
||||
)}
|
||||
{!(errorFetchingAuthDomainListResponse as APIError) && (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={authDomainListResponse?.data}
|
||||
onRow={(record): any => ({
|
||||
onClick: (
|
||||
event: React.SyntheticEvent<HTMLLinkElement, MouseEvent>,
|
||||
): void => {
|
||||
const target = event.target as HTMLLinkElement;
|
||||
const { columnAction } = target.dataset;
|
||||
switch (columnAction) {
|
||||
case 'configure':
|
||||
setRecord(record);
|
||||
|
||||
break;
|
||||
case 'delete':
|
||||
deleteDomainById(
|
||||
record.id,
|
||||
showErrorModal,
|
||||
refetchAuthDomainListResponse,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown action:', columnAction);
|
||||
}
|
||||
},
|
||||
})}
|
||||
loading={
|
||||
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
|
||||
}
|
||||
className="auth-domain-list"
|
||||
/>
|
||||
)}
|
||||
{(addDomain || record) && (
|
||||
<CreateEdit
|
||||
isCreate={!record}
|
||||
record={record}
|
||||
onClose={(): void => {
|
||||
setAddDomain(false);
|
||||
setRecord(undefined);
|
||||
refetchAuthDomainListResponse();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthDomain;
|
||||
@@ -1,101 +0,0 @@
|
||||
/* eslint-disable prefer-regex-literals */
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Form, Input, Modal, Typography } from 'antd';
|
||||
import { useForm } from 'antd/es/form/Form';
|
||||
import createDomainApi from 'api/v1/domains/create';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
function AddDomain({ refetch }: Props): JSX.Element {
|
||||
const { t } = useTranslation(['common', 'organizationsettings']);
|
||||
const [isAddDomains, setIsDomain] = useState(false);
|
||||
const [form] = useForm<FormProps>();
|
||||
const { org } = useAppContext();
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const onCreateHandler = async (): Promise<void> => {
|
||||
try {
|
||||
await createDomainApi({
|
||||
name: form.getFieldValue('domain'),
|
||||
orgId: (org || [])[0].id,
|
||||
});
|
||||
|
||||
notifications.success({
|
||||
message: 'Your domain has been added successfully.',
|
||||
duration: 15,
|
||||
});
|
||||
setIsDomain(false);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="auth-domains-title-container">
|
||||
<Typography.Title level={3}>
|
||||
{t('authenticated_domains', {
|
||||
ns: 'organizationsettings',
|
||||
})}
|
||||
</Typography.Title>
|
||||
<Button
|
||||
onClick={(): void => setIsDomain(true)}
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
{t('add_domain', { ns: 'organizationsettings' })}
|
||||
</Button>
|
||||
</div>
|
||||
<Modal
|
||||
centered
|
||||
title="Add Domain"
|
||||
className="add-domain-modal"
|
||||
footer={null}
|
||||
open={isAddDomains}
|
||||
destroyOnClose
|
||||
onCancel={(): void => setIsDomain(false)}
|
||||
>
|
||||
<Form form={form} onFinish={onCreateHandler} requiredMark>
|
||||
<Form.Item
|
||||
required
|
||||
name={['domain']}
|
||||
rules={[
|
||||
{
|
||||
message: 'Please enter a valid domain',
|
||||
required: true,
|
||||
pattern: new RegExp(
|
||||
'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$',
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="signoz.io" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Add Domain
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormProps {
|
||||
domain: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export default AddDomain;
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Button, Space, Typography } from 'antd';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { IconContainer, TitleContainer, TitleText } from './styles';
|
||||
|
||||
function Row({
|
||||
onClickHandler,
|
||||
Icon,
|
||||
buttonText,
|
||||
subTitle,
|
||||
title,
|
||||
isDisabled,
|
||||
}: RowProps): JSX.Element {
|
||||
return (
|
||||
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<IconContainer>{Icon}</IconContainer>
|
||||
|
||||
<TitleContainer>
|
||||
<TitleText>{title}</TitleText>
|
||||
<Typography.Text>{subTitle}</Typography.Text>
|
||||
</TitleContainer>
|
||||
|
||||
<Button disabled={isDisabled} onClick={onClickHandler} type="primary">
|
||||
{buttonText}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RowProps {
|
||||
onClickHandler: VoidFunction;
|
||||
Icon: ReactNode;
|
||||
title: string;
|
||||
subTitle: ReactNode;
|
||||
buttonText: string;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export default Row;
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Typography } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const TitleContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
`;
|
||||
|
||||
export const IconContainer = styled.div`
|
||||
min-width: 70px;
|
||||
`;
|
||||
|
||||
export const TitleText = styled(Typography)`
|
||||
font-weight: bold;
|
||||
`;
|
||||
@@ -1,118 +0,0 @@
|
||||
import { GoogleSquareFilled, KeyOutlined } from '@ant-design/icons';
|
||||
import { Typography } from 'antd';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { AuthDomain, GOOGLE_AUTH, SAML } from 'types/api/SAML/listDomain';
|
||||
|
||||
import Row, { RowProps } from './Row';
|
||||
import { RowContainer, RowSpace } from './styles';
|
||||
|
||||
function Create({
|
||||
ssoMethod,
|
||||
assignSsoMethod,
|
||||
setIsSettingsOpen,
|
||||
setIsEditModalOpen,
|
||||
}: CreateProps): JSX.Element {
|
||||
const { featureFlags } = useAppContext();
|
||||
const SSOFlag =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false;
|
||||
|
||||
const onGoogleAuthClickHandler = useCallback(() => {
|
||||
assignSsoMethod(GOOGLE_AUTH);
|
||||
setIsSettingsOpen(false);
|
||||
setIsEditModalOpen(true);
|
||||
}, [assignSsoMethod, setIsSettingsOpen, setIsEditModalOpen]);
|
||||
|
||||
const onEditSAMLHandler = useCallback(() => {
|
||||
assignSsoMethod(SAML);
|
||||
setIsSettingsOpen(false);
|
||||
setIsEditModalOpen(true);
|
||||
}, [assignSsoMethod, setIsSettingsOpen, setIsEditModalOpen]);
|
||||
|
||||
const ConfigureButtonText = useMemo(() => {
|
||||
switch (ssoMethod) {
|
||||
case GOOGLE_AUTH:
|
||||
return 'Edit Google Auth';
|
||||
case SAML:
|
||||
return 'Edit SAML';
|
||||
default:
|
||||
return 'Get Started';
|
||||
}
|
||||
}, [ssoMethod]);
|
||||
|
||||
const data: RowProps[] = SSOFlag
|
||||
? [
|
||||
{
|
||||
buttonText: ConfigureButtonText,
|
||||
Icon: <GoogleSquareFilled style={{ fontSize: '37px' }} />,
|
||||
title: 'Google Apps Authentication',
|
||||
subTitle: 'Let members sign-in with a Google workspace account',
|
||||
onClickHandler: onGoogleAuthClickHandler,
|
||||
isDisabled: false,
|
||||
},
|
||||
{
|
||||
buttonText: ConfigureButtonText,
|
||||
Icon: <KeyOutlined style={{ fontSize: '37px' }} />,
|
||||
onClickHandler: onEditSAMLHandler,
|
||||
subTitle: (
|
||||
<>
|
||||
Azure, Active Directory, Okta or your custom SAML 2.0 solution{' '}
|
||||
<a
|
||||
href="https://github.com/SigNoz/signoz/issues/8647"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
(Unsupported SAMLs)
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
title: 'SAML Authentication',
|
||||
isDisabled: false,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
buttonText: ConfigureButtonText,
|
||||
Icon: <GoogleSquareFilled style={{ fontSize: '37px' }} />,
|
||||
title: 'Google Apps Authentication',
|
||||
subTitle: 'Let members sign-in with a Google account',
|
||||
onClickHandler: onGoogleAuthClickHandler,
|
||||
isDisabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Text italic>
|
||||
SigNoz supports the following single sign-on services (SSO). Get started
|
||||
with setting your project’s SSO below
|
||||
</Typography.Text>
|
||||
|
||||
<RowContainer>
|
||||
<RowSpace direction="vertical">
|
||||
{data.map((rowData) => (
|
||||
<Row
|
||||
Icon={rowData.Icon}
|
||||
buttonText={rowData.buttonText}
|
||||
onClickHandler={rowData.onClickHandler}
|
||||
subTitle={rowData.subTitle}
|
||||
title={rowData.title}
|
||||
key={rowData.title}
|
||||
isDisabled={rowData.isDisabled}
|
||||
/>
|
||||
))}
|
||||
</RowSpace>
|
||||
</RowContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateProps {
|
||||
ssoMethod: AuthDomain['ssoType'];
|
||||
assignSsoMethod: (value: AuthDomain['ssoType']) => void;
|
||||
setIsSettingsOpen: (value: boolean) => void;
|
||||
setIsEditModalOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export default Create;
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Space } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const RowContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 1rem;
|
||||
`;
|
||||
|
||||
export const RowSpace = styled(Space)`
|
||||
&&& {
|
||||
row-gap: 1.5rem !important;
|
||||
}
|
||||
`;
|
||||
@@ -1,49 +0,0 @@
|
||||
import { InfoCircleFilled } from '@ant-design/icons';
|
||||
import { Card, Form, Input, Space, Typography } from 'antd';
|
||||
|
||||
function EditGoogleAuth(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Typography.Paragraph>
|
||||
Enter OAuth 2.0 credentials obtained from the Google API Console below. Read
|
||||
the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
docs
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</Typography.Paragraph>
|
||||
<Form.Item
|
||||
label="Client ID"
|
||||
name={['googleAuthConfig', 'clientId']}
|
||||
rules={[{ required: true, message: 'Please input Google Auth Client ID!' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Client Secret"
|
||||
name={['googleAuthConfig', 'clientSecret']}
|
||||
rules={[
|
||||
{ required: true, message: 'Please input Google Auth Client Secret!' },
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Card style={{ marginBottom: '1rem' }}>
|
||||
<Space>
|
||||
<InfoCircleFilled />
|
||||
<Typography>
|
||||
Google OAuth2 won’t be enabled unless you enter all the attributes above
|
||||
</Typography>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditGoogleAuth;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { InfoCircleFilled } from '@ant-design/icons';
|
||||
import { Card, Form, Input, Space, Typography } from 'antd';
|
||||
|
||||
function EditSAML(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label="SAML ACS URL"
|
||||
name={['samlConfig', 'samlIdp']}
|
||||
rules={[{ required: true, message: 'Please input your ACS URL!' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="SAML Entity ID"
|
||||
name={['samlConfig', 'samlEntity']}
|
||||
rules={[{ required: true, message: 'Please input your Entity Id!' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
rules={[{ required: true, message: 'Please input your Certificate!' }]}
|
||||
label="SAML X.509 Certificate"
|
||||
name={['samlConfig', 'samlCert']}
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
|
||||
<Card style={{ marginBottom: '1rem' }}>
|
||||
<Space>
|
||||
<InfoCircleFilled />
|
||||
<Typography>
|
||||
SAML won’t be enabled unless you enter all the attributes above
|
||||
</Typography>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditSAML;
|
||||
@@ -1,40 +0,0 @@
|
||||
import {
|
||||
AuthDomain,
|
||||
GOOGLE_AUTH,
|
||||
GoogleAuthConfig,
|
||||
isGoogleAuthConfig,
|
||||
isSAMLConfig,
|
||||
SAML,
|
||||
SAMLConfig,
|
||||
} from 'types/api/SAML/listDomain';
|
||||
|
||||
export function parseSamlForm(
|
||||
current: AuthDomain,
|
||||
formValues: AuthDomain,
|
||||
): SAMLConfig | undefined {
|
||||
if (current?.ssoType === SAML && isSAMLConfig(formValues?.samlConfig)) {
|
||||
return {
|
||||
...current.samlConfig,
|
||||
...formValues?.samlConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return current.samlConfig;
|
||||
}
|
||||
|
||||
export function parseGoogleAuthForm(
|
||||
current: AuthDomain,
|
||||
formValues: AuthDomain,
|
||||
): GoogleAuthConfig | undefined {
|
||||
if (
|
||||
current?.ssoType === GOOGLE_AUTH &&
|
||||
isGoogleAuthConfig(formValues?.googleAuthConfig)
|
||||
) {
|
||||
return {
|
||||
...current.googleAuthConfig,
|
||||
...formValues?.googleAuthConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return current.googleAuthConfig;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Button, Form, Space } from 'antd';
|
||||
import { useForm } from 'antd/lib/form/Form';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuthDomain, GOOGLE_AUTH, SAML } from 'types/api/SAML/listDomain';
|
||||
|
||||
import EditGoogleAuth from './EditGoogleAuth';
|
||||
import EditSAML from './EditSAML';
|
||||
import { parseGoogleAuthForm, parseSamlForm } from './helpers';
|
||||
|
||||
// renderFormInputs selectively renders form fields depending upon
|
||||
// sso type
|
||||
const renderFormInputs = (
|
||||
record: AuthDomain | undefined,
|
||||
): JSX.Element | undefined => {
|
||||
switch (record?.ssoType) {
|
||||
case GOOGLE_AUTH:
|
||||
return <EditGoogleAuth />;
|
||||
case SAML:
|
||||
default:
|
||||
return <EditSAML />;
|
||||
}
|
||||
};
|
||||
|
||||
function EditSSO({
|
||||
onRecordUpdateHandler,
|
||||
record,
|
||||
setEditModalOpen,
|
||||
}: EditFormProps): JSX.Element {
|
||||
const [form] = useForm<AuthDomain>();
|
||||
|
||||
const { t } = useTranslation(['common']);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const onFinishHandler = useCallback(() => {
|
||||
form
|
||||
.validateFields()
|
||||
.then(async (values) => {
|
||||
await onRecordUpdateHandler({
|
||||
...record,
|
||||
ssoEnabled: true,
|
||||
ssoType: record.ssoType,
|
||||
samlConfig: parseSamlForm(record, values),
|
||||
googleAuthConfig: parseGoogleAuthForm(record, values),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
notifications.error({
|
||||
message: t('something_went_wrong', { ns: 'common' }),
|
||||
});
|
||||
});
|
||||
}, [form, onRecordUpdateHandler, record, t, notifications]);
|
||||
|
||||
const onResetHandler = useCallback(() => {
|
||||
form.resetFields();
|
||||
setEditModalOpen(false);
|
||||
}, [setEditModalOpen, form]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
name="basic"
|
||||
initialValues={record}
|
||||
onFinishFailed={(error): void => {
|
||||
error.errorFields.forEach(({ errors }) => {
|
||||
notifications.error({
|
||||
message:
|
||||
errors[0].toString() || t('something_went_wrong', { ns: 'common' }),
|
||||
});
|
||||
});
|
||||
form.resetFields();
|
||||
}}
|
||||
layout="vertical"
|
||||
onFinish={onFinishHandler}
|
||||
autoComplete="off"
|
||||
form={form}
|
||||
>
|
||||
{renderFormInputs(record)}
|
||||
<Space
|
||||
style={{ width: '100%', justifyContent: 'flex-end' }}
|
||||
align="end"
|
||||
direction="horizontal"
|
||||
>
|
||||
<Button htmlType="button" onClick={onResetHandler}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Save Settings
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditFormProps {
|
||||
onRecordUpdateHandler: (record: AuthDomain) => Promise<boolean>;
|
||||
record: AuthDomain;
|
||||
setEditModalOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default EditSSO;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Switch } from 'antd';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { AuthDomain } from 'types/api/SAML/listDomain';
|
||||
|
||||
import { isSSOConfigValid } from '../helpers';
|
||||
|
||||
function SwitchComponent({
|
||||
isDefaultChecked,
|
||||
onRecordUpdateHandler,
|
||||
record,
|
||||
}: SwitchComponentProps): JSX.Element {
|
||||
const [isChecked, setIsChecked] = useState<boolean>(isDefaultChecked);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const onChangeHandler = async (checked: boolean): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
const response = await onRecordUpdateHandler({
|
||||
...record,
|
||||
ssoEnabled: checked,
|
||||
});
|
||||
|
||||
if (response) {
|
||||
setIsChecked(checked);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const isInValidVerificate = useMemo(() => !isSSOConfigValid(record), [record]);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
loading={isLoading}
|
||||
disabled={isInValidVerificate}
|
||||
checked={isChecked}
|
||||
onChange={onChangeHandler}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SwitchComponentProps {
|
||||
isDefaultChecked: boolean;
|
||||
onRecordUpdateHandler: (record: AuthDomain) => Promise<boolean>;
|
||||
record: AuthDomain;
|
||||
}
|
||||
|
||||
export default SwitchComponent;
|
||||
@@ -1,74 +0,0 @@
|
||||
import { AuthDomain, SAML } from 'types/api/SAML/listDomain';
|
||||
|
||||
import { isSSOConfigValid } from './helpers';
|
||||
|
||||
const inValidCase: AuthDomain['samlConfig'][] = [
|
||||
{
|
||||
samlCert: '',
|
||||
samlEntity: '',
|
||||
samlIdp: '',
|
||||
},
|
||||
{
|
||||
samlCert: '',
|
||||
samlEntity: '',
|
||||
samlIdp: 'asd',
|
||||
},
|
||||
{
|
||||
samlCert: 'sample certificate',
|
||||
samlEntity: '',
|
||||
samlIdp: '',
|
||||
},
|
||||
{
|
||||
samlCert: 'sample cert',
|
||||
samlEntity: 'sample entity',
|
||||
samlIdp: '',
|
||||
},
|
||||
];
|
||||
|
||||
const validCase: AuthDomain['samlConfig'][] = [
|
||||
{
|
||||
samlCert: 'sample cert',
|
||||
samlEntity: 'sample entity',
|
||||
samlIdp: 'sample idp',
|
||||
},
|
||||
];
|
||||
|
||||
describe('Utils', () => {
|
||||
inValidCase.forEach((config) => {
|
||||
it('should return invalid saml config', () => {
|
||||
expect(
|
||||
isSSOConfigValid({
|
||||
id: 'test-0',
|
||||
name: 'test',
|
||||
orgId: '32ed234',
|
||||
ssoEnabled: true,
|
||||
ssoType: SAML,
|
||||
samlConfig: {
|
||||
samlCert: config?.samlCert || '',
|
||||
samlEntity: config?.samlEntity || '',
|
||||
samlIdp: config?.samlIdp || '',
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
validCase.forEach((config) => {
|
||||
it('should return invalid saml config', () => {
|
||||
expect(
|
||||
isSSOConfigValid({
|
||||
id: 'test-0',
|
||||
name: 'test',
|
||||
orgId: '32ed234',
|
||||
ssoEnabled: true,
|
||||
ssoType: SAML,
|
||||
samlConfig: {
|
||||
samlCert: config?.samlCert || '',
|
||||
samlEntity: config?.samlEntity || '',
|
||||
samlIdp: config?.samlIdp || '',
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { AuthDomain, GOOGLE_AUTH, SAML } from 'types/api/SAML/listDomain';
|
||||
|
||||
export const ConfigureSsoButtonText = (
|
||||
ssoType: AuthDomain['ssoType'],
|
||||
): string => {
|
||||
switch (ssoType) {
|
||||
case SAML:
|
||||
return 'Edit SAML';
|
||||
case GOOGLE_AUTH:
|
||||
return 'Edit Google Auth';
|
||||
default:
|
||||
return 'Configure SSO';
|
||||
}
|
||||
};
|
||||
|
||||
export const EditModalTitleText = (
|
||||
ssoType: AuthDomain['ssoType'] | undefined,
|
||||
): string => {
|
||||
switch (ssoType) {
|
||||
case SAML:
|
||||
return 'Edit SAML Configuration';
|
||||
case GOOGLE_AUTH:
|
||||
return 'Edit Google Authentication';
|
||||
default:
|
||||
return 'Configure SSO';
|
||||
}
|
||||
};
|
||||
|
||||
export const isSSOConfigValid = (domain: AuthDomain): boolean => {
|
||||
switch (domain.ssoType) {
|
||||
case SAML:
|
||||
return (
|
||||
domain.samlConfig?.samlCert?.length !== 0 &&
|
||||
domain.samlConfig?.samlEntity?.length !== 0 &&
|
||||
domain.samlConfig?.samlIdp?.length !== 0
|
||||
);
|
||||
case GOOGLE_AUTH:
|
||||
return (
|
||||
domain.googleAuthConfig?.clientId?.length !== 0 &&
|
||||
domain.googleAuthConfig?.clientSecret?.length !== 0
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,263 +0,0 @@
|
||||
import { Button, Modal, Space, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import deleteDomain from 'api/v1/domains/delete';
|
||||
import listAllDomain from 'api/v1/domains/list';
|
||||
import updateDomain from 'api/v1/domains/update';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import APIError from 'types/api/error';
|
||||
import { AuthDomain } from 'types/api/SAML/listDomain';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import AddDomain from './AddDomain';
|
||||
import Create from './Create';
|
||||
import EditSSO from './Edit';
|
||||
import { ConfigureSsoButtonText, EditModalTitleText } from './helpers';
|
||||
import { ColumnWithTooltip } from './styles';
|
||||
import SwitchComponent from './Switch';
|
||||
|
||||
function AuthDomains(): JSX.Element {
|
||||
const { t } = useTranslation(['common', 'organizationsettings']);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState<boolean>(false);
|
||||
const { org } = useAppContext();
|
||||
const [currentDomain, setCurrentDomain] = useState<AuthDomain>();
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, refetch } = useQuery(['saml'], {
|
||||
queryFn: () => listAllDomain(),
|
||||
enabled: org !== null,
|
||||
});
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const assignSsoMethod = useCallback(
|
||||
(typ: AuthDomain['ssoType']): void => {
|
||||
setCurrentDomain({ ...currentDomain, ssoType: typ } as AuthDomain);
|
||||
},
|
||||
[currentDomain, setCurrentDomain],
|
||||
);
|
||||
|
||||
const onCloseHandler = useCallback(
|
||||
(func: Dispatch<SetStateAction<boolean>>) => (): void => {
|
||||
func(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onRecordUpdateHandler = useCallback(
|
||||
async (record: AuthDomain): Promise<boolean> => {
|
||||
try {
|
||||
await updateDomain(record);
|
||||
notifications.success({
|
||||
message: t('saml_settings', {
|
||||
ns: 'organizationsettings',
|
||||
}),
|
||||
});
|
||||
refetch();
|
||||
onCloseHandler(setIsEditModalOpen)();
|
||||
return true;
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[refetch, t, onCloseHandler, notifications],
|
||||
);
|
||||
|
||||
const onOpenHandler = useCallback(
|
||||
(func: Dispatch<SetStateAction<boolean>>) => (): void => {
|
||||
func(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onEditHandler = useCallback(
|
||||
(record: AuthDomain) => (): void => {
|
||||
if (!record.ssoType) {
|
||||
onOpenHandler(setIsSettingsOpen)();
|
||||
} else {
|
||||
onOpenHandler(setIsEditModalOpen)();
|
||||
}
|
||||
|
||||
setCurrentDomain(record);
|
||||
},
|
||||
[onOpenHandler],
|
||||
);
|
||||
|
||||
const onDeleteHandler = useCallback(
|
||||
(record: AuthDomain) => (): void => {
|
||||
Modal.confirm({
|
||||
centered: true,
|
||||
title: t('delete_domain', {
|
||||
ns: 'organizationsettings',
|
||||
}),
|
||||
content: t('delete_domain_message', {
|
||||
ns: 'organizationsettings',
|
||||
}),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteDomain({
|
||||
...record,
|
||||
});
|
||||
|
||||
notifications.success({
|
||||
message: t('common:success'),
|
||||
});
|
||||
refetch();
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[refetch, t, notifications],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<AuthDomain> = [
|
||||
{
|
||||
title: 'Domain',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<ColumnWithTooltip>
|
||||
<Typography>Enforce SSO</Typography>
|
||||
<TextToolTip
|
||||
{...{
|
||||
text: `When enabled, this option restricts users to SSO based authentication. For more information, click `,
|
||||
url: 'https://signoz.io/docs/userguide/sso-authentication/',
|
||||
}}
|
||||
/>{' '}
|
||||
</ColumnWithTooltip>
|
||||
),
|
||||
dataIndex: 'ssoEnabled',
|
||||
key: 'ssoEnabled',
|
||||
width: 80,
|
||||
render: (value: boolean, record: AuthDomain): JSX.Element => (
|
||||
<SwitchComponent
|
||||
onRecordUpdateHandler={onRecordUpdateHandler}
|
||||
isDefaultChecked={value}
|
||||
record={record}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
width: 100,
|
||||
render: (_, record: AuthDomain): JSX.Element => (
|
||||
<Button type="link" onClick={onEditHandler(record)}>
|
||||
{ConfigureSsoButtonText(record.ssoType)}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 50,
|
||||
render: (_, record): JSX.Element => (
|
||||
<Button onClick={onDeleteHandler(record)} danger type="link">
|
||||
Delete
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!isLoading && data?.data?.length === 0) {
|
||||
return (
|
||||
<Space direction="vertical" size="middle">
|
||||
<AddDomain refetch={refetch} />
|
||||
|
||||
<Modal
|
||||
centered
|
||||
title="Configure Authentication Method"
|
||||
onCancel={onCloseHandler(setIsSettingsOpen)}
|
||||
destroyOnClose
|
||||
open={isSettingsOpen}
|
||||
footer={null}
|
||||
>
|
||||
<Create
|
||||
ssoMethod={currentDomain?.ssoType as AuthDomain['ssoType']}
|
||||
assignSsoMethod={assignSsoMethod}
|
||||
setIsEditModalOpen={setIsEditModalOpen}
|
||||
setIsSettingsOpen={setIsSettingsOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
rowKey={(record: AuthDomain): string => record.name + v4()}
|
||||
dataSource={[]}
|
||||
tableLayout="fixed"
|
||||
bordered
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const tableData = data?.data || [];
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
centered
|
||||
title="Configure Authentication Method"
|
||||
onCancel={onCloseHandler(setIsSettingsOpen)}
|
||||
destroyOnClose
|
||||
open={isSettingsOpen}
|
||||
footer={null}
|
||||
>
|
||||
<Create
|
||||
ssoMethod={currentDomain?.ssoType as AuthDomain['ssoType']}
|
||||
assignSsoMethod={assignSsoMethod}
|
||||
setIsSettingsOpen={setIsSettingsOpen}
|
||||
setIsEditModalOpen={setIsEditModalOpen}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={isEditModalOpen}
|
||||
centered
|
||||
title={EditModalTitleText(currentDomain?.ssoType)}
|
||||
onCancel={onCloseHandler(setIsEditModalOpen)}
|
||||
destroyOnClose
|
||||
style={{ minWidth: '600px' }}
|
||||
footer={null}
|
||||
>
|
||||
<EditSSO
|
||||
onRecordUpdateHandler={onRecordUpdateHandler}
|
||||
record={currentDomain as AuthDomain}
|
||||
setEditModalOpen={setIsEditModalOpen}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<div className="auth-domains-container">
|
||||
<AddDomain refetch={refetch} />
|
||||
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
loading={isLoading}
|
||||
tableLayout="fixed"
|
||||
rowKey={(record: AuthDomain): string => record.name + v4()}
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthDomains;
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Row } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
export const ColumnWithTooltip = styled(Row)`
|
||||
&&& > article {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
`;
|
||||
@@ -1,23 +1,10 @@
|
||||
import { Button, Form, Modal } from 'antd';
|
||||
import { FormInstance } from 'antd/lib';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import get from 'api/v1/invite/get';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { PendingInvite } from 'types/api/user/getPendingInvites';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import InviteTeamMembers from '../InviteTeamMembers';
|
||||
import { InviteMemberFormValues } from '../PendingInvitesContainer';
|
||||
@@ -26,17 +13,7 @@ export interface InviteUserModalProps {
|
||||
isInviteTeamMemberModalOpen: boolean;
|
||||
toggleModal: (value: boolean) => void;
|
||||
form: FormInstance<InviteMemberFormValues>;
|
||||
setDataSource?: Dispatch<SetStateAction<DataProps[]>>;
|
||||
shouldCallApi?: boolean;
|
||||
}
|
||||
|
||||
interface DataProps {
|
||||
key: number;
|
||||
name: string;
|
||||
id: string;
|
||||
email: string;
|
||||
accessLevel: ROLES;
|
||||
inviteLink: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function InviteUserModal(props: InviteUserModalProps): JSX.Element {
|
||||
@@ -44,54 +21,15 @@ function InviteUserModal(props: InviteUserModalProps): JSX.Element {
|
||||
isInviteTeamMemberModalOpen,
|
||||
toggleModal,
|
||||
form,
|
||||
setDataSource,
|
||||
shouldCallApi = false,
|
||||
|
||||
onClose,
|
||||
} = props;
|
||||
const { notifications } = useNotifications();
|
||||
const { t } = useTranslation(['organizationsettings', 'common']);
|
||||
const { user } = useAppContext();
|
||||
|
||||
const [isInvitingMembers, setIsInvitingMembers] = useState<boolean>(false);
|
||||
const [modalForm] = Form.useForm<InviteMemberFormValues>(form);
|
||||
|
||||
const getPendingInvitesResponse = useQuery<
|
||||
SuccessResponseV2<PendingInvite[]>,
|
||||
APIError
|
||||
>({
|
||||
queryFn: get,
|
||||
queryKey: ['getPendingInvites', user?.accessJwt],
|
||||
enabled: shouldCallApi,
|
||||
});
|
||||
|
||||
const getParsedInviteData = useCallback(
|
||||
(payload: PendingInvite[] = []) =>
|
||||
payload?.map((data) => ({
|
||||
key: data.createdAt,
|
||||
name: data?.name,
|
||||
id: data.id,
|
||||
email: data.email,
|
||||
accessLevel: data.role,
|
||||
inviteLink: `${window.location.origin}${ROUTES.SIGN_UP}?token=${data.token}`,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
getPendingInvitesResponse.status === 'success' &&
|
||||
getPendingInvitesResponse?.data?.data
|
||||
) {
|
||||
const data = getParsedInviteData(
|
||||
getPendingInvitesResponse?.data?.data || [],
|
||||
);
|
||||
setDataSource?.(data);
|
||||
}
|
||||
}, [
|
||||
getParsedInviteData,
|
||||
getPendingInvitesResponse?.data?.data,
|
||||
getPendingInvitesResponse.status,
|
||||
setDataSource,
|
||||
]);
|
||||
|
||||
const onInviteClickHandler = useCallback(
|
||||
async (values: InviteMemberFormValues): Promise<void> => {
|
||||
try {
|
||||
@@ -119,10 +57,7 @@ function InviteUserModal(props: InviteUserModalProps): JSX.Element {
|
||||
);
|
||||
|
||||
setTimeout(async () => {
|
||||
const { data, status } = await getPendingInvitesResponse.refetch();
|
||||
if (status === 'success' && data.data) {
|
||||
setDataSource?.(getParsedInviteData(data?.data || []));
|
||||
}
|
||||
onClose();
|
||||
setIsInvitingMembers?.(false);
|
||||
toggleModal(false);
|
||||
}, 2000);
|
||||
@@ -134,15 +69,7 @@ function InviteUserModal(props: InviteUserModalProps): JSX.Element {
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
getParsedInviteData,
|
||||
getPendingInvitesResponse,
|
||||
notifications,
|
||||
setDataSource,
|
||||
setIsInvitingMembers,
|
||||
t,
|
||||
toggleModal,
|
||||
],
|
||||
[notifications, onClose, t, toggleModal],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -177,9 +104,4 @@ function InviteUserModal(props: InviteUserModalProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
InviteUserModal.defaultProps = {
|
||||
setDataSource: (): void => {},
|
||||
shouldCallApi: false,
|
||||
};
|
||||
|
||||
export default InviteUserModal;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ColumnsType } from 'antd/lib/table';
|
||||
import getAll from 'api/v1/user/get';
|
||||
import deleteUser from 'api/v1/user/id/delete';
|
||||
import update from 'api/v1/user/id/update';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -11,9 +12,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { UserResponse } from 'types/api/user/getUsers';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import DeleteMembersDetails from '../DeleteMembersDetails';
|
||||
@@ -210,10 +209,8 @@ function UserFunction({
|
||||
|
||||
function Members(): JSX.Element {
|
||||
const { org } = useAppContext();
|
||||
const { status, data, isLoading } = useQuery<
|
||||
SuccessResponseV2<UserResponse[]>,
|
||||
APIError
|
||||
>({
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryFn: () => getAll(),
|
||||
queryKey: ['getOrgUser', org?.[0].id],
|
||||
});
|
||||
@@ -221,7 +218,7 @@ function Members(): JSX.Element {
|
||||
const [dataSource, setDataSource] = useState<DataType[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'success' && data?.data && Array.isArray(data.data)) {
|
||||
if (data?.data && Array.isArray(data.data)) {
|
||||
const updatedData: DataType[] = data?.data?.map((e) => ({
|
||||
accessLevel: e.role,
|
||||
email: e.email,
|
||||
@@ -231,7 +228,7 @@ function Members(): JSX.Element {
|
||||
}));
|
||||
setDataSource(updatedData);
|
||||
}
|
||||
}, [data?.data, status]);
|
||||
}, [data]);
|
||||
|
||||
const columns: ColumnsType<DataType> = [
|
||||
{
|
||||
@@ -293,14 +290,17 @@ function Members(): JSX.Element {
|
||||
<div className="members-count"> ({dataSource.length}) </div>
|
||||
)}
|
||||
</Typography.Title>
|
||||
{!(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const useActiveLicenseV3 = (isLoggedIn: boolean): UseLicense =>
|
||||
queryFn: getActive,
|
||||
queryKey: [REACT_QUERY_KEY.GET_ACTIVE_LICENSE_V3],
|
||||
enabled: !!isLoggedIn,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
type UseLicense = UseQueryResult<SuccessResponseV2<LicenseResModel>, APIError>;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HelmetProvider } from 'react-helmet-async';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import store from 'store';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -19,9 +20,13 @@ const queryClient = new QueryClient({
|
||||
retry(failureCount, error): boolean {
|
||||
if (
|
||||
// in case of manually throwing errors please make sure to send error.response.status
|
||||
error instanceof AxiosError &&
|
||||
(error 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;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import './Login.styles.scss';
|
||||
|
||||
import LoginContainer from 'container/Login';
|
||||
import useURLQuery from 'hooks/useUrlQuery';
|
||||
|
||||
function Login(): JSX.Element {
|
||||
const urlQueryParams = useURLQuery();
|
||||
const jwt = urlQueryParams.get('jwt') || '';
|
||||
const refreshJwt = urlQueryParams.get('refreshjwt') || '';
|
||||
const userId = urlQueryParams.get('usr') || '';
|
||||
const ssoerror = urlQueryParams.get('ssoerror') || '';
|
||||
const withPassword = urlQueryParams.get('password') || '';
|
||||
|
||||
return (
|
||||
<div className="login-page-container">
|
||||
<div className="perilin-bg" />
|
||||
@@ -25,13 +17,7 @@ function Login(): JSX.Element {
|
||||
<div className="brand-title">SigNoz</div>
|
||||
</div>
|
||||
|
||||
<LoginContainer
|
||||
ssoerror={ssoerror}
|
||||
jwt={jwt}
|
||||
refreshjwt={refreshJwt}
|
||||
userId={userId}
|
||||
withPassword={withPassword}
|
||||
/>
|
||||
<LoginContainer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,45 +1,33 @@
|
||||
import { Typography } from 'antd';
|
||||
import getUserVersion from 'api/v1/version/getVersion';
|
||||
import getUserVersion from 'api/v1/version/get';
|
||||
import Spinner from 'components/Spinner';
|
||||
import ResetPasswordContainer from 'container/ResetPassword';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueries } from 'react-query';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
function ResetPassword(): JSX.Element {
|
||||
const { t } = useTranslation('common');
|
||||
const { user, isLoggedIn } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const [versionResponse] = useQueries([
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -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 don’t match. Please try again
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
|
||||
{isPasswordPolicyError && (
|
||||
<Typography.Paragraph className="password-error-message">
|
||||
{isPasswordNotValidMessage}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSignUp && (
|
||||
|
||||
@@ -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`;
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -55,3 +55,8 @@ export interface Warning {
|
||||
url: string;
|
||||
warnings: AdditionalWarnings[];
|
||||
}
|
||||
|
||||
export interface RawSuccessResponse<T> {
|
||||
status: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
44
frontend/src/types/api/v1/domains/list.ts
Normal file
44
frontend/src/types/api/v1/domains/list.ts
Normal 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;
|
||||
}
|
||||
39
frontend/src/types/api/v1/domains/post.ts
Normal file
39
frontend/src/types/api/v1/domains/post.ts
Normal 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;
|
||||
}
|
||||
37
frontend/src/types/api/v1/domains/put.ts
Normal file
37
frontend/src/types/api/v1/domains/put.ts
Normal 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;
|
||||
}
|
||||
10
frontend/src/types/api/v1/register/post.ts
Normal file
10
frontend/src/types/api/v1/register/post.ts
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface PayloadProps {
|
||||
export interface Info {
|
||||
version: string;
|
||||
ee: 'Y' | 'N';
|
||||
setupCompleted: boolean;
|
||||
32
frontend/src/types/api/v2/sessions/context/get.ts
Normal file
32
frontend/src/types/api/v2/sessions/context/get.ts
Normal 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[];
|
||||
}
|
||||
10
frontend/src/types/api/v2/sessions/email_password/post.ts
Normal file
10
frontend/src/types/api/v2/sessions/email_password/post.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface Props {
|
||||
email: string;
|
||||
password: string;
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
8
frontend/src/types/api/v2/sessions/rotate/post.ts
Normal file
8
frontend/src/types/api/v2/sessions/rotate/post.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface Props {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
14
go.mod
14
go.mod
@@ -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
53
go.sum
@@ -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
Reference in New Issue
Block a user