Compare commits

...

13 Commits

Author SHA1 Message Date
Sven Dowideit
891ebf8e13 Merge branch 'feat/EE-882/be-highlight' into feat/EE-882/EE-1672/registry-view 2021-09-28 12:48:16 +10:00
fhanportainer
24198e8ab4 feat(activity-log): added activity log (#5760)
* feat(datatable): backport datatable children components to CE.

* feat(sidebar): added auth logs to the side bar

* feat(auth-log): added auth log component

* feat(auth-log): added BE feature only to datatable title bar

* feat(activity-log): added activity log

Co-authored-by: Sven Dowideit <SvenDowideit@home.org.au>
2021-09-28 12:46:31 +10:00
Sven Dowideit
4f0c71e323 Merge branch 'feat/EE-882/be-highlight' into feat/EE-882/EE-1672/registry-view 2021-09-28 12:19:51 +10:00
Chaim Lev-Ari
b370193ca3 feat(roles): highlight BE added value (#5673)
* refactor(settings): backport auth views (#5705)

* feat(roles): highlight BE added value

* feat(roles): show default for standard user

* fix(feature-flags): replace feature id

* feat(roles): show no users when limited to be
2021-09-28 14:23:59 +13:00
Felix Han
105042e1b1 fix(registry): using <ng-transclude> 2021-09-27 03:53:22 +13:00
Felix Han
6c5a20146e feat(registry): highlight BE added value [EE-1672] 2021-09-27 03:37:52 +13:00
Chaim Lev-Ari
fc94c679c0 refactor(settings): backport auth views (#5705) 2021-09-24 14:29:17 +03:00
Chaim Lev-Ari
70e3614345 feat(k8s): highlight BE added value [EE-1673] (#5674) 2021-09-24 14:19:36 +03:00
Chaim Lev-Ari
49d6bb4786 feat(featureflags): expose is feature limited to BE 2021-09-24 14:18:54 +03:00
Chaim Lev-Ari
fa87f808f0 feat(app): introduce feature flags framework (#5622) 2021-09-24 14:18:54 +03:00
Felix Han
8c4acb7f04 feat(registry): highlight BE added value [EE-1672] 2021-09-22 10:34:46 +12:00
Chaim Lev-Ari
665b4794a3 feat(featureflags): expose is feature limited to BE 2021-09-19 11:15:48 +03:00
Chaim Lev-Ari
443e1ecd23 feat(app): introduce feature flags framework (#5622) 2021-09-19 11:15:47 +03:00
143 changed files with 4084 additions and 872 deletions

View File

@@ -71,6 +71,15 @@ body,
font-size: 0.9em;
}
.form-control.limited-be {
border-color: var(--BE-only);
}
.btn.limited-be {
background-color: var(--BE-only);
border-color: var(--BE-only);
}
input[type='checkbox'] {
margin-top: 1px;
vertical-align: middle;

View File

@@ -88,8 +88,13 @@ html {
--green-1: #164;
--green-2: #1ec863;
--orange-1: #e86925;
--BE-only: var(--orange-1);
}
/* Default Theme */
:root {
--bg-card-color: var(--grey-10);
--bg-main-color: var(--white-color);

View File

@@ -178,14 +178,14 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Allow resource over-commit
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" checked disabled /><i data-cy="kubeSetup-resourceOverCommitToggle"></i> </label>
<span class="text-muted small" style="margin-left: 15px;">
<i class="fa fa-user" aria-hidden="true"></i>
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-setup-overcommit" target="_blank"> Portainer Business Edition</a>.
</span>
<por-switch-field
label="Allow resource over-commit"
name="resource-over-commit-switch"
feature="'k8s-setup-default'"
ng-model="ctrl.formValues.EnableResourceOverCommit"
ng-change="ctrl.onChangeEnableResourceOverCommit()"
ng-data-cy="kubeSetup-resourceOverCommitToggle"
></por-switch-field>
</div>
</div>

View File

@@ -162,14 +162,13 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Load Balancer quota
</label>
<label class="switch" style="margin-left: 20px;" data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"> <input type="checkbox" disabled /><i></i> </label>
<span class="text-muted small" style="margin-left: 15px;">
<i class="fa fa-user" aria-hidden="true"></i>
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-lbquota" target="_blank"> Portainer Business Edition</a>.
</span>
<por-switch-field
ng-data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"
label="Load Balancer quota"
name="k8s-resourcepool-Ibquota"
feature="'k8s-resourcepool-Ibquota'"
ng-model="lbquota"
></por-switch-field>
</div>
</div>
<!-- #endregion -->
@@ -192,15 +191,13 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Enable quota
</label>
<label class="switch" style="margin-left: 20px;" data-cy="k8sNamespaceCreate-enableQuotaToggle"> <input type="checkbox" disabled /><i></i> </label>
<span class="text-muted small" style="margin-left: 15px;">
<i class="fa fa-user" aria-hidden="true"></i>
This feature is available in
<a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-storagequota" target="_blank"> Portainer Business Edition</a>.
</span>
<por-switch-field
ng-data-cy="k8sNamespaceCreate-enableQuotaToggle"
label="Enable quota"
name="k8s-resourcepool-storagequota"
feature="'k8s-resourcepool-storagequota'"
ng-model="storagequota"
></por-switch-field>
</div>
</div>
<!-- #endregion -->

View File

@@ -1,7 +1,10 @@
import _ from 'lodash-es';
import './rbac';
import componentsModule from './components';
import settingsModule from './settings';
import featureFlagModule from './feature-flags';
import userActivityModule from './user-activity';
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh();
@@ -18,7 +21,7 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat
return await Authentication.init();
}
angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsModule]).config([
angular.module('portainer.app', ['portainer.oauth', 'portainer.rbac', componentsModule, settingsModule, featureFlagModule, userActivityModule, 'portainer.shared.datatable']).config([
'$stateRegistryProvider',
function ($stateRegistryProvider) {
'use strict';
@@ -51,6 +54,18 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo
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 = {
@@ -403,16 +418,6 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo
},
};
var roles = {
name: 'portainer.roles',
url: '/roles',
views: {
'content@': {
templateUrl: './views/roles/roles.html',
},
},
};
$stateRegistryProvider.register(root);
$stateRegistryProvider.register(endpointRoot);
$stateRegistryProvider.register(portainer);
@@ -444,7 +449,6 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo
$stateRegistryProvider.register(user);
$stateRegistryProvider.register(teams);
$stateRegistryProvider.register(team);
$stateRegistryProvider.register(roles);
},
]);

View File

@@ -0,0 +1,18 @@
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);
}
}
}

View File

@@ -0,0 +1,23 @@
.be-indicator {
border: solid 1px var(--BE-only);
border-radius: 15px;
padding: 5px 10px;
font-weight: 400;
}
.be-indicator .icon {
color: #000000;
}
.be-indicator:hover {
text-decoration: none;
}
.be-indicator:hover .be-indicator-label {
text-decoration: underline;
}
.be-indicator-container {
border: solid 1px var(--BE-only);
margin: 15px;
}

View File

@@ -0,0 +1,5 @@
<a class="be-indicator" href="{{ $ctrl.url }}" target="_blank" rel="noopener" ng-if="$ctrl.limitedToBE">
<ng-transclude></ng-transclude>
<i class="fas fa-briefcase space-right"></i>
<span class="be-indicator-label">Business Edition Feature</span>
</a>

View File

@@ -0,0 +1,15 @@
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);

View File

@@ -0,0 +1,14 @@
export default class BoxSelectorItemController {
/* @ngInject */
constructor(featureService) {
Object.assign(this, { featureService });
this.limitedToBE = false;
}
$onInit() {
if (this.option.feature) {
this.limitedToBE = this.featureService.isLimitedToBE(this.feature);
}
}
}

View File

@@ -0,0 +1,111 @@
.boxselector_wrapper > div,
.boxselector_wrapper box-selector-item {
--selected-item-color: var(--blue-2);
flex: 1;
padding: 0.5rem;
}
.boxselector_wrapper .boxselector_header {
font-size: 14px;
margin-bottom: 5px;
font-weight: bold;
user-select: none;
}
.boxselector_header .fa,
.fab {
font-weight: normal;
}
.boxselector_wrapper input[type='radio'] {
display: none;
}
.boxselector_wrapper input[type='radio']:not(:disabled) ~ label {
cursor: pointer;
background-color: var(--bg-boxselector-wrapper-disabled-color);
}
.boxselector_wrapper input[type='radio']:not(:disabled):hover ~ label:hover {
cursor: pointer;
}
.boxselector_wrapper label {
font-weight: normal;
font-size: 12px;
display: block;
background: var(--bg-boxselector-color);
border: 1px solid var(--border-boxselector-color);
border-radius: 2px;
padding: 10px 10px 0 10px;
text-align: center;
box-shadow: var(--shadow-boxselector-color);
position: relative;
}
.box-selector-item input:disabled + label,
.boxselector_wrapper label.boxselector_disabled {
background: var(--bg-boxselector-disabled-color) !important;
border-color: #787878;
color: #787878;
cursor: not-allowed;
pointer-events: none;
}
.boxselector_wrapper input[type='radio']:checked + label {
background: var(--selected-item-color);
color: white;
padding-top: 2rem;
border-color: var(--selected-item-color);
}
.boxselector_wrapper input[type='radio']:checked + label::after {
color: var(--selected-item-color);
font-family: 'Font Awesome 5 Free';
border: 2px solid var(--selected-item-color);
content: '\f00c';
font-size: 16px;
font-weight: bold;
position: absolute;
top: -15px;
left: 50%;
transform: translateX(-50%);
height: 30px;
width: 30px;
line-height: 26px;
text-align: center;
border-radius: 50%;
background: white;
box-shadow: 0 2px 5px -2px rgba(0, 0, 0, 0.25);
}
@media only screen and (max-width: 700px) {
.boxselector_wrapper {
flex-direction: column;
}
}
.box-selector-item-description {
height: 1em;
}
.box-selector-item.limited.business {
--selected-item-color: var(--BE-only);
}
.box-selector-item.limited.business label {
border-color: var(--BE-only);
border-width: 2px;
}
.box-selector-item .limited-icon {
position: absolute;
left: 3em;
top: calc(50% - 0.5em);
height: 1em;
}
.box-selector-item.limited.business :checked + label {
background-color: initial;
color: initial;
}

View File

@@ -1,5 +1,6 @@
<div
class="box-selector-item"
ng-class="{ business: $ctrl.limitedToBE, limited: $ctrl.limitedToBE }"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
@@ -14,11 +15,13 @@
ng-value="$ctrl.option.value"
ng-disabled="$ctrl.disabled"
/>
<label for="{{ $ctrl.option.id }}" ng-click="$ctrl.onChange($ctrl.option.value)" t>
<label for="{{ $ctrl.option.id }}" ng-click="$ctrl.onChange($ctrl.option.value, $ctrl.limitedToBE)">
<i class="fas fa-briefcase limited-icon" ng-if="$ctrl.limitedToBE"></i>
<div class="boxselector_header">
<i ng-class="$ctrl.option.icon" aria-hidden="true" style="margin-right: 2px;"></i>
{{ $ctrl.option.label }}
</div>
<p ng-if="$ctrl.option.description">{{ $ctrl.option.description }}</p>
<p class="box-selector-item-description">{{ $ctrl.option.description }}</p>
</label>
</div>

View File

@@ -1,7 +1,12 @@
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,
bindings: {
radioName: '@',
isChecked: '<',

View File

@@ -4,10 +4,10 @@ export default class BoxSelectorController {
this.change = this.change.bind(this);
}
change(value) {
change(value, limited) {
this.ngModel = value;
if (this.onChange) {
this.onChange(value);
this.onChange(value, limited);
}
}

View File

@@ -3,89 +3,3 @@
flex-flow: row wrap;
margin: 0.5rem;
}
.boxselector_wrapper > div,
.boxselector_wrapper box-selector-item {
flex: 1;
padding: 0.5rem;
}
.boxselector_wrapper .boxselector_header {
font-size: 14px;
margin-bottom: 5px;
font-weight: bold;
user-select: none;
}
.boxselector_header .fa,
.fab {
font-weight: normal;
}
.boxselector_wrapper input[type='radio'] {
display: none;
}
.boxselector_wrapper input[type='radio']:not(:disabled) ~ label {
cursor: pointer;
background-color: var(--bg-boxselector-wrapper-disabled-color);
}
.boxselector_wrapper input[type='radio']:not(:disabled):hover ~ label:hover {
cursor: pointer;
}
.boxselector_wrapper label {
font-weight: normal;
font-size: 12px;
display: block;
background: var(--bg-boxselector-color);
border: 1px solid var(--border-boxselector-color);
border-radius: 2px;
padding: 10px 10px 0 10px;
text-align: center;
box-shadow: var(--shadow-boxselector-color);
position: relative;
}
.box-selector-item input:disabled + label,
.boxselector_wrapper label.boxselector_disabled {
background: var(--bg-boxselector-disabled-color) !important;
border-color: #787878;
color: #787878;
cursor: not-allowed;
pointer-events: none;
}
.boxselector_wrapper input[type='radio']:checked + label {
background: #337ab7;
color: white;
padding-top: 2rem;
border-color: #337ab7;
}
.boxselector_wrapper input[type='radio']:checked + label::after {
color: #337ab7;
font-family: 'Font Awesome 5 Free';
border: 2px solid #337ab7;
content: '\f00c';
font-size: 16px;
font-weight: bold;
position: absolute;
top: -15px;
left: 50%;
transform: translateX(-50%);
height: 30px;
width: 30px;
line-height: 26px;
text-align: center;
border-radius: 50%;
background: white;
box-shadow: 0 2px 5px -2px rgba(0, 0, 0, 0.25);
}
@media only screen and (max-width: 700px) {
.boxselector_wrapper {
flex-direction: column;
}
}

View File

@@ -15,6 +15,6 @@ angular.module('portainer.app').component('boxSelector', {
},
});
export function buildOption(id, icon, label, description, value) {
return { id, icon, label, description, value };
export function buildOption(id, icon, label, description, value, feature) {
return { id, icon, label, description, value, feature };
}

View File

@@ -1,26 +0,0 @@
<div class="datatable">
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" placeholder="Search..." ng-model-options="{ debounce: 300 }" />
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
Environment
</th>
<th>
Role
</th>
<th>Access origin</th>
</tr>
</thead>
<tbody>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Select a user to show associated access and role</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -92,6 +92,10 @@
float: none;
}
.datatable.datatable-empty .table > tbody > tr > td {
padding: 15px 0;
}
.tableMenu {
color: #767676;
padding: 10px;

View File

@@ -0,0 +1,19 @@
export default class DatatableFilterController {
isEnabled() {
return 0 < this.state.length && this.state.length < this.labels.length;
}
onChangeItem(filterValue) {
if (this.isChecked(filterValue)) {
return this.onChange(
this.filterKey,
this.state.filter((v) => v !== filterValue)
);
}
return this.onChange(this.filterKey, [...this.state, filterValue]);
}
isChecked(filterValue) {
return this.state.includes(filterValue);
}
}

View File

@@ -0,0 +1,32 @@
<div uib-dropdown dropdown-append-to-body auto-close="outsideClick" is-open="$ctrl.isOpen">
<span ng-transclude></span>
<div class="filter-button">
<span uib-dropdown-toggle class="table-filter" ng-class="{ 'filter-active': $ctrl.isEnabled() }">
Filter
<i class="fa" ng-class="[$ctrl.isEnabled() ? 'fa-check' : 'fa-filter']" aria-hidden="true"></i>
</span>
</div>
<div class="dropdown-menu" style="min-width: 0;" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuContent">
<div class="md-checkbox" ng-repeat="filter in $ctrl.labels track by filter.value">
<input
id="filter_{{ $ctrl.filterKey }}_{{ $index }}"
type="checkbox"
ng-value="filter.value"
ng-checked="$ctrl.state.includes(filter.value)"
ng-click="$ctrl.onChangeItem(filter.value)"
/>
<label for="filter_{{ $ctrl.filterKey }}_{{ $index }}">
{{ filter.label }}
</label>
</div>
</div>
<div>
<a class="btn btn-default btn-sm" ng-click="$ctrl.isOpen = false;">
Close
</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,13 @@
import controller from './datatable-filter.controller';
export const datatableFilter = {
bindings: {
labels: '<', // [{label, value}]
state: '<', // [filterValue]
filterKey: '@',
onChange: '<',
},
controller,
templateUrl: './datatable-filter.html',
transclude: true,
};

View File

@@ -128,7 +128,11 @@ angular.module('portainer.app').controller('GenericDatatableController', [
* https://github.com/portainer/portainer/pull/2877#issuecomment-503333425
* https://github.com/portainer/portainer/pull/2877#issuecomment-503537249
*/
this.$onInit = function () {
this.$onInit = function $onInit() {
this.$onInitGeneric();
};
this.$onInitGeneric = function $onInitGeneric() {
this.setDefaults();
this.prepareTableFromDataset();

View File

@@ -0,0 +1,16 @@
import angular from 'angular';
import 'angular-utils-pagination';
import { datatableTitlebar } from './titlebar';
import { datatableSearchbar } from './searchbar';
import { datatableSortIcon } from './sort-icon';
import { datatablePagination } from './pagination';
import { datatableFilter } from './filter';
export default angular
.module('portainer.shared.datatable', ['angularUtils.directives.dirPagination'])
.component('datatableTitlebar', datatableTitlebar)
.component('datatableSearchbar', datatableSearchbar)
.component('datatableSortIcon', datatableSortIcon)
.component('datatablePagination', datatablePagination)
.component('datatableFilter', datatableFilter).name;

View File

@@ -0,0 +1,9 @@
export const datatablePagination = {
bindings: {
onChangeLimit: '<',
limit: '<',
enableNoLimit: '<',
onChangePage: '<',
},
templateUrl: './pagination.html',
};

View File

@@ -0,0 +1,15 @@
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;"> Items per page </span>
<select class="form-control" ng-model="$ctrl.limit" ng-change="$ctrl.onChangeLimit($ctrl.limit)">
<option ng-if="$ctrl.enableNoLimit" ng-value="0">All</option>
<option ng-value="10">10</option>
<option ng-value="25">25</option>
<option ng-value="50">50</option>
<option ng-value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5" on-page-change="$ctrl.onChangePage(newPageNumber)"> </dir-pagination-controls>
</form>
</div>

View File

@@ -87,15 +87,10 @@
</td>
<td>
<a ng-if="$ctrl.canManageAccess(item)" ng-click="$ctrl.redirectToManageAccess(item)"> <i class="fa fa-users" aria-hidden="true"></i> Manage access </a>
<span
ng-if="$ctrl.canBrowse(item)"
class="text-muted space-left"
style="cursor: pointer;"
data-toggle="tooltip"
title="This feature is available in Portainer Business Edition"
>
<i class="fa fa-search" aria-hidden="true"></i> Browse</span
>
<be-feature-indicator feature="'registry-management'" ng-if="$ctrl.canBrowse(item)">
<span class="text-muted space-left" style="padding-right: 5px;"> <i class="fa fa-search" aria-hidden="true"></i> Browse</span>
</be-feature-indicator>
<span ng-if="!$ctrl.canBrowse(item) && !$ctrl.canManageAccess(item)"> - </span>
</td>
</tr>

View File

@@ -1,64 +0,0 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }" />
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
Name
</th>
<th>
Description
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-muted">Environment administrator</td>
<td class="text-muted">Full control of all resources in an environment</td>
</tr>
<tr>
<td class="text-muted">Helpdesk</td>
<td class="text-muted">Read-only access of all resources in an environment</td>
</tr>
<tr>
<td class="text-muted">Read-only user</td>
<td class="text-muted">Read-only access of assigned resources in an environment</td>
</tr>
<tr>
<td class="text-muted">Standard user</td>
<td class="text-muted">Full control of assigned resources in an environment</td>
</tr>
</tbody>
</table>
<div class="footer">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -0,0 +1,4 @@
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.filter" ng-change="$ctrl.onChange($ctrl.filter)" placeholder="Search..." ng-model-options="{ debounce: 300 }" />
</div>

View File

@@ -0,0 +1,7 @@
export const datatableSearchbar = {
bindings: {
onChange: '<',
ngModel: '<',
},
templateUrl: './datatable-searchbar.html',
};

View File

@@ -0,0 +1,5 @@
export default class datatableSortIconController {
isCurrentSortOrder() {
return this.selectedSortKey === this.key;
}
}

View File

@@ -0,0 +1,9 @@
<i
class="fa fa-sort-alpha-down"
ng-class="{
'fa-sort-alpha-down': !$ctrl.reverseOrder,
'fa-sort-alpha-up': $ctrl.reverseOrder,
}"
aria-hidden="true"
ng-if="$ctrl.isCurrentSortOrder()"
></i>

View File

@@ -0,0 +1,11 @@
import controller from './datatable-sort-icon.controller';
export const datatableSortIcon = {
bindings: {
key: '@',
selectedSortKey: '@',
reverseOrder: '<',
},
controller,
templateUrl: './datatable-sort-icon.html',
};

View File

@@ -0,0 +1,7 @@
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.icon" aria-hidden="true" style="margin-right: 2px;"></i>
<span style="margin-right: 10px;">{{ $ctrl.title }}</span>
<be-feature-indicator feature="'activity-audit'"></be-feature-indicator>
</div>
</div>

View File

@@ -0,0 +1,7 @@
export const datatableTitlebar = {
bindings: {
icon: '@',
title: '@',
},
templateUrl: './datatable-titlebar.html',
};

View File

@@ -10,5 +10,7 @@
ng-model="$ctrl.ngModel"
disabled="$ctrl.disabled"
on-change="($ctrl.onChange)"
feature="$ctrl.feature"
ng-data-cy="{{::$ctrl.ngDataCy}}"
></por-switch>
</label>

View File

@@ -1,7 +1,5 @@
import angular from 'angular';
import './por-switch-field.css';
export const porSwitchField = {
templateUrl: './por-switch-field.html',
bindings: {
@@ -10,8 +8,10 @@ export const porSwitchField = {
label: '@',
name: '@',
labelClass: '@',
ngDataCy: '@',
disabled: '<',
onChange: '<',
feature: '<', // feature id
},
};

View File

@@ -0,0 +1,14 @@
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);
}
}
}

View File

@@ -51,3 +51,19 @@
opacity: 0.5;
cursor: not-allowed;
}
.switch.limited {
pointer-events: none;
touch-action: none;
}
.switch.limited i {
opacity: 1;
cursor: not-allowed;
padding-right: 0;
padding-left: var(--switch-size);
}
.switch.business i {
background-color: var(--BE-only);
}

