feat: update profile + user theme

pull/5698/head
Nicolas Giard 2 years ago
parent 8e87a5d489
commit 55b0b00cee
No known key found for this signature in database
GPG Key ID: 85061B8F9D55B7C8

@ -580,7 +580,7 @@ exports.up = async knex => {
timezone: 'America/New_York',
dateFormat: 'YYYY-MM-DD',
timeFormat: '12h',
darkMode: false
appearance: 'site'
},
localeCode: 'en'
},
@ -597,7 +597,7 @@ exports.up = async knex => {
timezone: 'America/New_York',
dateFormat: 'YYYY-MM-DD',
timeFormat: '12h',
darkMode: false
appearance: 'site'
},
localeCode: 'en'
}

@ -40,6 +40,10 @@ module.exports = {
async userById (obj, args, context, info) {
const usr = await WIKI.models.users.query().findById(args.id)
if (!usr) {
throw new Error('Invalid User')
}
// const str = _.get(WIKI.auth.strategies, usr.providerKey)
// str.strategy = _.find(WIKI.data.authentication, ['key', str.strategyKey])
// usr.providerName = str.displayName
@ -56,25 +60,25 @@ module.exports = {
return usr
},
async profile (obj, args, context, info) {
if (!context.req.user || context.req.user.id < 1 || context.req.user.id === 2) {
throw new WIKI.Error.AuthRequired()
}
const usr = await WIKI.models.users.query().findById(context.req.user.id)
if (!usr.isActive) {
throw new WIKI.Error.AuthAccountBanned()
}
// async profile (obj, args, context, info) {
// if (!context.req.user || context.req.user.id < 1 || context.req.user.id === 2) {
// throw new WIKI.Error.AuthRequired()
// }
// const usr = await WIKI.models.users.query().findById(context.req.user.id)
// if (!usr.isActive) {
// throw new WIKI.Error.AuthAccountBanned()
// }
const providerInfo = _.get(WIKI.auth.strategies, usr.providerKey, {})
// const providerInfo = _.get(WIKI.auth.strategies, usr.providerKey, {})
usr.providerName = providerInfo.displayName || 'Unknown'
usr.lastLoginAt = usr.lastLoginAt || usr.updatedAt
usr.password = ''
usr.providerId = ''
usr.tfaSecret = ''
// usr.providerName = providerInfo.displayName || 'Unknown'
// usr.lastLoginAt = usr.lastLoginAt || usr.updatedAt
// usr.password = ''
// usr.providerId = ''
// usr.tfaSecret = ''
return usr
},
// return usr
// },
async lastLogins (obj, args, context, info) {
return WIKI.models.users.query()
.select('id', 'name', 'lastLoginAt')
@ -193,7 +197,7 @@ module.exports = {
},
async updateProfile (obj, args, context) {
try {
if (!context.req.user || context.req.user.id < 1 || context.req.user.id === 2) {
if (!context.req.user || context.req.user.id === WIKI.auth.guest.id) {
throw new WIKI.Error.AuthRequired()
}
const usr = await WIKI.models.users.query().findById(context.req.user.id)
@ -204,29 +208,33 @@ module.exports = {
throw new WIKI.Error.AuthAccountNotVerified()
}
if (!['', 'DD/MM/YYYY', 'DD.MM.YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD', 'YYYY/MM/DD'].includes(args.dateFormat)) {
if (args.dateFormat && !['', 'DD/MM/YYYY', 'DD.MM.YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD', 'YYYY/MM/DD'].includes(args.dateFormat)) {
throw new WIKI.Error.InputInvalid()
}
if (!['', 'light', 'dark'].includes(args.appearance)) {
if (args.appearance && !['site', 'light', 'dark'].includes(args.appearance)) {
throw new WIKI.Error.InputInvalid()
}
await WIKI.models.users.updateUser({
id: usr.id,
name: _.trim(args.name),
jobTitle: _.trim(args.jobTitle),
location: _.trim(args.location),
timezone: args.timezone,
dateFormat: args.dateFormat,
appearance: args.appearance
await WIKI.models.users.query().findById(usr.id).patch({
name: args.name?.trim() ?? usr.name,
meta: {
...usr.meta,
location: args.location?.trim() ?? usr.meta.location,
jobTitle: args.jobTitle?.trim() ?? usr.meta.jobTitle,
pronouns: args.pronouns?.trim() ?? usr.meta.pronouns
},
prefs: {
...usr.prefs,
timezone: args.timezone || usr.prefs.timezone,
dateFormat: args.dateFormat ?? usr.prefs.dateFormat,
timeFormat: args.timeFormat ?? usr.prefs.timeFormat,
appearance: args.appearance || usr.prefs.appearance
}
})
const newToken = await WIKI.models.users.refreshToken(usr.id)
return {
operation: graphHelper.generateSuccess('User profile updated successfully'),
jwt: newToken.token
operation: graphHelper.generateSuccess('User profile updated successfully')
}
} catch (err) {
return graphHelper.generateError(err)
@ -273,15 +281,15 @@ module.exports = {
groups (usr) {
return usr.$relatedQuery('groups')
}
},
UserProfile: {
async groups (usr) {
const usrGroups = await usr.$relatedQuery('groups')
return usrGroups.map(g => g.name)
},
async pagesTotal (usr) {
const result = await WIKI.models.pages.query().count('* as total').where('creatorId', usr.id).first()
return _.toSafeInteger(result.total)
}
}
// UserProfile: {
// async groups (usr) {
// const usrGroups = await usr.$relatedQuery('groups')
// return usrGroups.map(g => g.name)
// },
// async pagesTotal (usr) {
// const result = await WIKI.models.pages.query().count('* as total').where('creatorId', usr.id).first()
// return _.toSafeInteger(result.total)
// }
// }
}

@ -16,8 +16,6 @@ extend type Query {
id: UUID!
): User
profile: UserProfile
lastLogins: [UserLastLogin]
}
@ -66,13 +64,15 @@ extend type Mutation {
): DefaultResponse
updateProfile(
name: String!
location: String!
jobTitle: String!
timezone: String!
dateFormat: String!
appearance: String!
): UserTokenResponse
name: String
location: String
jobTitle: String
pronouns: String
timezone: String
dateFormat: String
timeFormat: String
appearance: UserSiteAppearance
): DefaultResponse
}
# -----------------------------------------------
@ -110,32 +110,13 @@ type User {
isVerified: Boolean
meta: JSON
prefs: JSON
pictureUrl: String
createdAt: Date
updatedAt: Date
lastLoginAt: Date
groups: [Group]
}
type UserProfile {
id: Int
name: String
email: String
providerKey: String
providerName: String
isSystem: Boolean
isVerified: Boolean
location: String
jobTitle: String
timezone: String
dateFormat: String
appearance: String
createdAt: Date
updatedAt: Date
lastLoginAt: Date
groups: [String]
pagesTotal: Int
}
type UserTokenResponse {
operation: Operation
jwt: String
@ -150,6 +131,12 @@ enum UserOrderBy {
lastLoginAt
}
enum UserSiteAppearance {
site
light
dark
}
input UserUpdateInput {
email: String
name: String

@ -22,7 +22,7 @@ module.exports = class User extends Model {
properties: {
id: {type: 'string'},
email: {type: 'string', format: 'email'},
email: {type: 'string'},
name: {type: 'string', minLength: 1, maxLength: 255},
pictureUrl: {type: 'string'},
isSystem: {type: 'boolean'},

@ -77,12 +77,12 @@ module.exports = configure(function (/* ctx */) {
extendViteConf (viteConf) {
viteConf.build.assetsDir = '_assets'
viteConf.build.rollupOptions = {
...viteConf.build.rollupOptions ?? {},
external: [
/^\/_site\//
]
}
// viteConf.build.rollupOptions = {
// ...viteConf.build.rollupOptions ?? {},
// external: [
// /^\/_site\//
// ]
// }
},
// viteVuePluginOptions: {},

@ -3,9 +3,10 @@ router-view
</template>
<script setup>
import { nextTick, onMounted, reactive } from 'vue'
import { nextTick, onMounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useSiteStore } from 'src/stores/site'
import { useUserStore } from 'src/stores/user'
import { setCssVar, useQuasar } from 'quasar'
/* global siteConfig */
@ -17,6 +18,7 @@ const $q = useQuasar()
// STORES
const siteStore = useSiteStore()
const userStore = useUserStore()
// ROUTER
@ -28,10 +30,24 @@ const state = reactive({
isInitialized: false
})
// WATCHERS
watch(() => userStore.appearance, (newValue) => {
if (newValue === 'site') {
$q.dark.set(siteStore.theme.dark)
} else {
$q.dark.set(newValue === 'dark')
}
})
// THEME
function applyTheme () {
$q.dark.set(siteStore.theme.dark)
if (userStore.appearance === 'site') {
$q.dark.set(siteStore.theme.dark)
} else {
$q.dark.set(userStore.appearance === 'dark')
}
setCssVar('primary', siteStore.theme.colorPrimary)
setCssVar('secondary', siteStore.theme.colorSecondary)
setCssVar('accent', siteStore.theme.colorAccent)
@ -51,12 +67,21 @@ if (typeof siteConfig !== 'undefined') {
router.beforeEach(async (to, from) => {
siteStore.routerLoading = true
// Site Info
if (!siteStore.id) {
console.info('No pre-cached site config. Loading site info...')
await siteStore.loadSite(window.location.hostname)
console.info(`Using Site ID ${siteStore.id}`)
applyTheme()
}
// User Auth
await userStore.refreshAuth()
// User Profile
if (userStore.authenticated && !userStore.profileLoaded) {
console.info(`Refreshing user ${userStore.id} profile...`)
await userStore.refreshProfile()
}
// Apply Theme
applyTheme()
})
router.afterEach(() => {
if (!state.isInitialized) {

@ -3,10 +3,10 @@ import { ApolloClient, InMemoryCache } from '@apollo/client/core'
import { setContext } from '@apollo/client/link/context'
import { createUploadLink } from 'apollo-upload-client'
export default boot(({ app }) => {
export default boot(({ app, store }) => {
// Authentication Link
const authLink = setContext(async (req, { headers }) => {
const token = 'test' // await window.auth0Client.getTokenSilently()
const token = store.state.value.user.token
return {
headers: {
...headers,

@ -1,13 +1,13 @@
<template lang='pug'>
q-btn.q-ml-md(flat, round, dense, color='grey')
q-icon(v-if='!state.user.picture', name='las la-user-circle')
q-icon(v-if='!userStore.authenticated || !userStore.pictureUrl', name='las la-user-circle')
q-avatar(v-else)
img(:src='state.user.picture')
img(:src='userStore.pictureUrl')
q-menu(auto-close)
q-card(flat, style='width: 300px;', :dark='false')
q-card-section(align='center')
.text-subtitle1.text-grey-7 {{state.user.name}}
.text-caption.text-grey-8 {{state.user.email}}
.text-subtitle1.text-grey-7 {{userStore.name}}
.text-caption.text-grey-8 {{userStore.email}}
q-separator(:dark='false')
q-card-actions(align='center')
q-btn(
@ -15,7 +15,7 @@ q-btn.q-ml-md(flat, round, dense, color='grey')
label='Profile'
icon='las la-user-alt'
color='primary'
href='/_profile'
to='/_profile'
no-caps
)
q-btn(flat
@ -29,13 +29,7 @@ q-btn.q-ml-md(flat, round, dense, color='grey')
</template>
<script setup>
import { reactive } from 'vue'
import { useUserStore } from 'src/stores/user'
const state = reactive({
user: {
name: 'John Doe',
email: 'test@example.com',
picture: null
}
})
const userStore = useUserStore()
</script>

@ -17,10 +17,10 @@ q-header.bg-header.text-white.site-header(
size='34px'
square
)
img(src='/_site/logo')
img(:src='`/_site/logo`')
img(
v-else
src='/_site/logo'
:src='`/_site/logo`'
style='height: 34px'
)
q-toolbar-title.text-h6(v-if='siteStore.logoText') {{siteStore.title}}

@ -117,6 +117,19 @@ q-layout(view='hHh lpR fFf', container)
dense
:aria-label='t(`admin.users.jobTitle`)'
)
q-separator.q-my-sm(inset)
q-item
blueprint-icon(icon='gender')
q-item-section
q-item-label {{t(`admin.users.pronouns`)}}
q-item-label(caption) {{t(`admin.users.pronounsHint`)}}
q-item-section
q-input(
outlined
v-model='state.user.meta.pronouns'
dense
:aria-label='t(`admin.users.pronouns`)'
)
q-card.shadow-1.q-pb-sm.q-mt-md(v-if='state.user.meta')
q-card-section
@ -181,18 +194,23 @@ q-layout(view='hHh lpR fFf', container)
]`
)
q-separator.q-my-sm(inset)
q-item(tag='label', v-ripple)
q-item
blueprint-icon(icon='light-on')
q-item-section
q-item-label {{t(`admin.users.darkMode`)}}
q-item-label {{t(`admin.users.appearance`)}}
q-item-label(caption) {{t(`admin.users.darkModeHint`)}}
q-item-section(avatar)
q-toggle(
v-model='state.user.prefs.darkMode'
color='primary'
checked-icon='las la-check'
unchecked-icon='las la-times'
:aria-label='t(`admin.users.darkMode`)'
q-item-section.col-auto
q-btn-toggle(
v-model='state.user.prefs.appearance'
push
glossy
no-caps
toggle-color='primary'
:options=`[
{ label: t('profile.appearanceDefault'), value: 'site' },
{ label: t('profile.appearanceLight'), value: 'light' },
{ label: t('profile.appearanceDark'), value: 'dark' }
]`
)
.col-12.col-lg-4

@ -1359,7 +1359,7 @@
"profile.activity.lastUpdatedOn": "Profile last updated on",
"profile.activity.pagesCreated": "Pages created",
"profile.activity.title": "Activity",
"profile.appearance": "Appearance",
"profile.appearance": "Site Appearance",
"profile.appearanceDark": "Dark",
"profile.appearanceDefault": "Site Default",
"profile.appearanceLight": "Light",
@ -1498,5 +1498,12 @@
"admin.utilities.disconnectWSHint": "Force all active websocket connections to be closed.",
"admin.utilities.disconnectWSSuccess": "All active websocket connections have been terminated.",
"admin.login.bgUploadSuccess": "Login background image uploaded successfully.",
"admin.login.saveSuccess": "Login configuration saved successfully."
"admin.login.saveSuccess": "Login configuration saved successfully.",
"profile.appearanceHint": "Use the light or dark theme.",
"profile.saving": "Saving profile...",
"profile.saveSuccess": "Profile saved successfully.",
"profile.saveFailed": "Failed to save profile changes.",
"admin.users.pronouns": "Pronouns",
"admin.users.pronounsHint": "The pronouns used to address this user.",
"admin.users.appearance": "Site Appearance"
}

@ -31,6 +31,7 @@ q-layout(view='hHh Lpr lff')
q-item(
clickable
v-ripple
href='/logout'
)
q-item-section(side)
q-icon(name='las la-sign-out-alt', color='negative')
@ -80,7 +81,7 @@ const sidenav = [
},
{
key: 'password',
label: 'Password',
label: 'Authentication',
icon: 'las la-key'
},
{

@ -2,12 +2,12 @@
.auth
.auth-content
.auth-logo
img(src='/_site/logo' :alt='siteStore.title')
img(:src='`/_site/logo`' :alt='siteStore.title')
h2.auth-site-title(v-if='siteStore.logoText') {{ siteStore.title }}
p.text-grey-7 Login to continue
auth-login-panel
.auth-bg(aria-hidden="true")
img(src='/_site/loginbg' alt='')
img(:src='`/_site/loginbg`' alt='')
</template>
<script setup>

@ -121,25 +121,27 @@ q-page.q-py-md(:style-fn='pageStyle')
:options='timeFormats'
)
q-separator.q-my-sm(inset)
q-item(tag='label', v-ripple)
q-item
blueprint-icon(icon='light-on')
q-item-section
q-item-label {{t(`profile.darkMode`)}}
q-item-label(caption) {{t(`profile.darkModeHint`)}}
q-item-section(avatar)
q-toggle(
v-model='state.config.darkMode'
color='primary'
checked-icon='las la-check'
unchecked-icon='las la-times'
:aria-label='t(`profile.darkMode`)'
q-item-label {{t(`profile.appearance`)}}
q-item-label(caption) {{t(`profile.appearanceHint`)}}
q-item-section.col-auto
q-btn-toggle(
v-model='state.config.appearance'
push
glossy
no-caps
toggle-color='primary'
:options='appearances'
)
.actions-bar.q-mt-lg
q-btn(
icon='las la-check'
unelevated
label='Save Changes'
:label='t(`common.actions.saveChanges`)'
color='secondary'
@click='save'
)
</template>
@ -152,6 +154,7 @@ 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
@ -161,6 +164,7 @@ const $q = useQuasar()
const siteStore = useSiteStore()
const dataStore = useDataStore()
const userStore = useUserStore()
// I18N
@ -176,14 +180,15 @@ useMeta({
const state = reactive({
config: {
name: 'John Doe',
email: 'john.doe@company.com',
name: '',
email: '',
location: '',
jobTitle: '',
pronouns: '',
timezone: '',
dateFormat: '',
timeFormat: '12h',
darkMode: false
appearance: 'site'
}
})
@ -199,6 +204,11 @@ const timeFormats = [
{ value: '12h', label: t('admin.general.defaultTimeFormat12h') },
{ value: '24h', label: t('admin.general.defaultTimeFormat24h') }
]
const appearances = [
{ value: 'site', label: t('profile.appearanceDefault') },
{ value: 'light', label: t('profile.appearanceLight') },
{ value: 'dark', label: t('profile.appearanceDark') }
]
// METHODS
@ -207,4 +217,73 @@ function pageStyle (offset, height) {
'min-height': `${height - 100 - offset}px`
}
}
async function save () {
$q.loading.show({
message: t('profile.saving')
})
try {
const respRaw = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation saveProfile (
$name: String
$location: String
$jobTitle: String
$pronouns: String
$timezone: String
$dateFormat: String
$timeFormat: String
$appearance: UserSiteAppearance
) {
updateProfile (
name: $name
location: $location
jobTitle: $jobTitle
pronouns: $pronouns
timezone: $timezone
dateFormat: $dateFormat
timeFormat: $timeFormat
appearance: $appearance
) {
operation {
succeeded
message
}
}
}
`,
variables: state.config
})
if (respRaw.data?.updateProfile?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('profile.saveSuccess')
})
userStore.$patch(state.config)
} else {
throw new Error(respRaw.data?.updateProfile?.operation?.message || 'An unexpected error occured')
}
} catch (err) {
$q.notify({
type: 'negative',
message: t('profile.saveFailed'),
caption: err.message
})
}
$q.loading.hide()
}
// MOUNTED
onMounted(() => {
state.config.name = userStore.name || ''
state.config.email = userStore.email
state.config.location = userStore.location || ''
state.config.jobTitle = userStore.jobTitle || ''
state.config.pronouns = userStore.pronouns || ''
state.config.timezone = userStore.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || ''
state.config.dateFormat = userStore.dateFormat || ''
state.config.timeFormat = userStore.timeFormat || '12h'
state.config.appearance = userStore.appearance || 'site'
})
</script>

@ -1,47 +1,95 @@
import { defineStore } from 'pinia'
import jwtDecode from 'jwt-decode'
import Cookies from 'js-cookie'
import gql from 'graphql-tag'
import { DateTime } from 'luxon'
export const useUserStore = defineStore('user', {
state: () => ({
id: 0,
id: '10000000-0000-4000-8000-000000000001',
email: '',
name: '',
pictureUrl: '',
localeCode: '',
defaultEditor: '',
timezone: '',
dateFormat: '',
appearance: '',
dateFormat: 'YYYY-MM-DD',
timeFormat: '12h',
appearance: 'site',
permissions: [],
iat: 0,
exp: 0,
authenticated: false
exp: null,
authenticated: false,
token: '',
profileLoaded: false
}),
getters: {},
actions: {
refreshAuth () {
async refreshAuth () {
const jwtCookie = Cookies.get('jwt')
if (jwtCookie) {
try {
const jwtData = jwtDecode(jwtCookie)
this.id = jwtData.id
this.email = jwtData.email
this.name = jwtData.name
this.pictureUrl = jwtData.av
this.localeCode = jwtData.lc
this.timezone = jwtData.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || ''
this.dateFormat = jwtData.df || ''
this.appearance = jwtData.ap || ''
// this.defaultEditor = jwtData.defaultEditor
this.permissions = jwtData.permissions
this.iat = jwtData.iat
this.exp = jwtData.exp
this.authenticated = true
this.exp = DateTime.fromSeconds(jwtData.exp, { zone: 'utc' })
this.token = jwtCookie
if (this.exp <= DateTime.utc()) {
console.info('Token has expired. Attempting renew...')
} else {
this.authenticated = true
}
} catch (err) {
console.debug('Invalid JWT. Silent authentication skipped.')
}
}
},
async refreshProfile () {
if (!this.authenticated || !this.id) {
return
}
try {
const respRaw = await APOLLO_CLIENT.query({
query: gql`
query refreshProfile (
$id: UUID!
) {
userById(id: $id) {
id
name
email
meta
prefs
lastLoginAt
groups {
id
name
}
}
}
`,
variables: {
id: this.id
}
})
const resp = respRaw?.data?.userById
if (!resp || resp.id !== this.id) {
throw new Error('Failed to fetch user profile!')
}
this.name = resp.name || 'Unknown User'
this.email = resp.email
this.pictureUrl = (resp.pictureUrl === 'local') ? `/_user/${this.id}/avatar` : resp.pictureUrl
this.location = resp.meta.location || ''
this.jobTitle = resp.meta.jobTitle || ''
this.pronouns = resp.meta.pronouns || ''
this.timezone = resp.prefs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || ''
this.dateFormat = resp.prefs.dateFormat || ''
this.timeFormat = resp.prefs.timeFormat || '12h'
this.appearance = resp.prefs.appearance || 'site'
this.profileLoaded = true
} catch (err) {
console.warn(err)
}
}
}
})

Loading…
Cancel
Save