From 86b62d6449cb10326eeb04f1aa1a9f2eb65acc6f Mon Sep 17 00:00:00 2001 From: Ivan Kapelyukhin Date: Tue, 18 Aug 2020 21:23:49 +0200 Subject: [PATCH 01/18] Add backend support for API keys --- src/jetstream/apikeys.go | 78 +++++++ .../datastore/20200814140918_ApiKeys.go | 21 ++ src/jetstream/main.go | 19 +- src/jetstream/middleware.go | 216 +++++++++++++----- src/jetstream/repository/apikeys/apikeys.go | 11 + .../repository/apikeys/psql_apikeys.go | 138 +++++++++++ .../repository/interfaces/apikeys.go | 8 + 7 files changed, 431 insertions(+), 60 deletions(-) create mode 100644 src/jetstream/apikeys.go create mode 100644 src/jetstream/datastore/20200814140918_ApiKeys.go create mode 100644 src/jetstream/repository/apikeys/apikeys.go create mode 100644 src/jetstream/repository/apikeys/psql_apikeys.go create mode 100644 src/jetstream/repository/interfaces/apikeys.go diff --git a/src/jetstream/apikeys.go b/src/jetstream/apikeys.go new file mode 100644 index 0000000000..5ce7c98ec0 --- /dev/null +++ b/src/jetstream/apikeys.go @@ -0,0 +1,78 @@ +package main + +import ( + "net/http" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/apikeys" + "github.com/labstack/echo" + log "github.com/sirupsen/logrus" +) + +func (p *portalProxy) addAPIKey(c echo.Context) error { + log.Debug("addAPIKey") + + userGUID := c.Get("user_id").(string) + comment := c.FormValue("comment") + + if len(comment) == 0 { + return echo.NewHTTPError(http.StatusBadRequest, "Comment can't be empty") + } + + apiKeysRepo, err := apikeys.NewPgsqlAPIKeysRepository(p.DatabaseConnectionPool) + if err != nil { + log.Errorf("Database error getting repo for API keys: %v", err) + return err + } + + apiKey, err := apiKeysRepo.AddAPIKey(userGUID, comment) + if err != nil { + log.Errorf("Error adding API key %v", err) + return err + } + + return c.JSON(http.StatusOK, apiKey) +} + +func (p *portalProxy) listAPIKeys(c echo.Context) error { + log.Debug("listAPIKeys") + + apiKeysRepo, err := apikeys.NewPgsqlAPIKeysRepository(p.DatabaseConnectionPool) + if err != nil { + log.Errorf("Database error getting repo for API keys: %v", err) + return err + } + + userGUID := c.Get("user_id").(string) + + apiKeys, err := apiKeysRepo.ListAPIKeys(userGUID) + if err != nil { + log.Errorf("Error listing API keys %v", err) + return nil + } + + return c.JSON(http.StatusOK, apiKeys) +} + +func (p *portalProxy) deleteAPIKey(c echo.Context) error { + log.Debug("deleteAPIKey") + + userGUID := c.Get("user_id").(string) + keyGUID := c.FormValue("guid") + + if len(keyGUID) == 0 { + return echo.NewHTTPError(http.StatusBadRequest, "API key guid can't be empty") + } + + apiKeysRepo, err := apikeys.NewPgsqlAPIKeysRepository(p.DatabaseConnectionPool) + if err != nil { + log.Errorf("Database error getting repo for API keys: %v", err) + return err + } + + if err = apiKeysRepo.DeleteAPIKey(userGUID, keyGUID); err != nil { + log.Errorf("Error deleting API key %v", err) + return echo.NewHTTPError(http.StatusBadRequest, "Error deleting API key") + } + + return nil +} diff --git a/src/jetstream/datastore/20200814140918_ApiKeys.go b/src/jetstream/datastore/20200814140918_ApiKeys.go new file mode 100644 index 0000000000..ba9566dda2 --- /dev/null +++ b/src/jetstream/datastore/20200814140918_ApiKeys.go @@ -0,0 +1,21 @@ +package datastore + +import ( + "database/sql" + + "bitbucket.org/liamstask/goose/lib/goose" +) + +func init() { + RegisterMigration(20200814140918, "ApiKeys", func(txn *sql.Tx, conf *goose.DBConf) error { + apiTokenTable := "CREATE TABLE IF NOT EXISTS api_keys (" + apiTokenTable += "guid VARCHAR(36) NOT NULL UNIQUE," + apiTokenTable += "secret VARCHAR(36) NOT NULL UNIQUE," + apiTokenTable += "user_guid VARCHAR(36) NOT NULL," + apiTokenTable += "comment VARCHAR(255) NOT NULL," + apiTokenTable += "PRIMARY KEY (guid) );" + + _, err := txn.Exec(apiTokenTable) + return err + }) +} diff --git a/src/jetstream/main.go b/src/jetstream/main.go index 955f6f0000..13dd1c7587 100644 --- a/src/jetstream/main.go +++ b/src/jetstream/main.go @@ -905,8 +905,19 @@ func (p *portalProxy) registerRoutes(e *echo.Echo, needSetupMiddleware bool) { // All routes in the session group need the user to be authenticated sessionGroup := pp.Group("/v1") - sessionGroup.Use(p.sessionMiddleware) - sessionGroup.Use(p.xsrfMiddleware) + sessionGroup.Use(p.sessionMiddleware()) + sessionGroup.Use(p.xsrfMiddleware()) + + sessionGroup.POST("/api_keys", p.addAPIKey) + sessionGroup.GET("/api_keys", p.listAPIKeys) + sessionGroup.DELETE("/api_keys", p.deleteAPIKey) + + apiKeyGroupConfig := MiddlewareConfig{Skipper: p.apiKeySkipper} + + apiKeyGroup := pp.Group("/v1") + apiKeyGroup.Use(p.apiKeyMiddleware) + apiKeyGroup.Use(p.sessionMiddlewareWithConfig(apiKeyGroupConfig)) + apiKeyGroup.Use(p.xsrfMiddlewareWithConfig(apiKeyGroupConfig)) for _, plugin := range p.Plugins { middlewarePlugin, err := plugin.GetMiddlewarePlugin() @@ -932,8 +943,8 @@ func (p *portalProxy) registerRoutes(e *echo.Echo, needSetupMiddleware bool) { sessionAuthGroup.GET("/session/verify", p.verifySession) // CNSI operations - sessionGroup.GET("/cnsis", p.listCNSIs) - sessionGroup.GET("/cnsis/registered", p.listRegisteredCNSIs) + apiKeyGroup.GET("/cnsis", p.listCNSIs) + apiKeyGroup.GET("/cnsis/registered", p.listRegisteredCNSIs) // Info sessionGroup.GET("/info", p.info) diff --git a/src/jetstream/middleware.go b/src/jetstream/middleware.go index 723ed2ad49..c7e1b115b0 100644 --- a/src/jetstream/middleware.go +++ b/src/jetstream/middleware.go @@ -2,6 +2,7 @@ package main import ( "crypto/subtle" + "database/sql" "errors" "fmt" "net/http" @@ -14,6 +15,7 @@ import ( "github.com/labstack/echo" log "github.com/sirupsen/logrus" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/apikeys" "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" ) @@ -28,6 +30,15 @@ const StratosSSOHeader = "x-stratos-sso-login" // Header to communicate any error during SSO const StratosSSOErrorHeader = "x-stratos-sso-error" +// APIKeyContextKey - context +const APIKeySkipperContextKey = "valid_api_key" + +// APIKeyHeader - API key authentication header name +const APIKeyHeader = "Authentication" + +// APIKeyAuthScheme - API key authentication scheme +const APIKeyAuthScheme = "Bearer" + func handleSessionError(config interfaces.PortalConfig, c echo.Context, err error, doNotLog bool, msg string) error { log.Debug("handleSessionError") @@ -65,75 +76,113 @@ func handleSessionError(config interfaces.PortalConfig, c echo.Context, err erro ) } -func (p *portalProxy) sessionMiddleware(h echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - log.Debug("sessionMiddleware") +type ( + // Skipper - skipper function for middlewares + Skipper func(echo.Context) bool - p.removeEmptyCookie(c) + // MiddlewareConfig defines the config for Logger middleware. + MiddlewareConfig struct { + // Skipper defines a function to skip middleware. + Skipper Skipper + } +) - userID, err := p.GetSessionValue(c, "user_id") - if err == nil { - c.Set("user_id", userID) - return h(c) - } +func (p *portalProxy) sessionMiddleware() echo.MiddlewareFunc { - // Don't log an error if we are verifying the session, as a failure is not an error - isVerify := strings.HasSuffix(c.Request().RequestURI, "/auth/session/verify") - if isVerify { - // Tell the frontend what the Cookie Domain is so it can check if sessions will work - c.Response().Header().Set(StratosDomainHeader, p.Config.CookieDomain) - } + return p.sessionMiddlewareWithConfig(MiddlewareConfig{}) +} - // Clear any session cookie - cookie := new(http.Cookie) - cookie.Name = p.SessionCookieName - cookie.Value = "" - cookie.Expires = time.Now().Add(-24 * time.Hour) - cookie.Domain = p.SessionStoreOptions.Domain - cookie.HttpOnly = p.SessionStoreOptions.HttpOnly - cookie.Secure = p.SessionStoreOptions.Secure - cookie.Path = p.SessionStoreOptions.Path - cookie.MaxAge = 0 - c.SetCookie(cookie) - - return handleSessionError(p.Config, c, err, isVerify, "User session could not be found") +func (p *portalProxy) sessionMiddlewareWithConfig(config MiddlewareConfig) echo.MiddlewareFunc { + // Default skipper function always returns false + if config.Skipper == nil { + config.Skipper = func(c echo.Context) bool { return false } } -} -// Support for Angular XSRF -func (p *portalProxy) xsrfMiddleware(h echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - log.Debug("xsrfMiddleware") + return func(h echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + log.Debug("sessionMiddleware") - // Only do this for mutating requests - i.e. we can ignore for GET or HEAD requests - if c.Request().Method == "GET" || c.Request().Method == "HEAD" { - return h(c) - } + if config.Skipper(c) { + log.Debug("Skipping sessionMiddleware") + return h(c) + } - // Routes registered with /apps are assumed to be web apps that do their own XSRF - if strings.HasPrefix(c.Request().URL.String(), "/pp/v1/apps/") { - return h(c) + p.removeEmptyCookie(c) + + userID, err := p.GetSessionValue(c, "user_id") + if err == nil { + c.Set("user_id", userID) + return h(c) + } + + // Don't log an error if we are verifying the session, as a failure is not an error + isVerify := strings.HasSuffix(c.Request().RequestURI, "/auth/session/verify") + if isVerify { + // Tell the frontend what the Cookie Domain is so it can check if sessions will work + c.Response().Header().Set(StratosDomainHeader, p.Config.CookieDomain) + } + + // Clear any session cookie + cookie := new(http.Cookie) + cookie.Name = p.SessionCookieName + cookie.Value = "" + cookie.Expires = time.Now().Add(-24 * time.Hour) + cookie.Domain = p.SessionStoreOptions.Domain + cookie.HttpOnly = p.SessionStoreOptions.HttpOnly + cookie.Secure = p.SessionStoreOptions.Secure + cookie.Path = p.SessionStoreOptions.Path + cookie.MaxAge = 0 + c.SetCookie(cookie) + + return handleSessionError(p.Config, c, err, isVerify, "User session could not be found") } + } +} - errMsg := "Failed to get stored XSRF token from user session" - token, err := p.GetSessionStringValue(c, XSRFTokenSessionName) - if err == nil { - // Check the token against the header - requestToken := c.Request().Header.Get(XSRFTokenHeader) - if len(requestToken) > 0 { - if compareTokens(requestToken, token) { - return h(c) +func (p *portalProxy) xsrfMiddleware() echo.MiddlewareFunc { + return p.xsrfMiddlewareWithConfig(MiddlewareConfig{}) +} + +func (p *portalProxy) xsrfMiddlewareWithConfig(config MiddlewareConfig) echo.MiddlewareFunc { + // Default skipper function always returns false + if config.Skipper == nil { + config.Skipper = func(c echo.Context) bool { return false } + } + + return func(h echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + log.Debug("xsrfMiddleware") + + // Only do this for mutating requests - i.e. we can ignore for GET or HEAD requests + if c.Request().Method == "GET" || c.Request().Method == "HEAD" { + return h(c) + } + + // Routes registered with /apps are assumed to be web apps that do their own XSRF + if strings.HasPrefix(c.Request().URL.String(), "/pp/v1/apps/") { + return h(c) + } + + errMsg := "Failed to get stored XSRF token from user session" + token, err := p.GetSessionStringValue(c, XSRFTokenSessionName) + if err == nil { + // Check the token against the header + requestToken := c.Request().Header.Get(XSRFTokenHeader) + if len(requestToken) > 0 { + if compareTokens(requestToken, token) { + return h(c) + } + errMsg = "Supplied XSRF Token does not match" + } else { + errMsg = "XSRF Token was not supplied in the header" } - errMsg = "Supplied XSRF Token does not match" - } else { - errMsg = "XSRF Token was not supplied in the header" } + return interfaces.NewHTTPShadowError( + http.StatusUnauthorized, + "XSRF Token could not be found or does not match", + "XSRF Token error: %s", errMsg, + ) } - return interfaces.NewHTTPShadowError( - http.StatusUnauthorized, - "XSRF Token could not be found or does not match", - "XSRF Token error: %s", errMsg, - ) } } @@ -254,3 +303,58 @@ func retryAfterUpgradeMiddleware(h echo.HandlerFunc, env *env.VarSet) echo.Handl return h(c) } } + +func getAPIKeyFromHeader(c echo.Context) (string, error) { + header := c.Request().Header.Get(APIKeyHeader) + + l := len(APIKeyAuthScheme) + if len(header) > l+1 && header[:l] == APIKeyAuthScheme { + return header[l+1:], nil + } + + return "", errors.New("No API key in the header") +} + +func (p *portalProxy) apiKeyMiddleware(h echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + log.Debug("apiKeyMiddleware") + + apiKey, err := getAPIKeyFromHeader(c) + if err != nil { + log.Debugf("apiKeyMiddleware: %v", err) + return h(c) + } + + apiKeysRepo, err := apikeys.NewPgsqlAPIKeysRepository(p.DatabaseConnectionPool) + if err != nil { + log.Errorf("apiKeyMiddleware: %v", err) + return h(c) + } + + userID, err := apiKeysRepo.GetAPIKeyUserID(apiKey) + if err != nil { + switch { + case err == sql.ErrNoRows: + log.Debug("apiKeyMiddleware: Invalid API key supplied") + default: + log.Warnf("apiKeyMiddleware: %v", err) + } + + return h(c) + } + + c.Set(APIKeySkipperContextKey, true) + c.Set("user_id", userID) + + // some endpoints check not only the context store, but also the contents of the session store + sessionValues := make(map[string]interface{}) + sessionValues["user_id"] = userID + p.setSessionValues(c, sessionValues) + + return h(c) + } +} + +func (p *portalProxy) apiKeySkipper(c echo.Context) bool { + return c.Get(APIKeySkipperContextKey) != nil && c.Get(APIKeySkipperContextKey).(bool) == true +} diff --git a/src/jetstream/repository/apikeys/apikeys.go b/src/jetstream/repository/apikeys/apikeys.go new file mode 100644 index 0000000000..f64cba0b67 --- /dev/null +++ b/src/jetstream/repository/apikeys/apikeys.go @@ -0,0 +1,11 @@ +package apikeys + +import "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + +// Repository - API keys repository +type Repository interface { + AddAPIKey(userID string, comment string) (*interfaces.APIKey, error) + GetAPIKeyUserID(keySecret string) (string, error) + ListAPIKeys(userID string) ([]interfaces.APIKey, error) + DeleteAPIKey(userGUID string, keyGUID string) error +} diff --git a/src/jetstream/repository/apikeys/psql_apikeys.go b/src/jetstream/repository/apikeys/psql_apikeys.go new file mode 100644 index 0000000000..e53b7c41b3 --- /dev/null +++ b/src/jetstream/repository/apikeys/psql_apikeys.go @@ -0,0 +1,138 @@ +package apikeys + +import ( + "database/sql" + "errors" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/datastore" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + uuid "github.com/satori/go.uuid" + log "github.com/sirupsen/logrus" +) + +var insertAPIKey = `INSERT INTO api_keys (guid, secret, user_guid, comment) VALUES ($1, $2, $3, $4)` +var getAPIKeyUserID = `SELECT user_guid FROM api_keys WHERE secret = $1` +var listAPIKeys = `SELECT guid, user_guid, comment FROM api_keys WHERE user_guid = $1` +var deleteAPIKey = `DELETE FROM api_keys WHERE user_guid = $1 AND guid = $2` + +// PgsqlAPIKeysRepository - Postgresql-backed API keys repository +type PgsqlAPIKeysRepository struct { + db *sql.DB +} + +// NewPgsqlAPIKeysRepository - get a reference to the API keys data source +func NewPgsqlAPIKeysRepository(dcp *sql.DB) (Repository, error) { + log.Debug("NewPgsqlAPIKeysRepository") + return &PgsqlAPIKeysRepository{db: dcp}, nil +} + +// InitRepositoryProvider - One time init for the given DB Provider +func InitRepositoryProvider(databaseProvider string) { + // Modify the database statements if needed, for the given database type + insertAPIKey = datastore.ModifySQLStatement(insertAPIKey, databaseProvider) + getAPIKeyUserID = datastore.ModifySQLStatement(getAPIKeyUserID, databaseProvider) + deleteAPIKey = datastore.ModifySQLStatement(deleteAPIKey, databaseProvider) + listAPIKeys = datastore.ModifySQLStatement(listAPIKeys, databaseProvider) +} + +// AddAPIKey - Add a new API key to the datastore. +func (p *PgsqlAPIKeysRepository) AddAPIKey(userID string, comment string) (*interfaces.APIKey, error) { + log.Debug("AddAPIKey") + + var err error + + // Validate args + if len(comment) > 255 { + msg := "comment must be less than 255 characters long" + log.Debug(msg) + err = errors.New(msg) + } + if err != nil { + return nil, err + } + + keyGUID := uuid.NewV4().String() + keySecret := uuid.NewV4().String() + + var result sql.Result + if result, err = p.db.Exec(insertAPIKey, keyGUID, keySecret, userID, comment); err != nil { + log.Errorf("unable to INSERT API key: %v", err) + return nil, err + } + + //Validate that 1 row has been updated + rowsUpdates, err := result.RowsAffected() + if err != nil { + return nil, errors.New("unable to INSERT api key: could not determine number of rows that were updated") + } else if rowsUpdates < 1 { + return nil, errors.New("unable to INSERT api key: no rows were updated") + } + + apiKey := &interfaces.APIKey{ + GUID: keyGUID, + Secret: keySecret, + UserGUID: userID, + Comment: comment, + } + + return apiKey, err +} + +// GetAPIKeyUserID - gets user ID for an API key +func (p *PgsqlAPIKeysRepository) GetAPIKeyUserID(keySecret string) (string, error) { + log.Debug("GetAPIKeyUserID") + + var ( + err error + userGUID string + ) + + if err = p.db.QueryRow(getAPIKeyUserID, keySecret).Scan(&userGUID); err != nil { + return "", err + } + + return userGUID, nil +} + +// ListAPIKeys - list API keys for a given user GUID +func (p *PgsqlAPIKeysRepository) ListAPIKeys(userID string) ([]interfaces.APIKey, error) { + log.Debug("ListAPIKeys") + + rows, err := p.db.Query(listAPIKeys, userID) + if err != nil { + log.Errorf("unable to list API keys: %v", err) + return nil, err + } + + result := []interfaces.APIKey{} + for rows.Next() { + var apiKey interfaces.APIKey + err = rows.Scan(&apiKey.GUID, &apiKey.UserGUID, &apiKey.Comment) + if err != nil { + log.Errorf("Scan: %v", err) + return nil, err + } + result = append(result, apiKey) + } + + return result, nil +} + +// DeleteAPIKey - delete an API key identified by its GUID +func (p *PgsqlAPIKeysRepository) DeleteAPIKey(userGUID string, keyGUID string) error { + log.Debug("DeleteAPIKey") + + result, err := p.db.Exec(deleteAPIKey, userGUID, keyGUID) + if err != nil { + return err + } + + rowsUpdates, err := result.RowsAffected() + if err != nil { + return errors.New("unable to DELETE api key: could not determine number of rows that were updated") + } else if rowsUpdates < 1 { + return errors.New("unable to DELETE api key: no rows were updated") + } + + return nil +} diff --git a/src/jetstream/repository/interfaces/apikeys.go b/src/jetstream/repository/interfaces/apikeys.go new file mode 100644 index 0000000000..fd8b1c71b2 --- /dev/null +++ b/src/jetstream/repository/interfaces/apikeys.go @@ -0,0 +1,8 @@ +package interfaces + +type APIKey struct { + GUID string `json:"guid"` + Secret string `json:"secret"` + UserGUID string `json:"user_guid"` + Comment string `json:"comment"` +} From 96248c09ac3673426458c129e9dfe3d81227e2fd Mon Sep 17 00:00:00 2001 From: Ivan Kapelyukhin Date: Wed, 19 Aug 2020 14:41:18 +0200 Subject: [PATCH 02/18] Add last_used field to API keys --- .../datastore/20200814140918_ApiKeys.go | 1 + src/jetstream/middleware.go | 15 ++++-- src/jetstream/repository/apikeys/apikeys.go | 3 +- .../repository/apikeys/psql_apikeys.go | 54 ++++++++++++++----- .../repository/interfaces/apikeys.go | 12 +++-- 5 files changed, 62 insertions(+), 23 deletions(-) diff --git a/src/jetstream/datastore/20200814140918_ApiKeys.go b/src/jetstream/datastore/20200814140918_ApiKeys.go index ba9566dda2..2a00b44365 100644 --- a/src/jetstream/datastore/20200814140918_ApiKeys.go +++ b/src/jetstream/datastore/20200814140918_ApiKeys.go @@ -13,6 +13,7 @@ func init() { apiTokenTable += "secret VARCHAR(36) NOT NULL UNIQUE," apiTokenTable += "user_guid VARCHAR(36) NOT NULL," apiTokenTable += "comment VARCHAR(255) NOT NULL," + apiTokenTable += "last_used TIMESTAMP," apiTokenTable += "PRIMARY KEY (guid) );" _, err := txn.Exec(apiTokenTable) diff --git a/src/jetstream/middleware.go b/src/jetstream/middleware.go index c7e1b115b0..d41f3bc540 100644 --- a/src/jetstream/middleware.go +++ b/src/jetstream/middleware.go @@ -319,7 +319,7 @@ func (p *portalProxy) apiKeyMiddleware(h echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { log.Debug("apiKeyMiddleware") - apiKey, err := getAPIKeyFromHeader(c) + apiKeySecret, err := getAPIKeyFromHeader(c) if err != nil { log.Debugf("apiKeyMiddleware: %v", err) return h(c) @@ -331,26 +331,31 @@ func (p *portalProxy) apiKeyMiddleware(h echo.HandlerFunc) echo.HandlerFunc { return h(c) } - userID, err := apiKeysRepo.GetAPIKeyUserID(apiKey) + apiKey, err := apiKeysRepo.GetAPIKeyBySecret(apiKeySecret) if err != nil { switch { case err == sql.ErrNoRows: log.Debug("apiKeyMiddleware: Invalid API key supplied") default: - log.Warnf("apiKeyMiddleware: %v", err) + log.Errorf("apiKeyMiddleware: %v", err) } return h(c) } c.Set(APIKeySkipperContextKey, true) - c.Set("user_id", userID) + c.Set("user_id", apiKey.UserGUID) // some endpoints check not only the context store, but also the contents of the session store sessionValues := make(map[string]interface{}) - sessionValues["user_id"] = userID + sessionValues["user_id"] = apiKey.UserGUID p.setSessionValues(c, sessionValues) + err = apiKeysRepo.UpdateAPIKeyLastUsed(apiKey.GUID) + if err != nil { + log.Errorf("apiKeyMiddleware: %v", err) + } + return h(c) } } diff --git a/src/jetstream/repository/apikeys/apikeys.go b/src/jetstream/repository/apikeys/apikeys.go index f64cba0b67..bf783990a0 100644 --- a/src/jetstream/repository/apikeys/apikeys.go +++ b/src/jetstream/repository/apikeys/apikeys.go @@ -5,7 +5,8 @@ import "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/inter // Repository - API keys repository type Repository interface { AddAPIKey(userID string, comment string) (*interfaces.APIKey, error) - GetAPIKeyUserID(keySecret string) (string, error) + GetAPIKeyBySecret(keySecret string) (*interfaces.APIKey, error) ListAPIKeys(userID string) ([]interfaces.APIKey, error) DeleteAPIKey(userGUID string, keyGUID string) error + UpdateAPIKeyLastUsed(keyGUID string) error } diff --git a/src/jetstream/repository/apikeys/psql_apikeys.go b/src/jetstream/repository/apikeys/psql_apikeys.go index e53b7c41b3..eee946696d 100644 --- a/src/jetstream/repository/apikeys/psql_apikeys.go +++ b/src/jetstream/repository/apikeys/psql_apikeys.go @@ -3,6 +3,7 @@ package apikeys import ( "database/sql" "errors" + "time" "github.com/cloudfoundry-incubator/stratos/src/jetstream/datastore" "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" @@ -11,9 +12,12 @@ import ( ) var insertAPIKey = `INSERT INTO api_keys (guid, secret, user_guid, comment) VALUES ($1, $2, $3, $4)` -var getAPIKeyUserID = `SELECT user_guid FROM api_keys WHERE secret = $1` -var listAPIKeys = `SELECT guid, user_guid, comment FROM api_keys WHERE user_guid = $1` +var getAPIKeyBySecret = `SELECT guid, user_guid, comment, last_used FROM api_keys WHERE secret = $1` +var listAPIKeys = `SELECT guid, user_guid, comment, last_used FROM api_keys WHERE user_guid = $1` var deleteAPIKey = `DELETE FROM api_keys WHERE user_guid = $1 AND guid = $2` +var updateAPIKeyLastUsed = `UPDATE api_keys SET last_used = $1 WHERE guid = $2` + +// UpdateAPIKeyLastUsed // PgsqlAPIKeysRepository - Postgresql-backed API keys repository type PgsqlAPIKeysRepository struct { @@ -30,9 +34,10 @@ func NewPgsqlAPIKeysRepository(dcp *sql.DB) (Repository, error) { func InitRepositoryProvider(databaseProvider string) { // Modify the database statements if needed, for the given database type insertAPIKey = datastore.ModifySQLStatement(insertAPIKey, databaseProvider) - getAPIKeyUserID = datastore.ModifySQLStatement(getAPIKeyUserID, databaseProvider) + getAPIKeyBySecret = datastore.ModifySQLStatement(getAPIKeyBySecret, databaseProvider) deleteAPIKey = datastore.ModifySQLStatement(deleteAPIKey, databaseProvider) listAPIKeys = datastore.ModifySQLStatement(listAPIKeys, databaseProvider) + updateAPIKeyLastUsed = datastore.ModifySQLStatement(updateAPIKeyLastUsed, databaseProvider) } // AddAPIKey - Add a new API key to the datastore. @@ -78,20 +83,24 @@ func (p *PgsqlAPIKeysRepository) AddAPIKey(userID string, comment string) (*inte return apiKey, err } -// GetAPIKeyUserID - gets user ID for an API key -func (p *PgsqlAPIKeysRepository) GetAPIKeyUserID(keySecret string) (string, error) { - log.Debug("GetAPIKeyUserID") +// GetAPIKeyBySecret - gets user ID for an API key +func (p *PgsqlAPIKeysRepository) GetAPIKeyBySecret(keySecret string) (*interfaces.APIKey, error) { + log.Debug("GetAPIKeyBySecret") + + var apiKey interfaces.APIKey - var ( - err error - userGUID string + err := p.db.QueryRow(getAPIKeyBySecret, keySecret).Scan( + &apiKey.GUID, + &apiKey.UserGUID, + &apiKey.Comment, + &apiKey.LastUsed, ) - if err = p.db.QueryRow(getAPIKeyUserID, keySecret).Scan(&userGUID); err != nil { - return "", err + if err != nil { + return nil, err } - return userGUID, nil + return &apiKey, nil } // ListAPIKeys - list API keys for a given user GUID @@ -107,7 +116,7 @@ func (p *PgsqlAPIKeysRepository) ListAPIKeys(userID string) ([]interfaces.APIKey result := []interfaces.APIKey{} for rows.Next() { var apiKey interfaces.APIKey - err = rows.Scan(&apiKey.GUID, &apiKey.UserGUID, &apiKey.Comment) + err = rows.Scan(&apiKey.GUID, &apiKey.UserGUID, &apiKey.Comment, &apiKey.LastUsed) if err != nil { log.Errorf("Scan: %v", err) return nil, err @@ -136,3 +145,22 @@ func (p *PgsqlAPIKeysRepository) DeleteAPIKey(userGUID string, keyGUID string) e return nil } + +// UpdateAPIKeyLastUsed - sets API key last_used field to current time +func (p *PgsqlAPIKeysRepository) UpdateAPIKeyLastUsed(keyGUID string) error { + log.Debug("UpdateAPIKeyLastUsed") + + result, err := p.db.Exec(updateAPIKeyLastUsed, time.Now(), keyGUID) + if err != nil { + return err + } + + rowsUpdates, err := result.RowsAffected() + if err != nil { + return errors.New("unable to UPDATE api key: could not determine number of rows that were updated") + } else if rowsUpdates < 1 { + return errors.New("unable to UPDATE api key: no rows were updated") + } + + return nil +} diff --git a/src/jetstream/repository/interfaces/apikeys.go b/src/jetstream/repository/interfaces/apikeys.go index fd8b1c71b2..a0ead69e35 100644 --- a/src/jetstream/repository/interfaces/apikeys.go +++ b/src/jetstream/repository/interfaces/apikeys.go @@ -1,8 +1,12 @@ package interfaces +import "time" + +// APIKey - represents API key DB entry type APIKey struct { - GUID string `json:"guid"` - Secret string `json:"secret"` - UserGUID string `json:"user_guid"` - Comment string `json:"comment"` + GUID string `json:"guid"` + Secret string `json:"secret"` + UserGUID string `json:"user_guid"` + Comment string `json:"comment"` + LastUsed *time.Time `json:"last_used"` } From 7664cd481d11484a9a2e33597b16e19588b59e49 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Thu, 20 Aug 2020 09:39:13 +0100 Subject: [PATCH 03/18] Add base api keys page --- src/frontend/packages/core/src/app.routing.ts | 1 + .../api-keys-page.component.html | 1 + .../api-keys-page.component.scss | 0 .../api-keys-page.component.spec.ts | 25 +++++++++++++++++++ .../api-keys-page/api-keys-page.component.ts | 15 +++++++++++ .../src/features/api-keys/api-keys.module.ts | 20 +++++++++++++++ .../src/features/api-keys/api-keys.routing.ts | 18 +++++++++++++ 7 files changed, 80 insertions(+) create mode 100644 src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html create mode 100644 src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.scss create mode 100644 src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.spec.ts create mode 100644 src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts create mode 100644 src/frontend/packages/core/src/features/api-keys/api-keys.module.ts create mode 100644 src/frontend/packages/core/src/features/api-keys/api-keys.routing.ts diff --git a/src/frontend/packages/core/src/app.routing.ts b/src/frontend/packages/core/src/app.routing.ts index 2020929d92..38b64826e3 100644 --- a/src/frontend/packages/core/src/app.routing.ts +++ b/src/frontend/packages/core/src/app.routing.ts @@ -95,6 +95,7 @@ const appRoutes: Routes = [ }, { path: 'about', loadChildren: () => import('./features/about/about.module').then(m => m.AboutModule) }, { path: 'user-profile', loadChildren: () => import('./features/user-profile/user-profile.module').then(m => m.UserProfileModule) }, + { path: 'api-keys', loadChildren: () => import('./features/api-keys/api-keys.module').then(m => m.ApiKeysModule) }, { path: 'events', loadChildren: () => import('./features/event-page/event-page.module').then(m => m.EventPageModule) }, { path: 'errors/:endpointId', diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html new file mode 100644 index 0000000000..cb1f3694b1 --- /dev/null +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html @@ -0,0 +1 @@ +API Keys \ No newline at end of file diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.scss b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.spec.ts b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.spec.ts new file mode 100644 index 0000000000..3904ebd1a7 --- /dev/null +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ApiKeysPageComponent } from './api-keys-page.component'; + +describe('ApiKeysPageComponent', () => { + let component: ApiKeysPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ApiKeysPageComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ApiKeysPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts new file mode 100644 index 0000000000..b48df1c470 --- /dev/null +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-api-keys-page', + templateUrl: './api-keys-page.component.html', + styleUrls: ['./api-keys-page.component.scss'] +}) +export class ApiKeysPageComponent implements OnInit { + + constructor() { } + + ngOnInit(): void { + } + +} diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys.module.ts b/src/frontend/packages/core/src/features/api-keys/api-keys.module.ts new file mode 100644 index 0000000000..5c91110e75 --- /dev/null +++ b/src/frontend/packages/core/src/features/api-keys/api-keys.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; + +import { CoreModule } from '../../core/core.module'; +import { SharedModule } from '../../shared/shared.module'; +import { ApiKeysPageComponent } from './api-keys-page/api-keys-page.component'; +import { ApiKeysRoutingModule } from './api-keys.routing'; + + +@NgModule({ + imports: [ + CoreModule, + SharedModule, + ApiKeysRoutingModule, + ], + declarations: [ + ApiKeysPageComponent + ] +}) +export class ApiKeysModule { } + diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys.routing.ts b/src/frontend/packages/core/src/features/api-keys/api-keys.routing.ts new file mode 100644 index 0000000000..395c58188b --- /dev/null +++ b/src/frontend/packages/core/src/features/api-keys/api-keys.routing.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { ApiKeysPageComponent } from './api-keys-page/api-keys-page.component'; + +const apiKeys: Routes = [ + { + path: '', + component: ApiKeysPageComponent + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(apiKeys), + ] +}) +export class ApiKeysRoutingModule { } From 3795db21ca282a162efdbdd331013e6cf51bbeef Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Thu, 20 Aug 2020 10:33:56 +0100 Subject: [PATCH 04/18] Add basic api key entity framework (untested) --- .../store/src/actions/apiKey.actions.ts | 51 ++++++++++ .../packages/store/src/apiKey.types.ts | 4 + .../store/src/effects/apiKey.effects.ts | 99 +++++++++++++++++++ .../src/helpers/stratos-entity-factory.ts | 4 + .../packages/store/src/store.module.ts | 4 +- .../store/src/stratos-action-builders.ts | 18 ++++ .../store/src/stratos-entity-catalog.ts | 8 ++ .../store/src/stratos-entity-generator.ts | 30 +++++- 8 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 src/frontend/packages/store/src/actions/apiKey.actions.ts create mode 100644 src/frontend/packages/store/src/apiKey.types.ts create mode 100644 src/frontend/packages/store/src/effects/apiKey.effects.ts diff --git a/src/frontend/packages/store/src/actions/apiKey.actions.ts b/src/frontend/packages/store/src/actions/apiKey.actions.ts new file mode 100644 index 0000000000..dcc2c50edd --- /dev/null +++ b/src/frontend/packages/store/src/actions/apiKey.actions.ts @@ -0,0 +1,51 @@ +import { apiKeyEntityType, STRATOS_ENDPOINT_TYPE, stratosEntityFactory } from '../helpers/stratos-entity-factory'; +import { PaginatedAction } from '../types/pagination.types'; +import { EntityRequestAction } from '../types/request.types'; + +export const API_KEY_ADD = '[API Key] Add API Key' +export const API_KEY_DELETE = '[API Key] Delete API Key' +export const API_KEY_GET_ALL = '[API Key] Get All API Key' + +abstract class BaseApiKeyAction implements EntityRequestAction { + entityType = apiKeyEntityType; + endpointType = STRATOS_ENDPOINT_TYPE; + entity = [stratosEntityFactory(apiKeyEntityType)] + constructor(public type: string) { } +} + +export interface PaginationApiKeyAction extends PaginatedAction, EntityRequestAction { + flattenPagination: boolean; +} +export interface SingleApiKeyAction extends EntityRequestAction { + guid: string; +} + + +// TODO: RC add to backend +// export class GetApiKey implements BaseApiKeyAction { + +// } + +// TODO: RC expand backend to allow stratos admins to manage other peoples keys + +export class AddApiKey extends BaseApiKeyAction implements SingleApiKeyAction { + constructor(public comment: string) { + super(API_KEY_ADD); + } + guid = 'ADD' +} + +export class DeleteApiKey extends BaseApiKeyAction implements SingleApiKeyAction { + constructor(public guid: string) { + super(API_KEY_DELETE); + } +} + +export class GetAllApiKeys extends BaseApiKeyAction implements PaginationApiKeyAction { + constructor() { + super(API_KEY_GET_ALL); + this.paginationKey = 'CURRENT_USERS'; + } + flattenPagination = true; + paginationKey: string; +} \ No newline at end of file diff --git a/src/frontend/packages/store/src/apiKey.types.ts b/src/frontend/packages/store/src/apiKey.types.ts new file mode 100644 index 0000000000..145db2edf3 --- /dev/null +++ b/src/frontend/packages/store/src/apiKey.types.ts @@ -0,0 +1,4 @@ +// TODO: RC fill out +export interface ApiKey { + +} \ No newline at end of file diff --git a/src/frontend/packages/store/src/effects/apiKey.effects.ts b/src/frontend/packages/store/src/effects/apiKey.effects.ts new file mode 100644 index 0000000000..fbaef6fade --- /dev/null +++ b/src/frontend/packages/store/src/effects/apiKey.effects.ts @@ -0,0 +1,99 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { catchError, mergeMap, switchMap } from 'rxjs/operators'; + +import { + AddApiKey, + API_KEY_ADD, + API_KEY_DELETE, + API_KEY_GET_ALL, + DeleteApiKey, + GetAllApiKeys, +} from '../actions/apiKey.actions'; +import { ApiKey } from '../apiKey.types'; +import { InternalAppState } from '../app-state'; +import { proxyAPIVersion } from '../jetstream'; +import { StartRequestAction, WrapperRequestActionFailed, WrapperRequestActionSuccess } from '../types/request.types'; + +const apiKeyUrlPath = `/pp/${proxyAPIVersion}/api_keys`; + +@Injectable() +export class ApiKeyEffect { + + constructor( + private http: HttpClient, + private actions$: Actions, + private store: Store, + ) { + } + + @Effect() add = this.actions$.pipe( + ofType(API_KEY_ADD), + mergeMap(action => { + const actionType = 'create'; + this.store.dispatch(new StartRequestAction(action, actionType)) + return this.http.post(apiKeyUrlPath, { + comment: action.comment + }).pipe( + switchMap(newApiKey => { + // TODO: RC FIX array/dispatch + // TODO: RC add to store? + this.store.dispatch(new WrapperRequestActionSuccess(null, action, actionType)); + return []; + }), + catchError(() => { + this.store.dispatch(new WrapperRequestActionFailed('Failed to add api key', action, actionType)); + return []; + }) + ); + }) + ); + + @Effect() delete = this.actions$.pipe( + ofType(API_KEY_DELETE), + mergeMap(action => { + const actionType = 'delete'; + this.store.dispatch(new StartRequestAction(action, actionType)) + + return this.http.request('delete', apiKeyUrlPath, { + headers: new HttpHeaders({ 'Content-Type': 'application/json' }), + body: { + guid: action.guid + } + }).pipe( + switchMap(() => { + // TODO: RC FIX array/dispatch + this.store.dispatch(new WrapperRequestActionSuccess(null, action, actionType)); + return []; + }), + catchError(() => { + this.store.dispatch(new WrapperRequestActionFailed('Failed to delete api key', action, actionType)); + return []; + }) + ); + }) + ); + + @Effect() getAll = this.actions$.pipe( + ofType(API_KEY_GET_ALL), + mergeMap(action => { + const actionType = 'fetch'; + this.store.dispatch(new StartRequestAction(action, actionType)) + return this.http.get(apiKeyUrlPath).pipe( + switchMap(res => { + // TODO: RC FIX array/dispatch + // TODO: RC add res to wrapper success + this.store.dispatch(new WrapperRequestActionSuccess(null, action, actionType)); + return []; + }), + catchError(() => { + this.store.dispatch(new WrapperRequestActionFailed('Failed to get all api keys', action, actionType)); + return []; + }) + ); + }) + ); + +} \ No newline at end of file diff --git a/src/frontend/packages/store/src/helpers/stratos-entity-factory.ts b/src/frontend/packages/store/src/helpers/stratos-entity-factory.ts index 1182c576a3..3be2b9115c 100644 --- a/src/frontend/packages/store/src/helpers/stratos-entity-factory.ts +++ b/src/frontend/packages/store/src/helpers/stratos-entity-factory.ts @@ -4,6 +4,7 @@ export const userFavouritesEntityType = 'userFavorites'; export const endpointEntityType = 'endpoint'; export const userProfileEntityType = 'userProfile'; export const systemInfoEntityType = 'systemInfo'; +export const apiKeyEntityType = 'apiKey'; export const metricEntityType = 'metrics'; @@ -31,6 +32,9 @@ entityCache[endpointEntityType] = EndpointSchema; const UserProfileInfoSchema = new StratosEntitySchema(userProfileEntityType, 'id'); entityCache[userProfileEntityType] = UserProfileInfoSchema; +const ApiKeySchema = new StratosEntitySchema(apiKeyEntityType, 'id'); // TODO: RC check id +entityCache[apiKeyEntityType] = ApiKeySchema; + export function stratosEntityFactory(key: string): EntitySchema { const entity = entityCache[key]; if (!entity) { diff --git a/src/frontend/packages/store/src/store.module.ts b/src/frontend/packages/store/src/store.module.ts index 794355dc3a..15116c2584 100644 --- a/src/frontend/packages/store/src/store.module.ts +++ b/src/frontend/packages/store/src/store.module.ts @@ -4,6 +4,7 @@ import { EffectsModule } from '@ngrx/effects'; import { ActionHistoryEffect } from './effects/action-history.effects'; import { APIEffect } from './effects/api.effects'; +import { ApiKeyEffect } from './effects/apiKey.effects'; import { AuthEffect } from './effects/auth.effects'; import { DashboardEffect } from './effects/dashboard.effects'; import { EndpointApiError } from './effects/endpoint-api-errors.effects'; @@ -45,7 +46,8 @@ import { AppReducersModule } from './reducers.module'; UserProfileEffect, RecursiveDeleteEffect, UserFavoritesEffect, - PermissionsEffects + PermissionsEffects, + ApiKeyEffect ]) ] }) diff --git a/src/frontend/packages/store/src/stratos-action-builders.ts b/src/frontend/packages/store/src/stratos-action-builders.ts index c64a95cd01..5f8b83f174 100644 --- a/src/frontend/packages/store/src/stratos-action-builders.ts +++ b/src/frontend/packages/store/src/stratos-action-builders.ts @@ -1,3 +1,4 @@ +import { AddApiKey, DeleteApiKey, GetAllApiKeys } from './actions/apiKey.actions'; import { AuthParams, BaseEndpointAction, @@ -195,4 +196,21 @@ export const userProfileActionBuilder: UserProfileActionBuilder = { get: (userGuid: string) => new FetchUserProfileAction(userGuid), updateProfile: (profile: UserProfileInfo, password: string) => new UpdateUserProfileAction(profile, password), updatePassword: (guid: string, passwordChanges: UserProfilePasswordUpdate) => new UpdateUserPasswordAction(guid, passwordChanges) +} + +export interface ApiKeyActionBuilder extends OrchestratedActionBuilders { + create: ( + comment: string + ) => AddApiKey; + delete: ( + guid: string + ) => DeleteApiKey; + getMultiple: ( + + ) => GetAllApiKeys; +} +export const apiKeyActionBuilder: ApiKeyActionBuilder = { + create: (comment: string) => new AddApiKey(comment), + delete: (guid: string) => new DeleteApiKey(guid), + getMultiple: () => new GetAllApiKeys() } \ No newline at end of file diff --git a/src/frontend/packages/store/src/stratos-entity-catalog.ts b/src/frontend/packages/store/src/stratos-entity-catalog.ts index 9237a16978..5a58cc14aa 100644 --- a/src/frontend/packages/store/src/stratos-entity-catalog.ts +++ b/src/frontend/packages/store/src/stratos-entity-catalog.ts @@ -1,8 +1,10 @@ +import { ApiKey } from './apiKey.types'; import { StratosCatalogEndpointEntity, StratosCatalogEntity, } from './entity-catalog/entity-catalog-entity/entity-catalog-entity'; import { + ApiKeyActionBuilder, EndpointActionBuilder, SystemInfoActionBuilder, UserFavoriteActionBuilder, @@ -39,6 +41,12 @@ export class StratosEntityCatalog { > metricsEndpoint: StratosCatalogEndpointEntity; + + apiKey: StratosCatalogEntity< + undefined, + ApiKey, + ApiKeyActionBuilder + > } export const stratosEntityCatalog = new StratosEntityCatalog(); diff --git a/src/frontend/packages/store/src/stratos-entity-generator.ts b/src/frontend/packages/store/src/stratos-entity-generator.ts index 0062f4fc4a..47f0064903 100644 --- a/src/frontend/packages/store/src/stratos-entity-generator.ts +++ b/src/frontend/packages/store/src/stratos-entity-generator.ts @@ -8,6 +8,7 @@ import { StratosCatalogEntity, } from '../../store/src/entity-catalog/entity-catalog-entity/entity-catalog-entity'; import { + apiKeyEntityType, endpointEntityType, STRATOS_ENDPOINT_TYPE, stratosEntityFactory, @@ -21,8 +22,11 @@ import { } from '../../store/src/reducers/favorite.reducer'; import { systemEndpointsReducer } from '../../store/src/reducers/system-endpoints.reducer'; import { EndpointModel } from '../../store/src/types/endpoint.types'; +import { ApiKey } from './apiKey.types'; import { IStratosEntityDefinition } from './entity-catalog/entity-catalog.types'; import { + ApiKeyActionBuilder, + apiKeyActionBuilder, EndpointActionBuilder, endpointActionBuilder, SystemInfoActionBuilder, @@ -52,7 +56,8 @@ export function generateStratosEntities(): StratosBaseCatalogEntity[] { generateSystemInfo(stratosType), generateUserFavorite(stratosType), generateUserProfile(stratosType), - generateMetricsEndpoint() + generateMetricsEndpoint(), + generateAPIKeys(stratosType) ] } @@ -159,3 +164,26 @@ function generateMetricsEndpoint() { ) return stratosEntityCatalog.metricsEndpoint; } + +function generateAPIKeys(stratosType) { + const definition: IStratosEntityDefinition = { + schema: stratosEntityFactory(apiKeyEntityType), + type: apiKeyEntityType, + endpoint: stratosType, + } + stratosEntityCatalog.apiKey = new StratosCatalogEntity< + undefined, + ApiKey, + ApiKeyActionBuilder + >( + definition, + { + dataReducers: [ + addOrUpdateUserFavoriteMetadataReducer, + deleteUserFavoriteMetadataReducer, + ], + actionBuilders: apiKeyActionBuilder + } + ) + return stratosEntityCatalog.apiKey; +} From 7e3c4c872ba6faaeb9a14a8180d07fd42619175c Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Thu, 20 Aug 2020 10:50:29 +0100 Subject: [PATCH 05/18] Add a basic api keys list (untested, need to wire in properties/columns + actions) --- .../api-keys-page.component.html | 3 +- .../api-keys-page/api-keys-page.component.ts | 9 ++- .../list-types/apiKeys/apiKey-data-source.ts | 44 +++++++++++ .../apiKeys/apiKey-list-config.service.ts | 78 +++++++++++++++++++ .../packages/core/src/shared/shared.module.ts | 2 + 5 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-data-source.ts create mode 100644 src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html index cb1f3694b1..6ea515a120 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html @@ -1 +1,2 @@ -API Keys \ No newline at end of file +API Keys + \ No newline at end of file diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts index b48df1c470..3ff5ca6dcb 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts @@ -1,9 +1,16 @@ import { Component, OnInit } from '@angular/core'; +import { ApiKeyListConfigService } from '../../../shared/components/list/list-types/apiKeys/apiKey-list-config.service'; +import { ListConfig } from '../../../shared/components/list/list.component.types'; + @Component({ selector: 'app-api-keys-page', templateUrl: './api-keys-page.component.html', - styleUrls: ['./api-keys-page.component.scss'] + styleUrls: ['./api-keys-page.component.scss'], + providers: [{ + provide: ListConfig, + useClass: ApiKeyListConfigService, + }] }) export class ApiKeysPageComponent implements OnInit { diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-data-source.ts b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-data-source.ts new file mode 100644 index 0000000000..1b186e74c4 --- /dev/null +++ b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-data-source.ts @@ -0,0 +1,44 @@ +import { Store } from '@ngrx/store'; + +import { GetAllApiKeys } from '../../../../../../../store/src/actions/apiKey.actions'; +import { ApiKey } from '../../../../../../../store/src/apiKey.types'; +import { AppState } from '../../../../../../../store/src/app-state'; +import { ListDataSource } from '../../data-sources-controllers/list-data-source'; +import { IListConfig } from '../../list.component.types'; + + +export class ApiKeyDataSource extends ListDataSource { + + constructor( + store: Store, + listConfig: IListConfig, + action: GetAllApiKeys, + ) { + // TODO: RC use for 'deleteing'? + // const rowStateHelper = new ListRowSateHelper(); + // const { rowStateManager, sub } = rowStateHelper.getRowStateManager( + // paginationMonitorFactory, + // entityMonitorFactory, + // action.paginationKey, + // action, + // EndpointRowStateSetUpManager, + // false + // ); + + super({ + store, + action, + schema: action.entity[0], + getRowUniqueId: (object) => action.entity[0].getId(object), + paginationKey: action.paginationKey, + isLocal: true, + transformEntities: [ + { + type: 'filter', + field: 'name' // TODO: RC assign correct column id + }, + ], + listConfig, + }); + } +} diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts new file mode 100644 index 0000000000..d5c5b0c233 --- /dev/null +++ b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { filter } from 'rxjs/operators'; + +import { ListView } from '../../../../../../../store/src/actions/list.actions'; +import { ApiKey } from '../../../../../../../store/src/apiKey.types'; +import { AppState } from '../../../../../../../store/src/app-state'; +import { stratosEntityCatalog } from '../../../../../../../store/src/stratos-entity-catalog'; +import { ITableColumn } from '../../list-table/table.types'; +import { IListAction, IListConfig, ListViewTypes } from '../../list.component.types'; +import { ApiKeyDataSource } from './apiKey-data-source'; + + + +@Injectable() +export class ApiKeyListConfigService implements IListConfig { + + private singleActions: IListAction[]; + + // TODO: RC Flesh out, get correct paths + public readonly columns: ITableColumn[] = [ + { + columnId: 'id', + headerCell: () => 'ID', + cellDefinition: { + valuePath: 'unknown' + }, + sort: { + type: 'sort', + orderKey: 'id', + field: 'unknown' + }, + cellFlex: '2' + }, + { + columnId: 'type', + headerCell: () => 'Type', + cellDefinition: { + valuePath: 'unknown' + }, + sort: { + type: 'sort', + orderKey: 'type', + field: 'cnsi_type' + }, + cellFlex: '2' + }, + ]; + + isLocal = true; + dataSource: ApiKeyDataSource; + viewType = ListViewTypes.TABLE_ONLY; + defaultView = 'table' as ListView; + text = { + title: '', + filter: 'Filter API Keys' + }; + enableTextFilter = false; // TODO: RC + + constructor( + store: Store, + ) { + this.singleActions = []; // TODO: RC add 'delete' + this.dataSource = new ApiKeyDataSource( + store, + this, + stratosEntityCatalog.apiKey.actions.getMultiple() + ); + } + + public getGlobalActions = () => []; + public getMultiActions = () => []; + public getSingleActions = () => this.singleActions; + public getColumns = () => this.columns; + public getDataSource = () => this.dataSource; + public getMultiFiltersConfigs = () => []; + +} diff --git a/src/frontend/packages/core/src/shared/shared.module.ts b/src/frontend/packages/core/src/shared/shared.module.ts index e5915d0af6..e18dd04581 100644 --- a/src/frontend/packages/core/src/shared/shared.module.ts +++ b/src/frontend/packages/core/src/shared/shared.module.ts @@ -57,6 +57,7 @@ import { TableCellStatusDirective } from './components/list/list-table/table-cel import { listTableCells } from './components/list/list-table/table-cell/table-cell.component'; import { TableComponent } from './components/list/list-table/table.component'; import { listTableComponents } from './components/list/list-table/table.types'; +import { ApiKeyListConfigService } from './components/list/list-types/apiKeys/apiKey-list-config.service'; import { EndpointCardComponent } from './components/list/list-types/endpoint/endpoint-card/endpoint-card.component'; import { EndpointListHelper } from './components/list/list-types/endpoint/endpoint-list.helpers'; import { EndpointsListConfigService } from './components/list/list-types/endpoint/endpoints-list-config.service'; @@ -318,6 +319,7 @@ import { UserPermissionDirective } from './user-permission.directive'; ListConfig, EndpointListHelper, EndpointsListConfigService, + ApiKeyListConfigService, ConfirmationDialogService, InternalEventMonitorFactory, MetricsRangeSelectorService, From 958982871ef4dae0f58779086adaf37a8a13634c Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Thu, 20 Aug 2020 12:03:36 +0100 Subject: [PATCH 06/18] Fix entity type related issues --- .../list-types/apiKeys/apiKey-data-source.ts | 2 +- .../apiKeys/apiKey-list-config.service.ts | 22 +++---- .../packages/store/src/apiKey.types.ts | 6 +- .../store/src/effects/apiKey.effects.ts | 66 ++++++++++++++----- .../src/helpers/stratos-entity-factory.ts | 2 +- .../store/src/stratos-entity-generator.ts | 4 -- 6 files changed, 69 insertions(+), 33 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-data-source.ts b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-data-source.ts index 1b186e74c4..b27b0c19fe 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-data-source.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-data-source.ts @@ -35,7 +35,7 @@ export class ApiKeyDataSource extends ListDataSource { transformEntities: [ { type: 'filter', - field: 'name' // TODO: RC assign correct column id + field: 'comment' // TODO: RC assign correct column id }, ], listConfig, diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts index d5c5b0c233..4ca4676a08 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts @@ -20,28 +20,28 @@ export class ApiKeyListConfigService implements IListConfig { // TODO: RC Flesh out, get correct paths public readonly columns: ITableColumn[] = [ { - columnId: 'id', - headerCell: () => 'ID', + columnId: 'guid', + headerCell: () => 'guid', cellDefinition: { - valuePath: 'unknown' + valuePath: 'guid' }, sort: { type: 'sort', - orderKey: 'id', - field: 'unknown' + orderKey: 'guid', + field: 'guid' }, cellFlex: '2' }, { - columnId: 'type', - headerCell: () => 'Type', + columnId: 'comment', + headerCell: () => 'comment', cellDefinition: { - valuePath: 'unknown' + valuePath: 'comment' }, sort: { type: 'sort', - orderKey: 'type', - field: 'cnsi_type' + orderKey: 'comment', + field: 'comment' }, cellFlex: '2' }, @@ -55,7 +55,7 @@ export class ApiKeyListConfigService implements IListConfig { title: '', filter: 'Filter API Keys' }; - enableTextFilter = false; // TODO: RC + enableTextFilter = true; // TODO: RC constructor( store: Store, diff --git a/src/frontend/packages/store/src/apiKey.types.ts b/src/frontend/packages/store/src/apiKey.types.ts index 145db2edf3..b4b3a30cac 100644 --- a/src/frontend/packages/store/src/apiKey.types.ts +++ b/src/frontend/packages/store/src/apiKey.types.ts @@ -1,4 +1,8 @@ // TODO: RC fill out export interface ApiKey { - + comment: string; + guid: string; + last_used: string; + secret: string; + user_guid: string; } \ No newline at end of file diff --git a/src/frontend/packages/store/src/effects/apiKey.effects.ts b/src/frontend/packages/store/src/effects/apiKey.effects.ts index fbaef6fade..2292a8f093 100644 --- a/src/frontend/packages/store/src/effects/apiKey.effects.ts +++ b/src/frontend/packages/store/src/effects/apiKey.effects.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; @@ -14,7 +14,10 @@ import { } from '../actions/apiKey.actions'; import { ApiKey } from '../apiKey.types'; import { InternalAppState } from '../app-state'; +import { BrowserStandardEncoder } from '../browser-encoder'; +import { entityCatalog } from '../entity-catalog/entity-catalog'; import { proxyAPIVersion } from '../jetstream'; +import { NormalizedResponse } from '../types/api.types'; import { StartRequestAction, WrapperRequestActionFailed, WrapperRequestActionSuccess } from '../types/request.types'; const apiKeyUrlPath = `/pp/${proxyAPIVersion}/api_keys`; @@ -34,17 +37,31 @@ export class ApiKeyEffect { mergeMap(action => { const actionType = 'create'; this.store.dispatch(new StartRequestAction(action, actionType)) - return this.http.post(apiKeyUrlPath, { - comment: action.comment - }).pipe( + const params = new HttpParams({ + encoder: new BrowserStandardEncoder(), + fromObject: { + comment: action.comment + } + }); + + return this.http.post(apiKeyUrlPath, params).pipe( switchMap(newApiKey => { // TODO: RC FIX array/dispatch - // TODO: RC add to store? - this.store.dispatch(new WrapperRequestActionSuccess(null, action, actionType)); + const guid = action.entity[0].getId(newApiKey); + const entityKey = entityCatalog.getEntityKey(action); + const response: NormalizedResponse = { + entities: { + [entityKey]: { + [guid]: newApiKey + } + }, + result: [guid] + } + this.store.dispatch(new WrapperRequestActionSuccess(response, action, actionType)); return []; }), - catchError(() => { - this.store.dispatch(new WrapperRequestActionFailed('Failed to add api key', action, actionType)); + catchError(err => { + this.store.dispatch(new WrapperRequestActionFailed(this.convertErrorToString(err), action, actionType)); return []; }) ); @@ -68,8 +85,8 @@ export class ApiKeyEffect { this.store.dispatch(new WrapperRequestActionSuccess(null, action, actionType)); return []; }), - catchError(() => { - this.store.dispatch(new WrapperRequestActionFailed('Failed to delete api key', action, actionType)); + catchError(err => { + this.store.dispatch(new WrapperRequestActionFailed(this.convertErrorToString(err), action, actionType)); return []; }) ); @@ -82,18 +99,37 @@ export class ApiKeyEffect { const actionType = 'fetch'; this.store.dispatch(new StartRequestAction(action, actionType)) return this.http.get(apiKeyUrlPath).pipe( - switchMap(res => { + switchMap((res: ApiKey[]) => { + const entityKey = entityCatalog.getEntityKey(action); + const response: NormalizedResponse = { + entities: { + [entityKey]: { + } + }, + result: [] + } + + res.forEach(apiKey => { + const guid = action.entity[0].getId(apiKey); + response.entities[entityKey][guid] = apiKey; + response.result.push(guid); + }); + + // TODO: RC FIX array/dispatch - // TODO: RC add res to wrapper success - this.store.dispatch(new WrapperRequestActionSuccess(null, action, actionType)); + this.store.dispatch(new WrapperRequestActionSuccess(response, action, actionType)); return []; }), - catchError(() => { - this.store.dispatch(new WrapperRequestActionFailed('Failed to get all api keys', action, actionType)); + catchError(err => { + this.store.dispatch(new WrapperRequestActionFailed(this.convertErrorToString(err), action, actionType)); return []; }) ); }) ); + private convertErrorToString(err: any): string { + // TODO: RC beef up + return err && err.error ? err.error : 'Failed API Key action'; + } } \ No newline at end of file diff --git a/src/frontend/packages/store/src/helpers/stratos-entity-factory.ts b/src/frontend/packages/store/src/helpers/stratos-entity-factory.ts index 3be2b9115c..2c570b69e5 100644 --- a/src/frontend/packages/store/src/helpers/stratos-entity-factory.ts +++ b/src/frontend/packages/store/src/helpers/stratos-entity-factory.ts @@ -32,7 +32,7 @@ entityCache[endpointEntityType] = EndpointSchema; const UserProfileInfoSchema = new StratosEntitySchema(userProfileEntityType, 'id'); entityCache[userProfileEntityType] = UserProfileInfoSchema; -const ApiKeySchema = new StratosEntitySchema(apiKeyEntityType, 'id'); // TODO: RC check id +const ApiKeySchema = new StratosEntitySchema(apiKeyEntityType, 'guid'); entityCache[apiKeyEntityType] = ApiKeySchema; export function stratosEntityFactory(key: string): EntitySchema { diff --git a/src/frontend/packages/store/src/stratos-entity-generator.ts b/src/frontend/packages/store/src/stratos-entity-generator.ts index 47f0064903..d1e9aed776 100644 --- a/src/frontend/packages/store/src/stratos-entity-generator.ts +++ b/src/frontend/packages/store/src/stratos-entity-generator.ts @@ -178,10 +178,6 @@ function generateAPIKeys(stratosType) { >( definition, { - dataReducers: [ - addOrUpdateUserFavoriteMetadataReducer, - deleteUserFavoriteMetadataReducer, - ], actionBuilders: apiKeyActionBuilder } ) From e04aae03072b164f7f348d425ea826f7af5e641a Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Thu, 20 Aug 2020 12:04:04 +0100 Subject: [PATCH 07/18] Add basic way to create api key --- .../add-api-key-dialog.component.html | 26 +++++++ .../add-api-key-dialog.component.scss | 0 .../add-api-key-dialog.component.spec.ts | 25 +++++++ .../add-api-key-dialog.component.ts | 70 +++++++++++++++++++ .../api-keys-page.component.html | 12 +++- .../api-keys-page/api-keys-page.component.ts | 33 ++++++++- .../src/features/api-keys/api-keys.module.ts | 4 +- 7 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.html create mode 100644 src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.scss create mode 100644 src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.spec.ts create mode 100644 src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.ts diff --git a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.html b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.html new file mode 100644 index 0000000000..11ddcd2ef7 --- /dev/null +++ b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.html @@ -0,0 +1,26 @@ +
+ + +
+
+
+