View File

@@ -1,4 +1,12 @@
<label class="switch" ng-class="$ctrl.className" style="margin-bottom: 0;">
<input type="checkbox" name="{{::$ctrl.name}}" id="{{::$ctrl.id}}" ng-model="$ctrl.ngModel" ng-disabled="$ctrl.disabled" ng-change="$ctrl.onChange($ctrl.ngModel)" />
<i></i>
<label class="switch" ng-class="[$ctrl.className, { business: $ctrl.limitedToBE, limited: $ctrl.limitedToBE }]" style="margin-bottom: 0;">
<input
type="checkbox"
name="{{::$ctrl.name}}"
id="{{::$ctrl.id}}"
ng-model="$ctrl.ngModel"
ng-disabled="$ctrl.disabled || $ctrl.limitedToBE"
ng-change="$ctrl.onChange($ctrl.ngModel)"
/>
<i data-cy="{{::$ctrl.ngDataCy}}"></i>
</label>
<be-feature-indicator ng-if="$ctrl.limitedToBE" feature="$ctrl.feature"></be-feature-indicator>

View File

@@ -1,14 +1,20 @@
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
},
};

View File

@@ -6,9 +6,20 @@ angular.module('portainer.app').directive('rdWidgetHeader', function rdWidgetTit
icon: '@',
classes: '@?',
},
transclude: true,
template:
'<div class="widget-header"><div class="row"><span ng-class="classes" class="pull-left"><i class="fa" ng-class="icon"></i> {{titleText}} </span><span ng-class="classes" class="pull-right" ng-transclude></span></div></div>',
transclude: {
title: '?headerTitle',
},
template: `
<div class="widget-header">
<div class="row">
<span ng-class="classes" class="pull-left">
<i class="fa" ng-class="icon"></i>
<span ng-transclude="title">{{ titleText }}</span>
</span>
<span ng-class="classes" class="pull-right" ng-transclude></span>
</div>
</div>
`,
restrict: 'E',
};
return directive;

