This is an automated email from the ASF dual-hosted git repository.

rohit pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cloudstack-primate.git


The following commit(s) were added to refs/heads/master by this push:
     new f748697  projects: Enabling Role based Users in Projects (#382)
f748697 is described below

commit f748697b6c76427aedfbb55104f2c32e72738fba
Author: Pearl Dsilva <[email protected]>
AuthorDate: Tue Aug 4 14:24:51 2020 +0530

    projects: Enabling Role based Users in Projects (#382)
    
    Enables creating role based users in projects
    UI for feature: apache/cloudstack#4128
    
    Also addresses issue: #485
    
    Co-authored-by: Pearl Dsilva <[email protected]>
---
 src/components/header/ProjectMenu.vue              |   1 +
 src/components/view/DetailsTab.vue                 |  14 +
 src/components/view/InfoCard.vue                   |  15 +
 src/components/view/ListView.vue                   |  11 +
 src/components/view/ResourceView.vue               |   3 +-
 src/config/section/project.js                      |  44 +-
 src/locales/en.json                                |  22 +
 src/store/modules/user.js                          |  27 +-
 src/views/AutogenView.vue                          |  17 +-
 src/views/project/AccountsTab.vue                  | 216 +++++++---
 src/views/project/AddAccountOrUserToProject.vue    | 332 ++++++++++++++++
 src/views/project/InvitationsTemplate.vue          |  23 +-
 src/views/project/ProjectDetailsTab.vue            |  57 +++
 src/views/project/iam/ProjectRolePermissionTab.vue | 442 +++++++++++++++++++++
 src/views/project/iam/ProjectRoleTab.vue           | 308 ++++++++++++++
 15 files changed, 1459 insertions(+), 73 deletions(-)

diff --git a/src/components/header/ProjectMenu.vue 
b/src/components/header/ProjectMenu.vue
index f55f963..11d72dd 100644
--- a/src/components/header/ProjectMenu.vue
+++ b/src/components/header/ProjectMenu.vue
@@ -91,6 +91,7 @@ export default {
     },
     changeProject (index) {
       const project = this.projects[index]
+      this.$store.dispatch('ProjectView', project.id)
       this.$store.dispatch('SetProject', project)
       this.$store.dispatch('ToggleTheme', project.id === undefined ? 'light' : 
'dark')
       this.$message.success(`Switched to "${project.displaytext}"`)
diff --git a/src/components/view/DetailsTab.vue 
b/src/components/view/DetailsTab.vue
index 12f623d..bdb63d9 100644
--- a/src/components/view/DetailsTab.vue
+++ b/src/components/view/DetailsTab.vue
@@ -93,6 +93,20 @@ export default {
     },
     $route () {
       this.dedicatedSectionActive = 
this.dedicatedRoutes.includes(this.$route.meta.name)
+      this.fetchProjectAdmins()
+    }
+  },
+  methods: {
+    fetchProjectAdmins () {
+      if (!this.resource.owner) {
+        return false
+      }
+      var owners = this.resource.owner
+      var projectAdmins = []
+      for (var owner of owners) {
+        projectAdmins.push(Object.keys(owner).includes('user') ? owner.account 
+ '(' + owner.user + ')' : owner.account)
+      }
+      this.resource.account = projectAdmins.join()
     }
   }
 }
diff --git a/src/components/view/InfoCard.vue b/src/components/view/InfoCard.vue
index 8e9a020..175189e 100644
--- a/src/components/view/InfoCard.vue
+++ b/src/components/view/InfoCard.vue
@@ -459,6 +459,21 @@
             <span v-else>{{ resource.zone || resource.zonename || 
resource.zoneid }}</span>
           </div>
         </div>
+        <div class="resource-detail-item" v-if="resource.owner">
+          <div class="resource-detail-item__label">{{ $t('label.owners') 
}}</div>
+          <div class="resource-detail-item__details">
+            <a-icon type="user" />
+            <template v-for="(item,idx) in resource.owner">
+              <span style="margin-right:5px" :key="idx">
+                <span v-if="$store.getters.userInfo.roletype !== 'User'">
+                  <router-link v-if="'user' in item" :to="{ path: 
'/accountuser', query: { username: item.user, domainid: resource.domainid 
}}">{{ item.account + '(' + item.user + ')' }}</router-link>
+                  <router-link v-else :to="{ path: '/account', query: { name: 
item.account, domainid: resource.domainid } }">{{ item.account }}</router-link>
+                </span>
+                <span v-else>{{ item.user ? item.account + '(' + item.user + 
')' : item.account }}</span>
+              </span>
+            </template>
+          </div>
+        </div>
         <div class="resource-detail-item" v-if="resource.account && 
!resource.account.startsWith('PrjAcct-')">
           <div class="resource-detail-item__label">{{ $t('label.account') 
}}</div>
           <div class="resource-detail-item__details">
diff --git a/src/components/view/ListView.vue b/src/components/view/ListView.vue
index 87aae72..5ab0cf6 100644
--- a/src/components/view/ListView.vue
+++ b/src/components/view/ListView.vue
@@ -169,6 +169,17 @@
       <router-link :to="{ path: '/pod/' + record.podid }">{{ text 
}}</router-link>
     </a>
     <span slot="account" slot-scope="text, record">
+      <template v-if="record.owner">
+        <template v-for="(item,idx) in record.owner">
+          <span style="margin-right:5px" :key="idx">
+            <span v-if="$store.getters.userInfo.roletype !== 'User'">
+              <router-link v-if="'user' in item" :to="{ path: '/accountuser', 
query: { username: item.user, domainid: record.domainid }}">{{ item.account + 
'(' + item.user + ')' }}</router-link>
+              <router-link v-else :to="{ path: '/account', query: { name: 
item.account, domainid: record.domainid } }">{{ item.account }}</router-link>
+            </span>
+            <span v-else>{{ item.user ? item.account + '(' + item.user + ')' : 
item.account }}</span>
+          </span>
+        </template>
+      </template>
       <template v-if="text && !text.startsWith('PrjAcct-')">
         <router-link
           v-if="'quota' in record && 
