diff --git a/Makefile b/Makefile index 4412ba32d1..1207c67cfe 100644 --- a/Makefile +++ b/Makefile @@ -353,6 +353,7 @@ init: if [ "$(EXTRA_GROUP_QUEUES)" == "thepentagon" ]; then \ tower-manage register_instance --hostname=isolated; \ tower-manage register_queue --queuename='thepentagon' --hostnames=isolated --controller=tower; \ + tower-manage generate_isolated_key | ssh -o "StrictHostKeyChecking no" root@isolated 'cat > /root/.ssh/authorized_keys'; \ elif [ "$(EXTRA_GROUP_QUEUES)" != "" ]; then \ tower-manage register_queue --queuename=$(EXTRA_GROUP_QUEUES) --hostnames=$(COMPOSE_HOST); \ fi; diff --git a/awx/main/conf.py b/awx/main/conf.py index c0bef6c794..0015e768d0 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -155,6 +155,47 @@ register( category_slug='jobs', ) +register( + 'AWX_ISOLATED_CHECK_INTERVAL', + field_class=fields.IntegerField, + label=_('Isolated status check interval'), + help_text=_('The number of seconds to sleep between status checks for jobs running on isolated instances.'), # noqa + category=_('Jobs'), + category_slug='jobs', +) + +register( + 'AWX_ISOLATED_LAUNCH_TIMEOUT', + field_class=fields.IntegerField, + label=_('Isolated launch timeout'), + help_text=_('The timeout (in seconds) for launching jobs on isolated instances. This includes the time needed to copy source control files (playbooks) to the isolated instance.'), + category=_('Jobs'), + category_slug='jobs', +) + +register( + 'AWX_ISOLATED_PRIVATE_KEY', + field_class=fields.CharField, + default='', + allow_blank=True, + encrypted=True, + label=_('The RSA private key for SSH traffic to isolated instances'), + help_text=_('The RSA private key for SSH traffic to isolated instances'), # noqa + category=_('Jobs'), + category_slug='jobs', +) + +register( + 'AWX_ISOLATED_PUBLIC_KEY', + field_class=fields.CharField, + default='', + allow_blank=True, + label=_('The RSA public key for SSH traffic to isolated instances'), + help_text=_('The RSA public key for SSH traffic to isolated instances'), # noqa + category=_('Jobs'), + category_slug='jobs', +) + register( 'STDOUT_MAX_BYTES_DISPLAY', field_class=fields.IntegerField, diff --git a/awx/main/isolated/isolated_manager.py b/awx/main/isolated/isolated_manager.py index 139231271b..4f8e602aad 100644 --- a/awx/main/isolated/isolated_manager.py +++ b/awx/main/isolated/isolated_manager.py @@ -5,7 +5,9 @@ import StringIO import json import os import re +import shutil import stat +import tempfile import time import logging @@ -141,7 +143,7 @@ class IsolatedManager(object): args.append('-%s' % ('v' * min(5, self.instance.verbosity))) buff = StringIO.StringIO() logger.debug('Starting job on isolated host with `run_isolated.yml` playbook.') - status, rc = run.run_pexpect( + status, rc = IsolatedManager.run_pexpect( args, self.awx_playbook_path(), self.env, buff, expect_passwords={ re.compile(r'Secret:\s*?$', re.M): base64.b64encode(json.dumps(secrets)) @@ -154,6 +156,22 @@ class IsolatedManager(object): self.stdout_handle.write(buff.getvalue()) return status, rc + @classmethod + def run_pexpect(cls, pexpect_args, *args, **kw): + isolated_ssh_path = None + try: + if getattr(settings, 'AWX_ISOLATED_PRIVATE_KEY', None): + isolated_ssh_path = tempfile.mkdtemp(prefix='ansible_tower_isolated') + os.chmod(isolated_ssh_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) + isolated_key = os.path.join(isolated_ssh_path, '.isolated') + ssh_sock = os.path.join(isolated_ssh_path, '.isolated_ssh_auth.sock') + run.open_fifo_write(isolated_key, settings.AWX_ISOLATED_PRIVATE_KEY) + pexpect_args = run.wrap_args_with_ssh_agent(pexpect_args, isolated_key, ssh_sock) + return run.run_pexpect(pexpect_args, *args, **kw) + finally: + if isolated_ssh_path: + shutil.rmtree(isolated_ssh_path) + def build_isolated_job_data(self): ''' Write the playbook and metadata into a collection of files on the local @@ -251,7 +269,7 @@ class IsolatedManager(object): buff = cStringIO.StringIO() logger.debug('Checking job on isolated host with `check_isolated.yml` playbook.') - status, rc = run.run_pexpect( + status, rc = IsolatedManager.run_pexpect( args, self.awx_playbook_path(), self.env, buff, cancelled_callback=self.cancelled_callback, idle_timeout=remaining, @@ -302,7 +320,7 @@ class IsolatedManager(object): json.dumps(extra_vars)] logger.debug('Cleaning up job on isolated host with `clean_isolated.yml` playbook.') buff = cStringIO.StringIO() - status, rc = run.run_pexpect( + status, rc = IsolatedManager.run_pexpect( args, self.awx_playbook_path(), self.env, buff, idle_timeout=60, job_timeout=60, pexpect_timeout=5 @@ -333,7 +351,7 @@ class IsolatedManager(object): env['ANSIBLE_STDOUT_CALLBACK'] = 'json' buff = cStringIO.StringIO() - status, rc = run.run_pexpect( + status, rc = IsolatedManager.run_pexpect( args, cls.awx_playbook_path(), env, buff, idle_timeout=60, job_timeout=60, pexpect_timeout=5 @@ -357,7 +375,7 @@ class IsolatedManager(object): continue if 'capacity' in task_result: instance.capacity = int(task_result['capacity']) - instance.save(update_fields=['capacity']) + instance.save(update_fields=['capacity', 'modified']) else: logger.warning('Could not update capacity of {}, msg={}'.format( instance.hostname, task_result.get('msg', 'unknown failure'))) diff --git a/awx/main/management/commands/generate_isolated_key.py b/awx/main/management/commands/generate_isolated_key.py new file mode 100644 index 0000000000..225fddc44a --- /dev/null +++ b/awx/main/management/commands/generate_isolated_key.py @@ -0,0 +1,45 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved +import datetime +import sys + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from django.conf import settings +from django.core.management.base import BaseCommand + +from awx.conf.models import Setting + + +class Command(BaseCommand): + """Generate and store a randomized RSA key for SSH traffic to isolated instances""" + help = 'Generates and stores a randomized RSA key for SSH traffic to isolated instances' + + def handle(self, *args, **kwargs): + if getattr(settings, 'AWX_ISOLATED_PRIVATE_KEY', False): + print settings.AWX_ISOLATED_PUBLIC_KEY + sys.exit(1) + + key = rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + backend=default_backend() + ) + Setting.objects.create( + key='AWX_ISOLATED_PRIVATE_KEY', + value=key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ) + ).save() + pemfile = Setting.objects.create( + key='AWX_ISOLATED_PUBLIC_KEY', + value=key.public_key().public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH + ) + " generated-by-awx@%s" % datetime.datetime.utcnow().isoformat() + ) + pemfile.save() + print pemfile.value diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index dcb8e800aa..b0f0c36a80 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -609,7 +609,8 @@ AWX_ISOLATED_CHECK_INTERVAL = 30 # The timeout (in seconds) for launching jobs on isolated nodes AWX_ISOLATED_LAUNCH_TIMEOUT = 600 -# The time between the background isolated heartbeat status check + +# The time (in seconds) between the periodic isolated heartbeat status check AWX_ISOLATED_PERIODIC_CHECK = 600 # Enable Pendo on the UI, possible values are 'off', 'anonymous', and 'detailed' diff --git a/awx/sso/conf.py b/awx/sso/conf.py index 90b4552b0e..f682f429b3 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -7,8 +7,11 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.utils.translation import ugettext_lazy as _ +# Django REST Framework +from rest_framework import serializers + # Tower -from awx.conf import register +from awx.conf import register, register_validate from awx.sso import fields from awx.main.validators import validate_private_key, validate_certificate from awx.sso.validators import * # noqa @@ -1083,3 +1086,23 @@ register( placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, feature_required='enterprise_auth', ) + + +def tacacs_validate(serializer, attrs): + if not serializer.instance: + return attrs + errors = [] + host = serializer.instance.TACACSPLUS_HOST + if 'TACACSPLUS_HOST' in attrs: + host = attrs['TACACSPLUS_HOST'] + secret = serializer.instance.TACACSPLUS_SECRET + if 'TACACSPLUS_SECRET' in attrs: + secret = attrs['TACACSPLUS_SECRET'] + if bool(host) ^ bool(secret): + errors.append('TACACSPLUS_HOST and TACACSPLUS_SECRET can only be both empty or both populated.') + if errors: + raise serializers.ValidationError(_('\n'.join(errors))) + return attrs + + +register_validate('tacacsplus', tacacs_validate) diff --git a/awx/ui/client/features/credentials/add-credentials.controller.js b/awx/ui/client/features/credentials/add-credentials.controller.js index b9a0a9fce6..88ed9b01b0 100644 --- a/awx/ui/client/features/credentials/add-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-credentials.controller.js @@ -6,7 +6,6 @@ function AddCredentialsController (models, $state) { let me = models.me; let credential = models.credential; let credentialType = models.credentialType; - let organization = models.organization; vm.panelTitle = 'NEW CREDENTIAL'; @@ -23,22 +22,18 @@ function AddCredentialsController (models, $state) { omit: ['user', 'team', 'inputs'] }); - vm.form.organization._placeholder = DEFAULT_ORGANIZATION_PLACEHOLDER; - vm.form.organization._data = organization.get('results'); - vm.form.organization._format = 'objects'; - vm.form.organization._exp = 'org as org.name for org in state._data'; - vm.form.organization._display = 'name'; - vm.form.organization._key = 'id'; + vm.form.organization._resource = 'organization'; + vm.form.organization._route = 'credentials.add.organization'; - vm.form.credential_type._data = credentialType.get('results'); - vm.form.credential_type._placeholder = 'SELECT A TYPE'; - vm.form.credential_type._format = 'grouped-object'; - vm.form.credential_type._display = 'name'; - vm.form.credential_type._key = 'id'; - vm.form.credential_type._exp = 'type as type.name group by type.kind for type in state._data'; + vm.form.credential_type._resource = 'credential_type'; + vm.form.credential_type._route = 'credentials.add.credentialType'; vm.form.inputs = { - _get: credentialType.mergeInputProperties, + _get: id => { + let type = credentialType.getById(id); + + return credentialType.mergeInputProperties(type); + }, _source: vm.form.credential_type, _reference: 'vm.form.inputs', _key: 'inputs' diff --git a/awx/ui/client/features/credentials/add-edit-credentials.view.html b/awx/ui/client/features/credentials/add-edit-credentials.view.html index d8b45d7a30..a49b5dbed0 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.view.html +++ b/awx/ui/client/features/credentials/add-edit-credentials.view.html @@ -1,4 +1,4 @@ - + {{ vm.panelTitle }} @@ -10,27 +10,26 @@ - + - + - + Type Details - + - - Credentials Permissions + + CREDENTIALS PERMISSIONS Details @@ -42,5 +41,5 @@ -
+
diff --git a/awx/ui/client/features/credentials/edit-credentials.controller.js b/awx/ui/client/features/credentials/edit-credentials.controller.js index b7370fe7c6..69126a5988 100644 --- a/awx/ui/client/features/credentials/edit-credentials.controller.js +++ b/awx/ui/client/features/credentials/edit-credentials.controller.js @@ -6,7 +6,7 @@ function EditCredentialsController (models, $state, $scope) { let me = models.me; let credential = models.credential; let credentialType = models.credentialType; - let organization = models.organization; + let selectedCredentialType = credentialType.getById(credential.get('credential_type')); vm.tab = { details: { @@ -21,9 +21,9 @@ function EditCredentialsController (models, $state, $scope) { }; $scope.$watch('$state.current.name', (value) => { - if (value === 'credentials.edit') { + if (/credentials.edit($|\.organization$)/.test(value)) { vm.tab.details._active = true; - vm.tab.details._permissions = false; + vm.tab.permissions._active = false; } else { vm.tab.permissions._active = true; vm.tab.details._active = false; @@ -39,23 +39,19 @@ function EditCredentialsController (models, $state, $scope) { omit: ['user', 'team', 'inputs'] }); - vm.form.organization._placeholder = DEFAULT_ORGANIZATION_PLACEHOLDER; - vm.form.organization._data = organization.get('results'); - vm.form.organization._format = 'objects'; - vm.form.organization._exp = 'org as org.name for org in state._data'; - vm.form.organization._display = 'name'; - vm.form.organization._key = 'id'; - vm.form.organization._value = organization.getById(credential.get('organization')); - - vm.form.credential_type._data = credentialType.get('results'); - vm.form.credential_type._format = 'grouped-object'; - vm.form.credential_type._display = 'name'; - vm.form.credential_type._key = 'id'; - vm.form.credential_type._exp = 'type as type.name group by type.kind for type in state._data'; - vm.form.credential_type._value = credentialType.getById(credential.get('credential_type')); + vm.form.organization._resource = 'organization'; + vm.form.organization._route = 'credentials.edit.organization'; + vm.form.organization._value = credential.get('summary_fields.organization.id'); + vm.form.organization._displayValue = credential.get('summary_fields.organization.name'); + vm.form.credential_type._resource = 'credential_type'; + vm.form.credential_type._route = 'credentials.edit.credentialType'; + vm.form.credential_type._value = selectedCredentialType.id; + vm.form.credential_type._displayValue = selectedCredentialType.name; + vm.form.inputs = { - _get (type) { + _get (id) { + let type = credentialType.getById(id); let inputs = credentialType.mergeInputProperties(type); if (type.id === credential.get('credential_type')) { @@ -77,7 +73,7 @@ function EditCredentialsController (models, $state, $scope) { }; vm.form.onSaveSuccess = res => { - $state.go('credentials', { reload: true }); + $state.go('credentials.edit', { credential_id: credential.get('id') }, { reload: true }); }; } diff --git a/awx/ui/client/features/credentials/index.js b/awx/ui/client/features/credentials/index.js index e8c5670cbe..0eb1e02233 100644 --- a/awx/ui/client/features/credentials/index.js +++ b/awx/ui/client/features/credentials/index.js @@ -1,18 +1,14 @@ -import PermissionsList from '../../src/access/permissions-list.controller'; -import CredentialForm from '../../src/credentials/credentials.form'; -import CredentialList from '../../src/credentials/credentials.list'; -import ListController from '../../src/credentials/list/credentials-list.controller'; +import LegacyCredentials from './legacy.credentials'; import AddController from './add-credentials.controller.js'; import EditController from './edit-credentials.controller.js'; import { N_ } from '../../src/i18n'; -function CredentialsResolve ($q, $stateParams, Me, Credential, CredentialType, Organization) { +function CredentialsResolve ($q, $stateParams, Me, Credential, CredentialType) { let id = $stateParams.credential_id; let promises = { me: new Me('get'), - credentialType: new CredentialType('get'), - organization: new Organization('get') + credentialType: new CredentialType('get') }; if (id) { @@ -29,46 +25,13 @@ CredentialsResolve.$inject = [ '$stateParams', 'MeModel', 'CredentialModel', - 'CredentialTypeModel', - 'OrganizationModel' + 'CredentialTypeModel' ]; -function CredentialsConfig ($stateProvider, $stateExtenderProvider, pathServiceProvider) { - let pathService = pathServiceProvider.$get(); +function CredentialsConfig ($stateExtenderProvider, legacyProvider, pathProvider) { + let path = pathProvider.$get(); let stateExtender = $stateExtenderProvider.$get(); - - stateExtender.addState({ - name: 'credentials', - route: '/credentials', - ncyBreadcrumb: { - label: N_('CREDENTIALS') - }, - views: { - '@': { - templateUrl: pathService.getViewPath('credentials/index') - }, - 'list@credentials': { - templateProvider: function(CredentialList, generateList) { - let html = generateList.build({ - list: CredentialList, - mode: 'edit' - }); - - return html; - }, - controller: ListController - } - }, - searchPrefix: 'credential', - resolve: { - Dataset: ['CredentialList', 'QuerySet', '$stateParams', 'GetBasePath', - function(list, qs, $stateParams, GetBasePath) { - let path = GetBasePath(list.basePath) || GetBasePath(list.name); - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ] - } - }); + let legacy = legacyProvider.$get(); stateExtender.addState({ name: 'credentials.add', @@ -78,7 +41,7 @@ function CredentialsConfig ($stateProvider, $stateExtenderProvider, pathServiceP }, views: { 'add@credentials': { - templateUrl: pathService.getViewPath('credentials/add-edit-credentials'), + templateUrl: path.getViewPath('credentials/add-edit-credentials'), controller: AddController, controllerAs: 'vm' } @@ -96,7 +59,7 @@ function CredentialsConfig ($stateProvider, $stateExtenderProvider, pathServiceP }, views: { 'edit@credentials': { - templateUrl: pathService.getViewPath('credentials/add-edit-credentials'), + templateUrl: path.getViewPath('credentials/add-edit-credentials'), controller: EditController, controllerAs: 'vm' } @@ -106,178 +69,24 @@ function CredentialsConfig ($stateProvider, $stateExtenderProvider, pathServiceP } }); - stateExtender.addState({ - name: "credentials.edit.permissions", - url: "/permissions?{permission_search:queryset}", - resolve: { - ListDefinition: () => { - return { - name: 'permissions', - disabled: '(organization === undefined ? true : false)', - // Do not transition the state if organization is undefined - ngClick: `(organization === undefined ? true : false)||$state.go('credentials.edit.permissions')`, - awToolTip: '{{permissionsTooltip}}', - dataTipWatch: 'permissionsTooltip', - awToolTipTabEnabledInEditMode: true, - dataPlacement: 'right', - basePath: 'api/v2/credentials/{{$stateParams.id}}/access_list/', - search: { - order_by: 'username' - }, - type: 'collection', - title: N_('Permissions'), - iterator: 'permission', - index: false, - open: false, - actions: { - add: { - ngClick: "$state.go('.add')", - label: 'Add', - awToolTip: N_('Add a permission'), - actionClass: 'btn List-buttonSubmit', - buttonContent: '+ ' + N_('ADD'), - ngShow: '(credential_obj.summary_fields.user_capabilities.edit || canAdd)' - } - }, - fields: { - username: { - key: true, - label: N_('User'), - linkBase: 'users', - class: 'col-lg-3 col-md-3 col-sm-3 col-xs-4' - }, - role: { - label: N_('Role'), - type: 'role', - nosort: true, - class: 'col-lg-4 col-md-4 col-sm-4 col-xs-4' - }, - team_roles: { - label: N_('Team Roles'), - type: 'team_roles', - nosort: true, - class: 'col-lg-5 col-md-5 col-sm-5 col-xs-4' - } - } - }; - }, - Dataset: ['QuerySet', '$stateParams', (qs, $stateParams) => { - let id = $stateParams.credential_id; - let path = `api/v2/credentials/${id}/access_list/`; - - return qs.search(path, $stateParams[`permission_search`]); - } - ] - }, - params: { - permission_search: { - value: { - page_size: "20", - order_by: "username" - }, - dynamic:true, - squash:"" - } - }, - ncyBreadcrumb: { - parent: "credentials.edit", - label: "PERMISSIONS" - }, - views: { - 'related': { - templateProvider: function(CredentialForm, GenerateForm) { - let html = GenerateForm.buildCollection({ - mode: 'edit', - related: `permissions`, - form: typeof(CredentialForm) === 'function' ? - CredentialForm() : CredentialForm - }); - return html; - }, - controller: 'PermissionsList' - } - } - }); - - stateExtender.addState({ - name: 'credentials.edit.permissions.add', - url: '/add-permissions', - resolve: { - usersDataset: [ - 'addPermissionsUsersList', - 'QuerySet', - '$stateParams', - 'GetBasePath', - (list, qs, $stateParams, GetBasePath) => { - let path = GetBasePath(list.basePath) || GetBasePath(list.name); - return qs.search(path, $stateParams.user_search); - - } - ], - teamsDataset: [ - 'addPermissionsTeamsList', - 'QuerySet', - '$stateParams', - 'GetBasePath', - (list, qs, $stateParams, GetBasePath) => { - let path = GetBasePath(list.basePath) || GetBasePath(list.name); - return qs.search(path, $stateParams.team_search); - } - ], - resourceData: ['CredentialModel', '$stateParams', (Credential, $stateParams) => { - return new Credential('get', $stateParams.credential_id) - .then(credential => ({ data: credential.get() })); - }], - }, - params: { - user_search: { - value: { - order_by: 'username', - page_size: 5 - }, - dynamic: true - }, - team_search: { - value: { - order_by: 'name', - page_size: 5 - }, - dynamic: true - } - }, - ncyBreadcrumb: { - skip: true - }, - views: { - 'modal@credentials.edit': { - template: ` - - ` - } - }, - onExit: $state => { - if ($state.transition) { - $('#add-permissions-modal').modal('hide'); - $('.modal-backdrop').remove(); - $('body').removeClass('modal-open'); - } - } - }); + stateExtender.addState(legacy.getStateConfiguration('list')); + stateExtender.addState(legacy.getStateConfiguration('edit-permissions')); + stateExtender.addState(legacy.getStateConfiguration('add-permissions')); + stateExtender.addState(legacy.getStateConfiguration('add-organization')); + stateExtender.addState(legacy.getStateConfiguration('edit-organization')); + stateExtender.addState(legacy.getStateConfiguration('add-credential-type')); + stateExtender.addState(legacy.getStateConfiguration('edit-credential-type')); } CredentialsConfig.$inject = [ - '$stateProvider', - '$stateExtenderProvider', - 'PathServiceProvider' + '$stateExtenderProvider', + 'LegacyCredentialsServiceProvider', + 'PathServiceProvider' ]; angular .module('at.features.credentials', []) .config(CredentialsConfig) .controller('AddController', AddController) - .controller('EditController', EditController); + .controller('EditController', EditController) + .service('LegacyCredentialsService', LegacyCredentials); diff --git a/awx/ui/client/features/credentials/legacy.credentials.js b/awx/ui/client/features/credentials/legacy.credentials.js new file mode 100644 index 0000000000..c0a55d559c --- /dev/null +++ b/awx/ui/client/features/credentials/legacy.credentials.js @@ -0,0 +1,348 @@ +import PermissionsList from '../../src/access/permissions-list.controller'; +import CredentialForm from '../../src/credentials/credentials.form'; +import CredentialList from '../../src/credentials/credentials.list'; +import OrganizationList from '../../src/organizations/organizations.list'; +import ListController from '../../src/credentials/list/credentials-list.controller'; +import { N_ } from '../../src/i18n'; + +function LegacyCredentialsService (pathService) { + this.list = { + name: 'credentials', + route: '/credentials', + ncyBreadcrumb: { + label: N_('CREDENTIALS') + }, + views: { + '@': { + templateUrl: pathService.getViewPath('credentials/index') + }, + 'list@credentials': { + templateProvider: function(CredentialList, generateList) { + let html = generateList.build({ + list: CredentialList, + mode: 'edit' + }); + + return html; + }, + controller: ListController + } + }, + searchPrefix: 'credential', + resolve: { + Dataset: ['CredentialList', 'QuerySet', '$stateParams', 'GetBasePath', + function(list, qs, $stateParams, GetBasePath) { + let path = GetBasePath(list.basePath) || GetBasePath(list.name); + return qs.search(path, $stateParams[`${list.iterator}_search`]); + } + ] + } + }; + + this.editPermissions = { + name: 'credentials.edit.permissions', + url: '/permissions?{permission_search:queryset}', + resolve: { + ListDefinition: () => { + return { + name: 'permissions', + disabled: 'organization === undefined', + ngClick: `organization === undefined || $state.go('credentials.edit.permissions')`, + awToolTip: '{{permissionsTooltip}}', + dataTipWatch: 'permissionsTooltip', + awToolTipTabEnabledInEditMode: true, + dataPlacement: 'right', + basePath: 'api/v2/credentials/{{$stateParams.id}}/access_list/', + search: { + order_by: 'username' + }, + type: 'collection', + title: N_('Permissions'), + iterator: 'permission', + index: false, + open: false, + actions: { + add: { + ngClick: `$state.go('.add')`, + label: 'Add', + awToolTip: N_('Add a permission'), + actionClass: 'btn List-buttonSubmit', + buttonContent: '+ ' + N_('ADD'), + ngShow: '(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + } + }, + fields: { + username: { + key: true, + label: N_('User'), + linkBase: 'users', + class: 'col-lg-3 col-md-3 col-sm-3 col-xs-4' + }, + role: { + label: N_('Role'), + type: 'role', + nosort: true, + class: 'col-lg-4 col-md-4 col-sm-4 col-xs-4' + }, + team_roles: { + label: N_('Team Roles'), + type: 'team_roles', + nosort: true, + class: 'col-lg-5 col-md-5 col-sm-5 col-xs-4' + } + } + }; + }, + Dataset: ['QuerySet', '$stateParams', (qs, $stateParams) => { + let id = $stateParams.credential_id; + let path = `api/v2/credentials/${id}/access_list/`; + + return qs.search(path, $stateParams[`permission_search`]); + } + ] + }, + params: { + permission_search: { + value: { + page_size: '20', + order_by: 'username' + }, + dynamic:true, + squash:'' + } + }, + ncyBreadcrumb: { + parent: 'credentials.edit', + label: 'PERMISSIONS' + }, + views: { + 'related': { + templateProvider: function(CredentialForm, GenerateForm) { + let html = GenerateForm.buildCollection({ + mode: 'edit', + related: `permissions`, + form: typeof(CredentialForm) === 'function' ? + CredentialForm() : CredentialForm + }); + return html; + }, + controller: 'PermissionsList' + } + } + }; + + this.addPermissions = { + name: 'credentials.edit.permissions.add', + url: '/add-permissions', + resolve: { + usersDataset: [ + 'addPermissionsUsersList', + 'QuerySet', + '$stateParams', + 'GetBasePath', + (list, qs, $stateParams, GetBasePath) => { + let path = GetBasePath(list.basePath) || GetBasePath(list.name); + return qs.search(path, $stateParams.user_search); + + } + ], + teamsDataset: [ + 'addPermissionsTeamsList', + 'QuerySet', + '$stateParams', + 'GetBasePath', + (list, qs, $stateParams, GetBasePath) => { + let path = GetBasePath(list.basePath) || GetBasePath(list.name); + return qs.search(path, $stateParams.team_search); + } + ], + resourceData: ['CredentialModel', '$stateParams', (Credential, $stateParams) => { + return new Credential('get', $stateParams.credential_id) + .then(credential => ({ data: credential.get() })); + }], + }, + params: { + user_search: { + value: { + order_by: 'username', + page_size: 5 + }, + dynamic: true + }, + team_search: { + value: { + order_by: 'name', + page_size: 5 + }, + dynamic: true + } + }, + ncyBreadcrumb: { + skip: true + }, + views: { + 'modal@credentials.edit': { + template: ` + + ` + } + }, + onExit: $state => { + if ($state.transition) { + $('#add-permissions-modal').modal('hide'); + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } + } + }; + + this.lookupTemplateProvider = (ListDefinition, generateList) => { + let html = generateList.build({ + mode: 'lookup', + list: ListDefinition, + input_type: 'radio' + }); + + return `${html}`; + }; + + this.organization = { + url: '/organization?selected', + searchPrefix: 'organization', + params: { + organization_search: { + value: { + page_size: 5, + order_by: 'name', + role_level: 'admin_role' + }, + dynamic: true, + squash: '' + } + }, + data: { + basePath: 'organizations', + formChildState: true + }, + ncyBreadcrumb: { + skip: true + }, + views: {}, + resolve: { + ListDefinition: ['OrganizationList', list => { + return list; + }], + Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath', + (list, qs, $stateParams, GetBasePath) => { + return qs.search( + GetBasePath('organizations'), + $stateParams[`${list.iterator}_search`] + ); + } + ] + }, + onExit: function($state) { + if ($state.transition) { + $('#form-modal').modal('hide'); + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } + } + }; + + this.credentialType = { + url: '/credential_type?selected', + searchPrefix: 'credential_type', + params: { + credential_type_search: { + value: { + page_size: 5, + order_by: 'name' + }, + dynamic: true, + squash: '' + } + }, + data: { + basePath: 'credential_types', + formChildState: true + }, + ncyBreadcrumb: { + skip: true + }, + views: {}, + resolve: { + ListDefinition: ['CredentialTypesList', list => { + return list; + }], + Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath', + (list, qs, $stateParams, GetBasePath) => { + return qs.search( + GetBasePath('credential_types'), + $stateParams[`${list.iterator}_search`] + ); + } + ] + }, + onExit: function($state) { + if ($state.transition) { + $('#form-modal').modal('hide'); + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } + } + }; + + this.getStateConfiguration = (name) => { + switch (name) { + case 'list': + return this.list; + case 'edit-permissions': + return this.editPermissions; + case 'add-permissions': + return this.addPermissions; + case 'add-organization': + this.organization.name = 'credentials.add.organization'; + this.organization.views['organization@credentials.add'] = { + templateProvider: this.lookupTemplateProvider + }; + + return this.organization; + case 'edit-organization': + this.organization.name = 'credentials.edit.organization'; + this.organization.views['organization@credentials.edit'] = { + templateProvider: this.lookupTemplateProvider + }; + + return this.organization; + case 'add-credential-type': + this.credentialType.name = 'credentials.add.credentialType'; + this.credentialType.views['credential_type@credentials.add'] = { + templateProvider: this.lookupTemplateProvider + }; + + return this.credentialType; + case 'edit-credential-type': + this.credentialType.name = 'credentials.edit.credentialType'; + this.credentialType.views['credential_type@credentials.edit'] = { + templateProvider: this.lookupTemplateProvider + }; + + return this.credentialType; + + default: + throw new Error(`Legacy state configuration for ${name} does not exist`); + }; + }; +} + +LegacyCredentialsService.$inject = [ + 'PathService' +]; + +export default LegacyCredentialsService; diff --git a/awx/ui/client/legacy-styles/lists.less b/awx/ui/client/legacy-styles/lists.less index b4d65d1862..484ea4440c 100644 --- a/awx/ui/client/legacy-styles/lists.less +++ b/awx/ui/client/legacy-styles/lists.less @@ -80,6 +80,28 @@ table, tbody { padding-left: 10px; } +.List-tableRow--disabled { + .List-tableCell, .List-tableCell * { + color: @b7grey; + cursor: not-allowed; + } +} + +.List-tableRow--disabled { + .List-actionButton:hover { + color: @list-action-icon; + background-color: @list-actn-bg !important; + } +} + +.List-tableRow--disabled { + .List-actionButtonCell * { + color: @default-err; + font-size: 11px; + text-transform: uppercase; + } +} + .List-tableCell { padding: 7px 15px; border-top:0px!important; diff --git a/awx/ui/client/lib/components/_index.less b/awx/ui/client/lib/components/_index.less index d26fbbc11d..729d577b54 100644 --- a/awx/ui/client/lib/components/_index.less +++ b/awx/ui/client/lib/components/_index.less @@ -1,7 +1,7 @@ @import 'action/_index'; @import 'input/_index'; -@import 'panel/_index'; @import 'modal/_index'; +@import 'panel/_index'; @import 'popover/_index'; @import 'tabs/_index'; @import 'utility/_index'; diff --git a/awx/ui/client/lib/components/action/_index.less b/awx/ui/client/lib/components/action/_index.less index 95231f8a41..6208e2a41d 100644 --- a/awx/ui/client/lib/components/action/_index.less +++ b/awx/ui/client/lib/components/action/_index.less @@ -1,7 +1,7 @@ .at-ActionGroup { - margin-top: @at-space-6x; + margin-top: @at-margin-panel; button:last-child { - margin-left: @at-space-5x; + margin-left: @at-margin-panel-inset; } } diff --git a/awx/ui/client/lib/components/form/action.directive.js b/awx/ui/client/lib/components/form/action.directive.js index 883e94cb89..1cc9c4ded2 100644 --- a/awx/ui/client/lib/components/form/action.directive.js +++ b/awx/ui/client/lib/components/form/action.directive.js @@ -38,14 +38,14 @@ function atFormActionController ($state) { vm.setCancelDefaults = () => { scope.text = 'CANCEL'; scope.fill = 'Hollow'; - scope.color = 'white'; - scope.action = () => $state.go('^'); + scope.color = 'default'; + scope.action = () => $state.go(scope.to || '^'); }; vm.setSaveDefaults = () => { scope.text = 'SAVE'; scope.fill = ''; - scope.color = 'green'; + scope.color = 'success'; scope.action = () => form.submit(); }; } @@ -64,7 +64,8 @@ function atFormAction (pathService) { link, scope: { state: '=', - type: '@' + type: '@', + to: '@' } }; } diff --git a/awx/ui/client/lib/components/form/form.directive.js b/awx/ui/client/lib/components/form/form.directive.js index 7838ace48b..d85ca856fd 100644 --- a/awx/ui/client/lib/components/form/form.directive.js +++ b/awx/ui/client/lib/components/form/form.directive.js @@ -2,6 +2,9 @@ function atFormLink (scope, el, attrs, controllers) { let formController = controllers[0]; let form = el[0]; + scope.ns = 'form'; + scope[scope.ns] = { modal: {} }; + formController.init(scope, form); } @@ -9,10 +12,10 @@ function AtFormController (eventService) { let vm = this || {}; let scope; + let modal; let form; vm.components = []; - vm.modal = {}; vm.state = { isValid: false, disabled: false, @@ -22,6 +25,7 @@ function AtFormController (eventService) { vm.init = (_scope_, _form_) => { scope = _scope_; form = _form_; + modal = scope[scope.ns].modal; vm.setListeners(); }; @@ -102,7 +106,7 @@ function AtFormController (eventService) { message = err.data; } - vm.modal.show('Unable to Submit', `Unexpected Error: ${message}`); + modal.show('Unable to Submit', `Unexpected Error: ${message}`); } }; @@ -110,7 +114,7 @@ function AtFormController (eventService) { let title = 'Unable to Submit'; let message = 'Unexpected server error. View the console for more information'; - vm.modal.show(title, message); + modal.show(title, message); return true; }; diff --git a/awx/ui/client/lib/components/form/form.partial.html b/awx/ui/client/lib/components/form/form.partial.html index dd2d00b40e..881f857fe6 100644 --- a/awx/ui/client/lib/components/form/form.partial.html +++ b/awx/ui/client/lib/components/form/form.partial.html @@ -5,5 +5,5 @@ - + diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index 3f24c7376b..e9a79c8d4c 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -7,8 +7,6 @@ import inputGroup from './input/group.directive'; import inputLabel from './input/label.directive'; import inputLookup from './input/lookup.directive'; import inputMessage from './input/message.directive'; -import inputNumber from './input/number.directive'; -import inputSelect from './input/select.directive'; import inputSecret from './input/secret.directive'; import inputText from './input/text.directive'; import inputTextarea from './input/textarea.directive'; @@ -34,9 +32,7 @@ angular .directive('atInputLabel', inputLabel) .directive('atInputLookup', inputLookup) .directive('atInputMessage', inputMessage) - .directive('atInputNumber', inputNumber) .directive('atInputSecret', inputSecret) - .directive('atInputSelect', inputSelect) .directive('atInputText', inputText) .directive('atInputTextarea', inputTextarea) .directive('atInputTextareaSecret', inputTextareaSecret) diff --git a/awx/ui/client/lib/components/input/_index.less b/awx/ui/client/lib/components/input/_index.less index bc04b24246..bff0e535d6 100644 --- a/awx/ui/client/lib/components/input/_index.less +++ b/awx/ui/client/lib/components/input/_index.less @@ -1,17 +1,21 @@ .at-Input { - .at-mixin-Placeholder(@at-gray-dark-3x); + .at-mixin-Placeholder(@at-color-input-placeholder); - height: @at-input-height; - background: @at-white; + height: @at-height-input; + background: @at-color-input-background; border-radius: @at-border-radius; - color: @at-gray-dark-5x; + color: @at-color-input-text; &, &:active { - border-color: @at-gray-dark-2x; + border-color: @at-color-input-border; } &:focus { - border-color: @at-blue; + border-color: @at-color-input-focus; + } + + &[disabled] { + background: @at-color-input-disabled; } } @@ -21,43 +25,43 @@ & > label { & > input[type=checkbox] { - height: @at-input-height; + height: @at-height-input; margin: 0; padding: 0; } & > p { margin: 0; - padding: 0 0 0 @at-space-6x; + padding: 0 0 0 @at-padding-panel; line-height: @at-line-height-tall; } } } .at-InputContainer { - margin-top: @at-space-6x; + margin-top: @at-margin-panel; } .at-Input-button { - min-width: @at-input-button-width; display: block; - height: @at-input-height; + height: @at-height-button; + line-height: 1; &, &:active, &:hover, &:focus { - color: @at-gray-dark-3x; - border-color: @at-gray-dark-2x; - background-color: @at-white; + color: @at-color-button-text-default; + border-color: @at-color-input-border; + background-color: @at-color-default; cursor: pointer; } } .at-Input--focus { - border-color: @at-blue; + border-color: @at-color-input-focus; } .at-Input--rejected { &, &:focus { - border-color: @at-red; + border-color: @at-color-input-error; } } @@ -66,7 +70,6 @@ height: 100%; width: 100%; left: 0; - right: @at-input-button-width; z-index: -2; opacity: 0; } @@ -77,15 +80,15 @@ .at-InputGroup { padding: 0; - margin: @at-space-6x 0 0 0; + margin: @at-margin-panel 0 0 0; } .at-InputGroup-border { position: absolute; - width: @at-inset-width; + width: 5px; height: 100%; - background: @at-gray-dark; - left: -@at-inset-width; + background: @at-color-panel-border; + left: -5px; } .at-InputGroup-button { @@ -93,19 +96,22 @@ & > button { height: 100%; + border-right: none; + color: @at-color-button-text-default; + min-width: @at-input-button-width; } } .at-InputGroup-title { - .at-mixin-Heading(@at-font-size-2x); - margin: 0 0 0 @at-space-5x; + .at-mixin-Heading(@at-font-size-panel-inset-heading); + margin: 0 0 0 @at-margin-panel-inset; } .at-InputGroup-divider { clear: both; margin: 0; padding: 0; - height: @at-space-6x; + height: @at-height-divider; } .at-InputLabel { @@ -114,17 +120,17 @@ } .at-InputLabel-name { - color: @at-gray-dark-4x; - font-size: @at-font-size-2x; - font-weight: @at-font-weight; + color: @at-color-form-label; + font-size: @at-font-size-form-label; + font-weight: @at-font-weight-body; text-transform: uppercase; } .at-InputLabel-hint { - margin-left: @at-space-4x; - color: @at-gray-dark-3x; - font-size: @at-font-size; - font-weight: @at-font-weight; + margin-left: @at-margin-form-label-hint; + color: @at-color-input-hint; + font-size: @at-font-size-help-text; + font-weight: @at-font-weight-body; line-height: @at-line-height-short; } @@ -137,15 +143,15 @@ margin-bottom: 0; & > input[type=checkbox] { - margin: 0 @at-space 0 0; + margin: 0 3px 0 0; position: relative; - top: @at-space; + top: 3px } & > p { - font-size: @at-font-size; - color: @at-gray-dark-4x; - font-weight: @at-font-weight; + font-size: @at-font-size-help-text; + color: @at-color-form-label; + font-weight: @at-font-weight-body; display: inline; margin: 0; padding: 0; @@ -153,16 +159,16 @@ } .at-InputMessage--rejected { - font-size: @at-font-size; - color: @at-red; - margin: @at-space-3x 0 0 0; + font-size: @at-font-size-help-text; + color: @at-color-error; + margin: @at-margin-input-message 0 0 0; padding: 0; } .at-InputLabel-required { - color: @at-red; - font-weight: @at-font-weight-2x; - font-size: @at-font-size-2x; + color: @at-color-error; + font-weight: @at-font-weight-heading; + font-size: @at-font-size-form-label; margin: 0; } @@ -171,13 +177,13 @@ width: 100%; & > i { - font-size: @at-font-size; + font-size: @at-font-size-button; position: absolute; z-index: 3; pointer-events: none; - top: @at-space-4x; - right: @at-space-4x; - color: @at-gray-dark-2x; + top: @at-height-input / 3; + right: @at-height-input / 3; + color: @at-color-input-icon; } } @@ -188,7 +194,7 @@ } .at-InputSelect-select { - height: @at-input-height; + height: @at-height-input; cursor: pointer; position: absolute; z-index: 1; diff --git a/awx/ui/client/lib/components/input/group.directive.js b/awx/ui/client/lib/components/input/group.directive.js index 5ffb8029b1..550cd1c0ef 100644 --- a/awx/ui/client/lib/components/input/group.directive.js +++ b/awx/ui/client/lib/components/input/group.directive.js @@ -49,7 +49,6 @@ function AtInputGroupController ($scope, $compile) { vm.insert(group); state._group = group; - vm.compile(group); }; vm.createComponentConfigs = inputs => { @@ -138,20 +137,22 @@ function AtInputGroupController ($scope, $compile) { vm.createComponent = (input, index) => { let tabindex = Number(scope.tab) + index; let col = input._expand ? 12 : scope.col; - - return angular.element( + let component = angular.element( `<${input._component} col="${col}" tab="${tabindex}" state="${state._reference}._group[${index}]"> ` ); + + $compile(component)(scope.$parent) + + return component; }; vm.createDivider = () => { - return angular.element(''); - }; + let divider = angular.element(''); + $compile(divider[0])(scope.$parent); - vm.compile = group => { - group.forEach(component => $compile(component._element[0])(scope.$parent)); + return divider; }; vm.clear = () => { diff --git a/awx/ui/client/lib/components/input/lookup.directive.js b/awx/ui/client/lib/components/input/lookup.directive.js index 950edeca39..50e7ddef88 100644 --- a/awx/ui/client/lib/components/input/lookup.directive.js +++ b/awx/ui/client/lib/components/input/lookup.directive.js @@ -9,43 +9,46 @@ function atInputLookupLink (scope, element, attrs, controllers) { inputController.init(scope, element, formController); } -function AtInputLookupController (baseInputController) { +function AtInputLookupController (baseInputController, $state, $stateParams) { let vm = this || {}; - vm.lookup = {}; + let scope; - vm.init = (scope, element, form) => { - baseInputController.call(vm, 'input', scope, element, form); + vm.init = (_scope_, element, form) => { + baseInputController.call(vm, 'input', _scope_, element, form); - vm.lookup.modal = { - title: 'Select Organization', - buttons: [ - { - type: 'cancel' - }, - { - type: 'select' - } - ] - }; + scope = _scope_; - vm.lookup.search = { - placeholder: 'test' - }; - - vm.lookup.table = { - - }; + scope.$watch(scope.state._resource, vm.watchResource); vm.check(); }; + vm.watchResource = () => { + if (scope[scope.state._resource]) { + scope.state._value = scope[scope.state._resource]; + scope.state._displayValue = scope[`${scope.state._resource}_name`]; + + vm.check(); + } + }; + vm.search = () => { - vm.modal.show('test'); + let params = {}; + + if (scope.state._value) { + params.selected = scope.state._value; + } + + $state.go(scope.state._route, params); }; } -AtInputLookupController.$inject = ['BaseInputController']; +AtInputLookupController.$inject = [ + 'BaseInputController', + '$state', + '$stateParams' +]; function atInputLookup (pathService) { return { diff --git a/awx/ui/client/lib/components/input/lookup.partial.html b/awx/ui/client/lib/components/input/lookup.partial.html index d5f0ca89b7..6f4d7a0a68 100644 --- a/awx/ui/client/lib/components/input/lookup.partial.html +++ b/awx/ui/client/lib/components/input/lookup.partial.html @@ -4,7 +4,7 @@
-
- - - - +
diff --git a/awx/ui/client/lib/components/input/number.directive.js b/awx/ui/client/lib/components/input/number.directive.js deleted file mode 100644 index be803212de..0000000000 --- a/awx/ui/client/lib/components/input/number.directive.js +++ /dev/null @@ -1,54 +0,0 @@ -const DEFAULT_STEP = '1'; -const DEFAULT_MIN = '0'; -const DEFAULT_MAX = '1000000000'; -const DEFAULT_PLACEHOLDER = ''; - -function atInputNumberLink (scope, element, attrs, controllers) { - let formController = controllers[0]; - let inputController = controllers[1]; - - if (scope.tab === '1') { - element.find('input')[0].focus(); - } - - inputController.init(scope, element, formController); -} - -function AtInputNumberController (baseInputController) { - let vm = this || {}; - - vm.init = (scope, element, form) => { - baseInputController.call(vm, 'input', scope, element, form); - - scope.state._step = scope.state._step || DEFAULT_STEP; - scope.state._min = scope.state._min || DEFAULT_MIN; - scope.state._max = scope.state._max || DEFAULT_MAX; - scope.state._placeholder = scope.state._placeholder || DEFAULT_PLACEHOLDER; - - vm.check(); - }; -} - -AtInputNumberController.$inject = ['BaseInputController']; - -function atInputNumber (pathService) { - return { - restrict: 'E', - transclude: true, - replace: true, - require: ['^^atForm', 'atInputNumber'], - templateUrl: pathService.getPartialPath('components/input/number'), - controller: AtInputNumberController, - controllerAs: 'vm', - link: atInputNumberLink, - scope: { - state: '=', - col: '@', - tab: '@' - } - }; -} - -atInputNumber.$inject = ['PathService']; - -export default atInputNumber; diff --git a/awx/ui/client/lib/components/input/number.partial.html b/awx/ui/client/lib/components/input/number.partial.html deleted file mode 100644 index 57aa355bfa..0000000000 --- a/awx/ui/client/lib/components/input/number.partial.html +++ /dev/null @@ -1,19 +0,0 @@ -
-
- - - - - -
-
diff --git a/awx/ui/client/lib/components/modal/_index.less b/awx/ui/client/lib/components/modal/_index.less index 11e962e98b..ba5ac620ec 100644 --- a/awx/ui/client/lib/components/modal/_index.less +++ b/awx/ui/client/lib/components/modal/_index.less @@ -1,10 +1,28 @@ +.at-Modal-body { + font-size: @at-font-size; + padding: 0; +} + +.at-Modal-dismiss { + .at-mixin-ButtonIcon(); + font-size: @at-font-size-modal-dismiss; + color: @at-color-icon-dismiss; + text-align: right; +} + +.at-Modal-heading { + margin: 0; + overflow: visible; + + & > .at-Modal-dismiss { + margin: 0; + } +} + .at-Modal-title { margin: 0; padding: 0; - .at-mixin-Heading(@at-font-size-3x); + .at-mixin-Heading(@at-font-size-modal-heading); } -.at-Modal-body { - font-size: @at-font-size; -} diff --git a/awx/ui/client/lib/components/modal/modal.directive.js b/awx/ui/client/lib/components/modal/modal.directive.js index 10f18a0afc..2733bb2e1e 100644 --- a/awx/ui/client/lib/components/modal/modal.directive.js +++ b/awx/ui/client/lib/components/modal/modal.directive.js @@ -1,45 +1,74 @@ const DEFAULT_ANIMATION_DURATION = 150; -function atModalLink (scope, el, attr, controllers) { +function atModalLink (scope, el, attrs, controllers) { let modalController = controllers[0]; - let container = el[0]; + let property = `scope.${scope.ns}.modal`; - modalController.init(scope, container); + let done = scope.$watch(property, () => { + modalController.init(scope, el); + done(); + }); } -function AtModalController () { +function AtModalController (eventService) { let vm = this; - let scope; - let container; + let overlay; + let modal; + let listeners; - vm.init = (_scope_, _container_) => { - scope = _scope_; - container = _container_; + vm.init = (scope, el) => { + overlay = el[0]; + modal = el.find('.at-Modal-window')[0]; - scope.state.show = vm.show; - scope.state.hide = vm.hide; + vm.modal = scope[scope.ns].modal; + vm.modal.show = vm.show; + vm.modal.hide = vm.hide; }; vm.show = (title, message) => { - scope.title = title; - scope.message = message; + vm.modal.title = title; + vm.modal.message = message; - container.style.display = 'block'; - container.style.opacity = 1; + event.stopPropagation(); + + listeners = eventService.addListeners([ + [window, 'click', vm.clickToHide] + ]); + + overlay.style.display = 'block'; + overlay.style.opacity = 1; }; vm.hide = () => { - container.style.opacity = 0; + overlay.style.opacity = 0; - setTimeout(() => { - container.style.display = 'none'; - scope.message = ''; - scope.title = ''; - }, DEFAULT_ANIMATION_DURATION); + eventService.remove(listeners); + + setTimeout(() => overlay.style.display = 'none', DEFAULT_ANIMATION_DURATION); + }; + + vm.clickToHide = event => { + if (vm.clickIsOutsideModal(event)) { + vm.hide(); + } + }; + + vm.clickIsOutsideModal = e => { + let m = modal.getBoundingClientRect(); + let cx = e.clientX; + let cy = e.clientY; + + if (cx < m.left || cx > m.right || cy > m.bottom || cy < m.top) { + return true; + } + + return false; }; } +AtModalController.$inject = ['EventService']; + function atModal (pathService) { return { restrict: 'E', @@ -50,9 +79,7 @@ function atModal (pathService) { controller: AtModalController, controllerAs: 'vm', link: atModalLink, - scope: { - state: '=' - } + scope: true }; } diff --git a/awx/ui/client/lib/components/modal/modal.partial.html b/awx/ui/client/lib/components/modal/modal.partial.html index 9d96d1ff2a..66db4d9d49 100644 --- a/awx/ui/client/lib/components/modal/modal.partial.html +++ b/awx/ui/client/lib/components/modal/modal.partial.html @@ -1,20 +1,31 @@