feat(oauth): update OAuth UX

pull/2749/head
Anthony Lapenna 2019-02-14 15:58:45 +13:00
parent 16226b1202
commit de76ba4e67
10 changed files with 278 additions and 139 deletions

View File

@ -55,7 +55,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
} }
if user == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { if user == nil && !settings.OAuthSettings.OAuthAutoCreateUsers {
return &httperror.HandlerError{http.StatusForbidden, "Unregistered account", portainer.ErrUnauthorized} return &httperror.HandlerError{http.StatusForbidden, "Account not created beforehand in Portainer and automatic user provisioning not enabled", portainer.ErrUnauthorized}
} }
if user == nil { if user == nil {

View File

@ -23,9 +23,18 @@ type Service struct{}
// GetAccessToken takes an access code and exchanges it for an access token from portainer OAuthSettings token endpoint // GetAccessToken takes an access code and exchanges it for an access token from portainer OAuthSettings token endpoint
func (*Service) GetAccessToken(code string, settings *portainer.OAuthSettings) (string, error) { func (*Service) GetAccessToken(code string, settings *portainer.OAuthSettings) (string, error) {
unescapedCode, err := url.QueryUnescape(code)
if err != nil {
return "", err
}
config := buildConfig(settings) config := buildConfig(settings)
token, err := config.Exchange(context.Background(), code) token, err := config.Exchange(context.Background(), unescapedCode)
return token.AccessToken, err if err != nil {
return "", err
}
return token.AccessToken, nil
} }
// GetUsername takes a token and retrieves the portainer OAuthSettings user identifier from resource server. // GetUsername takes a token and retrieves the portainer OAuthSettings user identifier from resource server.
@ -109,7 +118,6 @@ func buildConfig(oauthSettings *portainer.OAuthSettings) *oauth2.Config {
ClientSecret: oauthSettings.ClientSecret, ClientSecret: oauthSettings.ClientSecret,
Endpoint: endpoint, Endpoint: endpoint,
RedirectURL: oauthSettings.RedirectURI, RedirectURL: oauthSettings.RedirectURI,
// TODO figure out how to handle different providers, see https://github.com/golang/oauth2/issues/119 Scopes: []string{oauthSettings.Scopes},
Scopes: []string{oauthSettings.Scopes},
} }
} }

View File

@ -0,0 +1,56 @@
angular.module('portainer.extensions.oauth')
.controller('OAuthProviderSelectorController', function OAuthProviderSelectorController() {
var ctrl = this;
this.providers = [
{
userIdentifier: 'mail',
scope: 'id,email,name',
name: 'microsoft'
},
{
authUrl: 'https://accounts.google.com/o/oauth2/auth',
accessTokenUrl: 'https://accounts.google.com/o/oauth2/token',
resourceUrl: 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json',
userIdentifier: 'email',
scopes: 'profile email',
name: 'google'
},
{
authUrl: 'https://github.com/login/oauth/authorize',
accessTokenUrl: 'https://github.com/login/oauth/access_token',
resourceUrl: 'https://api.github.com/user',
userIdentifier: 'login',
scopes: 'id email name',
name: 'github'
},
{
name: 'custom'
}
];
this.$onInit = onInit;
function onInit() {
console.log(ctrl.provider.authUrl);
if (ctrl.provider.authUrl) {
ctrl.provider = getProviderByURL(ctrl.provider.authUrl);
} else {
ctrl.provider = ctrl.providers[0];
}
ctrl.onSelect(ctrl.provider);
}
function getProviderByURL(providerAuthURL) {
if (providerAuthURL.indexOf('login.microsoftonline.com') !== -1) {
return ctrl.providers[0];
}
else if (providerAuthURL.indexOf('accounts.google.com') !== -1) {
return ctrl.providers[1];
}
else if (providerAuthURL.indexOf('github.com') !== -1) {
return ctrl.providers[2];
}
return ctrl.provider[3];
}
});

View File

