Browse Source

feat: passkeys (add/remove)

NGPixel 1 year ago
parent
commit
4d285caaa7

+ 1 - 0
server/db/migrations/3.0.0.mjs

@@ -344,6 +344,7 @@ export async function up (knex) {
       table.string('name').notNullable()
       table.jsonb('auth').notNullable().defaultTo('{}')
       table.jsonb('meta').notNullable().defaultTo('{}')
+      table.jsonb('passkeys').notNullable().defaultTo('{}')
       table.jsonb('prefs').notNullable().defaultTo('{}')
       table.boolean('hasAvatar').notNullable().defaultTo(false)
       table.boolean('isSystem').notNullable().defaultTo(false)

+ 200 - 0
server/graph/resolvers/authentication.mjs

@@ -3,6 +3,8 @@ import { generateError, generateSuccess } from '../../helpers/graph.mjs'
 import jwt from 'jsonwebtoken'
 import ms from 'ms'
 import { DateTime } from 'luxon'
+import { v4 as uuid } from 'uuid'
+import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server'
 
 export default {
   Query: {
@@ -122,6 +124,52 @@ export default {
         return generateError(err)
       }
     },
+    /**
+     * Setup TFA
+     */
+    async setupTFA (obj, args, context) {
+      try {
+        const userId = context.req.user?.id
+        if (!userId) {
+          throw new Error('ERR_USER_NOT_AUTHENTICATED')
+        }
+
+        const usr = await WIKI.db.users.query().findById(userId)
+        if (!usr) {
+          throw new Error('ERR_INVALID_USER')
+        }
+
+        const str = WIKI.auth.strategies[args.strategyId]
+        if (!str) {
+          throw new Error('ERR_INVALID_STRATEGY')
+        }
+
+        if (!usr.auth[args.strategyId]) {
+          throw new Error('ERR_INVALID_STRATEGY')
+        }
+
+        if (usr.auth[args.strategyId].tfaIsActive) {
+          throw new Error('ERR_TFA_ALREADY_ACTIVE')
+        }
+
+        const tfaQRImage = await usr.generateTFA(args.strategyId, args.siteId)
+        const tfaToken = await WIKI.db.userKeys.generateToken({
+          kind: 'tfaSetup',
+          userId: usr.id,
+          meta: {
+            strategyId: args.strategyId
+          }
+        })
+
+        return {
+          operation: generateSuccess('TFA setup started'),
+          continuationToken: tfaToken,
+          tfaQRImage
+        }
+      } catch (err) {
+        return generateError(err)
+      }
+    },
     /**
      * Deactivate 2FA
      */
@@ -164,6 +212,158 @@ export default {
         return generateError(err)
       }
     },
+    /**
+     * Setup Passkey
+     */
+    async setupPasskey (obj, args, context) {
+      try {
+        const userId = context.req.user?.id
+        if (!userId) {
+          throw new Error('ERR_USER_NOT_AUTHENTICATED')
+        }
+
+        const usr = await WIKI.db.users.query().findById(userId)
+        if (!usr) {
+          throw new Error('ERR_INVALID_USER')
+        }
+
+        const site = WIKI.sites[args.siteId]
+        if (!site) {
+          throw new Error('ERR_INVALID_SITE')
+        } else if (site.hostname === '*') {
+          WIKI.logger.warn('Cannot use passkeys with a wildcard site hostname. Enter a valid hostname under the Administration Area > General.')
+          throw new Error('ERR_PK_HOSTNAME_MISSING')
+        }
+
+        const options = await generateRegistrationOptions({
+          rpName: site.config.title,
+          rpId: site.hostname,
+          userID: usr.id,
+          userName: usr.email,
+          userDisplayName: usr.name,
+          attestationType: 'none',
+          authenticatorSelection: {
+            residentKey: 'required',
+            userVerification: 'preferred'
+          },
+          excludeCredentials: usr.passkeys.authenticators?.map(authenticator => ({
+            id: new Uint8Array(authenticator.credentialID),
+            type: 'public-key',
+            transports: authenticator.transports
+          })) ?? []
+        })
+
+        usr.passkeys.reg = {
+          challenge: options.challenge,
+          rpId: site.hostname,
+          siteId: site.id
+        }
+
+        await usr.$query().patch({
+          passkeys: usr.passkeys
+        })
+
+        return {
+          operation: generateSuccess('Passkey registration options generated successfully.'),
+          registrationOptions: options
+        }
+      } catch (err) {
+        return generateError(err)
+      }
+    },
+    /**
+     * Finalize Passkey Registration
+     */
+    async finalizePasskey (obj, args, context) {
+      try {
+        const userId = context.req.user?.id
+        if (!userId) {
+          throw new Error('ERR_USER_NOT_AUTHENTICATED')
+        }
+
+        const usr = await WIKI.db.users.query().findById(userId)
+        if (!usr) {
+          throw new Error('ERR_INVALID_USER')
+        } else if (!usr.passkeys?.reg) {
+          throw new Error('ERR_PASSKEY_NOT_SETUP')
+        }
+
+        if (!args.name || args.name.trim().length < 1 || args.name.length > 255) {
+          throw new Error('ERR_PK_NAME_MISSING_OR_INVALID')
+        }
+
+        const verification = await verifyRegistrationResponse({
+          response: args.registrationResponse,
+          expectedChallenge: usr.passkeys.reg.challenge,
+          expectedOrigin: `https://${usr.passkeys.reg.rpId}`,
+          expectedRPID: usr.passkeys.reg.rpId,
+          requireUserVerification: true
+        })
+
+        if (!verification.verified) {
+          throw new Error('ERR_PK_VERIFICATION_FAILED')
+        }
+
+        if (!usr.passkeys.authenticators) {
+          usr.passkeys.authenticators = []
+        }
+        usr.passkeys.authenticators.push({
+          ...verification.registrationInfo,
+          id: uuid(),
+          createdAt: new Date(),
+          name: args.name,
+          siteId: usr.passkeys.reg.siteId,
+          transports: args.registrationResponse.response.transports
+        })
+
+        delete usr.passkeys.reg
+
+        await usr.$query().patch({
+          passkeys: JSON.stringify(usr.passkeys, (k, v) => {
+            if (v instanceof Uint8Array) {
+              return Array.apply([], v)
+            }
+            return v
+          })
+        })
+
+        return {
+          operation: generateSuccess('Passkey registered successfully.')
+        }
+      } catch (err) {
+        return generateError(err)
+      }
+    },
+    /**
+     * Deactivate a passkey
+     */
+    async deactivatePasskey (obj, args, context) {
+      try {
+        const userId = context.req.user?.id
+        if (!userId) {
+          throw new Error('ERR_USER_NOT_AUTHENTICATED')
+        }
+
+        const usr = await WIKI.db.users.query().findById(userId)
+        if (!usr) {
+          throw new Error('ERR_INVALID_USER')
+        } else if (!usr.passkeys?.authenticators) {
+          throw new Error('ERR_PASSKEY_NOT_SETUP')
+        }
+
+        usr.passkeys.authenticators = usr.passkeys.authenticators.filter(a => a.id !== args.id)
+
+        await usr.$query().patch({
+          passkeys: usr.passkeys
+        })
+
+        return {
+          operation: generateSuccess('Passkey deactivated successfully.')
+        }
+      } catch (err) {
+        return generateError(err)
+      }
+    },
     /**
      * Perform Password Change
      */

+ 8 - 0
server/graph/resolvers/user.mjs

@@ -2,6 +2,7 @@ import { generateError, generateSuccess } from '../../helpers/graph.mjs'
 import _, { isNil } from 'lodash-es'
 import path from 'node:path'
 import fs from 'fs-extra'
+import { DateTime } from 'luxon'
 
 export default {
   Query: {
@@ -59,6 +60,13 @@ export default {
         return auth
       })
 
+      usr.passkeys = usr.passkeys.authenticators?.map(a => ({
+        id: a.id,
+        createdAt: DateTime.fromISO(a.createdAt).toJSDate(),
+        name: a.name,
+        siteHostname: a.rpID
+      })) ?? []
+
       return usr
     },
     // async profile (obj, args, context, info) {

+ 29 - 0
server/graph/schemas/authentication.graphql

@@ -41,10 +41,28 @@ extend type Mutation {
     setup: Boolean
   ): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60)
 
+  setupTFA(
+    strategyId: UUID!
+    siteId: UUID!
+  ): AuthenticationSetupTFAResponse
+
   deactivateTFA(
     strategyId: UUID!
   ): DefaultResponse
 