$router.resolve(`${$route.path}/${record.account}`) !== '404'"
diff --git a/src/components/view/ResourceView.vue 
b/src/components/view/ResourceView.vue
index c7035a9..582dfc4 100644
--- a/src/components/view/ResourceView.vue
+++ b/src/components/view/ResourceView.vue
@@ -88,7 +88,8 @@ export default {
   data () {
     return {
       activeTab: '',
-      networkService: null
+      networkService: null,
+      projectAccount: null
     }
   },
   watch: {
diff --git a/src/config/section/project.js b/src/config/section/project.js
index 5769923..d3ad858 100644
--- a/src/config/section/project.js
+++ b/src/config/section/project.js
@@ -14,6 +14,7 @@
 // KIND, either express or implied.  See the License for the
 // specific language governing permissions and limitations
 // under the License.
+import store from '@/store'
 
 export default {
   name: 'project',
@@ -28,7 +29,20 @@ export default {
   tabs: [
     {
       name: 'details',
-      component: () => import('@/components/view/DetailsTab.vue')
+      component: () => import('@/views/project/ProjectDetailsTab.vue')
+    },
+    {
+      name: 'accounts',
+      component: () => import('@/views/project/AccountsTab.vue'),
+      show: (record, route, user) => { return ['Admin', 
'DomainAdmin'].includes(user.roletype) || record.isCurrentUserProjectAdmin }
+    },
+    {
+      name: 'project.roles',
+      component: () => import('@/views/project/iam/ProjectRoleTab.vue'),
+      show: (record, route, user) => {
+        return (['Admin', 'DomainAdmin'].includes(user.roletype) || 
record.isCurrentUserProjectAdmin) &&
+        'listProjectRoles' in store.getters.apis
+      }
     },
     {
       name: 'resources',
@@ -38,11 +52,6 @@ export default {
       name: 'limits',
       show: (record, route, user) => { return 
['Admin'].includes(user.roletype) },
       component: () => import('@/components/view/ResourceLimitTab.vue')
-    },
-    {
-      name: 'accounts',
-      show: (record, route, user) => { return record.account === user.account 
|| ['Admin', 'DomainAdmin'].includes(user.roletype) },
-      component: () => import('@/views/project/AccountsTab.vue')
     }
   ],
   actions: [
@@ -84,7 +93,7 @@ export default {
       dataView: true,
       args: ['displaytext'],
       show: (record, store) => {
-        return record.account === store.userInfo.account || ['Admin', 
'DomainAdmin'].includes(store.userInfo.roletype)
+        return (['Admin', 'DomainAdmin'].includes(store.userInfo.roletype)) || 
record.isCurrentUserProjectAdmin
       }
     },
     {
@@ -94,7 +103,7 @@ export default {
       message: 'message.activate.project',
       dataView: true,
       show: (record, store) => {
-        return (record.account === store.userInfo.account || ['Admin', 
'DomainAdmin'].includes(store.userInfo.roletype)) && record.state === 
'Suspended'
+        return ((['Admin', 'DomainAdmin'].includes(store.userInfo.roletype)) 
|| record.isCurrentUserProjectAdmin) && record.state === 'Suspended'
       }
     },
     {
@@ -105,7 +114,8 @@ export default {
       docHelp: 
'adminguide/projects.html#sending-project-membership-invitations',
       dataView: true,
       show: (record, store) => {
-        return (record.account === store.userInfo.account || ['Admin', 
'DomainAdmin'].includes(store.userInfo.roletype)) && record.state !== 
'Suspended'
+        return ((['Admin', 'DomainAdmin'].includes(store.userInfo.roletype)) ||
+        record.isCurrentUserProjectAdmin) && record.state !== 'Suspended'
       }
     },
     {
@@ -114,13 +124,11 @@ export default {
       label: 'label.action.project.add.account',
       docHelp: 'adminguide/projects.html#adding-project-members-from-the-ui',
       dataView: true,
-      args: ['projectid', 'account', 'email'],
-      show: (record, store) => { return record.account === 
store.userInfo.account || ['Admin', 
'DomainAdmin'].includes(store.userInfo.roletype) },
-      mapping: {
-        projectid: {
-          value: (record) => { return record.id }
-        }
-      }
+      popup: true,
+      show: (record, store) => {
+        return (['Admin', 'DomainAdmin'].includes(store.userInfo.roletype)) || 
record.isCurrentUserProjectAdmin
+      },
+      component: () => import('@/views/project/AddAccountOrUserToProject.vue')
     },
     {
       api: 'deleteProject',
@@ -129,7 +137,9 @@ export default {
       message: 'message.delete.project',
       docHelp: 'adminguide/projects.html#suspending-or-deleting-a-project',
       dataView: true,
-      show: (record, store) => { return record.account === 
store.userInfo.account || ['Admin', 
'DomainAdmin'].includes(store.userInfo.roletype) }
+      show: (record, store) => {
+        return (['Admin', 'DomainAdmin'].includes(store.userInfo.roletype)) || 
record.isCurrentUserProjectAdmin
+      }
     }
   ]
 }
diff --git a/src/locales/en.json b/src/locales/en.json
index 4d92e47..d3078e5 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -213,6 +213,7 @@
 "label.action.migrate.systemvm": "Migrate System VM",
 "label.action.migrate.systemvm.processing": "Migrating System VM....",
 "label.action.project.add.account": "Add Account to Project",
+"label.action.project.add.user": "Add User to Project",
 "label.action.reboot.instance": "Reboot Instance",
 "label.action.reboot.instance.processing": "Rebooting Instance....",
 "label.action.reboot.router": "Reboot Router",
@@ -339,6 +340,7 @@
 "label.add.portable.ip.range": "Add Portable IP Range",
 "label.add.primary.storage": "Add Primary Storage",
 "label.add.private.gateway": "Add Private Gateway",
+"label.add.project.role": "Add Project Role",
 "label.add.region": "Add Region",
 "label.add.resources": "Add Resources",
 "label.add.role": "Add Role",
@@ -608,6 +610,7 @@
 "label.create.nfs.secondary.staging.storage": "Create NFS Secondary Staging 
Store",
 "label.create.nfs.secondary.staging.store": "Create NFS secondary staging 
store",
 "label.create.project": "Create project",
+"label.create.project.role": "Create Project Role",
 "label.create.user": "Create user",
 "label.create.site.vpn.connection": "Create Site-to-Site VPN Connection",
 "label.create.site.vpn.gateway": "Create Site-to-Site VPN Gateway",
@@ -675,6 +678,7 @@
 "label.delete.pa": "Delete Palo Alto",
 "label.delete.portable.ip.range": "Delete Portable IP Range",
 "label.delete.project": "Delete project",
+"label.delete.project.role": "Delete Project Role",
 "label.delete.role": "Delete Role",
 "label.delete.rule": "Delete rule",
 "label.delete.secondary.staging.store": "Delete Secondary Staging Store",
@@ -694,6 +698,8 @@
 "label.deleting.failed": "Deleting Failed",
 "label.deleting.iso": "Deleting ISO",
 "label.deleting.processing": "Deleting....",
+"label.demote.project.owner": "Demote account to Regular role",
+"label.demote.project.owner.user": "Demote user to Regular role",
 "label.deleting.template": "Deleting template",
 "label.deny": "Deny",
 "label.deploymentplanner": "Deployment planner",
@@ -793,6 +799,7 @@
 "label.edit.lb.rule": "Edit LB rule",
 "label.edit.network.details": "Edit network details",
 "label.edit.project.details": "Edit project details",
+"label.edit.project.role": "Edit Project Role",
 "label.edit.region": "Edit Region",
 "label.edit.role": "Edit Role",
 "label.edit.rule": "Edit rule",
@@ -1239,6 +1246,7 @@
 "label.macaddresschanges": "MAC Address Changes",
 "label.macos": "MacOS",
 "label.make.project.owner": "Make account project owner",
+"label.make.user.project.owner": "Make user project owner",
 "label.makeredundant": "Make redundant",
 "label.manage": "Manage",
 "label.manage.resources": "Manage Resources",
@@ -1325,6 +1333,7 @@
 "label.metrics.network.usage": "Network Usage",
 "label.metrics.network.write": "Write",
 "label.metrics.num.cpu.cores": "Cores",
+
 "label.migrate.instance.to": "Migrate instance to",
 "label.migrate.instance.to.host": "Migrate instance to another host",
 "label.migrate.instance.to.ps": "Migrate instance to another primary storage",
@@ -1503,6 +1512,7 @@
 "label.owned.public.ips": "Owned Public IP Addresses",
 "label.owner.account": "Owner Account",
 "label.owner.domain": "Owner Domain",
+"label.owners": "Owners",
 "label.pa": "Palo Alto",
 "label.page": "page",
 "label.palo.alto.details": "Palo Alto details",
@@ -1593,6 +1603,10 @@
 "label.project.invitation": "Project Invitations",
 "label.project.invite": "Invite to project",
 "label.project.name": "Project name",
+"label.project.owner": "Project Owner(s)",
+"label.project.role": "Project Role",
+"label.project.roles": "Project Roles",
+"label.project.role.permissions": "Project Role Permissions",
 "label.project.view": "Project View",
 "label.projectaccountname": "Project Account Name",
 "label.projectid": "Project ID",
@@ -1712,6 +1726,8 @@
 "label.remove.network.offering": "Remove network offering",
 "label.remove.pf": "Remove port forwarding rule",
 "label.remove.project.account": "Remove account from project",
+"label.remove.project.role": "Remove project role",
+"label.remove.project.user": "Remove user from project",
 "label.remove.region": "Remove Region",
 "label.remove.rule": "Remove rule",
 "label.remove.ssh.key.pair": "Remove SSH Key Pair",
@@ -2094,6 +2110,7 @@
 "label.untagged": "Untagged",
 "label.update.instance.group": "Update Instance Group",
 "label.update.project.resources": "Update project resources",
+"label.update.project.role": "Update project role",
 "label.update.ssl": " SSL Certificate",
 "label.update.ssl.cert": " SSL Certificate",
 "label.update.to": "updated to",
@@ -2120,6 +2137,7 @@
 "label.usehttps": "Use HTTPS",
 "label.usenewdiskoffering": "Replace disk offering?",
 "label.user": "User",
+"label.user.as.admin": "Make User the Project Admin",
 "label.user.conflict": "Conflict",
 "label.user.details": "User details",
 "label.user.source": "source",
@@ -2440,6 +2458,7 @@
 "message.add.tag.for.networkacl": "Add tag for NetworkACL",
 "message.add.tag.processing": "Adding new tag...",
 "message.add.template": "Please enter the following data to create your new 
template",
+"message.add.user.to.project": "This form is to enable adding specific users 
of an account to a project.<br>Furthermore, a ProjectRole may be added to the 
added user/account to allow/disallow API access at project level.<br> We can 
also specify the role with which the user should be added to a project - 
Admin/Regular; if not specified, it defaults to 'Regular'",
 "message.add.volume": "Please fill in the following data to add a new volume.",
 "message.add.vpn.connection.failed": "Adding VPN Connection failed",
 "message.add.vpn.connection.processing": "Adding VPN Connection...",
@@ -3183,6 +3202,9 @@
 "message.zone.step.3.desc": "Please enter the following info to add a new pod",
 "message.zonewizard.enable.local.storage": "WARNING: If you enable local 
storage for this zone, you must do the following, depending on where you would 
like your system VMs to launch:<br/><br/>1. If system VMs need to be launched 
in shared primary storage, shared primary storage needs to be added to the zone 
after creation. You must also start the zone in a disabled state.<br/><br/>2. 
If system VMs need to be launched in local primary storage, 
system.vm.use.local.storage needs to be set  [...]
 "messgae.validate.min": "Please enter a value greater than or equal to {0}.",
+"migrate.from": "Migrate From",
+"migrate.to": "Migrate To",
+"migrationPolicy": "Migration Policy",
 "network.rate": "Network Rate",
 "router.health.checks": "Health Check",
 "side.by.side": "Side by Side",
diff --git a/src/store/modules/user.js b/src/store/modules/user.js
index c39e2f4..76271ff 100644
--- a/src/store/modules/user.js
+++ b/src/store/modules/user.js
@@ -91,7 +91,6 @@ const user = {
       return new Promise((resolve, reject) => {
         login(userInfo).then(response => {
           const result = response.loginresponse || {}
-
           Cookies.set('account', result.account, { expires: 1 })
           Cookies.set('domainid', result.domainid, { expires: 1 })
           Cookies.set('role', result.type, { expires: 1 })
@@ -100,7 +99,6 @@ const user = {
           Cookies.set('userfullname', result.firstname + ' ' + 
result.lastname, { expires: 1 })
           Cookies.set('userid', result.userid, { expires: 1 })
           Cookies.set('username', result.username, { expires: 1 })
-
           Vue.ls.set(ACCESS_TOKEN, result.sessionkey, 24 * 60 * 60 * 1000)
           commit('SET_TOKEN', result.sessionkey)
 
@@ -174,7 +172,7 @@ const user = {
           })
         }
 
-        api('listUsers', { username: Cookies.get('username'), listall: true 
}).then(response => {
+        api('listUsers', { username: Cookies.get('username') }).then(response 
=> {
           const result = response.listusersresponse.user[0]
           commit('SET_INFO', result)
           commit('SET_NAME', result.firstname + ' ' + result.lastname)
@@ -248,6 +246,29 @@ const user = {
       var jobsArray = Vue.ls.get(ASYNC_JOB_IDS, [])
       jobsArray.push(jobJson)
       commit('SET_ASYNC_JOB_IDS', jobsArray)
+    },
+    ProjectView ({ commit }, projectid) {
+      return new Promise((resolve, reject) => {
+        api('listApis', { projectid: projectid }).then(response => {
+          const apis = {}
+          const apiList = response.listapisresponse.api
+          for (var idx = 0; idx < apiList.length; idx++) {
+            const api = apiList[idx]
+            const apiName = api.name
+            apis[apiName] = {
+              params: api.params,
+              response: api.response
+            }
+          }
+          commit('SET_APIS', apis)
+          resolve(apis)
+          store.dispatch('GenerateRoutes', { apis }).then(() => {
+            router.addRoutes(store.getters.addRouters)
+          })
+        }).catch(error => {
+          reject(error)
+        })
+      })
     }
   }
 }
diff --git a/src/views/AutogenView.vue b/src/views/AutogenView.vue
index c60706c..9277f54 100644
--- a/src/views/AutogenView.vue
+++ b/src/views/AutogenView.vue
@@ -576,6 +576,18 @@ export default {
           this.items = []
         }
 
+        if (['listTemplates', 'listIsos'].includes(this.apiName) && 
this.items.length > 1) {
+          this.items = [...new Map(this.items.map(x => [x.id, x])).values()]
+        }
+
+        if (this.apiName === 'listProjects' && this.items.length > 0) {
+          this.columns.map(col => {
+            if (col.title === 'Account') {
+              col.title = this.$t('label.project.owner')
+            }
+          })
+        }
+
         for (let idx = 0; idx < this.items.length; idx++) {
           this.items[idx].key = idx
           for (const key in customRender) {
@@ -818,7 +830,6 @@ export default {
     execSubmit (e) {
       e.preventDefault()
       this.form.validateFields((err, values) => {
-        console.log(values)
         if (!err) {
           const params = {}
           if ('id' in this.resource && this.currentAction.params.map(i => { 
return i.name }).includes('id')) {
@@ -868,10 +879,6 @@ export default {
             }
           }
 
-          console.log(this.currentAction)
-          console.log(this.resource)
-          console.log(params)
-
           const resourceName = params.displayname || params.displaytext || 
params.name || params.hostname || params.username || params.ipaddress || 
params.virtualmachinename || this.resource.name
 
           var hasJobId = false
diff --git a/src/views/project/AccountsTab.vue 
b/src/views/project/AccountsTab.vue
index 53b28d9..8c986c5 100644
--- a/src/views/project/AccountsTab.vue
+++ b/src/views/project/AccountsTab.vue
@@ -21,29 +21,47 @@
       <a-col :md="24" :lg="24">
         <a-table
           size="small"
-          :loading="loading"
+          :loading="loading.table"
           :columns="columns"
           :dataSource="dataSource"
           :pagination="false"
-          :rowKey="record => record.accountid || record.account"
-        >
-          <span slot="action" v-if="record.role!==owner" slot-scope="text, 
record" class="account-button-action">
-            <a-tooltip placement="top">
-              <template slot="title">
-                {{ $t('label.make.project.owner') }}
-              </template>
+          :rowKey="record => record.userid ? record.userid : (record.accountid 
|| record.account)">
+          <span slot="user" slot-scope="text, record" v-if="record.userid">
+            {{ record.username }}
+          </span>
+          <span slot="projectrole" slot-scope="text, record" 
v-if="record.projectroleid">
+            {{ getProjectRole(record) }}
+          </span>
+          <span v-if="imProjectAdmin && dataSource.length > 1" slot="action" 
slot-scope="text, record" class="account-button-action">
+            <a-tooltip
+              slot="title"
+              placement="top"
+              :title="record.userid ? $t('label.make.user.project.owner') : 
$t('label.make.project.owner')">
               <a-button
+                v-if="record.role !== owner"
                 type="default"
                 shape="circle"
-                icon="user"
+                icon="arrow-up"
                 size="small"
-                :disabled="!('updateProject' in $store.getters.apis)"
-                @click="onMakeProjectOwner(record)" />
+                @click="promoteAccount(record)" />
             </a-tooltip>
-            <a-tooltip placement="top">
-              <template slot="title">
-                {{ $t('label.remove.project.account') }}
-              </template>
+            <a-tooltip
+              slot="title"
+              placement="top"
+              :title="record.userid ? $t('label.demote.project.owner.user') : 
$t('label.demote.project.owner')"
+              v-if="updateProjectApi.params.filter(x => x.name === 
'swapowner').length > 0">
+              <a-button
+                v-if="record.role === owner"
+                type="default"
+                shape="circle"
+                icon="arrow-down"
+                size="small"
+                @click="demoteAccount(record)" />
+            </a-tooltip>
+            <a-tooltip
+              slot="title"
+              placement="top"
+              :title="record.userid ? $t('label.remove.project.user') : 
$t('label.remove.project.account')">
               <a-button
                 type="danger"
                 shape="circle"
@@ -89,11 +107,20 @@ export default {
     return {
       columns: [],
       dataSource: [],
-      loading: false,
+      imProjectAdmin: false,
+      loading: {
+        user: false,
+        projectAccount: false,
+        roles: false,
+        table: false
+      },
       page: 1,
       pageSize: 10,
       itemCount: 0,
-      owner: 'Admin'
+      users: [],
+      projectRoles: [],
+      owner: 'Admin',
+      role: 'Regular'
     }
   },
   created () {
@@ -101,27 +128,36 @@ export default {
       {
         title: this.$t('label.account'),
         dataIndex: 'account',
-        width: '35%',
         scopedSlots: { customRender: 'account' }
       },
       {
-        title: this.$t('label.role'),
+        title: this.$t('label.roletype'),
         dataIndex: 'role',
         scopedSlots: { customRender: 'role' }
       },
       {
         title: this.$t('label.action'),
         dataIndex: 'action',
-        fixed: 'right',
-        width: 100,
         scopedSlots: { customRender: 'action' }
       }
     ]
-
+    if (this.isProjectRolesSupported()) {
+      this.columns.splice(1, 0, {
+        title: this.$t('label.user'),
+        dataIndex: 'userid',
+        scopedSlots: { customRender: 'user' }
+      })
+      this.columns.splice(this.columns.length - 1, 0, {
+        title: this.$t('label.project.role'),
+        dataIndex: 'projectroleid',
+        scopedSlots: { customRender: 'projectrole' }
+      })
+    }
     this.page = 1
     this.pageSize = 10
     this.itemCount = 0
   },
+  inject: ['parentFetchData'],
   mounted () {
     this.fetchData()
   },
@@ -140,55 +176,139 @@ export default {
       params.projectId = this.resource.id
       params.page = this.page
       params.pageSize = this.pageSize
-
-      this.loading = true
-
+      this.updateProjectApi = this.$store.getters.apis.updateProject
+      this.fetchUsers()
+      this.fetchProjectAccounts(params)
+      if (this.isProjectRolesSupported()) {
+        this.fetchProjectRoles()
+      }
+    },
+    changePage (page, pageSize) {
+      this.page = page
+      this.pageSize = pageSize
+      this.fetchData()
+    },
+    changePageSize (currentPage, pageSize) {
+      this.page = 0
+      this.pageSize = pageSize
+      this.fetchData()
+    },
+    isLoggedInUserProjectAdmin (user) {
+      if (['Admin', 
'DomainAdmin'].includes(this.$store.getters.userInfo.roletype)) {
+        return true
+      }
+      // If I'm the logged in user Or if I'm the logged in account And I'm the 
owner
+      if (((user.userid && user.userid === this.$store.getters.userInfo.id) ||
+        user.account === this.$store.getters.userInfo.account) &&
+        user.role === this.owner) {
+        return true
+      }
+      return false
+    },
+    isProjectRolesSupported () {
+      return ('listProjectRoles' in this.$store.getters.apis)
+    },
+    getProjectRole (record) {
+      const projectRole = this.projectRoles.filter(role => role.id === 
record.projectroleid)
+      return projectRole[0].name || projectRole[0].id || null
+    },
+    fetchUsers () {
+      this.loading.user = true
+      api('listUsers', { listall: true }).then(response => {
+        this.users = response.listusersresponse.user || []
+      }).catch(error => {
+        this.$notifyError(error)
+      }).finally(() => {
+        this.loading.user = false
+      })
+    },
+    fetchProjectRoles () {
+      this.loading.roles = true
+      api('listProjectRoles', { projectId: this.resource.id }).then(response 
=> {
+        this.projectRoles = response.listprojectrolesresponse.projectrole || []
+      }).catch(error => {
+        this.$notifyError(error)
+      }).finally(() => {
+        this.loading.roles = false
+      })
+    },
+    fetchProjectAccounts (params) {
+      this.loading.projectAccount = true
       api('listProjectAccounts', params).then(json => {
         const listProjectAccount = 
json.listprojectaccountsresponse.projectaccount
         const itemCount = json.listprojectaccountsresponse.count
-
         if (!listProjectAccount || listProjectAccount.length === 0) {
           this.dataSource = []
           return
         }
+        for (const projectAccount of listProjectAccount) {
+          this.imProjectAdmin = this.isLoggedInUserProjectAdmin(projectAccount)
+          if (this.imProjectAdmin) {
+            break
+          }
+        }
 
         this.itemCount = itemCount
         this.dataSource = listProjectAccount
       }).catch(error => {
         this.$notifyError(error)
       }).finally(() => {
-        this.loading = false
+        this.loading.projectAccount = false
       })
     },
-    changePage (page, pageSize) {
-      this.page = page
-      this.pageSize = pageSize
-      this.fetchData()
-    },
-    changePageSize (currentPage, pageSize) {
-      this.page = currentPage
-      this.pageSize = pageSize
-      this.fetchData()
-    },
     onMakeProjectOwner (record) {
       const title = this.$t('label.make.project.owner')
       const loading = this.$message.loading(title + 
`${this.$t('label.in.progress.for')} ` + record.account, 0)
       const params = {}
-
       params.id = this.resource.id
       params.account = record.account
+      this.updateProject(record, params, title, loading)
+    },
+    promoteAccount (record) {
+      var title = this.$t('label.make.project.owner')
+      const loading = this.$message.loading(title + 
`${this.$t('label.in.progress.for')} ` + record.account, 0)
+      const params = {}
+
+      params.id = this.resource.id
+      if (record.userid) {
+        params.userid = record.userid
+        // params.accountid = (record.user && record.user[0].accountid) || 
record.accountid
+        title = this.$t('label.make.user.project.owner')
+      } else {
+        params.account = record.account
+      }
+      params.roletype = this.owner
+      params.swapowner = false
+      this.updateProject(record, params, title, loading)
+    },
+    demoteAccount (record) {
+      var title = this.$t('label.demote.project.owner')
+      const loading = this.$message.loading(title + 
`${this.$t('label.in.progress.for')} ` + record.account, 0)
+      const params = {}
+      if (record.userid) {
+        params.userid = record.userid
+        // params.accountid = (record.user && record.user[0].accountid) || 
record.accountid
+        title = this.$t('label.demote.project.owner.user')
+      } else {
+        params.account = record.account
+      }
 
+      params.id = this.resource.id
+      params.roletype = 'Regular'
+      params.swapowner = false
+      this.updateProject(record, params, title, loading)
+    },
+    updateProject (record, params, title, loading) {
       api('updateProject', params).then(json => {
         const hasJobId = this.checkForAddAsyncJob(json, title, record.account)
-
         if (hasJobId) {
           this.fetchData()
         }
       }).catch(error => {
-        // show error
         this.$notifyError(error)
       }).finally(() => {
         setTimeout(loading, 1000)
+        this.parentFetchData()
       })
     },
     onShowConfirmDelete (record) {
@@ -209,18 +329,22 @@ export default {
       const title = this.$t('label.remove.project.account')
       const loading = this.$message.loading(title + 
`${this.$t('label.in.progress.for')} ` + record.account, 0)
       const params = {}
-
-      params.account = record.account
       params.projectid = this.resource.id
-
-      api('deleteAccountFromProject', params).then(json => {
+      if (record.userid) {
+        params.userid = record.userid
+        this.deleteOperation('deleteUserFromProject', params, record, title, 
loading)
+      } else {
+        params.account = record.account
+        this.deleteOperation('deleteAccountFromProject', params, record, 
title, loading)
+      }
+    },
+    deleteOperation (apiName, params, record, title, loading) {
+      api(apiName, params).then(json => {
         const hasJobId = this.checkForAddAsyncJob(json, title, record.account)
-
         if (hasJobId) {
           this.fetchData()
         }
       }).catch(error => {
-        // show error
         this.$notifyError(error)
       }).finally(() => {
         setTimeout(loading, 1000)
diff --git a/src/views/project/AddAccountOrUserToProject.vue 
b/src/views/project/AddAccountOrUserToProject.vue
new file mode 100644
index 0000000..0275ed7
--- /dev/null
+++ b/src/views/project/AddAccountOrUserToProject.vue
@@ -0,0 +1,332 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+<template>
+  <div>
+    <a-tabs class="form-layout">
+      <a-tab-pane key="1" :tab="$t('label.action.project.add.account')">
+        <a-form
+          :form="form"
+          @submit="addAccountToProject"
+          layout="vertical">
+          <a-form-item>
+            <span slot="label">
+              {{ $t('label.account') }}
+              <a-tooltip 
:title="apiParams.addAccountToProject.account.description">
+                <a-icon type="info-circle" style="color: rgba(0,0,0,.45)" />
+              </a-tooltip>
+            </span>
+            <a-input
+              v-decorator="['account']"
+              
:placeholder="apiParams.addAccountToProject.account.description"/>
+          </a-form-item>
+          <a-form-item>
+            <span slot="label">
+              {{ $t('label.email') }}
+              <a-tooltip 
:title="apiParams.addAccountToProject.email.description">
+                <a-icon type="info-circle" style="color: rgba(0,0,0,.45)" />
+              </a-tooltip>
+            </span>
+            <a-input v-decorator="[ 'email']"></a-input>
+          </a-form-item>
+          <a-form-item v-if="apiParams.addAccountToProject.projectroleid">
+            <span slot="label">
+              {{ $t('label.project.role') }}
+              <a-tooltip 
:title="apiParams.addAccountToProject.projectroleid.description">
+                <a-icon type="info-circle" style="color: rgba(0,0,0,.45)" />
+              </a-tooltip>
+            </span>
+            <a-select
+              showSearch
+              v-decorator="['projectroleid']"
+              :loading="loading"
+              :placeholder="$t('label.project.role')"
+            >
+              <a-select-option v-for="role in projectRoles" :key="role.id">
+                {{ role.name }}
+              </a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item v-if="apiParams.addAccountToProject.roletype">
+            <span slot="label">
+              {{ $t('label.roletype') }}
+              <a-tooltip 
:title="apiParams.addAccountToProject.roletype.description">
+                <a-icon type="info-circle" style="color: rgba(0,0,0,.45)" />
+              </a-tooltip>
+            </span>
+            <a-select
+              showSearch
+              v-decorator="['roletype']"
+              :placeholder="$t('label.roletype')">
+              <a-select-option value="Admin">Admin</a-select-option>
+              <a-select-option value="Regular">Regular</a-select-option>
+            </a-select>
+          </a-form-item>
+          <div :span="24" class="action-button">
+            <a-button @click="closeAction">{{ this.$t('label.cancel') 
}}</a-button>
+            <a-button type="primary" @click="addAccountToProject" 
:loading="loading">{{ $t('label.ok') }}</a-button>
+          </div>
+        </a-form>
+      </a-tab-pane>
+      <a-tab-pane key="2" :tab="$t('label.action.project.add.user')" 
v-if="apiParams.addUserToProject">
+        <a-form
+          :form="form"
+          @submit="addUserToProject"
+          layout="vertical">
+          <p v-html="$t('message.add.user.to.project')"></p>
+          <a-form-item>
+            <span slot="label">
+              {{ $t('label.user') }}
+              <a-tooltip 
:title="apiParams.addUserToProject.username.description">
+                <a-icon type="info-circle" style="color: rgba(0,0,0,.45)" />
+              </a-tooltip>
+            </span>
+            <a-input
+              v-decorator="['username']"
+              :placeholder="apiParams.addUserToProject.username.description"/>
+          </a-form-item>
+          <a-form-item>
+            <span slot="label">
+              {{ $t('label.email') }}
+              <a-tooltip :title="apiParams.addUserToProject.email.description">
+                <a-icon type="info-circle" style="color: rgba(0,0,0,.45)" />
+              </a-tooltip>
+            </span>
+            <a-input v-decorator="[ 'email']"></a-input>
+          </a-form-item>
+          <a-form-item>
+            <span slot="label">
+              {{ $t('label.project.role') }}
+              <a-tooltip 
:title="apiParams.addUserToProject.roletype.description">
+                <a-icon type="info-circle" style="color: rgba(0,0,0,.45)" />
+              </a-tooltip>
+            </span>
+            <a-select
+              showSearch
+              v-decorator="['projectroleid']"
+              :loading="loading"
+              :placeholder="$t('label.project.role')"
+            >
+              <a-select-option v-for="role in projectRoles" :key="role.id">
+                {{ role.name }}
+              </a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item>
+            <span slot="label">
+              {{ $t('label.roletype') }}
+              <a-tooltip 
:title="apiParams.addUserToProject.roletype.description">
+                <a-icon type="info-circle" style="color: rgba(0,0,0,.45)" />
+              </a-tooltip>
+            </span>
+            <a-select
+              showSearch
+              v-decorator="['roletype']"
+              :placeholder="$t('label.roletype')">
+              <a-select-option value="Admin">Admin</a-select-option>
+              <a-select-option value="Regular">Regular</a-select-option>
+            </a-select>
+          </a-form-item>
+          <div :span="24" class="action-button">
+            <a-button @click="closeAction">{{ this.$t('label.cancel') 
}}</a-button>
+            <a-button type="primary" @click="addUserToProject" 
:loading="loading">{{ $t('label.ok') }}</a-button>
+          </div>
+        </a-form>
+      </a-tab-pane>
+    </a-tabs>
+  </div>
+</template>
+<script>
+import { api } from '@/api'
+export default {
+  name: 'AddAccountOrUserToProject',
+  props: {
+    resource: {
+      type: Object,
+      required: true
+    }
+  },
+  data () {
+    return {
+      users: [],
+      accounts: [],
+      projectRoles: [],
+      selectedUser: null,
+      selectedAccount: null,
+      loading: false,
+      load: {
+        users: false,
+        accounts: false,
+        projectRoles: false
+      }
+    }
+  },
+  mounted () {
+    this.fetchData()
+  },
+  beforeCreate () {
+    this.form = this.$form.createForm(this)
+    const apis = ['addAccountToProject']
+    if ('addUserToProject' in this.$store.getters.apis) {
+      apis.push('addUserToProject')
+    }
+    this.apiParams = {}
+    for (var api of apis) {
+      const details = {}
+      const apiConfig = this.$store.getters.apis[api]
+      apiConfig.params.forEach(param => {
+        details[param.name] = param
+      })
+      this.apiParams[api] = details
+    }
+  },
+  methods: {
+    fetchData () {
+      this.fetchUsers()
+      this.fetchAccounts()
+      if (this.isProjectRolesSupported()) {
+        this.fetchProjectRoles()
+      }
+    },
+    fetchUsers () {
+      this.load.users = true
+      api('listUsers', { listall: true }).then(response => {
+        this.users = response.listusersresponse.user ? 
response.listusersresponse.user : []
+      }).catch(error => {
+        this.$notifyError(error)
+      }).finally(() => {
+        this.load.users = false
+      })
+    },
+    fetchAccounts () {
+      this.load.accounts = true
+      api('listAccounts', {
+        domainid: this.resource.domainid
+      }).then(response => {
+        this.accounts = response.listaccountsresponse.account || []
+      }).catch(error => {
+        this.$notifyError(error)
+      }).finally(() => {
+        this.load.accounts = false
+      })
+    },
+    fetchProjectRoles () {
+      this.load.projectRoles = true
+      api('listProjectRoles', {
+        projectid: this.resource.id
+      }).then(response => {
+        this.projectRoles = response.listprojectrolesresponse.projectrole || []
+      }).catch(error => {
+        this.$notifyError(error)
+      }).finally(() => {
+        this.load.projectRoles = false
+      })
+    },
+    isProjectRolesSupported () {
+      return ('listProjectRoles' in this.$store.getters.apis)
+    },
+    addAccountToProject (e) {
+      e.preventDefault()
+      this.form.validateFields((err, values) => {
+        if (err) {
+          return
+        }
+        this.loading = true
+        var params = {
+          projectid: this.resource.id
+        }
+        for (const key in values) {
+          const input = values[key]
+          if (input === undefined) {
+            continue
+          }
+          params[key] = input
+        }
+        api('addAccountToProject', params).then(response => {
+          this.$pollJob({
+            jobId: response.addaccounttoprojectresponse.jobid,
+            successMessage: `Successfully added account ${params.account} to 
project`,
+            errorMessage: `Failed to add account: ${params.account} to 
project`,
+            loadingMessage: `Adding Account: ${params.account} to project...`,
+            catchMessage: 'Error encountered while fetching async job result'
+          })
+        }).catch(error => {
+          this.$notifyError(error)
+        }).finally(() => {
+          this.$emit('refresh-data')
+          this.loading = false
+          this.closeAction()
+        })
+      })
+    },
+    addUserToProject (e) {
+      e.preventDefault()
+      this.form.validateFields((err, values) => {
+        if (err) {
+          return
+        }
+
+        this.loading = true
+        var params = {
+          projectid: this.resource.id
+        }
+        for (const key in values) {
+          const input = values[key]
+          if (input === undefined) {
+            continue
+          }
+          params[key] = input
+        }
+        api('addUserToProject', params).then(response => {
+          this.$pollJob({
+            jobId: response.addusertoprojectresponse.jobid,
+            successMessage: `Successfully added user ${params.username} to 
project`,
+            errorMessage: `Failed to add user: ${params.username} to project`,
+            loadingMessage: `Adding User ${params.username} to project...`,
+            catchMessage: 'Error encountered while fetching async job result'
+          })
+        }).catch(error => {
+          console.log('catch')
+          this.$notifyError(error)
+        }).finally(() => {
+          this.$emit('refresh-data')
+          this.loading = false
+          this.closeAction()
+        })
+      })
+    },
+    closeAction () {
+      this.$emit('close-action')
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+  .form-layout {
+    width: 80vw;
+
+    @media (min-width: 600px) {
+      width: 450px;
+    }
+  }
+.action-button {
+    text-align: right;
+
+    button {
+      margin-right: 5px;
+    }
+  }
+</style>
diff --git a/src/views/project/InvitationsTemplate.vue 
b/src/views/project/InvitationsTemplate.vue
index 3b95a84..719f4a0 100644
--- a/src/views/project/InvitationsTemplate.vue
+++ b/src/views/project/InvitationsTemplate.vue
@@ -116,6 +116,11 @@ export default {
         scopedSlots: { customRender: 'project' }
       },
       {
+        title: this.$t('label.account'),
+        dataIndex: 'account',
+        scopedSlots: { customRender: 'account' }
+      },
+      {
         title: this.$t('label.domain'),
         dataIndex: 'domain',
         scopedSlots: { customRender: 'domain' }
@@ -152,6 +157,18 @@ export default {
     this.page = 1
     this.pageSize = 10
     this.itemCount = 0
+    this.apiConfig = this.$store.getters.apis.listProjectInvitations || {}
+    this.apiParams = {}
+    this.apiConfig.params.forEach(param => {
+      this.apiParams[param.name] = param
+    })
+    if (this.apiParams.userid) {
+      this.columns.splice(2, 0, {
+        title: this.$t('label.user'),
+        dataIndex: 'userid',
+        scopedSlots: { customRender: 'user' }
+      })
+    }
   },
   mounted () {
     this.fetchData()
@@ -225,7 +242,11 @@ export default {
       const params = {}
 
       params.projectid = record.projectid
-      params.account = record.account
+      if (record.userid && record.userid !== null) {
+        params.userid = record.userid
+      } else {
+        params.account = record.account
+      }
       params.domainid = record.domainid
       params.accept = state
 
diff --git a/src/views/project/ProjectDetailsTab.vue 
b/src/views/project/ProjectDetailsTab.vue
new file mode 100644
index 0000000..ebffb59
--- /dev/null
+++ b/src/views/project/ProjectDetailsTab.vue
@@ -0,0 +1,57 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+<template>
+  <DetailsTab :resource="resource" />
+</template>
+<script>
+import DetailsTab from '@/components/view/DetailsTab'
+export default {
+  name: 'ProjectDetailsTab',
+  components: {
+    DetailsTab
+  },
+  props: {
+    resource: {
+      type: Object,
+      required: true
+    }
+  },
+  watch: {
+    resource (newItem, oldItem) {
+      if (!newItem || !newItem.id) {
+        return
+      }
+      this.resource = newItem
+      this.fetchProjectAccounts()
+    }
+  },
+  methods: {
+    fetchProjectAccounts () {
+      var owner = this.resource.owner
+      owner = owner.filter(projectaccount => {
+        return (projectaccount.userid && projectaccount.userid === 
this.$store.getters.userInfo.id) ||
+          projectaccount.account === this.$store.getters.userInfo.account
+      })
+      var isCurrentUserProjectAdmin = false
+      if (owner.length > 0) {
+        isCurrentUserProjectAdmin = true
+      }
+      this.$set(this.resource, 'isCurrentUserProjectAdmin', 
isCurrentUserProjectAdmin)
+    }
+  }
+}
+</script>
diff --git a/src/views/project/iam/ProjectRolePermissionTab.vue 
b/src/views/project/iam/ProjectRolePermissionTab.vue
new file mode 100644
index 0000000..b986108
--- /dev/null
+++ b/src/views/project/iam/ProjectRolePermissionTab.vue
@@ -0,0 +1,442 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+<template>
+  <a-icon v-if="loadingTable" type="loading" 
class="main-loading-spinner"></a-icon>
+  <div v-else>
+    <div v-if="updateTable" class="loading-overlay">
+      <a-icon type="loading" />
+    </div>
+    <div
+      class="rules-list ant-list ant-list-bordered"
+      :class="{'rules-list--overflow-hidden' : updateTable}" >
+
+      <div class="rules-table-item ant-list-item">
+        <div class="rules-table__col rules-table__col--grab"></div>
+        <div class="rules-table__col rules-table__col--rule 
rules-table__col--new">
+          <a-auto-complete
+            :autoFocus="true"
+            :filterOption="filterOption"
+            :dataSource="apis"
+            :value="newRule"
+            @change="val => newRule = val"
+            placeholder="Rule"
+            :class="{'rule-dropdown-error' : newRuleSelectError}" />
+        </div>
+        <div class="rules-table__col rules-table__col--permission">
+          <permission-editable
+            :defaultValue="newRulePermission"
+            @change="onPermissionChange(null, $event)" />
+        </div>
+        <div class="rules-table__col rules-table__col--description">
+          <a-input v-model="newRuleDescription" 
placeholder="Description"></a-input>
+        </div>
+        <div class="rules-table__col rules-table__col--actions">
+          <a-tooltip
+            placement="bottom">
+            <template slot="title">
+              Save new Rule
+            </template>
+            <a-button
+              icon="plus"
+              type="primary"
+              shape="circle"
+              @click="onRuleSave"
+            >
+            </a-button>
+          </a-tooltip>
+        </div>
+      </div>
+
+      <draggable
+        v-model="rules"
+        @change="changeOrder"
+        handle=".drag-handle"
+        animation="200"
+        ghostClass="drag-ghost">
+        <transition-group type="transition">
+          <div
+            v-for="(record, index) in rules"
+            :key="`item-${index}`"
+            class="rules-table-item ant-list-item">
+            <div class="rules-table__col rules-table__col--grab drag-handle">
+              <a-icon type="drag"></a-icon>
+            </div>
+            <div class="rules-table__col rules-table__col--rule">
+              {{ record.rule }}
+            </div>
+            <div class="rules-table__col rules-table__col--permission">
+              <permission-editable
+                :defaultValue="record.permission"
+                @change="onPermissionChange(record, $event)" />
+            </div>
+            <div class="rules-table__col rules-table__col--description">
+              <template v-if="record.description">
+                {{ record.description }}
+              </template>
+              <div v-else class="no-description">
+                No description entered.
+              </div>
+            </div>
+            <div class="rules-table__col rules-table__col--actions">
+              <rule-delete
+                :record="record"
+                @delete="onRuleDelete(record.id)" />
+            </div>
+          </div>
+        </transition-group>
+      </draggable>
+    </div>
+  </div>
+</template>
+
+<script>
+import { api } from '@/api'
+import draggable from 'vuedraggable'
+import PermissionEditable from '@/views/iam/PermissionEditable'
+import RuleDelete from '@/views/iam/RuleDelete'
+
+export default {
+  name: 'ProjectRolePermissionTab',
+  components: {
+    RuleDelete,
+    PermissionEditable,
+    draggable
+  },
+  props: {
+    resource: {
+      type: Object,
+      required: true
+    },
+    role: {
+      type: Object,
+      required: true
+    }
+  },
+  data () {
+    return {
+      loadingTable: true,
+      updateTable: false,
+      rules: null,
+      newRule: '',
+      newRulePermission: 'allow',
+      newRuleDescription: '',
+      newRuleSelectError: false,
+      drag: false,
+      apis: []
+    }
+  },
+  mounted () {
+    this.apis = Object.keys(this.$store.getters.apis).sort((a, b) => 
a.localeCompare(b))
+    this.fetchData()
+  },
+  watch: {
+    resource: function () {
+      this.fetchData(() => {
+        this.resetNewFields()
+      })
+    }
+  },
+  methods: {
+    filterOption (input, option) {
+      return (
+        
option.componentOptions.children[0].text.toUpperCase().indexOf(input.toUpperCase())
 >= 0
+      )
+    },
+    resetNewFields () {
+      this.newRule = ''
+      this.newRulePermission = 'allow'
+      this.newRuleDescription = ''
+      this.newRuleSelectError = false
+    },
+    fetchData (callback = null) {
+      if (!this.resource.id) return
+      api('listProjectRolePermissions', {
+        projectid: this.resource.id,
+        projectroleid: this.role.id
+      }).then(response => {
+        this.rules = 
response.listprojectrolepermissionsresponse.projectrolepermission
+      }).catch(error => {
+        console.error(error)
+      }).finally(() => {
+        this.loadingTable = false
+        this.updateTable = false
+        if (callback) callback()
+      })
+    },
+    changeOrder () {
+      api('updateProjectRolePermission', {}, 'POST', {
+        projectid: this.resource.id,
+        projectroleid: this.role.id,
+        ruleorder: this.rules.map(rule => rule.id)
+      }).catch(error => {
+        console.error(error)
+      }).finally(() => {
+        this.fetchData()
+      })
+    },
+    onRuleDelete (key) {
+      this.updateTable = true
+      api('deleteProjectRolePermission', {
+        id: key,
+        projectid: this.resource.id
+      }).catch(error => {
+        console.error(error)
+      }).finally(() => {
+        this.fetchData()
+      })
+    },
+    onPermissionChange (record, value) {
+      this.newRulePermission = value
+      if (!record) return
+      this.updateTable = true
+      api('updateProjectRolePermission', {
+        projectid: this.resource.id,
+        projectroleid: this.role.id,
+        projectrolepermissionid: record.id,
+        permission: value
+      }).then(() => {
+        this.fetchData()
+      }).catch(error => {
+        this.$notifyError(error)
+      })
+    },
+    onRuleSelect (value) {
+      this.newRule = value
+    },
+    onRuleSave () {
+      if (!this.newRule) {
+        this.newRuleSelectError = true
+        return
+      }
+      this.updateTable = true
+      api('createProjectRolePermission', {
+        rule: this.newRule,
+        permission: this.newRulePermission,
+        description: this.newRuleDescription,
+        projectroleid: this.role.id,
+        projectid: this.resource.id
+      }).then(() => {
+      }).catch(error => {
+        console.error(error)
+        this.$notifyError(error)
+      }).finally(() => {
+        this.resetNewFields()
+        this.fetchData()
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+  .main-loading-spinner {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 30px;
+  }
+  .role-add-btn {
+    margin-bottom: 15px;
+  }
+  .new-role-controls {
+    display: flex;
+
+    button {
+      &:not(:last-child) {
+        margin-right: 5px;
+      }
+    }
+
+  }
+
+  .rules-list {
+    max-height: 600px;
+    overflow: auto;
+
+    &--overflow-hidden {
+      overflow: hidden;
+    }
+
+  }
+
+  .rules-table {
+
+    &-item {
+      position: relative;
+      display: flex;
+      align-items: stretch;
+      padding: 0;
+      flex-wrap: wrap;
+
+      @media (min-width: 760px) {
+        flex-wrap: nowrap;
+        padding-right: 25px;
+      }
+
+    }
+
+    &__col {
+      display: flex;
+      align-items: center;
+      padding: 15px;
+
+      @media (min-width: 760px) {
+        padding: 15px 0;
+
+        &:not(:first-child) {
+          padding-left: 20px;
+        }
+
+        &:not(:last-child) {
+          border-right: 1px solid #e8e8e8;
+          padding-right: 20px;
+        }
+      }
+
+      &--grab {
+        position: absolute;
+        top: 4px;
+        left: 0;
+        width: 100%;
+
+        @media (min-width: 760px) {
+          position: relative;
+          top: auto;
+          width: 35px;
+          padding-left: 25px;
+          justify-content: center;
+        }
+
+      }
+
+      &--rule,
+      &--description {
+        word-break: break-all;
+        flex: 1;
+        width: 100%;
+
+        @media (min-width: 760px) {
+          width: auto;
+        }
+
+      }
+
+      &--rule {
+        padding-left: 60px;
+        background-color: rgba(#e6f7ff, 0.7);
+
+        @media (min-width: 760px) {
+          padding-left: 0;
+          background: none;
+        }
+
+      }
+
+      &--permission {
+        justify-content: center;
+        width: 100%;
+
+        .ant-select {
+          width: 100%;
+        }
+
+        @media (min-width: 760px) {
+          width: auto;
+
+          .ant-select {
+            width: auto;
+          }
+
+        }
+
+      }
+
+      &--actions {
+        max-width: 60px;
+        width: 100%;
+        padding-right: 0;
+
+        @media (min-width: 760px) {
+          width: auto;
+          max-width: 70px;
+          padding-right: 15px;
+        }
+
+      }
+
+      &--new {
+        padding-left: 15px;
+        background-color: transparent;
+
+        div {
+          width: 100%;
+        }
+
+      }
+
+    }
+
+  }
+
+  .no-description {
+    opacity: 0.4;
+    font-size: 0.7rem;
+
+    @media (min-width: 760px) {
+      display: none;
+    }
+
+  }
+
+  .drag-handle {
+    cursor: pointer;
+  }
+
+  .drag-ghost {
+    opacity: 0.5;
+    background: #f0f2f5;
+  }
+
+  .loading-overlay {
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    z-index: 5;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 3rem;
+    color: #39A7DE;
+    background-color: rgba(#fff, 0.8);
+  }
+</style>
+
+<style lang="scss">
+  .rules-table__col--new {
+    .ant-select {
+      width: 100%;
+    }
+  }
+  .rule-dropdown-error {
+    .ant-input {
+      border-color: #ff0000
+    }
+  }
+</style>
diff --git a/src/views/project/iam/ProjectRoleTab.vue 
b/src/views/project/iam/ProjectRoleTab.vue
new file mode 100644
index 0000000..75a3124
--- /dev/null
+++ b/src/views/project/iam/ProjectRoleTab.vue
@@ -0,0 +1,308 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+<template>
+  <div>
+    <a-button type="dashed" icon="plus" style="width: 100%; margin-bottom: 
15px" @click="openCreateModal">
+      {{ $t('label.create.project.role') }}
+    </a-button>
+    <a-row :gutter="12">
+      <a-col :md="24" :lg="24">
+        <a-table
+          size="small"
+          :loading="loading"
+          :columns="columns"
+          :dataSource="dataSource"
+          :rowKey="(record,idx) => record.projectid + '-' + idx"
+          :pagination="false">
+          <template slot="expandedRowRender" slot-scope="record">
+            <ProjectRolePermissionTab class="table" :resource="resource" 
:role="record"/>
+          </template>
+          <template slot="name" slot-scope="record"> {{ record }} </template>
+          <template slot="description" slot-scope="record">
+            {{ record }}
+          </template>
+          <span slot="action" slot-scope="text, record">
+            <a-tooltip placement="top">
+              <template slot="title">
+                {{ $t('label.update.project.role') }}
+              </template>
+              <a-button
+                type="default"
+                shape="circle"
+                icon="edit"
+                size="small"
+                style="margin:10px"
+                @click="openUpdateModal(record)" />
+            </a-tooltip>
+            <a-tooltip placement="top">
+              <template slot="title">
+                {{ $t('label.remove.project.role') }}
+              </template>
+              <a-button
+                type="danger"
+                shape="circle"
+                icon="delete"
+                size="small"
+                @click="deleteProjectRole(record)"/>
+            </a-tooltip>
+          </span>
+        </a-table>
+        <a-modal title="Edit Project Role" v-model="editModalVisible" 
:footer="null" :afterClose="closeAction">
+          <a-form
+            :form="form"
+            @submit="updateProjectRole"
+            layout="vertical">
+            <a-form-item :label="$t('label.name')">
+              <a-input v-decorator="[ 'name' ]"></a-input>
+            </a-form-item>
+            <a-form-item :label="$t('label.description')">
+              <a-input v-decorator="[ 'description' ]"></a-input>
+            </a-form-item>
+            <div :span="24" class="action-button">
+              <a-button @click="closeAction">{{ this.$t('label.cancel') 
}}</a-button>
+              <a-button type="primary" @click="updateProjectRole" 
:loading="loading">{{ $t('label.ok') }}</a-button>
+            </div>
+            <span slot="action" slot-scope="text, record">
+              <a-tooltip placement="top">
+                <template slot="title">
+                  {{ $t('label.update.project.role') }}
+                </template>
+                <a-button
+                  type="default"
+                  shape="circle"
+                  icon="edit"
+                  size="small"
+                  style="margin:10px"
+                  @click="openUpdateModal(record)" />
+              </a-tooltip>
+              <a-tooltip placement="top">
+                <template slot="title">
+                  {{ $t('label.remove.project.role') }}
+                </template>
+                <a-button
+                  type="danger"
+                  shape="circle"
+                  icon="delete"
+                  size="small"
+                  @click="deleteProjectRole(record)"/>
+              </a-tooltip>
+            </span>
+          </a-form>
+        </a-modal>
+        <a-modal title="Create Project Role" v-model="createModalVisible" 
:footer="null" :afterClose="closeAction">
+          <a-form
+            :form="form"
+            @submit="createProjectRole"
+            layout="vertical">
+            <a-form-item :label="$t('label.name')">
+              <a-input v-decorator="[ 'name', { rules: [{ required: true, 
message: 'Please provide input' }] }]"></a-input>
+            </a-form-item>
+            <a-form-item :label="$t('label.description')">
+              <a-input v-decorator="[ 'description' ]"></a-input>
+            </a-form-item>
+            <div :span="24" class="action-button">
+              <a-button @click="closeAction">{{ this.$t('label.cancel') 
}}</a-button>
+              <a-button type="primary" @click="createProjectRole" 
:loading="loading">{{ $t('label.ok') }}</a-button>
+            </div>
+          </a-form>
+        </a-modal>
+      </a-col>
+    </a-row>
+  </div>
+</template>
+<script>
+import { api } from '@/api'
+import ProjectRolePermissionTab from 
'@/views/project/iam/ProjectRolePermissionTab'
+export default {
+  name: 'ProjectRoleTab',
+  props: {
+    resource: {
+      type: Object,
+      required: true
+    }
+  },
+  components: {
+    ProjectRolePermissionTab
+  },
+  data () {
+    return {
+      columns: [],
+      dataSource: [],
+      loading: false,
+      createModalVisible: false,
+      editModalVisible: false,
+      selectedRole: null,
+      projectPermisssions: [],
+      customStyle: 'margin-bottom: -10px; border-bottom-style: none'
+    }
+  },
+  beforeCreate () {
+    this.form = this.$form.createForm(this)
+  },
+  created () {
+    this.columns = [
+      {
+        title: this.$t('label.name'),
+        dataIndex: 'name',
+        width: '35%',
+        scopedSlots: { customRender: 'name' }
+      },
+      {
+        title: this.$t('label.description'),
+        dataIndex: 'description'
+      },
+      {
+        title: this.$t('label.action'),
+        dataIndex: 'action',
+        width: 100,
+        scopedSlots: { customRender: 'action' }
+      }
+    ]
+  },
+  mounted () {
+    this.fetchData()
+  },
+  watch: {
+    resource (newItem, oldItem) {
+      if (!newItem || !newItem.id) {
+        return
+      }
+      this.resource = newItem
+      this.fetchData()
+    }
+  },
+  methods: {
+    fetchData () {
+      this.loading = true
+      api('listProjectRoles', { projectid: this.resource.id }).then(json => {
+        const projectRoles = json.listprojectrolesresponse.projectrole
+        if (!projectRoles || projectRoles.length === 0) {
+          this.dataSource = []
+          return
+        }
+        this.dataSource = projectRoles
+      }).catch(error => {
+        this.$notifyError(error)
+      }).finally(() => {
+        this.loading = false
+      })
+    },
+    openUpdateModal (role) {
+      this.selectedRole = role
+      this.editModalVisible = true
+    },
+    openCreateModal () {
+      this.createModalVisible = true
+    },
+    updateProjectRole (e) {
+      e.preventDefault()
+      this.form.validateFields((err, values) => {
+        if (err) {
+          return
+        }
+        var params = {}
+        this.loading = true
+        params.projectid = this.resource.id
+        params.id = this.selectedRole.id
+        for (const key in values) {
+          const input = values[key]
+          if (input === undefined) {
+            continue
+          }
+          params[key] = input
+        }
+        api('updateProjectRole', params).then(response => {
+          this.$notification.success({
+            message: this.$t('label.update.project.role'),
+            description: this.$t('label.update.project.role')
+          })
+        }).catch(error => {
+          this.$notifyError(error)
+        }).finally(() => {
+          this.loading = false
+          this.fetchData()
+          this.closeAction()
+        })
+      })
+    },
+    closeAction () {
+      if (this.editModalVisible) {
+        this.editModalVisible = false
+      }
+      if (this.createModalVisible) {
+        this.createModalVisible = false
+      }
+    },
+    createProjectRole (e) {
+      e.preventDefault()
+      this.form.validateFields((err, values) => {
+        if (err) {
+          return
+        }
+        this.loading = true
+        var params = {}
+        params.projectid = this.resource.id
+        for (const key in values) {
+          const input = values[key]
+          if (input === undefined) {
+            continue
+          }
+          params[key] = input
+        }
+        api('createProjectRole', params).then(response => {
+          this.$notification.success({
+            message: this.$t('label.create.project.role'),
+            description: this.$t('label.create.project.role')
+          })
+        }).catch(error => {
+          this.$notifyError(error)
+        }).finally(() => {
+          this.loading = false
+          this.fetchData()
+          this.closeAction()
+        })
+      })
+    },
+    deleteProjectRole (role) {
+      this.loading = true
+      api('deleteProjectRole', {
+        projectid: this.resource.id,
+        id: role.id
+      }).then(response => {
+        this.$notification.success({
+          message: this.$t('label.delete.project.role'),
+          description: this.$t('label.delete.project.role')
+        })
+      }).catch(error => {
+        this.$notifyError(error)
+      }).finally(() => {
+        this.loading = false
+        this.fetchData()
+        this.closeAction()
+      })
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+.action-button {
+    text-align: right;
+    button {
+      margin-right: 5px;
+    }
+  }
+</style>

Reply via email to