Browse Source

feat: admin - manage groups + permissions + page rules

Nicolas Giard 6 years ago
parent
commit
edb97b832d
36 changed files with 1112 additions and 958 deletions
  1. 5 0
      Makefile
  2. 58 43
      client/components/admin.vue
  3. 2 0
      client/components/admin/admin-auth.vue
  4. 45 30
      client/components/admin/admin-dashboard.vue
  5. 203 0
      client/components/admin/admin-groups-edit-permissions.vue
  6. 302 0
      client/components/admin/admin-groups-edit-rules.vue
  7. 147 0
      client/components/admin/admin-groups-edit-users.vue
  8. 19 287
      client/components/admin/admin-groups-edit.vue
  9. 22 36
      client/components/admin/admin-groups.vue
  10. 17 2
      client/components/admin/admin-system.vue
  11. 5 33
      client/components/admin/admin-users-create.vue
  12. 0 211
      client/components/common/criterias-item.vue
  13. 0 173
      client/components/common/criterias.vue
  14. 123 111
      client/components/common/nav-header.vue
  15. 9 7
      client/components/common/user-search.vue
  16. 1 0
      client/components/editor.vue
  17. 2 2
      client/graph/admin/groups/groups-mutation-update.gql
  18. 4 3
      client/graph/admin/groups/groups-query-single.gql
  19. 1 0
      client/graph/admin/system/system-query-info.gql
  20. 4 0
      client/static/svg/icon-apple-logo.svg
  21. 13 0
      client/static/svg/icon-delete-file.svg
  22. 2 0
      client/static/svg/icon-docker-logo.svg
  23. 2 0
      client/static/svg/icon-linux-logo.svg
  24. 4 0
      client/static/svg/icon-windows-logo.svg
  25. 18 8
      client/themes/default/components/page.vue
  26. 35 0
      dev/scripts/docker-clean-db.js
  27. 2 6
      package.json
  28. 1 0
      server/db/migrations/2.0.0.js
  29. 6 1
      server/graph/resolvers/group.js
  30. 5 2
      server/graph/resolvers/system.js
  31. 41 1
      server/graph/schemas/group.graphql
  32. 1 0
      server/graph/schemas/system.graphql
  33. 1 1
      server/jobs/purge-uploads.js
  34. 1 0
      server/master.js
  35. 10 0
      server/setup.js
  36. 1 1
      server/views/new.pug

+ 5 - 0
Makefile

@@ -33,6 +33,11 @@ docker-dev-rebuild: ## Rebuild dockerized dev image
 	rm -rf ./node_modules
 	docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . build --no-cache --force-rm
 
+docker-dev-clean: ## Clean DB, redis and data folders
+	rm -rf ./data
+	docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . exec db psql --dbname=wiki --username=postgres --command='DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public'
+	docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . exec redis redis-cli flushall
+
 docker-build: ## Run assets generation build in docker
 	docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . run wiki yarn build
 	docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . down

+ 58 - 43
client/components/admin.vue

@@ -9,13 +9,13 @@
             v-list-tile-title {{ $t('admin:dashboard.title') }}
           v-divider.my-2
           v-subheader.pl-4 {{ $t('admin:nav.site') }}
-          v-list-tile(to='/general')
+          v-list-tile(to='/general', v-if='hasPermission(`manage:system`)')
             v-list-tile-avatar: v-icon widgets
             v-list-tile-title {{ $t('admin:general.title') }}
-          v-list-tile(to='/locale')
+          v-list-tile(to='/locale', v-if='hasPermission(`manage:system`)')
             v-list-tile-avatar: v-icon language
             v-list-tile-title {{ $t('admin:locale.title') }}
-          v-list-tile(to='/navigation')
+          v-list-tile(to='/navigation', v-if='hasPermission([`manage:system`, `manage:navigation`])')
             v-list-tile-avatar: v-icon near_me
             v-list-tile-title {{ $t('admin:navigation.title') }}
           v-list-tile(to='/pages')
@@ -24,7 +24,7 @@
             v-list-tile-action
               v-chip(small, disabled, :color='darkMode ? `grey darken-3-d4` : `grey lighten-4`')
                 .caption.grey--text {{ info.pagesTotal }}
-          v-list-tile(to='/theme')
+          v-list-tile(to='/theme', v-if='hasPermission([`manage:system`, `manage:theme`])')
             v-list-tile-avatar: v-icon palette
             v-list-tile-title {{ $t('admin:theme.title') }}
           v-divider.my-2
@@ -41,44 +41,46 @@
             v-list-tile-action
               v-chip(small, disabled, :color='darkMode ? `grey darken-3-d4` : `grey lighten-4`')
                 .caption.grey--text {{ info.usersTotal }}
-          v-divider.my-2
-          v-subheader.pl-4 {{ $t('admin:nav.modules') }}
-          v-list-tile(to='/auth')
-            v-list-tile-avatar: v-icon lock_outline
-            v-list-tile-title {{ $t('admin:auth.title') }}
-          v-list-tile(to='/editor')
-            v-list-tile-avatar: v-icon transform
-            v-list-tile-title {{ $t('admin:editor.title') }}
-          v-list-tile(to='/logging')
-            v-list-tile-avatar: v-icon graphic_eq
-            v-list-tile-title {{ $t('admin:logging.title') }}
-          v-list-tile(to='/rendering')
-            v-list-tile-avatar: v-icon system_update_alt
-            v-list-tile-title {{ $t('admin:rendering.title') }}
-          v-list-tile(to='/search')
-            v-list-tile-avatar: v-icon search
-            v-list-tile-title {{ $t('admin:search.title') }}
-          v-list-tile(to='/storage')
-            v-list-tile-avatar: v-icon storage
-            v-list-tile-title {{ $t('admin:storage.title') }}
-          v-divider.my-2
-          v-subheader.pl-4 {{ $t('admin:nav.system') }}
-          v-list-tile(to='/api')
-            v-list-tile-avatar: v-icon call_split
-            v-list-tile-title {{ $t('admin:api.title') }}
-          v-list-tile(to='/mail')
-            v-list-tile-avatar: v-icon email
-            v-list-tile-title {{ $t('admin:mail.title') }}
-          v-list-tile(to='/system')
-            v-list-tile-avatar: v-icon tune
-            v-list-tile-title {{ $t('admin:system.title') }}
-          v-list-tile(to='/utilities')
-            v-list-tile-avatar: v-icon build
-            v-list-tile-title {{ $t('admin:utilities.title') }}
-          v-list-tile(to='/dev')
-            v-list-tile-avatar: v-icon weekend
-            v-list-tile-title {{ $t('admin:dev.title') }}
-          v-divider.my-2
+          template(v-if='hasPermission(`manage:system`)')
+            v-divider.my-2
+            v-subheader.pl-4 {{ $t('admin:nav.modules') }}
+            v-list-tile(to='/auth')
+              v-list-tile-avatar: v-icon lock_outline
+              v-list-tile-title {{ $t('admin:auth.title') }}
+            v-list-tile(to='/editor')
+              v-list-tile-avatar: v-icon transform
+              v-list-tile-title {{ $t('admin:editor.title') }}
+            v-list-tile(to='/logging')
+              v-list-tile-avatar: v-icon graphic_eq
+              v-list-tile-title {{ $t('admin:logging.title') }}
+            v-list-tile(to='/rendering')
+              v-list-tile-avatar: v-icon system_update_alt
+              v-list-tile-title {{ $t('admin:rendering.title') }}
+            v-list-tile(to='/search')
+              v-list-tile-avatar: v-icon search
+              v-list-tile-title {{ $t('admin:search.title') }}
+            v-list-tile(to='/storage')
+              v-list-tile-avatar: v-icon storage
+              v-list-tile-title {{ $t('admin:storage.title') }}
+            v-divider.my-2
+          template(v-if='hasPermission([`manage:system`, `manage:api`])')
+            v-subheader.pl-4 {{ $t('admin:nav.system') }}
+            v-list-tile(to='/api', v-if='hasPermission([`manage:system`, `manage:api`])')
+              v-list-tile-avatar: v-icon call_split
+              v-list-tile-title {{ $t('admin:api.title') }}
+            v-list-tile(to='/mail', v-if='hasPermission(`manage:system`)')
+              v-list-tile-avatar: v-icon email
+              v-list-tile-title {{ $t('admin:mail.title') }}
+            v-list-tile(to='/system', v-if='hasPermission(`manage:system`)')
+              v-list-tile-avatar: v-icon tune
+              v-list-tile-title {{ $t('admin:system.title') }}
+            v-list-tile(to='/utilities', v-if='hasPermission(`manage:system`)')
+              v-list-tile-avatar: v-icon build
+              v-list-tile-title {{ $t('admin:utilities.title') }}
+            v-list-tile(to='/dev', v-if='hasPermission([`manage:system`, `manage:api`])')
+              v-list-tile-avatar: v-icon weekend
+              v-list-tile-title {{ $t('admin:dev.title') }}
+            v-divider.my-2
           v-list-tile(to='/contribute')
             v-list-tile-avatar: v-icon favorite
             v-list-tile-title {{ $t('admin:contribute.title') }}
@@ -91,6 +93,7 @@
 </template>
 
 <script>
+import _ from 'lodash'
 import VueRouter from 'vue-router'
 import { get, sync } from 'vuex-pathify'
 
@@ -161,12 +164,24 @@ export default {
   },
   computed: {
     darkMode: get('site/dark'),
-    info: sync('admin/info')
+    info: sync('admin/info'),
+    permissions: get('user/permissions')
   },
   router,
   created() {
     this.$store.commit('page/SET_MODE', 'admin')
   },