+  setupPasskey(
+    siteId: UUID!
+  ): AuthenticationSetupPasskeyResponse
+
+  finalizePasskey(
+    registrationResponse: JSON!
+    name: String!
+  ): DefaultResponse
+
+  deactivatePasskey(
+    id: UUID!
+  ): DefaultResponse
+
   changePassword(
     continuationToken: String
     currentPassword: String
@@ -135,6 +153,17 @@ type AuthenticationTokenResponse {
   jwt: String
 }
 
+type AuthenticationSetupTFAResponse {
+  operation: Operation
+  continuationToken: String
+  tfaQRImage: String
+}
+
+type AuthenticationSetupPasskeyResponse {
+  operation: Operation
+  registrationOptions: JSON
+}
+
 input AuthenticationStrategyInput {
   key: String!
   strategyKey: String!

+ 8 - 0
server/graph/schemas/user.graphql

@@ -132,6 +132,7 @@ type User {
   name: String
   email: String
   auth: [UserAuth]
+  passkeys: [UserPasskey]
   hasAvatar: Boolean
   isSystem: Boolean
   isActive: Boolean
@@ -152,6 +153,13 @@ type UserAuth {
   config: JSON
 }
 
+type UserPasskey {
+  id: UUID
+  name: String
+  createdAt: Date
+  siteHostname: String
+}
+
 type UserDefaults {
   timezone: String
   dateFormat: String

+ 16 - 0
server/locales/en.json

@@ -1613,6 +1613,9 @@
   "editor.unsaved.body": "You have unsaved changes. Are you sure you want to leave the editor and discard any modifications you made since the last save?",
   "editor.unsaved.title": "Discard Unsaved Changes?",
   "editor.unsavedWarning": "You have unsaved edits. Are you sure you want to leave the editor?",
+  "error.ERR_PK_ALREADY_REGISTERED": "It looks like this authenticator is already registered.",
+  "error.ERR_PK_HOSTNAME_MISSING": "Your administrator must set a valid site hostname before passkeys can be used.",
+  "error.ERR_PK_USER_CANCELLED": "Passkey registration aborted. Make sure to remove the key from your device.",
   "fileman.7zFileType": "7zip Archive",
   "fileman.aacFileType": "AAC Audio File",
   "fileman.aiFileType": "Adobe Illustrator Document",
@@ -1755,6 +1758,7 @@
   "profile.authLoadingFailed": "Failed to load authentication methods.",
   "profile.authModifyTfa": "Modify 2FA",
   "profile.authSetTfa": "Set 2FA",
+  "profile.authSetTfaLoading": "Setting up 2FA... Please wait",
   "profile.avatar": "Avatar",
   "profile.avatarClearFailed": "Failed to clear profile picture.",
   "profile.avatarClearSuccess": "Profile picture cleared successfully.",
@@ -1798,6 +1802,18 @@
   "profile.pages.refreshSuccess": "Page list has been refreshed.",
   "profile.pages.subtitle": "List of pages I created or last modified",
   "profile.pages.title": "Pages",
+  "profile.passkeys": "Passkeys",
+  "profile.passkeysAdd": "Add Passkey",
+  "profile.passkeysDeactivateConfirm": "Are you sure you want to deactivate this passkey?",
+  "profile.passkeysDeactivateFailed": "Failed to deactivate the passkey.",
+  "profile.passkeysDeactivateSuccess": "Passkey deactivated successfully. You may still need to remove the passkey from your device.",
+  "profile.passkeysIntro": "Passkeys are a replacement for passwords for a faster, easier and more secure login. It relies on your device existing biometrics (phone, computer, security key) to validate your identity.",
+  "profile.passkeysInvalidName": "Passkey name is missing or invalid.",
+  "profile.passkeysName": "Passkey Name",
+  "profile.passkeysNameHint": "Enter a name for your passkey:",
+  "profile.passkeysSetupFailed": "Failed to setup new passkey.",
+  "profile.passkeysSetupSuccess": "Passkey registered successfully.",
+  "profile.passkeysUnsupported": "Passkeys are not supported on your device.",
   "profile.preferences": "Preferences",
   "profile.pronouns": "Pronouns",
   "profile.pronounsHint": "Let people know which pronouns should they use when referring to you.",

+ 2 - 2
server/models/users.mjs

@@ -74,7 +74,7 @@ export class User extends Model {
 
   async generateTFA(strategyId, siteId) {
     WIKI.logger.debug(`Generating new TFA secret for user ${this.id}...`)
-    const site = WIKI.sites[siteId] ?? WIKI.sites[0] ?? { config: { title: 'Wiki' }}
+    const site = WIKI.sites[siteId] ?? WIKI.sites[0] ?? { config: { title: 'Wiki' } }
     const tfaInfo = tfa.generateSecret({
       name: site.config.title,
       account: this.email
@@ -485,7 +485,7 @@ export class User extends Model {
     }
 
     if (user) {
-      user.auth[strategyId].password = await bcrypt.hash(newPassword, 12),
+      user.auth[strategyId].password = await bcrypt.hash(newPassword, 12)
       user.auth[strategyId].mustChangePwd = false
       await user.$query().patch({
         auth: user.auth

+ 24 - 24
server/modules/rendering/image-prefetch/renderer.mjs

@@ -1,30 +1,30 @@
-const request = require('request-promise')
+// TODO: refactor to use fetch()
 
-const prefetch = async (element) => {
-  const url = element.attr(`src`)
-  let response
-  try {
-    response = await request({
-      method: `GET`,
-      url,
-      resolveWithFullResponse: true
-    })
-  } catch (err) {
-    WIKI.logger.warn(`Failed to prefetch ${url}`)
-    WIKI.logger.warn(err)
-    return
-  }
-  const contentType = response.headers[`content-type`]
-  const image = Buffer.from(response.body).toString('base64')
-  element.attr('src', `data:${contentType};base64,${image}`)
-  element.removeClass('prefetch-candidate')
-}
+// const prefetch = async (element) => {
+//   const url = element.attr(`src`)
+//   let response
+//   try {
+//     response = await request({
+//       method: `GET`,
+//       url,
+//       resolveWithFullResponse: true
+//     })
+//   } catch (err) {
+//     WIKI.logger.warn(`Failed to prefetch ${url}`)
+//     WIKI.logger.warn(err)
+//     return
+//   }
+//   const contentType = response.headers[`content-type`]
+//   const image = Buffer.from(response.body).toString('base64')
+//   element.attr('src', `data:${contentType};base64,${image}`)
+//   element.removeClass('prefetch-candidate')
+// }
 
 module.exports = {
   async init($) {
-    const promises = $('img.prefetch-candidate').map((index, element) => {
-      return prefetch($(element))
-    }).toArray()
-    await Promise.all(promises)
+    // const promises = $('img.prefetch-candidate').map((index, element) => {
+    //   return prefetch($(element))
+    // }).toArray()
+    // await Promise.all(promises)
   }
 }

+ 2 - 6
server/package.json

@@ -42,9 +42,11 @@
     "@graphql-tools/schema": "10.0.0",
     "@graphql-tools/utils": "10.0.6",
     "@joplin/turndown-plugin-gfm": "1.0.50",
+    "@node-saml/passport-saml": "4.0.4",
     "@root/csr": "0.8.1",
     "@root/keypairs": "0.10.3",
     "@root/pem": "1.0.4",
+    "@simplewebauthn/server": "8.2.0",
     "acme": "3.0.3",
     "akismet-api": "6.0.0",
     "aws-sdk": "2.1472.0",
@@ -83,8 +85,6 @@
     "graphql-upload": "16.0.2",
     "he": "1.2.0",
     "highlight.js": "11.8.0",
-    "i18next": "23.5.1",
-    "i18next-node-fs-backend": "2.1.3",
     "image-size": "1.0.2",
     "js-base64": "3.7.5",
     "js-binary": "1.2.0",
@@ -138,7 +138,6 @@
     "passport-oauth2": "1.7.0",
     "passport-okta-oauth": "0.0.1",
     "passport-openidconnect": "0.1.1",
-    "passport-saml": "3.2.4",
     "passport-slack-oauth2": "1.2.0",
     "passport-twitch-strategy": "2.2.0",
     "pem-jwk": "2.0.0",
@@ -152,8 +151,6 @@
     "puppeteer-core": "21.3.8",
     "qr-image": "3.2.0",
     "remove-markdown": "0.5.0",
-    "request": "2.88.2",
-    "request-promise": "4.2.6",
     "safe-regex": "2.1.1",
     "sanitize-filename": "1.6.3",
     "scim-query-filter-parser": "2.0.4",
@@ -179,7 +176,6 @@
     "eslint-plugin-import": "2.28.1",
     "eslint-plugin-node": "11.1.0",
     "eslint-plugin-promise": "6.1.1",
-    "eslint-plugin-standard": "5.0.0",
     "nodemon": "3.0.1"
   },
   "overrides": {

+ 265 - 313
server/pnpm-lock.yaml

@@ -23,6 +23,9 @@ dependencies:
   '@joplin/turndown-plugin-gfm':
     specifier: 1.0.50
     version: 1.0.50
+  '@node-saml/passport-saml':
+    specifier: 4.0.4
+    version: 4.0.4
   '@root/csr':
     specifier: 0.8.1
     version: 0.8.1
@@ -32,6 +35,9 @@ dependencies:
   '@root/pem':
     specifier: 1.0.4
     version: 1.0.4
+  '@simplewebauthn/server':
+    specifier: 8.2.0
+    version: 8.2.0
   acme:
     specifier: 3.0.3
     version: 3.0.3
@@ -146,12 +152,6 @@ dependencies:
   highlight.js:
     specifier: 11.8.0
     version: 11.8.0
-  i18next:
-    specifier: 23.5.1
-    version: 23.5.1
-  i18next-node-fs-backend:
-    specifier: 2.1.3
-    version: 2.1.3
   image-size:
     specifier: 1.0.2
     version: 1.0.2
@@ -311,9 +311,6 @@ dependencies:
   passport-openidconnect:
     specifier: 0.1.1
     version: 0.1.1
-  passport-saml:
-    specifier: 3.2.4
-    version: 3.2.4
   passport-slack-oauth2:
     specifier: 1.2.0
     version: 1.2.0
@@ -353,12 +350,6 @@ dependencies:
   remove-markdown:
     specifier: 0.5.0
     version: 0.5.0
-  request:
-    specifier: 2.88.2
-    version: 2.88.2
-  request-promise:
-    specifier: 4.2.6
-    version: 4.2.6(request@2.88.2)
   safe-regex:
     specifier: 2.1.1
     version: 2.1.1
@@ -430,9 +421,6 @@ devDependencies:
   eslint-plugin-promise:
     specifier: 6.1.1
     version: 6.1.1(eslint@8.51.0)
-  eslint-plugin-standard:
-    specifier: 5.0.0
-    version: 5.0.0(eslint@8.51.0)
   nodemon:
     specifier: 3.0.1
     version: 3.0.1
@@ -754,12 +742,53 @@ packages:
       - encoding
     dev: false
 
-  /@babel/runtime@7.23.1:
-    resolution: {integrity: sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g==}
-    engines: {node: '>=6.9.0'}
-    dependencies:
-      regenerator-runtime: 0.14.0
+  /@cbor-extract/cbor-extract-darwin-arm64@2.1.1:
+    resolution: {integrity: sha512-blVBy5MXz6m36Vx0DfLd7PChOQKEs8lK2bD1WJn/vVgG4FXZiZmZb2GECHFvVPA5T7OnODd9xZiL3nMCv6QUhA==}
+    cpu: [arm64]
+    os: [darwin]
+    requiresBuild: true
+    dev: false
+    optional: true
+
+  /@cbor-extract/cbor-extract-darwin-x64@2.1.1:
+    resolution: {integrity: sha512-h6KFOzqk8jXTvkOftyRIWGrd7sKQzQv2jVdTL9nKSf3D2drCvQB/LHUxAOpPXo3pv2clDtKs3xnHalpEh3rDsw==}
+    cpu: [x64]
+    os: [darwin]
+    requiresBuild: true
+    dev: false
+    optional: true
+
+  /@cbor-extract/cbor-extract-linux-arm64@2.1.1:
+    resolution: {integrity: sha512-SxAaRcYf8S0QHaMc7gvRSiTSr7nUYMqbUdErBEu+HYA4Q6UNydx1VwFE68hGcp1qvxcy9yT5U7gA+a5XikfwSQ==}
+    cpu: [arm64]
+    os: [linux]
+    requiresBuild: true
+    dev: false
+    optional: true
+
+  /@cbor-extract/cbor-extract-linux-arm@2.1.1:
+    resolution: {integrity: sha512-ds0uikdcIGUjPyraV4oJqyVE5gl/qYBpa/Wnh6l6xLE2lj/hwnjT2XcZCChdXwW/YFZ1LUHs6waoYN8PmK0nKQ==}
+    cpu: [arm]
+    os: [linux]
+    requiresBuild: true
+    dev: false
+    optional: true
+
+  /@cbor-extract/cbor-extract-linux-x64@2.1.1:
+    resolution: {integrity: sha512-GVK+8fNIE9lJQHAlhOROYiI0Yd4bAZ4u++C2ZjlkS3YmO6hi+FUxe6Dqm+OKWTcMpL/l71N6CQAmaRcb4zyJuA==}
+    cpu: [x64]
+    os: [linux]
+    requiresBuild: true
+    dev: false
+    optional: true
+
+  /@cbor-extract/cbor-extract-win32-x64@2.1.1:
+    resolution: {integrity: sha512-2Niq1C41dCRIDeD8LddiH+mxGlO7HJ612Ll3D/E73ZWBmycued+8ghTr/Ho3CMOWPUEr08XtyBMVXAjqF+TcKw==}
+    cpu: [x64]
+    os: [win32]
+    requiresBuild: true
     dev: false
+    optional: true
 
   /@eslint-community/eslint-utils@4.4.0(eslint@8.51.0):
     resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
@@ -880,6 +909,10 @@ packages:
       graphql: 16.8.1
     dev: false
 
+  /@hexagon/base64@1.1.28:
+    resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
+    dev: false
+
   /@humanwhocodes/config-array@0.11.11:
     resolution: {integrity: sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==}
     engines: {node: '>=10.10.0'}
@@ -920,6 +953,39 @@ packages:
     resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
     dev: false
 
+  /@node-saml/node-saml@4.0.5:
+    resolution: {integrity: sha512-J5DglElbY1tjOuaR1NPtjOXkXY5bpUhDoKVoeucYN98A3w4fwgjIOPqIGcb6cQsqFq2zZ6vTCeKn5C/hvefSaw==}
+    engines: {node: '>= 14'}
+    dependencies:
+      '@types/debug': 4.1.9
+      '@types/passport': 1.0.13
+      '@types/xml-crypto': 1.4.3
+      '@types/xml-encryption': 1.2.2
+      '@types/xml2js': 0.4.12
+      '@xmldom/xmldom': 0.8.10
+      debug: 4.3.4
+      xml-crypto: 3.2.0
+      xml-encryption: 3.0.2
+      xml2js: 0.5.0
+      xmlbuilder: 15.1.1
+    transitivePeerDependencies:
+      - supports-color
+    dev: false
+
+  /@node-saml/passport-saml@4.0.4:
+    resolution: {integrity: sha512-xFw3gw0yo+K1mzlkW15NeBF7cVpRHN/4vpjmBKzov5YFImCWh/G0LcTZ8krH3yk2/eRPc3Or8LRPudVJBjmYaw==}
+    engines: {node: '>= 14'}
+    dependencies:
+      '@node-saml/node-saml': 4.0.5
+      '@types/express': 4.17.18
+      '@types/passport': 1.0.13
+      '@types/passport-strategy': 0.2.36
+      passport: 0.6.0
+      passport-strategy: 1.0.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: false
+
   /@nodelib/fs.scandir@2.1.5:
     resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
     engines: {node: '>= 8'}
@@ -1194,6 +1260,50 @@ packages:
     engines: {node: '>=8.0.0'}
     dev: false
 
+  /@peculiar/asn1-android@2.3.6:
+    resolution: {integrity: sha512-zkYh4DsiRhiNfg6tWaUuRc+huwlb9XJbmeZLrjTz9v76UK1Ehq3EnfJFED6P3sdznW/nqWe46LoM9JrqxcD58g==}
+    dependencies:
+      '@peculiar/asn1-schema': 2.3.6
+      asn1js: 3.0.5
+      tslib: 2.6.2
+    dev: false
+
+  /@peculiar/asn1-ecc@2.3.6:
+    resolution: {integrity: sha512-Hu1xzMJQWv8/GvzOiinaE6XiD1/kEhq2C/V89UEoWeZ2fLUcGNIvMxOr/pMyL0OmpRWj/mhCTXOZp4PP+a0aTg==}
+    dependencies:
+      '@peculiar/asn1-schema': 2.3.6
+      '@peculiar/asn1-x509': 2.3.6
+      asn1js: 3.0.5
+      tslib: 2.6.2
+    dev: false
+
+  /@peculiar/asn1-rsa@2.3.6:
+    resolution: {integrity: sha512-DswjJyAXZnvESuImGNTvbNKvh1XApBVqU+r3UmrFFTAI23gv62byl0f5OFKWTNhCf66WQrd3sklpsCZc/4+jwA==}
+    dependencies:
+      '@peculiar/asn1-schema': 2.3.6
+      '@peculiar/asn1-x509': 2.3.6
+      asn1js: 3.0.5
+      tslib: 2.6.2
+    dev: false
+
+  /@peculiar/asn1-schema@2.3.6:
+    resolution: {integrity: sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==}
+    dependencies:
+      asn1js: 3.0.5
+      pvtsutils: 1.3.5
+      tslib: 2.6.2
+    dev: false
+
+  /@peculiar/asn1-x509@2.3.6:
+    resolution: {integrity: sha512-dRwX31R1lcbIdzbztiMvLNTDoGptxdV7HocNx87LfKU0fEWh7fTWJjx4oV+glETSy6heF/hJHB2J4RGB3vVSYg==}
+    dependencies:
+      '@peculiar/asn1-schema': 2.3.6
+      asn1js: 3.0.5
+      ipaddr.js: 2.1.0
+      pvtsutils: 1.3.5
+      tslib: 2.6.2
+    dev: false
+
   /@protobufjs/aspromise@1.1.2:
     resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
     dev: false
@@ -1306,6 +1416,27 @@ packages:
       '@root/encoding': 1.0.1
     dev: false
 
+  /@simplewebauthn/server@8.2.0:
+    resolution: {integrity: sha512-nknf7kCa5V61Kk2zn1vTuKeAlyut9aWduIcbHNQWpMCEJqH/m8cXpb+9UV42MEQRIk8JVC1GSNeEx56QVTfJHw==}
+    engines: {node: '>=16.0.0'}
+    dependencies:
+      '@hexagon/base64': 1.1.28
+      '@peculiar/asn1-android': 2.3.6
+      '@peculiar/asn1-ecc': 2.3.6
+      '@peculiar/asn1-rsa': 2.3.6
+      '@peculiar/asn1-schema': 2.3.6
+      '@peculiar/asn1-x509': 2.3.6
+      '@simplewebauthn/typescript-types': 8.0.0
+      cbor-x: 1.5.4
+      cross-fetch: 4.0.0
+    transitivePeerDependencies:
+      - encoding
+    dev: false
+
+  /@simplewebauthn/typescript-types@8.0.0:
+    resolution: {integrity: sha512-d7Izb2H+LZJteXMkS8DmpAarD6mZdpIOu/av/yH4/u/3Pd6DKFLyBM3j8BMmUvUqpzvJvHARNrRfQYto58mtTQ==}
+    dev: false
+
   /@socket.io/component-emitter@3.1.0:
     resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==}
     dev: false
@@ -1360,6 +1491,12 @@ packages:
       '@types/node': 20.8.3
     dev: false
 
+  /@types/debug@4.1.9:
+    resolution: {integrity: sha512-8Hz50m2eoS56ldRlepxSBa6PWEVCtzUo/92HgLc2qTMnotJNIm7xP+UZhyWoYsyOdd5dxZ+NZLb24rsKyFs2ow==}
+    dependencies:
+      '@types/ms': 0.7.32
+    dev: false
+
   /@types/express-serve-static-core@4.17.37:
     resolution: {integrity: sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg==}
     dependencies:
@@ -1425,6 +1562,10 @@ packages:
     resolution: {integrity: sha512-Wj+fqpTLtTbG7c0tH47dkahefpLKEbB+xAZuLq7b4/IDHPl/n6VoXcyUQ2bypFlbSwvCr0y+bD4euTTqTJsPxQ==}
     dev: false
 
+  /@types/ms@0.7.32:
+    resolution: {integrity: sha512-xPSg0jm4mqgEkNhowKgZFBNtwoEwF6gJ4Dhww+GFpm3IgtNseHQZ5IqdNwnquZEoANxyDAKDRAdVo4Z72VvD/g==}
+    dev: false
+
   /@types/node-fetch@2.6.6:
     resolution: {integrity: sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==}
     dependencies:
@@ -1446,6 +1587,19 @@ packages:
     resolution: {integrity: sha512-STkyj0IQkgbmohF1afXQN64KucE3w7EgSbNJxqkJoq0KHVBV4nU5Pyku+TM9UCiCLXhZlkEFd8zq38P8lDFi6g==}
     dev: false
 
+  /@types/passport-strategy@0.2.36:
+    resolution: {integrity: sha512-hotVZuaCt04LJYXfZD5B+5UeCcRVG8IjKaLLGTJ1eFp0wiFQA2XfsqslGGInWje+OysNNLPH/ducce5GXHDC1Q==}
+    dependencies:
+      '@types/express': 4.17.18
+      '@types/passport': 1.0.13
+    dev: false
+
+  /@types/passport@1.0.13:
+    resolution: {integrity: sha512-XXURryL+EZAWtbQFOHX1eNB+RJwz5XMPPz1xrGpEKr2xUZCXM4NCPkHMtZQ3B2tTSG/1IRaAcTHjczRA4sSFCw==}
+    dependencies:
+      '@types/express': 4.17.18
+    dev: false
+
   /@types/qs@6.9.8:
     resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==}
     dev: false
@@ -1475,6 +1629,25 @@ packages:
       '@types/node': 20.8.3
     dev: false
 
+  /@types/xml-crypto@1.4.3:
+    resolution: {integrity: sha512-pnvKYb7vUsUIMc+C6JM/j779YWQgOMcwjnqHJ9cdaWXwWEBE1hAqthzeszRx62V5RWMvS+XS9w9tXMOYyUc8zg==}
+    dependencies:
+      '@types/node': 20.8.3
+      xpath: 0.0.27
+    dev: false
+
+  /@types/xml-encryption@1.2.2:
+    resolution: {integrity: sha512-UeuYOqW3ZzUQfwb/mb3GNZ2/DlVdh5mjJNmB/yFXgQr8/pwlVJ9I2w+AHPfRDzLshe7YpgUB4T1//qgbk6U87Q==}
+    dependencies:
+      '@types/node': 20.8.3
+    dev: false
+
+  /@types/xml2js@0.4.12:
+    resolution: {integrity: sha512-CZPpQKBZ8db66EP5hCjwvYrLThgZvnyZrPXK2W+UI1oOaWezGt34iOaUCX4Jah2X8+rQqjvl9VKEIT8TR1I0rA==}
+    dependencies:
+      '@types/node': 20.8.3
+    dev: false
+
   /@types/yauzl@2.10.1:
     resolution: {integrity: sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==}
     requiresBuild: true
@@ -1519,8 +1692,8 @@ packages:
     dev: false
     optional: true
 
-  /@xmldom/xmldom@0.7.13:
-    resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==}
+  /@xmldom/xmldom@0.8.10:
+    resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
     engines: {node: '>=10.0.0'}
     dev: false
 
@@ -1607,6 +1780,7 @@ packages:
       fast-json-stable-stringify: 2.1.0
       json-schema-traverse: 0.4.1
       uri-js: 4.4.1
+    dev: true
 
   /ajv@8.12.0:
     resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==}
@@ -1658,12 +1832,6 @@ packages:
     resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
     dev: false
 
-  /argparse@1.0.10:
-    resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
-    dependencies:
-      sprintf-js: 1.0.3
-    dev: false
-
   /argparse@2.0.1:
     resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
 
@@ -1752,6 +1920,15 @@ packages:
       safer-buffer: 2.1.2
     dev: false
 
+  /asn1js@3.0.5:
+    resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==}
+    engines: {node: '>=12.0.0'}
+    dependencies:
+      pvtsutils: 1.3.5
+      pvutils: 1.1.3
+      tslib: 2.6.2
+    dev: false
+
   /assert-plus@1.0.0:
     resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==}
     engines: {node: '>=0.8'}
@@ -1802,14 +1979,6 @@ packages:
       xml2js: 0.5.0
     dev: false
 
-  /aws-sign2@0.7.0:
-    resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==}
-    dev: false
-
-  /aws4@1.12.0:
-    resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==}
-    dev: false
-
   /axios@0.27.2:
     resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==}
     dependencies:
@@ -1852,12 +2021,6 @@ packages:
     engines: {node: '>=10.0.0'}
     dev: false
 
-  /bcrypt-pbkdf@1.0.2:
-    resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
-    dependencies:
-      tweetnacl: 0.14.5
-    dev: false
-
   /bcryptjs@2.4.3:
     resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
     dev: false
@@ -2042,8 +2205,26 @@ packages:
     engines: {node: '>=6'}
     dev: true
 
-  /caseless@0.12.0:
-    resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
+  /cbor-extract@2.1.1:
+    resolution: {integrity: sha512-1UX977+L+zOJHsp0mWFG13GLwO6ucKgSmSW6JTl8B9GUvACvHeIVpFqhU92299Z6PfD09aTXDell5p+lp1rUFA==}
+    hasBin: true
+    requiresBuild: true
+    dependencies:
+      node-gyp-build-optional-packages: 5.0.3
+    optionalDependencies:
+      '@cbor-extract/cbor-extract-darwin-arm64': 2.1.1
+      '@cbor-extract/cbor-extract-darwin-x64': 2.1.1
+      '@cbor-extract/cbor-extract-linux-arm': 2.1.1
+      '@cbor-extract/cbor-extract-linux-arm64': 2.1.1
+      '@cbor-extract/cbor-extract-linux-x64': 2.1.1
+      '@cbor-extract/cbor-extract-win32-x64': 2.1.1
+    dev: false
+    optional: true
+
+  /cbor-x@1.5.4:
+    resolution: {integrity: sha512-PVKILDn+Rf6MRhhcyzGXi5eizn1i0i3F8Fe6UMMxXBnWkalq9+C5+VTmlIjAYM4iF2IYF2N+zToqAfYOp+3rfw==}
+    optionalDependencies:
+      cbor-extract: 2.1.1
     dev: false
 
   /chalk@4.1.2:
@@ -2367,13 +2548,6 @@ packages:
     resolution: {integrity: sha512-YOrdiS4b/ItbPAtyEIpkhqryoul2Bu8vtX+SN2nmxsqPnqAfh48Nu9p6zdTp9iCgCoSb6Ib8B0y4UUznaVXgtA==}
     dev: false
 
-  /dashdash@1.14.1:
-    resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==}
-    engines: {node: '>=0.10'}
-    dependencies:
-      assert-plus: 1.0.0
-    dev: false
-
   /data-uri-to-buffer@6.0.1:
     resolution: {integrity: sha512-MZd3VlchQkp8rdend6vrx7MmVDJzSNTBvghvKjirLkD+WTChA3KUf0jkE68Q4UyctNqI11zZO9/x2Yx+ub5Cvg==}
     engines: {node: '>= 14'}
@@ -2602,13 +2776,6 @@ packages:
     dev: false
     optional: true
 
-  /ecc-jsbn@0.1.2:
-    resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==}
-    dependencies:
-      jsbn: 0.1.1
-      safer-buffer: 2.1.2
-    dev: false
-
   /ecdsa-sig-formatter@1.0.11:
     resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
     dependencies:
@@ -3150,10 +3317,6 @@ packages:
       - supports-color
     dev: false
 
-  /extend@3.0.2:
-    resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
-    dev: false
-
   /extract-zip@2.0.1:
     resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
     engines: {node: '>= 10.17.0'}
@@ -3187,6 +3350,7 @@ packages:
 
   /fast-json-stable-stringify@2.1.0:
     resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+    dev: true
 
   /fast-levenshtein@2.0.6:
     resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
@@ -3286,19 +3450,6 @@ packages:
     dependencies:
       is-callable: 1.2.7
 
-  /forever-agent@0.6.1:
-    resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}
-    dev: false
-
-  /form-data@2.3.3:
-    resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
-    engines: {node: '>= 0.12'}
-    dependencies:
-      asynckit: 0.4.0
-      combined-stream: 1.0.8
-      mime-types: 2.1.35
-    dev: false
-
   /form-data@4.0.0:
     resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
     engines: {node: '>= 6'}