@ -1,17 +1,49 @@
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
Provider Provider
</div> </div>
<div class="form-group"></div>
<div class="form-group"> <div class="form-group" style="margin-bottom: 0">
<div class="col-sm-12"> <div class="boxselector_wrapper">
<select <div ng-click="$ctrl.onSelect($ctrl.provider)">
class="form-control" <input type="radio" id="oauth_provider_microsoft" ng-model="$ctrl.provider" ng-value="$ctrl.providers[0]">
id="oauth-provider-selector" <label for="oauth_provider_microsoft">
ng-model="$ctrl.selectedProvider" <div class="boxselector_header">
ng-change="$ctrl.onSelect($ctrl.selectedProvider)" <i class="fab fa-microsoft" aria-hidden="true" style="margin-right: 2px;"></i>
ng-options="provider as provider.name for provider in $ctrl.providers" Microsoft
> </div>
</select> <p>Microsoft OAuth provider</p>
</label>
</div>
<div ng-click="$ctrl.onSelect($ctrl.provider)">
<input type="radio" id="oauth_provider_google" ng-model="$ctrl.provider" ng-value="$ctrl.providers[1]">
<label for="oauth_provider_google">
<div class="boxselector_header">
<i class="fab fa-google" aria-hidden="true" style="margin-right: 2px;"></i>
Google
</div>
<p>Google OAuth provider</p>
</label>
</div>
<div ng-click="$ctrl.onSelect($ctrl.provider)">
<input type="radio" id="oauth_provider_github" ng-model="$ctrl.provider" ng-value="$ctrl.providers[2]">
<label for="oauth_provider_github">
<div class="boxselector_header">
<i class="fab fa-github" aria-hidden="true" style="margin-right: 2px;"></i>
Github
</div>
<p>Github OAuth provider</p>
</label>
</div>
<div ng-click="$ctrl.onSelect($ctrl.provider)">
<input type="radio" id="oauth_provider_custom" ng-model="$ctrl.provider" ng-value="$ctrl.providers[3]">
<label for="oauth_provider_custom">
<div class="boxselector_header">
<i class="fa fa-user-check" aria-hidden="true" style="margin-right: 2px;"></i>
Custom
</div>
<p>Custom OAuth provider</p>
</label>
</div>
</div> </div>
</div> </div>

View File

@ -1,20 +1,8 @@
angular.module('portainer.extensions.oauth').component('oauthProvidersSelector', { angular.module('portainer.extensions.oauth').component('oauthProvidersSelector', {
templateUrl: 'app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.html', templateUrl: 'app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.html',
bindings: { bindings: {
onSelect: '<' onSelect: '<',
provider: '='
}, },
controller: function oauthProvidersSelectorController() { controller: 'OAuthProviderSelectorController'
this.providers = [
{
name: 'Facebook',
authUrl: 'https://www.facebook.com/v3.2/dialog/oauth',
accessTokenUrl: 'https://graph.facebook.com/v3.2/oauth/access_token',
resourceUrl: 'https://graph.facebook.com/v3.2/me?fields=email',
userIdentifier: 'email'
},
{
name: 'Custom'
}
];
}
}); });

View File

@ -1,11 +1,38 @@
angular.module('portainer.extensions.oauth') angular.module('portainer.extensions.oauth')
.controller('OAuthSettingsController', function OAuthSettingsController() { .controller('OAuthSettingsController', function OAuthSettingsController() {
this.onSelectProvider = onSelectProvider; var ctrl = this;
function onSelectProvider(provider) { this.state = {
this.settings.AuthorizationURI = provider.authUrl; provider: {},
this.settings.AccessTokenURI = provider.accessTokenUrl; overrideConfiguration: false,
this.settings.ResourceURI = provider.resourceUrl; microsoftTenantID: ''
this.settings.UserIdentifier = provider.userIdentifier; };
}
}); this.$onInit = onInit;
this.onSelectProvider = onSelectProvider;
this.onMicrosoftTenantIDChange = onMicrosoftTenantIDChange;
function onMicrosoftTenantIDChange() {
var tenantID = ctrl.state.microsoftTenantID;
ctrl.settings.AuthorizationURI = _.replace('https://login.microsoftonline.com/TENANT_ID/oauth2/authorize', 'TENANT_ID', tenantID);
ctrl.settings.AccessTokenURI = _.replace('https://login.microsoftonline.com/TENANT_ID/oauth2/token', 'TENANT_ID', tenantID);
ctrl.settings.ResourceURI = _.replace('https://graph.windows.net/TENANT_ID/me?api-version=2013-11-08', 'TENANT_ID', tenantID);
}
function onSelectProvider(provider) {
ctrl.state.provider = provider;
ctrl.settings.AuthorizationURI = provider.authUrl;
ctrl.settings.AccessTokenURI = provider.accessTokenUrl;
ctrl.settings.ResourceURI = provider.resourceUrl;
ctrl.settings.UserIdentifier = provider.userIdentifier;
ctrl.settings.Scopes = provider.scopes;
}
function onInit() {
if (ctrl.settings.RedirectURI === '') {
ctrl.settings.RedirectURI = window.location.origin;
}
ctrl.state.provider.authUrl = ctrl.settings.AuthorizationURI;
}
});

View File

