feat: profile (avatar, auth and groups) pages + various fixes

parent acc3b736
...@@ -400,14 +400,14 @@ router.get(['/t', '/t/*'], (req, res, next) => { ...@@ -400,14 +400,14 @@ router.get(['/t', '/t/*'], (req, res, next) => {
/** /**
* User Avatar * User Avatar
*/ */
router.get('/_userav/:uid', async (req, res, next) => { router.get('/_user/:uid/avatar', async (req, res, next) => {
if (!WIKI.auth.checkAccess(req.user, ['read:pages'])) { if (!WIKI.auth.checkAccess(req.user, ['read:pages'])) {
return res.sendStatus(403) return res.sendStatus(403)
} }
const av = await WIKI.db.users.getUserAvatarData(req.params.uid) const av = await WIKI.db.users.getUserAvatarData(req.params.uid)
if (av) { if (av) {
res.set('Content-Type', 'image/jpeg') res.set('Content-Type', 'image/jpeg')
res.send(av) return res.send(av)
} }
return res.sendStatus(404) return res.sendStatus(404)
......
...@@ -300,7 +300,7 @@ exports.up = async knex => { ...@@ -300,7 +300,7 @@ exports.up = async knex => {
table.jsonb('auth').notNullable().defaultTo('{}') table.jsonb('auth').notNullable().defaultTo('{}')
table.jsonb('meta').notNullable().defaultTo('{}') table.jsonb('meta').notNullable().defaultTo('{}')
table.jsonb('prefs').notNullable().defaultTo('{}') table.jsonb('prefs').notNullable().defaultTo('{}')
table.string('pictureUrl') table.boolean('hasAvatar').notNullable().defaultTo(false)
table.boolean('isSystem').notNullable().defaultTo(false) table.boolean('isSystem').notNullable().defaultTo(false)
table.boolean('isActive').notNullable().defaultTo(false) table.boolean('isActive').notNullable().defaultTo(false)
table.boolean('isVerified').notNullable().defaultTo(false) table.boolean('isVerified').notNullable().defaultTo(false)
......
const graphHelper = require('../../helpers/graph') const graphHelper = require('../../helpers/graph')
const _ = require('lodash') const _ = require('lodash')
const path = require('node:path')
const fs = require('fs-extra')
module.exports = { module.exports = {
Query: { Query: {
...@@ -273,9 +275,88 @@ module.exports = { ...@@ -273,9 +275,88 @@ module.exports = {
} catch (err) { } catch (err) {
return graphHelper.generateError(err) return graphHelper.generateError(err)
} }
},
/**
* UPLOAD USER AVATAR
*/
async uploadUserAvatar (obj, args) {
try {
const { filename, mimetype, createReadStream } = await args.image
WIKI.logger.debug(`Processing user ${args.id} avatar ${filename} of type ${mimetype}...`)
if (!WIKI.extensions.ext.sharp.isInstalled) {
throw new Error('This feature requires the Sharp extension but it is not installed.')
}
if (!['.png', '.jpg', '.webp', '.gif'].some(s => filename.endsWith(s))) {
throw new Error('Invalid File Extension. Must be png, jpg, webp or gif.')
}
const destFolder = path.resolve(
process.cwd(),
WIKI.config.dataPath,
`assets`
)
const destPath = path.join(destFolder, `userav-${args.id}.jpg`)
await fs.ensureDir(destFolder)
// -> Resize
await WIKI.extensions.ext.sharp.resize({
format: 'jpg',
inputStream: createReadStream(),
outputPath: destPath,
width: 180,
height: 180
})
// -> Set avatar flag for this user in the DB
await WIKI.db.users.query().findById(args.id).patch({ hasAvatar: true })
// -> Save image data to DB
const imgBuffer = await fs.readFile(destPath)
await WIKI.db.knex('userAvatars').insert({
id: args.id,
data: imgBuffer
}).onConflict('id').merge()
WIKI.logger.debug(`Processed user ${args.id} avatar successfully.`)
return {
operation: graphHelper.generateSuccess('User avatar uploaded successfully')
}
} catch (err) {
WIKI.logger.warn(err)
return graphHelper.generateError(err)
}
},
/**
* CLEAR USER AVATAR
*/
async clearUserAvatar (obj, args) {
try {
WIKI.logger.debug(`Clearing user ${args.id} avatar...`)
await WIKI.db.users.query().findById(args.id).patch({ hasAvatar: false })
await WIKI.db.knex('userAvatars').where({ id: args.id }).del()
WIKI.logger.debug(`Cleared user ${args.id} avatar successfully.`)
return {
operation: graphHelper.generateSuccess('User avatar cleared successfully')
}
} catch (err) {
WIKI.logger.warn(err)
return graphHelper.generateError(err)
}
} }
}, },
User: { User: {
async auth (usr) {
const authStrategies = await WIKI.db.authentication.getStrategies({ enabledOnly: true })
return _.transform(usr.auth, (result, value, key) => {
const authStrategy = _.find(authStrategies, ['id', key])
const authModule = _.find(WIKI.data.authentication, ['key', authStrategy.module])
if (!authStrategy || !authModule) { return }
result.push({
authId: key,
authName: authStrategy.displayName,
strategyKey: authStrategy.module,
strategyIcon: authModule.icon,
config: authStrategy.module === 'local' ? {
isTfaSetup: value.tfaSecret?.length > 0
} : {}
})
}, [])
},
groups (usr) { groups (usr) {
return usr.$relatedQuery('groups') return usr.$relatedQuery('groups')
} }
......
...@@ -73,6 +73,15 @@ extend type Mutation { ...@@ -73,6 +73,15 @@ extend type Mutation {
timeFormat: String timeFormat: String
appearance: UserSiteAppearance appearance: UserSiteAppearance
): DefaultResponse ): DefaultResponse
uploadUserAvatar(
id: UUID!
image: Upload!
): DefaultResponse
clearUserAvatar(
id: UUID!
): DefaultResponse
} }
# ----------------------------------------------- # -----------------------------------------------
...@@ -104,19 +113,27 @@ type User { ...@@ -104,19 +113,27 @@ type User {
id: UUID id: UUID
name: String name: String
email: String email: String
auth: JSON auth: [UserAuth]
hasAvatar: Boolean
isSystem: Boolean isSystem: Boolean
isActive: Boolean isActive: Boolean
isVerified: Boolean isVerified: Boolean
meta: JSON meta: JSON
prefs: JSON prefs: JSON
pictureUrl: String
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
lastLoginAt: Date lastLoginAt: Date
groups: [Group] groups: [Group]
} }
type UserAuth {
authId: UUID
authName: String
strategyKey: String
strategyIcon: String
config: JSON
}
type UserTokenResponse { type UserTokenResponse {
operation: Operation operation: Operation
jwt: String jwt: String
......
...@@ -105,7 +105,8 @@ module.exports = configure(function (/* ctx */) { ...@@ -105,7 +105,8 @@ module.exports = configure(function (/* ctx */) {
port: 3001, port: 3001,
proxy: { proxy: {
'/_graphql': 'http://localhost:3000/_graphql', '/_graphql': 'http://localhost:3000/_graphql',
'/_site': 'http://localhost:3000' '/_site': 'http://localhost:3000',
'/_user': 'http://localhost:3000'
} }
}, },
......
<template lang='pug'> <template lang='pug'>
q-btn.q-ml-md(flat, round, dense, color='grey') q-btn.q-ml-md(flat, round, dense, color='grey')
q-icon(v-if='!userStore.authenticated || !userStore.pictureUrl', name='las la-user-circle') q-icon(
q-avatar(v-else) v-if='!userStore.authenticated || !userStore.hasAvatar'
img(:src='userStore.pictureUrl') name='las la-user-circle'
)
q-avatar(
v-else
size='32px'
)
img(:src='`/_user/` + userStore.id + `/avatar`')
q-menu(auto-close) q-menu(auto-close)
q-card(flat, style='width: 300px;', :dark='false') q-card(flat, style='width: 300px;', :dark='false')
q-card-section(align='center') q-card-section(align='center')
...@@ -12,24 +18,32 @@ q-btn.q-ml-md(flat, round, dense, color='grey') ...@@ -12,24 +18,32 @@ q-btn.q-ml-md(flat, round, dense, color='grey')
q-card-actions(align='center') q-card-actions(align='center')
q-btn( q-btn(
flat flat
label='Profile' :label='t(`common.header.profile`)'
icon='las la-user-alt' icon='las la-user-alt'
color='primary' color='primary'
to='/_profile' to='/_profile'
no-caps no-caps
) )
q-btn(flat q-btn(flat
label='Logout' :label='t(`common.header.logout`)'
icon='las la-sign-out-alt' icon='las la-sign-out-alt'
color='red' color='red'
href='/logout' href='/logout'
no-caps no-caps
) )
q-tooltip Account q-tooltip {{ t('common.header.account') }}
</template> </template>
<script setup> <script setup>
import { useI18n } from 'vue-i18n'
import { useUserStore } from 'src/stores/user' import { useUserStore } from 'src/stores/user'
// STORES
const userStore = useUserStore() const userStore = useUserStore()
// I18N
const { t } = useI18n()
</script> </script>
...@@ -50,6 +50,7 @@ const { t } = useI18n() ...@@ -50,6 +50,7 @@ const { t } = useI18n()
// METHODS // METHODS
function create (editor) { function create (editor) {
pageStore.pageCreate({ editor }) window.location.assign('/_edit/new')
// pageStore.pageCreate({ editor })
} }
</script> </script>
...@@ -318,13 +318,17 @@ const quickaccess = [ ...@@ -318,13 +318,17 @@ const quickaccess = [
{ key: 'refCardVisibility', icon: 'las la-eye', label: t('editor.props.visibility') } { key: 'refCardVisibility', icon: 'las la-eye', label: t('editor.props.visibility') }
] ]
// REFS
const iptPagePassword = ref(null)
// WATCHERS // WATCHERS
watch(() => state.requirePassword, (newValue) => { watch(() => state.requirePassword, (newValue) => {
if (newValue) { if (newValue) {
nextTick(() => { nextTick(() => {
this.$refs.iptPagePassword.focus() iptPagePassword.value.focus()
this.$refs.iptPagePassword.$el.scrollIntoView({ iptPagePassword.value.$el.scrollIntoView({
behavior: 'smooth' behavior: 'smooth'
}) })
}) })
......
...@@ -11,6 +11,7 @@ q-layout(view='hHh Lpr lff') ...@@ -11,6 +11,7 @@ q-layout(view='hHh Lpr lff')
clickable clickable
:to='`/_profile/` + navItem.key' :to='`/_profile/` + navItem.key'
active-class='is-active' active-class='is-active'
:disabled='navItem.disabled'
v-ripple v-ripple
) )
q-item-section(side) q-item-section(side)
...@@ -21,12 +22,12 @@ q-layout(view='hHh Lpr lff') ...@@ -21,12 +22,12 @@ q-layout(view='hHh Lpr lff')
q-item( q-item(
clickable clickable
v-ripple v-ripple
to='/_profile/me' :to='`/_user/` + userStore.id'
) )
q-item-section(side) q-item-section(side)
q-icon(name='las la-id-card') q-icon(name='las la-id-card')
q-item-section q-item-section
q-item-label View Public Profile q-item-label {{ t('profile.viewPublicProfile') }}
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item( q-item(
clickable clickable
...@@ -36,7 +37,7 @@ q-layout(view='hHh Lpr lff') ...@@ -36,7 +37,7 @@ q-layout(view='hHh Lpr lff')
q-item-section(side) q-item-section(side)
q-icon(name='las la-sign-out-alt', color='negative') q-icon(name='las la-sign-out-alt', color='negative')
q-item-section q-item-section
q-item-label.text-negative Logout q-item-label.text-negative {{ t('common.header.logout') }}
router-view router-view
q-footer q-footer
q-bar.justify-center(dense) q-bar.justify-center(dense)
...@@ -44,13 +45,12 @@ q-layout(view='hHh Lpr lff') ...@@ -44,13 +45,12 @@ q-layout(view='hHh Lpr lff')
</template> </template>
<script setup> <script setup>
import gql from 'graphql-tag'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar' import { useMeta, useQuasar } from 'quasar'
import { onMounted, reactive, watch } from 'vue' import { onMounted, reactive, watch } from 'vue'
import { useSiteStore } from 'src/stores/site' import { useSiteStore } from 'src/stores/site'
import { useUserStore } from 'src/stores/user'
import HeaderNav from '../components/HeaderNav.vue' import HeaderNav from '../components/HeaderNav.vue'
...@@ -61,62 +61,60 @@ const $q = useQuasar() ...@@ -61,62 +61,60 @@ const $q = useQuasar()
// STORES // STORES
const siteStore = useSiteStore() const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N // I18N
const { t } = useI18n() const { t } = useI18n()
// META
useMeta({
titleTemplate: title => `${title} - ${t('profile.title')} - Wiki.js`
})
// DATA // DATA
const sidenav = [ const sidenav = [
{ {
key: 'info', key: 'info',
label: 'Profile', label: t('profile.title'),
icon: 'las la-user-circle' icon: 'las la-user-circle'
}, },
{ {
key: 'avatar', key: 'avatar',
label: 'Avatar', label: t('profile.avatar'),
icon: 'las la-otter' icon: 'las la-otter'
}, },
{ {
key: 'password', key: 'auth',
label: 'Authentication', label: t('profile.auth'),
icon: 'las la-key' icon: 'las la-key'
}, },
{ {
key: 'groups', key: 'groups',
label: 'Groups', label: t('profile.groups'),
icon: 'las la-users' icon: 'las la-users'
}, },
{ {
key: 'notifications', key: 'notifications',
label: 'Notifications', label: t('profile.notifications'),
icon: 'las la-bell' icon: 'las la-bell',
}, disabled: true
{
key: 'pages',
label: 'My Pages',
icon: 'las la-file-alt'
}, },
// {
// key: 'pages',
// label: 'My Pages',
// icon: 'las la-file-alt',
// disabled: true
// },
{ {
key: 'activity', key: 'activity',
label: 'Activity', label: t('profile.activity'),
icon: 'las la-history' icon: 'las la-history',
disabled: true
} }
] ]
const thumbStyle = {
right: '2px',
borderRadius: '5px',
backgroundColor: '#FFF',
width: '5px',
opacity: 0.5
}
const barStyle = {
backgroundColor: '#000',
width: '9px',
opacity: 0.1
}
</script> </script>
<style lang="scss"> <style lang="scss">
......
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">
404
</div>
<div class="text-h2" style="opacity:.4">
Oops. Nothing here...
</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps
/>
</div>
</div>
</template>
<script>
export default {
name: 'Error404'
}
</script>
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
.errorpage-hint {{error.hint}} .errorpage-hint {{error.hint}}
.errorpage-actions .errorpage-actions
q-btn( q-btn(
v-if='error.showHomeBtn'
push push
color='primary' color='primary'
label='Go to home' label='Go to home'
...@@ -38,6 +39,10 @@ const actions = { ...@@ -38,6 +39,10 @@ const actions = {
notfound: { notfound: {
code: 404 code: 404
}, },
unknownsite: {
code: 'X!?',
showHomeBtn: false
},
generic: { generic: {
code: '!?0' code: '!?0'
} }
...@@ -62,12 +67,14 @@ useMeta({ ...@@ -62,12 +67,14 @@ useMeta({
const error = computed(() => { const error = computed(() => {
if (route.params.action && actions[route.params.action]) { if (route.params.action && actions[route.params.action]) {
return { return {
showHomeBtn: true,
...actions[route.params.action], ...actions[route.params.action],
title: t(`common.error.${route.params.action}.title`), title: t(`common.error.${route.params.action}.title`),
hint: t(`common.error.${route.params.action}.hint`) hint: t(`common.error.${route.params.action}.hint`)
} }
} else { } else {
return { return {
showHomeBtn: true,
...actions.generic, ...actions.generic,
title: t('common.error.generic.title'), title: t('common.error.generic.title'),
hint: t('common.error.generic.hint') hint: t('common.error.generic.hint')
......
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">
404
</div>
<div class="text-h2" style="opacity:.4">
Oops. Nothing here...
</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps
/>
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'ErrorNotFound'
})
</script>
<template lang="pug">
q-page.q-py-md(:style-fn='pageStyle')
.text-header {{t('profile.auth')}}
.q-pa-md
.text-body2 {{ t('profile.authInfo') }}
q-list.q-mt-lg(
bordered
separator
)
q-item(
v-for='auth of state.authMethods'
:key='auth.id'
)
q-item-section(avatar)
q-avatar(
color='dark-5'
text-color='white'
rounded
)
q-icon(:name='`img:` + auth.strategyIcon')
q-item-section
strong {{auth.authName}}
template(v-if='auth.strategyKey === `local`')
q-item-section(v-if='auth.config.isTfaSetup', side)
q-btn(
icon='las la-fingerprint'
unelevated
:label='t(`profile.authModifyTfa`)'
color='primary'
@click=''
)
q-item-section(v-else, side)
q-btn(
icon='las la-fingerprint'
unelevated
:label='t(`profile.authSetTfa`)'
color='primary'
@click=''
)
q-item-section(side)
q-btn(
icon='las la-key'
unelevated
:label='t(`profile.authChangePassword`)'
color='primary'
@click=''
)
q-inner-loading(:showing='state.loading > 0')
</template>
<script setup>
import gql from 'graphql-tag'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { onMounted, reactive } from 'vue'
import { useUserStore } from 'src/stores/user'
// QUASAR
const $q = useQuasar()
// STORES
const userStore = useUserStore()
// I18N
const { t } = useI18n()
// META
useMeta({
title: t('profile.auth')
})
// DATA
const state = reactive({
authMethods: [],
loading: 0
})
// METHODS
function pageStyle (offset, height) {
return {
'min-height': `${height - 100 - offset}px`
}
}
async function fetchAuthMethods () {
state.loading++
try {
const respRaw = await APOLLO_CLIENT.query({
query: gql`
query getUserProfileAuthMethods (
$id: UUID!
) {
userById (
id: $id
) {
id
auth {
authId
authName
strategyKey
strategyIcon
config
}
}
}
`,
variables: {
id: userStore.id
},
fetchPolicy: 'network-only'
})
state.authMethods = respRaw.data?.userById?.auth ?? []
} catch (err) {
$q.notify({
type: 'negative',
message: t('profile.authLoadingFailed'),
caption: err.message
})
}
state.loading--
}
// MOUNTED
onMounted(() => {
fetchAuthMethods()
})
</script>
<template lang="pug">
q-page.q-py-md(:style-fn='pageStyle')
.text-header {{t('profile.avatar')}}
.row.q-gutter-lg.q-mt-xl.align-center
.col.text-center
q-avatar.profile-avatar-circ(
size='180px'
:color='userStore.hasAvatar ? `dark-1` : `primary`'
text-color='white'
:class='userStore.hasAvatar ? `is-image` : ``'
)
img(
v-if='userStore.hasAvatar',
:src='`/_user/` + userStore.id + `/avatar?` + state.assetTimestamp'
)
q-icon(
v-else,
name='las la-user'
)
.col
.text-body1 {{ t('profile.avatarUploadTitle') }}
.text-caption {{ t('profile.avatarUploadHint') }}
.q-mt-md
q-btn(
icon='las la-upload'
unelevated
:label='t(`profile.uploadNewAvatar`)'
color='primary'
@click='uploadImage'
)
.q-mt-md
q-btn.q-mr-sm(
icon='las la-times'
outline
:label='t(`common.actions.clear`)'
color='primary'
@click='clearImage'
:disable='!userStore.hasAvatar'
)
q-inner-loading(:showing='state.loading > 0')
</template>
<script setup>
import gql from 'graphql-tag'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { reactive } from 'vue'
import { useUserStore } from 'src/stores/user'
// QUASAR
const $q = useQuasar()
// STORES
const userStore = useUserStore()
// I18N
const { t } = useI18n()
// META
useMeta({
title: t('profile.avatar')
})
// DATA
const state = reactive({
loading: 0,
assetTimestamp: (new Date()).toISOString()
})
// METHODS
function pageStyle (offset, height) {
return {
'min-height': `${height - 100 - offset}px`
}
}
async function uploadImage () {
const input = document.createElement('input')
input.type = 'file'
input.onchange = async e => {
state.loading++
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation uploadUserAvatar (
$id: UUID!
$image: Upload!
) {
uploadUserAvatar (
id: $id
image: $image
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: userStore.id,
image: e.target.files[0]
}
})
if (resp?.data?.uploadUserAvatar?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('profile.avatarUploadSuccess')
})
state.assetTimestamp = (new Date()).toISOString()
userStore.$patch({
hasAvatar: true
})
} else {
throw new Error(resp?.data?.uploadUserAvatar?.operation?.message || 'An unexpected error occured.')
}
} catch (err) {
$q.notify({
type: 'negative',
message: t('profile.avatarUploadFailed'),
caption: err.message
})
}
state.loading--
}
input.click()
}
async function clearImage () {
state.loading++
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation clearUserAvatar (
$id: UUID!
) {
clearUserAvatar (
id: $id
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: userStore.id
}
})
if (resp?.data?.clearUserAvatar?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('profile.avatarClearSuccess')
})
state.assetTimestamp = (new Date()).toISOString()
userStore.$patch({
hasAvatar: false
})
} else {
throw new Error(resp?.data?.uploadUserAvatar?.operation?.message || 'An unexpected error occured.')
}
} catch (err) {
$q.notify({
type: 'negative',
message: t('profile.avatarClearFailed'),
caption: err.message
})
}
state.loading--
}
</script>
<style lang="scss">
.profile-avatar-circ {
box-shadow: 2px 2px 15px -5px var(--q-primary), -2px -2px 15px -5px var(--q-primary), inset 0 0 2px 8px rgba(255,255,255,.15);
&.is-image {
box-shadow: 0 0 0 5px rgba(0,0,0,.1);
}
}
</style>
<template lang="pug">
q-page.q-py-md(:style-fn='pageStyle')
.text-header {{t('profile.groups')}}
.q-pa-md
.text-body2 {{ t('profile.groupsInfo') }}
q-list.q-mt-lg(
bordered
separator
)
q-item(
v-if='state.groups.length === 0 && state.loading < 1'
)
q-item-section
span.text-negative {{ t('profile.groupsNone') }}
q-item(
v-for='grp of state.groups'
:key='grp.id'
)
q-item-section(avatar)
q-avatar(
color='secondary'
text-color='white'
icon='las la-users'
rounded
)
q-item-section
strong {{grp.name}}
q-inner-loading(:showing='state.loading > 0')
</template>
<script setup>
import gql from 'graphql-tag'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { onMounted, reactive } from 'vue'
import { useUserStore } from 'src/stores/user'
// QUASAR
const $q = useQuasar()
// STORES
const userStore = useUserStore()
// I18N
const { t } = useI18n()
// META
useMeta({
title: t('profile.avatar')
})
// DATA
const state = reactive({
groups: [],
loading: 0
})
// METHODS
function pageStyle (offset, height) {
return {
'min-height': `${height - 100 - offset}px`
}
}
async function fetchGroups () {
state.loading++
try {
const respRaw = await APOLLO_CLIENT.query({
query: gql`
query getUserProfileGroups (
$id: UUID!
) {
userById (
id: $id
) {
id
groups {
id
name
}
}
}
`,
variables: {
id: userStore.id
},
fetchPolicy: 'network-only'
})
state.groups = respRaw.data?.userById?.groups ?? []
} catch (err) {
$q.notify({
type: 'negative',
message: t('profile.groupsLoadingFailed'),
caption: err.message
})
}
state.loading--
}
// MOUNTED
onMounted(() => {
fetchGroups()
})
</script>
...@@ -80,11 +80,10 @@ q-page.q-py-md(:style-fn='pageStyle') ...@@ -80,11 +80,10 @@ q-page.q-py-md(:style-fn='pageStyle')
q-select( q-select(
outlined outlined
v-model='state.config.timezone' v-model='state.config.timezone'
:options='dataStore.timezones' :options='timezones'
option-value='value' :virtual-scroll-slice-size='100'
option-label='text' :virtual-scroll-slice-ratio-before='2'
emit-value :virtual-scroll-slice-ratio-after='2'
map-options
dense dense
options-dense options-dense
:aria-label='t(`admin.general.defaultTimezone`)' :aria-label='t(`admin.general.defaultTimezone`)'
...@@ -153,7 +152,6 @@ import { useMeta, useQuasar } from 'quasar' ...@@ -153,7 +152,6 @@ import { useMeta, useQuasar } from 'quasar'
import { onMounted, reactive, watch } from 'vue' import { onMounted, reactive, watch } from 'vue'
import { useSiteStore } from 'src/stores/site' import { useSiteStore } from 'src/stores/site'
import { useDataStore } from 'src/stores/data'
import { useUserStore } from 'src/stores/user' import { useUserStore } from 'src/stores/user'
// QUASAR // QUASAR
...@@ -163,7 +161,6 @@ const $q = useQuasar() ...@@ -163,7 +161,6 @@ const $q = useQuasar()
// STORES // STORES
const siteStore = useSiteStore() const siteStore = useSiteStore()
const dataStore = useDataStore()
const userStore = useUserStore() const userStore = useUserStore()
// I18N // I18N
...@@ -173,7 +170,7 @@ const { t } = useI18n() ...@@ -173,7 +170,7 @@ const { t } = useI18n()
// META // META
useMeta({ useMeta({
title: t('profile.title') title: t('profile.myInfo')
}) })
// DATA // DATA
...@@ -209,6 +206,7 @@ const appearances = [ ...@@ -209,6 +206,7 @@ const appearances = [
{ value: 'light', label: t('profile.appearanceLight') }, { value: 'light', label: t('profile.appearanceLight') },
{ value: 'dark', label: t('profile.appearanceDark') } { value: 'dark', label: t('profile.appearanceDark') }
] ]
const timezones = Intl.supportedValuesOf('timeZone')
// METHODS // METHODS
......
<template lang='pug'>
.fullscreen.bg-blue.text-white.text-center.q-pa-md.flex.flex-center
div
.text-h1 Unknown Site
.text-h2(style="opacity:.4") Oops. Nothing here...
q-btn(
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps
)
</template>
<script>
export default {
name: 'UnknownSite'
}
</script>
...@@ -20,7 +20,10 @@ const routes = [ ...@@ -20,7 +20,10 @@ const routes = [
component: () => import('layouts/ProfileLayout.vue'), component: () => import('layouts/ProfileLayout.vue'),
children: [ children: [
{ path: '', redirect: '/_profile/info' }, { path: '', redirect: '/_profile/info' },
{ path: 'info', component: () => import('pages/Profile.vue') } { path: 'info', component: () => import('src/pages/ProfileInfo.vue') },
{ path: 'avatar', component: () => import('src/pages/ProfileAvatar.vue') },
{ path: 'auth', component: () => import('src/pages/ProfileAuth.vue') },
{ path: 'groups', component: () => import('src/pages/ProfileGroups.vue') }
] ]
}, },
{ {
...@@ -70,8 +73,16 @@ const routes = [ ...@@ -70,8 +73,16 @@ const routes = [
// component: () => import('../pages/UnknownSite.vue') // component: () => import('../pages/UnknownSite.vue')
// }, // },
// Always leave this as last one, // --------------------------------
// but you can also remove it // SYSTEM ROUTES CATCH-ALL FALLBACK
// --------------------------------
{
path: '/_:catchAll(.*)*',
redirect: '/_error/notfound'
},
// -----------------------
// STANDARD PAGE CATCH-ALL
// -----------------------
{ {
path: '/:catchAll(.*)*', path: '/:catchAll(.*)*',
component: () => import('../layouts/MainLayout.vue'), component: () => import('../layouts/MainLayout.vue'),
......
...@@ -9,7 +9,7 @@ export const useUserStore = defineStore('user', { ...@@ -9,7 +9,7 @@ export const useUserStore = defineStore('user', {
id: '10000000-0000-4000-8000-000000000001', id: '10000000-0000-4000-8000-000000000001',
email: '', email: '',
name: '', name: '',
pictureUrl: '', hasAvatar: false,
localeCode: '', localeCode: '',
timezone: '', timezone: '',
dateFormat: 'YYYY-MM-DD', dateFormat: 'YYYY-MM-DD',
...@@ -58,6 +58,7 @@ export const useUserStore = defineStore('user', { ...@@ -58,6 +58,7 @@ export const useUserStore = defineStore('user', {
id id
name name
email email
hasAvatar
meta meta
prefs prefs
lastLoginAt lastLoginAt
...@@ -78,7 +79,7 @@ export const useUserStore = defineStore('user', { ...@@ -78,7 +79,7 @@ export const useUserStore = defineStore('user', {
} }
this.name = resp.name || 'Unknown User' this.name = resp.name || 'Unknown User'
this.email = resp.email this.email = resp.email
this.pictureUrl = (resp.pictureUrl === 'local') ? `/_user/${this.id}/avatar` : resp.pictureUrl this.hasAvatar = resp.hasAvatar ?? false
this.location = resp.meta.location || '' this.location = resp.meta.location || ''
this.jobTitle = resp.meta.jobTitle || '' this.jobTitle = resp.meta.jobTitle || ''
this.pronouns = resp.meta.pronouns || '' this.pronouns = resp.meta.pronouns || ''
......
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