diff --git a/client/components/admin/admin-groups-edit.vue b/client/components/admin/admin-groups-edit.vue index fe7cc156..939f3c63 100644 --- a/client/components/admin/admin-groups-edit.vue +++ b/client/components/admin/admin-groups-edit.vue @@ -14,7 +14,7 @@ v-icon mdi-arrow-left v-dialog(v-model='deleteGroupDialog', max-width='500', v-if='!group.isSystem') template(v-slot:activator='{ on }') - v-btn(color='red', large, outlined, v-on='{ on }') + v-btn.ml-2(color='red', large, outlined, v-on='{ on }') v-icon(color='red') mdi-trash-can-outline v-card .dialog-header.is-red Delete Group? diff --git a/client/components/admin/admin-locale.vue b/client/components/admin/admin-locale.vue index 987beab8..c5edd554 100644 --- a/client/components/admin/admin-locale.vue +++ b/client/components/admin/admin-locale.vue @@ -120,7 +120,7 @@ v-btn(v-else-if='item.isInstalled && item.installDate < item.updatedAt', icon, small, @click='download(item)') v-icon.blue--text mdi-cached v-btn(v-else-if='item.isInstalled', icon, small, @click='download(item)') - v-icon.green--text mdi-check + v-icon.green--text mdi-check-bold v-btn(v-else, icon, small, @click='download(item)') v-icon.grey--text mdi-cloud-download v-card.wiki-form.mt-3.animated.fadeInUp.wait-p5s diff --git a/client/components/admin/admin-theme.vue b/client/components/admin/admin-theme.vue index 39c3a26f..5bc8a742 100644 --- a/client/components/admin/admin-theme.vue +++ b/client/components/admin/admin-theme.vue @@ -114,7 +114,7 @@ item-key='value', :items-per-page='1000' ) - template(v-slot:items='thm') + template(v-slot:item='thm') td strong {{thm.item.text}} td @@ -124,7 +124,7 @@ v-btn(v-else-if='thm.item.isInstalled && thm.item.installDate < thm.item.updatedAt', icon) v-icon.blue--text mdi-cached v-btn(v-else-if='thm.item.isInstalled', icon) - v-icon.green--text mdi-check + v-icon.green--text mdi-check-bold v-btn(v-else, icon) v-icon.grey--text mdi-cloud-download diff --git a/client/components/admin/admin-users-create.vue b/client/components/admin/admin-users-create.vue index badcd38d..9060e335 100644 --- a/client/components/admin/admin-users-create.vue +++ b/client/components/admin/admin-users-create.vue @@ -67,10 +67,12 @@ :items='groups' item-text='name' item-value='id' + item-disabled='isSystem' outlined prepend-icon='mdi-account-group' v-model='group' label='Assign to Group(s)...' + dense clearable multiple ) @@ -104,7 +106,7 @@ import _ from 'lodash' import createUserMutation from 'gql/admin/users/users-mutation-create.gql' import providersQuery from 'gql/admin/users/users-query-strategies.gql' -import groupsQuery from 'gql/admin/auth/auth-query-groups.gql' +import groupsQuery from 'gql/admin/users/users-query-groups.gql' export default { props: { diff --git a/client/components/admin/admin-users-edit.vue b/client/components/admin/admin-users-edit.vue index cafd3e31..09fa0222 100644 --- a/client/components/admin/admin-users-edit.vue +++ b/client/components/admin/admin-users-edit.vue @@ -14,7 +14,7 @@ v-icon mdi-arrow-left v-dialog(v-model='deleteUserDialog', max-width='500', v-if='user.id !== currentUserId && !user.isSystem') template(v-slot:activator='{ on }') - v-btn.ml-3.animated.fadeInDown.wait-p1s(color='red', large, outlined, v-on='on') + v-btn.ml-3.animated.fadeInDown.wait-p1s(color='red', large, outlined, v-on='on', disabled) v-icon(color='red') mdi-trash-can-outline v-card .dialog-header.is-red Delete User? @@ -113,15 +113,35 @@ v-list-item-title Password v-list-item-subtitle •••••••• v-list-item-action - v-tooltip(top) - template(v-slot:activator='{ on }') - v-btn(icon, color='grey', x-small, v-on='on') - v-icon mdi-cached - span Change Password + v-menu( + v-model='editPop.newPassword' + :close-on-content-click='false' + min-width='350' + left + ) + template(v-slot:activator='{ on: menu }') + v-tooltip(top) + template(v-slot:activator='{ on: tooltip }') + v-btn(icon, color='grey', x-small, v-on='{ ...menu, ...tooltip }', @click='focusField(`iptNewPassword`)') + v-icon mdi-cached + span Change Password + v-card + v-text-field( + ref='iptNewPassword' + v-model='newPassword' + label='New Password' + solo + hide-details + append-icon='mdi-check' + type='password' + @click:append='editPop.newPassword = false' + @keydown.enter='editPop.newPassword = false' + @keydown.esc='editPop.newPassword = false' + ) v-list-item-action v-tooltip(top) template(v-slot:activator='{ on }') - v-btn(icon, color='grey', x-small, v-on='on') + v-btn(icon, color='grey', x-small, v-on='on', disabled) v-icon mdi-email span Send Password Reset Email v-divider @@ -151,22 +171,37 @@ span User Groups v-list(dense) template(v-for='(group, idx) in user.groups') - v-list-item + v-list-item(:key='`group-` + group.id') v-list-item-avatar(size='32') v-icon mdi-account-group-outline v-list-item-content v-list-item-title {{group.name}} v-list-item-action(v-if='!user.isSystem') - v-btn(icon, color='red', x-small) + v-btn(icon, color='red', x-small, @click='unassignGroup(group.id)') v-icon mdi-close v-divider(v-if='idx < user.groups.length - 1') v-alert.mx-3(v-if='user.groups.length < 1', outlined, color='grey darken-1', icon='mdi-alert') .caption This user is not assigned to any group yet. You must assign at least 1 group to a user. v-card-chin(v-if='!user.isSystem') v-spacer - v-btn(color='primary', text) - v-icon(left) mdi-clipboard-account - span Assign to group + v-select( + ref='iptAssignGroup' + :items='groups' + v-model='newGroup' + label='Select Group...' + item-value='id' + item-text='name' + item-disabled='isSystem' + solo + flat + dense + hide-details + @keydown.esc='editPop.assignGroup = false' + style='max-width: 300px;' + ) + v-btn.ml-2.px-4(depressed, color='primary', height='48', @click='assignGroup', :disabled='newGroup === 0') + v-icon(left) mdi-clipboard-account-outline + span Assign v-flex(xs6) v-card.animated.fadeInUp.wait-p2s v-toolbar(color='primary', dense, dark, flat) @@ -274,6 +309,8 @@ import _ from 'lodash' import { get } from 'vuex-pathify' import userQuery from 'gql/admin/users/users-query-single.gql' +import groupsQuery from 'gql/admin/users/users-query-groups.gql' +import updateUserMutation from 'gql/admin/users/users-mutation-update.gql' export default { data() { @@ -285,10 +322,18 @@ export default { pwd: false, location: false, jobTitle: false, - timezone: false + timezone: false, + newPassword: false, + assignGroup: false }, + newGroup: 0, + newPassword: '', user: { + email: '', name: '', + location: '', + jobTitle: '', + timezone: '', groups: [] }, timezones: [ @@ -550,13 +595,58 @@ export default { }, methods: { deleteUser() {}, - updateUser() {}, + async updateUser() { + this.$store.commit(`loadingStart`, 'admin-users-update') + const resp = await this.$apollo.mutate({ + mutation: updateUserMutation, + variables: { + id: this.user.id, + email: this.user.email, + name: this.user.name, + newPassword: this.newPassword, + groups: _.map(this.user.groups, 'id'), + location: this.user.location, + jobTitle: this.user.jobTitle, + timezone: this.user.timezone + } + }) + if (_.get(resp, 'data.users.update.responseResult.succeeded', false)) { + this.$store.commit('showNotification', { + style: 'success', + message: 'User updated successfully.', + icon: 'check' + }) + this.$router.push('/users') + } else { + this.$store.commit('showNotification', { + style: 'red', + message: _.get(resp, 'data.users.update.responseResult.message', 'An unexpected error occured.'), + icon: 'warning' + }) + } + this.$store.commit(`loadingStop`, 'admin-users-update') + }, focusField (ipt) { this.$nextTick(() => { _.delay(() => { this.$refs[ipt].focus() }, 200) }) + }, + assignGroup() { + if (_.some(this.user.groups, ['id', this.newGroup])) { + this.$store.commit('showNotification', { + message: 'User is already assigned to this group!', + style: 'error', + icon: 'alert' + }) + } else { + this.user.groups.push(_.find(this.groups, ['id', this.newGroup])) + this.newGroup = 0 + } + }, + unassignGroup(gid) { + this.user.groups = _.reject(this.user.groups, ['id', gid]) } }, apollo: { @@ -572,6 +662,14 @@ export default { watchLoading (isLoading) { this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-users-refresh') } + }, + groups: { + query: groupsQuery, + fetchPolicy: 'network-only', + update: (data) => data.groups.list, + watchLoading (isLoading) { + this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-groups-refresh') + } } } } diff --git a/client/components/common/nav-header.vue b/client/components/common/nav-header.vue index 9c98a53f..40a0f83f 100644 --- a/client/components/common/nav-header.vue +++ b/client/components/common/nav-header.vue @@ -40,8 +40,8 @@ v-list-item-avatar(size='24'): v-icon(color='indigo') mdi-file-document-edit-outline v-list-item-title.body-2 {{$t('common:header.edit')}} v-list-item.pl-4(@click='pageHistory', v-if='mode !== `history`') - v-list-item-avatar(size='24'): v-icon(color='indigo') mdi-history - v-list-item-title.body-2 {{$t('common:header.history')}} + v-list-item-avatar(size='24'): v-icon(color='grey lighten-2') mdi-history + v-list-item-title.body-2.grey--text.text--ligten-2 {{$t('common:header.history')}} v-list-item.pl-4(@click='pageSource', v-if='mode !== `source`') v-list-item-avatar(size='24'): v-icon(color='indigo') mdi-code-tags v-list-item-title.body-2 {{$t('common:header.viewSource')}} @@ -309,7 +309,12 @@ export default { window.location.assign(`/e/${this.locale}/${this.path}`) }, pageHistory () { - window.location.assign(`/h/${this.locale}/${this.path}`) + this.$store.commit('showNotification', { + style: 'indigo', + message: `Coming soon...`, + icon: 'ferry' + }) + // window.location.assign(`/h/${this.locale}/${this.path}`) }, pageSource () { window.location.assign(`/s/${this.locale}/${this.path}`) diff --git a/client/graph/admin/users/users-mutation-update.gql b/client/graph/admin/users/users-mutation-update.gql new file mode 100644 index 00000000..f7187c42 --- /dev/null +++ b/client/graph/admin/users/users-mutation-update.gql @@ -0,0 +1,12 @@ +mutation ($id: Int!, $email: String, $name: String, $newPassword: String, $groups: [Int], $location: String, $jobTitle: String, $timezone: String) { + users { + update(id: $id, email: $email, name: $name, newPassword: $newPassword, groups: $groups, location: $location, jobTitle: $jobTitle, timezone: $timezone) { + responseResult { + succeeded + errorCode + slug + message + } + } + } +} diff --git a/client/graph/admin/users/users-query-groups.gql b/client/graph/admin/users/users-query-groups.gql new file mode 100644 index 00000000..4bc1b91f --- /dev/null +++ b/client/graph/admin/users/users-query-groups.gql @@ -0,0 +1,9 @@ +query { + groups { + list { + id + name + isSystem + } + } +} diff --git a/client/themes/default/components/page.vue b/client/themes/default/components/page.vue index 1cb78fcc..ec1ad059 100644 --- a/client/themes/default/components/page.vue +++ b/client/themes/default/components/page.vue @@ -47,15 +47,15 @@ .caption.red--text {{$t('common:page.unpublished')}} status-indicator.ml-3(negative, pulse) v-divider - v-toolbar.px-2(:color='darkMode ? `grey darken-4-l3` : `grey lighten-4`', flat, :height='90') - div(style='padding-left: 376px;') - .headline.grey--text(:class='darkMode ? `text--lighten-2` : `text--darken-3`') {{title}} - .caption.grey--text.text--darken-1 {{description}} - v-spacer + v-container.grey.pa-0(fluid, :class='darkMode ? `darken-4-l3` : `lighten-4`') + v-row(no-gutters, align-content='center', style='height: 90px;') + v-col.pl-4(offset-xl='2', offset-lg='3') + .headline.grey--text(:class='darkMode ? `text--lighten-2` : `text--darken-3`') {{title}} + .caption.grey--text.text--darken-1 {{description}} v-divider - v-container.pl-5.pt-2(fill-height, fluid, grid-list-xl) + v-container.pl-5.pt-4(fluid, grid-list-xl) v-layout(row) - v-flex.page-col-sd(lg3, xl2, fill-height, v-if='$vuetify.breakpoint.lgAndUp', style='margin-top: -90px;') + v-flex.page-col-sd(lg3, xl2, v-if='$vuetify.breakpoint.lgAndUp', style='margin-top: -90px;') v-card(v-if='toc.length') .overline.pa-5.pb-0(:class='darkMode ? `blue--text text--lighten-2` : `primary--text`') {{$t('common:page.toc')}} v-list.pb-3(dense, nav, :class='darkMode ? `darken-3-d3` : ``') diff --git a/client/themes/default/scss/app.scss b/client/themes/default/scss/app.scss index ba3c5079..5df9fe7f 100644 --- a/client/themes/default/scss/app.scss +++ b/client/themes/default/scss/app.scss @@ -25,6 +25,16 @@ h1, h2, h3, h4, h5, h6 { position: relative; + &:before { + display: block; + content: " "; + width: 1px; + margin-top: -75px; + height: 75px; + visibility: hidden; + z-index: -1; + } + &:first-child { padding-top: 0; } @@ -84,7 +94,6 @@ } h2 { margin: 1rem 0 0 0; - padding: 8px 0 0 0; color: mc('grey', '800'); position: relative; @@ -114,8 +123,7 @@ } } h3 { - margin: 0; - padding: 8px 0 0 0; + margin: 8px 0 0 0; color: mc('grey', '700'); position: relative; @@ -135,8 +143,7 @@ } h4, h5, h6 { font-size: 1rem; - margin: 0; - padding: 8px 0 0 0; + margin: 8px 0 0 0; color: mc('grey', '700'); position: relative; @@ -165,19 +172,6 @@ } } - // scroll offset fix - - h1:before, h2:before, h3:before, h4:before, h5:before, h6:before { - display: block; - content: " "; - width: 1px; - height: 1px; - margin-top: -75px; - height: 75px; - visibility: hidden; - z-index: -1; - } - // --------------------------------- // PARAGRAPHS // --------------------------------- diff --git a/server/graph/resolvers/user.js b/server/graph/resolvers/user.js index 42a99ccb..b987b79a 100644 --- a/server/graph/resolvers/user.js +++ b/server/graph/resolvers/user.js @@ -43,19 +43,19 @@ module.exports = { delete(obj, args) { return WIKI.models.users.query().deleteById(args.id) }, - update(obj, args) { - return WIKI.models.users.query().patch({ - email: args.email, - name: args.name, - provider: args.provider, - providerId: args.providerId - }).where('id', args.id) + async update(obj, args) { + try { + await WIKI.models.users.updateUser(args) + + return { + responseResult: graphHelper.generateSuccess('User created successfully') + } + } catch (err) { + return graphHelper.generateError(err) + } }, resetPassword(obj, args) { return false - }, - setPassword(obj, args) { - return false } }, User: { diff --git a/server/graph/schemas/user.graphql b/server/graph/schemas/user.graphql index 31701bee..23333957 100644 --- a/server/graph/schemas/user.graphql +++ b/server/graph/schemas/user.graphql @@ -48,9 +48,12 @@ type UserMutation { id: Int! email: String name: String - providerKey: String - providerId: String - ): UserResponse @auth(requires: ["manage:users", "manage:system"]) + newPassword: String + groups: [Int] + location: String + jobTitle: String + timezone: String + ): DefaultResponse @auth(requires: ["manage:users", "manage:system"]) delete( id: Int! @@ -59,11 +62,6 @@ type UserMutation { resetPassword( id: Int! ): DefaultResponse - - setPassword( - id: Int! - passwordRaw: String! - ): DefaultResponse } # ----------------------------------------------- diff --git a/server/helpers/error.js b/server/helpers/error.js index cf1d27aa..2072c0c6 100644 --- a/server/helpers/error.js +++ b/server/helpers/error.js @@ -140,5 +140,9 @@ module.exports = { UserCreationFailed: CustomError('UserCreationFailed', { message: 'An unexpected error occured during user creation.', code: 1009 + }), + UserNotFound: CustomError('UserNotFound', { + message: 'This user does not exist.', + code: 1016 }) } diff --git a/server/models/users.js b/server/models/users.js index 20c56d32..d70a201f 100644 --- a/server/models/users.js +++ b/server/models/users.js @@ -374,6 +374,11 @@ module.exports = class User extends Model { throw new WIKI.Error.AuthTFAInvalid() } + /** + * Create a new user + * + * @param {Object} param0 User Fields + */ static async createNewUser ({ providerKey, email, passwordRaw, name, groups, mustChangePassword, sendWelcomeEmail }) { // Input sanitization email = _.toLower(email) @@ -487,6 +492,69 @@ module.exports = class User extends Model { } } + /** + * Update an existing user + * + * @param {Object} param0 User ID and fields to update + */ + static async updateUser ({ id, email, name, newPassword, groups, location, jobTitle, timezone }) { + const usr = await WIKI.models.users.query().findById(id) + if (usr) { + let usrData = {} + if (!_.isEmpty(email) && email !== usr.email) { + const dupUsr = await WIKI.models.users.query().select('id').where({ + email, + providerKey: usr.providerKey + }) + if (dupUsr) { + throw new WIKI.Error.AuthAccountAlreadyExists() + } + usrData.email = email + } + if (!_.isEmpty(name) && name !== usr.name) { + usrData.name = _.trim(name) + } + if (!_.isEmpty(newPassword)) { + if (newPassword.length < 6) { + throw new WIKI.Error.InputInvalid('Password must be at least 6 characters!') + } + usrData.password = newPassword + } + if (!_.isEmpty(groups)) { + const usrGroupsRaw = await usr.$relatedQuery('groups') + const usrGroups = _.map(usrGroupsRaw, 'id') + // Relate added groups + const addUsrGroups = _.difference(groups, usrGroups) + for (const grp of addUsrGroups) { + await usr.$relatedQuery('groups').relate(grp) + } + // Unrelate removed groups + const remUsrGroups = _.difference(usrGroups, groups) + for (const grp of remUsrGroups) { + await usr.$relatedQuery('groups').unrelate().where('groupId', grp) + } + } + if (!_.isEmpty(location) && location !== usr.location) { + usrData.location = _.trim(location) + } + if (!_.isEmpty(jobTitle) && jobTitle !== usr.jobTitle) { + usrData.jobTitle = _.trim(jobTitle) + } + if (!_.isEmpty(timezone) && timezone !== usr.timezone) { + usrData.timezone = timezone + } + await WIKI.models.users.query().patch(usrData).findById(id) + } else { + throw new WIKI.Error.UserNotFound() + } + } + + /** + * Register a new user (client-side registration) + * + * @param {Object} param0 User fields + * @param {Object} context GraphQL Context + */ static async register ({ email, password, name, verify = false, bypassChecks = false }, context) { const localStrg = await WIKI.models.authentication.getStrategy('local') // Check if self-registration is enabled