@ -1,48 +1,61 @@
<div> <div>
<!-- <oauth-providers-selector on-select="$ctrl.onSelectProvider" selected-provider="$ctrl.settings.provider" providers="$ctrl.providers"></oauth-providers-selector> --> <div class="col-sm-12 form-section-title">
<div class="col-sm-12 form-section-title"> Automatic user provisioning
Automatic user provisioning </div>
</div> <div class="form-group">
<div class="form-group"> <span class="col-sm-12 text-muted small">
<span class="col-sm-12 text-muted small"> With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role. If
With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role. If disabled, users must be created in Portainer in order to login.
disabled, users must be created in Portainer in order to login. </span>
</span> </div>
</div> <div class="form-group">
<div class="form-group"> <label class="col-sm-3 col-lg-2 control-label text-left">Automatic user provisioning</label>
<div class="col-sm-12">
<label for="oauth_provisioning"> Automatic user provisioning </label>
<label class="switch" style="margin-left: 20px"> <label class="switch" style="margin-left: 20px">
<input type="checkbox" ng-model="$ctrl.settings.OAuthAutoCreateUsers" /><i></i> <input type="checkbox" ng-model="$ctrl.settings.OAuthAutoCreateUsers" /><i></i>
</label> </label>
</div> </div>
</div>
<div ng-if="$ctrl.settings.OAuthAutoCreateUsers"> <div ng-if="$ctrl.settings.OAuthAutoCreateUsers">
<div class="form-group"> <div class="form-group">
<span class="col-sm-12 text-muted small"> <span class="col-sm-12 text-muted small">
The users created by the automatic provisioning feature can be added to a default team on creation. This setting is optional. The users created by the automatic provisioning feature can be added to a default team on creation. This setting is optional.
</span> </span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="team_provisioning" class="col-sm-2">Default team</label> <label class="col-sm-3 col-lg-2 control-label text-left">Default team</label>
<span class="small text-muted" style="margin-left: 20px;" ng-if="$ctrl.teams.length === 0"> <span class="small text-muted" style="margin-left: 20px;" ng-if="$ctrl.teams.length === 0">
You have not yet created any team. Head over the <a ui-sref="portainer.teams">teams view</a> to manage user teams. You have not yet created any team. Head over the <a ui-sref="portainer.teams">teams view</a> to manage user teams.
</span> </span>
<button type="button" class="btn btn-sm btn-danger" ng-click="$ctrl.settings.DefaultTeamID = null" ng-disabled="!$ctrl.settings.DefaultTeamID" <button type="button" class="btn btn-sm btn-danger" ng-click="$ctrl.settings.DefaultTeamID = null" ng-disabled="!$ctrl.settings.DefaultTeamID" ng-if="$ctrl.teams.length > 0"><i class="fa fa-times" aria-hidden="true"></i></button>
ng-if="$ctrl.teams.length > 0"> <div class="col-sm-9 col-lg-9" ng-if="$ctrl.teams.length > 0">
<i class="fa fa-times" aria-hidden="true"></i> <select class="form-control" ng-model="$ctrl.settings.DefaultTeamID" ng-options="team.Id as team.Name for team in $ctrl.teams">
</button> <option value="">No team</option>
<div class="col-sm-9 col-lg-9" ng-if="$ctrl.teams.length > 0"> </select>
<select class="form-control" ng-model="$ctrl.settings.DefaultTeamID" ng-options="team.Id as team.Name for team in $ctrl.teams"> </div>
</select>
</div> </div>
</div> </div>
</div>
<oauth-providers-selector on-select="$ctrl.onSelectProvider" provider="$ctrl.state.provider"></oauth-providers-selector>
<div class="col-sm-12 form-section-title">OAuth Configuration</div> <div class="col-sm-12 form-section-title">OAuth Configuration</div>
<div class="form-group" ng-if="$ctrl.state.provider.name == 'microsoft'">
<label for="oauth_microsoft_tenant_id" class="col-sm-3 col-lg-2 control-label text-left">
Tenant ID
<portainer-tooltip position="bottom" message="ID of the Azure AD directory in which you created the OAuth application"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
id="oauth_microsoft_tenant_id"
placeholder="xxxxxxxxxxxxxxxxxxxx"
ng-model="$ctrl.state.microsoftTenantID"
ng-change="$ctrl.onMicrosoftTenantIDChange()"
/>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="oauth_client_id" class="col-sm-3 col-lg-2 control-label text-left"> <label for="oauth_client_id" class="col-sm-3 col-lg-2 control-label text-left">
Client ID Client ID
@ -50,11 +63,11 @@
</label> </label>
<div class="col-sm-9 col-lg-10"> <div class="col-sm-9 col-lg-10">
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="oauth_client_id" id="oauth_client_id"
ng-model="$ctrl.settings.ClientID" ng-model="$ctrl.settings.ClientID"
placeholder="xxxxxxxxxxxxxxxxxxxx" placeholder="xxxxxxxxxxxxxxxxxxxx"
/> />
</div> </div>
</div> </div>
@ -63,129 +76,140 @@
<label for="oauth_client_secret" class="col-sm-3 col-lg-2 control-label text-left"> <label for="oauth_client_secret" class="col-sm-3 col-lg-2 control-label text-left">
Client Secret Client Secret
<portainer-tooltip <portainer-tooltip
position="bottom" position="bottom"
message="Client secret that authorization server supports" message="Client secret that authorization server supports"
></portainer-tooltip> ></portainer-tooltip>
</label> </label>
<div class="col-sm-9 col-lg-10"> <div class="col-sm-9 col-lg-10">
<input <input
type="password" type="password"
class="form-control" class="form-control"
id="oauth_client_secret" id="oauth_client_secret"
ng-model="$ctrl.settings.ClientSecret" ng-model="$ctrl.settings.ClientSecret"
placeholder="xxxxxxxxxxxxxxxxxxxx" placeholder="xxxxxxxxxxxxxxxxxxxx"
/> />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group" ng-if="$ctrl.state.provider.name == 'custom' || $ctrl.state.overrideConfiguration">
<label for="oauth_authorization_uri" class="col-sm-3 col-lg-2 control-label text-left"> <label for="oauth_authorization_uri" class="col-sm-3 col-lg-2 control-label text-left">
Authorization URI Authorization URI
<portainer-tooltip <portainer-tooltip
position="bottom" position="bottom"
message="URI where the user is redirected in order to login with OAuth provider" message="URI where the user is redirected in order to login with OAuth provider"
></portainer-tooltip> ></portainer-tooltip>
</label> </label>
<div class="col-sm-9 col-lg-10"> <div class="col-sm-9 col-lg-10">
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="oauth_authorization_uri" id="oauth_authorization_uri"
ng-model="$ctrl.settings.AuthorizationURI" ng-model="$ctrl.settings.AuthorizationURI"
placeholder="https://example.com/oauth/authorize" placeholder="https://example.com/oauth/authorize"
/> />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group" ng-if="$ctrl.state.provider.name == 'custom' || $ctrl.state.overrideConfiguration">
<label for="oauth_access_token_uri" class="col-sm-3 col-lg-2 control-label text-left"> <label for="oauth_access_token_uri" class="col-sm-3 col-lg-2 control-label text-left">
Access Token URI Access Token URI
<portainer-tooltip <portainer-tooltip
position="bottom" position="bottom"
message="URI where portainer will attempt to obtain an access token" message="URI where portainer will attempt to obtain an access token"
></portainer-tooltip> ></portainer-tooltip>
</label> </label>
<div class="col-sm-9 col-lg-10"> <div class="col-sm-9 col-lg-10">
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="oauth_access_token_uri" id="oauth_access_token_uri"
ng-model="$ctrl.settings.AccessTokenURI" ng-model="$ctrl.settings.AccessTokenURI"
placeholder="https://example.com/oauth/token" placeholder="https://example.com/oauth/token"
/> />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group" ng-if="$ctrl.state.provider.name == 'custom' || $ctrl.state.overrideConfiguration">
<label for="oauth_resource_uri" class="col-sm-3 col-lg-2 control-label text-left"> <label for="oauth_resource_uri" class="col-sm-3 col-lg-2 control-label text-left">
Resource URI Resource URI
<portainer-tooltip <portainer-tooltip
position="bottom" position="bottom"
message="URI where portainer will attempt to retrieve the user identifier value" message="URI where portainer will attempt to retrieve the user identifier value"
></portainer-tooltip> ></portainer-tooltip>
</label> </label>
<div class="col-sm-9 col-lg-10"> <div class="col-sm-9 col-lg-10">
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="oauth_resource_uri" id="oauth_resource_uri"
ng-model="$ctrl.settings.ResourceURI" ng-model="$ctrl.settings.ResourceURI"
placeholder="https://example.com/user" placeholder="https://example.com/user"
/> />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group" ng-if="$ctrl.state.provider.name == 'custom' || $ctrl.state.overrideConfiguration">
<label for="oauth_redirect_uri" class="col-sm-3 col-lg-2 control-label text-left"> <label for="oauth_redirect_uri" class="col-sm-3 col-lg-2 control-label text-left">
Redirect URI Redirect URI
<portainer-tooltip position="bottom" message="Set this as your portainer index"></portainer-tooltip> <portainer-tooltip position="bottom" message="Set this as your portainer index"></portainer-tooltip>
</label> </label>
<div class="col-sm-9 col-lg-10"> <div class="col-sm-9 col-lg-10">
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="oauth_redirect_uri" id="oauth_redirect_uri"
ng-model="$ctrl.settings.RedirectURI" ng-model="$ctrl.settings.RedirectURI"
placeholder="http://yourportainer.com/" placeholder="http://yourportainer.com/"
/> />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group" ng-if="$ctrl.state.provider.name == 'custom' || $ctrl.state.overrideConfiguration">
<label for="oauth_user_identifier" class="col-sm-3 col-lg-2 control-label text-left"> <label for="oauth_user_identifier" class="col-sm-3 col-lg-2 control-label text-left">
User Identifier User Identifier
<portainer-tooltip <portainer-tooltip
position="bottom" position="bottom"
message="Key that identifies the user in the resource server request" message="Key that identifies the user in the resource server request"
></portainer-tooltip> ></portainer-tooltip>
</label> </label>
<div class="col-sm-9 col-lg-10"> <div class="col-sm-9 col-lg-10">
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="oauth_user_identifier" id="oauth_user_identifier"
ng-model="$ctrl.settings.UserIdentifier" ng-model="$ctrl.settings.UserIdentifier"
placeholder="id" placeholder="id"
/> />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group" ng-if="$ctrl.state.provider.name == 'custom' || $ctrl.state.overrideConfiguration">
<label for="oauth_scopes" class="col-sm-3 col-lg-2 control-label text-left"> <label for="oauth_scopes" class="col-sm-3 col-lg-2 control-label text-left">
Scopes Scopes
<portainer-tooltip <portainer-tooltip
position="bottom" position="bottom"
message="Scopes that are required to obtain the user identifier separated by delimiter if server expects it" message="Scopes that are required to obtain the user identifier separated by delimiter if server expects it"
></portainer-tooltip> ></portainer-tooltip>
</label> </label>
<div class="col-sm-9 col-lg-10"> <div class="col-sm-9 col-lg-10">
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="oauth_scopes" id="oauth_scopes"
ng-model="$ctrl.settings.Scopes" ng-model="$ctrl.settings.Scopes"
placeholder="id,email,name" placeholder="id,email,name"
/> />
</div> </div>
</div> </div>
<div class="form-group" ng-if="$ctrl.state.provider.name != 'custom'">
<div class="col-sm-12">
<a class="small interactive" ng-if="!$ctrl.state.overrideConfiguration" ng-click="$ctrl.state.overrideConfiguration = true;">
<i class="fa fa-plus space-right" aria-hidden="true"></i> Override configuration
</a>
<a class="small interactive" ng-if="$ctrl.state.overrideConfiguration" ng-click="$ctrl.state.overrideConfiguration = false;">
<i class="fa fa-minus space-right" aria-hidden="true"></i> Hide advanced options
</a>
</div>
</div>
</div> </div>

