From 7ae5a3042c148f772ed420a8c6d6842347052f01 Mon Sep 17 00:00:00 2001
From: Chaim Lev-Ari
Date: Tue, 14 Dec 2021 21:14:53 +0200
Subject: [PATCH] feat(app): introduce component library in react [EE-1816]
(#6236)
* refactor(app): replace notification with es6 service (#6015) [EE-1897]
chore(app): format
* refactor(containers): remove the dependency on angular modal service (#6017) [EE-1898]
* refactor(app): remove angular from http-request [EE-1899] (#6016)
* feat(app): add axios [EE-2035](#6077)
* refactor(feature): remove angular dependency from feature service [EE-2034] (#6078)
* refactor(app): replace box-selector with react component (#6046)
fix: rename angular2react
refactor(app): make box-selector type generic
feat(app): add story for box-selector
feat(app): test box-selector
feat(app): add stories for box selector item
fix(app): remove unneccesary element
refactor(app): remove assign
* feat(feature): add be-indicator in react [EE-2005] (#6106)
* refactor(app): add react components for headers [EE-1949] (#6023)
* feat(auth): provide user context
* feat(app): added base header component [EE-1949]
style(app): reformat
refactor(app/header): use same api as angular
* feat(app): add breadcrumbs component [EE-2024]
* feat(app): remove u element from user links
* fix(users): handle axios errors
Co-authored-by: Chaim Lev-Ari
* refactor(app): convert switch component to react [EE-2005] (#6025)
Co-authored-by: Marcelo Rydel
---
.prettierrc | 5 +-
.storybook/preview.js | 10 +
app/assets/css/app.css | 17 -
app/assets/css/rdash.css | 88 -----
app/config.js | 27 +-
...ocker-features-configuration.controller.js | 43 ++-
.../docker-features-configuration.html | 104 ++---
.../edge-stack-deployment-type-selector.html | 2 +-
.../docker-compose-form.controller.js | 3 +-
.../docker-compose-form.html | 2 +-
.../kube-manifest-form.controller.js | 5 +
.../kube-manifest-form.html | 2 +-
app/index.js | 5 +
...-create-custom-template-view.controller.js | 2 +-
.../kube-create-custom-template-view.html | 2 +-
app/kubernetes/views/configure/configure.html | 23 +-
.../views/configure/configureController.js | 27 +-
app/kubernetes/views/deploy/deploy.html | 14 +-
.../views/deploy/deployController.js | 27 +-
.../components/storage-class-switch/index.js | 14 +
.../storage-class-switch.controller.js | 16 +
.../storage-class-switch.html | 12 +
.../create/createResourcePool.html | 25 +-
.../create/createResourcePoolController.js | 23 +-
.../resource-pools/edit/resourcePool.html | 25 +-
.../edit/resourcePoolController.js | 22 +-
app/portainer/__module.js | 26 +-
.../BEFeatureIndicator.controller.ts | 24 ++
.../BEFeatureIndicator.css} | 0
.../BEFeatureIndicator.html} | 0
.../BEFeatureIndicator.stories.tsx | 25 ++
.../BEFeatureIndicator/BEFeatureIndicator.tsx | 33 ++
.../components/BEFeatureIndicator/index.ts | 14 +
.../components/BEFeatureIndicator/utils.ts | 15 +
.../BoxSelector.css} | 0
.../BoxSelector/BoxSelector.module.css | 4 +
.../BoxSelector/BoxSelector.stories.tsx | 83 ++++
.../BoxSelector/BoxSelector.test.tsx | 59 +++
.../components/BoxSelector/BoxSelector.tsx | 49 +++
.../BoxSelector/BoxSelectorAngular.tsx | 49 +++
.../BoxSelectorItem.css} | 0
.../BoxSelector/BoxSelectorItem.stories.tsx | 77 ++++
.../BoxSelector/BoxSelectorItem.tsx | 77 ++++
app/portainer/components/BoxSelector/index.ts | 11 +
app/portainer/components/BoxSelector/types.ts | 12 +
.../Header/Breadcrumbs/Breadcrumbs.css | 3 +
.../Breadcrumbs/Breadcrumbs.stories.tsx | 27 ++
.../Header/Breadcrumbs/Breadcrumbs.tsx | 20 +
app/portainer/components/Header/Header.css | 103 +++++
app/portainer/components/Header/Header.html | 4 +
.../components/Header/Header.stories.tsx | 42 ++
app/portainer/components/Header/Header.tsx | 31 ++
.../Header/HeaderContent.controller.js | 15 +
.../components/Header/HeaderContent.html | 11 +
.../Header/HeaderContent.module.css | 19 +
.../components/Header/HeaderContent.tsx | 50 +++
.../Header/HeaderTitle.controller.js | 15 +
.../components/Header/HeaderTitle.html | 5 +
.../components/Header/HeaderTitle.tsx | 37 ++
app/portainer/components/Header/index.ts | 14 +
app/portainer/components/Link.tsx | 11 +-
.../porAccessManagementController.js | 7 +-
.../be-feature-indicator.controller.js | 18 -
.../components/be-feature-indicator/index.js | 15 -
.../box-selector-item.controller.js | 23 --
.../box-selector-item/box-selector-item.html | 27 --
.../box-selector/box-selector-item/index.js | 21 -
.../box-selector/box-selector.controller.js | 17 -
.../components/box-selector/box-selector.html | 15 -
.../components/box-selector/index.js | 20 -
.../registriesDatatableController.js | 5 +-
.../SwitchField/Switch.css} | 0
.../SwitchField/Switch.module.css | 3 +
.../SwitchField/Switch.stories.tsx | 35 ++
.../SwitchField/Switch.test.tsx | 20 +
.../form-components/SwitchField/Switch.tsx | 68 ++++
.../SwitchField/SwitchField.module.css | 9 +
.../SwitchField/SwitchField.stories.tsx | 56 +++
.../SwitchField/SwitchField.test.tsx | 37 ++
.../SwitchField/SwitchField.tsx | 72 ++++
.../form-components/SwitchField/index.ts | 1 +
.../components/form-components/index.js | 8 +-
.../git-form-auth-fieldset.controller.js | 15 +-
.../git-form-auth-fieldset.html | 8 +-
...it-form-auto-update-fieldset.controller.js | 17 +-
.../git-form-auto-update-fieldset.html | 10 +-
.../por-switch-field/por-switch-field.html | 16 -
.../por-switch-field/por-switch-field.js | 18 -
.../forms/por-switch/por-switch.controller.js | 14 -
.../forms/por-switch/por-switch.html | 12 -
.../components/forms/por-switch/por-switch.js | 21 -
app/portainer/components/header-content.js | 16 -
app/portainer/components/header-title.js | 19 -
app/portainer/components/header.js | 11 -
app/portainer/components/index.js | 6 +-
.../theme/theme-settings.controller.js | 3 +-
.../components/theme/theme-settings.html | 4 +-
app/portainer/feature-flags/enums.js | 10 -
app/portainer/feature-flags/enums.ts | 26 ++
.../feature-flags/feature-flags.service.js | 59 ---
.../feature-flags/feature-flags.service.ts | 58 +++
app/portainer/feature-flags/feature-ids.js | 13 -
app/portainer/feature-flags/index.js | 7 -
app/portainer/feature-flags/index.ts | 8 +
...ective.js => limited-feature.directive.ts} | 15 +-
app/portainer/helpers/userHelper.js | 16 -
app/portainer/helpers/userHelper.ts | 5 +
app/portainer/hooks/useLocalStorage.ts | 46 +++
app/portainer/hooks/useUser.tsx | 118 ++++++
app/portainer/models/user.js | 1 +
.../oauth-provider-selector.controller.js | 11 +-
.../oauth-providers-selector.html | 2 +-
.../oauth-settings.controller.js | 36 +-
.../oauth-settings/oauth-settings.html | 27 +-
.../access-viewer/access-viewer.controller.js | 6 +-
app/portainer/services/api/index.ts | 7 +
app/portainer/services/api/userService.js | 364 +++++++++---------
app/portainer/services/axios.ts | 42 ++
.../services/http-request.helper.test.ts | 69 ++++
app/portainer/services/http-request.helper.ts | 74 ++++
app/portainer/services/httpRequestHelper.js | 56 ---
app/portainer/services/index.ts | 12 +
.../services/modal.service/confirm.ts | 205 ++++++++++
app/portainer/services/modal.service/index.ts | 60 +++
.../services/modal.service/prompt.ts | 166 ++++++++
app/portainer/services/modal.service/utils.ts | 37 ++
app/portainer/services/modalService.js | 339 ----------------
app/portainer/services/notifications.js | 59 ---
app/portainer/services/notifications.test.ts | 47 +++
app/portainer/services/notifications.ts | 69 ++++
.../services/registryModalService.js | 4 +-
.../ad-settings/ad-settings.controller.js | 5 +-
.../ldap-settings-custom.controller.js | 5 +-
.../ldap-settings-openldap.controller.js | 4 +-
.../ldap-settings/ldap-settings.controller.js | 4 +-
.../ldap/ldap-settings/ldap-settings.html | 4 +-
.../ssl-certificate.controller.js | 11 +-
.../ssl-certificate/ssl-certificate.html | 2 +-
.../activity-logs-view.controller.js | 7 +-
.../auth-logs-view.controller.js | 5 +-
.../access/endpointAccessController.js | 4 +-
.../views/endpoints/edit/endpoint.html | 9 +-
.../endpoints/edit/endpointController.js | 6 +
.../groups/access/groupAccessController.js | 4 +-
.../settingsAuthentication.html | 4 +-
.../settingsAuthenticationController.js | 8 +-
app/portainer/views/settings/settings.html | 17 +-
.../views/settings/settingsController.js | 14 +-
.../wizard-aci.controller.js | 36 +-
.../wizard-endpoint-aci/wizard-aci.html | 2 +-
.../wizard-docker.controller.js | 81 ++--
.../wizard-endpoint-docker/wizard-docker.html | 19 +-
.../wizard-kubernetes.controller.js | 29 +-
.../wizard-kubernetes.html | 2 +-
app/react-tools/RootProvider.tsx | 6 +-
package.json | 13 +-
yarn.lock | 121 +++++-
157 files changed, 3204 insertions(+), 1469 deletions(-)
create mode 100644 app/kubernetes/views/resource-pools/components/storage-class-switch/index.js
create mode 100644 app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.controller.js
create mode 100644 app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.html
create mode 100644 app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts
rename app/portainer/components/{be-feature-indicator/be-feature-indicator.css => BEFeatureIndicator/BEFeatureIndicator.css} (100%)
rename app/portainer/components/{be-feature-indicator/be-feature-indicator.html => BEFeatureIndicator/BEFeatureIndicator.html} (100%)
create mode 100644 app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.stories.tsx
create mode 100644 app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.tsx
create mode 100644 app/portainer/components/BEFeatureIndicator/index.ts
create mode 100644 app/portainer/components/BEFeatureIndicator/utils.ts
rename app/portainer/components/{box-selector/box-selector.css => BoxSelector/BoxSelector.css} (100%)
create mode 100644 app/portainer/components/BoxSelector/BoxSelector.module.css
create mode 100644 app/portainer/components/BoxSelector/BoxSelector.stories.tsx
create mode 100644 app/portainer/components/BoxSelector/BoxSelector.test.tsx
create mode 100644 app/portainer/components/BoxSelector/BoxSelector.tsx
create mode 100644 app/portainer/components/BoxSelector/BoxSelectorAngular.tsx
rename app/portainer/components/{box-selector/box-selector-item/box-selector-item.css => BoxSelector/BoxSelectorItem.css} (100%)
create mode 100644 app/portainer/components/BoxSelector/BoxSelectorItem.stories.tsx
create mode 100644 app/portainer/components/BoxSelector/BoxSelectorItem.tsx
create mode 100644 app/portainer/components/BoxSelector/index.ts
create mode 100644 app/portainer/components/BoxSelector/types.ts
create mode 100644 app/portainer/components/Header/Breadcrumbs/Breadcrumbs.css
create mode 100644 app/portainer/components/Header/Breadcrumbs/Breadcrumbs.stories.tsx
create mode 100644 app/portainer/components/Header/Breadcrumbs/Breadcrumbs.tsx
create mode 100644 app/portainer/components/Header/Header.css
create mode 100644 app/portainer/components/Header/Header.html
create mode 100644 app/portainer/components/Header/Header.stories.tsx
create mode 100644 app/portainer/components/Header/Header.tsx
create mode 100644 app/portainer/components/Header/HeaderContent.controller.js
create mode 100644 app/portainer/components/Header/HeaderContent.html
create mode 100644 app/portainer/components/Header/HeaderContent.module.css
create mode 100644 app/portainer/components/Header/HeaderContent.tsx
create mode 100644 app/portainer/components/Header/HeaderTitle.controller.js
create mode 100644 app/portainer/components/Header/HeaderTitle.html
create mode 100644 app/portainer/components/Header/HeaderTitle.tsx
create mode 100644 app/portainer/components/Header/index.ts
delete mode 100644 app/portainer/components/be-feature-indicator/be-feature-indicator.controller.js
delete mode 100644 app/portainer/components/be-feature-indicator/index.js
delete mode 100644 app/portainer/components/box-selector/box-selector-item/box-selector-item.controller.js
delete mode 100644 app/portainer/components/box-selector/box-selector-item/box-selector-item.html
delete mode 100644 app/portainer/components/box-selector/box-selector-item/index.js
delete mode 100644 app/portainer/components/box-selector/box-selector.controller.js
delete mode 100644 app/portainer/components/box-selector/box-selector.html
delete mode 100644 app/portainer/components/box-selector/index.js
rename app/portainer/components/{forms/por-switch/por-switch.css => form-components/SwitchField/Switch.css} (100%)
create mode 100644 app/portainer/components/form-components/SwitchField/Switch.module.css
create mode 100644 app/portainer/components/form-components/SwitchField/Switch.stories.tsx
create mode 100644 app/portainer/components/form-components/SwitchField/Switch.test.tsx
create mode 100644 app/portainer/components/form-components/SwitchField/Switch.tsx
create mode 100644 app/portainer/components/form-components/SwitchField/SwitchField.module.css
create mode 100644 app/portainer/components/form-components/SwitchField/SwitchField.stories.tsx
create mode 100644 app/portainer/components/form-components/SwitchField/SwitchField.test.tsx
create mode 100644 app/portainer/components/form-components/SwitchField/SwitchField.tsx
create mode 100644 app/portainer/components/form-components/SwitchField/index.ts
delete mode 100644 app/portainer/components/forms/por-switch-field/por-switch-field.html
delete mode 100644 app/portainer/components/forms/por-switch-field/por-switch-field.js
delete mode 100644 app/portainer/components/forms/por-switch/por-switch.controller.js
delete mode 100644 app/portainer/components/forms/por-switch/por-switch.html
delete mode 100644 app/portainer/components/forms/por-switch/por-switch.js
delete mode 100644 app/portainer/components/header-content.js
delete mode 100644 app/portainer/components/header-title.js
delete mode 100644 app/portainer/components/header.js
delete mode 100644 app/portainer/feature-flags/enums.js
create mode 100644 app/portainer/feature-flags/enums.ts
delete mode 100644 app/portainer/feature-flags/feature-flags.service.js
create mode 100644 app/portainer/feature-flags/feature-flags.service.ts
delete mode 100644 app/portainer/feature-flags/feature-ids.js
delete mode 100644 app/portainer/feature-flags/index.js
create mode 100644 app/portainer/feature-flags/index.ts
rename app/portainer/feature-flags/{limited-feature.directive.js => limited-feature.directive.ts} (61%)
delete mode 100644 app/portainer/helpers/userHelper.js
create mode 100644 app/portainer/helpers/userHelper.ts
create mode 100644 app/portainer/hooks/useLocalStorage.ts
create mode 100644 app/portainer/hooks/useUser.tsx
create mode 100644 app/portainer/services/api/index.ts
create mode 100644 app/portainer/services/axios.ts
create mode 100644 app/portainer/services/http-request.helper.test.ts
create mode 100644 app/portainer/services/http-request.helper.ts
delete mode 100644 app/portainer/services/httpRequestHelper.js
create mode 100644 app/portainer/services/index.ts
create mode 100644 app/portainer/services/modal.service/confirm.ts
create mode 100644 app/portainer/services/modal.service/index.ts
create mode 100644 app/portainer/services/modal.service/prompt.ts
create mode 100644 app/portainer/services/modal.service/utils.ts
delete mode 100644 app/portainer/services/modalService.js
delete mode 100644 app/portainer/services/notifications.js
create mode 100644 app/portainer/services/notifications.test.ts
create mode 100644 app/portainer/services/notifications.ts
diff --git a/.prettierrc b/.prettierrc
index 86d3c52f2..184ed56cb 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -13,10 +13,11 @@
},
{
"files": [
- "*.{j,t}sx"
+ "*.{j,t}sx",
+ "*.ts"
],
"options": {
- "printWidth": 80,
+ "printWidth": 80
}
}
]
diff --git a/.storybook/preview.js b/.storybook/preview.js
index 9a31caa06..127a08b28 100644
--- a/.storybook/preview.js
+++ b/.storybook/preview.js
@@ -1,5 +1,7 @@
import '../app/assets/css';
+import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
+
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
@@ -9,3 +11,11 @@ export const parameters = {
},
},
};
+
+export const decorators = [
+ (Story) => (
+
+
+
+ ),
+];
diff --git a/app/assets/css/app.css b/app/assets/css/app.css
index 3a3a9c16a..e3e3d5480 100644
--- a/app/assets/css/app.css
+++ b/app/assets/css/app.css
@@ -596,10 +596,6 @@ a[ng-click] {
background-color: var(--bg-log-line-selected-color);
}
-.row.header .meta .page {
- padding-top: 7px;
-}
-
.tag:not(.token) {
padding: 2px 6px;
color: white;
@@ -739,19 +735,6 @@ a[ng-click] {
}
/*!toaster override*/
-/*angular-loading-bar override*/
-#loadingbar-placeholder {
- margin-bottom: 0;
- height: 3px;
-}
-
-#loading-bar .bar {
- position: relative;
- height: 3px;
- background: var(--blue-3);
-}
-/*!angular-loading-bar override*/
-
.monospaced {
font-family: monospace;
font-weight: 600;
diff --git a/app/assets/css/rdash.css b/app/assets/css/rdash.css
index e885c51f3..072f0880a 100644
--- a/app/assets/css/rdash.css
+++ b/app/assets/css/rdash.css
@@ -48,94 +48,6 @@
}
}
-/**
- * Header
- */
-.row.header {
- height: 60px;
- background: var(--bg-row-header-color);
- margin-bottom: 15px;
-}
-.row.header > div:last-child {
- padding-right: 0;
-}
-.row.header .meta .page {
- font-size: 17px;
- padding-top: 11px;
-}
-.row.header .meta .breadcrumb-links {
- font-size: 10px;
-}
-.row.header .meta div {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.row.header .login a {
- padding: 18px;
- display: block;
-}
-.row.header .user {
- min-width: 130px;
-}
-.row.header .user > .item {
- width: 65px;
- height: 60px;
- float: right;
- display: inline-block;
- text-align: center;
- vertical-align: middle;
-}
-.row.header .user > .item a {
- color: #919191;
- display: block;
-}
-.row.header .user > .item i {
- font-size: 20px;
- line-height: 55px;
-}
-.row.header .user > .item img {
- width: 40px;
- height: 40px;
- margin-top: 10px;
- border-radius: 2px;
-}
-.row.header .user > .item ul.dropdown-menu {
- border-radius: 2px;
- -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05);
- box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05);
-}
-.row.header .user > .item ul.dropdown-menu .dropdown-header {
- text-align: center;
-}
-.row.header .user > .item ul.dropdown-menu li.link {
- text-align: left;
-}
-.row.header .user > .item ul.dropdown-menu li.link a {
- padding-left: 7px;
- padding-right: 7px;
-}
-.row.header .user > .item ul.dropdown-menu:before {
- position: absolute;
- top: -7px;
- right: 23px;
- display: inline-block;
- border-right: 7px solid transparent;
- border-bottom: 7px solid rgba(0, 0, 0, 0.2);
- border-left: 7px solid transparent;
- content: '';
-}
-.row.header .user > .item ul.dropdown-menu:after {
- position: absolute;
- top: -6px;
- right: 24px;
- display: inline-block;
- border-right: 6px solid transparent;
- border-bottom: 6px solid #ffffff;
- border-left: 6px solid transparent;
- content: '';
-}
-
.loading {
width: 40px;
height: 40px;
diff --git a/app/config.js b/app/config.js
index 26f639522..0a7ef1125 100644
--- a/app/config.js
+++ b/app/config.js
@@ -1,6 +1,6 @@
-import toastr from 'toastr';
import { Terminal } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';
+import { agentInterceptor } from './portainer/services/axios';
/* @ngInject */
export function configApp($urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) {
@@ -21,28 +21,9 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService
$httpProvider.defaults.headers.put['Content-Type'] = 'application/json';
$httpProvider.defaults.headers.patch['Content-Type'] = 'application/json';
- $httpProvider.interceptors.push(
- /* @ngInject */ function (HttpRequestHelper) {
- return {
- request(config) {
- if (config.url.indexOf('/docker/') > -1) {
- config.headers['X-PortainerAgent-Target'] = HttpRequestHelper.portainerAgentTargetHeader();
- if (HttpRequestHelper.portainerAgentManagerOperation()) {
- config.headers['X-PortainerAgent-ManagerOperation'] = '1';
- }
- }
- return config;
- },
- };
- }
- );
-
- toastr.options = {
- timeOut: 3000,
- closeButton: true,
- progressBar: true,
- tapToDismiss: false,
- };
+ $httpProvider.interceptors.push(() => ({
+ request: agentInterceptor,
+ }));
Terminal.applyAddon(fit);
diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js
index 4570f8afc..4d6de8ede 100644
--- a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js
+++ b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js
@@ -1,14 +1,15 @@
-import { HIDE_AUTO_UPDATE_WINDOW } from 'Portainer/feature-flags/feature-ids';
+import { FeatureId } from '@/portainer/feature-flags/enums';
export default class DockerFeaturesConfigurationController {
/* @ngInject */
- constructor($async, EndpointService, Notifications, StateManager) {
+ constructor($async, $scope, EndpointService, Notifications, StateManager) {
this.$async = $async;
+ this.$scope = $scope;
this.EndpointService = EndpointService;
this.Notifications = Notifications;
this.StateManager = StateManager;
- this.limitedFeature = HIDE_AUTO_UPDATE_WINDOW;
+ this.limitedFeature = FeatureId.HIDE_AUTO_UPDATE_WINDOW;
this.formValues = {
enableHostManagementFeatures: false,
@@ -26,9 +27,45 @@ export default class DockerFeaturesConfigurationController {
this.state = {
actionInProgress: false,
+ autoUpdateSettings: { Enabled: false },
+ timeZone: '',
};
this.save = this.save.bind(this);
+ this.onChangeField = this.onChangeField.bind(this);
+ this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
+ this.onChangeEnableHostManagementFeatures = this.onChangeField('enableHostManagementFeatures');
+ this.onChangeAllowVolumeBrowserForRegularUsers = this.onChangeField('allowVolumeBrowserForRegularUsers');
+ this.onChangeDisableBindMountsForRegularUsers = this.onChangeField('disableBindMountsForRegularUsers');
+ this.onChangeDisablePrivilegedModeForRegularUsers = this.onChangeField('disablePrivilegedModeForRegularUsers');
+ this.onChangeDisableHostNamespaceForRegularUsers = this.onChangeField('disableHostNamespaceForRegularUsers');
+ this.onChangeDisableStackManagementForRegularUsers = this.onChangeField('disableStackManagementForRegularUsers');
+ this.onChangeDisableDeviceMappingForRegularUsers = this.onChangeField('disableDeviceMappingForRegularUsers');
+ this.onChangeDisableContainerCapabilitiesForRegularUsers = this.onChangeField('disableContainerCapabilitiesForRegularUsers');
+ this.onChangeDisableSysctlSettingForRegularUsers = this.onChangeField('disableSysctlSettingForRegularUsers');
+ }
+
+ onToggleAutoUpdate(value) {
+ return this.$scope.$evalAsync(() => {
+ this.state.autoUpdateSettings.Enabled = value;
+ });
+ }
+
+ onChange(values) {
+ return this.$scope.$evalAsync(() => {
+ this.formValues = {
+ ...this.formValues,
+ ...values,
+ };
+ });
+ }
+
+ onChangeField(field) {
+ return (value) => {
+ this.onChange({
+ [field]: value,
+ });
+ };
}
isContainerEditDisabled() {
diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.html b/app/docker/views/docker-features-configuration/docker-features-configuration.html
index 3f9675b6c..c08d902b8 100644
--- a/app/docker/views/docker-features-configuration/docker-features-configuration.html
+++ b/app/docker/views/docker-features-configuration/docker-features-configuration.html
@@ -20,24 +20,26 @@
@@ -49,12 +51,13 @@
-
+
Build method
-
+
@@ -197,12 +198,12 @@
diff --git a/app/kubernetes/views/configure/configureController.js b/app/kubernetes/views/configure/configureController.js
index 330cc9853..67cd69356 100644
--- a/app/kubernetes/views/configure/configureController.js
+++ b/app/kubernetes/views/configure/configureController.js
@@ -6,8 +6,8 @@ import { KubernetesIngressClass } from 'Kubernetes/ingress/models';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
-import { K8S_SETUP_DEFAULT } from '@/portainer/feature-flags/feature-ids';
-import { HIDE_AUTO_UPDATE_WINDOW } from 'Portainer/feature-flags/feature-ids';
+import { FeatureId } from '@/portainer/feature-flags/enums';
+
class KubernetesConfigureController {
/* #region CONSTRUCTOR */
@@ -15,6 +15,7 @@ class KubernetesConfigureController {
constructor(
$async,
$state,
+ $scope,
Notifications,
KubernetesStorageService,
EndpointService,
@@ -26,6 +27,7 @@ class KubernetesConfigureController {
) {
this.$async = $async;
this.$state = $state;
+ this.$scope = $scope;
this.Notifications = Notifications;
this.KubernetesStorageService = KubernetesStorageService;
this.EndpointService = EndpointService;
@@ -39,8 +41,10 @@ class KubernetesConfigureController {
this.onInit = this.onInit.bind(this);
this.configureAsync = this.configureAsync.bind(this);
- this.limitedFeature = K8S_SETUP_DEFAULT;
- this.limitedFeatureAutoWindow = HIDE_AUTO_UPDATE_WINDOW;
+ this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT;
+ this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW;
+ this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
+ this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this);
}
/* #endregion */
@@ -103,6 +107,15 @@ class KubernetesConfigureController {
}
/* #endregion */
+ onChangeEnableResourceOverCommit(enabled) {
+ this.$scope.$evalAsync(() => {
+ this.formValues.EnableResourceOverCommit = enabled;
+ if (enabled) {
+ this.formValues.ResourceOverCommitPercentage = 20;
+ }
+ });
+ }
+
/* #region CONFIGURE */
assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) {
endpoint.Kubernetes.Configuration.StorageClasses = storageClasses;
@@ -244,6 +257,12 @@ class KubernetesConfigureController {
return this.formValues.RestrictDefaultNamespace && !this.oldFormValues.RestrictDefaultNamespace;
}
+ onToggleAutoUpdate(value) {
+ return this.$scope.$evalAsync(() => {
+ this.state.autoUpdateSettings.Enabled = value;
+ });
+ }
+
/* #region ON INIT */
async onInit() {
this.state = {
diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html
index 135a5be00..ccc4d56a9 100644
--- a/app/kubernetes/views/deploy/deploy.html
+++ b/app/kubernetes/views/deploy/deploy.html
@@ -39,18 +39,24 @@
Build method
-
+
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js
index 5d6f08e15..77610061a 100644
--- a/app/kubernetes/views/deploy/deployController.js
+++ b/app/kubernetes/views/deploy/deployController.js
@@ -5,7 +5,8 @@ import uuidv4 from 'uuid/v4';
import PortainerError from 'Portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
-import { buildOption } from '@/portainer/components/box-selector';
+import { buildOption } from '@/portainer/components/BoxSelector';
+
class KubernetesDeployController {
/* @ngInject */
constructor($async, $state, $window, Authentication, ModalService, Notifications, KubernetesResourcePoolService, StackService, WebhookHelper, CustomTemplateService) {
@@ -67,7 +68,8 @@ class KubernetesDeployController {
this.getNamespacesAsync = this.getNamespacesAsync.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this);
this.buildAnalyticsProperties = this.buildAnalyticsProperties.bind(this);
- this.onDeployTypeChange = this.onDeployTypeChange.bind(this);
+ this.onChangeMethod = this.onChangeMethod.bind(this);
+ this.onChangeDeployType = this.onChangeDeployType.bind(this);
}
buildAnalyticsProperties() {
@@ -122,6 +124,19 @@ class KubernetesDeployController {
}
}
+ onChangeMethod(method) {
+ this.state.BuildMethod = method;
+ }
+
+ onChangeDeployType(type) {
+ this.state.DeployType = type;
+ if (type == this.ManifestDeployTypes.COMPOSE) {
+ this.DeployMethod = 'compose';
+ } else {
+ this.DeployMethod = 'manifest';
+ }
+ }
+
disableDeploy() {
const isGitFormInvalid =
this.state.BuildMethod === KubernetesDeployBuildMethods.GIT &&
@@ -275,14 +290,6 @@ class KubernetesDeployController {
return this.$async(this.getNamespacesAsync);
}
- onDeployTypeChange(value) {
- if (value == this.ManifestDeployTypes.COMPOSE) {
- this.DeployMethod = 'compose';
- } else {
- this.DeployMethod = 'manifest';
- }
- }
-
async uiCanExit() {
if (this.formValues.EditorContent && this.state.isEditorDirty) {
return this.ModalService.confirmWebEditorDiscard();
diff --git a/app/kubernetes/views/resource-pools/components/storage-class-switch/index.js b/app/kubernetes/views/resource-pools/components/storage-class-switch/index.js
new file mode 100644
index 000000000..502d1614f
--- /dev/null
+++ b/app/kubernetes/views/resource-pools/components/storage-class-switch/index.js
@@ -0,0 +1,14 @@
+import angular from 'angular';
+import controller from './storage-class-switch.controller.js';
+
+export const storageClassSwitch = {
+ templateUrl: './storage-class-switch.html',
+ controller,
+ bindings: {
+ value: '<',
+ onChange: '<',
+ name: '<',
+ },
+};
+
+angular.module('portainer.kubernetes').component('storageClassSwitch', storageClassSwitch);
diff --git a/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.controller.js b/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.controller.js
new file mode 100644
index 000000000..ddfc63312
--- /dev/null
+++ b/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.controller.js
@@ -0,0 +1,16 @@
+import { FeatureId } from '@/portainer/feature-flags/enums';
+
+class StorageClassSwitchController {
+ /* @ngInject */
+ constructor() {
+ this.featureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
+
+ this.handleChange = this.handleChange.bind(this);
+ }
+
+ handleChange(value) {
+ this.onChange(this.name, value);
+ }
+}
+
+export default StorageClassSwitchController;
diff --git a/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.html b/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.html
new file mode 100644
index 000000000..dc52f5f00
--- /dev/null
+++ b/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.html
@@ -0,0 +1,12 @@
+
diff --git a/app/kubernetes/views/resource-pools/create/createResourcePool.html b/app/kubernetes/views/resource-pools/create/createResourcePool.html
index 4ef095823..9023c8c0d 100644
--- a/app/kubernetes/views/resource-pools/create/createResourcePool.html
+++ b/app/kubernetes/views/resource-pools/create/createResourcePool.html
@@ -163,11 +163,12 @@
@@ -189,17 +190,9 @@
standard
-
+
+
+
diff --git a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js
index a352b0a9a..b767d09a8 100644
--- a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js
+++ b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js
@@ -12,15 +12,16 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
-import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids';
+import { FeatureId } from '@/portainer/feature-flags/enums';
class KubernetesCreateResourcePoolController {
/* #region CONSTRUCTOR */
/* @ngInject */
- constructor($async, $state, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointService) {
+ constructor($async, $state, $scope, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointService) {
Object.assign(this, {
$async,
$state,
+ $scope,
Notifications,
KubernetesNodeService,
KubernetesResourcePoolService,
@@ -30,11 +31,25 @@ class KubernetesCreateResourcePoolController {
});
this.IngressClassTypes = KubernetesIngressClassTypes;
- this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA;
- this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA;
+ this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA;
+
+ this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
+ this.onToggleLoadBalancerQuota = this.onToggleLoadBalancerQuota.bind(this);
}
/* #endregion */
+ onToggleStorageQuota(storageClassName, enabled) {
+ this.$scope.$evalAsync(() => {
+ this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled }));
+ });
+ }
+
+ onToggleLoadBalancerQuota(enabled) {
+ this.$scope.$evalAsync(() => {
+ this.formValues.UseLoadBalancersQuota = enabled;
+ });
+ }
+
onChangeIngressHostname() {
const state = this.state.duplicates.ingressHosts;
const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts');
diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.html b/app/kubernetes/views/resource-pools/edit/resourcePool.html
index efbf08b61..5b3a3cc6b 100644
--- a/app/kubernetes/views/resource-pools/edit/resourcePool.html
+++ b/app/kubernetes/views/resource-pools/edit/resourcePool.html
@@ -147,11 +147,12 @@
@@ -386,17 +387,9 @@
standard
-
+
+
+
diff --git a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js
index 630cd5d8e..1e82c25fd 100644
--- a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js
+++ b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js
@@ -16,7 +16,7 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
-import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids';
+import { FeatureId } from '@/portainer/feature-flags/enums';
class KubernetesResourcePoolController {
/* #region CONSTRUCTOR */
@@ -24,6 +24,7 @@ class KubernetesResourcePoolController {
constructor(
$async,
$state,
+ $scope,
Authentication,
Notifications,
LocalStorage,
@@ -42,6 +43,7 @@ class KubernetesResourcePoolController {
Object.assign(this, {
$async,
$state,
+ $scope,
Authentication,
Notifications,
LocalStorage,
@@ -61,11 +63,13 @@ class KubernetesResourcePoolController {
this.IngressClassTypes = KubernetesIngressClassTypes;
this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults;
- this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA;
- this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA;
+ this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA;
+ this.StorageQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this);
this.getEvents = this.getEvents.bind(this);
+ this.onToggleLoadBalancersQuota = this.onToggleLoadBalancersQuota.bind(this);
+ this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
}
/* #endregion */
@@ -80,6 +84,18 @@ class KubernetesResourcePoolController {
}
/* #endregion */
+ onToggleLoadBalancersQuota(checked) {
+ return this.$scope.$evalAsync(() => {
+ this.formValues.UseLoadBalancersQuota = checked;
+ });
+ }
+
+ onToggleStorageQuota(storageClassName, enabled) {
+ this.$scope.$evalAsync(() => {
+ this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled }));
+ });
+ }
+
/* #region INGRESS MANAGEMENT */
onChangeIngressHostname() {
const state = this.state.duplicates.ingressHosts;
diff --git a/app/portainer/__module.js b/app/portainer/__module.js
index 6df82a2d5..d7992c645 100644
--- a/app/portainer/__module.js
+++ b/app/portainer/__module.js
@@ -5,6 +5,7 @@ import componentsModule from './components';
import settingsModule from './settings';
import featureFlagModule from './feature-flags';
import userActivityModule from './user-activity';
+import servicesModule from './services';
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh();
@@ -22,12 +23,19 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat
}
angular
- .module('portainer.app', ['portainer.oauth', 'portainer.rbac', componentsModule, settingsModule, featureFlagModule, userActivityModule, 'portainer.shared.datatable'])
+ .module('portainer.app', [
+ 'portainer.oauth',
+ 'portainer.rbac',
+ componentsModule,
+ settingsModule,
+ featureFlagModule,
+ userActivityModule,
+ 'portainer.shared.datatable',
+ servicesModule,
+ ])
.config([
'$stateRegistryProvider',
function ($stateRegistryProvider) {
- 'use strict';
-
var root = {
name: 'root',
abstract: true,
@@ -56,18 +64,6 @@ angular
controller: 'SidebarController',
},
},
- resolve: {
- featuresServiceInitialized: /* @ngInject */ function featuresServiceInitialized($async, featureService, Notifications) {
- return $async(async () => {
- try {
- await featureService.init();
- } catch (e) {
- Notifications.error('Failed initializing features service', e);
- throw e;
- }
- });
- },
- },
};
var endpointRoot = {
diff --git a/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts
new file mode 100644
index 000000000..3520699ed
--- /dev/null
+++ b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts
@@ -0,0 +1,24 @@
+import { FeatureId } from '@/portainer/feature-flags/enums';
+
+import { getFeatureDetails } from './utils';
+
+export default class BeIndicatorController {
+ limitedToBE?: boolean;
+
+ url?: string;
+
+ feature?: FeatureId;
+
+ /* @ngInject */
+ constructor() {
+ this.limitedToBE = false;
+ this.url = '';
+ }
+
+ $onInit() {
+ const { url, limitedToBE } = getFeatureDetails(this.feature);
+
+ this.limitedToBE = limitedToBE;
+ this.url = url;
+ }
+}
diff --git a/app/portainer/components/be-feature-indicator/be-feature-indicator.css b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.css
similarity index 100%
rename from app/portainer/components/be-feature-indicator/be-feature-indicator.css
rename to app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.css
diff --git a/app/portainer/components/be-feature-indicator/be-feature-indicator.html b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.html
similarity index 100%
rename from app/portainer/components/be-feature-indicator/be-feature-indicator.html
rename to app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.html
diff --git a/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.stories.tsx b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.stories.tsx
new file mode 100644
index 000000000..d4bf97338
--- /dev/null
+++ b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.stories.tsx
@@ -0,0 +1,25 @@
+import { Meta } from '@storybook/react';
+
+import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
+import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
+
+import { BEFeatureIndicator, Props } from './BEFeatureIndicator';
+
+export default {
+ component: BEFeatureIndicator,
+ title: 'Components/BEFeatureIndicator',
+ argTypes: {
+ featureId: {
+ control: { type: 'select', options: Object.values(FeatureId) },
+ },
+ },
+} as Meta;
+
+// : JSX.IntrinsicAttributes & PropsWithChildren
+function Template({ featureId }: Props) {
+ initFeatureService(Edition.CE);
+
+ return ;
+}
+
+export const Example = Template.bind({});
diff --git a/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.tsx b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.tsx
new file mode 100644
index 000000000..b5dffd773
--- /dev/null
+++ b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.tsx
@@ -0,0 +1,33 @@
+import { PropsWithChildren } from 'react';
+
+import { FeatureId } from '@/portainer/feature-flags/enums';
+
+import { getFeatureDetails } from './utils';
+
+export interface Props {
+ featureId?: FeatureId;
+}
+
+export function BEFeatureIndicator({
+ featureId,
+ children,
+}: PropsWithChildren) {
+ const { url, limitedToBE } = getFeatureDetails(featureId);
+
+ if (!limitedToBE) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ Business Edition Feature
+
+ );
+}
diff --git a/app/portainer/components/BEFeatureIndicator/index.ts b/app/portainer/components/BEFeatureIndicator/index.ts
new file mode 100644
index 000000000..05a1f628f
--- /dev/null
+++ b/app/portainer/components/BEFeatureIndicator/index.ts
@@ -0,0 +1,14 @@
+import controller from './BEFeatureIndicator.controller';
+
+import './BEFeatureIndicator.css';
+
+export const beFeatureIndicatorAngular = {
+ templateUrl: './BEFeatureIndicator.html',
+ controller,
+ bindings: {
+ feature: '<',
+ },
+ transclude: true,
+};
+
+export { BEFeatureIndicator } from './BEFeatureIndicator';
diff --git a/app/portainer/components/BEFeatureIndicator/utils.ts b/app/portainer/components/BEFeatureIndicator/utils.ts
new file mode 100644
index 000000000..b2933c7b1
--- /dev/null
+++ b/app/portainer/components/BEFeatureIndicator/utils.ts
@@ -0,0 +1,15 @@
+import { FeatureId } from '@/portainer/feature-flags/enums';
+import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
+
+const BE_URL = 'https://www.portainer.io/business-upsell?from=';
+
+export function getFeatureDetails(featureId?: FeatureId) {
+ if (!featureId) {
+ return {};
+ }
+ const url = `${BE_URL}${featureId}`;
+
+ const limitedToBE = isLimitedToBE(featureId);
+
+ return { url, limitedToBE };
+}
diff --git a/app/portainer/components/box-selector/box-selector.css b/app/portainer/components/BoxSelector/BoxSelector.css
similarity index 100%
rename from app/portainer/components/box-selector/box-selector.css
rename to app/portainer/components/BoxSelector/BoxSelector.css
diff --git a/app/portainer/components/BoxSelector/BoxSelector.module.css b/app/portainer/components/BoxSelector/BoxSelector.module.css
new file mode 100644
index 000000000..83fdb4f94
--- /dev/null
+++ b/app/portainer/components/BoxSelector/BoxSelector.module.css
@@ -0,0 +1,4 @@
+.root {
+ float: left;
+ width: 100%;
+}
diff --git a/app/portainer/components/BoxSelector/BoxSelector.stories.tsx b/app/portainer/components/BoxSelector/BoxSelector.stories.tsx
new file mode 100644
index 000000000..7a9a7b473
--- /dev/null
+++ b/app/portainer/components/BoxSelector/BoxSelector.stories.tsx
@@ -0,0 +1,83 @@
+import { Meta } from '@storybook/react';
+import { useState } from 'react';
+
+import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
+import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
+
+import { BoxSelector } from './BoxSelector';
+import { BoxSelectorOption } from './types';
+
+const meta: Meta = {
+ title: 'BoxSelector',
+ component: BoxSelector,
+};
+
+export default meta;
+
+export function Example() {
+ const [value, setValue] = useState(3);
+ const options: BoxSelectorOption[] = [
+ {
+ description: 'description 1',
+ icon: 'fa fa-rocket',
+ id: '1',
+ value: 3,
+ label: 'option 1',
+ },
+ {
+ description: 'description 2',
+ icon: 'fa fa-rocket',
+ id: '2',
+ value: 4,
+ label: 'option 2',
+ },
+ ];
+
+ return (
+ {
+ setValue(value);
+ }}
+ value={value}
+ options={options}
+ />
+ );
+}
+
+export function LimitedFeature() {
+ initFeatureService(Edition.CE);
+ const [value, setValue] = useState(3);
+ const options: BoxSelectorOption[] = [
+ {
+ description: 'description 1',
+ icon: 'fa fa-rocket',
+ id: '1',
+ value: 3,
+ label: 'option 1',
+ },
+ {
+ description: 'description 2',
+ icon: 'fa fa-rocket',
+ id: '2',
+ value: 4,
+ label: 'option 2',
+ feature: FeatureId.ACTIVITY_AUDIT,
+ },
+ ];
+
+ return (
+ {
+ setValue(value);
+ }}
+ value={value}
+ options={options}
+ />
+ );
+}
+
+// regular example
+
+// story with limited feature
diff --git a/app/portainer/components/BoxSelector/BoxSelector.test.tsx b/app/portainer/components/BoxSelector/BoxSelector.test.tsx
new file mode 100644
index 000000000..5a063886b
--- /dev/null
+++ b/app/portainer/components/BoxSelector/BoxSelector.test.tsx
@@ -0,0 +1,59 @@
+import { render, fireEvent } from '@/react-tools/test-utils';
+
+import { BoxSelector, Props } from './BoxSelector';
+import { BoxSelectorOption } from './types';
+
+function renderDefault({
+ options = [],
+ onChange = () => {},
+ radioName = 'radio',
+ value,
+}: Partial> = {}) {
+ return render(
+
+ );
+}
+
+test('should render with the initial value selected and call onChange when clicking a different value', async () => {
+ const options: BoxSelectorOption[] = [
+ {
+ description: 'description 1',
+ icon: 'fa fa-rocket',
+ id: '1',
+ value: 3,
+ label: 'option 1',
+ },
+ {
+ description: 'description 2',
+ icon: 'fa fa-rocket',
+ id: '2',
+ value: 4,
+ label: 'option 2',
+ },
+ ];
+
+ const onChange = jest.fn();
+ const { getByLabelText } = renderDefault({
+ options,
+ onChange,
+ value: options[0].value,
+ });
+
+ const item1 = getByLabelText(options[0].label, {
+ exact: false,
+ }) as HTMLInputElement;
+ expect(item1.checked).toBeTruthy();
+
+ const item2 = getByLabelText(options[1].label, {
+ exact: false,
+ }) as HTMLInputElement;
+ expect(item2.checked).toBeFalsy();
+
+ fireEvent.click(item2);
+ expect(onChange).toHaveBeenCalledWith(options[1].value, false);
+});
diff --git a/app/portainer/components/BoxSelector/BoxSelector.tsx b/app/portainer/components/BoxSelector/BoxSelector.tsx
new file mode 100644
index 000000000..32cd106c1
--- /dev/null
+++ b/app/portainer/components/BoxSelector/BoxSelector.tsx
@@ -0,0 +1,49 @@
+import clsx from 'clsx';
+
+import type { FeatureId } from '@/portainer/feature-flags/enums';
+
+import './BoxSelector.css';
+import styles from './BoxSelector.module.css';
+import { BoxSelectorItem } from './BoxSelectorItem';
+import { BoxSelectorOption } from './types';
+
+export interface Props {
+ radioName: string;
+ value: T;
+ onChange(value: T, limitedToBE: boolean): void;
+ options: BoxSelectorOption[];
+}
+
+export function BoxSelector({
+ radioName,
+ value,
+ options,
+ onChange,
+}: Props) {
+ return (
+
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
+
+export function buildOption(
+ id: string,
+ icon: string,
+ label: string,
+ description: string,
+ value: T,
+ feature: FeatureId
+): BoxSelectorOption {
+ return { id, icon, label, description, value, feature };
+}
diff --git a/app/portainer/components/BoxSelector/BoxSelectorAngular.tsx b/app/portainer/components/BoxSelector/BoxSelectorAngular.tsx
new file mode 100644
index 000000000..e09737746
--- /dev/null
+++ b/app/portainer/components/BoxSelector/BoxSelectorAngular.tsx
@@ -0,0 +1,49 @@
+import {
+ IComponentOptions,
+ IComponentController,
+ IFormController,
+ IScope,
+} from 'angular';
+
+class BoxSelectorController implements IComponentController {
+ formCtrl!: IFormController;
+
+ onChange!: (value: string | number) => void;
+
+ radioName!: string;
+
+ $scope: IScope;
+
+ /* @ngInject */
+ constructor($scope: IScope) {
+ this.handleChange = this.handleChange.bind(this);
+
+ this.$scope = $scope;
+ }
+
+ handleChange(value: string | number, limitedToBE: boolean) {
+ this.$scope.$evalAsync(() => {
+ this.formCtrl.$setValidity(this.radioName, !limitedToBE, this.formCtrl);
+ this.onChange(value);
+ });
+ }
+}
+
+export const BoxSelectorAngular: IComponentOptions = {
+ template: ``,
+ bindings: {
+ value: '<',
+ onChange: '<',
+ options: '<',
+ radioName: '<',
+ },
+ require: {
+ formCtrl: '^form',
+ },
+ controller: BoxSelectorController,
+};
diff --git a/app/portainer/components/box-selector/box-selector-item/box-selector-item.css b/app/portainer/components/BoxSelector/BoxSelectorItem.css
similarity index 100%
rename from app/portainer/components/box-selector/box-selector-item/box-selector-item.css
rename to app/portainer/components/BoxSelector/BoxSelectorItem.css
diff --git a/app/portainer/components/BoxSelector/BoxSelectorItem.stories.tsx b/app/portainer/components/BoxSelector/BoxSelectorItem.stories.tsx
new file mode 100644
index 000000000..a3c518cda
--- /dev/null
+++ b/app/portainer/components/BoxSelector/BoxSelectorItem.stories.tsx
@@ -0,0 +1,77 @@
+import { Meta } from '@storybook/react';
+
+import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
+import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
+
+import { BoxSelectorItem } from './BoxSelectorItem';
+import { BoxSelectorOption } from './types';
+
+const meta: Meta = {
+ title: 'BoxSelector/Item',
+ args: {
+ selected: false,
+ description: 'description',
+ icon: 'fa-rocket',
+ label: 'label',
+ },
+};
+
+export default meta;
+
+interface ExampleProps {
+ selected?: boolean;
+ description?: string;
+ icon?: string;
+ label?: string;
+ feature?: FeatureId;
+}
+
+function Template({
+ selected,
+ description = 'description',
+ icon,
+ label = 'label',
+ feature,
+}: ExampleProps) {
+ const option: BoxSelectorOption = {
+ description,
+ icon: `fa ${icon}`,
+ id: 'id',
+ label,
+ value: 1,
+ feature,
+ };
+
+ return (
+
+ {}}
+ option={option}
+ radioName="radio"
+ selectedValue={selected ? option.value : 0}
+ />
+
+ );
+}
+
+export const Example = Template.bind({});
+
+export function SelectedItem() {
+ return ;
+}
+
+SelectedItem.args = {
+ selected: true,
+};
+
+export function LimitedFeatureItem() {
+ initFeatureService(Edition.CE);
+
+ return ;
+}
+
+export function SelectedLimitedFeatureItem() {
+ initFeatureService(Edition.CE);
+
+ return ;
+}
diff --git a/app/portainer/components/BoxSelector/BoxSelectorItem.tsx b/app/portainer/components/BoxSelector/BoxSelectorItem.tsx
new file mode 100644
index 000000000..c83439aa0
--- /dev/null
+++ b/app/portainer/components/BoxSelector/BoxSelectorItem.tsx
@@ -0,0 +1,77 @@
+import clsx from 'clsx';
+import ReactTooltip from 'react-tooltip';
+
+import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
+
+import './BoxSelectorItem.css';
+
+import { BoxSelectorOption } from './types';
+
+interface Props {
+ radioName: string;
+ option: BoxSelectorOption;
+ onChange(value: T, limitedToBE: boolean): void;
+ selectedValue: T;
+ disabled?: boolean;
+ tooltip?: string;
+}
+
+export function BoxSelectorItem({
+ radioName,
+ option,
+ onChange,
+ selectedValue,
+ disabled,
+ tooltip,
+}: Props) {
+ const limitedToBE = isLimitedToBE(option.feature);
+
+ const tooltipId = `box-selector-item-${radioName}-${option.id}`;
+ return (
+
+
onChange(option.value, limitedToBE)}
+ />
+
+ {tooltip && (
+
+ {tooltip}
+
+ )}
+
+ );
+}
diff --git a/app/portainer/components/BoxSelector/index.ts b/app/portainer/components/BoxSelector/index.ts
new file mode 100644
index 000000000..a51e63d32
--- /dev/null
+++ b/app/portainer/components/BoxSelector/index.ts
@@ -0,0 +1,11 @@
+import angular from 'angular';
+
+import { react2angular } from '@/react-tools/react2angular';
+
+import { BoxSelector, buildOption } from './BoxSelector';
+import { BoxSelectorAngular } from './BoxSelectorAngular';
+
+export { BoxSelector, buildOption };
+const BoxSelectorReact = react2angular(BoxSelector, ['value', 'onChange', 'options', 'radioName']);
+
+export default angular.module('app.portainer.component.box-selector', []).component('boxSelectorReact', BoxSelectorReact).component('boxSelector', BoxSelectorAngular).name;
diff --git a/app/portainer/components/BoxSelector/types.ts b/app/portainer/components/BoxSelector/types.ts
new file mode 100644
index 000000000..d0f16395a
--- /dev/null
+++ b/app/portainer/components/BoxSelector/types.ts
@@ -0,0 +1,12 @@
+import type { FeatureId } from '@/portainer/feature-flags/enums';
+
+export interface BoxSelectorOption {
+ id: string;
+ icon: string;
+ label: string;
+ description: string;
+ value: T;
+ disabled?: () => boolean;
+ tooltip?: () => string;
+ feature?: FeatureId;
+}
diff --git a/app/portainer/components/Header/Breadcrumbs/Breadcrumbs.css b/app/portainer/components/Header/Breadcrumbs/Breadcrumbs.css
new file mode 100644
index 000000000..e1a2826ff
--- /dev/null
+++ b/app/portainer/components/Header/Breadcrumbs/Breadcrumbs.css
@@ -0,0 +1,3 @@
+.breadcrumb-links {
+ font-size: 10px;
+}
diff --git a/app/portainer/components/Header/Breadcrumbs/Breadcrumbs.stories.tsx b/app/portainer/components/Header/Breadcrumbs/Breadcrumbs.stories.tsx
new file mode 100644
index 000000000..97287d55c
--- /dev/null
+++ b/app/portainer/components/Header/Breadcrumbs/Breadcrumbs.stories.tsx
@@ -0,0 +1,27 @@
+import { Meta } from '@storybook/react';
+import { UIRouter, pushStateLocationPlugin } from '@uirouter/react';
+
+import { Link } from '@/portainer/components/Link';
+
+import { Breadcrumbs } from './Breadcrumbs';
+
+const meta: Meta = {
+ title: 'Components/Header/Breadcrumbs',
+ component: Breadcrumbs,
+};
+
+export default meta;
+
+export function Example() {
+ return (
+
+
+ Environments
+
+ endpointName
+
+ String item
+
+
+ );
+}
diff --git a/app/portainer/components/Header/Breadcrumbs/Breadcrumbs.tsx b/app/portainer/components/Header/Breadcrumbs/Breadcrumbs.tsx
new file mode 100644
index 000000000..54a6e2566
--- /dev/null
+++ b/app/portainer/components/Header/Breadcrumbs/Breadcrumbs.tsx
@@ -0,0 +1,20 @@
+import { ReactNode } from 'react';
+
+import './Breadcrumbs.css';
+
+interface Props {
+ children: ReactNode[];
+}
+
+export function Breadcrumbs({ children }: Props) {
+ return (
+
+ {children.map((child, index) => (
+ <>
+ {child}
+ {index !== children.length - 1 ? ' > ' : ''}
+ >
+ ))}
+
+ );
+}
diff --git a/app/portainer/components/Header/Header.css b/app/portainer/components/Header/Header.css
new file mode 100644
index 000000000..08bc8c123
--- /dev/null
+++ b/app/portainer/components/Header/Header.css
@@ -0,0 +1,103 @@
+.row.header .meta .page {
+ padding-top: 7px;
+}
+
+body.hamburg .row.header .meta {
+ margin-left: 70px;
+}
+
+.row.header {
+ height: 60px;
+ background: var(--bg-row-header-color);
+ margin-bottom: 15px;
+}
+.row.header > div:last-child {
+ padding-right: 0;
+}
+.row.header .meta .page {
+ font-size: 17px;
+ padding-top: 11px;
+}
+
+.row.header .meta div {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.row.header .login a {
+ padding: 18px;
+ display: block;
+}
+.row.header .user {
+ min-width: 130px;
+}
+.row.header .user > .item {
+ width: 65px;
+ height: 60px;
+ float: right;
+ display: inline-block;
+ text-align: center;
+ vertical-align: middle;
+}
+.row.header .user > .item a {
+ color: #919191;
+ display: block;
+}
+.row.header .user > .item i {
+ font-size: 20px;
+ line-height: 55px;
+}
+.row.header .user > .item img {
+ width: 40px;
+ height: 40px;
+ margin-top: 10px;
+ border-radius: 2px;
+}
+.row.header .user > .item ul.dropdown-menu {
+ border-radius: 2px;
+ -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05);
+ box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05);
+}
+.row.header .user > .item ul.dropdown-menu .dropdown-header {
+ text-align: center;
+}
+.row.header .user > .item ul.dropdown-menu li.link {
+ text-align: left;
+}
+.row.header .user > .item ul.dropdown-menu li.link a {
+ padding-left: 7px;
+ padding-right: 7px;
+}
+.row.header .user > .item ul.dropdown-menu:before {
+ position: absolute;
+ top: -7px;
+ right: 23px;
+ display: inline-block;
+ border-right: 7px solid transparent;
+ border-bottom: 7px solid rgba(0, 0, 0, 0.2);
+ border-left: 7px solid transparent;
+ content: '';
+}
+.row.header .user > .item ul.dropdown-menu:after {
+ position: absolute;
+ top: -6px;
+ right: 24px;
+ display: inline-block;
+ border-right: 6px solid transparent;
+ border-bottom: 6px solid #ffffff;
+ border-left: 6px solid transparent;
+ content: '';
+}
+
+/*angular-loading-bar override*/
+#loadingbar-placeholder {
+ margin-bottom: 0;
+ height: 3px;
+}
+
+#loading-bar .bar {
+ position: relative;
+ height: 3px;
+ background: var(--blue-3);
+}
+/*!angular-loading-bar override*/
diff --git a/app/portainer/components/Header/Header.html b/app/portainer/components/Header/Header.html
new file mode 100644
index 000000000..b3e309b92
--- /dev/null
+++ b/app/portainer/components/Header/Header.html
@@ -0,0 +1,4 @@
+
diff --git a/app/portainer/components/Header/Header.stories.tsx b/app/portainer/components/Header/Header.stories.tsx
new file mode 100644
index 000000000..dc3e1f61f
--- /dev/null
+++ b/app/portainer/components/Header/Header.stories.tsx
@@ -0,0 +1,42 @@
+import { Meta, Story } from '@storybook/react';
+
+import { Link } from '@/portainer/components/Link';
+import { UserContext } from '@/portainer/hooks/useUser';
+import { UserViewModel } from '@/portainer/models/user';
+
+import { Header } from './Header';
+import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
+
+import { HeaderContent, HeaderTitle } from '.';
+
+export default {
+ component: Header,
+ title: 'Components/Header',
+} as Meta;
+
+interface StoryProps {
+ title: string;
+}
+
+function Template({ title }: StoryProps) {
+ return (
+
+
+
+
+
+ Container instances
+ Add container
+
+
+
+
+ );
+}
+
+export const Primary: Story = Template.bind({});
+Primary.args = {
+ title: 'Container details',
+};
diff --git a/app/portainer/components/Header/Header.tsx b/app/portainer/components/Header/Header.tsx
new file mode 100644
index 000000000..65e08e833
--- /dev/null
+++ b/app/portainer/components/Header/Header.tsx
@@ -0,0 +1,31 @@
+import { PropsWithChildren, createContext, useContext } from 'react';
+
+import './Header.css';
+
+const Context = createContext(null);
+
+export function useHeaderContext() {
+ const context = useContext(Context);
+
+ if (context == null) {
+ throw new Error('Should be nested inside a Header component');
+ }
+}
+
+export function Header({ children }: PropsWithChildren) {
+ return (
+
+
+
+ );
+}
+
+export const HeaderAngular = {
+ transclude: true,
+ templateUrl: './Header.html',
+};
diff --git a/app/portainer/components/Header/HeaderContent.controller.js b/app/portainer/components/Header/HeaderContent.controller.js
new file mode 100644
index 000000000..f69beb9f8
--- /dev/null
+++ b/app/portainer/components/Header/HeaderContent.controller.js
@@ -0,0 +1,15 @@
+export default class HeaderContentController {
+ /* @ngInject */
+ constructor(Authentication) {
+ this.Authentication = Authentication;
+
+ this.username = null;
+ }
+
+ $onInit() {
+ const userDetails = this.Authentication.getUserDetails();
+ if (userDetails) {
+ this.username = userDetails.username;
+ }
+ }
+}
diff --git a/app/portainer/components/Header/HeaderContent.html b/app/portainer/components/Header/HeaderContent.html
new file mode 100644
index 000000000..b7eb182c9
--- /dev/null
+++ b/app/portainer/components/Header/HeaderContent.html
@@ -0,0 +1,11 @@
+
diff --git a/app/portainer/components/Header/HeaderContent.module.css b/app/portainer/components/Header/HeaderContent.module.css
new file mode 100644
index 000000000..eb7cd29a1
--- /dev/null
+++ b/app/portainer/components/Header/HeaderContent.module.css
@@ -0,0 +1,19 @@
+.user-links {
+ margin-right: 25px;
+}
+
+.user-links > * + * {
+ margin-left: 5px;
+}
+
+.link {
+ cursor: pointer;
+}
+
+.link .link-text {
+ text-decoration: underline;
+}
+
+.link .link-icon {
+ margin-right: 2px;
+}
diff --git a/app/portainer/components/Header/HeaderContent.tsx b/app/portainer/components/Header/HeaderContent.tsx
new file mode 100644
index 000000000..c8cc535d3
--- /dev/null
+++ b/app/portainer/components/Header/HeaderContent.tsx
@@ -0,0 +1,50 @@
+import { PropsWithChildren } from 'react';
+import clsx from 'clsx';
+
+import { Link } from '@/portainer/components/Link';
+import { useUser } from '@/portainer/hooks/useUser';
+
+import controller from './HeaderContent.controller';
+import styles from './HeaderContent.module.css';
+import { useHeaderContext } from './Header';
+
+export function HeaderContent({ children }: PropsWithChildren) {
+ useHeaderContext();
+ const { user } = useUser();
+
+ return (
+
+
{children}
+ {user && (
+
+
+
+ my account
+
+
+
+ log out
+
+
+ )}
+
+ );
+}
+
+export const HeaderContentAngular = {
+ requires: '^rdHeader',
+ transclude: true,
+ templateUrl: './HeaderContent.html',
+ controller,
+};
diff --git a/app/portainer/components/Header/HeaderTitle.controller.js b/app/portainer/components/Header/HeaderTitle.controller.js
new file mode 100644
index 000000000..7db213186
--- /dev/null
+++ b/app/portainer/components/Header/HeaderTitle.controller.js
@@ -0,0 +1,15 @@
+export default class HeaderTitle {
+ /* @ngInject */
+ constructor(Authentication) {
+ this.Authentication = Authentication;
+
+ this.username = null;
+ }
+
+ $onInit() {
+ const userDetails = this.Authentication.getUserDetails();
+ if (userDetails) {
+ this.username = userDetails.username;
+ }
+ }
+}
diff --git a/app/portainer/components/Header/HeaderTitle.html b/app/portainer/components/Header/HeaderTitle.html
new file mode 100644
index 000000000..78e4a4647
--- /dev/null
+++ b/app/portainer/components/Header/HeaderTitle.html
@@ -0,0 +1,5 @@
+
+ {{ $ctrl.titleText }}
+
+ {{ $ctrl.username }}
+
diff --git a/app/portainer/components/Header/HeaderTitle.tsx b/app/portainer/components/Header/HeaderTitle.tsx
new file mode 100644
index 000000000..8ece359fa
--- /dev/null
+++ b/app/portainer/components/Header/HeaderTitle.tsx
@@ -0,0 +1,37 @@
+import { PropsWithChildren } from 'react';
+
+import { useUser } from '@/portainer/hooks/useUser';
+
+import { useHeaderContext } from './Header';
+import controller from './HeaderTitle.controller';
+
+interface Props {
+ title: string;
+}
+
+export function HeaderTitle({ title, children }: PropsWithChildren) {
+ useHeaderContext();
+ const { user } = useUser();
+
+ return (
+
+ {title}
+ {children}
+ {user && (
+
+ {user.Username}
+
+ )}
+
+ );
+}
+
+export const HeaderTitleAngular = {
+ requires: '^rdHeader',
+ bindings: {
+ titleText: '@',
+ },
+ transclude: true,
+ templateUrl: './HeaderTitle.html',
+ controller,
+};
diff --git a/app/portainer/components/Header/index.ts b/app/portainer/components/Header/index.ts
new file mode 100644
index 000000000..3dc1997ee
--- /dev/null
+++ b/app/portainer/components/Header/index.ts
@@ -0,0 +1,14 @@
+import angular from 'angular';
+
+import { Header, HeaderAngular } from './Header';
+import { HeaderContent, HeaderContentAngular } from './HeaderContent';
+import { HeaderTitle, HeaderTitleAngular } from './HeaderTitle';
+
+export { Header, HeaderTitle, HeaderContent };
+
+export default angular
+ .module('portainer.app.components.header', [])
+
+ .component('rdHeader', HeaderAngular)
+ .component('rdHeaderContent', HeaderContentAngular)
+ .component('rdHeaderTitle', HeaderTitleAngular).name;
diff --git a/app/portainer/components/Link.tsx b/app/portainer/components/Link.tsx
index 2a3540921..d41cec874 100644
--- a/app/portainer/components/Link.tsx
+++ b/app/portainer/components/Link.tsx
@@ -1,15 +1,20 @@
-import { ReactNode } from 'react';
+import { PropsWithChildren } from 'react';
import { UISref, UISrefProps } from '@uirouter/react';
+interface Props {
+ title?: string;
+}
+
export function Link({
+ title = '',
children,
...props
-}: { children: ReactNode } & UISrefProps) {
+}: PropsWithChildren & UISrefProps) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
- {children}
+ {children}
);
}
diff --git a/app/portainer/components/accessManagement/porAccessManagementController.js b/app/portainer/components/accessManagement/porAccessManagementController.js
index 2b4c8bc82..1541fe5fc 100644
--- a/app/portainer/components/accessManagement/porAccessManagementController.js
+++ b/app/portainer/components/accessManagement/porAccessManagementController.js
@@ -2,11 +2,12 @@ import _ from 'lodash-es';
import angular from 'angular';
import { RoleTypes } from '@/portainer/rbac/models/role';
+import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
class PorAccessManagementController {
/* @ngInject */
- constructor(Notifications, AccessService, RoleService, featureService) {
- Object.assign(this, { Notifications, AccessService, RoleService, featureService });
+ constructor(Notifications, AccessService, RoleService) {
+ Object.assign(this, { Notifications, AccessService, RoleService });
this.limitedToBE = false;
@@ -76,7 +77,7 @@ class PorAccessManagementController {
async $onInit() {
try {
if (this.limitedFeature) {
- this.limitedToBE = this.featureService.isLimitedToBE(this.limitedFeature);
+ this.limitedToBE = isLimitedToBE(this.limitedFeature);
}
const entity = this.accessControlledEntity;
diff --git a/app/portainer/components/be-feature-indicator/be-feature-indicator.controller.js b/app/portainer/components/be-feature-indicator/be-feature-indicator.controller.js
deleted file mode 100644
index 5d7a71cbf..000000000
--- a/app/portainer/components/be-feature-indicator/be-feature-indicator.controller.js
+++ /dev/null
@@ -1,18 +0,0 @@
-const BE_URL = 'https://www.portainer.io/business-upsell?from=';
-
-export default class BeIndicatorController {
- /* @ngInject */
- constructor(featureService) {
- Object.assign(this, { featureService });
-
- this.limitedToBE = false;
- }
-
- $onInit() {
- if (this.feature) {
- this.url = `${BE_URL}${this.feature}`;
-
- this.limitedToBE = this.featureService.isLimitedToBE(this.feature);
- }
- }
-}
diff --git a/app/portainer/components/be-feature-indicator/index.js b/app/portainer/components/be-feature-indicator/index.js
deleted file mode 100644
index 6d214f2d2..000000000
--- a/app/portainer/components/be-feature-indicator/index.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import angular from 'angular';
-import controller from './be-feature-indicator.controller.js';
-
-import './be-feature-indicator.css';
-
-export const beFeatureIndicator = {
- templateUrl: './be-feature-indicator.html',
- controller,
- bindings: {
- feature: '<',
- },
- transclude: true,
-};
-
-angular.module('portainer.app').component('beFeatureIndicator', beFeatureIndicator);
diff --git a/app/portainer/components/box-selector/box-selector-item/box-selector-item.controller.js b/app/portainer/components/box-selector/box-selector-item/box-selector-item.controller.js
deleted file mode 100644
index 720f67afe..000000000
--- a/app/portainer/components/box-selector/box-selector-item/box-selector-item.controller.js
+++ /dev/null
@@ -1,23 +0,0 @@
-export default class BoxSelectorItemController {
- /* @ngInject */
- constructor(featureService) {
- Object.assign(this, { featureService });
-
- this.limitedToBE = false;
- }
-
- handleChange(value) {
- this.formCtrl.$setValidity(this.radioName, !this.limitedToBE, this.formCtrl);
- this.onChange(value);
- }
-
- $onInit() {
- if (this.option.feature) {
- this.limitedToBE = this.featureService.isLimitedToBE(this.option.feature);
- }
- }
-
- $onDestroy() {
- this.formCtrl.$setValidity(this.radioName, true, this.formCtrl);
- }
-}
diff --git a/app/portainer/components/box-selector/box-selector-item/box-selector-item.html b/app/portainer/components/box-selector/box-selector-item/box-selector-item.html
deleted file mode 100644
index 5531ce800..000000000
--- a/app/portainer/components/box-selector/box-selector-item/box-selector-item.html
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
diff --git a/app/portainer/components/box-selector/box-selector-item/index.js b/app/portainer/components/box-selector/box-selector-item/index.js
deleted file mode 100644
index f43c0a439..000000000
--- a/app/portainer/components/box-selector/box-selector-item/index.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import angular from 'angular';
-
-import './box-selector-item.css';
-
-import controller from './box-selector-item.controller';
-
-angular.module('portainer.app').component('boxSelectorItem', {
- templateUrl: './box-selector-item.html',
- controller,
- require: {
- formCtrl: '^^form',
- },
- bindings: {
- radioName: '@',
- isChecked: '<',
- option: '<',
- onChange: '<',
- disabled: '<',
- tooltip: '<',
- },
-});
diff --git a/app/portainer/components/box-selector/box-selector.controller.js b/app/portainer/components/box-selector/box-selector.controller.js
deleted file mode 100644
index 8d7d65fff..000000000
--- a/app/portainer/components/box-selector/box-selector.controller.js
+++ /dev/null
@@ -1,17 +0,0 @@
-export default class BoxSelectorController {
- constructor() {
- this.isChecked = this.isChecked.bind(this);
- this.change = this.change.bind(this);
- }
-
- change(value, limited) {
- this.ngModel = value;
- if (this.onChange) {
- this.onChange(value, limited);
- }
- }
-
- isChecked(value) {
- return this.ngModel === value;
- }
-}
diff --git a/app/portainer/components/box-selector/box-selector.html b/app/portainer/components/box-selector/box-selector.html
deleted file mode 100644
index 5655934d4..000000000
--- a/app/portainer/components/box-selector/box-selector.html
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
diff --git a/app/portainer/components/box-selector/index.js b/app/portainer/components/box-selector/index.js
deleted file mode 100644
index 555b6191f..000000000
--- a/app/portainer/components/box-selector/index.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import angular from 'angular';
-
-import './box-selector.css';
-
-import controller from './box-selector.controller';
-
-angular.module('portainer.app').component('boxSelector', {
- templateUrl: './box-selector.html',
- controller,
- bindings: {
- radioName: '@',
- ngModel: '=',
- options: '<',
- onChange: '<',
- },
-});
-
-export function buildOption(id, icon, label, description, value, feature) {
- return { id, icon, label, description, value, feature };
-}
diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatableController.js b/app/portainer/components/datatables/registries-datatable/registriesDatatableController.js
index 67e46352c..86a472359 100644
--- a/app/portainer/components/datatables/registries-datatable/registriesDatatableController.js
+++ b/app/portainer/components/datatables/registries-datatable/registriesDatatableController.js
@@ -1,5 +1,6 @@
+import { FeatureId } from '@/portainer/feature-flags/enums';
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
-import { REGISTRY_MANAGEMENT } from '@/portainer/feature-flags/feature-ids';
+
angular.module('portainer.docker').controller('RegistriesDatatableController', RegistriesDatatableController);
/* @ngInject */
@@ -45,7 +46,7 @@ function RegistriesDatatableController($scope, $controller, $state, Authenticati
};
this.$onInit = function () {
- this.limitedFeature = REGISTRY_MANAGEMENT;
+ this.limitedFeature = FeatureId.REGISTRY_MANAGEMENT;
this.isAdmin = Authentication.isAdmin();
this.setDefaults();
this.prepareTableFromDataset();
diff --git a/app/portainer/components/forms/por-switch/por-switch.css b/app/portainer/components/form-components/SwitchField/Switch.css
similarity index 100%
rename from app/portainer/components/forms/por-switch/por-switch.css
rename to app/portainer/components/form-components/SwitchField/Switch.css
diff --git a/app/portainer/components/form-components/SwitchField/Switch.module.css b/app/portainer/components/form-components/SwitchField/Switch.module.css
new file mode 100644
index 000000000..9e1180572
--- /dev/null
+++ b/app/portainer/components/form-components/SwitchField/Switch.module.css
@@ -0,0 +1,3 @@
+.root {
+ margin-bottom: 0;
+}
diff --git a/app/portainer/components/form-components/SwitchField/Switch.stories.tsx b/app/portainer/components/form-components/SwitchField/Switch.stories.tsx
new file mode 100644
index 000000000..ffe2776ed
--- /dev/null
+++ b/app/portainer/components/form-components/SwitchField/Switch.stories.tsx
@@ -0,0 +1,35 @@
+import { Meta, Story } from '@storybook/react';
+import { useState } from 'react';
+
+import { Switch } from './Switch';
+
+export default {
+ title: 'Components/Form/SwitchField/Switch',
+} as Meta;
+
+export function Example() {
+ const [isChecked, setIsChecked] = useState(false);
+ function onChange() {
+ setIsChecked(!isChecked);
+ }
+
+ return ;
+}
+
+interface Args {
+ checked: boolean;
+}
+
+function Template({ checked }: Args) {
+ return {}} id="id" />;
+}
+
+export const Checked: Story = Template.bind({});
+Checked.args = {
+ checked: true,
+};
+
+export const Unchecked: Story = Template.bind({});
+Unchecked.args = {
+ checked: false,
+};
diff --git a/app/portainer/components/form-components/SwitchField/Switch.test.tsx b/app/portainer/components/form-components/SwitchField/Switch.test.tsx
new file mode 100644
index 000000000..2bef9376d
--- /dev/null
+++ b/app/portainer/components/form-components/SwitchField/Switch.test.tsx
@@ -0,0 +1,20 @@
+import { render } from '@testing-library/react';
+import { PropsWithChildren } from 'react';
+
+import { Switch, Props } from './Switch';
+
+function renderDefault({
+ name = 'default name',
+ checked = false,
+}: Partial> = {}) {
+ return render(
+ {}} />
+ );
+}
+
+test('should display a Switch component', async () => {
+ const { findByRole } = renderDefault();
+
+ const switchElem = await findByRole('checkbox');
+ expect(switchElem).toBeTruthy();
+});
diff --git a/app/portainer/components/form-components/SwitchField/Switch.tsx b/app/portainer/components/form-components/SwitchField/Switch.tsx
new file mode 100644
index 000000000..99b84d01e
--- /dev/null
+++ b/app/portainer/components/form-components/SwitchField/Switch.tsx
@@ -0,0 +1,68 @@
+import clsx from 'clsx';
+
+import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
+import { BEFeatureIndicator } from '@/portainer/components/BEFeatureIndicator';
+import { FeatureId } from '@/portainer/feature-flags/enums';
+import { r2a } from '@/react-tools/react2angular';
+
+import './Switch.css';
+
+import styles from './Switch.module.css';
+
+export interface Props {
+ checked: boolean;
+ id: string;
+ name: string;
+ onChange(checked: boolean): void;
+
+ className?: string;
+ dataCy?: string;
+ disabled?: boolean;
+ featureId?: FeatureId;
+}
+
+export function Switch({
+ name,
+ checked,
+ id,
+ disabled,
+ dataCy,
+ onChange,
+ featureId,
+ className,
+}: Props) {
+ const limitedToBE = isLimitedToBE(featureId);
+
+ return (
+ <>
+
+ {limitedToBE && }
+ >
+ );
+}
+
+export const SwitchAngular = r2a(Switch, [
+ 'name',
+ 'checked',
+ 'id',
+ 'disabled',
+ 'dataCy',
+ 'onChange',
+ 'feature',
+ 'className',
+]);
diff --git a/app/portainer/components/form-components/SwitchField/SwitchField.module.css b/app/portainer/components/form-components/SwitchField/SwitchField.module.css
new file mode 100644
index 000000000..475c82125
--- /dev/null
+++ b/app/portainer/components/form-components/SwitchField/SwitchField.module.css
@@ -0,0 +1,9 @@
+.root {
+ display: flex;
+ align-items: center;
+ margin: 0;
+}
+
+.label {
+ padding: 0;
+}
diff --git a/app/portainer/components/form-components/SwitchField/SwitchField.stories.tsx b/app/portainer/components/form-components/SwitchField/SwitchField.stories.tsx
new file mode 100644
index 000000000..4c952d809
--- /dev/null
+++ b/app/portainer/components/form-components/SwitchField/SwitchField.stories.tsx
@@ -0,0 +1,56 @@
+import { Meta, Story } from '@storybook/react';
+import { useState } from 'react';
+
+import { SwitchField } from './SwitchField';
+
+export default {
+ title: 'Components/Form/SwitchField',
+} as Meta;
+
+export function Example() {
+ const [isChecked, setIsChecked] = useState(false);
+ function onChange() {
+ setIsChecked(!isChecked);
+ }
+
+ return (
+
+ );
+}
+
+interface Args {
+ checked: boolean;
+ label: string;
+ labelClass: string;
+}
+
+function Template({ checked, label, labelClass }: Args) {
+ return (
+ {}}
+ label={label}
+ labelClass={labelClass}
+ />
+ );
+}
+
+export const Checked: Story = Template.bind({});
+Checked.args = {
+ checked: true,
+ label: 'label',
+ labelClass: 'col-sm-6',
+};
+
+export const Unchecked: Story = Template.bind({});
+Unchecked.args = {
+ checked: false,
+ label: 'label',
+ labelClass: 'col-sm-6',
+};
diff --git a/app/portainer/components/form-components/SwitchField/SwitchField.test.tsx b/app/portainer/components/form-components/SwitchField/SwitchField.test.tsx
new file mode 100644
index 000000000..cc61f7561
--- /dev/null
+++ b/app/portainer/components/form-components/SwitchField/SwitchField.test.tsx
@@ -0,0 +1,37 @@
+import { render, fireEvent } from '@/react-tools/test-utils';
+
+import { SwitchField, Props } from './SwitchField';
+
+function renderDefault({
+ name = 'default name',
+ checked = false,
+ label = 'label',
+ onChange = jest.fn(),
+}: Partial = {}) {
+ return render(
+
+ );
+}
+
+test('should display a Switch component', async () => {
+ const { findByRole } = renderDefault();
+
+ const switchElem = await findByRole('checkbox');
+ expect(switchElem).toBeTruthy();
+});
+
+test('clicking should emit on-change with the opposite value', async () => {
+ const onChange = jest.fn();
+ const checked = true;
+ const { findByRole } = renderDefault({ onChange, checked });
+
+ const switchElem = await findByRole('checkbox');
+ fireEvent.click(switchElem);
+
+ expect(onChange).toHaveBeenCalledWith(!checked);
+});
diff --git a/app/portainer/components/form-components/SwitchField/SwitchField.tsx b/app/portainer/components/form-components/SwitchField/SwitchField.tsx
new file mode 100644
index 000000000..ab6bfd2a7
--- /dev/null
+++ b/app/portainer/components/form-components/SwitchField/SwitchField.tsx
@@ -0,0 +1,72 @@
+import clsx from 'clsx';
+
+import { FeatureId } from '@/portainer/feature-flags/enums';
+import { Tooltip } from '@/portainer/components/Tip/Tooltip';
+import { r2a } from '@/react-tools/react2angular';
+
+import styles from './SwitchField.module.css';
+import { Switch } from './Switch';
+
+export interface Props {
+ label: string;
+ checked: boolean;
+ onChange(value: boolean): void;
+
+ name?: string;
+ tooltip?: string;
+ labelClass?: string;
+ dataCy?: string;
+ disabled?: boolean;
+ featureId?: FeatureId;
+}
+
+export function SwitchField({
+ tooltip,
+ checked,
+ label,
+ name,
+ labelClass,
+ dataCy,
+ disabled,
+ onChange,
+ featureId,
+}: Props) {
+ const toggleName = name ? `toggle_${name}` : '';
+
+ return (
+
+ );
+}
+
+export const SwitchFieldAngular = r2a(SwitchField, [
+ 'tooltip',
+ 'checked',
+ 'label',
+ 'name',
+ 'labelClass',
+ 'dataCy',
+ 'disabled',
+ 'onChange',
+ 'featureId',
+]);
diff --git a/app/portainer/components/form-components/SwitchField/index.ts b/app/portainer/components/form-components/SwitchField/index.ts
new file mode 100644
index 000000000..e743e7352
--- /dev/null
+++ b/app/portainer/components/form-components/SwitchField/index.ts
@@ -0,0 +1 @@
+export { SwitchField, SwitchFieldAngular } from './SwitchField';
diff --git a/app/portainer/components/form-components/index.js b/app/portainer/components/form-components/index.js
index a60a7555b..68e1fad9c 100644
--- a/app/portainer/components/form-components/index.js
+++ b/app/portainer/components/form-components/index.js
@@ -3,4 +3,10 @@ import angular from 'angular';
import { webEditorForm } from './web-editor-form';
import { fileUploadForm } from './file-upload-form';
-export default angular.module('portainer.app.components.form', []).component('webEditorForm', webEditorForm).component('fileUploadForm', fileUploadForm).name;
+import { SwitchFieldAngular } from './SwitchField';
+
+export default angular
+ .module('portainer.app.components.form', [])
+ .component('webEditorForm', webEditorForm)
+ .component('fileUploadForm', fileUploadForm)
+ .component('porSwitchField', SwitchFieldAngular).name;
diff --git a/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.controller.js b/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.controller.js
index 106446b74..bf484e9b9 100644
--- a/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.controller.js
+++ b/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.controller.js
@@ -1,20 +1,29 @@
class GitFormComposeAuthFieldsetController {
/* @ngInject */
- constructor() {
+ constructor($scope) {
+ Object.assign(this, { $scope });
+
this.authValues = {
username: '',
password: '',
};
+ this.handleChange = this.handleChange.bind(this);
this.onChangeField = this.onChangeField.bind(this);
this.onChangeAuth = this.onChangeAuth.bind(this);
this.onChangeUsername = this.onChangeField('RepositoryUsername');
this.onChangePassword = this.onChangeField('RepositoryPassword');
}
+ handleChange(...args) {
+ this.$scope.$evalAsync(() => {
+ this.onChange(...args);
+ });
+ }
+
onChangeField(field) {
return (value) => {
- this.onChange({
+ this.handleChange({
...this.model,
[field]: value,
});
@@ -27,7 +36,7 @@ class GitFormComposeAuthFieldsetController {
this.authValues.password = this.model.RepositoryPassword;
}
- this.onChange({
+ this.handleChange({
...this.model,
RepositoryAuthentication: auth,
RepositoryUsername: auth ? this.authValues.username : '',
diff --git a/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.html b/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.html
index bc6f762a4..4d53f4682 100644
--- a/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.html
+++ b/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.html
@@ -1,11 +1,11 @@
diff --git a/app/portainer/components/forms/git-form/git-form-auto-update-fieldset/git-form-auto-update-fieldset.controller.js b/app/portainer/components/forms/git-form/git-form-auto-update-fieldset/git-form-auto-update-fieldset.controller.js
index 560ff220f..01af3076c 100644
--- a/app/portainer/components/forms/git-form/git-form-auto-update-fieldset/git-form-auto-update-fieldset.controller.js
+++ b/app/portainer/components/forms/git-form/git-form-auto-update-fieldset/git-form-auto-update-fieldset.controller.js
@@ -1,14 +1,15 @@
-import { FORCE_REDEPLOYMENT } from '@/portainer/feature-flags/feature-ids';
+import { FeatureId } from '@/portainer/feature-flags/enums';
class GitFormAutoUpdateFieldsetController {
/* @ngInject */
- constructor(clipboard) {
+ constructor($scope, clipboard) {
+ Object.assign(this, { $scope, clipboard });
+
this.onChangeAutoUpdate = this.onChangeField('RepositoryAutomaticUpdates');
this.onChangeMechanism = this.onChangeField('RepositoryMechanism');
this.onChangeInterval = this.onChangeField('RepositoryFetchInterval');
- this.clipboard = clipboard;
- this.limitedFeature = FORCE_REDEPLOYMENT;
+ this.limitedFeature = FeatureId.FORCE_REDEPLOYMENT;
}
copyWebhook() {
@@ -19,9 +20,11 @@ class GitFormAutoUpdateFieldsetController {
onChangeField(field) {
return (value) => {
- this.onChange({
- ...this.model,
- [field]: value,
+ this.$scope.$evalAsync(() => {
+ this.onChange({
+ ...this.model,
+ [field]: value,
+ });
});
};
}
diff --git a/app/portainer/components/forms/git-form/git-form-auto-update-fieldset/git-form-auto-update-fieldset.html b/app/portainer/components/forms/git-form/git-form-auto-update-fieldset/git-form-auto-update-fieldset.html
index 13ea3e257..b10286636 100644
--- a/app/portainer/components/forms/git-form/git-form-auto-update-fieldset/git-form-auto-update-fieldset.html
+++ b/app/portainer/components/forms/git-form/git-form-auto-update-fieldset/git-form-auto-update-fieldset.html
@@ -1,7 +1,7 @@
@@ -59,7 +59,13 @@
diff --git a/app/portainer/components/forms/por-switch-field/por-switch-field.html b/app/portainer/components/forms/por-switch-field/por-switch-field.html
deleted file mode 100644
index 19f458722..000000000
--- a/app/portainer/components/forms/por-switch-field/por-switch-field.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
diff --git a/app/portainer/components/forms/por-switch-field/por-switch-field.js b/app/portainer/components/forms/por-switch-field/por-switch-field.js
deleted file mode 100644
index 0a38e0483..000000000
--- a/app/portainer/components/forms/por-switch-field/por-switch-field.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import angular from 'angular';
-
-export const porSwitchField = {
- templateUrl: './por-switch-field.html',
- bindings: {
- tooltip: '@',
- ngModel: '=',
- label: '@',
- name: '@',
- labelClass: '@',
- ngDataCy: '@',
- disabled: '<',
- onChange: '<',
- feature: '<', // feature id
- },
-};
-
-angular.module('portainer.app').component('porSwitchField', porSwitchField);
diff --git a/app/portainer/components/forms/por-switch/por-switch.controller.js b/app/portainer/components/forms/por-switch/por-switch.controller.js
deleted file mode 100644
index 0f7ed0532..000000000
--- a/app/portainer/components/forms/por-switch/por-switch.controller.js
+++ /dev/null
@@ -1,14 +0,0 @@
-export default class PorSwitchController {
- /* @ngInject */
- constructor(featureService) {
- Object.assign(this, { featureService });
-
- this.limitedToBE = false;
- }
-
- $onInit() {
- if (this.feature) {
- this.limitedToBE = this.featureService.isLimitedToBE(this.feature);
- }
- }
-}
diff --git a/app/portainer/components/forms/por-switch/por-switch.html b/app/portainer/components/forms/por-switch/por-switch.html
deleted file mode 100644
index acacc784c..000000000
--- a/app/portainer/components/forms/por-switch/por-switch.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
diff --git a/app/portainer/components/forms/por-switch/por-switch.js b/app/portainer/components/forms/por-switch/por-switch.js
deleted file mode 100644
index 793733fb9..000000000
--- a/app/portainer/components/forms/por-switch/por-switch.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import angular from 'angular';
-import controller from './por-switch.controller';
-
-import './por-switch.css';
-
-const porSwitch = {
- templateUrl: './por-switch.html',
- controller,
- bindings: {
- ngModel: '=',
- id: '@',
- className: '@',
- name: '@',
- ngDataCy: '@',
- disabled: '<',
- onChange: '<',
- feature: '<', // feature id
- },
-};
-
-angular.module('portainer.app').component('porSwitch', porSwitch);
diff --git a/app/portainer/components/header-content.js b/app/portainer/components/header-content.js
deleted file mode 100644
index b2fcf16d8..000000000
--- a/app/portainer/components/header-content.js
+++ /dev/null
@@ -1,16 +0,0 @@
-angular.module('portainer.app').directive('rdHeaderContent', [
- 'Authentication',
- function rdHeaderContent(Authentication) {
- var directive = {
- requires: '^rdHeader',
- transclude: true,
- link: function (scope) {
- scope.username = Authentication.getUserDetails().username;
- },
- template:
- '
',
- restrict: 'E',
- };
- return directive;
- },
-]);
diff --git a/app/portainer/components/header-title.js b/app/portainer/components/header-title.js
deleted file mode 100644
index 569038524..000000000
--- a/app/portainer/components/header-title.js
+++ /dev/null
@@ -1,19 +0,0 @@
-angular.module('portainer.app').directive('rdHeaderTitle', [
- 'Authentication',
- function rdHeaderTitle(Authentication) {
- var directive = {
- requires: '^rdHeader',
- scope: {
- titleText: '@',
- },
- link: function (scope) {
- scope.username = Authentication.getUserDetails().username;
- },
- transclude: true,
- template:
- '
{{titleText}} {{username}}
',
- restrict: 'E',
- };
- return directive;
- },
-]);
diff --git a/app/portainer/components/header.js b/app/portainer/components/header.js
deleted file mode 100644
index 11808fde7..000000000
--- a/app/portainer/components/header.js
+++ /dev/null
@@ -1,11 +0,0 @@
-angular.module('portainer.app').directive('rdHeader', function rdHeader() {
- var directive = {
- scope: {
- ngModel: '=',
- },
- transclude: true,
- template: '',
- restrict: 'EA',
- };
- return directive;
-});
diff --git a/app/portainer/components/index.js b/app/portainer/components/index.js
index f1ccae897..e4882a8d7 100644
--- a/app/portainer/components/index.js
+++ b/app/portainer/components/index.js
@@ -7,12 +7,16 @@ import gitFormModule from './forms/git-form';
import porAccessManagementModule from './accessManagement';
import formComponentsModule from './form-components';
import widgetModule from './widget';
+import boxSelectorModule from './BoxSelector';
+import headerModule from './Header';
import { ReactExampleAngular } from './ReactExample';
import { TooltipAngular } from './Tip/Tooltip';
+import { beFeatureIndicatorAngular } from './BEFeatureIndicator';
export default angular
- .module('portainer.app.components', [widgetModule, sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule])
+ .module('portainer.app.components', [headerModule, boxSelectorModule, widgetModule, sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule])
.component('portainerTooltip', TooltipAngular)
.component('reactExample', ReactExampleAngular)
+ .component('beFeatureIndicator', beFeatureIndicatorAngular)
.component('createAccessToken', CreateAccessTokenAngular).name;
diff --git a/app/portainer/components/theme/theme-settings.controller.js b/app/portainer/components/theme/theme-settings.controller.js
index e85c92d7d..0cdfda2e1 100644
--- a/app/portainer/components/theme/theme-settings.controller.js
+++ b/app/portainer/components/theme/theme-settings.controller.js
@@ -1,4 +1,4 @@
-import { buildOption } from '@/portainer/components/box-selector';
+import { buildOption } from '@/portainer/components/BoxSelector';
export default class ThemeSettingsController {
/* @ngInject */
@@ -31,6 +31,7 @@ export default class ThemeSettingsController {
this.ThemeManager.setTheme(theme);
}
this.state.themeInProgress = true;
+ this.state.userTheme = theme;
}
$onInit() {
diff --git a/app/portainer/components/theme/theme-settings.html b/app/portainer/components/theme/theme-settings.html
index 1d5e8b348..45a47b42d 100644
--- a/app/portainer/components/theme/theme-settings.html
+++ b/app/portainer/components/theme/theme-settings.html
@@ -10,8 +10,8 @@
diff --git a/app/portainer/feature-flags/enums.js b/app/portainer/feature-flags/enums.js
deleted file mode 100644
index ed8d0f2fb..000000000
--- a/app/portainer/feature-flags/enums.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export const EDITIONS = {
- CE: 0,
- BE: 1,
-};
-
-export const STATES = {
- HIDDEN: 0,
- VISIBLE: 1,
- LIMITED_BE: 2,
-};
diff --git a/app/portainer/feature-flags/enums.ts b/app/portainer/feature-flags/enums.ts
new file mode 100644
index 000000000..784a1b516
--- /dev/null
+++ b/app/portainer/feature-flags/enums.ts
@@ -0,0 +1,26 @@
+export enum Edition {
+ CE,
+ BE,
+}
+
+export enum FeatureState {
+ HIDDEN,
+ VISIBLE,
+ LIMITED_BE,
+}
+
+export enum FeatureId {
+ K8S_RESOURCE_POOL_LB_QUOTA = 'k8s-resourcepool-Ibquota',
+ K8S_RESOURCE_POOL_STORAGE_QUOTA = 'k8s-resourcepool-storagequota',
+ RBAC_ROLES = 'rbac-roles',
+ REGISTRY_MANAGEMENT = 'registry-management',
+ K8S_SETUP_DEFAULT = 'k8s-setup-default',
+ S3_BACKUP_SETTING = 's3-backup-setting',
+ HIDE_INTERNAL_AUTHENTICATION_PROMPT = 'hide-internal-authentication-prompt',
+ TEAM_MEMBERSHIP = 'team-membership',
+ HIDE_INTERNAL_AUTH = 'hide-internal-auth',
+ EXTERNAL_AUTH_LDAP = 'external-auth-ldap',
+ ACTIVITY_AUDIT = 'activity-audit',
+ FORCE_REDEPLOYMENT = 'force-redeployment',
+ HIDE_AUTO_UPDATE_WINDOW = 'hide-auto-update-window',
+}
diff --git a/app/portainer/feature-flags/feature-flags.service.js b/app/portainer/feature-flags/feature-flags.service.js
deleted file mode 100644
index 1d3874deb..000000000
--- a/app/portainer/feature-flags/feature-flags.service.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { EDITIONS, STATES } from './enums';
-
-import * as FEATURE_IDS from './feature-ids';
-
-export function featureService() {
- const state = {
- currentEdition: undefined,
- features: {},
- };
-
- return {
- selectShow,
- init,
- isLimitedToBE,
- };
-
- async function init() {
- // will be loaded on runtime
- const currentEdition = EDITIONS.CE;
- const features = {
- [FEATURE_IDS.K8S_RESOURCE_POOL_LB_QUOTA]: EDITIONS.BE,
- [FEATURE_IDS.K8S_RESOURCE_POOL_STORAGE_QUOTA]: EDITIONS.BE,
- [FEATURE_IDS.ACTIVITY_AUDIT]: EDITIONS.BE,
- [FEATURE_IDS.EXTERNAL_AUTH_LDAP]: EDITIONS.BE,
- [FEATURE_IDS.HIDE_INTERNAL_AUTH]: EDITIONS.BE,
- [FEATURE_IDS.HIDE_INTERNAL_AUTHENTICATION_PROMPT]: EDITIONS.BE,
- [FEATURE_IDS.K8S_SETUP_DEFAULT]: EDITIONS.BE,
- [FEATURE_IDS.RBAC_ROLES]: EDITIONS.BE,
- [FEATURE_IDS.REGISTRY_MANAGEMENT]: EDITIONS.BE,
- [FEATURE_IDS.S3_BACKUP_SETTING]: EDITIONS.BE,
- [FEATURE_IDS.TEAM_MEMBERSHIP]: EDITIONS.BE,
- [FEATURE_IDS.HIDE_AUTO_UPDATE_WINDOW]: EDITIONS.BE,
- [FEATURE_IDS.FORCE_REDEPLOYMENT]: EDITIONS.BE,
- };
-
- state.currentEdition = currentEdition;
- state.features = features;
- }
-
- function selectShow(featureId) {
- if (!state.features[featureId]) {
- return STATES.HIDDEN;
- }
-
- if (state.features[featureId] <= state.currentEdition) {
- return STATES.VISIBLE;
- }
-
- if (state.features[featureId] === EDITIONS.BE) {
- return STATES.LIMITED_BE;
- }
-
- return STATES.HIDDEN;
- }
-
- function isLimitedToBE(featureId) {
- return selectShow(featureId) === STATES.LIMITED_BE;
- }
-}
diff --git a/app/portainer/feature-flags/feature-flags.service.ts b/app/portainer/feature-flags/feature-flags.service.ts
new file mode 100644
index 000000000..623da5fb0
--- /dev/null
+++ b/app/portainer/feature-flags/feature-flags.service.ts
@@ -0,0 +1,58 @@
+import { Edition, FeatureId, FeatureState } from './enums';
+
+interface ServiceState {
+ currentEdition: Edition;
+ features: Record;
+}
+
+const state: ServiceState = {
+ currentEdition: Edition.CE,
+ features: >{},
+};
+
+export async function init(edition: Edition = Edition.CE) {
+ // will be loaded on runtime
+ const currentEdition = edition;
+ const features = {
+ [FeatureId.K8S_RESOURCE_POOL_LB_QUOTA]: Edition.BE,
+ [FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA]: Edition.BE,
+ [FeatureId.ACTIVITY_AUDIT]: Edition.BE,
+ [FeatureId.EXTERNAL_AUTH_LDAP]: Edition.BE,
+ [FeatureId.HIDE_INTERNAL_AUTH]: Edition.BE,
+ [FeatureId.HIDE_INTERNAL_AUTHENTICATION_PROMPT]: Edition.BE,
+ [FeatureId.K8S_SETUP_DEFAULT]: Edition.BE,
+ [FeatureId.RBAC_ROLES]: Edition.BE,
+ [FeatureId.REGISTRY_MANAGEMENT]: Edition.BE,
+ [FeatureId.S3_BACKUP_SETTING]: Edition.BE,
+ [FeatureId.TEAM_MEMBERSHIP]: Edition.BE,
+ [FeatureId.FORCE_REDEPLOYMENT]: Edition.BE,
+ [FeatureId.HIDE_AUTO_UPDATE_WINDOW]: Edition.BE,
+ };
+
+ state.currentEdition = currentEdition;
+ state.features = features;
+}
+
+export function selectShow(featureId?: FeatureId) {
+ if (!featureId) {
+ return FeatureState.VISIBLE;
+ }
+
+ if (!state.features[featureId]) {
+ return FeatureState.HIDDEN;
+ }
+
+ if (state.features[featureId] <= state.currentEdition) {
+ return FeatureState.VISIBLE;
+ }
+
+ if (state.features[featureId] === Edition.BE) {
+ return FeatureState.LIMITED_BE;
+ }
+
+ return FeatureState.HIDDEN;
+}
+
+export function isLimitedToBE(featureId?: FeatureId) {
+ return selectShow(featureId) === FeatureState.LIMITED_BE;
+}
diff --git a/app/portainer/feature-flags/feature-ids.js b/app/portainer/feature-flags/feature-ids.js
deleted file mode 100644
index c10cf30d4..000000000
--- a/app/portainer/feature-flags/feature-ids.js
+++ /dev/null
@@ -1,13 +0,0 @@
-export const K8S_RESOURCE_POOL_LB_QUOTA = 'k8s-resourcepool-Ibquota';
-export const K8S_RESOURCE_POOL_STORAGE_QUOTA = 'k8s-resourcepool-storagequota';
-export const RBAC_ROLES = 'rbac-roles';
-export const REGISTRY_MANAGEMENT = 'registry-management';
-export const K8S_SETUP_DEFAULT = 'k8s-setup-default';
-export const S3_BACKUP_SETTING = 's3-backup-setting';
-export const HIDE_INTERNAL_AUTHENTICATION_PROMPT = 'hide-internal-authentication-prompt';
-export const TEAM_MEMBERSHIP = 'team-membership';
-export const HIDE_INTERNAL_AUTH = 'hide-internal-auth';
-export const EXTERNAL_AUTH_LDAP = 'external-auth-ldap';
-export const ACTIVITY_AUDIT = 'activity-audit';
-export const HIDE_AUTO_UPDATE_WINDOW = 'hide-auto-update-window';
-export const FORCE_REDEPLOYMENT = 'force-redeployment';
diff --git a/app/portainer/feature-flags/index.js b/app/portainer/feature-flags/index.js
deleted file mode 100644
index 2830b08f2..000000000
--- a/app/portainer/feature-flags/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import angular from 'angular';
-
-import { limitedFeatureDirective } from './limited-feature.directive';
-import { featureService } from './feature-flags.service';
-import './feature-flags.css';
-
-export default angular.module('portainer.feature-flags', []).directive('limitedFeatureDir', limitedFeatureDirective).factory('featureService', featureService).name;
diff --git a/app/portainer/feature-flags/index.ts b/app/portainer/feature-flags/index.ts
new file mode 100644
index 000000000..68d713db5
--- /dev/null
+++ b/app/portainer/feature-flags/index.ts
@@ -0,0 +1,8 @@
+import angular from 'angular';
+
+import { limitedFeatureDirective } from './limited-feature.directive';
+import './feature-flags.css';
+
+export default angular
+ .module('portainer.feature-flags', [])
+ .directive('limitedFeatureDir', limitedFeatureDirective).name;
diff --git a/app/portainer/feature-flags/limited-feature.directive.js b/app/portainer/feature-flags/limited-feature.directive.ts
similarity index 61%
rename from app/portainer/feature-flags/limited-feature.directive.js
rename to app/portainer/feature-flags/limited-feature.directive.ts
index b368714f7..0a4542e59 100644
--- a/app/portainer/feature-flags/limited-feature.directive.js
+++ b/app/portainer/feature-flags/limited-feature.directive.ts
@@ -1,17 +1,20 @@
import _ from 'lodash-es';
+import { IAttributes, IDirective, IScope } from 'angular';
-import { STATES } from '@/portainer/feature-flags/enums';
+import { FeatureState } from '@/portainer/feature-flags/enums';
+
+import { selectShow } from './feature-flags.service';
const BASENAME = 'limitedFeature';
/* @ngInject */
-export function limitedFeatureDirective(featureService) {
+export function limitedFeatureDirective(): IDirective {
return {
restrict: 'A',
link,
};
- function link(scope, elem, attrs) {
+ function link(scope: IScope, elem: JQLite, attrs: IAttributes) {
const { limitedFeatureDir: featureId } = attrs;
if (!featureId) {
@@ -22,14 +25,14 @@ export function limitedFeatureDirective(featureService) {
.filter((attr) => attr.startsWith(BASENAME) && attr !== `${BASENAME}Dir`)
.map((attr) => [_.kebabCase(attr.replace(BASENAME, '')), attrs[attr]]);
- const state = featureService.selectShow(featureId);
+ const state = selectShow(featureId);
- if (state === STATES.HIDDEN) {
+ if (state === FeatureState.HIDDEN) {
elem.hide();
return;
}
- if (state === STATES.VISIBLE) {
+ if (state === FeatureState.VISIBLE) {
return;
}
diff --git a/app/portainer/helpers/userHelper.js b/app/portainer/helpers/userHelper.js
deleted file mode 100644
index 87eed766b..000000000
--- a/app/portainer/helpers/userHelper.js
+++ /dev/null
@@ -1,16 +0,0 @@
-angular.module('portainer.app').factory('UserHelper', [
- function UserHelperFactory() {
- 'use strict';
- var helper = {};
-
- helper.filterNonAdministratorUsers = function (users) {
- return users.filter(function (user) {
- if (user.Role !== 1) {
- return user;
- }
- });
- };
-
- return helper;
- },
-]);
diff --git a/app/portainer/helpers/userHelper.ts b/app/portainer/helpers/userHelper.ts
new file mode 100644
index 000000000..9f12eac4f
--- /dev/null
+++ b/app/portainer/helpers/userHelper.ts
@@ -0,0 +1,5 @@
+import { UserViewModel } from '../models/user';
+
+export function filterNonAdministratorUsers(users: UserViewModel[]) {
+ return users.filter((user) => user.Role !== 1);
+}
diff --git a/app/portainer/hooks/useLocalStorage.ts b/app/portainer/hooks/useLocalStorage.ts
new file mode 100644
index 000000000..19d8c1634
--- /dev/null
+++ b/app/portainer/hooks/useLocalStorage.ts
@@ -0,0 +1,46 @@
+import { useState, useCallback } from 'react';
+
+const localStoragePrefix = 'portainer';
+
+function keyBuilder(key: string) {
+ return `${localStoragePrefix}.${key}`;
+}
+
+export function useLocalStorage(
+ key: string,
+ defaultValue: T,
+ storage = localStorage
+): [T, (value: T) => void] {
+ const [value, setValue] = useState(get(key, defaultValue, storage));
+
+ const handleChange = useCallback(
+ (value) => {
+ setValue(value);
+ set(key, value, storage);
+ },
+ [key, storage]
+ );
+
+ return [value, handleChange];
+}
+
+export function get(
+ key: string,
+ defaultValue: T,
+ storage = localStorage
+): T {
+ const value = storage.getItem(keyBuilder(key));
+ if (!value) {
+ return defaultValue;
+ }
+
+ try {
+ return JSON.parse(value);
+ } catch (e) {
+ return defaultValue;
+ }
+}
+
+export function set(key: string, value: T, storage = localStorage) {
+ storage.setItem(keyBuilder(key), JSON.stringify(value));
+}
diff --git a/app/portainer/hooks/useUser.tsx b/app/portainer/hooks/useUser.tsx
new file mode 100644
index 000000000..05c3e229d
--- /dev/null
+++ b/app/portainer/hooks/useUser.tsx
@@ -0,0 +1,118 @@
+import jwtDecode from 'jwt-decode';
+import { useCurrentStateAndParams } from '@uirouter/react';
+import {
+ createContext,
+ ReactNode,
+ useContext,
+ useEffect,
+ useState,
+} from 'react';
+
+import { getUser } from '@/portainer/services/api/userService';
+
+import { UserViewModel } from '../models/user';
+
+import { useLocalStorage } from './useLocalStorage';
+
+interface State {
+ user?: UserViewModel;
+}
+
+const state: State = {};
+
+export const UserContext = createContext(null);
+
+export function useUser() {
+ const context = useContext(UserContext);
+
+ if (context === null) {
+ throw new Error('should be nested under UserProvider');
+ }
+
+ return context;
+}
+
+export function useAuthorizations(authorizations: string | string[]) {
+ const authorizationsArray =
+ typeof authorizations === 'string' ? [authorizations] : authorizations;
+
+ const { user } = useUser();
+ const { params } = useCurrentStateAndParams();
+
+ const { endpointId } = params;
+ if (!endpointId) {
+ return false;
+ }
+
+ if (!user) {
+ return false;
+ }
+
+ if (isAdmin(user)) {
+ return true;
+ }
+
+ if (
+ !user.EndpointAuthorizations ||
+ !user.EndpointAuthorizations[endpointId]
+ ) {
+ return false;
+ }
+
+ const userEndpointAuthorizations = user.EndpointAuthorizations[endpointId];
+ return authorizationsArray.some(
+ (authorization) => userEndpointAuthorizations[authorization]
+ );
+}
+
+interface AuthorizedProps {
+ authorizations: string | string[];
+ children: ReactNode;
+}
+
+export function Authorized({ authorizations, children }: AuthorizedProps) {
+ const isAllowed = useAuthorizations(authorizations);
+
+ return isAllowed ? <>{children}> : null;
+}
+
+interface UserProviderProps {
+ children: ReactNode;
+}
+
+export function UserProvider({ children }: UserProviderProps) {
+ const [jwt] = useLocalStorage('JWT', '');
+ const [user, setUser] = useState(null);
+
+ useEffect(() => {
+ if (state.user) {
+ setUser(state.user);
+ } else if (jwt !== '') {
+ const tokenPayload = jwtDecode(jwt) as { id: number };
+
+ loadUser(tokenPayload.id);
+ }
+ }, [jwt]);
+
+ if (jwt === '') {
+ return null;
+ }
+
+ if (user === null) {
+ return null;
+ }
+
+ return (
+ {children}
+ );
+
+ async function loadUser(id: number) {
+ const user = await getUser(id);
+ state.user = user;
+ setUser(user);
+ }
+}
+
+function isAdmin(user: UserViewModel): boolean {
+ return user.Role === 1;
+}
diff --git a/app/portainer/models/user.js b/app/portainer/models/user.js
index 4e54544c0..a720f2653 100644
--- a/app/portainer/models/user.js
+++ b/app/portainer/models/user.js
@@ -10,6 +10,7 @@ export function UserViewModel(data) {
}
this.AuthenticationMethod = data.AuthenticationMethod;
this.Checked = false;
+ this.EndpointAuthorizations = null;
}
export function UserTokenModel(data) {
diff --git a/app/portainer/oauth/components/oauth-providers-selector/oauth-provider-selector.controller.js b/app/portainer/oauth/components/oauth-providers-selector/oauth-provider-selector.controller.js
index a08daee0c..29552479b 100644
--- a/app/portainer/oauth/components/oauth-providers-selector/oauth-provider-selector.controller.js
+++ b/app/portainer/oauth/components/oauth-providers-selector/oauth-provider-selector.controller.js
@@ -1,13 +1,12 @@
-import { HIDE_INTERNAL_AUTH } from '@/portainer/feature-flags/feature-ids';
-
-import { buildOption } from '@/portainer/components/box-selector';
+import { buildOption } from '@/portainer/components/BoxSelector';
+import { FeatureId } from '@/portainer/feature-flags/enums';
export default class OAuthProviderSelectorController {
constructor() {
this.options = [
- buildOption('microsoft', 'fab fa-microsoft', 'Microsoft', 'Microsoft OAuth provider', 'microsoft', HIDE_INTERNAL_AUTH),
- buildOption('google', 'fab fa-google', 'Google', 'Google OAuth provider', 'google', HIDE_INTERNAL_AUTH),
- buildOption('github', 'fab fa-github', 'Github', 'Github OAuth provider', 'github', HIDE_INTERNAL_AUTH),
+ buildOption('microsoft', 'fab fa-microsoft', 'Microsoft', 'Microsoft OAuth provider', 'microsoft', FeatureId.HIDE_INTERNAL_AUTH),
+ buildOption('google', 'fab fa-google', 'Google', 'Google OAuth provider', 'google', FeatureId.HIDE_INTERNAL_AUTH),
+ buildOption('github', 'fab fa-github', 'Github', 'Github OAuth provider', 'github', FeatureId.HIDE_INTERNAL_AUTH),
buildOption('custom', 'fa fa-user-check', 'Custom', 'Custom OAuth provider', 'custom'),
];
}
diff --git a/app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.html b/app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.html
index f45db531b..ea141d0df 100644
--- a/app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.html
+++ b/app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.html
@@ -2,4 +2,4 @@
Provider
-
+
diff --git a/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js b/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js
index 3099340dd..25c7847ea 100644
--- a/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js
+++ b/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js
@@ -1,14 +1,14 @@
-import { HIDE_INTERNAL_AUTH } from '@/portainer/feature-flags/feature-ids';
import { baseHref } from '@/portainer/helpers/pathHelper';
-
+import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
+import { FeatureId } from '@/portainer/feature-flags/enums';
import providers, { getProviderByUrl } from './providers';
export default class OAuthSettingsController {
/* @ngInject */
- constructor(featureService) {
- this.featureService = featureService;
+ constructor($scope) {
+ Object.assign(this, { $scope });
- this.limitedFeature = HIDE_INTERNAL_AUTH;
+ this.limitedFeature = FeatureId.HIDE_INTERNAL_AUTH;
this.limitedFeatureClass = 'limited-be';
this.state = {
@@ -24,6 +24,7 @@ export default class OAuthSettingsController {
this.updateSSO = this.updateSSO.bind(this);
this.addTeamMembershipMapping = this.addTeamMembershipMapping.bind(this);
this.removeTeamMembership = this.removeTeamMembership.bind(this);
+ this.onToggleAutoTeamMembership = this.onToggleAutoTeamMembership.bind(this);
}
onMicrosoftTenantIDChange() {
@@ -62,8 +63,27 @@ export default class OAuthSettingsController {
this.useDefaultProviderConfiguration(provider);
}
- updateSSO() {
- this.settings.HideInternalAuth = this.featureService.isLimitedToBE(this.limitedFeature) ? false : this.settings.SSO;
+ updateSSO(checked) {
+ this.$scope.$evalAsync(() => {
+ this.settings.SSO = checked;
+ this.onChangeHideInternalAuth(checked);
+ });
+ }
+
+ onChangeHideInternalAuth(checked) {
+ this.$scope.$evalAsync(() => {
+ if (this.featureService.isLimitedToBE(this.limitedFeature)) {
+ return;
+ }
+
+ this.settings.HideInternalAuth = checked;
+ });
+ }
+
+ onToggleAutoTeamMembership(checked) {
+ this.$scope.$evalAsync(() => {
+ this.settings.OAuthAutoMapTeamMemberships = checked;
+ });
}
addTeamMembershipMapping() {
@@ -89,7 +109,7 @@ export default class OAuthSettingsController {
}
$onInit() {
- this.isLimitedToBE = this.featureService.isLimitedToBE(this.limitedFeature);
+ this.isLimitedToBE = isLimitedToBE(this.limitedFeature);
if (this.isLimitedToBE) {
return;
diff --git a/app/portainer/oauth/components/oauth-settings/oauth-settings.html b/app/portainer/oauth/components/oauth-settings/oauth-settings.html
index 8116cd999..9d441a8a2 100644
--- a/app/portainer/oauth/components/oauth-settings/oauth-settings.html
+++ b/app/portainer/oauth/components/oauth-settings/oauth-settings.html
@@ -7,11 +7,11 @@
@@ -21,10 +21,11 @@
@@ -93,7 +94,13 @@
diff --git a/app/portainer/rbac/components/access-viewer/access-viewer.controller.js b/app/portainer/rbac/components/access-viewer/access-viewer.controller.js
index a4a03e483..cbe830bca 100644
--- a/app/portainer/rbac/components/access-viewer/access-viewer.controller.js
+++ b/app/portainer/rbac/components/access-viewer/access-viewer.controller.js
@@ -1,11 +1,11 @@
import _ from 'lodash-es';
+import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
import AccessViewerPolicyModel from '../../models/access';
export default class AccessViewerController {
/* @ngInject */
- constructor(featureService, Notifications, RoleService, UserService, EndpointService, GroupService, TeamService, TeamMembershipService) {
- this.featureService = featureService;
+ constructor(Notifications, RoleService, UserService, EndpointService, GroupService, TeamService, TeamMembershipService) {
this.Notifications = Notifications;
this.RoleService = RoleService;
this.UserService = UserService;
@@ -102,7 +102,7 @@ export default class AccessViewerController {
async $onInit() {
try {
- const limitedToBE = this.featureService.isLimitedToBE(this.limitedFeature);
+ const limitedToBE = isLimitedToBE(this.limitedFeature);
if (limitedToBE) {
return;
diff --git a/app/portainer/services/api/index.ts b/app/portainer/services/api/index.ts
new file mode 100644
index 000000000..7caf483e5
--- /dev/null
+++ b/app/portainer/services/api/index.ts
@@ -0,0 +1,7 @@
+import angular from 'angular';
+
+import { UserService } from './userService';
+
+export const apiServicesModule = angular
+ .module('portainer.app.services.api', [])
+ .factory('UserService', UserService).name;
diff --git a/app/portainer/services/api/userService.js b/app/portainer/services/api/userService.js
index 769ff0116..bf8b461b5 100644
--- a/app/portainer/services/api/userService.js
+++ b/app/portainer/services/api/userService.js
@@ -1,196 +1,202 @@
import _ from 'lodash-es';
+
+import axios from '@/portainer/services/axios';
+
+const BASE_URL = '/users';
+
+import PortainerError from '@/portainer/error';
+import { filterNonAdministratorUsers } from '@/portainer/helpers/userHelper';
import { UserViewModel, UserTokenModel } from '../../models/user';
import { TeamMembershipModel } from '../../models/teamMembership';
-angular.module('portainer.app').factory('UserService', [
- '$q',
- 'Users',
- 'UserHelper',
- 'TeamService',
- 'TeamMembershipService',
- function UserServiceFactory($q, Users, UserHelper, TeamService, TeamMembershipService) {
- 'use strict';
- var service = {};
+export async function getUsers(includeAdministrators) {
+ try {
+ let { data } = await axios.get(BASE_URL);
- service.users = function (includeAdministrators) {
- var deferred = $q.defer();
+ const users = data.map((user) => new UserViewModel(user));
- Users.query({})
- .$promise.then(function success(data) {
- var users = data.map(function (user) {
- return new UserViewModel(user);
- });
- if (!includeAdministrators) {
- users = UserHelper.filterNonAdministratorUsers(users);
- }
- deferred.resolve(users);
- })
- .catch(function error(err) {
- deferred.reject({ msg: 'Unable to retrieve users', err: err });
+ if (includeAdministrators) {
+ return users;
+ }
+
+ return filterNonAdministratorUsers(users);
+ } catch (e) {
+ let err = e;
+ if (err.isAxiosError) {
+ err = new Error(e.response.data.message);
+ }
+
+ throw new PortainerError('Unable to retrieve users', err);
+ }
+}
+
+export async function getUser(id) {
+ try {
+ const { data: user } = await axios.get(`${BASE_URL}/${id}`);
+
+ return new UserViewModel(user);
+ } catch (e) {
+ let err = e;
+ if (err.isAxiosError) {
+ err = new Error(e.response.data.message);
+ }
+
+ throw new PortainerError('Unable to retrieve user details', err);
+ }
+}
+
+/* @ngInject */
+export function UserService($q, Users, TeamService, TeamMembershipService) {
+ 'use strict';
+ var service = {};
+
+ service.users = getUsers;
+
+ service.user = getUser;
+
+ service.createUser = function (username, password, role, teamIds) {
+ var deferred = $q.defer();
+
+ var payload = {
+ username: username,
+ password: password,
+ role: role,
+ };
+
+ Users.create({}, payload)
+ .$promise.then(function success(data) {
+ var userId = data.Id;
+ var teamMembershipQueries = [];
+ angular.forEach(teamIds, function (teamId) {
+ teamMembershipQueries.push(TeamMembershipService.createMembership(userId, teamId, 2));
});
-
- return deferred.promise;
- };
-
- service.user = function (id) {
- var deferred = $q.defer();
-
- Users.get({ id: id })
- .$promise.then(function success(data) {
- var user = new UserViewModel(data);
- deferred.resolve(user);
- })
- .catch(function error(err) {
- deferred.reject({ msg: 'Unable to retrieve user details', err: err });
+ $q.all(teamMembershipQueries).then(function success() {
+ deferred.resolve();
});
-
- return deferred.promise;
- };
-
- service.createUser = function (username, password, role, teamIds) {
- var deferred = $q.defer();
-
- var payload = {
- username: username,
- password: password,
- role: role,
- };
-
- Users.create({}, payload)
- .$promise.then(function success(data) {
- var userId = data.Id;
- var teamMembershipQueries = [];
- angular.forEach(teamIds, function (teamId) {
- teamMembershipQueries.push(TeamMembershipService.createMembership(userId, teamId, 2));
- });
- $q.all(teamMembershipQueries).then(function success() {
- deferred.resolve();
- });
- })
- .catch(function error(err) {
- deferred.reject({ msg: 'Unable to create user', err: err });
- });
-
- return deferred.promise;
- };
-
- service.deleteUser = function (id) {
- return Users.remove({ id: id }).$promise;
- };
-
- service.updateUser = function (id, { password, role, username }) {
- return Users.update({ id }, { password, role, username }).$promise;
- };
-
- service.updateUserPassword = function (id, currentPassword, newPassword) {
- var payload = {
- Password: currentPassword,
- NewPassword: newPassword,
- };
-
- return Users.updatePassword({ id: id }, payload).$promise;
- };
-
- service.updateUserTheme = function (id, userTheme) {
- return Users.updateTheme({ id }, { userTheme }).$promise;
- };
-
- service.userMemberships = function (id) {
- var deferred = $q.defer();
-
- Users.queryMemberships({ id: id })
- .$promise.then(function success(data) {
- var memberships = data.map(function (item) {
- return new TeamMembershipModel(item);
- });
- deferred.resolve(memberships);
- })
- .catch(function error(err) {
- deferred.reject({ msg: 'Unable to retrieve user memberships', err: err });
- });
-
- return deferred.promise;
- };
-
- service.userLeadingTeams = function (id) {
- var deferred = $q.defer();
-
- $q.all({
- teams: TeamService.teams(),
- memberships: service.userMemberships(id),
})
- .then(function success(data) {
- var memberships = data.memberships;
- var teams = data.teams.filter(function (team) {
- var membership = _.find(memberships, { TeamId: team.Id });
- if (membership && membership.Role === 1) {
- return team;
- }
- });
- deferred.resolve(teams);
- })
- .catch(function error(err) {
- deferred.reject({ msg: 'Unable to retrieve user teams', err: err });
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to create user', err: err });
+ });
+
+ return deferred.promise;
+ };
+
+ service.deleteUser = function (id) {
+ return Users.remove({ id: id }).$promise;
+ };
+
+ service.updateUser = function (id, { password, role, username }) {
+ return Users.update({ id }, { password, role, username }).$promise;
+ };
+
+ service.updateUserPassword = function (id, currentPassword, newPassword) {
+ var payload = {
+ Password: currentPassword,
+ NewPassword: newPassword,
+ };
+
+ return Users.updatePassword({ id: id }, payload).$promise;
+ };
+
+ service.updateUserTheme = function (id, userTheme) {
+ return Users.updateTheme({ id }, { userTheme }).$promise;
+ };
+
+ service.userMemberships = function (id) {
+ var deferred = $q.defer();
+
+ Users.queryMemberships({ id: id })
+ .$promise.then(function success(data) {
+ var memberships = data.map(function (item) {
+ return new TeamMembershipModel(item);
});
+ deferred.resolve(memberships);
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to retrieve user memberships', err: err });
+ });
- return deferred.promise;
- };
+ return deferred.promise;
+ };
- service.createAccessToken = function (id, description) {
- const deferred = $q.defer();
- const payload = { description };
- Users.createAccessToken({ id }, payload)
- .$promise.then((data) => {
- deferred.resolve(data);
- })
- .catch(function error(err) {
- deferred.reject({ msg: 'Unable to create user', err: err });
- });
- return deferred.promise;
- };
+ service.userLeadingTeams = function (id) {
+ var deferred = $q.defer();
- service.getAccessTokens = function (id) {
- var deferred = $q.defer();
-
- Users.getAccessTokens({ id: id })
- .$promise.then(function success(data) {
- var userTokens = data.map(function (item) {
- return new UserTokenModel(item);
- });
- deferred.resolve(userTokens);
- })
- .catch(function error(err) {
- deferred.reject({ msg: 'Unable to retrieve user tokens', err: err });
- });
-
- return deferred.promise;
- };
-
- service.deleteAccessToken = function (id, tokenId) {
- return Users.deleteAccessToken({ id: id, tokenId: tokenId }).$promise;
- };
-
- service.initAdministrator = function (username, password) {
- return Users.initAdminUser({ Username: username, Password: password }).$promise;
- };
-
- service.administratorExists = function () {
- var deferred = $q.defer();
-
- Users.checkAdminUser({})
- .$promise.then(function success() {
- deferred.resolve(true);
- })
- .catch(function error(err) {
- if (err.status === 404) {
- deferred.resolve(false);
+ $q.all({
+ teams: TeamService.teams(),
+ memberships: service.userMemberships(id),
+ })
+ .then(function success(data) {
+ var memberships = data.memberships;
+ var teams = data.teams.filter(function (team) {
+ var membership = _.find(memberships, { TeamId: team.Id });
+ if (membership && membership.Role === 1) {
+ return team;
}
- deferred.reject({ msg: 'Unable to verify administrator account existence', err: err });
});
+ deferred.resolve(teams);
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to retrieve user teams', err: err });
+ });
- return deferred.promise;
- };
+ return deferred.promise;
+ };
- return service;
- },
-]);
+ service.createAccessToken = function (id, description) {
+ const deferred = $q.defer();
+ const payload = { description };
+ Users.createAccessToken({ id }, payload)
+ .$promise.then((data) => {
+ deferred.resolve(data);
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to create user', err: err });
+ });
+ return deferred.promise;
+ };
+
+ service.getAccessTokens = function (id) {
+ var deferred = $q.defer();
+
+ Users.getAccessTokens({ id: id })
+ .$promise.then(function success(data) {
+ var userTokens = data.map(function (item) {
+ return new UserTokenModel(item);
+ });
+ deferred.resolve(userTokens);
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to retrieve user tokens', err: err });
+ });
+
+ return deferred.promise;
+ };
+
+ service.deleteAccessToken = function (id, tokenId) {
+ return Users.deleteAccessToken({ id: id, tokenId: tokenId }).$promise;
+ };
+
+ service.initAdministrator = function (username, password) {
+ return Users.initAdminUser({ Username: username, Password: password }).$promise;
+ };
+
+ service.administratorExists = function () {
+ var deferred = $q.defer();
+
+ Users.checkAdminUser({})
+ .$promise.then(function success() {
+ deferred.resolve(true);
+ })
+ .catch(function error(err) {
+ if (err.status === 404) {
+ deferred.resolve(false);
+ }
+ deferred.reject({ msg: 'Unable to verify administrator account existence', err: err });
+ });
+
+ return deferred.promise;
+ };
+
+ return service;
+}
diff --git a/app/portainer/services/axios.ts b/app/portainer/services/axios.ts
new file mode 100644
index 000000000..7d1176743
--- /dev/null
+++ b/app/portainer/services/axios.ts
@@ -0,0 +1,42 @@
+import axios, { AxiosRequestConfig } from 'axios';
+
+import { get as localStorageGet } from '../hooks/useLocalStorage';
+
+import {
+ portainerAgentManagerOperation,
+ portainerAgentTargetHeader,
+} from './http-request.helper';
+
+const axiosApiInstance = axios.create({ baseURL: '/api' });
+
+export default axiosApiInstance;
+
+axiosApiInstance.interceptors.request.use(async (config) => {
+ const newConfig = { ...config };
+
+ const jwt = localStorageGet('JWT', '');
+ if (jwt) {
+ newConfig.headers = {
+ Authorization: `Bearer ${jwt}`,
+ };
+ }
+
+ return newConfig;
+});
+
+export function agentInterceptor(config: AxiosRequestConfig) {
+ if (!config.url || !config.url.includes('/docker/')) {
+ return config;
+ }
+
+ const newConfig = { headers: config.headers || {}, ...config };
+
+ newConfig.headers['X-PortainerAgent-Target'] = portainerAgentTargetHeader();
+ if (portainerAgentManagerOperation()) {
+ newConfig.headers['X-PortainerAgent-ManagerOperation'] = '1';
+ }
+
+ return newConfig;
+}
+
+axiosApiInstance.interceptors.request.use(agentInterceptor);
diff --git a/app/portainer/services/http-request.helper.test.ts b/app/portainer/services/http-request.helper.test.ts
new file mode 100644
index 000000000..703b7bf8c
--- /dev/null
+++ b/app/portainer/services/http-request.helper.test.ts
@@ -0,0 +1,69 @@
+import {
+ registryAuthenticationHeader,
+ setRegistryAuthenticationHeader,
+ portainerAgentTargetHeader,
+ setPortainerAgentTargetHeader,
+ setPortainerAgentManagerOperation,
+ portainerAgentManagerOperation,
+ resetAgentHeaders,
+} from './http-request.helper';
+
+afterEach(() => {
+ resetAgentHeaders();
+});
+
+test('registryAuthenticationHeader', () => {
+ const header = 'header';
+
+ expect(registryAuthenticationHeader()).toBeUndefined();
+
+ setRegistryAuthenticationHeader(header);
+
+ expect(registryAuthenticationHeader()).toBe(header);
+
+ resetAgentHeaders();
+
+ expect(registryAuthenticationHeader()).toBeUndefined();
+});
+
+test('portainerAgentTargetHeader', () => {
+ const header = 'header';
+
+ expect(portainerAgentTargetHeader()).toBe('');
+
+ setPortainerAgentTargetHeader(header);
+
+ expect(portainerAgentTargetHeader()).toBe(header);
+
+ resetAgentHeaders();
+
+ expect(portainerAgentTargetHeader()).toBe('');
+});
+
+test('when setting portainerAgentTargetHeader more than once, should return headers in fifo, until the last one then it should be the last one', () => {
+ const headers = Array.from({ length: 5 }).map((_, i) => `header${i}`);
+
+ expect(portainerAgentTargetHeader()).toBe('');
+
+ headers.forEach((header) => setPortainerAgentTargetHeader(header));
+
+ headers.forEach((_, i) =>
+ expect(portainerAgentTargetHeader()).toBe(`header${i}`)
+ );
+
+ expect(portainerAgentTargetHeader()).toBe('header4');
+ expect(portainerAgentTargetHeader()).toBe('header4');
+ expect(portainerAgentTargetHeader()).toBe('header4');
+});
+
+test('portainerAgentManagerOperation', () => {
+ expect(portainerAgentManagerOperation()).toBe(false);
+
+ setPortainerAgentManagerOperation(true);
+
+ expect(portainerAgentManagerOperation()).toBe(true);
+
+ resetAgentHeaders();
+
+ expect(portainerAgentManagerOperation()).toBe(false);
+});
diff --git a/app/portainer/services/http-request.helper.ts b/app/portainer/services/http-request.helper.ts
new file mode 100644
index 000000000..3c0c7552a
--- /dev/null
+++ b/app/portainer/services/http-request.helper.ts
@@ -0,0 +1,74 @@
+interface Headers {
+ agentTargetQueue: string[];
+ agentManagerOperation: boolean;
+ registryAuthentication?: string;
+ agentTargetLastValue: string;
+}
+
+const headers: Headers = {
+ agentTargetQueue: [],
+ agentManagerOperation: false,
+ agentTargetLastValue: '',
+};
+
+export function registryAuthenticationHeader() {
+ return headers.registryAuthentication;
+}
+
+export function setRegistryAuthenticationHeader(headerValue: string) {
+ headers.registryAuthentication = headerValue;
+}
+
+// Due to the fact that async HTTP requests are decorated using an interceptor
+// we need to store and retrieve the headers using a first-in-first-out (FIFO) data structure.
+// Otherwise, sequential HTTP requests might end up using the same header value (incorrect in the case
+// of starting multiple containers on different nodes for example).
+// To prevent having to use the HttpRequestHelper.setPortainerAgentTargetHeader before EACH request,
+// we re-use the latest available header in the data structure (handy in thee case of multiple requests affecting
+// the same node in the same view).
+export function portainerAgentTargetHeader() {
+ if (headers.agentTargetQueue.length === 0) {
+ return headers.agentTargetLastValue;
+ }
+
+ if (headers.agentTargetQueue.length === 1) {
+ const [lastValue] = headers.agentTargetQueue;
+ headers.agentTargetLastValue = lastValue;
+ }
+
+ return headers.agentTargetQueue.shift() || '';
+}
+
+export function setPortainerAgentTargetHeader(headerValue: string) {
+ if (headerValue) {
+ headers.agentTargetQueue.push(headerValue);
+ }
+}
+
+export function setPortainerAgentManagerOperation(set: boolean) {
+ headers.agentManagerOperation = set;
+}
+
+export function portainerAgentManagerOperation() {
+ return headers.agentManagerOperation;
+}
+
+export function resetAgentHeaders() {
+ headers.agentTargetQueue = [];
+ headers.agentTargetLastValue = '';
+ headers.agentManagerOperation = false;
+ delete headers.registryAuthentication;
+}
+
+/* @ngInject */
+export function HttpRequestHelperAngular() {
+ return {
+ registryAuthenticationHeader,
+ setRegistryAuthenticationHeader,
+ portainerAgentTargetHeader,
+ setPortainerAgentTargetHeader,
+ setPortainerAgentManagerOperation,
+ portainerAgentManagerOperation,
+ resetAgentHeaders,
+ };
+}
diff --git a/app/portainer/services/httpRequestHelper.js b/app/portainer/services/httpRequestHelper.js
deleted file mode 100644
index 9e8b0c9f2..000000000
--- a/app/portainer/services/httpRequestHelper.js
+++ /dev/null
@@ -1,56 +0,0 @@
-angular.module('portainer.app').factory('HttpRequestHelper', [
- function HttpRequestHelper() {
- 'use strict';
-
- var service = {};
- var headers = {};
- headers.agentTargetQueue = [];
- headers.agentManagerOperation = false;
-
- service.registryAuthenticationHeader = function () {
- return headers.registryAuthentication;
- };
-
- service.setRegistryAuthenticationHeader = function (headerValue) {
- headers.registryAuthentication = headerValue;
- };
-
- // Due to the fact that async HTTP requests are decorated using an interceptor
- // we need to store and retrieve the headers using a first-in-first-out (FIFO) data structure.
- // Otherwise, sequential HTTP requests might end up using the same header value (incorrect in the case
- // of starting multiple containers on different nodes for example).
- // To prevent having to use the HttpRequestHelper.setPortainerAgentTargetHeader before EACH request,
- // we re-use the latest available header in the data structure (handy in thee case of multiple requests affecting
- // the same node in the same view).
- service.portainerAgentTargetHeader = function () {
- if (headers.agentTargetQueue.length === 0) {
- return headers.agentTargetLastValue;
- } else if (headers.agentTargetQueue.length === 1) {
- headers.agentTargetLastValue = headers.agentTargetQueue[0];
- }
- return headers.agentTargetQueue.shift();
- };
-
- service.setPortainerAgentTargetHeader = function (headerValue) {
- if (headerValue) {
- headers.agentTargetQueue.push(headerValue);
- }
- };
-
- service.setPortainerAgentManagerOperation = function (set) {
- headers.agentManagerOperation = set;
- };
-
- service.portainerAgentManagerOperation = function () {
- return headers.agentManagerOperation;
- };
-
- service.resetAgentHeaders = function () {
- headers.agentTargetQueue = [];
- delete headers.agentTargetLastValue;
- headers.agentManagerOperation = false;
- };
-
- return service;
- },
-]);
diff --git a/app/portainer/services/index.ts b/app/portainer/services/index.ts
new file mode 100644
index 000000000..66b824f5d
--- /dev/null
+++ b/app/portainer/services/index.ts
@@ -0,0 +1,12 @@
+import angular from 'angular';
+
+import { apiServicesModule } from './api';
+import { Notifications } from './notifications';
+import { ModalServiceAngular } from './modal.service';
+import { HttpRequestHelperAngular } from './http-request.helper';
+
+export default angular
+ .module('portainer.app.services', [apiServicesModule])
+ .factory('Notifications', Notifications)
+ .factory('ModalService', ModalServiceAngular)
+ .factory('HttpRequestHelper', HttpRequestHelperAngular).name;
diff --git a/app/portainer/services/modal.service/confirm.ts b/app/portainer/services/modal.service/confirm.ts
new file mode 100644
index 000000000..eb4655b05
--- /dev/null
+++ b/app/portainer/services/modal.service/confirm.ts
@@ -0,0 +1,205 @@
+import sanitize from 'sanitize-html';
+import bootbox from 'bootbox';
+
+import { applyBoxCSS, ButtonsOptions, confirmButtons } from './utils';
+
+type ConfirmCallback = (confirmed: boolean) => void;
+
+interface ConfirmAsyncOptions {
+ title: string;
+ message: string;
+ buttons: ButtonsOptions;
+}
+
+interface ConfirmOptions extends ConfirmAsyncOptions {
+ callback: ConfirmCallback;
+}
+
+export function confirmWebEditorDiscard() {
+ const options = {
+ title: 'Are you sure ?',
+ message:
+ 'You currently have unsaved changes in the editor. Are you sure you want to leave?',
+ buttons: {
+ confirm: {
+ label: 'Yes',
+ className: 'btn-danger',
+ },
+ },
+ };
+ return new Promise((resolve) => {
+ confirm({
+ ...options,
+ callback: (confirmed) => resolve(confirmed),
+ });
+ });
+}
+
+export function confirmAsync(options: ConfirmAsyncOptions) {
+ return new Promise((resolve) => {
+ confirm({
+ ...options,
+ callback: (confirmed) => resolve(confirmed),
+ });
+ });
+}
+
+export function confirm(options: ConfirmOptions) {
+ const box = bootbox.confirm({
+ title: options.title,
+ message: options.message,
+ buttons: confirmButtons(options.buttons),
+ callback: options.callback,
+ });
+
+ applyBoxCSS(box);
+}
+
+export function confirmAccessControlUpdate(callback: ConfirmCallback) {
+ confirm({
+ title: 'Are you sure ?',
+ message:
+ 'Changing the ownership of this resource will potentially restrict its management to some users.',
+ buttons: {
+ confirm: {
+ label: 'Change ownership',
+ className: 'btn-primary',
+ },
+ },
+ callback,
+ });
+}
+
+export function confirmImageForceRemoval(callback: ConfirmCallback) {
+ confirm({
+ title: 'Are you sure?',
+ message:
+ 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.',
+ buttons: {
+ confirm: {
+ label: 'Remove the image',
+ className: 'btn-danger',
+ },
+ },
+ callback,
+ });
+}
+
+export function cancelRegistryRepositoryAction(callback: ConfirmCallback) {
+ confirm({
+ title: 'Are you sure?',
+ message:
+ 'WARNING: interrupting this operation before it has finished will result in the loss of all tags. Are you sure you want to do this?',
+ buttons: {
+ confirm: {
+ label: 'Stop',
+ className: 'btn-danger',
+ },
+ },
+ callback,
+ });
+}
+
+export function confirmDeletion(message: string, callback: ConfirmCallback) {
+ const messageSanitized = sanitize(message);
+ confirm({
+ title: 'Are you sure ?',
+ message: messageSanitized,
+ buttons: {
+ confirm: {
+ label: 'Remove',
+ className: 'btn-danger',
+ },
+ },
+ callback,
+ });
+}
+
+export function confirmDeassociate(callback: ConfirmCallback) {
+ const message =
+ 'De-associating this Edge environment will mark it as non associated and will clear the registered Edge ID.
' +
+ 'Any agent started with the Edge key associated to this environment will be able to re-associate with this environment.
' +
+ 'You can re-use the Edge ID and Edge key that you used to deploy the existing Edge agent to associate a new Edge device to this environment.
';
+ confirm({
+ title: 'About de-associating',
+ message: sanitize(message),
+ buttons: {
+ confirm: {
+ label: 'De-associate',
+ className: 'btn-primary',
+ },
+ },
+ callback,
+ });
+}
+
+export function confirmUpdate(message: string, callback: ConfirmCallback) {
+ const messageSanitized = sanitize(message);
+
+ confirm({
+ title: 'Are you sure ?',
+ message: messageSanitized,
+ buttons: {
+ confirm: {
+ label: 'Update',
+ className: 'btn-warning',
+ },
+ },
+ callback,
+ });
+}
+
+export function confirmRedeploy(message: string, callback: ConfirmCallback) {
+ const messageSanitized = sanitize(message);
+
+ confirm({
+ title: '',
+ message: messageSanitized,
+ buttons: {
+ confirm: {
+ label: 'Redeploy the applications',
+ className: 'btn-primary',
+ },
+ cancel: {
+ label: "I'll do it later",
+ },
+ },
+ callback,
+ });
+}
+
+export function confirmDeletionAsync(message: string) {
+ return new Promise((resolve) => {
+ confirmDeletion(message, (confirmed) => resolve(confirmed));
+ });
+}
+
+export function confirmEndpointSnapshot(callback: ConfirmCallback) {
+ confirm({
+ title: 'Are you sure?',
+ message:
+ 'Triggering a manual refresh will poll each environment to retrieve its information, this may take a few moments.',
+ buttons: {
+ confirm: {
+ label: 'Continue',
+ className: 'btn-primary',
+ },
+ },
+ callback,
+ });
+}
+
+export function confirmImageExport(callback: ConfirmCallback) {
+ confirm({
+ title: 'Caution',
+ message:
+ 'The export may take several minutes, do not navigate away whilst the export is in progress.',
+ buttons: {
+ confirm: {
+ label: 'Continue',
+ className: 'btn-primary',
+ },
+ },
+ callback,
+ });
+}
diff --git a/app/portainer/services/modal.service/index.ts b/app/portainer/services/modal.service/index.ts
new file mode 100644
index 000000000..b27596d65
--- /dev/null
+++ b/app/portainer/services/modal.service/index.ts
@@ -0,0 +1,60 @@
+import sanitize from 'sanitize-html';
+import bootbox from 'bootbox';
+
+import {
+ cancelRegistryRepositoryAction,
+ confirmAccessControlUpdate,
+ confirmAsync,
+ confirmDeassociate,
+ confirmDeletion,
+ confirmDeletionAsync,
+ confirmEndpointSnapshot,
+ confirmImageExport,
+ confirmImageForceRemoval,
+ confirmRedeploy,
+ confirmUpdate,
+ confirmWebEditorDiscard,
+ confirm,
+} from './confirm';
+import {
+ confirmContainerDeletion,
+ confirmContainerRecreation,
+ confirmServiceForceUpdate,
+ confirmKubeconfigSelection,
+ selectRegistry,
+} from './prompt';
+
+export function enlargeImage(imageUrl: string) {
+ const imageSanitized = sanitize(imageUrl);
+
+ bootbox.dialog({
+ message: `
`,
+ className: 'image-zoom-modal',
+ onEscape: true,
+ });
+}
+
+/* @ngInject */
+export function ModalServiceAngular() {
+ return {
+ enlargeImage,
+ confirmWebEditorDiscard,
+ confirmAsync,
+ confirm,
+ confirmAccessControlUpdate,
+ confirmImageForceRemoval,
+ cancelRegistryRepositoryAction,
+ confirmDeletion,
+ confirmDeassociate,
+ confirmUpdate,
+ confirmRedeploy,
+ confirmDeletionAsync,
+ confirmContainerRecreation,
+ confirmEndpointSnapshot,
+ confirmImageExport,
+ confirmServiceForceUpdate,
+ selectRegistry,
+ confirmContainerDeletion,
+ confirmKubeconfigSelection,
+ };
+}
diff --git a/app/portainer/services/modal.service/prompt.ts b/app/portainer/services/modal.service/prompt.ts
new file mode 100644
index 000000000..dec674bc4
--- /dev/null
+++ b/app/portainer/services/modal.service/prompt.ts
@@ -0,0 +1,166 @@
+import sanitize from 'sanitize-html';
+import bootbox from 'bootbox';
+
+import { applyBoxCSS, ButtonsOptions, confirmButtons } from './utils';
+
+type PromptCallback = ((value: string) => void) | ((value: string[]) => void);
+
+interface InputOption {
+ text: string;
+ value: string;
+}
+
+interface PromptOptions {
+ title: string;
+ inputType?:
+ | 'text'
+ | 'textarea'
+ | 'email'
+ | 'select'
+ | 'checkbox'
+ | 'date'
+ | 'time'
+ | 'number'
+ | 'password'
+ | 'radio'
+ | 'range';
+ inputOptions: InputOption[];
+ buttons: ButtonsOptions;
+ value?: string;
+ callback: PromptCallback;
+}
+
+export function prompt(options: PromptOptions) {
+ const box = bootbox.prompt({
+ title: options.title,
+ inputType: options.inputType,
+ inputOptions: options.inputOptions,
+ buttons: confirmButtons(options.buttons),
+ // casting is done because ts definition expects string=>any, but library code can emit different values, based on inputType
+ callback: options.callback as (value: string) => void,
+ value: options.value,
+ });
+
+ applyBoxCSS(box);
+
+ return box;
+}
+
+export function confirmContainerDeletion(
+ title: string,
+ callback: PromptCallback
+) {
+ const sanitizedTitle = sanitize(title);
+
+ prompt({
+ title: sanitizedTitle,
+ inputType: 'checkbox',
+ inputOptions: [
+ {
+ text: 'Automatically remove non-persistent volumes',
+ value: '1',
+ },
+ ],
+ buttons: {
+ confirm: {
+ label: 'Remove',
+ className: 'btn-danger',
+ },
+ },
+ callback,
+ });
+}
+
+export function selectRegistry(options: PromptOptions) {
+ prompt(options);
+}
+
+export function confirmContainerRecreation(callback: PromptCallback) {
+ const box = prompt({
+ title: 'Are you sure?',
+
+ inputType: 'checkbox',
+ inputOptions: [
+ {
+ text: 'Pull latest image',
+ value: '1',
+ },
+ ],
+ buttons: {
+ confirm: {
+ label: 'Recreate',
+ className: 'btn-danger',
+ },
+ },
+ callback,
+ });
+
+ const message = `You're about to re-create this container, any non-persisted data will be lost. This container will be removed and another one will be created using the same configuration.`;
+
+ customizeCheckboxPrompt(box, message);
+}
+
+export function confirmServiceForceUpdate(
+ message: string,
+ callback: PromptCallback
+) {
+ const sanitizedMessage = sanitize(message);
+
+ const box = prompt({
+ title: 'Are you sure?',
+ inputType: 'checkbox',
+ inputOptions: [
+ {
+ text: 'Pull latest image version',
+ value: '1',
+ },
+ ],
+ buttons: {
+ confirm: {
+ label: 'Update',
+ className: 'btn-primary',
+ },
+ },
+ callback,
+ });
+
+ customizeCheckboxPrompt(box, sanitizedMessage);
+}
+
+export function confirmKubeconfigSelection(
+ options: InputOption[],
+ expiryMessage: string,
+ callback: PromptCallback
+) {
+ const message = sanitize(
+ `Select the kubernetes environment(s) to add to the kubeconfig file.${expiryMessage}`
+ );
+ const box = prompt({
+ title: 'Download kubeconfig file',
+ inputOptions: options,
+ buttons: {
+ confirm: {
+ label: 'Download file',
+ className: 'btn-primary',
+ },
+ },
+ callback,
+ });
+
+ customizeCheckboxPrompt(box, message, true, true);
+}
+
+function customizeCheckboxPrompt(
+ box: JQuery,
+ message: string,
+ toggleCheckbox = false,
+ showCheck = false
+) {
+ box.find('.bootbox-body').prepend(`${message}
`);
+ const checkbox = box.find('.bootbox-input-checkbox');
+ checkbox.prop('checked', toggleCheckbox);
+
+ if (showCheck) {
+ checkbox.addClass('visible');
+ }
+}
diff --git a/app/portainer/services/modal.service/utils.ts b/app/portainer/services/modal.service/utils.ts
new file mode 100644
index 000000000..043818a13
--- /dev/null
+++ b/app/portainer/services/modal.service/utils.ts
@@ -0,0 +1,37 @@
+import sanitize from 'sanitize-html';
+
+interface Button {
+ label: string;
+ className?: string;
+}
+
+export interface ButtonsOptions {
+ confirm: Button;
+ cancel?: Button;
+}
+
+export function confirmButtons(options: ButtonsOptions) {
+ return {
+ confirm: {
+ label: sanitize(options.confirm.label),
+ className:
+ options.confirm.className && sanitize(options.confirm.className),
+ },
+ cancel: {
+ label:
+ options.cancel && options.cancel.label
+ ? sanitize(options.cancel.label)
+ : 'Cancel',
+ },
+ };
+}
+
+export function applyBoxCSS(box: JQuery) {
+ box.css({
+ top: '50%',
+ 'margin-top': function marginTop() {
+ const height = box.height() || 0;
+ return -(height / 2);
+ },
+ });
+}
diff --git a/app/portainer/services/modalService.js b/app/portainer/services/modalService.js
deleted file mode 100644
index 91632979b..000000000
--- a/app/portainer/services/modalService.js
+++ /dev/null
@@ -1,339 +0,0 @@
-import bootbox from 'bootbox';
-
-angular.module('portainer.app').factory('ModalService', [
- '$sanitize',
- function ModalServiceFactory($sanitize) {
- 'use strict';
- var service = {};
-
- var applyBoxCSS = function (box) {
- box.css({
- top: '50%',
- 'margin-top': function () {
- return -(box.height() / 2);
- },
- });
- };
-
- var confirmButtons = function (options) {
- var buttons = {
- confirm: {
- label: $sanitize(options.buttons.confirm.label),
- className: $sanitize(options.buttons.confirm.className),
- },
- cancel: {
- label: options.buttons.cancel && options.buttons.cancel.label ? $sanitize(options.buttons.cancel.label) : 'Cancel',
- },
- };
- return buttons;
- };
-
- service.enlargeImage = function (image) {
- image = $sanitize(image);
- bootbox.dialog({
- message: '
',
- className: 'image-zoom-modal',
- onEscape: true,
- });
- };
-
- service.confirmWebEditorDiscard = confirmWebEditorDiscard;
- function confirmWebEditorDiscard() {
- const options = {
- title: 'Are you sure ?',
- message: 'You currently have unsaved changes in the editor. Are you sure you want to leave?',
- buttons: {
- confirm: {
- label: 'Yes',
- className: 'btn-danger',
- },
- },
- };
- return new Promise((resolve) => {
- service.confirm({ ...options, callback: (confirmed) => resolve(confirmed) });
- });
- }
-
- service.confirmAsync = confirmAsync;
- function confirmAsync(options) {
- return new Promise((resolve) => {
- service.confirm({ ...options, callback: (confirmed) => resolve(confirmed) });
- });
- }
-
- service.confirm = function (options) {
- var box = bootbox.confirm({
- title: options.title,
- message: options.message,
- buttons: confirmButtons(options),
- callback: options.callback,
- });
- applyBoxCSS(box);
- };
-
- function prompt(options) {
- var box = bootbox.prompt({
- title: options.title,
- inputType: options.inputType,
- inputOptions: options.inputOptions,
- buttons: confirmButtons(options),
- callback: options.callback,
- });
- applyBoxCSS(box);
- }
-
- function customCheckboxPrompt(options) {
- var box = bootbox.prompt({
- title: options.title,
- inputType: 'checkbox',
- inputOptions: options.inputOptions,
- buttons: confirmButtons(options),
- callback: options.callback,
- });
- applyBoxCSS(box);
- box.find('.bootbox-body').prepend('' + options.message + '
');
- box.find('.bootbox-input-checkbox').prop('checked', options.optionToggled);
- if (options.showCheck) {
- box.find('.bootbox-input-checkbox').addClass('visible');
- }
- }
-
- service.confirmAccessControlUpdate = function (callback) {
- service.confirm({
- title: 'Are you sure ?',
- message: 'Changing the ownership of this resource will potentially restrict its management to some users.',
- buttons: {
- confirm: {
- label: 'Change ownership',
- className: 'btn-primary',
- },
- },
- callback: callback,
- });
- };
-
- service.confirmImageForceRemoval = function (callback) {
- service.confirm({
- title: 'Are you sure?',
- message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.',
- buttons: {
- confirm: {
- label: 'Remove the image',
- className: 'btn-danger',
- },
- },
- callback: callback,
- });
- };
-
- service.cancelRegistryRepositoryAction = function (callback) {
- service.confirm({
- title: 'Are you sure?',
- message: 'WARNING: interrupting this operation before it has finished will result in the loss of all tags. Are you sure you want to do this?',
- buttons: {
- confirm: {
- label: 'Stop',
- className: 'btn-danger',
- },
- },
- callback: callback,
- });
- };
-
- service.confirmDeletion = function (message, callback) {
- message = $sanitize(message);
- service.confirm({
- title: 'Are you sure ?',
- message: message,
- buttons: {
- confirm: {
- label: 'Remove',
- className: 'btn-danger',
- },
- },
- callback: callback,
- });
- };
-
- service.confirmDeassociate = function (callback) {
- const message =
- 'De-associating this Edge environment will mark it as non associated and will clear the registered Edge ID.
' +
- 'Any agent started with the Edge key associated to this environment will be able to re-associate with this environment.
' +
- 'You can re-use the Edge ID and Edge key that you used to deploy the existing Edge agent to associate a new Edge device to this environment.
';
- service.confirm({
- title: 'About de-associating',
- message: $sanitize(message),
- buttons: {
- confirm: {
- label: 'De-associate',
- className: 'btn-primary',
- },
- },
- callback: callback,
- });
- };
-
- service.confirmUpdate = function (message, callback) {
- message = $sanitize(message);
- service.confirm({
- title: 'Are you sure ?',
- message: message,
- buttons: {
- confirm: {
- label: 'Update',
- className: 'btn-warning',
- },
- },
- callback: callback,
- });
- };
-
- service.confirmRedeploy = function (message, callback) {
- message = $sanitize(message);
- service.confirm({
- title: '',
- message: message,
- buttons: {
- confirm: {
- label: 'Redeploy the applications',
- className: 'btn-primary',
- },
- cancel: {
- label: "I'll do it later",
- },
- },
- callback: callback,
- });
- };
-
- service.confirmDeletionAsync = function confirmDeletionAsync(message) {
- return new Promise((resolve) => {
- service.confirmDeletion(message, (confirmed) => resolve(confirmed));
- });
- };
-
- service.confirmContainerDeletion = function (title, callback) {
- title = $sanitize(title);
- prompt({
- title: title,
- inputType: 'checkbox',
- inputOptions: [
- {
- text: 'Automatically remove non-persistent volumes',
- value: '1',
- },
- ],
- buttons: {
- confirm: {
- label: 'Remove',
- className: 'btn-danger',
- },
- },
- callback: callback,
- });
- };
-
- service.confirmContainerRecreation = function (callback) {
- customCheckboxPrompt({
- title: 'Are you sure?',
- message:
- "You're about to re-create this container, any non-persisted data will be lost. This container will be removed and another one will be created using the same configuration.",
- inputOptions: [
- {
- text: 'Pull latest image',
- value: '1',
- },
- ],
- buttons: {
- confirm: {
- label: 'Recreate',
- className: 'btn-danger',
- },
- },
- callback: callback,
- optionToggled: false,
- });
- };
-
- service.confirmEndpointSnapshot = function (callback) {
- service.confirm({
- title: 'Are you sure?',
- message: 'Triggering a manual refresh will poll each environment to retrieve its information, this may take a few moments.',
- buttons: {
- confirm: {
- label: 'Continue',
- className: 'btn-primary',
- },
- },
- callback: callback,
- });
- };
-
- service.confirmImageExport = function (callback) {
- service.confirm({
- title: 'Caution',
- message: 'The export may take several minutes, do not navigate away whilst the export is in progress.',
- buttons: {
- confirm: {
- label: 'Continue',
- className: 'btn-primary',
- },
- },
- callback: callback,
- });
- };
-
- service.confirmServiceForceUpdate = function (message, callback) {
- message = $sanitize(message);
- customCheckboxPrompt({
- title: 'Are you sure ?',
- message: message,
- inputOptions: [
- {
- text: 'Pull latest image version',
- value: '1',
- },
- ],
- buttons: {
- confirm: {
- label: 'Update',
- className: 'btn-primary',
- },
- },
- callback: callback,
- optionToggled: false,
- });
- };
-
- service.selectRegistry = function (options) {
- var box = bootbox.prompt({
- title: 'Which registry do you want to use?',
- inputType: 'select',
- value: options.defaultValue,
- inputOptions: options.options,
- callback: options.callback,
- });
- applyBoxCSS(box);
- };
-
- service.confirmKubeconfigSelection = function (options, expiryMessage, callback) {
- const message = 'Select the kubernetes environment(s) to add to the kubeconfig file.' + expiryMessage;
- customCheckboxPrompt({
- title: 'Download kubeconfig file',
- message: $sanitize(message),
- inputOptions: options,
- buttons: {
- confirm: {
- label: 'Download file',
- className: 'btn-primary',
- },
- },
- callback: callback,
- optionToggled: true,
- showCheck: true,
- });
- };
-
- return service;
- },
-]);
diff --git a/app/portainer/services/notifications.js b/app/portainer/services/notifications.js
deleted file mode 100644
index e1135e67b..000000000
--- a/app/portainer/services/notifications.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import _ from 'lodash-es';
-import toastr from 'toastr';
-import lodash from 'lodash-es';
-
-angular.module('portainer.app').factory('Notifications', [
- '$sanitize',
- function NotificationsFactory($sanitize) {
- 'use strict';
- var service = {};
-
- service.success = function (title, text) {
- toastr.success($sanitize(_.escape(text)), $sanitize(title));
- };
-
- service.warning = function (title, text) {
- toastr.warning($sanitize(_.escape(text)), $sanitize(title), { timeOut: 6000 });
- };
-
- function pickErrorMsg(e) {
- const props = [
- 'err.data.details',
- 'err.data.message',
- 'data.details',
- 'data.message',
- 'data.content',
- 'data.error',
- 'message',
- 'err.data[0].message',
- 'err.data.err',
- 'data.err',
- 'msg',
- ];
-
- let msg = '';
-
- lodash.forEach(props, (prop) => {
- const val = lodash.get(e, prop);
- if (typeof val === 'string') {
- msg = msg || val;
- }
- });
-
- return msg;
- }
-
- service.error = function (title, e, fallbackText) {
- const msg = pickErrorMsg(e) || fallbackText;
-
- // eslint-disable-next-line no-console
- console.error(e);
-
- if (msg !== 'Invalid JWT token') {
- toastr.error($sanitize(_.escape(msg)), $sanitize(title), { timeOut: 6000 });
- }
- };
-
- return service;
- },
-]);
diff --git a/app/portainer/services/notifications.test.ts b/app/portainer/services/notifications.test.ts
new file mode 100644
index 000000000..693178338
--- /dev/null
+++ b/app/portainer/services/notifications.test.ts
@@ -0,0 +1,47 @@
+import toastr from 'toastr';
+
+import { error, success, warning } from './notifications';
+
+jest.mock('toastr');
+
+it('calling success should show success message', () => {
+ const title = 'title';
+ const text = 'text';
+
+ success(title, text);
+
+ expect(toastr.success).toHaveBeenCalledWith(text, title);
+});
+
+it('calling error with Error should show error message', () => {
+ const title = 'title';
+ const errorMessage = 'message';
+ const fallback = 'fallback';
+
+ error(title, new Error(errorMessage), fallback);
+
+ expect(toastr.error).toHaveBeenCalledWith(
+ errorMessage,
+ title,
+ expect.anything()
+ );
+});
+
+it('calling error without Error should show fallback message', () => {
+ const title = 'title';
+
+ const fallback = 'fallback';
+
+ error(title, undefined, fallback);
+
+ expect(toastr.error).toHaveBeenCalledWith(fallback, title, expect.anything());
+});
+
+it('calling warning should show warning message', () => {
+ const title = 'title';
+ const text = 'text';
+
+ warning(title, text);
+
+ expect(toastr.warning).toHaveBeenCalledWith(text, title, expect.anything());
+});
diff --git a/app/portainer/services/notifications.ts b/app/portainer/services/notifications.ts
new file mode 100644
index 000000000..77cdd4ec9
--- /dev/null
+++ b/app/portainer/services/notifications.ts
@@ -0,0 +1,69 @@
+import _ from 'lodash-es';
+import toastr from 'toastr';
+import sanitize from 'sanitize-html';
+
+toastr.options = {
+ timeOut: 3000,
+ closeButton: true,
+ progressBar: true,
+ tapToDismiss: false,
+};
+
+export function success(title: string, text: string) {
+ toastr.success(sanitize(_.escape(text)), sanitize(title));
+}
+
+export function warning(title: string, text: string) {
+ toastr.warning(sanitize(_.escape(text)), sanitize(title), { timeOut: 6000 });
+}
+
+export function error(title: string, e?: Error, fallbackText = '') {
+ const msg = pickErrorMsg(e) || fallbackText;
+
+ // eslint-disable-next-line no-console
+ console.error(e);
+
+ if (msg !== 'Invalid JWT token') {
+ toastr.error(sanitize(_.escape(msg)), sanitize(title), { timeOut: 6000 });
+ }
+}
+
+/* @ngInject */
+export function Notifications() {
+ return {
+ success,
+ warning,
+ error,
+ };
+}
+
+function pickErrorMsg(e?: Error) {
+ if (!e) {
+ return '';
+ }
+
+ const props = [
+ 'err.data.details',
+ 'err.data.message',
+ 'data.details',
+ 'data.message',
+ 'data.content',
+ 'data.error',
+ 'message',
+ 'err.data[0].message',
+ 'err.data.err',
+ 'data.err',
+ 'msg',
+ ];
+
+ let msg = '';
+
+ props.forEach((prop) => {
+ const val = _.get(e, prop);
+ if (typeof val === 'string') {
+ msg = msg || val;
+ }
+ });
+
+ return msg;
+}
diff --git a/app/portainer/services/registryModalService.js b/app/portainer/services/registryModalService.js
index 6690cb771..04dbaa344 100644
--- a/app/portainer/services/registryModalService.js
+++ b/app/portainer/services/registryModalService.js
@@ -20,8 +20,8 @@ function ModalServiceFactory($q, ModalService, RegistryService) {
const defaultValue = String(_.get(registryModel, 'Registry.Id', '0'));
ModalService.selectRegistry({
- options,
- defaultValue,
+ inputOptions: options,
+ value: defaultValue,
callback: (registryId) => {
if (registryId) {
const registryModel = RegistryService.retrievePorRegistryModelFromRepositoryWithRegistries(repository, registries, registryId);
diff --git a/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js
index 969388459..2a372de2f 100644
--- a/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js
+++ b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js
@@ -1,5 +1,6 @@
import _ from 'lodash-es';
-import { HIDE_INTERNAL_AUTH } from '@/portainer/feature-flags/feature-ids';
+
+import { FeatureId } from '@/portainer/feature-flags/enums';
export default class AdSettingsController {
/* @ngInject */
@@ -8,7 +9,7 @@ export default class AdSettingsController {
this.featureService = featureService;
this.domainSuffix = '';
- this.limitedFeatureId = HIDE_INTERNAL_AUTH;
+ this.limitedFeatureId = FeatureId.HIDE_INTERNAL_AUTH;
this.onTlscaCertChange = this.onTlscaCertChange.bind(this);
this.searchUsers = this.searchUsers.bind(this);
this.searchGroups = this.searchGroups.bind(this);
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js
index 70c2ed83e..0ee280ccf 100644
--- a/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js
@@ -1,7 +1,8 @@
-import { EXTERNAL_AUTH_LDAP } from '@/portainer/feature-flags/feature-ids';
+import { FeatureId } from '@/portainer/feature-flags/enums';
+
export default class LdapSettingsCustomController {
constructor() {
- this.limitedFeatureId = EXTERNAL_AUTH_LDAP;
+ this.limitedFeatureId = FeatureId.EXTERNAL_AUTH_LDAP;
}
addLDAPUrl() {
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js
index 5115548d1..249a13fe5 100644
--- a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js
@@ -1,10 +1,10 @@
-import { EXTERNAL_AUTH_LDAP } from '@/portainer/feature-flags/feature-ids';
+import { FeatureId } from '@/portainer/feature-flags/enums';
export default class LdapSettingsOpenLDAPController {
/* @ngInject */
constructor() {
this.domainSuffix = '';
- this.limitedFeatureId = EXTERNAL_AUTH_LDAP;
+ this.limitedFeatureId = FeatureId.EXTERNAL_AUTH_LDAP;
this.findDomainSuffix = this.findDomainSuffix.bind(this);
this.parseDomainSuffix = this.parseDomainSuffix.bind(this);
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js
index b9a43d880..37ca91778 100644
--- a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js
+++ b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js
@@ -4,8 +4,8 @@ const SERVER_TYPES = {
AD: 2,
};
+import { FeatureId } from '@/portainer/feature-flags/enums';
import { buildLdapSettingsModel, buildOpenLDAPSettingsModel } from '@/portainer/settings/authentication/ldap/ldap-settings.model';
-import { EXTERNAL_AUTH_LDAP } from '@/portainer/feature-flags/feature-ids';
const DEFAULT_GROUP_FILTER = '(objectClass=groupOfNames)';
const DEFAULT_USER_FILTER = '(objectClass=inetOrgPerson)';
@@ -20,7 +20,7 @@ export default class LdapSettingsController {
this.boxSelectorOptions = [
{ id: 'ldap_custom', value: SERVER_TYPES.CUSTOM, label: 'Custom', icon: 'fa fa-server' },
- { id: 'ldap_openldap', value: SERVER_TYPES.OPEN_LDAP, label: 'OpenLDAP', icon: 'fa fa-server', feature: EXTERNAL_AUTH_LDAP },
+ { id: 'ldap_openldap', value: SERVER_TYPES.OPEN_LDAP, label: 'OpenLDAP', icon: 'fa fa-server', feature: FeatureId.EXTERNAL_AUTH_LDAP },
];
this.onTlscaCertChange = this.onTlscaCertChange.bind(this);
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html
index b1389f675..72e008858 100644
--- a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html
+++ b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html
@@ -12,8 +12,8 @@
diff --git a/app/portainer/settings/general/ssl-certificate/ssl-certificate.controller.js b/app/portainer/settings/general/ssl-certificate/ssl-certificate.controller.js
index 681d697ea..d79b7500b 100644
--- a/app/portainer/settings/general/ssl-certificate/ssl-certificate.controller.js
+++ b/app/portainer/settings/general/ssl-certificate/ssl-certificate.controller.js
@@ -1,7 +1,7 @@
class SslCertificateController {
/* @ngInject */
- constructor($async, $state, SSLService, Notifications) {
- Object.assign(this, { $async, $state, SSLService, Notifications });
+ constructor($async, $scope, $state, SSLService, Notifications) {
+ Object.assign(this, { $async, $scope, $state, SSLService, Notifications });
this.cert = null;
this.originalValues = {
@@ -26,12 +26,19 @@ class SslCertificateController {
this.keyFilePattern = `${pemPattern},.key`;
this.save = this.save.bind(this);
+ this.onChangeForceHTTPS = this.onChangeForceHTTPS.bind(this);
}
isFormChanged() {
return Object.entries(this.originalValues).some(([key, value]) => value != this.formValues[key]);
}
+ onChangeForceHTTPS(checked) {
+ return this.$scope.$evalAsync(() => {
+ this.formValues.forceHTTPS = checked;
+ });
+ }
+
async save() {
return this.$async(async () => {
this.state.actionInProgress = true;
diff --git a/app/portainer/settings/general/ssl-certificate/ssl-certificate.html b/app/portainer/settings/general/ssl-certificate/ssl-certificate.html
index 222f62f4e..29bd773a2 100644
--- a/app/portainer/settings/general/ssl-certificate/ssl-certificate.html
+++ b/app/portainer/settings/general/ssl-certificate/ssl-certificate.html
@@ -11,7 +11,7 @@
-
+
diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-view.controller.js b/app/portainer/user-activity/activity-logs-view/activity-logs-view.controller.js
index 77957cb59..7adfd6f7f 100644
--- a/app/portainer/user-activity/activity-logs-view/activity-logs-view.controller.js
+++ b/app/portainer/user-activity/activity-logs-view/activity-logs-view.controller.js
@@ -1,11 +1,14 @@
import moment from 'moment';
-import { ACTIVITY_AUDIT } from '@/portainer/feature-flags/feature-ids';
+
+import { FeatureId } from '@/portainer/feature-flags/enums';
export default class ActivityLogsViewController {
/* @ngInject */
constructor($async, Notifications) {
this.$async = $async;
this.Notifications = Notifications;
- this.limitedFeature = ACTIVITY_AUDIT;
+
+ this.limitedFeature = FeatureId.ACTIVITY_AUDIT;
+
this.state = {
keyword: '',
date: {
diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js b/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js
index f4ddb81ff..77a174804 100644
--- a/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js
+++ b/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js
@@ -1,5 +1,6 @@
import moment from 'moment';
-import { ACTIVITY_AUDIT } from '@/portainer/feature-flags/feature-ids';
+
+import { FeatureId } from '@/portainer/feature-flags/enums';
export default class AuthLogsViewController {
/* @ngInject */
@@ -7,7 +8,7 @@ export default class AuthLogsViewController {
this.$async = $async;
this.Notifications = Notifications;
- this.limitedFeature = ACTIVITY_AUDIT;
+ this.limitedFeature = FeatureId.ACTIVITY_AUDIT;
this.state = {
keyword: 'f',
date: {
diff --git a/app/portainer/views/endpoints/access/endpointAccessController.js b/app/portainer/views/endpoints/access/endpointAccessController.js
index 4600c1977..561c2193b 100644
--- a/app/portainer/views/endpoints/access/endpointAccessController.js
+++ b/app/portainer/views/endpoints/access/endpointAccessController.js
@@ -1,6 +1,6 @@
import angular from 'angular';
-import { RBAC_ROLES } from '@/portainer/feature-flags/feature-ids';
+import { FeatureId } from '@/portainer/feature-flags/enums';
class EndpointAccessController {
/* @ngInject */
@@ -12,7 +12,7 @@ class EndpointAccessController {
this.GroupService = GroupService;
this.$async = $async;
- this.limitedFeature = RBAC_ROLES;
+ this.limitedFeature = FeatureId.RBAC_ROLES;
this.updateAccess = this.updateAccess.bind(this);
this.updateAccessAsync = this.updateAccessAsync.bind(this);
diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html
index 74ba468b6..64e7ee1b9 100644
--- a/app/portainer/views/endpoints/edit/endpoint.html
+++ b/app/portainer/views/endpoints/edit/endpoint.html
@@ -53,11 +53,14 @@
+
+
diff --git a/app/portainer/views/endpoints/edit/endpointController.js b/app/portainer/views/endpoints/edit/endpointController.js
index 95d585ddd..86d590d60 100644
--- a/app/portainer/views/endpoints/edit/endpointController.js
+++ b/app/portainer/views/endpoints/edit/endpointController.js
@@ -111,6 +111,12 @@ function EndpointController(
return $async(onCreateTagAsync, tagName);
};
+ $scope.onToggleAllowSelfSignedCerts = function onToggleAllowSelfSignedCerts(checked) {
+ return $scope.$evalAsync(() => {
+ $scope.state.allowSelfSignedCerts = checked;
+ });
+ };
+
async function onCreateTagAsync(tagName) {
try {
const tag = await TagService.createTag(tagName);
diff --git a/app/portainer/views/groups/access/groupAccessController.js b/app/portainer/views/groups/access/groupAccessController.js
index e6112be28..630d8a3c4 100644
--- a/app/portainer/views/groups/access/groupAccessController.js
+++ b/app/portainer/views/groups/access/groupAccessController.js
@@ -1,4 +1,4 @@
-import { RBAC_ROLES } from '@/portainer/feature-flags/feature-ids';
+import { FeatureId } from '@/portainer/feature-flags/enums';
angular.module('portainer.app').controller('GroupAccessController', [
'$scope',
@@ -7,7 +7,7 @@ angular.module('portainer.app').controller('GroupAccessController', [
'GroupService',
'Notifications',
function ($scope, $state, $transition$, GroupService, Notifications) {
- $scope.limitedFeature = RBAC_ROLES;
+ $scope.limitedFeature = FeatureId.RBAC_ROLES;
$scope.updateAccess = function () {
$scope.state.actionInProgress = true;
diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html
index be3d9a3d5..fb8f65639 100644
--- a/app/portainer/views/settings/authentication/settingsAuthentication.html
+++ b/app/portainer/views/settings/authentication/settingsAuthentication.html
@@ -31,11 +31,11 @@
Changing from default is only recommended if you have additional layers of authentication in front of Portainer.
+
Authentication method
-
-
+
diff --git a/app/portainer/views/settings/authentication/settingsAuthenticationController.js b/app/portainer/views/settings/authentication/settingsAuthenticationController.js
index 456643781..23841e245 100644
--- a/app/portainer/views/settings/authentication/settingsAuthenticationController.js
+++ b/app/portainer/views/settings/authentication/settingsAuthenticationController.js
@@ -1,12 +1,14 @@
import angular from 'angular';
import _ from 'lodash-es';
-import { HIDE_INTERNAL_AUTH } from '@/portainer/feature-flags/feature-ids';
+import { FeatureId } from '@/portainer/feature-flags/enums';
import { buildLdapSettingsModel, buildAdSettingsModel } from '@/portainer/settings/authentication/ldap/ldap-settings.model';
angular.module('portainer.app').controller('SettingsAuthenticationController', SettingsAuthenticationController);
function SettingsAuthenticationController($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService, LDAPService) {
+ $scope.authMethod = 1;
+
$scope.state = {
uploadInProgress: false,
actionInProgress: false,
@@ -47,11 +49,13 @@ function SettingsAuthenticationController($q, $scope, $state, Notifications, Set
$scope.authOptions = [
{ id: 'auth_internal', icon: 'fa fa-users', label: 'Internal', description: 'Internal authentication mechanism', value: 1 },
{ id: 'auth_ldap', icon: 'fa fa-users', label: 'LDAP', description: 'LDAP authentication', value: 2 },
- { id: 'auth_ad', icon: 'fab fa-microsoft', label: 'Microsoft Active Directory', description: 'AD authentication', value: 4, feature: HIDE_INTERNAL_AUTH },
+ { id: 'auth_ad', icon: 'fab fa-microsoft', label: 'Microsoft Active Directory', description: 'AD authentication', value: 4, feature: FeatureId.HIDE_INTERNAL_AUTH },
{ id: 'auth_oauth', icon: 'fa fa-users', label: 'OAuth', description: 'OAuth authentication', value: 3 },
];
$scope.onChangeAuthMethod = function onChangeAuthMethod(value) {
+ $scope.authMethod = value;
+
if (value === 4) {
$scope.settings.AuthenticationMethod = 2;
$scope.formValues.ldap.serverType = 2;
diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html
index 67a4380b8..9aa3a6df8 100644
--- a/app/portainer/views/settings/settings.html
+++ b/app/portainer/views/settings/settings.html
@@ -256,19 +256,20 @@
Backup configuration
-
+
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js
index c7861e74e..5cd04ea78 100644
--- a/app/portainer/views/settings/settingsController.js
+++ b/app/portainer/views/settings/settingsController.js
@@ -1,7 +1,7 @@
import angular from 'angular';
-import { buildOption } from '@/portainer/components/box-selector';
-import { S3_BACKUP_SETTING } from '@/portainer/feature-flags/feature-ids';
+import { buildOption } from '@/portainer/components/BoxSelector';
+import { FeatureId } from '@/portainer/feature-flags/enums';
angular.module('portainer.app').controller('SettingsController', [
'$scope',
@@ -13,10 +13,10 @@ angular.module('portainer.app').controller('SettingsController', [
'FileSaver',
'Blob',
function ($scope, $state, Notifications, SettingsService, StateManager, BackupService, FileSaver) {
- $scope.s3BackupFeatureId = S3_BACKUP_SETTING;
+ $scope.s3BackupFeatureId = FeatureId.S3_BACKUP_SETTING;
$scope.backupOptions = [
buildOption('backup_file', 'fa fa-download', 'Download backup file', '', 'file'),
- buildOption('backup_s3', 'fa fa-upload', 'Store in S3', 'Define a cron schedule', 's3', S3_BACKUP_SETTING),
+ buildOption('backup_s3', 'fa fa-upload', 'Store in S3', 'Define a cron schedule', 's3', FeatureId.S3_BACKUP_SETTING),
];
$scope.state = {
@@ -74,6 +74,12 @@ angular.module('portainer.app').controller('SettingsController', [
backupFormType: $scope.BACKUP_FORM_TYPES.FILE,
};
+ $scope.onToggleAutoBackups = function onToggleAutoBackups(checked) {
+ $scope.$evalAsync(() => {
+ $scope.formValues.scheduleAutomaticBackups = checked;
+ });
+ };
+
$scope.onBackupOptionsChange = function (type, limited) {
$scope.formValues.backupFormType = type;
$scope.state.featureLimited = limited;
diff --git a/app/portainer/views/wizard/wizard-endpoints/wizard-endpoint-aci/wizard-aci.controller.js b/app/portainer/views/wizard/wizard-endpoints/wizard-endpoint-aci/wizard-aci.controller.js
index 7cac51c62..e3fe38a98 100644
--- a/app/portainer/views/wizard/wizard-endpoints/wizard-endpoint-aci/wizard-aci.controller.js
+++ b/app/portainer/views/wizard/wizard-endpoints/wizard-endpoint-aci/wizard-aci.controller.js
@@ -1,4 +1,4 @@
-import { buildOption } from '@/portainer/components/box-selector';
+import { buildOption } from '@/portainer/components/BoxSelector';
export default class WizardAciController {
/* @ngInject */
@@ -7,6 +7,24 @@ export default class WizardAciController {
this.EndpointService = EndpointService;
this.Notifications = Notifications;
this.NameValidator = NameValidator;
+
+ this.state = {
+ actionInProgress: false,
+ endpointType: 'api',
+ availableOptions: [buildOption('API', 'fa fa-bolt', 'API', '', 'api')],
+ };
+ this.formValues = {
+ name: '',
+ azureApplicationId: '',
+ azureTenantId: '',
+ azureAuthenticationKey: '',
+ };
+
+ this.onChangeEndpointType = this.onChangeEndpointType.bind(this);
+ }
+
+ onChangeEndpointType(endpointType) {
+ this.state.endpointType = endpointType;
}
addAciEndpoint() {
@@ -44,20 +62,4 @@ export default class WizardAciController {
azureAuthenticationKey: '',
};
}
-
- $onInit() {
- return this.$async(async () => {
- this.state = {
- actionInProgress: false,
- endpointType: 'api',
- availableOptions: [buildOption('API', 'fa fa-bolt', 'API', '', 'api')],
- };
- this.formValues = {
- name: '',
- azureApplicationId: '',
- azureTenantId: '',
- azureAuthenticationKey: '',
- };
- });
- }
}
diff --git a/app/portainer/views/wizard/wizard-endpoints/wizard-endpoint-aci/wizard-aci.html b/app/portainer/views/wizard/wizard-endpoints/wizard-endpoint-aci/wizard-aci.html
index 0232d4bcb..fc3e3836e 100644
--- a/app/portainer/views/wizard/wizard-endpoints/wizard-endpoint-aci/wizard-aci.html
+++ b/app/portainer/views/wizard/wizard-endpoints/wizard-endpoint-aci/wizard-aci.html
@@ -1,5 +1,5 @@