+ Create an API Key +

+
+
+ + + +
+ + + + + + + +
\ No newline at end of file diff --git a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.scss b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.spec.ts b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.spec.ts new file mode 100644 index 0000000000..db46cfe8bc --- /dev/null +++ b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AddApiKeyDialogComponent } from './add-api-key-dialog.component'; + +describe('AddApiKeyDialogComponent', () => { + let component: AddApiKeyDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AddApiKeyDialogComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AddApiKeyDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.ts b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.ts new file mode 100644 index 0000000000..0d153145eb --- /dev/null +++ b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.ts @@ -0,0 +1,70 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Subject, Subscription } from 'rxjs'; +import { filter, first, map, pairwise, tap } from 'rxjs/operators'; + +import { ApiKey } from '../../../../../store/src/apiKey.types'; +import { entityCatalog } from '../../../../../store/src/entity-catalog/entity-catalog'; +import { RequestInfoState } from '../../../../../store/src/reducers/api-request-reducer/types'; +import { stratosEntityCatalog } from '../../../../../store/src/stratos-entity-catalog'; +import { NormalizedResponse } from '../../../../../store/src/types/api.types'; +import { safeUnsubscribe } from '../../../core/utils.service'; + +@Component({ + selector: 'app-add-api-key-dialog', + templateUrl: './add-api-key-dialog.component.html', + styleUrls: ['./add-api-key-dialog.component.scss'] +}) +export class AddApiKeyDialogComponent implements OnInit, OnDestroy { + + private hasErrored = new Subject() + public hasErrored$ = this.hasErrored.asObservable(); + private isBusy = new Subject() + public isBusy$ = this.isBusy.asObservable(); + + private sub: Subscription; + + public formGroup: FormGroup; + + constructor( + private fb: FormBuilder, + public dialogRef: MatDialogRef, + ) { + this.formGroup = this.fb.group({ + comment: ['', Validators.required], + }); + + } + + ngOnInit(): void { + } + + ngOnDestroy(): void { + safeUnsubscribe(this.sub); + } + + submit() { + this.sub = stratosEntityCatalog.apiKey.api.create(this.formGroup.controls.comment.value).pipe( + tap(() => { + this.isBusy.next(true); + this.hasErrored.next(null); + }), + pairwise(), + filter(([oldR, newR]) => oldR.creating && !newR.creating), + map(([, newR]) => newR), + tap(state => { + if (state.error) { + this.hasErrored.next(`Failed to create key: ${state.message}`); + this.isBusy.next(false); + } else { + const response: NormalizedResponse = state.response; + const entityKey = entityCatalog.getEntityKey(stratosEntityCatalog.apiKey.actions.create('')); + this.dialogRef.close(response.entities[entityKey][response.result[0]]) + } + }), + first() + ).subscribe() + } + +} diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html index 6ea515a120..1ca07ffa7f 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html @@ -1,2 +1,12 @@ -API Keys + +

API Keys

+ +
+
+ Your API Key has been successfully created. Please record your key secret, there will be no later way to view it: + {{keyDetails.secret}} + +
\ No newline at end of file diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts index 3ff5ca6dcb..e578cf75f5 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts @@ -1,7 +1,11 @@ import { Component, OnInit } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Observable, Subject } from 'rxjs'; +import { first, map } from 'rxjs/operators'; import { ApiKeyListConfigService } from '../../../shared/components/list/list-types/apiKeys/apiKey-list-config.service'; import { ListConfig } from '../../../shared/components/list/list.component.types'; +import { AddApiKeyDialogComponent } from '../add-api-key-dialog/add-api-key-dialog.component'; @Component({ selector: 'app-api-keys-page', @@ -14,9 +18,36 @@ import { ListConfig } from '../../../shared/components/list/list.component.types }) export class ApiKeysPageComponent implements OnInit { - constructor() { } + public keyDetails = new Subject(); + public keyDetails$ = this.keyDetails.asObservable(); + + constructor( + private dialog: MatDialog, + ) { } ngOnInit(): void { } + addApiKey() { + this.showDialog().pipe(first()).subscribe(key => { + this.keyDetails.next(key); + }) + } + + clearKeyDetails() { + this.keyDetails.next(); + } + + private showDialog(): Observable { + // return of('TEST'); + return this.dialog.open(AddApiKeyDialogComponent, { + disableClose: true, + }).afterClosed().pipe( + map(a => { + console.log('RESULT: ', a); + return 'FINISHED' + }) + ); + } + } diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys.module.ts b/src/frontend/packages/core/src/features/api-keys/api-keys.module.ts index 5c91110e75..c2311b97d4 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys.module.ts +++ b/src/frontend/packages/core/src/features/api-keys/api-keys.module.ts @@ -4,6 +4,7 @@ import { CoreModule } from '../../core/core.module'; import { SharedModule } from '../../shared/shared.module'; import { ApiKeysPageComponent } from './api-keys-page/api-keys-page.component'; import { ApiKeysRoutingModule } from './api-keys.routing'; +import { AddApiKeyDialogComponent } from './add-api-key-dialog/add-api-key-dialog.component'; @NgModule({ @@ -13,7 +14,8 @@ import { ApiKeysRoutingModule } from './api-keys.routing'; ApiKeysRoutingModule, ], declarations: [ - ApiKeysPageComponent + ApiKeysPageComponent, + AddApiKeyDialogComponent ] }) export class ApiKeysModule { } From 02a8f213cc01f816c5fc2b3f1ae3d1615af881f2 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Thu, 20 Aug 2020 13:21:42 +0100 Subject: [PATCH 08/18] Wire in delete to list --- .../list/list-types/apiKeys/apiKey-list-config.service.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts index 4ca4676a08..88090f21d6 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts @@ -15,7 +15,12 @@ import { ApiKeyDataSource } from './apiKey-data-source'; @Injectable() export class ApiKeyListConfigService implements IListConfig { - private singleActions: IListAction[]; + private deleteAction: IListAction = { + action: (item: ApiKey) => stratosEntityCatalog.apiKey.api.delete(item.guid), + label: 'Delete', + description: 'Delete API Key', + } + private singleActions: IListAction[] = [this.deleteAction]; // TODO: RC Flesh out, get correct paths public readonly columns: ITableColumn[] = [ @@ -60,7 +65,6 @@ export class ApiKeyListConfigService implements IListConfig { constructor( store: Store, ) { - this.singleActions = []; // TODO: RC add 'delete' this.dataSource = new ApiKeyDataSource( store, this, From a0ba58f21e4b117ea90456ea1160c06ea21772f1 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Thu, 20 Aug 2020 13:55:34 +0100 Subject: [PATCH 09/18] Improve 'no api keys' ux --- .../api-keys-page.component.html | 19 +++++++++++--- .../api-keys-page/api-keys-page.component.ts | 25 ++++++++++++------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html index 1ca07ffa7f..0ad16ed2fa 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html @@ -1,12 +1,23 @@

API Keys

- +
+ +
+
Your API Key has been successfully created. Please record your key secret, there will be no later way to view it: {{keyDetails.secret}}
- \ No newline at end of file + + + + + \ No newline at end of file diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts index e578cf75f5..392e8eaa13 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts @@ -1,8 +1,9 @@ -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Observable, Subject } from 'rxjs'; import { first, map } from 'rxjs/operators'; +import { stratosEntityCatalog } from '../../../../../store/src/stratos-entity-catalog'; import { ApiKeyListConfigService } from '../../../shared/components/list/list-types/apiKeys/apiKey-list-config.service'; import { ListConfig } from '../../../shared/components/list/list.component.types'; import { AddApiKeyDialogComponent } from '../add-api-key-dialog/add-api-key-dialog.component'; @@ -16,20 +17,24 @@ import { AddApiKeyDialogComponent } from '../add-api-key-dialog/add-api-key-dial useClass: ApiKeyListConfigService, }] }) -export class ApiKeysPageComponent implements OnInit { +export class ApiKeysPageComponent { public keyDetails = new Subject(); public keyDetails$ = this.keyDetails.asObservable(); + public hasKeys$: Observable; constructor( private dialog: MatDialog, - ) { } - - ngOnInit(): void { + ) { + this.hasKeys$ = stratosEntityCatalog.apiKey.store.getPaginationMonitor().currentPage$.pipe( + map(page => page && !!page.length) + ) } addApiKey() { this.showDialog().pipe(first()).subscribe(key => { + // TODO: RC test cancel + console.log('DIAG RESULT:', key) this.keyDetails.next(key); }) } @@ -39,13 +44,15 @@ export class ApiKeysPageComponent implements OnInit { } private showDialog(): Observable { - // return of('TEST'); return this.dialog.open(AddApiKeyDialogComponent, { disableClose: true, }).afterClosed().pipe( - map(a => { - console.log('RESULT: ', a); - return 'FINISHED' + map(newApiKey => { + if (newApiKey && newApiKey.guid) { + stratosEntityCatalog.apiKey.api.getMultiple(); + return newApiKey; + } + return; }) ); } From 575e3dddc410c8318ab7e930da6d51379dcfcd12 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Thu, 20 Aug 2020 14:39:32 +0100 Subject: [PATCH 10/18] Final tidy up --- .../add-api-key-dialog.component.html | 14 +++--- .../add-api-key-dialog.component.scss | 28 ++++++++++++ .../add-api-key-dialog.component.ts | 15 +++---- .../api-keys-page.component.html | 21 ++++++--- .../api-keys-page.component.scss | 15 +++++++ .../api-keys-page/api-keys-page.component.ts | 8 ++-- .../list-types/apiKeys/apiKey-data-source.ts | 14 +----- .../apiKeys/apiKey-list-config.service.ts | 43 +++++++++++-------- .../store/src/actions/apiKey.actions.ts | 15 ++----- .../packages/store/src/apiKey.types.ts | 1 - .../store/src/effects/apiKey.effects.ts | 27 ++++++------ 11 files changed, 117 insertions(+), 84 deletions(-) diff --git a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.html b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.html index 11ddcd2ef7..bf044a85bf 100644 --- a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.html +++ b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.html @@ -1,24 +1,26 @@ -
- +
-
-
+
+

Create an API Key

+
+
- +
- + diff --git a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.scss b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.scss index e69de29bb2..a235091e2d 100644 --- a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.scss +++ b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.scss @@ -0,0 +1,28 @@ +.key-dialog { + &__loading { + left: 0; + position: absolute; + right: 0; + top: 0; + &-wrapper { + position: relative; + margin: 0 -24px; + transform: translateY(-24px); + } + } + + &__title { + display: flex; + h2 { + flex: 1; + } + } + + &__actions { + justify-content: flex-end; + } + + mat-form-field { + width: 100%; + } +} diff --git a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.ts b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.ts index 0d153145eb..a3894b1eaf 100644 --- a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.ts +++ b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.ts @@ -1,7 +1,7 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { MatDialogRef } from '@angular/material/dialog'; -import { Subject, Subscription } from 'rxjs'; +import { BehaviorSubject, Subscription } from 'rxjs'; import { filter, first, map, pairwise, tap } from 'rxjs/operators'; import { ApiKey } from '../../../../../store/src/apiKey.types'; @@ -16,11 +16,11 @@ import { safeUnsubscribe } from '../../../core/utils.service'; templateUrl: './add-api-key-dialog.component.html', styleUrls: ['./add-api-key-dialog.component.scss'] }) -export class AddApiKeyDialogComponent implements OnInit, OnDestroy { +export class AddApiKeyDialogComponent implements OnDestroy { - private hasErrored = new Subject() + private hasErrored = new BehaviorSubject(null); public hasErrored$ = this.hasErrored.asObservable(); - private isBusy = new Subject() + private isBusy = new BehaviorSubject(false) public isBusy$ = this.isBusy.asObservable(); private sub: Subscription; @@ -34,10 +34,7 @@ export class AddApiKeyDialogComponent implements OnInit, OnDestroy { this.formGroup = this.fb.group({ comment: ['', Validators.required], }); - - } - - ngOnInit(): void { + // this.isBusy.next(false); } ngOnDestroy(): void { diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html index 0ad16ed2fa..ced31d24f0 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html @@ -7,10 +7,21 @@

API Keys

-
- Your API Key has been successfully created. Please record your key secret, there will be no later way to view it: - {{keyDetails.secret}} - +
+ + + + vpn_keyNew API Key + + +

Your API Key has been successfully created. Use the following information to connect to Stratos.

+

Please safely record these details, there is no later way to view them

+
    +
  • Secret: {{keyDetails.secret}}
  • +
+ +
+
@@ -18,6 +29,6 @@

API Keys

\ No newline at end of file diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.scss b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.scss index e69de29bb2..291e1522e1 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.scss +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.scss @@ -0,0 +1,15 @@ +.keys-page { + &__new { + mat-card { + margin-bottom: 24px; + mat-card-header { + align-items: center; + display: flex; + margin-bottom: 15px; + mat-icon { + margin-right: 5px; + } + } + } + } +} \ No newline at end of file diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts index 392e8eaa13..7dc066fbbb 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts @@ -26,15 +26,13 @@ export class ApiKeysPageComponent { constructor( private dialog: MatDialog, ) { - this.hasKeys$ = stratosEntityCatalog.apiKey.store.getPaginationMonitor().currentPage$.pipe( - map(page => page && !!page.length) + this.hasKeys$ = stratosEntityCatalog.apiKey.store.getPaginationService().entities$.pipe( + map(entities => entities && !!entities.length) ) } addApiKey() { this.showDialog().pipe(first()).subscribe(key => { - // TODO: RC test cancel - console.log('DIAG RESULT:', key) this.keyDetails.next(key); }) } @@ -52,7 +50,7 @@ export class ApiKeysPageComponent { stratosEntityCatalog.apiKey.api.getMultiple(); return newApiKey; } - return; + return null; }) ); } diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-data-source.ts b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-data-source.ts index b27b0c19fe..a57eee6721 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-data-source.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-data-source.ts @@ -6,7 +6,6 @@ import { AppState } from '../../../../../../../store/src/app-state'; import { ListDataSource } from '../../data-sources-controllers/list-data-source'; import { IListConfig } from '../../list.component.types'; - export class ApiKeyDataSource extends ListDataSource { constructor( @@ -14,17 +13,6 @@ export class ApiKeyDataSource extends ListDataSource { listConfig: IListConfig, action: GetAllApiKeys, ) { - // TODO: RC use for 'deleteing'? - // const rowStateHelper = new ListRowSateHelper(); - // const { rowStateManager, sub } = rowStateHelper.getRowStateManager( - // paginationMonitorFactory, - // entityMonitorFactory, - // action.paginationKey, - // action, - // EndpointRowStateSetUpManager, - // false - // ); - super({ store, action, @@ -35,7 +23,7 @@ export class ApiKeyDataSource extends ListDataSource { transformEntities: [ { type: 'filter', - field: 'comment' // TODO: RC assign correct column id + field: 'comment' }, ], listConfig, diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts index 88090f21d6..85243d243e 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@angular/core'; +import { SortDirection } from '@angular/material/sort'; import { Store } from '@ngrx/store'; import { filter } from 'rxjs/operators'; @@ -6,40 +7,38 @@ import { ListView } from '../../../../../../../store/src/actions/list.actions'; import { ApiKey } from '../../../../../../../store/src/apiKey.types'; import { AppState } from '../../../../../../../store/src/app-state'; import { stratosEntityCatalog } from '../../../../../../../store/src/stratos-entity-catalog'; +import { ConfirmationDialogConfig } from '../../../confirmation-dialog.config'; +import { ConfirmationDialogService } from '../../../confirmation-dialog.service'; import { ITableColumn } from '../../list-table/table.types'; import { IListAction, IListConfig, ListViewTypes } from '../../list.component.types'; import { ApiKeyDataSource } from './apiKey-data-source'; - @Injectable() export class ApiKeyListConfigService implements IListConfig { private deleteAction: IListAction = { - action: (item: ApiKey) => stratosEntityCatalog.apiKey.api.delete(item.guid), + action: (item: ApiKey) => { + const confirmation = new ConfirmationDialogConfig( + 'Delete Key', + `Are you sure?`, + 'Delete', + true + ); + this.confirmDialog.open( + confirmation, + () => stratosEntityCatalog.apiKey.api.delete(item.guid) + ); + }, label: 'Delete', description: 'Delete API Key', } private singleActions: IListAction[] = [this.deleteAction]; - // TODO: RC Flesh out, get correct paths public readonly columns: ITableColumn[] = [ - { - columnId: 'guid', - headerCell: () => 'guid', - cellDefinition: { - valuePath: 'guid' - }, - sort: { - type: 'sort', - orderKey: 'guid', - field: 'guid' - }, - cellFlex: '2' - }, { columnId: 'comment', - headerCell: () => 'comment', + headerCell: () => 'Description', cellDefinition: { valuePath: 'comment' }, @@ -60,15 +59,21 @@ export class ApiKeyListConfigService implements IListConfig { title: '', filter: 'Filter API Keys' }; - enableTextFilter = true; // TODO: RC + enableTextFilter = true; constructor( store: Store, + private confirmDialog: ConfirmationDialogService, ) { + const action = stratosEntityCatalog.apiKey.actions.getMultiple(); + action.initialParams = { + 'order-direction': 'desc' as SortDirection, + 'order-direction-field': 'comment' + }; this.dataSource = new ApiKeyDataSource( store, this, - stratosEntityCatalog.apiKey.actions.getMultiple() + action ); } diff --git a/src/frontend/packages/store/src/actions/apiKey.actions.ts b/src/frontend/packages/store/src/actions/apiKey.actions.ts index dcc2c50edd..4b10e34bef 100644 --- a/src/frontend/packages/store/src/actions/apiKey.actions.ts +++ b/src/frontend/packages/store/src/actions/apiKey.actions.ts @@ -1,5 +1,5 @@ import { apiKeyEntityType, STRATOS_ENDPOINT_TYPE, stratosEntityFactory } from '../helpers/stratos-entity-factory'; -import { PaginatedAction } from '../types/pagination.types'; +import { PaginatedAction, PaginationParam } from '../types/pagination.types'; import { EntityRequestAction } from '../types/request.types'; export const API_KEY_ADD = '[API Key] Add API Key' @@ -13,21 +13,13 @@ abstract class BaseApiKeyAction implements EntityRequestAction { constructor(public type: string) { } } -export interface PaginationApiKeyAction extends PaginatedAction, EntityRequestAction { +interface PaginationApiKeyAction extends PaginatedAction, EntityRequestAction { flattenPagination: boolean; } -export interface SingleApiKeyAction extends EntityRequestAction { +interface SingleApiKeyAction extends EntityRequestAction { guid: string; } - -// TODO: RC add to backend -// export class GetApiKey implements BaseApiKeyAction { - -// } - -// TODO: RC expand backend to allow stratos admins to manage other peoples keys - export class AddApiKey extends BaseApiKeyAction implements SingleApiKeyAction { constructor(public comment: string) { super(API_KEY_ADD); @@ -48,4 +40,5 @@ export class GetAllApiKeys extends BaseApiKeyAction implements PaginationApiKeyA } flattenPagination = true; paginationKey: string; + initialParams: PaginationParam } \ No newline at end of file diff --git a/src/frontend/packages/store/src/apiKey.types.ts b/src/frontend/packages/store/src/apiKey.types.ts index b4b3a30cac..35ac4736b7 100644 --- a/src/frontend/packages/store/src/apiKey.types.ts +++ b/src/frontend/packages/store/src/apiKey.types.ts @@ -1,4 +1,3 @@ -// TODO: RC fill out export interface ApiKey { comment: string; guid: string; diff --git a/src/frontend/packages/store/src/effects/apiKey.effects.ts b/src/frontend/packages/store/src/effects/apiKey.effects.ts index 2292a8f093..d4dc3a033b 100644 --- a/src/frontend/packages/store/src/effects/apiKey.effects.ts +++ b/src/frontend/packages/store/src/effects/apiKey.effects.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; @@ -37,16 +37,14 @@ export class ApiKeyEffect { mergeMap(action => { const actionType = 'create'; this.store.dispatch(new StartRequestAction(action, actionType)) - const params = new HttpParams({ + + return this.http.post(apiKeyUrlPath, new HttpParams({ encoder: new BrowserStandardEncoder(), fromObject: { comment: action.comment } - }); - - return this.http.post(apiKeyUrlPath, params).pipe( + })).pipe( switchMap(newApiKey => { - // TODO: RC FIX array/dispatch const guid = action.entity[0].getId(newApiKey); const entityKey = entityCatalog.getEntityKey(action); const response: NormalizedResponse = { @@ -74,14 +72,15 @@ export class ApiKeyEffect { const actionType = 'delete'; this.store.dispatch(new StartRequestAction(action, actionType)) - return this.http.request('delete', apiKeyUrlPath, { - headers: new HttpHeaders({ 'Content-Type': 'application/json' }), - body: { - guid: action.guid - } + return this.http.delete(apiKeyUrlPath, { + params: new HttpParams({ + encoder: new BrowserStandardEncoder(), + fromObject: { + guid: action.guid + } + }) }).pipe( switchMap(() => { - // TODO: RC FIX array/dispatch this.store.dispatch(new WrapperRequestActionSuccess(null, action, actionType)); return []; }), @@ -115,8 +114,6 @@ export class ApiKeyEffect { response.result.push(guid); }); - - // TODO: RC FIX array/dispatch this.store.dispatch(new WrapperRequestActionSuccess(response, action, actionType)); return []; }), @@ -129,7 +126,7 @@ export class ApiKeyEffect { ); private convertErrorToString(err: any): string { - // TODO: RC beef up + // We should look into beefing this up / combining with generic error handling return err && err.error ? err.error : 'Failed API Key action'; } } \ No newline at end of file From 28210be22942e093c1908d2a9b6079348dab9dd2 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Thu, 20 Aug 2020 14:46:53 +0100 Subject: [PATCH 11/18] Other fixes --- .../endpoints-page.component.html | 2 +- .../shared/components/list/list.component.ts | 2 +- .../no-content-message.component.html | 2 +- .../page-header/page-header.component.html | 107 ++++++++++-------- 4 files changed, 60 insertions(+), 53 deletions(-) diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.html b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.html index c8e3f991c3..0775265f9d 100644 --- a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.html +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.html @@ -5,7 +5,7 @@

Endpoints

[routerLink]="'/endpoints/new/'" matTooltip="Register Endpoint"> add - diff --git a/src/frontend/packages/core/src/shared/components/list/list.component.ts b/src/frontend/packages/core/src/shared/components/list/list.component.ts index 318bd0dd7e..2bc6da4898 100644 --- a/src/frontend/packages/core/src/shared/components/list/list.component.ts +++ b/src/frontend/packages/core/src/shared/components/list/list.component.ts @@ -701,7 +701,7 @@ export class ListComponent implements OnInit, OnChanges, OnDestroy, AfterView map(requestInfo => ({ deleting: requestInfo.deleting.busy, error: requestInfo.deleting.error, - message: requestInfo.deleting.error ? `Sorry, deletion failed` : null + message: requestInfo.deleting.error ? requestInfo.deleting.message || `Sorry, deletion failed` : null })) ); }; diff --git a/src/frontend/packages/core/src/shared/components/no-content-message/no-content-message.component.html b/src/frontend/packages/core/src/shared/components/no-content-message/no-content-message.component.html index efe500ff02..18aefbc5f3 100644 --- a/src/frontend/packages/core/src/shared/components/no-content-message/no-content-message.component.html +++ b/src/frontend/packages/core/src/shared/components/no-content-message/no-content-message.component.html @@ -1,4 +1,4 @@ -
+
@@ -24,61 +25,67 @@
-
- -
-
- -
- +
+
+ +
+ - | - - - -
-

Recent Activity

- -
-
- - - -
-
- -
-
{{ username$ | async }}
-
+ | + + + +
+

Recent Activity

+ +
-
- - + + +
+
+ +
+
{{ username$ | async }}
+
+
+
+ + + +
+
+ -
- -
-
-
+ +
From 08840fac5aa32f2a5ae72d8cea22d45a6bca96d6 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Thu, 20 Aug 2020 15:54:45 +0100 Subject: [PATCH 12/18] Fix unit tests --- .../add-api-key-dialog.component.spec.ts | 19 +++++++++++++++-- .../add-api-key-dialog.component.ts | 1 - .../api-keys-page.component.spec.ts | 21 +++++++++++++++++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.spec.ts b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.spec.ts index db46cfe8bc..e27047ec08 100644 --- a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.spec.ts +++ b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.spec.ts @@ -1,16 +1,31 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogRef } from '@angular/material/dialog'; +import { BaseTestModules } from '../../../../test-framework/core-test.helper'; import { AddApiKeyDialogComponent } from './add-api-key-dialog.component'; describe('AddApiKeyDialogComponent', () => { let component: AddApiKeyDialogComponent; let fixture: ComponentFixture; + const mockDialogRef = { + close: () => { } + }; + beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ AddApiKeyDialogComponent ] + imports: [ + ...BaseTestModules, + ], + declarations: [AddApiKeyDialogComponent], + providers: [ + { + provide: MatDialogRef, + useValue: mockDialogRef + } + ] }) - .compileComponents(); + .compileComponents(); })); beforeEach(() => { diff --git a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.ts b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.ts index a3894b1eaf..eea0b54086 100644 --- a/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.ts +++ b/src/frontend/packages/core/src/features/api-keys/add-api-key-dialog/add-api-key-dialog.component.ts @@ -34,7 +34,6 @@ export class AddApiKeyDialogComponent implements OnDestroy { this.formGroup = this.fb.group({ comment: ['', Validators.required], }); - // this.isBusy.next(false); } ngOnDestroy(): void { diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.spec.ts b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.spec.ts index 3904ebd1a7..feb69a71d5 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.spec.ts +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.spec.ts @@ -1,16 +1,33 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogRef } from '@angular/material/dialog'; +import { TabNavService } from '../../../../tab-nav.service'; +import { BaseTestModules } from '../../../../test-framework/core-test.helper'; import { ApiKeysPageComponent } from './api-keys-page.component'; describe('ApiKeysPageComponent', () => { let component: ApiKeysPageComponent; let fixture: ComponentFixture; + const mockDialogRef = { + close: () => { } + }; + beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ ApiKeysPageComponent ] + imports: [ + ...BaseTestModules, + ], + declarations: [ApiKeysPageComponent], + providers: [ + { + provide: MatDialogRef, + useValue: mockDialogRef + }, + TabNavService + ] }) - .compileComponents(); + .compileComponents(); })); beforeEach(() => { From a5ba2817dda86323c3f08f1ecf30eb0666de2c08 Mon Sep 17 00:00:00 2001 From: Ivan Kapelyukhin Date: Mon, 24 Aug 2020 21:48:04 +0200 Subject: [PATCH 13/18] Add 'Last Used' column to API keys list --- .../apiKeys/apiKey-list-config.service.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts index 85243d243e..fbf3caac93 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts @@ -12,6 +12,7 @@ import { ConfirmationDialogService } from '../../../confirmation-dialog.service' import { ITableColumn } from '../../list-table/table.types'; import { IListAction, IListConfig, ListViewTypes } from '../../list.component.types'; import { ApiKeyDataSource } from './apiKey-data-source'; +import moment from 'moment'; @Injectable() @@ -49,6 +50,19 @@ export class ApiKeyListConfigService implements IListConfig { }, cellFlex: '2' }, + { + columnId: 'last_used', + headerCell: () => 'Last Used', + cellDefinition: { + getValue: row => row.last_used ? moment(row.last_used).format('LLL') : null + }, + sort: { + type: 'sort', + orderKey: 'last_used', + field: 'last_used' + }, + cellFlex: '1' + }, ]; isLocal = true; From a78f659cf6a8778f5597136051bffafb05c73797 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Tue, 25 Aug 2020 12:10:11 +0100 Subject: [PATCH 14/18] Don't flash up 'no entries' when we haven't loaded api keys yet --- .../api-keys/api-keys-page/api-keys-page.component.html | 9 ++++----- .../api-keys/api-keys-page/api-keys-page.component.ts | 7 ++++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html index ced31d24f0..f1da38761b 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.html @@ -24,11 +24,10 @@

API Keys

- - - + - \ No newline at end of file + }" toolbarAlign="stratos-api-key"> \ No newline at end of file diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts index 7dc066fbbb..0587de148a 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Observable, Subject } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { first, map, startWith } from 'rxjs/operators'; import { stratosEntityCatalog } from '../../../../../store/src/stratos-entity-catalog'; import { ApiKeyListConfigService } from '../../../shared/components/list/list-types/apiKeys/apiKey-list-config.service'; @@ -21,13 +21,14 @@ export class ApiKeysPageComponent { public keyDetails = new Subject(); public keyDetails$ = this.keyDetails.asObservable(); - public hasKeys$: Observable; + public hasKeys$: Observable; constructor( private dialog: MatDialog, ) { this.hasKeys$ = stratosEntityCatalog.apiKey.store.getPaginationService().entities$.pipe( - map(entities => entities && !!entities.length) + map(entities => entities && !!entities.length), + startWith(null), ) } From 1d0fd4b6fac022ef0d1eefd14e50e1e2a1f245b8 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Tue, 25 Aug 2020 12:12:47 +0100 Subject: [PATCH 15/18] Fix last used sorting - takes into account timezone - use cacheing to cater for often called sort --- .../apiKeys/apiKey-list-config.service.ts | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts index fbf3caac93..4b440bfd76 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts @@ -1,19 +1,22 @@ import { Injectable } from '@angular/core'; import { SortDirection } from '@angular/material/sort'; import { Store } from '@ngrx/store'; -import { filter } from 'rxjs/operators'; +import * as moment from 'moment'; import { ListView } from '../../../../../../../store/src/actions/list.actions'; import { ApiKey } from '../../../../../../../store/src/apiKey.types'; import { AppState } from '../../../../../../../store/src/app-state'; import { stratosEntityCatalog } from '../../../../../../../store/src/stratos-entity-catalog'; +import { PaginationEntityState } from '../../../../../../../store/src/types/pagination.types'; import { ConfirmationDialogConfig } from '../../../confirmation-dialog.config'; import { ConfirmationDialogService } from '../../../confirmation-dialog.service'; import { ITableColumn } from '../../list-table/table.types'; import { IListAction, IListConfig, ListViewTypes } from '../../list.component.types'; import { ApiKeyDataSource } from './apiKey-data-source'; -import moment from 'moment'; +type MomentCache = { + [key: string]: moment.Moment +} @Injectable() export class ApiKeyListConfigService implements IListConfig { @@ -36,6 +39,16 @@ export class ApiKeyListConfigService implements IListConfig { } private singleActions: IListAction[] = [this.deleteAction]; + // These are all used by sort, which can be called often. Ensure we cache where we can + private static lastUsedName = 'last_used'; + private lastUsedCache: MomentCache = {} + private static getLastUsedValue = (val: string, values: MomentCache) => { + if (!values[val]) { + values[val] = moment(val); + } + return values[val]; + } + public readonly columns: ITableColumn[] = [ { columnId: 'comment', @@ -51,18 +64,31 @@ export class ApiKeyListConfigService implements IListConfig { cellFlex: '2' }, { - columnId: 'last_used', + columnId: ApiKeyListConfigService.lastUsedName, headerCell: () => 'Last Used', cellDefinition: { getValue: row => row.last_used ? moment(row.last_used).format('LLL') : null }, - sort: { - type: 'sort', - orderKey: 'last_used', - field: 'last_used' + sort: (entities: ApiKey[], paginationState: PaginationEntityState) => { + if (entities && paginationState.params['order-direction-field'] === ApiKeyListConfigService.lastUsedName) { + const orderDirection = paginationState.params['order-direction']; + return entities.sort((a, b) => { + const valueA = ApiKeyListConfigService.getLastUsedValue(a.last_used, this.lastUsedCache); + const valueB = ApiKeyListConfigService.getLastUsedValue(b.last_used, this.lastUsedCache); + if (valueA.isAfter(valueB)) { + return orderDirection === 'desc' ? 1 : -1; + } + if (valueA.isBefore(valueB)) { + return orderDirection === 'desc' ? -1 : 1; + } + return 0; + }); + } else { + return entities; + } }, cellFlex: '1' - }, + } ]; isLocal = true; From 7751f1166b7bb9947291cf769d9e71b47056dd77 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Tue, 25 Aug 2020 13:30:12 +0100 Subject: [PATCH 16/18] Fix unit test after changes in master --- .../api-keys/api-keys-page/api-keys-page.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.spec.ts b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.spec.ts index feb69a71d5..1c2318bba0 100644 --- a/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.spec.ts +++ b/src/frontend/packages/core/src/features/api-keys/api-keys-page/api-keys-page.component.spec.ts @@ -1,8 +1,8 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { MatDialogRef } from '@angular/material/dialog'; -import { TabNavService } from '../../../../tab-nav.service'; import { BaseTestModules } from '../../../../test-framework/core-test.helper'; +import { TabNavService } from '../../../tab-nav.service'; import { ApiKeysPageComponent } from './api-keys-page.component'; describe('ApiKeysPageComponent', () => { From 6283dc1809cfd6cc3fb21a16ac2c019b00de690e Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Wed, 26 Aug 2020 09:10:50 +0100 Subject: [PATCH 17/18] Fix after moment change --- .../list/list-types/apiKeys/apiKey-list-config.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts index 4b440bfd76..77056267c6 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { SortDirection } from '@angular/material/sort'; import { Store } from '@ngrx/store'; -import * as moment from 'moment'; +import moment from 'moment'; import { ListView } from '../../../../../../../store/src/actions/list.actions'; import { ApiKey } from '../../../../../../../store/src/apiKey.types'; From 9e000f99cee3962cd42ac19f4f0b16b0603e0628 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Tue, 1 Sep 2020 09:25:00 +0100 Subject: [PATCH 18/18] Remove now unrequired sorting of api key last used date via moment --- .../apiKeys/apiKey-list-config.service.ts | 42 ++++--------------- 1 file changed, 9 insertions(+), 33 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts index 77056267c6..035a9067da 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/apiKeys/apiKey-list-config.service.ts @@ -7,20 +7,18 @@ import { ListView } from '../../../../../../../store/src/actions/list.actions'; import { ApiKey } from '../../../../../../../store/src/apiKey.types'; import { AppState } from '../../../../../../../store/src/app-state'; import { stratosEntityCatalog } from '../../../../../../../store/src/stratos-entity-catalog'; -import { PaginationEntityState } from '../../../../../../../store/src/types/pagination.types'; import { ConfirmationDialogConfig } from '../../../confirmation-dialog.config'; import { ConfirmationDialogService } from '../../../confirmation-dialog.service'; import { ITableColumn } from '../../list-table/table.types'; import { IListAction, IListConfig, ListViewTypes } from '../../list.component.types'; import { ApiKeyDataSource } from './apiKey-data-source'; -type MomentCache = { - [key: string]: moment.Moment -} - @Injectable() export class ApiKeyListConfigService implements IListConfig { + private static comment = 'comment'; + private static lastUsedName = 'last_used'; + private deleteAction: IListAction = { action: (item: ApiKey) => { const confirmation = new ConfirmationDialogConfig( @@ -39,26 +37,17 @@ export class ApiKeyListConfigService implements IListConfig { } private singleActions: IListAction[] = [this.deleteAction]; - // These are all used by sort, which can be called often. Ensure we cache where we can - private static lastUsedName = 'last_used'; - private lastUsedCache: MomentCache = {} - private static getLastUsedValue = (val: string, values: MomentCache) => { - if (!values[val]) { - values[val] = moment(val); - } - return values[val]; - } public readonly columns: ITableColumn[] = [ { - columnId: 'comment', + columnId: ApiKeyListConfigService.comment, headerCell: () => 'Description', cellDefinition: { valuePath: 'comment' }, sort: { type: 'sort', - orderKey: 'comment', + orderKey: ApiKeyListConfigService.comment, field: 'comment' }, cellFlex: '2' @@ -69,23 +58,10 @@ export class ApiKeyListConfigService implements IListConfig { cellDefinition: { getValue: row => row.last_used ? moment(row.last_used).format('LLL') : null }, - sort: (entities: ApiKey[], paginationState: PaginationEntityState) => { - if (entities && paginationState.params['order-direction-field'] === ApiKeyListConfigService.lastUsedName) { - const orderDirection = paginationState.params['order-direction']; - return entities.sort((a, b) => { - const valueA = ApiKeyListConfigService.getLastUsedValue(a.last_used, this.lastUsedCache); - const valueB = ApiKeyListConfigService.getLastUsedValue(b.last_used, this.lastUsedCache); - if (valueA.isAfter(valueB)) { - return orderDirection === 'desc' ? 1 : -1; - } - if (valueA.isBefore(valueB)) { - return orderDirection === 'desc' ? -1 : 1; - } - return 0; - }); - } else { - return entities; - } + sort: { + type: 'sort', + orderKey: ApiKeyListConfigService.lastUsedName, + field: 'last_used' }, cellFlex: '1' }