@@ -3443,12 +3594,6 @@ packages:
       async: 3.2.4
     dev: false
 
-  /getpass@0.1.7:
-    resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==}
-    dependencies:
-      assert-plus: 1.0.0
-    dev: false
-
   /github-from-package@0.0.0:
     resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
     dev: false
@@ -3595,20 +3740,6 @@ packages:
     engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
     dev: false
 
-  /har-schema@2.0.0:
-    resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==}
-    engines: {node: '>=4'}
-    dev: false
-
-  /har-validator@5.1.5:
-    resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==}
-    engines: {node: '>=6'}
-    deprecated: this library is no longer supported
-    dependencies:
-      ajv: 6.12.6
-      har-schema: 2.0.0
-    dev: false
-
   /has-bigints@1.0.2:
     resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
     dev: true
@@ -3726,15 +3857,6 @@ packages:
       - supports-color
     dev: false
 
-  /http-signature@1.2.0:
-    resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==}
-    engines: {node: '>=0.8', npm: '>=1.3.7'}
-    dependencies:
-      assert-plus: 1.0.0
-      jsprim: 1.4.2
-      sshpk: 1.17.0
-    dev: false
-
   /https-proxy-agent@5.0.1:
     resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
     engines: {node: '>= 6'}
@@ -3755,20 +3877,6 @@ packages:
       - supports-color
     dev: false
 
