Repository: modoboa/modoboa (language: python)

Issue:
Feature Request: WebAuthn
WebAuthn support for hardware-based authentication


Discussion / hints:
Something like this added would be awesome
https://github.com/mkalioby/django-mfa2
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

still no interest in this?
not sure how it was done but Mesh Central Supports this works great with my Yubikey, would love to have it with Modoboa
Adding such a feature would probably require to change the library we use currently (django-otp) by a new one which supports webauthn. I guess it's a huge update to develop, need to think about it.
I'm working on it, see #2585 to see advancement :)
I am highly intrested in this. How do I get more people on board?

Fix patch:
diff --git a/.github/workflows/modoboa.yml b/.github/workflows/modoboa.yml
index 6a5e50e99..547398e61 100644
--- a/.github/workflows/modoboa.yml
+++ b/.github/workflows/modoboa.yml
@@ -89,7 +89,7 @@ jobs:
           pip install -r requirements.txt
           pip install -r ldap-requirements.txt
           pip install -r test-requirements.txt
-          python setup.py develop
+          pip install -e .
           echo "Testing redis connection"
           redis-cli -h $REDIS_HOST -p $REDIS_PORT ping
         env:
diff --git a/.gitignore b/.gitignore
index 744a42bf3..09ebf86ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,5 @@ test_project/modoboa.db
 .ash_history
 modoboa.kdev4
 .kateproject
+test_project/cert.crt
+test_project/cert.key
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index cbc9bca12..98931c989 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -13,14 +13,14 @@ repos:
         name: prettier
         entry: prettier --config frontend/.prettierrc.json --write --ignore-unknown
         language: node
-        types: [javascript]
+        types_or: [javascript, jsx, ts, tsx, vue]
         additional_dependencies: ["prettier"]
       - id: eslint
         name: eslint
         language: node
