feat(gitcredential): remove GitCredential BE-12919 (#2838)

This commit is contained in:
andres-portainer
2026-06-11 18:53:24 -03:00
committed by GitHub
parent f3f0ca8e21
commit 0da42c01b6
59 changed files with 277 additions and 1279 deletions
-15
View File
@@ -24,7 +24,6 @@ type legacyGitAuthentication struct {
Password string
Provider int `json:",omitempty"`
AuthorizationType int `json:",omitempty"`
GitCredentialID int
}
func (lrc *legacyRepoConfig) toRepoConfig() *gittypes.RepoConfig {
@@ -41,12 +40,6 @@ func (lrc *legacyRepoConfig) toRepoConfig() *gittypes.RepoConfig {
}
if lrc.Authentication != nil {
if lrc.Authentication.GitCredentialID != 0 {
log.Warn().
Int("git_credential_id", lrc.Authentication.GitCredentialID).
Msg("stack has a GitCredentialID reference which is not supported in CE; credential reference will be dropped during migration")
}
cfg.Authentication = &gittypes.GitAuthentication{
Username: lrc.Authentication.Username,
Password: lrc.Authentication.Password,
@@ -213,14 +206,6 @@ func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error {
TLSSkipVerify: t.GitConfig.TLSSkipVerify,
}
if cfg.Authentication != nil && cfg.Authentication.GitCredentialID != 0 {
log.Warn().
Int("git_credential_id", cfg.Authentication.GitCredentialID).
Msg("custom template has a GitCredentialID reference which is not supported in CE; credential reference will be dropped during migration")
cfg.Authentication.GitCredentialID = 0
}
key := gitSourceKey(cfg)
var newSrcID portainer.SourceID
-4
View File
@@ -96,8 +96,4 @@ type GitAuthentication struct {
Password string
Provider GitProvider `json:",omitempty"`
AuthorizationType GitCredentialAuthType `json:",omitempty"`
// Git credentials identifier when the value is not 0
// When the value is 0, Username and Password are set without using saved credential
// This is introduced since 2.15.0
GitCredentialID int `example:"0"`
}
+2 -1
View File
@@ -10,6 +10,7 @@ import (
)
// gitSourceStore is the minimal intersection of CE and EE DataStoreTx that these functions need.
// Both EE and CE DataStoreTx satisfy it, even though they are incompatible as full interface types.
type gitSourceStore interface {
Workflow() dataservices.WorkflowService
Source() dataservices.SourceService
@@ -345,7 +346,7 @@ func gitAuthMatches(a, b *gittypes.GitAuthentication) bool {
return false
}
return a.Username == b.Username && a.Password == b.Password && a.GitCredentialID == b.GitCredentialID
return a.Username == b.Username && a.Password == b.Password
}
// ValidateUniqueSource validates there are no other sources with the same URL and credentials.
@@ -751,6 +751,186 @@ func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) {
require.Equal(t, "hash-2", wf.Artifacts[1].Files[0].Hash)
}
func TestUpdateArtifactFileForStack_MultipleArtifactsOnlyMatchingUpdated(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
var srcID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{
{StackID: 10, Files: []portainer.ArtifactFile{{SourceID: srcID, Hash: "hash-10"}}},
{StackID: 20, Files: []portainer.ArtifactFile{{SourceID: srcID, Hash: "hash-20"}}},
},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return UpdateArtifactFileForStack(tx, workflowID, 10, srcID, func(a *portainer.ArtifactFile) {
a.Hash = "updated-hash-10"
})
})
require.NoError(t, err)
wf, err := store.Workflow().Read(workflowID)
require.NoError(t, err)
require.Equal(t, "updated-hash-10", wf.Artifacts[0].Files[0].Hash)
require.Equal(t, "hash-20", wf.Artifacts[1].Files[0].Hash)
}
func TestUpdateArtifactFileForEdgeStack_MultipleArtifactsOnlyMatchingUpdated(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
var srcID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{
{EdgeStackID: 10, Files: []portainer.ArtifactFile{{SourceID: srcID, Hash: "hash-10"}}},
{EdgeStackID: 20, Files: []portainer.ArtifactFile{{SourceID: srcID, Hash: "hash-20"}}},
},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return UpdateArtifactFileForEdgeStack(tx, workflowID, 10, srcID, func(a *portainer.ArtifactFile) {
a.Hash = "updated-hash-10"
})
})
require.NoError(t, err)
wf, err := store.Workflow().Read(workflowID)
require.NoError(t, err)
require.Equal(t, "updated-hash-10", wf.Artifacts[0].Files[0].Hash)
require.Equal(t, "hash-20", wf.Artifacts[1].Files[0].Hash)
}
func TestGitSourceAndArtifactForStack_MultipleArtifactsReturnsCorrectOne(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
gitSrc := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"},
}
err := tx.Source().Create(gitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{
{StackID: 10, Files: []portainer.ArtifactFile{{SourceID: gitSrc.ID, Ref: "refs/heads/main", Hash: "hash-10"}}},
{StackID: 20, Files: []portainer.ArtifactFile{{SourceID: gitSrc.ID, Ref: "refs/heads/dev", Hash: "hash-20"}}},
},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
var src *portainer.Source
var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 20)
return txErr
})
require.NoError(t, err)
require.NotNil(t, src)
require.NotNil(t, file)
require.Equal(t, "refs/heads/dev", file.Ref)
require.Equal(t, "hash-20", file.Hash)
}
func TestGitSourceAndArtifactForEdgeStack_MultipleArtifactsReturnsCorrectOne(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
gitSrc := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-edge-repo"},
}
err := tx.Source().Create(gitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{
{EdgeStackID: 10, Files: []portainer.ArtifactFile{{SourceID: gitSrc.ID, Ref: "refs/heads/main", Hash: "hash-10"}}},
{EdgeStackID: 20, Files: []portainer.ArtifactFile{{SourceID: gitSrc.ID, Ref: "refs/heads/dev", Hash: "hash-20"}}},
},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
var src *portainer.Source
var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, workflowID, 20)
return txErr
})
require.NoError(t, err)
require.NotNil(t, src)
require.NotNil(t, file)
require.Equal(t, "refs/heads/dev", file.Ref)
require.Equal(t, "hash-20", file.Hash)
}
func TestMergeSourceAndFile_ConfigHashComesFromFileNotSource(t *testing.T) {
t.Parallel()
// ConfigHash must come from ArtifactFile.Hash, not src.Git.
// A Source shared by two stacks has one Git.ConfigHash field;
// if reads used it instead of ArtifactFile.Hash they would clobber each other.
src := &portainer.Source{
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
},
}
file := &portainer.ArtifactFile{
Hash: "artifact-hash",
}
cfg := MergeSourceAndFile(src, file)
require.NotNil(t, cfg)
require.Equal(t, "artifact-hash", cfg.ConfigHash)
}
func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
@@ -43,14 +43,10 @@ type customTemplateUpdatePayload struct {
// Use authentication to clone the Git repository
RepositoryAuthentication bool `example:"true"`
// Username used in basic authentication. Required when RepositoryAuthentication is true
// and RepositoryGitCredentialID is 0. Ignored if RepositoryAuthType is token
RepositoryUsername string `example:"myGitUsername"`
// Password used in basic authentication or token used in token authentication.
// Required when RepositoryAuthentication is true and RepositoryGitCredentialID is 0
// Required when RepositoryAuthentication is true
RepositoryPassword string `example:"myGitPassword"`
// GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication
// is true and RepositoryUsername/RepositoryPassword are not provided
RepositoryGitCredentialID int `example:"0"`
// Path to the Stack file inside the Git repository
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
// Content of stack file
@@ -43,10 +43,8 @@ type RepositoryConfigPayload struct {
// Use basic authentication to clone the Git repository
Authentication bool `example:"true"`
// Username used in basic authentication. Required when RepositoryAuthentication is true
// and RepositoryGitCredentialID is 0
Username string `example:"myGitUsername"`
// Password used in basic authentication. Required when RepositoryAuthentication is true
// and RepositoryGitCredentialID is 0
Password string `example:"myGitPassword"`
// TLSSkipVerify skips SSL verification when cloning the Git repository
TLSSkipVerify bool `example:"false"`
@@ -1,13 +1,8 @@
import { IFormController } from 'angular';
import { FormikErrors } from 'formik';
import { notifyError } from '@/portainer/services/notifications';
import { IAuthenticationService } from '@/portainer/services/types';
import { GitAuthModel } from '@/react/portainer/gitops/types';
import { gitAuthValidation } from '@/react/portainer/gitops/AuthFieldset';
import { GitCredential } from '@/react/portainer/account/git-credentials/types';
import { getGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { validateForm } from '@@/form-components/validate-form';
@@ -18,10 +13,6 @@ export default class GitFormAuthFieldsetController {
gitFormAuthFieldset?: IFormController;
gitCredentials: Array<GitCredential> = [];
Authentication: IAuthenticationService;
value?: GitAuthModel;
isAuthEdit: boolean;
@@ -29,12 +20,8 @@ export default class GitFormAuthFieldsetController {
onChange?: (value: GitAuthModel) => void;
/* @ngInject */
constructor(
$async: <T>(fn: () => Promise<T>) => Promise<T>,
Authentication: IAuthenticationService
) {
constructor($async: <T>(fn: () => Promise<T>) => Promise<T>) {
this.$async = $async;
this.Authentication = Authentication;
this.isAuthEdit = false;
this.handleChange = this.handleChange.bind(this);
@@ -65,7 +52,7 @@ export default class GitFormAuthFieldsetController {
);
this.errors = await validateForm<GitAuthModel>(
() => gitAuthValidation(this.gitCredentials, isAuthEdit, false),
() => gitAuthValidation(isAuthEdit, false),
value
);
if (this.errors && Object.keys(this.errors).length > 0) {
@@ -79,24 +66,11 @@ export default class GitFormAuthFieldsetController {
}
async $onInit() {
if (isBE) {
try {
// Only BE version support /gitcredentials
this.gitCredentials = await getGitCredentials(
this.Authentication.getUserDetails().ID
);
} catch (err) {
notifyError(
'Failure',
err as Error,
'Unable to retrieve user saved git credentials'
);
}
}
// this should never happen, but just in case
if (!this.value) {
throw new Error('GitFormController: value is required');
}
await this.runGitValidation(this.value, this.isAuthEdit);
}
}
@@ -3,11 +3,6 @@ import { FormikErrors } from 'formik';
import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types';
import { validateGitForm } from '@/react/portainer/gitops/GitForm';
import { notifyError } from '@/portainer/services/notifications';
import { IAuthenticationService } from '@/portainer/services/types';
import { getGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { GitCredential } from '@/react/portainer/account/git-credentials/types';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
export default class GitFormController {
errors?: FormikErrors<GitFormModel>;
@@ -16,10 +11,6 @@ export default class GitFormController {
gitForm?: IFormController;
gitCredentials: Array<GitCredential> = [];
Authentication: IAuthenticationService;
value?: GitFormModel;
onChange?: (value: GitFormModel) => void;
@@ -29,12 +20,8 @@ export default class GitFormController {
deployMethod?: DeployMethod;
/* @ngInject */
constructor(
$async: <T>(fn: () => Promise<T>) => Promise<T>,
Authentication: IAuthenticationService
) {
constructor($async: <T>(fn: () => Promise<T>) => Promise<T>) {
this.$async = $async;
this.Authentication = Authentication;
this.handleChange = this.handleChange.bind(this);
this.runGitFormValidation = this.runGitFormValidation.bind(this);
@@ -67,7 +54,6 @@ export default class GitFormController {
this.gitForm?.$setValidity('gitForm', true, this.gitForm);
this.errors = await validateGitForm(
this.gitCredentials,
value,
isCreatedFromCustomTemplate,
this.deployMethod
@@ -79,20 +65,6 @@ export default class GitFormController {
}
async $onInit() {
if (isBE) {
try {
this.gitCredentials = await getGitCredentials(
this.Authentication.getUserDetails().ID
);
} catch (err) {
notifyError(
'Failure',
err as Error,
'Unable to retrieve user saved git credentials'
);
}
}
// this should never happen, but just in case
if (!this.value) {
throw new Error('GitFormController: value is required');
@@ -5,8 +5,6 @@ import { updateGitStack } from '@/react/portainer/gitops/queries/useUpdateGitSta
import { updateGitStackSettings } from '@/react/portainer/gitops/queries/useUpdateGitStackSettings';
import { queryKeys } from '@/react/common/stacks/queries/query-keys';
import { transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { saveGitCredentialsIfNeeded } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
import { useCurrentUser } from '@/react/hooks/useUser';
import { withError } from '@/react-tools/react-query';
import { FormValues } from './types';
@@ -19,18 +17,12 @@ interface MutationArgs {
export function useUpdateGitStack(stack: Stack) {
const queryClient = useQueryClient();
const { user } = useCurrentUser();
return useMutation({
mutationFn: async ({
values,
repullImageAndRedeploy,
webhookId,
}: MutationArgs) => {
const resolvedAuth = await saveGitCredentialsIfNeeded(
user.Id,
values.git
);
const autoUpdate = transformAutoUpdateViewModel(
values.git.AutoUpdate,
webhookId
@@ -40,11 +32,9 @@ export function useUpdateGitStack(stack: Stack) {
RepositoryURL: values.git.RepositoryURL,
ConfigFilePath: values.git.ComposeFilePathInRepository,
RepositoryReferenceName: values.git.RepositoryReferenceName,
RepositoryAuthentication: resolvedAuth.RepositoryAuthentication,
RepositoryGitCredentialID: resolvedAuth.RepositoryGitCredentialID,
RepositoryUsername: resolvedAuth.RepositoryUsername,
RepositoryPassword: resolvedAuth.RepositoryPassword || undefined,
RepositoryAuthorizationType: resolvedAuth.RepositoryAuthorizationType,
RepositoryAuthentication: values.git.RepositoryAuthentication,
RepositoryUsername: values.git.RepositoryUsername,
RepositoryPassword: values.git.RepositoryPassword || undefined,
TLSSkipVerify: values.git.TLSSkipVerify,
AutoUpdate: autoUpdate,
AdditionalFiles: values.git.AdditionalFiles,
@@ -61,11 +51,9 @@ export function useUpdateGitStack(stack: Stack) {
Env: values.env,
Prune: values.prune,
StackName: values.kube.name.trim() || undefined,
RepositoryAuthentication: resolvedAuth.RepositoryAuthentication,
RepositoryGitCredentialID: resolvedAuth.RepositoryGitCredentialID,
RepositoryUsername: resolvedAuth.RepositoryUsername,
RepositoryPassword: resolvedAuth.RepositoryPassword || undefined,
RepositoryAuthorizationType: resolvedAuth.RepositoryAuthorizationType,
RepositoryAuthentication: values.git.RepositoryAuthentication,
RepositoryUsername: values.git.RepositoryUsername,
RepositoryPassword: values.git.RepositoryPassword || undefined,
RepullImageAndRedeploy: repullImageAndRedeploy,
});
return { redeployAttempted: true, redeployFailed: false };
@@ -3,8 +3,6 @@ import { boolean, object, SchemaOf, string } from 'yup';
import { StackType } from '@/react/common/stacks/types';
import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm';
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { useCurrentUser } from '@/react/hooks/useUser';
import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset';
@@ -13,8 +11,6 @@ import { FormValues } from './types';
export function useValidationSchema(
stackType: StackType
): SchemaOf<FormValues> {
const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const isKubernetes = stackType === StackType.Kubernetes;
return useMemo(
@@ -26,7 +22,6 @@ export function useValidationSchema(
}).required()
: object({ name: string().default('') }).optional(),
git: buildGitValidationSchema(
gitCredentialsQuery.data || [],
false,
isKubernetes ? 'manifest' : 'compose',
true
@@ -36,6 +31,6 @@ export function useValidationSchema(
prune: boolean().default(false),
redeployNow: boolean().default(false),
}),
[gitCredentialsQuery.data, isKubernetes]
[isKubernetes]
);
}
@@ -51,8 +51,6 @@ export function GitPullButton({ stack }: { stack: Stack }) {
Prune: stack.Option?.Prune,
RepositoryAuthorizationType:
stack.GitConfig?.Authentication?.AuthorizationType,
RepositoryGitCredentialID:
stack.GitConfig?.Authentication?.GitCredentialID,
RepositoryUsername: stack.GitConfig?.Authentication?.Username,
},
{
@@ -22,8 +22,6 @@ export type KubernetesGitRepositoryPayload = {
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** GitCredentialID used to identify the binded git credential */
repositoryGitCredentialId?: number;
/** Path to the Stack file inside the Git repository */
manifestFile?: string;
@@ -26,8 +26,6 @@ export type StandaloneGitRepositoryPayload = {
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** GitCredentialID used to identify the binded git credential */
repositoryGitCredentialId?: number;
/** Path to the Stack file inside the Git repository */
composeFile?: string;
@@ -28,8 +28,6 @@ export type SwarmGitRepositoryPayload = {
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** GitCredentialID used to identify the binded git credential */
repositoryGitCredentialId?: number;
/** Path to the Stack file inside the Git repository */
composeFile?: string;
@@ -199,7 +199,6 @@ function createSwarmStack({ method, payload }: SwarmCreatePayload) {
repositoryAuthentication: payload.git.RepositoryAuthentication,
repositoryUsername: payload.git.RepositoryUsername,
repositoryPassword: payload.git.RepositoryPassword,
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
tlsSkipVerify: payload.git.TLSSkipVerify,
@@ -250,7 +249,6 @@ function createStandaloneStack({ method, payload }: StandaloneCreatePayload) {
repositoryAuthentication: payload.git.RepositoryAuthentication,
repositoryUsername: payload.git.RepositoryUsername,
repositoryPassword: payload.git.RepositoryPassword,
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
tlsSkipVerify: payload.git.TLSSkipVerify,
@@ -299,7 +297,6 @@ function createKubernetesStack({ method, payload }: KubernetesCreatePayload) {
repositoryAuthentication: payload.git.RepositoryAuthentication,
repositoryUsername: payload.git.RepositoryUsername,
repositoryPassword: payload.git.RepositoryPassword,
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: transformAutoUpdateViewModel(
-1
View File
@@ -102,7 +102,6 @@ export interface GitStackPayload {
prune?: boolean;
RepositoryReferenceName?: string;
RepositoryAuthentication?: boolean;
RepositoryGitCredentialID?: number;
RepositoryUsername?: string;
RepositoryPassword?: string;
RepositoryAuthorizationType?: AuthTypeOption;
@@ -102,7 +102,6 @@ describe('CreateStackForm - Webhook ID Integration', () => {
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryGitCredentialID: 0,
TLSSkipVerify: false,
AdditionalFiles: [],
AutoUpdate: {
@@ -115,8 +114,6 @@ describe('CreateStackForm - Webhook ID Integration', () => {
RepositoryAuthorizationType: undefined,
SupportRelativePath: false,
FilesystemPath: '',
SaveCredential: false,
NewCredentialName: '',
},
}),
});
@@ -82,15 +82,12 @@ function renderComponent({
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryGitCredentialID: 0,
TLSSkipVerify: false,
AdditionalFiles: [],
AutoUpdate: undefined,
RepositoryAuthorizationType: undefined,
SupportRelativePath: false,
FilesystemPath: '',
SaveCredential: false,
NewCredentialName: '',
...initialValues,
},
});
@@ -3,9 +3,7 @@ import { getGitValidationSchema } from './validation';
describe('Git validation', () => {
it('should pass validation with valid git form values', async () => {
const schema = getGitValidationSchema({
gitCredentials: [],
});
const schema = getGitValidationSchema();
const validData: GitFormValues = {
RepositoryURL: 'https://github.com/user/repo',
@@ -14,24 +12,19 @@ describe('Git validation', () => {
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryGitCredentialID: 0,
TLSSkipVerify: false,
AdditionalFiles: [],
AutoUpdate: undefined,
RepositoryAuthorizationType: undefined,
SupportRelativePath: false,
FilesystemPath: '',
SaveCredential: false,
NewCredentialName: '',
};
await expect(schema.validate(validData)).resolves.toBeDefined();
});
it('should fail validation when repository URL is empty', async () => {
const schema = getGitValidationSchema({
gitCredentials: [],
});
const schema = getGitValidationSchema();
const invalidData = {
RepositoryURL: '',
@@ -41,30 +34,4 @@ describe('Git validation', () => {
await expect(schema.validate(invalidData)).rejects.toThrow();
});
it('should require credential name when saveCredential is true', async () => {
const schema = getGitValidationSchema({
gitCredentials: [],
});
const invalidData: GitFormValues = {
RepositoryURL: 'https://github.com/user/repo',
RepositoryReferenceName: 'refs/heads/main',
ComposeFilePathInRepository: 'docker-compose.yml',
RepositoryAuthentication: true,
RepositoryUsername: 'user',
RepositoryPassword: 'pass',
RepositoryGitCredentialID: 0,
TLSSkipVerify: false,
AdditionalFiles: [],
AutoUpdate: undefined,
RepositoryAuthorizationType: undefined,
SupportRelativePath: false,
FilesystemPath: '',
SaveCredential: true,
NewCredentialName: '',
};
await expect(schema.validate(invalidData)).rejects.toThrow();
});
});
@@ -1,16 +1,11 @@
import { SchemaOf, object, boolean, string } from 'yup';
import { GitCredential } from '@/react/portainer/account/git-credentials/types';
import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm';
import { GitFormValues } from './types';
export function getGitValidationSchema({
gitCredentials = [],
}: {
gitCredentials: Array<GitCredential> | undefined;
}): SchemaOf<GitFormValues> {
return buildGitValidationSchema(gitCredentials, false, 'compose').concat(
export function getGitValidationSchema(): SchemaOf<GitFormValues> {
return buildGitValidationSchema(false, 'compose').concat(
object({
SupportRelativePath: boolean().default(false),
FilesystemPath: string()
@@ -27,15 +27,12 @@ export function mockFormValues(overrides: DeepPartial<FormValues>): FormValues {
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryGitCredentialID: 0,
TLSSkipVerify: false,
AdditionalFiles: [],
AutoUpdate: undefined,
RepositoryAuthorizationType: undefined,
SupportRelativePath: false,
FilesystemPath: '',
SaveCredential: false,
NewCredentialName: '',
},
template: {
selectedId: undefined,
@@ -4,13 +4,11 @@ import _ from 'lodash';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { useStacks } from '@/react/common/stacks/queries/useStacks';
import { useContainers } from '@/react/docker/containers/queries/useContainers';
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
import { getValidationSchema } from './validation';
export function useValidationSchema(environmentId: EnvironmentId) {
const { user } = useCurrentUser();
const { isAdmin } = useIsEdgeAdmin();
const stacksQuery = useStacks();
@@ -18,11 +16,9 @@ export function useValidationSchema(environmentId: EnvironmentId) {
select: (containers) =>
containers.flatMap((c) => c.Names).map((name) => _.trimStart(name, '/')),
});
const gitCredentialsQuery = useGitCredentials(user.Id);
const containerNames = containersQuery.data;
const stacks = stacksQuery.data;
const gitCredentials = gitCredentialsQuery.data;
return useMemo(
() =>
@@ -31,8 +27,7 @@ export function useValidationSchema(environmentId: EnvironmentId) {
environmentId,
stacks,
containerNames,
gitCredentials,
}),
[isAdmin, environmentId, stacks, containerNames, gitCredentials]
[isAdmin, environmentId, stacks, containerNames]
);
}
@@ -1,7 +1,6 @@
import { object, array, number, mixed, SchemaOf, bool } from 'yup';
import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
import { GitCredential } from '@/react/portainer/account/git-credentials/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { nameValidation } from '@/react/docker/stacks/common/NameField';
import { Stack } from '@/react/common/stacks/types';
@@ -19,17 +18,15 @@ export function getValidationSchema({
environmentId,
stacks,
containerNames = [],
gitCredentials = [],
}: {
isAdmin: boolean;
environmentId: EnvironmentId;
stacks?: Array<Stack>;
containerNames?: Array<string>;
gitCredentials?: Array<GitCredential>;
}): SchemaOf<FormValues> {
return getBaseValidationSchema({ isAdmin, environmentId, stacks }).concat(
object({
git: getGitValidationSchema({ gitCredentials }).when('method', {
git: getGitValidationSchema().when('method', {
is: 'repository',
then: (schema) => schema.required(),
otherwise: () => mixed(),
@@ -12,8 +12,6 @@ import { useMemo } from 'react';
import Lazy from 'yup/lib/Lazy';
import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm';
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { useCurrentUser } from '@/react/hooks/useUser';
import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFieldset/validation';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
@@ -43,8 +41,6 @@ export function useValidation({
appTemplate: TemplateViewModel | undefined;
customTemplate: CustomTemplate | undefined;
}): Lazy<SchemaOf<FormValues>> {
const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const nameValidation = useNameValidation();
const edgeGroupsQuery = useEdgeGroups();
const edgeGroups = edgeGroupsQuery.data;
@@ -131,7 +127,6 @@ export function useValidation({
? 'compose'
: 'manifest';
return buildGitValidationSchema(
gitCredentialsQuery.data || [],
!!customTemplate,
deploymentMethod
);
@@ -144,12 +139,6 @@ export function useValidation({
useManifestNamespaces: boolean().default(false),
})
),
[
appTemplate?.Env,
customTemplate,
edgeGroups,
gitCredentialsQuery.data,
nameValidation,
]
[appTemplate?.Env, customTemplate, edgeGroups, nameValidation]
);
}
@@ -52,7 +52,6 @@ const expectedCustomTemplatePayload = {
repositoryReferenceName: 'refs/heads/main',
filePathInRepository: 'docker/voting.yaml',
repositoryAuthentication: false,
repositoryGitCredentialId: 0,
repositoryPassword: '',
filesystemPath: '/test',
supportRelativePath: true,
@@ -85,7 +85,6 @@ const customTemplatesResponseBody = [
Authentication: {
Username: '',
Password: '',
GitCredentialID: 0,
},
ConfigHash: '1db40a888e07da7d9455897aadd349d0bc83bd83',
TLSSkipVerify: false,
@@ -145,16 +144,6 @@ const edgeGroups = [
},
];
const gitCredentials = [
{
id: 1,
userId: 1,
name: 'test',
username: 'portainer-test',
creationDate: 1732761658,
},
];
const registries = [
{
Id: 1,
@@ -238,11 +227,6 @@ export function renderCreateForm() {
server.use(http.get('/api/edge_stacks', () => HttpResponse.json([])));
server.use(http.get('/api/edge_groups', () => HttpResponse.json(edgeGroups)));
server.use(http.get('/api/registries', () => HttpResponse.json(registries)));
server.use(
http.get('/api/users/1/gitcredentials', () =>
HttpResponse.json(gitCredentials)
)
);
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(CreateForm), user)
);
@@ -1,6 +1,5 @@
import { useRouter } from '@uirouter/react';
import { useCurrentUser } from '@/react/hooks/useUser';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { notifySuccess } from '@/portainer/services/notifications';
@@ -26,7 +25,6 @@ export function useCreate({
}) {
const router = useRouter();
const mutation = useCreateEdgeStack();
const { user } = useCurrentUser();
return {
isLoading: mutation.isLoading,
@@ -105,7 +103,6 @@ export function useCreate({
value,
}));
return {
userId: user.Id,
deploymentType: values.deploymentType,
edgeGroups: values.groupIds,
name: values.name,
@@ -46,7 +46,6 @@ describe('GitForm', () => {
Authentication: {
Username: '',
Password: '',
RepositoryGitCredentialID: 0,
},
},
PrePullImage: false,
@@ -33,7 +33,6 @@ import { Registry } from '@/react/portainer/registries/types/registry';
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
import { parseRelativePathResponse } from '@/react/portainer/gitops/RelativePathFieldset/utils';
import { useSaveCredentialsIfRequired } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
import { GitReferenceCard } from '@/react/portainer/gitops/GitReferenceCard';
import { LoadingButton } from '@@/buttons';
@@ -65,8 +64,6 @@ interface FormValues {
export function GitForm({ stack }: { stack: EdgeStack }) {
const router = useRouter();
const updateStackMutation = useUpdateEdgeStackGitMutation();
const { saveCredentials, isLoading: isSaveCredentialsLoading } =
useSaveCredentialsIfRequired();
const [webhookId] = useState(
() => stack.AutoUpdate?.Webhook || createWebhookId()
@@ -96,9 +93,7 @@ export function GitForm({ stack }: { stack: EdgeStack }) {
webhookId={webhookId}
onUpdateSettingsClick={handleUpdateSettings}
gitUrl={gitConfig.URL}
isLoading={
updateStackMutation.isLoading || isSaveCredentialsLoading
}
isLoading={updateStackMutation.isLoading}
isUpdateVersion={!!updateStackMutation.variables?.updateVersion}
stack={stack}
/>
@@ -109,9 +104,7 @@ export function GitForm({ stack }: { stack: EdgeStack }) {
return;
}
const credentialId = await saveCredentials(values.authentication);
updateStackMutation.mutate(getPayload(values, credentialId, false), {
updateStackMutation.mutate(getPayload(values, false), {
onSuccess() {
notifySuccess('Success', 'Stack updated successfully');
router.stateService.reload();
@@ -123,9 +116,7 @@ export function GitForm({ stack }: { stack: EdgeStack }) {
);
async function handleSubmit(values: FormValues) {
const credentialId = await saveCredentials(values.authentication);
updateStackMutation.mutate(getPayload(values, credentialId, true), {
updateStackMutation.mutate(getPayload(values, true), {
onSuccess() {
notifySuccess('Success', 'Stack updated successfully');
router.stateService.reload();
@@ -135,16 +126,12 @@ export function GitForm({ stack }: { stack: EdgeStack }) {
function getPayload(
{ authentication, autoUpdate, privateRegistryId, ...values }: FormValues,
credentialId: number | undefined,
updateVersion: boolean
): UpdateEdgeStackGitPayload {
return {
updateVersion,
id: stack.Id,
authentication: transformGitAuthenticationViewModel({
...authentication,
RepositoryGitCredentialID: credentialId,
}),
authentication: transformGitAuthenticationViewModel(authentication),
autoUpdate: transformAutoUpdateViewModel(autoUpdate, webhookId),
registries:
typeof privateRegistryId !== 'undefined'
@@ -23,8 +23,6 @@ export type GitRepositoryPayload = {
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** GitCredentialID used to identify the binded git credential */
repositoryGitCredentialId?: number;
/** Path to the Stack file inside the Git repository */
filePathInRepository?: string;
/** List of identifiers of EdgeGroups */
@@ -8,8 +8,6 @@ import {
GitFormModel,
RelativePathModel,
} from '@/react/portainer/gitops/types';
import { saveGitCredentialsIfNeeded } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
import { UserId } from '@/portainer/users/types';
import { DeploymentType, StaggerConfig } from '../../types';
@@ -22,7 +20,6 @@ export function useCreateEdgeStack() {
}
export type BasePayload = {
userId: UserId;
/** Name of the stack */
name: string;
/** Content of the Stack file */
@@ -92,7 +89,7 @@ function createEdgeStack({ method, payload }: CreateEdgeStackPayload) {
Webhook: payload.webhook,
});
case 'git':
return createStackAndGitCredential(payload.userId, payload);
return createEdgeStackFromGit(payload);
case 'string':
return createStackFromFileContent({
deploymentType: payload.deploymentType,
@@ -112,16 +109,13 @@ function createEdgeStack({ method, payload }: CreateEdgeStackPayload) {
}
}
async function createStackAndGitCredential(
userId: UserId,
function createEdgeStackFromGit(
payload: BasePayload & {
git: GitFormModel;
relativePathSettings?: RelativePathModel;
autoUpdate: AutoUpdateResponse | null;
}
) {
const resolvedAuth = await saveGitCredentialsIfNeeded(userId, payload.git);
return createStackFromGit({
deploymentType: payload.deploymentType,
edgeGroups: payload.edgeGroups,
@@ -135,10 +129,9 @@ async function createStackAndGitCredential(
repositoryUrl: payload.git.RepositoryURL,
repositoryReferenceName: payload.git.RepositoryReferenceName,
filePathInRepository: payload.git.ComposeFilePathInRepository,
repositoryAuthentication: resolvedAuth.RepositoryAuthentication,
repositoryUsername: resolvedAuth.RepositoryUsername,
repositoryPassword: resolvedAuth.RepositoryPassword,
repositoryGitCredentialId: resolvedAuth.RepositoryGitCredentialID,
repositoryAuthentication: payload.git.RepositoryAuthentication,
repositoryUsername: payload.git.RepositoryUsername,
repositoryPassword: payload.git.RepositoryPassword,
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
perDeviceConfigsGroupMatchType:
@@ -1,130 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { success as notifySuccess } from '@/portainer/services/notifications';
import { UserId } from '@/portainer/users/types';
import { isBE } from '../../feature-flags/feature-flags.service';
import { GitCredential, UpdateGitCredentialPayload } from './types';
export async function getGitCredentials(userId: number) {
try {
const { data } = await axios.get<GitCredential[]>(buildGitUrl(userId));
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to get git credentials');
}
}
export async function getGitCredential(userId: number, id: number) {
try {
const { data } = await axios.get<GitCredential>(buildGitUrl(userId, id));
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to get git credential');
}
}
export async function deleteGitCredential(credential: GitCredential) {
try {
await axios.delete<GitCredential[]>(
buildGitUrl(credential.userId, credential.id)
);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to delete git credential');
}
}
export async function updateGitCredential(
credential: Partial<UpdateGitCredentialPayload>,
userId: number,
id: number
) {
try {
const { data } = await axios.put(buildGitUrl(userId, id), credential);
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update credential');
}
}
export function useUpdateGitCredentialMutation() {
const queryClient = useQueryClient();
return useMutation(
({
credential,
userId,
id,
}: {
credential: UpdateGitCredentialPayload;
userId: number;
id: number;
}) => updateGitCredential(credential, userId, id),
{
onSuccess: (_, data) => {
notifySuccess(
'Git credential updated successfully',
data.credential.name
);
return queryClient.invalidateQueries(['gitcredentials']);
},
meta: {
error: {
title: 'Failure',
message: 'Unable to update credential',
},
},
}
);
}
export function useDeleteGitCredentialMutation() {
const queryClient = useQueryClient();
return useMutation(deleteGitCredential, {
onSuccess: (_, credential) => {
notifySuccess('Git Credential deleted successfully', credential.name);
return queryClient.invalidateQueries(['gitcredentials']);
},
meta: {
error: {
title: 'Failure',
message: 'Unable to delete git credential',
},
},
});
}
export function useGitCredentials(
userId: UserId,
{ enabled }: { enabled?: boolean } = {}
) {
return useQuery(['gitcredentials'], () => getGitCredentials(userId), {
enabled: isBE && enabled,
meta: {
error: {
title: 'Failure',
message: 'Unable to retrieve git credentials',
},
},
});
}
export function useGitCredential(userId: number, id: number) {
return useQuery(['gitcredentials', id], () => getGitCredential(userId, id), {
meta: {
error: {
title: 'Failure',
message: 'Unable to retrieve git credential',
},
},
});
}
export function buildGitUrl(userId: number, credentialId?: number) {
return credentialId
? `/users/${userId}/gitcredentials/${credentialId}`
: `/users/${userId}/gitcredentials`;
}
@@ -1,124 +0,0 @@
import { useQueryClient, useMutation } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import {
GitAuthModel,
GitCredentialsModel,
} from '@/react/portainer/gitops/types';
import { useCurrentUser } from '@/react/hooks/useUser';
import { UserId } from '@/portainer/users/types';
import { GitCredential } from '../types';
import { buildGitUrl } from '../git-credentials.service';
export interface CreateGitCredentialPayload {
userId: number;
name: string;
username?: string;
password: string;
}
export function useCreateGitCredentialMutation() {
const queryClient = useQueryClient();
return useMutation(createGitCredential, {
onSuccess: (_, payload) => {
notifySuccess('Credentials created successfully', payload.name);
return queryClient.invalidateQueries(['gitcredentials']);
},
meta: {
error: {
title: 'Failure',
message: 'Unable to create credential',
},
},
});
}
async function createGitCredential(gitCredential: CreateGitCredentialPayload) {
try {
const { data } = await axios.post<{ gitCredential: GitCredential }>(
buildGitUrl(gitCredential.userId),
gitCredential
);
return data.gitCredential;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create git credential');
}
}
export function useSaveCredentialsIfRequired() {
const saveCredentialsMutation = useCreateGitCredentialMutation();
const { user } = useCurrentUser();
return {
saveCredentials: saveCredentialsIfRequired,
isLoading: saveCredentialsMutation.isLoading,
};
async function saveCredentialsIfRequired(authentication?: GitAuthModel) {
if (!authentication) {
return undefined;
}
if (
!authentication.SaveCredential ||
!authentication.RepositoryPassword ||
!authentication.NewCredentialName
) {
return authentication.RepositoryGitCredentialID;
}
try {
const credential = await saveCredentialsMutation.mutateAsync({
userId: user.Id,
username: authentication.RepositoryUsername,
password: authentication.RepositoryPassword,
name: authentication.NewCredentialName,
});
return credential.id;
} catch (err) {
notifyError('Error', err as Error, 'Unable to save credentials');
return undefined;
}
}
}
export async function saveGitCredentialsIfNeeded(
userId: UserId,
gitModel: GitAuthModel
): Promise<GitCredentialsModel> {
let credentialsId = gitModel.RepositoryGitCredentialID;
let username = gitModel.RepositoryUsername;
let password = gitModel.RepositoryPassword;
if (
gitModel.SaveCredential &&
gitModel.RepositoryAuthentication &&
password &&
username &&
gitModel.NewCredentialName
) {
const cred = await createGitCredential({
name: gitModel.NewCredentialName,
password,
username,
userId,
});
credentialsId = cred.id;
}
// clear username and password if credentials are provided
if (credentialsId && username) {
username = '';
password = '';
}
return {
RepositoryAuthentication: gitModel.RepositoryAuthentication,
RepositoryGitCredentialID: credentialsId,
RepositoryUsername: username,
RepositoryPassword: password,
RepositoryAuthorizationType: gitModel.RepositoryAuthorizationType,
};
}
@@ -1,33 +1,4 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types';
export interface GitCredentialTableSettings
extends SortableTableSettings, PaginationTableSettings {}
export enum AuthTypeOption {
Basic = 0,
Token = 1,
}
export interface GitCredentialFormValues {
name: string;
username?: string;
password: string;
}
export interface UpdateGitCredentialPayload {
name: string;
username?: string;
password: string;
}
export type GitCredential = {
id: number;
userId: number;
name: string;
username: string;
creationDate: number;
authorizationType: AuthTypeOption;
};
@@ -96,12 +96,6 @@ export type WorkflowsDeploymentPlatform =
export type GittypesGitAuthentication = {
AuthorizationType?: number;
/**
* Git credentials identifier when the value is not 0
* When the value is 0, Username and Password are set without using saved credential
* This is introduced since 2.15.0
*/
GitCredentialID?: number;
Password?: string;
Provider?: number;
Username?: string;
@@ -7844,14 +7838,9 @@ export type CustomtemplatesCustomTemplateUpdatePayload = {
* Use authentication to clone the Git repository
*/
RepositoryAuthentication?: boolean;
/**
* GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication
* is true and RepositoryUsername/RepositoryPassword are not provided
*/
RepositoryGitCredentialID?: number;
/**
* Password used in basic authentication or token used in token authentication.
* Required when RepositoryAuthentication is true and RepositoryGitCredentialID is 0
* Required when RepositoryAuthentication is true
*/
RepositoryPassword?: string;
/**
@@ -7864,7 +7853,6 @@ export type CustomtemplatesCustomTemplateUpdatePayload = {
RepositoryURL: string;
/**
* Username used in basic authentication. Required when RepositoryAuthentication is true
* and RepositoryGitCredentialID is 0. Ignored if RepositoryAuthType is token
*/
RepositoryUsername?: string;
/**
@@ -87,7 +87,6 @@ export const zWorkflowsDeploymentPlatform = z.enum(WorkflowsDeploymentPlatform);
export const zGittypesGitAuthentication = z.object({
AuthorizationType: z.int().optional(),
GitCredentialID: z.int().optional(),
Password: z.string().optional(),
Provider: z.int().optional(),
Username: z.string().optional(),
@@ -2986,7 +2985,6 @@ export const zCustomtemplatesCustomTemplateUpdatePayload = z.object({
Note: z.string().optional(),
Platform: z.union([z.literal(1), z.literal(2)]).optional(),
RepositoryAuthentication: z.boolean().optional(),
RepositoryGitCredentialID: z.int().optional(),
RepositoryPassword: z.string().optional(),
RepositoryReferenceName: z.string().optional(),
RepositoryURL: z.string(),
@@ -1,15 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import {
GitCredential,
AuthTypeOption,
} from '@/react/portainer/account/git-credentials/types';
import { AuthTypeOption } from '@/react/portainer/account/git-credentials/types';
import { GitAuthModel } from '@/react/portainer/gitops/types';
import { AuthFieldset, gitAuthValidation } from './AuthFieldset';
// Simple mocks to avoid complex dependencies
vi.mock('../../feature-flags/feature-flags.service', () => ({
isBE: true,
isLimitedToBE: () => false,
@@ -22,26 +18,11 @@ vi.mock('@/react/hooks/useDebounce', () => ({
],
}));
vi.mock('./CredentialSelector', () => ({
CredentialSelector: () => (
<div data-cy="credential-selector">Credential Selector</div>
),
}));
vi.mock('./NewCredentialForm', () => ({
NewCredentialForm: () => (
<div data-cy="new-credential-form">New Credential Form</div>
),
}));
const defaultGitAuthModel: GitAuthModel = {
RepositoryAuthentication: false,
RepositoryGitCredentialID: 0,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: false,
NewCredentialName: '',
};
function renderAuthFieldset({
@@ -97,26 +78,6 @@ describe('AuthFieldset', () => {
).toBeInTheDocument();
});
it('should render credential selector when authentication is enabled', () => {
renderAuthFieldset({
value: { ...defaultGitAuthModel, RepositoryAuthentication: true },
});
expect(screen.getByTestId('credential-selector')).toBeInTheDocument();
});
it('should render new credential form when password is provided', () => {
renderAuthFieldset({
value: {
...defaultGitAuthModel,
RepositoryAuthentication: true,
RepositoryPassword: 'password123',
},
});
expect(screen.getByTestId('new-credential-form')).toBeInTheDocument();
});
it('should not render interactive fields when authentication is disabled', () => {
renderAuthFieldset({
value: { ...defaultGitAuthModel, RepositoryAuthentication: false },
@@ -128,9 +89,6 @@ describe('AuthFieldset', () => {
expect(
screen.queryByTestId('component-gitPasswordInput')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('credential-selector')
).not.toBeInTheDocument();
});
});
@@ -220,12 +178,9 @@ describe('AuthFieldset', () => {
it('should handle value prop with all fields populated', () => {
const value: GitAuthModel = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'testuser',
RepositoryPassword: 'testpass',
RepositoryAuthorizationType: AuthTypeOption.Token,
SaveCredential: true,
NewCredentialName: 'test-credential',
};
renderAuthFieldset({ value });
@@ -236,73 +191,30 @@ describe('AuthFieldset', () => {
expect(
screen.getByTestId('component-gitPasswordInput')
).toBeInTheDocument();
expect(screen.getByTestId('credential-selector')).toBeInTheDocument();
expect(screen.getByTestId('new-credential-form')).toBeInTheDocument();
});
it('should handle value prop with git credential selected', () => {
const value: GitAuthModel = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 1,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: false,
NewCredentialName: '',
};
renderAuthFieldset({ value });
expect(screen.getByTestId('credential-selector')).toBeInTheDocument();
// shouldn't render credential inputs for selected credential
expect(
screen.queryByTestId('component-gitUsernameInput')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('component-gitPasswordInput')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('new-credential-form')
).not.toBeInTheDocument();
});
});
});
describe('gitAuthValidation', () => {
const mockGitCredentials: GitCredential[] = [
{
id: 1,
userId: 1,
name: 'existing-credential',
username: 'testuser',
creationDate: Date.now(),
authorizationType: AuthTypeOption.Basic,
},
];
describe('default values', () => {
it('should provide correct default values', async () => {
const schema = gitAuthValidation([], false, false);
const schema = gitAuthValidation(false, false);
const result = await schema.validate({});
expect(result).toEqual({
RepositoryAuthentication: false,
RepositoryGitCredentialID: 0,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: false,
NewCredentialName: '',
});
});
});
describe('authentication disabled', () => {
it('should allow empty values when authentication is disabled', async () => {
const schema = gitAuthValidation([], false, false);
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: false,
RepositoryGitCredentialID: 0,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
@@ -313,12 +225,11 @@ describe('gitAuthValidation', () => {
});
});
describe('authentication enabled without git credential', () => {
it('should require username when authentication is enabled and no git credential is selected', async () => {
const schema = gitAuthValidation([], false, false);
describe('authentication enabled', () => {
it('should require username when authentication is enabled', async () => {
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: '',
RepositoryPassword: 'password',
RepositoryAuthorizationType: AuthTypeOption.Basic,
@@ -329,11 +240,10 @@ describe('gitAuthValidation', () => {
);
});
it('should require password when authentication is enabled, no git credential, not auth edit, and not from custom template', async () => {
const schema = gitAuthValidation([], false, false);
it('should require password when authentication is enabled, not auth edit, and not from custom template', async () => {
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
@@ -344,26 +254,23 @@ describe('gitAuthValidation', () => {
);
});
it('should set default authorization type when authentication is enabled, no git credential, not auth edit, and not from custom template', async () => {
const schema = gitAuthValidation([], false, false);
it('should set default authorization type when authentication is enabled', async () => {
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: undefined,
};
// The schema provides a default value when authorization type is undefined
const result = await schema.validate(data);
expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Basic);
});
it('should accept valid authorization types', async () => {
const schema = gitAuthValidation([], false, false);
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: AuthTypeOption.Token,
@@ -374,126 +281,12 @@ describe('gitAuthValidation', () => {
});
it('should reject invalid authorization types', async () => {
const schema = gitAuthValidation([], false, false);
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: 999, // Invalid value
};
await expect(schema.validate(data)).rejects.toThrow();
});
it('should reject string authorization types that are not valid enum values', async () => {
const schema = gitAuthValidation([], false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType:
'invalid-auth-type' as unknown as AuthTypeOption,
};
await expect(schema.validate(data)).rejects.toThrow();
});
});
describe('authentication enabled with git credential', () => {
it('should not require username when git credential is selected', async () => {
const schema = gitAuthValidation([], false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 1,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
};
const result = await schema.validate(data);
expect(result.RepositoryUsername).toBe('');
});
it('should not require password when git credential is selected', async () => {
const schema = gitAuthValidation([], false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 1,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
};
const result = await schema.validate(data);
expect(result.RepositoryPassword).toBe('');
});
it('should not require authorization type when git credential is selected', async () => {
const schema = gitAuthValidation([], false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 1,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: undefined,
};
const result = await schema.validate(data);
expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Basic); // Default value
});
it('should accept the authorization type from the selected git credential', async () => {
const schema = gitAuthValidation(mockGitCredentials, false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 1, // This matches the mockGitCredentials[0].id
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic, // This matches mockGitCredentials[0].authorizationType
};
const result = await schema.validate(data);
expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Basic);
});
it('should not require authorization type validation when git credential is selected', async () => {
const schema = gitAuthValidation(mockGitCredentials, false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 1,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: undefined, // Should not be required when git credential is selected
};
const result = await schema.validate(data);
expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Basic); // Should default to Basic
});
it('should reject invalid authorization type even when git credential is selected', async () => {
const schema = gitAuthValidation(mockGitCredentials, false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 1,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: 999, // Invalid value - should be rejected by oneOf validation
};
await expect(schema.validate(data)).rejects.toThrow();
});
it('should reject string authorization type that is not valid enum value even when git credential is selected', async () => {
const schema = gitAuthValidation(mockGitCredentials, false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 1,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType:
'invalid-auth-type' as unknown as AuthTypeOption, // Invalid string value
RepositoryAuthorizationType: 999,
};
await expect(schema.validate(data)).rejects.toThrow();
@@ -502,10 +295,9 @@ describe('gitAuthValidation', () => {
describe('auth edit mode', () => {
it('should not require password when in auth edit mode', async () => {
const schema = gitAuthValidation([], true, false);
const schema = gitAuthValidation(true, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
@@ -516,26 +308,24 @@ describe('gitAuthValidation', () => {
});
it('should not require authorization type when in auth edit mode', async () => {
const schema = gitAuthValidation([], true, false);
const schema = gitAuthValidation(true, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: undefined,
};
const result = await schema.validate(data);
expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Basic); // Default value
expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Basic);
});
});
describe('created from custom template', () => {
it('should not require password when created from custom template', async () => {
const schema = gitAuthValidation([], false, true);
const schema = gitAuthValidation(false, true);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
@@ -546,218 +336,57 @@ describe('gitAuthValidation', () => {
});
it('should not require authorization type when created from custom template', async () => {
const schema = gitAuthValidation([], false, true);
const schema = gitAuthValidation(false, true);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: undefined,
};
const result = await schema.validate(data);
expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Basic); // Default value
});
});
describe('save credential validation', () => {
it('should not require new credential name when save credential is disabled', async () => {
const schema = gitAuthValidation([], false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: false,
NewCredentialName: '',
};
const result = await schema.validate(data);
expect(result.NewCredentialName).toBe('');
});
it('should require new credential name when save credential is enabled and not in auth edit mode', async () => {
const schema = gitAuthValidation([], false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: true,
NewCredentialName: '',
};
await expect(schema.validate(data)).rejects.toThrow('Name is required');
});
it('should not require new credential name when save credential is enabled but in auth edit mode', async () => {
const schema = gitAuthValidation([], true, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: true,
NewCredentialName: '',
};
const result = await schema.validate(data);
expect(result.NewCredentialName).toBe('');
});
it('should reject duplicate credential names', async () => {
const schema = gitAuthValidation(mockGitCredentials, false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: true,
NewCredentialName: 'existing-credential',
};
await expect(schema.validate(data)).rejects.toThrow(
'This name is already been used, please try another one'
);
});
it('should accept unique credential names', async () => {
const schema = gitAuthValidation(mockGitCredentials, false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: true,
NewCredentialName: 'new-credential',
};
const result = await schema.validate(data);
expect(result.NewCredentialName).toBe('new-credential');
});
it('should validate credential name format - valid names', async () => {
const schema = gitAuthValidation([], false, false);
const validNames = ['my-name', 'abc-123', 'test_credential', 'simple123'];
await Promise.all(
validNames.map(async (name) => {
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: true,
NewCredentialName: name,
};
const result = await schema.validate(data);
expect(result.NewCredentialName).toBe(name);
})
);
});
it('should validate credential name format - invalid names', async () => {
const schema = gitAuthValidation([], false, false);
const invalidNames = [
'My-Name',
'ABC-123',
'test@credential',
'simple 123',
'test.credential',
];
await Promise.all(
invalidNames.map(async (name) => {
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: true,
NewCredentialName: name,
};
await expect(schema.validate(data)).rejects.toThrow(
"This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123')."
);
})
);
expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Basic);
});
});
describe('complex scenarios', () => {
it('should handle complete valid data with save credential', async () => {
const schema = gitAuthValidation([], false, false);
it('should handle complete valid data', async () => {
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'testuser',
RepositoryPassword: 'testpassword',
RepositoryAuthorizationType: AuthTypeOption.Token,
SaveCredential: true,
NewCredentialName: 'my-test-credential',
};
const result = await schema.validate(data);
expect(result).toEqual(data);
});
it('should handle complete valid data with git credential', async () => {
const schema = gitAuthValidation(mockGitCredentials, false, false);
it('should handle auth edit mode', async () => {
const schema = gitAuthValidation(true, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 1,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: false,
NewCredentialName: '',
};
const result = await schema.validate(data);
expect(result).toEqual(data);
});
it('should handle auth edit mode with save credential', async () => {
const schema = gitAuthValidation([], true, false);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'testuser',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: true,
NewCredentialName: '',
};
const result = await schema.validate(data);
expect(result.RepositoryPassword).toBe('');
expect(result.NewCredentialName).toBe('');
});
it('should handle custom template creation with save credential', async () => {
const schema = gitAuthValidation([], false, true);
it('should handle custom template creation', async () => {
const schema = gitAuthValidation(false, true);
const data = {
RepositoryAuthentication: true,
RepositoryGitCredentialID: 0,
RepositoryUsername: 'testuser',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
SaveCredential: true,
NewCredentialName: 'template-credential',
};
const result = await schema.validate(data);
expect(result.RepositoryPassword).toBe('');
expect(result.NewCredentialName).toBe('template-credential');
});
});
});
@@ -1,19 +1,15 @@
import { FormikErrors } from 'formik';
import { boolean, mixed, number, object, SchemaOf, string } from 'yup';
import { boolean, mixed, object, SchemaOf, string } from 'yup';
import { useState } from 'react';
import { GitAuthModel } from '@/react/portainer/gitops/types';
import {
AuthTypeOption,
GitCredential,
} from '@/react/portainer/account/git-credentials/types';
import { AuthTypeOption } from '@/react/portainer/account/git-credentials/types';
import { SwitchField } from '@@/form-components/SwitchField';
import { TextTip } from '@@/Tip/TextTip';
import { isBE } from '../../feature-flags/feature-flags.service';
import { CredentialSelector } from './CredentialSelector';
import { CredentialsSection } from './CredentialsSection';
interface Props {
@@ -57,43 +53,16 @@ export function AuthFieldset({
</TextTip>
)}
{isBE && (
<CredentialSelector
onChange={handleChangeGitCredential}
value={value.RepositoryGitCredentialID}
/>
)}
{!value.RepositoryGitCredentialID && (
<CredentialsSection
value={value}
onChange={handleChange}
errors={errors}
/>
)}
<CredentialsSection
value={value}
onChange={handleChange}
errors={errors}
/>
</>
)}
</>
);
function handleChangeGitCredential(gitCredential?: GitCredential | null) {
handleChange(
gitCredential
? {
RepositoryGitCredentialID: gitCredential.id,
RepositoryUsername: gitCredential?.username,
RepositoryPassword: '',
SaveCredential: false,
NewCredentialName: '',
}
: {
RepositoryGitCredentialID: 0,
RepositoryUsername: '',
RepositoryPassword: '',
}
);
}
function handleChange(partialValue: Partial<GitAuthModel>) {
onChange(partialValue);
setValue((value) => ({ ...value, ...partialValue }));
@@ -101,51 +70,31 @@ export function AuthFieldset({
}
export function gitAuthValidation(
gitCredentials: Array<GitCredential>,
isAuthEdit: boolean,
isCreatedFromCustomTemplate: boolean
): SchemaOf<GitAuthModel> {
return object({
RepositoryAuthentication: boolean().default(false),
RepositoryGitCredentialID: number().default(0),
RepositoryUsername: string()
.when(['RepositoryAuthentication', 'RepositoryGitCredentialID'], {
is: (auth: boolean, id: number) => auth && !id,
.when(['RepositoryAuthentication'], {
is: (auth: boolean) => auth,
then: string().required('Username is required'),
})
.default(''),
RepositoryPassword: string()
.when(['RepositoryAuthentication', 'RepositoryGitCredentialID'], {
is: (auth: boolean, id: number) =>
auth && !id && !isAuthEdit && !isCreatedFromCustomTemplate,
.when(['RepositoryAuthentication'], {
is: (auth: boolean) =>
auth && !isAuthEdit && !isCreatedFromCustomTemplate,
then: string().required('Personal Access Token is required'),
})
.default(''),
RepositoryAuthorizationType: mixed()
.oneOf(Object.values(AuthTypeOption))
.when(['RepositoryAuthentication', 'RepositoryGitCredentialID'], {
is: (auth: boolean, id: number) =>
isBE && auth && !id && !isAuthEdit && !isCreatedFromCustomTemplate,
.when(['RepositoryAuthentication'], {
is: (auth: boolean) =>
isBE && auth && !isAuthEdit && !isCreatedFromCustomTemplate,
then: mixed().required('Authorization type is required'),
})
.default(AuthTypeOption.Basic),
SaveCredential: boolean().default(false),
NewCredentialName: string()
.default('')
.when(['RepositoryAuthentication', 'SaveCredential'], {
is: (RepositoryAuthentication: boolean, SaveCredential: boolean) =>
RepositoryAuthentication && SaveCredential && !isAuthEdit,
then: string()
.required('Name is required')
.test(
'is-unique',
'This name is already been used, please try another one',
(name) => !!name && !gitCredentials.find((x) => x.name === name)
)
.matches(
/^[-_a-z0-9]+$/,
"This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123')."
),
}),
});
}
@@ -1,50 +0,0 @@
import { GitCredential } from '@/react/portainer/account/git-credentials/types';
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { useUser } from '@/react/hooks/useUser';
import { FormControl } from '@@/form-components/FormControl';
import { Select } from '@@/form-components/ReactSelect';
export function CredentialSelector({
value,
onChange,
error,
}: {
value?: number;
onChange(gitCredential?: GitCredential | null): void;
error?: string;
}) {
const { user } = useUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const gitCredentials = gitCredentialsQuery.data ?? [];
return (
<div className="form-group">
<div className="col-sm-12">
<FormControl
label="Git Credentials"
inputId="git-creds-selector"
errors={error}
>
<Select
placeholder="select git credential or fill in below"
value={gitCredentials.find(
(gitCredential) => gitCredential.id === value
)}
options={gitCredentials}
getOptionLabel={(gitCredential) => gitCredential.name}
getOptionValue={(gitCredential) => gitCredential.id.toString()}
onChange={onChange}
isClearable
noOptionsMessage={() => 'no saved credentials'}
inputId="git-creds-selector"
data-cy="git-credentials-selector"
id="git-credentials-selector"
/>
</FormControl>
</div>
</div>
);
}
@@ -10,8 +10,6 @@ import { AuthTypeOption } from '../../account/git-credentials/types';
import { isBE } from '../../feature-flags/feature-flags.service';
import { GitAuthModel } from '../types';
import { NewCredentialForm } from './NewCredentialForm';
export const defaultAuthTypeOptions = [
{
value: AuthTypeOption.Basic,
@@ -71,12 +69,9 @@ export function CredentialsSection({
<Input
value={username}
name="repository_username"
placeholder={
value.RepositoryGitCredentialID ? '' : 'git username'
}
placeholder="git username"
onChange={(e) => setUsername(e.target.value)}
data-cy="component-gitUsernameInput"
readOnly={!!value.RepositoryGitCredentialID}
/>
</FormControl>
</div>
@@ -95,14 +90,10 @@ export function CredentialsSection({
placeholder="*******"
onChange={(e) => setPassword(e.target.value)}
data-cy="component-gitPasswordInput"
readOnly={!!value.RepositoryGitCredentialID}
/>
</FormControl>
</div>
</div>
{isBE && value.RepositoryPassword && (
<NewCredentialForm value={value} onChange={onChange} errors={errors} />
)}
</>
);
}
@@ -1,57 +0,0 @@
import { FormikErrors } from 'formik';
import { Checkbox } from '@@/form-components/Checkbox';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { TextTip } from '@@/Tip/TextTip';
import { GitAuthModel } from '../types';
export function NewCredentialForm({
value,
onChange,
errors,
}: {
value: GitAuthModel;
onChange: (value: Partial<GitAuthModel>) => void;
errors?: FormikErrors<GitAuthModel>;
}) {
return (
<div className="form-group">
<div className="col-sm-12">
<FormControl label="">
<div className="flex items-center gap-2">
<Checkbox
id="repository-save-credential"
data-cy="gitops-save-credential-checkbox"
label="save credential"
checked={value.SaveCredential || false}
className="[&+label]:mb-0"
onChange={(e) => onChange({ SaveCredential: e.target.checked })}
/>
<Input
value={value.NewCredentialName || ''}
data-cy="gitops-new-credential-name-input"
name="new_credential_name"
placeholder="credential name"
className="ml-4 w-48"
onChange={(e) => onChange({ NewCredentialName: e.target.value })}
disabled={!value.SaveCredential}
/>
{errors?.NewCredentialName && (
<div className="small text-danger">
{errors.NewCredentialName}
</div>
)}
{value.SaveCredential && (
<TextTip color="blue">
This git credential can be managed through your account page
</TextTip>
)}
</div>
</FormControl>
</div>
</div>
);
}
@@ -6,18 +6,13 @@ export function parseAuthResponse(
if (!auth) {
return {
RepositoryAuthentication: false,
NewCredentialName: '',
RepositoryGitCredentialID: 0,
RepositoryPassword: '',
RepositoryUsername: '',
SaveCredential: false,
};
}
return {
RepositoryAuthentication: true,
NewCredentialName: '',
RepositoryGitCredentialID: auth.GitCredentialID,
RepositoryPassword: '',
RepositoryUsername: auth.Username,
};
@@ -26,19 +21,12 @@ export function parseAuthResponse(
export function transformGitAuthenticationViewModel(
auth?: GitAuthModel
): GitAuthenticationResponse | null {
if (
!auth ||
!auth.RepositoryAuthentication ||
typeof auth.RepositoryGitCredentialID === 'undefined' ||
(auth.RepositoryGitCredentialID === 0 && auth.RepositoryPassword === '')
) {
if (!auth || !auth.RepositoryAuthentication) {
return null;
}
if (auth.RepositoryGitCredentialID !== 0) {
return {
GitCredentialID: auth.RepositoryGitCredentialID,
};
if (!auth.RepositoryUsername && !auth.RepositoryPassword) {
return null;
}
return {
@@ -10,7 +10,6 @@ export type PathSelectorGitModel = Pick<
| 'RepositoryAuthentication'
| 'RepositoryPassword'
| 'RepositoryUsername'
| 'RepositoryGitCredentialID'
| 'RepositoryAuthorizationType'
| 'RepositoryURL'
| 'RepositoryReferenceName'
+1 -31
View File
@@ -1,9 +1,7 @@
import { Meta } from '@storybook/react-webpack5';
import { Form, Formik } from 'formik';
import { http, HttpResponse } from 'msw';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { GitCredential } from '@/react/portainer/account/git-credentials/types';
import { GitForm, buildGitValidationSchema } from './GitForm';
import { DeployMethod, GitFormModel } from './types';
@@ -11,32 +9,6 @@ import { DeployMethod, GitFormModel } from './types';
export default {
component: GitForm,
title: 'Components/Forms/GitForm',
parameters: {
msw: {
handlers: [
http.get<{ userId: string }, Array<GitCredential>>(
'/api/users/:userId/gitcredentials',
({ params }) =>
HttpResponse.json([
{
id: 1,
name: 'credential-1',
username: 'username-1',
userId: parseInt(params.userId, 10),
creationDate: 0,
},
{
id: 2,
name: 'credential-2',
username: 'username-2',
userId: parseInt(params.userId, 10),
creationDate: 0,
},
])
),
],
},
},
} as Meta;
const WrappedComponent = withUserProvider(GitForm);
@@ -65,15 +37,13 @@ export function Primary({
AdditionalFiles: [],
RepositoryReferenceName: '',
ComposeFilePathInRepository: '',
NewCredentialName: '',
SaveCredential: false,
TLSSkipVerify: false,
};
return (
<Formik
initialValues={initialValues}
validationSchema={() => buildGitValidationSchema([], false, 'compose')}
validationSchema={() => buildGitValidationSchema(false, 'compose')}
onSubmit={() => {}}
>
{({ values, errors, setValues }) => (
+2 -11
View File
@@ -12,8 +12,6 @@ import { FormSection } from '@@/form-components/FormSection';
import { validateForm } from '@@/form-components/validate-form';
import { SwitchField } from '@@/form-components/SwitchField';
import { GitCredential } from '../account/git-credentials/types';
import { AdditionalFileField } from './AdditionalFilesField';
import { gitAuthValidation, AuthFieldset } from './AuthFieldset';
import { AutoUpdateFieldset } from './AutoUpdateFieldset';
@@ -152,24 +150,17 @@ export function GitForm({
}
export async function validateGitForm(
gitCredentials: Array<GitCredential>,
formValues: GitFormModel,
isCreatedFromCustomTemplate: boolean,
deployMethod: DeployMethod = 'compose'
) {
return validateForm<GitFormModel>(
() =>
buildGitValidationSchema(
gitCredentials,
isCreatedFromCustomTemplate,
deployMethod
),
() => buildGitValidationSchema(isCreatedFromCustomTemplate, deployMethod),
formValues
);
}
export function buildGitValidationSchema(
gitCredentials: Array<GitCredential>,
isCreatedFromCustomTemplate: boolean,
deployMethod: DeployMethod,
isEdit = false
@@ -200,6 +191,6 @@ export function buildGitValidationSchema(
AutoUpdate: autoUpdateValidation().nullable(),
TLSSkipVerify: boolean().default(false),
}).concat(
gitAuthValidation(gitCredentials, isEdit, isCreatedFromCustomTemplate)
gitAuthValidation(isEdit, isCreatedFromCustomTemplate)
) as SchemaOf<GitFormModel>;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { GitCredentialsModel } from '../types';
import { GitAuthModel } from '../types';
export interface RefFieldModel extends GitCredentialsModel {
export interface RefFieldModel extends GitAuthModel {
RepositoryURL: string;
TLSSkipVerify?: boolean;
}
@@ -22,7 +22,5 @@ export const dummyGitForm: GitFormModel = {
AdditionalFiles: [],
RepositoryReferenceName: '',
ComposeFilePathInRepository: '',
NewCredentialName: '',
SaveCredential: false,
TLSSkipVerify: false,
};
@@ -7,7 +7,6 @@ import { useGitRefs } from '../queries/useGitRefs';
interface Creds {
username?: string;
password?: string;
gitCredentialId?: number;
authorizationType?: AuthTypeOption;
}
@@ -62,8 +61,7 @@ export function useGitRepoValidity({
}
);
const hasCreds =
!!(creds?.username && creds?.password) || !!creds?.gitCredentialId;
const hasCreds = !!(creds?.username && creds?.password);
const errorMessage = getGitValidityError(query.error, hasCreds);
@@ -12,7 +12,6 @@ export interface GitFilePreviewParams {
username?: string;
password?: string;
authorizationType?: AuthTypeOption;
gitCredentialId?: number;
tlsSkipVerify?: boolean;
}
@@ -12,7 +12,6 @@ interface RefsPayload {
username?: string;
password?: string;
authorizationType?: AuthTypeOption;
gitCredentialId?: number;
stackId?: number;
fromEdgeStack?: boolean;
createdFromCustomTemplateID?: number;
@@ -18,7 +18,6 @@ interface DeployGitPayload {
Prune?: boolean;
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
RepullImageAndRedeploy?: boolean;
RepositoryGitCredentialID?: number;
StackName?: string;
}
@@ -17,7 +17,6 @@ export interface GitStackPayload {
ConfigFilePath?: string;
RepositoryReferenceName?: string;
RepositoryAuthentication?: boolean;
RepositoryGitCredentialID?: number;
RepositoryUsername?: string;
RepositoryPassword?: string;
RepositoryAuthorizationType?: AuthTypeOption;
+3 -19
View File
@@ -1,7 +1,4 @@
import {
AuthTypeOption,
GitCredential,
} from '@/react/portainer/account/git-credentials/types';
import { AuthTypeOption } from '@/react/portainer/account/git-credentials/types';
import {
AutoUpdateModel,
@@ -20,7 +17,6 @@ export interface GitAuthenticationResponse {
Username?: string;
Password?: string;
AuthorizationType?: AuthTypeOption;
GitCredentialID?: number;
}
export interface RepoConfigResponse {
@@ -32,21 +28,13 @@ export interface RepoConfigResponse {
TLSSkipVerify: boolean;
}
export type GitCredentialsModel = {
export type GitAuthModel = {
RepositoryAuthentication?: boolean;
RepositoryUsername?: string;
RepositoryPassword?: string;
RepositoryGitCredentialID?: GitCredential['id'];
RepositoryAuthorizationType?: AuthTypeOption;
};
export type GitNewCredentialModel = {
NewCredentialName?: string;
SaveCredential?: boolean;
};
export type GitAuthModel = GitCredentialsModel & GitNewCredentialModel;
export type DeployMethod = 'compose' | 'manifest' | 'helm';
export interface GitFormModel extends GitAuthModel {
@@ -94,14 +82,10 @@ export function toGitFormModel(
RepositoryURL: URL,
ComposeFilePathInRepository: ConfigFilePath,
RepositoryReferenceName: ReferenceName,
RepositoryAuthentication: !!(
Authentication &&
(Authentication?.GitCredentialID || Authentication?.Username)
),
RepositoryAuthentication: !!Authentication?.Username,
RepositoryUsername: Authentication?.Username,
RepositoryPassword: Authentication?.Password,
RepositoryAuthorizationType: Authentication?.AuthorizationType,
RepositoryGitCredentialID: Authentication?.GitCredentialID,
TLSSkipVerify,
AutoUpdate: autoUpdate,
};
+1 -8
View File
@@ -7,20 +7,13 @@ import { GitFormModel, RepoConfigResponse } from './types';
export function getAuthentication(
model: Pick<
GitFormModel,
| 'RepositoryAuthentication'
| 'RepositoryPassword'
| 'RepositoryUsername'
| 'RepositoryGitCredentialID'
'RepositoryAuthentication' | 'RepositoryPassword' | 'RepositoryUsername'
>
) {
if (!model.RepositoryAuthentication) {
return undefined;
}
if (model.RepositoryGitCredentialID) {
return { gitCredentialId: model.RepositoryGitCredentialID };
}
return {
username: model.RepositoryUsername,
password: model.RepositoryPassword,
@@ -6,8 +6,6 @@ import { validation as commonFieldsValidation } from '@/react/portainer/custom-t
import { Platform } from '@/react/portainer/templates/types';
import { variablesValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm';
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
import { edgeFieldsetValidation } from '@/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation';
import { DeployMethod } from '@/react/portainer/gitops/types';
@@ -28,8 +26,6 @@ export function useValidation({
viewType: 'kube' | 'docker' | 'edge';
deployMethod: DeployMethod;
}) {
const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const customTemplatesQuery = useCustomTemplates({
params: {
edge: undefined,
@@ -60,12 +56,7 @@ export function useValidation({
}),
Git: mixed().when('Method', {
is: git.value,
then: () =>
buildGitValidationSchema(
gitCredentialsQuery.data || [],
false,
deployMethod
),
then: () => buildGitValidationSchema(false, deployMethod),
}),
Variables: variablesValidation(),
EdgeSettings: viewType === 'edge' ? edgeFieldsetValidation() : mixed(),
@@ -74,11 +65,6 @@ export function useValidation({
templates: customTemplatesQuery.data,
})
),
[
customTemplatesQuery.data,
gitCredentialsQuery.data,
viewType,
deployMethod,
]
[customTemplatesQuery.data, viewType, deployMethod]
);
}
@@ -6,8 +6,6 @@ import { validation as commonFieldsValidation } from '@/react/portainer/custom-t
import { Platform } from '@/react/portainer/templates/types';
import { variablesValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm';
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
import { edgeFieldsetValidation } from '@/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation';
import { DeployMethod } from '@/react/portainer/gitops/types';
@@ -26,8 +24,6 @@ export function useValidation({
viewType: TemplateViewType;
deployMethod: DeployMethod;
}) {
const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const customTemplatesQuery = useCustomTemplates({
params: {
edge: undefined,
@@ -49,13 +45,7 @@ export function useValidation({
.default(StackType.DockerCompose),
FileContent: string().required('Template is required.'),
Git: isGit
? buildGitValidationSchema(
gitCredentialsQuery.data || [],
false,
deployMethod
)
: mixed(),
Git: isGit ? buildGitValidationSchema(false, deployMethod) : mixed(),
Variables: variablesValidation(),
EdgeSettings: viewType === 'edge' ? edgeFieldsetValidation() : mixed(),
}).concat(
@@ -64,13 +54,6 @@ export function useValidation({
currentTemplateId: templateId,
})
),
[
customTemplatesQuery.data,
gitCredentialsQuery.data,
isGit,
templateId,
viewType,
deployMethod,
]
[customTemplatesQuery.data, isGit, templateId, viewType, deployMethod]
);
}
@@ -16,9 +16,6 @@ import { GitFormModel } from '@/react/portainer/gitops/types';
import { DefinitionFieldValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { AccessControlFormData } from '@/react/portainer/access-control/types';
import { applyResourceControl } from '@/react/portainer/access-control/access-control.service';
import { useCurrentUser } from '@/react/hooks/useUser';
import { UserId } from '@/portainer/users/types';
import { saveGitCredentialsIfNeeded } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
import { json2formData } from '@/portainer/helpers/json';
import { Platform } from '../../types';
@@ -43,14 +40,13 @@ interface CreateTemplatePayload {
}
export function useCreateTemplateMutation() {
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation(
async (
payload: CreateTemplatePayload & { AccessControl?: AccessControlFormData }
) => {
const template = await createTemplate(user.Id, payload);
const template = await createTemplate(payload);
const resourceControl = template.ResourceControl;
if (resourceControl && payload.AccessControl) {
@@ -66,29 +62,26 @@ export function useCreateTemplateMutation() {
);
}
function createTemplate(userId: UserId, payload: CreateTemplatePayload) {
function createTemplate(payload: CreateTemplatePayload) {
switch (payload.Method) {
case 'editor':
return createTemplateFromText(payload);
case 'upload':
return createTemplateFromFile(payload);
case 'repository':
return createTemplateAndGitCredential(userId, payload);
return createTemplateFromGitPayload(payload);
default:
throw new Error('Unknown method');
}
}
async function createTemplateAndGitCredential(
userId: UserId,
{ Git: gitModel, ...values }: CreateTemplatePayload
) {
const resolvedAuth = await saveGitCredentialsIfNeeded(userId, gitModel);
function createTemplateFromGitPayload({
Git: gitModel,
...values
}: CreateTemplatePayload) {
return createTemplateFromGit({
...values,
...gitModel,
...resolvedAuth,
...(values.EdgeSettings
? {
EdgeSettings: {
@@ -218,10 +211,6 @@ interface CustomTemplateFromGitRepositoryPayload {
RepositoryUsername?: string;
/** Password used in basic authentication when RepositoryAuthentication is true. */
RepositoryPassword?: string;
/** GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication
* is true and RepositoryUsername/RepositoryPassword are not provided
*/
RepositoryGitCredentialID?: number;
/** Path to the Stack file inside the Git repository. */
ComposeFilePathInRepository?: string;
/** Definitions of variables in the stack file. */
@@ -75,11 +75,6 @@ interface CustomTemplateUpdatePayload {
RepositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true */
RepositoryPassword?: string;
/**
* GitCredentialID used to identify the bound git credential.
* Required when RepositoryAuthentication is true and RepositoryUsername/RepositoryPassword are not provided
*/
RepositoryGitCredentialID?: number;
/** Path to the Stack file inside the Git repository */
ComposeFilePathInRepository?: string;
/** Content of stack file */
-1
View File
@@ -12,5 +12,4 @@ export const userHandlers = [
'/api/users/:userId/memberships',
() => HttpResponse.json([])
),
http.get('/api/users/:userId/gitcredentials', () => HttpResponse.json([])),
];