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

parent acc3b736
......@@ -400,14 +400,14 @@ router.get(['/t', '/t/*'], (req, res, next) => {
/**
* 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'])) {
return res.sendStatus(403)
}
const av = await WIKI.db.users.getUserAvatarData(req.params.uid)
if (av) {
res.set('Content-Type', 'image/jpeg')
res.send(av)
return res.send(av)
}
return res.sendStatus(404)
......
......@@ -300,7 +300,7 @@ exports.up = async knex => {
table.jsonb('auth').notNullable().defaultTo('{}')
table.jsonb('meta').notNullable().defaultTo('{}')
table.jsonb('prefs').notNullable().defaultTo('{}')
table.string('pictureUrl')
table.boolean('hasAvatar').notNullable().defaultTo(false)
table.boolean('isSystem').notNullable().defaultTo(false)
table.boolean('isActive').notNullable().defaultTo(false)
table.boolean('isVerified').notNullable().defaultTo(false)
......
const graphHelper = require('../../helpers/graph')
const _ = require('lodash')
const path = require('node:path')
const fs = require('fs-extra')
module.exports = {
Query: {
......@@ -273,9 +275,88 @@ module.exports = {
} catch (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: {
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) {
return usr.$relatedQuery('groups')
}
......
......@@ -73,6 +73,15 @@ extend type Mutation {
timeFormat: String
appearance: UserSiteAppearance
): DefaultResponse
uploadUserAvatar(
id: UUID!
image: Upload!
): DefaultResponse
clearUserAvatar(
id: UUID!
): DefaultResponse
}
# -----------------------------------------------
......@@ -104,19 +113,27 @@ type User {
id: UUID
name: String
email: String
auth: JSON
auth: [UserAuth]
hasAvatar: Boolean
isSystem: Boolean
isActive: Boolean
isVerified: Boolean
meta: JSON
prefs: JSON
pictureUrl: String
createdAt: Date
updatedAt: Date
lastLoginAt: Date
groups: [Group]
}
type UserAuth {
authId: UUID
authName: String
strategyKey: String
strategyIcon: String
config: JSON
}
type UserTokenResponse {
operation: Operation
jwt: String
......
......@@ -105,7 +105,8 @@ module.exports = configure(function (/* ctx */) {
port: 3001,
proxy: {
'/_graphql': 'http://localhost:3000/_graphql',
'/_site': 'http://localhost:3000'
'/_site': 'http://localhost:3000',
'/_user': 'http://localhost:3000'
}
},
......
<template lang='pug'>
q-btn.q-ml-md(flat, round, dense, color='grey')
q-icon(v-if='!userStore.authenticated || !userStore.pictureUrl', name='las la-user-circle')
q-avatar(v-else)
img(:src='userStore.pictureUrl')
q-icon(
v-if='!userStore.authenticated || !userStore.hasAvatar'
name='las la-user-circle'
)
q-avatar(
v-else
size='32px'
)
img(:src='`/_user/` + userStore.id + `/avatar`')
q-menu(auto-close)
q-card(flat, style='width: 300px;', :dark='false')
q-card-section(align='center')
......@@ -12,24 +18,32 @@ q-btn.q-ml-md(flat, round, dense, color='grey')
q-card-actions(align='center')
q-btn(
flat
label='Profile'
:label='t(`common.header.profile`)'
icon='las la-user-alt'
color='primary'
to='/_profile'
no-caps
)
q-btn(flat
label='Logout'
:label='t(`common.header.logout`)'
icon='las la-sign-out-alt'
color='red'
href='/logout'
no-caps
)
q-tooltip Account
q-tooltip {{ t('common.header.account') }}
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { useUserStore } from 'src/stores/user'
// STORES
const userStore = useUserStore()
// I18N
const { t } = useI18n()
</script>
......@@ -50,6 +50,7 @@ const { t } = useI18n()
// METHODS
function create (editor) {
pageStore.pageCreate({ editor })
window.location.assign('/_edit/new')
// pageStore.pageCreate({ editor })
}
</script>
......@@ -318,13 +318,17 @@ const quickaccess = [
{ key: 'refCardVisibility', icon: 'las la-eye', label: t('editor.props.visibility') }
]
// REFS
const iptPagePassword = ref(null)
// WATCHERS
watch(() => state.requirePassword, (newValue) => {
if (newValue) {
nextTick(() => {
this.$refs.iptPagePassword.focus()
this.$refs.iptPagePassword.$el.scrollIntoView({
iptPagePassword.value.focus()
iptPagePassword.value.$el.scrollIntoView({
behavior: 'smooth'
})
})
......
......@@ -11,6 +11,7 @@ q-layout(view='hHh Lpr lff')
clickable
:to='`/_profile/` + navItem.key'
active-class='is-active'
:disabled='navItem.disabled'
v-ripple
)
q-item-section(side)
......@@ -21,12 +22,12 @@ q-layout(view='hHh Lpr lff')
q-item(
clickable
v-ripple
to='/_profile/me'
:to='`/_user/` + userStore.id'
)
q-item-section(side)
q-icon(name='las la-id-card')
q-item-section
q-item-label View Public Profile
q-item-label {{ t('profile.viewPublicProfile') }}
q-separator.q-my-sm(inset)
q-item(
clickable
......@@ -36,7 +37,7 @@ q-layout(view='hHh Lpr lff')
q-item-section(side)
q-icon(name='las la-sign-out-alt', color='negative')
q-item-section
q-item-label.text-negative Logout
q-item-label.text-negative {{ t('common.header.logout') }}
router-view
q-footer
q-bar.justify-center(dense)
......@@ -44,13 +45,12 @@ q-layout(view='hHh Lpr lff')
</template>
<script setup>
import gql from 'graphql-tag'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { onMounted, reactive, watch } from 'vue'
import { useSiteStore } from 'src/stores/site'
import { useUserStore } from 'src/stores/user'
import HeaderNav from '../components/HeaderNav.vue'
......@@ -61,62 +61,60 @@ const $q = useQuasar()
// STORES
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N
const { t } = useI18n()
// META
useMeta({
titleTemplate: title => `${title} - ${t('profile.title')} - Wiki.js`
})
// DATA
const sidenav = [
{
key: 'info',
label: 'Profile',
label: t('profile.title'),
icon: 'las la-user-circle'
},
{
key: 'avatar',
label: 'Avatar',
label: t('profile.avatar'),
icon: 'las la-otter'
},
{
key: 'password',
label: 'Authentication',
key: 'auth',
label: t('profile.auth'),
icon: 'las la-key'
},
{
key: 'groups',
label: 'Groups',
label: t('profile.groups'),
icon: 'las la-users'
},
{
key: 'notifications',
label: 'Notifications',
icon: 'las la-bell'
},
{
key: 'pages',
label: 'My Pages',
icon: 'las la-file-alt'
label: t('profile.notifications'),
icon: 'las la-bell',
disabled: true
},
// {
// key: 'pages',
// label: 'My Pages',
// icon: 'las la-file-alt',
// disabled: true
// },
{
key: 'activity',
label: 'Activity',
icon: 'las la-history'
label: t('profile.activity'),
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>
<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 @@
.errorpage-hint {{error.hint}}
.errorpage-actions
q-btn(
v-if='error.showHomeBtn'
push
color='primary'
label='Go to home'
......@@ -38,6 +39,10 @@ const actions = {
notfound: {
code: 404
},
unknownsite: {
code: 'X!?',
showHomeBtn: false
},
generic: {
code: '!?0'
}
......@@ -62,12 +67,14 @@ useMeta({
const error = computed(() => {
if (route.params.action && actions[route.params.action]) {
return {
showHomeBtn: true,
...actions[route.params.action],
title: t(`common.error.${route.params.action}.title`),
hint: t(`common.error.${route.params.action}.hint`)
}
} else {
return {
showHomeBtn: true,
...actions.generic,
title: t('common.error.generic.title'),
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')
q-select(
outlined
v-model='state.config.timezone'
:options='dataStore.timezones'
option-value='value'
option-label='text'
emit-value
map-options
:options='timezones'
:virtual-scroll-slice-size='100'
:virtual-scroll-slice-ratio-before='2'
:virtual-scroll-slice-ratio-after='2'
dense
options-dense
:aria-label='t(`admin.general.defaultTimezone`)'
......@@ -153,7 +152,6 @@ import { useMeta, useQuasar } from 'quasar'
import { onMounted, reactive, watch } from 'vue'
import { useSiteStore } from 'src/stores/site'
import { useDataStore } from 'src/stores/data'
import { useUserStore } from 'src/stores/user'
// QUASAR
......@@ -163,7 +161,6 @@ const $q = useQuasar()
// STORES
const siteStore = useSiteStore()
const dataStore = useDataStore()
const userStore = useUserStore()
// I18N
......@@ -173,7 +170,7 @@ const { t } = useI18n()
// META
useMeta({
title: t('profile.title')
title: t('profile.myInfo')
})
// DATA
......@@ -209,6 +206,7 @@ const appearances = [
{ value: 'light', label: t('profile.appearanceLight') },
{ value: 'dark', label: t('profile.appearanceDark') }
]
const timezones = Intl.supportedValuesOf('timeZone')
// 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 = [
component: () => import('layouts/ProfileLayout.vue'),
children: [
{ 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 = [
// 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(.*)*',
component: () => import('../layouts/MainLayout.vue'),
......
......@@ -9,7 +9,7 @@ export const useUserStore = defineStore('user', {
id: '10000000-0000-4000-8000-000000000001',
email: '',
name: '',
pictureUrl: '',
hasAvatar: false,
localeCode: '',
timezone: '',
dateFormat: 'YYYY-MM-DD',
......@@ -58,6 +58,7 @@ export const useUserStore = defineStore('user', {
id
name
email
hasAvatar
meta
prefs
lastLoginAt
......@@ -78,7 +79,7 @@ export const useUserStore = defineStore('user', {
}
this.name = resp.name || 'Unknown User'
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.jobTitle = resp.meta.jobTitle || ''
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