View File

@@ -0,0 +1,10 @@
export const EDITIONS = {
CE: 0,
BE: 1,
};
export const STATES = {
HIDDEN: 0,
VISIBLE: 1,
LIMITED_BE: 2,
};

View File

@@ -0,0 +1,51 @@
import { EDITIONS, STATES } from './enums';
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 = {
'k8s-resourcepool-Ibquota': EDITIONS.BE,
'k8s-resourcepool-storagequota': EDITIONS.BE,
's3-backup-setting': EDITIONS.BE,
'k8s-setup-default': EDITIONS.BE,
'registry-management': EDITIONS.BE,
'activity-audit': EDITIONS.BE,
'rbac-roles': 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;
}
}

View File

@@ -0,0 +1,6 @@
import angular from 'angular';
import { limitedFeatureDirective } from './limited-feature.directive';
import { featureService } from './feature-flags.service';
export default angular.module('portainer.feature-flags', []).directive('limitedFeature', limitedFeatureDirective).factory('featureService', featureService).name;

View File

@@ -0,0 +1,30 @@
import { STATES } from '@/portainer/feature-flags/enums';
/* @ngInject */
export function limitedFeatureDirective(featureService) {
return {
restrict: 'A',
link,
};
function link(scope, elem, attrs) {
const { limitedFeatureClass, limitedFeature: featureId } = attrs;
if (!featureId) {
throw new Error('feature is required');
}
const state = featureService.selectShow(featureId);
if (state === STATES.HIDDEN) {
elem.hide();
return;
}
if (state === STATES.VISIBLE) {
return;
}
elem.addClass(limitedFeatureClass);
}
}