-  /i18next-node-fs-backend@2.1.3:
-    resolution: {integrity: sha512-CreMFiVl3ChlMc5ys/e0QfuLFOZyFcL40Jj6jaKD6DxZ/GCUMxPI9BpU43QMWUgC7r+PClpxg2cGXAl0CjG04g==}
-    deprecated: replaced by i18next-fs-backend
-    dependencies:
-      js-yaml: 3.13.1
-      json5: 2.0.0
-    dev: false
-
-  /i18next@23.5.1:
-    resolution: {integrity: sha512-JelYzcaCoFDaa+Ysbfz2JsGAKkrHiMG6S61+HLBUEIPaF40WMwW9hCPymlQGrP+wWawKxKPuSuD71WZscCsWHg==}
-    dependencies:
-      '@babel/runtime': 7.23.1
-    dev: false
-
   /iconv-lite@0.4.24:
     resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
     engines: {node: '>=0.10.0'}
@@ -3866,6 +3974,11 @@ packages:
     engines: {node: '>= 0.10'}
     dev: false
 
+  /ipaddr.js@2.1.0:
+    resolution: {integrity: sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==}
+    engines: {node: '>= 10'}
+    dev: false
+
   /is-arguments@1.1.1:
     resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==}
     engines: {node: '>= 0.4'}
