diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 6972c34df..d3454bc02 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -81,6 +81,7 @@ type Handler struct { UserHandler *users.Handler WebSocketHandler *websocket.Handler WebhookHandler *webhooks.Handler + UserHelmHandler *helm.Handler } // @title PortainerCE API diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go index e95263e57..86352c375 100644 --- a/api/http/handler/helm/handler.go +++ b/api/http/handler/helm/handler.go @@ -52,10 +52,11 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor h.Handle("/{id}/kubernetes/helm", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmInstall))).Methods(http.MethodPost) + // Deprecated h.Handle("/{id}/kubernetes/helm/repositories", - bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userGetHelmRepos))).Methods(http.MethodGet) + httperror.LoggerHandler(h.userGetHelmRepos)).Methods(http.MethodGet) h.Handle("/{id}/kubernetes/helm/repositories", - bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userCreateHelmRepo))).Methods(http.MethodPost) + httperror.LoggerHandler(h.userCreateHelmRepo)).Methods(http.MethodPost) return h } diff --git a/api/http/handler/helm/user_helm_repos.go b/api/http/handler/helm/user_helm_repos.go index 1c159955a..886d44530 100644 --- a/api/http/handler/helm/user_helm_repos.go +++ b/api/http/handler/helm/user_helm_repos.go @@ -27,7 +27,7 @@ func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error { return libhelm.ValidateHelmRepositoryURL(p.URL, nil) } -// @id HelmUserRepositoryCreate +// @id HelmUserRepositoryCreateDeprecated // @summary Create a user helm repository // @description Create a user helm repository. // @description **Access policy**: authenticated @@ -42,6 +42,7 @@ func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error { // @failure 400 "Invalid request" // @failure 403 "Permission denied" // @failure 500 "Server error" +// @deprecated // @router /endpoints/{id}/kubernetes/helm/repositories [post] func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { tokenData, err := security.RetrieveTokenData(r) @@ -85,7 +86,7 @@ func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Reques return response.JSON(w, record) } -// @id HelmUserRepositoriesList +// @id HelmUserRepositoriesListDeprecated // @summary List a users helm repositories // @description Inspect a user helm repositories. // @description **Access policy**: authenticated @@ -98,6 +99,7 @@ func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Reques // @failure 400 "Invalid request" // @failure 403 "Permission denied" // @failure 500 "Server error" +// @deprecated // @router /endpoints/{id}/kubernetes/helm/repositories [get] func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { tokenData, err := security.RetrieveTokenData(r) diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index dd0bcd372..7c1364736 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -37,6 +37,7 @@ type Handler struct { CryptoService portainer.CryptoService passwordStrengthChecker security.PasswordStrengthChecker AdminCreationDone chan<- struct{} + FileService portainer.FileService } // NewHandler creates a handler to manage user operations. @@ -77,5 +78,10 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit publicRouter.Handle("/users/admin/check", httperror.LoggerHandler(h.adminCheck)).Methods(http.MethodGet) publicRouter.Handle("/users/admin/init", httperror.LoggerHandler(h.adminInit)).Methods(http.MethodPost) + // Helm repositories + authenticatedRouter.Handle("/users/{id}/helm/repositories", httperror.LoggerHandler(h.userGetHelmRepos)).Methods(http.MethodGet) + authenticatedRouter.Handle("/users/{id}/helm/repositories", httperror.LoggerHandler(h.userCreateHelmRepo)).Methods(http.MethodPost) + authenticatedRouter.Handle("/users/{id}/helm/repositories/{repositoryID}", httperror.LoggerHandler(h.userDeleteHelmRepo)).Methods(http.MethodDelete) + return h } diff --git a/api/http/handler/users/user_helm_repos.go b/api/http/handler/users/user_helm_repos.go new file mode 100644 index 000000000..e8490b2e0 --- /dev/null +++ b/api/http/handler/users/user_helm_repos.go @@ -0,0 +1,196 @@ +package users + +import ( + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/pkg/libhelm" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" +) + +type helmUserRepositoryResponse struct { + GlobalRepository string `json:"GlobalRepository"` + UserRepositories []portainer.HelmUserRepository `json:"UserRepositories"` +} + +type addHelmRepoUrlPayload struct { + URL string `json:"url"` +} + +func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error { + return libhelm.ValidateHelmRepositoryURL(p.URL, nil) +} + +// @id HelmUserRepositoryCreate +// @summary Create a user helm repository +// @description Create a user helm repository. +// @description **Access policy**: authenticated +// @tags helm +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "User identifier" +// @param payload body addHelmRepoUrlPayload true "Helm Repository" +// @success 200 {object} portainer.HelmUserRepository "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /users/{id}/helm/repositories [post] +func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userIDEndpoint, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + userID := portainer.UserID(userIDEndpoint) + if tokenData.ID != userID { + return httperror.Forbidden("Couldn't create Helm repositories for another user", httperrors.ErrUnauthorized) + } + + p := new(addHelmRepoUrlPayload) + err = request.DecodeAndValidateJSONPayload(r, p) + if err != nil { + return httperror.BadRequest("Invalid Helm repository URL", err) + } + + // lowercase, remove trailing slash + p.URL = strings.TrimSuffix(strings.ToLower(p.URL), "/") + + records, err := handler.DataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID) + if err != nil { + return httperror.InternalServerError("Unable to access the DataStore", err) + } + + // check if repo already exists - by doing case insensitive comparison + for _, record := range records { + if strings.EqualFold(record.URL, p.URL) { + errMsg := "Helm repo already registered for user" + return httperror.BadRequest(errMsg, errors.New(errMsg)) + } + } + + record := portainer.HelmUserRepository{ + UserID: userID, + URL: p.URL, + } + + err = handler.DataStore.HelmUserRepository().Create(&record) + if err != nil { + return httperror.InternalServerError("Unable to save a user Helm repository URL", err) + } + + return response.JSON(w, record) +} + +// @id HelmUserRepositoriesList +// @summary List a users helm repositories +// @description Inspect a user helm repositories. +// @description **Access policy**: authenticated +// @tags helm +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "User identifier" +// @success 200 {object} helmUserRepositoryResponse "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /users/{id}/helm/repositories [get] +func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userIDEndpoint, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + userID := portainer.UserID(userIDEndpoint) + if tokenData.ID != userID { + return httperror.Forbidden("Couldn't create Helm repositories for another user", httperrors.ErrUnauthorized) + } + + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return httperror.InternalServerError("Unable to retrieve settings from the database", err) + } + + userRepos, err := handler.DataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID) + if err != nil { + return httperror.InternalServerError("Unable to get user Helm repositories", err) + } + + resp := helmUserRepositoryResponse{ + GlobalRepository: settings.HelmRepositoryURL, + UserRepositories: userRepos, + } + + return response.JSON(w, resp) +} + +// @id HelmUserRepositoryDelete +// @summary Delete a users helm repositoryies +// @description **Access policy**: authenticated +// @tags helm +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "User identifier" +// @param repositoryID path int true "Repository identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /users/{id}/helm/repositories/{repositoryID} [delete] +func (handler *Handler) userDeleteHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userIDEndpoint, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + userID := portainer.UserID(userIDEndpoint) + if tokenData.ID != userID { + return httperror.Forbidden("Couldn't create Helm repositories for another user", httperrors.ErrUnauthorized) + } + + repositoryID, err := request.RetrieveNumericRouteVariableValue(r, "repositoryID") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + userRepos, err := handler.DataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID) + if err != nil { + return httperror.InternalServerError("Unable to get user Helm repositories", err) + } + + for _, repo := range userRepos { + if repo.ID == portainer.HelmUserRepositoryID(repositoryID) && repo.UserID == userID { + err = handler.DataStore.HelmUserRepository().Delete(portainer.HelmUserRepositoryID(repositoryID)) + if err != nil { + return httperror.InternalServerError("Unable to delete user Helm repository", err) + } + } + } + + return response.JSON(w, nil) +} diff --git a/api/http/server.go b/api/http/server.go index 7800524f1..b14ecaf68 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -282,6 +282,7 @@ func (server *Server) Start() error { userHandler.DataStore = server.DataStore userHandler.CryptoService = server.CryptoService userHandler.AdminCreationDone = server.AdminCreationDone + userHandler.FileService = server.FileService var websocketHandler = websocket.NewHandler(server.KubernetesTokenCacheManager, requestBouncer) websocketHandler.DataStore = server.DataStore diff --git a/app/assets/ico/helm.svg b/app/assets/ico/helm.svg index a6dcc4034..79558ad76 100644 --- a/app/assets/ico/helm.svg +++ b/app/assets/ico/helm.svg @@ -1 +1 @@ - \ No newline at end of file +Helm \ No newline at end of file diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index a2e19f83c..249001f5d 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -327,7 +327,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo const deploy = { name: 'kubernetes.deploy', - url: '/deploy?templateId&referrer&tab', + url: '/deploy?templateId&referrer&tab&buildMethod&chartName', views: { 'content@': { component: 'kubernetesDeployView', diff --git a/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.controller.js b/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.controller.js deleted file mode 100644 index 373bfda48..000000000 --- a/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.controller.js +++ /dev/null @@ -1,39 +0,0 @@ -export default class HelmAddRepositoryController { - /* @ngInject */ - constructor($state, $async, HelmService, Notifications) { - this.$state = $state; - this.$async = $async; - this.HelmService = HelmService; - this.Notifications = Notifications; - } - - doesRepoExist() { - if (!this.state.repository) { - return false; - } - // lowercase, strip trailing slash and compare - return this.repos.includes(this.state.repository.toLowerCase().replace(/\/$/, '')); - } - - async addRepository() { - this.state.isAddingRepo = true; - try { - await this.HelmService.addHelmRepository(this.endpoint.Id, { url: this.state.repository }); - this.Notifications.success('Success', 'Helm repository added successfully'); - this.$state.reload(this.$state.current); - } catch (err) { - this.Notifications.error('Installation error', err); - } finally { - this.state.isAddingRepo = false; - } - } - - $onInit() { - return this.$async(async () => { - this.state = { - isAddingRepo: false, - repository: '', - }; - }); - } -} diff --git a/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.html b/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.html deleted file mode 100644 index c79ab1c7e..000000000 --- a/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.html +++ /dev/null @@ -1,72 +0,0 @@ - -
-
-
- -
- Additional repositories -
-
- -
-
-
- -
- -
-
Add a Helm repository. All Helm charts in the repository will be added to the list.
-
-
- -
-
- -
-
- -
-
-
-

