From 55b0b00cee9f8cbb9482ca117ac1d0a753dc9e59 Mon Sep 17 00:00:00 2001 From: Nicolas Giard Date: Mon, 12 Sep 2022 03:11:07 +0000 Subject: [PATCH] feat: update profile + user theme --- server/db/migrations/3.0.0.js | 4 +- server/graph/resolvers/user.js | 90 ++++++++++++---------- server/graph/schemas/user.graphql | 45 ++++------- server/models/users.js | 2 +- ux/quasar.config.js | 12 +-- ux/src/App.vue | 31 +++++++- ux/src/boot/apollo.js | 4 +- ux/src/components/AccountMenu.vue | 20 ++--- ux/src/components/HeaderNav.vue | 4 +- ux/src/components/UserEditOverlay.vue | 36 ++++++--- ux/src/i18n/locales/en.json | 11 ++- ux/src/layouts/ProfileLayout.vue | 3 +- ux/src/pages/Login.vue | 4 +- ux/src/pages/Profile.vue | 107 ++++++++++++++++++++++---- ux/src/stores/user.js | 82 ++++++++++++++++---- 15 files changed, 311 insertions(+), 144 deletions(-) diff --git a/server/db/migrations/3.0.0.js b/server/db/migrations/3.0.0.js index 53354351..31eba7e3 100644 --- a/server/db/migrations/3.0.0.js +++ b/server/db/migrations/3.0.0.js @@ -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' } diff --git a/server/graph/resolvers/user.js b/server/graph/resolvers/user.js index f6103595..bb5e32a6 100644 --- a/server/graph/resolvers/user.js +++ b/server/graph/resolvers/user.js @@ -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) + // } + // } } diff --git a/server/graph/schemas/user.graphql b/server/graph/schemas/user.graphql index d1e74818..52ed1032 100644 --- a/server/graph/schemas/user.graphql +++ b/server/graph/schemas/user.graphql @@ -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 diff --git a/server/models/users.js b/server/models/users.js index 706aaefb..360c7146 100644 --- a/server/models/users.js +++ b/server/models/users.js @@ -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'}, diff --git a/ux/quasar.config.js b/ux/quasar.config.js index a78a2c1b..ac81ea2b 100644 --- a/ux/quasar.config.js +++ b/ux/quasar.config.js @@ -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: {}, diff --git a/ux/src/App.vue b/ux/src/App.vue index 7e276ee8..b88ec1bf 100644 --- a/ux/src/App.vue +++ b/ux/src/App.vue @@ -3,9 +3,10 @@ router-view diff --git a/ux/src/components/HeaderNav.vue b/ux/src/components/HeaderNav.vue index fcf30fb0..4a8b4fdb 100644 --- a/ux/src/components/HeaderNav.vue +++ b/ux/src/components/HeaderNav.vue @@ -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}} diff --git a/ux/src/components/UserEditOverlay.vue b/ux/src/components/UserEditOverlay.vue index 3d5d34b3..321cd2d9 100644 --- a/ux/src/components/UserEditOverlay.vue +++ b/ux/src/components/UserEditOverlay.vue @@ -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 diff --git a/ux/src/i18n/locales/en.json b/ux/src/i18n/locales/en.json index ccaca75d..09d5447c 100644 --- a/ux/src/i18n/locales/en.json +++ b/ux/src/i18n/locales/en.json @@ -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" } diff --git a/ux/src/layouts/ProfileLayout.vue b/ux/src/layouts/ProfileLayout.vue index c39b7d36..128bf25c 100644 --- a/ux/src/layouts/ProfileLayout.vue +++ b/ux/src/layouts/ProfileLayout.vue @@ -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' }, { diff --git a/ux/src/pages/Login.vue b/ux/src/pages/Login.vue index a9eb07f3..e6f97c74 100644 --- a/ux/src/pages/Login.vue +++ b/ux/src/pages/Login.vue @@ -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='') diff --git a/ux/src/stores/user.js b/ux/src/stores/user.js index bdadff5f..26b2131d 100644 --- a/ux/src/stores/user.js +++ b/ux/src/stores/user.js @@ -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) + } } } })