@@ -4008,10 +4121,6 @@ packages:
     dependencies:
       which-typed-array: 1.1.11
 
-  /is-typedarray@1.0.0:
-    resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==}
-    dev: false
-
   /is-weakref@1.0.2:
     resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==}
     dependencies:
@@ -4030,10 +4139,6 @@ packages:
     resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
     dev: true
 
-  /isstream@0.1.2:
-    resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==}
-    dev: false
-
   /jmespath@0.16.0:
     resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==}
     engines: {node: '>= 0.6.0'}
@@ -4054,24 +4159,12 @@ packages:
     dev: false
     optional: true
 
-  /js-yaml@3.13.1:
-    resolution: {integrity: sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==}
-    hasBin: true
-    dependencies:
-      argparse: 1.0.10
-      esprima: 4.0.1
-    dev: false
-
   /js-yaml@4.1.0:
     resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
     hasBin: true
     dependencies:
       argparse: 2.0.1
 
-  /jsbn@0.1.1:
-    resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==}
-    dev: false
-
   /jsdom@22.1.0:
     resolution: {integrity: sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==}
     engines: {node: '>=16'}
@@ -4116,23 +4209,16 @@ packages:
 
   /json-schema-traverse@0.4.1:
     resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+    dev: true
 
   /json-schema-traverse@1.0.0:
     resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
     dev: false
 
-  /json-schema@0.4.0:
-    resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
-    dev: false
-
   /json-stable-stringify-without-jsonify@1.0.1:
     resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
     dev: true
 
-  /json-stringify-safe@5.0.1:
-    resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
-    dev: false
-
   /json5@1.0.2:
     resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
     hasBin: true
@@ -4140,14 +4226,6 @@ packages:
       minimist: 1.2.8
     dev: true
 
-  /json5@2.0.0:
-    resolution: {integrity: sha512-0EdQvHuLm7yJ7lyG5dp7Q3X2ku++BG5ZHaJ5FTnaXpKqDrw4pMxel5Bt3oAYMthnrthFBdnZ1FcsXTPyrQlV0w==}
-    engines: {node: '>=6'}
-    hasBin: true
-    dependencies:
-      minimist: 1.2.8
-    dev: false
-
   /jsonfile@4.0.0:
     resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
     optionalDependencies:
@@ -4186,16 +4264,6 @@ packages:
       semver: 7.5.4
     dev: false
 
-  /jsprim@1.4.2:
-    resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==}
-    engines: {node: '>=0.6.0'}
-    dependencies:
-      assert-plus: 1.0.0
-      extsprintf: 1.3.0
-      json-schema: 0.4.0
-      verror: 1.10.0
-    dev: false
-
   /jwa@1.4.1:
     resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==}
     dependencies:
@@ -4763,6 +4831,13 @@ packages:
     engines: {node: '>= 6.13.0'}
     dev: false
 
+  /node-gyp-build-optional-packages@5.0.3:
+    resolution: {integrity: sha512-k75jcVzk5wnnc/FMxsf4udAoTEUv2jY3ycfdSd3yWu6Cnd1oee6/CfZJApyscA4FJOmdoixWwiwOyf16RzD5JA==}
+    hasBin: true
+    requiresBuild: true
+    dev: false
+    optional: true
+
   /node-jose@2.2.0:
     resolution: {integrity: sha512-XPCvJRr94SjLrSIm4pbYHKLEaOsDvJCpyFw/6V/KK/IXmyZ6SFBzAUDO9HQf4DB/nTEFcRGH87mNciOP23kFjw==}
     dependencies:
@@ -4829,10 +4904,6 @@ packages:
     resolution: {integrity: sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==}
     dev: false
 
-  /oauth-sign@0.9.0:
-    resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
-    dev: false
-
   /oauth@0.9.15:
     resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==}
     dev: false
@@ -5194,22 +5265,6 @@ packages:
       passport-strategy: 1.0.0
     dev: false
 
-  /passport-saml@3.2.4:
-    resolution: {integrity: sha512-JSgkFXeaexLNQh1RrOvJAgjLnZzH/S3HbX/mWAk+i7aulnjqUe7WKnPl1NPnJWqP7Dqsv0I2Xm6KIFHkftk0HA==}
-    engines: {node: '>= 12'}
-    deprecated: For versions >= 4, please use scopped package @node-saml/passport-saml
-    dependencies:
-      '@xmldom/xmldom': 0.7.13
-      debug: 4.3.4
-      passport-strategy: 1.0.0
-      xml-crypto: 2.1.5
-      xml-encryption: 2.0.0
-      xml2js: 0.4.23
-      xmlbuilder: 15.1.1
-    transitivePeerDependencies:
-      - supports-color
-    dev: false
-
   /passport-slack-oauth2@1.2.0:
     resolution: {integrity: sha512-SeQl8uPoi4ajhzgIvwQM7gW/6yPrKH0hPFjxcP/426SOZ0M9ZNDOfSa32q3NTw7KcwYOTjyWX/2xdJndQE7Rkg==}
     dependencies:
@@ -5281,10 +5336,6 @@ packages:
     resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
     dev: false
 
-  /performance-now@2.1.0:
-    resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
-    dev: false
-
   /pg-cloudflare@1.1.1:
     resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==}
     requiresBuild: true
@@ -5579,6 +5630,17 @@ packages:
       - utf-8-validate
     dev: false
 
+  /pvtsutils@1.3.5:
+    resolution: {integrity: sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==}
+    dependencies:
+      tslib: 2.6.2
+    dev: false
+
+  /pvutils@1.1.3:
+    resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
+    engines: {node: '>=6.0.0'}
+    dev: false
+
   /qr-image@3.2.0:
     resolution: {integrity: sha512-rXKDS5Sx3YipVsqmlMJsJsk6jXylEpiHRC2+nJy66fxA5ExYyGa4PqwteW69SaVmAb2OQ18HbYriT7cGQMbduw==}
     dev: false
@@ -5597,11 +5659,6 @@ packages:
       side-channel: 1.0.4
     dev: false
 
-  /qs@6.5.3:
-    resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==}
-    engines: {node: '>=0.6'}
-    dev: false
-
   /querystring@0.2.0:
     resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==}
     engines: {node: '>=0.4.x'}
@@ -5717,10 +5774,6 @@ packages:
       resolve: 1.22.6
     dev: false
 
-  /regenerator-runtime@0.14.0:
-    resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==}
-    dev: false
-
   /regexp-tree@0.1.27:
     resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
     hasBin: true
@@ -5744,57 +5797,6 @@ packages:
     resolution: {integrity: sha512-x917M80K97K5IN1L8lUvFehsfhR8cYjGQ/yAMRI9E7JIKivtl5Emo5iD13DhMr+VojzMCiYk8V2byNPwT/oapg==}
     dev: false
 