View File

@@ -0,0 +1,73 @@
<div class="datatable">
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" ng-change="$ctrl.onTextFilterChange()" placeholder="Search..." ng-model-options="{ debounce: 300 }" />
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('EndpointName')">
Environment
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'EndpointName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'EndpointName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('RoleName')">
Role
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'RoleName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'RoleName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Access origin</th>
</tr>
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit)) track by $index"
>
<td>{{ item.EndpointName }}</td>
<td>{{ item.RoleName }}</td>
<td
>{{ item.TeamName ? 'Team' : 'User' }} <code ng-if="item.TeamName">{{ item.TeamName }}</code> access defined on {{ item.AccessLocation }}
<code ng-if="item.GroupName">{{ item.GroupName }}</code>
<a ng-if="item.AccessLocation === 'endpoint'" ui-sref="portainer.endpoints.endpoint.access({id: item.EndpointId})"
><i style="margin-left: 5px;" class="fa fa-users" aria-hidden="true"></i> Manage access
</a>
<a ng-if="item.AccessLocation === 'endpoint group'" ui-sref="portainer.groups.group.access({id: item.GroupId})"
><i style="margin-left: 5px;" class="fa fa-users" aria-hidden="true"></i> Manage access
</a>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Select a user to show associated access and role</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">The selected user does not have access to any environment(s)</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
angular.module('portainer.app').component('accessViewerDatatable', {
templateUrl: './accessViewerDatatable.html',
export const accessViewerDatatable = {
templateUrl: './access-viewer-datatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
@@ -8,4 +8,4 @@ angular.module('portainer.app').component('accessViewerDatatable', {
orderBy: '@',
dataset: '<',
},
});
};

View File

@@ -0,0 +1,128 @@
import _ from 'lodash-es';
import AccessViewerPolicyModel from '../../models/access';
export default class AccessViewerController {
/* @ngInject */
constructor(featureService, Notifications, RoleService, UserService, EndpointService, GroupService, TeamService, TeamMembershipService) {
this.featureService = featureService;
this.Notifications = Notifications;
this.RoleService = RoleService;
this.UserService = UserService;
this.EndpointService = EndpointService;
this.GroupService = GroupService;
this.TeamService = TeamService;
this.TeamMembershipService = TeamMembershipService;
this.limitedFeature = 'rbac-roles';
this.users = [];
}
onUserSelect() {
this.userRoles = [];
const userRoles = {};
const user = this.selectedUser;
const userMemberships = _.filter(this.teamMemberships, { UserId: user.Id });
for (const [, endpoint] of _.entries(this.endpoints)) {
let role = this.getRoleFromUserEndpointPolicy(user, endpoint);
if (role) {
userRoles[endpoint.Id] = role;
continue;
}
role = this.getRoleFromUserEndpointGroupPolicy(user, endpoint);
if (role) {
userRoles[endpoint.Id] = role;
continue;
}
role = this.getRoleFromTeamEndpointPolicies(userMemberships, endpoint);
if (role) {
userRoles[endpoint.Id] = role;
continue;
}
role = this.getRoleFromTeamEndpointGroupPolicies(userMemberships, endpoint);
if (role) {
userRoles[endpoint.Id] = role;
}
}
this.userRoles = _.values(userRoles);
}
findLowestRole(policies) {
return _.first(_.orderBy(policies, 'RolePriority', 'desc'));
}
getRoleFromUserEndpointPolicy(user, endpoint) {
const policyRoles = [];
const policy = (endpoint.UserAccessPolicies || {})[user.Id];
if (policy) {
const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, null, null);
policyRoles.push(accessPolicy);
}
return this.findLowestRole(policyRoles);
}
getRoleFromUserEndpointGroupPolicy(user, endpoint) {
const policyRoles = [];
const policy = this.groupUserAccessPolicies[endpoint.GroupId][user.Id];
if (policy) {
const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, this.groups[endpoint.GroupId], null);
policyRoles.push(accessPolicy);
}
return this.findLowestRole(policyRoles);
}
getRoleFromTeamEndpointPolicies(memberships, endpoint) {
const policyRoles = [];
for (const membership of memberships) {
const policy = (endpoint.TeamAccessPolicies || {})[membership.TeamId];
if (policy) {
const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, null, this.teams[membership.TeamId]);
policyRoles.push(accessPolicy);
}
}
return this.findLowestRole(policyRoles);
}
getRoleFromTeamEndpointGroupPolicies(memberships, endpoint) {
const policyRoles = [];
for (const membership of memberships) {
const policy = this.groupTeamAccessPolicies[endpoint.GroupId][membership.TeamId];
if (policy) {
const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, this.groups[endpoint.GroupId], this.teams[membership.TeamId]);
policyRoles.push(accessPolicy);
}
}
return this.findLowestRole(policyRoles);
}
async $onInit() {
try {
const limitedToBE = this.featureService.isLimitedToBE(this.limitedFeature);
if (limitedToBE) {
return;
}
this.users = await this.UserService.users();
this.endpoints = _.keyBy((await this.EndpointService.endpoints()).value, 'Id');
const groups = await this.GroupService.groups();
this.groupUserAccessPolicies = {};
this.groupTeamAccessPolicies = {};
_.forEach(groups, (group) => {
this.groupUserAccessPolicies[group.Id] = group.UserAccessPolicies;
this.groupTeamAccessPolicies[group.Id] = group.TeamAccessPolicies;
});
this.groups = _.keyBy(groups, 'Id');
this.roles = _.keyBy(await this.RoleService.roles(), 'Id');
this.teams = _.keyBy(await this.TeamService.teams(), 'Id');
this.teamMemberships = await this.TeamMembershipService.memberships();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve accesses');
}
}
}

View File

@@ -0,0 +1,43 @@
<div class="col-sm-12" style="margin-bottom: 0px;">
<rd-widget>
<rd-widget-header icon="fa-user-lock">
<header-title>
Effective access viewer
<be-feature-indicator feature="$ctrl.limitedFeature" class="space-left"></be-feature-indicator>
</header-title>
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="col-sm-12 form-section-title">
User
</div>
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted" ng-if="$ctrl.users.length === 0">
No user available
</span>
<ui-select ng-if="$ctrl.users.length > 0" ng-model="$ctrl.selectedUser" ng-change="$ctrl.onUserSelect()">
<ui-select-match placeholder="Select a user">
<span>{{ $select.selected.Username }}</span>
</ui-select-match>
<ui-select-choices repeat="item in ($ctrl.users | filter: $select.search)">
<span>{{ item.Username }}</span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-sm-12 form-section-title">
Access
</div>
<div>
<div class="small text-muted" style="margin-bottom: 15px;">
<i class="fa fa-info-circle blue-icon space-right" aria-hidden="true"></i>
Effective role for each environment will be displayed for the selected user
</div>
</div>
<access-viewer-datatable table-key="access_viewer" dataset="$ctrl.userRoles" order-by="EndpointName"> </access-viewer-datatable>
</form>
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -0,0 +1,6 @@
import controller from './access-viewer.controller';
export const accessViewer = {
templateUrl: './access-viewer.html',
controller,
};

View File

@@ -0,0 +1,15 @@
import controller from './roles-datatable.controller';
import './roles-datatable.css';
export const rolesDatatable = {
templateUrl: './roles-datatable.html',
controller,
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
},
};

View File

@@ -0,0 +1,15 @@
import angular from 'angular';
import { RoleTypes } from '../../models/role';
export default class RolesDatatableController {
/* @ngInject */
constructor($controller, $scope) {
this.limitedFeature = 'rbac-roles';
angular.extend(this, $controller('GenericDatatableController', { $scope }));
}
isDefaultItem(item) {
return item.ID === RoleTypes.STANDARD;
}
}

View File

@@ -0,0 +1,7 @@
th.be-visual-indicator-col {
width: 300px;
}
td.be-visual-indicator-col {
text-align: center;
}

View File

@@ -0,0 +1,83 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Description')">
Description
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Description' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Description' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th class="be-visual-indicator-col"></th>
</tr>
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{ active: item.Checked }"
>
<td>{{ item.Name }}</td>
<td>{{ item.Description }}</td>
<td class="be-visual-indicator-col" ng-switch on="$ctrl.isDefaultItem(item)">
<be-feature-indicator ng-switch-when="false" feature="$ctrl.limitedFeature"></be-feature-indicator>
<b ng-switch-when="true">Default</b>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">No role available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -0,0 +1,33 @@
import { rolesView } from './views/roles';
import { accessViewer } from './components/access-viewer';
import { accessViewerDatatable } from './components/access-viewer/access-viewer-datatable';
import { rolesDatatable } from './components/roles-datatable';
import { RoleService } from './services/role.service';
import { RolesFactory } from './services/role.rest';
angular
.module('portainer.rbac', ['ngResource'])
.constant('API_ENDPOINT_ROLES', 'api/roles')
.component('accessViewer', accessViewer)
.component('accessViewerDatatable', accessViewerDatatable)
.component('rolesDatatable', rolesDatatable)
.component('rolesView', rolesView)
.factory('RoleService', RoleService)
.factory('Roles', RolesFactory)
.config(config);
/* @ngInject */
function config($stateRegistryProvider) {
const roles = {
name: 'portainer.roles',
url: '/roles',
views: {
'content@': {
component: 'rolesView',
},
},
};
$stateRegistryProvider.register(roles);
}

View File

@@ -0,0 +1,16 @@
export default function AccessViewerPolicyModel(policy, endpoint, roles, group, team) {
this.EndpointId = endpoint.Id;
this.EndpointName = endpoint.Name;
this.RoleId = policy.RoleId;
this.RoleName = roles[policy.RoleId].Name;
this.RolePriority = roles[policy.RoleId].Priority;
if (group) {
this.GroupId = group.Id;
this.GroupName = group.Name;
}
if (team) {
this.TeamId = team.Id;
this.TeamName = team.Name;
}
this.AccessLocation = group ? 'environment group' : 'environment';
}

View File

@@ -0,0 +1,14 @@
export function RoleViewModel(id, name, description, authorizations) {
this.ID = id;
this.Name = name;
this.Description = description;
this.Authorizations = authorizations;
}
export const RoleTypes = Object.freeze({
ENDPOINT_ADMIN: 1,
HELPDESK: 2,
STANDARD: 3,
READ_ONLY: 4,
OPERATOR: 5,
});

View File

@@ -0,0 +1,14 @@
/* @ngInject */
export function RolesFactory($resource, API_ENDPOINT_ROLES) {
return $resource(
API_ENDPOINT_ROLES + '/:id',
{},
{
create: { method: 'POST', ignoreLoadingBar: true },
query: { method: 'GET', isArray: true },
get: { method: 'GET', params: { id: '@id' } },
update: { method: 'PUT', params: { id: '@id' } },
remove: { method: 'DELETE', params: { id: '@id' } },
}
);
}

View File

@@ -0,0 +1,19 @@
import { RoleViewModel, RoleTypes } from '../models/role';
export function RoleService() {
const rolesData = [
new RoleViewModel(RoleTypes.ENDPOINT_ADMIN, 'Environment administrator', 'Full control of all resources in an environment', []),
new RoleViewModel(RoleTypes.OPERATOR, 'Operator', 'Operational Control of all existing resources in an environment', []),
new RoleViewModel(RoleTypes.HELPDESK, 'Helpdesk', 'Read-only access of all resources in an environment', []),
new RoleViewModel(RoleTypes.READ_ONLY, 'Read-only user', 'Read-only access of assigned resources in an environment', []),
new RoleViewModel(RoleTypes.STANDARD, 'Standard user', 'Full control of assigned resources in an environment', []),
];
return {
roles,
};
function roles() {
return rolesData;
}
}

