瀏覽代碼

Roles and permissions

Alan Colon 7 年之前
父節點
當前提交
3ea451174c

+ 41 - 7
app/api-service.js

@@ -1,9 +1,12 @@
+const decode = require('jsonwebtoken/decode')
 const app = require('./app')
 app.service('api', function($http) {
+  window.api = this
   this.opts = {
     headers: {},
     withCredentials: true
   }
+  this.claims = {}
   this.postProcess = (res) => res.data
   this.get = (path, opts = {}) => $http.get(path, Object.assign({}, opts, this.opts)).then(this.postProcess)
   this.post = (path, data, opts = {}) => $http.post(path, data, Object.assign({}, opts, this.opts)).then(this.postProcess)
@@ -11,13 +14,48 @@ app.service('api', function($http) {
   this.patch = (path, data, opts = {}) => $http.patch(path, data, Object.assign({}, opts, this.opts)).then(this.postProcess)
   this.delete = (path, opts = {}) => $http.delete(path, Object.assign({}, opts, this.opts)).then(this.postProcess)
 
-  this.login = data => this.post('/api/auth/login', data)
-  this.setToken = token => {
-    localStorage.setItem('token', token)
+  const setUser = (user, token) => {
+    const decoded = decode(token)
     this.token = token
+    this.user = user
+    this.claims = decoded
     this.opts.headers.authentication = `Bearer ${token}`
+    localStorage.setItem('token', token)
+    localStorage.setItem('user', JSON.stringify(user))
+  }
+
+  const clearUser = () => {
+    this.token = null
+    this.user = null
+    this.claims = {}
+    delete this.opts.headers.authentication
+    localStorage.removeItem('token')
+    localStorage.removeItem('user')
+  }
+
+  this.login = async data => {
+    const res = await this.post('/api/auth/login', data)
+    setUser(res.user, res.token)
+  }
+  this.logout = async () => {
+    clearUser()
+  }
+
+  this.restore = () => {
+    const token = localStorage.getItem('token')
+    const userJson = localStorage.getItem('user')
+    if (token && userJson) {
+      try {
+        const user = JSON.parse(userJson)
+        setUser(user, token)
+      } catch (err) {
+        console.warn(`Unable to restore login:`, err)
+      }
+    }
   }
 
+  this.restore()
+
   this.crud = (apiPrefix) => ({
     list: () => this.get(apiPrefix),
     create: data => this.post(apiPrefix, data),
@@ -30,8 +68,4 @@ app.service('api', function($http) {
     lookup: (ids) => this.get(apiPrefix, { params: { ids: ids.join(',')}})
   })
 
-  if (localStorage.getItem('token')) {
-    this.setToken(localStorage.getItem('token'))
-  }
-
 })

+ 4 - 1
app/assets/index.js

@@ -11,5 +11,8 @@ module.exports = {
   deleteIcon: require('@alancnet/material-design-icons/action_ic_delete_48px.svg'),
   saveIcon: require('@alancnet/material-design-icons/content_ic_save_48px.svg'),
   undoIcon: require('@alancnet/material-design-icons/content_ic_undo_48px.svg'),
-  dollarIcon: require('@alancnet/icomoon-svg/coin-dollar.svg')
+  dollarIcon: require('@alancnet/icomoon-svg/coin-dollar.svg'),
+  dropdownIcon: require('@alancnet/material-design-icons/navigation_ic_expand_more_48px.svg'),
+  dropupIcon: require('@alancnet/material-design-icons/navigation_ic_expand_less_48px.svg'),
+  viewIcon: require('@alancnet/material-design-icons/image_ic_remove_red_eye_48px.svg')
 }

+ 1 - 0
app/components/index.js

@@ -5,3 +5,4 @@ require('./user-area')
 require('./home-page')
 require('./user-pages')
 require('./role-pages')
+require('./permissions-select')

+ 0 - 1
app/components/login-page.js

@@ -27,7 +27,6 @@ app.component('appLoginPage', {
     this.submit = async form => {
       try {
         const res = await api.login(this.model)
-        api.setToken(res.token)
         $location.url('/dashboard')
       } catch (err) {
         $mdToast.showSimple(`Login failed`)

+ 45 - 0
app/components/permissions-select.js

@@ -0,0 +1,45 @@
+const app = require('../app')
+const { Set } = require('immutable')
+
+app.component('appPermissionsSelect', {
+  template: html`
+      <md-list>
+        <md-subheader>Permissions</md-subheader>
+        <md-list-item ng-repeat="permission in ::$ctrl.permissions track by permission.key" md-long-text>
+          <md-checkbox ng-model="message.selected" ng-checked="$ctrl.set.has(permission.key)" ng-click="$ctrl.toggle(permission.key)"></md-checkbox>
+          <div class="md-list-item-text">
+            <h3>{{permission.key}}</h3>
+            <p>{{permission.description}}</p>
+          </div>
+        </md-list-item>
+      </md-list>
+  `,
+  bindings: {
+    ngModel: '='
+  },
+  controller: function(api, $scope) {
+
+    api.get('/api/auth/permissions').then(x => {
+      this.permissions = x
+    })
+
+    const modelWatcher = $scope.$watch('$ctrl.ngModel', (model) => {
+      if (model) {
+        this.set = new Set(model.split(','))
+      } else {
+        this.set = new Set()
+      }
+    })
+
+    $scope.$on('$destroy', () => modelWatcher())
+
+    this.toggle = permission => {
+      if (this.set.has(permission)) {
+        this.set = this.set.remove(permission)
+      } else {
+        this.set = this.set.add(permission)
+      }
+      this.ngModel = this.set.join(',')
+    }
+  }
+})

+ 4 - 1
app/components/role-pages.js

@@ -17,7 +17,10 @@ pages({
         '0': 'Empty',
         'one': '1 permission',
         'other': '{} permissions'  
-      }"></ng-pluralize></td>`
+      }"></ng-pluralize></td>`,
+      field: html`
+        <app-permissions-select flex ng-model="model.permissions"></app-permissions-select>
+      `
     }
   ]
 })

+ 58 - 15
app/components/user-area.js

@@ -1,5 +1,5 @@
 const app = require('../app')
-const { logo, menuIcon, userIcon, dashboardIcon, roleIcon } = require('../assets')
+const { logo, menuIcon, userIcon, dashboardIcon, roleIcon, dropdownIcon, dropupIcon } = require('../assets')
 const assets = require('../assets')
 
 app.component('appUserArea', {
@@ -11,8 +11,10 @@ app.component('appUserArea', {
         class="md-sidenav-left"
         md-is-locked-open="$mdMedia('gt-md')"
         md-whiteframe="4"
-        layout="column">
+        layout="column"
+        md-theme="nav">
 
+        <md-content md-theme="nav">
         <header>
           <div class="logo-container">
             <img class="logo" src="${logo}" />
@@ -21,36 +23,67 @@ app.component('appUserArea', {
 
         <app-user-area-nav></app-user-area-nav>
 
-        <md-menu-item ng-repeat="crud in ctrl.cruds">
+        <md-menu-item ng-repeat="crud in $ctrl.cruds">
           <md-button ng-href="/{{crud.paramPlural}}">
             <md-icon md-svg-icon="{{assets[crud.iconAsset]}}"></md-icon>
             {{crud.titlePlural}}
           </md-button>
         </md-menu-item>
 
-        <h3>
-          Administration
-        </h3>
+        <div ng-if="$ctrl.api.claims.USER_READ || $ctrl.api.claims.ROLE_READ">
+          <h3>
+            Administration
+          </h3>
 
-        <md-menu-item>
-          <md-button ng-href="/users">
-            <md-icon md-svg-icon="${userIcon}"></md-icon>
-            Users
-          </md-button>
-        </md-menu-item>
+          <md-menu-item ng-if="$ctrl.api.claims.USER_READ">
+            <md-button ng-href="/users">
+              <md-icon md-svg-icon="${userIcon}"></md-icon>
+              Users
+            </md-button>
+          </md-menu-item>
+          <md-menu-item ng-if="$ctrl.api.claims.ROLE_READ">
+            <md-button ng-href="/roles">
+              <md-icon md-svg-icon="${roleIcon}"></md-icon>
+              Roles
+            </md-button>
+          </md-menu-item>
+        </div>
         <!-- <md-menu-item>
           <md-button ng-href="/roles">
             <md-icon md-svg-icon="${roleIcon}"></md-icon>
             Roles
           </md-button>
         </md-menu-item> -->
+        </md-content>
       </md-sidenav>
       <md-content flex>
         <md-toolbar>
           <div class="md-toolbar-tools">
-            <md-button class="md-icon-button" aria-label="Settings" ng-hide="$mdMedia('gt-md')" ng-click="ctrl.toggleNav()">
+            <md-button class="md-icon-button" aria-label="Settings" ng-hide="$mdMedia('gt-md')" ng-click="$ctrl.toggleNav()">
               <md-icon md-svg-icon="${menuIcon}"></md-icon>
             </md-button>
+            <h1 flex>{{$ctrl.titleText}}</h1>
+            <md-menu md-position-mode="target-right target">
+              <md-button class="md-raised md-accent hue-10" ng-click="$mdMenu.open($event)">
+                <md-icon md-svg-icon="${userIcon}"></md-icon>
+                {{$ctrl.api.user.name}}
+                <md-icon md-svg-icon="${dropdownIcon}"></md-icon>
+              </md-button>
+              <md-menu-content>
+                <md-menu-item>
+                  <md-button ng-click="$mdMenu.close($event)">
+                    <md-icon md-svg-icon="${userIcon}"></md-icon>
+                    {{$ctrl.api.user.name}}
+                    <md-icon md-svg-icon="${dropupIcon}"></md-icon>
+                  </md-button>
+                </md-menu-item>
+                <md-menu-item>
+                  <md-button class="md-warn" ng-click="$ctrl.logout()">
+                    Log out
+                  </md-button>
+                </md-menu-item>
+              </md-menu-content>
+            </md-menu>
           </div>
         </md-toolbar>
         <div layout-padding>
@@ -59,8 +92,14 @@ app.component('appUserArea', {
       </md-content>
     </div>
   `,
-  controllerAs: 'ctrl',
-  controller: function($mdSidenav, $mdMedia, $scope, cruds) {
+  bindings: {
+    titleText: '@'
+  },
+  controller: function($mdSidenav, $mdMedia, $scope, cruds, api, $location) {
+    this.api = api
+    if (!api.user || !api.token) {
+      $location.url('/login')
+    }
     this.cruds = cruds.filter(crud => crud.showNav !== false)
     $scope.assets = assets
     $scope.$mdMedia = $mdMedia
@@ -68,5 +107,9 @@ app.component('appUserArea', {
     this.toggleNav = () => {
       $mdSidenav('left').toggle()
     }
+    this.logout = async () => {
+      await api.logout()
+      $location.url('/login')
+    }
   }
 })

+ 17 - 2
app/components/user-pages.js

@@ -19,13 +19,28 @@ pages({
     },
     {
       titleName: 'Email',
-      camelName: 'email'
+      camelName: 'email',
+      attrs: {
+        autocomplete: 'off'
+      }
     },
     {
       titleName: 'Password',
       camelName: 'password',
       type: 'password',
-      inList: false
+      inList: false,
+      attrs: {
+        autocomplete: 'new-password',
+        name: 'new-password',
+        readonly: 'true',
+        onfocus: `this.removeAttribute('readonly')`
+      }
+    },
+    {
+      camelName: 'roles',
+      type: 'multi-select',
+      inList: false,
+      apiPrefix: '/api/roles'
     }
   ]
 })

+ 55 - 6
app/crud/pages/details.js

@@ -1,6 +1,7 @@
 const _ = require('lodash')
 const app = require('../../app')
-const { dollarIcon } = require('../../assets')
+const { Set } = require('immutable')
+const { dollarIcon, dropdownIcon } = require('../../assets')
 /**
  * @param {CrudPagesOptions} opts 
  */
@@ -19,7 +20,7 @@ const details = (opts) => {
         md-item-text="item.name || item.key || item.id"
         md-require-match="true"
         md-selected-item-change="ctrl.autocomplete.${raw(column.camelName)}.onChange()"
-        md-min-length="0"s
+        md-min-length="0"
         placeholder="${column.titleName}"
         ng-hide="${hideCriteria(column)}">
         <md-item-template>
@@ -29,7 +30,22 @@ const details = (opts) => {
     `
   }
 
-  const attrs = obj => obj ? _.toPairs().map((key, value) => html`${key}="${value}"`).join(' ') : ''
+  const multiSelectInput = column => {
+    if (!column.apiPrefix) throw new Error('apiPrefix is required for multi-select fields')
+
+    return html`
+      <md-input-container>
+        <label>${column.titleName}</label>
+        <md-select ng-model="model.${raw(column.camelName)}" multiple>
+          <md-option ng-repeat="item in ::ctrl.multiSelect.${raw(column.camelName)}.items" value="{{item.key}}">
+            {{item.name || item.key}}
+          </md-option>
+        </md-select>
+      </md-input-container>
+    `
+  }
+
+  const attrs = obj => obj ? raw(_.toPairs(obj).map(([key, value]) => `${key}="${value}"`).join(' ')) : ''
 
   const standardInput = column => html`
     <md-input-container flex ng-hide="${hideCriteria(column)}">
@@ -51,6 +67,8 @@ const details = (opts) => {
     ? autocompleteInput(column)
     : column.type === 'currency'
     ? currencyInput(column)
+    : column.type === 'multi-select'
+    ? multiSelectInput(column)
     : standardInput(column)
 
   const layout = () => {
@@ -88,9 +106,7 @@ const details = (opts) => {
   }
 
   const template = html`
-      <app-user-area>
-        <h1 ng-show="ctrl.isNew">New ${opts.titleName}</h1>
-        <h1 ng-hide="ctrl.isNew">${opts.titleName} Details</h1>
+      <app-user-area title-text="${opts.titles && opts.titles.details ? `{{ctrl.titleFn(ctrl)}}` : `${opts.titleName} Details`}">
         <form name="form" ng-submit="ctrl.submit()" layout="column" layout-margin
           flex-xs="100"
           flex-sm="100"
@@ -112,6 +128,7 @@ const details = (opts) => {
     template,
     controllerAs: 'ctrl',
     controller: function(api, $scope, $routeParams, $mdToast, $location, $q, util) {
+      this.titleFn = opts.titles && opts.titles.details
       this.$routeParams = $routeParams
       this.template = template // For inspection purposes
 
@@ -179,6 +196,38 @@ const details = (opts) => {
         })
       })
 
+      /* Multi-Select fields */
+      this.multiSelect = {}
+      opts.columns.filter(c => c.type === 'multi-select').forEach(c => {
+        const crud = api.crud(util.fillPath(c.apiPrefix, $routeParams))
+        const ms = {}
+        this.multiSelect[c.camelName] = ms
+        const updateLabel = () => {
+          ms.label = ms.set.toArray()
+            .map(key => ms.lookup[key].name || key)
+            .join(', ')
+        }
+        this.loadingPromise.then(() => {
+          crud.list().then(items => {
+            ms.lookup = _.fromPairs(items.map(x => [x.key, x]))
+            ms.items = items
+            ms.set = new Set($scope.model[c.camelName])
+            updateLabel()
+          })
+        })
+        ms.add = (key) => {
+          ms.set = ms.set.add(key)
+          $scope.model[c.camelName] = ms.set.toArray()
+          updateLabel()
+        }
+        ms.remove = (key) => {
+          ms.set = ms.set.remove(key)
+          $scope.model[c.camelName] = ms.set.toArray()
+          updateLabel()
+        }
+        ms.toggle = (key) =>
+          ms.set.has(key) ? ms.remove(key) : ms.add(key)
+      })
     }
   })
 }

+ 24 - 12
app/crud/pages/list.js

@@ -1,41 +1,50 @@
 const app = require('../../app')
-const {editIcon, createIcon, deleteIcon} = require('../../assets')
+const {editIcon, createIcon, deleteIcon, viewIcon} = require('../../assets')
 
 /**
  * @param {CrudPagesOptions} opts 
  */
 const list = (opts) => {
-  const hideCriteria = column => column.routeParam ? `ctrl.$routeParams.${column.routeParam} !== '${opts.paramAll}'` : 'false'
+  const hideCriteria = column => column.routeParam ? `$ctrl.$routeParams.${column.routeParam} !== '${opts.paramAll}'` : 'false'
 
   const defaultHeader = column =>
     html`<th ng-hide="${hideCriteria(column)}" md-column>${column.titleName}</th>`
   const defaultCell = column => {
     if (column.type === 'autocomplete') {
-      return html`<td ng-hide="${hideCriteria(column)}" md-cell>{{${raw(opts.camelName)}.${raw(column.camelName)} && (ctrl.autocomplete.${raw(column.camelName)}.lookup[${raw(opts.camelName)}.${raw(column.camelName)}] || ctrl.autocomplete.${raw(column.camelName)}.load(${raw(opts.camelName)}.${raw(column.camelName)}))}}</td>`
+      return html`<td ng-hide="${hideCriteria(column)}" md-cell>{{${raw(opts.camelName)}.${raw(column.camelName)} && ($ctrl.autocomplete.${raw(column.camelName)}.lookup[${raw(opts.camelName)}.${raw(column.camelName)}] || $ctrl.autocomplete.${raw(column.camelName)}.load(${raw(opts.camelName)}.${raw(column.camelName)}))}}</td>`
     } else {
       return html`<td ng-hide="${hideCriteria(column)}" md-cell>{{${raw(opts.camelName)}.${raw(column.camelName)}}}</td>`
     }
   }
   const template = html`
-      <app-user-area>
-        <h1>${opts.titlePlural}</h1>
+      <app-user-area title-text="${opts.titles && opts.titles.details ? `{{$ctrl.titleFn($ctrl)}}` : opts.titlePlural}">
         <md-table-container>
-          <table md-table md-row-select md-auto-select  ng-model="ctrl.selected" md-progress="ctrl.promise">
-              <thead md-head md-order="query.order" md-on-reorder="ctrl.getRecords">
+          <table md-table md-row-select md-auto-select  ng-model="$ctrl.selected" md-progress="$ctrl.promise">
+              <thead md-head md-order="query.order" md-on-reorder="$ctrl.getRecords">
                 <tr md-row>
                   ${opts.columns.filter(c => c.inList).map(c => c.header || defaultHeader(c))}
                   <th md-column>Actions</th>
                 </tr>
               </thead>
               <tbody md-body>
-                <tr md-row md-select="${opts.camelName}" md-select-id="name" md-auto-select ng-repeat="${raw(opts.camelName)} in ctrl.data track by ${raw(opts.camelName)}.id">
+                <tr md-row md-select="${opts.camelName}" md-select-id="name" md-auto-select ng-repeat="${raw(opts.camelName)} in $ctrl.data track by ${raw(opts.camelName)}.id">
                   ${opts.columns.filter(c => c.inList).map(c => c.cell || defaultCell(c))}
                   <td md-cell>
-                    <md-button ng-href="{{::ctrl.appPrefix}}/{{${raw(opts.camelName)}.id}}">
+                    <md-button
+                      ng-if="$ctrl.api.claims.${opts.constantName}_UPDATE"
+                      ng-href="{{::$ctrl.appPrefix}}/{{${raw(opts.camelName)}.id}}">
                       <md-icon md-svg-icon="${editIcon}"></md-icon>
                       Edit
                     </md-button>
-                    <md-button ng-click="ctrl.delete(${raw(opts.camelName)})">
+                    <md-button
+                      ng-if="!$ctrl.api.claims.${opts.constantName}_UPDATE"
+                      ng-href="{{::$ctrl.appPrefix}}/{{${raw(opts.camelName)}.id}}">
+                      <md-icon md-svg-icon="${viewIcon}"></md-icon>
+                      View
+                    </md-button>
+                    <md-button
+                      ng-if="!$ctrl.api.claims.${opts.constantName}_DELETE"
+                      ng-click="$ctrl.delete(${raw(opts.camelName)})">
                       <md-icon md-svg-src="${deleteIcon}"></md-icon>
                       Delete
                     </md-button>
@@ -46,7 +55,9 @@ const list = (opts) => {
             </table>
         </md-table-container>
         <div layout="row" layout-align="end">
-          <md-button class="md-fab" aria-label="Add ${opts.titleName}" ng-href="{{::ctrl.appPrefix}}/new">
+          <md-button
+            ng-if="$ctrl.api.claims.${opts.constantName}_CREATE"
+            class="md-fab" aria-label="Add ${opts.titleName}" ng-href="{{::$ctrl.appPrefix}}/new">
             <md-icon md-svg-src="${createIcon}"></md-icon>
           </md-button>
         </div>
@@ -55,8 +66,9 @@ const list = (opts) => {
   console.log(`crud: app${opts.pascalPlural}Page`)
   app.component(`app${opts.pascalPlural}Page`, {
     template,
-    controllerAs: 'ctrl',
     controller: function(api, $mdToast, $routeParams, util, $interpolate, $scope) {
+      this.api = api
+      this.titleFn = opts.titles && opts.titles.list
       this.$routeParams = $routeParams
       this.apiPrefix = util.fillPath(opts.apiPrefix, $routeParams)
       this.appPrefix = util.fillPath(opts.appPrefix, $routeParams)

+ 1 - 2
app/crud/pages/trash.js

@@ -11,8 +11,7 @@ const list = (opts) => {
   console.log(`crud: app${opts.pascalPlural}TrashPage`)
   app.component(`app${opts.pascalPlural}TrashPage`, {
     template: html`
-      <app-user-area>
-        <h1>${opts.titlePlural} Trash</h1>
+      <app-user-area title-text="${opts.titlePlural} Trash">
         <md-table-container>
           <table md-table md-row-select md-auto-select md-multiple ng-model="ctrl.selected" md-progress="ctrl.promise">
               <thead md-head md-order="query.order" md-on-reorder="ctrl.getRecords">

+ 2 - 0
lib/controllers/auth/decode.js

@@ -7,6 +7,8 @@ const decode = async (req, res, next) => {
     try {
       const decoded = await JWT.verify(token, config.auth.jwtSecret)
       req.user = decoded.user
+      req.claims = decoded
+      req.token = token
       next()
     } catch (err) {
       res.setHeader('X-JWT-Error', err.message || err.toString())

+ 9 - 1
lib/controllers/auth/login.js

@@ -11,6 +11,13 @@ module.exports = {
     if (user) {
       const success = await bcrypt.compare(req.body.password, user.password)
       if (success) {
+        const permissions = _.chain(await user.getRoles())
+          .map(role => (role.permissions || '').split(','))
+          .flatten()
+          .uniq()
+          .map(permission => [permission, 1])
+          .fromPairs()
+          .value()
         const sid = aguid()
         const exp = Math.floor(Date.now()/1000) + config.auth.jwtExpires
         await Session.create({
@@ -20,7 +27,8 @@ module.exports = {
         })
         const token = JWT.sign({
           sid,
-          exp, 
+          exp,
+          ...permissions,
           user: user.sanitize()
         }, config.auth.jwtSecret);
         return res.status(200).send({

+ 16 - 2
lib/controllers/auth/permissions.js

@@ -1,16 +1,30 @@
+const _ = require('lodash')
 const permissions = []
 
-const register = (perm) => {
+const permissionDescriptions = {}
+const register = (perm, description) => {
 
   if (!permissions.includes(perm)) {
     permissions.push(perm)
     permissions.sort()
   }
 
+  if (description) {
+    permissionDescriptions[perm] = description
+  }
+
 }
 
 const list = (req, res) => {
-  res.status(200).send(permissions)
+  res.status(200).send(
+    _.chain(permissions)
+    .sort()
+    .map(key => ({
+      key,
+      description: permissionDescriptions[key]
+    }))
+    .value()
+  )
 }
 
 

+ 25 - 2
lib/controllers/auth/verify.js

@@ -1,13 +1,36 @@
-const verify = (permission) => (req, res, next) => {
+/**
+ * 
+ * @param {*} permissions Series of permissions or arrays of permissions, any of which must be fully satisfied to pass.
+ * 
+ * Example: verify('A', ['B', 'C']) means: A OR (B AND C)
+ */
+
+const verify = (...permissions) => (req, res, next) => {
   const verified = !!req.user
   if (!verified) {
     if (process.env.SKIP_AUTH) {
       console.warn(`Skipping auth on ${req.path}`)
-      return next()
+      if (next) next()
+      return true
     }
     if (res) res.status(403).end()
+    return false
   } else {
+    if (permissions && permissions.length) {
+      for (let permission of permissions) {
+        if (
+          (typeof permission === 'string' && req.claims[permission])
+          || (Array.isArray(permission) && permission.every(p => req.claims[permission]))
+        ) {
+          if (next) next()
+          return true
+        }
+      }
+      if (res) res.status(401).end()
+      return false
+    }
     if (next) next()
+    return true
   }
   return verified
 }

+ 61 - 7
lib/crud/controller.js

@@ -1,6 +1,8 @@
 const _ = require('lodash')
 const defaults = require('./defaults')
 const { Op } = require('sequelize')
+const { diffBy } = require('../util')
+const { sequelize } = require('../database')
 
 const crudController = (opts) => {
   opts = defaults(opts)
@@ -43,19 +45,71 @@ const crudController = (opts) => {
       res.status(200).send(data)
     }
   }
+
+  const setAssociations = async (record, data, transaction) => {
+    for (let [key, assoc] of _.toPairs(Type.associations)) {
+      if (record[key] && assoc.associationType === 'BelongsToMany' && assoc.target.attributes.key) {
+        await assoc.set(
+          data,
+          await assoc.target.findAll({
+            attributes: ['id'],
+            where: {
+              key: {
+                [Op.in]: record[key]
+              }
+            }
+          }),
+          {transaction}
+        )
+      }
+    }
+  }
+  
+  const getAssociations = async (data, json) => {
+    for (let [key, assoc] of _.toPairs(Type.associations)) {
+      if (assoc.associationType === 'BelongsToMany' && assoc.target.attributes.key) {
+        json[key] = (await assoc.get(data, {
+          attributes: ['id', 'key']
+        })).map(x => x.key)
+      }
+    }
+  }
+
   const create = async (req, res) => {
-    const record = { ...req.body, ...(await subset(req))}
-    const data = (await Type.create(record))
-    res.status(200).send(data && data.sanitize ? data.sanitize() : data)
+    const transaction = await sequelize.transaction()
+    try {
+      const record = { ...req.body, ...(await subset(req))}
+      const data = (await Type.create(record, {transaction}))
+      await setAssociations(record, data, transaction)
+      await transaction.commit()
+      res.status(200).send(data && data.sanitize ? data.sanitize() : data)
+    } catch (err) {
+      await transaction.rollback()
+      throw err
+    }
   }
   const read = async (req, res) => {
     const data = (await Type.findOne({where: {id: req.params[opts.routeParam]}}))
-    res.status(200).send(data && data.sanitize ? data.sanitize() : data)
+    const json = data && data.sanitize ? data.sanitize() : data.toJSON()
+    await getAssociations(data, json)
+    res.status(200).send(json)
   }
   const update = async (req, res) => {
-    const record = _.omit(req.body, _.keys(await subset(req)))
-    const data = (await Type.update(record, { where: { id: req.params[opts.routeParam] } }))
-    res.status(200).send(data && data.sanitize ? data.sanitize() : data)
+    const transaction = await sequelize.transaction()
+    try {
+      const record = _.omit(req.body, _.keys(await subset(req)))
+      const updated = (await Type.update(record, { where: { id: req.params[opts.routeParam] }, transaction }))
+      const data = (await Type.findOne({where: { id: req.params[opts.routeParam] }}))
+      const json = data && data.sanitize ? data.sanitize() : data.toJSON()
+
+      await setAssociations(record, data, transaction)
+      await transaction.commit()
+      await getAssociations(data, json)
+      res.status(200).send(json)
+    } catch (err) {
+      await transaction.rollback()
+      throw err
+    }
   }
   const $delete = async (req, res) => {
     const data = (await Type.destroy({ where: { id: req.params[opts.routeParam] } }))

+ 5 - 5
lib/crud/routes.js

@@ -11,11 +11,11 @@ module.exports = (opts) => {
   const TYPE_DELETE = `${opts.constantName}_DELETE`
   const TYPE_UNDELETE = `${opts.constantName}_UNDELETE`
 
-  permissions.register(TYPE_CREATE)
-  permissions.register(TYPE_READ)
-  permissions.register(TYPE_UPDATE)
-  permissions.register(TYPE_DELETE)
-  permissions.register(TYPE_UNDELETE)
+  permissions.register(TYPE_CREATE, `Create ${opts.titlePlural}.`)
+  permissions.register(TYPE_READ, `List and read ${opts.titlePlural}.`)
+  permissions.register(TYPE_UPDATE, `Update ${opts.titlePlural}.`)
+  permissions.register(TYPE_DELETE, `Delete ${opts.titlePlural}.`)
+  permissions.register(TYPE_UNDELETE, `View undeleted ${opts.titlePlural} and restore deleted ${opts.titlePlural}.`)
 
   if (controller.list) app.get(`${opts.apiPrefix}`, verify(TYPE_READ), asyncHandler(controller.list))
   if (controller.create) app.post(`${opts.apiPrefix}`, verify(TYPE_CREATE), asyncHandler(controller.create))

+ 25 - 1
lib/database/index.js

@@ -1,3 +1,4 @@
+const _ = require('lodash')
 const config = require('../../config')
 const bcrypt = require('bcrypt')
 const sequelize = require('./sequelize')
@@ -5,6 +6,27 @@ const User = require('./user')
 const Session = require('./session')
 const Role = require('./role')
 
+const UserRole = User.belongsToMany(Role, { through: 'userRoles' })
+
+const upsert = async (Type, object, fields) => {
+  if (!fields) {
+    fields = ['key']
+  }
+  for (var field of fields) {
+    if (!object[field]) {
+      throw new Error(`Missing upsert field '${field}' in ${JSON.stringify(object)}.`)
+    }
+  }
+  const existing = await Type.findOne({where: _.pick(object, fields)})
+  if (existing) {
+    Object.assign(existing, object)
+    await existing.save()
+    return existing
+  } else {
+    return await Type.create(object)
+  }
+}
+
 module.exports = {
   init: () => {
     return sequelize.sync()
@@ -12,5 +34,7 @@ module.exports = {
   sequelize,
   User,
   Session,
-  Role
+  Role,
+  UserRole,
+  upsert
 }

+ 22 - 1
lib/util.js

@@ -1,6 +1,27 @@
+const _ = require('lodash')
+const { Set } = require('immutable')
 const fillPath = (path, data) =>
   path.replace(/:(\w+)/g, (text, key) => data[key] || text)
 
+const diffBy = (before, after, selector) => {
+  // Collect new, collect old, collect same
+  const beforeDict = _.fromPairs(before.map(x => [selector(x), x]))
+  const afterDict = _.fromPairs(before.map(x => [selector(x), x]))
+  const beforeKeys = new Set(before.map(selector))
+  const afterKeys = new Set(after.map(selector))
+
+  const newKeys = afterKeys.subtract(beforeKeys)
+  const oldKeys = beforeKeys.subtract(afterKeys)
+  const commonKeys = beforeKeys.intersect(afterKeys)
+
+  return {
+    'new': newKeys.map(key => afterDict[key]),
+    old: oldKeys.map(key => beforeDict[key]),
+    common: commonKeys.map(key => [beforeDict[key], afterDict[key]])
+  }
+}
+
 module.exports = {
-  fillPath
+  fillPath,
+  diffBy
 }

+ 2 - 0
package.json

@@ -34,6 +34,7 @@
     "es6-string-html-template": "^1.0.2",
     "express": "^4.16.3",
     "express-async-handler": "^1.1.3",
+    "immutable": "^3.8.2",
     "jquery": "^3.3.1",
     "jsonwebtoken": "^8.2.2",
     "lodash": "^4.17.10",
@@ -41,6 +42,7 @@
     "password-prompt": "^1.0.4",
     "plural": "^1.1.0",
     "sequelize": "^4.37.6",
+    "set": "^1.1.1",
     "vorpal": "^1.12.0",
     "xlsx": "^0.13.0"
   },