feat: user profile page - save info + change pwd

This commit is contained in:
NGPixel 2020-04-05 18:51:48 -04:00 committed by Nicolas Giard
parent c7f3c9d908
commit 1e4d513252
19 changed files with 1613 additions and 1062 deletions

View File

@ -123,7 +123,7 @@
v-list-item-avatar(size='24', tile): v-icon mdi-heart-outline v-list-item-avatar(size='24', tile): v-icon mdi-heart-outline
v-list-item-title {{ $t('admin:contribute.title') }} v-list-item-title {{ $t('admin:contribute.title') }}
v-content(:class='darkMode ? "grey darken-4" : ""') v-content(:class='darkMode ? "grey darken-4" : "grey lighten-5"')
transition(name='admin-router') transition(name='admin-router')
router-view router-view

View File

@ -195,7 +195,9 @@
<script> <script>
import _ from 'lodash' import _ from 'lodash'
import nanoid from 'nanoid/non-secure/generate' import { customAlphabet } from 'nanoid/non-secure'
const nanoid = customAlphabet('1234567890abcdef', 10)
export default { export default {
props: { props: {
@ -241,7 +243,7 @@ export default {
methods: { methods: {
addRule(group) { addRule(group) {
this.group.pageRules.push({ this.group.pageRules.push({
id: nanoid('1234567890abcdef', 10), id: nanoid(),
path: '', path: '',
roles: [], roles: [],
match: 'START', match: 'START',

View File

@ -166,7 +166,7 @@ export default {
return 'mdi-linux' return 'mdi-linux'
} }
case 'win32': case 'win32':
return 'mdi-windows' return 'mdi-microsoft-windows'
default: default:
return '' return ''
} }

View File

@ -355,8 +355,12 @@ export default {
}, },
async saveAndClose() { async saveAndClose() {
try { try {
await this.save({ rethrow: true }) if (this.$store.get('editor/mode') === 'create') {
await this.exit() await this.save()
} else {
await this.save({ rethrow: true })
await this.exit()
}
} catch (err) { } catch (err) {
// Error is already handled // Error is already handled
} }

View File

@ -170,7 +170,7 @@
v-spacer v-spacer
loader(v-model='isLoading', :color='loaderColor', :title='loaderTitle', :subtitle='$t(`auth:pleaseWait`)') loader(v-model='isLoading', :color='loaderColor', :title='loaderTitle', :subtitle='$t(`auth:pleaseWait`)')
nav-footer(color='grey darken-5') nav-footer(color='grey darken-5', dark-color='grey darken-5')
notify notify
</template> </template>

View File

@ -8,9 +8,9 @@
.headline.primary--text.animated.fadeInLeft {{$t('profile:title')}} .headline.primary--text.animated.fadeInLeft {{$t('profile:title')}}
.subheading.grey--text.animated.fadeInLeft {{$t('profile:subtitle')}} .subheading.grey--text.animated.fadeInLeft {{$t('profile:subtitle')}}
v-spacer v-spacer
v-btn.animated.fadeInDown(outlined, color='primary', disabled).mr-0 //- v-btn.animated.fadeInDown(outlined, color='primary', disabled).mr-0
v-icon(left) mdi-earth //- v-icon(left) mdi-earth
span {{$t('profile:viewPublicProfile')}} //- span {{$t('profile:viewPublicProfile')}}
v-flex(lg6 xs12) v-flex(lg6 xs12)
v-card v-card
v-toolbar(color='primary', dark, dense, flat) v-toolbar(color='primary', dark, dense, flat)
@ -30,8 +30,9 @@
left left
) )
template(v-slot:activator='{ on }') template(v-slot:activator='{ on }')
v-btn(icon, color='grey', x-small, v-on='on', @click='focusField(`iptDisplayName`)') v-btn(text, color='grey', small, v-on='on', @click='focusField(`iptDisplayName`)')
v-icon mdi-pencil v-icon(left) mdi-pencil
span {{ $t('common:actions:edit') }}
v-card v-card
v-text-field( v-text-field(
ref='iptDisplayName' ref='iptDisplayName'
@ -59,8 +60,9 @@
left left
) )
template(v-slot:activator='{ on }') template(v-slot:activator='{ on }')
v-btn(icon, color='grey', x-small, v-on='on', @click='focusField(`iptLocation`)') v-btn(text, color='grey', small, v-on='on', @click='focusField(`iptLocation`)')
v-icon mdi-pencil v-icon(left) mdi-pencil
span {{ $t('common:actions:edit') }}
v-card v-card
v-text-field( v-text-field(
ref='iptLocation' ref='iptLocation'
@ -88,8 +90,9 @@
left left
) )
template(v-slot:activator='{ on }') template(v-slot:activator='{ on }')
v-btn(icon, color='grey', x-small, v-on='on', @click='focusField(`iptJobTitle`)') v-btn(text, color='grey', small, v-on='on', @click='focusField(`iptJobTitle`)')
v-icon mdi-pencil v-icon(left) mdi-pencil
span {{ $t('common:actions:edit') }}
v-card v-card
v-text-field( v-text-field(
ref='iptJobTitle' ref='iptJobTitle'
@ -117,8 +120,9 @@
left left
) )
template(v-slot:activator='{ on }') template(v-slot:activator='{ on }')
v-btn(icon, color='grey', x-small, v-on='on', @click='focusField(`iptTimezone`)') v-btn(text, color='grey', small, v-on='on', @click='focusField(`iptTimezone`)')
v-icon mdi-pencil v-icon(left) mdi-pencil
span {{ $t('common:actions:edit') }}
v-card v-card
v-select( v-select(
ref='iptTimezone' ref='iptTimezone'
@ -135,15 +139,15 @@
) )
v-card-chin v-card-chin
v-spacer v-spacer
v-btn.px-4(color='success') v-btn.px-4(color='success', depressed, @click='saveProfile', :loading='saveLoading')
v-icon(left) mdi-content-save v-icon(left) mdi-content-save
span {{$t('common:actions.save')}} span {{$t('common:actions.save')}}
v-card.mt-3 v-card.mt-3
v-toolbar(color='primary', dark, dense, flat) v-toolbar(color='primary', dark, dense, flat)
v-toolbar-title v-toolbar-title
.subtitle-1 Authentication .subtitle-1 {{$t('profile:auth.title')}}
v-card-text v-card-text.pt-0
v-subheader.pl-0 Provider v-subheader.pl-0: span.subtitle-2 {{$t('profile:auth.provider')}}
v-toolbar( v-toolbar(
flat flat
:color='$vuetify.theme.dark ? "grey darken-2" : "purple lighten-5"' :color='$vuetify.theme.dark ? "grey darken-2" : "purple lighten-5"'
@ -151,36 +155,64 @@
:class='$vuetify.theme.dark ? "grey--text text--lighten-1" : "purple--text text--darken-4"' :class='$vuetify.theme.dark ? "grey--text text--lighten-1" : "purple--text text--darken-4"'
) )
v-icon(:color='$vuetify.theme.dark ? "grey lighten-1" : "purple darken-4"') mdi-shield-lock v-icon(:color='$vuetify.theme.dark ? "grey lighten-1" : "purple darken-4"') mdi-shield-lock
.subheading.ml-3 Local .subheading.ml-3 {{ user.providerName }}
v-divider.mt-3 //- v-divider.mt-3
v-subheader.pl-0 Two-Factor Authentication (2FA) //- v-subheader.pl-0: span.subtitle-2 Two-Factor Authentication (2FA)
.caption.mb-2 2FA adds an extra layer of security by requiring a unique code generated on your smartphone when signing in. //- .caption.mb-2 2FA adds an extra layer of security by requiring a unique code generated on your smartphone when signing in.
v-btn(color='purple darken-4', disabled).ml-0 Enable 2FA //- v-btn(color='purple darken-4', disabled).ml-0 Enable 2FA
v-btn(color='purple darken-4', dark, depressed, disabled).ml-0 Disable 2FA //- v-btn(color='purple darken-4', dark, depressed, disabled).ml-0 Disable 2FA
v-divider.mt-3 template(v-if='user.providerKey === `local`')
v-subheader.pl-0 Change Password v-divider.mt-3
v-text-field(label='Current Password', type='password', prepend-icon='mdi-textbox-password') v-subheader.pl-0: span.subtitle-2 {{$t('profile:auth.changePassword')}}
v-text-field(label='New Password', type='password', prepend-icon='mdi-textbox-password') v-text-field(
v-text-field(label='Confirm New Password', type='password', prepend-icon='mdi-textbox-password') ref='iptCurrentPass'
v-model='currentPass'
outlined
:label='$t(`profile:auth.currentPassword`)'
type='password'
prepend-inner-icon='mdi-textbox-password'
)
v-text-field(
ref='iptNewPass'
v-model='newPass'
outlined
:label='$t(`profile:auth.newPassword`)'
type='password'
prepend-inner-icon='mdi-textbox-password'
counter='255'
loading
)
password-strength(slot='progress', v-model='newPass')
v-text-field(
ref='iptVerifyPass'
v-model='verifyPass'
outlined
:label='$t(`profile:auth.verifyPassword`)'
type='password'
prepend-inner-icon='mdi-textbox-password'
hide-details
)
v-card-chin v-card-chin
v-spacer v-spacer
v-btn.px-4(color='purple darken-4', dark) v-btn.px-4(color='purple darken-4', dark, depressed, @click='changePassword', :loading='changePassLoading')
v-icon(left) mdi-progress-check v-icon(left) mdi-progress-check
span Change Password span {{$t('profile:auth.changePassword')}}
v-flex(lg6 xs12) v-flex(lg6 xs12)
//- v-card
//- v-toolbar(color='primary', dark, dense, flat)
//- v-toolbar-title
//- .subtitle-1 Picture
//- v-card-title
//- 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-btn(outlined).mx-4 Upload Picture
//- v-btn(outlined, disabled) Remove Picture
v-card v-card
v-toolbar(color='primary', dark, dense, flat) v-toolbar(color='primary', dark, dense, flat)
v-toolbar-title v-toolbar-title
.subtitle-1 Picture .subtitle-1 {{$t('profile:groups.title')}}
v-card-title
v-avatar(size='64', color='grey')
v-icon(size='64', color='grey lighten-2') mdi-account-circle
v-btn(depressed).mx-4.elevation-1 Upload Picture
v-btn(depressed, disabled).elevation-1 Remove Picture
v-card.mt-3
v-toolbar(color='primary', dark, dense, flat)
v-toolbar-title
.subtitle-1 Groups
v-list(dense) v-list(dense)
template(v-for='(grp, idx) of user.groups') template(v-for='(grp, idx) of user.groups')
v-list-item(:key='`grp-id-` + grp') v-list-item(:key='`grp-id-` + grp')
@ -192,34 +224,51 @@
v-card.mt-3 v-card.mt-3
v-toolbar(color='teal', dark, dense, flat) v-toolbar(color='teal', dark, dense, flat)
v-toolbar-title v-toolbar-title
.subtitle-1 Activity .subtitle-1 {{$t('profile:activity.title')}}
v-card-text.grey--text.text--darken-2 v-card-text.grey--text.text--darken-2
.caption.grey--text Joined on .caption.grey--text {{$t('profile:activity.joinedOn')}}
.body-2: strong {{ user.createdAt | moment('LLLL') }} .body-2: strong {{ user.createdAt | moment('LLLL') }}
.caption.grey--text.mt-3 Profile last updated on .caption.grey--text.mt-3 {{$t('profile:activity.lastUpdatedOn')}}
.body-2: strong {{ user.updatedAt | moment('LLLL') }} .body-2: strong {{ user.updatedAt | moment('LLLL') }}
.caption.grey--text.mt-3 Last login on .caption.grey--text.mt-3 {{$t('profile:activity.lastLoginOn')}}
.body-2: strong {{ user.lastLoginOn | moment('LLLL') }} .body-2: strong {{ user.lastLoginAt | moment('LLLL') }}
v-divider.mt-3 v-divider.mt-3
.caption.grey--text.mt-3 Pages created .caption.grey--text.mt-3 {{$t('profile:activity.pagesCreated')}}
.body-2: strong 0 .body-2: strong {{ user.pagesTotal }}
.caption.grey--text.mt-3 Comments posted .caption.grey--text.mt-3 {{$t('profile:activity.commentsPosted')}}
.body-2: strong 0 .body-2: strong 0
</template> </template>
<script> <script>
import { get } from 'vuex-pathify'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import _ from 'lodash' import _ from 'lodash'
import Cookies from 'js-cookie'
import validate from 'validate.js'
import PasswordStrength from '../common/password-strength.vue'
export default { export default {
components: {
PasswordStrength
},
data() { data() {
return { return {
saveLoading: false,
changePassLoading: false,
user: { user: {
name: 'unknown',
location: '',
jobTitle: '',
timezone: '',
createdAt: '1970-01-01', createdAt: '1970-01-01',
updatedAt: '1970-01-01', updatedAt: '1970-01-01',
lastLoginOn: '1970-01-01', lastLoginAt: '1970-01-01',
groups: [] groups: []
}, },
currentPass: '',
newPass: '',
verifyPass: '',
editPop: { editPop: {
name: false, name: false,
location: false, location: false,
@ -480,6 +529,27 @@ export default {
] ]
} }
}, },
computed: {
pictureUrl: get('user/pictureUrl'),
picture () {
if (this.pictureUrl && this.pictureUrl.length > 1) {
return {
kind: 'image',
url: this.pictureUrl
}
} else {
const nameParts = this.user.name.toUpperCase().split(' ')
let initials = _.head(nameParts).charAt(0)
if (nameParts.length > 1) {
initials += _.last(nameParts).charAt(0)
}
return {
kind: 'initials',
initials
}
}
}
},
methods: { methods: {
/** /**
* Focus an input after delay * Focus an input after delay
@ -490,6 +560,164 @@ export default {
this.$refs[ipt].focus() this.$refs[ipt].focus()
}, 200) }, 200)
}) })
},
/**
* Save User Profile
*/
async saveProfile () {
this.saveLoading = true
this.$store.commit(`loadingStart`, 'profile-save')
try {
const respRaw = await this.$apollo.mutate({
mutation: gql`
mutation ($name: String!, $location: String!, $jobTitle: String!, $timezone: String!) {
users {
updateProfile(name: $name, location: $location, jobTitle: $jobTitle, timezone: $timezone) {
responseResult {
succeeded
errorCode
slug
message
}
jwt
}
}
}
`,
variables: {
name: this.user.name,
location: this.user.location,
jobTitle: this.user.jobTitle,
timezone: this.user.timezone
}
})
const resp = _.get(respRaw, 'data.users.updateProfile.responseResult', {})
if (resp.succeeded) {
Cookies.set('jwt', _.get(respRaw, 'data.users.updateProfile.jwt', ''), { expires: 365 })
this.$store.set('user/name', this.user.name)
this.$store.commit('showNotification', {
message: this.$t('profile:save.success'),
style: 'success',
icon: 'check'
})
} else {
throw new Error(resp.message)
}
} catch (err) {
this.$store.commit('pushGraphError', err)
}
this.$store.commit(`loadingStop`, 'profile-save')
this.saveLoading = false
},
/**
* Change Password
*/
async changePassword () {
const validation = validate({
current: this.currentPass,
password: this.newPass,
verifyPassword: this.verifyPass
}, {
current: {
presence: {
message: this.$t('auth:missingPassword'),
allowEmpty: false
},
length: {
minimum: 6,
tooShort: this.$t('auth:passwordTooShort')
}
},
password: {
presence: {
message: this.$t('auth:missingPassword'),
allowEmpty: false
},
length: {
minimum: 6,
tooShort: this.$t('auth:passwordTooShort')
}
},
verifyPassword: {
equality: {
attribute: 'password',
message: this.$t('auth:passwordNotMatch')
}
}
}, { fullMessages: false })
if (validation) {
if (validation.current) {
this.$store.commit('showNotification', {
style: 'red',
message: validation.current[0],
icon: 'warning'
})
this.$refs.iptCurrentPass.focus()
} else if (validation.password) {
this.$store.commit('showNotification', {
style: 'red',
message: validation.password[0],
icon: 'warning'
})
this.$refs.iptNewPass.focus()
} else if (validation.verifyPassword) {
this.$store.commit('showNotification', {
style: 'red',
message: validation.verifyPassword[0],
icon: 'warning'
})
this.$refs.iptVerifyPass.focus()
}
} else {
this.changePassLoading = true
this.$store.commit(`loadingStart`, 'profile-changepassword')
try {
const respRaw = await this.$apollo.mutate({
mutation: gql`
mutation ($current: String!, $new: String!) {
users {
changePassword(current: $current, new: $new) {
responseResult {
succeeded
errorCode
slug
message
}
jwt
}
}
}
`,
variables: {
current: this.currentPass,
new: this.newPass
}
})
const resp = _.get(respRaw, 'data.users.changePassword.responseResult', {})
if (resp.succeeded) {
this.currentPass = ''
this.newPass = ''
this.verifyPass = ''
Cookies.set('jwt', _.get(respRaw, 'data.users.changePassword.jwt', ''), { expires: 365 })
this.$store.commit('showNotification', {
message: this.$t('profile:auth.changePassSuccess'),
style: 'success',
icon: 'check'
})
} else {
throw new Error(resp.message)
}
} catch (err) {
this.$store.commit('pushGraphError', err)
}
this.$store.commit(`loadingStop`, 'profile-changepassword')
this.changePassLoading = false
}
} }
}, },
apollo: { apollo: {
@ -501,6 +729,7 @@ export default {
id id
name name
email email
providerKey
providerName providerName
isSystem isSystem
isVerified isVerified
@ -509,8 +738,9 @@ export default {
timezone timezone
createdAt createdAt
updatedAt updatedAt
lastLoginOn lastLoginAt
groups groups
pagesTotal
} }
} }
} }

View File

@ -23,7 +23,7 @@
solo solo
flat flat
prepend-icon='mdi-email' prepend-icon='mdi-email'
background-color='grey lighten-4' :background-color='$vuetify.theme.dark ? `grey darken-3` : `grey lighten-4`'
hide-details hide-details
ref='iptEmail' ref='iptEmail'
v-model='email' v-model='email'
@ -34,7 +34,7 @@
solo solo
flat flat
prepend-icon='mdi-textbox-password' prepend-icon='mdi-textbox-password'
background-color='grey lighten-4' :background-color='$vuetify.theme.dark ? `grey darken-3` : `grey lighten-4`'
ref='iptPassword' ref='iptPassword'
v-model='password' v-model='password'
:append-icon='hidePassword ? "mdi-eye-off" : "mdi-eye"' :append-icon='hidePassword ? "mdi-eye-off" : "mdi-eye"'
@ -50,7 +50,7 @@
solo solo
flat flat
prepend-icon='mdi-textbox-password' prepend-icon='mdi-textbox-password'
background-color='grey lighten-4' :background-color='$vuetify.theme.dark ? `grey darken-3` : `grey lighten-4`'
hide-details hide-details
ref='iptVerifyPassword' ref='iptVerifyPassword'
v-model='verifyPassword' v-model='verifyPassword'
@ -63,7 +63,7 @@
solo solo
flat flat
prepend-icon='mdi-account' prepend-icon='mdi-account'
background-color='grey lighten-4' :background-color='$vuetify.theme.dark ? `grey darken-3` : `grey lighten-4`'
ref='iptName' ref='iptName'
v-model='name' v-model='name'
:placeholder='$t("auth:fields.name")' :placeholder='$t("auth:fields.name")'
@ -85,14 +85,14 @@
) {{ $t('auth:actions.register') }} ) {{ $t('auth:actions.register') }}
v-spacer v-spacer
v-divider v-divider
v-card-actions.py-3.grey.lighten-4 v-card-actions.py-3.grey(:class='$vuetify.theme.dark ? `darken-4-l1` : `lighten-4`')
v-spacer v-spacer
i18next.caption(path='auth:switchToLogin.text', tag='div') i18next.caption(path='auth:switchToLogin.text', tag='div')
a.caption(href='/login', place='link') {{ $t('auth:switchToLogin.link') }} a.caption(href='/login', place='link') {{ $t('auth:switchToLogin.link') }}
v-spacer v-spacer
loader(v-model='isLoading', :mode='loaderMode', :icon='loaderIcon', :color='loaderColor', :title='loaderTitle', :subtitle='loaderSubtitle') loader(v-model='isLoading', :mode='loaderMode', :icon='loaderIcon', :color='loaderColor', :title='loaderTitle', :subtitle='loaderSubtitle')
nav-footer(color='grey darken-4', dark-color='grey darken-4') nav-footer(color='grey darken-5', dark-color='grey darken-5')
notify notify
</template> </template>

View File

@ -29,9 +29,8 @@ export default {
computed: { computed: {
company: get('site/company'), company: get('site/company'),
contentLicense: get('site/contentLicense'), contentLicense: get('site/contentLicense'),
darkMode: get('site/dark'),
bgColor() { bgColor() {
if (!this.darkMode) { if (!this.$vuetify.theme.dark) {
return this.color return this.color
} else { } else {
return this.darkColor return this.darkColor

View File

@ -48,13 +48,13 @@
"apollo-server": "2.11.0", "apollo-server": "2.11.0",
"apollo-server-express": "2.11.0", "apollo-server-express": "2.11.0",
"auto-load": "3.0.4", "auto-load": "3.0.4",
"aws-sdk": "2.639.0", "aws-sdk": "2.653.0",
"azure-search-client": "3.1.5", "azure-search-client": "3.1.5",
"bcryptjs-then": "1.0.1", "bcryptjs-then": "1.0.1",
"bluebird": "3.7.2", "bluebird": "3.7.2",
"body-parser": "1.19.0", "body-parser": "1.19.0",
"brute-knex": "4.0.0", "brute-knex": "4.0.0",
"chalk": "3.0.0", "chalk": "4.0.0",
"cheerio": "1.0.0-rc.3", "cheerio": "1.0.0-rc.3",
"chokidar": "3.3.1", "chokidar": "3.3.1",
"clean-css": "4.2.3", "clean-css": "4.2.3",
@ -75,7 +75,7 @@
"express-session": "1.17.0", "express-session": "1.17.0",
"file-type": "14.1.4", "file-type": "14.1.4",
"filesize": "6.1.0", "filesize": "6.1.0",
"fs-extra": "8.1.0", "fs-extra": "9.0.0",
"getos": "3.1.5", "getos": "3.1.5",
"graphql": "14.6.0", "graphql": "14.6.0",
"graphql-list-fields": "2.0.2", "graphql-list-fields": "2.0.2",
@ -84,7 +84,7 @@
"graphql-tools": "4.0.7", "graphql-tools": "4.0.7",
"he": "1.2.0", "he": "1.2.0",
"highlight.js": "9.18.1", "highlight.js": "9.18.1",
"i18next": "19.3.2", "i18next": "19.3.4",
"i18next-express-middleware": "1.9.1", "i18next-express-middleware": "1.9.1",
"i18next-node-fs-backend": "2.1.3", "i18next-node-fs-backend": "2.1.3",
"image-size": "0.8.3", "image-size": "0.8.3",
@ -94,7 +94,7 @@
"jsonwebtoken": "8.5.1", "jsonwebtoken": "8.5.1",
"katex": "0.11.1", "katex": "0.11.1",
"klaw": "3.0.0", "klaw": "3.0.0",
"knex": "0.20.11", "knex": "0.20.13",
"lodash": "4.17.15", "lodash": "4.17.15",
"markdown-it": "10.0.0", "markdown-it": "10.0.0",
"markdown-it-abbr": "1.0.4", "markdown-it-abbr": "1.0.4",
@ -118,10 +118,10 @@
"mssql": "6.2.0", "mssql": "6.2.0",
"multer": "1.4.2", "multer": "1.4.2",
"mysql2": "2.1.0", "mysql2": "2.1.0",
"nanoid": "2.1.11", "nanoid": "3.0.2",
"node-2fa": "1.1.2", "node-2fa": "1.1.2",
"node-cache": "5.1.0", "node-cache": "5.1.0",
"nodemailer": "6.4.5", "nodemailer": "6.4.6",
"objection": "2.1.3", "objection": "2.1.3",
"passport": "0.4.1", "passport": "0.4.1",
"passport-auth0": "1.3.2", "passport-auth0": "1.3.2",
@ -130,7 +130,7 @@
"passport-discord": "0.1.3", "passport-discord": "0.1.3",
"passport-dropbox-oauth2": "1.1.0", "passport-dropbox-oauth2": "1.1.0",
"passport-facebook": "3.0.0", "passport-facebook": "3.0.0",
"passport-github2": "0.1.11", "passport-github2": "0.1.12",
"passport-gitlab2": "5.0.0", "passport-gitlab2": "5.0.0",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0", "passport-jwt": "4.0.0",
@ -143,9 +143,9 @@
"passport-saml": "1.3.3", "passport-saml": "1.3.3",
"passport-twitch-oauth": "1.0.0", "passport-twitch-oauth": "1.0.0",
"pem-jwk": "2.0.0", "pem-jwk": "2.0.0",
"pg": "7.18.2", "pg": "8.0.0",
"pg-hstore": "2.3.3", "pg-hstore": "2.3.3",
"pg-query-stream": "3.0.3", "pg-query-stream": "3.0.4",
"pg-tsquery": "8.1.0", "pg-tsquery": "8.1.0",
"pug": "2.0.4", "pug": "2.0.4",
"punycode": "2.1.1", "punycode": "2.1.1",
@ -162,22 +162,22 @@
"simple-git": "1.132.0", "simple-git": "1.132.0",
"solr-node": "1.2.1", "solr-node": "1.2.1",
"sqlite3": "4.1.1", "sqlite3": "4.1.1",
"ssh2": "0.8.8", "ssh2": "0.8.9",
"ssh2-promise": "0.1.6", "ssh2-promise": "0.1.6",
"striptags": "3.1.1", "striptags": "3.1.1",
"subscriptions-transport-ws": "0.9.16", "subscriptions-transport-ws": "0.9.16",
"tar-fs": "2.0.0", "tar-fs": "2.0.1",
"twemoji": "12.1.5", "twemoji": "12.1.5",
"uslug": "1.0.4", "uslug": "1.0.4",
"uuid": "7.0.2", "uuid": "7.0.3",
"validate.js": "0.13.1", "validate.js": "0.13.1",
"winston": "3.2.1", "winston": "3.2.1",
"xss": "1.0.6", "xss": "1.0.6",
"yargs": "15.3.0" "yargs": "15.3.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.8.4", "@babel/cli": "^7.8.4",
"@babel/core": "^7.8.7", "@babel/core": "^7.9.0",
"@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-decorators": "^7.8.3", "@babel/plugin-proposal-decorators": "^7.8.3",
"@babel/plugin-proposal-export-namespace-from": "^7.8.3", "@babel/plugin-proposal-export-namespace-from": "^7.8.3",
@ -188,11 +188,11 @@
"@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-syntax-import-meta": "^7.8.3", "@babel/plugin-syntax-import-meta": "^7.8.3",
"@babel/polyfill": "^7.8.7", "@babel/polyfill": "^7.8.7",
"@babel/preset-env": "^7.8.7", "@babel/preset-env": "^7.9.0",
"@mdi/font": "5.0.45", "@mdi/font": "5.0.45",
"@panter/vue-i18next": "0.15.2", "@panter/vue-i18next": "0.15.2",
"@requarks/ckeditor5": "12.4.0-wiki.14", "@requarks/ckeditor5": "12.4.0-wiki.14",
"@vue/babel-preset-app": "4.2.3", "@vue/babel-preset-app": "4.3.0",
"animate-sass": "0.8.2", "animate-sass": "0.8.2",
"animated-number-vue": "1.0.0", "animated-number-vue": "1.0.0",
"apollo-cache-inmemory": "1.6.5", "apollo-cache-inmemory": "1.6.5",
@ -204,10 +204,10 @@
"apollo-link-persisted-queries": "0.2.2", "apollo-link-persisted-queries": "0.2.2",
"apollo-link-ws": "1.0.19", "apollo-link-ws": "1.0.19",
"apollo-utilities": "1.3.3", "apollo-utilities": "1.3.3",
"autoprefixer": "9.7.4", "autoprefixer": "9.7.5",
"babel-eslint": "10.1.0", "babel-eslint": "10.1.0",
"babel-jest": "25.1.0", "babel-jest": "25.2.6",
"babel-loader": "^8.0.6", "babel-loader": "^8.1.0",
"babel-plugin-graphql-tag": "2.5.0", "babel-plugin-graphql-tag": "2.5.0",
"babel-plugin-lodash": "3.3.4", "babel-plugin-lodash": "3.3.4",
"babel-plugin-prismjs": "2.0.1", "babel-plugin-prismjs": "2.0.1",
@ -216,37 +216,37 @@
"chart.js": "2.9.3", "chart.js": "2.9.3",
"clean-webpack-plugin": "3.0.0", "clean-webpack-plugin": "3.0.0",
"clipboard": "2.0.6", "clipboard": "2.0.6",
"codemirror": "5.52.0", "codemirror": "5.52.2",
"copy-webpack-plugin": "5.1.1", "copy-webpack-plugin": "5.1.1",
"core-js": "3.6.4", "core-js": "3.6.4",
"css-loader": "3.4.2", "css-loader": "3.4.2",
"cssnano": "4.1.10", "cssnano": "4.1.10",
"d3": "5.15.0", "d3": "5.15.1",
"duplicate-package-checker-webpack-plugin": "3.0.0", "duplicate-package-checker-webpack-plugin": "3.0.0",
"epic-spinners": "1.1.0", "epic-spinners": "1.1.0",
"eslint": "6.8.0", "eslint": "6.8.0",
"eslint-config-requarks": "1.0.7", "eslint-config-requarks": "1.0.7",
"eslint-config-standard": "14.1.0", "eslint-config-standard": "14.1.1",
"eslint-plugin-import": "2.20.1", "eslint-plugin-import": "2.20.2",
"eslint-plugin-node": "11.0.0", "eslint-plugin-node": "11.1.0",
"eslint-plugin-promise": "4.2.1", "eslint-plugin-promise": "4.2.1",
"eslint-plugin-standard": "4.0.1", "eslint-plugin-standard": "4.0.1",
"eslint-plugin-vue": "6.2.2", "eslint-plugin-vue": "6.2.2",
"fibers": "4.0.2", "fibers": "4.0.2",
"file-loader": "5.1.0", "file-loader": "6.0.0",
"filepond": "4.13.0", "filepond": "4.13.0",
"filepond-plugin-file-validate-type": "1.2.4", "filepond-plugin-file-validate-type": "1.2.5",
"filesize.js": "2.0.0", "filesize.js": "2.0.0",
"graphql-persisted-document-loader": "2.0.0", "graphql-persisted-document-loader": "2.0.0",
"graphql-tag": "^2.10.3", "graphql-tag": "^2.10.3",
"hammerjs": "2.0.8", "hammerjs": "2.0.8",
"html-webpack-plugin": "4.0.0-beta.8", "html-webpack-plugin": "4.0.4",
"html-webpack-pug-plugin": "2.0.0", "html-webpack-pug-plugin": "2.0.0",
"i18next-chained-backend": "2.0.1", "i18next-chained-backend": "2.0.1",
"i18next-localstorage-backend": "3.1.1", "i18next-localstorage-backend": "3.1.1",
"i18next-xhr-backend": "3.2.2", "i18next-xhr-backend": "3.2.2",
"ignore-loader": "0.1.2", "ignore-loader": "0.1.2",
"jest": "25.1.0", "jest": "25.2.7",
"js-cookie": "2.2.1", "js-cookie": "2.2.1",
"mermaid": "8.4.8", "mermaid": "8.4.8",
"mini-css-extract-plugin": "0.9.0", "mini-css-extract-plugin": "0.9.0",
@ -260,7 +260,7 @@
"postcss-loader": "3.0.0", "postcss-loader": "3.0.0",
"postcss-preset-env": "6.7.0", "postcss-preset-env": "6.7.0",
"postcss-selector-parser": "6.0.2", "postcss-selector-parser": "6.0.2",
"prismjs": "1.19.0", "prismjs": "1.20.0",
"pug-lint": "2.6.0", "pug-lint": "2.6.0",
"pug-loader": "2.4.0", "pug-loader": "2.4.0",
"pug-plain-loader": "1.0.0", "pug-plain-loader": "1.0.0",
@ -272,9 +272,9 @@
"script-ext-html-webpack-plugin": "2.1.4", "script-ext-html-webpack-plugin": "2.1.4",
"simple-progress-webpack-plugin": "1.1.2", "simple-progress-webpack-plugin": "1.1.2",
"style-loader": "1.1.3", "style-loader": "1.1.3",
"terser": "4.6.6", "terser": "4.6.10",
"twemoji-awesome": "1.0.6", "twemoji-awesome": "1.0.6",
"url-loader": "3.0.0", "url-loader": "4.0.0",
"velocity-animate": "1.5.2", "velocity-animate": "1.5.2",
"viz.js": "2.1.2", "viz.js": "2.1.2",
"vue": "2.6.11", "vue": "2.6.11",
@ -283,7 +283,7 @@
"vue-clipboards": "1.3.0", "vue-clipboards": "1.3.0",
"vue-filepond": "6.0.2", "vue-filepond": "6.0.2",
"vue-hot-reload-api": "2.3.4", "vue-hot-reload-api": "2.3.4",
"vue-loader": "15.9.0", "vue-loader": "15.9.1",
"vue-moment": "4.1.0", "vue-moment": "4.1.0",
"vue-router": "3.1.6", "vue-router": "3.1.6",
"vue-status-indicator": "1.2.1", "vue-status-indicator": "1.2.1",
@ -291,12 +291,12 @@
"vue2-animate": "2.1.3", "vue2-animate": "2.1.3",
"vuedraggable": "2.23.2", "vuedraggable": "2.23.2",
"vuescroll": "4.15.0", "vuescroll": "4.15.0",
"vuetify": "2.2.17", "vuetify": "2.2.20",
"vuetify-loader": "1.4.3", "vuetify-loader": "1.4.3",
"vuex": "3.1.3", "vuex": "3.1.3",
"vuex-pathify": "1.4.1", "vuex-pathify": "1.4.1",
"vuex-persistedstate": "2.7.1", "vuex-persistedstate": "3.0.1",
"webpack": "4.42.0", "webpack": "4.42.1",
"webpack-bundle-analyzer": "3.6.1", "webpack-bundle-analyzer": "3.6.1",
"webpack-cli": "3.3.11", "webpack-cli": "3.3.11",
"webpack-dev-middleware": "3.7.2", "webpack-dev-middleware": "3.7.2",

View File

@ -278,6 +278,10 @@ router.get(['/i', '/i/:id'], async (req, res, next) => {
* Profile * Profile
*/ */
router.get(['/p', '/p/*'], (req, res, next) => { router.get(['/p', '/p/*'], (req, res, next) => {
if (!req.user || req.user.id < 1 || req.user.id === 2) {
return res.render('unauthorized', { action: 'view' })
}
_.set(res.locals, 'pageMeta.title', 'User Profile') _.set(res.locals, 'pageMeta.title', 'User Profile')
res.render('profile') res.render('profile')
}) })

View File

@ -0,0 +1,8 @@
exports.up = knex => {
return knex.schema
.alterTable('users', table => {
table.string('lastLoginAt')
})
}
exports.down = knex => { }

View File

@ -0,0 +1,8 @@
exports.up = knex => {
return knex.schema
.alterTable('users', table => {
table.string('lastLoginAt')
})
}
exports.down = knex => { }

View File

@ -9,7 +9,7 @@ const moment = require('moment')
const graphHelper = require('../../helpers/graph') const graphHelper = require('../../helpers/graph')
const request = require('request-promise') const request = require('request-promise')
const crypto = require('crypto') const crypto = require('crypto')
const nanoid = require('nanoid/non-secure/generate') const nanoid = require('nanoid/non-secure').customAlphabet('1234567890abcdef', 10)
/* global WIKI */ /* global WIKI */
@ -150,7 +150,7 @@ module.exports = {
roles = _.concat(roles, ['write:pages', 'manage:pages', 'read:source', 'read:history', 'write:assets', 'manage:assets']) roles = _.concat(roles, ['write:pages', 'manage:pages', 'read:source', 'read:history', 'write:assets', 'manage:assets'])
} }
return { return {
id: nanoid('1234567890abcdef', 10), id: nanoid(),
roles: roles, roles: roles,
match: r.exact ? 'EXACT' : 'START', match: r.exact ? 'EXACT' : 'START',
deny: r.deny, deny: r.deny,

View File

@ -1,4 +1,5 @@
const graphHelper = require('../../helpers/graph') const graphHelper = require('../../helpers/graph')
const _ = require('lodash')
/* global WIKI */ /* global WIKI */
@ -35,15 +36,16 @@ module.exports = {
if (!usr.isActive) { if (!usr.isActive) {
throw new WIKI.Error.AuthAccountBanned() throw new WIKI.Error.AuthAccountBanned()
} }
const usrGroups = await usr.$relatedQuery('groups')
return { const providerInfo = _.find(WIKI.data.authentication, ['key', usr.providerKey])
...usr,
password: '', usr.providerName = _.get(providerInfo, 'title', 'Unknown')
providerKey: '', usr.lastLoginAt = usr.lastLoginAt || usr.updatedAt
tfaSecret: '', usr.password = ''
lastLoginOn: '1970-01-01', usr.providerId = ''
groups: usrGroups.map(g => g.name) usr.tfaSecret = ''
}
return usr
} }
}, },
UserMutation: { UserMutation: {
@ -124,11 +126,88 @@ module.exports = {
}, },
resetPassword (obj, args) { resetPassword (obj, args) {
return false return false
},
async updateProfile (obj, args, context) {
try {
if (!context.req.user || context.req.user.id < 1 || context.req.user.id === 2) {
throw new WIKI.Error.AuthRequired()
}
const usr = await WIKI.models.users.query().findById(context.req.user.id)
if (!usr.isActive) {
throw new WIKI.Error.AuthAccountBanned()
}
if (!usr.isVerified) {
throw new WIKI.Error.AuthAccountNotVerified()
}
await WIKI.models.users.updateUser({
id: usr.id,
name: _.trim(args.name),
jobTitle: _.trim(args.jobTitle),
location: _.trim(args.location),
timezone: args.timezone
})
const newToken = await WIKI.models.users.refreshToken(usr.id)
return {
responseResult: graphHelper.generateSuccess('User profile updated successfully'),
jwt: newToken.token
}
} catch (err) {
return graphHelper.generateError(err)
}
},
async changePassword (obj, args, context) {
try {
if (!context.req.user || context.req.user.id < 1 || context.req.user.id === 2) {
throw new WIKI.Error.AuthRequired()
}
const usr = await WIKI.models.users.query().findById(context.req.user.id)
if (!usr.isActive) {
throw new WIKI.Error.AuthAccountBanned()
}
if (!usr.isVerified) {
throw new WIKI.Error.AuthAccountNotVerified()
}
if (usr.providerKey !== 'local') {
throw new WIKI.Error.AuthProviderInvalid()
}
try {
await usr.verifyPassword(args.current)
} catch (err) {
throw new WIKI.Error.AuthPasswordInvalid()
}
await WIKI.models.users.updateUser({
id: usr.id,
newPassword: args.new
})
const newToken = await WIKI.models.users.refreshToken(usr)
return {
responseResult: graphHelper.generateSuccess('Password changed successfully'),
jwt: newToken.token
}
} catch (err) {
return graphHelper.generateError(err)
}
} }
}, },
User: { User: {
groups(usr) { groups (usr) {
return usr.$relatedQuery('groups') return usr.$relatedQuery('groups')
} }
},
UserProfile: {
async groups (usr) {
const usrGroups = await usr.$relatedQuery('groups')
return usrGroups.map(g => g.name)
},
async pagesTotal (usr) {
const result = await WIKI.models.pages.query().count('* as total').where('creatorId', usr.id).first()
return _.toSafeInteger(result.total)
}
} }
} }

View File

@ -76,6 +76,18 @@ type UserMutation {
resetPassword( resetPassword(
id: Int! id: Int!
): DefaultResponse ): DefaultResponse
updateProfile(
name: String!
location: String!
jobTitle: String!
timezone: String!
): UserTokenResponse
changePassword(
current: String!
new: String!
): UserTokenResponse
} }
# ----------------------------------------------- # -----------------------------------------------
@ -117,6 +129,7 @@ type UserProfile {
id: Int! id: Int!
name: String! name: String!
email: String! email: String!
providerKey: String
providerName: String providerName: String
isSystem: Boolean! isSystem: Boolean!
isVerified: Boolean! isVerified: Boolean!
@ -125,6 +138,12 @@ type UserProfile {
timezone: String! timezone: String!
createdAt: Date! createdAt: Date!
updatedAt: Date! updatedAt: Date!
lastLoginOn: Date! lastLoginAt: Date
groups: [String]! groups: [String]!
pagesTotal: Int!
}
type UserTokenResponse {
responseResult: ResponseStatus!
jwt: String
} }

View File

@ -57,6 +57,10 @@ module.exports = {
message: 'Invalid email / username or password.', message: 'Invalid email / username or password.',
code: 1002 code: 1002
}), }),
AuthPasswordInvalid: CustomError('AuthPasswordInvalid', {
message: 'Password is incorrect.',
code: 1020
}),
AuthProviderInvalid: CustomError('AuthProviderInvalid', { AuthProviderInvalid: CustomError('AuthProviderInvalid', {
message: 'Invalid authentication provider.', message: 'Invalid authentication provider.',
code: 1003 code: 1003

View File

@ -2,7 +2,7 @@
const Model = require('objection').Model const Model = require('objection').Model
const moment = require('moment') const moment = require('moment')
const nanoid = require('nanoid') const nanoid = require('nanoid').nanoid
/** /**
* Users model * Users model

View File

@ -341,6 +341,9 @@ module.exports = class User extends Model {
user.groups = await user.$relatedQuery('groups').select('groups.id', 'permissions') user.groups = await user.$relatedQuery('groups').select('groups.id', 'permissions')
} }
// Update Last Login Date
await WIKI.models.users.query().findById(user.id).patch({ lastLoginAt: new Date().toISOString() })
return { return {
token: jwt.sign({ token: jwt.sign({
id: user.id, id: user.id,

2089
yarn.lock

File diff suppressed because it is too large Load Diff