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)
+ }
}
}
})