-        types: [javascript]
-        entry: eslint -c frontend/.eslintrc.js --ignore-pattern modoboa/* --fix 
-        additional_dependencies: ["eslint-plugin-vue"]
+        types_or: [javascript, jsx, ts, tsx, vue]
+        entry: eslint -c frontend/.eslint.conf.js --fix
+        additional_dependencies: ["eslint-plugin-vue", "eslint-config-prettier", "prettier", "eslint"]
   - repo: https://github.com/psf/black
     rev: "24.2.0"
     hooks:
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 05818ca08..3609760a7 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -3,3 +3,6 @@ django-debug-toolbar
 pre-commit
 black
 pylint
+django-extensions
+Werkzeug
+pyOpenSSL
diff --git a/doc/contributing/getting_started.rst b/doc/contributing/getting_started.rst
index 07019b0c9..8054713ac 100644
--- a/doc/contributing/getting_started.rst
+++ b/doc/contributing/getting_started.rst
@@ -18,7 +18,7 @@ Then, just run the following command::
 
 Then if not done already, run this command to create an OIDC application
 in order to be able to log in from the frontend::
-  $ docker exec modoboa-api '/bin/sh -c python3 /code/test_project/manage.py createapplication --name frontend --algorithm RS256 --redirect-uris 'http://localhost:3000/login/logged' public authorization_grant_type authorization-code'
+  $ docker exec modoboa-api '/bin/sh -c python3 /code/test_project/python manage.py createapplication --name frontend --client-id "LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD" --user 1 --algorithm RS256 --redirect-uris 'https://localhost:3000/login/logged' public authorization-code'
 
 It will start the docker environment and make a Modoboa instance
 available at ``http://localhost:8000`` and the new admin interface at ``http://localhost:8080``
@@ -42,10 +42,10 @@ To do so, run the following commands::
   $ source <path>/bin/activate
   $ git clone https://github.com/modoboa/modoboa.git
   $ cd modoboa
-  $ python setup.py develop
+  $ pip install -e .
   $ pip install -r dev-requirements.txt
 
-The ``develop`` command creates a symbolic link to your local copy so
+This will create a symbolic link to your local copy so
 any modification you make will be automatically available in your
 environment, no need to copy them.
 
diff --git a/docker-compose.yml b/docker-compose.yml
index ca6c9b327..4a6186688 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -36,9 +36,9 @@ services:
     command: >
       sh -c "cd test_project
              python3 manage.py migrate
-             python3 manage.py load_initial_data
+             python3 manage.py load_initial_data --dev
              django-admin compilemessages
-             python3 manage.py runserver 0:8000"
+             python3 manage.py runserver_plus 0.0.0.0:8000 --cert-file cert.crt"
     volumes:
       - .:/code
 
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
index cd426dca6..cd965d33d 100644
--- a/docker/Dockerfile.dev
+++ b/docker/Dockerfile.dev
@@ -13,9 +13,9 @@ RUN python3 -m venv $VIRTUAL_ENV
 WORKDIR /tmp
 COPY requirements.txt /tmp
 COPY test-requirements.txt /tmp
+COPY dev-requirements.txt /tmp
 RUN python -m pip install -U pip
-RUN pip install -r requirements.txt -r test-requirements.txt
-
+RUN pip install -r requirements.txt -r test-requirements.txt -r dev-requirements.txt
 FROM base as run
 COPY docker/doveadm /usr/local/bin
 RUN chmod +x /usr/local/bin/doveadm
diff --git a/frontend/.eslint.conf.js b/frontend/.eslint.conf.js
new file mode 100644
index 000000000..08d8c5542
--- /dev/null
+++ b/frontend/.eslint.conf.js
@@ -0,0 +1,37 @@
+import pluginVue from 'eslint-plugin-vue'
+import eslintConfigPrettier from 'eslint-config-prettier'
+
+export default [
+  ...pluginVue.configs['flat/recommended'],
+  eslintConfigPrettier,
+  {
+    rules: {
+      'vue/component-name-in-template-casing': [
+        'error',
+        'PascalCase',
+        {
+          registeredComponentsOnly: true,
+          ignores: [],
+        },
+      ],
+    },
+    ignores: [
+      '.DS_Store',
+      'node_modules',
+      '/dist',
+      '.env.local',
+      '.env.*.local',
+      'npm-debug.log*',
+      'yarn-debug.log*',
+      'yarn-error.log*',
+      'pnpm-debug.log*',
+      '.idea',
+      '.vscode',
+      '*.suo',
+      '*.ntvs*',
+      '*.njsproj',
+      '.sln',
+      '*.sw?',
+    ],
+  },
+]
diff --git a/frontend/.eslintignore b/frontend/.eslintignore
deleted file mode 100644
index 11f5d7142..000000000
--- a/frontend/.eslintignore
+++ /dev/null
@@ -1,22 +0,0 @@
-.DS_Store
-node_modules
-/dist
-
-# local env files
-.env.local
-.env.*.local
-
-# Log files
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-
-# Editor directories and files
-.idea
-.vscode
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
-*.sw?
diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js
deleted file mode 100644
index 626b012df..000000000
--- a/frontend/.eslintrc.js
+++ /dev/null
@@ -1,17 +0,0 @@
-module.exports = {
-  root: true,
-  env: {
-    node: true,
-  },
-  extends: ['plugin:vue/vue3-recommended', 'eslint:recommended'],
-  rules: {
-    'vue/component-name-in-template-casing': [
-      'error',
-      'PascalCase',
-      {
-        registeredComponentsOnly: true,
-        ignores: [],
-      },
-    ],
-  },
-}
diff --git a/frontend/gettext.config.js b/frontend/gettext.config.js
deleted file mode 100644
index 3ea2ba7cb..000000000
--- a/frontend/gettext.config.js
+++ /dev/null
@@ -1,24 +0,0 @@
-module.exports = {
-  output: {
-    path: './src/locale',
-    locales: [
-      'en',
-      'br',
-      'cs',
-      'de',
-      'el',
-      'es',
-      'fi',
-      'fr',
-      'it',
-      'ja',
-      'nl',
-      'pl',
-      'pt',
-      'ru',
-      'sv',
-      'tr',
-      'zh',
-    ],
-  },
-}
diff --git a/frontend/gettext.config.json b/frontend/gettext.config.json
new file mode 100644
index 000000000..f293026f6
--- /dev/null
+++ b/frontend/gettext.config.json
@@ -0,0 +1,24 @@
+{
+  "output": {
+    "path": "./src/locale",
+    "locales": [
+      "en",
+      "br",
+      "cs",
+      "de",
+      "el",
+      "es",
+      "fi",
+      "fr",
+      "it",
+      "ja",
+      "nl",
+      "pl",
+      "pt",
+      "ru",
+      "sv",
+      "tr",
+      "zh"
+    ]
+  }
+}
diff --git a/frontend/package.json b/frontend/package.json
index ec645a86a..23c0be268 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,7 @@
 {
   "name": "frontend",
   "version": "3.0.0",
+  "type": "module",
   "private": true,
   "scripts": {
     "dev": "vite",
@@ -14,31 +15,34 @@
     "gettext:compile": "vue-gettext-compile"
   },
   "dependencies": {
+    "@github/webauthn-json": "^2.1.1",
     "@mdi/font": "^7.3.67",
-    "apexcharts": "^3.45.2",
-    "axios": "^1.6.7",
-    "core-js": "^3.29.0",
-    "debounce": "^2.0.0",
+    "apexcharts": "^3.49.1",
+    "axios": "^1.7.2",
+    "core-js": "^3.37.1",
+    "debounce": "^2.1.0",
     "js-cookie": "^3.0.5",
     "luxon": "^3.4.4",
     "oidc-client-ts": "^3.0.1",
     "pinia": "^2.1.7",
     "qrcode.vue": "^3.4.1",
     "roboto-fontface": "*",
-    "vue": "^3.4.19",
-    "vue-router": "^4.0.0",
-    "vue3-apexcharts": "^1.5.2",
-    "vue3-gettext": "^3.0.0-beta.4",
-    "vuetify": "^3.5.4"
+    "vue": "^3.4.29",
+    "vue-router": "^4.3.3",
+    "vue3-apexcharts": "^1.5.3",
+    "vue3-gettext": "^3.0.0-beta.5",
+    "vuetify": "^3.6.9"
   },
   "devDependencies": {
-    "@vitejs/plugin-vue": "^5.0.4",
-    "eslint": "^8.56.0",
-    "eslint-plugin-vue": "^9.21.1",
-    "prettier": "^3.1.1",
-    "sass": "^1.69.5",
+    "@vitejs/plugin-basic-ssl": "^1.1.0",
+    "@vitejs/plugin-vue": "^5.0.5",
+    "eslint": "^9.5.0",
+    "eslint-config-prettier": "^9.1.0",
+    "eslint-plugin-vue": "^9.26.0",
+    "prettier": "^3.3.2",
+    "sass": "^1.77.5",
     "unplugin-fonts": "^1.0.3",
-    "vite": "^5.1.3",
-    "vite-plugin-vuetify": "^2.0.1"
+    "vite": "^5.3.1",
+    "vite-plugin-vuetify": "^2.0.3"
   }
 }
diff --git a/frontend/public/config.json b/frontend/public/config.json
index 29d53a3df..7c7f4ef46 100644
--- a/frontend/public/config.json
+++ b/frontend/public/config.json
@@ -1,7 +1,7 @@
 {
-    "API_BASE_URL": "/api/v2/",
-    "API_DOC_URL": "/api/schema-v2/swagger/",
-    "OAUTH_AUTHORITY_URL": "http://localhost:8000/api/o",
-    "OAUTH_CLIENT_ID": "LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD",
-    "OAUTH_REDIRECT_URI": "http://localhost:3000/login/logged"
+  "API_BASE_URL": "/api/v2/",
+  "API_DOC_URL": "/api/schema-v2/swagger/",
+  "OAUTH_AUTHORITY_URL": "https://localhost:8000/api/o",
+  "OAUTH_CLIENT_ID": "LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD",
+  "OAUTH_REDIRECT_URI": "https://localhost:3000/login/logged"
 }
diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js
index f9fade5dc..14beca4c3 100644
--- a/frontend/src/api/auth.js
+++ b/frontend/src/api/auth.js
@@ -13,4 +13,16 @@ export default {
   changePassword(data) {
     return repository.post('/reset_confirm/', data)
   },
+  beginFidoRegistration() {
+    return repository.post('/fido/registration/begin/')
+  },
+  endFidoRegistration(data) {
+    return repository.post('/fido/registration/end/', data)
+  },
+  getAllFidoRegistred() {
+    return repository.get('/fido/')
+  },
+  deleteFido(id) {
+    return repository.delete(`/fido/${id}/`)
+  },
 }
diff --git a/frontend/src/components/account/BackupCodeAuthForm.vue b/frontend/src/components/account/BackupCodeAuthForm.vue
new file mode 100644
index 000000000..e94d1f41f
--- /dev/null
+++ b/frontend/src/components/account/BackupCodeAuthForm.vue
@@ -0,0 +1,116 @@
+<template>
+  <div>
+    <v-card flat>
+      <v-card-title>
+        <span class="text-h6">
+          {{ $gettext('Backup codes') }}
+        </span>
+      </v-card-title>
+      <v-card-text>
+        <template v-if="tfa_enabled">
+          <v-alert type="info" class="mb-2">
+            {{
+              $gettext(
+                "You already have generated backup codes for this account. If you don't remember them or have used them all, you generate new ones."
+              )
+            }}
+          </v-alert>
+          <v-form ref="editTFAForm">
+            <label class="m-label">{{ $gettext('Password') }} </label>
+            <v-text-field
+              v-model="password"
+              autocomplete="new-password"
+              type="password"
+              variant="outlined"
+              :rules="[rules.required]"
+              :error-messages="passwordError"
+            />
+            <div class="mt-5">
+              <v-btn
+                :loading="loadingReset"
+                color="success"
+                @click="resetRecoveryCodes"
+              >
+                {{ $gettext('Reset recovery codes') }}
+              </v-btn>
+            </div>
+          </v-form>
+        </template>
+        <template v-else>
+          <div tag="p" class="my-4">
+            {{
+              $gettext(
+                'No two-factor authentificator is yet set for your account.'
+              )
+            }}
+          </div>
+        </template>
+        <v-dialog v-model="showCodesResetDialog" max-width="800px" persistent>
+          <RecoveryCodesResetDialog
+            :tokens="newTokens"
+            @close="closeRecoveryCodesResetDialog"
+          />
+        </v-dialog>
+      </v-card-text>
+    </v-card>
+  </div>
+</template>
+
+<script setup lang="js">
+import accountApi from '@/api/account'
+import RecoveryCodesResetDialog from './RecoveryCodesResetDialog'
+import { useGettext } from 'vue3-gettext'
+import { useBusStore, useAuthStore } from '@/stores'
+import rules from '@/plugins/rules'
+import { ref, computed } from 'vue'
+
+const { $gettext } = useGettext()
+const busStore = useBusStore()
+const authStore = useAuthStore()
+
+const tfa_enabled = computed(() => authStore.authUser.tfa_enabled)
+
+const loadingReset = ref(false)
+const editTFAForm = ref()
+const password = ref('')
+const newTokens = ref([])
+const passwordError = ref()
+const showCodesResetDialog = ref(false)
+
+async function resetRecoveryCodes() {
+  const isValid = await editTFAForm.value.validate()
+  if (!isValid) {
+    return
+  }
+  const payload = {
+    password: password.value,
+  }
+  loadingReset.value = true
+
+  accountApi
+    .resetRecoveryCodes(payload)
+    .then((response) => {
+      newTokens.value = response.data.tokens
+      showCodesResetDialog.value = true
+    })
+    .catch((error) => {
+      if (error.response.status === 400) {
+        passwordError.value = error.response.data.password
+      } else {
+        busStore.displayNotification({
+          msg: $gettext(error.response.data),
+          type: 'error',
+        })
+      }
+    })
+    .finally(() => {
+      loadingReset.value = false
+      editTFAForm.value.reset()
+    })
+}
+
+function closeRecoveryCodesResetDialog() {
+  newTokens.value = []
+  showCodesResetDialog.value = false
+}
+</script>
diff --git a/frontend/src/components/account/FidoAuthForm.vue b/frontend/src/components/account/FidoAuthForm.vue
new file mode 100644
index 000000000..2c1995e6b
--- /dev/null
+++ b/frontend/src/components/account/FidoAuthForm.vue
@@ -0,0 +1,231 @@
+<template>
+  <div>
+    <v-card flat>
+      <v-card-title>
+        <span class="text-h6">
+          {{ $gettext('WebAuthn') }}
+        </span>
+      </v-card-title>
+      <v-card-text>
+        <v-row>
+          <v-col v-if="fidoCreds.length === 0" cols="4">
+            <div tag="p" class="my-4">
+              {{
+                $gettext(
+                  "You don't have any WebAuthN registered as a second authentication method."
+                )
+              }}
+            </div>
+          </v-col>
+          <v-col v-else cols="8">
+            <h3>{{ $gettext('Registered Webauthn devices') }}</h3>
+            <v-data-table-virtual
+              :headers="headers"
+              :items="fidoCreds"
+              height="400"
+              item-value="id"
+            >
+              <template #[`item.added_on`]="{ item }">
+                {{ $date(item.added_on) }}
+              </template>
+              <template #[`item.last_used`]="{ item }">
+                <template v-if="item.last_used === null">
+                  {{ $gettext('Never') }}
+                </template>
+                <template v-else>
+                  {{ $date(item.last_used) }}
+                </template>
+              </template>
+              <template #[`item.enabled`]="{ item }">
+                {{ $yesno(item.enabled) }}
+              </template>
+              <template #[`item.actions`]="{ item }">
+                <v-icon class="me-2" size="small" @click="editCred(item)">
+                  mdi-pencil
+                </v-icon>
+                <v-icon size="small" @click="deleteCred(item)">
+                  mdi-delete
+                </v-icon>
+              </template>
+            </v-data-table-virtual>
+          </v-col>
+          <v-col cols="4">
+            <template v-if="browserCapable">
+              <h3>
+                {{
+                  $gettext('Enter a name to register a new webauthn device.')
+                }}
+              </h3>
+              <v-form ref="fidoForm" @submit.prevent="startFidoRegistration">
+                <v-text-field
+                  v-model="name"
+                  :label="$gettext('New device name')"
+                  :rules="[rules.required]"
+                />
+                <v-btn
+                  color="success"
+                  type="submit"
+                  :loading="registrationLoading"
+                >
+                  {{ $gettext('Setup device') }}
+                </v-btn>
+              </v-form>
+            </template>
+            <template v-else>
+              <v-alert type="error">
+                {{
+                  $gettext(
+                    'Your browser does not seem compatible with WebAuthN'
+                  )
+                }}
+              </v-alert>
+            </template>
+          </v-col>
+        </v-row>
+      </v-card-text>
+    </v-card>
+  </div>
+  <ConfirmDialog ref="confirmDeletion" />
+  <ConfirmDialog ref="confirmEdition" :callback_agree="checkForm">
+    <v-form ref="editForm">
+      <v-text-field
+        v-model="editName"
+        :label="$gettext('name')"
+        :rules="[rules.required]"
+      />
+      <v-switch
+        v-model="editEnabled"
+        :label="$gettext('enabled')"
+        color="principal"
+      />
+    </v-form>
+  </ConfirmDialog>
+  <v-dialog v-model="showBackupTokenDialog" max-width="800px" persistent>
+    <RecoveryCodesResetDialog
+      :tokens="tokens"
+      @close="closeRecoveryCodesResetDialog"
+    />
+  </v-dialog>
+</template>
+
+<script setup lang="js">
+import { ref, onMounted, computed } from 'vue'
+import authApi from '@/api/auth.js'
+import { useGettext } from 'vue3-gettext'
+import rules from '@/plugins/rules.js'
+import { useAuthStore } from '@/stores'
+import ConfirmDialog from '@/components/tools/ConfirmDialog.vue'
+import RecoveryCodesResetDialog from './RecoveryCodesResetDialog.vue'
+import {
+  create,
+  parseCreationOptionsFromJSON,
+} from '@github/webauthn-json/browser-ponyfill'
+
+const authStore = useAuthStore()
+
+const { $gettext } = useGettext()
+
+const name = ref()
+const registrationLoading = ref(false)
+const browserCapable = !!(
+  navigator.credentials &&
+  navigator.credentials.create &&
+  navigator.credentials.get &&
+  window.PublicKeyCredential
+)
+const fidoForm = ref()
+const fidoCreds = computed(() => authStore.fidoCreds)
+const headers = [
+  { title: $gettext('Name'), key: 'name' },
+  { title: $gettext('Added On'), key: 'added_on' },
+  { title: $gettext('Last Used'), key: 'last_used' },
+  { title: $gettext('Use count'), key: 'use_count' },
+  { title: $gettext('Enabled'), key: 'enabled' },
+  { title: $gettext('Actions'), key: 'actions' },
+]
+const confirmDeletion = ref()
+const confirmEdition = ref()
+const editForm = ref()
+const editName = ref('')
+const editEnabled = ref(false)
+const tokens = ref()
+const showBackupTokenDialog = ref(false)
+
+async function startFidoRegistration() {
+  const { valid } = await fidoForm.value.validate()
+  if (!valid) {
+    return
+  }
+  registrationLoading.value = true
+  const creationOption = await authApi.beginFidoRegistration()
+  if (creationOption) {
+    const options = parseCreationOptionsFromJSON(creationOption.data)
+    try {
+      const device = await create(options)
+      const result = device.toJSON()
+      result.name = name.value
+      authStore.addFidoCred(result).then((res) => {
+        if (res.status === 200) {
+          tokens.value = res.data.tokens
+          showBackupTokenDialog.value = true
+        }
+      })
+      fidoForm.value.reset()
+    } catch {
+      registrationLoading.value = false
+      return
+    }
+  }
+  registrationLoading.value = false
+}
+
+function closeRecoveryCodesResetDialog() {
+  tokens.value = []
+  showBackupTokenDialog.value = false
+}
+
+async function deleteCred(cred) {
+  const result = await confirmDeletion.value.open(
+    $gettext('Warning'),
+    $gettext('Do you really want to delete this WebAuthn device?'),
+    {
+      color: 'warning',
+      cancelLabel: $gettext('No'),
+      agreeLabel: $gettext('Yes'),
+    }
+  )
+  if (!result) {
+    return
+  }
+  authStore.deleteFidoCreds(cred.id)
+}
+
+async function checkForm() {
+  return (await editForm.value.validate()).valid
+}
+
+async function editCred(cred) {
+  editName.value = cred.name
+  editEnabled.value = cred.enabled
+
+  const result = await confirmEdition.value.open(
+    $gettext('Edition'),
+    $gettext('Edit your device'),
+    {
+      color: 'warning',
+      cancelLabel: $gettext('Cancel'),
+      agreeLabel: $gettext('Save'),
+    }
+  )
+  if (!result) {
+    return
+  }
+
+  data = { name: editName.value, enabled: editEnabled.value }
+  //TODO : send it !
+}
+
+onMounted(() => {
+  authStore.getFidoCreds()
+})
+</script>
diff --git a/frontend/src/components/account/TwoFactorAuthForm.vue b/frontend/src/components/account/TotpAuthForm.vue
similarity index 63%
rename from frontend/src/components/account/TwoFactorAuthForm.vue
rename to frontend/src/components/account/TotpAuthForm.vue
index d8d538029..f9860dee2 100644
--- a/frontend/src/components/account/TwoFactorAuthForm.vue
+++ b/frontend/src/components/account/TotpAuthForm.vue
@@ -2,8 +2,8 @@
   <div>
     <v-card flat>
       <v-card-title>
-        <span class="text-subtitle-1">
-          {{ $gettext('Two factor authentication') }}
+        <span class="text-h6">
+          {{ $gettext('One-time passwords') }}
         </span>
       </v-card-title>
       <v-card-text>
@@ -37,38 +37,47 @@
                 }}
               </v-alert>
               <label class="m-label">{{ $gettext('Pin code') }}</label>
-              <v-otp-input v-model="pinCode" />
+              <v-otp-input
+                v-model="pinCode"
+                :error="pinCodeErrors.length !== 0"
+              />
               <v-btn color="primary" @click="finalizeTFASetup">
                 {{ $gettext('Register') }}
               </v-btn>
             </v-col>
           </v-row>
         </template>
-        <template v-else-if="tokens.length">
+        <template v-else-if="successSetup">
           <v-alert type="success">
             {{
               $gettext(
-                'Congratulations! Two-Factor Authentication is now enabled for your account.'
+                'Congratulations! Two-factor authentication using one-time passwords is now enabled for your account.'
               )
             }}
           </v-alert>
-          <p>
-            {{
-              $gettext(
-                "The following recovery codes can be used one time each to let you regain access to your account, in case you lose your phone for example. Make sure to save them in a safe place, otherwise you won't be able to access your account anymore."
-              )
-            }}
-          </p>
-          <ul>
-            <li v-for="token in tokens" :key="token">
-              {{ token }}
-            </li>
-          </ul>
+          <template v-if="tokens.length">
+            <p class="mt-4">
+              {{
+                $gettext(
+                  "The following recovery codes can be used one time each to let you regain access to your account, in case you lose your phone for example. Make sure to save them in a safe place, otherwise you won't be able to access your account anymore."
+                )
+              }}
+            </p>
+            <v-table density="compact">
+              <tbody>
+                <tr v-for="token in tokens" :key="token">
+                  <td>{{ token }}</td>
+                </tr>
+              </tbody>
+            </v-table>
+          </template>
         </template>
-        <template v-else-if="tfa_enabled">
+        <template v-else-if="totp_enabled">
           <v-alert type="info" class="mb-2">
             {{
-              $gettext('Two-Factor Authentication is enabled for your account.')
+              $gettext(
+                'Two-factor authentication using one-time passwords is enabled for your account.'
+              )
             }}
           </v-alert>
           <v-form ref="editTFAForm">
@@ -90,9 +99,6 @@
               >
                 {{ $gettext('Disable 2FA') }}
               </v-btn>
-              <v-btn :loading="loadingReset" @click="resetRecoveryCodes">
-                {{ $gettext('Reset recovery codes') }}
-              </v-btn>
             </div>
           </v-form>
         </template>
@@ -100,28 +106,21 @@
           <div tag="p" class="my-4">
             {{
               $gettext(
-                "Two-Factor Authentication (2FA) is not yet activated for your account. Enabling this feature will increase your account's security."
+                'Two-factor authentication (2FA) using one-time passwords is not yet activated for your account.'
               )
             }}
           </div>
           <v-btn color="success" @click="startTFASetup">
-            {{ $gettext('Enable 2FA') }}
+            {{ $gettext('Register authenticator') }}
           </v-btn>
         </template>
       </v-card-text>
     </v-card>
-    <v-dialog v-model="showCodesResetDialog" max-width="800px" persistent>
-      <RecoveryCodesResetDialog
-        :tokens="newTokens"
-        @close="closeRecoveryCodesResetDialog"
-      />
-    </v-dialog>
   </div>
 </template>
 
 <script setup lang="js">
 import accountApi from '@/api/account'
-import RecoveryCodesResetDialog from './RecoveryCodesResetDialog'
 import QrcodeVue from 'qrcode.vue'
 import { useBusStore, useAuthStore } from '@/stores'
 import { ref, computed } from 'vue'
@@ -133,21 +132,18 @@ const busStore = useBusStore()
 const authStore = useAuthStore()
 
 const editTFAForm = ref()
-
-const newTokens = ref([])
 const pinCode = ref('')
-const pinCodeErrors = ref()
+const pinCodeErrors = ref([])
 const key = ref(null)
 const qrURL = ref(null)
 const clicked = ref(false)
-const showCodesResetDialog = ref(false)
 const loadingDisable = ref(false)
-const loadingReset = ref(false)
 const password = ref('')
 const passwordError = ref([])
 const tokens = ref([])
+const successSetup = ref(false)
 
-const tfa_enabled = computed(() => authStore.authUser.tfa_enabled)
+const totp_enabled = computed(() => authStore.authUser.totp_enabled)
 
 function getKey() {
   accountApi.getKeyForTFASetup().then((resp) => {
@@ -167,17 +163,20 @@ function startTFASetup() {
   })
 }
 
-async function finalizeTFASetup() {
+function finalizeTFASetup() {
   authStore
     .finalizeTFASetup(pinCode.value)
     .then((response) => {
       key.value = null
       qrURL.value = null
-      tokens.value = response.data.tokens
+      if (response.status === 200) {
+        tokens.value = response.data.tokens
+      }
+      successSetup.value = true
     })
     .catch((error) => {
       if (error.response.status === 400) {
-        pinCodeErrors.value = error.response.data
+        pinCodeErrors.value = error.response.data.pin_code
       }
     })
 }
@@ -211,46 +210,6 @@ async function disableTFA() {
       editTFAForm.value.reset()
     })
 }
-
-async function resetRecoveryCodes() {
-  const isValid = await editTFAForm.value.validate()
-  if (!isValid) {
-    return
-  }
-  const payload = {
-    password: password.value,
-  }
-  loadingReset.value = true
-
-  accountApi
-    .resetRecoveryCodes(payload)
-    .then((response) => {
-      newTokens.value = response.data.tokens
-      showCodesResetDialog.value = true
-    })
-    .catch((error) => {
-      if (error.response.status === 400) {
-        passwordError.value = error.response.data.password
-      } else {
-        busStore.displayNotification({
-          msg: $gettext(error.response.data),
-          type: 'error',
-        })
-      }
-    })
-    .finally(() => {
-      loadingReset.value = false
-      editTFAForm.value.reset()
-    })
-}
-
-function closeRecoveryCodesResetDialog() {
-  newTokens.value = []
-  showCodesResetDialog.value = false
-}
-if (!tfa_enabled.value) {
-  getKey()
-}
 </script>
 
 <style>
diff --git a/frontend/src/components/tools/ConfirmDialog.vue b/frontend/src/components/tools/ConfirmDialog.vue
index 5faae125f..64b393d07 100644
--- a/frontend/src/components/tools/ConfirmDialog.vue
+++ b/frontend/src/components/tools/ConfirmDialog.vue
@@ -34,6 +34,14 @@ import { useGettext } from 'vue3-gettext'
 
 const { $gettext } = useGettext()
 
+const props = defineProps({
+  // the callback should return true or false, returning false inhibits the close event
+  callback_agree: {
+    type: Function,
+    default: null,
+  },
+})
+
 const dialog = ref(false)
 const message = ref('')
 const title = ref('')
@@ -61,7 +69,10 @@ defineExpose({
   open,
 })
 
-function agree() {
+async function agree() {
+  if (props.callback_agree !== null && !(await props.callback_agree())) {
+    return
+  }
   storedResolve(true)
   dialog.value = false
 }
diff --git a/frontend/src/layouts/dashboard/NavBar.vue b/frontend/src/layouts/dashboard/NavBar.vue
index f9bebedf6..b8a951b26 100644
--- a/frontend/src/layouts/dashboard/NavBar.vue
+++ b/frontend/src/layouts/dashboard/NavBar.vue
@@ -167,20 +167,10 @@ const mainColor = computed(() => {
   return 'primary'
 })
 
-if (authUser.role === 'SuperAdmins') {
-  // created
-  parametersApi.getApplications().then((response) => {
-    response.data.forEach((item) => {
-      mainMenuItems[6].children.push({
-        text: item.label,
-        to: { name: 'ParametersEdit', params: { app: item.name } },
-      })
-    })
-  })
-}
-
 const imapMigration = computed(() => parametersStore.imapMigrationEnabled)
 
+const settings = []
+
 const mainMenuItems = [
   {
     text: $gettext('Dashboard'),
@@ -247,7 +237,7 @@ const mainMenuItems = [
   {
     icon: 'mdi-cog',
     text: $gettext('Settings'),
-    children: [],
+    children: settings,
     roles: ['SuperAdmins'],
   },
   {
@@ -318,7 +308,17 @@ async function logout() {
 }
 
 onMounted(() => {
-  if (authUser.role === 'SuperAdmins' && parametersStore.imapMigrationEnabled === null) {
+  if (authUser.value.role === 'SuperAdmins') {
+    parametersApi.getApplications().then((response) => {
+      response.data.forEach((item) => {
+        settings.push({
+          text: item.label,
+          to: { name: 'ParametersEdit', params: { app: item.name } },
+        })
+      })
+    })
+  }
+  if (authUser.value.role === 'SuperAdmins' && parametersStore.imapMigrationEnabled === null) {
     parametersApi.getApplication('imap_migration').then((response) => {
       parametersStore.imapMigrationEnabled =
         response.data.params.enabled_imapmigration
diff --git a/frontend/src/plugins/filters.js b/frontend/src/plugins/filters.js
index 4caeaa4e5..e82fc5a30 100644
--- a/frontend/src/plugins/filters.js
+++ b/frontend/src/plugins/filters.js
@@ -10,7 +10,7 @@ export default {
     }
     app.config.globalProperties.$date = (value) => {
       return DateTime.fromISO(value)
-        .setLocale('en')
+        .setLocale(gettext.current)
         .toLocaleString(DateTime.DATETIME_MED)
     }
     app.config.globalProperties.$truncate = (value, length, clamp) => {
diff --git a/frontend/src/stores/auth.store.js b/frontend/src/stores/auth.store.js
index 4ea1dd199..1b9a0b46c 100644
--- a/frontend/src/stores/auth.store.js
+++ b/frontend/src/stores/auth.store.js
@@ -9,6 +9,7 @@ import { UserManager } from 'oidc-client-ts'
 import repository from '@/api/repository'
 import accountApi from '@/api/account'
 import accountsApi from '@/api/accounts'
+import authApi from '@/api/auth.js'
 
 import { useGlobalConfig } from '@/main'
 
@@ -28,6 +29,7 @@ export const useAuthStore = defineStore('auth', () => {
     filterProtocolClaims: true,
     loadUserInfo: true,
   })
+  const fidoCreds = ref([])
 
   const userHasMailbox = computed(() => {
     return authUser.value.mailbox !== null
@@ -41,6 +43,34 @@ export const useAuthStore = defineStore('auth', () => {
     })
   }
 
+  async function getFidoCreds() {
+    return authApi.getAllFidoRegistred().then((resp) => {
+      fidoCreds.value = resp.data
+      if (fidoCreds.value.length > 0) {
+        authUser.value.tfa_enabled = true
+        authUser.value.webauthn_enabled = true
+      }
+      return resp
+    })
+  }
+
+  async function addFidoCred(result) {
+    return authApi.endFidoRegistration(result).then((res) => {
+      getFidoCreds()
+      return res
+    })
+  }
+
+  async function deleteFidoCreds(id) {
+    return authApi.deleteFido(id).then((res) => {
+      fidoCreds.value = fidoCreds.value.filter((cred) => cred.id !== id)
+      authUser.value.tfa_enabled = res.data.tfa_enabled
+      if (!res.data.tfa_enabled) {
+        authUser.value.webauthn_enabled = false
+      }
+    })
+  }
+
   function getAccessToken() {
     return manager.getUser().then((user) => {
       if (!user) {
@@ -120,17 +150,14 @@ export const useAuthStore = defineStore('auth', () => {
     })
   }
 
-  async function finalizeTFASetup(pinCode) {
-    return accountApi
-      .finalizeTFASetup(pinCode)
-      .then((response) => {
-        const cookie = Cookies.withAttributes({ sameSite: 'strict' })
-        cookie.set('token', response.data.access)
-        cookie.set('refreshToken', response.data.refresh)
-        initialize()
-        return response
-      })
-      .catch((error) => error)
+  function finalizeTFASetup(pinCode) {
+    return accountApi.finalizeTFASetup(pinCode).then((response) => {
+      const cookie = Cookies.withAttributes({ sameSite: 'strict' })
+      cookie.set('token', response.data.access)
+      cookie.set('refreshToken', response.data.refresh)
+      fetchUser()
+      return response
+    })
   }
 
   return {
@@ -138,9 +165,13 @@ export const useAuthStore = defineStore('auth', () => {
     completeLogin,
     isAuthenticated,
     userHasMailbox,
+    fidoCreds,
     validateAccess,
     fetchUser,
     getAccessToken,
+    getFidoCreds,
+    addFidoCred,
+    deleteFidoCreds,
     initialize,
     login,
     $reset,
diff --git a/frontend/src/views/account/APISetupView.vue b/frontend/src/views/account/APISetupView.vue
deleted file mode 100644
index c1cc75d1e..000000000
--- a/frontend/src/views/account/APISetupView.vue
+++ /dev/null
@@ -1,131 +0,0 @@
-<template>
-  <div>
-    <v-toolbar flat>
-      <v-toolbar-title>{{ $gettext('API access') }}</v-toolbar-title>
-    </v-toolbar>
-    <div class="pa-4">
-      <v-card v-if="!token" flat>
-        <v-card-text>
-          {{ $gettext('Your API token has not been generated yet.') }}
-        </v-card-text>
-        <v-card-actions>
-          <v-btn
-            color="success"
-            variant="flat"
-            :loading="loading"
-            @click="generateKey"
-          >
-            {{ $gettext('Generate') }}
-          </v-btn>
-        </v-card-actions>
-      </v-card>
-      <v-card v-else flat>
-        <v-card-text>
-          <label class="m-label">
-            {{ $gettext('Your API token is:') }}
-          </label>
-          <code class="ml-5 mr-5">
-            {{ token }}
-          </code>
-          <v-btn
-            icon="mdi-clipboard-plus"
-            density="compact"
-            :title="$gettext('Copy token to clipboard')"
-            variant="text"
-            @click="copyToClipboard()"
-          >
-          </v-btn>
-          <v-btn
-            icon="mdi-delete"
-            density="compact"
-            color="error"
-            variant="text"
-            :title="$gettext('Delete token')"
-            @click="openDeletionDialog"
-          >
-          </v-btn>
-        </v-card-text>
-      </v-card>
-      <v-alert type="info" variant="tonal" border="start" class="mt-4">
-        {{ $gettext('A documentation of the API is available') }}
-        <a :href="apiDocUrl" target="_blank">{{ $gettext('here') }}</a
-        >.
-      </v-alert>
-    </div>
-    <ConfirmDialog ref="confirm" @agree="deleteToken" />
-  </div>
-</template>
-
-<script setup lang="js">
-import accountApi from '@/api/account'
-import { useBusStore } from '@/stores'
-import ConfirmDialog from '@/components/tools/ConfirmDialog.vue'
-import { ref, computed, inject, onMounted } from 'vue'
-import { useGettext } from 'vue3-gettext'
-
-const { $gettext } = useGettext()
-const busStore = useBusStore()
-
-const $config = inject('$config')
-
-const apiDocUrl = computed(() => $config.API_DOC_URL)
-
-const loading = ref(false)
-const token = ref(null)
-const confirm = ref()
-
-function copyToClipboard() {
-  navigator.clipboard.writeText(token.value)
-  busStore.displayNotification({
-    msg: $gettext('API token copied to your clipboard'),
-    type: 'success',
-  })
-}
-
-function generateKey() {
-  loading.value = true
-  accountApi
-    .createAPIToken()
-    .then((resp) => {
-      token.value = resp.data.token
-      busStore.displayNotification({
-        msg: $gettext('API token created'),
-        type: 'success',
-      })
-    })
-    .finally(() => (loading.value = false))
-}
-
-function openDeletionDialog() {
-  confirm.value.open(
-    $gettext('Warning'),
-    $gettext('You are about to delete your API access token'),
-    {
-      color: 'error',
-      cancelLabel: $gettext('Cancel'),
-      agreeLabel: $gettext('Proceed'),
-    }
-  )
-}
-
-async function deleteToken() {
-  await accountApi.deleteAPIToken()
-  busStore.displayNotification({
-    msg: $gettext('API token deleted'),
-    type: 'success',
-  })
-  token.value = null
-}
-
-onMounted(() => {
-  accountApi.getAPIToken().then((resp) => {
-    token.value = resp.data.token
-  })
-})
-</script>
-
-<style scoped>
-.v-toolbar {
-  background-color: #f7f8fa !important;
-}
-</style>
diff --git a/frontend/src/views/account/ForwardView.vue b/frontend/src/views/account/ForwardView.vue
deleted file mode 100644
index 0c34331c0..000000000
--- a/frontend/src/views/account/ForwardView.vue
+++ /dev/null
@@ -1,18 +0,0 @@
-<template>
-  <v-toolbar flat>
-    <v-toolbar-title>{{ $gettext('Forward') }}</v-toolbar-title>
-  </v-toolbar>
-  <div class="pa-4">
-    <ForwardForm />
-  </div>
-</template>
-
-<script setup lang="js">
-import ForwardForm from '@/components/account/ForwardForm.vue'
-</script>
-
-<style scoped>
-.v-toolbar {
-  background-color: #f7f8fa !important;
-}
-</style>
diff --git a/frontend/src/views/account/ProfileView.vue b/frontend/src/views/account/ProfileView.vue
deleted file mode 100644
index 89b5bdd5c..000000000
--- a/frontend/src/views/account/ProfileView.vue
+++ /dev/null
@@ -1,23 +0,0 @@
-<template>
-  <div>
-    <v-toolbar flat>
-      <v-toolbar-title>{{ $gettext('Profile') }}</v-toolbar-title>
-    </v-toolbar>
-    <div class="pa-4">
-      <ProfileForm />
-    </div>
-  </div>
-</template>
-
-<script setup lang="js">
-import ProfileForm from '@/components/account/ProfileForm.vue'
-import { useGettext } from 'vue3-gettext'
-
-const { $gettext } = useGettext()
-</script>
-
-<style scoped>
-.v-toolbar {
-  background-color: #f7f8fa !important;
-}
-</style>
diff --git a/frontend/src/views/account/SecurityView.vue b/frontend/src/views/account/SecurityView.vue
deleted file mode 100644
index dddfd6085..000000000
--- a/frontend/src/views/account/SecurityView.vue
+++ /dev/null
@@ -1,23 +0,0 @@
-<template>
-  <div>
-    <v-toolbar flat>
-      <v-toolbar-title>{{ $gettext('Security') }}</v-toolbar-title>
-    </v-toolbar>
-    <div class="pa-4">
-      <TwoFactorAuthForm />
-    </div>
-  </div>
-</template>
-
-<script setup lang="js">
-import TwoFactorAuthForm from '@/components/account/TwoFactorAuthForm.vue'
-import { useGettext } from 'vue3-gettext'
-
-const { $gettext } = useGettext()
-</script>
-
-<style scoped>
-.v-toolbar {
-  background-color: #f7f8fa !important;
-}
-</style>
diff --git a/frontend/src/views/account/SettingsView.vue b/frontend/src/views/account/SettingsView.vue
index 6180c83e9..eab7b06b4 100644
--- a/frontend/src/views/account/SettingsView.vue
+++ b/frontend/src/views/account/SettingsView.vue
@@ -1,56 +1,59 @@
 <template>
-<v-toolbar flat>
-  <v-toolbar-title>{{ $gettext('Account settings') }}</v-toolbar-title>
-</v-toolbar>
-<div class="pa-4">
-  <v-tabs
-    v-model="tab"
-    color="primary"
-    class="mb-4"
-  >
-    <v-tab value="profile">
-      {{ $gettext('Profile') }}
-    </v-tab>
-    <v-tab value="security">
-      {{ $gettext('Security') }}
-    </v-tab>
-    <v-tab value="forward" v-if="authStore.userHasMailbox">
-      {{ $gettext('Forward') }}
-    </v-tab>
-    <v-tab value="autoreply" v-if="authStore.userHasMailbox">
-      {{ $gettext('Auto-reply message') }}
-    </v-tab>
-    <v-tab value="api" v-if="authStore.authUser.role === 'SuperAdmins'">
-      {{ $gettext('API access') }}
-    </v-tab>
-  </v-tabs>
-  <v-window v-model="tab">
-    <v-window-item value="profile">
-      <ProfileForm />
-    </v-window-item>
-    <v-window-item value="security">
-      <TwoFactorAuthForm />
-    </v-window-item>
-    <v-window-item value="forward" v-if="authStore.userHasMailbox">
-      <ForwardForm />
-    </v-window-item>
-    <v-window-item value="autoreply" v-if="authStore.userHasMailbox">
-      <AutoReplyForm />
-    </v-window-item>
-    <v-window-item value="api" v-if="authStore.authUser.role === 'SuperAdmins'">
-      <APISetupForm />
-    </v-window-item>
-  </v-window>
-</div>
+  <v-toolbar flat>
+    <v-toolbar-title>{{ $gettext('Account settings') }}</v-toolbar-title>
+  </v-toolbar>
+  <div class="pa-4">
+    <v-tabs v-model="tab" color="primary" class="mb-4">
+      <v-tab value="profile">
+        {{ $gettext('Profile') }}
+      </v-tab>
+      <v-tab value="security">
+        {{ $gettext('Two-factor auth') }}
+      </v-tab>
+      <v-tab v-if="authStore.userHasMailbox" value="forward">
+        {{ $gettext('Forward') }}
+      </v-tab>
+      <v-tab v-if="authStore.userHasMailbox" value="autoreply">
+        {{ $gettext('Auto-reply message') }}
+      </v-tab>
+      <v-tab v-if="authStore.authUser.role === 'SuperAdmins'" value="api">
+        {{ $gettext('API access') }}
+      </v-tab>
+    </v-tabs>
+    <v-window v-model="tab">
+      <v-window-item value="profile">
+        <ProfileForm />
+      </v-window-item>
+      <v-window-item value="security">
+        <TotpAuthForm />
+        <BackupCodeAuthForm />
+        <FidoAuthForm />
+      </v-window-item>
+      <v-window-item v-if="authStore.userHasMailbox" value="forward">
+        <ForwardForm />
+      </v-window-item>
+      <v-window-item v-if="authStore.userHasMailbox" value="autoreply">
+        <AutoReplyForm />
+      </v-window-item>
+      <v-window-item
+        v-if="authStore.authUser.role === 'SuperAdmins'"
+        value="api"
+      >
+        <APISetupForm />
+      </v-window-item>
+    </v-window>
+  </div>
 </template>
 
 <script setup>
 import { ref } from 'vue'
 import ProfileForm from '@/components/account/ProfileForm.vue'
-import TwoFactorAuthForm from '@/components/account/TwoFactorAuthForm.vue'
+import TotpAuthForm from '@/components/account/TotpAuthForm.vue'
+import BackupCodeAuthForm from '@/components/account/BackupCodeAuthForm.vue'
 import ForwardForm from '@/components/account/ForwardForm.vue'
 import AutoReplyForm from '@/components/account/AutoReplyForm.vue'
 import APISetupForm from '@/components/account/APISetupForm.vue'
+import FidoAuthForm from '@/components/account/FidoAuthForm.vue'
 import { useAuthStore } from '@/stores'
 
 const authStore = useAuthStore()
@@ -58,10 +61,10 @@ const tab = ref()
 </script>
 
 <style scoped>
- .v-toolbar {
-   background-color: #f7f8fa !important;
- }
- .v-tabs {
-   background-color: #f7f8fa !important;
- }
+.v-toolbar {
+  background-color: #f7f8fa !important;
+}
+.v-tabs {
+  background-color: #f7f8fa !important;
+}
 </style>
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index b5d3d6fb1..004c0e2e7 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -2,6 +2,7 @@
 import vue from '@vitejs/plugin-vue'
 import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
 import ViteFonts from 'unplugin-fonts/vite'
+import basicSsl from '@vitejs/plugin-basic-ssl'
 
 // Utilities
 import { defineConfig } from 'vite'
@@ -10,6 +11,7 @@ import { fileURLToPath, URL } from 'node:url'
 // https://vitejs.dev/config/
 export default defineConfig({
   plugins: [
+    basicSsl(),
     vue({
       template: { transformAssetUrls },
     }),
@@ -42,10 +44,14 @@ export default defineConfig({
   server: {
     port: 3000,
     proxy: {
-      '/api':
-        process.env.DOCKER == 'yes'
-          ? 'http://api:8000'
-          : 'http://127.0.0.1:8000',
+      '/api': {
+        target:
+          process.env.DOCKER == 'yes'
+            ? 'https://api:8000'
+            : 'http://127.0.0.1:8000',
+        secure: false,
+      },
     },
+    https: true,
   },
 })
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 178dfbc1c..3a7e878af 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -29,125 +29,125 @@
     chalk "^2.4.2"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.23.9":
-  version "7.23.9"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.9.tgz#7b903b6149b0f8fa7ad564af646c4c38a77fc44b"
-  integrity sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==
-
-"@esbuild/aix-ppc64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz#d1bc06aedb6936b3b6d313bf809a5a40387d2b7f"
-  integrity sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==
-
-"@esbuild/android-arm64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz#7ad65a36cfdb7e0d429c353e00f680d737c2aed4"
-  integrity sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==
-
-"@esbuild/android-arm@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.12.tgz#b0c26536f37776162ca8bde25e42040c203f2824"
-  integrity sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==
-
-"@esbuild/android-x64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.12.tgz#cb13e2211282012194d89bf3bfe7721273473b3d"
-  integrity sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==
-
-"@esbuild/darwin-arm64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz#cbee41e988020d4b516e9d9e44dd29200996275e"
-  integrity sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==
-
-"@esbuild/darwin-x64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz#e37d9633246d52aecf491ee916ece709f9d5f4cd"
-  integrity sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==
-
-"@esbuild/freebsd-arm64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz#1ee4d8b682ed363b08af74d1ea2b2b4dbba76487"
-  integrity sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==
-
-"@esbuild/freebsd-x64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz#37a693553d42ff77cd7126764b535fb6cc28a11c"
-  integrity sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==
-
-"@esbuild/linux-arm64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz#be9b145985ec6c57470e0e051d887b09dddb2d4b"
-  integrity sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==
-
-"@esbuild/linux-arm@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz#207ecd982a8db95f7b5279207d0ff2331acf5eef"
-  integrity sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==
-
-"@esbuild/linux-ia32@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz#d0d86b5ca1562523dc284a6723293a52d5860601"
-  integrity sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==
-
-"@esbuild/linux-loong64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz#9a37f87fec4b8408e682b528391fa22afd952299"
-  integrity sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==
-
-"@esbuild/linux-mips64el@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz#4ddebd4e6eeba20b509d8e74c8e30d8ace0b89ec"
-  integrity sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==
-
-"@esbuild/linux-ppc64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz#adb67dadb73656849f63cd522f5ecb351dd8dee8"
-  integrity sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==
-
-"@esbuild/linux-riscv64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz#11bc0698bf0a2abf8727f1c7ace2112612c15adf"
-  integrity sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==
-
-"@esbuild/linux-s390x@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz#e86fb8ffba7c5c92ba91fc3b27ed5a70196c3cc8"
-  integrity sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==
-
-"@esbuild/linux-x64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz#5f37cfdc705aea687dfe5dfbec086a05acfe9c78"
-  integrity sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==
-
-"@esbuild/netbsd-x64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz#29da566a75324e0d0dd7e47519ba2f7ef168657b"
-  integrity sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==
-
-"@esbuild/openbsd-x64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz#306c0acbdb5a99c95be98bdd1d47c916e7dc3ff0"
-  integrity sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==
-
-"@esbuild/sunos-x64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz#0933eaab9af8b9b2c930236f62aae3fc593faf30"
-  integrity sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==
-
-"@esbuild/win32-arm64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz#773bdbaa1971b36db2f6560088639ccd1e6773ae"
-  integrity sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==
-
-"@esbuild/win32-ia32@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz#000516cad06354cc84a73f0943a4aa690ef6fd67"
-  integrity sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==
-
-"@esbuild/win32-x64@0.19.12":
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae"
-  integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==
+"@babel/parser@^7.24.7":
+  version "7.24.7"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.7.tgz#9a5226f92f0c5c8ead550b750f5608e766c8ce85"
+  integrity sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==
+
+"@esbuild/aix-ppc64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
+  integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==
+
+"@esbuild/android-arm64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052"
+  integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
+
+"@esbuild/android-arm@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28"
+  integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==
+
+"@esbuild/android-x64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e"
+  integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==
+
+"@esbuild/darwin-arm64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a"
+  integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==
+
+"@esbuild/darwin-x64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22"
+  integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==
+
+"@esbuild/freebsd-arm64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e"
+  integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==
+
+"@esbuild/freebsd-x64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261"
+  integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==
+
+"@esbuild/linux-arm64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b"
+  integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
+
+"@esbuild/linux-arm@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9"
+  integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==
+
+"@esbuild/linux-ia32@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2"
+  integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==
+
+"@esbuild/linux-loong64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df"
+  integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==
+
+"@esbuild/linux-mips64el@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe"
+  integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==
+
+"@esbuild/linux-ppc64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4"
+  integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==
+
+"@esbuild/linux-riscv64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc"
+  integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==
+
+"@esbuild/linux-s390x@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de"
+  integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==
+
+"@esbuild/linux-x64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0"
+  integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==
+
+"@esbuild/netbsd-x64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047"
+  integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==
+
+"@esbuild/openbsd-x64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70"
+  integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==
+
+"@esbuild/sunos-x64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b"
+  integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==
+
+"@esbuild/win32-arm64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d"
+  integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==
+
+"@esbuild/win32-ia32@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b"
+  integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==
+
+"@esbuild/win32-x64@0.21.5":
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c"
+  integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==
 
 "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
   version "4.4.0"
@@ -161,44 +161,66 @@
   resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63"
   integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==
 
-"@eslint/eslintrc@^2.1.4":
-  version "2.1.4"
-  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad"
-  integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==
+"@eslint/config-array@^0.16.0":
+  version "0.16.0"
+  resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.16.0.tgz#bb3364fc39ee84ec3a62abdc4b8d988d99dfd706"
+  integrity sha512-/jmuSd74i4Czf1XXn7wGRWZCuyaUZ330NH1Bek0Pplatt4Sy1S5haN21SCLLdbeKslQ+S0wEJ+++v5YibSi+Lg==
+  dependencies:
+    "@eslint/object-schema" "^2.1.4"
+    debug "^4.3.1"
+    minimatch "^3.0.5"
+
+"@eslint/eslintrc@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.1.0.tgz#dbd3482bfd91efa663cbe7aa1f506839868207b6"
+  integrity sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==
   dependencies:
     ajv "^6.12.4"
     debug "^4.3.2"
-    espree "^9.6.0"
-    globals "^13.19.0"
+    espree "^10.0.1"
+    globals "^14.0.0"
     ignore "^5.2.0"
     import-fresh "^3.2.1"
     js-yaml "^4.1.0"
     minimatch "^3.1.2"
     strip-json-comments "^3.1.1"
 
-"@eslint/js@8.56.0":
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b"
-  integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==
+"@eslint/js@9.5.0":
+  version "9.5.0"
+  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.5.0.tgz#0e9c24a670b8a5c86bff97b40be13d8d8f238045"
+  integrity sha512-A7+AOT2ICkodvtsWnxZP4Xxk3NbZ3VMHd8oihydLRGrJgqqdEz1qSeEgXYyT/Cu8h1TWWsQRejIx48mtjZ5y1w==
 
-"@humanwhocodes/config-array@^0.11.13":
-  version "0.11.14"
-  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b"
-  integrity sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==
-  dependencies:
-    "@humanwhocodes/object-schema" "^2.0.2"
-    debug "^4.3.1"
-    minimatch "^3.0.5"
+"@eslint/object-schema@^2.1.4":
+  version "2.1.4"
+  resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.4.tgz#9e69f8bb4031e11df79e03db09f9dbbae1740843"
+  integrity sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==
+
+"@github/webauthn-json@^2.1.1":
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/@github/webauthn-json/-/webauthn-json-2.1.1.tgz#648e63fc28050917d2882cc2b27817a88cb420fc"
+  integrity sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==
 
 "@humanwhocodes/module-importer@^1.0.1":
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
   integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
 
-"@humanwhocodes/object-schema@^2.0.2":
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917"
-  integrity sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==
+"@humanwhocodes/retry@^0.3.0":
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.3.0.tgz#6d86b8cb322660f03d3f0aa94b99bdd8e172d570"
+  integrity sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==
+
+"@isaacs/cliui@^8.0.2":
+  version "8.0.2"
+  resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
+  integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==
+  dependencies:
+    string-width "^5.1.2"
+    string-width-cjs "npm:string-width@^4.2.0"
+    strip-ansi "^7.0.1"
+    strip-ansi-cjs "npm:strip-ansi@^6.0.1"
+    wrap-ansi "^8.1.0"
+    wrap-ansi-cjs "npm:wrap-ansi@^7.0.0"
 
 "@jridgewell/sourcemap-codec@^1.4.15":
   version "1.4.15"
@@ -231,70 +253,90 @@
     "@nodelib/fs.scandir" "2.1.5"
     fastq "^1.6.0"
 
-"@rollup/rollup-android-arm-eabi@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.11.0.tgz#78693f843483a511bce6ce1d8a153a49f4cbab87"
-  integrity sha512-BV+u2QSfK3i1o6FucqJh5IK9cjAU6icjFFhvknzFgu472jzl0bBojfDAkJLBEsHFMo+YZg6rthBvBBt8z12IBQ==
-
-"@rollup/rollup-android-arm64@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.11.0.tgz#772cb1856f720863d982c17f25d3fbb930c946d3"
-  integrity sha512-0ij3iw7sT5jbcdXofWO2NqDNjSVVsf6itcAkV2I6Xsq4+6wjW1A8rViVB67TfBEan7PV2kbLzT8rhOVWLI2YXw==
-
-"@rollup/rollup-darwin-arm64@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.11.0.tgz#cac4aac05432ac684a6c6c5788e8db738e047a22"
-  integrity sha512-yPLs6RbbBMupArf6qv1UDk6dzZvlH66z6NLYEwqTU0VHtss1wkI4UYeeMS7TVj5QRVvaNAWYKP0TD/MOeZ76Zg==
-
-"@rollup/rollup-darwin-x64@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.11.0.tgz#e36d66c6b2d4d716c94926a375d6865b0c0c9c02"
-  integrity sha512-OvqIgwaGAwnASzXaZEeoJY3RltOFg+WUbdkdfoluh2iqatd090UeOG3A/h0wNZmE93dDew9tAtXgm3/+U/B6bw==
-
-"@rollup/rollup-linux-arm-gnueabihf@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.11.0.tgz#f7cc62debb348fb109724c882b99adc5c3f73e31"
-  integrity sha512-X17s4hZK3QbRmdAuLd2EE+qwwxL8JxyVupEqAkxKPa/IgX49ZO+vf0ka69gIKsaYeo6c1CuwY3k8trfDtZ9dFg==
-
-"@rollup/rollup-linux-arm64-gnu@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.11.0.tgz#ed398c7434734101e77a1fd0b05682b2c2027ecd"
-  integrity sha512-673Lu9EJwxVB9NfYeA4AdNu0FOHz7g9t6N1DmT7bZPn1u6bTF+oZjj+fuxUcrfxWXE0r2jxl5QYMa9cUOj9NFg==
-
-"@rollup/rollup-linux-arm64-musl@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.11.0.tgz#4860560d18d9f18568859a800b1e79fe3b85df9e"
-  integrity sha512-yFW2msTAQNpPJaMmh2NpRalr1KXI7ZUjlN6dY/FhWlOclMrZezm5GIhy3cP4Ts2rIAC+IPLAjNibjp1BsxCVGg==
-
-"@rollup/rollup-linux-riscv64-gnu@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.11.0.tgz#25bc0e9dbd5feab0b6fa68b2b87ce5f28a8a7c19"
-  integrity sha512-kKT9XIuhbvYgiA3cPAGntvrBgzhWkGpBMzuk1V12Xuoqg7CI41chye4HU0vLJnGf9MiZzfNh4I7StPeOzOWJfA==
-
-"@rollup/rollup-linux-x64-gnu@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.11.0.tgz#8c318d9c33a89cf9f917a1be90b0d8c75e0ab3ba"
-  integrity sha512-6q4ESWlyTO+erp1PSCmASac+ixaDv11dBk1fqyIuvIUc/CmRAX2Zk+2qK1FGo5q7kyDcjHCFVwgGFCGIZGVwCA==
-
-"@rollup/rollup-linux-x64-musl@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.11.0.tgz#132cf0787c966b3b22cf4475706fc4cb08ec880c"
-  integrity sha512-vIAQUmXeMLmaDN78HSE4Kh6xqof2e3TJUKr+LPqXWU4NYNON0MDN9h2+t4KHrPAQNmU3w1GxBQ/n01PaWFwa5w==
-
-"@rollup/rollup-win32-arm64-msvc@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.11.0.tgz#18e49376786def75843e605bdc8059a301c11dad"
-  integrity sha512-LVXo9dDTGPr0nezMdqa1hK4JeoMZ02nstUxGYY/sMIDtTYlli1ZxTXBYAz3vzuuvKO4X6NBETciIh7N9+abT1g==
-
-"@rollup/rollup-win32-ia32-msvc@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.11.0.tgz#ff9281d8b189436b7c234a52e7e0b635631c8667"
-  integrity sha512-xZVt6K70Gr3I7nUhug2dN6VRR1ibot3rXqXS3wo+8JP64t7djc3lBFyqO4GiVrhNaAIhUCJtwQ/20dr0h0thmQ==
-
-"@rollup/rollup-win32-x64-msvc@4.11.0":
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.11.0.tgz#a59158f78d07cd3ac15092ffca2f0319f3bb69f6"
-  integrity sha512-f3I7h9oTg79UitEco9/2bzwdciYkWr8pITs3meSDSlr1TdvQ7IxkQaaYN2YqZXX5uZhiYL+VuYDmHwNzhx+HOg==
+"@pkgjs/parseargs@^0.11.0":
+  version "0.11.0"
+  resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
+  integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
+
+"@rollup/rollup-android-arm-eabi@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz#bbd0e616b2078cd2d68afc9824d1fadb2f2ffd27"
+  integrity sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==
+
+"@rollup/rollup-android-arm64@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz#97255ef6384c5f73f4800c0de91f5f6518e21203"
+  integrity sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==
+
+"@rollup/rollup-darwin-arm64@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz#b6dd74e117510dfe94541646067b0545b42ff096"
+  integrity sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==
+
+"@rollup/rollup-darwin-x64@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz#e07d76de1cec987673e7f3d48ccb8e106d42c05c"
+  integrity sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==
+
+"@rollup/rollup-linux-arm-gnueabihf@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz#9f1a6d218b560c9d75185af4b8bb42f9f24736b8"
+  integrity sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==
+
+"@rollup/rollup-linux-arm-musleabihf@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz#53618b92e6ffb642c7b620e6e528446511330549"
+  integrity sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==
+
+"@rollup/rollup-linux-arm64-gnu@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz#99a7ba5e719d4f053761a698f7b52291cefba577"
+  integrity sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==
+
+"@rollup/rollup-linux-arm64-musl@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz#f53db99a45d9bc00ce94db8a35efa7c3c144a58c"
+  integrity sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==
+
+"@rollup/rollup-linux-powerpc64le-gnu@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz#cbb0837408fe081ce3435cf3730e090febafc9bf"
+  integrity sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==
+
+"@rollup/rollup-linux-riscv64-gnu@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz#8ed09c1d1262ada4c38d791a28ae0fea28b80cc9"
+  integrity sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==
+
+"@rollup/rollup-linux-s390x-gnu@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz#938138d3c8e0c96f022252a28441dcfb17afd7ec"
+  integrity sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==
+
+"@rollup/rollup-linux-x64-gnu@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz#1a7481137a54740bee1ded4ae5752450f155d942"
+  integrity sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==
+
+"@rollup/rollup-linux-x64-musl@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz#f1186afc601ac4f4fc25fac4ca15ecbee3a1874d"
+  integrity sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==
+
+"@rollup/rollup-win32-arm64-msvc@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz#ed6603e93636a96203c6915be4117245c1bd2daf"
+  integrity sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==
+
+"@rollup/rollup-win32-ia32-msvc@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz#14e0b404b1c25ebe6157a15edb9c46959ba74c54"
+  integrity sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==
+
+"@rollup/rollup-win32-x64-msvc@4.18.0":
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz#5d694d345ce36b6ecf657349e03eb87297e68da4"
+  integrity sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==
 
 "@types/estree@1.0.5":
   version "1.0.5"
@@ -326,104 +368,110 @@
   resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109"
   integrity sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==
 
-"@ungap/structured-clone@^1.2.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
-  integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==
+"@vitejs/plugin-basic-ssl@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.1.0.tgz#8b840305a6b48e8764803435ec0c716fa27d3802"
+  integrity sha512-wO4Dk/rm8u7RNhOf95ZzcEmC9rYOncYgvq4z3duaJrCgjN8BxAnDVyndanfcJZ0O6XZzHz6Q0hTimxTg8Y9g/A==
 
-"@vitejs/plugin-vue@^5.0.4":
-  version "5.0.4"
-  resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.0.4.tgz#508d6a0f2440f86945835d903fcc0d95d1bb8a37"
-  integrity sha512-WS3hevEszI6CEVEx28F8RjTX97k3KsrcY6kvTg7+Whm5y3oYvcqzVeGCU3hxSAn4uY2CLCkeokkGKpoctccilQ==
+"@vitejs/plugin-vue@^5.0.5":
+  version "5.0.5"
+  resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.0.5.tgz#e3dc11e427d4b818b7e3202766ad156e3d5e2eaa"
+  integrity sha512-LOjm7XeIimLBZyzinBQ6OSm3UBCNVCpLkxGC0oWmm2YPzVZoxMsdvNVimLTBzpAnR9hl/yn1SHGuRfe6/Td9rQ==
 
-"@vue/compiler-core@3.4.19":
-  version "3.4.19"
-  resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.4.19.tgz#3161b1ede69da00f3ce8155dfab907a3eaa0515e"
-  integrity sha512-gj81785z0JNzRcU0Mq98E56e4ltO1yf8k5PQ+tV/7YHnbZkrM0fyFyuttnN8ngJZjbpofWE/m4qjKBiLl8Ju4w==
+"@vue/compiler-core@3.4.29":
+  version "3.4.29"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.4.29.tgz#6c0878e98716b1cb64e7d44ed07feda96ab7f639"
+  integrity sha512-TFKiRkKKsRCKvg/jTSSKK7mYLJEQdUiUfykbG49rubC9SfDyvT2JrzTReopWlz2MxqeLyxh9UZhvxEIBgAhtrg==
   dependencies:
-    "@babel/parser" "^7.23.9"
-    "@vue/shared" "3.4.19"
+    "@babel/parser" "^7.24.7"
+    "@vue/shared" "3.4.29"
     entities "^4.5.0"
     estree-walker "^2.0.2"
-    source-map-js "^1.0.2"
-
-"@vue/compiler-dom@3.4.19":
-  version "3.4.19"
-  resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.4.19.tgz#2457e57e978f431e3b5fd11fc50a3e92d5816f9a"
-  integrity sha512-vm6+cogWrshjqEHTzIDCp72DKtea8Ry/QVpQRYoyTIg9k7QZDX6D8+HGURjtmatfgM8xgCFtJJaOlCaRYRK3QA==
-  dependencies:
-    "@vue/compiler-core" "3.4.19"
-    "@vue/shared" "3.4.19"
-
-"@vue/compiler-sfc@3.4.19":
-  version "3.4.19"
-  resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.4.19.tgz#33b238ded6d63e51f6a7048b742626f6007df129"
-  integrity sha512-LQ3U4SN0DlvV0xhr1lUsgLCYlwQfUfetyPxkKYu7dkfvx7g3ojrGAkw0AERLOKYXuAGnqFsEuytkdcComei3Yg==
-  dependencies:
-    "@babel/parser" "^7.23.9"
-    "@vue/compiler-core" "3.4.19"
-    "@vue/compiler-dom" "3.4.19"
-    "@vue/compiler-ssr" "3.4.19"
-    "@vue/shared" "3.4.19"
+    source-map-js "^1.2.0"
+
+"@vue/compiler-dom@3.4.29":
+  version "3.4.29"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.4.29.tgz#c8f55528c8d8c8c36687d56a19e53b268c7d6c56"
+  integrity sha512-A6+iZ2fKIEGnfPJejdB7b1FlJzgiD+Y/sxxKwJWg1EbJu6ZPgzaPQQ51ESGNv0CP6jm6Z7/pO6Ia8Ze6IKrX7w==
+  dependencies:
+    "@vue/compiler-core" "3.4.29"
+    "@vue/shared" "3.4.29"
+
+"@vue/compiler-sfc@3.4.29":
+  version "3.4.29"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.4.29.tgz#da7927c5c736048995fe9c6604288633e0ac161a"
+  integrity sha512-zygDcEtn8ZimDlrEQyLUovoWgKQic6aEQqRXce2WXBvSeHbEbcAsXyCk9oG33ZkyWH4sl9D3tkYc1idoOkdqZQ==
+  dependencies:
+    "@babel/parser" "^7.24.7"
+    "@vue/compiler-core" "3.4.29"
+    "@vue/compiler-dom" "3.4.29"
+    "@vue/compiler-ssr" "3.4.29"
+    "@vue/shared" "3.4.29"
     estree-walker "^2.0.2"
-    magic-string "^0.30.6"
-    postcss "^8.4.33"
-    source-map-js "^1.0.2"
+    magic-string "^0.30.10"
+    postcss "^8.4.38"
+    source-map-js "^1.2.0"
 
-"@vue/compiler-ssr@3.4.19":
-  version "3.4.19"
-  resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.4.19.tgz#1f8ee06005ebbaa354f8783fad84e9f7ea4a69c2"
-  integrity sha512-P0PLKC4+u4OMJ8sinba/5Z/iDT84uMRRlrWzadgLA69opCpI1gG4N55qDSC+dedwq2fJtzmGald05LWR5TFfLw==
+"@vue/compiler-ssr@3.4.29":
+  version "3.4.29"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.4.29.tgz#210b0267667fe1e5ec69ca4e3c473f94da6ac37f"
+  integrity sha512-rFbwCmxJ16tDp3N8XCx5xSQzjhidYjXllvEcqX/lopkoznlNPz3jyy0WGJCyhAaVQK677WWFt3YO/WUEkMMUFQ==
   dependencies:
-    "@vue/compiler-dom" "3.4.19"
-    "@vue/shared" "3.4.19"
+    "@vue/compiler-dom" "3.4.29"
+    "@vue/shared" "3.4.29"
 
 "@vue/devtools-api@^6.5.0":
   version "6.6.1"
   resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.1.tgz#7c14346383751d9f6ad4bea0963245b30220ef83"
   integrity sha512-LgPscpE3Vs0x96PzSSB4IGVSZXZBZHpfxs+ZA1d+VEPwHdOXowy/Y2CsvCAIFrf+ssVU1pD1jidj505EpUnfbA==
 
-"@vue/reactivity@3.4.19":
-  version "3.4.19"
-  resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.4.19.tgz#8cf335d97d07881d8184cb23289289dc18b03f60"
-  integrity sha512-+VcwrQvLZgEclGZRHx4O2XhyEEcKaBi50WbxdVItEezUf4fqRh838Ix6amWTdX0CNb/b6t3Gkz3eOebfcSt+UA==
+"@vue/devtools-api@^6.5.1":
+  version "6.6.3"
+  resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.3.tgz#b23a588154cba8986bba82b6e1d0248bde3fd1a0"
+  integrity sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw==
+
+"@vue/reactivity@3.4.29":
+  version "3.4.29"
+  resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.4.29.tgz#a821b12b765ecb9a1923a401d6c0979dc668f7af"
+  integrity sha512-w8+KV+mb1a8ornnGQitnMdLfE0kXmteaxLdccm2XwdFxXst4q/Z7SEboCV5SqJNpZbKFeaRBBJBhW24aJyGINg==
   dependencies:
-    "@vue/shared" "3.4.19"
+    "@vue/shared" "3.4.29"
 
-"@vue/runtime-core@3.4.19":
-  version "3.4.19"
-  resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.4.19.tgz#ef10357fdf3afdf68523b55424541000105e2aeb"
-  integrity sha512-/Z3tFwOrerJB/oyutmJGoYbuoadphDcJAd5jOuJE86THNZji9pYjZroQ2NFsZkTxOq0GJbb+s2kxTYToDiyZzw==
+"@vue/runtime-core@3.4.29":
+  version "3.4.29"
+  resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.4.29.tgz#3c7d5ef00aa8ab1e1de9de0a8656f21db3cd8367"
+  integrity sha512-s8fmX3YVR/Rk5ig0ic0NuzTNjK2M7iLuVSZyMmCzN/+Mjuqqif1JasCtEtmtoJWF32pAtUjyuT2ljNKNLeOmnQ==
   dependencies:
-    "@vue/reactivity" "3.4.19"
-    "@vue/shared" "3.4.19"
+    "@vue/reactivity" "3.4.29"
+    "@vue/shared" "3.4.29"
 
-"@vue/runtime-dom@3.4.19":
-  version "3.4.19"
-  resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.4.19.tgz#079141e31d9f47515b9595f29843d51011f88739"
-  integrity sha512-IyZzIDqfNCF0OyZOauL+F4yzjMPN2rPd8nhqPP2N1lBn3kYqJpPHHru+83Rkvo2lHz5mW+rEeIMEF9qY3PB94g==
+"@vue/runtime-dom@3.4.29":
+  version "3.4.29"
+  resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.4.29.tgz#02d4199e8280b3f1332ec35e03bfcc244ce2bfdb"
+  integrity sha512-gI10atCrtOLf/2MPPMM+dpz3NGulo9ZZR9d1dWo4fYvm+xkfvRrw1ZmJ7mkWtiJVXSsdmPbcK1p5dZzOCKDN0g==
   dependencies:
-    "@vue/runtime-core" "3.4.19"
-    "@vue/shared" "3.4.19"
+    "@vue/reactivity" "3.4.29"
+    "@vue/runtime-core" "3.4.29"
+    "@vue/shared" "3.4.29"
     csstype "^3.1.3"
 
-"@vue/server-renderer@3.4.19":
-  version "3.4.19"
-  resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.4.19.tgz#e6f8ff5268d0758766ca9835375218924d5f0eb6"
-  integrity sha512-eAj2p0c429RZyyhtMRnttjcSToch+kTWxFPHlzGMkR28ZbF1PDlTcmGmlDxccBuqNd9iOQ7xPRPAGgPVj+YpQw==
+"@vue/server-renderer@3.4.29":
+  version "3.4.29"
+  resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.4.29.tgz#7a6d322837d836dd3affa69553f9fba140f91723"
+  integrity sha512-HMLCmPI2j/k8PVkSBysrA2RxcxC5DgBiCdj7n7H2QtR8bQQPqKAe8qoaxLcInzouBmzwJ+J0x20ygN/B5mYBng==
   dependencies:
-    "@vue/compiler-ssr" "3.4.19"
-    "@vue/shared" "3.4.19"
+    "@vue/compiler-ssr" "3.4.29"
+    "@vue/shared" "3.4.29"
 
-"@vue/shared@3.4.19":
-  version "3.4.19"
-  resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.4.19.tgz#28105147811bcf1e6612bf1c9ab0c6d91ada019c"
-  integrity sha512-/KliRRHMF6LoiThEy+4c1Z4KB/gbPrGjWwJR+crg2otgrf/egKzRaCPvJ51S5oetgsgXLfc4Rm5ZgrKHZrtMSw==
+"@vue/shared@3.4.29":
+  version "3.4.29"
+  resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.4.29.tgz#84908c284e88a269f8bceee59707b14eb4b2d284"
+  integrity sha512-hQ2gAQcBO/CDpC82DCrinJNgOHI2v+FA7BDW4lMSPeBpQ7sRe2OLHWe5cph1s7D8DUQAwRt18dBDfJJ220APEA==
 
-"@vuetify/loader-shared@^2.0.1":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@vuetify/loader-shared/-/loader-shared-2.0.1.tgz#4bb50ce6455b1c37958a58a63cc32e4ae6829287"
-  integrity sha512-zy5/ohEO7RcJaWYu2Xiy8TBEOkTb42XvWvSAJwXAtY8OlwqyGhzzBp9OvMVjLGIuFXumBpXKlsaVIkeN0OWWSw==
+"@vuetify/loader-shared@^2.0.3":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@vuetify/loader-shared/-/loader-shared-2.0.3.tgz#11451c717e4a352ec311da52a79c857cd256c92f"
+  integrity sha512-Ss3GC7eJYkp2SF6xVzsT7FAruEmdihmn4OCk2+UocREerlXKWgOKKzTN5PN3ZVN5q05jHHrsNhTuWbhN61Bpdg==
   dependencies:
     upath "^2.0.1"
 
@@ -442,6 +490,11 @@ acorn@^8.11.3, acorn@^8.9.0:
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a"
   integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==
 
+acorn@^8.12.0:
+  version "8.12.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.0.tgz#1627bfa2e058148036133b8d9b51a700663c294c"
+  integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==
+
 ajv@^6.12.4:
   version "6.12.6"
   resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
@@ -457,6 +510,11 @@ ansi-regex@^5.0.1:
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
   integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
 
+ansi-regex@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a"
+  integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==
+
 ansi-styles@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@@ -464,13 +522,18 @@ ansi-styles@^3.2.1:
   dependencies:
     color-convert "^1.9.0"
 
-ansi-styles@^4.1.0:
+ansi-styles@^4.0.0, ansi-styles@^4.1.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
   integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
   dependencies:
     color-convert "^2.0.1"
 
+ansi-styles@^6.1.0:
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
+  integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
+
 anymatch@~3.1.2:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
@@ -479,10 +542,10 @@ anymatch@~3.1.2:
     normalize-path "^3.0.0"
     picomatch "^2.0.4"
 
-apexcharts@^3.45.2:
-  version "3.45.2"
-  resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-3.45.2.tgz#18a6343da122c12d71ac9f35260aacd31b16fbfb"
-  integrity sha512-PpuM4sJWy70sUh5U1IFn1m1p45MdHSChLUNnqEoUUUHSU2IHZugFrsVNhov1S8Q0cvfdrCRCvdBtHGSs6PSAWQ==
+apexcharts@^3.49.1:
+  version "3.49.1"
+  resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-3.49.1.tgz#837d1d4fd80f2c092f0587e8fb6bbc31ad57a0f3"
+  integrity sha512-MqGtlq/KQuO8j0BBsUJYlRG8VBctKwYdwuBtajHgHTmSgUU3Oai+8oYN/rKCXwXzrUlYA+GiMgotAIbXY2BCGw==
   dependencies:
     "@yr/monotone-cubic-spline" "^1.0.3"
     svg.draggable.js "^2.2.2"
@@ -507,12 +570,12 @@ asynckit@^0.4.0:
   resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
   integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
 
-axios@^1.6.7:
-  version "1.6.7"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7"
-  integrity sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==
+axios@^1.7.2:
+  version "1.7.2"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.2.tgz#b625db8a7051fbea61c35a3cbb3a1daa7b9c7621"
+  integrity sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==
   dependencies:
-    follow-redirects "^1.15.4"
+    follow-redirects "^1.15.6"
     form-data "^4.0.0"
     proxy-from-env "^1.1.0"
 
@@ -539,6 +602,13 @@ brace-expansion@^1.1.7:
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
+brace-expansion@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
+  integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
+  dependencies:
+    balanced-match "^1.0.0"
+
 braces@^3.0.2, braces@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
@@ -629,10 +699,10 @@ concat-map@0.0.1:
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
 
-core-js@^3.29.0:
-  version "3.36.0"
-  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.36.0.tgz#e752fa0b0b462a0787d56e9d73f80b0f7c0dde68"
-  integrity sha512-mt7+TUBbTFg5+GngsAxeKBTl5/VS0guFeJacYge9OmHb+m058UwwIm41SE9T4Den7ClatV57B6TYTuJ0CX1MAw==
+core-js@^3.37.1:
+  version "3.37.1"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.37.1.tgz#d21751ddb756518ac5a00e4d66499df981a62db9"
+  integrity sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==
 
 cosmiconfig@^9.0.0:
   version "9.0.0"
@@ -644,7 +714,7 @@ cosmiconfig@^9.0.0:
     js-yaml "^4.1.0"
     parse-json "^5.2.0"
 
-cross-spawn@^7.0.2:
+cross-spawn@^7.0.0, cross-spawn@^7.0.2:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
   integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
@@ -668,10 +738,10 @@ csstype@^3.1.3:
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
   integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
 
-debounce@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/debounce/-/debounce-2.0.0.tgz#b2f914518a1481466f4edaee0b063e4d473ad549"
-  integrity sha512-xRetU6gL1VJbs85Mc4FoEGSjQxzpdxRyFhe3lmWFyy2EzydIcD4xzUvRJMD+NPDfMwKNhxa3PvsIOU32luIWeA==
+debounce@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/debounce/-/debounce-2.1.0.tgz#eab25eaf254b848fcfccffbde75bdaa44842caa3"
+  integrity sha512-OkL3+0pPWCqoBc/nhO9u6TIQNTK44fnBnzuVtJAbp13Naxw9R6u21x+8tVTka87AhDZ3htqZ2pSSsZl9fqL2Wg==
 
 debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4:
   version "4.3.4"
@@ -690,12 +760,20 @@ delayed-stream@~1.0.0:
   resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
   integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
 
-doctrine@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
-  integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==
-  dependencies:
-    esutils "^2.0.2"
+eastasianwidth@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
+  integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
+
+emoji-regex@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+  integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+
+emoji-regex@^9.2.2:
+  version "9.2.2"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
+  integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
 
 entities@^4.5.0:
   version "4.5.0"
@@ -714,34 +792,34 @@ error-ex@^1.3.1:
   dependencies:
     is-arrayish "^0.2.1"
 
-esbuild@^0.19.3:
-  version "0.19.12"
-  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.12.tgz#dc82ee5dc79e82f5a5c3b4323a2a641827db3e04"
-  integrity sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==
+esbuild@^0.21.3:
+  version "0.21.5"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d"
+  integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==
   optionalDependencies:
-    "@esbuild/aix-ppc64" "0.19.12"
-    "@esbuild/android-arm" "0.19.12"
-    "@esbuild/android-arm64" "0.19.12"
-    "@esbuild/android-x64" "0.19.12"
-    "@esbuild/darwin-arm64" "0.19.12"
-    "@esbuild/darwin-x64" "0.19.12"
-    "@esbuild/freebsd-arm64" "0.19.12"
-    "@esbuild/freebsd-x64" "0.19.12"
-    "@esbuild/linux-arm" "0.19.12"
-    "@esbuild/linux-arm64" "0.19.12"
-    "@esbuild/linux-ia32" "0.19.12"
-    "@esbuild/linux-loong64" "0.19.12"
-    "@esbuild/linux-mips64el" "0.19.12"
-    "@esbuild/linux-ppc64" "0.19.12"
-    "@esbuild/linux-riscv64" "0.19.12"
-    "@esbuild/linux-s390x" "0.19.12"
-    "@esbuild/linux-x64" "0.19.12"
-    "@esbuild/netbsd-x64" "0.19.12"
-    "@esbuild/openbsd-x64" "0.19.12"
-    "@esbuild/sunos-x64" "0.19.12"
-    "@esbuild/win32-arm64" "0.19.12"
-    "@esbuild/win32-ia32" "0.19.12"
-    "@esbuild/win32-x64" "0.19.12"
+    "@esbuild/aix-ppc64" "0.21.5"
+    "@esbuild/android-arm" "0.21.5"
+    "@esbuild/android-arm64" "0.21.5"
+    "@esbuild/android-x64" "0.21.5"
+    "@esbuild/darwin-arm64" "0.21.5"
+    "@esbuild/darwin-x64" "0.21.5"
+    "@esbuild/freebsd-arm64" "0.21.5"
+    "@esbuild/freebsd-x64" "0.21.5"
+    "@esbuild/linux-arm" "0.21.5"
+    "@esbuild/linux-arm64" "0.21.5"
+    "@esbuild/linux-ia32" "0.21.5"
+    "@esbuild/linux-loong64" "0.21.5"
+    "@esbuild/linux-mips64el" "0.21.5"
+    "@esbuild/linux-ppc64" "0.21.5"
+    "@esbuild/linux-riscv64" "0.21.5"
+    "@esbuild/linux-s390x" "0.21.5"
+    "@esbuild/linux-x64" "0.21.5"
+    "@esbuild/netbsd-x64" "0.21.5"
+    "@esbuild/openbsd-x64" "0.21.5"
+    "@esbuild/sunos-x64" "0.21.5"
+    "@esbuild/win32-arm64" "0.21.5"
+    "@esbuild/win32-ia32" "0.21.5"
+    "@esbuild/win32-x64" "0.21.5"
 
 escape-string-regexp@^1.0.5:
   version "1.0.5"
@@ -753,20 +831,26 @@ escape-string-regexp@^4.0.0:
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
   integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
 
-eslint-plugin-vue@^9.21.1:
-  version "9.21.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-9.21.1.tgz#da5629efa48527cec98278dca0daa90fada4caf7"
-  integrity sha512-XVtI7z39yOVBFJyi8Ljbn7kY9yHzznKXL02qQYn+ta63Iy4A9JFBw6o4OSB9hyD2++tVT+su9kQqetUyCCwhjw==
+eslint-config-prettier@^9.1.0:
+  version "9.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz#31af3d94578645966c082fcb71a5846d3c94867f"
+  integrity sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==
+
+eslint-plugin-vue@^9.26.0:
+  version "9.26.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-9.26.0.tgz#bf7f5cce62c8f878059b91edae44d22974133af5"
+  integrity sha512-eTvlxXgd4ijE1cdur850G6KalZqk65k1JKoOI2d1kT3hr8sPD07j1q98FRFdNnpxBELGPWxZmInxeHGF/GxtqQ==
   dependencies:
     "@eslint-community/eslint-utils" "^4.4.0"
+    globals "^13.24.0"
     natural-compare "^1.4.0"
     nth-check "^2.1.1"
-    postcss-selector-parser "^6.0.13"
-    semver "^7.5.4"
+    postcss-selector-parser "^6.0.15"
+    semver "^7.6.0"
     vue-eslint-parser "^9.4.2"
     xml-name-validator "^4.0.0"
 
-eslint-scope@^7.1.1, eslint-scope@^7.2.2:
+eslint-scope@^7.1.1:
   version "7.2.2"
   resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f"
   integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==
@@ -774,46 +858,55 @@ eslint-scope@^7.1.1, eslint-scope@^7.2.2:
     esrecurse "^4.3.0"
     estraverse "^5.2.0"
 
-eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3:
+eslint-scope@^8.0.1:
+  version "8.0.1"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.0.1.tgz#a9601e4b81a0b9171657c343fb13111688963cfc"
+  integrity sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og==
+  dependencies:
+    esrecurse "^4.3.0"
+    estraverse "^5.2.0"
+
+eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1:
   version "3.4.3"
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
   integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
 
-eslint@^8.56.0:
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.56.0.tgz#4957ce8da409dc0809f99ab07a1b94832ab74b15"
-  integrity sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==
+eslint-visitor-keys@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz#e3adc021aa038a2a8e0b2f8b0ce8f66b9483b1fb"
+  integrity sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==
+
+eslint@^9.5.0:
+  version "9.5.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.5.0.tgz#11856034b94a9e1a02cfcc7e96a9f0956963cd2f"
+  integrity sha512-+NAOZFrW/jFTS3dASCGBxX1pkFD0/fsO+hfAkJ4TyYKwgsXZbqzrw+seCYFCcPCYXvnD67tAnglU7GQTz6kcVw==
   dependencies:
     "@eslint-community/eslint-utils" "^4.2.0"
     "@eslint-community/regexpp" "^4.6.1"
-    "@eslint/eslintrc" "^2.1.4"
-    "@eslint/js" "8.56.0"
-    "@humanwhocodes/config-array" "^0.11.13"
+    "@eslint/config-array" "^0.16.0"
+    "@eslint/eslintrc" "^3.1.0"
+    "@eslint/js" "9.5.0"
     "@humanwhocodes/module-importer" "^1.0.1"
+    "@humanwhocodes/retry" "^0.3.0"
     "@nodelib/fs.walk" "^1.2.8"
-    "@ungap/structured-clone" "^1.2.0"
     ajv "^6.12.4"
     chalk "^4.0.0"
     cross-spawn "^7.0.2"
     debug "^4.3.2"
-    doctrine "^3.0.0"
     escape-string-regexp "^4.0.0"
-    eslint-scope "^7.2.2"
-    eslint-visitor-keys "^3.4.3"
-    espree "^9.6.1"
-    esquery "^1.4.2"
+    eslint-scope "^8.0.1"
+    eslint-visitor-keys "^4.0.0"
+    espree "^10.0.1"
+    esquery "^1.5.0"
     esutils "^2.0.2"
     fast-deep-equal "^3.1.3"
-    file-entry-cache "^6.0.1"
+    file-entry-cache "^8.0.0"
     find-up "^5.0.0"
     glob-parent "^6.0.2"
-    globals "^13.19.0"
-    graphemer "^1.4.0"
     ignore "^5.2.0"
     imurmurhash "^0.1.4"
     is-glob "^4.0.0"
     is-path-inside "^3.0.3"
-    js-yaml "^4.1.0"
     json-stable-stringify-without-jsonify "^1.0.1"
     levn "^0.4.1"
     lodash.merge "^4.6.2"
@@ -823,7 +916,16 @@ eslint@^8.56.0:
     strip-ansi "^6.0.1"
     text-table "^0.2.0"
 
-espree@^9.3.1, espree@^9.6.0, espree@^9.6.1:
+espree@^10.0.1:
+  version "10.1.0"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-10.1.0.tgz#8788dae611574c0f070691f522e4116c5a11fc56"
+  integrity sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==
+  dependencies:
+    acorn "^8.12.0"
+    acorn-jsx "^5.3.2"
+    eslint-visitor-keys "^4.0.0"
+
+espree@^9.3.1:
   version "9.6.1"
   resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f"
   integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==
@@ -832,7 +934,7 @@ espree@^9.3.1, espree@^9.6.0, espree@^9.6.1:
     acorn-jsx "^5.3.2"
     eslint-visitor-keys "^3.4.1"
 
-esquery@^1.4.0, esquery@^1.4.2:
+esquery@^1.4.0, esquery@^1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b"
   integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==
@@ -894,12 +996,12 @@ fastq@^1.6.0:
   dependencies:
     reusify "^1.0.4"
 
-file-entry-cache@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"
-  integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==
+file-entry-cache@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f"
+  integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==
   dependencies:
-    flat-cache "^3.0.4"
+    flat-cache "^4.0.0"
 
 fill-range@^7.0.1:
   version "7.0.1"
@@ -923,25 +1025,32 @@ find-up@^5.0.0:
     locate-path "^6.0.0"
     path-exists "^4.0.0"
 
-flat-cache@^3.0.4:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee"
-  integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==
+flat-cache@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c"
+  integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==
   dependencies:
     flatted "^3.2.9"
-    keyv "^4.5.3"
-    rimraf "^3.0.2"
+    keyv "^4.5.4"
 
 flatted@^3.2.9:
   version "3.2.9"
   resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf"
   integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==
 
-follow-redirects@^1.15.4:
+follow-redirects@^1.15.6:
   version "1.15.6"
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
   integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
 
+foreground-child@^3.1.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.2.1.tgz#767004ccf3a5b30df39bed90718bab43fe0a59f7"
+  integrity sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==
+  dependencies:
+    cross-spawn "^7.0.0"
+    signal-exit "^4.0.1"
+
 form-data@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
@@ -961,7 +1070,7 @@ fsevents@~2.3.2, fsevents@~2.3.3:
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
   integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
 
-gettext-extractor@^3.6.2:
+gettext-extractor@^3.8.0:
   version "3.8.0"
   resolved "https://registry.yarnpkg.com/gettext-extractor/-/gettext-extractor-3.8.0.tgz#b2e8497a5952b039e5f488dca3d2e3aea2cb54b4"
   integrity sha512-i/3mDQufQoJd2/EKm/B+VlaYrt3yGjVfLZu8DQpESKH29klNiW6z2S89FVCIEB85bDNgtGCeM/3A/yR1njr/Lw==
@@ -988,7 +1097,7 @@ glob-parent@^6.0.2:
   dependencies:
     is-glob "^4.0.3"
 
-"glob@5 - 7", glob@^7.1.3, glob@^7.2.0:
+"glob@5 - 7":
   version "7.2.3"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
   integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
@@ -1000,17 +1109,28 @@ glob-parent@^6.0.2:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-globals@^13.19.0:
+glob@^10.4.1:
+  version "10.4.1"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.1.tgz#0cfb01ab6a6b438177bfe6a58e2576f6efe909c2"
+  integrity sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==
+  dependencies:
+    foreground-child "^3.1.0"
+    jackspeak "^3.1.2"
+    minimatch "^9.0.4"
+    minipass "^7.1.2"
+    path-scurry "^1.11.1"
+
+globals@^13.24.0:
   version "13.24.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171"
   integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==
   dependencies:
     type-fest "^0.20.2"
 
-graphemer@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
-  integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
+globals@^14.0.0:
+  version "14.0.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
+  integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
 
 has-flag@^3.0.0:
   version "3.0.0"
@@ -1075,6 +1195,11 @@ is-extglob@^2.1.1:
   resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
   integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
 
+is-fullwidth-code-point@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
+  integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+
 is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
@@ -1097,6 +1222,15 @@ isexe@^2.0.0:
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
   integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
 
+jackspeak@^3.1.2:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.0.tgz#a75763ff36ad778ede6a156d8ee8b124de445b4a"
+  integrity sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==
+  dependencies:
+    "@isaacs/cliui" "^8.0.2"
+  optionalDependencies:
+    "@pkgjs/parseargs" "^0.11.0"
+
 js-cookie@^3.0.5:
   version "3.0.5"
   resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc"
@@ -1139,7 +1273,7 @@ jwt-decode@^4.0.0:
   resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b"
   integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==
 
-keyv@^4.5.3:
+keyv@^4.5.4:
   version "4.5.4"
   resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
   integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
@@ -1181,6 +1315,11 @@ lodash@^4.17.21:
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
 
+lru-cache@^10.2.0:
+  version "10.2.2"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.2.tgz#48206bc114c1252940c41b25b41af5b545aca878"
+  integrity sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==
+
 lru-cache@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
@@ -1193,10 +1332,10 @@ luxon@^3.4.4:
   resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.4.4.tgz#cf20dc27dc532ba41a169c43fdcc0063601577af"
   integrity sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==
 
-magic-string@^0.30.6:
-  version "0.30.7"
-  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.7.tgz#0cecd0527d473298679da95a2d7aeb8c64048505"
-  integrity sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==
+magic-string@^0.30.10:
+  version "0.30.10"
+  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.10.tgz#123d9c41a0cb5640c892b041d4cfb3bd0aa4b39e"
+  integrity sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==
   dependencies:
     "@jridgewell/sourcemap-codec" "^1.4.15"
 
@@ -1232,6 +1371,18 @@ minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
   dependencies:
     brace-expansion "^1.1.7"
 
+minimatch@^9.0.4:
+  version "9.0.4"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51"
+  integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==
+  dependencies:
+    brace-expansion "^2.0.1"
+
+"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2:
+  version "7.1.2"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
+  integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
+
 ms@2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
@@ -1343,6 +1494,14 @@ path-key@^3.1.0:
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
   integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
 
+path-scurry@^1.11.1:
+  version "1.11.1"
+  resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2"
+  integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==
+  dependencies:
+    lru-cache "^10.2.0"
+    minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
+
 picocolors@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
@@ -1366,37 +1525,37 @@ pofile@1.0.x:
   resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.0.11.tgz#35aff58c17491d127a07336d5522ebc9df57c954"
   integrity sha512-Vy9eH1dRD9wHjYt/QqXcTz+RnX/zg53xK+KljFSX30PvdDMb2z+c6uDUeblUGqqJgz3QFsdlA0IJvHziPmWtQg==
 
-pofile@^1.1.3:
+pofile@^1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.1.4.tgz#eab7e29f5017589b2a61b2259dff608c0cad76a2"
   integrity sha512-r6Q21sKsY1AjTVVjOuU02VYKVNQGJNQHjTIvs4dEbeuuYfxgYk/DGD2mqqq4RDaVkwdSq0VEtmQUOPe/wH8X3g==
 
-postcss-selector-parser@^6.0.13:
-  version "6.0.15"
-  resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz#11cc2b21eebc0b99ea374ffb9887174855a01535"
-  integrity sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==
+postcss-selector-parser@^6.0.15:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz#49694cb4e7c649299fea510a29fa6577104bcf53"
+  integrity sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==
   dependencies:
     cssesc "^3.0.0"
     util-deprecate "^1.0.2"
 
-postcss@^8.4.33, postcss@^8.4.35:
-  version "8.4.35"
-  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.35.tgz#60997775689ce09011edf083a549cea44aabe2f7"
-  integrity sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==
+postcss@^8.4.38:
+  version "8.4.38"
+  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e"
+  integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==
   dependencies:
     nanoid "^3.3.7"
     picocolors "^1.0.0"
-    source-map-js "^1.0.2"
+    source-map-js "^1.2.0"
 
 prelude-ls@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
   integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
 
-prettier@^3.1.1:
-  version "3.2.5"
-  resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368"
-  integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==
+prettier@^3.3.2:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.2.tgz#03ff86dc7c835f2d2559ee76876a3914cec4a90a"
+  integrity sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==
 
 proxy-from-env@^1.1.0:
   version "1.1.0"
@@ -1435,38 +1594,34 @@ reusify@^1.0.4:
   resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
   integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
 
-rimraf@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
-  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
-  dependencies:
-    glob "^7.1.3"
-
 roboto-fontface@*:
   version "0.10.0"
   resolved "https://registry.yarnpkg.com/roboto-fontface/-/roboto-fontface-0.10.0.tgz#7eee40cfa18b1f7e4e605eaf1a2740afb6fd71b0"
   integrity sha512-OlwfYEgA2RdboZohpldlvJ1xngOins5d7ejqnIBWr9KaMxsnBqotpptRXTyfNRLnFpqzX6sTDt+X+a+6udnU8g==
 
-rollup@^4.2.0:
-  version "4.11.0"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.11.0.tgz#28f28d8f8660facfed001b512d3cfda8c0dc09b6"
-  integrity sha512-2xIbaXDXjf3u2tajvA5xROpib7eegJ9Y/uPlSFhXLNpK9ampCczXAhLEb5yLzJyG3LAdI1NWtNjDXiLyniNdjQ==
+rollup@^4.13.0:
+  version "4.18.0"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.18.0.tgz#497f60f0c5308e4602cf41136339fbf87d5f5dda"
+  integrity sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==
   dependencies:
     "@types/estree" "1.0.5"
   optionalDependencies:
-    "@rollup/rollup-android-arm-eabi" "4.11.0"
-    "@rollup/rollup-android-arm64" "4.11.0"
-    "@rollup/rollup-darwin-arm64" "4.11.0"
-    "@rollup/rollup-darwin-x64" "4.11.0"
-    "@rollup/rollup-linux-arm-gnueabihf" "4.11.0"
-    "@rollup/rollup-linux-arm64-gnu" "4.11.0"
-    "@rollup/rollup-linux-arm64-musl" "4.11.0"
-    "@rollup/rollup-linux-riscv64-gnu" "4.11.0"
-    "@rollup/rollup-linux-x64-gnu" "4.11.0"
-    "@rollup/rollup-linux-x64-musl" "4.11.0"
-    "@rollup/rollup-win32-arm64-msvc" "4.11.0"
-    "@rollup/rollup-win32-ia32-msvc" "4.11.0"
-    "@rollup/rollup-win32-x64-msvc" "4.11.0"
+    "@rollup/rollup-android-arm-eabi" "4.18.0"
+    "@rollup/rollup-android-arm64" "4.18.0"
+    "@rollup/rollup-darwin-arm64" "4.18.0"
+    "@rollup/rollup-darwin-x64" "4.18.0"
+    "@rollup/rollup-linux-arm-gnueabihf" "4.18.0"
+    "@rollup/rollup-linux-arm-musleabihf" "4.18.0"
+    "@rollup/rollup-linux-arm64-gnu" "4.18.0"
+    "@rollup/rollup-linux-arm64-musl" "4.18.0"
+    "@rollup/rollup-linux-powerpc64le-gnu" "4.18.0"
+    "@rollup/rollup-linux-riscv64-gnu" "4.18.0"
+    "@rollup/rollup-linux-s390x-gnu" "4.18.0"
+    "@rollup/rollup-linux-x64-gnu" "4.18.0"
+    "@rollup/rollup-linux-x64-musl" "4.18.0"
+    "@rollup/rollup-win32-arm64-msvc" "4.18.0"
+    "@rollup/rollup-win32-ia32-msvc" "4.18.0"
+    "@rollup/rollup-win32-x64-msvc" "4.18.0"
     fsevents "~2.3.2"
 
 run-parallel@^1.1.9:
@@ -1476,22 +1631,27 @@ run-parallel@^1.1.9:
   dependencies:
     queue-microtask "^1.2.2"
 
-sass@^1.69.5:
-  version "1.71.0"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.71.0.tgz#b3085759b9b2ab503a977aecb7e91153bf941117"
-  integrity sha512-HKKIKf49Vkxlrav3F/w6qRuPcmImGVbIXJ2I3Kg0VMA+3Bav+8yE9G5XmP5lMj6nl4OlqbPftGAscNaNu28b8w==
+sass@^1.77.5:
+  version "1.77.5"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.5.tgz#5f9009820297521356e962c0bed13ee36710edfe"
+  integrity sha512-oDfX1mukIlxacPdQqNb6mV2tVCrnE+P3nVYioy72V5tlk56CPNcO4TCuFcaCRKKfJ1M3lH95CleRS+dVKL2qMg==
   dependencies:
     chokidar ">=3.0.0 <4.0.0"
     immutable "^4.0.0"
     source-map-js ">=0.6.2 <2.0.0"
 
-semver@^7.3.6, semver@^7.5.4:
+semver@^7.3.6:
   version "7.6.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d"
   integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==
   dependencies:
     lru-cache "^6.0.0"
 
+semver@^7.6.0:
+  version "7.6.2"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13"
+  integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==
+
 shebang-command@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
@@ -1504,18 +1664,69 @@ shebang-regex@^3.0.0:
   resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
   integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
 
-"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2:
+signal-exit@^4.0.1:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
+  integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
+
+"source-map-js@>=0.6.2 <2.0.0":
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
   integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
 
-strip-ansi@^6.0.1:
+source-map-js@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af"
+  integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==
+
+"string-width-cjs@npm:string-width@^4.2.0":
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
+string-width@^4.1.0:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
+string-width@^5.0.1, string-width@^5.1.2:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
+  integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
+  dependencies:
+    eastasianwidth "^0.2.0"
+    emoji-regex "^9.2.2"
+    strip-ansi "^7.0.1"
+
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
   dependencies:
     ansi-regex "^5.0.1"
 
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
+strip-ansi@^7.0.1:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
+  integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==
+  dependencies:
+    ansi-regex "^6.0.1"
+
 strip-json-comments@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
@@ -1602,10 +1813,10 @@ to-regex-range@^5.0.1:
   dependencies:
     is-number "^7.0.0"
 
-tslib@^2.4.0:
-  version "2.6.2"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
-  integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
+tslib@^2.6.2:
+  version "2.6.3"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0"
+  integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==
 
 type-check@^0.4.0, type-check@~0.4.0:
   version "0.4.0"
@@ -1669,23 +1880,23 @@ util-deprecate@^1.0.2:
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
 
-vite-plugin-vuetify@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/vite-plugin-vuetify/-/vite-plugin-vuetify-2.0.1.tgz#172ffb6c46fec469fa96b3df03fd11b90d48f5d6"
-  integrity sha512-GlRVAruohE8b0FqmeYYh1cYg3n8THGOv066uMA44qLv9uhUxSLw55CS7fi2yU0wH363TJ2vq36zUsPTjRFrjGQ==
+vite-plugin-vuetify@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/vite-plugin-vuetify/-/vite-plugin-vuetify-2.0.3.tgz#b65ee4e05cfc6bf2b478a32b6d58b42398519f1e"
+  integrity sha512-HbYajgGgb/noaVKNRhnnXIiQZrNXfNIeanUGAwXgOxL6h/KULS40Uf51Kyz8hNmdegF+DwjgXXI/8J1PNS83xw==
   dependencies:
-    "@vuetify/loader-shared" "^2.0.1"
+    "@vuetify/loader-shared" "^2.0.3"
     debug "^4.3.3"
     upath "^2.0.1"
 
-vite@^5.1.3:
-  version "5.1.3"
-  resolved "https://registry.yarnpkg.com/vite/-/vite-5.1.3.tgz#dd072653a80225702265550a4700561740dfde55"
-  integrity sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==
+vite@^5.3.1:
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/vite/-/vite-5.3.1.tgz#bb2ca6b5fd7483249d3e86b25026e27ba8a663e6"
+  integrity sha512-XBmSKRLXLxiaPYamLv3/hnP/KXDai1NDexN0FpkTaZXTfycHvkRHoenpgl/fvuK/kPbB6xAgoyiryAhQNxYmAQ==
   dependencies:
-    esbuild "^0.19.3"
-    postcss "^8.4.35"
-    rollup "^4.2.0"
+    esbuild "^0.21.3"
+    postcss "^8.4.38"
+    rollup "^4.13.0"
   optionalDependencies:
     fsevents "~2.3.3"
 
@@ -1707,48 +1918,48 @@ vue-eslint-parser@^9.4.2:
     lodash "^4.17.21"
     semver "^7.3.6"
 
-vue-router@^4.0.0:
-  version "4.2.5"
-  resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.2.5.tgz#b9e3e08f1bd9ea363fdd173032620bc50cf0e98a"
-  integrity sha512-DIUpKcyg4+PTQKfFPX88UWhlagBEBEfJ5A8XDXRJLUnZOvcpMF8o/dnL90vpVkGaPbjvXazV/rC1qBKrZlFugw==
+vue-router@^4.3.3:
+  version "4.3.3"
+  resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.3.3.tgz#7505509d429a36694b12ba1f6530016c5ce5f6bf"
+  integrity sha512-8Q+u+WP4N2SXY38FDcF2H1dUEbYVHVPtPCPZj/GTZx8RCbiB8AtJP9+YIxn4Vs0svMTNQcLIzka4GH7Utkx9xQ==
   dependencies:
-    "@vue/devtools-api" "^6.5.0"
+    "@vue/devtools-api" "^6.5.1"
 
-vue3-apexcharts@^1.5.2:
-  version "1.5.2"
-  resolved "https://registry.yarnpkg.com/vue3-apexcharts/-/vue3-apexcharts-1.5.2.tgz#fc179d80e34e6f25982dae809c27173d7374db48"
-  integrity sha512-rGbgUJDjtsyjfRF0uzwDjzt8+M7ICSRAbm1N9KCDiczW8BSpbEZuaEsJDJYnJuLFIIVXIGilYzIcjNBf6NbeYA==
+vue3-apexcharts@^1.5.3:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/vue3-apexcharts/-/vue3-apexcharts-1.5.3.tgz#45f685a2dd21058c0d76ab2145eb3ec27d8fb14c"
+  integrity sha512-yaHTPoj0iVKAtEVg8wEwIwwvf0VG+lPYNufCf3txRzYQOqdKPoZaZ9P3Dj3X+2A1XY9O1kcTk9HVqvLo+rppvQ==
 
-vue3-gettext@^3.0.0-beta.4:
-  version "3.0.0-beta.4"
-  resolved "https://registry.yarnpkg.com/vue3-gettext/-/vue3-gettext-3.0.0-beta.4.tgz#76368e5a8e469cea351e57e00b8f5bbf33062d80"
-  integrity sha512-EW9F5NexUQW9GNJMjujmMJo8hUQvw6POCVQN9ya0RdapPVEfmkXjuxuhkVEKyBNxq5lRPCepfYkMT8Pu3M6T6w==
+vue3-gettext@^3.0.0-beta.5:
+  version "3.0.0-beta.5"
+  resolved "https://registry.yarnpkg.com/vue3-gettext/-/vue3-gettext-3.0.0-beta.5.tgz#e7b122d636e3ec4e8b3ef02355fe7e8c35a4f305"
+  integrity sha512-Dn1qZrFgtaKg/9mMMypvQn+lyyHPAAylQBN4kPGqqr8oAMOJGyKg+rCLCcoyuLGLDvPGBgtawOTQ/0dFmGbDrw==
   dependencies:
     chalk "^4.1.2"
     command-line-args "^5.2.1"
     cosmiconfig "^9.0.0"
-    gettext-extractor "^3.6.2"
-    glob "^7.2.0"
+    gettext-extractor "^3.8.0"
+    glob "^10.4.1"
     parse5 "^6.0.1"
     parse5-htmlparser2-tree-adapter "^6.0.1"
-    pofile "^1.1.3"
-    tslib "^2.4.0"
+    pofile "^1.1.4"
+    tslib "^2.6.2"
 
-vue@^3.4.19:
-  version "3.4.19"
-  resolved "https://registry.yarnpkg.com/vue/-/vue-3.4.19.tgz#f9ae0a44db86628548736ff04152830726a97263"
-  integrity sha512-W/7Fc9KUkajFU8dBeDluM4sRGc/aa4YJnOYck8dkjgZoXtVsn3OeTGni66FV1l3+nvPA7VBFYtPioaGKUmEADw==
+vue@^3.4.29:
+  version "3.4.29"
+  resolved "https://registry.yarnpkg.com/vue/-/vue-3.4.29.tgz#fad5a0fe6dfa5c4a2cfbbc48c489e7989616a15b"
+  integrity sha512-8QUYfRcYzNlYuzKPfge1UWC6nF9ym0lx7mpGVPJYNhddxEf3DD0+kU07NTL0sXuiT2HuJuKr/iEO8WvXvT0RSQ==
   dependencies:
-    "@vue/compiler-dom" "3.4.19"
-    "@vue/compiler-sfc" "3.4.19"
-    "@vue/runtime-dom" "3.4.19"
-    "@vue/server-renderer" "3.4.19"
-    "@vue/shared" "3.4.19"
+    "@vue/compiler-dom" "3.4.29"
+    "@vue/compiler-sfc" "3.4.29"
+    "@vue/runtime-dom" "3.4.29"
+    "@vue/server-renderer" "3.4.29"
+    "@vue/shared" "3.4.29"
 
-vuetify@^3.5.4:
-  version "3.5.4"
-  resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.5.4.tgz#f919c5194995a123815c277a95812bc230e33464"
-  integrity sha512-fHgfWMI7+z/UtbVPOezX+O1MNBOOMBW9HnKejcBIyQQ7jFRnTHbDQmbINf25FK0wrg/zkjfzyOmWWREKW39eXg==
+vuetify@^3.6.9:
+  version "3.6.9"
+  resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.6.9.tgz#6c063fa6e8097e854406467db25e8d8a40e26e71"
+  integrity sha512-LVIZL0OCwzg3Aw2F8nT1rK/renpTPOcDEPe5E002Vj+VjXwsCNqwwxNteGfrcNlp0vgIJ8WusMvaxp69UJ3NJg==
 
 webpack-sources@^3.2.3:
   version "3.2.3"
@@ -1767,6 +1978,24 @@ which@^2.0.1:
   dependencies:
     isexe "^2.0.0"
 
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+  dependencies:
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
+
+wrap-ansi@^8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
+  integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
+  dependencies:
+    ansi-styles "^6.1.0"
+    string-width "^5.0.1"
+    strip-ansi "^7.0.1"
+
 wrappy@1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
diff --git a/modoboa/admin/api/v1/serializers.py b/modoboa/admin/api/v1/serializers.py
index 5c8e11283..c2cd1b7f4 100644
--- a/modoboa/admin/api/v1/serializers.py
+++ b/modoboa/admin/api/v1/serializers.py
@@ -253,6 +253,8 @@ class Meta:
             "secondary_email",
             "domains",
             "tfa_enabled",
+            "totp_enabled",
+            "webauthn_enabled",
             "date_joined",
             "last_login",
         )
diff --git a/modoboa/core/api/v1/serializers.py b/modoboa/core/api/v1/serializers.py
index 50af8729c..6f322c9ca 100644
--- a/modoboa/core/api/v1/serializers.py
+++ b/modoboa/core/api/v1/serializers.py
@@ -23,7 +23,7 @@ def validate_pin_code(self, value):
 
     def validate(self, data):
         user = self.context["user"]
-        if user.tfa_enabled or user.staticdevice_set.exists():
+        if user.totp_enabled:
             raise serializers.ValidationError(_("2FA is already enabled"))
         return data
 
diff --git a/modoboa/core/api/v1/viewsets.py b/modoboa/core/api/v1/viewsets.py
index da8c8b634..34478de7e 100644
--- a/modoboa/core/api/v1/viewsets.py
+++ b/modoboa/core/api/v1/viewsets.py
@@ -59,12 +59,13 @@ def tfa_disable(self, request):
             context={"user": request.user, "remote_addr": request.META["REMOTE_ADDR"]},
         )
         serializer.is_valid(raise_exception=True)
-        if not request.user.tfa_enabled:
+        if not request.user.totp_enabled:
             # We include it as "password" to display the error
             return response.Response({"error": _("2FA is not enabled")}, status=403)
         request.user.totpdevice_set.all().delete()
-        request.user.staticdevice_set.all().delete()
-        request.user.tfa_enabled = False
+        request.user.totp_enabled = False
+        if not request.user.tfa_enabled:
+            request.user.staticdevice_set.all().delete()
         request.user.save()
         return response.Response()
 
diff --git a/modoboa/core/api/v2/serializers.py b/modoboa/core/api/v2/serializers.py
index d52d86acd..5b7c23202 100644
--- a/modoboa/core/api/v2/serializers.py
+++ b/modoboa/core/api/v2/serializers.py
@@ -238,6 +238,35 @@ def validate(self, data):
         return data
 
 
+class FIDOSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = models.UserFidoKeys
+        fields = ["id", "name", "enabled", "added_on", "last_used", "use_count"]
+        # TODO : read-only fields for everything other than name and enabled :)
+
+
+class FidoRegistrationSerializer(serializers.Serializer):
+    """Serializer used to finish the fido key registration."""
+
+    type = serializers.CharField()
+    id = serializers.CharField()
+    rawId = serializers.CharField()
+    authenticatorAttachment = serializers.CharField()
+    response = serializers.JSONField()
+    name = serializers.CharField()
+
+
+class FidoAuthenticationSerializer(serializers.Serializer):
+    """Serializer used to finish the fido key authentication."""
+
+    authenticatorAttachment = serializers.CharField()
+    clientExtensionResults = serializers.JSONField()
+    id = serializers.CharField()
+    rawId = serializers.CharField()
+    response = serializers.JSONField()
+    type = serializers.CharField()
+
+
 class LogSerializer(serializers.ModelSerializer):
     """Log serializer."""
 
diff --git a/modoboa/core/api/v2/urls.py b/modoboa/core/api/v2/urls.py
index 48993e231..dd6f47cd5 100644
--- a/modoboa/core/api/v2/urls.py
+++ b/modoboa/core/api/v2/urls.py
@@ -12,6 +12,7 @@
 router.register(r"account", viewsets.AccountViewSet, basename="account")
 router.register(r"languages", viewsets.LanguageViewSet, basename="language")
 router.register(r"logs/audit-trail", viewsets.LogViewSet)
+router.register(r"fido", viewsets.FIDOViewSet, basename="fido")
 
 urlpatterns = router.urls
 urlpatterns += [
diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py
index a0b469016..aa17fee7d 100644
--- a/modoboa/core/api/v2/viewsets.py
+++ b/modoboa/core/api/v2/viewsets.py
@@ -2,24 +2,43 @@
 
 from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
 from drf_spectacular.utils import extend_schema
-from rest_framework import filters, permissions, response, viewsets
+from rest_framework import filters, permissions, response, viewsets, mixins
 from rest_framework.authtoken.models import Token
 from rest_framework.decorators import action
 from rest_framework.exceptions import PermissionDenied
 
-# from rest_framework_simplejwt.tokens import RefreshToken
-
 from modoboa.admin.api.v1 import serializers as admin_v1_serializers
 from modoboa.core.api.v1 import serializers as core_v1_serializers
 from modoboa.core.api.v1 import viewsets as core_v1_viewsets
 from modoboa.lib import pagination
 from modoboa.lib.throttle import GetThrottleViewsetMixin
 
+import base64
+
 from ... import constants
+from ... import fido2_auth as f2_auth
 from ... import models
 from . import serializers
 
 
+def create_static_tokens(request):
+    """Utility function to create or recreate
+    static tokens if needed."""
+    device = request.user.staticdevice_set.first()
+    if device is None:
+        device = StaticDevice.objects.create(
+            user=request.user, name="{} static device".format(request.user)
+        )
+    elif device is not None:
+        return None
+    tokens = []
+    for cpt in range(10):
+        token = StaticToken.random_token()
+        device.token_set.create(token=token)
+        tokens.append(token)
+    return tokens
+
+
 class AccountViewSet(core_v1_viewsets.AccountViewSet):
     """Account viewset."""
 
@@ -80,31 +99,16 @@ def manage_api_token(self, request):
         serializer = serializers.UserAPITokenSerializer({"token": str(token)})
         return response.Response(serializer.data, status=status)
 
-    # @action(methods=["post"], detail=False, url_path="tfa/verify")
-    # def tfa_verify_code(self, request):
-    #     """Verify given code validity."""
-    #     serializer = serializers.VerifyTFACodeSerializer(
-    #         data=request.data, context={"user": request.user}
-    #     )
-    #     serializer.is_valid(raise_exception=True)
-    #     refresh = RefreshToken.for_user(request.user)
-    #     refresh[constants.TFA_DEVICE_TOKEN_KEY] = serializer.validated_data[
-    #         "code"
-    #     ].persistent_id
-    #     return response.Response(
-    #         {"refresh": str(refresh), "access": str(refresh.access_token)}
-    #     )
-
     @action(methods=["get"], detail=False, url_path="tfa/setup/key")
     def tfa_setup_get_key(self, request):
         """Get a key and url to finalize the setup process."""
-        if request.user.tfa_enabled:
+        if request.user.totp_enabled:
             return response.Response(status=404)
         device = request.user.totpdevice_set.first()
         if not device:
             return response.Response(status=404)
         return response.Response(
-            {"key": device.key, "url": device.config_url},
+            {"key": base64.b32encode(device.bin_key), "url": device.config_url},
             content_type="application/json",
         )
 
@@ -117,28 +121,13 @@ def tfa_setup_check(self, request):
         )
         serializer.is_valid(raise_exception=True)
         # create static device for recovery purposes
-        device = StaticDevice.objects.create(
-            user=request.user, name="{} static device".format(request.user)
-        )
-        tokens = []
-        for cpt in range(10):
-            token = StaticToken.random_token()
-            device.token_set.create(token=token)
-            tokens.append(token)
+        tokens = create_static_tokens(request)
         # Set enable flag to True so we can't go here anymore
-        request.user.tfa_enabled = True
+        request.user.totp_enabled = True
         request.user.save()
-        # Generate new tokens
-        device = request.user.totpdevice_set.first()
-        # refresh = RefreshToken.for_user(request.user)
-        # refresh[constants.TFA_DEVICE_TOKEN_KEY] = device.persistent_id
-        return response.Response(
-            {
-                "tokens": tokens,
-                # "refresh": str(refresh),
-                # "access": str(refresh.access_token),
-            }
-        )
+        if tokens is None:
+            return response.Response(status=204)
+        return response.Response({"tokens": tokens})
 
 
 class LogViewSet(GetThrottleViewsetMixin, viewsets.ReadOnlyModelViewSet):
@@ -167,3 +156,63 @@ def list(self, request, *args, **kwargs):
             {"code": lang[0], "label": lang[1]} for lang in constants.LANGUAGES
         ]
         return response.Response(languages)
+
+
+class FIDOViewSet(
+    GetThrottleViewsetMixin,
+    mixins.ListModelMixin,
+    mixins.DestroyModelMixin,
+    mixins.UpdateModelMixin,
+    viewsets.GenericViewSet,
+):
+    """Fido management viewset."""
+
+    permission_classes = (permissions.IsAuthenticated,)
+    serializer_class = serializers.FIDOSerializer
+
+    def get_queryset(self):
+        if self.request.user.webauthn_enabled:
+            return models.UserFidoKeys.objects.filter(user=self.request.user)
+        return None
+
+    def destroy(self, request, *args, **kwargs):
+        super().destroy(request, *args, **kwargs)
+        queryset = self.get_queryset()
+        to_save = False
+        if not queryset.exists():
+            request.user.webauthn_enabled = False
+            to_save = True
+        if not request.user.tfa_enabled:
+            request.user.staticdevice_set.all().delete()
+            to_save = True
+        if to_save:
+            request.user.save()
+        return response.Response({"tfa_enabled": request.user.tfa_enabled})
+
+    @action(methods=["post"], detail=False, url_path="registration/begin")
+    def registration_begin(self, request):
+        """An Api View which starts the registration process of a fido key."""
+        options = f2_auth.begin_registration(request)
+        return response.Response(dict(options))
+
+    @action(methods=["post"], detail=False, url_path="registration/end")
+    def registration_end(self, request):
+        """An Api View to complete the registration process of a fido key."""
+        serializer = serializers.FidoRegistrationSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        credential_data = f2_auth.end_registration(
+            request.data, request.session.pop("fido2_state"), request.user.id
+        )
+        models.UserFidoKeys.objects.create(
+            name=request.data["name"],
+            credential_data=credential_data,
+            user=request.user,
+        )
+        if not request.user.webauthn_enabled:
+            request.user.webauthn_enabled = True
+            request.user.save()
+
+        tokens = create_static_tokens(request)
+        if tokens is not None:
+            return response.Response({"tokens": tokens})
+        return response.Response(status=204)
diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl
index a172cbb57..8e44df365 100644
--- a/modoboa/core/commands/templates/settings.py.tpl
+++ b/modoboa/core/commands/templates/settings.py.tpl
@@ -112,8 +112,6 @@ MIDDLEWARE = (
     'django.middleware.common.CommonMiddleware',
     'django.middleware.csrf.CsrfViewMiddleware',
     'django.contrib.auth.middleware.AuthenticationMiddleware',
-    'django_otp.middleware.OTPMiddleware',
-    'modoboa.core.middleware.TwoFAMiddleware',
     'django.contrib.messages.middleware.MessageMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
     'modoboa.core.middleware.LocalConfigMiddleware',
diff --git a/modoboa/core/constants.py b/modoboa/core/constants.py
index 612682e07..8ebed6d0a 100644
--- a/modoboa/core/constants.py
+++ b/modoboa/core/constants.py
@@ -114,5 +114,7 @@
     SMS_BACKENDS.insert(1, ("dummy", gettext_lazy("Dummy")))
 
 TFA_DEVICE_TOKEN_KEY = "otp_device_id"
+TFA_PRE_VERIFY_USER_PK = "tfa_pre_verify_user_pk"
+TFA_PRE_VERIFY_USER_BACKEND = "tfa_pre_verify_user_backend"
 
 DOVEADM_PASS_SCHEME_ALARM = "doveadm_password_scheme_fail"
diff --git a/modoboa/core/fido2_auth.py b/modoboa/core/fido2_auth.py
new file mode 100644
index 000000000..084c81378
--- /dev/null
+++ b/modoboa/core/fido2_auth.py
@@ -0,0 +1,80 @@
+from fido2.webauthn import (
+    PublicKeyCredentialRpEntity,
+    PublicKeyCredentialUserEntity,
+    AttestedCredentialData,
+    UserVerificationRequirement,
+)
+from fido2.server import Fido2Server
+from fido2.utils import websafe_decode, websafe_encode
+import fido2.features
+
+from django.utils import timezone
+
+from modoboa.core.models import User, UserFidoKeys
+
+
+def set_json_mapping():
+    try:
+        fido2.features.webauthn_json_mapping.enabled = True
+    except ValueError:
+        pass
+
+
+def create_fido2_server():
+    rp = PublicKeyCredentialRpEntity(name="Demo server", id="localhost")
+    return Fido2Server(rp)
+
+
+def get_creds_from_user(user_id):
+    return [
+        AttestedCredentialData(websafe_decode(uf.credential_data))
+        for uf in UserFidoKeys.objects.filter(user=user_id)
+    ]
+
+
+def begin_registration(request):
+    set_json_mapping()
+    server = create_fido2_server()
+    options, state = server.register_begin(
+        PublicKeyCredentialUserEntity(
+            id=request.user.pk,
+            name=request.user.username.encode("utf-8"),
+            display_name=request.user.username,
+        ),
+        get_creds_from_user(request.user.pk),
+        user_verification=UserVerificationRequirement.DISCOURAGED,
+        extensions={"credentialProtectionPolicy": "userVerificationOptional"},
+    )
+    request.session["fido2_state"] = state
+    return options
+
+
+def end_registration(data, fido2_state, user_id):
+    set_json_mapping()
+    server = create_fido2_server()
+    auth_data = server.register_complete(fido2_state, data)
+    return websafe_encode(auth_data.credential_data)
+
+
+def begin_authentication(user_id: int):
+    set_json_mapping()
+    server = create_fido2_server()
+    return server.authenticate_begin(get_creds_from_user(user_id))
+
+
+def end_authentication(user: User, state: str, data: dict):
+    set_json_mapping()
+    server = create_fido2_server()
+    # FIXME: review the next function...
+    credentials = get_creds_from_user(user.pk)
+    result = server.authenticate_complete(
+        state,
+        credentials,
+        data,
+    )
+    for key in user.userfidokeys_set.all():
+        cred = AttestedCredentialData(websafe_decode(key.credential_data))
+        if cred.credential_id == result.credential_id:
+            key.last_used = timezone.now()
+            key.use_count += 1
+            key.save()
diff --git a/modoboa/core/forms.py b/modoboa/core/forms.py
index e5bda6f0c..4a162244d 100644
--- a/modoboa/core/forms.py
+++ b/modoboa/core/forms.py
@@ -9,22 +9,13 @@
 
 import django_otp
 
-from modoboa.core.models import User
+from modoboa.core.models import User, UserFidoKeys
 from modoboa.lib.form_utils import UserKwargModelFormMixin
 from modoboa.parameters import tools as param_tools
 
 
-class LoginForm(forms.Form):
-    """User login form."""
+class AuthenticationForm(auth_forms.AuthenticationForm):
 
-    username = forms.CharField(
-        label=gettext_lazy("Username"),
-        widget=forms.TextInput(attrs={"class": "form-control"}),
-    )
-    password = forms.CharField(
-        label=gettext_lazy("Password"),
-        widget=forms.PasswordInput(attrs={"class": "form-control"}),
-    )
     rememberme = forms.BooleanField(initial=False, required=False)
 
 
@@ -117,6 +108,21 @@ def save(self, commit=True):
         return user
 
 
+class TwoFAChoiceForm(forms.Form):
+    """Form to select the 2FA method of choice."""
+
+    two_fa_choices = forms.ChoiceField(choices=(), required=True)
+
+    def __init__(self, *args, **kwargs):
+        user = kwargs.pop("user")
+        fido_keys = UserFidoKeys.objects.filter(user=user)
+        if fido_keys.exists():
+            self.fields["two_fa_choices.choices"].choices = [
+                ("TOTP", "TOTP or recovery codes"),
+                ("FIDO", "Webauthn device"),
+            ]
+
+
 class APIAccessForm(forms.Form):
     """Form to control API access."""
 
@@ -153,7 +159,7 @@ def send_mail(
     ):
         """Send message to secondary email instead."""
         to_email = context["user"].secondary_email
-        super(PasswordResetForm, self).send_mail(
+        super().send_mail(
             subject_template_name,
             email_template_name,
             context,
@@ -185,6 +191,7 @@ def clean_code(self):
 class Verify2FACodeForm(UserKwargModelFormMixin, forms.Form):
     """A form to verify 2FA codes validity."""
 
+    next = forms.CharField(required=False)
     tfa_code = forms.CharField()
 
     def clean_tfa_code(self):
diff --git a/modoboa/core/management/commands/load_initial_data.py b/modoboa/core/management/commands/load_initial_data.py
index 8f22d5806..8e7218a80 100644
--- a/modoboa/core/management/commands/load_initial_data.py
+++ b/modoboa/core/management/commands/load_initial_data.py
@@ -9,8 +9,11 @@
 from functools import reduce
 
 from django.contrib.auth.models import Group
+from django.core.management import call_command
 from django.core.management.base import BaseCommand
 
+from oauth2_provider.models import get_application_model
+
 from modoboa.lib.permissions import add_permissions_to_group
 from ... import constants, extensions, models, signals
 
@@ -27,11 +30,19 @@ def add_arguments(self, parser):
             default="admin",
             help="Username of the initial super administrator.",
         )
+        (
+            parser.add_argument(
+                "--extra-fixtures",
+                action="store_true",
+                default=False,
+                help="Also load some fixtures from the admin application.",
+            ),
+        )
         parser.add_argument(
-            "--extra-fixtures",
+            "--dev",
             action="store_true",
             default=False,
-            help="Also load some fixtures from the admin application.",
+            help="Setup dev environment. DO NOT USE IN PRODUCTION",
         )
 
     def handle(self, *args, **options):
@@ -76,3 +87,16 @@ def handle(self, *args, **options):
             from modoboa.admin import factories
 
             factories.populate_database()
+
+        if options["dev"]:
+            app_model = get_application_model()
+            if not app_model.objects.filter(name="frontend").exists():
+                call_command(
+                    "createapplication",
+                    "--algorithm=RS256",
+                    "--redirect-uris=https://localhost:3000/login/logged",
+                    "--name=frontend",
+                    "--client-id=LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD",
+                    "public",
+                    "authorization-code",
+                )
diff --git a/modoboa/core/middleware.py b/modoboa/core/middleware.py
index b9aaef4aa..042bd1a7e 100644
--- a/modoboa/core/middleware.py
+++ b/modoboa/core/middleware.py
@@ -1,7 +1,5 @@
 """Core middlewares."""
 
-from django import http
-from django.urls import reverse
 from django.utils.deprecation import MiddlewareMixin
 
 from . import models
@@ -13,26 +11,3 @@ class LocalConfigMiddleware(MiddlewareMixin):
     def process_request(self, request):
         """Inject LocalConfig instance to request."""
         request.localconfig = models.LocalConfig.objects.first()
-
-
-class TwoFAMiddleware:
-    """Custom 2FA middleware to enforce verification if user has TFA enabled."""
-
-    def __init__(self, get_response=None):
-        self.get_response = get_response
-
-    def __call__(self, request):
-        user = getattr(request, "user", None)
-        redirect_url = reverse("core:2fa_verify")
-        url_exceptions = (redirect_url, "/jsi18n/")
-        condition = (
-            user
-            and not user.is_anonymous
-            and user.tfa_enabled
-            and not request.path.startswith("/api/")
-            and request.path not in url_exceptions
-            and not user.is_verified()
-        )
-        if condition:
-            return http.HttpResponseRedirect(reverse("core:2fa_verify"))
-        return self.get_response(request)
diff --git a/modoboa/core/migrations/0028_userfidokeys.py b/modoboa/core/migrations/0028_userfidokeys.py
new file mode 100644
index 000000000..2ee9150c0
--- /dev/null
+++ b/modoboa/core/migrations/0028_userfidokeys.py
@@ -0,0 +1,42 @@
+# Generated by Django 4.2.11 on 2024-05-02 16:58
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("core", "0027_alter_user_language"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="UserFidoKeys",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("name", models.CharField(max_length=255)),
+                ("enabled", models.BooleanField(default=True)),
+                ("added_on", models.DateTimeField(auto_now_add=True)),
+                ("last_used", models.DateTimeField(default=None, null=True)),
+                ("use_count", models.IntegerField(default=0)),
+                ("credential_data", models.TextField()),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+        ),
+    ]
diff --git a/modoboa/core/migrations/0029_rename_tfa_enabled_user_totp_enabled_and_more.py b/modoboa/core/migrations/0029_rename_tfa_enabled_user_totp_enabled_and_more.py
new file mode 100644
index 000000000..ff6c44df1
--- /dev/null
+++ b/modoboa/core/migrations/0029_rename_tfa_enabled_user_totp_enabled_and_more.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.2.13 on 2024-06-21 15:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("core", "0028_userfidokeys"),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name="user",
+            old_name="tfa_enabled",
+            new_name="totp_enabled",
+        ),
+        migrations.AddField(
+            model_name="user",
+            name="webauthn_enabled",
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/modoboa/core/models.py b/modoboa/core/models.py
index fabcca31f..c509cc150 100644
--- a/modoboa/core/models.py
+++ b/modoboa/core/models.py
@@ -80,7 +80,8 @@ class User(AbstractUser):
         ),
     )
 
-    tfa_enabled = models.BooleanField(default=False)
+    totp_enabled = models.BooleanField(default=False)
+    webauthn_enabled = models.BooleanField(default=False)
 
     _parameters = models.JSONField(default=dict, encoder=DjangoJSONEncoder)
 
@@ -204,6 +205,10 @@ def encoded_address(self):
             )
         return self.email
 
+    @property
+    def tfa_enabled(self):
+        return self.totp_enabled or self.webauthn_enabled
+
     def is_owner(self, obj):
         """Tell is the user is the unique owner of this object.
 
@@ -412,6 +417,16 @@ def to_csv(self, csvwriter):
 reversion.register(User)
 
 
+class UserFidoKeys(models.Model):
+    user = models.ForeignKey(User, on_delete=models.CASCADE)
+    name = models.CharField(max_length=255)
+    enabled = models.BooleanField(default=True)
+    added_on = models.DateTimeField(auto_now_add=True)
+    last_used = models.DateTimeField(null=True, default=None)
+    use_count = models.IntegerField(default=0)
+    credential_data = models.TextField()
+
+
 def populate_callback(user, group="SimpleUsers"):
     """Populate callback
 
diff --git a/modoboa/core/templatetags/core_tags.py b/modoboa/core/templatetags/core_tags.py
index 859996d0d..e7285fcf3 100644
--- a/modoboa/core/templatetags/core_tags.py
+++ b/modoboa/core/templatetags/core_tags.py
@@ -249,7 +249,7 @@ def get_modoboa_logo():
     except AttributeError:
         logo = None
     if logo is None:
-        return os.path.join(settings.STATIC_URL, "css/modoboa.png")
+        return os.path.join(settings.STATIC_URL, "css/modoboa-white.png")
     return logo
 
 
diff --git a/modoboa/core/urls.py b/modoboa/core/urls.py
index 76b6e31fa..510c0b197 100644
--- a/modoboa/core/urls.py
+++ b/modoboa/core/urls.py
@@ -10,13 +10,23 @@
 urlpatterns = [
     path("", views.RootDispatchView.as_view(), name="root"),
     path("dashboard/", views.DashboardView.as_view(), name="dashboard"),
-    path("accounts/login/", views.dologin, name="login"),
+    path("accounts/login/", views.LoginView.as_view(), name="login"),
     path("accounts/logout/", views.dologout, name="logout"),
     path(
         "accounts/2fa_verify/",
         views.TwoFactorCodeVerifyView.as_view(),
         name="2fa_verify",
     ),
+    path(
+        "accounts/fido/authenticate/begin",
+        views.FidoAuthenticationBeginView.as_view(),
+        name="fido_auth_begin",
+    ),
+    path(
+        "accounts/fido/authenticate/end",
+        views.FidoAuthenticationEndView.as_view(),
+        name="fido_auth_end",
+    ),
     path("core/", views.viewsettings, name="index"),
     path("core/parameters/", views.parameters, name="parameters"),
     path("core/info/", views.information, name="information"),
diff --git a/modoboa/core/views/__init__.py b/modoboa/core/views/__init__.py
index eb728b210..494c4061d 100644
--- a/modoboa/core/views/__init__.py
+++ b/modoboa/core/views/__init__.py
@@ -10,11 +10,13 @@
 )
 from .auth import (
     PasswordResetView,
-    dologin,
+    LoginView,
     dologout,
     VerifySMSCodeView,
     ResendSMSCodeView,
     TwoFactorCodeVerifyView,
+    FidoAuthenticationBeginView,
+    FidoAuthenticationEndView,
 )
 from .base import RootDispatchView
 from .dashboard import DashboardView
@@ -22,15 +24,17 @@
 
 __all__ = [
     "DashboardView",
+    "LoginView",
     "PasswordResetView",
     "ResendSMSCodeView",
     "RootDispatchView",
     "VerifySMSCodeView",
     "api_access",
     "check_top_notifications",
-    "dologin",
     "dologout",
     "TwoFactorCodeVerifyView",
+    "FidoAuthenticationBeginView",
+    "FidoAuthenticationEndView",
     "index",
     "information",
     "logs",
diff --git a/modoboa/core/views/auth.py b/modoboa/core/views/auth.py
index 708708064..e1c4ed06c 100644
--- a/modoboa/core/views/auth.py
+++ b/modoboa/core/views/auth.py
@@ -1,12 +1,14 @@
 """Core authentication views."""
 
+from functools import cached_property
 import logging
+import urllib.parse
 
 import oath
 
 from django.conf import settings
-from django.http import HttpResponse, HttpResponseRedirect, Http404, JsonResponse
-from django.template.loader import render_to_string
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponseRedirect, Http404, JsonResponse
 from django.urls import reverse
 from django.utils import translation
 from django.utils.encoding import force_bytes
@@ -14,22 +16,21 @@
 from django.utils.http import urlsafe_base64_encode
 from django.utils.translation import gettext as _
 from django.views import generic
-from django.views.decorators.cache import never_cache
 from django.views.decorators.http import require_http_methods
 
-from django.contrib.auth import authenticate, login, logout, views as auth_views
-from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth import load_backend, login, logout
 from django.contrib.auth.tokens import default_token_generator
+import django.contrib.auth.views as auth_views
 
 import django_otp
+from rest_framework.views import APIView
 
-from modoboa.core import forms
+from modoboa.core import constants, fido2_auth, forms, models
+from modoboa.core.api.v2 import serializers
 from modoboa.core.password_hashers import get_password_hasher
 from modoboa.lib import cryptutils
-from modoboa.lib.views import UserFormKwargsMixin
 from modoboa.parameters import tools as param_tools
 
-from .. import models
 from .. import sms_backends
 from .. import signals
 from .base import find_nextlocation
@@ -37,101 +38,110 @@
 logger = logging.getLogger("modoboa.auth")
 
 
-def dologin(request):
-    """Try to authenticate."""
-    error = None
-    if request.method == "POST":
-        form = forms.LoginForm(request.POST)
-        if form.is_valid():
-            logger = logging.getLogger("modoboa.auth")
-            user = authenticate(
-                username=form.cleaned_data["username"],
-                password=form.cleaned_data["password"],
+class LoginViewMixin:
+    @cached_property
+    def logger(self):
+        return logging.getLogger("modoboa.auth")
+
+    def get_user(self):
+        try:
+            user_id = self.request.session[constants.TFA_PRE_VERIFY_USER_PK]
+            backend_path = self.request.session[constants.TFA_PRE_VERIFY_USER_BACKEND]
+            assert backend_path in settings.AUTHENTICATION_BACKENDS
+            backend = load_backend(backend_path)
+            user = backend.get_user(user_id)
+            if user is not None:
+                user.backend = backend_path
+            return user
+        except (KeyError, AssertionError):
+            return None
+
+    def login(self, user, rememberme):
+        login(self.request, user)
+        if not rememberme:
+            self.request.session.set_expiry(0)
+
+        translation.activate(user.language)
+
+        self.logger.info(_("User '%s' successfully logged in") % user.username)
+        response = HttpResponseRedirect(find_nextlocation(self.request, user))
+        response.set_cookie(settings.LANGUAGE_COOKIE_NAME, user.language)
+        return response
+
+
+class LoginView(LoginViewMixin, auth_views.LoginView):
+    """Login view with 2FA support."""
+
+    form_class = forms.AuthenticationForm
+    template_name = "registration/login.html"
+
+    def get_context_data(self, **kwargs):
+        announcements = signals.get_announcements.send(
+            sender="login", location="loginpage"
+        )
+        announcements = [announcement[1] for announcement in announcements]
+        return super().get_context_data(announcements=announcements, **kwargs)
+
+    def check_password_hash(self, user, form):
+        condition = user.is_local and param_tools.get_global_parameter(
+            "update_scheme", raise_exception=False
+        )
+        if not condition:
+            return
+        # check if password scheme is correct
+        scheme = param_tools.get_global_parameter(
+            "password_scheme", raise_exception=False
+        )
+        # use SHA512CRYPT as default fallback
+        if scheme is None:
+            pwhash = get_password_hasher("sha512crypt")()
+        else:
+            pwhash = get_password_hasher(scheme)()
+        if not user.password.startswith(pwhash.scheme):
+            logging.info(
+                _("Password scheme mismatch. Updating %s password"),
+                user.username,
             )
-            if user and user.is_active:
-                condition = user.is_local and param_tools.get_global_parameter(
-                    "update_scheme", raise_exception=False
-                )
-                if condition:
-                    # check if password scheme is correct
-                    scheme = param_tools.get_global_parameter(
-                        "password_scheme", raise_exception=False
-                    )
-                    # use SHA512CRYPT as default fallback
-                    if scheme is None:
-                        pwhash = get_password_hasher("sha512crypt")()
-                    else:
-                        pwhash = get_password_hasher(scheme)()
-                    if not user.password.startswith(pwhash.scheme):
-                        logging.info(
-                            _("Password scheme mismatch. Updating %s password"),
-                            user.username,
-                        )
-                        user.set_password(form.cleaned_data["password"])
-                        user.save()
-                    if pwhash.needs_rehash(user.password):
-                        logging.info(
-                            _(
-                                "Password hash parameter missmatch. "
-                                "Updating %s password"
-                            ),
-                            user.username,
-                        )
-                        user.set_password(form.cleaned_data["password"])
-                        user.save()
-
-                login(request, user)
-                if not form.cleaned_data["rememberme"]:
-                    request.session.set_expiry(0)
-
-                translation.activate(request.user.language)
-
-                logger.info(_("User '%s' successfully logged in") % user.username)
-                signals.user_login.send(
-                    sender="dologin",
-                    username=form.cleaned_data["username"],
-                    password=form.cleaned_data["password"],
-                )
-                response = HttpResponseRedirect(find_nextlocation(request, user))
-                response.set_cookie(
-                    settings.LANGUAGE_COOKIE_NAME, request.user.language
-                )
-                return response
-
-            error = _("Your username and password didn't match. Please try again.")
-            logger.warning(
-                "Failed connection attempt from '%(addr)s' as user '%(user)s'"
-                % {
-                    "addr": request.META["REMOTE_ADDR"],
-                    "user": escape(form.cleaned_data["username"]),
-                }
+            user.set_password(form.cleaned_data["password"])
+            user.save()
+        if pwhash.needs_rehash(user.password):
+            logging.info(
+                _("Password hash parameter missmatch. " "Updating %s password"),
+                user.username,
             )
+            user.set_password(form.cleaned_data["password"])
+            user.save()
 
-        nextlocation = request.POST.get("next", "")
-        httpcode = 401
-    else:
-        form = forms.LoginForm()
-        nextlocation = request.GET.get("next", "")
-        httpcode = 200
-
-    announcements = signals.get_announcements.send(sender="login", location="loginpage")
-    announcements = [announcement[1] for announcement in announcements]
-    return HttpResponse(
-        render_to_string(
-            "registration/login.html",
-            {
-                "form": form,
-                "error": error,
-                "next": nextlocation,
-                "annoucements": announcements,
-            },
-            request,
-        ),
-        status=httpcode,
-    )
-
-
-dologin = never_cache(dologin)
+    def form_valid(self, form):
+        user = form.get_user()
+        self.check_password_hash(user, form)
+        # FIXME: remove ASAP
+        signals.user_login.send(
+            sender="dologin",
+            username=user.username,
+            password=form.cleaned_data["password"],
+        )
+        if user.tfa_enabled:
+            self.request.session[constants.TFA_PRE_VERIFY_USER_PK] = user.pk
+            self.request.session[constants.TFA_PRE_VERIFY_USER_BACKEND] = user.backend
+            self.request.session["rememberme"] = form.cleaned_data["rememberme"]
+            nextlocation = self.request.POST.get("next", self.request.GET.get("next"))
+            url = reverse("core:2fa_verify")
+            if nextlocation:
+                url += f"?next={urllib.parse.quote(nextlocation)}"
+            return HttpResponseRedirect(url)
+        return self.login(user, form.cleaned_data["rememberme"])
+
+    def form_invalid(self, form):
+        # FIXME: should we return a 401 as before ?
+        self.logger.warning(
+            "Failed connection attempt from '%(addr)s' as user '%(user)s'"
+            % {
+                "addr": self.request.META["REMOTE_ADDR"],
+                "user": escape(form.cleaned_data["username"]),
+            }
+        )
+        return super().form_invalid(form)
 
 
 @require_http_methods(["POST"])
@@ -244,15 +254,64 @@ def get(self, request, *args, **kwargs):
         return JsonResponse({"status": "ok"})
 
 
-class TwoFactorCodeVerifyView(
-    LoginRequiredMixin, UserFormKwargsMixin, generic.FormView
-):
+class TwoFactorCodeVerifyView(LoginViewMixin, generic.FormView):
     """View to verify a 2FA code after login."""
 
     form_class = forms.Verify2FACodeForm
     template_name = "registration/twofactor_code_verify.html"
 
+    def dispatch(self, request, *args, **kwargs):
+        self.user = self.get_user()
+        if not self.user:
+            return HttpResponseRedirect(reverse("core:login"))
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs.update({"user": self.user})
+        return kwargs
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        has_totp_device = django_otp.user_has_device(self.user)
+        context["totp_device"] = has_totp_device
+        context["webauthn_device"] = self.user.webauthn_enabled
+        context["nextlocation"] = self.request.POST.get(
+            "next", self.request.GET.get("next")
+        )
+        if context["nextlocation"] is None:
+            context.pop("nextlocation")
+        return context
+
     def form_valid(self, form):
         """Login user."""
+        response = self.login(self.user, self.request.session.pop("rememberme", False))
         django_otp.login(self.request, form.cleaned_data["tfa_code"])
-        return HttpResponseRedirect(find_nextlocation(self.request, self.request.user))
+        return response
+
+
+class FidoAuthenticationBeginView(generic.View):
+    """FIDO authentication process, begining."""
+
+    def post(self, request, *args, **kwargs):
+        user_id = request.session.get(constants.TFA_PRE_VERIFY_USER_PK)
+        if not user_id:
+            raise PermissionDenied
+        options, state = fido2_auth.begin_authentication(user_id)
+        request.session["fido_state"] = state
+        return JsonResponse(dict(options))
+
+
+class FidoAuthenticationEndView(LoginViewMixin, APIView):
+    """FIDO authentication process, end."""
+
+    def post(self, request, *args, **kwargs):
+        user = self.get_user()
+        fido_state = request.session.pop("fido_state", None)
+        if not user or not fido_state:
+            raise PermissionDenied
+        serializer = serializers.FidoAuthenticationSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        fido2_auth.end_authentication(user, fido_state, serializer.validated_data)
+        response = self.login(user, self.request.session.pop("rememberme", False))
+        return JsonResponse({"next": response.url})
diff --git a/modoboa/core/views/user.py b/modoboa/core/views/user.py
index ce97e1698..a8105f301 100644
--- a/modoboa/core/views/user.py
+++ b/modoboa/core/views/user.py
@@ -127,13 +127,13 @@ def api_access(request):
 def security(request):
     """View to manage security settings."""
     context = {"user_has_device": django_otp.user_has_device(request.user)}
-    if not request.user.tfa_enabled:
+    if not request.user.totp_enabled:
         device = request.user.staticdevice_set.first()
         if device:
             tokens = device.token_set.all().values_list("token", flat=True)
             context.update({"tokens": tokens})
             # Set enable flag to True so we can't go here anymore
-            request.user.tfa_enabled = True
+            request.user.totp_enabled = True
             request.user.save()
         else:
             device = request.user.totpdevice_set.first()
diff --git a/modoboa/static/css/modoboa-white.png b/modoboa/static/css/modoboa-white.png
new file mode 100644
index 000000000..6d49dd63b
Binary files /dev/null and b/modoboa/static/css/modoboa-white.png differ
diff --git a/modoboa/static/css/modoboa.png b/modoboa/static/css/modoboa.png
index 34d2d4f3f..a00cf98b6 100644
Binary files a/modoboa/static/css/modoboa.png and b/modoboa/static/css/modoboa.png differ
diff --git a/modoboa/static/css/offline.css b/modoboa/static/css/offline.css
index 9defa70a5..c15fdeb37 100644
--- a/modoboa/static/css/offline.css
+++ b/modoboa/static/css/offline.css
@@ -1,40 +1,142 @@
-@media (max-width: 767px) {
-    form div.checkbox button.btn {
-        margin-top: 20px;
-        margin-bottom: 0;
-    }
+body {
+    margin: 0;
+    font-family: Arial, sans-serif;
+    height: 100vh;
+    background-color: #f0f2f5;
+    display: flex;
+    align-items: center;
+    justify-content: center;
 }
 
-body {
-    background-color: #EDEDEC;
+.container {
+    display: flex;
+    width: 100%;
+    height: 100%;
+}
+
+.column {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    padding: 20px;
+}
+
+.left-column {
+    background-color: #046bf8;
+    text-align: center;
+}
+
+.right-column {
+    background-color: #ffffff;
+    display: flex;
+    justify-content: center;
+    align-items: center;
 }
 
-#logo {
-    margin-bottom: 5%;
+.login-box {
+    width: 100%;
+    max-width: 400px;
+    text-align: center;
 }
 
-html body div.container div#box div#logo.row ul.thumbnails li {
-    list-style-type: none;
+.message-box {
+    width: 100%;
+    max-width: 600px;
+    text-align: center;
 }
 
-.thumbnail {
-    background-color: transparent;
-    border-color: transparent;
+.logo img {
+    width: 400px;
+    margin-bottom: 20px;
 }
 
-.thumbnails {
-    padding-left: 0;
+h2 {
+    margin-bottom: 20px;
+    font-size: 24px;
+    color: #333;
 }
 
 .input-group {
-    border-collapse: separate;
-    display: table;
-    position: relative;
+    margin-bottom: 15px;
+    text-align: left;
+    width: 100%;
+}
+
+.input-group label {
+    display: block;
+    margin-bottom: 5px;
+    font-size: 14px;
+    color: #555;
+}
+
+.input-group input {
+    width: 100%;
+    padding: 10px;
+    border: 1px solid #ddd;
+    border-radius: 5px;
+    font-size: 16px;
+    color: #333;
+}
+
+.checkbox-group {
+    display: flex;
+    align-items: center;
+}
+
+.checkbox-group input {
+    width: auto;
+    margin-right: 10px;
 }
 
-.form-actions { 
-    background-color: transparent;
-    padding-right: 0px;
-    padding-left: 0px;
-    margin-bottom: 0px;
-}
\ No newline at end of file
+.btn {
+    width: 100%;
+    padding: 10px;
+    background-color: #007bff;
+    border: none;
+    border-radius: 5px;
+    color: white;
+    font-size: 16px;
+    cursor: pointer;
+    transition: background-color 0.3s;
+}
+
+.btn:hover {
+    background-color: #0056b3;
+}
+
+.footer {
+    margin-top: 20px;
+}
+
+.footer a {
+    color: #007bff;
+    text-decoration: none;
+    font-size: 14px;
+}
+
+.footer a:hover {
+    text-decoration: underline;
+}
+
+.alert-box {
+    background-color: #007bff;
+    color: white;
+    padding: 10px;
+    border-radius: 5px;
+    margin-bottom: 20px;
+}
+
+.mt {
+  margin-top: 5px;
+}
+
+@media (max-width: 768px) {
+  .container {
+    flex-direction: column;
+  }
+  .column {
+    width: 100%;
+  }
+}
diff --git a/modoboa/static/js/webauthn-json.browser-ponyfill.js b/modoboa/static/js/webauthn-json.browser-ponyfill.js
new file mode 100644
index 000000000..664592809
--- /dev/null
+++ b/modoboa/static/js/webauthn-json.browser-ponyfill.js
@@ -0,0 +1,193 @@
+// src/webauthn-json/base64url.ts
+function base64urlToBuffer(baseurl64String) {
+  const padding = "==".slice(0, (4 - baseurl64String.length % 4) % 4);
+  const base64String = baseurl64String.replace(/-/g, "+").replace(/_/g, "/") + padding;
+  const str = atob(base64String);
+  const buffer = new ArrayBuffer(str.length);
+  const byteView = new Uint8Array(buffer);
+  for (let i = 0; i < str.length; i++) {
+    byteView[i] = str.charCodeAt(i);
+  }
+  return buffer;
+}
+function bufferToBase64url(buffer) {
+  const byteView = new Uint8Array(buffer);
+  let str = "";
+  for (const charCode of byteView) {
+    str += String.fromCharCode(charCode);
+  }
+  const base64String = btoa(str);
+  const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
+  return base64urlString;
+}
+
+// src/webauthn-json/convert.ts
+var copyValue = "copy";
+var convertValue = "convert";
+function convert(conversionFn, schema, input) {
+  if (schema === copyValue) {
+    return input;
+  }
+  if (schema === convertValue) {
+    return conversionFn(input);
+  }
+  if (schema instanceof Array) {
+    return input.map((v) => convert(conversionFn, schema[0], v));
+  }
+  if (schema instanceof Object) {
+    const output = {};
+    for (const [key, schemaField] of Object.entries(schema)) {
+      if (schemaField.derive) {
+        const v = schemaField.derive(input);
+        if (v !== void 0) {
+          input[key] = v;
+        }
+      }
+      if (!(key in input)) {
+        if (schemaField.required) {
+          throw new Error(`Missing key: ${key}`);
+        }
+        continue;
+      }
+      if (input[key] == null) {
+        output[key] = null;
+        continue;
+      }
+      output[key] = convert(conversionFn, schemaField.schema, input[key]);
+    }
+    return output;
+  }
+}
+function derived(schema, derive) {
+  return {
+    required: true,
+    schema,
+    derive
+  };
+}
+function required(schema) {
+  return {
+    required: true,
+    schema
+  };
+}
+function optional(schema) {
+  return {
+    required: false,
+    schema
+  };
+}
+
+// src/webauthn-json/basic/schema.ts
+var publicKeyCredentialDescriptorSchema = {
+  type: required(copyValue),
+  id: required(convertValue),
+  transports: optional(copyValue)
+};
+var simplifiedExtensionsSchema = {
+  appid: optional(copyValue),
+  appidExclude: optional(copyValue),
+  credProps: optional(copyValue)
+};
+var simplifiedClientExtensionResultsSchema = {
+  appid: optional(copyValue),
+  appidExclude: optional(copyValue),
+  credProps: optional(copyValue)
+};
+var credentialCreationOptions = {
+  publicKey: required({
+    rp: required(copyValue),
+    user: required({
+      id: required(convertValue),
+      name: required(copyValue),
+      displayName: required(copyValue)
+    }),
+    challenge: required(convertValue),
+    pubKeyCredParams: required(copyValue),
+    timeout: optional(copyValue),
+    excludeCredentials: optional([publicKeyCredentialDescriptorSchema]),
+    authenticatorSelection: optional(copyValue),
+    attestation: optional(copyValue),
+    extensions: optional(simplifiedExtensionsSchema)
+  }),
+  signal: optional(copyValue)
+};
+var publicKeyCredentialWithAttestation = {
+  type: required(copyValue),
+  id: required(copyValue),
+  rawId: required(convertValue),
+  authenticatorAttachment: optional(copyValue),
+  response: required({
+    clientDataJSON: required(convertValue),
+    attestationObject: required(convertValue),
+    transports: derived(copyValue, (response) => {
+      var _a;
+      return ((_a = response.getTransports) == null ? void 0 : _a.call(response)) || [];
+    })
+  }),
+  clientExtensionResults: derived(simplifiedClientExtensionResultsSchema, (pkc) => pkc.getClientExtensionResults())
+};
+var credentialRequestOptions = {
+  mediation: optional(copyValue),
+  publicKey: required({
+    challenge: required(convertValue),
+    timeout: optional(copyValue),
+    rpId: optional(copyValue),
+    allowCredentials: optional([publicKeyCredentialDescriptorSchema]),
+    userVerification: optional(copyValue),
+    extensions: optional(simplifiedExtensionsSchema)
+  }),
+  signal: optional(copyValue)
+};
+var publicKeyCredentialWithAssertion = {
+  type: required(copyValue),
+  id: required(copyValue),
+  rawId: required(convertValue),
+  authenticatorAttachment: optional(copyValue),
+  response: required({
+    clientDataJSON: required(convertValue),
+    authenticatorData: required(convertValue),
+    signature: required(convertValue),
+    userHandle: required(convertValue)
+  }),
+  clientExtensionResults: derived(simplifiedClientExtensionResultsSchema, (pkc) => pkc.getClientExtensionResults())
+};
+
+// src/webauthn-json/basic/api.ts
+function createRequestFromJSON(requestJSON) {
+  return convert(base64urlToBuffer, credentialCreationOptions, requestJSON);
+}
+function createResponseToJSON(credential) {
+  return convert(bufferToBase64url, publicKeyCredentialWithAttestation, credential);
+}
+function getRequestFromJSON(requestJSON) {
+  return convert(base64urlToBuffer, credentialRequestOptions, requestJSON);
+}
+function getResponseToJSON(credential) {
+  return convert(bufferToBase64url, publicKeyCredentialWithAssertion, credential);
+}
+
+// src/webauthn-json/basic/supported.ts
+function supported() {
+  return !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get && window.PublicKeyCredential);
+}
+
+// src/webauthn-json/browser-ponyfill.ts
+async function create(options) {
+  const response = await navigator.credentials.create(options);
+  response.toJSON = () => createResponseToJSON(response);
+  return response;
+}
+async function get(options) {
+  const response = await navigator.credentials.get(options);
+  response.toJSON = () => getResponseToJSON(response);
+  return response;
+}
+export {
+  create,
+  get,
+  createRequestFromJSON as parseCreationOptionsFromJSON,
+  getRequestFromJSON as parseRequestOptionsFromJSON,
+  supported
+};
+//# sourceMappingURL=webauthn-json.browser-ponyfill.js.map
diff --git a/modoboa/templates/registration/base.html b/modoboa/templates/registration/base.html
index 8c1343368..12feed341 100644
--- a/modoboa/templates/registration/base.html
+++ b/modoboa/templates/registration/base.html
@@ -7,35 +7,21 @@
     <meta http-equiv="X-UA-Compatible" content="IE=edge" />
     <title>{% trans "Welcome to Modoboa" %}</title>
     <link rel="icon" type="image/png" href="{% static 'css/logo-icon.png' %}" />
-    <link rel="stylesheet" type="text/css" href="{% static 'bootstrap/dist/css/bootstrap.min.css' %}" />
     <link rel="stylesheet" type="text/css" href="{% static 'css/offline.css' %}" />
     {% block extra_css %}{% endblock %}
   </head>
   <body>
     <div class="container">
-      <div id="box" class="col-lg-9 col-lg-offset-1">
-        <div id="logo" class="row">
-          <ul class="thumbnails">
-            <li>
-              <div class="col-lg-offset-4 col-lg-5 col-md-offset-4 col-md-5 col-sm-offset-4 col-sm-5">
-                <a href="#" class="thumbnail">
-                  <img src="{% get_modoboa_logo %}" />
-                </a>
-              </div>
-            </li>
-          </ul>
-        </div>
-        <div class=" divider"></div>
+      <div class="column left-column">
+        <a href="#" class="logo">
+          <img src="{% get_modoboa_logo %}" />
+        </a>
+        {% block sub_content %}{% endblock %}
+      </div>
+      <div class="column right-column">
         {% block main_content %}{% endblock %}
       </div>
-      {% block sub_content %}{% endblock %}
     </div>
-    <!-- Javascript -->
-    <script type="text/javascript" src="{% static 'jquery/jquery.min.js' %}"></script>
-    <script type="text/javascript" src="{% static 'bootstrap/dist/js/bootstrap.min.js' %}"></script>
-    {% if TEMPLATE_DEBUG %}
-    <script src="//maxcdn.bootstrapcdn.com/bootlint/0.4.0/bootlint.min.js"></script>
-    {% endif %}
     <script type="text/javascript" src="{% url 'javascript-catalog' %}"></script>
     {% block extra_js %}{% endblock %}
   </body>
diff --git a/modoboa/templates/registration/login.html b/modoboa/templates/registration/login.html
index 931f296bc..3ee975c4f 100644
--- a/modoboa/templates/registration/login.html
+++ b/modoboa/templates/registration/login.html
@@ -7,25 +7,28 @@
 {% endblock %}
 
 {% block main_content %}
-  <form method="post" action="{% url 'core:login' %}" class="login-form form-horizontal">
-    {% csrf_token %}
-    {% render_field form.username %}
-    {% render_field form.password %}
-    <input type="hidden" name="next" value="{{ next }}" />
-    <div class="form-group">
-      <div class="control-label col-lg-4 col-md-4 col-sm-4 ">
+  <div class="login-box">
+    <h2>{% translate "Identification" %}</h2>
+    <form method="post" action="{% url 'core:login' %}" class="login-form form-horizontal">
+      {% csrf_token %}
+      <div class="input-group">
+        {% render_field form.username %}
       </div>
-      <div class="col-lg-5 col-md-5 col-sm-5">
-        <div class="checkbox">
-          <label class="checkbox" for="id_rememberme">
-            <input type="checkbox" id="id_rememberme" name="rememberme" />{% trans "Remember me" %}
-          </label>
-          <button class="btn btn-primary col-lg-12" type="submit">{% trans "Log in" %}</button>
-          <a class="pull-right" href="{% url 'password_reset' %}">{% trans "Forgot password?" %}</a>
-        </div>
+      <div class="input-group">
+        {% render_field form.password %}
       </div>
+      <input type="hidden" name="next" value="{{ next }}" />
+
+      <div class="input-group checkbox-group">
+        <input type="checkbox" id="remember" name="remember">
+        <label for="remember">{% translate "Remember me" %}</label>
+      </div>
+      <button class="btn" type="submit">{% trans "Log in" %}</button>
+    </form>
+    <div class="footer">
+      <a class="pull-right" href="{% url 'password_reset' %}">{% trans "Forgot password?" %}</a>
     </div>
-  </form>
+  </div>
   <div class="login-error">
     {% if error %}{% alert error "danger" %}{% endif %}
   </div>
@@ -33,15 +36,15 @@
 
 {% block sub_content %}
   {% for annoucement in annoucements %}
-    <div class="col-xs-12 col-sm-offset-3 col-sm-6">{{ annoucement|safe }}</div>
+    <div>{{ annoucement|safe }}</div>
   {% endfor %}
 {% endblock %}
 
 {% block extra_js %}
   <script type="text/javascript">
    $(document).ready(function() {
-       $("form").submit(function(){  
-           $(":submit",this).attr("disabled","disabled");  
+       $("form").submit(function(){
+           $(":submit", this).attr("disabled", "disabled");
        });
        $('#id_username').focus();
    });
diff --git a/modoboa/templates/registration/password_reset_complete.html b/modoboa/templates/registration/password_reset_complete.html
index 94fa32a98..8b6e9cc7e 100644
--- a/modoboa/templates/registration/password_reset_complete.html
+++ b/modoboa/templates/registration/password_reset_complete.html
@@ -3,7 +3,7 @@
 {% load i18n %}
 
 {% block main_content %}
-  <div class="alert alert-success">
+  <div class="message-box">
     {% url 'core:login' as login_url %}
     {% blocktrans with url=login_url trimmed %}
       Your password has been set. You may go ahead and <a href="{{ url }}">sign in</a> now.
diff --git a/modoboa/templates/registration/password_reset_confirm.html b/modoboa/templates/registration/password_reset_confirm.html
index dc7c9a965..c219b77d2 100644
--- a/modoboa/templates/registration/password_reset_confirm.html
+++ b/modoboa/templates/registration/password_reset_confirm.html
@@ -3,21 +3,26 @@
 {% load i18n form_tags %}
 
 {% block main_content %}
-  {% if validlink %}
-    <h3 class="text-center">{% trans "Change password" %}</h3>
-    <form method="post" class="form-horizontal" style="margin-top: 20px">
-      {% csrf_token %}
-      {% render_form form %}
-      <div class="col-sm-offset-4 col-sm-5">
-        <button class="btn btn-primary col-lg-12" type="submit">{% trans "Submit" %}</button>
+  <div class="login-box">
+    {% if validlink %}
+      <h3 class="text-center">{% trans "Change password" %}</h3>
+      <form method="post" style="margin-top: 20px">
+        {% csrf_token %}
+        <div class="input-group">
+          {% render_field form.new_password1 %}
+        </div>
+        <div class="input-group">
+          {% render_field form.new_password2 %}
+        </div>
+        <button class="btn" type="submit">{% trans "Submit" %}</button>
+      </form>
+    {% else %}
+      <div class="alert alert-danger">
+        {% blocktrans trimmed %}
+          The password reset link was invalid, possibly because it has already been used.
+          Please request a new password reset.
+        {% endblocktrans %}
       </div>
-    </form>
-  {% else %}
-    <div class="alert alert-danger">
-      {% blocktrans trimmed %}
-        The password reset link was invalid, possibly because it has already been used.
-        Please request a new password reset.
-      {% endblocktrans %}
-    </div>
-  {% endif %}
+    {% endif %}
+  </div>
 {% endblock %}
diff --git a/modoboa/templates/registration/password_reset_done.html b/modoboa/templates/registration/password_reset_done.html
index f75e8954f..4068be476 100644
--- a/modoboa/templates/registration/password_reset_done.html
+++ b/modoboa/templates/registration/password_reset_done.html
@@ -3,7 +3,8 @@
 {% load i18n %}
 
 {% block main_content %}
-  <div class="alert alert-info">
+  <div class="message-box">
+    <h3 class="text-center">{% trans "Forgot password?" %}</h3>
     {% blocktrans trimmed %}
       <p>We've emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly.</p>
       <p>If you don't receive an email, please make sure you've entered your primary address, and check your spam folder.</p>
diff --git a/modoboa/templates/registration/password_reset_form.html b/modoboa/templates/registration/password_reset_form.html
index 672e40bea..1dae339fd 100644
--- a/modoboa/templates/registration/password_reset_form.html
+++ b/modoboa/templates/registration/password_reset_form.html
@@ -3,31 +3,26 @@
 {% load i18n form_tags %}
 
 {% block main_content %}
-
-  {% if announcement %}
-    <div class="row">
-      <div class="col-xs-12 col-sm-offset-3 col-sm-7">
-        <div class="alert alert-warning text-center">
-          {{ announcement }}
+  <div class="login-box">
+    {% if announcement %}
+      <div class="row">
+        <div class="col-xs-12 col-sm-offset-3 col-sm-7">
+          <div class="alert alert-warning text-center">
+            {{ announcement }}
+          </div>
         </div>
       </div>
-    </div>
-  {% endif %}
+    {% endif %}
 
-  <h3 class="text-center">{% trans "Forgot password?" %}</h3>
-  <form class="form-horizontal" method="post" style="margin-top: 20px">
-    {% csrf_token %}
-    {% render_field form.email %}
-    <div class="row">
+    <h3 class="text-center">{% trans "Forgot password?" %}</h3>
+    <form method="post" style="margin-top: 20px">
+      {% csrf_token %}
+      <div class="input-group">
+        {% render_field form.email %}
+      </div>
       <div class="col-sm-offset-4 col-sm-5">
-        <div class="alert alert-info">
-          {% trans "Please fill-in your primary email address" %}
-        </div>
+        <button type="submit" class="btn">{% trans "Submit" %}</button>
       </div>
-    </div>
-    <div class="col-sm-offset-4 col-sm-5">
-      <button type="submit" class="btn btn-primary col-lg-12">{% trans "Submit" %}</button>
-    </div>
-  </form>
-
+    </form>
+  </div>
 {% endblock %}
diff --git a/modoboa/templates/registration/twofactor_code_verify.html b/modoboa/templates/registration/twofactor_code_verify.html
index 0a39eb4f4..2b00eaaf6 100644
--- a/modoboa/templates/registration/twofactor_code_verify.html
+++ b/modoboa/templates/registration/twofactor_code_verify.html
@@ -7,24 +7,113 @@
 {% endblock %}
 
 {% block main_content %}
-  <div class="row">
-    <div class="col-sm-offset-4 col-sm-6">
-      <form method="post" action="{% url 'core:2fa_verify' %}" class="login-form">
+  <div class="login-box">
+    <h2>{% translate "Verification" %}</h2>
+    <div id="methodSelection">
+      {% if totp_device %}
+        <button class="btn" onclick="showTOTP()">{% translate "Use TOTP/backup code" %}</button>
+      {% endif %}
+      {% if webauthn_device %}
+        <button class="mt btn" onclick="showWebAuthn()">{% translate "Use Webauthn device" %}</button>
+      {% endif %}
+    </div>
+    <div id="totp_form" style="display:none;">
+      <form method="post" action="{% url 'core:2fa_verify' %}">
         {% csrf_token %}
-        <div class="form-group{% if form.tfa_code.errors %} has-error{% endif %}">
-          <label class="control-label">Two-Factor authentication code</label>
-          <input id="id_tfa_code" name="tfa_code" class="form-control">
+        <input type="hidden" id="id_next" name="next" value="{{ nextlocation }}">
+        <div class="input-group{% if form.tfa_code.errors %} has-error{% endif %}">
+          <label>{% translate "One-time password" %}</label>
+          <input id="id_tfa_code" name="tfa_code">
           <span class="help-block">
             {{ form.tfa_code.errors }}
           </span>
           <p class="help-block">
-            Enter the code from the two-factor app on your mobile
-            device. If you've lost your device, you may enter one of your
-            recovery codes.
+            {% blocktranslate trimmed %}
+              Enter the code from the two-factor app on your mobile
+              device. If you've lost your device, you may enter one of your
+              recovery codes.
+            {% endblocktranslate %}
           </p>
         </div>
-        <button class="btn btn-primary col-sm-12" type="submit">Verify code</button>
+        <button class="btn btn-primary" type="submit">Verify code</button>
       </form>
+      <button class="mt" onclick="backToSelection()">Back</button>
+    </div>
+    <div id="webautn_form" style="display:none;">
+      <button class="btn" onclick="useWebAuthn()">Log using Webauthn</button>
+      <button class="mt" onclick="backToSelection()">Back</button>
     </div>
   </div>
 {% endblock %}
+
+{% block extra_js %}
+  <script type="module" defer>
+  import {
+    get,
+    parseRequestOptionsFromJSON,
+  } from "{% static 'js/webauthn-json.browser-ponyfill.js' %}"
+
+  function showTOTP() {
+    document.getElementById("totp_form").style.display = 'block'
+    document.getElementById("methodSelection").style.display = 'none'
+  }
+  window.showTOTP = showTOTP
+
+  function backToSelection() {
+    document.getElementById("totp_form").style.display = 'none'
+    document.getElementById("webautn_form").style.display = 'none'
+    document.getElementById("methodSelection").style.display = 'block'
+  }
+  window.backToSelection = backToSelection
+
+  function showWebAuthn() {
+    document.getElementById("webautn_form").style.display = 'block'
+    document.getElementById("methodSelection").style.display = 'none'
+  }
+  window.showWebAuthn = showWebAuthn
+
+  async function useWebAuthn() {
+    const csrf = getCookie('csrftoken')
+    const request = await fetch("{% url 'core:fido_auth_begin' %}", {
+      method: 'POST',
+      headers: { "X-CSRFToken": csrf, 'Content-Type': 'application/json' }
+    })
+    if (!request.ok) {
+      throw new Error('No credential available to authenticate!')
+    }
+    let json = await request.json()
+    let options = parseRequestOptionsFromJSON(json)
+
+    let response = await get(options);
+    let params = new URLSearchParams(window.location.search)
+    let url = "{% url 'core:fido_auth_end' %}"
+    if (params.has('next')) {
+      url += `?next=${encodeURIComponent(params.get('next'))}`
+    }
+    let result = await fetch(url, {
+      method: 'POST',
+      headers: { "X-CSRFToken": csrf, 'Content-Type': 'application/json'},
+      body: JSON.stringify(response),
+    })
+    json = await result.json()
+    location.href = json.next
+  }
+  window.useWebAuthn = useWebAuthn
+
+  function getCookie(cname) {
+    const name = cname + "="
+    const decodedCookie = decodeURIComponent(document.cookie)
+    const ca = decodedCookie.split(';')
+    for(let i = 0; i <ca.length; i++) {
+      let c = ca[i]
+      while (c.charAt(0) == ' ') {
+        c = c.substring(1)
+      }
+      if (c.indexOf(name) == 0) {
+        return c.substring(name.length, c.length)
+      }
+    }
+    return ""
+  }
+  </script>
+{% endblock %}
diff --git a/pyproject.toml b/pyproject.toml
index 519a26022..1fe7c53e0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
 [build-system]
-requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2"]
+requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.4"]
 build-backend = "setuptools.build_meta"
 
 [project]
@@ -43,3 +43,9 @@ repository = "https://github.com/modoboa/modoboa"
 
 [tool.setuptools.dynamic]
 version = {attr = "modoboa.version.get_version"}
+dependencies = {file = ["requirements.txt"]}
+optional-dependencies.dev = { file = ["dev-requirements.txt"] }
+optional-dependencies.test = { file = ["test-requirements.txt"] }
+optional-dependencies.ldap = { file = ["ldap-requirements.txt"] }
+optional-dependencies.mysql = { file = ["mysql-requirements.txt"] }
+optional-dependencies.postgresql = { file = ["postgresql-requirements.txt"] }
diff --git a/requirements.txt b/requirements.txt
index 3a4ff3597..76fbb4fef 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -22,6 +22,7 @@ bcrypt  # Requires libffi-dev and python-dev
 asgiref
 dnspython==2.6.1
 feedparser==6.0.11
+fido2==1.1.3
 gevent==24.2.1
 Pillow  # Optional dependency for django-ckeditor
 progressbar33==2.4
diff --git a/setup.py b/setup.py
index c5c91f3eb..eb16ce2dd 100644
--- a/setup.py
+++ b/setup.py
@@ -9,39 +9,11 @@
 
 from os import path
 
-try:
-    from pip.req import parse_requirements
-except ImportError:
-    # pip >= 10
-    from pip._internal.req import parse_requirements
-
 from setuptools import find_packages, setup
 
 
-def get_requirements(requirements_file):
-    """Use pip to parse requirements file."""
-    requirements = []
-    if path.isfile(requirements_file):
-        for req in parse_requirements(requirements_file, session="hack"):
-            try:
-                if req.markers:
-                    requirements.append("%s;%s" % (req.req, req.markers))
-                else:
-                    requirements.append("%s" % req.req)
-            except AttributeError:
-                # pip >= 20.0.2
-                requirements.append(req.requirement)
-    return requirements
-
-
 if __name__ == "__main__":
     HERE = path.abspath(path.dirname(__file__))
-    INSTALL_REQUIRES = get_requirements(path.join(HERE, "requirements.txt"))
-    MYSQL_REQUIRES = get_requirements(path.join(HERE, "mysql-requirements.txt"))
-    POSTGRESQL_REQUIRES = get_requirements(
-        path.join(HERE, "postgresql-requirements.txt")
-    )
-    LDAP_REQUIRES = get_requirements(path.join(HERE, "ldap-requirements.txt"))
 
     with open(path.join(HERE, "README.rst")) as readme:
         LONG_DESCRIPTION = readme.read()
@@ -52,42 +24,15 @@ def local_scheme(version):
         return ""
 
     setup(
-        name="modoboa",
-        description="Mail hosting made simple",
         long_description=LONG_DESCRIPTION,
-        license="ISC",
-        url="http://modoboa.org/",
-        author="Antoine Nguyen",
-        author_email="tonio@ngyn.org",
-        classifiers=[
-            "Development Status :: 5 - Production/Stable",
-            "Environment :: Web Environment",
-            "Framework :: Django :: 2.2",
-            "Intended Audience :: System Administrators",
-            "License :: OSI Approved :: ISC License (ISCL)",
-            "Operating System :: OS Independent",
-            "Programming Language :: Python :: 3",
-            "Programming Language :: Python :: 3.7",
-            "Programming Language :: Python :: 3.8",
-            "Programming Language :: Python :: 3.9",
-            "Topic :: Communications :: Email",
-            "Topic :: Internet :: WWW/HTTP",
-        ],
-        keywords="email",
         packages=find_packages(
             exclude=["doc", "test_data", "test_project", "frontend"]
         ),
         include_package_data=True,
         zip_safe=False,
         scripts=["bin/modoboa-admin.py"],
-        install_requires=INSTALL_REQUIRES,
         use_scm_version={"local_scheme": local_scheme},
-        python_requires=">=3.7",
-        setup_requires=["setuptools_scm>6.4"],
         extras_require={
-            "ldap": LDAP_REQUIRES,
-            "mysql": MYSQL_REQUIRES,
-            "postgresql": POSTGRESQL_REQUIRES,
             "argon2": ["argon2-cffi >= 16.1.0"],
         },
     )


Test patch:
diff --git a/modoboa/core/api/v2/tests.py b/modoboa/core/api/v2/tests.py
index 20cef99c8..d04b8b03d 100644
--- a/modoboa/core/api/v2/tests.py
+++ b/modoboa/core/api/v2/tests.py
@@ -80,7 +80,6 @@
 
 
 class ParametersAPITestCase(ModoAPITestCase):
-
     def setUp(self):
         super().setUp()
         cache.delete("password_scheme_choice")
@@ -184,7 +183,6 @@ def test_doveadm_alarm(self):
 
 
 class AccountViewSetTestCase(ModoAPITestCase):
-
     def test_me(self):
         url = reverse("v2:account-me")
         resp = self.client.get(url)
@@ -226,7 +224,7 @@ def test_tfa_setup(self):
         self.assertEqual(resp.status_code, 200)
         self.assertEqual(resp.content_type, "application/json")
 
-        user.tfa_enabled = True
+        user.totp_enabled = True
         user.save()
         resp = self.client.get(url)
         self.assertEqual(resp.status_code, 404)
@@ -275,7 +273,7 @@ def test_tfa_setup_modify(
         self.assertEqual(resp.status_code, 200)
 
         user.refresh_from_db()
-        self.assertEqual(user.tfa_enabled, False)
+        self.assertEqual(user.totp_enabled, False)
 
     # def test_api_token(self):
     #     # 1. Obtain a JWT token so we can safely play with basic token
@@ -574,7 +572,6 @@ def test_password_change(self, client_post, client_get):
 
 
 class LanguageViewSetTestCase(ModoAPITestCase):
-
     def test_list(self):
         url = reverse("v2:language-list")
         resp = self.client.get(url)
@@ -582,7 +579,6 @@ def test_list(self):
 
 
 class ComponentAPITestCase(ModoAPITestCase):
-
     def test_information(self):
         url = reverse("v2:components_information")
         resp = self.client.get(url)
diff --git a/modoboa/core/tests/test_authentication.py b/modoboa/core/tests/test_authentication.py
index 80d0ba416..f1897a491 100644
--- a/modoboa/core/tests/test_authentication.py
+++ b/modoboa/core/tests/test_authentication.py
@@ -53,7 +53,7 @@ def test_authentication(self):
     def test_authentication_with_2fa(self, login_mock, match_mock):
         user = models.User.objects.get(username="user@test.com")
         user.totpdevice_set.create(name="Device")
-        user.tfa_enabled = True
+        user.totp_enabled = True
         user.save()
         data = {"username": "user@test.com", "password": "toto"}
         response = self.client.post(reverse("core:login"), data)
diff --git a/modoboa/core/tests/test_viewsets.py b/modoboa/core/tests/test_viewsets.py
index c2c6553e2..cacf8e2ce 100644
--- a/modoboa/core/tests/test_viewsets.py
+++ b/modoboa/core/tests/test_viewsets.py
@@ -51,6 +51,7 @@ def test_tfa_setup_process(self, verify_mock, password_ok="password"):
 
         self.assertIn("recovery codes", response.json()["content"])
         admin.refresh_from_db()
+        self.assertTrue(admin.totp_enabled)
         self.assertTrue(admin.tfa_enabled)
         device = admin.staticdevice_set.first()
         self.assertIsNot(device, None)
diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py
index 6bbf33ab2..5795fd09e 100644
--- a/test_project/test_project/settings.py
+++ b/test_project/test_project/settings.py
@@ -47,6 +47,7 @@
 
 # The email address that error messages come from, such as those sent to ADMINS
 # SERVER_EMAIL = 'webmaster@example.net'
+EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
 
 # Security settings
 
@@ -76,6 +77,7 @@
     "django_otp.plugins.otp_static",
     "django_rename_app",
     "django_rq",
+    "django_extensions",  # Just for docker (SSL support)
 )
 
 # A dedicated place to register Modoboa applications
@@ -120,8 +122,6 @@
     "django.middleware.common.CommonMiddleware",
     "django.middleware.csrf.CsrfViewMiddleware",
     "django.contrib.auth.middleware.AuthenticationMiddleware",
-    "django_otp.middleware.OTPMiddleware",
-    "modoboa.core.middleware.TwoFAMiddleware",
     "django.contrib.messages.middleware.MessageMiddleware",
     "django.middleware.clickjacking.XFrameOptionsMiddleware",
     "modoboa.core.middleware.LocalConfigMiddleware",