View File

@@ -0,0 +1,6 @@
import controller from './roles.controller';
export const rolesView = {
templateUrl: './roles.html',
controller,
};

View File

@@ -0,0 +1,20 @@
import _ from 'lodash-es';
export default class RolesController {
/* @ngInject */
constructor(Notifications, RoleService) {
this.Notifications = Notifications;
this.RoleService = RoleService;
}
async $onInit() {
this.roles = [];
try {
this.roles = await this.RoleService.roles();
this.roles = _.orderBy(this.roles, 'Priority', 'asc');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve roles');
}
}
}

View File

@@ -0,0 +1,18 @@
<rd-header>
<rd-header-title title-text="Roles">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.roles" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Role management</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<roles-datatable title-text="Roles" title-icon="fa-file-code" dataset="$ctrl.roles" table-key="roles"></roles-datatable>
</div>
</div>
<div class="row">
<access-viewer> </access-viewer>
</div>

View File

@@ -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',
};

View File

@@ -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',
};

View File

@@ -0,0 +1,14 @@
<div class="col-sm-12 form-section-title">
Team membership
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small" ng-transclude="description"> </span>
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Automatic team membership
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.ngModel" /><i></i> </label>
</div>
</div>

View File

@@ -0,0 +1,9 @@
export const autoTeamMembershipToggle = {
templateUrl: './auto-team-membership-toggle.html',
transclude: {
description: 'fieldDescription',
},
bindings: {
ngModel: '=',
},
};

View File

@@ -0,0 +1,14 @@
<div class="col-sm-12 form-section-title">
Automatic user provisioning
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small" ng-transclude="description"> </span>
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Automatic user provisioning
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.ngModel" /><i></i> </label>
</div>
</div>

View File

@@ -0,0 +1,9 @@
export const autoUserProvisionToggle = {
templateUrl: './auto-user-provision-toggle.html',
transclude: {
description: 'fieldDescription',
},
bindings: {
ngModel: '=',
},
};

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -0,0 +1,115 @@
<auto-user-provision-toggle ng-model="$ctrl.settings.AutoCreateUsers">
<field-description>
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.
</field-description>
</auto-user-provision-toggle>
<div>
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group col-sm-12 text-muted small">
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.
</div>
</div>
<div class="col-sm-12 form-section-title">
AD configuration
</div>
<div class="form-group">
<div class="col-sm-12 small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
You can configure multiple AD Controllers for authentication fallback. Make sure all servers are using the same configuration (i.e. if TLS is enabled, they should all use the
same certificates).
</p>
</div>
</div>
<div class="form-group">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left" style="display: flex; flex-wrap: wrap;">
AD Controller
<button type="button" class="label label-default interactive" style="border: 0;" ng-click="$ctrl.addLDAPUrl()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> Add additional server
</button>
</label>
<div class="col-sm-9 col-lg-10">
<div ng-repeat="url in $ctrl.settings.URLs track by $index" style="display: flex; margin-bottom: 10px;">
<input type="text" class="form-control" id="ldap_url" ng-model="$ctrl.settings.URLs[$index]" placeholder="e.g. 10.0.0.10:389 or myldap.domain.tld:389" />
<button ng-if="$index > 0" class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeLDAPUrl($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div class="form-group">
<label for="ldap_username" class="col-sm-3 control-label text-left">
Service Account
<portainer-tooltip position="bottom" message="Account that will be used to search for users."></portainer-tooltip>
</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
id="ldap_username"
ng-model="$ctrl.settings.ReaderDN"
placeholder="reader@domain.tld"
ng-change="$ctrl.onAccountChange($ctrl.settings.ReaderDN)"
/>
</div>
</div>
<div class="form-group">
<label for="ldap_password" class="col-sm-3 control-label text-left">
Service Account Password
<portainer-tooltip position="bottom" message="If you do not enter a password, Portainer will leave the current password unchanged."></portainer-tooltip>
</label>
<div class="col-sm-9">
<input type="password" class="form-control" id="ldap_password" ng-model="$ctrl.settings.Password" placeholder="password" autocomplete="new-password" />
</div>
</div>
<ldap-connectivity-check
ng-if="!$ctrl.settings.TLSConfig.TLS && !$ctrl.settings.StartTLS"
settings="$ctrl.settings"
state="$ctrl.state"
connectivity-check="$ctrl.connectivityCheck"
></ldap-connectivity-check>
<ldap-settings-security
title="AD Connectivity Security"
settings="$ctrl.settings"
tlsca-cert="$ctrl.tlscaCert"
upload-in-progress="$ctrl.state.uploadInProgress"
on-tlsca-cert-change="($ctrl.onTlscaCertChange)"
></ldap-settings-security>
<ldap-connectivity-check
ng-if="$ctrl.settings.TLSConfig.TLS || $ctrl.settings.StartTLS"
settings="$ctrl.settings"
state="$ctrl.state"
connectivity-check="$ctrl.connectivityCheck"
></ldap-connectivity-check>
<ldap-user-search
style="margin-top: 5px;"
show-username-format="true"
settings="$ctrl.settings.SearchSettings"
domain-suffix="{{ $ctrl.domainSuffix }}"
base-filter="(objectClass=user)"
on-search-click="($ctrl.searchUsers)"
></ldap-user-search>
<ldap-group-search
style="margin-top: 5px;"
settings="$ctrl.settings.GroupSearchSettings"
domain-suffix="{{ $ctrl.domainSuffix }}"
base-filter="(objectClass=group)"
on-search-click="($ctrl.searchGroups)"
></ldap-group-search>
<ldap-settings-test-login settings="$ctrl.settings"></ldap-settings-test-login>

View File

@@ -0,0 +1,12 @@
import controller from './ad-settings.controller';
export const adSettings = {
templateUrl: './ad-settings.html',
controller,
bindings: {
settings: '=',
tlscaCert: '=',
state: '=',
connectivityCheck: '<',
},
};

View File

@@ -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;

View File

@@ -0,0 +1,8 @@
export const ldapConnectivityCheck = {
templateUrl: './ldap-connectivity-check.html',
bindings: {
settings: '<',
state: '<',
connectivityCheck: '<',
},
};

View File

@@ -0,0 +1,19 @@
<div class="form-group">
<label for="ldap_password" class="col-sm-3 control-label text-left">
Connectivity check
<i class="fa fa-check green-icon" style="margin-left: 5px;" ng-if="$ctrl.state.successfulConnectivityCheck"></i>
<i class="fa fa-times red-icon" style="margin-left: 5px;" ng-if="$ctrl.state.failedConnectivityCheck"></i>
</label>
<div class="col-sm-9">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="($ctrl.state.connectivityCheckInProgress) || (!$ctrl.settings.URLs.length) || ((!$ctrl.settings.ReaderDN || !$ctrl.settings.Password) && !$ctrl.settings.AnonymousMode)"
ng-click="$ctrl.connectivityCheck()"
button-spinner="$ctrl.state.connectivityCheckInProgress"
>
<span ng-hide="$ctrl.state.connectivityCheckInProgress">Test connectivity</span>
<span ng-show="$ctrl.state.connectivityCheckInProgress">Testing connectivity...</span>
</button>
</div>
</div>

View File

@@ -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: '<',
},
};

View File

@@ -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');
}
});
}
}

View File