-  /request-promise-core@1.1.4(request@2.88.2):
-    resolution: {integrity: sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==}
-    engines: {node: '>=0.10.0'}
-    peerDependencies:
-      request: ^2.34
-    dependencies:
-      lodash: 4.17.21
-      request: 2.88.2
-    dev: false
-
-  /request-promise@4.2.6(request@2.88.2):
-    resolution: {integrity: sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==}
-    engines: {node: '>=0.10.0'}
-    deprecated: request-promise has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142
-    peerDependencies:
-      request: ^2.34
-    dependencies:
-      bluebird: 3.7.2
-      request: 2.88.2
-      request-promise-core: 1.1.4(request@2.88.2)
-      stealthy-require: 1.1.1
-      tough-cookie: 2.5.0
-    dev: false
-
-  /request@2.88.2:
-    resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==}
-    engines: {node: '>= 6'}
-    deprecated: request has been deprecated, see https://github.com/request/request/issues/3142
-    dependencies:
-      aws-sign2: 0.7.0
-      aws4: 1.12.0
-      caseless: 0.12.0
-      combined-stream: 1.0.8
-      extend: 3.0.2
-      forever-agent: 0.6.1
-      form-data: 2.3.3
-      har-validator: 5.1.5
-      http-signature: 1.2.0
-      is-typedarray: 1.0.0
-      isstream: 0.1.2
-      json-stringify-safe: 5.0.1
-      mime-types: 2.1.35
-      oauth-sign: 0.9.0
-      performance-now: 2.1.0
-      qs: 6.5.3
-      safe-buffer: 5.2.1
-      tough-cookie: 2.5.0
-      tunnel-agent: 0.6.0
-      uuid: 3.4.0
-    dev: false
-
   /require-directory@2.1.1:
     resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
     engines: {node: '>=0.10.0'}
@@ -5934,10 +5936,6 @@ packages:
     resolution: {integrity: sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==}
     dev: false
 
-  /sax@1.3.0:
-    resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==}
-    dev: false
-
   /saxes@6.0.0:
     resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
     engines: {node: '>=v12.22.7'}
@@ -6168,36 +6166,11 @@ packages:
     engines: {node: '>= 10.x'}
     dev: false
 
-  /sprintf-js@1.0.3:
-    resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
-    dev: false
-
-  /sshpk@1.17.0:
-    resolution: {integrity: sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==}
-    engines: {node: '>=0.10.0'}
-    hasBin: true
-    dependencies:
-      asn1: 0.2.6
-      assert-plus: 1.0.0
-      bcrypt-pbkdf: 1.0.2
-      dashdash: 1.14.1
-      ecc-jsbn: 0.1.2
-      getpass: 0.1.7
-      jsbn: 0.1.1
-      safer-buffer: 2.1.2
-      tweetnacl: 0.14.5
-    dev: false
-
   /statuses@2.0.1:
     resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
     engines: {node: '>= 0.8'}
     dev: false
 
-  /stealthy-require@1.1.1:
-    resolution: {integrity: sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==}
-    engines: {node: '>=0.10.0'}
-    dev: false
-
   /streamsearch@1.1.0:
     resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
     engines: {node: '>=10.0.0'}
@@ -6421,14 +6394,6 @@ packages:
       nopt: 1.0.10
     dev: true
 
-  /tough-cookie@2.5.0:
-    resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==}
-    engines: {node: '>=0.8'}
-    dependencies:
-      psl: 1.9.0
-      punycode: 2.3.0
-    dev: false
-
   /tough-cookie@4.1.3:
     resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==}
     engines: {node: '>=6'}
@@ -6495,10 +6460,6 @@ packages:
       domino: 2.1.6
     dev: false
 
-  /tweetnacl@0.14.5:
-    resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
-    dev: false
-
   /twemoji-parser@14.0.0:
     resolution: {integrity: sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==}
     dev: false
@@ -6714,12 +6675,6 @@ packages:
     engines: {node: '>= 0.4.0'}
     dev: false
 
-  /uuid@3.4.0:
-    resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==}
-    deprecated: Please upgrade  to version 7 or higher.  Older versions may use Math.random() in certain circumstances, which is known to be problematic.  See https://v8.dev/blog/math-random for details.
-    hasBin: true
-    dev: false
-
   /uuid@8.0.0:
     resolution: {integrity: sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==}
     hasBin: true
@@ -6887,19 +6842,19 @@ packages:
         optional: true
     dev: false
 
-  /xml-crypto@2.1.5:
-    resolution: {integrity: sha512-xOSJmGFm+BTXmaPYk8pPV3duKo6hJuZ5niN4uMzoNcTlwYs0jAu/N3qY+ud9MhE4N7eMRuC1ayC7Yhmb7MmAWg==}
-    engines: {node: '>=0.4.0'}
+  /xml-crypto@3.2.0:
+    resolution: {integrity: sha512-qVurBUOQrmvlgmZqIVBqmb06TD2a/PpEUfFPgD7BuBfjmoH4zgkqaWSIJrnymlCvM2GGt9x+XtJFA+ttoAufqg==}
+    engines: {node: '>=4.0.0'}
     dependencies:
-      '@xmldom/xmldom': 0.7.13
+      '@xmldom/xmldom': 0.8.10
       xpath: 0.0.32
     dev: false
 
-  /xml-encryption@2.0.0:
-    resolution: {integrity: sha512-4Av83DdvAgUQQMfi/w8G01aJshbEZP9ewjmZMpS9t3H+OCZBDvyK4GJPnHGfWiXlArnPbYvR58JB9qF2x9Ds+Q==}
+  /xml-encryption@3.0.2:
+    resolution: {integrity: sha512-VxYXPvsWB01/aqVLd6ZMPWZ+qaj0aIdF+cStrVJMcFj3iymwZeI0ABzB3VqMYv48DkSpRhnrXqTUkR34j+UDyg==}
     engines: {node: '>=12'}
     dependencies:
-      '@xmldom/xmldom': 0.7.13
+      '@xmldom/xmldom': 0.8.10
       escape-html: 1.0.3
       xpath: 0.0.32
     dev: false
@@ -6909,14 +6864,6 @@ packages:
     engines: {node: '>=12'}
     dev: false
 
-  /xml2js@0.4.23:
-    resolution: {integrity: sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==}
-    engines: {node: '>=4.0.0'}
-    dependencies:
-      sax: 1.3.0
-      xmlbuilder: 11.0.1
-    dev: false
-
   /xml2js@0.4.4:
     resolution: {integrity: sha512-9ERdxLOo4EazMDHAS/vsuZiTXIMur6ydcRfzGrFVJ4qM78zD3ohUgPJC7NYpGwd5rnS0ufSydMJClh6jyH+V0w==}
     dependencies:
@@ -6946,6 +6893,11 @@ packages:
     resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
     dev: false
 
+  /xpath@0.0.27:
+    resolution: {integrity: sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ==}
+    engines: {node: '>=0.6.0'}
+    dev: false
+
   /xpath@0.0.32:
     resolution: {integrity: sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==}
     engines: {node: '>=0.6.0'}

+ 1 - 0
ux/package.json

@@ -17,6 +17,7 @@
     "@lezer/common": "1.1.0",
     "@mdi/font": "7.3.67",
     "@quasar/extras": "1.16.7",
+    "@simplewebauthn/browser": "8.3.1",
     "@tiptap/core": "2.1.11",
     "@tiptap/extension-code-block": "2.1.11",
     "@tiptap/extension-code-block-lowlight": "2.1.11",

+ 13 - 0
ux/pnpm-lock.yaml

@@ -17,6 +17,9 @@ dependencies:
   '@quasar/extras':
     specifier: 1.16.7
     version: 1.16.7
+  '@simplewebauthn/browser':
+    specifier: 8.3.1
+    version: 8.3.1
   '@tiptap/core':
     specifier: 2.1.11
     version: 2.1.11(@tiptap/pm@2.1.11)
@@ -709,6 +712,16 @@ packages:
       picomatch: 2.3.1
     dev: true
 
+  /@simplewebauthn/browser@8.3.1:
+    resolution: {integrity: sha512-bMW7oOkxX4ydRAkkPtJ1do2k9yOoIGc/hZYebcuEOVdJoC6wwVpu97mYY7Mz8B9hLlcaR5WFgBsLl5tSJVzm8A==}
+    dependencies:
+      '@simplewebauthn/typescript-types': 8.0.0
+    dev: false
+
+  /@simplewebauthn/typescript-types@8.0.0:
+    resolution: {integrity: sha512-d7Izb2H+LZJteXMkS8DmpAarD6mZdpIOu/av/yH4/u/3Pd6DKFLyBM3j8BMmUvUqpzvJvHARNrRfQYto58mtTQ==}
+    dev: false
+
   /@socket.io/component-emitter@3.1.0:
     resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==}
     dev: false

File diff suppressed because it is too large
+ 0 - 0
ux/public/_assets/icons/fluent-add-key.svg


File diff suppressed because it is too large
+ 0 - 0
ux/public/_assets/icons/fluent-fingerprint.svg


+ 83 - 0
ux/src/components/PasskeyCreateDialog.vue

