diff --git a/app/portainer/settings/authentication/auth-method-constants.js b/app/portainer/settings/authentication/auth-method-constants.js
new file mode 100644
index 000000000..dc9bf0a0c
--- /dev/null
+++ b/app/portainer/settings/authentication/auth-method-constants.js
@@ -0,0 +1,11 @@
+export const authenticationMethodTypesMap = {
+ INTERNAL: 1,
+ LDAP: 2,
+ OAUTH: 3,
+};
+
+export const authenticationMethodTypesLabels = {
+ [authenticationMethodTypesMap.INTERNAL]: 'Internal',
+ [authenticationMethodTypesMap.LDAP]: 'LDAP',
+ [authenticationMethodTypesMap.OAUTH]: 'OAuth',
+};
diff --git a/app/portainer/settings/authentication/auth-type-constants.js b/app/portainer/settings/authentication/auth-type-constants.js
new file mode 100644
index 000000000..84de1d959
--- /dev/null
+++ b/app/portainer/settings/authentication/auth-type-constants.js
@@ -0,0 +1,11 @@
+export const authenticationActivityTypesMap = {
+ AuthSuccess: 1,
+ AuthFailure: 2,
+ Logout: 3,
+};
+
+export const authenticationActivityTypesLabels = {
+ [authenticationActivityTypesMap.AuthSuccess]: 'Authentication success',
+ [authenticationActivityTypesMap.AuthFailure]: 'Authentication failure',
+ [authenticationActivityTypesMap.Logout]: 'Logout',
+};
diff --git a/app/portainer/settings/authentication/auto-team-membership-toggle/auto-team-membership-toggle.html b/app/portainer/settings/authentication/auto-team-membership-toggle/auto-team-membership-toggle.html
new file mode 100644
index 000000000..19a9d9ab6
--- /dev/null
+++ b/app/portainer/settings/authentication/auto-team-membership-toggle/auto-team-membership-toggle.html
@@ -0,0 +1,14 @@
+
+ Team membership
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/auto-team-membership-toggle/index.js b/app/portainer/settings/authentication/auto-team-membership-toggle/index.js
new file mode 100644
index 000000000..6ad9c60c1
--- /dev/null
+++ b/app/portainer/settings/authentication/auto-team-membership-toggle/index.js
@@ -0,0 +1,9 @@
+export const autoTeamMembershipToggle = {
+ templateUrl: './auto-team-membership-toggle.html',
+ transclude: {
+ description: 'fieldDescription',
+ },
+ bindings: {
+ ngModel: '=',
+ },
+};
diff --git a/app/portainer/settings/authentication/auto-user-provision-toggle/auto-user-provision-toggle.html b/app/portainer/settings/authentication/auto-user-provision-toggle/auto-user-provision-toggle.html
new file mode 100644
index 000000000..aac43263a
--- /dev/null
+++ b/app/portainer/settings/authentication/auto-user-provision-toggle/auto-user-provision-toggle.html
@@ -0,0 +1,14 @@
+
+ Automatic user provisioning
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/auto-user-provision-toggle/index.js b/app/portainer/settings/authentication/auto-user-provision-toggle/index.js
new file mode 100644
index 000000000..68c7b95d1
--- /dev/null
+++ b/app/portainer/settings/authentication/auto-user-provision-toggle/index.js
@@ -0,0 +1,9 @@
+export const autoUserProvisionToggle = {
+ templateUrl: './auto-user-provision-toggle.html',
+ transclude: {
+ description: 'fieldDescription',
+ },
+ bindings: {
+ ngModel: '=',
+ },
+};
diff --git a/app/portainer/settings/authentication/index.js b/app/portainer/settings/authentication/index.js
new file mode 100644
index 000000000..a6e907ebe
--- /dev/null
+++ b/app/portainer/settings/authentication/index.js
@@ -0,0 +1,12 @@
+import angular from 'angular';
+
+import ldapModule from './ldap';
+
+import { autoUserProvisionToggle } from './auto-user-provision-toggle';
+import { autoTeamMembershipToggle } from './auto-team-membership-toggle';
+
+export default angular
+ .module('portainer.settings.authentication', [ldapModule])
+
+ .component('autoUserProvisionToggle', autoUserProvisionToggle)
+ .component('autoTeamMembershipToggle', autoTeamMembershipToggle).name;
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
new file mode 100644
index 000000000..4a988b321
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js
@@ -0,0 +1,61 @@
+import _ from 'lodash-es';
+
+export default class AdSettingsController {
+ /* @ngInject */
+ constructor(LDAPService) {
+ this.LDAPService = LDAPService;
+
+ this.domainSuffix = '';
+
+ this.onTlscaCertChange = this.onTlscaCertChange.bind(this);
+ this.searchUsers = this.searchUsers.bind(this);
+ this.searchGroups = this.searchGroups.bind(this);
+ this.parseDomainName = this.parseDomainName.bind(this);
+ this.onAccountChange = this.onAccountChange.bind(this);
+ }
+
+ parseDomainName(account) {
+ this.domainName = '';
+
+ if (!account || !account.includes('@')) {
+ return;
+ }
+
+ const [, domainName] = account.split('@');
+ if (!domainName) {
+ return;
+ }
+
+ const parts = _.compact(domainName.split('.'));
+ this.domainSuffix = parts.map((part) => `dc=${part}`).join(',');
+ }
+
+ onAccountChange(account) {
+ this.parseDomainName(account);
+ }
+
+ searchUsers() {
+ return this.LDAPService.users(this.settings);
+ }
+
+ searchGroups() {
+ return this.LDAPService.groups(this.settings);
+ }
+
+ onTlscaCertChange(file) {
+ this.tlscaCert = file;
+ }
+
+ addLDAPUrl() {
+ this.settings.URLs.push('');
+ }
+
+ removeLDAPUrl(index) {
+ this.settings.URLs.splice(index, 1);
+ }
+
+ $onInit() {
+ this.tlscaCert = this.settings.TLSCACert;
+ this.parseDomainName(this.settings.ReaderDN);
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html
new file mode 100644
index 000000000..29c7d7008
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html
@@ -0,0 +1,115 @@
+
+
+ With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s). If
+ disabled, users must be created in Portainer beforehand.
+
+
+
+
+
+ Information
+
+
+ When using Microsoft AD authentication, Portainer will delegate user authentication to the Domain Controller(s) configured below; if there is no connectivity, Portainer will
+ fallback to internal authentication.
+
+
+
+
+ AD configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ad-settings/index.js b/app/portainer/settings/authentication/ldap/ad-settings/index.js
new file mode 100644
index 000000000..59a474097
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ad-settings/index.js
@@ -0,0 +1,12 @@
+import controller from './ad-settings.controller';
+
+export const adSettings = {
+ templateUrl: './ad-settings.html',
+ controller,
+ bindings: {
+ settings: '=',
+ tlscaCert: '=',
+ state: '=',
+ connectivityCheck: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/index.js b/app/portainer/settings/authentication/ldap/index.js
new file mode 100644
index 000000000..2b3612be8
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/index.js
@@ -0,0 +1,44 @@
+import angular from 'angular';
+
+import { adSettings } from './ad-settings';
+import { ldapSettings } from './ldap-settings';
+import { ldapSettingsCustom } from './ldap-settings-custom';
+import { ldapSettingsOpenLdap } from './ldap-settings-openldap';
+
+import { ldapConnectivityCheck } from './ldap-connectivity-check';
+import { ldapGroupsDatatable } from './ldap-groups-datatable';
+import { ldapGroupSearch } from './ldap-group-search';
+import { ldapGroupSearchItem } from './ldap-group-search-item';
+import { ldapUserSearch } from './ldap-user-search';
+import { ldapUserSearchItem } from './ldap-user-search-item';
+import { ldapSettingsDnBuilder } from './ldap-settings-dn-builder';
+import { ldapSettingsGroupDnBuilder } from './ldap-settings-group-dn-builder';
+import { ldapCustomGroupSearch } from './ldap-custom-group-search';
+import { ldapSettingsSecurity } from './ldap-settings-security';
+import { ldapSettingsTestLogin } from './ldap-settings-test-login';
+import { ldapCustomUserSearch } from './ldap-custom-user-search';
+import { ldapUsersDatatable } from './ldap-users-datatable';
+import { LDAPService } from './ldap.service';
+import { LDAP } from './ldap.rest';
+
+export default angular
+ .module('portainer.settings.authentication.ldap', [])
+ .service('LDAPService', LDAPService)
+ .service('LDAP', LDAP)
+ .component('ldapConnectivityCheck', ldapConnectivityCheck)
+ .component('ldapGroupsDatatable', ldapGroupsDatatable)
+ .component('ldapSettings', ldapSettings)
+ .component('adSettings', adSettings)
+ .component('ldapGroupSearch', ldapGroupSearch)
+ .component('ldapGroupSearchItem', ldapGroupSearchItem)
+ .component('ldapUserSearch', ldapUserSearch)
+ .component('ldapUserSearchItem', ldapUserSearchItem)
+ .component('ldapSettingsCustom', ldapSettingsCustom)
+ .component('ldapSettingsDnBuilder', ldapSettingsDnBuilder)
+ .component('ldapSettingsGroupDnBuilder', ldapSettingsGroupDnBuilder)
+ .component('ldapCustomGroupSearch', ldapCustomGroupSearch)
+ .component('ldapSettingsOpenLdap', ldapSettingsOpenLdap)
+ .component('ldapSettingsSecurity', ldapSettingsSecurity)
+ .component('ldapSettingsTestLogin', ldapSettingsTestLogin)
+ .component('ldapCustomUserSearch', ldapCustomUserSearch)
+ .component('ldapUsersDatatable', ldapUsersDatatable).name;
diff --git a/app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js
new file mode 100644
index 000000000..b8a7fd136
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js
@@ -0,0 +1,8 @@
+export const ldapConnectivityCheck = {
+ templateUrl: './ldap-connectivity-check.html',
+ bindings: {
+ settings: '<',
+ state: '<',
+ connectivityCheck: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html
new file mode 100644
index 000000000..f69a5a699
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html
@@ -0,0 +1,19 @@
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js
new file mode 100644
index 000000000..5f6c10ce9
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js
@@ -0,0 +1,10 @@
+import controller from './ldap-custom-group-search.controller';
+
+export const ldapCustomGroupSearch = {
+ templateUrl: './ldap-custom-group-search.html',
+ controller,
+ bindings: {
+ settings: '=',
+ onSearchClick: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js
new file mode 100644
index 000000000..4c746f50a
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js
@@ -0,0 +1,34 @@
+export default class LdapCustomGroupSearchController {
+ /* @ngInject */
+ constructor($async, Notifications) {
+ Object.assign(this, { $async, Notifications });
+
+ this.groups = null;
+ this.showTable = false;
+
+ this.onRemoveClick = this.onRemoveClick.bind(this);
+ this.onAddClick = this.onAddClick.bind(this);
+ this.search = this.search.bind(this);
+ }
+
+ onAddClick() {
+ this.settings.push({ GroupBaseDN: '', GroupAttribute: '', GroupFilter: '' });
+ }
+
+ onRemoveClick(index) {
+ this.settings.splice(index, 1);
+ }
+
+ search() {
+ return this.$async(async () => {
+ try {
+ this.groups = null;
+ this.showTable = true;
+ this.groups = await this.onSearchClick();
+ } catch (error) {
+ this.showTable = false;
+ this.Notifications.error('Failure', error, 'Failed to search users');
+ }
+ });
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html
new file mode 100644
index 000000000..2423aa316
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html
@@ -0,0 +1,64 @@
+
+ Teams auto-population configurations
+
+
+
+
+
+
+ Extra search configuration
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js
new file mode 100644
index 000000000..9163676e6
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js
@@ -0,0 +1,10 @@
+import controller from './ldap-custom-user-search.controller';
+
+export const ldapCustomUserSearch = {
+ templateUrl: './ldap-custom-user-search.html',
+ controller,
+ bindings: {
+ settings: '=',
+ onSearchClick: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js
new file mode 100644
index 000000000..e672e9ed4
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js
@@ -0,0 +1,33 @@
+export default class LdapCustomUserSearchController {
+ /* @ngInject */
+ constructor($async, Notifications) {
+ Object.assign(this, { $async, Notifications });
+
+ this.users = null;
+
+ this.onRemoveClick = this.onRemoveClick.bind(this);
+ this.onAddClick = this.onAddClick.bind(this);
+ this.search = this.search.bind(this);
+ }
+
+ onAddClick() {
+ this.settings.push({ BaseDN: '', UserNameAttribute: '', Filter: '' });
+ }
+
+ onRemoveClick(index) {
+ this.settings.splice(index, 1);
+ }
+
+ search() {
+ return this.$async(async () => {
+ try {
+ this.users = null;
+ this.showTable = true;
+ this.users = await this.onSearchClick();
+ } catch (error) {
+ this.showTable = false;
+ this.Notifications.error('Failure', error, 'Failed to search users');
+ }
+ });
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html
new file mode 100644
index 000000000..df7c3114b
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html
@@ -0,0 +1,64 @@
+
+ User search configurations
+
+
+
+
+
+
+ Extra search configuration
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js b/app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js
new file mode 100644
index 000000000..929db04a6
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js
@@ -0,0 +1,14 @@
+import controller from './ldap-group-search-item.controller';
+
+export const ldapGroupSearchItem = {
+ templateUrl: './ldap-group-search-item.html',
+ controller,
+ bindings: {
+ config: '=',
+ index: '<',
+ domainSuffix: '@',
+ baseFilter: '@',
+
+ onRemoveClick: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js
new file mode 100644
index 000000000..95a1cc31a
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js
@@ -0,0 +1,51 @@
+export default class LdapSettingsAdGroupSearchItemController {
+ /* @ngInject */
+ constructor(Notifications) {
+ Object.assign(this, { Notifications });
+
+ this.groups = [];
+
+ this.onChangeBaseDN = this.onChangeBaseDN.bind(this);
+ }
+
+ onChangeBaseDN(baseDN) {
+ this.config.GroupBaseDN = baseDN;
+ }
+
+ addGroup() {
+ this.groups.push({ type: 'ou', value: '' });
+ }
+
+ removeGroup($index) {
+ this.groups.splice($index, 1);
+ this.onGroupsChange();
+ }
+
+ onGroupsChange() {
+ const groupsFilter = this.groups.map(({ type, value }) => `(${type}=${value})`).join('');
+ this.onFilterChange(groupsFilter ? `(&${this.baseFilter}(|${groupsFilter}))` : `${this.baseFilter}`);
+ }
+
+ onFilterChange(filter) {
+ this.config.GroupFilter = filter;
+ }
+
+ parseGroupFilter() {
+ const match = this.config.GroupFilter.match(/^\(&\(objectClass=(\w+)\)\(\|((\(\w+=.+\))+)\)\)$/);
+ if (!match) {
+ return;
+ }
+
+ const [, , groupFilter = ''] = match;
+
+ this.groups = groupFilter
+ .slice(1, -1)
+ .split(')(')
+ .map((str) => str.split('='))
+ .map(([type, value]) => ({ type, value }));
+ }
+
+ $onInit() {
+ this.parseGroupFilter();
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html
new file mode 100644
index 000000000..fa5a86680
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html
@@ -0,0 +1,68 @@
+
+
+
+
+ Extra search configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search/index.js b/app/portainer/settings/authentication/ldap/ldap-group-search/index.js
new file mode 100644
index 000000000..99bb6f061
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-group-search/index.js
@@ -0,0 +1,13 @@
+import controller from './ldap-group-search.controller';
+
+export const ldapGroupSearch = {
+ templateUrl: './ldap-group-search.html',
+ controller,
+ bindings: {
+ settings: '=',
+ domainSuffix: '@',
+ baseFilter: '@',
+
+ onSearchClick: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js
new file mode 100644
index 000000000..c431bb230
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js
@@ -0,0 +1,36 @@
+import _ from 'lodash-es';
+
+export default class LdapGroupSearchController {
+ /* @ngInject */
+ constructor($async, Notifications) {
+ Object.assign(this, { $async, Notifications });
+
+ this.groups = null;
+
+ this.onRemoveClick = this.onRemoveClick.bind(this);
+ this.onAddClick = this.onAddClick.bind(this);
+ this.search = this.search.bind(this);
+ }
+
+ onAddClick() {
+ const lastSetting = _.last(this.settings);
+ this.settings.push({ GroupBaseDN: this.domainSuffix, GroupAttribute: lastSetting.GroupAttribute, GroupFilter: this.baseFilter });
+ }
+
+ onRemoveClick(index) {
+ this.settings.splice(index, 1);
+ }
+
+ search() {
+ return this.$async(async () => {
+ try {
+ this.groups = null;
+ this.showTable = true;
+ this.groups = await this.onSearchClick();
+ } catch (error) {
+ this.showTable = false;
+ this.Notifications.error('Failure', error, 'Failed to search users');
+ }
+ });
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html
new file mode 100644
index 000000000..1b5840f6d
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html
@@ -0,0 +1,32 @@
+
+ Teams auto-population configurations
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-groups-datatable/index.js b/app/portainer/settings/authentication/ldap/ldap-groups-datatable/index.js
new file mode 100644
index 000000000..28cacef0c
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-groups-datatable/index.js
@@ -0,0 +1,12 @@
+export const ldapGroupsDatatable = {
+ templateUrl: './ldap-groups-datatable.html',
+ controller: 'GenericDatatableController',
+ bindings: {
+ titleText: '@',
+ titleIcon: '@',
+ dataset: '<',
+ tableKey: '@',
+ orderBy: '@',
+ reverseOrder: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-groups-datatable/ldap-groups-datatable.html b/app/portainer/settings/authentication/ldap/ldap-groups-datatable/ldap-groups-datatable.html
new file mode 100644
index 000000000..061448f70
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-groups-datatable/ldap-groups-datatable.html
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ User Name
+
+
+
+
+
+ Groups
+
+
+
+
+
+
+ {{ item.Name }}
+
+
+ {{ group }}
+
+
+
+ Loading...
+
+
+ No groups found.
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js
new file mode 100644
index 000000000..321223717
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js
@@ -0,0 +1,15 @@
+import controller from './ldap-settings-custom.controller';
+
+export const ldapSettingsCustom = {
+ templateUrl: './ldap-settings-custom.html',
+ controller,
+ bindings: {
+ settings: '=',
+ tlscaCert: '=',
+ state: '=',
+ onTlscaCertChange: '<',
+ connectivityCheck: '<',
+ onSearchUsersClick: '<',
+ onSearchGroupsClick: '<',
+ },
+};
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
new file mode 100644
index 000000000..6fbea91fb
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js
@@ -0,0 +1,9 @@
+export default class LdapSettingsCustomController {
+ addLDAPUrl() {
+ this.settings.URLs.push('');
+ }
+
+ removeLDAPUrl(index) {
+ this.settings.URLs.splice(index, 1);
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html
new file mode 100644
index 000000000..14cc35279
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html
@@ -0,0 +1,99 @@
+
+
+ Information
+
+
+ When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails.
+
+
+
+
+ LDAP configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/index.js
new file mode 100644
index 000000000..bedcd03c8
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/index.js
@@ -0,0 +1,15 @@
+import controller from './ldap-settings-dn-builder.controller';
+
+export const ldapSettingsDnBuilder = {
+ templateUrl: './ldap-settings-dn-builder.html',
+ controller,
+ bindings: {
+ // ngModel: string (dc=,cn=,)
+ ngModel: '<',
+ // onChange(string) => void
+ onChange: '<',
+ // suffix: string (dc=,dc=,)
+ suffix: '@',
+ label: '@',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.controller.js
new file mode 100644
index 000000000..4b829967a
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.controller.js
@@ -0,0 +1,84 @@
+export default class LdapSettingsBaseDnBuilderController {
+ /* @ngInject */
+ constructor() {
+ this.entries = [];
+ }
+
+ addEntry() {
+ this.entries.push({ type: 'ou', value: '' });
+ }
+
+ removeEntry($index) {
+ this.entries.splice($index, 1);
+ this.onEntriesChange();
+ }
+
+ moveUp($index) {
+ if ($index <= 0) {
+ return;
+ }
+ arrayMove(this.entries, $index, $index - 1);
+ this.onEntriesChange();
+ }
+
+ moveDown($index) {
+ if ($index >= this.entries.length - 1) {
+ return;
+ }
+ arrayMove(this.entries, $index, $index + 1);
+ this.onEntriesChange();
+ }
+
+ onEntriesChange() {
+ const dn = this.entries
+ .filter(({ value }) => value)
+ .map(({ type, value }) => `${type}=${value}`)
+ .concat(this.suffix)
+ .filter((value) => value)
+ .join(',');
+
+ this.onChange(dn);
+ }
+
+ getOUValues(dn, domainSuffix = '') {
+ const regex = /(\w+)=(\w*),?/;
+ let ouValues = [];
+ let left = dn;
+ let match = left.match(regex);
+ while (match && left !== domainSuffix) {
+ const [, type, value] = match;
+ ouValues.push({ type, value });
+ left = left.replace(regex, '');
+ match = left.match(/(\w+)=(\w+),?/);
+ }
+ return ouValues;
+ }
+
+ parseBaseDN() {
+ this.entries = this.getOUValues(this.ngModel, this.suffix);
+ }
+
+ $onChanges({ suffix, ngModel }) {
+ if ((!suffix && !ngModel) || (suffix && suffix.isFirstChange())) {
+ return;
+ }
+ this.onEntriesChange();
+ }
+
+ $onInit() {
+ this.parseBaseDN();
+ }
+}
+
+function arrayMove(array, fromIndex, toIndex) {
+ if (!checkValidIndex(array, fromIndex) || !checkValidIndex(array, toIndex)) {
+ throw new Error('index is out of bounds');
+ }
+ const [item] = array.splice(fromIndex, 1);
+
+ array.splice(toIndex, 0, item);
+
+ function checkValidIndex(array, index) {
+ return index >= 0 && index <= array.length;
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.html b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.html
new file mode 100644
index 000000000..4ce6c1bdc
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.html
@@ -0,0 +1,36 @@
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/index.js
new file mode 100644
index 000000000..e3d0818cd
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/index.js
@@ -0,0 +1,17 @@
+import controller from './ldap-settings-group-dn-builder.controller';
+
+export const ldapSettingsGroupDnBuilder = {
+ templateUrl: './ldap-settings-group-dn-builder.html',
+ controller,
+ bindings: {
+ // ngModel: string (dc=,cn=,)
+ ngModel: '<',
+ // onChange(string) => void
+ onChange: '<',
+ // suffix: string (dc=,dc=,)
+ suffix: '@',
+ // index: int >= 0
+ index: '<',
+ onRemoveClick: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.controller.js
new file mode 100644
index 000000000..32ee7f3ee
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.controller.js
@@ -0,0 +1,55 @@
+export default class LdapSettingsGroupDnBuilderController {
+ /* @ngInject */
+ constructor() {
+ this.groupName = '';
+ this.entries = '';
+
+ this.onEntriesChange = this.onEntriesChange.bind(this);
+ this.onGroupNameChange = this.onGroupNameChange.bind(this);
+ this.onGroupChange = this.onGroupChange.bind(this);
+ this.removeGroup = this.removeGroup.bind(this);
+ }
+
+ onEntriesChange(entries) {
+ this.onGroupChange(this.groupName, entries);
+ }
+
+ onGroupNameChange() {
+ this.onGroupChange(this.groupName, this.entries);
+ }
+
+ onGroupChange(groupName, entries) {
+ if (!groupName) {
+ return;
+ }
+ const groupNameEntry = `cn=${groupName}`;
+ this.onChange(this.index, entries || this.suffix ? `${groupNameEntry},${entries || this.suffix}` : groupNameEntry);
+ }
+
+ removeGroup() {
+ this.onRemoveClick(this.index);
+ }
+
+ parseEntries(value, suffix) {
+ if (value === suffix) {
+ this.groupName = '';
+ this.entries = suffix;
+ return;
+ }
+
+ const [groupName, entries] = this.ngModel.split(/,(.+)/);
+ this.groupName = groupName.replace('cn=', '');
+ this.entries = entries || '';
+ }
+
+ $onChange({ ngModel, suffix }) {
+ if ((!suffix || suffix.isFirstChange()) && !ngModel) {
+ return;
+ }
+ this.parseEntries(ngModel.value, suffix.value);
+ }
+
+ $onInit() {
+ this.parseEntries(this.ngModel, this.suffix);
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.html b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.html
new file mode 100644
index 000000000..8f138ff1b
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.html
@@ -0,0 +1,21 @@
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js
new file mode 100644
index 000000000..b88f8008c
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js
@@ -0,0 +1,16 @@
+import controller from './ldap-settings-openldap.controller';
+
+export const ldapSettingsOpenLdap = {
+ templateUrl: './ldap-settings-openldap.html',
+ controller,
+ bindings: {
+ settings: '=',
+ tlscaCert: '=',
+ state: '=',
+ connectivityCheck: '<',
+
+ onTlscaCertChange: '<',
+ onSearchUsersClick: '<',
+ onSearchGroupsClick: '<',
+ },
+};
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
new file mode 100644
index 000000000..fd37e4ff7
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js
@@ -0,0 +1,42 @@
+export default class LdapSettingsOpenLDAPController {
+ /* @ngInject */
+ constructor() {
+ this.domainSuffix = '';
+
+ this.findDomainSuffix = this.findDomainSuffix.bind(this);
+ this.parseDomainSuffix = this.parseDomainSuffix.bind(this);
+ this.onAccountChange = this.onAccountChange.bind(this);
+ }
+
+ findDomainSuffix() {
+ const serviceAccount = this.settings.ReaderDN;
+ let domainSuffix = this.parseDomainSuffix(serviceAccount);
+ if (!domainSuffix && this.settings.SearchSettings.length > 0) {
+ const searchSettings = this.settings.SearchSettings[0];
+ domainSuffix = this.parseDomainSuffix(searchSettings.BaseDN);
+ }
+
+ this.domainSuffix = domainSuffix;
+ }
+
+ parseDomainSuffix(string = '') {
+ const index = string.toLowerCase().indexOf('dc=');
+ return index !== -1 ? string.substring(index) : '';
+ }
+
+ onAccountChange(serviceAccount) {
+ this.domainSuffix = this.parseDomainSuffix(serviceAccount);
+ }
+
+ addLDAPUrl() {
+ this.settings.URLs.push('');
+ }
+
+ removeLDAPUrl(index) {
+ this.settings.URLs.splice(index, 1);
+ }
+
+ $onInit() {
+ this.findDomainSuffix();
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html
new file mode 100644
index 000000000..3b84f5f44
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html
@@ -0,0 +1,129 @@
+
+
+ Information
+
+
+ When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails.
+
+
+
+
+ LDAP configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-security/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-security/index.js
new file mode 100644
index 000000000..8a4c43ac4
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-security/index.js
@@ -0,0 +1,10 @@
+export const ldapSettingsSecurity = {
+ templateUrl: './ldap-settings-security.html',
+ bindings: {
+ settings: '=',
+ tlscaCert: '<',
+ onTlscaCertChange: '<',
+ uploadInProgress: '<',
+ title: '@',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html b/app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html
new file mode 100644
index 000000000..527c8fcc2
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html
@@ -0,0 +1,57 @@
+
+ {{ $ctrl.title || 'LDAP security' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-test-login/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/index.js
new file mode 100644
index 000000000..e4aefc097
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/index.js
@@ -0,0 +1,9 @@
+import controller from './ldap-settings-test-login.controller';
+
+export const ldapSettingsTestLogin = {
+ templateUrl: './ldap-settings-test-login.html',
+ controller,
+ bindings: {
+ settings: '=',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.controller.js
new file mode 100644
index 000000000..811f70aa9
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.controller.js
@@ -0,0 +1,31 @@
+const TEST_STATUS = {
+ LOADING: 'LOADING',
+ SUCCESS: 'SUCCESS',
+ FAILURE: 'FAILURE',
+};
+
+export default class LdapSettingsTestLogin {
+ /* @ngInject */
+ constructor($async, LDAPService, Notifications) {
+ Object.assign(this, { $async, LDAPService, Notifications });
+
+ this.TEST_STATUS = TEST_STATUS;
+
+ this.state = {
+ testStatus: '',
+ };
+ }
+
+ async testLogin(username, password) {
+ return this.$async(async () => {
+ this.state.testStatus = TEST_STATUS.LOADING;
+ try {
+ const response = await this.LDAPService.testLogin(this.settings, username, password);
+ this.state.testStatus = response.valid ? TEST_STATUS.SUCCESS : TEST_STATUS.FAILURE;
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Unable to test login');
+ this.state.testStatus = TEST_STATUS.FAILURE;
+ }
+ });
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.html b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.html
new file mode 100644
index 000000000..a69b89674
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.html
@@ -0,0 +1,32 @@
+
+ Test login
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings.model.js b/app/portainer/settings/authentication/ldap/ldap-settings.model.js
new file mode 100644
index 000000000..d14711eec
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings.model.js
@@ -0,0 +1,54 @@
+export function buildLdapSettingsModel() {
+ return {
+ AnonymousMode: true,
+ ReaderDN: '',
+ URLs: [''],
+ ServerType: 0,
+ TLSConfig: {
+ TLS: false,
+ TLSSkipVerify: false,
+ },
+ StartTLS: false,
+ SearchSettings: [
+ {
+ BaseDN: '',
+ Filter: '',
+ UserNameAttribute: '',
+ },
+ ],
+ GroupSearchSettings: [
+ {
+ GroupBaseDN: '',
+ GroupFilter: '',
+ GroupAttribute: '',
+ },
+ ],
+ AutoCreateUsers: true,
+ };
+}
+
+export function buildAdSettingsModel() {
+ const settings = buildLdapSettingsModel();
+
+ settings.ServerType = 2;
+ settings.AnonymousMode = false;
+ settings.SearchSettings[0].UserNameAttribute = 'sAMAccountName';
+ settings.SearchSettings[0].Filter = '(objectClass=user)';
+ settings.GroupSearchSettings[0].GroupAttribute = 'member';
+ settings.GroupSearchSettings[0].GroupFilter = '(objectClass=group)';
+
+ return settings;
+}
+
+export function buildOpenLDAPSettingsModel() {
+ const settings = buildLdapSettingsModel();
+
+ settings.ServerType = 1;
+ settings.AnonymousMode = false;
+ settings.SearchSettings[0].UserNameAttribute = 'uid';
+ settings.SearchSettings[0].Filter = '(objectClass=inetOrgPerson)';
+ settings.GroupSearchSettings[0].GroupAttribute = 'member';
+ settings.GroupSearchSettings[0].GroupFilter = '(objectClass=groupOfNames)';
+
+ return settings;
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/index.js b/app/portainer/settings/authentication/ldap/ldap-settings/index.js
new file mode 100644
index 000000000..90e86951e
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings/index.js
@@ -0,0 +1,11 @@
+import controller from './ldap-settings.controller';
+
+export const ldapSettings = {
+ templateUrl: './ldap-settings.html',
+ controller,
+ bindings: {
+ settings: '=',
+ state: '<',
+ connectivityCheck: '<',
+ },
+};
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
new file mode 100644
index 000000000..306740e18
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js
@@ -0,0 +1,66 @@
+const SERVER_TYPES = {
+ CUSTOM: 0,
+ OPEN_LDAP: 1,
+ AD: 2,
+};
+
+import { buildOpenLDAPSettingsModel } from '@/portainer/settings/authentication/ldap/ldap-settings.model';
+
+const DEFAULT_GROUP_FILTER = '(objectClass=groupOfNames)';
+const DEFAULT_USER_FILTER = '(objectClass=inetOrgPerson)';
+
+export default class LdapSettingsController {
+ /* @ngInject */
+ constructor(LDAPService) {
+ Object.assign(this, { LDAPService, SERVER_TYPES });
+
+ this.tlscaCert = null;
+
+ 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' },
+ ];
+
+ this.onTlscaCertChange = this.onTlscaCertChange.bind(this);
+ this.searchUsers = this.searchUsers.bind(this);
+ this.searchGroups = this.searchGroups.bind(this);
+ this.onChangeServerType = this.onChangeServerType.bind(this);
+ }
+
+ onTlscaCertChange(file) {
+ this.tlscaCert = file;
+ }
+
+ $onInit() {
+ this.tlscaCert = this.settings.TLSCACert;
+ }
+
+ onChangeServerType(serverType) {
+ switch (serverType) {
+ case SERVER_TYPES.OPEN_LDAP:
+ return this.onChangeToOpenLDAP();
+ default:
+ break;
+ }
+ }
+
+ onChangeToOpenLDAP() {
+ this.settings = buildOpenLDAPSettingsModel();
+ }
+
+ searchUsers() {
+ const settings = {
+ ...this.settings,
+ SearchSettings: this.settings.SearchSettings.map((search) => ({ ...search, Filter: search.Filter || DEFAULT_USER_FILTER })),
+ };
+ return this.LDAPService.users(settings);
+ }
+
+ searchGroups() {
+ const settings = {
+ ...this.settings,
+ GroupSearchSettings: this.settings.GroupSearchSettings.map((search) => ({ ...search, GroupFilter: search.GroupFilter || DEFAULT_GROUP_FILTER })),
+ };
+ return this.LDAPService.groups(settings);
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html
new file mode 100644
index 000000000..d00a84ba5
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html
@@ -0,0 +1,35 @@
+
+
+
+ With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s).
+ If disabled, users must be created in Portainer beforehand.
+
+
+
+
+ Server Type
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js b/app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js
new file mode 100644
index 000000000..414883713
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js
@@ -0,0 +1,14 @@
+import controller from './ldap-user-search-item.controller';
+
+export const ldapUserSearchItem = {
+ templateUrl: './ldap-user-search-item.html',
+ controller,
+ bindings: {
+ config: '=',
+ index: '<',
+ showUsernameFormat: '<',
+ domainSuffix: '@',
+ baseFilter: '@',
+ onRemoveClick: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js
new file mode 100644
index 000000000..a42ffdc72
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js
@@ -0,0 +1,67 @@
+export default class LdapUserSearchItemController {
+ /* @ngInject */
+ constructor() {
+ this.groups = [];
+
+ this.onBaseDNChange = this.onBaseDNChange.bind(this);
+ this.onGroupChange = this.onGroupChange.bind(this);
+ this.onGroupsChange = this.onGroupsChange.bind(this);
+ this.removeGroup = this.removeGroup.bind(this);
+ }
+
+ onBaseDNChange(baseDN) {
+ this.config.BaseDN = baseDN;
+ }
+
+ onGroupChange(index, group) {
+ this.groups[index] = group;
+ this.onGroupsChange(this.groups);
+ }
+
+ onGroupsChange(groups) {
+ this.config.Filter = this.generateUserFilter(groups);
+ }
+
+ removeGroup(index) {
+ this.groups.splice(index, 1);
+ this.onGroupsChange(this.groups);
+ }
+
+ addGroup() {
+ this.groups.push(this.domainSuffix ? `cn=,${this.domainSuffix}` : 'cn=');
+ }
+
+ generateUserFilter(groups) {
+ const filteredGroups = groups.filter((group) => group !== this.domainSuffix);
+
+ if (!filteredGroups.length) {
+ return this.baseFilter;
+ }
+
+ const groupsFilter = filteredGroups.map((group) => `(memberOf=${group})`);
+
+ return `(&${this.baseFilter}${groupsFilter.length > 1 ? `(|${groupsFilter.join('')})` : groupsFilter[0]})`;
+ }
+
+ parseFilter() {
+ const filter = this.config.Filter;
+ if (filter === this.baseFilter) {
+ return;
+ }
+
+ if (!filter.includes('|')) {
+ const index = filter.indexOf('memberOf=');
+ if (index > -1) {
+ this.groups = [filter.slice(index + 9, -2)];
+ }
+ return;
+ }
+
+ const members = filter.slice(filter.indexOf('|') + 2, -3);
+ this.groups = members.split(')(').map((member) => member.replace('memberOf=', ''));
+ }
+
+ $onInit() {
+ this.parseFilter();
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html
new file mode 100644
index 000000000..f21386f3e
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html
@@ -0,0 +1,75 @@
+
+
+
+
+ Extra search configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search/index.js b/app/portainer/settings/authentication/ldap/ldap-user-search/index.js
new file mode 100644
index 000000000..bb3d3009a
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-user-search/index.js
@@ -0,0 +1,14 @@
+import controller from './ldap-user-search.controller';
+
+export const ldapUserSearch = {
+ templateUrl: './ldap-user-search.html',
+ controller,
+ bindings: {
+ settings: '=',
+ domainSuffix: '@',
+ showUsernameFormat: '<',
+ baseFilter: '@',
+
+ onSearchClick: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js
new file mode 100644
index 000000000..6d5ff11eb
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js
@@ -0,0 +1,38 @@
+import _ from 'lodash';
+
+export default class LdapUserSearchController {
+ /* @ngInject */
+ constructor($async, Notifications) {
+ Object.assign(this, { $async, Notifications });
+
+ this.users = null;
+ this.showTable = false;
+
+ this.onRemoveClick = this.onRemoveClick.bind(this);
+ this.onAddClick = this.onAddClick.bind(this);
+ this.search = this.search.bind(this);
+ }
+
+ onAddClick() {
+ const lastSetting = _.last(this.settings);
+ this.settings.push({ BaseDN: this.domainSuffix, UserNameAttribute: lastSetting.UserNameAttribute, Filter: this.baseFilter });
+ }
+
+ onRemoveClick(index) {
+ this.settings.splice(index, 1);
+ }
+
+ search() {
+ return this.$async(async () => {
+ try {
+ this.users = null;
+ this.showTable = true;
+ const users = await this.onSearchClick();
+ this.users = _.compact(users);
+ } catch (error) {
+ this.Notifications.error('Failure', error, 'Failed to search users');
+ this.showTable = false;
+ }
+ });
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html
new file mode 100644
index 000000000..3d491b62c
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html
@@ -0,0 +1,33 @@
+
+ User search configurations
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-users-datatable/index.js b/app/portainer/settings/authentication/ldap/ldap-users-datatable/index.js
new file mode 100644
index 000000000..4c80771d4
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-users-datatable/index.js
@@ -0,0 +1,12 @@
+export const ldapUsersDatatable = {
+ templateUrl: './ldap-users-datatable.html',
+ controller: 'GenericDatatableController',
+ bindings: {
+ titleText: '@',
+ titleIcon: '@',
+ dataset: '<',
+ tableKey: '@',
+ orderBy: '@',
+ reverseOrder: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-users-datatable/ldap-users-datatable.html b/app/portainer/settings/authentication/ldap/ldap-users-datatable/ldap-users-datatable.html
new file mode 100644
index 000000000..9817654f2
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-users-datatable/ldap-users-datatable.html
@@ -0,0 +1,71 @@
+
diff --git a/app/portainer/settings/authentication/ldap/ldap.rest.js b/app/portainer/settings/authentication/ldap/ldap.rest.js
new file mode 100644
index 000000000..e93d5277c
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap.rest.js
@@ -0,0 +1,15 @@
+const API_ENDPOINT_LDAP = 'api/ldap';
+
+/* @ngInject */
+export function LDAP($resource) {
+ return $resource(
+ `${API_ENDPOINT_LDAP}/:action`,
+ {},
+ {
+ check: { method: 'POST', params: { action: 'check' } },
+ users: { method: 'POST', isArray: true, params: { action: 'users' } },
+ groups: { method: 'POST', isArray: true, params: { action: 'groups' } },
+ testLogin: { method: 'POST', params: { action: 'test' } },
+ }
+ );
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap.service.js b/app/portainer/settings/authentication/ldap/ldap.service.js
new file mode 100644
index 000000000..875f83a02
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap.service.js
@@ -0,0 +1,29 @@
+/* @ngInject */
+export function LDAPService(LDAP) {
+ return { users, groups, check, testLogin };
+
+ function users(ldapSettings) {
+ return LDAP.users({ ldapSettings }).$promise;
+ }
+
+ async function groups(ldapSettings) {
+ const userGroups = await LDAP.groups({ ldapSettings }).$promise;
+ return userGroups.map(({ Name, Groups }) => {
+ let name = Name;
+ if (Name.includes(',') && Name.includes('=')) {
+ const [cnName] = Name.split(',');
+ const split = cnName.split('=');
+ name = split[1];
+ }
+ return { Groups, Name: name };
+ });
+ }
+
+ function check(ldapSettings) {
+ return LDAP.check({ ldapSettings }).$promise;
+ }
+
+ function testLogin(ldapSettings, username, password) {
+ return LDAP.testLogin({ ldapSettings, username, password }).$promise;
+ }
+}
diff --git a/app/portainer/settings/index.js b/app/portainer/settings/index.js
index 42e4e25ac..629f9b5ed 100644
--- a/app/portainer/settings/index.js
+++ b/app/portainer/settings/index.js
@@ -1,5 +1,6 @@
import angular from 'angular';
+import authenticationModule from './authentication';
import generalModule from './general';
-export default angular.module('portainer.settings', [generalModule]).name;
+export default angular.module('portainer.settings', [authenticationModule, generalModule]).name;
diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html
index 2d36bcd76..481f5a290 100644
--- a/app/portainer/views/settings/authentication/settingsAuthentication.html
+++ b/app/portainer/views/settings/authentication/settingsAuthentication.html
@@ -34,43 +34,10 @@
Authentication method
-
-
-
+
+
+
Information
@@ -79,345 +46,23 @@
-
-
-
- Information
-
-
- When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails.
-
-
+
-
- LDAP configuration
-
+
-
-
-
-
-
-
-
-
-
-
-
- LDAP security
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Automatic user provisioning
-
-
-
- With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group
- name(s). If disabled, users must be created in Portainer beforehand.
-
-
-
-
-
- User search configurations
-
-
-
-
-
-
- Extra search configuration
-
-
-
-
-
-
-
-
- add user search configuration
-
-
-
-
-
-
- Group search configurations
-
-
-
-
-
-
- Extra search configuration
-
-
-
-
-
-
-
-
- add group search configuration
-
-
-
-
-
-
-
-
-
+
@@ -425,7 +70,13 @@
-
+
Save settings
Saving...
diff --git a/app/portainer/views/settings/authentication/settingsAuthenticationController.js b/app/portainer/views/settings/authentication/settingsAuthenticationController.js
index f03b88f46..555bec411 100644
--- a/app/portainer/views/settings/authentication/settingsAuthenticationController.js
+++ b/app/portainer/views/settings/authentication/settingsAuthenticationController.js
@@ -1,181 +1,244 @@
-angular.module('portainer.app').controller('SettingsAuthenticationController', [
- '$q',
- '$scope',
- '$state',
- 'Notifications',
- 'SettingsService',
- 'FileUploadService',
- 'TeamService',
- function ($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService) {
- $scope.state = {
- successfulConnectivityCheck: false,
- failedConnectivityCheck: false,
- uploadInProgress: false,
- connectivityCheckInProgress: false,
- actionInProgress: false,
- availableUserSessionTimeoutOptions: [
- {
- key: '1 hour',
- value: '1h',
- },
- {
- key: '4 hours',
- value: '4h',
- },
- {
- key: '8 hours',
- value: '8h',
- },
- {
- key: '24 hours',
- value: '24h',
- },
- { key: '1 week', value: `${24 * 7}h` },
- { key: '1 month', value: `${24 * 30}h` },
- { key: '6 months', value: `${24 * 30 * 6}h` },
- { key: '1 year', value: `${24 * 30 * 12}h` },
- ],
- };
+import angular from 'angular';
+import _ from 'lodash-es';
- $scope.formValues = {
- UserSessionTimeout: $scope.state.availableUserSessionTimeoutOptions[0],
- TLSCACert: '',
- LDAPSettings: {
- AnonymousMode: true,
- ReaderDN: '',
- URL: '',
- TLSConfig: {
- TLS: false,
- TLSSkipVerify: false,
- },
- StartTLS: false,
- SearchSettings: [
- {
- BaseDN: '',
- Filter: '',
- UserNameAttribute: '',
- },
- ],
- GroupSearchSettings: [
- {
- GroupBaseDN: '',
- GroupFilter: '',
- GroupAttribute: '',
- },
- ],
- AutoCreateUsers: true,
+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.state = {
+ uploadInProgress: false,
+ actionInProgress: false,
+ availableUserSessionTimeoutOptions: [
+ {
+ key: '1 hour',
+ value: '1h',
},
- };
+ {
+ key: '4 hours',
+ value: '4h',
+ },
+ {
+ key: '8 hours',
+ value: '8h',
+ },
+ {
+ key: '24 hours',
+ value: '24h',
+ },
+ { key: '1 week', value: `${24 * 7}h` },
+ { key: '1 month', value: `${24 * 30}h` },
+ { key: '6 months', value: `${24 * 30 * 6}h` },
+ { key: '1 year', value: `${24 * 30 * 12}h` },
+ ],
+ };
- $scope.isOauthEnabled = function isOauthEnabled() {
- return $scope.settings && $scope.settings.AuthenticationMethod === 3;
- };
+ $scope.formValues = {
+ UserSessionTimeout: $scope.state.availableUserSessionTimeoutOptions[0],
+ TLSCACert: '',
+ ldap: {
+ serverType: 0,
+ adSettings: buildAdSettingsModel(),
+ ldapSettings: buildLdapSettingsModel(),
+ },
+ };
- $scope.addSearchConfiguration = function () {
- $scope.formValues.LDAPSettings.SearchSettings.push({ BaseDN: '', UserNameAttribute: '', Filter: '' });
- };
+ $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 },
+ { id: 'auth_oauth', icon: 'fa fa-users', label: 'OAuth', description: 'OAuth authentication', value: 3 },
+ ];
- $scope.removeSearchConfiguration = function (index) {
- $scope.formValues.LDAPSettings.SearchSettings.splice(index, 1);
- };
-
- $scope.addGroupSearchConfiguration = function () {
- $scope.formValues.LDAPSettings.GroupSearchSettings.push({ GroupBaseDN: '', GroupAttribute: '', GroupFilter: '' });
- };
-
- $scope.removeGroupSearchConfiguration = function (index) {
- $scope.formValues.LDAPSettings.GroupSearchSettings.splice(index, 1);
- };
-
- $scope.LDAPConnectivityCheck = function () {
- var settings = angular.copy($scope.settings);
- var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null;
-
- if ($scope.formValues.LDAPSettings.AnonymousMode) {
- settings.LDAPSettings['ReaderDN'] = '';
- settings.LDAPSettings['Password'] = '';
- }
-
- var uploadRequired = ($scope.formValues.LDAPSettings.TLSConfig.TLS || $scope.formValues.LDAPSettings.StartTLS) && !$scope.formValues.LDAPSettings.TLSConfig.TLSSkipVerify;
- $scope.state.uploadInProgress = uploadRequired;
-
- $scope.state.connectivityCheckInProgress = true;
- $q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(TLSCAFile, null, null))
- .then(function success() {
- addLDAPDefaultPort(settings, $scope.formValues.LDAPSettings.TLSConfig.TLS);
- return SettingsService.checkLDAPConnectivity(settings);
- })
- .then(function success() {
- $scope.state.failedConnectivityCheck = false;
- $scope.state.successfulConnectivityCheck = true;
- Notifications.success('Connection to LDAP successful');
- })
- .catch(function error(err) {
- $scope.state.failedConnectivityCheck = true;
- $scope.state.successfulConnectivityCheck = false;
- Notifications.error('Failure', err, 'Connection to LDAP failed');
- })
- .finally(function final() {
- $scope.state.uploadInProgress = false;
- $scope.state.connectivityCheckInProgress = false;
- });
- };
-
- $scope.saveSettings = function () {
- var settings = angular.copy($scope.settings);
- var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null;
-
- if ($scope.formValues.LDAPSettings.AnonymousMode) {
- settings.LDAPSettings['ReaderDN'] = '';
- settings.LDAPSettings['Password'] = '';
- }
-
- var uploadRequired = ($scope.formValues.LDAPSettings.TLSConfig.TLS || $scope.formValues.LDAPSettings.StartTLS) && !$scope.formValues.LDAPSettings.TLSConfig.TLSSkipVerify;
- $scope.state.uploadInProgress = uploadRequired;
-
- $scope.state.actionInProgress = true;
- $q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(TLSCAFile, null, null))
- .then(function success() {
- addLDAPDefaultPort(settings, $scope.formValues.LDAPSettings.TLSConfig.TLS);
- return SettingsService.update(settings);
- })
- .then(function success() {
- Notifications.success('Authentication settings updated');
- })
- .catch(function error(err) {
- Notifications.error('Failure', err, 'Unable to update authentication settings');
- })
- .finally(function final() {
- $scope.state.uploadInProgress = false;
- $scope.state.actionInProgress = false;
- });
- };
-
- // Add default port if :port is not defined in URL
- function addLDAPDefaultPort(settings, tlsEnabled) {
- if (settings.LDAPSettings.URL.indexOf(':') === -1) {
- settings.LDAPSettings.URL += tlsEnabled ? ':636' : ':389';
- }
+ $scope.onChangeAuthMethod = function onChangeAuthMethod(value) {
+ if (value === 4) {
+ $scope.settings.AuthenticationMethod = 2;
+ $scope.formValues.ldap.serverType = 2;
+ return;
}
- function initView() {
- $q.all({
- settings: SettingsService.settings(),
- teams: TeamService.teams(),
+ if (value === 2) {
+ $scope.settings.AuthenticationMethod = 2;
+ $scope.formValues.ldap.serverType = $scope.formValues.ldap.ldapSettings.ServerType;
+ return;
+ }
+
+ $scope.settings.AuthenticationMethod = value;
+ };
+
+ $scope.authenticationMethodSelected = function authenticationMethodSelected(value) {
+ if (!$scope.settings) {
+ return false;
+ }
+
+ if (value === 4) {
+ return $scope.settings.AuthenticationMethod === 2 && $scope.formValues.ldap.serverType === 2;
+ }
+
+ if (value === 2) {
+ return $scope.settings.AuthenticationMethod === 2 && $scope.formValues.ldap.serverType !== 2;
+ }
+
+ return $scope.settings.AuthenticationMethod === value;
+ };
+
+ $scope.isOauthEnabled = function isOauthEnabled() {
+ return $scope.settings && $scope.settings.AuthenticationMethod === 3;
+ };
+
+ $scope.LDAPConnectivityCheck = LDAPConnectivityCheck;
+ function LDAPConnectivityCheck() {
+ const settings = angular.copy($scope.settings);
+
+ const { settings: ldapSettings, uploadRequired, tlscaFile } = prepareLDAPSettings();
+ settings.LDAPSettings = ldapSettings;
+ $scope.state.uploadInProgress = uploadRequired;
+
+ $scope.state.connectivityCheckInProgress = true;
+
+ $q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(tlscaFile, null, null))
+ .then(function success() {
+ return LDAPService.check(settings.LDAPSettings);
})
- .then(function success(data) {
- var settings = data.settings;
- $scope.teams = data.teams;
- $scope.settings = settings;
- $scope.formValues.LDAPSettings = settings.LDAPSettings;
- $scope.OAuthSettings = settings.OAuthSettings;
- $scope.formValues.TLSCACert = settings.LDAPSettings.TLSConfig.TLSCACert;
- })
- .catch(function error(err) {
- Notifications.error('Failure', err, 'Unable to retrieve application settings');
- });
+ .then(function success() {
+ $scope.state.failedConnectivityCheck = false;
+ $scope.state.successfulConnectivityCheck = true;
+ Notifications.success('Connection to LDAP successful');
+ })
+ .catch(function error(err) {
+ $scope.state.failedConnectivityCheck = true;
+ $scope.state.successfulConnectivityCheck = false;
+ Notifications.error('Failure', err, 'Connection to LDAP failed');
+ })
+ .finally(function final() {
+ $scope.state.uploadInProgress = false;
+ $scope.state.connectivityCheckInProgress = false;
+ });
+ }
+
+ $scope.saveSettings = function () {
+ const settings = angular.copy($scope.settings);
+
+ const { settings: ldapSettings, uploadRequired, tlscaFile } = prepareLDAPSettings();
+ settings.LDAPSettings = ldapSettings;
+ $scope.state.uploadInProgress = uploadRequired;
+
+ $scope.state.actionInProgress = true;
+
+ $q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(tlscaFile, null, null))
+ .then(function success() {
+ return SettingsService.update(settings);
+ })
+ .then(function success() {
+ Notifications.success('Authentication settings updated');
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to update authentication settings');
+ })
+ .finally(function final() {
+ $scope.state.uploadInProgress = false;
+ $scope.state.actionInProgress = false;
+ });
+ };
+
+ function prepareLDAPSettings() {
+ const tlscaCert = $scope.formValues.TLSCACert;
+
+ const tlscaFile = tlscaCert !== $scope.settings.LDAPSettings.TLSConfig.TLSCACert ? tlscaCert : null;
+
+ const isADServer = $scope.formValues.ldap.serverType === 2;
+
+ const settings = isADServer ? $scope.formValues.ldap.adSettings : $scope.formValues.ldap.ldapSettings;
+
+ if (settings.AnonymousMode && !isADServer) {
+ settings.ReaderDN = '';
+ settings.Password = '';
}
- initView();
- },
-]);
+ if (isADServer) {
+ settings.AnonymousMode = false;
+ }
+
+ settings.URLs = settings.URLs.map((url) => {
+ if (url.includes(':')) {
+ return url;
+ }
+ return url + (settings.TLSConfig.TLS ? ':636' : ':389');
+ });
+
+ const uploadRequired = (settings.TLSConfig.TLS || settings.StartTLS) && !settings.TLSConfig.TLSSkipVerify;
+
+ settings.URL = settings.URLs[0];
+
+ return { settings, uploadRequired, tlscaFile };
+ }
+
+ $scope.isLDAPFormValid = isLDAPFormValid;
+ function isLDAPFormValid() {
+ const ldapSettings = $scope.formValues.ldap.serverType === 2 ? $scope.formValues.ldap.adSettings : $scope.formValues.ldap.ldapSettings;
+ const isTLSMode = ldapSettings.TLSConfig.TLS || ldapSettings.StartTLS;
+
+ return (
+ _.compact(ldapSettings.URLs).length &&
+ (ldapSettings.AnonymousMode || (ldapSettings.ReaderDN && ldapSettings.Password)) &&
+ (!isTLSMode || $scope.formValues.TLSCACert || ldapSettings.TLSConfig.TLSSkipVerify)
+ );
+ }
+
+ $scope.isOAuthTeamMembershipFormValid = isOAuthTeamMembershipFormValid;
+ function isOAuthTeamMembershipFormValid() {
+ if ($scope.settings && $scope.settings.OAuthSettings.OAuthAutoMapTeamMemberships) {
+ if (!$scope.settings.OAuthSettings.TeamMemberships.OAuthClaimName) {
+ return false;
+ }
+ const hasInvalidMapping = $scope.settings.OAuthSettings.TeamMemberships.OAuthClaimMappings.some((m) => !(m.ClaimValRegex && m.Team));
+ if (hasInvalidMapping) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ function initView() {
+ $q.all({
+ settings: SettingsService.settings(),
+ teams: TeamService.teams(),
+ })
+ .then(function success(data) {
+ var settings = data.settings;
+ $scope.teams = data.teams;
+ $scope.settings = settings;
+
+ $scope.OAuthSettings = settings.OAuthSettings;
+ $scope.authMethod = settings.AuthenticationMethod;
+ if (settings.AuthenticationMethod === 2 && settings.LDAPSettings.ServerType === 2) {
+ $scope.authMethod = 4;
+ }
+
+ $scope.formValues.ldap.serverType = settings.LDAPSettings.ServerType;
+ if (settings.LDAPSettings.ServerType === 2) {
+ $scope.formValues.ldap.adSettings = settings.LDAPSettings;
+ } else {
+ $scope.formValues.ldap.ldapSettings = settings.LDAPSettings;
+ }
+
+ if (settings.LDAPSettings.URL) {
+ settings.LDAPSettings.URLs = [settings.LDAPSettings.URL];
+ }
+ if (!settings.LDAPSettings.URLs) {
+ settings.LDAPSettings.URLs = [];
+ }
+ if (!settings.LDAPSettings.URLs.length) {
+ settings.LDAPSettings.URLs.push('');
+ }
+ if (!settings.LDAPSettings.ServerType) {
+ settings.LDAPSettings.ServerType = 0;
+ }
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to retrieve application settings');
+ });
+ }
+
+ initView();
+}