Jelajahi Sumber

feat: update profile + user theme

Nicolas Giard 2 tahun lalu
induk
melakukan
55b0b00cee

+ 2 - 2
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'
     }

+ 49 - 41
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)
+  //   }
+  // }
 }

+ 16 - 29
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

+ 1 - 1
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'},

+ 6 - 6
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: {},
 

+ 28 - 3
ux/src/App.vue

@@ -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) {

+ 2 - 2
ux/src/boot/apollo.js

@@ -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,

+ 7 - 13
ux/src/components/AccountMenu.vue

@@ -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>

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

+ 27 - 9
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

+ 9 - 2
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"
 }

+ 2 - 1
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'
   },
   {

+ 2 - 2
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='')
 </template>
 
 <script setup>

+ 93 - 14
ux/src/pages/Profile.vue

@@ -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>

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