@@ -0,0 +1,64 @@
<div class="col-sm-12 form-section-title" style="float: initial;">
Teams auto-population configurations
</div>
<rd-widget ng-repeat="config in $ctrl.settings | limitTo: (1 - $ctrl.settings)" style="display: block; margin-bottom: 10px;">
<rd-widget-body>
<div class="form-group" ng-if="$index > 0" style="margin-bottom: 10px;">
<span class="col-sm-12 text-muted small">
Extra search configuration
</span>
</div>
<div class="form-group">
<label for="ldap_group_basedn_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Base DN
<portainer-tooltip position="bottom" message="The distinguished name of the element from which the LDAP server will search for groups."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input type="text" class="form-control" id="ldap_group_basedn_{{ $index }}" ng-model="config.GroupBaseDN" placeholder="dc=ldap,dc=domain,dc=tld" />
</div>
<label for="ldap_group_att_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Membership Attribute
<portainer-tooltip position="bottom" message="LDAP attribute which denotes the group membership."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input type="text" class="form-control" id="ldap_group_att_{{ $index }}" ng-model="config.GroupAttribute" placeholder="member" />
</div>
</div>
<div class="form-group">
<label for="ldap_group_filter_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Filter
<portainer-tooltip position="bottom" message="The LDAP search filter used to select group elements, optional."></portainer-tooltip>
</label>
<div ng-class="{ 'col-sm-7 col-md-9': $index, 'col-sm-8 col-md-10': !$index }">
<input type="text" class="form-control" id="ldap_group_filter_{{ $index }}" ng-model="config.GroupFilter" placeholder="(objectClass=account)" />
</div>
<div class="col-sm-1" ng-if="$index > 0">
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.onRemoveClick($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</rd-widget-body>
</rd-widget>
<div class="form-group" style="margin-top: 10px;">
<div class="col-sm-12">
<button class="label label-default interactive" style="border: 0;" ng-click="$ctrl.onAddClick()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add group search configuration
</button>
</div>
<div class="col-sm-12" style="margin-top: 10px;">
<button class="btn btm-sm btn-primary" type="button" ng-click="$ctrl.search()">
Display User/Group matching
</button>
</div>
</div>
<div ng-if="$ctrl.showTable">
<div class="form-group">
<ldap-groups-datatable dataset="$ctrl.groups" title-text="Groups" title-icon="fa-users" table-key="ldapGroups"></ldap-groups-datatable>
</div>
</div>

View File

@@ -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: '<',
},
};

View File

@@ -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');
}
});
}
}

View File

@@ -0,0 +1,64 @@
<div class="col-sm-12 form-section-title" style="float: initial;">
User search configurations
</div>
<rd-widget ng-repeat="config in $ctrl.settings | limitTo: (1 - $ctrl.settings)" style="display: block; margin-bottom: 10px;">
<rd-widget-body>
<div class="form-group" ng-if="$index > 0" style="margin-bottom: 10px;">
<span class="col-sm-12 text-muted small">
Extra search configuration
</span>
</div>
<div class="form-group">
<label for="ldap_basedn_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Base DN
<portainer-tooltip position="bottom" message="The distinguished name of the element from which the LDAP server will search for users."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input type="text" class="form-control" id="ldap_basedn_{{ $index }}" ng-model="config.BaseDN" placeholder="dc=ldap,dc=domain,dc=tld" />
</div>
<label for="ldap_username_att_{{ $index }}" class="col-sm-4 col-md-3 col-lg-2 control-label text-left">
Username attribute
<portainer-tooltip position="bottom" message="LDAP attribute which denotes the username."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-3 col-lg-4">
<input type="text" class="form-control" id="ldap_username_att_{{ $index }}" ng-model="config.UserNameAttribute" placeholder="uid" />
</div>
</div>
<div class="form-group">
<label for="ldap_filter_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Filter
<portainer-tooltip position="bottom" message="The LDAP search filter used to select user elements, optional."></portainer-tooltip>
</label>
<div ng-class="{ 'col-sm-7 col-md-9': $index, 'col-sm-8 col-md-10': !$index }">
<input type="text" class="form-control" id="ldap_filter_{{ $index }}" ng-model="config.Filter" placeholder="(objectClass=account)" />
</div>
<div class="col-sm-1" ng-if="$index > 0">
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.onRemoveClick($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</rd-widget-body>
</rd-widget>
<div class="form-group" style="margin-top: 10px;">
<div class="col-sm-12">
<button class="label label-default interactive" style="border: 0;" ng-click="$ctrl.onAddClick()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add user search configuration
</button>
</div>
<div class="col-sm-12" style="margin-top: 10px;">
<button class="btn btm-sm btn-primary" type="button" ng-click="$ctrl.search()">
Display Users
</button>
</div>
</div>
<div ng-if="$ctrl.showTable">
<div class="form-group">
<ldap-users-datatable dataset="$ctrl.users" title-text="Users" title-icon="fa-users" table-key="ldapUsers" order-by="" reverse-order=""></ldap-users-datatable>
</div>
</div>

View File

@@ -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: '<',
},
};

View File

@@ -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();
}
}

View File

@@ -0,0 +1,68 @@
<rd-widget>
<rd-widget-body>
<div ng-if="$ctrl.index > 0" style="margin-bottom: 10px;">
<span class="text-muted small">
Extra search configuration
</span>
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.onRemoveClick($ctrl.index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
<ldap-settings-dn-builder
label="Group Search Path (optional)"
suffix="{{ $ctrl.domainSuffix }}"
ng-model="$ctrl.config.GroupBaseDN"
on-change="($ctrl.onChangeBaseDN)"
></ldap-settings-dn-builder>
<div class="form-group">
<label class="col-sm-4 col-md-2 control-label text-left">
Group Base DN
</label>
<div class="col-sm-8 col-md-10">
{{ $ctrl.config.GroupBaseDN }}
</div>
</div>
<div class="form-group">
<div class="col-sm-12" style="margin-bottom: 5px;">
<label class="control-label text-left">Groups</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addGroup()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add another group
</span>
</div>
<div class="col-sm-12" ng-if="$ctrl.groups.length">
<rd-widget>
<rd-widget-body>
<div class="form-group no-margin-last-child" ng-repeat="entry in $ctrl.groups">
<div class="col-sm-4">
<select class="form-control" ng-model="entry.type" ng-change="$ctrl.onGroupsChange()">
<option value="ou">OU Name</option>
<option value="cn">Folder Name</option>
</select>
</div>
<div class="col-sm-5">
<input class="form-control" ng-model="entry.value" ng-change="$ctrl.onGroupsChange()" />
</div>
<div class="col-sm-3 text-right">
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeGroup($index)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="form-group no-margin-last-child">
<label class="col-sm-4 col-md-2 control-label text-left">
Group Filter
</label>
<div class="col-sm-8 col-md-10">
{{ $ctrl.config.GroupFilter }}
</div>
</div>
</rd-widget-body>
</rd-widget>

View File

@@ -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: '<',
},
};

View File

@@ -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');
}
});
}
}

View File

@@ -0,0 +1,32 @@
<div class="col-sm-12 form-section-title" style="float: initial;">
Teams auto-population configurations
</div>
<div style="margin-top: 10px;" ng-repeat="config in $ctrl.settings | limitTo: (1 - $ctrl.settings)">
<ldap-group-search-item
config="config"
domain-suffix="{{ $ctrl.domainSuffix }}"
index="$index"
base-filter="{{ $ctrl.baseFilter }}"
on-remove-click="($ctrl.onRemoveClick)"
></ldap-group-search-item>
</div>
<div class="form-group" style="margin-top: 10px;">
<div class="col-sm-12">
<button class="label label-default interactive" style="border: 0;" ng-click="$ctrl.onAddClick()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add group search configuration
</button>
</div>
<div class="col-sm-12" style="margin-top: 10px;">
<button class="btn btm-sm btn-primary" type="button" ng-click="$ctrl.search()">
Display User/Group matching
</button>
</div>
</div>
<div ng-if="$ctrl.showTable">
<div class="form-group">
<ldap-groups-datatable dataset="$ctrl.groups" title-text="Groups" title-icon="fa-users" table-key="ldapGroups"></ldap-groups-datatable>
</div>
</div>

