diff --git a/app/portainer/components/forms/git-form/git-form-auth-fieldset.controller.ts b/app/portainer/components/forms/git-form/git-form-auth-fieldset.controller.ts deleted file mode 100644 index 7ff59703a..000000000 --- a/app/portainer/components/forms/git-form/git-form-auth-fieldset.controller.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { IFormController } from 'angular'; -import { FormikErrors } from 'formik'; - -import { GitAuthModel } from '@/react/portainer/gitops/types'; -import { gitAuthValidation } from '@/react/portainer/gitops/AuthFieldset'; - -import { validateForm } from '@@/form-components/validate-form'; - -export default class GitFormAuthFieldsetController { - errors?: FormikErrors = {}; - - $async: (fn: () => Promise) => Promise; - - gitFormAuthFieldset?: IFormController; - - value?: GitAuthModel; - - isAuthEdit: boolean; - - onChange?: (value: GitAuthModel) => void; - - /* @ngInject */ - constructor($async: (fn: () => Promise) => Promise) { - this.$async = $async; - - this.isAuthEdit = false; - this.handleChange = this.handleChange.bind(this); - this.runGitValidation = this.runGitValidation.bind(this); - } - - async handleChange(newValues: Partial) { - // this should never happen, but just in case - if (!this.value) { - throw new Error('GitFormController: value is required'); - } - - const value = { - ...this.value, - ...newValues, - }; - this.onChange?.(value); - await this.runGitValidation(value, this.isAuthEdit); - } - - async runGitValidation(value: GitAuthModel, isAuthEdit: boolean) { - return this.$async(async () => { - this.errors = {}; - this.gitFormAuthFieldset?.$setValidity( - 'gitFormAuth', - true, - this.gitFormAuthFieldset - ); - - this.errors = await validateForm( - () => gitAuthValidation(isAuthEdit, false), - value - ); - if (this.errors && Object.keys(this.errors).length > 0) { - this.gitFormAuthFieldset?.$setValidity( - 'gitFormAuth', - false, - this.gitFormAuthFieldset - ); - } - }); - } - - async $onInit() { - // 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); - } -} diff --git a/app/portainer/components/forms/git-form/git-form-auth-fieldset.ts b/app/portainer/components/forms/git-form/git-form-auth-fieldset.ts deleted file mode 100644 index 702b74f3a..000000000 --- a/app/portainer/components/forms/git-form/git-form-auth-fieldset.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { IComponentOptions } from 'angular'; - -import controller from './git-form-auth-fieldset.controller'; - -export const gitFormAuthFieldset: IComponentOptions = { - controller, - template: ` - - - -`, - bindings: { - value: '<', - onChange: '<', - isAuthExplanationVisible: '<', - isAuthEdit: '<', - }, -}; diff --git a/app/portainer/components/forms/git-form/git-form-auto-update-fieldset.controller.ts b/app/portainer/components/forms/git-form/git-form-auto-update-fieldset.controller.ts deleted file mode 100644 index febb285d6..000000000 --- a/app/portainer/components/forms/git-form/git-form-auto-update-fieldset.controller.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { IFormController } from 'angular'; -import { FormikErrors } from 'formik'; - -import { IAuthenticationService } from '@/portainer/services/types'; -import { AutoUpdateModel } from '@/react/portainer/gitops/types'; -import { autoUpdateValidation } from '@/react/portainer/gitops/AutoUpdateFieldset/validation'; - -import { validateForm } from '@@/form-components/validate-form'; - -export default class GitFormAutoUpdateFieldsetController { - errors?: FormikErrors = {}; - - $async: (fn: () => Promise) => Promise; - - gitFormAutoUpdate?: IFormController; - - Authentication: IAuthenticationService; - - value?: AutoUpdateModel; - - onChange?: (value: AutoUpdateModel) => void; - - /* @ngInject */ - constructor( - $async: (fn: () => Promise) => Promise, - Authentication: IAuthenticationService - ) { - this.$async = $async; - this.Authentication = Authentication; - - this.handleChange = this.handleChange.bind(this); - this.runGitValidation = this.runGitValidation.bind(this); - } - - async handleChange(newValues: Partial) { - // this should never happen, but just in case - if (!this.value) { - throw new Error('GitFormController: value is required'); - } - - const value = { - ...this.value, - ...newValues, - }; - this.onChange?.(value); - await this.runGitValidation(value); - } - - async runGitValidation(value: AutoUpdateModel) { - return this.$async(async () => { - this.errors = {}; - this.gitFormAutoUpdate?.$setValidity( - 'gitFormAuth', - true, - this.gitFormAutoUpdate - ); - - this.errors = await validateForm( - () => autoUpdateValidation(), - value - ); - if (this.errors && Object.keys(this.errors).length > 0) { - this.gitFormAutoUpdate?.$setValidity( - 'gitFormAuth', - false, - this.gitFormAutoUpdate - ); - } - }); - } - - async $onInit() { - // this should never happen, but just in case - if (!this.value) { - throw new Error('GitFormController: value is required'); - } - - await this.runGitValidation(this.value); - } -} diff --git a/app/portainer/components/forms/git-form/git-form-auto-update-fieldset.ts b/app/portainer/components/forms/git-form/git-form-auto-update-fieldset.ts deleted file mode 100644 index 2cacd537f..000000000 --- a/app/portainer/components/forms/git-form/git-form-auto-update-fieldset.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { IComponentOptions } from 'angular'; - -import controller from './git-form-auto-update-fieldset.controller'; - -export const gitFormAutoUpdate: IComponentOptions = { - template: ` - - - `, - bindings: { - value: '<', - onChange: '<', - environmentType: '@', - isForcePullVisible: '<', - baseWebhookUrl: '@', - webhookId: '@', - webhooksDocs: '@', - }, - controller, -}; diff --git a/app/portainer/components/forms/git-form/git-form-ref-field.ts b/app/portainer/components/forms/git-form/git-form-ref-field.ts deleted file mode 100644 index 670c2d123..000000000 --- a/app/portainer/components/forms/git-form/git-form-ref-field.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { IComponentOptions, IFormController } from 'angular'; - -import { GitFormModel } from '@/react/portainer/gitops/types'; -import { AsyncService } from '@/portainer/services/types'; -import { refFieldValidation } from '@/react/portainer/gitops/RefField/RefField'; - -import { validateForm } from '@@/form-components/validate-form'; - -class GitFormRefFieldController { - $async: AsyncService; - - value?: string; - - onChange?: (value: string) => void; - - gitFormRefField?: IFormController; - - error?: string = ''; - - model?: GitFormModel; - - stackId?: number = 0; - - /* @ngInject */ - constructor($async: AsyncService) { - this.$async = $async; - - this.handleChange = this.handleChange.bind(this); - this.runValidation = this.runValidation.bind(this); - } - - async handleChange(value: string) { - return this.$async(async () => { - this.onChange?.(value); - await this.runValidation(value); - }); - } - - async runValidation(value: string) { - return this.$async(async () => { - this.error = ''; - this.gitFormRefField?.$setValidity( - 'gitFormRefField', - true, - this.gitFormRefField - ); - - this.error = await validateForm( - () => refFieldValidation(), - value - ); - if (this.error) { - this.gitFormRefField?.$setValidity( - 'gitFormRefField', - false, - this.gitFormRefField - ); - } - }); - } -} - -export const gitFormRefField: IComponentOptions = { - controller: GitFormRefFieldController, - template: ` - - - -`, - bindings: { - isUrlValid: '<', - value: '<', - onChange: '<', - model: '<', - stackId: '<', - }, -}; diff --git a/app/portainer/components/forms/git-form/git-form.controller.ts b/app/portainer/components/forms/git-form/git-form.controller.ts index 789f82207..43a52956f 100644 --- a/app/portainer/components/forms/git-form/git-form.controller.ts +++ b/app/portainer/components/forms/git-form/git-form.controller.ts @@ -19,8 +19,6 @@ export default class GitFormController { deployMethod?: DeployMethod; - isSourceSelectionVisible?: boolean; - /* @ngInject */ constructor($async: (fn: () => Promise) => Promise) { this.$async = $async; @@ -41,26 +39,15 @@ export default class GitFormController { }; this.onChange?.(value); - const isCreatedFromCustomTemplate = - !!this.createdFromCustomTemplateId && - this.createdFromCustomTemplateId > 0; - await this.runGitFormValidation(value, isCreatedFromCustomTemplate); + await this.runGitFormValidation(value); } - async runGitFormValidation( - value: GitFormModel, - isCreatedFromCustomTemplate: boolean - ) { + async runGitFormValidation(value: GitFormModel) { return this.$async(async () => { this.errors = {}; this.gitForm?.$setValidity('gitForm', true, this.gitForm); - this.errors = await validateGitForm( - value, - isCreatedFromCustomTemplate, - this.deployMethod, - this.isSourceSelectionVisible - ); + this.errors = await validateGitForm(value, this.deployMethod); if (this.errors && Object.keys(this.errors).length > 0) { this.gitForm?.$setValidity('gitForm', false, this.gitForm); } @@ -73,9 +60,6 @@ export default class GitFormController { throw new Error('GitFormController: value is required'); } - const isCreatedFromCustomTemplate = - !!this.createdFromCustomTemplateId && - this.createdFromCustomTemplateId > 0; - await this.runGitFormValidation(this.value, isCreatedFromCustomTemplate); + await this.runGitFormValidation(this.value); } } diff --git a/app/portainer/components/forms/git-form/git-form.ts b/app/portainer/components/forms/git-form/git-form.ts index 90247f294..20eca21d3 100644 --- a/app/portainer/components/forms/git-form/git-form.ts +++ b/app/portainer/components/forms/git-form/git-form.ts @@ -18,7 +18,6 @@ export const gitForm: IComponentOptions = { webhook-id="$ctrl.webhookId" webhooks-docs="$ctrl.webhooksDocs" created-from-custom-template-id="$ctrl.createdFromCustomTemplateId" - is-source-selection-visible="$ctrl.isSourceSelectionVisible" errors="$ctrl.errors"> `, @@ -35,7 +34,6 @@ export const gitForm: IComponentOptions = { webhookId: '@', webhooksDocs: '@', createdFromCustomTemplateId: '<', - isSourceSelectionVisible: '<', }, controller, }; diff --git a/app/portainer/components/forms/git-form/index.ts b/app/portainer/components/forms/git-form/index.ts index 26793f584..b6b4cc4c7 100644 --- a/app/portainer/components/forms/git-form/index.ts +++ b/app/portainer/components/forms/git-form/index.ts @@ -1,13 +1,7 @@ import angular from 'angular'; import { gitForm } from './git-form'; -import { gitFormAuthFieldset } from './git-form-auth-fieldset'; -import { gitFormAutoUpdate } from './git-form-auto-update-fieldset'; -import { gitFormRefField } from './git-form-ref-field'; export const gitFormModule = angular .module('portainer.app.components.git-form', []) - .component('gitForm', gitForm) // kube deploy + docker stack create - .component('gitFormAuthFieldset', gitFormAuthFieldset) - .component('gitFormAutoUpdateFieldset', gitFormAutoUpdate) - .component('gitFormRefField', gitFormRefField).name; + .component('gitForm', gitForm).name; diff --git a/app/portainer/react/components/git-form.ts b/app/portainer/react/components/git-form.ts index d798df263..74dbf1449 100644 --- a/app/portainer/react/components/git-form.ts +++ b/app/portainer/react/components/git-form.ts @@ -4,12 +4,7 @@ import { r2a } from '@/react-tools/react2angular'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; -import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset'; import { GitForm } from '@/react/portainer/gitops/GitForm'; -import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset'; -import { RefField } from '@/react/portainer/gitops/RefField'; -import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay'; -import { GitReferenceCard } from '@/react/portainer/gitops/GitReferenceCard'; export const gitFormModule = angular .module('portainer.app.components.forms.git', []) @@ -23,63 +18,10 @@ export const gitFormModule = angular 'deployMethod', 'isAdditionalFilesFieldVisible', 'isForcePullVisible', - 'isAuthExplanationVisible', 'errors', 'baseWebhookUrl', 'webhookId', 'webhooksDocs', - 'createdFromCustomTemplateId', 'isAutoUpdateVisible', - 'isSourceSelectionVisible', ]) - ) - - .component( - 'gitFormGitReferenceCard', - r2a(withReactQuery(GitReferenceCard), [ - 'stackId', - 'stackType', - 'gitConfig', - 'autoUpdate', - 'currentDeploymentInfo', - 'sourceId', - ]) - ) - .component( - 'reactGitFormAutoUpdateFieldset', - r2a(withUIRouter(withReactQuery(AutoUpdateFieldset)), [ - 'value', - 'onChange', - 'environmentType', - 'isForcePullVisible', - 'errors', - 'baseWebhookUrl', - 'webhookId', - 'webhooksDocs', - ]) - ) - .component( - 'reactGitFormAuthFieldset', - r2a(withUIRouter(withReactQuery(withCurrentUser(AuthFieldset))), [ - 'value', - 'isAuthExplanationVisible', - 'onChange', - 'errors', - ]) - ) - .component( - 'reactGitFormRefField', - r2a(withUIRouter(withReactQuery(withCurrentUser(RefField))), [ - 'error', - 'model', - 'onChange', - 'stackId', - 'createdFromCustomTemplateId', - 'value', - 'isUrlValid', - ]) - ) - .component( - 'timeWindowDisplay', - r2a(withReactQuery(withUIRouter(TimeWindowDisplay)), []) ).name; diff --git a/app/react/common/stacks/EditGitSettings/EditGitSettingsModal.test.tsx b/app/react/common/stacks/EditGitSettings/EditGitSettingsModal.test.tsx index eb25e8b0e..b574bbab3 100644 --- a/app/react/common/stacks/EditGitSettings/EditGitSettingsModal.test.tsx +++ b/app/react/common/stacks/EditGitSettings/EditGitSettingsModal.test.tsx @@ -29,6 +29,7 @@ vi.mock('@/portainer/services/notifications', () => ({ const mockStack = createMockStack({ Id: 1, EndpointId: 1, + GitSourceId: 1, GitConfig: { URL: 'https://github.com/test/repo', ReferenceName: 'main', @@ -146,6 +147,20 @@ describe('EditGitSettingsModal', () => { function renderComponent(onClose = vi.fn()) { server.use( + http.get('/api/gitops/sources', () => + HttpResponse.json([ + { + id: 1, + name: 'test-source', + type: 'git', + url: 'https://github.com/test/repo', + status: 'valid', + usedBy: 0, + environments: 0, + lastSync: 0, + }, + ]) + ), http.post('/api/gitops/repo/refs', () => HttpResponse.json([])), http.post('/api/gitops/repo/files/search', () => HttpResponse.json([])) ); diff --git a/app/react/common/stacks/EditGitSettings/EditGitSettingsModal.tsx b/app/react/common/stacks/EditGitSettings/EditGitSettingsModal.tsx index b1f47d00e..77c45344c 100644 --- a/app/react/common/stacks/EditGitSettings/EditGitSettingsModal.tsx +++ b/app/react/common/stacks/EditGitSettings/EditGitSettingsModal.tsx @@ -19,13 +19,19 @@ interface Props { } export function EditGitSettingsModal({ stack, onClose }: Props) { - const validationSchema = useValidationSchema(stack.Type, !!stack.GitSourceId); + const validationSchema = useValidationSchema(stack.Type); const [webhookId] = useState( () => stack.AutoUpdate?.Webhook || createWebhookId() ); + const mutation = useUpdateGitStack(stack); + const gitModel = toGitFormModel( - stack.GitConfig, + stack.GitSourceId, + { + ReferenceName: stack.GitConfig?.ReferenceName ?? '', + ConfigFilePath: stack.GitConfig?.ConfigFilePath ?? '', + }, parseAutoUpdateResponse(stack.AutoUpdate) ); @@ -35,15 +41,12 @@ export function EditGitSettingsModal({ stack, onClose }: Props) { ...gitModel, AdditionalFiles: stack.AdditionalFiles || [], SourceId: stack.GitSourceId, - RepositoryURLValid: !!gitModel.RepositoryURL, }, env: stack.Env || [], prune: stack.Option?.Prune || false, redeployNow: false, }; - const mutation = useUpdateGitStack(stack); - return ( void; isSubmitting: boolean; webhookId: string; @@ -99,7 +97,6 @@ export function InnerForm({ baseWebhookUrl={baseStackWebhookUrl()} webhookId={webhookId} webhooksDocs="/user/docker/stacks/webhooks" - isAuthExplanationVisible isAdditionalFilesFieldVisible isAutoUpdateVisible errors={errors.git} @@ -107,7 +104,6 @@ export function InnerForm({ stackType === StackType.Kubernetes ? 'manifest' : 'compose' } isDockerStandalone={isDockerStandalone} - isSourceSelectionVisible={!!gitSourceId} /> { const isKubernetes = stackType === StackType.Kubernetes; @@ -22,17 +21,12 @@ export function useValidationSchema( name: string().default(''), }).required() : object({ name: string().default('') }).optional(), - git: buildGitValidationSchema( - false, - isKubernetes ? 'manifest' : 'compose', - true, - isSourceSelection - ), + git: buildGitValidationSchema(isKubernetes ? 'manifest' : 'compose'), env: envVarValidation(), prune: boolean().default(false), redeployNow: boolean().default(false), }), - [isKubernetes, isSourceSelection] + [isKubernetes] ); } diff --git a/app/react/common/stacks/GitPullButton.tsx b/app/react/common/stacks/GitPullButton.tsx index 5011b33b8..28d661916 100644 --- a/app/react/common/stacks/GitPullButton.tsx +++ b/app/react/common/stacks/GitPullButton.tsx @@ -46,12 +46,8 @@ export function GitPullButton({ stack }: { stack: Stack }) { mutation.mutate( { RepullImageAndRedeploy: result.repullImageAndRedeploy, - RepositoryAuthentication: !!stack.GitConfig?.Authentication, Env: stack.Env || [], Prune: stack.Option?.Prune, - RepositoryAuthorizationType: - stack.GitConfig?.Authentication?.AuthorizationType, - RepositoryUsername: stack.GitConfig?.Authentication?.Username, }, { onSuccess: () => { diff --git a/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts b/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts index 2e8a10f10..46f128739 100644 --- a/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts +++ b/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts @@ -15,22 +15,14 @@ export type KubernetesGitRepositoryPayload = { /** When set, URL and auth are resolved from the stored Source record */ sourceId?: number; - /** URL of a Git repository hosting the Stack file */ - repositoryUrl: string; /** Reference name of a Git repository hosting the Stack file */ repositoryReferenceName?: string; - /** Use basic authentication to clone the Git repository */ - repositoryAuthentication?: boolean; - /** Username used in basic authentication. Required when RepositoryAuthentication is true. */ - repositoryUsername?: string; - /** Password used in basic authentication. Required when RepositoryAuthentication is true. */ - repositoryPassword?: string; + /** Path to the Stack file inside the Git repository */ manifestFile?: string; additionalFiles?: Array; - /** TLSSkipVerify skips SSL verification when cloning the Git repository */ - tlsSkipVerify?: boolean; + /** Optional GitOps update configuration */ autoUpdate?: AutoUpdateResponse | null; environmentId: EnvironmentId; diff --git a/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts b/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts index f46d5e95f..76957d1ca 100644 --- a/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts +++ b/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts @@ -16,16 +16,11 @@ export type StandaloneGitRepositoryPayload = { /** Whether the stack is from an app template */ fromAppTemplate?: boolean; - /** URL of a Git repository hosting the Stack file */ - repositoryUrl: string; + /** URL of a Git repository hosting the Stack file (used for app templates) */ + repositoryUrl?: string; /** Reference name of a Git repository hosting the Stack file */ repositoryReferenceName?: string; - /** Use basic authentication to clone the Git repository */ - repositoryAuthentication?: boolean; - /** Username used in basic authentication. Required when RepositoryAuthentication is true. */ - repositoryUsername?: string; - /** Password used in basic authentication. Required when RepositoryAuthentication is true. */ - repositoryPassword?: string; + /** Path to the Stack file inside the Git repository */ composeFile?: string; @@ -38,8 +33,7 @@ export type StandaloneGitRepositoryPayload = { supportRelativePath?: boolean; /** Local filesystem path */ filesystemPath?: string; - /** TLSSkipVerify skips SSL verification when cloning the Git repository */ - tlsSkipVerify?: boolean; + /** ID of an existing Source. When set, repositoryUrl and authentication fields are ignored. */ sourceId?: number; environmentId: EnvironmentId; diff --git a/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts b/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts index 6593fbb56..f4ca507d7 100644 --- a/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts +++ b/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts @@ -18,16 +18,11 @@ export type SwarmGitRepositoryPayload = { /** Swarm cluster identifier */ swarmID: string; - /** URL of a Git repository hosting the Stack file */ - repositoryUrl: string; + /** URL of a Git repository hosting the Stack file (used for app templates) */ + repositoryUrl?: string; /** Reference name of a Git repository hosting the Stack file */ repositoryReferenceName?: string; - /** Use basic authentication to clone the Git repository */ - repositoryAuthentication?: boolean; - /** Username used in basic authentication. Required when RepositoryAuthentication is true. */ - repositoryUsername?: string; - /** Password used in basic authentication. Required when RepositoryAuthentication is true. */ - repositoryPassword?: string; + /** Path to the Stack file inside the Git repository */ composeFile?: string; @@ -40,8 +35,7 @@ export type SwarmGitRepositoryPayload = { supportRelativePath?: boolean; /** Local filesystem path */ filesystemPath?: string; - /** TLSSkipVerify skips SSL verification when cloning the Git repository */ - tlsSkipVerify?: boolean; + /** ID of an existing Source. When set, repositoryUrl and authentication fields are ignored. */ sourceId?: number; environmentId: EnvironmentId; diff --git a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts index 8004f71be..09ef62742 100644 --- a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts +++ b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts @@ -196,12 +196,8 @@ function createSwarmStack({ method, payload }: SwarmCreatePayload) { repositoryUrl: payload.git.RepositoryURL, repositoryReferenceName: payload.git.RepositoryReferenceName, composeFile: payload.git.ComposeFilePathInRepository, - repositoryAuthentication: payload.git.RepositoryAuthentication, - repositoryUsername: payload.git.RepositoryUsername, - repositoryPassword: payload.git.RepositoryPassword, filesystemPath: payload.relativePathSettings?.FilesystemPath, supportRelativePath: payload.relativePathSettings?.SupportRelativePath, - tlsSkipVerify: payload.git.TLSSkipVerify, sourceId: payload.git.SourceId, autoUpdate: transformAutoUpdateViewModel( payload.git.AutoUpdate, @@ -247,12 +243,8 @@ function createStandaloneStack({ method, payload }: StandaloneCreatePayload) { repositoryUrl: payload.git.RepositoryURL, repositoryReferenceName: payload.git.RepositoryReferenceName, composeFile: payload.git.ComposeFilePathInRepository, - repositoryAuthentication: payload.git.RepositoryAuthentication, - repositoryUsername: payload.git.RepositoryUsername, - repositoryPassword: payload.git.RepositoryPassword, filesystemPath: payload.relativePathSettings?.FilesystemPath, supportRelativePath: payload.relativePathSettings?.SupportRelativePath, - tlsSkipVerify: payload.git.TLSSkipVerify, sourceId: payload.git.SourceId, autoUpdate: transformAutoUpdateViewModel( payload.git.AutoUpdate, @@ -294,14 +286,9 @@ function createKubernetesStack({ method, payload }: KubernetesCreatePayload) { stackName: payload.name, sourceId: payload.git.SourceId, - repositoryUrl: payload.git.RepositoryURL, repositoryReferenceName: payload.git.RepositoryReferenceName, manifestFile: payload.git.ComposeFilePathInRepository, - repositoryAuthentication: payload.git.RepositoryAuthentication, - repositoryUsername: payload.git.RepositoryUsername, - repositoryPassword: payload.git.RepositoryPassword, - tlsSkipVerify: payload.git.TLSSkipVerify, autoUpdate: transformAutoUpdateViewModel( payload.git.AutoUpdate, payload.webhook diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.test.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.test.tsx index cabdb4d24..5f7fc097a 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.test.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.test.tsx @@ -325,7 +325,7 @@ describe('CreateStackForm', () => { expect(requestBody).toMatchObject( expect.objectContaining({ name: 'test-stack', - repositoryUrl: 'https://github.com/test/repo', + sourceId: 1, repositoryReferenceName: 'refs/heads/main', composeFile: 'docker-compose.yml', }) diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.webhook.test.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.webhook.test.tsx index 8df8ebf2d..d7d38a0eb 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.webhook.test.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.webhook.test.tsx @@ -96,13 +96,8 @@ describe('CreateStackForm - Webhook ID Integration', () => { method: 'repository', name: 'test-stack', git: { - RepositoryURL: 'https://github.com/test/repo', RepositoryReferenceName: 'main', ComposeFilePathInRepository: 'docker-compose.yml', - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', - TLSSkipVerify: false, AdditionalFiles: [], AutoUpdate: { RepositoryAutomaticUpdates: true, @@ -111,7 +106,6 @@ describe('CreateStackForm - Webhook ID Integration', () => { ForcePullImage: false, RepositoryAutomaticUpdatesForce: false, }, - RepositoryAuthorizationType: undefined, SupportRelativePath: false, FilesystemPath: '', }, diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.test.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.test.tsx index 7da40bb37..37cf38ed4 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.test.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.test.tsx @@ -59,10 +59,9 @@ function renderComponent({ const values = mockFormValues({ method: 'repository', git: { - RepositoryURL: '', + SourceId: 0, RepositoryReferenceName: 'refs/heads/main', ComposeFilePathInRepository: 'docker-compose.yml', - TLSSkipVerify: false, AdditionalFiles: [], AutoUpdate: undefined, SupportRelativePath: false, diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.tsx index f2214cfa7..6d742139c 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.tsx @@ -33,9 +33,7 @@ export function GitSection({ webhookId, isDockerStandalone = false }: Props) { deployMethod="compose" isDockerStandalone={isDockerStandalone} isAdditionalFilesFieldVisible - isAuthExplanationVisible isForcePullVisible - isSourceSelectionVisible errors={errors.git} baseWebhookUrl={baseStackWebhookUrl()} webhookId={webhookId} diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.test.ts b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.test.ts index 64f950b67..57002167a 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.test.ts +++ b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.test.ts @@ -7,16 +7,10 @@ describe('Git validation', () => { const validData: GitFormValues = { SourceId: 1, - RepositoryURL: 'https://github.com/user/repo', RepositoryReferenceName: 'refs/heads/main', ComposeFilePathInRepository: 'docker-compose.yml', - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', - TLSSkipVerify: false, AdditionalFiles: [], AutoUpdate: undefined, - RepositoryAuthorizationType: undefined, SupportRelativePath: false, FilesystemPath: '', }; @@ -24,11 +18,10 @@ describe('Git validation', () => { await expect(schema.validate(validData)).resolves.toBeDefined(); }); - it('should fail validation when repository URL is empty', async () => { + it('should fail validation when SourceId is missing', async () => { const schema = getGitValidationSchema(); const invalidData = { - RepositoryURL: '', RepositoryReferenceName: 'refs/heads/main', ComposeFilePathInRepository: 'docker-compose.yml', }; diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.ts b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.ts index c973c2d3d..90eecfe67 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.ts +++ b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.ts @@ -5,7 +5,7 @@ import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm'; import { GitFormValues } from './types'; export function getGitValidationSchema(): SchemaOf { - return buildGitValidationSchema(false, 'compose', false, true).concat( + return buildGitValidationSchema('compose').concat( object({ SupportRelativePath: boolean().default(false), FilesystemPath: string() diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/test-utils.ts b/app/react/docker/stacks/CreateView/CreateStackForm/test-utils.ts index 67fe33993..e08eb89bb 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/test-utils.ts +++ b/app/react/docker/stacks/CreateView/CreateStackForm/test-utils.ts @@ -21,16 +21,11 @@ export function mockFormValues(overrides: DeepPartial): FormValues { file: null, }, git: { - RepositoryURL: '', + SourceId: 0, RepositoryReferenceName: '', ComposeFilePathInRepository: '', - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', - TLSSkipVerify: false, AdditionalFiles: [], AutoUpdate: undefined, - RepositoryAuthorizationType: undefined, SupportRelativePath: false, FilesystemPath: '', }, diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.test.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.test.tsx index 44e1020e2..388944bc5 100644 --- a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.test.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.test.tsx @@ -158,6 +158,7 @@ describe('conditional form rendering', () => { ConfigHash: '', TLSSkipVerify: false, }, + GitSourceId: 1, FromAppTemplate: false, }); renderComponent({ @@ -266,6 +267,7 @@ describe('git and duplication form combination', () => { ConfigHash: '', TLSSkipVerify: false, }, + GitSourceId: 1, FromAppTemplate: false, }); renderComponent({ diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx index 3d71be7f5..bf2aa291c 100644 --- a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx @@ -94,16 +94,17 @@ export function StackInfoTab({ /> ) : (
- {stack.GitConfig && !stack.FromAppTemplate && ( - - )} + {!!stack.GitConfig && + !stack.FromAppTemplate && + !!stack.GitSourceId && ( + + )} {isRegular && !!stackFileContent && ( , relativePath: mixed().when('method', { diff --git a/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx b/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx index 8d9366e5c..608e97500 100644 --- a/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx +++ b/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx @@ -140,7 +140,6 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) { baseWebhookUrl={baseEdgeStackWebhookUrl()} webhookId={webhookId} isAutoUpdateVisible={isBE} - isSourceSelectionVisible /> {isBE && ( diff --git a/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx b/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx index 2cc674fa1..4b2d3504f 100644 --- a/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx +++ b/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx @@ -111,7 +111,6 @@ export function KubeManifestForm({ baseWebhookUrl={baseEdgeStackWebhookUrl()} webhookId={webhookId} isAutoUpdateVisible={isBE} - isSourceSelectionVisible /> )} diff --git a/app/react/edge/edge-stacks/CreateView/tests/custom-templates.test.tsx b/app/react/edge/edge-stacks/CreateView/tests/custom-templates.test.tsx index ee7bbae98..345938edb 100644 --- a/app/react/edge/edge-stacks/CreateView/tests/custom-templates.test.tsx +++ b/app/react/edge/edge-stacks/CreateView/tests/custom-templates.test.tsx @@ -47,18 +47,13 @@ const expectedCustomTemplatePayload = { UpdateFailureAction: 3, }, useManifestNamespaces: false, - repositoryUrl: 'https://github.com/testA113/nginx-public', - repositoryUsername: '', repositoryReferenceName: 'refs/heads/main', filePathInRepository: 'docker/voting.yaml', - repositoryAuthentication: false, - repositoryPassword: '', filesystemPath: '/test', supportRelativePath: true, perDeviceConfigsGroupMatchType: 'file', perDeviceConfigsMatchType: 'file', perDeviceConfigsPath: 'test', - tlsSkipVerify: false, autoUpdate: null, }; diff --git a/app/react/edge/edge-stacks/CreateView/useRenderCustomTemplate.tsx b/app/react/edge/edge-stacks/CreateView/useRenderCustomTemplate.tsx index 21c930fff..fb4da0be6 100644 --- a/app/react/edge/edge-stacks/CreateView/useRenderCustomTemplate.tsx +++ b/app/react/edge/edge-stacks/CreateView/useRenderCustomTemplate.tsx @@ -3,7 +3,10 @@ import { SetStateAction, useEffect, useState } from 'react'; import { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; import { useCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile'; import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate'; -import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types'; +import { + CustomTemplate, + getTemplateSourceId, +} from '@/react/portainer/templates/custom-templates/types'; import { StackType } from '@/react/common/stacks/types'; import { toGitFormModel } from '@/react/portainer/gitops/types'; @@ -79,7 +82,7 @@ function getValuesFromTemplate( template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose, - git: toGitFormModel(template.GitConfig), + git: toGitFormModel(getTemplateSourceId(template), template.GitConfig), ...(template.EdgeSettings ? { prePullImage: template.EdgeSettings.PrePullImage || false, diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.test.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.test.tsx index 8f00220eb..bbd70c7d5 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.test.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.test.tsx @@ -48,6 +48,7 @@ describe('GitForm', () => { Password: '', }, }, + GitSourceId: 1, PrePullImage: false, RetryDeploy: false, RetryPeriod: 0, diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx index 18fd23791..33f169be6 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx @@ -1,10 +1,10 @@ import { useState } from 'react'; import { Form, Formik, useFormikContext } from 'formik'; import { useRouter } from '@uirouter/react'; +import { array, number, object } from 'yup'; -import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset'; import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset'; -import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { GitSourceSelector } from '@/react/portainer/gitops/sources/GitSourceSelector'; import { parseAutoUpdateResponse, transformAutoUpdateViewModel, @@ -12,17 +12,12 @@ import { import { RefField } from '@/react/portainer/gitops/RefField'; import { AutoUpdateModel, - GitAuthModel, RelativePathModel, } from '@/react/portainer/gitops/types'; import { baseEdgeStackWebhookUrl, createWebhookId, } from '@/portainer/helpers/webhookHelper'; -import { - parseAuthResponse, - transformGitAuthenticationViewModel, -} from '@/react/portainer/gitops/AuthFieldset/utils'; import { EdgeGroup } from '@/react/edge/edge-groups/types'; import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types'; import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector'; @@ -33,6 +28,7 @@ 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 { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { GitReferenceCard } from '@/react/portainer/gitops/GitReferenceCard'; import { LoadingButton } from '@@/buttons'; @@ -41,6 +37,7 @@ import { TextTip } from '@@/Tip/TextTip'; import { FormError } from '@@/form-components/FormError'; import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset'; import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types'; +import { Link } from '@@/Link'; import { useEdgeGroupHasType } from '../useEdgeGroupHasType'; import { PrivateRegistryFieldset } from '../../../components/PrivateRegistryFieldset'; @@ -55,7 +52,6 @@ interface FormValues { deploymentType: DeploymentType; autoUpdate: AutoUpdateModel; refName: string; - authentication: GitAuthModel; envVars: EnvVar[]; privateRegistryId?: Registry['Id']; relativePath: RelativePathModel; @@ -73,26 +69,26 @@ export function GitForm({ stack }: { stack: EdgeStack }) { return null; } - const gitConfig = stack.GitConfig; - const initialValues: FormValues = { groupIds: stack.EdgeGroups, deploymentType: stack.DeploymentType, autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate), refName: stack.GitConfig.ReferenceName, - authentication: parseAuthResponse(stack.GitConfig.Authentication), relativePath: parseRelativePathResponse(stack), envVars: stack.EnvVars || [], }; return ( - + {({ values, isValid }) => { return ( - {!!stack.GitConfig && ( - - )} + setFieldValue('refName', value)} - model={{ ...values.authentication, RepositoryURL: gitUrl }} + sourceId={stack.GitSourceId} error={errors.refName} - isUrlValid /> - - Object.entries(value).forEach(([key, value]) => { - setFieldValue(`authentication.${key}`, value); - }) - } - errors={errors.authentication} - /> + + + Credentials are managed by the source.{' '} + + Edit source + + {isBE && ( ); } + +function formValidation() { + return object({ + groupIds: array() + .of(number().required()) + .required() + .min(1, 'At least one edge group is required'), + }); +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/useUpdateEdgeStackGitMutation.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/useUpdateEdgeStackGitMutation.ts index c81a53efc..b5af2b636 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/useUpdateEdgeStackGitMutation.ts +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/useUpdateEdgeStackGitMutation.ts @@ -6,10 +6,7 @@ import { withError, withInvalidate, } from '@/react-tools/react-query'; -import { - AutoUpdateResponse, - GitAuthenticationResponse, -} from '@/react/portainer/gitops/types'; +import { AutoUpdateResponse } from '@/react/portainer/gitops/types'; import { buildUrl } from '@/react/edge/edge-stacks/queries/buildUrl'; import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types'; import { EdgeGroup } from '@/react/edge/edge-groups/types'; @@ -21,7 +18,6 @@ export interface UpdateEdgeStackGitPayload { id: EdgeStack['Id']; autoUpdate: AutoUpdateResponse | null; refName: string; - authentication: GitAuthenticationResponse | null; groupIds: EdgeGroup['Id'][]; deploymentType: DeploymentType; updateVersion: boolean; diff --git a/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromGit.ts b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromGit.ts index 9a144079f..de04e6ffa 100644 --- a/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromGit.ts +++ b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromGit.ts @@ -19,12 +19,6 @@ export type GitRepositoryPayload = { repositoryUrl?: string; /** Reference name of a Git repository hosting the Stack file */ repositoryReferenceName?: string; - /** Use basic authentication to clone the Git repository */ - repositoryAuthentication?: boolean; - /** Username used in basic authentication. Required when RepositoryAuthentication is true. */ - repositoryUsername?: string; - /** Password used in basic authentication. Required when RepositoryAuthentication is true. */ - repositoryPassword?: string; /** Path to the Stack file inside the Git repository */ filePathInRepository?: string; /** List of identifiers of EdgeGroups */ diff --git a/app/react/edge/edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack.ts b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack.ts index 8aca06ddc..97cca3fab 100644 --- a/app/react/edge/edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack.ts +++ b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack.ts @@ -131,12 +131,8 @@ function createEdgeStackFromGit( staggerConfig: payload.staggerConfig, useManifestNamespaces: payload.useManifestNamespaces, sourceId: payload.git.SourceId, - repositoryUrl: payload.git.RepositoryURL, repositoryReferenceName: payload.git.RepositoryReferenceName, filePathInRepository: payload.git.ComposeFilePathInRepository, - repositoryAuthentication: payload.git.RepositoryAuthentication, - repositoryUsername: payload.git.RepositoryUsername, - repositoryPassword: payload.git.RepositoryPassword, filesystemPath: payload.relativePathSettings?.FilesystemPath, supportRelativePath: payload.relativePathSettings?.SupportRelativePath, perDeviceConfigsGroupMatchType: @@ -144,7 +140,6 @@ function createEdgeStackFromGit( perDeviceConfigsMatchType: payload.relativePathSettings?.PerDeviceConfigsMatchType, perDeviceConfigsPath: payload.relativePathSettings?.PerDeviceConfigsPath, - tlsSkipVerify: payload.git.TLSSkipVerify, autoUpdate: payload.autoUpdate, }); } diff --git a/app/react/edge/edge-stacks/types.ts b/app/react/edge/edge-stacks/types.ts index 158c49079..78f33744f 100644 --- a/app/react/edge/edge-stacks/types.ts +++ b/app/react/edge/edge-stacks/types.ts @@ -88,6 +88,7 @@ export type EdgeStack = Partial & { ManifestPath: string; DeploymentType: DeploymentType; UseManifestNamespaces: boolean; + GitSourceId?: number; } & Partial<{ // EE Registries: RegistryId[]; diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationDetailsWidget.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationDetailsWidget.tsx index 19fb38a29..04798c1b2 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationDetailsWidget.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationDetailsWidget.tsx @@ -68,10 +68,9 @@ export function ApplicationDetailsWidget() { {!isSystemNamespace && ( <> - {!!stack?.GitConfig && ( + {!!stack?.GitConfig && !!stack.GitSourceId && (
({ - isBE: true, - isLimitedToBE: () => false, -})); - -vi.mock('@/react/hooks/useDebounce', () => ({ - useDebounce: (value: unknown, callback: (value: unknown) => void) => [ - value, - callback, - ], -})); - -const defaultGitAuthModel: GitAuthModel = { - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', - RepositoryAuthorizationType: AuthTypeOption.Basic, -}; - -function renderAuthFieldset({ - value = defaultGitAuthModel, - onChange = vi.fn(), - isAuthExplanationVisible = false, - errors = {}, -}: { - value?: GitAuthModel; - onChange?: (value: Partial) => void; - isAuthExplanationVisible?: boolean; - errors?: Record; -} = {}) { - return render( - - ); -} - -describe('AuthFieldset', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('key component rendering', () => { - it('should render authentication toggle', () => { - renderAuthFieldset(); - - expect(screen.getByTestId('component-gitAuthToggle')).toBeInTheDocument(); - }); - - it('should render username input when authentication is enabled', () => { - renderAuthFieldset({ - value: { ...defaultGitAuthModel, RepositoryAuthentication: true }, - }); - - expect( - screen.getByTestId('component-gitUsernameInput') - ).toBeInTheDocument(); - }); - - it('should render password input when authentication is enabled', () => { - renderAuthFieldset({ - value: { ...defaultGitAuthModel, RepositoryAuthentication: true }, - }); - - expect( - screen.getByTestId('component-gitPasswordInput') - ).toBeInTheDocument(); - }); - - it('should not render interactive fields when authentication is disabled', () => { - renderAuthFieldset({ - value: { ...defaultGitAuthModel, RepositoryAuthentication: false }, - }); - - expect( - screen.queryByTestId('component-gitUsernameInput') - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId('component-gitPasswordInput') - ).not.toBeInTheDocument(); - }); - }); - - describe('props handling', () => { - it('should handle onChange prop', () => { - const onChange = vi.fn(); - renderAuthFieldset({ onChange }); - - expect(onChange).toBeDefined(); - }); - - it('should handle errors prop for username', () => { - const errors = { RepositoryUsername: 'Username is required' }; - renderAuthFieldset({ - value: { ...defaultGitAuthModel, RepositoryAuthentication: true }, - errors, - }); - - expect(screen.getByText('Username is required')).toBeInTheDocument(); - }); - - it('should handle errors prop for password', () => { - const errors = { RepositoryPassword: 'Password is required' }; - renderAuthFieldset({ - value: { ...defaultGitAuthModel, RepositoryAuthentication: true }, - errors, - }); - - expect(screen.getByText('Password is required')).toBeInTheDocument(); - }); - - it('should handle multiple errors', () => { - const errors = { - RepositoryUsername: 'Username is required', - RepositoryPassword: 'Password is required', - }; - renderAuthFieldset({ - value: { ...defaultGitAuthModel, RepositoryAuthentication: true }, - errors, - }); - - expect(screen.getByText('Username is required')).toBeInTheDocument(); - expect(screen.getByText('Password is required')).toBeInTheDocument(); - }); - - it('should handle empty errors object', () => { - const errors = {}; - renderAuthFieldset({ - value: { ...defaultGitAuthModel, RepositoryAuthentication: true }, - errors, - }); - - expect( - screen.getByTestId('component-gitUsernameInput') - ).toBeInTheDocument(); - expect( - screen.getByTestId('component-gitPasswordInput') - ).toBeInTheDocument(); - }); - - it('should handle isAuthExplanationVisible prop when true', () => { - renderAuthFieldset({ - value: { ...defaultGitAuthModel, RepositoryAuthentication: true }, - isAuthExplanationVisible: true, - }); - - expect( - screen.getByText( - 'Enabling authentication will store the credentials and it is advisable to use a git service account' - ) - ).toBeInTheDocument(); - }); - - it('should handle isAuthExplanationVisible prop when false', () => { - renderAuthFieldset({ - value: { ...defaultGitAuthModel, RepositoryAuthentication: true }, - isAuthExplanationVisible: false, - }); - - expect( - screen.queryByText( - 'Enabling authentication will store the credentials and it is advisable to use a git service account' - ) - ).not.toBeInTheDocument(); - }); - - it('should handle value prop with all fields populated', () => { - const value: GitAuthModel = { - RepositoryAuthentication: true, - RepositoryUsername: 'testuser', - RepositoryPassword: 'testpass', - RepositoryAuthorizationType: AuthTypeOption.Token, - }; - - renderAuthFieldset({ value }); - - expect( - screen.getByTestId('component-gitUsernameInput') - ).toBeInTheDocument(); - expect( - screen.getByTestId('component-gitPasswordInput') - ).toBeInTheDocument(); - }); - }); -}); - -describe('gitAuthValidation', () => { - describe('default values', () => { - it('should provide correct default values', async () => { - const schema = gitAuthValidation(false, false); - const result = await schema.validate({}); - - expect(result).toEqual({ - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', - RepositoryAuthorizationType: AuthTypeOption.Basic, - }); - }); - }); - - describe('authentication disabled', () => { - it('should allow empty values when authentication is disabled', async () => { - const schema = gitAuthValidation(false, false); - const data = { - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', - RepositoryAuthorizationType: AuthTypeOption.Basic, - }; - - const result = await schema.validate(data); - expect(result.RepositoryAuthentication).toBe(false); - }); - }); - - describe('authentication enabled', () => { - it('should require username when authentication is enabled', async () => { - const schema = gitAuthValidation(false, false); - const data = { - RepositoryAuthentication: true, - RepositoryUsername: '', - RepositoryPassword: 'password', - RepositoryAuthorizationType: AuthTypeOption.Basic, - }; - - await expect(schema.validate(data)).rejects.toThrow( - 'Username is required' - ); - }); - - 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, - RepositoryUsername: 'username', - RepositoryPassword: '', - RepositoryAuthorizationType: AuthTypeOption.Basic, - }; - - await expect(schema.validate(data)).rejects.toThrow( - 'Personal Access Token is required' - ); - }); - - it('should set default authorization type when authentication is enabled', async () => { - const schema = gitAuthValidation(false, false); - const data = { - RepositoryAuthentication: true, - RepositoryUsername: 'username', - RepositoryPassword: 'password', - RepositoryAuthorizationType: 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 data = { - RepositoryAuthentication: true, - RepositoryUsername: 'username', - RepositoryPassword: 'password', - RepositoryAuthorizationType: AuthTypeOption.Token, - }; - - const result = await schema.validate(data); - expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Token); - }); - - it('should reject invalid authorization types', async () => { - const schema = gitAuthValidation(false, false); - const data = { - RepositoryAuthentication: true, - RepositoryUsername: 'username', - RepositoryPassword: 'password', - RepositoryAuthorizationType: 999, - }; - - await expect(schema.validate(data)).rejects.toThrow(); - }); - }); - - describe('auth edit mode', () => { - it('should not require password when in auth edit mode', async () => { - const schema = gitAuthValidation(true, false); - const data = { - RepositoryAuthentication: true, - RepositoryUsername: 'username', - RepositoryPassword: '', - RepositoryAuthorizationType: AuthTypeOption.Basic, - }; - - const result = await schema.validate(data); - expect(result.RepositoryPassword).toBe(''); - }); - - it('should not require authorization type when in auth edit mode', async () => { - const schema = gitAuthValidation(true, false); - const data = { - RepositoryAuthentication: true, - RepositoryUsername: 'username', - RepositoryPassword: 'password', - RepositoryAuthorizationType: undefined, - }; - - const result = await schema.validate(data); - 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 data = { - RepositoryAuthentication: true, - RepositoryUsername: 'username', - RepositoryPassword: '', - RepositoryAuthorizationType: AuthTypeOption.Basic, - }; - - const result = await schema.validate(data); - expect(result.RepositoryPassword).toBe(''); - }); - - it('should not require authorization type when created from custom template', async () => { - const schema = gitAuthValidation(false, true); - const data = { - RepositoryAuthentication: true, - RepositoryUsername: 'username', - RepositoryPassword: 'password', - RepositoryAuthorizationType: undefined, - }; - - const result = await schema.validate(data); - expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Basic); - }); - }); - - describe('complex scenarios', () => { - it('should handle complete valid data', async () => { - const schema = gitAuthValidation(false, false); - const data = { - RepositoryAuthentication: true, - RepositoryUsername: 'testuser', - RepositoryPassword: 'testpassword', - RepositoryAuthorizationType: AuthTypeOption.Token, - }; - - const result = await schema.validate(data); - expect(result).toEqual(data); - }); - - it('should handle auth edit mode', async () => { - const schema = gitAuthValidation(true, false); - const data = { - RepositoryAuthentication: true, - RepositoryUsername: 'testuser', - RepositoryPassword: '', - RepositoryAuthorizationType: AuthTypeOption.Basic, - }; - - const result = await schema.validate(data); - expect(result.RepositoryPassword).toBe(''); - }); - - it('should handle custom template creation', async () => { - const schema = gitAuthValidation(false, true); - const data = { - RepositoryAuthentication: true, - RepositoryUsername: 'testuser', - RepositoryPassword: '', - RepositoryAuthorizationType: AuthTypeOption.Basic, - }; - - const result = await schema.validate(data); - expect(result.RepositoryPassword).toBe(''); - }); - }); -}); diff --git a/app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx b/app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx deleted file mode 100644 index d6de2d264..000000000 --- a/app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { FormikErrors } from 'formik'; -import { boolean, mixed, object, SchemaOf, string } from 'yup'; -import { useState } from 'react'; - -import { GitAuthModel } from '@/react/portainer/gitops/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 { CredentialsSection } from './CredentialsSection'; - -interface Props { - value: GitAuthModel; - onChange: (value: Partial) => void; - isAuthExplanationVisible?: boolean; - errors?: FormikErrors; -} - -export function AuthFieldset({ - value: initialValue, - onChange, - isAuthExplanationVisible, - errors, -}: Props) { - const [value, setValue] = useState(initialValue); // TODO: remove this state when form is not inside angularjs - - return ( - <> -
-
- - handleChange({ RepositoryAuthentication: value }) - } - data-cy="component-gitAuthToggle" - /> -
-
- - {value.RepositoryAuthentication && ( - <> - {isAuthExplanationVisible && ( - - Enabling authentication will store the credentials and it is - advisable to use a git service account - - )} - - - - )} - - ); - - function handleChange(partialValue: Partial) { - onChange(partialValue); - setValue((value) => ({ ...value, ...partialValue })); - } -} - -export function gitAuthValidation( - isAuthEdit: boolean, - isCreatedFromCustomTemplate: boolean -): SchemaOf { - return object({ - RepositoryAuthentication: boolean().default(false), - RepositoryUsername: string() - .when(['RepositoryAuthentication', 'SourceId'], { - is: (auth: boolean, sourceId?: number) => auth && !sourceId, - then: string().required('Username is required'), - }) - .default(''), - RepositoryPassword: string() - .when(['RepositoryAuthentication', 'SourceId'], { - is: (auth: boolean, sourceId?: number) => - auth && !sourceId && !isAuthEdit && !isCreatedFromCustomTemplate, - then: string().required('Personal Access Token is required'), - }) - .default(''), - RepositoryAuthorizationType: mixed() - .oneOf(Object.values(AuthTypeOption)) - .when(['RepositoryAuthentication', 'SourceId'], { - is: (auth: boolean, sourceId?: number) => - isBE && - auth && - !sourceId && - !isAuthEdit && - !isCreatedFromCustomTemplate, - then: mixed().required('Authorization type is required'), - }) - .default(AuthTypeOption.Basic), - }); -} diff --git a/app/react/portainer/gitops/AuthFieldset/CredentialsSection.tsx b/app/react/portainer/gitops/AuthFieldset/CredentialsSection.tsx deleted file mode 100644 index dee882d49..000000000 --- a/app/react/portainer/gitops/AuthFieldset/CredentialsSection.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { FormikErrors } from 'formik'; - -import { useDebounce } from '@/react/hooks/useDebounce'; - -import { FormControl } from '@@/form-components/FormControl'; -import { Input } from '@@/form-components/Input'; -import { RadioGroup } from '@@/RadioGroup/RadioGroup'; - -import { AuthTypeOption } from '../../account/git-credentials/types'; -import { isBE } from '../../feature-flags/feature-flags.service'; -import { GitAuthModel } from '../types'; - -export const defaultAuthTypeOptions = [ - { - value: AuthTypeOption.Basic, - label: 'Basic', - }, - { - value: AuthTypeOption.Token, - label: 'Token', - }, -] as const; - -export function CredentialsSection({ - value, - onChange, - errors, -}: { - value: GitAuthModel; - onChange: (value: Partial) => void; - errors?: FormikErrors; -}) { - const [username, setUsername] = useDebounce( - value.RepositoryUsername || '', - (username) => onChange({ RepositoryUsername: username }) - ); - const [password, setPassword] = useDebounce( - value.RepositoryPassword || '', - (password) => onChange({ RepositoryPassword: password }) - ); - const [authType, setAuthType] = useDebounce( - value.RepositoryAuthorizationType || AuthTypeOption.Basic, - (authType) => onChange({ RepositoryAuthorizationType: authType }) - ); - - return ( - <> - {isBE && ( -
-
- - setAuthType(value)} - name="AuthorizationType" - /> - -
-
- )} - -
-
- - setUsername(e.target.value)} - data-cy="component-gitUsernameInput" - /> - -
-
-
-
- - setPassword(e.target.value)} - data-cy="component-gitPasswordInput" - /> - -
-
- - ); -} diff --git a/app/react/portainer/gitops/AuthFieldset/index.ts b/app/react/portainer/gitops/AuthFieldset/index.ts deleted file mode 100644 index b8c58ac84..000000000 --- a/app/react/portainer/gitops/AuthFieldset/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AuthFieldset, gitAuthValidation } from './AuthFieldset'; diff --git a/app/react/portainer/gitops/AuthFieldset/utils.ts b/app/react/portainer/gitops/AuthFieldset/utils.ts deleted file mode 100644 index 3806c4053..000000000 --- a/app/react/portainer/gitops/AuthFieldset/utils.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { GitAuthenticationResponse, GitAuthModel } from '../types'; - -export function parseAuthResponse( - auth?: GitAuthenticationResponse -): GitAuthModel { - if (!auth) { - return { - RepositoryAuthentication: false, - RepositoryPassword: '', - RepositoryUsername: '', - }; - } - - return { - RepositoryAuthentication: true, - RepositoryPassword: '', - RepositoryUsername: auth.Username, - }; -} - -export function transformGitAuthenticationViewModel( - auth?: GitAuthModel -): GitAuthenticationResponse | null { - if (!auth || !auth.RepositoryAuthentication) { - return null; - } - - if (!auth.RepositoryUsername && !auth.RepositoryPassword) { - return null; - } - - return { - Username: auth.RepositoryUsername, - Password: auth.RepositoryPassword, - }; -} diff --git a/app/react/portainer/gitops/ComposePathField/ComposePathField.test.tsx b/app/react/portainer/gitops/ComposePathField/ComposePathField.test.tsx index f42bcb88e..4e583986d 100644 --- a/app/react/portainer/gitops/ComposePathField/ComposePathField.test.tsx +++ b/app/react/portainer/gitops/ComposePathField/ComposePathField.test.tsx @@ -29,10 +29,8 @@ const defaultProps = { onChange: vi.fn(), isCompose: true, model: { - RepositoryURL: 'https://github.com/example/repo', + SourceId: 1, ComposeFilePathInRepository: 'docker-compose.yml', - RepositoryAuthentication: false, - TLSSkipVerify: false, } as GitFormModel, isDockerStandalone: false, }; diff --git a/app/react/portainer/gitops/ComposePathField/ComposePathField.tsx b/app/react/portainer/gitops/ComposePathField/ComposePathField.tsx index 03364312a..e1f2c75d4 100644 --- a/app/react/portainer/gitops/ComposePathField/ComposePathField.tsx +++ b/app/react/portainer/gitops/ComposePathField/ComposePathField.tsx @@ -16,7 +16,6 @@ interface Props { isCompose: boolean; model: GitFormModel; isDockerStandalone: boolean; - createdFromCustomTemplateId?: number; } export function ComposePathField({ @@ -26,7 +25,6 @@ export function ComposePathField({ model, isDockerStandalone, errors, - createdFromCustomTemplateId, }: Props) { const [inputValue, updateInputValue] = useStateWrapper(value, onChange); @@ -77,7 +75,6 @@ export function ComposePathField({ placeholder={isCompose ? 'docker-compose.yml' : 'manifest.yml'} model={model} inputId="stack_repository_path" - createdFromCustomTemplateId={createdFromCustomTemplateId} /> ) : ( ; export function PathSelector({ @@ -26,7 +17,6 @@ export function PathSelector({ dirOnly, readOnly, inputId, - createdFromCustomTemplateId, }: { value: string; onChange(value: string): void; @@ -35,24 +25,15 @@ export function PathSelector({ dirOnly?: boolean; readOnly?: boolean; inputId: string; - createdFromCustomTemplateId?: number; }) { - const creds = getAuthentication(model); const payload = { - repository: model.RepositoryURL, keyword: value, reference: model.RepositoryReferenceName, - tlsSkipVerify: model.TLSSkipVerify, dirOnly, - createdFromCustomTemplateId, sourceId: model.SourceId, - ...creds, }; - const enabled = Boolean( - ((model.RepositoryURL && model.RepositoryURLValid) || model.SourceId) && - value - ); + const enabled = !!(model.SourceId && value); const { data: searchResults } = useSearch(payload, enabled); return ( diff --git a/app/react/portainer/gitops/GitForm.stories.tsx b/app/react/portainer/gitops/GitForm.stories.tsx index 4da41b611..e3d8254b7 100644 --- a/app/react/portainer/gitops/GitForm.stories.tsx +++ b/app/react/portainer/gitops/GitForm.stories.tsx @@ -15,7 +15,6 @@ const WrappedComponent = withUserProvider(GitForm); interface Args { isAdditionalFilesFieldVisible: boolean; - isAuthExplanationVisible: boolean; isDockerStandalone: boolean; deployMethod: DeployMethod; isForcePullVisible: boolean; @@ -24,26 +23,20 @@ interface Args { export function Primary({ deployMethod, isAdditionalFilesFieldVisible, - isAuthExplanationVisible, isDockerStandalone, isForcePullVisible, }: Args) { const initialValues: GitFormModel = { - RepositoryURL: '', - RepositoryURLValid: false, - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', + SourceId: 0, AdditionalFiles: [], RepositoryReferenceName: '', ComposeFilePathInRepository: '', - TLSSkipVerify: false, }; return ( buildGitValidationSchema(false, 'compose')} + validationSchema={() => buildGitValidationSchema(deployMethod)} onSubmit={() => {}} > {({ values, errors, setValues }) => ( @@ -53,7 +46,6 @@ export function Primary({ errors={errors} onChange={(value) => setValues({ ...values, ...value })} isAdditionalFilesFieldVisible={isAdditionalFilesFieldVisible} - isAuthExplanationVisible={isAuthExplanationVisible} isDockerStandalone={isDockerStandalone} isForcePullVisible={isForcePullVisible} deployMethod={deployMethod} @@ -68,7 +60,6 @@ export function Primary({ Primary.args = { isAdditionalFilesFieldVisible: true, - isAuthExplanationVisible: true, isAutoUpdateVisible: true, isDockerStandalone: true, isForcePullVisible: true, diff --git a/app/react/portainer/gitops/GitForm.tsx b/app/react/portainer/gitops/GitForm.tsx index 8f4f14ac0..283d94bde 100644 --- a/app/react/portainer/gitops/GitForm.tsx +++ b/app/react/portainer/gitops/GitForm.tsx @@ -1,20 +1,17 @@ import { useState } from 'react'; -import { array, boolean, number, object, SchemaOf, string } from 'yup'; +import { array, number, object, SchemaOf, string } from 'yup'; import { FormikErrors } from 'formik'; import { ComposePathField } from '@/react/portainer/gitops/ComposePathField'; import { RefField } from '@/react/portainer/gitops/RefField'; -import { GitFormUrlField } from '@/react/portainer/gitops/GitFormUrlField'; import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types'; import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay'; import { GitSourceSelector } from '@/react/portainer/gitops/sources/GitSourceSelector'; import { FormSection } from '@@/form-components/FormSection'; import { validateForm } from '@@/form-components/validate-form'; -import { SwitchField } from '@@/form-components/SwitchField'; import { AdditionalFileField } from './AdditionalFilesField'; -import { gitAuthValidation, AuthFieldset } from './AuthFieldset'; import { AutoUpdateFieldset } from './AutoUpdateFieldset'; import { autoUpdateValidation } from './AutoUpdateFieldset/validation'; import { refFieldValidation } from './RefField/RefField'; @@ -27,15 +24,11 @@ interface Props { isDockerStandalone?: boolean; isAdditionalFilesFieldVisible?: boolean; isForcePullVisible?: boolean; - isAuthExplanationVisible?: boolean; errors?: FormikErrors; baseWebhookUrl?: string; webhookId?: string; webhooksDocs?: string; - createdFromCustomTemplateId?: number; isAutoUpdateVisible?: boolean; - /** When true, shows a SourceSelector instead of the manual git fields. The manual git fields are deprecated and will be removed (BE-13047). */ - isSourceSelectionVisible?: boolean; } export function GitForm({ @@ -46,87 +39,34 @@ export function GitForm({ isDockerStandalone = false, isAdditionalFilesFieldVisible, isForcePullVisible, - isAuthExplanationVisible, errors = {}, baseWebhookUrl, webhookId, webhooksDocs, - createdFromCustomTemplateId, isAutoUpdateVisible = true, - isSourceSelectionVisible = false, }: Props) { const [value, setValue] = useState(initialValue); // TODO: remove this state when form is not inside angularjs return ( - {isSourceSelectionVisible ? ( - - handleChange({ - SourceId: source?.id, - RepositoryURL: source?.url ?? '', - RepositoryReferenceName: initialValue.RepositoryReferenceName, - ComposeFilePathInRepository: - initialValue.ComposeFilePathInRepository, - RepositoryURLValid: !!source, - }) - } - error={errors.SourceId as string | undefined} - /> - ) : ( - <> - - - { - handleChange({ - RepositoryURL: value, - RepositoryReferenceName: initialValue.RepositoryReferenceName, - ComposeFilePathInRepository: - initialValue.ComposeFilePathInRepository, - RepositoryURLValid: false, - }); - }} - onChangeRepositoryValid={(isValid) => - handleChange({ - RepositoryURLValid: isValid, - }) - } - model={value} - createdFromCustomTemplateId={createdFromCustomTemplateId} - errors={errors.RepositoryURL} - /> - -
-
- handleChange({ TLSSkipVerify: value })} - name="TLSSkipVerify" - tooltip="Enabling this will allow skipping TLS validation for any self-signed certificate." - labelClass="col-sm-3 col-lg-2" - /> -
-
- - )} + + handleChange({ + SourceId: source?.id, + RepositoryReferenceName: initialValue.RepositoryReferenceName, + ComposeFilePathInRepository: + initialValue.ComposeFilePathInRepository, + }) + } + error={errors.SourceId} + /> handleChange({ RepositoryReferenceName: value })} - model={value} + sourceId={value.SourceId} error={errors.RepositoryReferenceName} - isUrlValid={value.RepositoryURLValid} - createdFromCustomTemplateId={createdFromCustomTemplateId} /> {isAdditionalFilesFieldVisible && ( @@ -174,37 +113,19 @@ export function GitForm({ export async function validateGitForm( formValues: GitFormModel, - isCreatedFromCustomTemplate: boolean, - deployMethod: DeployMethod = 'compose', - isSourceSelection = false + deployMethod: DeployMethod = 'compose' ) { return validateForm( - () => - buildGitValidationSchema( - isCreatedFromCustomTemplate, - deployMethod, - false, - isSourceSelection - ), + () => buildGitValidationSchema(deployMethod), formValues ); } export function buildGitValidationSchema( - isCreatedFromCustomTemplate: boolean, - deployMethod: DeployMethod, - isEdit = false, - isSourceSelection = false + deployMethod: DeployMethod ): SchemaOf { return object({ - // In source-selection mode the repository URL is derived from the selected - // source (not user-editable), so the user provides a SourceId instead and - // the URL itself needs no validation. - RepositoryURL: isSourceSelection - ? string() - : string() - .test('valid URL', 'The URL must be a valid URL', isValidGitUrl) - .required('Repository URL is required'), + RepositoryURL: string().optional(), RepositoryReferenceName: refFieldValidation(), ComposeFilePathInRepository: string().required( deployMethod === 'compose' @@ -212,25 +133,9 @@ export function buildGitValidationSchema( : 'Manifest file path is required' ), AdditionalFiles: array(string().required('Path is required')).default([]), - RepositoryURLValid: boolean().default(false), AutoUpdate: autoUpdateValidation().nullable(), - TLSSkipVerify: boolean().default(false), - SourceId: isSourceSelection - ? number().min(1, 'Source is required').required('Source is required') - : number().optional().nullable(), - }).concat( - gitAuthValidation(isEdit, isCreatedFromCustomTemplate) - ) as SchemaOf; -} - -function isValidGitUrl(value?: string) { - if (!value) { - return true; - } - - try { - return !!new URL(value).hostname; - } catch { - return false; - } + SourceId: number() + .min(1, 'Source is required') + .required('Source is required'), + }) as SchemaOf; } diff --git a/app/react/portainer/gitops/GitFormUrlField.test.tsx b/app/react/portainer/gitops/GitFormUrlField.test.tsx deleted file mode 100644 index 775630084..000000000 --- a/app/react/portainer/gitops/GitFormUrlField.test.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { vi } from 'vitest'; -import { HttpResponse } from 'msw'; - -import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; -import { useDebounce } from '@/react/hooks/useDebounce'; -import { server, http } from '@/setup-tests/server'; -import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; - -import { GitFormModel } from './types'; -import { GitFormUrlField } from './GitFormUrlField'; -import { getAuthentication } from './utils'; - -vi.mock('@/react/hooks/useDebounce', () => ({ - useDebounce: vi.fn(), -})); - -vi.mock('../feature-flags/feature-flags.service', () => ({ - isBE: true, -})); - -vi.mock('./utils', async (importActual) => ({ - ...(await importActual()), - getAuthentication: vi.fn(), -})); - -const mockUseDebounce = vi.mocked(useDebounce); -const mockGetAuthentication = vi.mocked(getAuthentication); - -describe('GitFormUrlField', () => { - const defaultModel: GitFormModel = { - RepositoryURL: '', - ComposeFilePathInRepository: '', - RepositoryAuthentication: false, - RepositoryURLValid: false, - TLSSkipVerify: false, - }; - - const defaultProps = { - value: '', - onChange: vi.fn(), - onChangeRepositoryValid: vi.fn(), - model: defaultModel, - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockUseDebounce.mockImplementation((value, onChange) => [value, onChange]); - mockGetAuthentication.mockReturnValue(undefined); - }); - - function renderComponent(props = {}) { - const Component = withTestQueryProvider(() => ( - - )); - return render(); - } - - describe('Basic rendering', () => { - it('should render with correct structure', () => { - renderComponent(); - - expect(screen.getByText(/repository url/i)).toBeInTheDocument(); - expect( - screen.getByPlaceholderText( - 'e.g. https://github.com/portainer/portainer-compose' - ) - ).toBeInTheDocument(); - expect(screen.getByTestId('component-gitUrlInput')).toBeInTheDocument(); - expect( - screen.getByTestId('component-gitUrlRefreshButton') - ).toBeInTheDocument(); - }); - - it('should display the current value in the input', () => { - const testUrl = 'https://github.com/test/repo'; - renderComponent({ value: testUrl }); - - expect(screen.getByDisplayValue(testUrl)).toBeInTheDocument(); - }); - - it('should mark input as required', () => { - renderComponent(); - - const input = screen.getByTestId('component-gitUrlInput'); - expect(input).toHaveAttribute('required'); - }); - - it('should have correct input name and type', () => { - renderComponent(); - - const input = screen.getByTestId('component-gitUrlInput'); - expect(input).toHaveAttribute('name', 'repoUrlField'); - expect(input).toHaveAttribute('type', 'text'); - }); - }); - - describe('Input handling', () => { - it('should call onChange when input value changes', async () => { - const user = userEvent.setup(); - const mockOnChange = vi.fn(); - renderComponent({ onChange: mockOnChange }); - - const input = screen.getByTestId('component-gitUrlInput'); - await user.clear(input); - await user.type(input, 'test'); - - expect(mockOnChange).toHaveBeenCalledWith('t'); - expect(mockOnChange).toHaveBeenCalledWith('e'); - expect(mockOnChange).toHaveBeenCalledWith('s'); - expect(mockOnChange).toHaveBeenLastCalledWith('t'); - }); - - it('should use debounced value and onChange', () => { - const debouncedValue = 'debounced-value'; - const debouncedOnChange = vi.fn(); - mockUseDebounce.mockReturnValue([debouncedValue, debouncedOnChange]); - - renderComponent(); - - expect(screen.getByDisplayValue(debouncedValue)).toBeInTheDocument(); - }); - }); - - describe('Repository validation', () => { - it('should display error message when repo check fails with Portainer error', async () => { - const restoreConsole = suppressConsoleLogs(); - - const errorMessage = 'Repository not found'; - server.use( - http.post('/api/gitops/repo/refs', () => - HttpResponse.json( - { message: errorMessage, details: errorMessage }, - { status: 422 } - ) - ) - ); - - renderComponent({ value: 'https://github.com/test/repo' }); - - await waitFor(() => - expect(screen.getByText(errorMessage)).toBeInTheDocument() - ); - - restoreConsole(); - }); - - it('should not display error message when repo check fails with non-Portainer error', async () => { - const restoreConsole = suppressConsoleLogs(); - - server.use( - http.post('/api/gitops/repo/refs', () => - HttpResponse.json('Network error', { status: 500 }) - ) - ); - - renderComponent({ value: 'https://github.com/test/repo' }); - - await waitFor(() => - expect( - screen.queryByLabelText('Checking repository') - ).not.toBeInTheDocument() - ); - - expect(screen.queryByText('Network error')).not.toBeInTheDocument(); - - restoreConsole(); - }); - - it('should transform "Authentication required" error when no creds', async () => { - const restoreConsole = suppressConsoleLogs(); - - server.use( - http.post('/api/gitops/repo/refs', () => - HttpResponse.json( - { - message: 'Authentication required: Repository not found.', - details: 'Authentication required: Repository not found.', - }, - { status: 422 } - ) - ) - ); - - renderComponent({ value: 'https://github.com/private/repo' }); - - await waitFor(() => - expect( - screen.getByText( - 'Git repository could not be found or is private, please ensure that the URL is correct or credentials are provided.' - ) - ).toBeInTheDocument() - ); - - restoreConsole(); - }); - - it('should display custom errors prop', () => { - const customError = 'Custom validation error'; - renderComponent({ errors: customError }); - - expect(screen.getByText(customError)).toBeInTheDocument(); - }); - - it('should prioritize repo error message over custom errors', async () => { - const restoreConsole = suppressConsoleLogs(); - - const repoError = 'Repository error'; - const customError = 'Custom validation error'; - - server.use( - http.post('/api/gitops/repo/refs', () => - HttpResponse.json( - { message: repoError, details: repoError }, - { status: 422 } - ) - ) - ); - - renderComponent({ - value: 'https://github.com/test/repo', - errors: customError, - }); - - await waitFor(() => - expect(screen.getByText(repoError)).toBeInTheDocument() - ); - expect(screen.queryByText(customError)).not.toBeInTheDocument(); - - restoreConsole(); - }); - }); - - describe('Status icons', () => { - it('should show no status when URL is empty', () => { - renderComponent({ value: '' }); - - expect( - screen.queryByLabelText('Checking repository') - ).not.toBeInTheDocument(); - expect( - screen.queryByLabelText('Repository detected') - ).not.toBeInTheDocument(); - expect( - screen.queryByLabelText( - 'Repository does not exist, or is not accessible' - ) - ).not.toBeInTheDocument(); - }); - - it('should announce repository detected when repo is valid', async () => { - renderComponent({ value: 'https://github.com/test/repo' }); - - await waitFor(() => - expect(screen.getByLabelText('Repository detected')).toBeInTheDocument() - ); - }); - - it('should announce inaccessible when repo check fails and is fetched', async () => { - const restoreConsole = suppressConsoleLogs(); - - server.use( - http.post('/api/gitops/repo/refs', () => - HttpResponse.json( - { message: 'not found', details: '' }, - { status: 422 } - ) - ) - ); - - renderComponent({ value: 'https://github.com/test/repo' }); - - await waitFor(() => - expect( - screen.getByLabelText( - 'Repository does not exist, or is not accessible' - ) - ).toBeInTheDocument() - ); - - restoreConsole(); - }); - - it('should not announce inaccessible while still loading', async () => { - let resolveRequest!: () => void; - const requestPending = new Promise((resolve) => { - resolveRequest = resolve; - }); - - server.use( - http.post('/api/gitops/repo/refs', async () => { - await requestPending; - return HttpResponse.json(['refs/heads/main']); - }) - ); - - renderComponent({ value: 'https://github.com/test/repo' }); - - await waitFor(() => - expect(screen.getByLabelText('Checking repository')).toBeInTheDocument() - ); - - expect( - screen.queryByLabelText( - 'Repository does not exist, or is not accessible' - ) - ).not.toBeInTheDocument(); - - resolveRequest(); - }); - }); - - describe('Refresh functionality', () => { - it('should disable refresh button when repository is not valid', () => { - renderComponent({ - model: { ...defaultModel, RepositoryURLValid: false }, - }); - - expect( - screen.getByTestId('component-gitUrlRefreshButton') - ).toBeDisabled(); - }); - - it('should enable refresh button when repository is valid', () => { - renderComponent({ model: { ...defaultModel, RepositoryURLValid: true } }); - - expect( - screen.getByTestId('component-gitUrlRefreshButton') - ).not.toBeDisabled(); - }); - - it('should send force=true as query param when refresh is clicked', async () => { - const user = userEvent.setup(); - - const requestUrls: string[] = []; - server.use( - http.post('/api/gitops/repo/refs', ({ request }) => { - requestUrls.push(request.url); - return HttpResponse.json(['refs/heads/main']); - }) - ); - - renderComponent({ - value: 'https://github.com/test/repo', - model: { ...defaultModel, RepositoryURLValid: true }, - }); - - await waitFor(() => expect(requestUrls).toHaveLength(1)); - - await user.click(screen.getByRole('button', { name: /Refresh/ })); - - await waitFor(() => expect(requestUrls).toHaveLength(2)); - - expect(new URL(requestUrls[1]).searchParams.get('force')).toBe('true'); - }); - }); -}); diff --git a/app/react/portainer/gitops/GitFormUrlField.tsx b/app/react/portainer/gitops/GitFormUrlField.tsx deleted file mode 100644 index b19dc91c3..000000000 --- a/app/react/portainer/gitops/GitFormUrlField.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { ChangeEvent, useState } from 'react'; -import { RefreshCcw, Loader2, X, Check } from 'lucide-react'; - -import { useDebounce } from '@/react/hooks/useDebounce'; -import { useGitRepoValidity } from '@/react/portainer/gitops/hooks/useGitRepoValidity'; - -import { FormControl } from '@@/form-components/FormControl'; -import { Input } from '@@/form-components/Input'; -import { Button } from '@@/buttons'; -import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; - -import { isBE } from '../feature-flags/feature-flags.service'; - -import { GitFormModel } from './types'; -import { getAuthentication } from './utils'; - -interface Props { - value: string; - onChange(value: string): void; - onChangeRepositoryValid(value: boolean): void; - model: GitFormModel; - createdFromCustomTemplateId?: number; - errors?: string; - placeholder?: string; -} - -export function GitFormUrlField({ - value, - onChange, - onChangeRepositoryValid, - model, - createdFromCustomTemplateId, - errors, - placeholder = 'e.g. https://github.com/portainer/portainer-compose', -}: Props) { - const creds = getAuthentication(model); - const [force, setForce] = useState(false); - const { errorMessage, isChecking, isValid, query } = useGitRepoValidity({ - url: value, - creds, - force, - tlsSkipVerify: model.TLSSkipVerify, - createdFromCustomTemplateId, - enabled: isBE, - onSettled: onChangeRepositoryValid, - onAfterSettle: () => setForce(false), - }); - - const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange); - - const fieldErrorMessage = errorMessage || errors; - - return ( -
-
- - -
- - {debouncedValue !== '' && ( -
- {isChecking && ( - - - )} - {!isChecking && isValid === false && query.isFetched && ( - - - - - )} - {!isChecking && isValid === true && ( - - - - - )} -
- )} -
- -
-
- ); - - function handleChange(e: ChangeEvent) { - debouncedOnChange(e.target.value); - } - - function onRefresh() { - setForce(true); - } -} diff --git a/app/react/portainer/gitops/GitReferenceCard.test.tsx b/app/react/portainer/gitops/GitReferenceCard.test.tsx index 6c7256392..2f8007e7f 100644 --- a/app/react/portainer/gitops/GitReferenceCard.test.tsx +++ b/app/react/portainer/gitops/GitReferenceCard.test.tsx @@ -3,6 +3,7 @@ import { HttpResponse } from 'msw'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { server, http } from '@/setup-tests/server'; +import { withTestRouter } from '@/react/test-utils/withRouter'; import { RepoConfigResponse } from './types'; import { GitReferenceCard } from './GitReferenceCard'; @@ -22,14 +23,16 @@ const defaultGitConfig: RepoConfigResponse = { function renderCard( overrides: Partial[0]> = {} ) { - const Component = withTestQueryProvider(() => ( - - )); + const Component = withTestQueryProvider( + withTestRouter(() => ( + + )) + ); return render(); } diff --git a/app/react/portainer/gitops/GitReferenceCard.tsx b/app/react/portainer/gitops/GitReferenceCard.tsx index f899e9d24..28bb79004 100644 --- a/app/react/portainer/gitops/GitReferenceCard.tsx +++ b/app/react/portainer/gitops/GitReferenceCard.tsx @@ -24,19 +24,17 @@ import { Link } from '@@/Link'; import { getGitValidityError } from './hooks/useGitRepoValidity'; export function GitReferenceCard({ - stackId, stackType, gitConfig, autoUpdate, currentDeploymentInfo, sourceId, }: { - stackId: number; stackType: 'docker' | 'helm' | 'edge' | 'edge-helm' | 'kubernetes'; gitConfig: RepoConfigResponse; autoUpdate?: AutoUpdateResponse | null; currentDeploymentInfo?: StackDeploymentInfo | null; - sourceId?: number; + sourceId: number; }) { const hasDivergence = isGitConfigDiverged(gitConfig, currentDeploymentInfo); @@ -47,16 +45,11 @@ export function GitReferenceCard({ const commitId = deployed?.ConfigHash ?? gitConfig.ConfigHash; const sourceIdToShow = deployed?.SourceID ?? sourceId; - const fromEdgeStack = stackType === 'edge' || stackType === 'edge-helm'; - const refCheckQuery = useGitRefs( { - repository: url || '', - stackId, - fromEdgeStack, sourceId: sourceIdToShow, }, - { enabled: !!url, suppressError: true } + { enabled: !!sourceIdToShow, suppressError: true } ); const repoError = getGitValidityError( @@ -76,15 +69,12 @@ export function GitReferenceCard({ const enableFileCheck = stackType !== 'helm' && stackType !== 'edge-helm'; const fileCheckQuery = useSearch( { - repository: url || '', keyword: configFilePath || '', - stackId, - fromEdgeStack, reference, sourceId: sourceIdToShow, }, enableFileCheck && - !!url && + !!sourceIdToShow && !!reference && !!configFilePath && !hasRepoError && diff --git a/app/react/portainer/gitops/RefField/RefField.tsx b/app/react/portainer/gitops/RefField/RefField.tsx index 5f78168c9..b3d7788d6 100644 --- a/app/react/portainer/gitops/RefField/RefField.tsx +++ b/app/react/portainer/gitops/RefField/RefField.tsx @@ -1,7 +1,6 @@ import { PropsWithChildren, ReactNode } from 'react'; import { SchemaOf, string } from 'yup'; -import { StackId } from '@/react/common/stacks/types'; import { useStateWrapper } from '@/react/hooks/useStateWrapper'; import { FormControl } from '@@/form-components/FormControl'; @@ -11,27 +10,15 @@ import { TextTip } from '@@/Tip/TextTip'; import { isBE } from '../../feature-flags/feature-flags.service'; import { RefSelector } from './RefSelector'; -import { RefFieldModel } from './types'; interface Props { value: string; onChange(value: string): void; - model: RefFieldModel; + sourceId?: number; error?: string; - isUrlValid?: boolean; - stackId?: StackId; - createdFromCustomTemplateId?: number; } -export function RefField({ - value, - onChange, - model, - error, - isUrlValid, - stackId, - createdFromCustomTemplateId, -}: Props) { +export function RefField({ value, onChange, sourceId, error }: Props) { const [inputValue, updateInputValue] = useStateWrapper(value, onChange); const inputId = 'repository-reference-field'; return isBE ? ( @@ -50,10 +37,7 @@ export function RefField({ inputId={inputId} value={value} onChange={onChange} - model={model} - isUrlValid={isUrlValid} - stackId={stackId} - createdFromCustomTemplateId={createdFromCustomTemplateId} + sourceId={sourceId} /> ) : ( diff --git a/app/react/portainer/gitops/RefField/RefSelector.tsx b/app/react/portainer/gitops/RefField/RefSelector.tsx index f9bc899e8..c0486aa88 100644 --- a/app/react/portainer/gitops/RefField/RefSelector.tsx +++ b/app/react/portainer/gitops/RefField/RefSelector.tsx @@ -1,43 +1,24 @@ -import { StackId } from '@/react/common/stacks/types'; import { useGitRefs } from '@/react/portainer/gitops/queries/useGitRefs'; import { PortainerSelect } from '@@/form-components/PortainerSelect'; -import { getAuthentication } from '../utils'; - -import { RefFieldModel } from './types'; - export function RefSelector({ - model, + sourceId, value, onChange, - isUrlValid, - stackId, - createdFromCustomTemplateId, inputId, }: { - model: RefFieldModel; + sourceId?: number; value: string; - stackId?: StackId; - createdFromCustomTemplateId?: number; onChange: (value: string) => void; - isUrlValid?: boolean; inputId: string; }) { - const creds = getAuthentication(model); - const payload = { - repository: model.RepositoryURL, - stackId, - createdFromCustomTemplateId, - tlsSkipVerify: model.TLSSkipVerify, - sourceId: model.SourceId, - ...creds, - }; - const { data: refs } = useGitRefs>( - payload, { - enabled: !!((model.RepositoryURL && isUrlValid) || model.SourceId), + sourceId: sourceId!, + }, + { + enabled: !!sourceId, select: (refs) => { if (refs.length === 0) { return [{ value: 'refs/heads/main', label: 'refs/heads/main' }]; diff --git a/app/react/portainer/gitops/RefField/types.ts b/app/react/portainer/gitops/RefField/types.ts deleted file mode 100644 index dd4b918dd..000000000 --- a/app/react/portainer/gitops/RefField/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { GitAuthModel } from '../types'; - -export interface RefFieldModel extends GitAuthModel { - RepositoryURL: string; - TLSSkipVerify?: boolean; - SourceId?: number; -} diff --git a/app/react/portainer/gitops/RelativePathFieldset/utils.ts b/app/react/portainer/gitops/RelativePathFieldset/utils.ts index 998ba0058..c3e892453 100644 --- a/app/react/portainer/gitops/RelativePathFieldset/utils.ts +++ b/app/react/portainer/gitops/RelativePathFieldset/utils.ts @@ -14,13 +14,8 @@ export function parseRelativePathResponse(stack: EdgeStack): RelativePathModel { } export const dummyGitForm: GitFormModel = { - RepositoryURL: '', - RepositoryURLValid: false, - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', + SourceId: 0, AdditionalFiles: [], RepositoryReferenceName: '', ComposeFilePathInRepository: '', - TLSSkipVerify: false, }; diff --git a/app/react/portainer/gitops/hooks/useGitRepoValidity.ts b/app/react/portainer/gitops/hooks/useGitRepoValidity.ts index a4f0ab150..dffd74fa6 100644 --- a/app/react/portainer/gitops/hooks/useGitRepoValidity.ts +++ b/app/react/portainer/gitops/hooks/useGitRepoValidity.ts @@ -1,57 +1,30 @@ import { isAxiosError } from '@/portainer/services/axios/utils/isAxiosError'; import { isDefaultResponse } from '../../services/axios/utils/parseAxiosError'; -import { AuthTypeOption } from '../../account/git-credentials/types'; import { useGitRefs } from '../queries/useGitRefs'; -interface Creds { - username?: string; - password?: string; - authorizationType?: AuthTypeOption; -} - interface Params { - url: string; - creds?: Creds; force?: boolean; - tlsSkipVerify?: boolean; - createdFromCustomTemplateId?: number; - fromEdgeStack?: boolean; - stackId?: number; - /** When set, the refs check will use credentials from the stored Source record */ sourceId?: number; enabled?: boolean; onSettled?(isValid?: boolean): void; - // run after onSettled, useful for clearing local flags like force onAfterSettle?(): void; } export function useGitRepoValidity({ - url, - creds, - force, - tlsSkipVerify, - fromEdgeStack, - createdFromCustomTemplateId, - stackId, sourceId, + force, enabled, onSettled, onAfterSettle, }: Params) { const query = useGitRefs( { - repository: url, - ...creds, - tlsSkipVerify, - createdFromCustomTemplateID: createdFromCustomTemplateId, - stackId, + sourceId: sourceId!, force, - fromEdgeStack, - sourceId, }, { - enabled: (!!url || !!sourceId) && enabled, + enabled: !!sourceId && enabled, select: () => true, suppressError: true, onSettled(isValid) { @@ -65,9 +38,7 @@ export function useGitRepoValidity({ } ); - const hasCreds = !!(creds?.username && creds?.password) || !!sourceId; - - const errorMessage = getGitValidityError(query.error, hasCreds); + const errorMessage = getGitValidityError(query.error, !!sourceId); const isChecking = query.isInitialLoading || query.isFetching; diff --git a/app/react/portainer/gitops/queries/useGitFilePreview.ts b/app/react/portainer/gitops/queries/useGitFilePreview.ts index 9f6c342f7..0f8ed657e 100644 --- a/app/react/portainer/gitops/queries/useGitFilePreview.ts +++ b/app/react/portainer/gitops/queries/useGitFilePreview.ts @@ -2,17 +2,9 @@ import { useQuery } from '@tanstack/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios/axios'; -import { AuthTypeOption } from '../../account/git-credentials/types'; -import { omitPassword } from '../utils'; - export interface GitFilePreviewParams { - repository: string; targetFile: string; reference?: string; - username?: string; - password?: string; - authorizationType?: AuthTypeOption; - tlsSkipVerify?: boolean; /** When set, resolves URL and auth from the stored Source record */ sourceId?: number; } @@ -35,12 +27,9 @@ export function useGitFilePreview( ) { const { enabled = true, select } = options; return useQuery({ - queryKey: ['gitops', 'file-preview', omitPassword(params)], + queryKey: ['gitops', 'file-preview', params], queryFn: () => getFilePreview(params), - enabled: - enabled && - (!!params.repository || !!params.sourceId) && - !!params.targetFile, + enabled: enabled && !!params.sourceId && !!params.targetFile, select, retry: false, }); diff --git a/app/react/portainer/gitops/queries/useGitRefs.ts b/app/react/portainer/gitops/queries/useGitRefs.ts index 4f3f40122..75d60815e 100644 --- a/app/react/portainer/gitops/queries/useGitRefs.ts +++ b/app/react/portainer/gitops/queries/useGitRefs.ts @@ -4,20 +4,9 @@ import axios from '@/portainer/services/axios/axios'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { withError } from '@/react-tools/react-query'; -import { AuthTypeOption } from '../../account/git-credentials/types'; -import { omitPassword } from '../utils'; - interface RefsPayload { - repository: string; - username?: string; - password?: string; - authorizationType?: AuthTypeOption; - stackId?: number; - fromEdgeStack?: boolean; - createdFromCustomTemplateID?: number; - tlsSkipVerify?: boolean; force?: boolean; - sourceId?: number; + sourceId: number; } export function useGitRefs( @@ -39,7 +28,7 @@ export function useGitRefs( } = {} ) { return useQuery({ - queryKey: ['gitops', 'refs', omitPassword(payload)], + queryKey: ['gitops', 'refs', payload], queryFn: () => listRefs(payload), enabled: isBE && enabled, retry: false, diff --git a/app/react/portainer/gitops/queries/useSearch.ts b/app/react/portainer/gitops/queries/useSearch.ts index 63a73fe39..5a3e0f0e6 100644 --- a/app/react/portainer/gitops/queries/useSearch.ts +++ b/app/react/portainer/gitops/queries/useSearch.ts @@ -2,30 +2,19 @@ import { useQuery } from '@tanstack/react-query'; import axios from '@/portainer/services/axios/axios'; -import { AuthTypeOption } from '../../account/git-credentials/types'; import { isBE } from '../../feature-flags/feature-flags.service'; -import { omitPassword } from '../utils'; interface SearchPayload { - repository: string; keyword: string; reference?: string; - username?: string; - password?: string; - authorizationType?: AuthTypeOption; - tlsSkipVerify?: boolean; dirOnly?: boolean; - createdFromCustomTemplateId?: number; - stackId?: number; - fromEdgeStack?: boolean; sourceId?: number; } export function useSearch(payload: SearchPayload, enabled: boolean) { return useQuery({ - queryKey: ['gitops', 'search', omitPassword(payload)], + queryKey: ['gitops', 'search', payload], queryFn: () => searchRepo(payload), - enabled: isBE && enabled, retry: false, cacheTime: 0, diff --git a/app/react/portainer/gitops/queries/useUpdateGitStack.ts b/app/react/portainer/gitops/queries/useUpdateGitStack.ts index f72e55944..3898f62d1 100644 --- a/app/react/portainer/gitops/queries/useUpdateGitStack.ts +++ b/app/react/portainer/gitops/queries/useUpdateGitStack.ts @@ -6,14 +6,8 @@ import axios, { parseAxiosError } from '@/portainer/services/axios/axios'; import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset'; -import { AuthTypeOption } from '../../account/git-credentials/types'; - interface DeployGitPayload { RepositoryReferenceName?: string; - RepositoryAuthentication?: boolean; - RepositoryUsername?: string; - RepositoryPassword?: string; - RepositoryAuthorizationType?: AuthTypeOption; Env?: EnvVarValues; Prune?: boolean; // RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack diff --git a/app/react/portainer/gitops/queries/useUpdateGitStackSettings.ts b/app/react/portainer/gitops/queries/useUpdateGitStackSettings.ts index 82056f93e..b9b0e4e97 100644 --- a/app/react/portainer/gitops/queries/useUpdateGitStackSettings.ts +++ b/app/react/portainer/gitops/queries/useUpdateGitStackSettings.ts @@ -8,7 +8,6 @@ import { withError } from '@/react-tools/react-query'; import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types'; import { AutoUpdateResponse } from '../types'; -import { AuthTypeOption } from '../../account/git-credentials/types'; export interface GitStackPayload { env: Array; @@ -16,10 +15,6 @@ export interface GitStackPayload { RepositoryURL?: string; ConfigFilePath?: string; RepositoryReferenceName?: string; - RepositoryAuthentication?: boolean; - RepositoryUsername?: string; - RepositoryPassword?: string; - RepositoryAuthorizationType?: AuthTypeOption; AutoUpdate?: AutoUpdateResponse | null; TLSSkipVerify?: boolean; Registries?: number[]; diff --git a/app/react/portainer/gitops/types.ts b/app/react/portainer/gitops/types.ts index 205fffb0d..f96eb4bad 100644 --- a/app/react/portainer/gitops/types.ts +++ b/app/react/portainer/gitops/types.ts @@ -28,65 +28,53 @@ export interface RepoConfigResponse { TLSSkipVerify: boolean; } -export type GitAuthModel = { - RepositoryAuthentication?: boolean; - RepositoryUsername?: string; - RepositoryPassword?: string; - RepositoryAuthorizationType?: AuthTypeOption; -}; - export type DeployMethod = 'compose' | 'manifest' | 'helm'; -export interface GitFormModel extends GitAuthModel { - RepositoryURL: string; - RepositoryURLValid?: boolean; +export interface GitFormModel { + SourceId?: number; ComposeFilePathInRepository?: string; RepositoryReferenceName?: string; AdditionalFiles?: string[]; - TLSSkipVerify?: boolean; /** * Auto update * * if undefined, GitForm won't show the AutoUpdate fieldset */ AutoUpdate?: AutoUpdateModel; - /** ID of an existing Source. When set, inline URL and credentials are ignored. */ - SourceId?: number; + + /** Used to create stacks from app templates */ + RepositoryURL?: string; } export function getDefaultModel( autoUpdate: AutoUpdateModel = getDefaultAutoUpdateValues() ): GitFormModel { return { - RepositoryURL: '', ComposeFilePathInRepository: 'docker-compose.yml', RepositoryReferenceName: 'refs/heads/main', - RepositoryAuthentication: false, - TLSSkipVerify: false, AutoUpdate: autoUpdate, + SourceId: 0, }; } export function toGitFormModel( - response?: RepoConfigResponse, + sourceId?: number, + response?: Omit< + RepoConfigResponse, + 'URL' | 'TLSSkipVerify' | 'ConfigHash' | 'Authentication' + >, autoUpdate?: AutoUpdateModel ): GitFormModel { if (!response) { return getDefaultModel(autoUpdate); } - const { URL, ReferenceName, ConfigFilePath, Authentication, TLSSkipVerify } = - response; + const { ReferenceName, ConfigFilePath } = response; return { - RepositoryURL: URL, ComposeFilePathInRepository: ConfigFilePath, RepositoryReferenceName: ReferenceName, - RepositoryAuthentication: !!Authentication?.Username, - RepositoryUsername: Authentication?.Username, - RepositoryPassword: Authentication?.Password, - RepositoryAuthorizationType: Authentication?.AuthorizationType, - TLSSkipVerify, AutoUpdate: autoUpdate, + SourceId: sourceId, }; } diff --git a/app/react/portainer/gitops/utils.ts b/app/react/portainer/gitops/utils.ts index 20b933ed5..7c5f69df9 100644 --- a/app/react/portainer/gitops/utils.ts +++ b/app/react/portainer/gitops/utils.ts @@ -2,31 +2,7 @@ import { StackDeploymentInfo } from '@/react/common/stacks/types'; import { confirm } from '@@/modals/confirm'; -import { GitFormModel, RepoConfigResponse } from './types'; - -export function getAuthentication( - model: Pick< - GitFormModel, - 'RepositoryAuthentication' | 'RepositoryPassword' | 'RepositoryUsername' - > -) { - if (!model.RepositoryAuthentication) { - return undefined; - } - - return { - username: model.RepositoryUsername, - password: model.RepositoryPassword, - }; -} - -/** Returns a copy of the object without `password` to keep it out of query keys and devtools. */ -export function omitPassword( - obj: T -): Omit { - const { password, ...rest } = obj; - return rest; -} +import { RepoConfigResponse } from './types'; export function confirmEnableTLSVerify() { return confirm({ diff --git a/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx b/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx index 3c8c144e1..c14b35e23 100644 --- a/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx +++ b/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx @@ -131,7 +131,6 @@ export function InnerForm({ })) } errors={errors.Git} - isSourceSelectionVisible /> )} diff --git a/app/react/portainer/templates/custom-templates/CreateView/useInitialValues.ts b/app/react/portainer/templates/custom-templates/CreateView/useInitialValues.ts index f0f1eca95..79e3b3709 100644 --- a/app/react/portainer/templates/custom-templates/CreateView/useInitialValues.ts +++ b/app/react/portainer/templates/custom-templates/CreateView/useInitialValues.ts @@ -48,15 +48,10 @@ export function useInitialValues({ Logo: '', Variables: [], Git: { - RepositoryURL: '', + SourceId: 0, RepositoryReferenceName: '', - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', ComposeFilePathInRepository: initialFilePathInRepository, AdditionalFiles: [], - RepositoryURLValid: true, - TLSSkipVerify: false, }, AccessControl: isEdge ? undefined diff --git a/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx b/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx index f9fa2caef..230b1117c 100644 --- a/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx +++ b/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx @@ -56,8 +56,7 @@ export function useValidation({ }), Git: mixed().when('Method', { is: git.value, - then: () => - buildGitValidationSchema(false, deployMethod, false, true), + then: () => buildGitValidationSchema(deployMethod), }), Variables: variablesValidation(), EdgeSettings: viewType === 'edge' ? edgeFieldsetValidation() : mixed(), diff --git a/app/react/portainer/templates/custom-templates/EditView/EditForm.tsx b/app/react/portainer/templates/custom-templates/EditView/EditForm.tsx index 476abf0bb..22f4ce8a1 100644 --- a/app/react/portainer/templates/custom-templates/EditView/EditForm.tsx +++ b/app/react/portainer/templates/custom-templates/EditView/EditForm.tsx @@ -41,7 +41,6 @@ export function EditForm({ isGit, templateId: template.Id, deployMethod, - isSourceSelection: isGit, }); const fileContentQuery = useCustomTemplateFile(template.Id); diff --git a/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx b/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx index e5bddde90..d96f9fe1f 100644 --- a/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx +++ b/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx @@ -119,7 +119,6 @@ export function InnerForm({ values.Type === StackType.Kubernetes ? 'manifest' : 'compose' } errors={typeof errors.Git === 'object' ? errors.Git : undefined} - isSourceSelectionVisible={!!values.Git.SourceId} />
diff --git a/app/react/portainer/templates/custom-templates/EditView/useInitialValues.ts b/app/react/portainer/templates/custom-templates/EditView/useInitialValues.ts index fb2aae55c..e215898b7 100644 --- a/app/react/portainer/templates/custom-templates/EditView/useInitialValues.ts +++ b/app/react/portainer/templates/custom-templates/EditView/useInitialValues.ts @@ -3,7 +3,7 @@ import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser'; import { toGitFormModel } from '@/react/portainer/gitops/types'; import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; -import { CustomTemplate } from '../types'; +import { CustomTemplate, getTemplateSourceId } from '../types'; import { FormValues } from './types'; @@ -34,10 +34,7 @@ export function useInitialValues({ Logo: template.Logo, Variables: template.Variables, Git: template.GitConfig - ? { - ...toGitFormModel(template.GitConfig), - SourceId: template.artifact?.files?.[0]?.sourceId, - } + ? toGitFormModel(getTemplateSourceId(template), template.GitConfig) : undefined, AccessControl: !isEdge && template.ResourceControl diff --git a/app/react/portainer/templates/custom-templates/EditView/useValidation.tsx b/app/react/portainer/templates/custom-templates/EditView/useValidation.tsx index 4947a4bea..4f9f79737 100644 --- a/app/react/portainer/templates/custom-templates/EditView/useValidation.tsx +++ b/app/react/portainer/templates/custom-templates/EditView/useValidation.tsx @@ -18,13 +18,11 @@ export function useValidation({ templateId, viewType, deployMethod, - isSourceSelection, }: { isGit: boolean; templateId: CustomTemplate['Id']; viewType: TemplateViewType; deployMethod: DeployMethod; - isSourceSelection?: boolean; }) { const customTemplatesQuery = useCustomTemplates({ params: { @@ -47,14 +45,7 @@ export function useValidation({ .default(StackType.DockerCompose), FileContent: string().required('Template is required.'), - Git: isGit - ? buildGitValidationSchema( - false, - deployMethod, - false, - isSourceSelection ?? false - ) - : mixed(), + Git: isGit ? buildGitValidationSchema(deployMethod) : mixed(), Variables: variablesValidation(), EdgeSettings: viewType === 'edge' ? edgeFieldsetValidation() : mixed(), }).concat( @@ -63,13 +54,6 @@ export function useValidation({ currentTemplateId: templateId, }) ), - [ - customTemplatesQuery.data, - isGit, - isSourceSelection, - templateId, - viewType, - deployMethod, - ] + [customTemplatesQuery.data, isGit, templateId, viewType, deployMethod] ); } diff --git a/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/DeployForm.tsx b/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/DeployForm.tsx index 3c9d78398..d46bb3037 100644 --- a/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/DeployForm.tsx +++ b/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/DeployForm.tsx @@ -11,7 +11,10 @@ import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser'; import { AccessControlForm } from '@/react/portainer/access-control'; import { parseAccessControlFormData } from '@/react/portainer/access-control/utils'; import { NameField } from '@/react/docker/stacks/common/NameField'; -import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types'; +import { + CustomTemplate, + getTemplateSourceId, +} from '@/react/portainer/templates/custom-templates/types'; import { isTemplateVariablesEnabled, renderTemplate, @@ -195,7 +198,10 @@ export function DeployForm({ payload: { name: values.name, environmentId, - git: toGitFormModel(template.GitConfig), + git: toGitFormModel( + getTemplateSourceId(template), + template.GitConfig + ), accessControl: values.accessControl, }, } @@ -206,7 +212,10 @@ export function DeployForm({ name: values.name, environmentId, swarmId: swarmIdQuery.data || '', - git: toGitFormModel(template.GitConfig), + git: toGitFormModel( + getTemplateSourceId(template), + template.GitConfig + ), accessControl: values.accessControl, }, }; diff --git a/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts b/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts index bc89a4658..31d6c3eea 100644 --- a/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts +++ b/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts @@ -201,24 +201,14 @@ interface CustomTemplateFromGitRepositoryPayload { Platform: Platform; /** Type of created stack. Required. */ Type: StackType; - /** References an existing Source for git credentials/URL. When set, inline URL and auth are ignored. */ + /** References an existing Source for git credentials/URL. */ SourceId?: number; - /** URL of a Git repository hosting the Stack file. Required. */ - RepositoryURL: string; /** Reference name of a Git repository hosting the Stack file. */ RepositoryReferenceName?: string; - /** Use basic authentication to clone the Git repository. */ - RepositoryAuthentication?: boolean; - /** Username used in basic authentication when RepositoryAuthentication is true. */ - RepositoryUsername?: string; - /** Password used in basic authentication when RepositoryAuthentication is true. */ - RepositoryPassword?: string; /** Path to the Stack file inside the Git repository. */ ComposeFilePathInRepository?: string; /** Definitions of variables in the stack file. */ Variables: VariableDefinition[]; - /** Indicates whether to skip SSL verification when cloning the Git repository. */ - TLSSkipVerify?: boolean; /** Indicates if the Kubernetes template is created from a Docker Compose file. */ IsComposeFormat?: boolean; /** Indicates if this template is for Edge Stack. */ diff --git a/app/react/portainer/templates/custom-templates/types.ts b/app/react/portainer/templates/custom-templates/types.ts index 9e95bea7d..efc73e0f9 100644 --- a/app/react/portainer/templates/custom-templates/types.ts +++ b/app/react/portainer/templates/custom-templates/types.ts @@ -103,6 +103,12 @@ export type CustomTemplate = { }; }; +export function getTemplateSourceId( + template?: Pick +) { + return template?.artifact?.files?.[0]?.sourceId; +} + /** * EdgeTemplateSettings represents the configuration of a custom template for Edge */