+  methods: {
+    hasPermission(prm) {
+      if (_.isArray(prm)) {
+        return _.some(prm, p => {
+          return _.includes(this.permissions, p)
+        })
+      } else {
+        return _.includes(this.permissions, prm)
+      }
+    }
+  },
   apollo: {
     info: {
       query: statsQuery,

+ 2 - 0
client/components/admin/admin-auth.vue

@@ -130,6 +130,7 @@
                       outline
                       background-color='grey lighten-2'
                       persistent-hint
+                      small-chips
                       deletable-chips
                       clearable
                       multiple
@@ -145,6 +146,7 @@
                       v-model='strategy.autoEnrollGroups'
                       prepend-icon='people'
                       hint='Automatically assign new users to these groups.'
+                      small-chips
                       persistent-hint
                       deletable-chips
                       clearable

+ 45 - 30
client/components/admin/admin-dashboard.vue

@@ -8,38 +8,53 @@
             .headline.primary--text {{ $t('admin:dashboard.title') }}
             .subheading.grey--text {{ $t('admin:dashboard.subtitle') }}
       v-flex(xs12 md6 lg4 xl3 d-flex)
-        v-card.primary.dashboard-card(dark)
-          v-card-text
-            v-icon.dashboard-icon insert_drive_file
-            .subheading Pages
-            animated-number.display-1(
-              :value='info.pagesTotal'
-              :duration='2000'
-              :formatValue='round'
-              easing='easeOutQuint'
-              )
+        v-hover
+          v-card.primary.dashboard-card(
+            dark
+            slot-scope='{ hover }'
+            :class='hover ? `elevation-10` : `elevation-2`'
+            )
+            v-card-text
+              v-icon.dashboard-icon insert_drive_file
+              .subheading Pages
+              animated-number.display-1(
+                :value='info.pagesTotal'
+                :duration='2000'
+                :formatValue='round'
+                easing='easeOutQuint'
+                )
       v-flex(xs12 md6 lg4 xl3 d-flex)
-        v-card.indigo.lighten-1.dashboard-card(dark)
-          v-card-text
-            v-icon.dashboard-icon person
-            .subheading Users
-            animated-number.display-1(
-              :value='info.usersTotal'
-              :duration='2000'
-              :formatValue='round'
-              easing='easeOutQuint'
-              )
+        v-hover
+          v-card.indigo.lighten-1.dashboard-card(
+            dark
+            slot-scope='{ hover }'
+            :class='hover ? `elevation-10` : `elevation-2`'
+            )
+            v-card-text
+              v-icon.dashboard-icon person
+              .subheading Users
+              animated-number.display-1(
+                :value='info.usersTotal'
+                :duration='2000'
+                :formatValue='round'
+                easing='easeOutQuint'
+                )
       v-flex(xs12 md6 lg4 xl3 d-flex)
-        v-card.indigo.lighten-2.dashboard-card(dark)
-          v-card-text
-            v-icon.dashboard-icon people
-            .subheading Groups
-            animated-number.display-1(
-              :value='info.groupsTotal'
-              :duration='2000'
-              :formatValue='round'
-              easing='easeOutQuint'
-              )
+        v-hover
+          v-card.indigo.lighten-2.dashboard-card(
+            dark
+            slot-scope='{ hover }'
+            :class='hover ? `elevation-10` : `elevation-2`'
+            )
+            v-card-text
+              v-icon.dashboard-icon people
+              .subheading Groups
+              animated-number.display-1(
+                :value='info.groupsTotal'
+                :duration='2000'
+                :formatValue='round'
+                easing='easeOutQuint'
+                )
       v-flex(xs12 md6 lg12 xl3 d-flex)
         v-card.dashboard-card(
           :class='isLatestVersion ? "teal lighten-2" : "red lighten-2"'

+ 203 - 0
client/components/admin/admin-groups-edit-permissions.vue

@@ -0,0 +1,203 @@
+<template lang="pug">
+  v-card.wiki-form(flat)
+    v-card-text
+      v-text-field(
+        outline
+        background-color='grey lighten-3'
+        v-model='group.name'
+        label='Group Name'
+        counter='255'
+        prepend-icon='people'
+        )
+      v-alert.radius-7(
+        v-if='group.isSystem'
+        color='orange darken-2'
+        :class='$vuetify.dark ? "grey darken-4" : "orange lighten-5"'
+        outline
+        :value='true'
+        icon='lock_outline'
+        ) This is a system group. Some permissions cannot be modified.
+    v-container.px-3.pb-3.pt-0(fluid, grid-list-md)
+      v-layout(row, wrap)
+        v-flex(xs12, md6, lg4, v-for='pmGroup in permissions')
+          v-card.md2(flat, :class='$vuetify.dark ? "grey darken-3-d5" : "white"')
+            v-subheader {{pmGroup.category}}
+            v-card-text.pt-0
+              template(v-for='(pm, idx) in pmGroup.items')
+                v-checkbox.pt-0(
+                  :key='pm.permission'
+                  :label='pm.permission'
+                  :hint='pm.hint'
+                  persistent-hint
+                  color='primary'
+                  v-model='group.permissions'
+                  :value='pm.permission'
+                  :append-icon='pm.warning ? "warning" : null',
+                  :disabled='(group.isSystem && pm.restrictedForSystem) || group.id === 1 || pm.disabled'
+                )
+                v-divider.mt-3(v-if='idx < pmGroup.items.length - 1')
+</template>
+
+<script>
+export default {
+  props: {
+    value: {
+      type: Object
+    }
+  },
+  data() {
+    return {
+      permissions: [
+        {
+          category: 'Content',
+          items: [
+            {
+              permission: 'read:pages',
+              hint: 'Can view pages, as specified in the Page Rules',
+              warning: false,
+              restrictedForSystem: false,
+              disabled: false
+            },
+            {
+              permission: 'write:pages',
+              hint: 'Can view and create new pages, as specified in the Page Rules',
+              warning: false,
+              restrictedForSystem: false,
+              disabled: false
+            },
+            {
+              permission: 'manage:pages',
+              hint: 'Can view, create, edit and move existing pages as specified in the Page Rules',
+              warning: false,
+              restrictedForSystem: false,
+              disabled: false
+            },
+            {
+              permission: 'delete:pages',
+              hint: 'Can delete existing pages, as specified in the Page Rules',
+              warning: false,
+              restrictedForSystem: false,
+              disabled: false
+            },
+            {
+              permission: 'read:assets',
+              hint: 'Can view / use assets (such as images and files), as specified in the Page Rules',
+              warning: false,
+              restrictedForSystem: false,
+              disabled: false
+            },
+            {
+              permission: 'write:assets',
+              hint: 'Can upload new assets (such as images and files), as specified in the Page Rules',
+              warning: false,
+              restrictedForSystem: false,
+              disabled: false
+            },
+            {
+              permission: 'manage:assets',
+              hint: 'Can edit and delete assets (such as images and files), as specified in the Page Rules',
+              warning: false,
+              restrictedForSystem: false,
+              disabled: false
+            },
+            {
+              permission: 'read:comments',
+              hint: 'Can view comments, as specified in the Page Rules',
+              warning: false,
+              restrictedForSystem: false,
+              disabled: false
+            },
+            {
+              permission: 'write:comments',
+              hint: 'Can post new comments, as specified in the Page Rules',
+              warning: false,
+              restrictedForSystem: false,
+              disabled: false
+            },
+            {
+              permission: 'manage:comments',
+              hint: 'Can edit and delete comments, as specified in the Page Rules',
+              warning: false,
+              restrictedForSystem: false,
+              disabled: false
+            }
+          ]
+        },
+        {
+          category: 'Users',
+          items: [
+            {
+              permission: 'write:users',
+              hint: 'Can create or authorize new users, but not modify existing ones',
+              warning: false,
+              restrictedForSystem: true,
+              disabled: false
+            },
+            {
+              permission: 'manage:users',
+              hint: 'Can manage all users (but not users with administrative permissions)',
+              warning: false,
+              restrictedForSystem: true,
+              disabled: false
+            },
+            {
+              permission: 'write:groups',
+              hint: 'Can manage groups and assign CONTENT permissions / page rules',
+              warning: false,
+              restrictedForSystem: true,
+              disabled: false
+            },
+            {
+              permission: 'manage:groups',
+              hint: 'Can manage groups and assign ANY permissions (but not manage:system) / page rules',
+              warning: true,
+              restrictedForSystem: true,
+              disabled: false
+            }
+          ]
+        },
+        {
+          category: 'Administration',
+          items: [
+            {
+              permission: 'manage:navigation',
+              hint: 'Can manage the site navigation',
+              warning: false,
+              restrictedForSystem: true,
+              disabled: false
+            },
+            {
+              permission: 'manage:theme',
+              hint: 'Can manage and modify themes',
+              warning: false,
+              restrictedForSystem: true,
+              disabled: false
+            },
+            {
+              permission: 'manage:api',
+              hint: 'Can generate and revoke API keys',
+              warning: true,
+              restrictedForSystem: true,
+              disabled: false
+            },
+            {
+              permission: 'manage:system',
+              hint: 'Can manage and access everything. Root administrator.',
+              warning: true,
+              restrictedForSystem: true,
+              disabled: true
+
+            }
+          ]
+        }
+      ]
+    }
+  },
+  computed: {
+    group: {
+      get() { return this.value },
+      set(val) { this.$set('input', val) }
+    }
+  }
+}
+</script>

+ 302 - 0
client/components/admin/admin-groups-edit-rules.vue

@@ -0,0 +1,302 @@
+<template lang="pug">
+  v-card.wiki-form
+    v-card-text(v-if='group.id === 1')
+      v-alert.radius-7(
+        :class='$vuetify.dark ? "grey darken-4" : "orange lighten-5"'
+        color='orange darken-2'
+        outline
+        :value='true'
+        icon='lock_outline'
+        ) This group has access to everything.
+    template(v-else)
+      v-card-title(:class='$vuetify.dark ? `grey darken-3-d5` : `grey lighten-5`')
+        v-alert.radius-7(
+          :class='$vuetify.dark ? `grey darken-3-d3` : `white`'
+          :value='true'
+          color='grey'
+          outline
+          icon='info'
+          ) You must enable global content permissions (under Permissions tab) for page rules to have any effect.
+        v-spacer
+        v-btn(depressed, color='primary', @click='addRule')
+          v-icon(left) add
+          | Add Rule
+        v-menu(
+          right
+          offset-y
+          nudge-left='115'
+          )
+          v-btn.is-icon(slot='activator', flat, outline, color='primary')
+            v-icon more_horiz
+          v-list(dense)
+            v-list-tile(@click='comingSoon')
+              v-list-tile-avatar
+                v-icon keyboard_capslock
+              v-list-tile-title Load Preset
+            v-divider
+            v-list-tile(@click='comingSoon')
+              v-list-tile-avatar
+                v-icon publish
+              v-list-tile-title Save As Preset
+            v-divider
+            v-list-tile(@click='comingSoon')
+              v-list-tile-avatar
+                v-icon cloud_upload
+              v-list-tile-title Import Rules
+            v-divider
+            v-list-tile(@click='comingSoon')
+              v-list-tile-avatar
+                v-icon cloud_download
+              v-list-tile-title Export Rules
+      v-card-text(:class='$vuetify.dark ? `grey darken-4-l5` : `white`')
+        .rules
+          .caption(v-if='group.pageRules.length === 0')
+            em(:class='$vuetify.dark ? `grey--text` : `blue-grey--text`') This group has no page rules yet.
+          .rule(v-for='rule of group.pageRules', :key='rule.id')
+            v-btn.ma-0.rule-deny-btn(
+              solo
+              :color='rule.deny ? "red" : "green"'
+              dark
+              @click='rule.deny = !rule.deny'
+              )
+              v-icon(v-if='rule.deny') block
+              v-icon(v-else) check_circle
+            //- Roles
+            v-select.ml-1(
+              solo
+              :items='roles'
+              v-model='rule.roles'
+              placeholder='Select Role(s)...'
+              hide-details
+              multiple
+              chips
+              deletable-chips
+              small-chips
+              style='flex: 0 1 440px;'
+              :menu-props='{ "maxHeight": 500 }'
+              clearable
+              dense
+              )
+              template(slot='selection', slot-scope='{ item, index }')
+                v-chip.white--text.ml-0(v-if='index <= 2', small, label, :color='rule.deny ? `red` : `green`').caption {{ item.value }}
+                v-chip.white--text.ml-0(v-if='index === 3', small, label, :color='rule.deny ? `red lighten-2` : `green lighten-2`').caption + {{ rule.roles.length - 3 }} more
+              template(slot='item', slot-scope='props')
+                v-list-tile-action(style='min-width: 30px;')
+                  v-checkbox(
+                    v-model='props.tile.props.value'
+                    hide-details
+                    color='primary'
+                  )
+                v-icon.mr-2(:color='rule.deny ? `red` : `green`') {{props.item.icon}}
+                v-list-tile-content
+                  v-list-tile-title.body-2 {{props.item.text}}
+                v-chip.mr-2.grey--text(label, small, :color='$vuetify.dark ? `grey darken-4` : `grey lighten-4`').caption {{props.item.value}}
+
+            //- Match
+            v-select.ml-1.mr-1(
+              solo
+              :items='matches'
+              v-model='rule.match'
+              placeholder='Match...'
+              hide-details
+              style='flex: 0 1 250px;'
+              dense
+              )
+              template(slot='selection', slot-scope='{ item, index }')
+                .body-1 {{item.text}}
+              template(slot='item', slot-scope='data')
+                v-list-tile-avatar
+                  v-avatar.white--text.radius-4(color='blue', size='30', tile) {{ data.item.icon }}
+                v-list-tile-content
+                  v-list-tile-title(v-html='data.item.text')
+            //- Locales
+            v-select.mr-1(
+              :background-color='$vuetify.dark ? `grey darken-3-d5` : `blue-grey lighten-5`'
+              solo
+              :items='locales'
+              v-model='rule.locales'
+              placeholder='Any Locale'
+              multiple
+              hide-details
+              dense
+              :menu-props='{ "minWidth": 250 }'
+              style='flex: 0 1 150px;'
+              )
+              template(slot='selection', slot-scope='{ item, index }')
+                v-chip.white--text.ml-0(v-if='rule.locales.length === 1', small, label, :color='rule.deny ? `red` : `green`').caption {{ item.value.toUpperCase() }}
+                v-chip.white--text.ml-0(v-else-if='index === 0', small, label, :color='rule.deny ? `red` : `green`').caption {{ rule.locales.length }} locales
+              v-list-tile(slot='prepend-item', @click='rule.locales = []')
+                v-list-tile-action(style='min-width: 30px;')
+                  v-checkbox(
+                    :input-value='rule.locales.length === 0'
+                    hide-details
+                    color='primary'
+                    readonly
+                  )
+                v-icon.mr-2(:color='rule.deny ? `red` : `green`') public
+                v-list-tile-content
+                  v-list-tile-title.body-2 Any Locale
+              v-divider(slot='prepend-item')
+              template(slot='item', slot-scope='props')
+                v-list-tile-action(style='min-width: 30px;')
+                  v-checkbox(
+                    v-model='props.tile.props.value'
+                    hide-details
+                    color='primary'
+                  )
+                v-icon.mr-2(:color='rule.deny ? `red` : `green`') language
+                v-list-tile-content
+                  v-list-tile-title.body-2 {{props.item.text}}
+                v-chip.mr-2.grey--text(label, small, :color='$vuetify.dark ? `grey darken-4` : `grey lighten-4`').caption {{props.item.value.toUpperCase()}}
+
+            //- Path
+            v-text-field(
+              solo
+              v-model='rule.path'
+              label='Path'
+              :prefix='rule.match !== `END` ? `/` : null'
+              :placeholder='rule.match === `REGEX` ? `Regular Expression` : `Path`'
+              :suffix='rule.match === `REGEX` ? `/` : null'
+              hide-details
+              :color='$vuetify.dark ? `grey` : `blue-grey`'
+              )
+
+            v-btn(icon, @click='removeRule(rule.id)')
+              v-icon(:color='$vuetify.dark ? `grey` : `blue-grey`') clear
+</template>
+
+<script>
+import _ from 'lodash'
+import nanoid from 'nanoid/non-secure/generate'
+
+export default {
+  props: {
+    value: {
+      type: Object
+    }
+  },
+  data() {
+    return {
+      roles: [
+        { text: 'Read Pages', value: 'READ', icon: 'insert_drive_file' },
+        { text: 'Create Pages', value: 'WRITE', icon: 'insert_drive_file' },
+        { text: 'Edit + Move Pages', value: 'MANAGE', icon: 'insert_drive_file' },
+        { text: 'Delete Pages', value: 'DELETE', icon: 'insert_drive_file' },
+        { text: 'Read / Use Assets', value: 'AS_READ', icon: 'camera' },
+        { text: 'Upload Assets', value: 'AS_WRITE', icon: 'camera' },
+        { text: 'Edit + Delete Assets', value: 'AS_MANAGE', icon: 'camera' },
+        { text: 'Read Comments', value: 'CM_READ', icon: 'insert_comment' },
+        { text: 'Create Comments', value: 'CM_WRITE', icon: 'insert_comment' },
+        { text: 'Edit + Delete Comments', value: 'CM_MANAGE', icon: 'insert_comment' }
+      ],
+      matches: [
+        { text: 'Path Starts With...', value: 'START', icon: '/...' },
+        { text: 'Path is Exactly...', value: 'EXACT', icon: '=' },
+        { text: 'Path Ends With...', value: 'END', icon: '.../' },
+        { text: 'Path Matches Regex...', value: 'REGEX', icon: '$.*' }
+      ],
+      locales: [
+        { text: 'English', value: 'en' },
+        { text: 'Français', value: 'fr' },
+      ]
+    }
+  },
+  computed: {
+    group: {
+      get() { return this.value },
+      set(val) { this.$set('input', val) }
+    }
+  },
+  methods: {
+    addRule(group) {
+      this.group.pageRules.push({
+        id: nanoid('1234567890abcdef', 10),
+        path: '',
+        roles: [],
+        match: 'START',
+        deny: false,
+        locales: []
+      })
+    },
+    removeRule(rule) {
+      this.group.pageRules.splice(_.findIndex(this.group.pageRules, ['id', rule.id]), 1)
+    },
+    comingSoon() {
+      this.$store.commit('showNotification', {
+        style: 'indigo',
+        message: `Coming soon...`,
+        icon: 'directions_boat'
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+.rules {
+  background-color: mc('blue-grey', '50');
+  border-radius: 4px;
+  padding: 1rem;
+  position: relative;
+
+  @at-root .theme--dark & {
+    background-color: mc('grey', '800');
+  }
+}
+
+.rule {
+  display: flex;
+  background-color: mc('blue-grey', '100');
+  border-radius: 4px;
+  padding: .5rem;
+
+  &-enter-active, &-leave-active {
+    transition: all .5s ease;
+  }
+  &-enter, &-leave-to {
+    opacity: 0;
+  }
+
+  @at-root .theme--dark & {
+    background-color: mc('grey', '700');
+  }
+
+  & + .rule {
+    margin-top: .5rem;
+    position: relative;
+
+    &::before {
+      content: '+';
+      position: absolute;
+      width: 2rem;
+      height: 2rem;
+      border-radius: 50%;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      font-weight: 600;
+      color: mc('blue-grey', '700');
+      font-size: 1.25rem;
+      background-color: mc('blue-grey', '50');
+      left: -2rem;
+      top: -1.3rem;
+
+      @at-root .theme--dark & {
+        background-color: mc('grey', '800');
+        color: mc('grey', '600');
+      }
+    }
+  }
+
+  .input-group + * {
+    margin-left: .5rem;
+  }
+
+  &-deny-btn {
+    height: 48px;
+    border-radius: 2px 0 0 2px;
+    min-width: 0;
+  }
+
+}
+</style>

+ 147 - 0
client/components/admin/admin-groups-edit-users.vue

@@ -0,0 +1,147 @@
+<template lang="pug">
+  v-card.wiki-form
+    v-card-title(:class='$vuetify.dark ? `grey darken-3-d3` : `grey lighten-5`')
+      v-text-field(
+        outline
+        flat
+        prepend-inner-icon='search'
+        v-model='search'
+        label='Search Group Users...'
+        hide-details
+      )
+      v-spacer
+      v-btn(color='primary', depressed, @click='searchUserDialog = true', :disabled='group.id === 2')
+        v-icon(left) assignment_ind
+        | Assign User
+    v-data-table(
+      :items='group.users',
+      :headers='headers',
+      :search='search'
+      :pagination.sync='pagination',
+      :rows-per-page-items='[15]'
+      hide-actions
+    )
+      template(slot='items', slot-scope='props')
+        tr(:active='props.selected')
+          td.text-xs-right {{ props.item.id }}
+          td {{ props.item.name }}
+          td {{ props.item.email }}
+          td
+            v-menu(bottom, right, min-width='200')
+              v-btn(icon, slot='activator'): v-icon.grey--text.text--darken-1 more_horiz
+              v-list
+                v-list-tile(:to='`/users/` + props.item.id')
+                  v-list-tile-action: v-icon(color='primary') person
+                  v-list-tile-content
+                    v-list-tile-title View User Profile
+                template(v-if='props.item.id !== 2')
+                  v-divider
+                  v-list-tile(@click='unassignUser(props.item.id)')
+                    v-list-tile-action: v-icon(color='orange') highlight_off
+                    v-list-tile-content
+                      v-list-tile-title Unassign
+      template(slot='no-data')
+        v-alert.ma-3(icon='warning', :value='true', outline) No users to display.
+    .text-xs-center.py-2(v-if='group.users.length > 15')
+      v-pagination(v-model='pagination.page', :length='pages')
+
+    user-search(v-model='searchUserDialog', @select='assignUser')
+</template>
+
+<script>
+import UserSearch from '../common/user-search.vue'
+
+import assignUserMutation from 'gql/admin/groups/groups-mutation-assign.gql'
+import unassignUserMutation from 'gql/admin/groups/groups-mutation-unassign.gql'
+
+export default {
+  props: {
+    value: {
+      type: Object
+    }
+  },
+  components: {
+    UserSearch
+  },
+  data() {
+    return {
+      headers: [
+        { text: 'ID', value: 'id', width: 50, align: 'right' },
+        { text: 'Name', value: 'name' },
+        { text: 'Email', value: 'email' },
+        { text: '', value: 'actions', sortable: false, width: 50 }
+      ],
+      searchUserDialog: false,
+      pagination: {},
+      search: ''
+    }
+  },
+  computed: {
+    group: {
+      get() { return this.value },
+      set(val) { this.$set('input', val) }
+    },
+    pages () {
+      if (this.pagination.rowsPerPage == null || this.pagination.totalItems == null) {
+        return 0
+      }
+
+      return Math.ceil(this.pagination.totalItems / this.pagination.rowsPerPage)
+    }
+  },
+  methods: {
+    async assignUser(id) {
+      try {
+        await this.$apollo.mutate({
+          mutation: assignUserMutation,
+          variables: {
+            groupId: this.group.id,
+            userId: id
+          },
+          watchLoading (isLoading) {
+            this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-groups-assign')
+          }
+        })
+        this.$store.commit('showNotification', {
+          style: 'success',
+          message: `User has been assigned to ${this.group.name}.`,
+          icon: 'assignment_ind'
+        })
+        this.$emit('refresh')
+      } catch (err) {
+        this.$store.commit('showNotification', {
+          style: 'red',
+          message: err.message,
+          icon: 'warning'
+        })
+      }
+    },
+    async unassignUser(id) {
+      try {
+        await this.$apollo.mutate({
+          mutation: unassignUserMutation,
+          variables: {
+            groupId: this.group.id,
+            userId: id
+          },
+          watchLoading (isLoading) {
+            this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-groups-unassign')
+          }
+        })
+        this.$store.commit('showNotification', {
+          style: 'success',
+          message: `User has been unassigned from ${this.group.name}.`,
+          icon: 'assignment_ind'
+        })
+        this.$emit('refresh')
+      } catch (err) {
+        this.$store.commit('showNotification', {
+          style: 'red',
+          message: err.message,
+          icon: 'warning'
+        })
+      }
+    }
+  }
+}
+</script>

+ 19 - 287
client/components/admin/admin-groups-edit.vue

@@ -6,130 +6,57 @@
           img(src='/svg/icon-social-group.svg', alt='Edit Group', style='width: 80px;')
           .admin-header-title
             .headline.blue--text.text--darken-2 Edit Group
-            .subheading.grey--text {{name}}
+            .subheading.grey--text {{group.name}}
           v-spacer
           .caption.grey--text ID #[strong {{group.id}}]
           v-divider.mx-3(vertical)
-          v-btn(color='indigo', large, outline, to='/groups')
+          v-btn(color='grey', large, outline, to='/groups')
             v-icon arrow_back
           v-dialog(v-model='deleteGroupDialog', max-width='500', v-if='!group.isSystem')
             v-btn(color='red', large, outline, slot='activator')
               v-icon(color='red') delete
             v-card
               .dialog-header.is-red Delete Group?
-              v-card-text Are you sure you want to delete group #[strong {{ name }}]? All users will be unassigned from this group.
+              v-card-text Are you sure you want to delete group #[strong {{ group.name }}]? All users will be unassigned from this group.
               v-card-actions
                 v-spacer
                 v-btn(flat, @click='deleteGroupDialog = false') Cancel
                 v-btn(color='red', dark, @click='deleteGroup') Delete
-          v-btn(color='primary', large, depressed, @click='updateGroup')
+          v-btn(color='success', large, depressed, @click='updateGroup')
             v-icon(left) check
             span Update Group
         v-card.mt-3
           v-tabs(v-model='tab', :color='$vuetify.dark ? "primary" : "grey darken-2"', fixed-tabs, slider-color='white', show-arrows, dark)
-            v-tab(key='properties') Properties
             v-tab(key='permissions') Permissions
             v-tab(key='rules') Page Rules
             v-tab(key='users') Users
 
-            v-tab-item(key='properties', :transition='false', :reverse-transition='false')
-              v-card
-                v-card-text
-                  v-text-field(
-                    outline
-                    background-color='grey lighten-3'
-                    v-model='name'
-                    label='Group Name'
-                    counter='255'
-                    prepend-icon='people'
-                    )
-
             v-tab-item(key='permissions', :transition='false', :reverse-transition='false')
-              v-container.pa-3(fluid, grid-list-md)
-                v-layout(row, wrap)
-                  v-flex(xs12, md6, lg4, v-for='pmGroup in permissions')
-                    v-card.md2.grey(flat, :class='$vuetify.dark ? "darken-4" : "lighten-5"')
-                      v-subheader {{pmGroup.category}}
-                      v-card-text.pt-0
-                        template(v-for='(pm, idx) in pmGroup.items')
-                          v-checkbox.pt-0(
-                            :key='pm.permission'
-                            :label='pm.permission'
-                            :hint='pm.hint'
-                            persistent-hint
-                            color='primary'
-                            v-model='group.permissions'
-                            :value='pm.permission'
-                            :append-icon='pm.warning ? "warning" : null',
-                            :disabled='(group.isSystem && pm.restrictedForSystem) || group.id === 1 || pm.disabled'
-                          )
-                          v-divider.mt-3(v-if='idx < pmGroup.items.length - 1')
+              group-permissions(v-model='group', @refresh='refresh')
 
             v-tab-item(key='rules', :transition='false', :reverse-transition='false')
-              v-card
-                v-card-title.pb-0
-                  v-spacer
-                  v-btn(flat, outline)
-                    v-icon(left) arrow_drop_down
-                    | Load Preset
-                  v-btn(flat, outline)
-                    v-icon(left) vertical_align_bottom
-                    | Import Rules
-                .pa-3.pl-4
-                  criterias
+              group-rules(v-model='group', @refresh='refresh')
 
             v-tab-item(key='users', :transition='false', :reverse-transition='false')
-              v-card
-                v-card-title.pb-0
-                  v-spacer
-                  v-btn(color='primary', outline, flat, @click='searchUserDialog = true')
-                    v-icon(left) assignment_ind
-                    | Assign User
-                v-data-table(
-                  :items='group.users',
-                  :headers='headers',
-                  :search='search',
-                  :pagination.sync='pagination',
-                  :rows-per-page-items='[15]'
-                  hide-actions
-                )
-                  template(slot='items', slot-scope='props')
-                    tr(:active='props.selected')
-                      td.text-xs-right {{ props.item.id }}
-                      td {{ props.item.name }}
-                      td {{ props.item.email }}
-                      td
-                        v-menu(bottom, right, min-width='200')
-                          v-btn(icon, slot='activator'): v-icon.grey--text.text--darken-1 more_horiz
-                          v-list
-                            v-list-tile(@click='unassignUser(props.item.id)')
-                              v-list-tile-action: v-icon(color='orange') highlight_off
-                              v-list-tile-content
-                                v-list-tile-title Unassign
-                  template(slot='no-data')
-                    v-alert.ma-3(icon='warning', :value='true', outline) No users to display.
-                .text-xs-center.py-2(v-if='users.length > 15')
-                  v-pagination(v-model='pagination.page', :length='pages')
-
-    user-search(v-model='searchUserDialog', @select='assignUser')
+              group-users(v-model='group', @refresh='refresh')
 </template>
 
 <script>
 import _ from 'lodash'
 
-import Criterias from '../common/criterias.vue'
-import UserSearch from '../common/user-search.vue'
+import GroupPermissions from './admin-groups-edit-permissions.vue'
+import GroupRules from './admin-groups-edit-rules.vue'
+import GroupUsers from './admin-groups-edit-users.vue'
 
 import groupQuery from 'gql/admin/groups/groups-query-single.gql'
-import assignUserMutation from 'gql/admin/groups/groups-mutation-assign.gql'
 import deleteGroupMutation from 'gql/admin/groups/groups-mutation-delete.gql'
-import unassignUserMutation from 'gql/admin/groups/groups-mutation-unassign.gql'
 import updateGroupMutation from 'gql/admin/groups/groups-mutation-update.gql'
 
 export default {
   components: {
-    Criterias,
-    UserSearch
+    GroupPermissions,
+    GroupRules,
+    GroupUsers
   },
   data() {
     return {
@@ -141,158 +68,10 @@ export default {
         pageRules: [],
         users: []
       },
-      name: '',
       deleteGroupDialog: false,
-      searchUserDialog: false,
-      pagination: {},
-      permissions: [
-        {
-          category: 'Content',
-          items: [
-            {
-              permission: 'read:pages',
-              hint: 'Can view pages, as specified in the Page Rules',
-              warning: false,
-              restrictedForSystem: false,
-              disabled: false
-            },
-            {
-              permission: 'write:pages',
-              hint: 'Can view and create new pages, as specified in the Page Rules',
-              warning: false,
-              restrictedForSystem: false,
-              disabled: false
-            },
-            {
-              permission: 'manage:pages',
-              hint: 'Can view, create, edit and move existing pages as specified in the Page Rules',
-              warning: false,
-              restrictedForSystem: false,
-              disabled: false
-            },
-            {
-              permission: 'delete:pages',
-              hint: 'Can delete existing pages, as specified in the Page Rules',
-              warning: false,
-              restrictedForSystem: false,
-              disabled: false
-            },
-            {
-              permission: 'write:assets',
-              hint: 'Can upload assets (such as images and files), as specified in the Page Rules',
-              warning: false,
-              restrictedForSystem: false,
-              disabled: false
-            },
-            {
-              permission: 'read:comments',
-              hint: 'Can view comments, as specified in the Page Rules',
-              warning: false,
-              restrictedForSystem: false,
-              disabled: false
-            },
-            {
-              permission: 'write:comments',
-              hint: 'Can post new comments, as specified in the Page Rules',
-              warning: false,
-              restrictedForSystem: false,
-              disabled: false
-            }
-          ]
-        },
-        {
-          category: 'Users',
-          items: [
-            {
-              permission: 'write:users',
-              hint: 'Can create or authorize new users, but not modify existing ones',
-              warning: false,
-              restrictedForSystem: true,
-              disabled: false
-            },
-            {
-              permission: 'manage:users',
-              hint: 'Can manage all users (but not users with administrative permissions)',
-              warning: false,
-              restrictedForSystem: true,
-              disabled: false
-            },
-            {
-              permission: 'write:groups',
-              hint: 'Can manage groups and assign CONTENT permissions / page rules',
-              warning: false,
-              restrictedForSystem: true,
-              disabled: false
-            },
-            {
-              permission: 'manage:groups',
-              hint: 'Can manage groups and assign ANY permissions (but not manage:system) / page rules',
-              warning: true,
-              restrictedForSystem: true,
-              disabled: false
-            }
-          ]
-        },
-        {
-          category: 'Administration',
-          items: [
-            {
-              permission: 'manage:navigation',
-              hint: 'Can manage the site navigation',
-              warning: false,
-              restrictedForSystem: true,
-              disabled: false
-            },
-            {
-              permission: 'manage:theme',
-              hint: 'Can manage and modify themes',
-              warning: false,
-              restrictedForSystem: true,
-              disabled: false
-            },
-            {
-              permission: 'manage:api',
-              hint: 'Can generate and revoke API keys',
-              warning: true,
-              restrictedForSystem: true,
-              disabled: false
-            },
-            {
-              permission: 'manage:system',
-              hint: 'Can manage and access everything. Root administrator.',
-              warning: true,
-              restrictedForSystem: true,
-              disabled: true
-
-            }
-          ]
-        }
-      ],
-      users: [],
-      headers: [
-        { text: 'ID', value: 'id', width: 50, align: 'right' },
-        { text: 'Name', value: 'name' },
-        { text: 'Email', value: 'email' },
-        { text: '', value: 'actions', sortable: false, width: 50 }
-      ],
-      search: '',
       tab: '1'
     }
   },
-  computed: {
-    pages () {
-      if (this.pagination.rowsPerPage == null || this.pagination.totalItems == null) {
-        return 0
-      }
-
-      return Math.ceil(this.pagination.totalItems / this.pagination.rowsPerPage)
-    }
-  },
-  watch: {
-    group(newValue, oldValue) {
-      this.name = newValue.name
-    }
-  },
   methods: {
     async updateGroup() {
       try {
@@ -300,7 +79,9 @@ export default {
           mutation: updateGroupMutation,
           variables: {
             id: this.group.id,
-            name: this.name
+            name: this.group.name,
+            permissions: this.group.permissions,
+            pageRules: this.group.pageRules
           },
           watchLoading (isLoading) {
             this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-groups-update')
@@ -345,57 +126,8 @@ export default {
         })
       }
     },
-    async assignUser(id) {
-      try {
-        await this.$apollo.mutate({
-          mutation: assignUserMutation,
-          variables: {
-            groupId: this.group.id,
-            userId: id
-          },
-          watchLoading (isLoading) {
-            this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-groups-assign')
-          }
-        })
-        this.$store.commit('showNotification', {
-          style: 'success',
-          message: `User has been assigned to ${this.group.name}.`,
-          icon: 'assignment_ind'
-        })
-        this.$apollo.queries.group.refetch()
-      } catch (err) {
-        this.$store.commit('showNotification', {
-          style: 'red',
-          message: err.message,
-          icon: 'warning'
-        })
-      }
-    },
-    async unassignUser(id) {
-      try {
-        await this.$apollo.mutate({
-          mutation: unassignUserMutation,
-          variables: {
-            groupId: this.group.id,
-            userId: id
-          },
-          watchLoading (isLoading) {
-            this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-groups-unassign')
-          }
-        })
-        this.$store.commit('showNotification', {
-          style: 'success',
-          message: `User has been unassigned from ${this.group.name}.`,
-          icon: 'assignment_ind'
-        })
-        this.$apollo.queries.group.refetch()
-      } catch (err) {
-        this.$store.commit('showNotification', {
-          style: 'red',
-          message: err.message,
-          icon: 'warning'
-        })
-      }
+    async refresh() {
+      return this.$apollo.queries.group.refetch()
     }
   },
   apollo: {
@@ -407,7 +139,7 @@ export default {
         }
       },
       fetchPolicy: 'network-only',
-      update: (data) => data.groups.single,
+      update: (data) => _.cloneDeep(data.groups.single),
       watchLoading (isLoading) {
         this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-groups-refresh')
       }

+ 22 - 36
client/components/admin/admin-groups.vue

@@ -14,19 +14,18 @@
             v-btn(color='primary', depressed, slot='activator', large)
               v-icon(left) add
               span New Group
-            v-card
+            v-card.wiki-form
               .dialog-header.is-short New Group
               v-card-text
                 v-text-field.md2(
-                  solo,
-                  flat,
-                  background-color='grey lighten-4'
+                  outline
+                  background-color='grey lighten-3'
                   prepend-icon='people'
                   v-model='newGroupName'
                   label='Group Name'
                   counter='255'
                   @keyup.enter='createGroup'
-                  ref='groupNameInput'
+                  ref='groupNameIpt'
                   )
               v-card-chin
                 v-spacer
@@ -44,7 +43,7 @@
             template(slot='items', slot-scope='props')
               tr.is-clickable(:active='props.selected', @click='$router.push("/groups/" + props.item.id)')
                 td.text-xs-right {{ props.item.id }}
-                td {{ props.item.name }}
+                td: strong {{ props.item.name }}
                 td {{ props.item.userCount }}
                 td {{ props.item.createdAt | moment('calendar') }}
                 td {{ props.item.updatedAt | moment('calendar') }}
@@ -93,6 +92,15 @@ export default {
       return Math.ceil(this.pagination.totalItems / this.pagination.rowsPerPage)
     }
   },
+  watch: {
+    newGroupDialog(newValue, oldValue) {
+      if (newValue) {
+        this.$nextTick(() => {
+          this.$refs.groupNameIpt.focus()
+        })
+      }
+    }
+  },
   methods: {
     async refresh() {
       await this.$apollo.queries.groups.refetch()
@@ -103,6 +111,14 @@ export default {
       })
     },
     async createGroup() {
+      if (_.trim(this.newGroupName).length < 1) {
+        this.$store.commit('showNotification', {
+          style: 'red',
+          message: 'Enter a group name.',
+          icon: 'warning'
+        })
+        return
+      }
       this.newGroupDialog = false
       try {
         await this.$apollo.mutate({
@@ -138,36 +154,6 @@ export default {
           icon: 'warning'
         })
       }
-    },
-    async deleteGroupConfirm(group) {
-      this.deleteGroupDialog = true
-      this.selectedGroup = group
-    },
-    async deleteGroup() {
-      this.deleteGroupDialog = false
-      try {
-        await this.$apollo.mutate({
-          mutation: deleteGroupMutation,
-          variables: {
-            id: this.selectedGroup.id
-          },
-          watchLoading (isLoading) {
-            this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-groups-delete')
-          }
-        })
-        await this.$apollo.queries.groups.refetch()
-        this.$store.commit('showNotification', {
-          style: 'success',
-          message: `Group ${this.selectedGroup.name} has been deleted.`,
-          icon: 'delete'
-        })
-      } catch (err) {
-        this.$store.commit('showNotification', {
-          style: 'red',
-          message: err.message,
-          icon: 'warning'
-        })
-      }
     }
   },
   apollo: {

+ 17 - 2
client/components/admin/admin-system.vue

@@ -33,10 +33,11 @@
                 v-subheader {{ $t('admin:system.hostInfo') }}
                 v-list-tile(avatar)
                   v-list-tile-avatar
-                    v-icon.blue-grey.white--text bubble_chart
+                    v-avatar.blue-grey(size='40')
+                      img(:src='`/svg/icon-` + platformLogo + `-logo.svg`', alt='Platform', style='width: 24px;')
                   v-list-tile-content
                     v-list-tile-title {{ $t('admin:system.os') }}
-                    v-list-tile-sub-title {{ info.operatingSystem }}
+                    v-list-tile-sub-title {{ (info.platform === 'docker') ? 'Docker Container (Linux)' : info.operatingSystem }}
                 v-list-tile(avatar)
                   v-list-tile-avatar
                     v-icon.blue-grey.white--text computer
@@ -127,6 +128,20 @@ export default {
   computed: {
     dbVersion() {
       return _.get(this.info, 'dbVersion', '').replace(/(?:\r\n|\r|\n)/g, '<br />')
+    },
+    platformLogo() {
+      switch (this.info.platform) {
+        case 'docker':
+          return 'docker'
+        case 'darwin':
+          return 'apple'
+        case 'linux':
+          return 'linux'
+        case 'win32':
+          return 'windows'
+        default:
+          return ''
+      }
     }
   },
   methods: {

+ 5 - 33
client/components/admin/admin-users-create.vue

@@ -1,29 +1,23 @@
 <template lang="pug">
   v-dialog(v-model='isShown', max-width='550')
-    v-card
+    v-card.wiki-form
       .dialog-header.is-short New Local User
       v-card-text
         v-text-field.md2(
-          solo
-          flat
-          background-color='grey lighten-4'
+          outline
           prepend-icon='email'
           v-model='email'
           label='Email Address'
           ref='emailInput'
           )
         v-text-field.md2(
-          solo
-          flat
-          background-color='grey lighten-4'
+          outline
           prepend-icon='person'
           v-model='name'
           label='Name'
           )
         v-text-field.md2(
-          solo
-          flat
-          background-color='grey lighten-4'
+          outline
           prepend-icon='lock'
           append-icon='casino'
           v-model='password'
@@ -31,32 +25,10 @@
           counter='255'
           @click:append='generatePwd'
           )
-        v-text-field.md2(
-          solo
-          flat
-          background-color='grey lighten-4'
-          prepend-icon='title'
-          v-model='jobTitle'
-          label='Job Title'
-          counter='255'
-          hint='Optional'
-          persistent-hint
-          )
-        v-text-field.md2(
-          solo
-          flat
-          background-color='grey lighten-4'
-          prepend-icon='public'
-          v-model='location'
-          label='Location'
-          counter='255'
-          hint='Optional'
-          persistent-hint
-          )
       v-card-chin
         v-spacer
         v-btn(flat, @click='isShown = false') Cancel
-        v-btn(color='primary', @click='createUser') Create
+        v-btn(color='primary', @click='createUser') Create User
 </template>
 
 <script>

+ 0 - 211
client/components/common/criterias-item.vue

@@ -1,211 +0,0 @@
-<template lang="pug">
-  .criterias-item
-    //- Type
-    v-select(
-      solo
-      :items='filteredCriteriaTypes'
-      v-model='item.type'
-      placeholder='Rule Type'
-      ref='typeSelect'
-      hide-details
-      )
-      template(slot='item', slot-scope='data')
-        v-list-tile-avatar
-          v-avatar(:color='data.item.color', size='40', tile): v-icon(color='white') {{ data.item.icon }}
-        v-list-tile-content
-          v-list-tile-title(v-html='data.item.text')
-          v-list-tile-sub-title.caption(v-html='data.item.description')
-
-    //- Operator
-    v-select(
-      solo
-      :items='filteredCriteriaOperators'
-      v-model='item.operator'
-      placeholder='Operator'
-      :disabled='!item.type'
-      :class='!item.type ? "blue-grey lighten-4" : ""'
-      hide-details
-      )
-      template(slot='item', slot-scope='data')
-        v-list-tile-avatar
-          v-avatar.white--text(color='blue', size='30', tile) {{ data.item.icon }}
-        v-list-tile-content
-          v-list-tile-title(v-html='data.item.text')
-
-    //- Value
-    v-select(
-      v-if='item.type === "country"'
-      solo
-      :items='countries'
-      v-model='item.value'
-      placeholder='Countries...'
-      multiple
-      item-text='name'
-      item-value='code'
-      hide-details
-      )
-    v-text-field(
-      v-else-if='item.type === "path"'
-      solo
-      v-model='item.value'
-      label='Path (e.g. /section)'
-      hide-details
-      )
-    v-text-field(
-      v-else-if='item.type === "date"'
-      solo
-      @click.native.stop='dateActivator = true'
-      v-model='item.value'
-      label='YYYY-MM-DD'
-      readonly
-      hide-details
-      )
-    v-text-field(
-      v-else-if='item.type === "time"'
-      solo
-      @click.native.stop='timeActivator = true'
-      v-model='item.value'
-      label='HH:MM'
-      readonly
-      hide-details
-      )
-    v-select(
-      v-else-if='item.type === "group"'
-      solo
-      :items='groups'
-      v-model='item.value'
-      placeholder='Group...'
-      item-text='name'
-      item-value='id'
-      hide-details
-      )
-    v-text-field.blue-grey.lighten-4(
-      v-else
-      solo
-      disabled
-      hide-details
-      )
-
-    v-dialog(lazy, v-model='dateActivator', width='290px', ref='dateDialog')
-      v-date-picker(v-model='item.value', scrollable, color='primary')
-        v-btn(flat, color='primary' @click='$refs.dateDialog.save(date)', block) ok
-
-    v-dialog(lazy, v-model='timeActivator', width='300px', ref='timeDialog')
-      v-time-picker(v-model='item.value', scrollable, color='primary')
-        v-btn(flat, color='primary' @click='$refs.timeDialog.save(time)', block) ok
-
-    v-btn(icon, @click='remove'): v-icon(color='blue-grey') clear
-</template>
-
-<script>
-import _ from 'lodash'
-
-// import countriesQuery from 'gql/upsells-query-countries.gql'
-
-export default {
-  inject: ['allowedCriteriaTypes'],
-  props: {
-    value: {
-      type: Object,
-      default() { return {} }
-    },
-    groupIndex: {
-      type: Number,
-      default() { return 0 }
-    },
-    itemIndex: {
-      type: Number,
-      default() { return 0 }
-    }
-  },
-  data() {
-    return {
-      item: {
-        operator: '',
-        type: '',
-        value: ''
-      },
-      dateActivator: false,
-      dateDialog: false,
-      timeActivator: false,
-      timeDialog: false,
-      countries: [],
-      groups: [],
-      criteriaTypes: [
-        { text: 'Path', value: 'path', icon: 'space_bar', color: 'blue', description: 'Match the path of the document being viewed.' },
-        { text: 'Date', value: 'date', icon: 'date_range', color: 'blue', description: 'Match the current calendar day.' },
-        { text: 'Time', value: 'time', icon: 'access_time', color: 'blue', description: 'Match the current time of day.' },
-        { text: 'User Country', value: 'country', icon: 'public', color: 'red', description: `Match the user's country.` },
-        { text: 'User Group', value: 'group', icon: 'group', color: 'orange', description: 'Match the user group assignments.' }
-      ],
-      criteriaOperators: {
-        country: [
-          { text: 'In', value: 'in', icon: '[...]' },
-          { text: 'Not In', value: 'notIn', icon: '[ x ]' }
-        ],
-        path: [
-          { text: 'Matches Exactly', value: 'eq', icon: '=' },
-          { text: 'NOT Matches Exactly', value: 'ne', icon: '!=' },
-          { text: 'Starts With', value: 'sw', icon: 'x...' },
-          { text: 'NOT Starts With', value: 'nsw', icon: '!x...' },
-          { text: 'Ends With', value: 'ew', icon: '...x' },
-          { text: 'NOT Ends With', value: 'new', icon: '!...x' },
-          { text: 'Matches Regex', value: 'regexp', icon: '^x$' }
-        ],
-        date: [
-          { text: 'On or After', value: 'gte', icon: '>=' },
-          { text: 'On or Before', value: 'lte', icon: '<=' }
-        ],
-        time: [
-          { text: 'At or Later Than', value: 'gte', icon: '>=' },
-          { text: 'At or Before', value: 'lte', icon: '<=' }
-        ],
-        group: [
-          { text: 'Is Part Of', value: 'in', icon: '[...]' },
-          { text: 'Is Not Part Of', value: 'notIn', icon: '[ x ]' }
-        ]
-      }
-    }
-  },
-  computed: {
-    filteredCriteriaOperators() {
-      return _.get(this.criteriaOperators, this.item.type, [])
-    },
-    filteredCriteriaTypes() {
-      console.info(this.allowedCriteriaTypes)
-      return _.filter(this.criteriaTypes, c => _.includes(this.allowedCriteriaTypes, c.value))
-    },
-    itemType() {
-      return this.item.type
-    }
-  },
-  watch: {
-    itemType(newValue, oldValue) {
-      this.item.operator = _.head(this.criteriaOperators[newValue]).value
-      this.item.value = ''
-    },
-    item: {
-      handler(newValue, oldValue) {
-        this.$emit('update', this.groupIndex, this.itemIndex, this.item)
-      },
-      deep: true
-    }
-  },
-  mounted() {
-    if (!this.item.type) {
-      this.$refs.typeSelect.showMenu()
-    }
-  },
-  methods: {
-    remove() {
-      this.$emit('remove', this.groupIndex, this.itemIndex)
-    }
-  }
-  // apollo: {
-  //   countries: {
-  //     query: countriesQuery,
-  //     update: (data) => data.location.countries
-  //   }
-  // }
-}
-</script>

+ 0 - 173
client/components/common/criterias.vue

@@ -1,173 +0,0 @@
-<template lang="pug">
-  .criterias
-    transition-group(name='criterias-group', tag='div')
-      .criterias-group(v-for='(group, g) in groups', :key='g')
-        transition-group(name='criterias-item', tag='div')
-          criterias-item(v-for='(item, i) in group', :key='i', :item='item', :group-index='g', :item-index='i', @update='updateItem', @remove='removeItem')
-        .criterias-item-more
-          v-btn.ml-0(@click='addItem(group)', small, color='blue-grey lighten-2', dark, depressed)
-            v-icon(color='white', left) add
-            | Add condition
-    .criterias-group-more
-      v-btn(@click='addGroup', small, color='blue-grey lighten-1', dark, depressed)
-        v-icon(color='white', left) add
-        | Add condition group
-</template>
-
-<script>
-import CriteriasItem from './criterias-item.vue'
-
-export default {
-  components: {
-    CriteriasItem
-  },
-  props: {
-    value: {
-      type: Array,
-      default() { return [] }
-    },
-    types: {
-      type: Array,
-      default() {
-        return ['country', 'path', 'date', 'time', 'group']
-      }
-    }
-  },
-  provide () {
-    return {
-      allowedCriteriaTypes: this.types
-    }
-  },
-  data() {
-    return {
-      dataGroups: this.value || []
-    }
-  },
-  computed: {
-    groups: {
-      get() { return this.dataGroups },
-      set(grp) {
-        this.dataGroups = grp
-      }
-    }
-  },
-  watch: {
-    dataGroups(newValue, oldValue) {
-      if (newValue !== oldValue) {
-        this.$emit('input', newValue)
-      }
-    }
-  },
-  methods: {
-    addGroup() {
-      this.dataGroups.push([{}])
-    },
-    addItem(group) {
-      group.push({})
-    },
-    updateItem(groupIndex, itemIndex, item) {
-      console.info(item)
-      this.$set(this.dataGroups[groupIndex], itemIndex, item)
-    },
-    removeItem(groupIndex, itemIndex) {
-      this.dataGroups[groupIndex].splice(itemIndex, 1)
-      if (this.dataGroups[groupIndex].length < 1) {
-        this.dataGroups.splice(groupIndex, 1)
-      }
-    }
-  }
-}
-</script>
-
-<style lang="scss">
-.criterias {
-  &-group {
-    background-color: mc('blue-grey', '100');
-    border-radius: 4px;
-    padding: 1rem;
-    position: relative;
-
-    &-enter-active, &-leave-active {
-      transition: all .5s ease;
-    }
-    &-enter, &-leave-to {
-      opacity: 0;
-    }
-
-    & + .criterias-group {
-      margin-top: 1rem;
-
-      &::before {
-        content: 'OR';
-        position: absolute;
-        display: inline-flex;
-        padding: 0 2rem;
-        top: -1.25rem;
-        left: 2rem;
-        background-color: mc('blue-grey', '100');
-        color: mc('blue-grey', '700');
-        font-weight: 600;
-        font-size: .9rem;
-      }
-    }
-
-    &-more {
-      margin: .5rem 0 0 .4rem;
-    }
-  }
-
-  &-item {
-    display: flex;
-    background-color: mc('blue-grey', '200');
-    border-radius: 4px;
-    padding: .5rem;
-
-    &-enter-active, &-leave-active {
-      transition: all .5s ease;
-    }
-    &-enter, &-leave-to {
-      opacity: 0;
-    }
-
-    & + .criterias-item {
-      margin-top: .5rem;
-      position: relative;
-
-      &::before {
-        content: 'AND';
-        position: absolute;
-        width: 2rem;
-        height: 2rem;
-        border-radius: 50%;
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        font-weight: 600;
-        color: mc('blue-grey', '700');
-        font-size: .7rem;
-        background-color: mc('blue-grey', '100');
-        left: -2rem;
-        top: -1.3rem;
-      }
-    }
-
-    .input-group {
-      &:nth-child(1) {
-        flex: 0 1 350px;
-      }
-
-      &:nth-child(2) {
-        flex: 0 1 250px;
-      }
-
-      & + * {
-        margin-left: .5rem;
-      }
-    }
-
-    &-more {
-      margin-top: .15rem;
-    }
-  }
-}
-</style>

+ 123 - 111
client/components/common/nav-header.vue

@@ -16,117 +16,123 @@
         :loading='searchIsLoading',
         @keyup.enter='searchEnter'
       )
-    v-menu(open-on-hover, offset-y, bottom, left, min-width='250')
-      v-toolbar-side-icon.btn-animate-app(slot='activator')
-        v-icon view_module
-      v-list(dense, :light='!$vuetify.dark', :dark='$vuetify.dark', :class='$vuetify.dark ? `grey darken-4` : ``').py-0
-        v-list-tile(avatar, href='/')
-          v-list-tile-avatar: v-icon(color='blue') home
-          v-list-tile-content Home
-        v-list-tile(avatar, @click='pageNew')
-          v-list-tile-avatar: v-icon(color='green') add_box
-          v-list-tile-content New Page
-        template(v-if='path && path.length')
-          v-divider.my-0
-          v-subheader Current Page
-          v-list-tile(avatar, @click='pageView', v-if='mode !== `view`')
-            v-list-tile-avatar: v-icon(color='indigo') subject
-            v-list-tile-content View
-          v-list-tile(avatar, @click='pageEdit', v-if='mode !== `edit`')
-            v-list-tile-avatar: v-icon(color='indigo') edit
-            v-list-tile-content Edit
-          v-list-tile(avatar, @click='pageHistory', v-if='mode !== `history`')
-            v-list-tile-avatar: v-icon(color='indigo') history
-            v-list-tile-content History
-          v-list-tile(avatar, @click='pageSource', v-if='mode !== `source`')
-            v-list-tile-avatar: v-icon(color='indigo') code
-            v-list-tile-content View Source
-          v-list-tile(avatar, @click='pageMove')
-            v-list-tile-avatar: v-icon(color='indigo') forward
-            v-list-tile-content Move / Rename
-          v-list-tile(avatar, @click='pageDelete')
-            v-list-tile-avatar: v-icon(color='red darken-2') delete
-            v-list-tile-content Delete
-        v-divider.my-0
-        v-subheader Assets
-        v-list-tile(avatar, @click='')
-          v-list-tile-avatar: v-icon(color='blue-grey') burst_mode
-          v-list-tile-content Images &amp; Files
-    v-toolbar-title(:class='{ "ml-2": $vuetify.breakpoint.mdAndUp, "ml-0": $vuetify.breakpoint.smAndDown }')
-      span.subheading {{title}}
-    v-spacer(v-if='searchIsShown && $vuetify.breakpoint.mdAndUp')
-    transition(name='navHeaderSearch')
-      v-text-field(
-        ref='searchField',
-        v-if='searchIsShown && $vuetify.breakpoint.mdAndUp',
-        v-model='search',
-        clearable,
-        color='white',
-        label='Search...',
-        single-line,
-        solo
-        flat
-        hide-details,
-        prepend-inner-icon='search',
-        :loading='searchIsLoading',
-        @keyup.enter='searchEnter'
-      )
-        v-progress-linear(
-          indeterminate,
-          slot='progress',
-          height='2',
-          color='blue'
-        )
-    v-spacer
-    .navHeaderLoading.mr-3
-      v-progress-circular(indeterminate, color='blue', :size='22', :width='2' v-show='isLoading')
-    slot(name='actions')
-    v-btn(
-      v-if='!hideSearch && $vuetify.breakpoint.smAndDown'
-      @click='searchToggle'
-      icon
-      )
-      v-icon(color='grey') search
-    v-tooltip(bottom, v-if='isAuthenticated && isAdmin')
-      v-btn.btn-animate-rotate(icon, href='/a', slot='activator')
-        v-icon(color='grey') settings
-      span Admin
-    v-menu(offset-y, min-width='300')
-      v-tooltip(bottom, slot='activator')
-        v-btn.btn-animate-grow(icon, slot='activator', outline, :color='isAuthenticated ? `blue` : `grey darken-3`')
-          v-icon(color='grey') account_circle
-        span Account
-      v-list.py-0
-        template(v-if='isAuthenticated')
-          v-list-tile.py-3.grey(avatar, :class='$vuetify.dark ? `darken-4-l5` : `lighten-5`')
-            v-list-tile-avatar
-              v-avatar.blue(v-if='picture.kind === `initials`', :size='40')
-                span.white--text.subheading {{picture.initials}}
-              v-avatar(v-else-if='picture.kind === `image`', :size='40')
-                v-img(:src='picture.url')
-            v-list-tile-content
-              v-list-tile-title {{name}}
-              v-list-tile-sub-title {{email}}
-          v-divider.my-0
-          v-list-tile(href='/w')
-            v-list-tile-action: v-icon(color='blue') web
-            v-list-tile-title My Wiki
-          v-divider.my-0
-          v-list-tile(href='/p')
-            v-list-tile-action: v-icon(color='blue') person
-            v-list-tile-title Profile
-          v-divider.my-0
-          v-list-tile(@click='logout')
-            v-list-tile-action: v-icon(color='red') exit_to_app
-            v-list-tile-title Logout
-        template(v-else)
-          v-list-tile(href='/login')
-            v-list-tile-action: v-icon(color='grey') person
-            v-list-tile-title Login
-          v-divider.my-0
-          v-list-tile(href='/register')
-            v-list-tile-action: v-icon(color='grey') person_add
-            v-list-tile-title Register
+    v-layout(row)
+      v-flex(xs6, :md4='searchIsShown', :md6='!searchIsShown')
+        v-toolbar.nav-header-inner(color='black', dark, flat)
+          v-menu(open-on-hover, offset-y, bottom, left, min-width='250')
+            v-toolbar-side-icon.btn-animate-app(slot='activator')
+              v-icon view_module
+            v-list(dense, :light='!$vuetify.dark', :dark='$vuetify.dark', :class='$vuetify.dark ? `grey darken-4` : ``').py-0
+              v-list-tile(avatar, href='/')
+                v-list-tile-avatar: v-icon(color='blue') home
+                v-list-tile-content Home
+              v-list-tile(avatar, @click='pageNew')
+                v-list-tile-avatar: v-icon(color='green') add_box
+                v-list-tile-content New Page
+              template(v-if='path && path.length')
+                v-divider.my-0
+                v-subheader Current Page
+                v-list-tile(avatar, @click='pageView', v-if='mode !== `view`')
+                  v-list-tile-avatar: v-icon(color='indigo') subject
+                  v-list-tile-content View
+                v-list-tile(avatar, @click='pageEdit', v-if='mode !== `edit`')
+                  v-list-tile-avatar: v-icon(color='indigo') edit
+                  v-list-tile-content Edit
+                v-list-tile(avatar, @click='pageHistory', v-if='mode !== `history`')
+                  v-list-tile-avatar: v-icon(color='indigo') history
+                  v-list-tile-content History
+                v-list-tile(avatar, @click='pageSource', v-if='mode !== `source`')
+                  v-list-tile-avatar: v-icon(color='indigo') code
+                  v-list-tile-content View Source
+                v-list-tile(avatar, @click='pageMove')
+                  v-list-tile-avatar: v-icon(color='indigo') forward
+                  v-list-tile-content Move / Rename
+                v-list-tile(avatar, @click='pageDelete')
+                  v-list-tile-avatar: v-icon(color='red darken-2') delete
+                  v-list-tile-content Delete
+              v-divider.my-0
+              v-subheader Assets
+              v-list-tile(avatar, @click='')
+                v-list-tile-avatar: v-icon(color='blue-grey') burst_mode
+                v-list-tile-content Images &amp; Files
+          v-toolbar-title(:class='{ "ml-2": $vuetify.breakpoint.mdAndUp, "ml-0": $vuetify.breakpoint.smAndDown }')
+            span.subheading {{title}}
+      v-flex(md4, v-if='searchIsShown && $vuetify.breakpoint.mdAndUp')
+        v-toolbar.nav-header-inner(color='black', dark, flat)
+          transition(name='navHeaderSearch')
+            v-text-field(
+              ref='searchField',
+              v-if='searchIsShown && $vuetify.breakpoint.mdAndUp',
+              v-model='search',
+              clearable,
+              color='white',
+              label='Search...',
+              single-line,
+              solo
+              flat
+              hide-details,
+              prepend-inner-icon='search',
+              :loading='searchIsLoading',
+              @keyup.enter='searchEnter'
+            )
+              v-progress-linear(
+                indeterminate,
+                slot='progress',
+                height='2',
+                color='blue'
+              )
+      v-flex(xs6, :md4='searchIsShown', :md6='!searchIsShown')
+        v-toolbar.nav-header-inner(color='black', dark, flat)
+          v-spacer
+          .navHeaderLoading.mr-3
+            v-progress-circular(indeterminate, color='blue', :size='22', :width='2' v-show='isLoading')
+          slot(name='actions')
+          v-btn(
+            v-if='!hideSearch && $vuetify.breakpoint.smAndDown'
+            @click='searchToggle'
+            icon
+            )
+            v-icon(color='grey') search
+          v-tooltip(bottom, v-if='isAuthenticated && isAdmin')
+            v-btn.btn-animate-rotate(icon, href='/a', slot='activator')
+              v-icon(color='grey') settings
+            span Admin
+          v-menu(offset-y, min-width='300')
+            v-tooltip(bottom, slot='activator')
+              v-btn.btn-animate-grow(icon, slot='activator', outline, :color='isAuthenticated ? `blue` : `grey darken-3`')
+                v-icon(color='grey') account_circle
+              span Account
+            v-list.py-0
+              template(v-if='isAuthenticated')
+                v-list-tile.py-3.grey(avatar, :class='$vuetify.dark ? `darken-4-l5` : `lighten-5`')
+                  v-list-tile-avatar
+                    v-avatar.blue(v-if='picture.kind === `initials`', :size='40')
+                      span.white--text.subheading {{picture.initials}}
+                    v-avatar(v-else-if='picture.kind === `image`', :size='40')
+                      v-img(:src='picture.url')
+                  v-list-tile-content
+                    v-list-tile-title {{name}}
+                    v-list-tile-sub-title {{email}}
+                v-divider.my-0
+                v-list-tile(href='/w')
+                  v-list-tile-action: v-icon(color='blue') web
+                  v-list-tile-title My Wiki
+                v-divider.my-0
+                v-list-tile(href='/p')
+                  v-list-tile-action: v-icon(color='blue') person
+                  v-list-tile-title Profile
+                v-divider.my-0
+                v-list-tile(@click='logout')
+                  v-list-tile-action: v-icon(color='red') exit_to_app
+                  v-list-tile-title Logout
+              template(v-else)
+                v-list-tile(href='/login')
+                  v-list-tile-action: v-icon(color='grey') person
+                  v-list-tile-title Login
+                v-divider.my-0
+                v-list-tile(href='/register')
+                  v-list-tile-action: v-icon(color='grey') person_add
+                  v-list-tile-title Register
 
     page-selector(mode='create', v-model='newPageModal', :open-handler='pageNewCreate')
 </template>
@@ -251,6 +257,12 @@ export default {
       padding-right: 14px;
     }
   }
+
+  &-inner {
+    .v-toolbar__content {
+      padding: 0;
+    }
+  }
 }
 
 .navHeaderSearch {

+ 9 - 7
client/components/common/user-search.vue

@@ -3,7 +3,7 @@
     v-model='dialogOpen'
     max-width='650'
     )
-    v-card
+    v-card.wiki-form
       .dialog-header
         span Search User
         v-spacer
@@ -16,23 +16,25 @@
           )
       v-card-text
         v-text-field(
-          solo
-          flat
+          outline
           label='Search Users...'
           v-model='search'
-          prepend-icon='search'
-          :background-color='$vuetify.dark ? "grey darken-4" : "blue lighten-5"'
+          prepend-inner-icon='search'
           color='primary'
           ref='searchIpt'
           hide-details
           )
-        v-list(two-line)
+        v-list.grey.mt-3.py-0.radius-7(
+          :class='$vuetify.dark ? `darken-3-d5` : `lighten-3`'
+          two-line
+          dense
+          )
           template(v-for='(usr, idx) in items')
             v-list-tile(:key='usr.id', @click='setUser(usr.id)')
               v-list-tile-avatar(size='40', color='primary')
                 span.body-1.white--text {{usr.name | initials}}
               v-list-tile-content
-                v-list-tile-title {{usr.name}}
+                v-list-tile-title.body-2 {{usr.name}}
                 v-list-tile-sub-title {{usr.email}}
               v-list-tile-action
                 v-icon(color='primary') arrow_forward

+ 1 - 0
client/components/editor.vue

@@ -243,6 +243,7 @@ export default {
             })
             this.$store.set('editor/id', _.get(resp, 'page.id'))
             this.$store.set('editor/mode', 'update')
+            window.location.assign(`/${this.$store.get('page/path')}`)
           } else {
             throw new Error(_.get(resp, 'responseResult.message'))
           }

+ 2 - 2
client/graph/admin/groups/groups-mutation-update.gql

@@ -1,6 +1,6 @@
-mutation ($id: Int!, $name: String!) {
+mutation ($id: Int!, $name: String!, $permissions: [String]!, $pageRules: [PageRuleInput]!) {
   groups {
-    update(id: $id, name: $name) {
+    update(id: $id, name: $name, permissions: $permissions, pageRules: $pageRules) {
       responseResult {
         succeeded
         errorCode

+ 4 - 3
client/graph/admin/groups/groups-query-single.gql

@@ -8,9 +8,10 @@ query ($id: Int!) {
       pageRules {
         id
         path
-        role
-        exact
-        allow
+        roles
+        match
+        deny
+        locales
       }
       users {
         id

+ 1 - 0
client/graph/admin/system/system-query-info.gql

@@ -9,6 +9,7 @@ query {
       latestVersion
       latestVersionReleaseDate
       operatingSystem
+      platform
       hostname
       cpuCores
       ramTotal

+ 4 - 0
client/static/svg/icon-apple-logo.svg

@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px">
+    <path style="fill:#ffffff;" d="M 16.125 1 C 14.972 1.067 13.648328 1.7093438 12.861328 2.5273438 C 12.150328 3.2713438 11.589359 4.3763125 11.818359 5.4453125 C 13.071359 5.4783125 14.329031 4.8193281 15.082031 3.9863281 C 15.785031 3.2073281 16.318 2.12 16.125 1 z M 16.193359 5.4433594 C 14.384359 5.4433594 13.628 6.5546875 12.375 6.5546875 C 11.086 6.5546875 9.9076562 5.5136719 8.3476562 5.5136719 C 6.2256562 5.5146719 3 7.4803281 3 12.111328 C 3 16.324328 6.8176563 21 8.9726562 21 C 10.281656 21.013 10.599 20.176969 12.375 20.167969 C 14.153 20.154969 14.536656 21.011 15.847656 21 C 17.323656 20.989 18.476359 19.367031 19.318359 18.082031 C 19.922359 17.162031 20.170672 16.692344 20.638672 15.652344 C 17.165672 14.772344 16.474672 9.1716719 20.638672 8.0136719 C 19.852672 6.6726719 17.558359 5.4433594 16.193359 5.4433594 z"/>
+</svg>

+ 13 - 0
client/static/svg/icon-delete-file.svg

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
+    <g>
+        <path d="M3.929,43.929c3.902,-3.903 10.239,-3.903 14.142,0c3.902,3.902 3.902,10.239 0,14.142c-3.903,3.902 -10.24,3.902 -14.142,0c-3.903,-3.903 -3.903,-10.24 0,-14.142Z" style="fill:#ff5576;"/>
+        <path d="M7.5,56c-0.4,0 -0.75,-0.15 -1.05,-0.45c-0.6,-0.6 -0.6,-1.55 0,-2.1l7,-7c0.6,-0.6 1.55,-0.6 2.1,0c0.6,0.6 0.6,1.55 0,2.1l-7,7c-0.3,0.3 -0.65,0.45 -1.05,0.45Z" style="fill:#fff;fill-rule:nonzero;"/>
+        <path d="M14.5,56c-0.4,0 -0.75,-0.15 -1.05,-0.45l-7,-7c-0.6,-0.6 -0.6,-1.55 0,-2.1c0.6,-0.6 1.55,-0.6 2.1,0l7,7c0.6,0.6 0.6,1.55 0,2.1c-0.3,0.3 -0.65,0.45 -1.05,0.45Z" style="fill:#fff;fill-rule:nonzero;"/>
+    </g>
+    <g>
+        <path d="M53,19.5c-0.85,0 -1.5,0.65 -1.5,1.5l-0.05,33.5c0,1.95 -1.55,3.5 -3.5,3.5l-21.95,0c-0.85,0 -1.5,0.65 -1.5,1.5c0,0.85 0.65,1.5 1.5,1.5l21.95,0c3.6,0 6.5,-2.9 6.5,-6.5l0.05,-33.5c0,-0.85 -0.7,-1.5 -1.5,-1.5Z" style="fill:#fff;fill-rule:nonzero;"/>
+        <path d="M51.15,6.4c-2.2,-2.2 -5.05,-3.4 -8.15,-3.4l-27,0c-3.6,0 -6.5,2.9 -6.5,6.5l0,24c0,0.85 0.65,1.5 1.5,1.5c0.85,0 1.5,-0.65 1.5,-1.5l0,-24c0,-1.95 1.55,-3.5 3.5,-3.5l25.5,0l0,3.5c0,3.6 2.9,6.5 6.5,6.5l5,0c0.6,0 1.15,-0.4 1.4,-0.9c0.1,-0.2 0.15,-0.4 0.15,-0.6c0,-0.05 0,-0.1 0,-0.2c-0.1,-3 -1.3,-5.8 -3.4,-7.9Zm-6.65,3.1l0,-3.35c1.7,0.3 3.25,1.1 4.5,2.35c1.25,1.25 2.05,2.8 2.35,4.5l-3.35,0c-1.95,0 -3.5,-1.55 -3.5,-3.5Z" style="fill:#fff;fill-rule:nonzero;"/>
+    </g>
+</svg>

File diff suppressed because it is too large
+ 2 - 0
client/static/svg/icon-docker-logo.svg


File diff suppressed because it is too large
+ 2 - 0
client/static/svg/icon-linux-logo.svg


+ 4 - 0
client/static/svg/icon-windows-logo.svg

@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px">
+  <path style="fill:#ffffff;" d="M 21 2 L 12 3.5 L 12 11 L 21 11 L 21 2 z M 10 3.8320312 L 3 5 L 3 11 L 10 11 L 10 3.8320312 z M 3 13 L 3 19 L 10 20.167969 L 10 13 L 3 13 z M 12 13 L 12 20.5 L 21 22 L 21 13 L 12 13 z"/>
+</svg>

+ 18 - 8
client/themes/default/components/page.vue

@@ -6,16 +6,28 @@
       dark
       app
       clipped
-      :mini-variant='$vuetify.breakpoint.md || $vuetify.breakpoint.sm'
-      mini-variant-width='80'
       mobile-break-point='600'
-      :temporary='$vuetify.breakpoint.xs'
+      :temporary='$vuetify.breakpoint.mdAndDown'
       v-model='navShown'
       )
       vue-scroll(:ops='scrollStyle')
         nav-sidebar(:color='darkMode ? `grey darken-3` : `primary`')
           slot(name='sidebar')
 
+    v-fab-transition
+      v-btn(
+        fab
+        color='primary'
+        fixed
+        bottom
+        left
+        small
+        @click='navShown = !navShown'
+        v-if='$vuetify.breakpoint.mdAndDown'
+        v-show='!navShown'
+        )
+        v-icon menu
+
     v-content
       template(v-if='path !== `home`')
         v-toolbar(:color='darkMode ? `grey darken-4-d3` : `grey lighten-3`', flat, dense)
@@ -167,7 +179,8 @@ export default {
   },
   data() {
     return {
-      navOpen: false,
+      navShown: false,
+      navExpanded: false,
       upBtnShown: false,
       scrollOpts: {
         duration: 1500,
@@ -203,10 +216,6 @@ export default {
   },
   computed: {
     darkMode: get('site/dark'),
-    navShown: {
-      get() { return this.navOpen || this.$vuetify.breakpoint.smAndUp },
-      set(val) { this.navOpen = val }
-    },
     rating: {
       get () {
         return 3.5
@@ -232,6 +241,7 @@ export default {
   },
   mounted () {
     Prism.highlightAllUnder(this.$refs.container)
+    this.navShown = this.$vuetify.breakpoint.smAndUp
   },
   methods: {
     toggleNavigation () {

+ 35 - 0
dev/scripts/docker-clean-db.js

@@ -0,0 +1,35 @@
+const { Client } = require('pg')
+const fs = require('fs')
+const path = require('path')
+const yaml = require('js-yaml')
+
+let config = {}
+
+try {
+  conf = yaml.safeLoad(
+    cfgHelper.parseConfigValue(
+      fs.readFileSync(path.join(process.cwd(), 'dev/docker/config.yml'), 'utf8')
+    )
+  )
+} catch (err) {
+  console.error(err.message)
+  process.exit(1)
+}
+
+const client = new Client({
+  user: config.db.username,
+  host: config.db.host,
+  database: config.db.database,
+  password: config.db.password,
+  port: config.db.port,
+})
+
+async function main () {
+  await client.connect()
+  await client.query('DROP SCHEMA public CASCADE;')
+  await client.query('CREATE SCHEMA public;')
+  await client.end()
+  console.info('Success.')
+}
+
+main()

+ 2 - 6
package.json

@@ -10,11 +10,7 @@
     "dev": "node wiki dev",
     "build": "webpack --profile --config dev/webpack/webpack.prod.js",
     "watch": "webpack --config dev/webpack/webpack.dev.js",
-    "test": "eslint --format codeframe --ext .js,.vue . && pug-lint server/views && jest",
-    "docker:dev:up": "docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . up -d && docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . exec wiki yarn dev",
-    "docker:dev:down": "docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . down",
-    "docker:dev:rebuild": "rmdir node_modules /s /q && docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . build --no-cache --force-rm",
-    "docker:build": "docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . run wiki yarn build && docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . down"
+    "test": "eslint --format codeframe --ext .js,.vue . && pug-lint server/views && jest"
   },
   "bin": {
     "wiki": "wiki.js"
@@ -39,7 +35,7 @@
   },
   "homepage": "https://github.com/Requarks/wiki#readme",
   "engines": {
-    "node": ">=10.10"
+    "node": ">=10.12"
   },
   "dependencies": {
     "apollo-server": "2.2.2",

+ 1 - 0
server/db/migrations/2.0.0.js

@@ -56,6 +56,7 @@ exports.up = knex => {
       table.increments('id').primary()
       table.string('name').notNullable()
       table.json('permissions').notNullable()
+      table.json('pageRules').notNullable()
       table.boolean('isSystem').notNullable().defaultTo(false)
       table.string('createdAt').notNullable()
       table.string('updatedAt').notNullable()

+ 6 - 1
server/graph/resolvers/group.js

@@ -41,6 +41,7 @@ module.exports = {
       const group = await WIKI.models.groups.query().insertAndFetch({
         name: args.name,
         permissions: JSON.stringify(WIKI.data.groups.defaultPermissions),
+        pageRules: JSON.stringify([]),
         isSystem: false
       })
       return {
@@ -69,7 +70,11 @@ module.exports = {
       }
     },
     async update(obj, args) {
-      await WIKI.models.groups.query().patch({ name: args.name }).where('id', args.id)
+      await WIKI.models.groups.query().patch({
+        name: args.name,
+        permissions: JSON.stringify(args.permissions),
+        pageRules: JSON.stringify(args.pageRules)
+      }).where('id', args.id)
       return {
         responseResult: graphHelper.generateSuccess('Group has been updated.')
       }

+ 5 - 2
server/graph/resolvers/system.js

@@ -77,11 +77,14 @@ module.exports = {
         const osInfo = await getos()
         osLabel = `${os.type()} - ${osInfo.dist} (${osInfo.codename || os.platform()}) ${osInfo.release || os.release()} ${os.arch()}`
       }
+      return osLabel
+    },
+    async platform () {
       const isDockerized = await fs.pathExists('/.dockerenv')
       if (isDockerized) {
-        osLabel = `${osLabel} (Docker Container)`
+        return 'docker'
       }
-      return osLabel
+      return os.platform()
     },
     hostname() {
       return os.hostname()

+ 41 - 1
server/graph/schemas/group.graphql

@@ -37,6 +37,8 @@ type GroupMutation {
   update(
     id: Int!
     name: String!
+    permissions: [String]!
+    pageRules: [PageRuleInput]!
   ): DefaultResponse @auth(requires: ["write:groups", "manage:groups", "manage:system"])
 
   delete(
@@ -77,8 +79,46 @@ type Group {
   name: String!
   isSystem: Boolean!
   permissions: [String]!
-  pageRules: [Right]
+  pageRules: [PageRule]
   users: [UserMinimal]
   createdAt: Date!
   updatedAt: Date!
 }
+
+type PageRule {
+  id: String!
+  deny: Boolean!
+  match: PageRuleMatch!
+  roles: [PageRuleRole]!
+  path: String!
+  locales: [String]!
+}
+
+input PageRuleInput {
+  id: String!
+  deny: Boolean!
+  match: PageRuleMatch!
+  roles: [PageRuleRole]!
+  path: String!
+  locales: [String]!
+}
+
+enum PageRuleRole {
+  READ
+  WRITE
+  MANAGE
+  DELETE
+  AS_READ
+  AS_WRITE
+  AS_MANAGE
+  CM_READ
+  CM_WRITE
+  CM_MANAGE
+}
+
+enum PageRuleMatch {
+  START
+  EXACT
+  END
+  REGEX
+}

+ 1 - 0
server/graph/schemas/system.graphql

@@ -44,6 +44,7 @@ type SystemInfo {
   nodeVersion: String
   operatingSystem: String
   pagesTotal: Int
+  platform: String
   ramTotal: String
   redisHost: String
   redisTotalRAM: String

+ 1 - 1
server/jobs/purge-uploads.js

@@ -11,7 +11,7 @@ module.exports = async (job) => {
   WIKI.logger.info('Purging orphaned upload files...')
 
   try {
-    const uplTempPath = path.resolve(process.cwd(), WIKI.config.paths.data, 'temp-upload')
+    const uplTempPath = path.resolve(process.cwd(), WIKI.config.paths.data, 'uploads')
     const ls = await fs.readdirAsync(uplTempPath)
     const fifteenAgo = moment().subtract(15, 'minutes')
 

+ 1 - 0
server/master.js

@@ -153,6 +153,7 @@ module.exports = async () => {
 
   app.use((err, req, res, next) => {
     res.status(err.status || 500)
+    res.locals.pageMeta.title = 'Error'
     res.render('error', {
       message: err.message,
       error: WIKI.IS_DEBUG ? err : {}

+ 10 - 0
server/setup.js

@@ -26,6 +26,7 @@ module.exports = () => {
   const cfgHelper = require('./helpers/config')
   const crypto = Promise.promisifyAll(require('crypto'))
   const pem2jwk = require('pem-jwk').pem2jwk
+  const semver = require('semver')
 
   // ----------------------------------------
   // Define Express App
@@ -83,6 +84,11 @@ module.exports = () => {
     WIKI.telemetry.sendEvent('setup', 'finalize')
 
     try {
+      // Basic checks
+      if (!semver.satisfies(process.version, '>=10.14')) {
+        throw new Error('Node.js 10.14.x or later required!')
+      }
+
       // Upgrade from WIKI.js 1.x?
       if (req.body.upgrade) {
         await WIKI.system.upgradeFromMongo({
@@ -205,11 +211,15 @@ module.exports = () => {
       const adminGroup = await WIKI.models.groups.query().insert({
         name: 'Administrators',
         permissions: JSON.stringify(['manage:system']),
+        pageRules: [],
         isSystem: true
       })
       const guestGroup = await WIKI.models.groups.query().insert({
         name: 'Guests',
         permissions: JSON.stringify(['read:pages']),
+        pageRules: [
+          { id: 'guest', roles: ['READ', 'AS_READ', 'CM_READ'], match: 'START', deny: false, path: '', locales: [] }
+        ],
         isSystem: true
       })
 

+ 1 - 1
server/views/new.pug

@@ -5,7 +5,7 @@ block body
     v-app
       .newpage
         .newpage-content
-          img.animated.fadeIn(src='/svg/icon-close-window.svg', alt='Henry')
+          img.animated.fadeIn(src='/svg/icon-delete-file.svg', alt='Not Found')
           .headline= t('newpage.title')
           .subheading.mt-3= t('newpage.subtitle')
           v-btn.mt-5(href='/e' + pagePath, large)

Some files were not shown because too many files changed in this diff