feat(admin): migrate users to vue 3 composable

parent 6e303ac6
...@@ -89,7 +89,7 @@ module.exports = { ...@@ -89,7 +89,7 @@ module.exports = {
await WIKI.models.users.createNewUser({ ...args, passwordRaw: args.password, isVerified: true }) await WIKI.models.users.createNewUser({ ...args, passwordRaw: args.password, isVerified: true })
return { return {
status: graphHelper.generateSuccess('User created successfully') operation: graphHelper.generateSuccess('User created successfully')
} }
} catch (err) { } catch (err) {
return graphHelper.generateError(err) return graphHelper.generateError(err)
...@@ -106,7 +106,7 @@ module.exports = { ...@@ -106,7 +106,7 @@ module.exports = {
WIKI.events.outbound.emit('addAuthRevoke', { id: args.id, kind: 'u' }) WIKI.events.outbound.emit('addAuthRevoke', { id: args.id, kind: 'u' })
return { return {
status: graphHelper.generateSuccess('User deleted successfully') operation: graphHelper.generateSuccess('User deleted successfully')
} }
} catch (err) { } catch (err) {
if (err.message.indexOf('foreign') >= 0) { if (err.message.indexOf('foreign') >= 0) {
...@@ -121,7 +121,7 @@ module.exports = { ...@@ -121,7 +121,7 @@ module.exports = {
await WIKI.models.users.updateUser(args.id, args.patch) await WIKI.models.users.updateUser(args.id, args.patch)
return { return {
status: graphHelper.generateSuccess('User updated successfully') operation: graphHelper.generateSuccess('User updated successfully')
} }
} catch (err) { } catch (err) {
return graphHelper.generateError(err) return graphHelper.generateError(err)
...@@ -132,7 +132,7 @@ module.exports = { ...@@ -132,7 +132,7 @@ module.exports = {
await WIKI.models.users.query().patch({ isVerified: true }).findById(args.id) await WIKI.models.users.query().patch({ isVerified: true }).findById(args.id)
return { return {
status: graphHelper.generateSuccess('User verified successfully') operation: graphHelper.generateSuccess('User verified successfully')
} }
} catch (err) { } catch (err) {
return graphHelper.generateError(err) return graphHelper.generateError(err)
...@@ -143,7 +143,7 @@ module.exports = { ...@@ -143,7 +143,7 @@ module.exports = {
await WIKI.models.users.query().patch({ isActive: true }).findById(args.id) await WIKI.models.users.query().patch({ isActive: true }).findById(args.id)
return { return {
status: graphHelper.generateSuccess('User activated successfully') operation: graphHelper.generateSuccess('User activated successfully')
} }
} catch (err) { } catch (err) {
return graphHelper.generateError(err) return graphHelper.generateError(err)
...@@ -160,7 +160,7 @@ module.exports = { ...@@ -160,7 +160,7 @@ module.exports = {
WIKI.events.outbound.emit('addAuthRevoke', { id: args.id, kind: 'u' }) WIKI.events.outbound.emit('addAuthRevoke', { id: args.id, kind: 'u' })
return { return {
status: graphHelper.generateSuccess('User deactivated successfully') operation: graphHelper.generateSuccess('User deactivated successfully')
} }
} catch (err) { } catch (err) {
return graphHelper.generateError(err) return graphHelper.generateError(err)
...@@ -171,7 +171,7 @@ module.exports = { ...@@ -171,7 +171,7 @@ module.exports = {
await WIKI.models.users.query().patch({ tfaIsActive: true, tfaSecret: null }).findById(args.id) await WIKI.models.users.query().patch({ tfaIsActive: true, tfaSecret: null }).findById(args.id)
return { return {
status: graphHelper.generateSuccess('User 2FA enabled successfully') operation: graphHelper.generateSuccess('User 2FA enabled successfully')
} }
} catch (err) { } catch (err) {
return graphHelper.generateError(err) return graphHelper.generateError(err)
...@@ -182,7 +182,7 @@ module.exports = { ...@@ -182,7 +182,7 @@ module.exports = {
await WIKI.models.users.query().patch({ tfaIsActive: false, tfaSecret: null }).findById(args.id) await WIKI.models.users.query().patch({ tfaIsActive: false, tfaSecret: null }).findById(args.id)
return { return {
status: graphHelper.generateSuccess('User 2FA disabled successfully') operation: graphHelper.generateSuccess('User 2FA disabled successfully')
} }
} catch (err) { } catch (err) {
return graphHelper.generateError(err) return graphHelper.generateError(err)
...@@ -225,7 +225,7 @@ module.exports = { ...@@ -225,7 +225,7 @@ module.exports = {
const newToken = await WIKI.models.users.refreshToken(usr.id) const newToken = await WIKI.models.users.refreshToken(usr.id)
return { return {
status: graphHelper.generateSuccess('User profile updated successfully'), operation: graphHelper.generateSuccess('User profile updated successfully'),
jwt: newToken.token jwt: newToken.token
} }
} catch (err) { } catch (err) {
......
...@@ -76,6 +76,7 @@ ...@@ -76,6 +76,7 @@
"uuid": "8.3.2", "uuid": "8.3.2",
"v-network-graph": "0.5.16", "v-network-graph": "0.5.16",
"vue": "3.2.31", "vue": "3.2.31",
"vue-codemirror": "5.0.1",
"vue-i18n": "9.1.10", "vue-i18n": "9.1.10",
"vue-router": "4.0.15", "vue-router": "4.0.15",
"vuedraggable": "4.1.0", "vuedraggable": "4.1.0",
......
...@@ -501,7 +501,7 @@ import { fileOpen } from 'browser-fs-access' ...@@ -501,7 +501,7 @@ import { fileOpen } from 'browser-fs-access'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { exportFile, useQuasar } from 'quasar' import { exportFile, useQuasar } from 'quasar'
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue' import { computed, onMounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAdminStore } from 'src/stores/admin' import { useAdminStore } from 'src/stores/admin'
......
<template lang="pug"> <template lang="pug">
q-dialog(ref='dialog', @hide='onDialogHide') q-dialog(ref='dialogRef', @hide='onDialogHide')
q-card(style='min-width: 650px;') q-card(style='min-width: 650px;')
q-card-section.card-header q-card-section.card-header
q-icon(name='img:/_assets/icons/fluent-password-reset.svg', left, size='sm') q-icon(name='img:/_assets/icons/fluent-password-reset.svg', left, size='sm')
span {{$t(`admin.users.changePassword`)}} span {{t(`admin.users.changePassword`)}}
q-form.q-py-sm(ref='changeUserPwdForm', @submit='save') q-form.q-py-sm(ref='changeUserPwdForm', @submit='save')
q-item q-item
blueprint-icon(icon='password') blueprint-icon(icon='password')
q-item-section q-item-section
q-input( q-input(
outlined outlined
v-model='userPassword' v-model='state.userPassword'
dense dense
:rules=`[ :rules='userPasswordValidation'
val => val.length > 0 || $t('admin.users.passwordMissing'),
val => val.length >= 8 || $t('admin.users.passwordTooShort')
]`
hide-bottom-space hide-bottom-space
:label='$t(`admin.users.password`)' :label='t(`admin.users.password`)'
:aria-label='$t(`admin.users.password`)' :aria-label='t(`admin.users.password`)'
lazy-rules='ondemand' lazy-rules='ondemand'
autofocus autofocus
) )
...@@ -41,159 +38,182 @@ q-dialog(ref='dialog', @hide='onDialogHide') ...@@ -41,159 +38,182 @@ q-dialog(ref='dialog', @hide='onDialogHide')
q-item(tag='label', v-ripple) q-item(tag='label', v-ripple)
blueprint-icon(icon='password-reset') blueprint-icon(icon='password-reset')
q-item-section q-item-section
q-item-label {{$t(`admin.users.mustChangePwd`)}} q-item-label {{t(`admin.users.mustChangePwd`)}}
q-item-label(caption) {{$t(`admin.users.mustChangePwdHint`)}} q-item-label(caption) {{t(`admin.users.mustChangePwdHint`)}}
q-item-section(avatar) q-item-section(avatar)
q-toggle( q-toggle(
v-model='userMustChangePassword' v-model='state.userMustChangePassword'
color='primary' color='primary'
checked-icon='las la-check' checked-icon='las la-check'
unchecked-icon='las la-times' unchecked-icon='las la-times'
:aria-label='$t(`admin.users.mustChangePwd`)' :aria-label='t(`admin.users.mustChangePwd`)'
) )
q-card-actions.card-actions q-card-actions.card-actions
q-space q-space
q-btn.acrylic-btn( q-btn.acrylic-btn(
flat flat
:label='$t(`common.actions.cancel`)' :label='t(`common.actions.cancel`)'
color='grey' color='grey'
padding='xs md' padding='xs md'
@click='hide' @click='onDialogCancel'
) )
q-btn( q-btn(
unelevated unelevated
:label='$t(`common.actions.update`)' :label='t(`common.actions.update`)'
color='primary' color='primary'
padding='xs md' padding='xs md'
@click='save' @click='save'
:loading='isLoading' :loading='state.isLoading'
) )
</template> </template>
<script> <script setup>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import sampleSize from 'lodash/sampleSize' import sampleSize from 'lodash/sampleSize'
import zxcvbn from 'zxcvbn' import zxcvbn from 'zxcvbn'
export default { import { useI18n } from 'vue-i18n'
props: { import { useDialogPluginComponent, useQuasar } from 'quasar'
userId: { import { computed, reactive, ref } from 'vue'
type: String,
required: true // PROPS
}
}, const props = defineProps({
emits: ['ok', 'hide'], userId: {
data () { type: String,
required: true
}
})
// EMITS
defineEmits([
...useDialogPluginComponent.emits
])
// QUASAR
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
const $q = useQuasar()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
userPassword: '',
userMustChangePassword: false,
isLoading: false
})
// REFS
const changeUserPwdForm = ref(null)
// COMPUTED
const passwordStrength = computed(() => {
if (state.userPassword.length < 8) {
return { return {
userPassword: '', color: 'negative',
userMustChangePassword: false, label: t('admin.users.pwdStrengthWeak')
isLoading: false
} }
}, } else {
computed: { switch (zxcvbn(state.userPassword).score) {
passwordStrength () { case 1:
if (this.userPassword.length < 8) {
return { return {
color: 'negative', color: 'deep-orange-7',
label: this.$t('admin.users.pwdStrengthWeak') label: t('admin.users.pwdStrengthPoor')
} }
} else { case 2:
switch (zxcvbn(this.userPassword).score) { return {
case 1: color: 'purple-7',
return { label: t('admin.users.pwdStrengthMedium')
color: 'deep-orange-7',
label: this.$t('admin.users.pwdStrengthPoor')
}
case 2:
return {
color: 'purple-7',
label: this.$t('admin.users.pwdStrengthMedium')
}
case 3:
return {
color: 'blue-7',
label: this.$t('admin.users.pwdStrengthGood')
}
case 4:
return {
color: 'green-7',
label: this.$t('admin.users.pwdStrengthStrong')
}
default:
return {
color: 'negative',
label: this.$t('admin.users.pwdStrengthWeak')
}
} }
} case 3:
} return {
}, color: 'blue-7',
methods: { label: t('admin.users.pwdStrengthGood')
show () {
this.$refs.dialog.show()
},
hide () {
this.$refs.dialog.hide()
},
onDialogHide () {
this.$emit('hide')
},
randomizePassword () {
const pwdChars = 'abcdefghkmnpqrstuvwxyzABCDEFHJKLMNPQRSTUVWXYZ23456789_*=?#!()+'
this.userPassword = sampleSize(pwdChars, 16).join('')
},
async save () {
this.isLoading = true
try {
const isFormValid = await this.$refs.changeUserPwdForm.validate(true)
if (!isFormValid) {
throw new Error(this.$t('admin.users.createInvalidData'))
} }
const resp = await this.$apollo.mutate({ case 4:
mutation: gql` return {
mutation adminUpdateUserPwd ( color: 'green-7',
$id: UUID! label: t('admin.users.pwdStrengthStrong')
$patch: UserUpdateInput! }
) { default:
updateUser ( return {
id: $id color: 'negative',
patch: $patch label: t('admin.users.pwdStrengthWeak')
) { }
status { }
succeeded }
message })
}
} // VALIDATION RULES
}
`, const userPasswordValidation = [
variables: { val => val.length > 0 || t('admin.users.passwordMissing'),
id: this.userId, val => val.length >= 8 || t('admin.users.passwordTooShort')
patch: { ]
newPassword: this.userPassword,
mustChangePassword: this.userMustChangePassword // METHODS
function randomizePassword () {
const pwdChars = 'abcdefghkmnpqrstuvwxyzABCDEFHJKLMNPQRSTUVWXYZ23456789_*=?#!()+'
state.userPassword = sampleSize(pwdChars, 16).join('')
}
async function save () {
state.isLoading = true
try {
const isFormValid = await changeUserPwdForm.value.validate(true)
if (!isFormValid) {
throw new Error(t('admin.users.createInvalidData'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation adminUpdateUserPwd (
$id: UUID!
$patch: UserUpdateInput!
) {
updateUser (
id: $id
patch: $patch
) {
operation {
succeeded
message
} }
} }
})
if (resp?.data?.updateUser?.status?.succeeded) {
this.$q.notify({
type: 'positive',
message: this.$t('admin.users.createSuccess')
})
this.$emit('ok', {
mustChangePassword: this.userMustChangePassword
})
this.hide()
} else {
throw new Error(resp?.data?.updateUser?.status?.message || 'An unexpected error occured.')
} }
} catch (err) { `,
this.$q.notify({ variables: {
type: 'negative', id: props.userId,
message: err.message patch: {
}) newPassword: state.userPassword,
mustChangePassword: state.userMustChangePassword
}
} }
this.isLoading = false })
if (resp?.data?.updateUser?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('admin.users.createSuccess')
})
onDialogOK({
mustChangePassword: state.userMustChangePassword
})
} else {
throw new Error(resp?.data?.updateUser?.operation?.message || 'An unexpected error occured.')
} }
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
})
} }
state.isLoading = false
} }
</script> </script>
<template lang="pug"> <template lang="pug">
q-dialog(ref='dialog', @hide='onDialogHide') q-dialog(ref='dialogRef', @hide='onDialogHide')
q-card(style='min-width: 650px;') q-card(style='min-width: 650px;')
q-card-section.card-header q-card-section.card-header
q-icon(name='img:/_assets/icons/fluent-plus-plus.svg', left, size='sm') q-icon(name='img:/_assets/icons/fluent-plus-plus.svg', left, size='sm')
span {{$t(`admin.users.create`)}} span {{t(`admin.users.create`)}}
q-form.q-py-sm(ref='createUserForm', @submit='create') q-form.q-py-sm(ref='createUserForm', @submit='create')
q-item q-item
blueprint-icon(icon='person') blueprint-icon(icon='person')
q-item-section q-item-section
q-input( q-input(
outlined outlined
v-model='userName' v-model='state.userName'
dense dense
:rules=`[ :rules='userNameValidation'
val => val.length > 0 || $t('admin.users.nameMissing'),
val => /^[^<>"]+$/.test(val) || $t('admin.users.nameInvalidChars')
]`
hide-bottom-space hide-bottom-space
:label='$t(`common.field.name`)' :label='t(`common.field.name`)'
:aria-label='$t(`common.field.name`)' :aria-label='t(`common.field.name`)'
lazy-rules='ondemand' lazy-rules='ondemand'
autofocus autofocus
ref='iptName' ref='iptName'
...@@ -28,16 +25,13 @@ q-dialog(ref='dialog', @hide='onDialogHide') ...@@ -28,16 +25,13 @@ q-dialog(ref='dialog', @hide='onDialogHide')
q-item-section q-item-section
q-input( q-input(
outlined outlined
v-model='userEmail' v-model='state.userEmail'
dense dense
type='email' type='email'
:rules=`[ :rules='userEmailValidation'
val => val.length > 0 || $t('admin.users.emailMissing'),
val => /^.+\@.+\..+$/.test(val) || $t('admin.users.emailInvalid')
]`
hide-bottom-space hide-bottom-space
:label='$t(`admin.users.email`)' :label='t(`admin.users.email`)'
:aria-label='$t(`admin.users.email`)' :aria-label='t(`admin.users.email`)'
lazy-rules='ondemand' lazy-rules='ondemand'
autofocus autofocus
) )
...@@ -46,15 +40,12 @@ q-dialog(ref='dialog', @hide='onDialogHide') ...@@ -46,15 +40,12 @@ q-dialog(ref='dialog', @hide='onDialogHide')
q-item-section q-item-section
q-input( q-input(
outlined outlined
v-model='userPassword' v-model='state.userPassword'
dense dense
:rules=`[ :rules='userPasswordValidation'
val => val.length > 0 || $t('admin.users.passwordMissing'),
val => val.length >= 8 || $t('admin.users.passwordTooShort')
]`
hide-bottom-space hide-bottom-space
:label='$t(`admin.users.password`)' :label='t(`admin.users.password`)'
:aria-label='$t(`admin.users.password`)' :aria-label='t(`admin.users.password`)'
lazy-rules='ondemand' lazy-rules='ondemand'
autofocus autofocus
) )
...@@ -79,8 +70,8 @@ q-dialog(ref='dialog', @hide='onDialogHide') ...@@ -79,8 +70,8 @@ q-dialog(ref='dialog', @hide='onDialogHide')
q-item-section q-item-section
q-select( q-select(
outlined outlined
:options='groups' :options='state.groups'
v-model='userGroups' v-model='state.userGroups'
multiple multiple
map-options map-options
emit-value emit-value
...@@ -88,29 +79,26 @@ q-dialog(ref='dialog', @hide='onDialogHide') ...@@ -88,29 +79,26 @@ q-dialog(ref='dialog', @hide='onDialogHide')
option-label='name' option-label='name'
options-dense options-dense
dense dense
:rules=`[ :rules='userGroupsValidation'
val => val.length > 0 || $t('admin.users.groupsMissing')
]`
hide-bottom-space hide-bottom-space
:label='$t(`admin.users.groups`)' :label='t(`admin.users.groups`)'
:aria-label='$t(`admin.users.groups`)' :aria-label='t(`admin.users.groups`)'
lazy-rules='ondemand' lazy-rules='ondemand'
:loading='loadingGroups' :loading='state.loadingGroups'
) )
template(v-slot:selected) template(v-slot:selected)
.text-caption(v-if='userGroups.length > 1') .text-caption(v-if='state.userGroups.length > 1')
i18n-t(keypath='admin.users.groupsSelected') i18n-t(keypath='admin.users.groupsSelected')
template(#count) template(#count)
strong {{ userGroups.length }} strong {{ state.userGroups.length }}
.text-caption(v-else-if='userGroups.length === 1') .text-caption(v-else-if='state.userGroups.length === 1')
i18n-t(keypath='admin.users.groupSelected') i18n-t(keypath='admin.users.groupSelected')
template(#group) template(#group)
strong {{ selectedGroupName }} strong {{ selectedGroupName }}
span(v-else) span(v-else)
template(v-slot:option='{ itemProps, itemEvents, opt, selected, toggleOption }') template(v-slot:option='{ itemProps, opt, selected, toggleOption }')
q-item( q-item(
v-bind='itemProps' v-bind='itemProps'
v-on='itemEvents'
) )
q-item-section(side) q-item-section(side)
q-checkbox( q-checkbox(
...@@ -123,214 +111,254 @@ q-dialog(ref='dialog', @hide='onDialogHide') ...@@ -123,214 +111,254 @@ q-dialog(ref='dialog', @hide='onDialogHide')
q-item(tag='label', v-ripple) q-item(tag='label', v-ripple)
blueprint-icon(icon='password-reset') blueprint-icon(icon='password-reset')
q-item-section q-item-section
q-item-label {{$t(`admin.users.mustChangePwd`)}} q-item-label {{t(`admin.users.mustChangePwd`)}}
q-item-label(caption) {{$t(`admin.users.mustChangePwdHint`)}} q-item-label(caption) {{t(`admin.users.mustChangePwdHint`)}}
q-item-section(avatar) q-item-section(avatar)
q-toggle( q-toggle(
v-model='userMustChangePassword' v-model='state.userMustChangePassword'
color='primary' color='primary'
checked-icon='las la-check' checked-icon='las la-check'
unchecked-icon='las la-times' unchecked-icon='las la-times'
:aria-label='$t(`admin.users.mustChangePwd`)' :aria-label='t(`admin.users.mustChangePwd`)'
) )
q-item(tag='label', v-ripple) q-item(tag='label', v-ripple)
blueprint-icon(icon='email-open') blueprint-icon(icon='email-open')
q-item-section q-item-section
q-item-label {{$t(`admin.users.sendWelcomeEmail`)}} q-item-label {{t(`admin.users.sendWelcomeEmail`)}}
q-item-label(caption) {{$t(`admin.users.sendWelcomeEmailHint`)}} q-item-label(caption) {{t(`admin.users.sendWelcomeEmailHint`)}}
q-item-section(avatar) q-item-section(avatar)
q-toggle( q-toggle(
v-model='userSendWelcomeEmail' v-model='state.userSendWelcomeEmail'
color='primary' color='primary'
checked-icon='las la-check' checked-icon='las la-check'
unchecked-icon='las la-times' unchecked-icon='las la-times'
:aria-label='$t(`admin.users.sendWelcomeEmail`)' :aria-label='t(`admin.users.sendWelcomeEmail`)'
) )
q-card-actions.card-actions q-card-actions.card-actions
q-checkbox( q-checkbox(
v-model='keepOpened' v-model='state.keepOpened'
color='primary' color='primary'
:label='$t(`admin.users.createKeepOpened`)' :label='t(`admin.users.createKeepOpened`)'
size='sm' size='sm'
) )
q-space q-space
q-btn.acrylic-btn( q-btn.acrylic-btn(
flat flat
:label='$t(`common.actions.cancel`)' :label='t(`common.actions.cancel`)'
color='grey' color='grey'
padding='xs md' padding='xs md'
@click='hide' @click='onDialogCancel'
) )
q-btn( q-btn(
unelevated unelevated
:label='$t(`common.actions.create`)' :label='t(`common.actions.create`)'
color='primary' color='primary'
padding='xs md' padding='xs md'
@click='create' @click='create'
:loading='loading > 0' :loading='state.loading > 0'
) )
</template> </template>
<script> <script setup>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import sampleSize from 'lodash/sampleSize' import sampleSize from 'lodash/sampleSize'
import zxcvbn from 'zxcvbn' import zxcvbn from 'zxcvbn'
import cloneDeep from 'lodash/cloneDeep' import cloneDeep from 'lodash/cloneDeep'
import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { computed, onMounted, reactive, ref } from 'vue'
export default { // EMITS
emits: ['ok', 'hide'],
data () { defineEmits([
...useDialogPluginComponent.emits
])
// QUASAR
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
const $q = useQuasar()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
userName: '',
userEmail: '',
userPassword: '',
userGroups: [],
userMustChangePassword: false,
userSendWelcomeEmail: false,
keepOpened: false,
groups: [],
loadingGroups: false,
loading: false
})
// REFS
const createUserForm = ref(null)
const iptName = ref(null)
// COMPUTED
const passwordStrength = computed(() => {
if (state.userPassword.length < 8) {
return { return {
userName: '', color: 'negative',
userEmail: '', label: t('admin.users.pwdStrengthWeak')
userPassword: '',
userGroups: [],
userMustChangePassword: false,
userSendWelcomeEmail: false,
keepOpened: false,
groups: [],
loadingGroups: false,
loading: false
} }
}, } else {
computed: { switch (zxcvbn(state.userPassword).score) {
passwordStrength () { case 1:
if (this.userPassword.length < 8) {
return { return {
color: 'negative', color: 'deep-orange-7',
label: this.$t('admin.users.pwdStrengthWeak') label: t('admin.users.pwdStrengthPoor')
} }
} else { case 2:
switch (zxcvbn(this.userPassword).score) { return {
case 1: color: 'purple-7',
return { label: t('admin.users.pwdStrengthMedium')
color: 'deep-orange-7', }
label: this.$t('admin.users.pwdStrengthPoor') case 3:
} return {
case 2: color: 'blue-7',
return { label: t('admin.users.pwdStrengthGood')
color: 'purple-7', }
label: this.$t('admin.users.pwdStrengthMedium') case 4:
} return {
case 3: color: 'green-7',
return { label: t('admin.users.pwdStrengthStrong')
color: 'blue-7', }
label: this.$t('admin.users.pwdStrengthGood') default:
} return {
case 4: color: 'negative',
return { label: t('admin.users.pwdStrengthWeak')
color: 'green-7',
label: this.$t('admin.users.pwdStrengthStrong')
}
default:
return {
color: 'negative',
label: this.$t('admin.users.pwdStrengthWeak')
}
} }
}
},
selectedGroupName () {
return this.groups.filter(g => g.id === this.userGroups[0])[0]?.name
} }
}, }
methods: { })
async show () { const selectedGroupName = computed(() => {
this.$refs.dialog.show() return state.groups.filter(g => g.id === state.userGroups[0])[0]?.name
})
this.loading++ // VALIDATION RULES
this.loadingGroups = true
const resp = await this.$apollo.query({ const userNameValidation = [
query: gql` val => val.length > 0 || t('admin.users.nameMissing'),
query getGroupsForCreateUser { val => /^[^<>"]+$/.test(val) || t('admin.users.nameInvalidChars')
groups { ]
id
name const userEmailValidation = [
} val => val.length > 0 || t('admin.users.emailMissing'),
} val => /^.+@.+\..+$/.test(val) || t('admin.users.emailInvalid')
`, ]
fetchPolicy: 'network-only'
}) const userPasswordValidation = [
this.groups = cloneDeep(resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-0000-000000000001') ?? []) val => val.length > 0 || t('admin.users.passwordMissing'),
this.loadingGroups = false val => val.length >= 8 || t('admin.users.passwordTooShort')
this.loading-- ]
},
hide () { const userGroupsValidation = [
this.$refs.dialog.hide() val => val.length > 0 || t('admin.users.groupsMissing')
}, ]
onDialogHide () {
this.$emit('hide') // METHODS
},
randomizePassword () { async function loadGroups () {
const pwdChars = 'abcdefghkmnpqrstuvwxyzABCDEFHJKLMNPQRSTUVWXYZ23456789_*=?#!()+' state.loading++
this.userPassword = sampleSize(pwdChars, 16).join('') state.loadingGroups = true
}, const resp = await APOLLO_CLIENT.query({
async create () { query: gql`
this.loading++ query getGroupsForCreateUser {
try { groups {
const isFormValid = await this.$refs.createUserForm.validate(true) id
if (!isFormValid) { name
throw new Error(this.$t('admin.users.createInvalidData'))
} }
const resp = await this.$apollo.mutate({ }
mutation: gql` `,
mutation createUser ( fetchPolicy: 'network-only'
$name: String! })
$email: String! state.groups = cloneDeep(resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-8000-000000000001') ?? [])
$password: String! state.loadingGroups = false
$groups: [UUID]! state.loading--
$mustChangePassword: Boolean! }
$sendWelcomeEmail: Boolean!
) { function randomizePassword () {
createUser ( const pwdChars = 'abcdefghkmnpqrstuvwxyzABCDEFHJKLMNPQRSTUVWXYZ23456789_*=?#!()+'
name: $name state.userPassword = sampleSize(pwdChars, 16).join('')
email: $email }
password: $password
groups: $groups async function create () {
mustChangePassword: $mustChangePassword state.loading++
sendWelcomeEmail: $sendWelcomeEmail try {
) { const isFormValid = await createUserForm.value.validate(true)
status { if (!isFormValid) {
succeeded throw new Error(t('admin.users.createInvalidData'))
message }
} const resp = await APOLLO_CLIENT.mutate({
} mutation: gql`
mutation createUser (
$name: String!
$email: String!
$password: String!
$groups: [UUID]!
$mustChangePassword: Boolean!
$sendWelcomeEmail: Boolean!
) {
createUser (
name: $name
email: $email
password: $password
groups: $groups
mustChangePassword: $mustChangePassword
sendWelcomeEmail: $sendWelcomeEmail
) {
operation {
succeeded
message
} }
`,
variables: {
name: this.userName,
email: this.userEmail,
password: this.userPassword,
groups: this.userGroups,
mustChangePassword: this.userMustChangePassword,
sendWelcomeEmail: this.userSendWelcomeEmail
} }
})
if (resp?.data?.createUser?.status?.succeeded) {
this.$q.notify({
type: 'positive',
message: this.$t('admin.users.createSuccess')
})
if (this.keepOpened) {
this.userName = ''
this.userEmail = ''
this.userPassword = ''
this.$refs.iptName.focus()
} else {
this.$emit('ok')
this.hide()
}
} else {
throw new Error(resp?.data?.createUser?.status?.message || 'An unexpected error occured.')
} }
} catch (err) { `,
this.$q.notify({ variables: {
type: 'negative', name: state.userName,
message: err.message email: state.userEmail,
}) password: state.userPassword,
groups: state.userGroups,
mustChangePassword: state.userMustChangePassword,
sendWelcomeEmail: state.userSendWelcomeEmail
}
})
if (resp?.data?.createUser?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('admin.users.createSuccess')
})
if (state.keepOpened) {
state.userName = ''
state.userEmail = ''
state.userPassword = ''
iptName.value.focus()
} else {
onDialogOK()
} }
this.loading-- } else {
throw new Error(resp?.data?.createUser?.operation?.message || 'An unexpected error occured.')
} }
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
})
} }
state.loading--
} }
// MOUNTED
onMounted(loadGroups)
</script> </script>
...@@ -3,26 +3,26 @@ q-layout(view='hHh lpR fFf', container) ...@@ -3,26 +3,26 @@ q-layout(view='hHh lpR fFf', container)
q-header.card-header.q-px-md.q-py-sm q-header.card-header.q-px-md.q-py-sm
q-icon(name='img:/_assets/icons/fluent-account.svg', left, size='md') q-icon(name='img:/_assets/icons/fluent-account.svg', left, size='md')
div div
span {{$t(`admin.users.edit`)}} span {{t(`admin.users.edit`)}}
.text-caption {{user.name}} .text-caption {{state.user.name}}
q-space q-space
q-btn-group(push) q-btn-group(push)
q-btn( q-btn(
push push
color='grey-6' color='grey-6'
text-color='white' text-color='white'
:aria-label='$t(`common.actions.refresh`)' :aria-label='t(`common.actions.refresh`)'
icon='las la-redo-alt' icon='las la-redo-alt'
@click='load' @click='fetchUser'
:loading='loading > 0' :loading='state.loading > 0'
) )
q-tooltip(anchor='center left', self='center right') {{$t(`common.actions.refresh`)}} q-tooltip(anchor='center left', self='center right') {{t(`common.actions.refresh`)}}
q-btn( q-btn(
push push
color='white' color='white'
text-color='grey-7' text-color='grey-7'
:label='$t(`common.actions.close`)' :label='t(`common.actions.close`)'
:aria-label='$t(`common.actions.close`)' :aria-label='t(`common.actions.close`)'
icon='las la-times' icon='las la-times'
@click='close' @click='close'
) )
...@@ -30,14 +30,14 @@ q-layout(view='hHh lpR fFf', container) ...@@ -30,14 +30,14 @@ q-layout(view='hHh lpR fFf', container)
push push
color='positive' color='positive'
text-color='white' text-color='white'
:label='$t(`common.actions.save`)' :label='t(`common.actions.save`)'
:aria-label='$t(`common.actions.save`)' :aria-label='t(`common.actions.save`)'
icon='las la-check' icon='las la-check'
@click='save()' @click='save()'
:disabled='loading > 0' :disabled='state.loading > 0'
) )
q-drawer.bg-dark-6(:model-value='true', :width='250', dark) q-drawer.bg-dark-6(:model-value='true', :width='250', dark)
q-list(padding, v-if='loading < 1') q-list(padding, v-if='state.loading < 1')
q-item( q-item(
v-for='sc of sections' v-for='sc of sections'
:key='`section-` + sc.key' :key='`section-` + sc.key'
...@@ -50,111 +50,111 @@ q-layout(view='hHh lpR fFf', container) ...@@ -50,111 +50,111 @@ q-layout(view='hHh lpR fFf', container)
q-icon(:name='sc.icon', color='white') q-icon(:name='sc.icon', color='white')
q-item-section {{sc.text}} q-item-section {{sc.text}}
q-page-container q-page-container
q-page(v-if='loading > 0') q-page(v-if='state.loading > 0')
.flex.q-pa-lg.items-center .flex.q-pa-lg.items-center
q-spinner-tail(color='primary', size='32px', :thickness='2') q-spinner-tail(color='primary', size='32px', :thickness='2')
.text-caption.text-primary.q-pl-md: strong {{$t('admin.users.loading')}} .text-caption.text-primary.q-pl-md: strong {{t('admin.users.loading')}}
q-page(v-else-if='$route.params.section === `overview`') q-page(v-else-if='route.params.section === `overview`')
.q-pa-md .q-pa-md
.row.q-col-gutter-md .row.q-col-gutter-md
.col-12.col-lg-8 .col-12.col-lg-8
q-card.shadow-1.q-pb-sm q-card.shadow-1.q-pb-sm
q-card-section q-card-section
.text-subtitle1 {{$t('admin.users.profile')}} .text-subtitle1 {{t('admin.users.profile')}}
q-item q-item
blueprint-icon(icon='contact') blueprint-icon(icon='contact')
q-item-section q-item-section
q-item-label {{$t(`admin.users.name`)}} q-item-label {{t(`admin.users.name`)}}
q-item-label(caption) {{$t(`admin.users.nameHint`)}} q-item-label(caption) {{t(`admin.users.nameHint`)}}
q-item-section q-item-section
q-input( q-input(
outlined outlined
v-model='user.name' v-model='state.user.name'
dense dense
:rules=`[ :rules=`[
val => invalidCharsRegex.test(val) || $t('admin.users.nameInvalidChars') val => invalidCharsRegex.test(val) || t('admin.users.nameInvalidChars')
]` ]`
hide-bottom-space hide-bottom-space
:aria-label='$t(`admin.users.name`)' :aria-label='t(`admin.users.name`)'
) )
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item q-item
blueprint-icon(icon='envelope') blueprint-icon(icon='envelope')
q-item-section q-item-section
q-item-label {{$t(`admin.users.email`)}} q-item-label {{t(`admin.users.email`)}}
q-item-label(caption) {{$t(`admin.users.emailHint`)}} q-item-label(caption) {{t(`admin.users.emailHint`)}}
q-item-section q-item-section
q-input( q-input(
outlined outlined
v-model='user.email' v-model='state.user.email'
dense dense
:aria-label='$t(`admin.users.email`)' :aria-label='t(`admin.users.email`)'
) )
template(v-if='user.meta') template(v-if='state.user.meta')
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item q-item
blueprint-icon(icon='address') blueprint-icon(icon='address')
q-item-section q-item-section
q-item-label {{$t(`admin.users.location`)}} q-item-label {{t(`admin.users.location`)}}
q-item-label(caption) {{$t(`admin.users.locationHint`)}} q-item-label(caption) {{t(`admin.users.locationHint`)}}
q-item-section q-item-section
q-input( q-input(
outlined outlined
v-model='user.meta.location' v-model='state.user.meta.location'
dense dense
:aria-label='$t(`admin.users.location`)' :aria-label='t(`admin.users.location`)'
) )
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item q-item
blueprint-icon(icon='new-job') blueprint-icon(icon='new-job')
q-item-section q-item-section
q-item-label {{$t(`admin.users.jobTitle`)}} q-item-label {{t(`admin.users.jobTitle`)}}
q-item-label(caption) {{$t(`admin.users.jobTitleHint`)}} q-item-label(caption) {{t(`admin.users.jobTitleHint`)}}
q-item-section q-item-section
q-input( q-input(
outlined outlined
v-model='user.meta.jobTitle' v-model='state.user.meta.jobTitle'
dense dense
:aria-label='$t(`admin.users.jobTitle`)' :aria-label='t(`admin.users.jobTitle`)'
) )
q-card.shadow-1.q-pb-sm.q-mt-md(v-if='user.meta') q-card.shadow-1.q-pb-sm.q-mt-md(v-if='state.user.meta')
q-card-section q-card-section
.text-subtitle1 {{$t('admin.users.preferences')}} .text-subtitle1 {{t('admin.users.preferences')}}
q-item q-item
blueprint-icon(icon='timezone') blueprint-icon(icon='timezone')
q-item-section q-item-section
q-item-label {{$t(`admin.users.timezone`)}} q-item-label {{t(`admin.users.timezone`)}}
q-item-label(caption) {{$t(`admin.users.timezoneHint`)}} q-item-label(caption) {{t(`admin.users.timezoneHint`)}}
q-item-section q-item-section
q-select( q-select(
outlined outlined
v-model='user.prefs.timezone' v-model='state.user.prefs.timezone'
:options='timezones' :options='dataStore.timezones'
option-value='value' option-value='value'
option-label='text' option-label='text'
emit-value emit-value
map-options map-options
dense dense
options-dense options-dense
:aria-label='$t(`admin.users.timezone`)' :aria-label='t(`admin.users.timezone`)'
) )
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item q-item
blueprint-icon(icon='calendar') blueprint-icon(icon='calendar')
q-item-section q-item-section
q-item-label {{$t(`admin.users.dateFormat`)}} q-item-label {{t(`admin.users.dateFormat`)}}
q-item-label(caption) {{$t(`admin.users.dateFormatHint`)}} q-item-label(caption) {{t(`admin.users.dateFormatHint`)}}
q-item-section q-item-section
q-select( q-select(
outlined outlined
v-model='user.prefs.dateFormat' v-model='state.user.prefs.dateFormat'
emit-value emit-value
map-options map-options
dense dense
:aria-label='$t(`admin.users.dateFormat`)' :aria-label='t(`admin.users.dateFormat`)'
:options=`[ :options=`[
{ label: $t('profile.localeDefault'), value: '' }, { label: t('profile.localeDefault'), value: '' },
{ label: 'DD/MM/YYYY', value: 'DD/MM/YYYY' }, { label: 'DD/MM/YYYY', value: 'DD/MM/YYYY' },
{ label: 'DD.MM.YYYY', value: 'DD.MM.YYYY' }, { label: 'DD.MM.YYYY', value: 'DD.MM.YYYY' },
{ label: 'MM/DD/YYYY', value: 'MM/DD/YYYY' }, { label: 'MM/DD/YYYY', value: 'MM/DD/YYYY' },
...@@ -166,168 +166,168 @@ q-layout(view='hHh lpR fFf', container) ...@@ -166,168 +166,168 @@ q-layout(view='hHh lpR fFf', container)
q-item q-item
blueprint-icon(icon='clock') blueprint-icon(icon='clock')
q-item-section q-item-section
q-item-label {{$t(`admin.users.timeFormat`)}} q-item-label {{t(`admin.users.timeFormat`)}}
q-item-label(caption) {{$t(`admin.users.timeFormatHint`)}} q-item-label(caption) {{t(`admin.users.timeFormatHint`)}}
q-item-section.col-auto q-item-section.col-auto
q-btn-toggle( q-btn-toggle(
v-model='user.prefs.timeFormat' v-model='state.user.prefs.timeFormat'
push push
glossy glossy
no-caps no-caps
toggle-color='primary' toggle-color='primary'
:options=`[ :options=`[
{ label: $t('profile.timeFormat12h'), value: '12h' }, { label: t('profile.timeFormat12h'), value: '12h' },
{ label: $t('profile.timeFormat24h'), value: '24h' } { label: t('profile.timeFormat24h'), value: '24h' }
]` ]`
) )
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item(tag='label', v-ripple) q-item(tag='label', v-ripple)
blueprint-icon(icon='light-on') blueprint-icon(icon='light-on')
q-item-section q-item-section
q-item-label {{$t(`admin.users.darkMode`)}} q-item-label {{t(`admin.users.darkMode`)}}
q-item-label(caption) {{$t(`admin.users.darkModeHint`)}} q-item-label(caption) {{t(`admin.users.darkModeHint`)}}
q-item-section(avatar) q-item-section(avatar)
q-toggle( q-toggle(
v-model='user.prefs.darkMode' v-model='state.user.prefs.darkMode'
color='primary' color='primary'
checked-icon='las la-check' checked-icon='las la-check'
unchecked-icon='las la-times' unchecked-icon='las la-times'
:aria-label='$t(`admin.users.darkMode`)' :aria-label='t(`admin.users.darkMode`)'
) )
.col-12.col-lg-4 .col-12.col-lg-4
q-card.shadow-1.q-pb-sm q-card.shadow-1.q-pb-sm
q-card-section q-card-section
.text-subtitle1 {{$t('admin.users.info')}} .text-subtitle1 {{t('admin.users.info')}}
q-item q-item
blueprint-icon(icon='person', :hue-rotate='-45') blueprint-icon(icon='person', :hue-rotate='-45')
q-item-section q-item-section
q-item-label {{$t(`common.field.id`)}} q-item-label {{t(`common.field.id`)}}
q-item-label: strong {{userId}} q-item-label: strong {{state.user.id}}
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item q-item
blueprint-icon(icon='calendar-plus', :hue-rotate='-45') blueprint-icon(icon='calendar-plus', :hue-rotate='-45')
q-item-section q-item-section
q-item-label {{$t(`common.field.createdOn`)}} q-item-label {{t(`common.field.createdOn`)}}
q-item-label: strong {{humanizeDate(user.createdAt)}} q-item-label: strong {{humanizeDate(state.user.createdAt)}}
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item q-item
blueprint-icon(icon='summertime', :hue-rotate='-45') blueprint-icon(icon='summertime', :hue-rotate='-45')
q-item-section q-item-section
q-item-label {{$t(`common.field.lastUpdated`)}} q-item-label {{t(`common.field.lastUpdated`)}}
q-item-label: strong {{humanizeDate(user.updatedAt)}} q-item-label: strong {{humanizeDate(state.user.updatedAt)}}
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item q-item
blueprint-icon(icon='enter', :hue-rotate='-45') blueprint-icon(icon='enter', :hue-rotate='-45')
q-item-section q-item-section
q-item-label {{$t(`admin.users.lastLoginAt`)}} q-item-label {{t(`admin.users.lastLoginAt`)}}
q-item-label: strong {{humanizeDate(user.lastLoginAt)}} q-item-label: strong {{humanizeDate(state.user.lastLoginAt)}}
q-card.shadow-1.q-pb-sm.q-mt-md(v-if='user.meta') q-card.shadow-1.q-pb-sm.q-mt-md(v-if='state.user.meta')
q-card-section q-card-section
.text-subtitle1 {{$t('admin.users.notes')}} .text-subtitle1 {{t('admin.users.notes')}}
q-input.q-mt-sm( q-input.q-mt-sm(
outlined outlined
v-model='user.meta.notes' v-model='state.user.meta.notes'
type='textarea' type='textarea'
:aria-label='$t(`admin.users.notes`)' :aria-label='t(`admin.users.notes`)'
input-style='min-height: 243px' input-style='min-height: 243px'
:hint='$t(`admin.users.noteHint`)' :hint='t(`admin.users.noteHint`)'
) )
q-page(v-else-if='$route.params.section === `activity`') q-page(v-else-if='route.params.section === `activity`')
span --- span ---
q-page(v-else-if='$route.params.section === `auth`') q-page(v-else-if='route.params.section === `auth`')
.q-pa-md .q-pa-md
.row.q-col-gutter-md .row.q-col-gutter-md
.col-12.col-lg-7 .col-12.col-lg-7
q-card.shadow-1.q-pb-sm q-card.shadow-1.q-pb-sm
q-card-section q-card-section
.text-subtitle1 {{$t('admin.users.passAuth')}} .text-subtitle1 {{t('admin.users.passAuth')}}
q-item q-item
blueprint-icon(icon='password', :hue-rotate='45') blueprint-icon(icon='password', :hue-rotate='45')
q-item-section q-item-section
q-item-label {{$t(`admin.users.changePassword`)}} q-item-label {{t(`admin.users.changePassword`)}}
q-item-label(caption) {{$t(`admin.users.changePasswordHint`)}} q-item-label(caption) {{t(`admin.users.changePasswordHint`)}}
q-item-label(caption): strong(:class='localAuth.password ? `text-positive` : `text-negative`') {{localAuth.password ? $t(`admin.users.pwdSet`) : $t(`admin.users.pwdNotSet`)}} q-item-label(caption): strong(:class='localAuth.password ? `text-positive` : `text-negative`') {{localAuth.password ? t(`admin.users.pwdSet`) : t(`admin.users.pwdNotSet`)}}
q-item-section(side) q-item-section(side)
q-btn.acrylic-btn( q-btn.acrylic-btn(
flat flat
icon='las la-arrow-circle-right' icon='las la-arrow-circle-right'
color='primary' color='primary'
@click='changePassword' @click='changePassword'
:label='$t(`common.actions.proceed`)' :label='t(`common.actions.proceed`)'
) )
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item(tag='label', v-ripple) q-item(tag='label', v-ripple)
blueprint-icon(icon='password-reset') blueprint-icon(icon='password-reset')
q-item-section q-item-section
q-item-label {{$t(`admin.users.mustChangePwd`)}} q-item-label {{t(`admin.users.mustChangePwd`)}}
q-item-label(caption) {{$t(`admin.users.mustChangePwdHint`)}} q-item-label(caption) {{t(`admin.users.mustChangePwdHint`)}}
q-item-section(avatar) q-item-section(avatar)
q-toggle( q-toggle(
v-model='localAuth.mustChangePwd' v-model='localAuth.mustChangePwd'
color='primary' color='primary'
checked-icon='las la-check' checked-icon='las la-check'
unchecked-icon='las la-times' unchecked-icon='las la-times'
:aria-label='$t(`admin.users.mustChangePwd`)' :aria-label='t(`admin.users.mustChangePwd`)'
) )
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item(tag='label', v-ripple) q-item(tag='label', v-ripple)
blueprint-icon(icon='key') blueprint-icon(icon='key')
q-item-section q-item-section
q-item-label {{$t(`admin.users.pwdAuthRestrict`)}} q-item-label {{t(`admin.users.pwdAuthRestrict`)}}
q-item-label(caption) {{$t(`admin.users.pwdAuthRestrictHint`)}} q-item-label(caption) {{t(`admin.users.pwdAuthRestrictHint`)}}
q-item-section(avatar) q-item-section(avatar)
q-toggle( q-toggle(
v-model='localAuth.restrictLogin' v-model='localAuth.restrictLogin'
color='primary' color='primary'
checked-icon='las la-check' checked-icon='las la-check'
unchecked-icon='las la-times' unchecked-icon='las la-times'
:aria-label='$t(`admin.users.pwdAuthRestrict`)' :aria-label='t(`admin.users.pwdAuthRestrict`)'
) )
q-card.shadow-1.q-pb-sm.q-mt-md q-card.shadow-1.q-pb-sm.q-mt-md
q-card-section q-card-section
.text-subtitle1 {{$t('admin.users.tfa')}} .text-subtitle1 {{t('admin.users.tfa')}}
q-item(tag='label', v-ripple) q-item(tag='label', v-ripple)
blueprint-icon(icon='key') blueprint-icon(icon='key')
q-item-section q-item-section
q-item-label {{$t(`admin.users.tfaRequired`)}} q-item-label {{t(`admin.users.tfaRequired`)}}
q-item-label(caption) {{$t(`admin.users.tfaRequiredHint`)}} q-item-label(caption) {{t(`admin.users.tfaRequiredHint`)}}
q-item-section(avatar) q-item-section(avatar)
q-toggle( q-toggle(
v-model='localAuth.tfaRequired' v-model='localAuth.tfaRequired'
color='primary' color='primary'
checked-icon='las la-check' checked-icon='las la-check'
unchecked-icon='las la-times' unchecked-icon='las la-times'
:aria-label='$t(`admin.users.tfaRequired`)' :aria-label='t(`admin.users.tfaRequired`)'
) )
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item q-item
blueprint-icon(icon='password', :hue-rotate='45') blueprint-icon(icon='password', :hue-rotate='45')
q-item-section q-item-section
q-item-label {{$t(`admin.users.tfaInvalidate`)}} q-item-label {{t(`admin.users.tfaInvalidate`)}}
q-item-label(caption) {{$t(`admin.users.tfaInvalidateHint`)}} q-item-label(caption) {{t(`admin.users.tfaInvalidateHint`)}}
q-item-label(caption): strong(:class='localAuth.tfaSecret ? `text-positive` : `text-negative`') {{localAuth.tfaSecret ? $t(`admin.users.tfaSet`) : $t(`admin.users.tfaNotSet`)}} q-item-label(caption): strong(:class='localAuth.tfaSecret ? `text-positive` : `text-negative`') {{localAuth.tfaSecret ? t(`admin.users.tfaSet`) : t(`admin.users.tfaNotSet`)}}
q-item-section(side) q-item-section(side)
q-btn.acrylic-btn( q-btn.acrylic-btn(
flat flat
icon='las la-arrow-circle-right' icon='las la-arrow-circle-right'
color='primary' color='primary'
@click='invalidateTFA' @click='invalidateTFA'
:label='$t(`common.actions.proceed`)' :label='t(`common.actions.proceed`)'
) )
.col-12.col-lg-5 .col-12.col-lg-5
q-card.shadow-1.q-pb-sm q-card.shadow-1.q-pb-sm
q-card-section q-card-section
.text-subtitle1 {{$t('admin.users.linkedProviders')}} .text-subtitle1 {{t('admin.users.linkedProviders')}}
q-banner.q-mt-md( q-banner.q-mt-md(
v-if='linkedAuthProviders.length < 1' v-if='linkedAuthProviders.length < 1'
rounded rounded
:class='$q.dark.isActive ? `bg-negative text-white` : `bg-grey-2 text-grey-7`' :class='$q.dark.isActive ? `bg-negative text-white` : `bg-grey-2 text-grey-7`'
) {{$t('admin.users.noLinkedProviders')}} ) {{t('admin.users.noLinkedProviders')}}
template( template(
v-for='(prv, idx) in linkedAuthProviders' v-for='(prv, idx) in linkedAuthProviders'
:key='prv._id' :key='prv._id'
...@@ -339,15 +339,15 @@ q-layout(view='hHh lpR fFf', container) ...@@ -339,15 +339,15 @@ q-layout(view='hHh lpR fFf', container)
q-item-label {{prv._moduleName}} q-item-label {{prv._moduleName}}
q-item-label(caption) {{prv.key}} q-item-label(caption) {{prv.key}}
q-page(v-else-if='$route.params.section === `groups`') q-page(v-else-if='route.params.section === `groups`')
.q-pa-md .q-pa-md
.row.q-col-gutter-md .row.q-col-gutter-md
.col-12.col-lg-8 .col-12.col-lg-8
q-card.shadow-1.q-pb-sm q-card.shadow-1.q-pb-sm
q-card-section q-card-section
.text-subtitle1 {{$t('admin.users.groups')}} .text-subtitle1 {{t('admin.users.groups')}}
template( template(
v-for='(grp, idx) of user.groups' v-for='(grp, idx) of state.user.groups'
:key='grp.id' :key='grp.id'
) )
q-separator.q-my-sm(inset, v-if='idx > 0') q-separator.q-my-sm(inset, v-if='idx > 0')
...@@ -361,17 +361,17 @@ q-layout(view='hHh lpR fFf', container) ...@@ -361,17 +361,17 @@ q-layout(view='hHh lpR fFf', container)
icon='las la-times' icon='las la-times'
color='accent' color='accent'
@click='unassignGroup(grp.id)' @click='unassignGroup(grp.id)'
:aria-label='$t(`admin.users.unassignGroup`)' :aria-label='t(`admin.users.unassignGroup`)'
) )
q-tooltip(anchor='center left' self='center right') {{$t('admin.users.unassignGroup')}} q-tooltip(anchor='center left' self='center right') {{t('admin.users.unassignGroup')}}
q-card.shadow-1.q-py-sm.q-mt-md q-card.shadow-1.q-py-sm.q-mt-md
q-item q-item
blueprint-icon(icon='join') blueprint-icon(icon='join')
q-item-section q-item-section
q-select( q-select(
outlined outlined
:options='groups' :options='state.groups'
v-model='groupToAdd' v-model='state.groupToAdd'
map-options map-options
emit-value emit-value
option-value='id' option-value='id'
...@@ -379,33 +379,33 @@ q-layout(view='hHh lpR fFf', container) ...@@ -379,33 +379,33 @@ q-layout(view='hHh lpR fFf', container)
options-dense options-dense
dense dense
hide-bottom-space hide-bottom-space
:label='$t(`admin.users.groups`)' :label='t(`admin.users.groups`)'
:aria-label='$t(`admin.users.groups`)' :aria-label='t(`admin.users.groups`)'
:loading='loading > 0' :loading='state.loading > 0'
) )
q-item-section(side) q-item-section(side)
q-btn( q-btn(
unelevated unelevated
icon='las la-plus' icon='las la-plus'
:label='$t(`admin.users.assignGroup`)' :label='t(`admin.users.assignGroup`)'
color='primary' color='primary'
@click='assignGroup' @click='assignGroup'
) )
q-page(v-else-if='$route.params.section === `metadata`') q-page(v-else-if='route.params.section === `metadata`')
.q-pa-md .q-pa-md
.row.q-col-gutter-md .row.q-col-gutter-md
.col-12.col-lg-8 .col-12.col-lg-8
q-card.shadow-1.q-pb-sm q-card.shadow-1.q-pb-sm
q-card-section.flex.items-center q-card-section.flex.items-center
.text-subtitle1 {{$t('admin.users.metadata')}} .text-subtitle1 {{t('admin.users.metadata')}}
q-space q-space
q-badge( q-badge(
v-if='metadataInvalidJSON' v-if='state.metadataInvalidJSON'
color='negative' color='negative'
) )
q-icon.q-mr-xs(name='las la-exclamation-triangle', size='20px') q-icon.q-mr-xs(name='las la-exclamation-triangle', size='20px')
span {{$t('admin.users.invalidJSON')}} span {{t('admin.users.invalidJSON')}}
q-badge.q-py-xs( q-badge.q-py-xs(
v-else v-else
label='JSON' label='JSON'
...@@ -413,80 +413,79 @@ q-layout(view='hHh lpR fFf', container) ...@@ -413,80 +413,79 @@ q-layout(view='hHh lpR fFf', container)
) )
q-item q-item
q-item-section q-item-section
q-no-ssr(:placeholder='$t(`common.loading`)') q-no-ssr(:placeholder='t(`common.loading`)')
util-code-editor.admin-theme-cm( codemirror.metadata-codemirror(
v-model='metadata' v-model='metadata'
language='json' :extensions='[json()]'
:min-height='500'
) )
q-page(v-else-if='$route.params.section === `operations`') q-page(v-else-if='route.params.section === `operations`')
.q-pa-md .q-pa-md
.row.q-col-gutter-md .row.q-col-gutter-md
.col-12.col-lg-8 .col-12.col-lg-8
q-card.shadow-1.q-pb-sm q-card.shadow-1.q-pb-sm
q-card-section q-card-section
.text-subtitle1 {{$t('admin.users.operations')}} .text-subtitle1 {{t('admin.users.operations')}}
q-item q-item
blueprint-icon(icon='email-open', :hue-rotate='45') blueprint-icon(icon='email-open', :hue-rotate='45')
q-item-section q-item-section
q-item-label {{$t(`admin.users.sendWelcomeEmail`)}} q-item-label {{t(`admin.users.sendWelcomeEmail`)}}
q-item-label(caption) {{$t(`admin.users.sendWelcomeEmailAltHint`)}} q-item-label(caption) {{t(`admin.users.sendWelcomeEmailAltHint`)}}
q-item-section(side) q-item-section(side)
q-btn.acrylic-btn( q-btn.acrylic-btn(
flat flat
icon='las la-arrow-circle-right' icon='las la-arrow-circle-right'
color='primary' color='primary'
@click='sendWelcomeEmail' @click='sendWelcomeEmail'
:label='$t(`common.actions.proceed`)' :label='t(`common.actions.proceed`)'
) )
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item q-item
blueprint-icon(icon='apply', :hue-rotate='45') blueprint-icon(icon='apply', :hue-rotate='45')
q-item-section q-item-section
q-item-label {{user.isVerified ? $t(`admin.users.unverify`) : $t(`admin.users.verify`)}} q-item-label {{state.user.isVerified ? t(`admin.users.unverify`) : t(`admin.users.verify`)}}
q-item-label(caption) {{user.isVerified ? $t(`admin.users.unverifyHint`) : $t(`admin.users.verifyHint`)}} q-item-label(caption) {{state.user.isVerified ? t(`admin.users.unverifyHint`) : t(`admin.users.verifyHint`)}}
q-item-label(caption): strong(:class='user.isVerified ? `text-positive` : `text-negative`') {{user.isVerified ? $t(`admin.users.verified`) : $t(`admin.users.unverified`)}} q-item-label(caption): strong(:class='state.user.isVerified ? `text-positive` : `text-negative`') {{state.user.isVerified ? t(`admin.users.verified`) : t(`admin.users.unverified`)}}
q-item-section(side) q-item-section(side)
q-btn.acrylic-btn( q-btn.acrylic-btn(
flat flat
icon='las la-arrow-circle-right' icon='las la-arrow-circle-right'
color='primary' color='primary'
@click='toggleVerified' @click='toggleVerified'
:label='$t(`common.actions.proceed`)' :label='t(`common.actions.proceed`)'
) )
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item q-item
blueprint-icon(icon='unfriend', :hue-rotate='45') blueprint-icon(icon='unfriend', :hue-rotate='45')
q-item-section q-item-section
q-item-label {{user.isActive ? $t(`admin.users.ban`) : $t(`admin.users.unban`)}} q-item-label {{state.user.isActive ? t(`admin.users.ban`) : t(`admin.users.unban`)}}
q-item-label(caption) {{user.isActive ? $t(`admin.users.banHint`) : $t(`admin.users.unbanHint`)}} q-item-label(caption) {{state.user.isActive ? t(`admin.users.banHint`) : t(`admin.users.unbanHint`)}}
q-item-label(caption): strong(:class='user.isActive ? `text-positive` : `text-negative`') {{user.isActive ? $t(`admin.users.active`) : $t(`admin.users.banned`)}} q-item-label(caption): strong(:class='state.user.isActive ? `text-positive` : `text-negative`') {{state.user.isActive ? t(`admin.users.active`) : t(`admin.users.banned`)}}
q-item-section(side) q-item-section(side)
q-btn.acrylic-btn( q-btn.acrylic-btn(
flat flat
icon='las la-arrow-circle-right' icon='las la-arrow-circle-right'
color='primary' color='primary'
@click='toggleBan' @click='toggleBan'
:label='$t(`common.actions.proceed`)' :label='t(`common.actions.proceed`)'
) )
q-card.shadow-1.q-py-sm.q-mt-md q-card.shadow-1.q-py-sm.q-mt-md
q-item q-item
blueprint-icon(icon='denied', :hue-rotate='140') blueprint-icon(icon='denied', :hue-rotate='140')
q-item-section q-item-section
q-item-label {{$t(`admin.users.delete`)}} q-item-label {{t(`admin.users.delete`)}}
q-item-label(caption) {{$t(`admin.users.deleteHint`)}} q-item-label(caption) {{t(`admin.users.deleteHint`)}}
q-item-section(side) q-item-section(side)
q-btn.acrylic-btn( q-btn.acrylic-btn(
flat flat
icon='las la-arrow-circle-right' icon='las la-arrow-circle-right'
color='negative' color='negative'
@click='deleteUser' @click='deleteUser'
:label='$t(`common.actions.proceed`)' :label='t(`common.actions.proceed`)'
) )
</template> </template>
<script> <script setup>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import cloneDeep from 'lodash/cloneDeep' import cloneDeep from 'lodash/cloneDeep'
import some from 'lodash/some' import some from 'lodash/some'
...@@ -494,284 +493,339 @@ import find from 'lodash/find' ...@@ -494,284 +493,339 @@ import find from 'lodash/find'
import findKey from 'lodash/findKey' import findKey from 'lodash/findKey'
import _get from 'lodash/get' import _get from 'lodash/get'
import map from 'lodash/map' import map from 'lodash/map'
import { get } from 'vuex-pathify'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import UtilCodeEditor from './UtilCodeEditor.vue'
import { useI18n } from 'vue-i18n'
import { useQuasar } from 'quasar'
import { computed, onMounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAdminStore } from 'src/stores/admin'
import { useDataStore } from 'src/stores/data'
import UserChangePwdDialog from './UserChangePwdDialog.vue' import UserChangePwdDialog from './UserChangePwdDialog.vue'
import { Codemirror } from 'vue-codemirror'
import { json } from '@codemirror/lang-json'
// import { oneDark } from '@codemirror/theme-one-dark'
// QUASAR
const $q = useQuasar()
// STORES
const adminStore = useAdminStore()
const dataStore = useDataStore()
export default { // ROUTER
components: {
UtilCodeEditor const router = useRouter()
const route = useRoute()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
invalidCharsRegex: /^[^<>"]+$/,
user: {
meta: {},
prefs: {},
groups: []
}, },
data () { groups: [],
return { groupToAdd: null,
invalidCharsRegex: /^[^<>"]+$/, loading: 0,
sections: [ metadataInvalidJSON: false
{ key: 'overview', text: this.$t('admin.users.overview'), icon: 'las la-user' }, })
{ key: 'activity', text: this.$t('admin.users.activity'), icon: 'las la-chart-area' },
{ key: 'auth', text: this.$t('admin.users.auth'), icon: 'las la-key' }, const sections = [
{ key: 'groups', text: this.$t('admin.users.groups'), icon: 'las la-users' }, { key: 'overview', text: t('admin.users.overview'), icon: 'las la-user' },
{ key: 'metadata', text: this.$t('admin.users.metadata'), icon: 'las la-clipboard-list' }, { key: 'activity', text: t('admin.users.activity'), icon: 'las la-chart-area' },
{ key: 'operations', text: this.$t('admin.users.operations'), icon: 'las la-tools' } { key: 'auth', text: t('admin.users.auth'), icon: 'las la-key' },
], { key: 'groups', text: t('admin.users.groups'), icon: 'las la-users' },
user: { { key: 'metadata', text: t('admin.users.metadata'), icon: 'las la-clipboard-list' },
meta: {}, { key: 'operations', text: t('admin.users.operations'), icon: 'las la-tools' }
prefs: {}, ]
groups: []
}, // COMPUTED
groups: [],
groupToAdd: null, const metadata = computed({
loading: 0, get () { return JSON.stringify(state.user.meta ?? {}, null, 2) },
metadataInvalidJSON: false set (val) {
try {
state.user.meta = JSON.parse(val)
state.metadataInvalidJSON = false
} catch (err) {
state.metadataInvalidJSON = true
} }
}
})
const localAuthId = computed(() => {
return findKey(state.user.auth, ['module', 'local'])
})
const localAuth = computed({
get () {
return localAuthId.value ? _get(state.user.auth, localAuthId.value, {}) : {}
}, },
computed: { set (val) {
timezones: get('data/timezones', false), if (localAuthId.value) {
userId: get('admin/overlayOpts@id', false), state.user.auth[localAuthId.value] = val
metadata: {
get () { return JSON.stringify(this.user.meta ?? {}, null, 2) },
set (val) {
try {
this.user.meta = JSON.parse(val)
this.metadataInvalidJSON = false
} catch (err) {
this.metadataInvalidJSON = true
}
}
},
localAuthId () {
return findKey(this.user.auth, ['module', 'local'])
},
localAuth: {
get () {
return this.localAuthId ? _get(this.user.auth, this.localAuthId, {}) : {}
},
set (val) {
if (this.localAuthId) {
this.user.auth[this.localAuthId] = val
}
}
},
linkedAuthProviders () {
if (!this.user?.auth) { return [] }
return map(this.user.auth, (obj, key) => {
return {
...obj,
_id: key
}
}).filter(prv => prv.module !== 'local')
} }
}, }
watch: { })
$route: 'checkRoute'
}, const linkedAuthProviders = computed(() => {
mounted () { if (!state.user?.auth) { return [] }
this.checkRoute()
this.load() return map(state.user.auth, (obj, key) => {
}, return {
methods: { ...obj,
async load () { _id: key
this.loading++ }
this.$q.loading.show() }).filter(prv => prv.module !== 'local')
try { })
const resp = await this.$apollo.query({
query: gql` // WATCHERS
query adminFetchUser (
$id: UUID! watch(() => route.params.section, checkRoute)
) {
groups { // METHODS
id
name async function fetchUser () {
} state.loading++
userById( $q.loading.show()
id: $id try {
) { const resp = await APOLLO_CLIENT.query({
id query: gql`
email query adminFetchUser (
name $id: UUID!
isSystem ) {
isVerified groups {
isActive id
auth name
meta }
prefs userById(
lastLoginAt id: $id
createdAt ) {
updatedAt id
groups { email
id name
name isSystem
} isVerified
} isActive
auth
meta
prefs
lastLoginAt
createdAt
updatedAt
groups {
id
name
} }
`, }
variables: {
id: this.userId
},
fetchPolicy: 'network-only'
})
this.groups = resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-0000-000000000001') ?? []
if (resp?.data?.userById) {
this.user = cloneDeep(resp.data.userById)
} else {
throw new Error('An unexpected error occured while fetching user details.')
}
} catch (err) {
this.$q.notify({
type: 'negative',
message: err.message
})
}
this.$q.loading.hide()
this.loading--
},
close () {
this.$store.set('admin/overlay', '')
},
checkRoute () {
if (!this.$route.params.section) {
this.$router.replace({ params: { section: 'overview' } })
}
if (this.$route.params.section === 'metadata') {
this.metadataInvalidJSON = false
}
},
humanizeDate (val) {
if (!val) { return '---' }
return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_FULL)
},
assignGroup () {
if (!this.groupToAdd) {
this.$q.notify({
type: 'negative',
message: this.$t('admin.users.noGroupSelected')
})
} else if (some(this.user.groups, gr => gr.id === this.groupToAdd)) {
this.$q.notify({
type: 'warning',
message: this.$t('admin.users.groupAlreadyAssigned')
})
} else {
const newGroup = find(this.groups, ['id', this.groupToAdd])
this.user.groups = [...this.user.groups, newGroup]
}
},
unassignGroup (id) {
if (this.user.groups.length <= 1) {
this.$q.notify({
type: 'negative',
message: this.$t('admin.users.minimumGroupRequired')
})
} else {
this.user.groups = this.user.groups.filter(gr => gr.id === id)
}
},
async save (patch, { silent, keepOpen } = { silent: false, keepOpen: false }) {
this.$q.loading.show()
if (!patch) {
patch = {
name: this.user.name,
email: this.user.email,
isVerified: this.user.isVerified,
isActive: this.user.isActive,
meta: this.user.meta,
prefs: this.user.prefs,
groups: this.user.groups.map(gr => gr.id)
} }
} `,
try { variables: {
const resp = await this.$apollo.mutate({ id: adminStore.overlayOpts.id
mutation: gql` },
mutation adminSaveUser ( fetchPolicy: 'network-only'
$id: UUID! })
$patch: UserUpdateInput! state.groups = resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-0000-000000000001') ?? []
) { if (resp?.data?.userById) {
updateUser ( state.user = cloneDeep(resp.data.userById)
id: $id } else {
patch: $patch throw new Error('An unexpected error occured while fetching user details.')
) { }
status { } catch (err) {
succeeded $q.notify({
message type: 'negative',
} message: err.message
} })
}
$q.loading.hide()
state.loading--
}
function close () {
adminStore.$patch({ overlay: '' })
}
function checkRoute () {
if (!route.params.section) {
router.replace({ params: { section: 'overview' } })
}
if (route.params.section === 'metadata') {
state.metadataInvalidJSON = false
}
}
function humanizeDate (val) {
if (!val) { return '---' }
return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_FULL)
}
function assignGroup () {
if (!state.groupToAdd) {
$q.notify({
type: 'negative',
message: t('admin.users.noGroupSelected')
})
} else if (some(state.user.groups, gr => gr.id === state.groupToAdd)) {
$q.notify({
type: 'warning',
message: t('admin.users.groupAlreadyAssigned')
})
} else {
const newGroup = find(state.groups, ['id', state.groupToAdd])
state.user.groups = [...state.user.groups, newGroup]
}
}
function unassignGroup (id) {
if (state.user.groups.length <= 1) {
$q.notify({
type: 'negative',
message: t('admin.users.minimumGroupRequired')
})
} else {
state.user.groups = state.user.groups.filter(gr => gr.id === id)
}
}
async function save (patch, { silent, keepOpen } = { silent: false, keepOpen: false }) {
$q.loading.show()
if (!patch) {
patch = {
name: state.user.name,
email: state.user.email,
isVerified: state.user.isVerified,
isActive: state.user.isActive,
meta: state.user.meta,
prefs: state.user.prefs,
groups: state.user.groups.map(gr => gr.id)
}
}
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation adminSaveUser (
$id: UUID!
$patch: UserUpdateInput!
) {
updateUser (
id: $id
patch: $patch
) {
operation {
succeeded
message
} }
`,
variables: {
id: this.userId,
patch
} }
})
if (resp?.data?.updateUser?.status?.succeeded) {
if (!silent) {
this.$q.notify({
type: 'positive',
message: this.$t('admin.users.saveSuccess')
})
}
if (!keepOpen) {
this.close()
}
} else {
throw new Error(resp?.data?.updateUser?.status?.message || 'An unexpected error occured.')
} }
} catch (err) { `,
this.$q.notify({ variables: {
type: 'negative', id: adminStore.overlayOpts.id,
message: err.message patch
})
} }
this.$q.loading.hide() })
}, if (resp?.data?.updateUser?.operation?.succeeded) {
changePassword () { if (!silent) {
this.$q.dialog({ $q.notify({
component: UserChangePwdDialog,
componentProps: {
userId: this.userId
}
}).onOk(({ mustChangePassword }) => {
this.localAuth = {
...this.localAuth,
mustChangePwd: mustChangePassword
}
})
},
invalidateTFA () {
this.$q.dialog({
title: this.$t('admin.users.tfaInvalidate'),
message: this.$t('admin.users.tfaInvalidateConfirm'),
cancel: true,
persistent: true,
ok: {
label: this.$t('common.actions.confirm')
}
}).onOk(() => {
this.localAuth.tfaSecret = ''
this.$q.notify({
type: 'positive', type: 'positive',
message: this.$t('admin.users.tfaInvalidateSuccess') message: t('admin.users.saveSuccess')
}) })
}) }
}, if (!keepOpen) {
async sendWelcomeEmail () { close()
}
}, } else {
toggleVerified () { throw new Error(resp?.data?.updateUser?.operation?.message || 'An unexpected error occured.')
this.user.isVerified = !this.user.isVerified
this.save({
isVerified: this.user.isVerified
}, { silent: true, keepOpen: true })
},
toggleBan () {
this.user.isActive = !this.user.isActive
this.save({
isActive: this.user.isActive
}, { silent: true, keepOpen: true })
},
async deleteUser () {
} }
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
})
} }
$q.loading.hide()
}
function changePassword () {
$q.dialog({
component: UserChangePwdDialog,
componentProps: {
userId: adminStore.overlayOpts.id
}
}).onOk(({ mustChangePassword }) => {
localAuth.value = {
...localAuth.value,
mustChangePwd: mustChangePassword
}
})
}
function invalidateTFA () {
$q.dialog({
title: t('admin.users.tfaInvalidate'),
message: t('admin.users.tfaInvalidateConfirm'),
cancel: true,
persistent: true,
ok: {
label: t('common.actions.confirm')
}
}).onOk(() => {
localAuth.value.tfaSecret = ''
$q.notify({
type: 'positive',
message: t('admin.users.tfaInvalidateSuccess')
})
})
}
async function sendWelcomeEmail () {
} }
function toggleVerified () {
state.user.isVerified = !state.user.isVerified
save({
isVerified: state.user.isVerified
}, { silent: true, keepOpen: true })
}
function toggleBan () {
state.user.isActive = !state.user.isActive
save({
isActive: state.user.isActive
}, { silent: true, keepOpen: true })
}
async function deleteUser () {
}
// MOUNTED
onMounted(() => {
checkRoute()
fetchUser()
})
</script> </script>
<style lang="scss" scoped>
.metadata-codemirror {
&:deep(.cm-editor) {
height: 150px;
min-height: 100px;
border-radius: 5px;
border: 1px solid #CCC;
}
}
</style>
...@@ -168,6 +168,8 @@ const headers = [ ...@@ -168,6 +168,8 @@ const headers = [
} }
] ]
// WATCHERS
watch(() => adminStore.overlay, (newValue, oldValue) => { watch(() => adminStore.overlay, (newValue, oldValue) => {
if (newValue === '' && oldValue === 'GroupEditOverlay') { if (newValue === '' && oldValue === 'GroupEditOverlay') {
router.push('/_admin/groups') router.push('/_admin/groups')
...@@ -175,9 +177,7 @@ watch(() => adminStore.overlay, (newValue, oldValue) => { ...@@ -175,9 +177,7 @@ watch(() => adminStore.overlay, (newValue, oldValue) => {
} }
}) })
watch(() => route.params.id, () => { watch(() => route.params.id, checkOverlay)
checkOverlay()
})
// METHODS // METHODS
......
...@@ -4,12 +4,12 @@ q-page.admin-groups ...@@ -4,12 +4,12 @@ q-page.admin-groups
.col-auto .col-auto
img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-account.svg') img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-account.svg')
.col.q-pl-md .col.q-pl-md
.text-h5.text-primary.animated.fadeInLeft {{ $t('admin.users.title') }} .text-h5.text-primary.animated.fadeInLeft {{ t('admin.users.title') }}
.text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ $t('admin.users.subtitle') }} .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.users.subtitle') }}
.col-auto.flex.items-center .col-auto.flex.items-center
q-input.denser.q-mr-sm( q-input.denser.q-mr-sm(
outlined outlined
v-model='search' v-model='state.search'
dense dense
:class='$q.dark.isActive ? `bg-dark` : `bg-white`' :class='$q.dark.isActive ? `bg-dark` : `bg-white`'
) )
...@@ -28,29 +28,29 @@ q-page.admin-groups ...@@ -28,29 +28,29 @@ q-page.admin-groups
flat flat
color='secondary' color='secondary'
@click='load' @click='load'
:loading='loading > 0' :loading='state.loading > 0'
) )
q-btn( q-btn(
unelevated unelevated
icon='las la-plus' icon='las la-plus'
:label='$t(`admin.users.create`)' :label='t(`admin.users.create`)'
color='primary' color='primary'
@click='createUser' @click='createUser'
:disabled='loading > 0' :disabled='state.loading > 0'
) )
q-separator(inset) q-separator(inset)
.row.q-pa-md.q-col-gutter-md .row.q-pa-md.q-col-gutter-md
.col-12 .col-12
q-card.shadow-1 q-card.shadow-1
q-table( q-table(
:rows='users' :rows='state.users'
:columns='headers' :columns='headers'
row-key='id' row-key='id'
flat flat
hide-header hide-header
hide-bottom hide-bottom
:rows-per-page-options='[0]' :rows-per-page-options='[0]'
:loading='loading > 0' :loading='state.loading > 0'
) )
template(v-slot:body-cell-id='props') template(v-slot:body-cell-id='props')
q-td(:props='props') q-td(:props='props')
...@@ -92,7 +92,7 @@ q-page.admin-groups ...@@ -92,7 +92,7 @@ q-page.admin-groups
:to='`/_admin/users/` + props.row.id' :to='`/_admin/users/` + props.row.id'
icon='las la-pen' icon='las la-pen'
color='indigo' color='indigo'
:label='$t(`common.actions.edit`)' :label='t(`common.actions.edit`)'
no-caps no-caps
) )
q-btn.acrylic-btn( q-btn.acrylic-btn(
...@@ -100,146 +100,178 @@ q-page.admin-groups ...@@ -100,146 +100,178 @@ q-page.admin-groups
flat flat
icon='las la-trash' icon='las la-trash'
color='accent' color='accent'
@click='deleteGroup(props.row)' @click='deleteUser(props.row)'
) )
</template> </template>
<script> <script setup>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import cloneDeep from 'lodash/cloneDeep' import cloneDeep from 'lodash/cloneDeep'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { sync } from 'vuex-pathify' import { useI18n } from 'vue-i18n'
import { createMetaMixin } from 'quasar' import { useMeta, useQuasar } from 'quasar'
import { onBeforeUnmount, onMounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAdminStore } from 'src/stores/admin'
import UserCreateDialog from '../components/UserCreateDialog.vue' import UserCreateDialog from '../components/UserCreateDialog.vue'
export default { // QUASAR
mixins: [
createMetaMixin(function () { const $q = useQuasar()
return {
title: this.$t('admin.users.title') // STORES
}
}) const adminStore = useAdminStore()
],
data () { // ROUTER
return {
users: [], const router = useRouter()
loading: 0, const route = useRoute()
search: ''
} // I18N
},
computed: { const { t } = useI18n()
overlay: sync('admin/overlay', false),
headers () { // META
return [
{ useMeta({
align: 'center', title: t('admin.users.title')
field: 'id', })
name: 'id',
sortable: false, // DATA
style: 'width: 20px'
}, const state = reactive({
{ users: [],
label: this.$t('common.field.name'), loading: 0,
align: 'left', search: ''
field: 'name', })
name: 'name',
sortable: true const headers = [
}, {
{ align: 'center',
label: this.$t('admin.users.email'), field: 'id',
align: 'left', name: 'id',
field: 'email', sortable: false,
name: 'email', style: 'width: 20px'
sortable: false
},
{
align: 'left',
field: 'createdAt',
name: 'date',
sortable: false
},
{
label: '',
align: 'right',
field: 'edit',
name: 'edit',
sortable: false,
style: 'width: 250px'
}
]
}
}, },
watch: { {
overlay (newValue, oldValue) { label: t('common.field.name'),
if (newValue === '' && oldValue === 'UserEditOverlay') { align: 'left',
this.$router.push('/_admin/users') field: 'name',
this.load() name: 'name',
} sortable: true
},
$route: 'checkOverlay'
}, },
mounted () { {
this.checkOverlay() label: t('admin.users.email'),
this.load() align: 'left',
field: 'email',
name: 'email',
sortable: false
}, },
beforeUnmount () { {
this.overlay = '' align: 'left',
field: 'createdAt',
name: 'date',
sortable: false
}, },
methods: { {
async load () { label: '',
this.loading++ align: 'right',
this.$q.loading.show() field: 'edit',
const resp = await this.$apollo.query({ name: 'edit',
query: gql` sortable: false,
query getUsers { style: 'width: 250px'
users { }
id ]
name
email // WATCHERS
isSystem
isActive watch(() => adminStore.overlay, (newValue, oldValue) => {
createdAt if (newValue === '' && oldValue === 'UserEditOverlay') {
lastLoginAt router.push('/_admin/users')
} load()
} }
`, })
fetchPolicy: 'network-only'
}) watch(() => route.params.id, checkOverlay)
this.users = cloneDeep(resp?.data?.users)
this.$q.loading.hide() // METHODS
this.loading--
}, async function load () {
humanizeDate (val) { state.loading++
return DateTime.fromISO(val).toRelative() $q.loading.show()
}, const resp = await APOLLO_CLIENT.query({
checkOverlay () { query: gql`
if (this.$route.params && this.$route.params.id) { query getUsers {
this.$store.set('admin/overlayOpts', { id: this.$route.params.id }) users {
this.$store.set('admin/overlay', 'UserEditOverlay') id
} else { name
this.$store.set('admin/overlay', '') email
} isSystem
}, isActive
createUser () { createdAt
this.$q.dialog({ lastLoginAt
component: UserCreateDialog
}).onOk(() => {
this.load()
})
},
deleteUser (gr) {
this.$q.dialog({
// component: UserDeleteDialog,
componentProps: {
group: gr
} }
}).onOk(() => { }
this.load() `,
}) fetchPolicy: 'network-only'
} })
state.users = cloneDeep(resp?.data?.users)
$q.loading.hide()
state.loading--
}
function humanizeDate (val) {
return DateTime.fromISO(val).toRelative()
}
function checkOverlay () {
if (route.params?.id) {
adminStore.$patch({
overlayOpts: { id: route.params.id },
overlay: 'UserEditOverlay'
})
} else {
adminStore.$patch({
overlay: ''
})
} }
} }
function createUser () {
$q.dialog({
component: UserCreateDialog
}).onOk(() => {
this.load()
})
}
function deleteUser (usr) {
$q.dialog({
// component: UserDeleteDialog,
componentProps: {
user: usr
}
}).onOk(load)
}
// MOUNTED
onMounted(() => {
checkOverlay()
load()
})
// BEFORE UNMOUNT
onBeforeUnmount(() => {
adminStore.$patch({
overlay: ''
})
})
</script> </script>
<style lang='scss'> <style lang='scss'>
......
...@@ -42,7 +42,7 @@ const routes = [ ...@@ -42,7 +42,7 @@ const routes = [
// -> Users // -> Users
// { path: 'auth', component: () => import('../pages/AdminAuth.vue') }, // { path: 'auth', component: () => import('../pages/AdminAuth.vue') },
{ path: 'groups/:id?/:section?', component: () => import('../pages/AdminGroups.vue') }, { path: 'groups/:id?/:section?', component: () => import('../pages/AdminGroups.vue') },
// { path: 'users/:id?/:section?', component: () => import('../pages/AdminUsers.vue') }, { path: 'users/:id?/:section?', component: () => import('../pages/AdminUsers.vue') },
// -> System // -> System
// { path: 'api', component: () => import('../pages/AdminApi.vue') }, // { path: 'api', component: () => import('../pages/AdminApi.vue') },
{ path: 'extensions', component: () => import('../pages/AdminExtensions.vue') }, { path: 'extensions', component: () => import('../pages/AdminExtensions.vue') },
......
This diff was suppressed by a .gitattributes entry.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment