From 0c2f07988a2f3811ee02807ae0508a1a8cff2bc1 Mon Sep 17 00:00:00 2001 From: LP B Date: Wed, 10 Jun 2026 20:34:46 +0200 Subject: [PATCH] feat(app/sources): source create view (#2680) Co-authored-by: Chaim Lev-Ari Co-authored-by: Claude Sonnet 4.6 --- api/gitops/workflows/filter.go | 11 +- api/gitops/workflows/source_artifact.go | 18 +- api/gitops/workflows/source_artifact_test.go | 94 ++++++++++ api/http/handler/gitops/sources/create_git.go | 98 ++++++---- .../handler/gitops/sources/create_git_test.go | 131 ++++++++++++- api/http/handler/gitops/sources/get.go | 18 +- api/http/handler/gitops/sources/handler.go | 1 + .../gitops/sources/source_connection.go | 40 +++- api/http/handler/gitops/sources/types.go | 20 +- api/http/handler/gitops/sources/update_git.go | 123 +++++++------ .../handler/gitops/sources/update_git_test.go | 51 +++++ api/http/handler/gitops/sources/utils.go | 5 - api/http/handler/gitops/sources/utils_test.go | 8 +- app/docker/filters/filters.js | 11 +- app/portainer/__module.js | 14 ++ app/portainer/react/views/gitops.ts | 5 + app/react-tools/useDebugPropChanges.ts | 2 + .../components/BoxSelector/BoxSelector.tsx | 6 +- app/react/components/BoxSelector/types.ts | 2 +- .../components/Stepper/useWizardSteps.ts | 12 +- app/react/components/Widget/WidgetTitle.tsx | 3 + .../form-components/PortainerSelect.tsx | 2 +- .../PortainerSelectCustomRenderers.tsx | 49 +++++ .../generated-api/portainer/sdk.gen.ts | 76 ++++++-- .../generated-api/portainer/types.gen.ts | 93 +++++++--- .../generated-api/portainer/zod.gen.ts | 62 +++---- .../gitops/sources/CreateView/CreateForm.tsx | 70 +++++++ .../gitops/sources/CreateView/CreateView.tsx | 47 +++++ .../sources/CreateView/WizardContext.tsx | 16 ++ .../sources/CreateView/WizardFooter.test.tsx | 174 ++++++++++++++++++ .../sources/CreateView/WizardFooter.tsx | 38 ++++ .../sources/CreateView/WizardHeader.tsx | 23 +++ .../CreateView/steps/AccessControlStep.tsx | 13 ++ .../CreateView/steps/Authentication.tsx | 35 ++++ .../sources/CreateView/steps/ConfigureGit.tsx | 53 ++++++ .../CreateView/steps/ConfigureHelm.tsx | 3 + .../CreateView/steps/ConfigureRegistry.tsx | 3 + .../CreateView/steps/ConfigureStep.tsx | 70 +++++++ .../CreateView/steps/ConnectionTest.test.tsx | 135 ++++++++++++++ .../CreateView/steps/ConnectionTest.tsx | 64 +++++++ .../CreateView/steps/TypeSelectStep.tsx | 34 ++++ .../CreateView/steps/typeSelectOptions.tsx | 80 ++++++++ .../gitops/sources/CreateView/type.test.ts | 115 ++++++++++++ .../gitops/sources/CreateView/type.ts | 64 +++++++ .../CreateView/useSourceCreateMutation.ts | 28 +++ .../CreateView/useTestSourceConnection.ts | 43 +++++ .../sources/CreateView/validation.test.ts | 143 ++++++++++++++ .../gitops/sources/CreateView/validation.tsx | 51 +++++ .../gitops/sources/ItemView/ItemView.tsx | 4 +- .../SettingsTab/EditForm/EditAuthWidget.tsx | 59 ++---- .../gitops/sources/ListView/ListView.tsx | 7 +- .../sources/components/GitAuthentication.tsx | 46 +++++ .../sources/components/PasswordField.tsx | 41 +++++ .../components/ProviderCredentialFields.tsx | 40 ++++ .../sources/components/UsernameField.tsx | 30 +++ .../useTestSourceConnectionMutation.ts | 4 +- app/react/portainer/gitops/sources/types.ts | 1 - app/react/test-utils/withTestQuery.tsx | 11 +- app/react/utils/context.tsx | 33 ++++ app/react/utils/hash.test.ts | 13 ++ app/react/utils/hash.ts | 8 + 61 files changed, 2272 insertions(+), 282 deletions(-) create mode 100644 app/react/components/form-components/PortainerSelectCustomRenderers.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/CreateForm.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/CreateView.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/WizardContext.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/WizardFooter.test.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/WizardFooter.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/WizardHeader.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/steps/AccessControlStep.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/steps/Authentication.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/steps/ConfigureGit.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/steps/ConfigureHelm.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/steps/ConfigureRegistry.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/steps/ConfigureStep.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/steps/ConnectionTest.test.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/steps/ConnectionTest.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/steps/TypeSelectStep.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/steps/typeSelectOptions.tsx create mode 100644 app/react/portainer/gitops/sources/CreateView/type.test.ts create mode 100644 app/react/portainer/gitops/sources/CreateView/type.ts create mode 100644 app/react/portainer/gitops/sources/CreateView/useSourceCreateMutation.ts create mode 100644 app/react/portainer/gitops/sources/CreateView/useTestSourceConnection.ts create mode 100644 app/react/portainer/gitops/sources/CreateView/validation.test.ts create mode 100644 app/react/portainer/gitops/sources/CreateView/validation.tsx create mode 100644 app/react/portainer/gitops/sources/components/GitAuthentication.tsx create mode 100644 app/react/portainer/gitops/sources/components/PasswordField.tsx create mode 100644 app/react/portainer/gitops/sources/components/ProviderCredentialFields.tsx create mode 100644 app/react/portainer/gitops/sources/components/UsernameField.tsx create mode 100644 app/react/utils/context.tsx create mode 100644 app/react/utils/hash.test.ts create mode 100644 app/react/utils/hash.ts diff --git a/api/gitops/workflows/filter.go b/api/gitops/workflows/filter.go index e3b7472d2d..1e87f280f6 100644 --- a/api/gitops/workflows/filter.go +++ b/api/gitops/workflows/filter.go @@ -16,6 +16,8 @@ import ( "github.com/portainer/portainer/api/set" "github.com/portainer/portainer/api/slicesx" "github.com/portainer/portainer/api/stacks/stackutils" + + "github.com/rs/zerolog/log" ) func EndpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool { @@ -115,7 +117,8 @@ func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.Restrict access, err := resolveKubeAccess(k8sFactory, sc, &ep) if err != nil { - return nil, err + log.Warn().Err(err).Str("context", "buildEndpointAccessMap").Int("endpoint_id", int(epID)).Msg("Failed to resolve kube access for endpoint, skipping") + continue } result[epID] = access @@ -148,7 +151,8 @@ func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.Endpoint kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep) if err != nil { - return nil, err + log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube client for endpoint, skipping") + continue } access := accessMap[envID] @@ -157,7 +161,8 @@ func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.Endpoint apps, err := kcl.GetApplications("", "") if err != nil { - return nil, err + log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube applications for endpoint, skipping") + continue } for _, s := range stacks { diff --git a/api/gitops/workflows/source_artifact.go b/api/gitops/workflows/source_artifact.go index c0681707bf..c592a20ecc 100644 --- a/api/gitops/workflows/source_artifact.go +++ b/api/gitops/workflows/source_artifact.go @@ -348,8 +348,9 @@ func gitAuthMatches(a, b *gittypes.GitAuthentication) bool { return a.Username == b.Username && a.Password == b.Password && a.GitCredentialID == b.GitCredentialID } -// ValidateUniqueSourceURL validates there are no other sources with the same URL -func ValidateUniqueSourceURL(tx gitSourceStore, url string, sourceID portainer.SourceID) (bool, error) { +// ValidateUniqueSource validates there are no other sources with the same URL and credentials. +// Pass empty strings for username and password when the source has no authentication. +func ValidateUniqueSource(tx gitSourceStore, url, username, password string, sourceID portainer.SourceID) (bool, error) { normalizedURL, err := gittypes.NormalizeURL(gittypes.SanitizeURL(url)) if err != nil { return false, err @@ -361,8 +362,12 @@ func ValidateUniqueSourceURL(tx gitSourceStore, url string, sourceID portainer.S } normalized, err := gittypes.NormalizeURL(gittypes.SanitizeURL(s.Git.URL)) + if err != nil || normalized != normalizedURL { + return false + } - return err == nil && normalized == normalizedURL + existingUsername, existingPassword := gitAuthCredentials(s.Git.Authentication) + return existingUsername == username && existingPassword == password }) if err != nil { @@ -371,3 +376,10 @@ func ValidateUniqueSourceURL(tx gitSourceStore, url string, sourceID portainer.S return len(existing) == 0, nil } + +func gitAuthCredentials(auth *gittypes.GitAuthentication) (username, password string) { + if auth == nil { + return "", "" + } + return auth.Username, auth.Password +} diff --git a/api/gitops/workflows/source_artifact_test.go b/api/gitops/workflows/source_artifact_test.go index 58b96936da..5437e24e48 100644 --- a/api/gitops/workflows/source_artifact_test.go +++ b/api/gitops/workflows/source_artifact_test.go @@ -769,3 +769,97 @@ func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) { require.NoError(t, err) require.Equal(t, "https://github.com/example/repo", src.Git.URL) } + +func newSourceWithAuth(url, username, password string) *portainer.Source { + return &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.RepoConfig{ + URL: url, + Authentication: &gittypes.GitAuthentication{ + Username: username, + Password: password, + }, + }, + } +} + +func newAuthlessSource(url string) *portainer.Source { + return &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.RepoConfig{URL: url}, + } +} + +func validateUniqueSourceInStore(t *testing.T, store *datastore.Store, url, username, password string, sourceID portainer.SourceID) bool { + t.Helper() + + var isUnique bool + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + isUnique, err = ValidateUniqueSource(tx, url, username, password, sourceID) + return err + })) + + return isUnique +} + +func TestValidateUniqueSource_SameURLAndCreds_IsDuplicate(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.Source().Create(newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")) + })) + + require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0)) +} + +func TestValidateUniqueSource_SameURLDifferentCreds_IsUnique(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.Source().Create(newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")) + })) + + require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "bob", "other", 0)) +} + +func TestValidateUniqueSource_TwoAuthlessSameURL_IsDuplicate(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.Source().Create(newAuthlessSource("https://github.com/org/repo.git")) + })) + + require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "", "", 0)) +} + +func TestValidateUniqueSource_AuthlessVsAuthenticated_IsUnique(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.Source().Create(newAuthlessSource("https://github.com/org/repo.git")) + })) + + require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0)) +} + +func TestValidateUniqueSource_ExcludesSelf(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret") + if err := tx.Source().Create(src); err != nil { + return err + } + srcID = src.ID + return nil + })) + + require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", srcID)) +} diff --git a/api/http/handler/gitops/sources/create_git.go b/api/http/handler/gitops/sources/create_git.go index c037fffefd..fcde7aaa21 100644 --- a/api/http/handler/gitops/sources/create_git.go +++ b/api/http/handler/gitops/sources/create_git.go @@ -8,6 +8,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/workflows" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -15,10 +16,8 @@ import ( // GitAuthenticationPayload holds authentication parameters for a git source type GitAuthenticationPayload struct { - Username string `json:"username"` - Password string `json:"password"` - Provider gittypes.GitProvider `json:"provider"` - AuthorizationType gittypes.GitCredentialAuthType `json:"authorizationType"` + Username string `json:"username"` + Password string `json:"password"` } // GitSourceCreatePayload holds the parameters for creating a git-backed source @@ -38,38 +37,10 @@ func (payload *GitSourceCreatePayload) Validate(_ *http.Request) error { return nil } -// BuildGitSource constructs a portainer.Source from a GitSourceCreatePayload -func BuildGitSource(payload GitSourceCreatePayload) *portainer.Source { - gitConfig := &gittypes.RepoConfig{ - URL: payload.URL, - TLSSkipVerify: payload.TLSSkipVerify, - } - - if payload.Authentication != nil { - gitConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.Authentication.Username, - Password: payload.Authentication.Password, - Provider: payload.Authentication.Provider, - AuthorizationType: payload.Authentication.AuthorizationType, - } - } - - name := payload.Name - if strings.TrimSpace(name) == "" { - name = gittypes.RepoName(payload.URL) - } - - return &portainer.Source{ - Name: name, - Type: portainer.SourceTypeGit, - Git: gitConfig, - } -} - // @id GitOpsSourcesCreateGit // @summary Create a Git source // @description Creates a new GitOps source backed by a Git repository. -// @description **Access policy**: admin +// @description **Access policy**: administrator // @tags gitops // @security ApiKeyAuth // @security jwt @@ -79,6 +50,7 @@ func BuildGitSource(payload GitSourceCreatePayload) *portainer.Source { // @success 201 {object} portainer.Source // @failure 400 "Invalid request payload" // @failure 403 "Access denied" +// @failure 409 "A source with this URL and credentials already exists" // @failure 500 "Server error" // @router /gitops/sources/git [post] func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -88,11 +60,28 @@ func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httpe return httperror.BadRequest("Invalid request payload", err) } - src := BuildGitSource(payload) + src, err := BuildGitSource(payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + username, password := "", "" + if payload.Authentication != nil { + username = payload.Authentication.Username + password = payload.Authentication.Password + } if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if isUnique, err := workflows.ValidateUniqueSource(tx, payload.URL, username, password, 0); err != nil { + return err + } else if !isUnique { + return ErrDuplicateSource + } + return tx.Source().Create(src) - }); err != nil { + }); errors.Is(err, ErrDuplicateSource) { + return httperror.Conflict("A source with this URL and credentials already exists", err) + } else if err != nil { return httperror.InternalServerError("Unable to create source", err) } @@ -100,3 +89,42 @@ func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httpe return response.JSONWithStatus(w, src, http.StatusCreated) } + +// BuildGitSource constructs a portainer.Source from a GitSourceCreatePayload +func BuildGitSource(payload GitSourceCreatePayload) (*portainer.Source, error) { + src := BuildBaseGitSource(payload) + src.Git.Authentication = BuildAuth(payload.Authentication) + + return src, nil +} + +// BuildBaseGitSource constructs the source skeleton (name, URL, TLS) without +// authentication. +func BuildBaseGitSource(payload GitSourceCreatePayload) *portainer.Source { + name := payload.Name + if strings.TrimSpace(name) == "" { + name = gittypes.RepoName(payload.URL) + } + + return &portainer.Source{ + Name: name, + Type: portainer.SourceTypeGit, + Git: &gittypes.RepoConfig{ + URL: payload.URL, + TLSSkipVerify: payload.TLSSkipVerify, + }, + } +} + +// BuildAuth constructs basic git authentication from the payload, returning nil +// when no authentication is provided. +func BuildAuth(payload *GitAuthenticationPayload) *gittypes.GitAuthentication { + if payload == nil { + return nil + } + + return &gittypes.GitAuthentication{ + Username: payload.Username, + Password: payload.Password, + } +} diff --git a/api/http/handler/gitops/sources/create_git_test.go b/api/http/handler/gitops/sources/create_git_test.go index 5050a03837..7ca776843e 100644 --- a/api/http/handler/gitops/sources/create_git_test.go +++ b/api/http/handler/gitops/sources/create_git_test.go @@ -16,9 +16,10 @@ import ( func TestBuildGitSource_DerivesNameFromURL(t *testing.T) { t.Parallel() - src := BuildGitSource(GitSourceCreatePayload{ + src, err := BuildGitSource(GitSourceCreatePayload{ URL: "https://github.com/org/my-repo.git", }) + require.NoError(t, err) require.Equal(t, "my-repo", src.Name) require.Equal(t, portainer.SourceTypeGit, src.Type) @@ -28,10 +29,11 @@ func TestBuildGitSource_DerivesNameFromURL(t *testing.T) { func TestBuildGitSource_UsesExplicitName(t *testing.T) { t.Parallel() - src := BuildGitSource(GitSourceCreatePayload{ + src, err := BuildGitSource(GitSourceCreatePayload{ Name: "custom-name", URL: "https://github.com/org/repo.git", }) + require.NoError(t, err) require.Equal(t, "custom-name", src.Name) } @@ -39,13 +41,14 @@ func TestBuildGitSource_UsesExplicitName(t *testing.T) { func TestBuildGitSource_WithAuthentication(t *testing.T) { t.Parallel() - src := BuildGitSource(GitSourceCreatePayload{ + src, err := BuildGitSource(GitSourceCreatePayload{ URL: "https://github.com/org/repo.git", Authentication: &GitAuthenticationPayload{ Username: "alice", Password: "secret", }, }) + require.NoError(t, err) require.NotNil(t, src.Git.Authentication) require.Equal(t, "alice", src.Git.Authentication.Username) @@ -149,6 +152,128 @@ func TestGitSourceCreate_MissingURL(t *testing.T) { require.Equal(t, http.StatusBadRequest, rr.Code) } +func TestGitSourceCreate_ConflictOnDuplicateURLAndCredentials(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Authentication: &GitAuthenticationPayload{ + Username: "alice", + Password: "secret", + }, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, body)) + require.Equal(t, http.StatusCreated, rr.Code) + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, body)) + require.Equal(t, http.StatusConflict, rr.Code) +} + +func TestGitSourceCreate_AllowsDuplicateURLWithDifferentCredentials(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + first, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Authentication: &GitAuthenticationPayload{ + Username: "alice", + Password: "secret", + }, + }) + require.NoError(t, err) + + second, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Authentication: &GitAuthenticationPayload{ + Username: "bob", + Password: "other", + }, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, first)) + require.Equal(t, http.StatusCreated, rr.Code) + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, second)) + require.Equal(t, http.StatusCreated, rr.Code) +} + +func TestGitSourceCreate_ConflictOnDuplicateAuthlessSource(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, body)) + require.Equal(t, http.StatusCreated, rr.Code) + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, body)) + require.Equal(t, http.StatusConflict, rr.Code) +} + +func TestGitSourceCreate_AllowsAuthlessAndAuthenticatedSameURL(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + authless, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + }) + require.NoError(t, err) + + authenticated, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Authentication: &GitAuthenticationPayload{ + Username: "alice", + Password: "secret", + }, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, authless)) + require.Equal(t, http.StatusCreated, rr.Code) + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, authenticated)) + require.Equal(t, http.StatusCreated, rr.Code) +} + func TestGitSourceCreate_MalformedJSON(t *testing.T) { t.Parallel() _, store := datastore.MustNewTestStore(t, false, true) diff --git a/api/http/handler/gitops/sources/get.go b/api/http/handler/gitops/sources/get.go index ebb7c6b861..51f83ac067 100644 --- a/api/http/handler/gitops/sources/get.go +++ b/api/http/handler/gitops/sources/get.go @@ -13,8 +13,7 @@ import ( ) type gitAuthInfo struct { - Type gittypes.GitCredentialAuthType `json:"type"` - Username string `json:"username"` + Username string `json:"username"` } type connectionInfo struct { @@ -23,7 +22,7 @@ type connectionInfo struct { Authentication *gitAuthInfo `json:"authentication,omitempty"` } -type autoUpdateInfo struct { +type AutoUpdateInfo struct { Mechanism string `json:"mechanism,omitempty"` FetchInterval string `json:"fetchInterval,omitempty"` } @@ -32,7 +31,7 @@ type autoUpdateInfo struct { type SourceDetail struct { Source Connection connectionInfo `json:"connection" validate:"required"` - AutoUpdate *autoUpdateInfo `json:"autoUpdate,omitempty"` + AutoUpdate *AutoUpdateInfo `json:"autoUpdate,omitempty"` Workflows []workflows.Workflow `json:"workflows"` } @@ -85,9 +84,9 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H } func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []workflows.Workflow) SourceDetail { - var autoUpdate *autoUpdateInfo + var autoUpdate *AutoUpdateInfo if len(sourceWfs) > 0 { - autoUpdate = buildAutoUpdateInfo(sourceWfs[0].AutoUpdate) + autoUpdate = BuildAutoUpdateInfo(sourceWfs[0].AutoUpdate) } return SourceDetail{ @@ -114,24 +113,23 @@ func buildGitAuthInfo(auth *gittypes.GitAuthentication) *gitAuthInfo { return nil } return &gitAuthInfo{ - Type: auth.AuthorizationType, Username: auth.Username, } } -func buildAutoUpdateInfo(autoUpdate *portainer.AutoUpdateSettings) *autoUpdateInfo { +func BuildAutoUpdateInfo(autoUpdate *portainer.AutoUpdateSettings) *AutoUpdateInfo { if autoUpdate == nil { return nil } switch { case autoUpdate.Interval != "": - return &autoUpdateInfo{ + return &AutoUpdateInfo{ Mechanism: "Interval", FetchInterval: autoUpdate.Interval, } case autoUpdate.Webhook != "": - return &autoUpdateInfo{ + return &AutoUpdateInfo{ Mechanism: "Webhook", } default: diff --git a/api/http/handler/gitops/sources/handler.go b/api/http/handler/gitops/sources/handler.go index dcc3c749f6..960322c2ea 100644 --- a/api/http/handler/gitops/sources/handler.go +++ b/api/http/handler/gitops/sources/handler.go @@ -45,6 +45,7 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor adminRouter := h.PathPrefix("/gitops/sources").Subrouter() adminRouter.Use(bouncer.AdminAccess) adminRouter.Handle("/git", httperror.LoggerHandler(h.gitSourceCreate)).Methods(http.MethodPost) + adminRouter.Handle("/test", httperror.LoggerHandler(h.gitSourceTest)).Methods(http.MethodPost) adminRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet) adminRouter.Handle("/{id}", httperror.LoggerHandler(h.gitSourceUpdate)).Methods(http.MethodPut) adminRouter.Handle("/{id}", httperror.LoggerHandler(h.sourceDelete)).Methods(http.MethodDelete) diff --git a/api/http/handler/gitops/sources/source_connection.go b/api/http/handler/gitops/sources/source_connection.go index ae8b68aea4..0d77f085f4 100644 --- a/api/http/handler/gitops/sources/source_connection.go +++ b/api/http/handler/gitops/sources/source_connection.go @@ -14,10 +14,10 @@ import ( "github.com/portainer/portainer/pkg/libhttp/response" ) -// @id GitOpsSourcesTestGit -// @summary Test a Git source connection +// @id GitOpsSourcesTestById +// @summary Test the connection of a stored source // @description Tests connectivity for a GitOps source, applying optional overrides to the stored configuration. -// @description **Access policy**: admin +// @description **Access policy**: administrator // @tags gitops // @security ApiKeyAuth // @security jwt @@ -72,6 +72,40 @@ type ConnectionTestResult struct { Error string `json:"error,omitempty"` } +// @id GitOpsSourcesTest +// @summary Test a Git source connection +// @description Tests connectivity for Git connection details that have not been persisted yet. +// @description **Access policy**: administrator +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body GitSourceCreatePayload true "Git connection details" +// @success 200 {object} ConnectionTestResult "Connection test result" +// @failure 400 "Invalid request payload" +// @failure 403 "Access denied" +// @failure 500 "Server error" +// @router /gitops/sources/test [post] +func (h *Handler) gitSourceTest(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload GitSourceCreatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + src, err := BuildGitSource(payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + if src.Git == nil { + return httperror.InternalServerError("Source has no git configuration", nil) + } + + result := testSourceConnection(r.Context(), h.gitService, src.Git) + + return response.JSON(w, result) +} + // testSourceConnection verifies that a git repository is reachable with the given config. func testSourceConnection(ctx context.Context, gitService portainer.GitService, config *gittypes.RepoConfig) ConnectionTestResult { var username, password string diff --git a/api/http/handler/gitops/sources/types.go b/api/http/handler/gitops/sources/types.go index 38ea9a17c4..ec401bf3c7 100644 --- a/api/http/handler/gitops/sources/types.go +++ b/api/http/handler/gitops/sources/types.go @@ -4,22 +4,20 @@ import ( "fmt" portainer "github.com/portainer/portainer/api" - gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/gitops/workflows" ) // Source represents a unique git repository used as a GitOps source across one or more workflows. type Source struct { - ID portainer.SourceID `json:"id" validate:"required"` - Name string `json:"name" validate:"required"` - Type SourceType `json:"type" validate:"required"` - URL string `json:"url" validate:"required"` - Status workflows.Status `json:"status" validate:"required"` - Error string `json:"error,omitempty"` - Provider gittypes.GitProvider `json:"provider,omitempty"` - UsedBy int `json:"usedBy"` - Environments int `json:"environments"` - LastSync int64 `json:"lastSync"` + ID portainer.SourceID `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + Type SourceType `json:"type" validate:"required"` + URL string `json:"url" validate:"required"` + Status workflows.Status `json:"status" validate:"required"` + Error string `json:"error,omitempty"` + UsedBy int `json:"usedBy"` + Environments int `json:"environments"` + LastSync int64 `json:"lastSync"` } type SourceType string diff --git a/api/http/handler/gitops/sources/update_git.go b/api/http/handler/gitops/sources/update_git.go index f6c2a725fb..867baa3b56 100644 --- a/api/http/handler/gitops/sources/update_git.go +++ b/api/http/handler/gitops/sources/update_git.go @@ -15,8 +15,8 @@ import ( ) var ( - ErrNotGitSource = errors.New("source is not a Git source") - ErrDuplicateSourceURL = errors.New("a source with this URL already exists") + ErrNotGitSource = errors.New("source is not a Git source") + ErrDuplicateSource = errors.New("a source with this URL and credentials already exists") ) // GitSourceUpdatePayload holds the parameters for creating a git-backed source @@ -29,10 +29,8 @@ type GitSourceUpdatePayload struct { } type GitAuthenticationUpdatePayload struct { - Username *string `json:"username"` - Password *string `json:"password"` - Provider *gittypes.GitProvider `json:"provider" swaggertype:"integer" enums:"0,1,2,3,4,5,6"` - AuthorizationType *gittypes.GitCredentialAuthType `json:"authorizationType" swaggertype:"integer" enums:"0,1"` + Username *string `json:"username"` + Password *string `json:"password"` } // Validate implements the portainer.Validatable interface @@ -43,7 +41,7 @@ func (payload *GitSourceUpdatePayload) Validate(_ *http.Request) error { // @id GitOpsSourcesUpdateGit // @summary Update a Git source // @description Updates an existing GitOps source backed by a Git repository. -// @description **Access policy**: admin +// @description **Access policy**: administrator // @tags gitops // @security ApiKeyAuth // @security jwt @@ -55,7 +53,7 @@ func (payload *GitSourceUpdatePayload) Validate(_ *http.Request) error { // @failure 400 "Invalid request payload" // @failure 403 "Access denied" // @failure 404 "Source not found" -// @failure 409 "A source with this URL already exists" +// @failure 409 "A source with this URL and credentials already exists" // @failure 500 "Server error" // @router /gitops/sources/{id} [put] func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -77,14 +75,6 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { var err error - if payload.URL != nil { - if isUnique, err := workflows.ValidateUniqueSourceURL(tx, *payload.URL, sourceID); err != nil { - return err - } else if !isUnique { - return ErrDuplicateSourceURL - } - } - if src, err = tx.Source().Read(sourceID); err != nil { return err } @@ -93,13 +83,25 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe return err } + username, password := "", "" + if src.Git != nil && src.Git.Authentication != nil { + username = src.Git.Authentication.Username + password = src.Git.Authentication.Password + } + + if isUnique, err := workflows.ValidateUniqueSource(tx, src.Git.URL, username, password, sourceID); err != nil { + return err + } else if !isUnique { + return ErrDuplicateSource + } + return tx.Source().Update(src.ID, src) }); h.dataStore.IsErrObjectNotFound(err) { return httperror.NotFound("Unable to find a source with the specified identifier", err) } else if errors.Is(err, ErrNotGitSource) { return httperror.BadRequest("Source is not a Git source", err) - } else if errors.Is(err, ErrDuplicateSourceURL) { - return httperror.Conflict("A source with this URL already exists", err) + } else if errors.Is(err, ErrDuplicateSource) { + return httperror.Conflict("A source with this URL and credentials already exists", err) } else if err != nil { return httperror.InternalServerError("Unable to update source", err) } @@ -111,6 +113,27 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe // ApplyGitSourceChanges applies the payload changes to the source in place func ApplyGitSourceChanges(src *portainer.Source, payload GitSourceUpdatePayload) error { + if err := ApplyBaseGitSourceChanges(src, payload); err != nil { + return err + } + + if payload.Authentication == nil { + return nil + } + + if *payload.Authentication == (GitAuthenticationUpdatePayload{}) { + src.Git.Authentication = nil + return nil + } + + src.Git.Authentication = ApplyAuthChanges(src.Git.Authentication, *payload.Authentication) + + return nil +} + +// ApplyBaseGitSourceChanges applies the non-authentication field changes (name, +// URL, reference, TLS) to the source in place, ensuring src.Git is set +func ApplyBaseGitSourceChanges(src *portainer.Source, payload GitSourceUpdatePayload) error { if src.Type != portainer.SourceTypeGit { return ErrNotGitSource } @@ -119,55 +142,41 @@ func ApplyGitSourceChanges(src *portainer.Source, payload GitSourceUpdatePayload src.Name = *payload.Name } - gitConfig := src.Git - if gitConfig == nil { - gitConfig = &gittypes.RepoConfig{} + if src.Git == nil { + src.Git = &gittypes.RepoConfig{} } if payload.URL != nil { - gitConfig.URL = *payload.URL + src.Git.URL = *payload.URL } if payload.ReferenceName != nil { - gitConfig.ReferenceName = *payload.ReferenceName + src.Git.ReferenceName = *payload.ReferenceName } if payload.TLSSkipVerify != nil { - gitConfig.TLSSkipVerify = *payload.TLSSkipVerify + src.Git.TLSSkipVerify = *payload.TLSSkipVerify } - var auth *gittypes.GitAuthentication - if payload.Authentication == nil { - auth = gitConfig.Authentication - } else if *payload.Authentication != (GitAuthenticationUpdatePayload{}) { - existing := gitConfig.Authentication - if existing != nil { - copied := *existing - auth = &copied - } else { - auth = &gittypes.GitAuthentication{} - } - - authPayload := *payload.Authentication - if authPayload.AuthorizationType != nil { - auth.AuthorizationType = *authPayload.AuthorizationType - } - - if authPayload.Username != nil { - auth.Username = *authPayload.Username - } - - if authPayload.Password != nil { - auth.Password = *authPayload.Password - } - - if authPayload.Provider != nil { - auth.Provider = *authPayload.Provider - } - } - - gitConfig.Authentication = auth - src.Git = gitConfig - return nil } + +// ApplyAuthChanges returns a copy of the existing authentication (or a fresh +// one) with the basic credential changes applied. +func ApplyAuthChanges(existing *gittypes.GitAuthentication, payload GitAuthenticationUpdatePayload) *gittypes.GitAuthentication { + auth := &gittypes.GitAuthentication{} + if existing != nil { + copied := *existing + auth = &copied + } + + if payload.Username != nil { + auth.Username = *payload.Username + } + + if payload.Password != nil { + auth.Password = *payload.Password + } + + return auth +} diff --git a/api/http/handler/gitops/sources/update_git_test.go b/api/http/handler/gitops/sources/update_git_test.go index 31ff2f58f0..b5d0c7bb0d 100644 --- a/api/http/handler/gitops/sources/update_git_test.go +++ b/api/http/handler/gitops/sources/update_git_test.go @@ -300,6 +300,57 @@ func TestGitSourceUpdate_MalformedJSON(t *testing.T) { require.Equal(t, http.StatusBadRequest, rr.Code) } +func TestGitSourceUpdate_ConflictWhenAuthChangesMatchAnotherSource(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + existing := &portainer.Source{ + Name: "existing", + Type: portainer.SourceTypeGit, + Git: &gittypes.RepoConfig{ + URL: "https://github.com/org/repo.git", + Authentication: &gittypes.GitAuthentication{ + Username: "alice", + Password: "secret", + }, + }, + } + if err := tx.Source().Create(existing); err != nil { + return err + } + + other := &portainer.Source{ + Name: "other", + Type: portainer.SourceTypeGit, + Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo.git"}, + } + if err := tx.Source().Create(other); err != nil { + return err + } + srcID = other.ID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + alice := "alice" + secret := "secret" + body, err := json.Marshal(GitSourceUpdatePayload{ + Authentication: &GitAuthenticationUpdatePayload{ + Username: &alice, + Password: &secret, + }, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body)) + require.Equal(t, http.StatusConflict, rr.Code) +} + func TestGitSourceUpdate_NonNumericID(t *testing.T) { t.Parallel() _, store := datastore.MustNewTestStore(t, false, true) diff --git a/api/http/handler/gitops/sources/utils.go b/api/http/handler/gitops/sources/utils.go index 5811ad7b0f..9f743a28df 100644 --- a/api/http/handler/gitops/sources/utils.go +++ b/api/http/handler/gitops/sources/utils.go @@ -20,12 +20,8 @@ func (h *Handler) buildSource(ctx context.Context, src *portainer.Source, stats } url := "" - var provider gittypes.GitProvider if src.Git != nil { url = gittypes.SanitizeURL(src.Git.URL) - if src.Git.Authentication != nil { - provider = src.Git.Authentication.Provider - } } return Source{ @@ -35,7 +31,6 @@ func (h *Handler) buildSource(ctx context.Context, src *portainer.Source, stats URL: url, Status: status, Error: sourceErr, - Provider: provider, UsedBy: stats.WorkflowCount, Environments: len(stats.EndpointIDs), LastSync: stats.LastSync, diff --git a/api/http/handler/gitops/sources/utils_test.go b/api/http/handler/gitops/sources/utils_test.go index 13d8b4896a..0b155d8486 100644 --- a/api/http/handler/gitops/sources/utils_test.go +++ b/api/http/handler/gitops/sources/utils_test.go @@ -49,15 +49,15 @@ func TestRedactWorkflowCredentials(t *testing.T) { func TestBuildAutoUpdateInfo(t *testing.T) { t.Parallel() - assert.Nil(t, buildAutoUpdateInfo(nil)) - assert.Nil(t, buildAutoUpdateInfo(&portainer.AutoUpdateSettings{})) + assert.Nil(t, BuildAutoUpdateInfo(nil)) + assert.Nil(t, BuildAutoUpdateInfo(&portainer.AutoUpdateSettings{})) - got := buildAutoUpdateInfo(&portainer.AutoUpdateSettings{Interval: "5m"}) + got := BuildAutoUpdateInfo(&portainer.AutoUpdateSettings{Interval: "5m"}) require.NotNil(t, got) assert.Equal(t, "Interval", got.Mechanism) assert.Equal(t, "5m", got.FetchInterval) - got = buildAutoUpdateInfo(&portainer.AutoUpdateSettings{Webhook: "abc123"}) + got = BuildAutoUpdateInfo(&portainer.AutoUpdateSettings{Webhook: "abc123"}) require.NotNil(t, got) assert.Equal(t, "Webhook", got.Mechanism) assert.Empty(t, got.FetchInterval) diff --git a/app/docker/filters/filters.js b/app/docker/filters/filters.js index 641d9dec99..0bef3566d8 100644 --- a/app/docker/filters/filters.js +++ b/app/docker/filters/filters.js @@ -1,4 +1,7 @@ import _ from 'lodash-es'; + +import { strToHash } from '@/react/utils/hash'; + import { hideShaSum, joinCommand, nodeStatusBadge, taskStatusBadge, trimContainerName, trimSHA, trimVersionTag } from './utils'; function includeString(text, values) { @@ -7,14 +10,6 @@ function includeString(text, values) { }); } -function strToHash(str) { - var hash = 0; - for (var i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - return hash; -} - function hashToHexColor(hash) { var color = '#'; for (var i = 0; i < 3; ) { diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 6dbb99ac52..c13c31ea77 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -340,6 +340,9 @@ angular component: 'sourcesListView', }, }, + data: { + access: AccessHeaders.Admin, + }, }; var gitopsSourceDetail = { @@ -355,6 +358,16 @@ angular }, }; + const gitopsSourceCreate = { + name: 'portainer.gitops.sources.new', + url: '/new', + views: { + 'content@': { + component: 'sourceCreateView', + }, + }, + }; + var init = { name: 'portainer.init', abstract: true, @@ -479,6 +492,7 @@ angular $stateRegistryProvider.register(workflows); $stateRegistryProvider.register(gitopsSources); $stateRegistryProvider.register(gitopsSourceDetail); + $stateRegistryProvider.register(gitopsSourceCreate); $stateRegistryProvider.register(init); $stateRegistryProvider.register(initAdmin); $stateRegistryProvider.register(settings); diff --git a/app/portainer/react/views/gitops.ts b/app/portainer/react/views/gitops.ts index 74eed8e7ba..6de8cc318e 100644 --- a/app/portainer/react/views/gitops.ts +++ b/app/portainer/react/views/gitops.ts @@ -3,6 +3,7 @@ import angular from 'angular'; import { WorkflowsView } from '@/react/portainer/gitops/WorkflowsView/WorkflowsView'; import { ListView as SourcesListView } from '@/react/portainer/gitops/sources/ListView/ListView'; import { ItemView as SourceItemView } from '@/react/portainer/gitops/sources/ItemView/ItemView'; +import { CreateView as SourceCreateView } from '@/react/portainer/gitops/sources/CreateView/CreateView'; import { r2a } from '@/react-tools/react2angular'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withUIRouter } from '@/react-tools/withUIRouter'; @@ -20,4 +21,8 @@ export const gitopsViewsModule = angular .component( 'sourceItemView', r2a(withUIRouter(withCurrentUser(SourceItemView)), []) + ) + .component( + 'sourceCreateView', + r2a(withUIRouter(withCurrentUser(SourceCreateView)), []) ).name; diff --git a/app/react-tools/useDebugPropChanges.ts b/app/react-tools/useDebugPropChanges.ts index b5389d7a5e..80d133e653 100644 --- a/app/react-tools/useDebugPropChanges.ts +++ b/app/react-tools/useDebugPropChanges.ts @@ -1,3 +1,4 @@ +/* eslint-disable react-hooks/refs */ /* eslint-disable no-console */ import { intersection } from 'lodash'; import { useEffect, useRef } from 'react'; @@ -49,4 +50,5 @@ export function useDebugPropChanges( } lastProps.current = newProps; } +/* eslint-enable react-hooks/refs */ /* eslint-enable no-console */ diff --git a/app/react/components/BoxSelector/BoxSelector.tsx b/app/react/components/BoxSelector/BoxSelector.tsx index 8ecd1192e9..f7af4f1072 100644 --- a/app/react/components/BoxSelector/BoxSelector.tsx +++ b/app/react/components/BoxSelector/BoxSelector.tsx @@ -61,13 +61,15 @@ export function BoxSelector({ > {options .filter((option) => !option.hide) - .map((option) => ( + .map(({ disabled, ...option }) => ( extends IconProps { readonly label: string; readonly description?: ReactNode; readonly value: T; - readonly disabled?: () => boolean; + readonly disabled?: boolean | (() => boolean); readonly tooltip?: () => string; readonly feature?: FeatureId; readonly disabledWhenLimited?: boolean; diff --git a/app/react/components/Stepper/useWizardSteps.ts b/app/react/components/Stepper/useWizardSteps.ts index a6d1d2a517..e06b4c07eb 100644 --- a/app/react/components/Stepper/useWizardSteps.ts +++ b/app/react/components/Stepper/useWizardSteps.ts @@ -5,13 +5,13 @@ export interface StepConfig { label: string; } -interface UseWizardStepsOptions { - steps: Array; +interface UseWizardStepsOptions { + steps: Array; initialStepId?: string; } -interface WizardStepState { - currentStep: StepConfig; +export interface WizardStepState { + currentStep: T; currentStepIndex: number; isFirstStep: boolean; isLastStep: boolean; @@ -23,10 +23,10 @@ interface WizardStepState { goToStepByIndex: (index: number) => void; } -export function useWizardSteps({ +export function useWizardSteps({ steps, initialStepId, -}: UseWizardStepsOptions): WizardStepState { +}: UseWizardStepsOptions): WizardStepState { const initialIndex = useMemo(() => { if (!initialStepId) return 0; const index = steps.findIndex((s) => s.id === initialStepId); diff --git a/app/react/components/Widget/WidgetTitle.tsx b/app/react/components/Widget/WidgetTitle.tsx index feaf4185fa..5d0652c805 100644 --- a/app/react/components/Widget/WidgetTitle.tsx +++ b/app/react/components/Widget/WidgetTitle.tsx @@ -8,6 +8,7 @@ interface Props { title: ReactNode; icon?: ReactNode; className?: string; + subtitle?: string; } export function WidgetTitle({ @@ -15,6 +16,7 @@ export function WidgetTitle({ icon, className, children, + subtitle, }: PropsWithChildren) { const { titleId } = useWidgetContext(); @@ -29,6 +31,7 @@ export function WidgetTitle({ {children} + {subtitle && {subtitle}} ); } diff --git a/app/react/components/form-components/PortainerSelect.tsx b/app/react/components/form-components/PortainerSelect.tsx index b1eb4d3fe0..e14aabb457 100644 --- a/app/react/components/form-components/PortainerSelect.tsx +++ b/app/react/components/form-components/PortainerSelect.tsx @@ -27,7 +27,7 @@ export interface GroupOption { options: Option[]; } -type Options = OptionsOrGroups< +export type Options = OptionsOrGroups< Option, GroupBase> >; diff --git a/app/react/components/form-components/PortainerSelectCustomRenderers.tsx b/app/react/components/form-components/PortainerSelectCustomRenderers.tsx new file mode 100644 index 0000000000..4faf5aa246 --- /dev/null +++ b/app/react/components/form-components/PortainerSelectCustomRenderers.tsx @@ -0,0 +1,49 @@ +import { + GroupBase, + OptionProps, + SingleValueProps, + SelectComponentsConfig, + components, +} from 'react-select'; +import { ReactNode } from 'react'; + +import { Option } from './PortainerSelect'; + +export function CustomComponents( + render: (data: Option) => ReactNode +): SelectComponentsConfig, false, GroupBase>> { + return { + Option: CustomOption(render), + SingleValue: CustomSingleValue(render), + }; +} + +function CustomOption(render: (data: Option) => ReactNode) { + return function CustomOptionRenderer({ + data, + ...props + }: OptionProps, false, GroupBase>>) { + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {render(data)} + + ); + }; +} + +function CustomSingleValue( + render: (data: Option) => ReactNode +) { + return function CustomOptionRenderer({ + data, + ...props + }: SingleValueProps, false, GroupBase>>) { + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {render(data)} + + ); + }; +} diff --git a/app/react/portainer/generated-api/portainer/sdk.gen.ts b/app/react/portainer/generated-api/portainer/sdk.gen.ts index bfb2d06c94..ed05449d16 100644 --- a/app/react/portainer/generated-api/portainer/sdk.gen.ts +++ b/app/react/portainer/generated-api/portainer/sdk.gen.ts @@ -451,9 +451,12 @@ import type { GitOpsSourcesSummaryData, GitOpsSourcesSummaryErrors, GitOpsSourcesSummaryResponses, - GitOpsSourcesTestGitData, - GitOpsSourcesTestGitErrors, - GitOpsSourcesTestGitResponses, + GitOpsSourcesTestByIdData, + GitOpsSourcesTestByIdErrors, + GitOpsSourcesTestByIdResponses, + GitOpsSourcesTestData, + GitOpsSourcesTestErrors, + GitOpsSourcesTestResponses, GitOpsSourcesUpdateGitData, GitOpsSourcesUpdateGitErrors, GitOpsSourcesUpdateGitResponses, @@ -1103,9 +1106,11 @@ import { zGitOpsSourcesListQuery, zGitOpsSourcesListResponse, zGitOpsSourcesSummaryResponse, - zGitOpsSourcesTestGitBody, - zGitOpsSourcesTestGitPath, - zGitOpsSourcesTestGitResponse, + zGitOpsSourcesTestBody, + zGitOpsSourcesTestByIdBody, + zGitOpsSourcesTestByIdPath, + zGitOpsSourcesTestByIdResponse, + zGitOpsSourcesTestResponse, zGitOpsSourcesUpdateGitBody, zGitOpsSourcesUpdateGitPath, zGitOpsSourcesUpdateGitResponse, @@ -3944,7 +3949,7 @@ export const gitOpsSourceGet = ( * Update a Git source * * Updates an existing GitOps source backed by a Git repository. - * **Access policy**: admin + * **Access policy**: administrator */ export const gitOpsSourcesUpdateGit = ( options: Options @@ -3978,30 +3983,30 @@ export const gitOpsSourcesUpdateGit = ( }); /** - * Test a Git source connection + * Test the connection of a stored source * * Tests connectivity for a GitOps source, applying optional overrides to the stored configuration. - * **Access policy**: admin + * **Access policy**: administrator */ -export const gitOpsSourcesTestGit = ( - options: Options +export const gitOpsSourcesTestById = ( + options: Options ) => (options.client ?? client).post< - GitOpsSourcesTestGitResponses, - GitOpsSourcesTestGitErrors, + GitOpsSourcesTestByIdResponses, + GitOpsSourcesTestByIdErrors, ThrowOnError >({ requestValidator: async (data) => await z .object({ - body: zGitOpsSourcesTestGitBody.optional(), - path: zGitOpsSourcesTestGitPath, + body: zGitOpsSourcesTestByIdBody.optional(), + path: zGitOpsSourcesTestByIdPath, query: z.never().optional(), }) .parseAsync(data), responseType: 'json', responseValidator: async (data) => - await zGitOpsSourcesTestGitResponse.parseAsync(data), + await zGitOpsSourcesTestByIdResponse.parseAsync(data), security: [ { name: 'X-API-KEY', type: 'apiKey' }, { name: 'Authorization', type: 'apiKey' }, @@ -4018,7 +4023,7 @@ export const gitOpsSourcesTestGit = ( * Create a Git source * * Creates a new GitOps source backed by a Git repository. - * **Access policy**: admin + * **Access policy**: administrator */ export const gitOpsSourcesCreateGit = ( options: Options @@ -4084,6 +4089,43 @@ export const gitOpsSourcesSummary = ( ...options, }); +/** + * Test a Git source connection + * + * Tests connectivity for Git connection details that have not been persisted yet. + * **Access policy**: administrator + */ +export const gitOpsSourcesTest = ( + options: Options +) => + (options.client ?? client).post< + GitOpsSourcesTestResponses, + GitOpsSourcesTestErrors, + ThrowOnError + >({ + requestValidator: async (data) => + await z + .object({ + body: zGitOpsSourcesTestBody, + path: z.never().optional(), + query: z.never().optional(), + }) + .parseAsync(data), + responseType: 'json', + responseValidator: async (data) => + await zGitOpsSourcesTestResponse.parseAsync(data), + security: [ + { name: 'X-API-KEY', type: 'apiKey' }, + { name: 'Authorization', type: 'apiKey' }, + ], + url: '/gitops/sources/test', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + /** * List all GitOps workflows * diff --git a/app/react/portainer/generated-api/portainer/types.gen.ts b/app/react/portainer/generated-api/portainer/types.gen.ts index b01b2fceb3..5515e10928 100644 --- a/app/react/portainer/generated-api/portainer/types.gen.ts +++ b/app/react/portainer/generated-api/portainer/types.gen.ts @@ -4208,7 +4208,6 @@ export type SslSslUpdatePayload = { }; export type SourcesGitAuthInfo = { - type?: number; username?: string; }; @@ -4218,11 +4217,6 @@ export type SourcesConnectionInfo = { tlsSkipVerify?: boolean; }; -export type SourcesAutoUpdateInfo = { - fetchInterval?: string; - mechanism?: string; -}; - export const SourcesSourceType = { /** * SourceTypeGit @@ -4249,7 +4243,6 @@ export type SourcesSourceDetail = { id: number; lastSync?: number; name: string; - provider?: number; status: WorkflowsStatus; type: SourcesSourceType; url: string; @@ -4257,13 +4250,17 @@ export type SourcesSourceDetail = { workflows?: Array; }; +export type SourcesAutoUpdateInfo = { + fetchInterval?: string; + mechanism?: string; +}; + export type SourcesSource = { environments?: number; error?: string; id: number; lastSync?: number; name: string; - provider?: number; status: WorkflowsStatus; type: SourcesSourceType; url: string; @@ -4279,9 +4276,7 @@ export type SourcesGitSourceUpdatePayload = { }; export type SourcesGitAuthenticationUpdatePayload = { - authorizationType?: 0 | 1; password?: string; - provider?: 0 | 1 | 2 | 3 | 4 | 5 | 6; username?: string; }; @@ -4293,9 +4288,7 @@ export type SourcesGitSourceCreatePayload = { }; export type SourcesGitAuthenticationPayload = { - authorizationType?: number; password?: string; - provider?: number; username?: string; }; @@ -5714,8 +5707,8 @@ export type PortainerSourceType = (typeof PortainerSourceType)[keyof typeof PortainerSourceType]; export type PortainerSource = { - gitConfig?: GittypesRepoConfig; - helmConfig?: PortainerHelmConfig; + git?: GittypesRepoConfig; + helm?: PortainerHelmConfig; id?: number; lastSync?: number; name?: string; @@ -6614,7 +6607,6 @@ export type PortainerCustomTemplatePlatform = (typeof PortainerCustomTemplatePlatform)[keyof typeof PortainerCustomTemplatePlatform]; export type PortainerCustomTemplate = { - ArtifactSources?: PortainerArtifactSources; /** * User identifier who created this template */ @@ -6670,21 +6662,25 @@ export type PortainerCustomTemplate = { */ Type?: 1 | 2 | 3; Variables?: Array; + artifact?: PortainerArtifact; +}; + +export type PortainerArtifactFile = { + hash?: string; + path?: string; + ref?: string; + sourceId?: number; }; export type PortainerArtifact = { - configFilePath?: string; - configHash?: string; + edgeGroups?: Array; edgeStackId?: number; - referenceName?: string; + envGroups?: Array; + envIds?: Array; + files?: Array; stackId?: number; }; -export type PortainerArtifactSources = { - artifact?: PortainerArtifact; - sourceIds?: Array; -}; - export type MotdMotd = { ContentLayout?: { [key: string]: string; @@ -11285,7 +11281,7 @@ export type GitOpsSourcesUpdateGitErrors = { */ 404: unknown; /** - * A source with this URL already exists + * A source with this URL and credentials already exists */ 409: unknown; /** @@ -11304,7 +11300,7 @@ export type GitOpsSourcesUpdateGitResponses = { export type GitOpsSourcesUpdateGitResponse = GitOpsSourcesUpdateGitResponses[keyof GitOpsSourcesUpdateGitResponses]; -export type GitOpsSourcesTestGitData = { +export type GitOpsSourcesTestByIdData = { /** * Optional connection overrides; omitted fields fall back to stored values */ @@ -11319,7 +11315,7 @@ export type GitOpsSourcesTestGitData = { url: '/gitops/sources/{id}/test'; }; -export type GitOpsSourcesTestGitErrors = { +export type GitOpsSourcesTestByIdErrors = { /** * Invalid request payload */ @@ -11338,15 +11334,15 @@ export type GitOpsSourcesTestGitErrors = { 500: unknown; }; -export type GitOpsSourcesTestGitResponses = { +export type GitOpsSourcesTestByIdResponses = { /** * Connection test result */ 200: SourcesConnectionTestResult; }; -export type GitOpsSourcesTestGitResponse = - GitOpsSourcesTestGitResponses[keyof GitOpsSourcesTestGitResponses]; +export type GitOpsSourcesTestByIdResponse = + GitOpsSourcesTestByIdResponses[keyof GitOpsSourcesTestByIdResponses]; export type GitOpsSourcesCreateGitData = { /** @@ -11367,6 +11363,10 @@ export type GitOpsSourcesCreateGitErrors = { * Access denied */ 403: unknown; + /** + * A source with this URL and credentials already exists + */ + 409: unknown; /** * Server error */ @@ -11411,6 +11411,41 @@ export type GitOpsSourcesSummaryResponses = { export type GitOpsSourcesSummaryResponse = GitOpsSourcesSummaryResponses[keyof GitOpsSourcesSummaryResponses]; +export type GitOpsSourcesTestData = { + /** + * Git connection details + */ + body: SourcesGitSourceCreatePayload; + path?: never; + query?: never; + url: '/gitops/sources/test'; +}; + +export type GitOpsSourcesTestErrors = { + /** + * Invalid request payload + */ + 400: unknown; + /** + * Access denied + */ + 403: unknown; + /** + * Server error + */ + 500: unknown; +}; + +export type GitOpsSourcesTestResponses = { + /** + * Connection test result + */ + 200: SourcesConnectionTestResult; +}; + +export type GitOpsSourcesTestResponse = + GitOpsSourcesTestResponses[keyof GitOpsSourcesTestResponses]; + export type GitOpsWorkflowsListData = { body?: never; path?: never; diff --git a/app/react/portainer/generated-api/portainer/zod.gen.ts b/app/react/portainer/generated-api/portainer/zod.gen.ts index e18d9625b7..0e2369365a 100644 --- a/app/react/portainer/generated-api/portainer/zod.gen.ts +++ b/app/react/portainer/generated-api/portainer/zod.gen.ts @@ -1118,7 +1118,6 @@ export const zSslSslUpdatePayload = z.object({ }); export const zSourcesGitAuthInfo = z.object({ - type: z.int().optional(), username: z.string().optional(), }); @@ -1128,13 +1127,13 @@ export const zSourcesConnectionInfo = z.object({ tlsSkipVerify: z.boolean().optional(), }); +export const zSourcesSourceType = z.enum(SourcesSourceType); + export const zSourcesAutoUpdateInfo = z.object({ fetchInterval: z.string().optional(), mechanism: z.string().optional(), }); -export const zSourcesSourceType = z.enum(SourcesSourceType); - export const zSourcesSourceDetail = z.object({ autoUpdate: zSourcesAutoUpdateInfo.optional(), connection: zSourcesConnectionInfo, @@ -1143,7 +1142,6 @@ export const zSourcesSourceDetail = z.object({ id: z.int(), lastSync: z.int().optional(), name: z.string(), - provider: z.int().optional(), status: zWorkflowsStatus, type: zSourcesSourceType, url: z.string(), @@ -1157,7 +1155,6 @@ export const zSourcesSource = z.object({ id: z.int(), lastSync: z.int().optional(), name: z.string(), - provider: z.int().optional(), status: zWorkflowsStatus, type: zSourcesSourceType, url: z.string(), @@ -1165,19 +1162,7 @@ export const zSourcesSource = z.object({ }); export const zSourcesGitAuthenticationUpdatePayload = z.object({ - authorizationType: z.union([z.literal(0), z.literal(1)]).optional(), password: z.string().optional(), - provider: z - .union([ - z.literal(0), - z.literal(1), - z.literal(2), - z.literal(3), - z.literal(4), - z.literal(5), - z.literal(6), - ]) - .optional(), username: z.string().optional(), }); @@ -1190,9 +1175,7 @@ export const zSourcesGitSourceUpdatePayload = z.object({ }); export const zSourcesGitAuthenticationPayload = z.object({ - authorizationType: z.int().optional(), password: z.string().optional(), - provider: z.int().optional(), username: z.string().optional(), }); @@ -1802,8 +1785,8 @@ export const zPortainerHelmConfig = z.object({ }); export const zPortainerSource = z.object({ - gitConfig: zGittypesRepoConfig.optional(), - helmConfig: zPortainerHelmConfig.optional(), + git: zGittypesRepoConfig.optional(), + helm: zPortainerHelmConfig.optional(), id: z.int().optional(), lastSync: z.int().optional(), name: z.string().optional(), @@ -2159,21 +2142,23 @@ export const zPortainerCustomTemplatePlatform = z.enum( PortainerCustomTemplatePlatform ); +export const zPortainerArtifactFile = z.object({ + hash: z.string().optional(), + path: z.string().optional(), + ref: z.string().optional(), + sourceId: z.int().optional(), +}); + export const zPortainerArtifact = z.object({ - configFilePath: z.string().optional(), - configHash: z.string().optional(), + edgeGroups: z.array(z.int()).optional(), edgeStackId: z.int().optional(), - referenceName: z.string().optional(), + envGroups: z.array(z.int()).optional(), + envIds: z.array(z.int()).optional(), + files: z.array(zPortainerArtifactFile).optional(), stackId: z.int().optional(), }); -export const zPortainerArtifactSources = z.object({ - artifact: zPortainerArtifact.optional(), - sourceIds: z.array(z.int()).optional(), -}); - export const zPortainerCustomTemplate = z.object({ - ArtifactSources: zPortainerArtifactSources.optional(), CreatedByUserId: z.int().optional(), Description: z.string().optional(), EdgeTemplate: z.boolean().optional(), @@ -2189,6 +2174,7 @@ export const zPortainerCustomTemplate = z.object({ Title: z.string().optional(), Type: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(), Variables: z.array(zPortainerCustomTemplateVariableDefinition).optional(), + artifact: zPortainerArtifact.optional(), }); export const zMotdMotd = z.object({ @@ -4066,16 +4052,16 @@ export const zGitOpsSourcesUpdateGitResponse = zPortainerSource; /** * Optional connection overrides; omitted fields fall back to stored values */ -export const zGitOpsSourcesTestGitBody = zSourcesGitSourceUpdatePayload; +export const zGitOpsSourcesTestByIdBody = zSourcesGitSourceUpdatePayload; -export const zGitOpsSourcesTestGitPath = z.object({ +export const zGitOpsSourcesTestByIdPath = z.object({ id: z.int(), }); /** * Connection test result */ -export const zGitOpsSourcesTestGitResponse = zSourcesConnectionTestResult; +export const zGitOpsSourcesTestByIdResponse = zSourcesConnectionTestResult; /** * Git source details @@ -4092,6 +4078,16 @@ export const zGitOpsSourcesCreateGitResponse = zPortainerSource; */ export const zGitOpsSourcesSummaryResponse = zWorkflowsStatusSummary; +/** + * Git connection details + */ +export const zGitOpsSourcesTestBody = zSourcesGitSourceCreatePayload; + +/** + * Connection test result + */ +export const zGitOpsSourcesTestResponse = zSourcesConnectionTestResult; + export const zGitOpsWorkflowsListQuery = z.object({ search: z.string().optional(), sort: z.string().optional(), diff --git a/app/react/portainer/gitops/sources/CreateView/CreateForm.tsx b/app/react/portainer/gitops/sources/CreateView/CreateForm.tsx new file mode 100644 index 0000000000..b5b527bfe1 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/CreateForm.tsx @@ -0,0 +1,70 @@ +import { Form, Formik, FormikHelpers } from 'formik'; +import { useRouter } from '@uirouter/react'; + +import { notifySuccess } from '@/portainer/services/notifications'; + +import { Widget } from '@@/Widget'; + +import { FormValues, formValuesToCreatePayload } from './type'; +import { useCreateSourceMutation } from './useSourceCreateMutation'; +import { WizardStep, useWizardContext } from './WizardContext'; +import { WizardHeader } from './WizardHeader'; +import { WizardFooter } from './WizardFooter'; + +const initialFormValues: FormValues = { + name: '', + type: 'git', + git: { + url: '', + authentication: { + authEnabled: true, + }, + connectionOk: false, + }, +}; + +type Props = { + steps: WizardStep[]; +}; + +export function CreateForm({ steps }: Props) { + const mutation = useCreateSourceMutation(); + const router = useRouter(); + const { currentStep, isLastStep, goToNextStep } = useWizardContext(); + + return ( + +
+ + + + + + +
+ ); + + function handleSubmit( + formValues: FormValues, + { setTouched, setSubmitting }: FormikHelpers + ) { + if (!isLastStep) { + goToNextStep(); + setTouched({}); + setSubmitting(false); + return; + } + + mutation.mutate(formValuesToCreatePayload(formValues), { + onSuccess: () => { + notifySuccess('Success', 'Source successfully created'); + router.stateService.go('.^'); + }, + onSettled: () => setSubmitting(false), + }); + } +} diff --git a/app/react/portainer/gitops/sources/CreateView/CreateView.tsx b/app/react/portainer/gitops/sources/CreateView/CreateView.tsx new file mode 100644 index 0000000000..96765a138a --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/CreateView.tsx @@ -0,0 +1,47 @@ +import { useWizardSteps } from '@@/Stepper/useWizardSteps'; +import { PageHeader } from '@@/PageHeader'; + +import { TypeSelectStep, validateTypeSelectStep } from './steps/TypeSelectStep'; +import { ConfigureStep, validateConfigureStep } from './steps/ConfigureStep'; +import { WizardStep, WizardProvider } from './WizardContext'; +import { CreateForm } from './CreateForm'; + +const steps: WizardStep[] = [ + { + id: 'type', + label: 'Select source type', + component: TypeSelectStep, + validateStep: validateTypeSelectStep, + }, + { + id: 'configure', + label: 'Configure connection', + component: ConfigureStep, + validateStep: validateConfigureStep, + }, +]; + +export function CreateView() { + const context = useWizardSteps({ steps }); + + return ( +
+ + +
+
+ + + +
+
+
+ ); +} diff --git a/app/react/portainer/gitops/sources/CreateView/WizardContext.tsx b/app/react/portainer/gitops/sources/CreateView/WizardContext.tsx new file mode 100644 index 0000000000..5e00cd811f --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/WizardContext.tsx @@ -0,0 +1,16 @@ +import { ComponentProps, ComponentType } from 'react'; +import { Formik } from 'formik'; + +import { createContext } from '@/react/utils/context'; + +import { StepConfig, WizardStepState } from '@@/Stepper/useWizardSteps'; + +export type WizardStep = StepConfig & { + component: ComponentType; + validateStep: () => ComponentProps['validationSchema']; +}; + +const { Provider: WizardProvider, useContext: useWizardContext } = + createContext>('WizardContext'); + +export { WizardProvider, useWizardContext }; diff --git a/app/react/portainer/gitops/sources/CreateView/WizardFooter.test.tsx b/app/react/portainer/gitops/sources/CreateView/WizardFooter.test.tsx new file mode 100644 index 0000000000..b460ef3691 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/WizardFooter.test.tsx @@ -0,0 +1,174 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Formik } from 'formik'; +import * as yup from 'yup'; + +import { WizardStepState } from '@@/Stepper/useWizardSteps'; + +import { WizardStep, WizardProvider } from './WizardContext'; +import { WizardFooter } from './WizardFooter'; +import { FormValues } from './type'; + +function noop() {} + +const firstStep: WizardStep = { + id: 'step-1', + label: 'First', + component: () => null, + validateStep: () => yup.object(), +}; + +const lastStep: WizardStep = { + id: 'step-2', + label: 'Last', + component: () => null, + validateStep: () => yup.object(), +}; + +function buildWizardContext( + overrides: Partial> = {} +): WizardStepState { + return { + currentStep: firstStep, + currentStepIndex: 0, + isFirstStep: true, + isLastStep: false, + canGoBack: false, + canGoForward: true, + goToNextStep: noop, + goToPreviousStep: noop, + goToStep: noop, + goToStepByIndex: noop, + ...overrides, + }; +} + +const initialFormValues: FormValues = { + name: '', + type: 'git', + git: { + url: '', + connectionOk: false, + authentication: { authEnabled: false }, + }, +}; + +const validFormValues: FormValues = { + name: 'my-source', + type: 'git', + git: { + url: 'https://github.com/org/repo.git', + connectionOk: true, + authentication: { authEnabled: false }, + }, +}; + +function renderFooter({ + wizardContext = buildWizardContext(), + formValues = initialFormValues, + validationSchema, + validateOnMount, +}: { + wizardContext?: WizardStepState; + formValues?: FormValues; + validationSchema?: Parameters[0]['validationSchema']; + validateOnMount?: boolean; +} = {}) { + return render( + + + + + + ); +} + +describe('WizardFooter', () => { + it('disables Back on first step', () => { + renderFooter({ wizardContext: buildWizardContext({ isFirstStep: true }) }); + + expect(screen.getByRole('button', { name: /back/i })).toBeDisabled(); + }); + + it('enables Back when not on first step', () => { + renderFooter({ + wizardContext: buildWizardContext({ + isFirstStep: false, + canGoBack: true, + currentStepIndex: 1, + }), + }); + + expect(screen.getByRole('button', { name: /back/i })).not.toBeDisabled(); + }); + + it('shows Continue on non-last step', () => { + renderFooter({ wizardContext: buildWizardContext({ isLastStep: false }) }); + + expect(screen.getByRole('button', { name: /continue/i })).toBeVisible(); + }); + + it('shows Create on last step', () => { + renderFooter({ + wizardContext: buildWizardContext({ + isFirstStep: false, + isLastStep: true, + currentStep: lastStep, + currentStepIndex: 1, + canGoBack: true, + canGoForward: false, + }), + formValues: validFormValues, + }); + + expect(screen.getByRole('button', { name: /create/i })).toBeVisible(); + }); + + it('disables Continue when form is invalid', async () => { + renderFooter({ + wizardContext: buildWizardContext({ isLastStep: false }), + formValues: initialFormValues, + validationSchema: yup.object({ name: yup.string().required() }), + validateOnMount: true, + }); + + expect( + await screen.findByRole('button', { name: /continue/i }) + ).toBeDisabled(); + }); + + it('enables Continue when form is valid', async () => { + renderFooter({ + wizardContext: buildWizardContext({ isLastStep: false }), + formValues: validFormValues, + validationSchema: yup.object({ name: yup.string().required() }), + validateOnMount: true, + }); + + expect( + await screen.findByRole('button', { name: /continue/i }) + ).not.toBeDisabled(); + }); + + it('calls goToPreviousStep when Back is clicked', async () => { + const goToPreviousStep = vi.fn(); + const user = userEvent.setup(); + + renderFooter({ + wizardContext: buildWizardContext({ + isFirstStep: false, + canGoBack: true, + goToPreviousStep, + }), + }); + + await user.click(screen.getByRole('button', { name: /back/i })); + + expect(goToPreviousStep).toHaveBeenCalledOnce(); + }); +}); diff --git a/app/react/portainer/gitops/sources/CreateView/WizardFooter.tsx b/app/react/portainer/gitops/sources/CreateView/WizardFooter.tsx new file mode 100644 index 0000000000..cf1329bed7 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/WizardFooter.tsx @@ -0,0 +1,38 @@ +import { useFormikContext } from 'formik'; + +import { StickyFooter } from '@@/StickyFooter/StickyFooter'; +import { Button, LoadingButton } from '@@/buttons'; + +import { FormValues } from './type'; +import { useWizardContext } from './WizardContext'; + +export function WizardFooter() { + const { isFirstStep, isLastStep, goToPreviousStep } = useWizardContext(); + const { isSubmitting, isValid } = useFormikContext(); + + return ( + + + + {isLastStep ? 'Create' : 'Continue'} + + + ); +} diff --git a/app/react/portainer/gitops/sources/CreateView/WizardHeader.tsx b/app/react/portainer/gitops/sources/CreateView/WizardHeader.tsx new file mode 100644 index 0000000000..9847708957 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/WizardHeader.tsx @@ -0,0 +1,23 @@ +import { Stepper } from '@@/Stepper/Stepper'; + +import { WizardStep, useWizardContext } from './WizardContext'; + +type Props = { + steps: WizardStep[]; +}; + +export function WizardHeader({ steps }: Props) { + const { currentStepIndex, goToStepByIndex } = useWizardContext(); + + return ( +
+
+ +
+
+ ); +} diff --git a/app/react/portainer/gitops/sources/CreateView/steps/AccessControlStep.tsx b/app/react/portainer/gitops/sources/CreateView/steps/AccessControlStep.tsx new file mode 100644 index 0000000000..8cc96361a3 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/steps/AccessControlStep.tsx @@ -0,0 +1,13 @@ +import { Widget } from '@@/Widget'; + +export function AccessControlStep() { + return ( + <> + + + + ); +} diff --git a/app/react/portainer/gitops/sources/CreateView/steps/Authentication.tsx b/app/react/portainer/gitops/sources/CreateView/steps/Authentication.tsx new file mode 100644 index 0000000000..78806577a5 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/steps/Authentication.tsx @@ -0,0 +1,35 @@ +import { useFormikContext } from 'formik'; + +import { FormSection } from '@@/form-components/FormSection'; + +import { GitAuthentication } from '../../components/GitAuthentication'; +import { FormValues } from '../type'; + +export function Authentication() { + const { values, setValues, errors } = useFormikContext(); + + if (values.type !== 'git') { + return null; + } + + const { authentication } = values.git; + + return ( + + + setValues((old) => ({ + ...old, + git: { + ...old.git, + authentication: { ...old.git.authentication, ...changed }, + }, + })) + } + toggleDataCy="git-auth-toggle" + /> + + ); +} diff --git a/app/react/portainer/gitops/sources/CreateView/steps/ConfigureGit.tsx b/app/react/portainer/gitops/sources/CreateView/steps/ConfigureGit.tsx new file mode 100644 index 0000000000..4f5b255f09 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/steps/ConfigureGit.tsx @@ -0,0 +1,53 @@ +import { useFormikContext } from 'formik'; + +import { Input } from '@@/form-components/Input'; +import { FormControl } from '@@/form-components/FormControl'; +import { SwitchField } from '@@/form-components/SwitchField'; + +import { FormValues } from '../type'; + +import { Authentication } from './Authentication'; +import { ConnectionTest } from './ConnectionTest'; + +export function ConfigureGit() { + const { values, setFieldValue, errors } = useFormikContext(); + + if (values.type !== 'git') { + return null; + } + + return ( +
+ + setFieldValue('git.url', value)} + /> + + + setFieldValue('git.tlsSkipVerify', value)} + tooltip="Enabling this will allow skipping TLS validation for any self-signed certificate." + data-cy="tls-skip-verify" + /> + + + + +
+ ); +} diff --git a/app/react/portainer/gitops/sources/CreateView/steps/ConfigureHelm.tsx b/app/react/portainer/gitops/sources/CreateView/steps/ConfigureHelm.tsx new file mode 100644 index 0000000000..0aff44cb44 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/steps/ConfigureHelm.tsx @@ -0,0 +1,3 @@ +export function ConfigureHelm() { + return <>Helm Panel; +} diff --git a/app/react/portainer/gitops/sources/CreateView/steps/ConfigureRegistry.tsx b/app/react/portainer/gitops/sources/CreateView/steps/ConfigureRegistry.tsx new file mode 100644 index 0000000000..177b9c4ebd --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/steps/ConfigureRegistry.tsx @@ -0,0 +1,3 @@ +export function ConfigureRegistry() { + return <>Registry panel; +} diff --git a/app/react/portainer/gitops/sources/CreateView/steps/ConfigureStep.tsx b/app/react/portainer/gitops/sources/CreateView/steps/ConfigureStep.tsx new file mode 100644 index 0000000000..f8418b18e1 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/steps/ConfigureStep.tsx @@ -0,0 +1,70 @@ +import { useFormikContext } from 'formik'; +import { ComponentType } from 'react'; + +import { Widget } from '@@/Widget'; +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; + +import { FormValues } from '../type'; +import { validationSchema } from '../validation'; + +import { ConfigureGit } from './ConfigureGit'; +import { ConfigureHelm } from './ConfigureHelm'; +import { ConfigureRegistry } from './ConfigureRegistry'; + +const panels: Record< + FormValues['type'], + { title: string; component: ComponentType } +> = { + git: { title: 'Git Repository', component: ConfigureGit }, + helm: { title: 'Helm Repository', component: ConfigureHelm }, + registry: { title: 'OCI Registry', component: ConfigureRegistry }, +}; + +export function ConfigureStep() { + const { values } = useFormikContext(); + + const { title, component: ConfigurePanel } = panels[values.type]; + + return ( + <> + + + + + + + ); +} + +export function validateConfigureStep() { + return validationSchema().pick(['name', 'git']); +} + +function SharedFields() { + const { values, errors, setFieldValue } = useFormikContext(); + + return ( +
+ + setFieldValue('name', value)} + /> + +
+ ); +} diff --git a/app/react/portainer/gitops/sources/CreateView/steps/ConnectionTest.test.tsx b/app/react/portainer/gitops/sources/CreateView/steps/ConnectionTest.test.tsx new file mode 100644 index 0000000000..2b6464c920 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/steps/ConnectionTest.test.tsx @@ -0,0 +1,135 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { Formik } from 'formik'; +import { http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; + +import { FormValues } from '../type'; + +import { ConnectionTest } from './ConnectionTest'; + +const baseGitValues: FormValues['git'] = { + url: 'https://github.com/org/repo.git', + tlsSkipVerify: false, + connectionOk: false, + authentication: { + authEnabled: false, + }, +}; + +const invalidGitValues: FormValues['git'] = { + url: '', + tlsSkipVerify: false, + connectionOk: false, + authentication: { + authEnabled: false, + }, +}; + +function renderConnectionTest(gitValues: FormValues['git']) { + const initialValues: FormValues = { + name: 'test-source', + type: 'git', + git: gitValues, + }; + + const Wrapped = withTestQueryProvider(ConnectionTest); + + return render( + {}}> + + + ); +} + +describe('ConnectionTest', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders nothing when git URL is empty', () => { + renderConnectionTest(invalidGitValues); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('shows "Testing connection…" while debounce is pending, then success', async () => { + server.use( + http.post('/api/gitops/sources/test', () => + HttpResponse.json({ success: true }) + ) + ); + + renderConnectionTest(baseGitValues); + + expect(screen.getByText('Testing connection...')).toBeInTheDocument(); + expect(screen.queryByText('Connection successful')).not.toBeInTheDocument(); + + // Advance past the debounce + await vi.advanceTimersByTimeAsync(600); + + await waitFor(() => { + expect(screen.getByText('Connection successful')).toBeVisible(); + }); + expect(screen.queryByText('Testing connection...')).not.toBeInTheDocument(); + }); + + it('shows success alert when gitOpsSourcesTest returns success:true', async () => { + server.use( + http.post('/api/gitops/sources/test', () => + HttpResponse.json({ success: true }) + ) + ); + + renderConnectionTest(baseGitValues); + + await vi.advanceTimersByTimeAsync(600); + + await waitFor(() => { + expect(screen.getByText('Connection successful')).toBeVisible(); + }); + }); + + it('shows failure alert when gitOpsSourcesTest returns success:false', async () => { + server.use( + http.post('/api/gitops/sources/test', () => + HttpResponse.json({ success: false, error: 'Repository not found' }) + ) + ); + + renderConnectionTest(baseGitValues); + + await vi.advanceTimersByTimeAsync(600); + + await waitFor(() => { + expect(screen.getByText('Repository not found')).toBeVisible(); + }); + }); + + it('shows failure alert when the API returns an error', async () => { + const restoreConsole = suppressConsoleLogs(); + + server.use( + http.post('/api/gitops/sources/test', () => + HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }) + ) + ); + + renderConnectionTest(baseGitValues); + + await vi.advanceTimersByTimeAsync(600); + + await waitFor(() => { + expect(screen.getByText('Connection failed')).toBeVisible(); + }); + + restoreConsole(); + }); +}); diff --git a/app/react/portainer/gitops/sources/CreateView/steps/ConnectionTest.tsx b/app/react/portainer/gitops/sources/CreateView/steps/ConnectionTest.tsx new file mode 100644 index 0000000000..61d52a33a5 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/steps/ConnectionTest.tsx @@ -0,0 +1,64 @@ +import { useEffect } from 'react'; +import { useFormikContext } from 'formik'; +import { isEqual } from 'lodash'; + +import { useDebouncedValue } from '@/react/hooks/useDebouncedValue'; + +import { Alert } from '@@/Alert'; + +import { FormValues, gitFormValuesToTestPayload } from '../type'; +import { useTestSourceConnection } from '../useTestSourceConnection'; +import { validateGitConnection } from '../validation'; + +export function ConnectionTest() { + const { values, setFieldValue } = useFormikContext(); + const { git } = values; + + const livePayload = validateGitConnection().isValidSync(git) + ? gitFormValuesToTestPayload(git) + : undefined; + + const debouncedPayload = useDebouncedValue(livePayload); + const query = useTestSourceConnection(debouncedPayload); + + const settled = isEqual(debouncedPayload, livePayload) && !query.isFetching; + const connectionOk = settled && query.data?.success === true; + + useEffect(() => { + setFieldValue('git.connectionOk', connectionOk); + }, [connectionOk, setFieldValue]); + + if (!livePayload) { + return null; + } + + if (!settled) { + return ( + + Checking that Portainer can reach the repository. + + ); + } + + if (query.isError) { + return ( + + Unable to test the connection. Please try again. + + ); + } + + if (query.data?.success) { + return ( + + Portainer reached the repository with these details. + + ); + } + + return ( + + {query.data?.error || 'Unable to reach the repository.'} + + ); +} diff --git a/app/react/portainer/gitops/sources/CreateView/steps/TypeSelectStep.tsx b/app/react/portainer/gitops/sources/CreateView/steps/TypeSelectStep.tsx new file mode 100644 index 0000000000..73d44b999e --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/steps/TypeSelectStep.tsx @@ -0,0 +1,34 @@ +import { useFormikContext } from 'formik'; + +import { Widget } from '@@/Widget'; +import { BoxSelector } from '@@/BoxSelector'; + +import { FormValues } from '../type'; +import { validationSchema } from '../validation'; + +import { sourceTypeOptions } from './typeSelectOptions'; + +export function TypeSelectStep() { + const { values, setFieldValue } = useFormikContext(); + + return ( + <> + + + setFieldValue('type', type)} + radioName="source-type-selector" + options={sourceTypeOptions} + /> + + + ); +} + +export function validateTypeSelectStep() { + return validationSchema().pick(['type']); +} diff --git a/app/react/portainer/gitops/sources/CreateView/steps/typeSelectOptions.tsx b/app/react/portainer/gitops/sources/CreateView/steps/typeSelectOptions.tsx new file mode 100644 index 0000000000..8587a03a9b --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/steps/typeSelectOptions.tsx @@ -0,0 +1,80 @@ +import { Cylinder, Radio } from 'lucide-react'; + +import GitIcon from '@/assets/ico/git.svg?c'; +import HelmIcon from '@/assets/ico/helm.svg?c'; + +import { BoxSelectorOption } from '@@/BoxSelector'; + +const git: BoxSelectorOption<'git'> = { + id: 'git', + label: 'Git Repository', + value: 'git', + icon: GitIcon, + iconType: 'logo', + description: Description({ + txt: 'Connect to a Git repository (GitHub, Gitlab, Bitbucket, etc) to pull configurations files, manifests, and other deployment assets.', + items: ['Branch & tag selection', 'SSH or HTTPS auth', 'Webhook support'], + }), +}; + +const helm: BoxSelectorOption<'helm'> = { + id: 'helm', + value: 'helm', + label: 'Helm Repository', + icon: HelmIcon, + iconType: 'logo', + description: Description({ + txt: 'Connect to a Helm chart repository to deploy and manage Helm charts across your environments.', + items: [ + 'Chart versioning', + 'Values customization', + 'Dependency management', + ], + }), + disabled: true, +}; + +const registry: BoxSelectorOption<'registry'> = { + id: 'registry', + value: 'registry', + label: 'OCI Registry', + icon: Radio, + description: Description({ + txt: 'Connect to an OCI-compliant container registry to pull artifacts and container images.', + items: [ + 'Image tags & digests', + 'Private registry auth', + 'Artifact support', + ], + }), + disabled: true, +}; + +const s3: BoxSelectorOption<'s3'> = { + id: 's3', + value: 's3', + label: 'S3 Bucket', + icon: Cylinder, + description: Description({ + txt: 'Connect to an S3-compatible bucket (AWS, S3, MinIO, etc) to fetch configuration files and assets.', + items: ['Prefix filtering', 'IAM or key auth', 'Version support'], + }), + disabled: true, +}; + +export const sourceTypeOptions = [git, helm, registry, s3]; + +function Description({ txt, items }: { txt: string; items: string[] }) { + return ( +
+ {txt} +
+
    + {items.map((v, k) => ( +
  • {v}
  • + ))} +
+
+
+ ); +} diff --git a/app/react/portainer/gitops/sources/CreateView/type.test.ts b/app/react/portainer/gitops/sources/CreateView/type.test.ts new file mode 100644 index 0000000000..4a7019abd6 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/type.test.ts @@ -0,0 +1,115 @@ +import { formValuesToCreatePayload, gitFormValuesToTestPayload } from './type'; + +const baseGit = { + url: 'https://github.com/org/repo.git', + tlsSkipVerify: false, + connectionOk: false, +}; + +describe('formValuesToCreatePayload', () => { + it('populates authentication when authEnabled with username and password', () => { + const payload = formValuesToCreatePayload({ + name: 'my-source', + type: 'git', + git: { + ...baseGit, + authentication: { + authEnabled: true, + username: 'alice', + password: 'secret', + }, + }, + }); + + expect(payload.git.authentication).toEqual({ + username: 'alice', + password: 'secret', + }); + }); + + it('omits authentication when authEnabled is false', () => { + const payload = formValuesToCreatePayload({ + name: 'my-source', + type: 'git', + git: { + ...baseGit, + authentication: { authEnabled: false }, + }, + }); + + expect(payload.git.authentication).toBeUndefined(); + }); + + it('omits authentication when authEnabled but username is missing', () => { + const payload = formValuesToCreatePayload({ + name: 'my-source', + type: 'git', + git: { + ...baseGit, + authentication: { + authEnabled: true, + password: 'secret', + }, + }, + }); + + expect(payload.git.authentication).toBeUndefined(); + }); + + it('omits authentication when authEnabled but password is missing', () => { + const payload = formValuesToCreatePayload({ + name: 'my-source', + type: 'git', + git: { + ...baseGit, + authentication: { + authEnabled: true, + username: 'alice', + }, + }, + }); + + expect(payload.git.authentication).toBeUndefined(); + }); + + it('does not include connectionOk in the create payload', () => { + const payload = formValuesToCreatePayload({ + name: 'my-source', + type: 'git', + git: { + ...baseGit, + connectionOk: true, + authentication: { authEnabled: false }, + }, + }); + + expect(payload.git).not.toHaveProperty('connectionOk'); + }); +}); + +describe('gitFormValuesToTestPayload', () => { + it('populates authentication when authEnabled with username and password', () => { + const payload = gitFormValuesToTestPayload({ + ...baseGit, + authentication: { + authEnabled: true, + username: 'alice', + password: 'secret', + }, + }); + + expect(payload.authentication).toEqual({ + username: 'alice', + password: 'secret', + }); + }); + + it('omits authentication when authEnabled is false', () => { + const payload = gitFormValuesToTestPayload({ + ...baseGit, + authentication: { authEnabled: false }, + }); + + expect(payload.authentication).toBeUndefined(); + }); +}); diff --git a/app/react/portainer/gitops/sources/CreateView/type.ts b/app/react/portainer/gitops/sources/CreateView/type.ts new file mode 100644 index 0000000000..44d14b7b6e --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/type.ts @@ -0,0 +1,64 @@ +import { + type SourcesGitAuthenticationPayload, + type SourcesGitSourceCreatePayload, +} from '@api/types.gen'; + +import { CreateSourcePayload } from './useSourceCreateMutation'; + +type GitFormValues = { + url: string; + authentication: { + authEnabled: boolean; + username?: string; + password?: string; + }; + tlsSkipVerify?: boolean; + /** Mirrors the connection-test result; not sent in the create payload. */ + connectionOk: boolean; +}; + +export const FormValueTypes = ['git', 'registry', 'helm'] as const; + +export type FormValues = { + name: string; + type: (typeof FormValueTypes)[number]; + git: GitFormValues; +}; + +export function formValuesToCreatePayload({ + name, + type, + git: { authentication, tlsSkipVerify, url }, +}: FormValues): CreateSourcePayload { + return { + type, + git: { + name, + tlsSkipVerify, + url, + authentication: buildAuthPayload(authentication), + }, + }; +} + +export function gitFormValuesToTestPayload({ + authentication, + url, + tlsSkipVerify, +}: GitFormValues): SourcesGitSourceCreatePayload { + return { + url, + tlsSkipVerify, + authentication: buildAuthPayload(authentication), + }; +} + +function buildAuthPayload( + auth: GitFormValues['authentication'] +): SourcesGitAuthenticationPayload | undefined { + const { authEnabled, username, password } = auth; + if (!authEnabled || !username || !password) { + return undefined; + } + return { username, password }; +} diff --git a/app/react/portainer/gitops/sources/CreateView/useSourceCreateMutation.ts b/app/react/portainer/gitops/sources/CreateView/useSourceCreateMutation.ts new file mode 100644 index 0000000000..d47a7fcfe4 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/useSourceCreateMutation.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { gitOpsSourcesCreateGit } from '@api/sdk.gen'; +import { type SourcesGitSourceCreatePayload } from '@api/types.gen'; + +import { withError, withInvalidate } from '@/react-tools/react-query'; + +import { sourceQueryKeys } from '../queries/query-keys'; + +export type CreateSourcePayload = { + type: 'git' | 'registry' | 'helm'; + git: SourcesGitSourceCreatePayload; +}; + +export function useCreateSourceMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createSource, + ...withError('Unable to create source'), + ...withInvalidate(queryClient, [sourceQueryKeys.all]), + }); +} + +async function createSource(payload: CreateSourcePayload) { + const { data } = await gitOpsSourcesCreateGit({ body: payload.git }); + return data; +} diff --git a/app/react/portainer/gitops/sources/CreateView/useTestSourceConnection.ts b/app/react/portainer/gitops/sources/CreateView/useTestSourceConnection.ts new file mode 100644 index 0000000000..a275827fb8 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/useTestSourceConnection.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query'; + +import { gitOpsSourcesTest } from '@api/sdk.gen'; +import { type SourcesGitSourceCreatePayload } from '@api/types.gen'; + +import { strToHash } from '@/react/utils/hash'; + +import { sourceQueryKeys } from '../queries/query-keys'; + +export function useTestSourceConnection( + payload: SourcesGitSourceCreatePayload | undefined +) { + const payloadHashedPassword = { + ...payload, + + authentication: { + ...payload?.authentication, + password: null, + passwordHash: payload?.authentication?.password + ? strToHash(payload.authentication.password) + : undefined, + }, + }; + + return useQuery({ + queryKey: [ + ...sourceQueryKeys.all, + 'connection-test', + payloadHashedPassword, + ], + queryFn: async () => { + if (!payload) { + throw new Error('Connection details are required'); + } + const { data } = await gitOpsSourcesTest({ body: payload }); + return data; + }, + enabled: !!payload, + retry: false, + refetchOnWindowFocus: false, + staleTime: 5000, + }); +} diff --git a/app/react/portainer/gitops/sources/CreateView/validation.test.ts b/app/react/portainer/gitops/sources/CreateView/validation.test.ts new file mode 100644 index 0000000000..27b5bf9170 --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/validation.test.ts @@ -0,0 +1,143 @@ +import { validateGitConnection, validationSchema } from './validation'; + +const baseAuth = { + authEnabled: false, +}; + +const validGitValues = { + url: 'https://github.com/org/repo.git', + tlsSkipVerify: false, + connectionOk: true, + authentication: baseAuth, +}; + +describe('validateGitConnection (pick schema — no connectionOk)', () => { + it('passes when url is valid and auth disabled', async () => { + const schema = validateGitConnection(); + await expect( + schema.isValid({ + url: 'https://github.com/org/repo.git', + tlsSkipVerify: false, + authentication: baseAuth, + }) + ).resolves.toBe(true); + }); + + it('does not require connectionOk', async () => { + const schema = validateGitConnection(); + await expect( + schema.isValid({ + url: 'https://github.com/org/repo.git', + authentication: baseAuth, + }) + ).resolves.toBe(true); + }); + + it('rejects empty URL', async () => { + const schema = validateGitConnection(); + await expect( + schema.isValid({ + url: '', + authentication: baseAuth, + }) + ).resolves.toBe(false); + }); + + it('rejects localhost URL', async () => { + const schema = validateGitConnection(); + await expect( + schema.isValid({ + url: 'http://localhost/repo.git', + authentication: baseAuth, + }) + ).resolves.toBe(false); + }); + + it('accepts a normal https URL', async () => { + const schema = validateGitConnection(); + await expect( + schema.isValid({ + url: 'https://gitlab.example.com/org/repo.git', + authentication: baseAuth, + }) + ).resolves.toBe(true); + }); +}); + +describe('validationSchema git.authentication', () => { + it('requires username and password when authEnabled is true', async () => { + const schema = validationSchema(); + const result = await schema.isValid({ + name: 'src', + type: 'git', + git: { + ...validGitValues, + authentication: { ...baseAuth, authEnabled: true }, + }, + }); + expect(result).toBe(false); + }); + + it('passes when authEnabled is false and no credentials provided', async () => { + const schema = validationSchema(); + const result = await schema.isValid({ + name: 'src', + type: 'git', + git: validGitValues, + }); + expect(result).toBe(true); + }); + + it('passes when authEnabled is true and credentials are provided', async () => { + const schema = validationSchema(); + const result = await schema.isValid({ + name: 'src', + type: 'git', + git: { + ...validGitValues, + authentication: { + ...baseAuth, + authEnabled: true, + username: 'alice', + password: 'secret', + }, + }, + }); + expect(result).toBe(true); + }); +}); + +describe('validationSchema full git (requires connectionOk)', () => { + it('fails when connectionOk is false', async () => { + const schema = validationSchema(); + const result = await schema.isValid({ + name: 'src', + type: 'git', + git: { + ...validGitValues, + connectionOk: false, + }, + }); + expect(result).toBe(false); + }); + + it('passes when connectionOk is true', async () => { + const schema = validationSchema(); + const result = await schema.isValid({ + name: 'src', + type: 'git', + git: validGitValues, + }); + expect(result).toBe(true); + }); + + it('requires name', async () => { + const schema = validationSchema(); + const result = await schema.isValid({ + name: '', + type: 'git', + git: validGitValues, + }); + expect(result).toBe(false); + }); +}); diff --git a/app/react/portainer/gitops/sources/CreateView/validation.tsx b/app/react/portainer/gitops/sources/CreateView/validation.tsx new file mode 100644 index 0000000000..1515dd919b --- /dev/null +++ b/app/react/portainer/gitops/sources/CreateView/validation.tsx @@ -0,0 +1,51 @@ +import { bool, mixed, object, SchemaOf, string } from 'yup'; + +import { isValidUrl } from '@@/form-components/validate-url'; + +import { FormValues, FormValueTypes } from './type'; + +export function validationSchema(): SchemaOf { + return object({ + name: string().required('Name is required.'), + type: mixed() + .oneOf([...FormValueTypes]) + .required() + .default('git'), + git: validateGit(), + }); +} + +export function validateGitConnection() { + return validateGit().pick(['url', 'authentication', 'tlsSkipVerify']); +} + +function validateGit(): SchemaOf { + return object({ + authentication: object({ + authEnabled: bool().required().default(false), + username: string().when('authEnabled', { + is: true, + then: string().required('Username is required'), + }), + password: string().when('authEnabled', { + is: true, + then: string().required('Password is required'), + }), + }), + url: string() + .required('Repository URL is required.') + .test( + 'valid repository URL', + 'The repository URL must be a valid URL (localhost cannot be used)', + (value) => + isValidUrl( + value, + (url) => !!url.hostname && url.hostname !== 'localhost' + ) + ), + tlsSkipVerify: bool(), + connectionOk: bool() + .oneOf([true], 'The connection test must succeed before continuing.') + .required(), + }); +} diff --git a/app/react/portainer/gitops/sources/ItemView/ItemView.tsx b/app/react/portainer/gitops/sources/ItemView/ItemView.tsx index 3de2b90aec..3305084d07 100644 --- a/app/react/portainer/gitops/sources/ItemView/ItemView.tsx +++ b/app/react/portainer/gitops/sources/ItemView/ItemView.tsx @@ -79,11 +79,11 @@ function PageContent({ source }: { source: SourceDetail }) { name: ( <> Workflows{' '} - + ), icon: GitCommit, - widget: , + widget: , selectedTabParam: 'workflows', }, ], diff --git a/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/EditAuthWidget.tsx b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/EditAuthWidget.tsx index e015261074..2ab8a5656d 100644 --- a/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/EditAuthWidget.tsx +++ b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/EditAuthWidget.tsx @@ -2,15 +2,13 @@ import { LockIcon } from 'lucide-react'; import { useFormikContext } from 'formik'; import { Card } from '@@/primitives/Card'; -import { SwitchField } from '@@/form-components/SwitchField'; -import { FormControl } from '@@/form-components/FormControl'; -import { Input } from '@@/form-components/Input'; + +import { GitAuthentication } from '../../../components/GitAuthentication'; import { SettingsFormValues } from './types'; export function EditAuthWidget() { - const { values, errors, setFieldValue } = - useFormikContext(); + const { values, errors, setValues } = useFormikContext(); return ( @@ -20,44 +18,19 @@ export function EditAuthWidget() { subtitle="Choose how Portainer authenticates to this source" /> -
- setFieldValue('authEnabled', checked)} - data-cy="source-auth-enabled" - /> -
- - {values.authEnabled && ( - <> - - setFieldValue('username', e.target.value)} - data-cy="component-gitUsernameInput" - /> - - - - setFieldValue('password', e.target.value)} - data-cy="component-gitPasswordInput" - /> - - - )} + + setValues((oldValues) => ({ ...oldValues, ...changed })) + } + toggleDataCy="source-auth-enabled" + />
); diff --git a/app/react/portainer/gitops/sources/ListView/ListView.tsx b/app/react/portainer/gitops/sources/ListView/ListView.tsx index df1983f7c9..7132143046 100644 --- a/app/react/portainer/gitops/sources/ListView/ListView.tsx +++ b/app/react/portainer/gitops/sources/ListView/ListView.tsx @@ -5,6 +5,7 @@ import { SortOption, } from '@@/SortableList/SortableList'; import { StatusSummaryBar } from '@@/StatusSummaryBar/StatusSummaryBar'; +import { AddButton } from '@@/buttons'; import { useSources } from '../queries/useSources'; import { useSourcesSummary } from '../queries/useSourcesSummary'; @@ -72,7 +73,11 @@ export function ListView() { return ( <> - + +
+ Add new +
+
) => void; + toggleDataCy: string; +}; + +export function GitAuthentication({ + values, + errors, + isEditing, + onChange, + toggleDataCy, +}: Props) { + return ( + <> +
+ onChange({ authEnabled: value })} + data-cy={toggleDataCy} + /> +
+ {values.authEnabled && ( + + )} + + ); +} diff --git a/app/react/portainer/gitops/sources/components/PasswordField.tsx b/app/react/portainer/gitops/sources/components/PasswordField.tsx new file mode 100644 index 0000000000..690703261a --- /dev/null +++ b/app/react/portainer/gitops/sources/components/PasswordField.tsx @@ -0,0 +1,41 @@ +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; + +type Props = { + value: string; + onChange: (value: string) => void; + label: string; + tooltip?: string; + error?: string; + required?: boolean; +}; + +export function PasswordField({ + value, + onChange, + label, + tooltip, + error, + required, +}: Props) { + return ( + + onChange(e.target.value)} + data-cy="component-gitPasswordInput" + /> + + ); +} diff --git a/app/react/portainer/gitops/sources/components/ProviderCredentialFields.tsx b/app/react/portainer/gitops/sources/components/ProviderCredentialFields.tsx new file mode 100644 index 0000000000..053409733e --- /dev/null +++ b/app/react/portainer/gitops/sources/components/ProviderCredentialFields.tsx @@ -0,0 +1,40 @@ +import { PasswordField } from './PasswordField'; +import { UsernameField } from './UsernameField'; + +export type CredentialValues = { + username?: string; + password?: string; +}; + +type Props = { + values: CredentialValues; + isEditing?: boolean; + errors?: { username?: string; password?: string }; + onChange: (values: Partial) => void; +}; + +export function ProviderCredentialFields({ + values: { username, password }, + isEditing = false, + errors, + onChange, +}: Props) { + return ( +
+ onChange({ username: value })} + error={errors?.username} + /> + + onChange({ password: value })} + label="Personal Access Token" + tooltip="Provide a personal access token or password" + error={errors?.password} + required={!isEditing} + /> +
+ ); +} diff --git a/app/react/portainer/gitops/sources/components/UsernameField.tsx b/app/react/portainer/gitops/sources/components/UsernameField.tsx new file mode 100644 index 0000000000..c007ed0d7c --- /dev/null +++ b/app/react/portainer/gitops/sources/components/UsernameField.tsx @@ -0,0 +1,30 @@ +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; + +type Props = { + value: string; + onChange: (value: string) => void; + tooltip?: string; + error?: string; +}; + +export function UsernameField({ value, onChange, tooltip, error }: Props) { + return ( + + onChange(e.target.value)} + data-cy="component-gitUsernameInput" + placeholder="git username" + /> + + ); +} diff --git a/app/react/portainer/gitops/sources/queries/useTestSourceConnectionMutation.ts b/app/react/portainer/gitops/sources/queries/useTestSourceConnectionMutation.ts index de4c05285a..c7c51c8552 100644 --- a/app/react/portainer/gitops/sources/queries/useTestSourceConnectionMutation.ts +++ b/app/react/portainer/gitops/sources/queries/useTestSourceConnectionMutation.ts @@ -1,7 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { type SourcesConnectionTestResult } from '@api/types.gen'; -import { gitOpsSourcesTestGit } from '@api/sdk.gen'; +import { gitOpsSourcesTestById } from '@api/sdk.gen'; import { withError } from '@/react-tools/react-query'; @@ -15,7 +15,7 @@ async function testSourceConnection( id: Source['id'], payload: UpdateSourcePayload ): Promise { - const { data } = await gitOpsSourcesTestGit({ path: { id }, body: payload }); + const { data } = await gitOpsSourcesTestById({ path: { id }, body: payload }); return data; } diff --git a/app/react/portainer/gitops/sources/types.ts b/app/react/portainer/gitops/sources/types.ts index a43030c3cb..0b0b8a3a8a 100644 --- a/app/react/portainer/gitops/sources/types.ts +++ b/app/react/portainer/gitops/sources/types.ts @@ -12,7 +12,6 @@ export interface Source { url: string; status: SourceStatus; error?: string; - provider?: number; usedBy: number; environments: number; lastSync: number; diff --git a/app/react/test-utils/withTestQuery.tsx b/app/react/test-utils/withTestQuery.tsx index fbbec6910c..136e686bbf 100644 --- a/app/react/test-utils/withTestQuery.tsx +++ b/app/react/test-utils/withTestQuery.tsx @@ -1,14 +1,21 @@ import { ComponentType } from 'react'; -import { MutationCache, QueryClient } from '@tanstack/react-query'; +import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'; import { withReactQuery } from '@/react-tools/withReactQuery'; export function withTestQueryProvider( WrappedComponent: ComponentType, - { onMutationError }: { onMutationError?(error: unknown): void } = {} + { + onMutationError, + onQueryError, + }: { + onMutationError?(error: unknown): void; + onQueryError?(error: unknown): void; + } = {} ) { const testQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, + queryCache: new QueryCache({ onError: onQueryError }), mutationCache: new MutationCache({ onError: onMutationError, }), diff --git a/app/react/utils/context.tsx b/app/react/utils/context.tsx new file mode 100644 index 0000000000..6b39ef2bdc --- /dev/null +++ b/app/react/utils/context.tsx @@ -0,0 +1,33 @@ +import { + PropsWithChildren, + createContext as reactCreateContext, + useContext as reactUseContext, +} from 'react'; + +/** + * Reduce the boilerplate code to create a custom context and provider hook + * @param displayName Display name of the Context in react inspector + * @returns A Provider and custom hook for this context + */ +export function createContext(displayName: string) { + const Context = reactCreateContext(null); + Context.displayName = displayName; + + return { Provider, useContext }; + + function Provider({ + children, + context, + }: PropsWithChildren<{ context: TContext }>) { + return {children}; + } + + function useContext() { + const context = reactUseContext(Context); + if (context === null) { + throw new Error(`should be nested under Provider - ${displayName}`); + } + + return context; + } +} diff --git a/app/react/utils/hash.test.ts b/app/react/utils/hash.test.ts new file mode 100644 index 0000000000..cc4c2ed8f4 --- /dev/null +++ b/app/react/utils/hash.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { strToHash } from './hash'; + +describe('strToHash', () => { + it('returns the same value for the same input', () => { + expect(strToHash('password123')).toBe(strToHash('password123')); + }); + + it('returns different values for different inputs', () => { + expect(strToHash('password123')).not.toBe(strToHash('password456')); + }); +}); diff --git a/app/react/utils/hash.ts b/app/react/utils/hash.ts new file mode 100644 index 0000000000..fb1a815205 --- /dev/null +++ b/app/react/utils/hash.ts @@ -0,0 +1,8 @@ +/** Non-cryptographic hash for cache keys and display purposes — do not use for security. */ +export function strToHash(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return hash; +}