View File

@ -1,7 +1,8 @@
angular.module('portainer.extensions.oauth').component('oauthSettings', { angular.module('portainer.extensions.oauth').component('oauthSettings', {
templateUrl: 'app/extensions/oauth/components/oauth-settings/oauth-settings.html', templateUrl: 'app/extensions/oauth/components/oauth-settings/oauth-settings.html',
bindings: { bindings: {
settings: '<', settings: '=',
teams: '<' teams: '<'
} },
controller: 'OAuthSettingsController'
}); });

View File

@ -120,7 +120,7 @@ angular.module('portainer.app').controller('AuthenticationController', ['$q', '$
$state.go('portainer.home'); $state.go('portainer.home');
}) })
.catch(function error() { .catch(function error() {
$scope.state.AuthenticationError = 'Failed to authenticate with OAuth2 Provider'; $scope.state.AuthenticationError = 'Unable to login via OAuth';
$scope.state.isInOAuthProcess = false; $scope.state.isInOAuthProcess = false;
}); });
} }

View File

@ -49,7 +49,7 @@
</div> </div>
</div> </div>
</div> </div>
<div ng-if="settings.AuthenticationMethod === 1"> <div ng-if="settings.AuthenticationMethod === 1">
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
Information Information
@ -58,7 +58,7 @@
When using internal authentication, Portainer will encrypt user passwords and store credentials locally. When using internal authentication, Portainer will encrypt user passwords and store credentials locally.
</div> </div>
</div> </div>
<div ng-if="settings.AuthenticationMethod === 2"> <div ng-if="settings.AuthenticationMethod === 2">
<div> <div>
@ -73,7 +73,7 @@
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
LDAP configuration LDAP configuration
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left"> <label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left">
LDAP Server LDAP Server
@ -325,6 +325,9 @@
<oauth-settings ng-if="isOauthEnabled()" settings="OAuthSettings" teams="teams"></oauth-settings> <oauth-settings ng-if="isOauthEnabled()" settings="OAuthSettings" teams="teams"></oauth-settings>
<!-- actions --> <!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-click="saveSettings()" ng-disabled="state.actionInProgress" button-spinner="state.actionInProgress"> <button type="button" class="btn btn-primary btn-sm" ng-click="saveSettings()" ng-disabled="state.actionInProgress" button-spinner="state.actionInProgress">