diff --git a/awx/ui/client/features/applications/add-applications.controller.js b/awx/ui/client/features/applications/add-applications.controller.js
new file mode 100644
index 0000000000..5a23f4194e
--- /dev/null
+++ b/awx/ui/client/features/applications/add-applications.controller.js
@@ -0,0 +1,79 @@
+function AddApplicationsController (models, $state, strings) {
+ const vm = this || {};
+
+ const { application, me, organization } = models;
+ const omit = [
+ 'authorization_grant_type',
+ 'client_id',
+ 'client_secret',
+ 'client_type',
+ 'created',
+ 'modified',
+ 'related',
+ 'skip_authorization',
+ 'summary_fields',
+ 'type',
+ 'url',
+ 'user'
+ ];
+
+ vm.mode = 'add';
+ vm.strings = strings;
+ vm.panelTitle = strings.get('add.PANEL_TITLE');
+
+ vm.tab = {
+ details: { _active: true },
+ permissions: { _disabled: true },
+ users: { _disabled: true }
+ };
+
+ vm.form = application.createFormSchema('post', { omit });
+
+ vm.form.organization = {
+ type: 'field',
+ label: 'Organization',
+ id: 'organization'
+ };
+ vm.form.description = {
+ type: 'String',
+ label: 'Description',
+ id: 'description'
+ };
+
+ vm.form.disabled = !application.isCreatable();
+
+ vm.form.organization._resource = 'organization';
+ vm.form.organization._route = 'applications.add.organization';
+ vm.form.organization._model = organization;
+ vm.form.organization._placeholder = strings.get('SELECT AN ORGANIZATION');
+
+ vm.form.name.required = true;
+ vm.form.organization.required = true;
+ vm.form.redirect_uris.required = true;
+
+ delete vm.form.name.help_text;
+
+ vm.form.save = data => {
+ const hiddenData = {
+ authorization_grant_type: 'implicit',
+ user: me.get('id'),
+ client_type: 'public'
+ };
+
+ const payload = _.merge(data, hiddenData);
+
+ return application.request('post', { data: payload });
+ };
+
+ vm.form.onSaveSuccess = res => {
+ $state.go('applications.edit', { application_id: res.data.id }, { reload: true });
+ };
+}
+
+AddApplicationsController.$inject = [
+ 'resolvedModels',
+ '$state',
+ 'ApplicationsStrings'
+];
+
+export default AddApplicationsController;
diff --git a/awx/ui/client/features/applications/add-edit-applications.view.html b/awx/ui/client/features/applications/add-edit-applications.view.html
new file mode 100644
index 0000000000..af1c7c41c3
--- /dev/null
+++ b/awx/ui/client/features/applications/add-edit-applications.view.html
@@ -0,0 +1,36 @@
+
+
+ {{ vm.panelTitle }}
+
+
+
+ {{:: vm.strings.get('tab.DETAILS') }}
+ {{:: vm.strings.get('tab.PERMISSIONS') }}
+ {{:: vm.strings.get('tab.USERS') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awx/ui/client/features/applications/applications.strings.js b/awx/ui/client/features/applications/applications.strings.js
new file mode 100644
index 0000000000..7f7f0eee0d
--- /dev/null
+++ b/awx/ui/client/features/applications/applications.strings.js
@@ -0,0 +1,34 @@
+function ApplicationsStrings (BaseString) {
+ BaseString.call(this, 'applications');
+
+ const { t } = this;
+ const ns = this.applications;
+
+ ns.state = {
+ LIST_BREADCRUMB_LABEL: t.s('APPLICATIONS'),
+ ADD_BREADCRUMB_LABEL: t.s('CREATE APPLICATION'),
+ EDIT_BREADCRUMB_LABEL: t.s('EDIT APPLICATION'),
+ USER_LIST_BREADCRUMB_LABEL: t.s('USERS'),
+ PERMISSIONS_BREADCRUMB_LABEL: t.s('PERMISSIONS')
+ };
+
+ ns.tab = {
+ DETAILS: t.s('Details'),
+ PERMISSIONS: t.s('Permissions'),
+ USERS: t.s('Users')
+ };
+
+ ns.add = {
+ PANEL_TITLE: t.s('NEW APPLICATION')
+ };
+
+ ns.list = {
+ ROW_ITEM_LABEL_EXPIRED: t.s('EXPIRATION'),
+ ROW_ITEM_LABEL_ORGANIZATION: t.s('ORG'),
+ ROW_ITEM_LABEL_MODIFIED: t.s('LAST MODIFIED')
+ };
+}
+
+ApplicationsStrings.$inject = ['BaseStringService'];
+
+export default ApplicationsStrings;
diff --git a/awx/ui/client/features/applications/edit-applications.controller.js b/awx/ui/client/features/applications/edit-applications.controller.js
new file mode 100644
index 0000000000..f7369d9023
--- /dev/null
+++ b/awx/ui/client/features/applications/edit-applications.controller.js
@@ -0,0 +1,124 @@
+function EditApplicationsController (models, $state, strings, $scope) {
+ const vm = this || {};
+
+ const { me, application, organization } = models;
+
+ const omit = [
+ 'authorization_grant_type',
+ 'client_id',
+ 'client_secret',
+ 'client_type',
+ 'created',
+ 'modified',
+ 'related',
+ 'skip_authorization',
+ 'summary_fields',
+ 'type',
+ 'url',
+ 'user'
+ ];
+ const isEditable = application.isEditable();
+
+ vm.mode = 'edit';
+ vm.strings = strings;
+ vm.panelTitle = application.get('name');
+
+ vm.tab = {
+ details: {
+ _active: true,
+ _go: 'applications.edit',
+ _params: { application_id: application.get('id') }
+ },
+ permissions: {
+ _go: 'applications.edit.permissions',
+ _params: { application_id: application.get('id') }
+ },
+ users: {
+ _go: 'applications.edit.users',
+ _params: { application_id: application.get('id') }
+ }
+ };
+
+ $scope.$watch('$state.current.name', (value) => {
+ if (/applications.edit.users/.test(value)) {
+ vm.tab.details._active = false;
+ vm.tab.permissions._active = false;
+ vm.tab.users._active = true;
+ } else if (/applications.edit($|\.organization$)/.test(value)) {
+ vm.tab.details._active = true;
+ vm.tab.permissions._active = false;
+ vm.tab.users._active = false;
+ } else {
+ vm.tab.details._active = false;
+ vm.tab.permissions._active = true;
+ vm.tab.users._active = false;
+ }
+ });
+
+ // Only exists for permissions compatibility
+ $scope.application_obj = application.get();
+
+ if (isEditable) {
+ vm.form = application.createFormSchema('put', { omit });
+ } else {
+ vm.form = application.createFormSchema({ omit });
+ vm.form.disabled = !isEditable;
+ }
+
+ vm.form.disabled = !isEditable;
+
+ const isOrgAdmin = _.some(me.get('related.admin_of_organizations.results'), (org) => org.id === organization.get('id'));
+ const isSuperuser = me.get('is_superuser');
+ const isCurrentAuthor = Boolean(application.get('summary_fields.created_by.id') === me.get('id'));
+
+ vm.form.organization = {
+ type: 'field',
+ label: 'Organization',
+ id: 'organization'
+ };
+ vm.form.description = {
+ type: 'String',
+ label: 'Description',
+ id: 'description'
+ };
+
+ vm.form.organization._resource = 'organization';
+ vm.form.organization._route = 'applications.add.organization';
+ vm.form.organization._model = organization;
+ vm.form.organization._placeholder = strings.get('SELECT AN ORGANIZATION');
+
+ // TODO: org not returned via api endpoint, check on this
+ vm.form.organization._value = application.get('organization');
+
+ vm.form.organization._disabled = true;
+ if (isSuperuser || isOrgAdmin || (application.get('organization') === null && isCurrentAuthor)) {
+ vm.form.organization._disabled = false;
+ }
+
+ vm.form.name.required = true;
+ vm.form.organization.required = true;
+ vm.form.redirect_uris.required = true;
+
+ delete vm.form.name.help_text;
+
+ vm.form.save = data => {
+ const hiddenData = {
+ authorization_grant_type: 'implicit',
+ user: me.get('id'),
+ client_type: 'public'
+ };
+
+ const payload = _.merge(data, hiddenData);
+
+ return application.request('post', { data: payload });
+ };
+}
+
+EditApplicationsController.$inject = [
+ 'resolvedModels',
+ '$state',
+ 'ApplicationsStrings',
+ '$scope'
+];
+
+export default EditApplicationsController;
diff --git a/awx/ui/client/features/applications/index.js b/awx/ui/client/features/applications/index.js
new file mode 100644
index 0000000000..4844b90609
--- /dev/null
+++ b/awx/ui/client/features/applications/index.js
@@ -0,0 +1,437 @@
+
+import AddController from './add-applications.controller';
+import EditController from './edit-applications.controller';
+import ListController from './list-applications.controller';
+import UserListController from './list-applications-users.controller';
+import ApplicationsStrings from './applications.strings';
+import { N_ } from '../../src/i18n';
+
+const MODULE_NAME = 'at.features.applications';
+
+const addEditTemplate = require('~features/applications/add-edit-applications.view.html');
+const listTemplate = require('~features/applications/list-applications.view.html');
+const indexTemplate = require('~features/applications/index.view.html');
+const userListTemplate = require('~features/applications/list-applications-users.view.html');
+
+function ApplicationsDetailResolve ($q, $stateParams, Me, Application, Organization) {
+ const id = $stateParams.application_id;
+
+ const promises = {
+ me: new Me('get').then((me) => me.extend('get', 'admin_of_organizations'))
+ };
+
+ if (!id) {
+ promises.application = new Application('options');
+ promises.organization = new Organization();
+
+ return $q.all(promises);
+ }
+
+ promises.application = new Application(['get', 'options'], [id, id]);
+
+ return $q.all(promises)
+ .then(models => {
+ const orgId = models.application.get('organization');
+
+ const dependents = {
+ organization: new Organization('get', orgId)
+ };
+
+ return $q.all(dependents)
+ .then(related => {
+ models.organization = related.organization;
+
+ return models;
+ });
+ });
+}
+
+ApplicationsDetailResolve.$inject = [
+ '$q',
+ '$stateParams',
+ 'MeModel',
+ 'ApplicationModel',
+ 'OrganizationModel'
+];
+
+function ApplicationsRun ($stateExtender, strings) {
+ $stateExtender.addState({
+ name: 'applications',
+ route: '/applications',
+ ncyBreadcrumb: {
+ label: strings.get('state.LIST_BREADCRUMB_LABEL')
+ },
+ data: {
+ activityStream: true,
+ // TODO: double-check activity stream works
+ activityStreamTarget: 'application'
+ },
+ views: {
+ '@': {
+ templateUrl: indexTemplate,
+ },
+ 'list@applications': {
+ templateUrl: listTemplate,
+ controller: ListController,
+ controllerAs: 'vm'
+ }
+ },
+ searchPrefix: 'application',
+ resolve: {
+ resolvedModels: [
+ 'ApplicationModel',
+ (Application) => {
+ const app = new Application(['options']);
+ return app;
+ }
+ ],
+ Dataset: [
+ '$stateParams',
+ 'Wait',
+ 'GetBasePath',
+ 'QuerySet',
+ ($stateParams, Wait, GetBasePath, qs) => {
+ const searchParam = $stateParams.application_search;
+ const searchPath = GetBasePath('applications');
+
+ Wait('start');
+ return qs.search(searchPath, searchParam)
+ .finally(() => {
+ Wait('stop');
+ });
+ }
+ ],
+ }
+ });
+
+ $stateExtender.addState({
+ name: 'applications.add',
+ route: '/add',
+ ncyBreadcrumb: {
+ label: strings.get('state.ADD_BREADCRUMB_LABEL')
+ },
+ data: {
+ activityStream: true,
+ // TODO: double-check activity stream works
+ activityStreamTarget: 'application'
+ },
+ views: {
+ 'add@applications': {
+ templateUrl: addEditTemplate,
+ controller: AddController,
+ controllerAs: 'vm'
+ }
+ },
+ resolve: {
+ resolvedModels: ApplicationsDetailResolve
+ }
+ });
+
+ $stateExtender.addState({
+ name: 'applications.edit',
+ route: '/:application_id',
+ ncyBreadcrumb: {
+ label: strings.get('state.EDIT_BREADCRUMB_LABEL')
+ },
+ data: {
+ activityStream: true,
+ activityStreamTarget: 'application',
+ activityStreamId: 'application_id'
+ },
+ views: {
+ 'edit@applications': {
+ templateUrl: addEditTemplate,
+ controller: EditController,
+ controllerAs: 'vm'
+ }
+ },
+ resolve: {
+ resolvedModels: ApplicationsDetailResolve
+ }
+ });
+
+ $stateExtender.addState({
+ name: 'applications.add.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: {
+ 'organization@applications.add': {
+ templateProvider: (ListDefinition, generateList) => {
+ const html = generateList.build({
+ mode: 'lookup',
+ list: ListDefinition,
+ input_type: 'radio'
+ });
+
+ return `${html}`;
+ }
+ }
+ },
+ resolve: {
+ ListDefinition: ['OrganizationList', list => list],
+ Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath',
+ (list, qs, $stateParams, GetBasePath) => qs.search(
+ GetBasePath('organizations'),
+ $stateParams[`${list.iterator}_search`]
+ )
+ ]
+ },
+ onExit ($state) {
+ if ($state.transition) {
+ $('#form-modal').modal('hide');
+ $('.modal-backdrop').remove();
+ $('body').removeClass('modal-open');
+ }
+ }
+ });
+
+ $stateExtender.addState({
+ name: 'applications.edit.permissions',
+ route: '/permissions?{permission_search:queryset}',
+ ncyBreadcrumb: {
+ label: strings.get('state.PERMISSIONS_BREADCRUMB_LABEL'),
+ parent: 'applications.edit'
+ },
+ params: {
+ permission_search: {
+ dynamic: true,
+ squash: '',
+ value: {
+ page_size: '20',
+ order_by: 'username'
+ }
+ }
+ },
+ resolve: {
+ Dataset: ['QuerySet', '$stateParams', (qs, $stateParams) => {
+ const id = $stateParams.application_id;
+ // TODO: no access_list endpoint given by api
+ const path = `api/v2/applications/${id}/access_list/`;
+
+ return qs.search(path, $stateParams.permission_search);
+ }],
+ ListDefinition: () => ({
+ name: 'permissions',
+ disabled: 'organization === undefined',
+ ngClick: 'organization === undefined || $state.go(\'applications.edit.permissions\')',
+ awToolTip: '{{permissionsTooltip}}',
+ dataTipWatch: 'permissionsTooltip',
+ awToolTipTabEnabledInEditMode: true,
+ dataPlacement: 'right',
+ basePath: 'api/v2/applications/{{$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: '(application_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'
+ }
+ }
+ }),
+ }
+ });
+
+ $stateExtender.addState({
+ name: 'applications.edit.permissions.add',
+ url: '/add-permissions',
+ resolve: {
+ usersDataset: [
+ 'addPermissionsUsersList',
+ 'QuerySet',
+ '$stateParams',
+ 'GetBasePath',
+ 'resourceData',
+ (list, qs, $stateParams, GetBasePath, resourceData) => {
+ let path;
+
+ if (resourceData.data.organization) {
+ path = `${GetBasePath('organizations')}${resourceData.data.organization}/users`;
+ } else {
+ path = list.basePath || GetBasePath(list.name);
+ }
+
+ return qs.search(path, $stateParams.user_search);
+ }
+ ],
+ teamsDataset: [
+ 'addPermissionsTeamsList',
+ 'QuerySet',
+ '$stateParams',
+ 'GetBasePath',
+ 'resourceData',
+ (list, qs, $stateParams, GetBasePath, resourceData) => {
+ const path = GetBasePath(list.basePath) || GetBasePath(list.name);
+ const org = resourceData.data.organization;
+
+ if (!org) {
+ return null;
+ }
+
+ $stateParams[`${list.iterator}_search`].organization = org;
+
+ return qs.search(path, $stateParams.team_search);
+ }
+ ],
+ resourceData: ['ApplicationModel', '$stateParams', (Application, $stateParams) =>
+ new Application('get', $stateParams.application_id)
+ .then(application => ({ data: application.get() }))
+ ]
+ },
+ params: {
+ user_search: {
+ value: {
+ order_by: 'username',
+ page_size: 5,
+ is_superuser: false
+ },
+ dynamic: true
+ },
+ team_search: {
+ value: {
+ order_by: 'name',
+ page_size: 5
+ },
+ dynamic: true
+ }
+ },
+ ncyBreadcrumb: {
+ skip: true
+ },
+ views: {
+ 'modal@applications.edit': {
+ template: `
+
+ `
+ }
+ },
+ onExit: $state => {
+ if ($state.transition) {
+ $('#add-permissions-modal').modal('hide');
+ $('.modal-backdrop').remove();
+ $('body').removeClass('modal-open');
+ }
+ }
+ });
+
+ $stateExtender.addState({
+ name: 'applications.edit.users',
+ route: '/users',
+ ncyBreadcrumb: {
+ label: strings.get('state.USER_LIST_BREADCRUMB_LABEL'),
+ parent: 'applications.edit'
+ },
+ data: {
+ activityStream: true,
+ // TODO: double-check activity stream works
+ activityStreamTarget: 'application'
+ },
+ views: {
+ 'userList@applications.edit': {
+ templateUrl: userListTemplate,
+ controller: UserListController,
+ controllerAs: 'vm'
+ }
+ },
+ params: {
+ user_search: {
+ value: {
+ order_by: 'user',
+ page_size: 20
+ },
+ dynamic: true
+ }
+ },
+ searchPrefix: 'user',
+ resolve: {
+ resolvedModels: [
+ 'ApplicationModel',
+ (Application) => {
+ const app = new Application(['options']);
+ return app;
+ }
+ ],
+ Dataset: [
+ '$stateParams',
+ 'Wait',
+ 'GetBasePath',
+ 'QuerySet',
+ ($stateParams, Wait, GetBasePath, qs) => {
+ const searchParam = $stateParams.user_search;
+ const searchPath = `${GetBasePath('applications')}${$stateParams.application_id}/tokens`;
+
+ Wait('start');
+ return qs.search(searchPath, searchParam)
+ .finally(() => {
+ Wait('stop');
+ });
+ }
+ ],
+ }
+ });
+}
+
+ApplicationsRun.$inject = [
+ '$stateExtender',
+ 'ApplicationsStrings'
+];
+
+angular
+ .module(MODULE_NAME, [])
+ .service('ApplicationsStrings', ApplicationsStrings)
+ .run(ApplicationsRun);
+
+export default MODULE_NAME;
diff --git a/awx/ui/client/features/applications/index.view.html b/awx/ui/client/features/applications/index.view.html
new file mode 100644
index 0000000000..b4135fb791
--- /dev/null
+++ b/awx/ui/client/features/applications/index.view.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/awx/ui/client/features/applications/list-applications-users.controller.js b/awx/ui/client/features/applications/list-applications-users.controller.js
new file mode 100644
index 0000000000..1c6856498b
--- /dev/null
+++ b/awx/ui/client/features/applications/list-applications-users.controller.js
@@ -0,0 +1,55 @@
+/** ***********************************************
+ * Copyright (c) 2018 Ansible, Inc.
+ *
+ * All Rights Reserved
+ ************************************************ */
+function ListApplicationsUsersController (
+ $filter,
+ $scope,
+ Dataset,
+ resolvedModels,
+ strings
+) {
+ const vm = this || {};
+ // const application = resolvedModels;
+
+ vm.strings = strings;
+ // smart-search
+ const name = 'users';
+ const iterator = 'user';
+ const key = 'user_dataset';
+
+ $scope.list = { iterator, name, basePath: 'applications' };
+ $scope.collection = { iterator };
+ $scope[key] = Dataset.data;
+ vm.usersCount = Dataset.data.count;
+ $scope[name] = Dataset.data.results;
+
+ vm.getLastUsed = user => {
+ const lastUsed = _.get(user, 'last_used');
+
+ if (!lastUsed) {
+ return undefined;
+ }
+
+ let html = $filter('longDate')(lastUsed);
+
+ const { username, id } = _.get(user, 'summary_fields.last_used', {});
+
+ if (username && id) {
+ html += ` by ${$filter('sanitize')(username)}`;
+ }
+
+ return html;
+ };
+}
+
+ListApplicationsUsersController.$inject = [
+ '$filter',
+ '$scope',
+ 'Dataset',
+ 'resolvedModels',
+ 'ApplicationsStrings'
+];
+
+export default ListApplicationsUsersController;
diff --git a/awx/ui/client/features/applications/list-applications-users.view.html b/awx/ui/client/features/applications/list-applications-users.view.html
new file mode 100644
index 0000000000..3ab579e565
--- /dev/null
+++ b/awx/ui/client/features/applications/list-applications-users.view.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/awx/ui/client/features/applications/list-applications.controller.js b/awx/ui/client/features/applications/list-applications.controller.js
new file mode 100644
index 0000000000..d035f1cb57
--- /dev/null
+++ b/awx/ui/client/features/applications/list-applications.controller.js
@@ -0,0 +1,146 @@
+/** ***********************************************
+ * Copyright (c) 2018 Ansible, Inc.
+ *
+ * All Rights Reserved
+ ************************************************ */
+function ListApplicationsController (
+ $filter,
+ $scope,
+ $state,
+ Alert,
+ Dataset,
+ ProcessErrors,
+ Prompt,
+ resolvedModels,
+ strings,
+ Wait,
+) {
+ const vm = this || {};
+ const application = resolvedModels;
+
+ vm.strings = strings;
+ vm.activeId = $state.params.application_id;
+
+ $scope.canAdd = application.options('actions.POST');
+
+ // smart-search
+ const name = 'applications';
+ const iterator = 'application';
+ const key = 'application_dataset';
+
+ $scope.list = { iterator, name, basePath: 'applications' };
+ $scope.collection = { iterator };
+ $scope[key] = Dataset.data;
+ vm.applicationsCount = Dataset.data.count;
+ $scope[name] = Dataset.data.results;
+ $scope.$on('updateDataset', (e, dataset) => {
+ $scope[key] = dataset;
+ $scope[name] = dataset.results;
+ vm.applicationsCount = dataset.count;
+ });
+
+ vm.getModified = app => {
+ const modified = _.get(app, 'modified');
+
+ if (!modified) {
+ return undefined;
+ }
+
+ let html = $filter('longDate')(modified);
+
+ const { username, id } = _.get(app, 'summary_fields.modified_by', {});
+
+ if (username && id) {
+ html += ` by ${$filter('sanitize')(username)}`;
+ }
+
+ return html;
+ };
+
+ vm.deleteApplication = app => {
+ if (!app) {
+ Alert(strings.get('error.DELETE'), strings.get('alert.MISSING_PARAMETER'));
+ return;
+ }
+
+ application.getDependentResourceCounts(application.id)
+ .then(counts => displayApplicationDeletePrompt(app, counts));
+ };
+
+ function createErrorHandler (path, action) {
+ return ({ data, status }) => {
+ const hdr = strings.get('error.HEADER');
+ const msg = strings.get('error.CALL', { path, action, status });
+ ProcessErrors($scope, data, status, null, { hdr, msg });
+ };
+ }
+
+ function handleSuccessfulDelete (app) {
+ const { page } = _.get($state.params, 'application_search');
+ let reloadListStateParams = null;
+
+ if ($scope.applications.length === 1 && !_.isEmpty(page) && page !== '1') {
+ reloadListStateParams = _.cloneDeep($state.params);
+ const pageNumber = (parseInt(reloadListStateParams.application_search.page, 0) - 1);
+ reloadListStateParams.application_search.page = pageNumber.toString();
+ }
+
+ if (parseInt($state.params.application_id, 0) === app.id) {
+ $state.go('applications', reloadListStateParams, { reload: true });
+ } else if (parseInt($state.params.application_id, 0) === application.id) {
+ $state.go('applications', reloadListStateParams, { reload: true });
+ } else {
+ $state.go('.', reloadListStateParams, { reload: true });
+ }
+ }
+
+ function displayApplicationDeletePrompt (app, counts) {
+ Prompt({
+ action () {
+ $('#prompt-modal').modal('hide');
+ Wait('start');
+ application
+ .request('delete', app.id)
+ .then(() => handleSuccessfulDelete(app))
+ .catch(createErrorHandler('delete application', 'DELETE'))
+ .finally(() => Wait('stop'));
+ },
+ hdr: strings.get('DELETE'),
+ resourceName: $filter('sanitize')(app.name),
+ body: buildApplicationDeletePromptHTML(counts),
+ });
+ }
+
+ function buildApplicationDeletePromptHTML (counts) {
+ const buildCount = count => `${count}`;
+ const buildLabel = label => `
+ ${$filter('sanitize')(label)}`;
+ const buildCountLabel = ({ count, label }) => `
+ ${buildLabel(label)}${buildCount(count)}
`;
+
+ const displayedCounts = counts.filter(({ count }) => count > 0);
+
+ const html = `
+ ${displayedCounts.length ? strings.get('deleteResource.USED_BY', 'application') : ''}
+ ${strings.get('deleteResource.CONFIRM', 'application')}
+ ${displayedCounts.map(buildCountLabel).join('')}
+ `;
+
+ return html;
+ }
+}
+
+ListApplicationsController.$inject = [
+ '$filter',
+ '$scope',
+ '$state',
+ 'Alert',
+ 'Dataset',
+ 'ProcessErrors',
+ 'Prompt',
+ 'resolvedModels',
+ 'ApplicationsStrings',
+ 'Wait'
+];
+
+export default ListApplicationsController;
diff --git a/awx/ui/client/features/applications/list-applications.view.html b/awx/ui/client/features/applications/list-applications.view.html
new file mode 100644
index 0000000000..839178607f
--- /dev/null
+++ b/awx/ui/client/features/applications/list-applications.view.html
@@ -0,0 +1,65 @@
+
+
+ APPLICATIONS
+
+ {{ vm.applicationsCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awx/ui/client/features/index.js b/awx/ui/client/features/index.js
index 1cb67b73d4..75093bf39c 100644
--- a/awx/ui/client/features/index.js
+++ b/awx/ui/client/features/index.js
@@ -2,6 +2,7 @@ import atLibServices from '~services';
import atLibComponents from '~components';
import atLibModels from '~models';
+import atFeaturesApplications from '~features/applications';
import atFeaturesCredentials from '~features/credentials';
import atFeaturesTemplates from '~features/templates';
@@ -11,6 +12,7 @@ angular.module(MODULE_NAME, [
atLibServices,
atLibComponents,
atLibModels,
+ atFeaturesApplications,
atFeaturesCredentials,
atFeaturesTemplates
]);
diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js
index a827ee890c..70c2a7d6de 100644
--- a/awx/ui/client/lib/components/components.strings.js
+++ b/awx/ui/client/lib/components/components.strings.js
@@ -73,6 +73,7 @@ function ComponentsStrings (BaseString) {
MANAGEMENT_JOBS: t.s('Management Jobs'),
INSTANCES: t.s('Instances'),
INSTANCE_GROUPS: t.s('Instance Groups'),
+ APPLICATIONS: t.s('Applications'),
SETTINGS: t.s('Settings'),
FOOTER_ABOUT: t.s('About'),
FOOTER_COPYRIGHT: t.s('Copyright © 2017 Red Hat, Inc.')
diff --git a/awx/ui/client/lib/components/layout/_index.less b/awx/ui/client/lib/components/layout/_index.less
index 96159da01e..fd4e29c144 100644
--- a/awx/ui/client/lib/components/layout/_index.less
+++ b/awx/ui/client/lib/components/layout/_index.less
@@ -110,6 +110,10 @@
padding: @at-padding-side-nav-item-icon;
}
+ i.fa-cubes {
+ margin-left: -4px;
+ }
+
&:hover,
&.is-active {
background: @at-color-side-nav-item-background-hover;
@@ -119,12 +123,16 @@
color: @at-color-side-nav-content;
margin-left: @at-highlight-left-border-margin-makeup;
}
+
+ i.fa-cubes {
+ margin-left: -9px;
+ }
}
}
.at-Layout-sideNavSpacer {
background: inherit;
- height: @at-height-side-nav-spacer;
+ height: 5px;
}
&--expanded {
@@ -213,4 +221,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/awx/ui/client/lib/components/layout/layout.partial.html b/awx/ui/client/lib/components/layout/layout.partial.html
index 0ffa4a1ad1..b1ada5e000 100644
--- a/awx/ui/client/lib/components/layout/layout.partial.html
+++ b/awx/ui/client/lib/components/layout/layout.partial.html
@@ -71,6 +71,9 @@
system-admin-only="true">
+
+
diff --git a/awx/ui/client/lib/components/list/list.directive.js b/awx/ui/client/lib/components/list/list.directive.js
index 28c3c546ea..6ff88e506f 100644
--- a/awx/ui/client/lib/components/list/list.directive.js
+++ b/awx/ui/client/lib/components/list/list.directive.js
@@ -1,6 +1,10 @@
const templateUrl = require('~components/list/list.partial.html');
-// TODO: figure out emptyListReason scope property
+function atListLink (scope, element, attrs) {
+ if (!attrs.results) {
+ throw new Error('at-list directive requires results attr to set up the empty list properly');
+ }
+}
function AtListController (strings) {
this.strings = strings;
@@ -17,6 +21,7 @@ function atList () {
scope: {
results: '=',
},
+ link: atListLink,
controller: AtListController,
controllerAs: 'vm',
};
diff --git a/awx/ui/client/lib/models/Application.js b/awx/ui/client/lib/models/Application.js
new file mode 100644
index 0000000000..5c8cdbd066
--- /dev/null
+++ b/awx/ui/client/lib/models/Application.js
@@ -0,0 +1,51 @@
+let Base;
+
+function createFormSchema (method, config) {
+ if (!config) {
+ config = method;
+ method = 'GET';
+ }
+
+ const schema = Object.assign({}, this.options(`actions.${method.toUpperCase()}`));
+
+ if (config && config.omit) {
+ config.omit.forEach(key => delete schema[key]);
+ }
+
+ Object.keys(schema).forEach(key => {
+ schema[key].id = key;
+
+ if (this.has(key)) {
+ schema[key]._value = this.get(key);
+ }
+ });
+
+ return schema;
+}
+
+function setDependentResources () {
+ this.dependentResources = [];
+}
+
+function ApplicationModel (method, resource, config) {
+ // TODO: change to applications
+ Base.call(this, 'applications');
+
+ this.Constructor = ApplicationModel;
+ this.createFormSchema = createFormSchema.bind(this);
+ this.setDependentResources = setDependentResources.bind(this);
+
+ return this.create(method, resource, config);
+}
+
+function ApplicationModelLoader (BaseModel) {
+ Base = BaseModel;
+
+ return ApplicationModel;
+}
+
+ApplicationModelLoader.$inject = [
+ 'BaseModel',
+];
+
+export default ApplicationModelLoader;
diff --git a/awx/ui/client/lib/models/Base.js b/awx/ui/client/lib/models/Base.js
index 01392eafb9..a2c202aa79 100644
--- a/awx/ui/client/lib/models/Base.js
+++ b/awx/ui/client/lib/models/Base.js
@@ -361,7 +361,11 @@ function normalizePath (resource) {
}
function isEditable () {
- const canEdit = this.get('summary_fields.user_capabilities.edit');
+ let canEdit = this.get('summary_fields.user_capabilities.edit');
+
+ if (canEdit === undefined) {
+ canEdit = true;
+ }
if (canEdit) {
return true;
diff --git a/awx/ui/client/lib/models/index.js b/awx/ui/client/lib/models/index.js
index fdecf596fd..9f3e57b3cd 100644
--- a/awx/ui/client/lib/models/index.js
+++ b/awx/ui/client/lib/models/index.js
@@ -1,5 +1,6 @@
import atLibServices from '~services';
+import Application from '~models/Application';
import Base from '~models/Base';
import Config from '~models/Config';
import Credential from '~models/Credential';
@@ -28,6 +29,7 @@ angular
.module(MODULE_NAME, [
atLibServices
])
+ .service('ApplicationModel', Application)
.service('BaseModel', Base)
.service('ConfigModel', Config)
.service('CredentialModel', Credential)
diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js
index 0fd75d8280..ab93141b7d 100644
--- a/awx/ui/client/src/app.js
+++ b/awx/ui/client/src/app.js
@@ -316,8 +316,23 @@ angular
activateTab();
});
- $transitions.onSuccess({}, function(trans) {
+ $transitions.onCreate({}, function(trans) {
+ console.log('$onCreate ' +trans.to().name);
+ });
+ $transitions.onBefore({}, function(trans) {
+ console.log('$onBefore ' +trans.to().name);
+ });
+ $transitions.onError({}, function(trans) {
+
+ console.log('$onError ' +trans.to().name);
+ });
+ $transitions.onExit({}, function(trans) {
+ console.log('$onExit ' +trans.to().name);
+ });
+
+ $transitions.onSuccess({}, function(trans) {
+ console.log('$onSuccess ' +trans.to().name);
if(trans.to() === trans.from()) {
// check to see if something other than a search param has changed
let toParamsWithoutSearchKeys = {};
diff --git a/awx/ui/client/src/users/tokens/application.list.js b/awx/ui/client/src/users/tokens/application.list.js
new file mode 100644
index 0000000000..37df5c2717
--- /dev/null
+++ b/awx/ui/client/src/users/tokens/application.list.js
@@ -0,0 +1,22 @@
+export default ['i18n', function(i18n) {
+ return {
+
+ name: 'applications',
+ search: {
+ order_by: 'id'
+ },
+ iterator: 'application',
+ // TODO: change
+ basePath: 'projects',
+ listTitle: i18n._('APPLICATIONS'),
+ index: false,
+ hover: true,
+
+ fields: {
+ name: {
+ key: true,
+ label: i18n._('Name'),
+ columnClass: 'col-md-3 col-sm-3 col-xs-9'
+ },
+ }
+ };}];
diff --git a/awx/ui/client/src/users/tokens/users-tokens.controller.js b/awx/ui/client/src/users/tokens/users-tokens.controller.js
new file mode 100644
index 0000000000..377e4012ac
--- /dev/null
+++ b/awx/ui/client/src/users/tokens/users-tokens.controller.js
@@ -0,0 +1,108 @@
+/*************************************************
+ * Copyright (c) 2015 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+export default ['Rest', 'Wait', 'UserTokensFormObject',
+ 'ProcessErrors', 'GetBasePath', 'Alert',
+ 'GenerateForm', '$scope', '$state', 'CreateSelect2', 'GetChoices' 'i18n',
+ function(
+ Rest, Wait, UserTokensFormObject,
+ ProcessErrors, GetBasePath, Alert,
+ GenerateForm, $scope, $state, CreateSelect2, GetChoices i18n
+ ) {
+
+ var generator = GenerateForm,
+ form = UserTokensFormObject;
+
+ init();
+
+ function init() {
+ Rest.setUrl(GetBasePath('users') + '/authorized_tokens');
+ Rest.options()
+ .then(({data}) => {
+ if (!data.actions.POST) {
+ $state.go("^");
+ Alert('Permission Error', 'You do not have permission to add a token.', 'alert-info');
+ }
+ });
+ // apply form definition's default field values
+ GenerateForm.applyDefaults(form, $scope);
+ }
+
+ CreateSelect2({
+ element: '#user_token_scope',
+ multiple: false
+ });
+
+ // Save
+ $scope.formSave = function() {
+ // var params,
+ // v = $scope.notification_type.value;
+ //
+ // generator.clearApiErrors($scope);
+ // params = {
+ // "name": $scope.name,
+ // "description": $scope.description,
+ // "organization": $scope.organization,
+ // "notification_type": v,
+ // "notification_configuration": {}
+ // };
+ //
+ // function processValue(value, i, field) {
+ // if (field.type === 'textarea') {
+ // if (field.name === 'headers') {
+ // $scope[i] = JSON.parse($scope[i]);
+ // } else {
+ // $scope[i] = $scope[i].toString().split('\n');
+ // }
+ // }
+ // if (field.type === 'checkbox') {
+ // $scope[i] = Boolean($scope[i]);
+ // }
+ // if (field.type === 'number') {
+ // $scope[i] = Number($scope[i]);
+ // }
+ // if (i === "username" && $scope.notification_type.value === "email" && value === null) {
+ // $scope[i] = "";
+ // }
+ // if (field.type === 'sensitive' && value === null) {
+ // $scope[i] = "";
+ // }
+ // return $scope[i];
+ // }
+ //
+ // params.notification_configuration = _.object(Object.keys(form.fields)
+ // .filter(i => (form.fields[i].ngShow && form.fields[i].ngShow.indexOf(v) > -1))
+ // .map(i => [i, processValue($scope[i], i, form.fields[i])]));
+ //
+ // delete params.notification_configuration.email_options;
+ //
+ // for(var j = 0; j < form.fields.email_options.options.length; j++) {
+ // if(form.fields.email_options.options[j].ngShow && form.fields.email_options.options[j].ngShow.indexOf(v) > -1) {
+ // params.notification_configuration[form.fields.email_options.options[j].value] = Boolean($scope[form.fields.email_options.options[j].value]);
+ // }
+ // }
+ //
+ // Wait('start');
+ // Rest.setUrl(url);
+ // Rest.post(params)
+ // .then(() => {
+ // $state.go('notifications', {}, { reload: true });
+ // Wait('stop');
+ // })
+ // .catch(({data, status}) => {
+ // ProcessErrors($scope, data, status, form, {
+ // hdr: 'Error!',
+ // msg: 'Failed to add new notifier. POST returned status: ' + status
+ // });
+ // });
+ };
+
+ $scope.formCancel = function() {
+ $state.go('notifications');
+ };
+
+ }
+];
diff --git a/awx/ui/client/src/users/tokens/users-tokens.form.js b/awx/ui/client/src/users/tokens/users-tokens.form.js
new file mode 100644
index 0000000000..6dc73b734f
--- /dev/null
+++ b/awx/ui/client/src/users/tokens/users-tokens.form.js
@@ -0,0 +1,70 @@
+/*************************************************
+ * Copyright (c) 2015 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+ /**
+ * @ngdoc function
+ * @name forms.function:Tokens
+ * @description This form is for adding a token on the user's page
+*/
+
+export default ['i18n',
+function(i18n) {
+ return {
+ addTitle: i18n._('CREATE TOKEN'),
+ name: 'token',
+ basePath: 'tokens',
+ well: false,
+ formLabelSize: 'col-lg-3',
+ formFieldSize: 'col-lg-9',
+ iterator: 'token',
+ stateTree: 'users',
+ fields: {
+ application: {
+ label: i18n._('Application'),
+ type: 'lookup',
+ list: 'ApplicationList',
+ sourceModel: 'application',
+ // TODO: update to actual path
+ basePath: 'projects',
+ sourceField: 'name',
+ dataTitle: i18n._('Application'),
+ required: true,
+ dataContainer: 'body',
+ dataPlacement: 'right',
+ ngDisabled: '!(token.summary_fields.user_capabilities.edit || canAdd)',
+ awLookupWhen: '(token.summary_fields.user_capabilities.edit || canAdd)'
+ // TODO: help popover
+ },
+ scope: {
+ label: i18n._('Description'),
+ type: 'select',
+ class: 'Form-dropDown--scmType',
+ defaultText: 'Choose a scope',
+ ngOptions: 'scope.label for scope in scope_options track by scope.value',
+ required: true,
+ ngDisabled: '!(token.summary_fields.user_capabilities.edit || canAdd)'
+ // TODO: help popover
+ }
+ },
+ buttons: {
+ cancel: {
+ ngClick: 'formCancel()',
+ ngShow: '(token.summary_fields.user_capabilities.edit || canAdd)'
+ },
+ close: {
+ ngClick: 'formCancel()',
+ ngShow: '!(token.summary_fields.user_capabilities.edit || canAdd)'
+ },
+ save: {
+ ngClick: 'formSave()',
+ ngDisabled: true,
+ ngShow: '(token.summary_fields.user_capabilities.edit || canAdd)'
+ }
+ },
+ related: {
+ }
+ };
+ }];