@@ -0,0 +1,83 @@
+<template lang="pug">
+q-dialog(ref='dialogRef', @hide='onDialogHide', persistent)
+  q-card(style='min-width: 650px;')
+    q-card-section.card-header
+      q-icon(name='img:/_assets/icons/fluent-add-key.svg', left, size='sm')
+      span {{t(`profile.passkeysAdd`)}}
+    .q-py-sm
+      .text-body2.q-px-md.q-py-sm {{t(`profile.passkeysNameHint`)}}
+      q-item
+        blueprint-icon(icon='key')
+        q-item-section
+          q-input(
+            outlined
+            v-model='state.name'
+            dense
+            hide-bottom-space
+            :label='t(`profile.passkeysName`)'
+            :aria-label='t(`profile.passkeysName`)'
+            autofocus
+            @keyup.enter='save'
+            )
+    q-card-actions.card-actions
+      q-space
+      q-btn.acrylic-btn(
+        flat
+        :label='t(`common.actions.cancel`)'
+        color='grey'
+        padding='xs md'
+        @click='onDialogCancel'
+        )
+      q-btn(
+        unelevated
+        :label='t(`common.actions.save`)'
+        color='primary'
+        padding='xs md'
+        @click='save'
+        )
+</template>
+
+<script setup>
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { reactive } from 'vue'
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  name: ''
+})
+
+// METHODS
+
+async function save () {
+  try {
+    if (!state.name || state.name.trim().length < 1 || state.name.length > 255) {
+      throw new Error(t('profile.passkeysInvalidName'))
+    }
+    onDialogOK({
+      name: state.name
+    })
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+}
+</script>

+ 146 - 159
ux/src/components/SetupTfaDialog.vue

@@ -1,97 +1,57 @@
 <template lang="pug">
-q-dialog(ref='dialogRef', @hide='onDialogHide')
-  q-card(style='min-width: 650px;')
+q-dialog(ref='dialogRef', @hide='onDialogHide', persistent)
+  q-card.setup2fadialog(style='min-width: 450px;')
     q-card-section.card-header
-      q-icon(name='img:/_assets/icons/fluent-password-reset.svg', left, size='sm')
-      span {{t(`admin.users.changePassword`)}}
-    q-form.q-py-sm(ref='changeUserPwdForm', @submit='save')
-      q-item
-        blueprint-icon(icon='lock')
-        q-item-section
-          q-input(
-            outlined
-            v-model='state.currentPassword'
-            dense
-            :rules='currentPasswordValidation'
-            hide-bottom-space
-            :label='t(`auth.changePwd.currentPassword`)'
-            :aria-label='t(`auth.changePwd.currentPassword`)'
-            lazy-rules='ondemand'
-            autofocus
-            )
-      q-item
-        blueprint-icon(icon='password')
-        q-item-section
-          q-input(
-            outlined
-            v-model='state.newPassword'
-            dense
-            :rules='newPasswordValidation'
-            hide-bottom-space
-            :label='t(`auth.changePwd.newPassword`)'
-            :aria-label='t(`auth.changePwd.newPassword`)'
-            lazy-rules='ondemand'
-            autofocus
-            )
-            template(#append)
-              .flex.items-center
-                q-badge(
-                  :color='passwordStrength.color'
-                  :label='passwordStrength.label'
-                )
-                q-separator.q-mx-sm(vertical)
-                q-btn(
-                  flat
-                  dense
-                  padding='none xs'
-                  color='brown'
-                  @click='randomizePassword'
-                  )
-                  q-icon(name='las la-dice-d6')
-                  .q-pl-xs.text-caption: strong Generate
-      q-item
-        blueprint-icon(icon='good-pincode')
-        q-item-section
-          q-input(
-            outlined
-            v-model='state.verifyPassword'
-            dense
-            :rules='verifyPasswordValidation'
-            hide-bottom-space
-            :label='t(`auth.changePwd.newPasswordVerify`)'
-            :aria-label='t(`auth.changePwd.newPasswordVerify`)'
-            lazy-rules='ondemand'
-            autofocus
-            )
-    q-card-actions.card-actions
-      q-space
-      q-btn.acrylic-btn(
-        flat
-        :label='t(`common.actions.cancel`)'
-        color='grey'
-        padding='xs md'
-        @click='onDialogCancel'
-        )
-      q-btn(
-        unelevated
-        :label='t(`common.actions.update`)'
-        color='primary'
-        padding='xs md'
-        @click='save'
-        :loading='state.isLoading'
-        )
+      q-icon(name='img:/_assets/icons/fluent-fingerprint.svg', left, size='sm')
+      span {{t(`profile.authSetTfa`)}}
+    template(v-if='!state.isInit')
+      q-linear-progress(query, color='positive')
+      q-card-section.text-center.text-grey {{t(`profile.authSetTfaLoading`)}}
+    template(v-else)
+      q-card-section.text-center
+        p {{t('auth.tfaSetupInstrFirst')}}
+        div(style='justify-content: center; display: flex;')
+          div(v-html='state.tfaQRImage', style='width: 200px;')
+        p.q-mt-sm {{t('auth.tfaSetupInstrSecond')}}
+        .flex.justify-center
+          v-otp-input(
+            v-model:value='state.securityCode'
+            :num-inputs='6'
+            :should-auto-focus='true'
+            input-classes='otp-input'
+            input-type='number'
+            separator=''
+          )
+        q-inner-loading(:showing='state.isLoading')
+      q-card-actions.card-actions
+        q-space
+        q-btn.acrylic-btn(
+          flat
+          :label='t(`common.actions.cancel`)'
+          color='grey'
+          padding='xs md'
+          @click='onDialogCancel'
+          )
+        q-btn(
+          unelevated
+          :label='t(`auth.tfa.verifyToken`)'
+          color='primary'
+          padding='xs md'
+          @click='save'
+          :loading='state.isLoading'
+          )
 </template>
 
 <script setup>
 import gql from 'graphql-tag'
-import zxcvbn from 'zxcvbn'
-import { sampleSize } from 'lodash-es'
 import { useI18n } from 'vue-i18n'
 import { useDialogPluginComponent, useQuasar } from 'quasar'
-import { computed, reactive, ref } from 'vue'
+import { onMounted, reactive } from 'vue'
 
 import { useSiteStore } from 'src/stores/site'
 
+import VOtpInput from 'vue3-otp-input'
+
 // PROPS
 
 const props = defineProps({
@@ -123,96 +83,78 @@ const { t } = useI18n()
 // DATA
 
 const state = reactive({
-  currentPassword: '',
-  newPassword: '',
-  verifyPassword: '',
-  isLoading: false
+  isInit: false,
+  isLoading: false,
+  securityCode: '',
+  tfaQRImage: '',
+  continuationToken: ''
 })
 
-// REFS
-
-const changeUserPwdForm = ref(null)
-
-// COMPUTED
+// METHODS
 
-const passwordStrength = computed(() => {
-  if (state.newPassword.length < 8) {
-    return {
-      color: 'negative',
-      label: t('admin.users.pwdStrengthWeak')
-    }
-  } else {
-    switch (zxcvbn(state.newPassword).score) {
-      case 1:
-        return {
-          color: 'deep-orange-7',
-          label: t('admin.users.pwdStrengthPoor')
-        }
-      case 2:
-        return {
-          color: 'purple-7',
-          label: t('admin.users.pwdStrengthMedium')
-        }
-      case 3:
-        return {
-          color: 'blue-7',
-          label: t('admin.users.pwdStrengthGood')
-        }
-      case 4:
-        return {
-          color: 'green-7',
-          label: t('admin.users.pwdStrengthStrong')
-        }
-      default:
-        return {
-          color: 'negative',
-          label: t('admin.users.pwdStrengthWeak')
+async function load () {
+  state.isInit = false
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation setupTfa (
+          $strategyId: UUID!
+          $siteId: UUID!
+          ) {
+            setupTFA (
+              strategyId: $strategyId
+              siteId: $siteId
+            ) {
+            operation {
+              succeeded
+              message
+            }
+            continuationToken
+            tfaQRImage
+          }
         }
+      `,
+      variables: {
+        strategyId: props.strategyId,
+        siteId: siteStore.id
+      }
+    })
+    if (resp?.data?.setupTFA?.operation?.succeeded) {
+      state.continuationToken = resp.data.setupTFA.continuationToken
+      state.tfaQRImage = resp.data.setupTFA.tfaQRImage
+      state.isInit = true
+    } else {
+      throw new Error(resp?.data?.setupTFA?.operation?.message || 'An unexpected error occured.')
     }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+    onDialogCancel()
   }
-})
-
-// VALIDATION RULES
-
-const currentPasswordValidation = [
-  val => val.length > 0 || t('auth.errors.missingPassword')
-]
-const newPasswordValidation = [
-  val => val.length > 0 || t('auth.errors.missingPassword'),
-  val => val.length >= 8 || t('auth.errors.passwordTooShort')
-]
-const verifyPasswordValidation = [
-  val => val.length > 0 || t('auth.errors.missingVerifyPassword'),
-  val => val === state.newPassword || t('auth.errors.passwordsNotMatch')
-]
-
-// METHODS
-
-function randomizePassword () {
-  const pwdChars = 'abcdefghkmnpqrstuvwxyzABCDEFHJKLMNPQRSTUVWXYZ23456789_*=?#!()+'
-  state.newPassword = sampleSize(pwdChars, 16).join('')
 }
 
 async function save () {
   state.isLoading = true
   try {
-    const isFormValid = await changeUserPwdForm.value.validate(true)
-    if (!isFormValid) {
-      throw new Error(t('auth.errors.fields'))
+    if (!/^[0-9]{6}$/.test(state.securityCode)) {
+      throw new Error(t('auth.errors.tfaMissing'))
     }
     const resp = await APOLLO_CLIENT.mutate({
       mutation: gql`
-        mutation changePwd (
-          $currentPassword: String
-          $newPassword: String!
+        mutation(
+          $continuationToken: String!
+          $securityCode: String!
           $strategyId: UUID!
           $siteId: UUID!
           ) {
-          changePassword (
-            currentPassword: $currentPassword
-            newPassword: $newPassword
+          loginTFA(
+            continuationToken: $continuationToken
+            securityCode: $securityCode
             strategyId: $strategyId
             siteId: $siteId
+            setup: true
             ) {
             operation {
               succeeded
@@ -222,20 +164,23 @@ async function save () {
         }
       `,
       variables: {
-        currentPassword: state.currentPassword,
-        newPassword: state.newPassword,
+        continuationToken: state.continuationToken,
+        securityCode: state.securityCode,
         strategyId: props.strategyId,
         siteId: siteStore.id
       }
     })
-    if (resp?.data?.changePassword?.operation?.succeeded) {
+    if (resp.data?.loginTFA?.operation?.succeeded) {
+      state.continuationToken = ''
+      state.securityCode = ''
       $q.notify({
         type: 'positive',
-        message: t('auth.changePwd.success')
+        message: t('auth.tfaSetupSuccess')
       })
+      state.isLoading = false
       onDialogOK()
     } else {
-      throw new Error(resp?.data?.changePassword?.operation?.message || 'An unexpected error occured.')
+      throw new Error(resp.data?.loginTFA?.operation?.message || t('auth.errors.loginError'))
     }
   } catch (err) {
     $q.notify({
@@ -245,4 +190,46 @@ async function save () {
   }
   state.isLoading = false
 }
+
+onMounted(() => {
+  load()
+})
 </script>
+
+<style lang="scss">
+.setup2fadialog {
+  .otp-input {
+    width: 100%;
+    height: 48px;
+    padding: 5px;
+    margin: 0 5px 7px;
+    font-size: 20px;
+    border-radius: 6px;
+    text-align: center;
+
+    @at-root .body--light & {
+      border: 2px solid rgba(0, 0, 0, 0.2);
+    }
+
+    @at-root .body--dark & {
+      border: 2px solid rgba(255, 255, 255, 0.3);
+    }
+
+    &:focus-visible {
+      outline-color: $primary;
+    }
+
+    /* Background colour of an input field with value */
+    &.is-complete {
+      border-color: $positive;
+      border-width: 2px;
+    }
+
+    &::-webkit-inner-spin-button,
+    &::-webkit-outer-spin-button {
+      -webkit-appearance: none;
+      margin: 0;
+    }
+  }
+}
+</style>

+ 13 - 0
ux/src/helpers/localization.js

@@ -0,0 +1,13 @@
+/**
+ * Parse an error message for an error code and translate
+ *
+ * @param {String} val Value to parse
+ * @param {Function} t vue-i18n translation method
+ */
+export function localizeError (val, t) {
+  if (val?.startsWith('ERR_')) {
+    return t(`error.${val}`)
+  } else {
+    return val
+  }
+}

+ 1 - 0
ux/src/pages/AdminSites.vue

@@ -98,6 +98,7 @@ q-page.admin-locale
                 icon='las la-trash'
                 color='negative'
                 @click='deleteSite(site)'
+                :aria-label='t(`common.actions.delete`)'
                 )
 </template>
 

+ 218 - 6
ux/src/pages/ProfileAuth.vue

@@ -47,6 +47,46 @@ q-page.q-py-md(:style-fn='pageStyle')
               @click='changePassword(auth.authId)'
             )
 
+  .text-header.q-mt-md {{t('profile.passkeys')}}
+  .q-pa-md
+    .text-body2 {{ t('profile.passkeysIntro') }}
+    q-list.q-mt-lg(
+      v-if="state.passkeys?.length > 0"
+      bordered
+      separator
+      )
+      q-item(
+        v-for='pkey of state.passkeys'
+        :key='pkey.id'
+        )
+        q-item-section(avatar)
+          q-avatar(
+            color='secondary'
+            text-color='white'
+            rounded
+            )
+            q-icon(name='las la-key')
+        q-item-section
+          strong {{pkey.name}}
+          .text-caption {{ pkey.siteHostname }}
+          .text-caption.text-grey-7 {{ humanizeDate(pkey.createdAt) }}
+        q-item-section(side)
+          q-btn.acrylic-btn(
+            flat
+            icon='las la-trash'
+            :aria-label='t(`common.actions.delete`)'
+            color='negative'
+            @click='deactivatePasskey(pkey)'
+          )
+    .q-mt-md
+      q-btn(
+        icon='las la-plus'
+        unelevated
+        :label='t(`profile.passkeysAdd`)'
+        color='primary'
+        @click='setupPasskey'
+      )
+
   q-inner-loading(:showing='state.loading > 0')
 </template>
 
@@ -55,11 +95,16 @@ import gql from 'graphql-tag'
 import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'
 import { onMounted, reactive } from 'vue'
+import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'
+import { localizeError } from 'src/helpers/localization'
+import { DateTime } from 'luxon'
 
+import { useSiteStore } from 'src/stores/site'
 import { useUserStore } from 'src/stores/user'
 
 import ChangePwdDialog from 'src/components/ChangePwdDialog.vue'
 import SetupTfaDialog from 'src/components/SetupTfaDialog.vue'
+import PasskeyCreateDialog from 'src/components/PasskeyCreateDialog.vue'
 
 // QUASAR
 
@@ -67,6 +112,7 @@ const $q = useQuasar()
 
 // STORES
 
+const siteStore = useSiteStore()
 const userStore = useUserStore()
 
 // I18N
@@ -83,6 +129,7 @@ useMeta({
 
 const state = reactive({
   authMethods: [],
+  passkeys: [],
   loading: 0
 })
 
@@ -94,6 +141,10 @@ function pageStyle (offset, height) {
   }
 }
 
+function humanizeDate (val) {
+  return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_MED)
+}
+
 async function fetchAuthMethods () {
   state.loading++
   try {
@@ -113,6 +164,12 @@ async function fetchAuthMethods () {
               strategyIcon
               config
             }
+            passkeys {
+              id
+              name
+              createdAt
+              siteHostname
+            }
           }
         }
       `,
@@ -122,6 +179,7 @@ async function fetchAuthMethods () {
       fetchPolicy: 'network-only'
     })
     state.authMethods = respRaw.data?.userById?.auth ?? []
+    state.passkeys = respRaw.data?.userById?.passkeys ?? []
   } catch (err) {
     $q.notify({
       type: 'negative',
@@ -189,12 +247,166 @@ function disableTfa (strategyId) {
 }
 
 function setupTfa (strategyId) {
-  // $q.dialog({
-  //   component: SetupTfaDialog,
-  //   componentProps: {
-  //     strategyId
-  //   }
-  // })
+  $q.dialog({
+    component: SetupTfaDialog,
+    componentProps: {
+      strategyId
+    }
+  }).onOk(() => {
+    fetchAuthMethods()
+  })
+}
+
+async function setupPasskey () {
+  try {
+    if (!browserSupportsWebAuthn()) {
+      throw new Error(t('profile.passkeysUnsupported'))
+    }
+    $q.loading.show()
+
+    // -> Generation registration options
+
+    const genResp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation setupPasskey (
+          $siteId: UUID!
+        ) {
+          setupPasskey(
+            siteId: $siteId
+          ) {
+            operation {
+              succeeded
+              message
+            }
+            registrationOptions
+          }
+        }
+      `,
+      variables: {
+        siteId: siteStore.id
+      }
+    })
+    if (genResp?.data?.setupPasskey?.operation?.succeeded) {
+      state.registrationOptions = genResp.data.setupPasskey.registrationOptions
+    } else {
+      throw new Error(localizeError(genResp?.data?.setupPasskey?.operation?.message, t))
+    }
+
+    // -> Start registration on the authenticator
+
+    let attResp
+    try {
+      attResp = await startRegistration(state.registrationOptions)
+    } catch (err) {
+      if (err.name === 'InvalidStateError') {
+        throw new Error(t('error.ERR_PK_ALREADY_REGISTERED'))
+      } else {
+        throw err
+      }
+    }
+
+    // -> Prompt for passkey name
+
+    $q.loading.hide()
+    const passkeyName = await new Promise((resolve, reject) => {
+      $q.dialog({
+        component: PasskeyCreateDialog
+      }).onOk(({ name }) => {
+        resolve(name)
+      }).onCancel(() => {
+        reject(new Error(t('error.ERR_PK_USER_CANCELLED')))
+      })
+    })
+    $q.loading.show()
+
+    // -> Verify the authenticator response
+
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation finalizePasskey (
+          $registrationResponse: JSON!
+          $name: String!
+        ) {
+          finalizePasskey(
+            registrationResponse: $registrationResponse
+            name: $name
+          ) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        registrationResponse: attResp,
+        name: passkeyName
+      }
+    })
+    if (resp?.data?.finalizePasskey?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('profile.passkeysSetupSuccess')
+      })
+    } else {
+      throw new Error(resp?.data?.finalizePasskey?.operation?.message)
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: t('profile.passkeysSetupFailed'),
+      caption: err.message ?? 'An unexpected error occured.'
+    })
+  }
+  await fetchAuthMethods()
+  $q.loading.hide()
+}
+
+async function deactivatePasskey (pkey) {
+  $q.dialog({
+    title: t('common.actions.confirm'),
+    message: t('profile.passkeysDeactivateConfirm'),
+    cancel: true
+  }).onOk(async () => {
+    $q.loading.show()
+    try {
+      const resp = await APOLLO_CLIENT.mutate({
+        mutation: gql`
+          mutation deactivatePasskey (
+            $id: UUID!
+          ) {
+            deactivatePasskey(
+              id: $id
+            ) {
+              operation {
+                succeeded
+                message
+              }
+            }
+          }
+        `,
+        variables: {
+          id: pkey.id
+        }
+      })
+      if (resp?.data?.deactivatePasskey?.operation?.succeeded) {
+        $q.notify({
+          type: 'positive',
+          message: t('profile.passkeysDeactivateSuccess')
+        })
+      } else {
+        throw new Error(resp?.data?.deactivatePasskey?.operation?.message)
+      }
+    } catch (err) {
+      $q.notify({
+        type: 'negative',
+        message: t('profile.passkeysDeactivateFailed'),
+        caption: err.message ?? 'An unexpected error occured.'
+      })
+    }
+    await fetchAuthMethods()
+    $q.loading.hide()
+  })
 }
 
 // MOUNTED

Some files were not shown because too many files changed in this diff