View File

@@ -1,5 +1,5 @@
angular.module('portainer.app').component('rolesDatatable', {
templateUrl: './rolesDatatable.html',
export const ldapGroupsDatatable = {
templateUrl: './ldap-groups-datatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
@@ -9,4 +9,4 @@ angular.module('portainer.app').component('rolesDatatable', {
orderBy: '@',
reverseOrder: '<',
},
});
};

View File

@@ -0,0 +1,77 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('Name')">
User Name
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
Groups
</th>
</tr>
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{ active: item.Checked }"
>
<td>
{{ item.Name }}
</td>
<td>
<p ng-repeat="group in item.Groups" style="margin: 0;">{{ group }}</p>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="5" class="text-center text-muted">No groups found.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -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: '<',
},
};

View File

@@ -0,0 +1,9 @@
export default class LdapSettingsCustomController {
addLDAPUrl() {
this.settings.URLs.push('');
}
removeLDAPUrl(index) {
this.settings.URLs.splice(index, 1);
}
}

View File

@@ -0,0 +1,99 @@
<div>
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group col-sm-12 text-muted small">
When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails.
</div>
</div>
<div class="col-sm-12 form-section-title">
LDAP configuration
</div>
<div class="form-group">
<div class="col-sm-12 small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
You can configure multiple LDAP Servers for authentication fallback. Make sure all servers are using the same configuration (i.e. if TLS is enabled, they should all use the
same certificates).
</p>
</div>
</div>
<div class="form-group">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left" style="display: flex; flex-wrap: wrap;">
LDAP Server
<button type="button" class="label label-default interactive" style="border: 0;" ng-click="$ctrl.addLDAPUrl()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> Add additional server
</button>
</label>
<div class="col-sm-9 col-lg-10">
<div ng-repeat="url in $ctrl.settings.URLs track by $index" style="display: flex; margin-bottom: 10px;">
<input type="text" class="form-control" id="ldap_url" ng-model="$ctrl.settings.URLs[$index]" placeholder="e.g. 10.0.0.10:389 or myldap.domain.tld:389" />
<button ng-if="$index > 0" class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeLDAPUrl($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<!-- Anonymous mode-->
<div class="form-group">
<div class="col-sm-12">
<label for="anonymous_mode" class="control-label text-left">
Anonymous mode
<portainer-tooltip position="bottom" message="Enable this option if the server is configured for Anonymous access."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" id="anonymous_mode" ng-model="$ctrl.settings.AnonymousMode" /><i></i> </label>
</div>
</div>
<!-- !Anonymous mode-->
<div ng-if="!$ctrl.settings.AnonymousMode">
<div class="form-group">
<label for="ldap_username" class="col-sm-3 col-lg-2 control-label text-left">
Reader DN
<portainer-tooltip position="bottom" message="Account that will be used to search for users."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="ldap_username" ng-model="$ctrl.settings.ReaderDN" placeholder="{{ $ctrl.clickToSetValues.readerDNPlaceholder }}" />
</div>
</div>
<div class="form-group">
<label for="ldap_password" class="col-sm-3 col-lg-2 control-label text-left">
Password
<portainer-tooltip position="bottom" message="If you do not enter a password, Portainer will leave the current password unchanged."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="password" class="form-control" id="ldap_password" ng-model="$ctrl.settings.Password" placeholder="password" autocomplete="new-password" />
</div>
</div>
</div>
<ldap-connectivity-check
ng-if="!$ctrl.settings.TLSConfig.TLS && !$ctrl.settings.StartTLS"
settings="$ctrl.settings"
state="$ctrl.state"
connectivity-check="$ctrl.connectivityCheck"
></ldap-connectivity-check>
<ldap-settings-security
settings="$ctrl.settings"
tlsca-cert="$ctrl.tlscaCert"
upload-in-progress="$ctrl.state.uploadInProgress"
on-tlsca-cert-change="($ctrl.onTlscaCertChange)"
></ldap-settings-security>
<ldap-connectivity-check
ng-if="$ctrl.settings.TLSConfig.TLS || $ctrl.settings.StartTLS"
settings="$ctrl.settings"
state="$ctrl.state"
connectivity-check="$ctrl.connectivityCheck"
></ldap-connectivity-check>
<ldap-custom-user-search style="margin-top: 5px;" settings="$ctrl.settings.SearchSettings" on-search-click="($ctrl.onSearchUsersClick)"></ldap-custom-user-search>
<ldap-custom-group-search style="margin-top: 5px;" settings="$ctrl.settings.GroupSearchSettings" on-search-click="($ctrl.onSearchGroupsClick)"></ldap-custom-group-search>
<ldap-settings-test-login settings="$ctrl.settings"></ldap-settings-test-login>

View File

@@ -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: '@',
},
};

View File

@@ -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;
}
}

View File

@@ -0,0 +1,36 @@
<div class="form-group ldap-dn-builder">
<div class="col-sm-12" style="margin-bottom: 5px;">
<label class="control-label text-left">{{ $ctrl.label || 'DN entries' }}</label>
<button type="button" class="label label-default interactive" style="margin-left: 10px; border: 0;" ng-click="$ctrl.addEntry()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add another entry
</button>
</div>
<div class="col-sm-12" ng-if="$ctrl.entries.length">
<rd-widget>
<rd-widget-body>
<div class="form-group no-margin-last-child" ng-repeat="entry in $ctrl.entries">
<div class="col-sm-4">
<select class="form-control" ng-model="entry.type" ng-change="$ctrl.onEntriesChange()">
<option value="ou">OU Name</option>
<option value="cn">Folder Name</option>
</select>
</div>
<div class="col-sm-5">
<input class="form-control" ng-model="entry.value" ng-change="$ctrl.onEntriesChange()" />
</div>
<div class="col-sm-3 text-right">
<button class="btn btn-sm btn-primary" type="button" ng-disabled="$first" ng-click="$ctrl.moveUp($index)">
<i class="fa fa-arrow-up" aria-hidden="true"></i>
</button>
<button class="btn btn-sm btn-primary" type="button" ng-disabled="$last" ng-click="$ctrl.moveDown($index)">
<i class="fa fa-arrow-down" aria-hidden="true"></i>
</button>
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeEntry($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -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: '<',
},
};

View File

@@ -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);
}
}

View File

@@ -0,0 +1,21 @@
<div class="form-group">
<label for="group-name-input" class="col-sm-4 control-label text-left">
Group Name
</label>
<div class="col-sm-7" style="padding-left: 0;">
<input type="text" class="form-control" id="group-name-input" ng-model="$ctrl.groupName" ng-change="$ctrl.onGroupNameChange()" />
</div>
<div class="col-sm-1">
<button type="button" class="btn btn-danger btn-sm" ng-if="$ctrl.onRemoveClick" ng-click="$ctrl.onRemoveClick()">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<ldap-settings-dn-builder
ng-model="$ctrl.entries"
label="Path to group"
suffix="{{ $ctrl.suffix }}"
on-change="($ctrl.onEntriesChange)"
on-remove-click="($ctrl.removeGroup)"
></ldap-settings-dn-builder>

View File

@@ -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: '<',
},
};

View File

@@ -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();
}
}

Some files were not shown because too many files have changed in this diff Show More