A valid URL beginning with http(s) is required.

-
-
-
- -
-
-
-

Helm repository already exists.

-
-
-
- -
-
- -
-
-
-
-
-
diff --git a/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.js b/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.js deleted file mode 100644 index b31fd402e..000000000 --- a/app/kubernetes/components/helm/helm-templates/helm-add-repository/helm-add-repository.js +++ /dev/null @@ -1,11 +0,0 @@ -import angular from 'angular'; -import controller from './helm-add-repository.controller'; - -angular.module('portainer.kubernetes').component('helmAddRepository', { - templateUrl: './helm-add-repository.html', - controller, - bindings: { - repos: '<', - endpoint: '<', - }, -}); diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html index 0e7a4a7fb..781adeba6 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html +++ b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html @@ -1,5 +1,5 @@ -
+
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html index 95f192662..bd586ecc8 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html +++ b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html @@ -1,53 +1,54 @@
- - -
-
-
- -
+
+
+ {{ $ctrl.titleText }} +
- {{ $ctrl.titleText }} -
+ +
+ +
+
+
+
Select the Helm chart to use. Bring further Helm charts into your selection list via User settings - Helm repositories.
+ +
- -
- -
-
- -
- - -
- Loading... -
Initial download of Helm Charts can take a few minutes
-
-
No helm charts available.
-
-
-
+
+ + +
+ Loading... +
Initial download of Helm Charts can take a few minutes
+
+
No helm charts available.
+
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js index a4038c1c7..2366e8d5a 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js +++ b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js @@ -7,7 +7,6 @@ angular.module('portainer.kubernetes').component('helmTemplatesList', { bindings: { loading: '<', titleText: '@', - titleIcon: '@', charts: '<', tableKey: '@', selectAction: '<', diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js b/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js index 7af3f3436..f6e9359eb 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js +++ b/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js @@ -50,11 +50,11 @@ export default class HelmTemplatesController { this.state.actionInProgress = true; try { const payload = { - Name: this.state.appName, + Name: this.stackName, Repo: this.state.chart.repo, Chart: this.state.chart.name, Values: this.state.values, - Namespace: this.state.resourcePool.Namespace.Name, + Namespace: this.namespace, }; await this.HelmService.install(this.endpoint.Id, payload); this.Notifications.success('Success', 'Helm Chart successfully installed'); @@ -96,7 +96,7 @@ export default class HelmTemplatesController { this.state.reposLoading = true; try { // fetch globally set helm repo and user helm repos (parallel) - const { GlobalRepository, UserRepositories } = await this.HelmService.getHelmRepositories(this.endpoint.Id); + const { GlobalRepository, UserRepositories } = await this.HelmService.getHelmRepositories(this.user.ID); this.state.globalRepository = GlobalRepository; const userHelmReposUrls = UserRepositories.map((repo) => repo.URL); const uniqueHelmRepos = [...new Set([GlobalRepository, ...userHelmReposUrls])].map((url) => url.toLowerCase()).filter((url) => url); // remove duplicates and blank, to lowercase @@ -155,6 +155,8 @@ export default class HelmTemplatesController { $onInit() { return this.$async(async () => { + this.user = this.Authentication.getUserDetails(); + this.state = { appName: '', chart: null, @@ -178,6 +180,13 @@ export default class HelmTemplatesController { const helmRepos = await this.getHelmRepoURLs(); await Promise.all([this.getLatestCharts(helmRepos), this.getResourcePools()]); + if (this.state.charts.length > 0 && this.$state.params.chartName) { + const chart = this.state.charts.find((chart) => chart.name === this.$state.params.chartName); + if (chart) { + this.selectHelmChart(chart); + } + } + this.state.viewReady = true; }); } diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.html b/app/kubernetes/components/helm/helm-templates/helm-templates.html index d241ea88e..9c3ef3a42 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates.html +++ b/app/kubernetes/components/helm/helm-templates/helm-templates.html @@ -1,180 +1,108 @@ - - - - - -

- - The Global Helm Repository is not configured. - Configure Global Helm Repository in Settings. -

-
-
-
-
+
-
- - {{ $ctrl.state.chart.name }} +
+
+
+ +
+
+ {{ $ctrl.state.chart.name }} + + + Helm + +
+
+
+
+
+
+
+ +
+
- -
- -
-
Description
-
-
-
-
-
-
- -
Configuration
- -
- -
- -
-
-
-
- - You do not have access to any namespace. Contact your administrator to get access to a namespace. -
-
- - -
- -
- -
-
-
-
-
-
-

- - This field is required. -

-

- - This field must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', - or 'abc-123'). -

-
-
-
- -
-
- - - - Loading values.yaml... - - -
-
- -
- -
-
-
- - - - - You can get more information about Helm values file format in the - official documentation. - - - -
-
-
- -
- - -
Actions
-
-
- - -
-
- -
-
+ +
+
+
+ + + + Loading values.yaml... + + +
+
+ +
+ +
+
+ + + + + You can get more information about Helm values file format in the + official documentation. + + + +
+
+ +
+ + +
Actions
+
+
+ +
+
+ +
-
-
- -
-
- -
-
+
+
+ @@ -64,7 +64,7 @@ on-change="(ctrl.onChangeMethod)" > -
+
Deployment type
+ +
+ +
+ + -
Actions
-
+
Actions
+
+ + + + ); + + async function onDeleteClick(selectedItems: HelmRepository[]) { + const confirmed = await confirmDestructive({ + title: 'Confirm action', + message: `Are you sure you want to remove the selected Helm ${pluralize( + selectedItems.length, + 'repository', + 'repositories' + )}?`, + }); + + if (!confirmed) { + return; + } + + deleteHelmRepoMutation.mutate(selectedItems); + } +} diff --git a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/columns/helper.ts b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/columns/helper.ts new file mode 100644 index 000000000..279aba6c3 --- /dev/null +++ b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { HelmRepository } from '../types'; + +export const columnHelper = createColumnHelper(); diff --git a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/columns/index.ts b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/columns/index.ts new file mode 100644 index 000000000..a0a5b7b97 --- /dev/null +++ b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/columns/index.ts @@ -0,0 +1,3 @@ +import { url } from './url'; + +export const columns = [url]; diff --git a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/columns/url.tsx b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/columns/url.tsx new file mode 100644 index 000000000..3654d2309 --- /dev/null +++ b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/columns/url.tsx @@ -0,0 +1,3 @@ +import { columnHelper } from './helper'; + +export const url = columnHelper.accessor('URL', { id: 'url' }); diff --git a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts new file mode 100644 index 000000000..cf7503974 --- /dev/null +++ b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts @@ -0,0 +1,108 @@ +import { useMutation, useQuery, useQueryClient } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { success as notifySuccess } from '@/portainer/services/notifications'; +import { withError } from '@/react-tools/react-query'; +import { pluralize } from '@/portainer/helpers/strings'; + +import { + CreateHelmRepositoryPayload, + HelmRepository, + HelmRepositories, +} from './types'; + +export async function createHelmRepository( + helmRepository: CreateHelmRepositoryPayload +) { + try { + const { data } = await axios.post<{ helmRepository: HelmRepository }>( + buildUrl(helmRepository.UserId), + helmRepository + ); + return data.helmRepository; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to create Helm repository'); + } +} + +export async function getHelmRepositories(userId: number) { + try { + const { data } = await axios.get(buildUrl(userId)); + return data; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to get Helm repositories'); + } +} + +export async function deleteHelmRepository(repo: HelmRepository) { + try { + await axios.delete(buildUrl(repo.UserId, repo.Id)); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to delete Helm repository'); + } +} + +export async function deleteHelmRepositories(repos: HelmRepository[]) { + try { + await Promise.all(repos.map((repo) => deleteHelmRepository(repo))); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to delete Helm repositories'); + } +} + +export function useDeleteHelmRepositoryMutation() { + const queryClient = useQueryClient(); + + return useMutation(deleteHelmRepository, { + onSuccess: (_, helmRepository) => { + notifySuccess('Helm repository deleted successfully', helmRepository.URL); + return queryClient.invalidateQueries(['helmrepositories']); + }, + ...withError('Unable to delete Helm repository'), + }); +} + +export function useDeleteHelmRepositoriesMutation() { + const queryClient = useQueryClient(); + + return useMutation(deleteHelmRepositories, { + onSuccess: () => { + notifySuccess( + 'Success', + `Helm ${pluralize( + deleteHelmRepositories.length, + 'repository', + 'repositories' + )} deleted successfully` + ); + return queryClient.invalidateQueries(['helmrepositories']); + }, + ...withError('Unable to delete Helm repositories'), + }); +} + +export function useHelmRepositories(userId: number) { + return useQuery('helmrepositories', () => getHelmRepositories(userId), { + staleTime: 20, + ...withError('Unable to retrieve Helm repositories'), + }); +} + +export function useCreateHelmRepositoryMutation() { + const queryClient = useQueryClient(); + + return useMutation(createHelmRepository, { + onSuccess: (_, payload) => { + notifySuccess('Helm repository created successfully', payload.URL); + return queryClient.invalidateQueries(['helmrepositories']); + }, + ...withError('Unable to create Helm repository'), + }); +} + +function buildUrl(userId: number, helmRepositoryId?: number) { + if (helmRepositoryId) { + return `/users/${userId}/helm/repositories/${helmRepositoryId}`; + } + return `/users/${userId}/helm/repositories`; +} diff --git a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/index.ts b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/index.ts new file mode 100644 index 000000000..64dad6abd --- /dev/null +++ b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/index.ts @@ -0,0 +1 @@ +export { HelmRepositoryDatatable } from './HelmRepositoryDatatable'; diff --git a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/types.ts b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/types.ts new file mode 100644 index 000000000..c883e39df --- /dev/null +++ b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/types.ts @@ -0,0 +1,20 @@ +export interface CreateHelmRepositoryPayload { + UserId: number; + URL: string; +} + +export interface HelmRepositoryFormValues { + URL: string; +} + +export interface HelmRepository { + Id: number; + UserId: number; + URL: string; + Global: boolean; +} + +export interface HelmRepositories { + UserRepositories: HelmRepository[]; + GlobalRepository: string; +} diff --git a/app/react/portainer/account/help-repositories/CreateHelmRepositoryView/CreateHelmRepositoriesView.tsx b/app/react/portainer/account/help-repositories/CreateHelmRepositoryView/CreateHelmRepositoriesView.tsx new file mode 100644 index 000000000..88be91427 --- /dev/null +++ b/app/react/portainer/account/help-repositories/CreateHelmRepositoryView/CreateHelmRepositoriesView.tsx @@ -0,0 +1,28 @@ +import { PageHeader } from '@@/PageHeader'; +import { Widget, WidgetBody } from '@@/Widget'; + +import { CreateHelmRepositoryForm } from './CreateHelmRespositoriesForm'; + +export function CreateHelmRepositoriesView() { + return ( + <> + + +
+
+ + + + + +
+
+ + ); +} diff --git a/app/react/portainer/account/help-repositories/CreateHelmRepositoryView/CreateHelmRespositoriesForm.tsx b/app/react/portainer/account/help-repositories/CreateHelmRepositoryView/CreateHelmRespositoriesForm.tsx new file mode 100644 index 000000000..5ac249bab --- /dev/null +++ b/app/react/portainer/account/help-repositories/CreateHelmRepositoryView/CreateHelmRespositoriesForm.tsx @@ -0,0 +1,41 @@ +import { useRouter } from '@uirouter/react'; + +import { useCurrentUser } from '@/react/hooks/useUser'; + +import { + CreateHelmRepositoryPayload, + HelmRepositoryFormValues, +} from '../../AccountView/HelmRepositoryDatatable/types'; +import { + useHelmRepositories, + useCreateHelmRepositoryMutation, +} from '../../AccountView/HelmRepositoryDatatable/helm-repositories.service'; +import { HelmRepositoryForm } from '../components/HelmRepositoryForm'; + +export function CreateHelmRepositoryForm() { + const router = useRouter(); + const currentUser = useCurrentUser(); + + const createHelmRepositoryMutation = useCreateHelmRepositoryMutation(); + const helmReposQuery = useHelmRepositories(currentUser.user.Id); + + return ( + x.URL) || []} + /> + ); + + function onSubmit(values: HelmRepositoryFormValues) { + const payload: CreateHelmRepositoryPayload = { + ...values, + UserId: currentUser.user.Id, + }; + createHelmRepositoryMutation.mutate(payload, { + onSuccess: () => { + router.stateService.go('portainer.account'); + }, + }); + } +} diff --git a/app/react/portainer/account/help-repositories/CreateHelmRepositoryView/index.ts b/app/react/portainer/account/help-repositories/CreateHelmRepositoryView/index.ts new file mode 100644 index 000000000..fcfce0093 --- /dev/null +++ b/app/react/portainer/account/help-repositories/CreateHelmRepositoryView/index.ts @@ -0,0 +1 @@ +export { CreateHelmRepositoriesView } from './CreateHelmRepositoriesView'; diff --git a/app/react/portainer/account/help-repositories/components/CreateHelmRepositoryForm.validation.ts b/app/react/portainer/account/help-repositories/components/CreateHelmRepositoryForm.validation.ts new file mode 100644 index 000000000..9b51b0859 --- /dev/null +++ b/app/react/portainer/account/help-repositories/components/CreateHelmRepositoryForm.validation.ts @@ -0,0 +1,19 @@ +import { object, string } from 'yup'; + +import { isValidUrl } from '@@/form-components/validate-url'; + +export function noDuplicateURLsSchema(urls: string[]) { + return string() + .required('URL is required') + .test('not existing name', 'URL is already added', (newName) => + urls.every((name) => name !== newName) + ); +} + +export function validationSchema(urls: string[]) { + return object().shape({ + URL: noDuplicateURLsSchema(urls) + .test('valid-url', 'Invalid URL', (value) => !value || isValidUrl(value)) + .required('URL is required'), + }); +} diff --git a/app/react/portainer/account/help-repositories/components/HelmRepositoryForm.tsx b/app/react/portainer/account/help-repositories/components/HelmRepositoryForm.tsx new file mode 100644 index 000000000..b4925ca13 --- /dev/null +++ b/app/react/portainer/account/help-repositories/components/HelmRepositoryForm.tsx @@ -0,0 +1,74 @@ +import { Field, Form, Formik } from 'formik'; +import { useRouter } from '@uirouter/react'; + +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; +import { LoadingButton } from '@@/buttons/LoadingButton'; +import { Button } from '@@/buttons'; + +import { HelmRepositoryFormValues } from '../../AccountView/HelmRepositoryDatatable/types'; + +import { validationSchema } from './CreateHelmRepositoryForm.validation'; + +type Props = { + isEditing?: boolean; + isLoading: boolean; + onSubmit: (formValues: HelmRepositoryFormValues) => void; + URLs: string[]; +}; + +const defaultInitialValues: HelmRepositoryFormValues = { + URL: '', +}; + +export function HelmRepositoryForm({ + isEditing = false, + isLoading, + onSubmit, + URLs, +}: Props) { + const router = useRouter(); + + return ( + + initialValues={defaultInitialValues} + enableReinitialize + validationSchema={() => validationSchema(URLs)} + onSubmit={(values) => onSubmit(values)} + validateOnMount + > + {({ values, errors, handleSubmit, isValid, dirty }) => ( +
+ + + +
+
+ + {isEditing ? 'Update Helm repository' : 'Save Helm repository'} + + {isEditing && ( + + )} +
+
+
+ )} + + ); +} diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ScreenBannerFieldset.tsx b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ScreenBannerFieldset.tsx index cc1590ee1..9d2108b55 100644 --- a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ScreenBannerFieldset.tsx +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ScreenBannerFieldset.tsx @@ -23,7 +23,7 @@ export function ScreenBannerFieldset() { setIsEnabled(checked)} diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx index 38428c8e6..8936e6f87 100644 --- a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx +++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx @@ -12,7 +12,7 @@ export function HelmSection() {
- You can specify the URL to your own helm repository here. See the{' '} + You can specify the URL to your own Helm repository here. See the{' '} - - - -