Parcourir la source

Enable the Vue 3 front-end

Bubka il y a 1 an
Parent
commit
9efb54adf4
100 fichiers modifiés avec 961 ajouts et 4328 suppressions
  1. 0 0
      resources/js/App.vue
  2. 0 58
      resources/js/api.js
  3. 85 55
      resources/js/app.js
  4. 0 0
      resources/js/assets/app.scss
  5. 0 0
      resources/js/components/ActionButtons.vue
  6. 0 41
      resources/js/components/App.vue
  7. 0 42
      resources/js/components/Button.vue
  8. 0 0
      resources/js/components/DestinationGroupSelector.vue
  9. 48 38
      resources/js/components/Dots.vue
  10. 0 23
      resources/js/components/FieldError.vue
  11. 0 58
      resources/js/components/Footer.vue
  12. 0 317
      resources/js/components/Form.js
  13. 0 64
      resources/js/components/FormButtons.vue
  14. 0 57
      resources/js/components/FormCheckbox.vue
  15. 0 81
      resources/js/components/FormField.vue
  16. 0 132
      resources/js/components/FormPasswordField.vue
  17. 0 54
      resources/js/components/FormSelect.vue
  18. 0 43
      resources/js/components/FormSwitch.vue
  19. 0 95
      resources/js/components/FormToggle.vue
  20. 0 31
      resources/js/components/FormWrapper.vue
  21. 0 0
      resources/js/components/GroupSwitch.vue
  22. 63 51
      resources/js/components/Kicker.vue
  23. 0 63
      resources/js/components/Modal.vue
  24. 0 0
      resources/js/components/OtpDisplay.vue
  25. 0 294
      resources/js/components/OtpDisplayer.vue
  26. 0 0
      resources/js/components/QrContentDisplay.vue
  27. 0 19
      resources/js/components/ResponsiveWidthWrapper.vue
  28. 0 0
      resources/js/components/SearchBox.vue
  29. 0 55
      resources/js/components/SettingTabs.vue
  30. 43 20
      resources/js/components/Spinner.vue
  31. 0 0
      resources/js/components/Toolbar.vue
  32. 115 119
      resources/js/components/TotpLooper.vue
  33. 0 73
      resources/js/components/Twofaccount.vue
  34. 32 30
      resources/js/components/VersionChecker.vue
  35. 0 0
      resources/js/components/formElements/Button.vue
  36. 0 0
      resources/js/components/formElements/ButtonBackCloseCancel.vue
  37. 0 0
      resources/js/components/formElements/FieldError.vue
  38. 0 0
      resources/js/components/formElements/Form.js
  39. 0 0
      resources/js/components/formElements/FormButtons.vue
  40. 0 0
      resources/js/components/formElements/FormCheckbox.vue
  41. 0 0
      resources/js/components/formElements/FormErrors.js
  42. 0 0
      resources/js/components/formElements/FormField.vue
  43. 0 0
      resources/js/components/formElements/FormLockField.vue
  44. 0 0
      resources/js/components/formElements/FormPasswordField.vue
  45. 0 0
      resources/js/components/formElements/FormSelect.vue
  46. 0 0
      resources/js/components/formElements/FormToggle.vue
  47. 0 37
      resources/js/components/index.js
  48. 0 0
      resources/js/composables/helpers.js
  49. 0 0
      resources/js/helpers.js
  50. 0 0
      resources/js/icons.js
  51. 0 14
      resources/js/langs/i18n.js
  52. 0 0
      resources/js/layouts/Footer.vue
  53. 0 0
      resources/js/layouts/FormWrapper.vue
  54. 0 0
      resources/js/layouts/Modal.vue
  55. 0 0
      resources/js/layouts/ResponsiveWidthWrapper.vue
  56. 0 0
      resources/js/layouts/SettingTabs.vue
  57. 0 140
      resources/js/mixins.js
  58. 0 4
      resources/js/packages/clipboard.js
  59. 0 95
      resources/js/packages/fontawesome.js
  60. 0 10
      resources/js/packages/vue-storage.js
  61. 0 0
      resources/js/router/index.js
  62. 0 0
      resources/js/router/middlewarePipeline.js
  63. 0 0
      resources/js/router/middlewares/authGuard.js
  64. 0 0
      resources/js/router/middlewares/noEmptyError.js
  65. 0 0
      resources/js/router/middlewares/noRegistration.js
  66. 0 0
      resources/js/router/middlewares/setReturnTo.js
  67. 0 0
      resources/js/router/middlewares/starter.js
  68. 0 127
      resources/js/routes.js
  69. 0 0
      resources/js/services/appSettingService.js
  70. 0 0
      resources/js/services/authService.js
  71. 0 0
      resources/js/services/groupService.js
  72. 0 0
      resources/js/services/httpClientFactory.js
  73. 0 0
      resources/js/services/systemService.js
  74. 0 0
      resources/js/services/twofaccountService.js
  75. 0 0
      resources/js/services/userService.js
  76. 0 0
      resources/js/services/webauthn/identifyAuthenticationError.js
  77. 0 0
      resources/js/services/webauthn/identifyRegistrationError.js
  78. 0 0
      resources/js/services/webauthn/isValidDomain.js
  79. 0 0
      resources/js/services/webauthn/webauthnAbortService.js
  80. 0 0
      resources/js/services/webauthn/webauthnService.js
  81. 0 0
      resources/js/stores/appSettings.js
  82. 0 0
      resources/js/stores/bus.js
  83. 0 0
      resources/js/stores/groups.js
  84. 0 0
      resources/js/stores/notify.js
  85. 0 0
      resources/js/stores/twofaccounts.js
  86. 0 0
      resources/js/stores/user.js
  87. 95 107
      resources/js/views/About.vue
  88. 0 830
      resources/js/views/Accounts.vue
  89. 0 127
      resources/js/views/Capture.vue
  90. 42 93
      resources/js/views/Error.vue
  91. 0 127
      resources/js/views/Groups.vue
  92. 78 112
      resources/js/views/Start.vue
  93. 0 27
      resources/js/views/auth/Autolock.vue
  94. 146 177
      resources/js/views/auth/Login.vue
  95. 93 140
      resources/js/views/auth/Register.vue
  96. 0 0
      resources/js/views/auth/RequestReset.vue
  97. 0 60
      resources/js/views/auth/password/Request.vue
  98. 58 66
      resources/js/views/auth/password/Reset.vue
  99. 0 55
      resources/js/views/auth/webauthn/Lost.vue
  100. 63 67
      resources/js/views/auth/webauthn/Recover.vue

+ 0 - 0
resources/js_vue3/App.vue → resources/js/App.vue


+ 0 - 58
resources/js/api.js

@@ -1,58 +0,0 @@
-import Vue      from 'vue'
-import axios    from 'axios'
-import VueAxios from 'vue-axios'
-import router   from './routes.js'
-
-Vue.use(VueAxios, axios)
-
-Vue.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
-Vue.axios.defaults.headers.common['Content-Type'] = 'application/json'
-
-if (window.appConfig.subdirectory) {
-    Vue.axios.defaults.baseURL = window.appConfig.subdirectory;
-}
-
-Vue.axios.interceptors.response.use(response => response, error => {
-    
-    // Whether or not the promise must be returned, if unauthenticated is received
-    // we update the auth state of the front-end
-    if ( error.response.status === 401 ) {
-        Vue.$storage.remove('authenticated')
-    }
-
-    // Return the error when we need to handle it at component level
-    if( error.config.hasOwnProperty('returnError') && error.config.returnError === true ) {
-        return Promise.reject(error);
-    }
-
-    // Return the error for form validation at component level
-    if( error.response.status === 422 ) {
-        return Promise.reject(error);
-    }
-
-    // Push to the login view and force the page to refresh to get a fresh CSRF token
-    if ( error.response.status === 401 ) {
-        router.push({ name: 'login', params: { forceRefresh: true } })
-        return new Promise(() => {})
-    }
-
-    if ( error.response.status === 407 ) {
-        router.push({ name: 'genericError', params: { err: error.response, closable: false } })
-        return new Promise(() => {})
-    }
-
-    // we push to a specific or generic error view
-    let routeName = 'genericError'
-
-    // api calls are stateless so when user inactivity is detected
-    // by the backend middleware it cannot logout the user directly
-    // so it returns a 418 response.
-    // We catch the 418 response and push the user to the autolock view
-    if ( error.response.status === 418 ) routeName = 'autolock'
-    
-    if ( error.response.status === 404 ) routeName = '404'
-
-    router.push({ name: routeName, params: { err: error.response } })
-    return new Promise(() => {})
-
-})

+ 85 - 55
resources/js/app.js

@@ -1,65 +1,95 @@
-import Vue              from 'vue'
-import mixins           from './mixins'
-import VueStorage       from './packages/vue-storage'
-import i18n             from './langs/i18n'
-import router           from './routes'
-import api              from './api'
-import FontAwesome      from './packages/fontawesome'
-import Clipboard        from './packages/clipboard'
-import Notifications    from 'vue-notification'
+import '/resources/js/assets/app.scss'; 
 
-import './components'
+import Notifications from '@kyvg/vue3-notification'
+import App from './App.vue'
+import router from './router'
+import FontAwesomeIcon from './icons'
+// import helpers from './helpers'
 
-Vue.use(Notifications)
+const app = createApp(App)
 
-const app = new Vue({
-    el: '#app',
-    data: {
-        appSettings: window.appSettings,
-        appConfig: window.appConfig,
-        userPreferences: window.userPreferences,
-        isDemoApp: window.isDemoApp,
-        isTestingApp: window.isTestingApp,
-        prefersDarkScheme: window.matchMedia('(prefers-color-scheme: dark)').matches,
-        spinner: {
-            active: false,
-            message: 'loading'
-        },
-    },
+// Immutable app properties provided by the laravel blade view
+const $2fauth = {
+    prefix: '2fauth_',
+    config: window.appConfig,
+    version: window.appVersion,
+    isDemoApp: window.isDemoApp,
+    isTestingApp: window.isTestingApp,
+    langs: window.appLocales,
+}
+app.provide('2fauth', readonly($2fauth))
 
-    computed: {
-        showDarkMode: function() {
-            return this.userPreferences.theme == 'dark' ||
-                (this.userPreferences.theme == 'system' && this.prefersDarkScheme)
+// Stores
+const pinia = createPinia()
+pinia.use(({ store }) => {
+    store.$2fauth = $2fauth;
+});
+app.use(pinia)
+
+// Router
+app.use(router)
+
+// Localization
+app.use(i18nVue, {
+    lang: document.documentElement.lang.substring(0, 2),
+    resolve: async lang => {
+        const langs = import.meta.glob('../lang/*.json');
+        if (lang.includes('php_')) {
+            return await langs[`../lang/${lang}.json`]();
         }
-    },
+    }
+})
 
-    mounted () {
-        this.mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)')
-        this.$nextTick(() => {
-            this.mediaQueryList.addEventListener('change', this.setDarkScheme)
-        })
-    },
+// Notifications
+app.use(Notifications)
 
-    beforeDestroy () {
-        this.mediaQueryList.removeEventListener('change', this.setDarkScheme)
-    },
+// Global components registration
+import ResponsiveWidthWrapper from '@/layouts/ResponsiveWidthWrapper.vue'
+import FormWrapper from '@/layouts/FormWrapper.vue'
+import Footer from '@/layouts/Footer.vue'
+import Modal from '@/layouts/Modal.vue'
+import VueButton           from '@/components/formElements/Button.vue'
+import ButtonBackCloseCancel from '@/components/formElements/ButtonBackCloseCancel.vue'
+import FieldError       from '@/components/formElements/FieldError.vue'
+import FormField        from '@/components/formElements/FormField.vue'
+import FormPasswordField        from '@/components/formElements/FormPasswordField.vue'
+import FormSelect       from '@/components/formElements/FormSelect.vue'
+import FormToggle       from '@/components/formElements/FormToggle.vue'
+import FormCheckbox     from '@/components/formElements/FormCheckbox.vue'
+import FormButtons      from '@/components/formElements/FormButtons.vue'
+import Kicker           from '@/components/Kicker.vue'
 
-    methods: {
-        setDarkScheme ({ matches }) {
-            this.prefersDarkScheme = matches
-        },
+app
+    .component('FontAwesomeIcon', FontAwesomeIcon)
+    .component('ResponsiveWidthWrapper', ResponsiveWidthWrapper)
+    .component('FormWrapper', FormWrapper)
+    .component('VueFooter', Footer)
+    .component('Modal', Modal)
+    .component('VueButton', VueButton)
+    .component('ButtonBackCloseCancel', ButtonBackCloseCancel)
+    .component('FieldError', FieldError)
+    .component('FormField', FormField)
+    .component('FormPasswordField', FormPasswordField)
+    .component('FormSelect', FormSelect)
+    .component('FormToggle', FormToggle)
+    .component('FormCheckbox', FormCheckbox)
+    .component('FormButtons', FormButtons)
+    .component('Kicker', Kicker)
 
-        showSpinner(message) {
-            this.spinner.message = message;
-            this.spinner.active = true;
-        },
+// Global error handling
+// import { useNotifyStore } from '@/stores/notify'
+// if (process.env.NODE_ENV != 'development') {
+//     app.config.errorHandler = (err) => {
+//         useNotifyStore().error(err)
+//     }
+// }
 
-        hideSpinner() {
-            this.spinner.active = false;
-            this.spinner.message = 'loading';
-        }
-    },
-    i18n,
-    router,
-});
+// Helpers
+// app.config.globalProperties.$helpers = helpers
+
+// App mounting
+app.mount('#app')
+
+// Theme
+import { useUserStore } from '@/stores/user'
+useUserStore().applyUserPrefs()

+ 0 - 0
resources/js_vue3/assets/app.scss → resources/js/assets/app.scss


+ 0 - 0
resources/js_vue3/components/ActionButtons.vue → resources/js/components/ActionButtons.vue


+ 0 - 41
resources/js/components/App.vue

@@ -1,41 +0,0 @@
-<template>
-    <div>
-        <kicker v-if="kickInactiveUser"></kicker>
-        <div v-if="this.$root.isDemoApp" class="demo has-background-warning has-text-centered is-size-7-mobile">
-            {{ $t('commons.demo_do_not_post_sensitive_data') }}
-        </div>
-        <div v-if="this.$root.isTestingApp" class="demo has-background-warning has-text-centered is-size-7-mobile">
-            {{ $t('commons.testing_do_not_post_sensitive_data') }}
-        </div>
-        <!-- Loading spinner -->
-        <spinner :active="$root.spinner.active" :message="$root.spinner.message"/>
-        <notifications id="vueNotification" role="alert" width="100%" position="top" :duration="4000" :speed="0" :max="1" classes="notification is-radiusless" />
-        <main class="main-section">
-            <router-view></router-view>
-        </main>
-    </div>
-</template>
-
-<script>
-    import Spinner from "./Spinner.vue";
-
-    export default {
-        name: 'App',
-        components: {
-            Spinner
-        },
-
-        data(){
-            return {
-            }
-        },
-
-        computed: {
-
-            kickInactiveUser: function () {
-                return parseInt(this.$root.userPreferences.kickUserAfter) > 0 && this.$route.meta.requiresAuth
-            }
-
-        }
-    }
-</script>

+ 0 - 42
resources/js/components/Button.vue

@@ -1,42 +0,0 @@
-<template>
-    <button 
-        :type="nativeType"
-        :disabled="isLoading || isDisabled"
-        :class="{
-            'button': true,
-            [`${color}`]: true,
-            'is-loading': isLoading,
-        }"
-        v-on:click="$emit('click')">
-        <slot />
-    </button>
-</template>
-
-<script>
-    export default {
-        name: 'VButton',
-
-        props: {
-            color: {
-                type: String,
-                default: 'is-link'
-            },
-
-            nativeType: {
-                type: String,
-                default: 'submit'
-            },
-
-            isLoading: {
-                type: Boolean,
-                default: false
-            },
-
-            isDisabled: {
-                type: Boolean,
-                default: false
-            }
-        }
-    }
-
-</script>

+ 0 - 0
resources/js_vue3/components/DestinationGroupSelector.vue → resources/js/components/DestinationGroupSelector.vue


+ 48 - 38
resources/js/components/Dots.vue

@@ -1,44 +1,54 @@
-<template>
-    <ul class="dots">
-        <li v-for="n in stepCount" :key="n" :data-is-active="n == activeDot ? true : null"></li>
-    </ul>
-</template>
-
-<script>
-    export default {
-        name: 'Dots',
-
-        data() {
-            return {
-                activeDot: 0
-            }
+<script setup>
+    const props = defineProps({
+        stepCount: {
+            type: Number,
+            default: 10
         },
-
-        mounted() {
-            if (this.initialIndex != null) {
-                this.turnOn(this.initialIndex)
-            }
+        initialIndex: {
+            type: Number,
+            default: null
         },
-
-        props: {
-            stepCount: {
-                type: Number,
-                default: 10
-            },
-            initialIndex: {
-                type: Number,
-                default: null
-            },
+        period: { // Used only to identify the dots component in Accounts.vue
+            type: Number,
+            default: null
         },
+    })
 
-        methods: {
+    const activeDot = ref(0)
+    const isOff = computed(() => {
+        return activeDot.value == -1
+    })
 
-            /**
-             * 
-             */
-            turnOn: function(index) {
-                this.activeDot = index < 10 ? index + 1 : 1
-            },
-        },
+    /**
+     * Turns On dots
+     */
+    function turnOn(index) {
+        activeDot.value = index < props.stepCount ? index + 1 : 1
     }
-</script>
+
+    /**
+     * Turns Off all dots
+     */
+    function turnOff() {
+        activeDot.value = -1
+    }
+
+    onMounted(() => {
+        if (! isNaN(props.initialIndex)) {
+            turnOn(props.initialIndex)
+        }
+    })
+
+    defineExpose({
+        turnOn,
+        turnOff,
+        props
+    })
+
+</script>
+
+<template>
+    <ul class="dots" :class="{'off' : isOff}">
+        <li v-for="n in stepCount" :key="n" :data-is-active="n == activeDot ? true : null"></li>
+    </ul>
+</template>

+ 0 - 23
resources/js/components/FieldError.vue

@@ -1,23 +0,0 @@
-<template>
-    <div role="alert">
-        <p :id="'valError' + field[0].toUpperCase() + field.toLowerCase().slice(1)" class="help is-danger" v-if="form.errors.has(field)" v-html="form.errors.get(field)" />
-    </div>
-</template>
-
-<script>
-export default {
-    name: 'field-error',
-
-    props: {
-        form: {
-            type: Object,
-            required: true
-        },
-
-        field: {
-            type: String,
-            required: true
-        }
-    }
-}
-</script>

+ 0 - 58
resources/js/components/Footer.vue

@@ -1,58 +0,0 @@
-<template>
-    <footer>
-        <div class="columns is-gapless" v-if="showButtons">
-            <div class="column has-text-centered">
-                <div class="field is-grouped">
-                    <slot></slot>
-                </div>
-            </div>
-        </div>
-        <div v-if="editMode" class="content has-text-centered">
-            <button id="lnkExitEdit" class="button is-ghost is-like-text" @click="exitEdit">{{ $t('commons.done') }}</button>
-        </div>
-        <div v-else class="content has-text-centered">
-            <div v-if="$route.meta.showAbout === true" class="is-size-6">
-                <router-link id="lnkAbout" :to="{ name: 'about' }" class="has-text-grey">
-                    2FAuth – <span class="has-text-weight-bold">v{{ appVersion }}</span>
-                </router-link>
-            </div>
-            <div v-else>
-                <router-link id="lnkSettings"  :to="{ name: 'settings.options' }" class="has-text-grey">
-                    {{ $t('settings.settings') }}<span v-if="$root.appSettings.latestRelease && $root.appSettings.checkForUpdate" class="release-flag"></span>
-                </router-link>
-                <span v-if="!this.$root.appConfig.proxyAuth || (this.$root.appConfig.proxyAuth && this.$root.appConfig.proxyLogoutUrl)">
-                    - <button id="lnkSignOut" class="button is-text is-like-text has-text-grey" @click="logout">{{ $t('auth.sign_out') }}</button>
-                </span>
-            </div>
-        </div>
-    </footer>
-</template>
-
-<script>
-    export default {
-        name: 'VueFooter',
-
-        data(){
-            return {
-            }
-        },
-
-        props: {
-            showButtons: true,
-            editMode: false,
-        },
-
-        methods: {
-            logout() {
-                if(confirm(this.$t('auth.confirm.logout'))) {
-
-                    this.appLogout()
-                }
-            },
-
-            exitEdit() {
-                this.$emit('exit-edit')
-            }
-        }
-    };
-</script>

+ 0 - 317
resources/js/components/Form.js

@@ -1,317 +0,0 @@
-import Vue      from 'vue'
-import Errors   from './FormErrors'
-
-class Form {
-    /**
-     * Create a new form instance.
-     *
-     * @param {Object} data
-     */
-    constructor (data = {}) {
-        this.isBusy = false
-        this.isDisabled = false
-        // this.successful = false
-        this.errors = new Errors()
-        this.originalData = this.deepCopy(data)
-
-        Object.assign(this, data)
-    }
-
-    /**
-     * Fill form data.
-     *
-     * @param {Object} data
-     */
-    fill (data) {
-        this.keys().forEach(key => {
-            this[key] = data[key]
-        })
-    }
-
-    /**
-     * Update original form data.
-     */
-    setOriginal () {
-      Object.keys(this)
-        .filter(key => !Form.ignore.includes(key))
-        .forEach(key => {
-            this.originalData[key] = this.deepCopy(this[key])
-        })
-    }
-
-    /**
-     * Fill form data.
-     *
-     * @param {Object} data
-     */
-    fillWithKeyValueObject (data) {
-        this.keys().forEach(key => {
-            const keyValueObject = data.find(s => s.key === key.toString())
-            if(keyValueObject != undefined) {
-                this[key] = keyValueObject.value
-            }
-        })
-    }
-
-    /**
-     * Get the form data.
-     *
-     * @return {Object}
-     */
-    data () {
-        return this.keys().reduce((data, key) => (
-            { ...data, [key]: this[key] }
-        ), {})
-    }
-
-    /**
-     * Get the form data keys.
-     *
-     * @return {Array}
-     */
-    keys () {
-        return Object.keys(this)
-            .filter(key => !Form.ignore.includes(key))
-    }
-
-    /**
-     * Start processing the form.
-     */
-    startProcessing () {
-        this.errors.clear()
-        this.isBusy = true
-        // this.successful = false
-    }
-
-    /**
-     * Finish processing the form.
-     */
-    finishProcessing () {
-        this.isBusy = false
-        // this.successful = true
-    }
-
-    /**
-     * Clear the form errors.
-     */
-    clear () {
-        this.errors.clear()
-        // this.successful = false
-    }
-
-    /**
-     * Reset the form fields.
-     */
-    reset () {
-      Object.keys(this)
-        .filter(key => !Form.ignore.includes(key))
-        .forEach(key => {
-          this[key] = this.deepCopy(this.originalData[key])
-        })
-    }
-
-    /**
-     * Submit the form via a GET request.
-     *
-     * @param  {String} url
-     * @param  {Object} config (axios config)
-     * @return {Promise}
-     */
-    get (url, config = {}) {
-        return this.submit('get', url, config)
-    }
-
-    /**
-     * Submit the form via a POST request.
-     *
-     * @param  {String} url
-     * @param  {Object} config (axios config)
-     * @return {Promise}
-     */
-    post (url, config = {}) {
-        return this.submit('post', url, config)
-    }
-
-    /**
-     * Submit the form via a PATCH request.
-     *
-     * @param  {String} url
-     * @param  {Object} config (axios config)
-     * @return {Promise}
-     */
-    patch (url, config = {}) {
-        return this.submit('patch', url, config)
-    }
-
-    /**
-     * Submit the form via a PUT request.
-     *
-     * @param  {String} url
-     * @param  {Object} config (axios config)
-     * @return {Promise}
-     */
-    put (url, config = {}) {
-        return this.submit('put', url, config)
-    }
-
-    /**
-     * Submit the form via a DELETE request.
-     *
-     * @param  {String} url
-     * @param  {Object} config (axios config)
-     * @return {Promise}
-     */
-    delete (url, config = {}) {
-        return this.submit('delete', url, config)
-    }
-
-    /**
-     * Submit the form data via an HTTP request.
-     *
-     * @param  {String} method (get, post, patch, put)
-     * @param  {String} url
-     * @param  {Object} config (axios config)
-     * @return {Promise}
-     */
-    submit (method, url, config = {}) {
-        this.startProcessing()
-
-        const data = method === 'get'
-            ? { params: this.data() }
-            : this.data()
-
-        return new Promise((resolve, reject) => {
-            // (Form.axios || axios).request({ url: this.route(url), method, data, ...config })
-            Vue.axios.request({ url: this.route(url), method, data, ...config })
-                .then(response => {
-                    this.finishProcessing()
-
-                    resolve(response)
-                })
-                .catch(error => {
-                    this.isBusy = false
-
-                    if (error.response) {
-                        this.errors.set(this.extractErrors(error.response))
-                    }
-
-                    reject(error)
-                })
-        })
-    }
-
-    /**
-     * Submit the form data via an HTTP request.
-     *
-     * @param  {String} method (get, post, patch, put)
-     * @param  {String} url
-     * @param  {Object} config (axios config)
-     * @return {Promise}
-     */
-    upload (url, formData, config = {}) {
-        this.startProcessing()
-
-        return new Promise((resolve, reject) => {
-            // (Form.axios || axios).request({ url: this.route(url), method, data, ...config })
-            Vue.axios.request({ url: this.route(url), method: 'post', data: formData, header: {'Content-Type' : 'multipart/form-data'}, ...config })
-                .then(response => {
-                    this.finishProcessing()
-
-                    resolve(response)
-                })
-                .catch(error => {
-                    this.isBusy = false
-
-                    if (error.response) {
-                        this.errors.set(this.extractErrors(error.response))
-                    }
-
-                    reject(error)
-                })
-        })
-    }
-
-    /**
-     * Extract the errors from the response object.
-     *
-     * @param  {Object} response
-     * @return {Object}
-     */
-    extractErrors (response) {
-        if (!response.data || typeof response.data !== 'object') {
-            return { error: Form.errorMessage }
-        }
-
-        if (response.data.errors) {
-            return { ...response.data.errors }
-        }
-
-        if (response.data.message) {
-            return { error: response.data.message }
-        }
-
-        return { ...response.data }
-    }
-
-    /**
-     * Get a named route.
-     *
-     * @param  {String} name
-     * @return {Object} parameters
-     * @return {String}
-     */
-    route (name, parameters = {}) {
-        let url = name
-
-        if (Form.routes.hasOwnProperty(name)) {
-            url = decodeURI(Form.routes[name])
-        }
-
-        if (typeof parameters !== 'object') {
-            parameters = { id: parameters }
-        }
-
-        Object.keys(parameters).forEach(key => {
-            url = url.replace(`{${key}}`, parameters[key])
-        })
-
-        return url
-    }
-
-    /**
-     * Clear errors on keydown.
-     *
-     * @param {KeyboardEvent} event
-     */
-    onKeydown (event) {
-        if (event.target.name) {
-            this.errors.clear(event.target.name)
-        }
-    }
-
-    /**
-     * Deep copy the given object.
-     *
-     * @param  {Object} obj
-     * @return {Object}
-     */
-    deepCopy (obj) {
-        if (obj === null || typeof obj !== 'object') {
-            return obj
-        }
-    
-        const copy = Array.isArray(obj) ? [] : {}
-    
-        Object.keys(obj).forEach(key => {
-            copy[key] = this.deepCopy(obj[key])
-        })
-    
-        return copy
-    }
-}
-
-Form.routes = {}
-Form.errorMessage = 'Something went wrong. Please try again.'
-Form.ignore = ['isBusy', 'isDisabled', 'errors', 'originalData']
-
-export default Form

+ 0 - 64
resources/js/components/FormButtons.vue

@@ -1,64 +0,0 @@
-<template>
-    <div class="field is-grouped">
-        <div class="control">
-            <v-button :id="submitId" :color="color" :isLoading="isBusy" :disabled="isDisabled" >{{ caption }}</v-button>
-        </div>
-        <div class="control" v-if="showCancelButton">
-            <router-link :id="cancelId" :to="{ name: cancelLandingView }" class="button is-text">{{ $t('commons.cancel') }}</router-link>
-        </div>
-    </div>
-</template>
-
-<script>
-    export default {
-        name: 'FormButtons',
-        
-        data() {
-            return {
-
-            }
-        },
-
-        props: {
-            showCancelButton: {
-                type: Boolean,
-                default: false
-            },
-
-            isBusy: {
-                type: Boolean,
-                default: false
-            },
-
-            isDisabled: {
-                type: Boolean,
-                default: false
-            },
-
-            caption: {
-                type: String,
-                default: 'Submit'
-            },
-
-            cancelLandingView: {
-                type: String,
-                default: ''
-            },
-
-            color: {
-                type: String,
-                default: 'is-link'
-            },
-
-            submitId: {
-                type: String,
-                default: 'btnSubmit'
-            },
-
-            cancelId: {
-                type: String,
-                default: 'btnCancel'
-            },
-        }
-    }
-</script>

+ 0 - 57
resources/js/components/FormCheckbox.vue

@@ -1,57 +0,0 @@
-<template>
-    <div class="field">
-        <input :id="fieldName" type="checkbox" :name="fieldName" class="is-checkradio is-info" v-model="form[fieldName]" v-on:change="$emit(fieldName, form[fieldName])" v-bind="$attrs">
-        <label tabindex="0" :for="fieldName" class="label" :class="labelClass" v-html="label" v-on:keypress.space.prevent="setCheckbox"></label>
-        <p class="help" v-html="help" v-if="help"></p>
-    </div>
-</template>
-
-<script>
-    export default {
-        name: 'FormCheckbox',
-        inheritAttrs: false,
-        
-        data() {
-            return {
-
-            }
-        },
-
-        props: {
-            label: {
-                type: String,
-                default: ''
-            },
-
-            labelClass: {
-                type: String,
-                default: ''
-            },
-
-            fieldName: {
-                type: String,
-                default: '',
-                required: true
-            },
-
-            form: {
-                type: Object,
-                required: true
-            },
-
-            help: {
-                type: String,
-                default: ''
-            },
-        },
-
-        methods: {
-            setCheckbox(event) {
-                if (this.$attrs['disabled'] == undefined) {
-                    this.form[this.fieldName] = !this.form[this.fieldName]
-                    this.$emit(this.fieldName, this.form[this.fieldName])
-                }
-            }
-        }
-    }
-</script>

+ 0 - 81
resources/js/components/FormField.vue

@@ -1,81 +0,0 @@
-<template>
-    <div class="field" :class="{ 'pt-3' : hasOffset }">
-        <label :for="this.inputId(inputType,fieldName)" class="label" v-html="label"></label>
-        <div class="control">
-            <input 
-                :disabled="isDisabled" 
-                :id="this.inputId(inputType,fieldName)" 
-                :type="inputType" 
-                class="input" 
-                v-model="form[fieldName]" 
-                :placeholder="placeholder" 
-                v-bind="$attrs" 
-                v-on:change="$emit('field-changed', form[fieldName])"
-                :maxlength="this.maxLength" 
-            />
-        </div>
-        <field-error :form="form" :field="fieldName" />
-        <p class="help" v-html="help" v-if="help"></p>
-    </div> 
-</template>
-
-<script>
-    export default {
-        name: 'FormField',
-        inheritAttrs: false,
-        
-        data() {
-            return {
-
-            }
-        },
-
-        props: {
-            label: {
-                type: String,
-                default: ''
-            },
-
-            fieldName: {
-                type: String,
-                default: '',
-                required: true
-            },
-
-            inputType: {
-                type: String,
-                default: 'text'
-            },
-
-            form: {
-                type: Object,
-                required: true
-            },
-
-            placeholder: {
-                type: String,
-                default: ''
-            },
-
-            help: {
-                type: String,
-                default: ''
-            },
-
-            hasOffset: {
-                type: Boolean,
-                default: false
-            },
-
-            isDisabled: {
-                type: Boolean,
-                default: false
-            },
-
-            maxLength: {
-                type: Number,
-                default: null
-            }
-        }
-    }
-</script>

+ 0 - 132
resources/js/components/FormPasswordField.vue

@@ -1,132 +0,0 @@
-<template>
-    <div class="field" :class="{ 'pt-3' : hasOffset }">
-        <label :for="this.inputId('password',fieldName)" class="label" v-html="label"></label>
-        <div class="control has-icons-right">
-            <input
-                :disabled="isDisabled"
-                :id="this.inputId('password',fieldName)"
-                :type="currentType" 
-                class="input" 
-                v-model="form[fieldName]" 
-                :placeholder="placeholder" 
-                v-bind="$attrs" 
-                v-on:change="$emit('field-changed', form[fieldName])"
-                v-on:keyup="checkCapsLock"
-            />
-            <span v-if="currentType == 'password'" role="button" id="btnTogglePassword" tabindex="0" class="icon is-small is-right is-clickable" @keyup.enter="setFieldType('text')" @click="setFieldType('text')" :title="$t('auth.forms.reveal_password')">
-                <font-awesome-icon :icon="['fas', 'eye-slash']" />
-            </span>
-            <span v-else role="button" id="btnTogglePassword" tabindex="0" class="icon is-small is-right is-clickable" @keyup.enter="setFieldType('password')" @click="setFieldType('password')" :title="$t('auth.forms.hide_password')">
-                <font-awesome-icon :icon="['fas', 'eye']" />
-            </span>
-        </div>
-        <p class="help is-warning" v-if="hasCapsLockOn" v-html="$t('auth.forms.caps_lock_is_on')" />
-        <field-error :form="form" :field="fieldName" />
-        <p class="help" v-html="help" v-if="help"></p>
-        <div v-if="showRules" class="columns is-mobile is-size-7 mt-0">
-            <div class="column is-one-third">
-                <span class="has-text-weight-semibold">{{ $t("auth.forms.mandatory_rules") }}</span><br />
-                <span class="is-underscored" id="valPwdIsLongEnough" :class="{'is-dot' : IsLongEnough}"></span>{{ $t('auth.forms.is_long_enough') }}<br/>
-            </div>
-            <div class="column">
-                <span class="has-text-weight-semibold">{{ $t("auth.forms.optional_rules_you_should_follow") }}</span><br />
-                <span class="is-underscored" id="valPwdHasLowerCase" :class="{'is-dot' : hasLowerCase}"></span>{{ $t('auth.forms.has_lower_case') }}<br/>
-                <span class="is-underscored" id="valPwdHasUpperCase" :class="{'is-dot' : hasUpperCase}"></span>{{ $t('auth.forms.has_upper_case') }}<br/>
-                <span class="is-underscored" id="valPwdHasSpecialChar" :class="{'is-dot' : hasSpecialChar}"></span>{{ $t('auth.forms.has_special_char') }}<br/>
-                <span class="is-underscored" id="valPwdHasNumber" :class="{'is-dot' : hasNumber}"></span>{{ $t('auth.forms.has_number') }}
-            </div>
-        </div>
-    </div> 
-</template>
-
-<script>
-    export default {
-        name: 'FormPasswordField',
-        inheritAttrs: false,
-        
-        data() {
-            return {
-                currentType: this.inputType,
-                hasCapsLockOn: false,
-            }
-        },
-
-        computed: {
-            hasLowerCase() {
-                return /[a-z]/.test(this.form[this.fieldName])
-            },
-            hasUpperCase() {
-                return /[A-Z]/.test(this.form[this.fieldName])
-            },
-            hasNumber() {
-                return /[0-9]/.test(this.form[this.fieldName])
-            },
-            hasSpecialChar() {
-                return /[^A-Za-z0-9]/.test(this.form[this.fieldName])
-            },
-            IsLongEnough() {
-                return this.form[this.fieldName].length >= 8
-            },
-        },
-
-        props: {
-            label: {
-                type: String,
-                default: ''
-            },
-
-            fieldName: {
-                type: String,
-                default: '',
-                required: true
-            },
-
-            inputType: {
-                type: String,
-                default: 'password'
-            },
-
-            form: {
-                type: Object,
-                required: true
-            },
-
-            placeholder: {
-                type: String,
-                default: ''
-            },
-
-            help: {
-                type: String,
-                default: ''
-            },
-
-            hasOffset: {
-                type: Boolean,
-                default: false
-            },
-
-            isDisabled: {
-                type: Boolean,
-                default: false
-            },
-
-            showRules: {
-                type: Boolean,
-                default: false
-            },
-        },
-
-        methods: {
-            checkCapsLock(event) {
-                this.hasCapsLockOn = event.getModifierState('CapsLock') ? true : false
-            },
-
-            setFieldType(event) {
-                if (this.currentType != event) {
-                    this.currentType = event
-                }
-            }
-        },
-    }
-</script>

+ 0 - 54
resources/js/components/FormSelect.vue

@@ -1,54 +0,0 @@
-<template>
-    <div class="field">
-        <label class="label" v-html="label"></label>
-        <div class="control">
-            <div class="select">
-                <select v-model="form[fieldName]" v-on:change="$emit(fieldName, form[fieldName])">
-                    <option v-for="option in options" :value="option.value">{{ option.text }}</option>
-                </select>
-            </div>
-        </div>
-        <field-error :form="form" :field="fieldName" />
-        <p class="help" v-html="help" v-if="help"></p>
-    </div>
-</template>
-
-<script>
-    export default {
-        name: 'FormSelect',
-        
-        data() {
-            return {
-
-            }
-        },
-
-        props: {
-            label: {
-                type: String,
-                default: ''
-            },
-
-            fieldName: {
-                type: String,
-                default: '',
-                required: true
-            },
-
-            options: {
-                type: Array,
-                required: true
-            },
-
-            form: {
-                type: Object,
-                required: true
-            },
-
-            help: {
-                type: String,
-                default: ''
-            },
-        }
-    }
-</script>

+ 0 - 43
resources/js/components/FormSwitch.vue

@@ -1,43 +0,0 @@
-<template>
-    <div class="field">
-        <label :for="fieldName" class="label" v-html="label"></label>
-        <input :id="fieldName" type="checkbox" :name="fieldName" class="switch is-thin is-info" v-model="form[fieldName]">
-        <label :for="fieldName" class="label"></label>
-        <p class="help" v-html="help" v-if="help"></p>
-    </div>
-</template>
-
-<script>
-    export default {
-        name: 'FormSwitch',
-        
-        data() {
-            return {
-
-            }
-        },
-
-        props: {
-            label: {
-                type: String,
-                default: ''
-            },
-
-            fieldName: {
-                type: String,
-                default: '',
-                required: true
-            },
-
-            form: {
-                type: Object,
-                required: true
-            },
-
-            help: {
-                type: String,
-                default: ''
-            },
-        }
-    }
-</script>

+ 0 - 95
resources/js/components/FormToggle.vue

@@ -1,95 +0,0 @@
-<template>
-    <div class="field" :class="{ 'pt-3' : hasOffset }" role="radiogroup" :aria-labelledby="inputId('label', fieldName)">
-        <label v-if="label" :id="inputId('label', fieldName)" class="label" v-html="label"></label>
-        <div class="is-toggle buttons">
-            <button 
-                :id="inputId('button',fieldName + choice.value)"
-                role="radio" 
-                type="button"
-                class="button" 
-                :aria-checked="form[fieldName] === choice.value"
-                :disabled="isDisabled" 
-                v-for="(choice, index) in choices" 
-                :key="index" 
-                :class="{
-                    'is-link' : form[fieldName] === choice.value,
-                    'is-dark' : $root.showDarkMode,
-                    'is-multiline' : choice.legend,
-                }" 
-                v-on:click.stop="setRadio(choice.value)" 
-                :title="choice.title ? choice.title : ''"
-            >
-                <input 
-                    :id="inputId(inputType, choice.value)" 
-                    :type="inputType" 
-                    class="is-hidden" 
-                    :checked="form[fieldName] === choice.value" 
-                    :value="choice.value" 
-                    v-model="form[fieldName]" 
-                    :disabled="isDisabled" 
-                />
-                <span v-if="choice.legend" v-html="choice.legend" class="is-block is-size-7"></span>
-                <font-awesome-icon :icon="['fas', choice.icon]" v-if="choice.icon" class="mr-2" /> {{ choice.text }}
-            </button>
-        </div>
-        <field-error :form="form" :field="fieldName" />
-        <p class="help" v-html="help" v-if="help"></p>
-    </div>
-</template>
-
-<script>
-    export default {
-        name: 'FormToggle',
-        
-        data() {
-            return {
-                inputType: 'radio'
-            }
-        },
-
-        props: {
-            label: {
-                type: String,
-                default: ''
-            },
-
-            fieldName: {
-                type: String,
-                default: '',
-                required: true
-            },
-
-            choices: {
-                type: Array,
-                required: true
-            },
-
-            form: {
-                type: Object,
-                required: true
-            },
-
-            help: {
-                type: String,
-                default: ''
-            },
-
-            hasOffset: {
-                type: Boolean,
-                default: false
-            },
-
-            isDisabled: {
-                type: Boolean,
-                default: false
-            }
-        },
-
-        methods: {
-            setRadio(event) {
-                this.form[this.fieldName] = event
-                this.$emit(this.fieldName, this.form[this.fieldName])
-            }
-        }
-    }
-</script>

+ 0 - 31
resources/js/components/FormWrapper.vue

@@ -1,31 +0,0 @@
-<template>
-    <responsive-width-wrapper>
-        <h1 class="title has-text-grey-dark" v-html="title" v-if="title"></h1>
-        <div id="punchline" v-if="punchline" class="block" v-html="punchline"></div>
-        <slot />
-    </responsive-width-wrapper>   
-</template>
-
-<script>
-    export default {
-        name: 'FormWrapper',
-        
-        data() {
-            return {
-
-            }
-        },
-
-        props: {
-            title: {
-                type: String,
-                default: ''
-            },
-
-            punchline: {
-                type: String,
-                default: ''
-            },
-        }
-    }
-</script>

+ 0 - 0
resources/js_vue3/components/GroupSwitch.vue → resources/js/components/GroupSwitch.vue


+ 63 - 51
resources/js/components/Kicker.vue

@@ -1,58 +1,70 @@
-<template>
-
-</template>
-
-<script>
-
-    export default {
-        name: 'Kicker',
-
-        data: function () {
-            return {
-                events: ['click', 'mousedown', 'scroll', 'keypress', 'load'],
-                logoutTimer: null
-            }
-        },
-
-        mounted() {
-
-            this.events.forEach(function (event) {
-                window.addEventListener(event, this.resetTimer)
-            }, this);
-
-            this.setTimer()
-        },
-
-        destroyed() {
-
-            this.events.forEach(function (event) {
-                window.removeEventListener(event, this.resetTimer)
-            }, this);
-
-            clearTimeout(this.logoutTimer)
+<script setup>
+    import { useUserStore } from '@/stores/user'
+
+    const user = useUserStore()
+    const events = ref(['mousedown', 'scroll', 'keypress'])
+    const logoutTimer = ref(null)
+    // const elapsed = ref(0)
+    // const counter = ref(null)
+
+    const props = defineProps({
+        kickAfter: {
+            type: Number,
+            required: true
         },
+    })
 
-        methods: {
-
-            setTimer: function() {
-
-                this.logoutTimer = setTimeout(this.logoutUser, this.$root.userPreferences.kickUserAfter * 60 * 1000)
-            },
-
-            logoutUser: function() {
-
-                clearTimeout(this.logoutTimer)
-
-                this.$router.push({ name: 'autolock' })
-            },
+    watch(
+        () => props.kickAfter,
+        () => {
+            restartTimer()
+        }
+    )
+
+    onMounted(() => {
+        events.value.forEach(function (event) {
+            window.addEventListener(event, restartTimer)
+        }, this)
+
+        startTimer()
+    })
+
+    onUnmounted(() => {
+        events.value.forEach(function (event) {
+            window.removeEventListener(event, restartTimer)
+        }, this)
+
+        stopTimer()
+    })
+
+    function startTimer() {
+        logoutTimer.value = setTimeout(logoutUser, props.kickAfter * 60 * 1000)
+        // counter.value = setInterval(() => {
+        //     elapsed.value += 1
+        //     console.log(elapsed.value + '/' + props.kickAfter * 60)
+        // }, 1000)
+    }
 
-            resetTimer: function() {
+    // Triggers the user logout
+    function logoutUser() {
+        clearTimeout(logoutTimer.value)
+        user.logout({ kicked: true})
+    }
 
-                clearTimeout(this.logoutTimer)
+    // Restarts the timer
+    function restartTimer() {
+        stopTimer()
+        startTimer()
+    }
 
-                this.setTimer()
-            }
-        }
+    // Stops the timer
+    function stopTimer() {
+        clearTimeout(logoutTimer.value)
+        // elapsed.value = 0
+        // clearInterval(counter.value)
     }
+</script>
+
+<template>
 
-</script>
+</template>

+ 0 - 63
resources/js/components/Modal.vue

@@ -1,63 +0,0 @@
-<template>
-    <div class="modal modal-otp" v-bind:class="{ 'is-active': isActive }">
-        <div class="modal-background" @click.stop="closeModal"></div>
-        <div class="modal-content">
-            <section class="section">
-                <div class="columns is-centered">
-                    <div class="column is-three-quarters">
-                        <div class="modal-slot box has-text-centered is-shadowless">
-                            <slot></slot>
-                        </div>
-                    </div>
-                </div>
-            </section>
-        </div>
-        <div v-if="this.showcloseButton" class="fullscreen-footer">
-            <!-- Close button -->
-            <button id="btnClose" ref="closeModalButton" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" @click.stop="closeModal">
-                {{ $t('commons.close') }}
-            </button>
-        </div>
-    </div>
-</template>
-
-<script>
-export default {
-    name: 'Modal',
-
-    data(){
-        return {
-            showcloseButton: this.closable,
-        }
-    },
-
-    props: {
-        value: Boolean,
-        closable: {
-            type: Boolean,
-            default: true
-        },
-    },
-
-    computed: {
-        isActive: {
-            get () {
-                return this.value
-            },
-            set (value) {
-                this.$emit('input', value)
-            }
-        }
-    },
-
-    methods: {
-        closeModal: function(event) {
-            if (event) {
-                this.isActive = false
-                this.$notify({ clean: true })
-                this.$parent.$emit('modalClose')
-            }
-        }
-    }
-}
-</script>

+ 0 - 0
resources/js_vue3/components/OtpDisplay.vue → resources/js/components/OtpDisplay.vue


+ 0 - 294
resources/js/components/OtpDisplayer.vue

@@ -1,294 +0,0 @@
-<template>
-    <div>
-        <figure class="image is-64x64" :class="{ 'no-icon': !internal_icon }" style="display: inline-block">
-            <img :src="$root.appConfig.subdirectory + '/storage/icons/' + internal_icon" v-if="internal_icon" :alt="$t('twofaccounts.icon_to_illustrate_the_account')">
-        </figure>
-        <p class="is-size-4 has-ellipsis" :class="$root.showDarkMode ? 'has-text-grey-light' : 'has-text-grey'">{{ internal_service }}</p>
-        <p class="is-size-6 has-ellipsis" :class="$root.showDarkMode ? 'has-text-grey' : 'has-text-grey-light'">{{ internal_account }}</p>
-        <p>
-            <span id="otp" role="log" ref="otp" tabindex="0" class="otp is-size-1 is-clickable px-3" :class="$root.showDarkMode ? 'has-text-white' : 'has-text-grey-dark'" @click="copyOTP(internal_password, true)" @keyup.enter="copyOTP(internal_password, true)" :title="$t('commons.copy_to_clipboard')">
-                {{ displayPwd(this.internal_password) }}
-            </span>
-        </p>
-        <dots v-show="isTimeBased(internal_otp_type)" ref="dots"></dots>
-        <ul v-show="isHMacBased(internal_otp_type)">
-            <li>counter: {{ internal_counter }}</li>
-        </ul>
-        <totp-looper 
-            v-if="this.hasTOTP"
-            :period="internal_period" 
-            :generated_at="internal_generated_at" 
-            :autostart="false" 
-            v-on:loop-ended="getOtp()"
-            v-on:loop-started="turnDotsOn($event)"
-            v-on:stepped-up="turnDotsOn($event)"
-            ref="looper"
-        ></totp-looper>
-    </div>
-</template>
-
-<script>
-    import TotpLooper from './TotpLooper'
-    import Dots from './Dots'
-
-    export default {
-        name: 'OtpDisplayer',
-
-        data() {
-            return {
-                internal_id: null,
-                internal_otp_type: '',
-                internal_account: '',
-                internal_service: '',
-                internal_icon: '',
-                internal_secret: null,
-                internal_digits: null,
-                internal_algorithm: null,
-                internal_period: null,
-                internal_counter: null,
-                internal_password: '',
-                internal_uri: '',
-                internal_generated_at: null,
-                hasTOTP: false
-            }
-        },
-
-        props: {
-            otp_type : String,
-            account : String,
-            service : String,
-            icon : String,
-            secret : String,
-            digits : Number,
-            algorithm : String,
-            period : null,
-            counter : null,
-            image : String,
-            qrcode : null,
-            uri : String
-        },
-
-        computed: {
-
-        },
-
-        components: {
-            TotpLooper,
-            Dots,
-        },
-
-        mounted: function() {
-            
-        },
-
-        methods: {
-
-            /**
-             * 
-             */
-            turnDotsOn(stepIndex) {
-                this.$refs.dots.turnOn(stepIndex)
-            },
-
-            copyOTP (otp, permit_closing) {
-                // see https://web.dev/async-clipboard/ for future Clipboard API usage.
-                // The API should allow to copy the password on each trip without user interaction.
-
-                // For now too many browsers don't support the clipboard-write permission
-                // (see https://developer.mozilla.org/en-US/docs/Web/API/Permissions#browser_support)
-
-                const rawOTP = otp.replace(/ /g, '')
-                const success = this.$clipboard(rawOTP)
-
-                if (success == true) {
-                    if(this.$root.userPreferences.kickUserAfter == -1) {
-                        this.appLogout()
-                    }
-                    else if(this.$root.userPreferences.closeOtpOnCopy && (permit_closing || false) === true) {
-                        this.$parent.isActive = false
-                        this.clearOTP()
-                    }
-
-                    this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
-                }
-            },
-
-            isTimeBased: function(otp_type) {
-                return (otp_type === 'totp' || otp_type === 'steamtotp')
-            },
-
-            isHMacBased: function(otp_type) {
-                return otp_type === 'hotp'
-            },
-
-            async show(id) {
-
-                // 3 possible cases :
-                //   - Trigger when user ask for an otp of an existing account: the ID is provided so we fetch the account data
-                //     from db but without the uri.
-                //     This prevent the uri (a sensitive data) to transit via http request unnecessarily. In this
-                //     case this.otp_type is sent by the backend.
-                //   - Trigger when user use the Quick Uploader and preview the account: No ID but we have an URI.
-                //   - Trigger when user use the Advanced form and preview the account: We should have all OTP parameter
-                //     to obtain an otp, including Secret and otp_type which are required
-
-                this.internal_otp_type = this.otp_type
-                this.internal_account = this.account
-                this.internal_service = this.service
-                this.internal_icon = this.icon
-                this.internal_secret = this.secret
-                this.internal_digits = this.digits
-                this.internal_algorithm = this.algorithm
-                this.internal_period = this.period
-                this.internal_counter = this.counter
-
-                if( id ) {
-
-                    this.internal_id = id
-                    const { data } = await this.axios.get('api/v1/twofaccounts/' + this.internal_id)
-
-                    this.internal_service = data.service
-                    this.internal_account = data.account
-                    this.internal_icon = data.icon
-                    this.internal_otp_type = data.otp_type
-
-                    if( this.isHMacBased(data.otp_type) && data.counter ) {
-                        this.internal_counter = data.counter
-                    }
-                }
-
-                // We force the otp_type to be based on the uri
-                if( this.uri ) {
-                    this.internal_uri = this.uri
-                    this.internal_otp_type = this.uri.slice(0, 15 ).toLowerCase() === "otpauth://totp/" ? 'totp' : 'hotp';
-                }
-
-                if( this.internal_id || this.uri || this.secret ) { // minimun required vars to get an otp from the backend
-                    try {
-                        if( this.isTimeBased(this.internal_otp_type) || this.isHMacBased(this.internal_otp_type)) {
-                            await this.getOtp()
-                        }
-                        else this.$router.push({ name: 'genericError', params: { err: this.$t('errors.not_a_supported_otp_type') } });
-
-                        this.$parent.isActive = true
-                        this.focusOnOTP()
-                    }
-                    catch(error) {
-                        this.clearOTP()
-                    }
-                    finally {
-                        this.$root.hideSpinner();
-                    }
-                } else  {
-                    this.$root.hideSpinner();
-                }
-            },
-
-            /**
-             * 
-             */
-            getOtp: async function() {
-
-                await this.axios(this.getOtpRequest()).then(response => {
-
-                    let otp = response.data
-
-                    this.internal_password = otp.password
-
-                    if(this.$root.userPreferences.copyOtpOnDisplay) {
-                        this.copyOTP(otp.password)
-                    }
-
-                    if (this.isTimeBased(otp.otp_type)) {
-                        this.internal_generated_at = otp.generated_at
-                        this.internal_period = otp.period
-                        this.hasTOTP = true
-
-                        this.$nextTick(() => {
-                            this.$refs.looper.startLoop()
-                        })
-                    }
-                    else if (this.isHMacBased(otp.otp_type)) {
-
-                        this.internal_counter = otp.counter
-
-                        // returned counter & uri are incremented
-                        this.$emit('increment-hotp', { nextHotpCounter: otp.counter, nextUri: otp.uri })
-                    }
-                })
-                .catch(error => {
-                    if (error.response.status === 422) {
-                        this.$emit('validation-error', error.response)
-                    }
-                    throw error
-                })
-            },
-
-            /**
-             * 
-             */
-            getOtpRequest() {
-
-                if(this.internal_id) {
-                    return {
-                        method: 'get',
-                        url: '/api/v1/twofaccounts/' + this.internal_id + '/otp'
-                    }
-                }
-                else if(this.internal_uri) {
-                    return {
-                        method: 'post',
-                        url: '/api/v1/twofaccounts/otp',
-                        data: {
-                            uri: this.internal_uri
-                        }
-                    }
-                }
-                else {
-                    return {
-                        method: 'post',
-                        url: '/api/v1/twofaccounts/otp',
-                        data: {
-                            service     : this.internal_service,
-                            account     : this.internal_account,
-                            icon        : this.internal_icon,
-                            otp_type    : this.internal_otp_type,
-                            secret      : this.internal_secret,
-                            digits      : this.internal_digits,
-                            algorithm   : this.internal_algorithm,
-                            period      : this.internal_period,
-                            counter     : this.internal_counter,
-                        }
-                    }
-                }
-            },
-
-            /**
-             * 
-             */
-            clearOTP: function() {
-                
-                this.internal_id = this.internal_counter = this.internal_generated_at = null
-                this.internal_service = this.internal_account = this.internal_icon = this.internal_otp_type = this.internal_secret = ''
-                this.internal_password = '... ...'
-                this.hasTOTP = false
-
-                this.$refs.looper?.clearLooper();
-            },
-
-            /**
-             * 
-             */
-            focusOnOTP() {
-                this.$nextTick(() => {
-                    this.$refs.otp.focus()
-                })
-            }
-
-        },
-
-        beforeDestroy () {
-            
-        }
-    }
-</script>

+ 0 - 0
resources/js_vue3/components/QrContentDisplay.vue → resources/js/components/QrContentDisplay.vue


+ 0 - 19
resources/js/components/ResponsiveWidthWrapper.vue

@@ -1,19 +0,0 @@
-<template>
-    <div class="columns is-centered">
-        <div class="form-column column is-two-thirds-tablet is-half-desktop is-half-widescreen is-one-third-fullhd">
-            <slot />
-        </div>
-    </div>    
-</template>
-
-<script>
-    export default {
-        name: 'ResponsiveWidthWrapper',
-        
-        data() {
-            return {
-
-            }
-        },
-    }
-</script>

+ 0 - 0
resources/js_vue3/components/SearchBox.vue → resources/js/components/SearchBox.vue


+ 0 - 55
resources/js/components/SettingTabs.vue

@@ -1,55 +0,0 @@
-<template>
-    <div class="options-header">
-        <responsive-width-wrapper>
-            <div class="tabs is-centered is-fullwidth">
-                <ul>
-                    <li v-for="tab in tabs" :key="tab.view" :class="{ 'is-active': tab.view === activeTab }">
-                        <router-link :id="tab.id" :to="{ name: tab.view, params: {returnTo: $route.params.returnTo} }">{{ tab.name }}</router-link>
-                    </li>
-                </ul>
-            </div>
-        </responsive-width-wrapper>
-    </div>
-</template>
-
-<script>
-
-    export default {
-        name: 'SettingTabs',
-
-        data(){
-            return {
-                tabs: [
-                	{
-                		'name' : this.$t('settings.options'),
-                        'view' : 'settings.options',
-                        'id'   : 'lnkTabOptions'
-                	},
-                	{
-                		'name' : this.$t('settings.account'),
-                        'view' : 'settings.account',
-                        'id'   : 'lnkTabAccount'
-                	},
-                	{
-                		'name' : this.$t('settings.oauth'),
-                        'view' : 'settings.oauth.tokens',
-                        'id'   : 'lnkTabOAuth'
-                	},
-                	{
-                		'name' : this.$t('settings.webauthn'),
-                        'view' : 'settings.webauthn.devices',
-                        'id'   : 'lnkTabWebauthn'
-                	},
-            	]
-            }
-        },
-
-        props: {
-            activeTab: {
-                type: String,
-                default: ''
-            },
-        },
-    }
-
-</script>

+ 43 - 20
resources/js/components/Spinner.vue

@@ -1,31 +1,49 @@
+<script setup>
+    const props = defineProps({
+        isVisible: Boolean,
+        type: {
+            type: String,
+            default: 'inline'
+        },
+        message: {
+            type: String,
+            default: 'commons.generating_otp'
+        }
+    })
+</script>
+
 <template>
-    <div v-if="active" class="spinner-container">
-        <div class="spinner-wrapper">
-            <span class="is-size-1 spinner">
-                <font-awesome-icon :icon="['fas', 'spinner']" spin />
+    <div v-if="isVisible">
+        <div v-if="type == 'fullscreen'" class="spinner-container">
+            <div class="spinner-wrapper">
+                <span id="icnSpinnerFull" class="is-size-1 spinner">
+                    <FontAwesomeIcon :icon="['fas', 'spinner']" spin />
+                </span>
+                <span>{{ $t(message) }}</span>
+            </div>
+        </div>
+        <div v-if="type == 'fullscreen-overlay'" class="spinner-overlay-container">
+            <div class="spinner-wrapper">
+                <span id="icnSpinnerFull" class="is-size-1 spinner">
+                    <FontAwesomeIcon :icon="['fas', 'spinner']" spin />
+                </span>
+                <span>{{ $t(message) }}</span>
+            </div>
+        </div>
+        <FontAwesomeIcon v-else-if="type == 'raw'" :icon="['fas', 'spinner']" spin />
+        <div v-else class="has-text-centered mt-6">
+            <span id="icnSpinner" class="is-size-4">
+                <FontAwesomeIcon :icon="['fas', 'spinner']" spin />
             </span>
-            <span>{{ message }}</span>
         </div>
     </div>
 </template>
 
-<script>
-export default {
-    name: 'Spinner',
-    props: {
-        active: {
-            type: Boolean,
-            default: false
-        },
-        message: String,
-    }
-}
-</script>
-
 <style scoped>
-.spinner-container {
+.spinner-container,
+.spinner-overlay-container {
     text-align: center;
-    z-index: 10000;
+    z-index: 100000;
     position: absolute;
     top: 0;
     left: 0;
@@ -35,6 +53,11 @@ export default {
     align-items: center;
     justify-content: center;
 }
+.spinner-container,
+.spinner-overlay-container {
+    top: 25%;
+    height: 50%;
+}
 .spinner {
     display: block;
 }

+ 0 - 0
resources/js_vue3/components/Toolbar.vue → resources/js/components/Toolbar.vue


+ 115 - 119
resources/js/components/TotpLooper.vue

@@ -1,126 +1,122 @@
-<template>
-    <div>
-    </div>
-</template>
-
-<script>
-    export default {
-        name: 'TotpLooper',
-
-        data() {
-            return {
-                generatedAt: null,
-                remainingTimeout: null,
-                initialStepToNextStepTimeout: null,
-                stepToStepInterval: null,
-                stepIndex: null,
-            }
+<script setup>
+    const props = defineProps({
+        step_count: {
+            type: Number,
+            default: 10
         },
-
-        props: {
-            step_count: {
-                type: Number,
-                default: 10
-            },
-            period : Number,
-            generated_at: Number,
-            autostart: {
-                type: Boolean,
-                default: true
-            },
+        period : Number,
+        generated_at: Number,
+        autostart: {
+            type: Boolean,
+            default: true
         },
+    })
+
+    const generatedAt = ref(null)
+    const remainingTimeout = ref(null)
+    const initialStepToNextStepTimeout = ref(null)
+    const stepToStepInterval = ref(null)
+    const stepIndex = ref(null)
+
+    //                              |<----period p----->|
+    //     |                        |                   |
+    //     |------- ··· ------------|--------|----------|---------->
+    //     |                        |        |          |
+    //  unix T0                 Tp.start   Tgen_at    Tp.end
+    //                              |        |          |
+    //  elapsedTimeInCurrentPeriod--|<------>|          |
+    //  (in ms)                     |        |          |
+    //                              ● ● ● ● ●|● ◌ ◌ ◌ ◌ |
+    //                              | |      ||         |
+    //                              | |      |<-------->|--remainingTimeBeforeEndOfPeriod (for remainingTimeout)
+    //    durationBetweenTwoSteps-->|-|<     ||         
+    //   (for stepToStepInterval)   | |     >||<---durationFromInitialToNextStep (for initialStepToNextStepTimeout)
+    //                                        |
+    //                                        |
+    //                                    stepIndex
+
+    const elapsedTimeInCurrentPeriod = computed(() => {
+        return generatedAt.value % props.period
+    })
+
+    const remainingTimeBeforeEndOfPeriod = computed(() => {
+        return props.period - elapsedTimeInCurrentPeriod.value
+    })
+
+    const durationBetweenTwoSteps = computed(() => {
+        return props.period / props.step_count
+    })
+
+    const initialStepIndex = computed(() => {
+        let relativePosition = (elapsedTimeInCurrentPeriod.value * props.step_count) / props.period
+
+        return (Math.floor(relativePosition) + 0)
+    })
+
+    const emit = defineEmits(['loop-started', 'loop-ended', 'stepped-up'])
+
+    /**
+     * Starts looping
+     */
+    const startLoop = (generated_at = null) => {
+        clearLooper()
+        generatedAt.value = generated_at != null ? generated_at : props.generated_at
+
+        emit('loop-started', initialStepIndex.value)
+
+        stepIndex.value = initialStepIndex.value
+
+        // Main timeout that runs until the end of the period
+        remainingTimeout.value = setTimeout(function() {
+            clearLooper()
+            emit('loop-ended')
+        }, remainingTimeBeforeEndOfPeriod.value * 1000);
+
+        // During the remainingTimeout countdown we emit an event every durationBetweenTwoSteps seconds,
+        // except for the first next dot
+        let durationFromInitialToNextStep =  (Math.ceil(elapsedTimeInCurrentPeriod.value / durationBetweenTwoSteps.value) * durationBetweenTwoSteps.value) - elapsedTimeInCurrentPeriod.value
+
+        initialStepToNextStepTimeout.value = setTimeout(function() {
+            if( durationFromInitialToNextStep > 0 ) {
+                stepIndex.value += 1
+                emit('stepped-up', stepIndex.value)
+            }
+            stepToStepInterval.value = setInterval(function() {
+                stepIndex.value += 1
+                emit('stepped-up', stepIndex.value)
+            }, durationBetweenTwoSteps.value * 1000)
+        }, durationFromInitialToNextStep * 1000)
+    }
 
-        computed: {
-
-            //                              |<----period p----->|
-            //     |                        |                   |
-            //     |------- ··· ------------|--------|----------|---------->
-            //     |                        |        |          |
-            //  unix T0                 Tp.start   Tgen_at    Tp.end
-            //                              |        |          |
-            //  elapsedTimeInCurrentPeriod--|<------>|          |
-            //  (in ms)                     |        |          |
-            //                              ● ● ● ● ●|● ◌ ◌ ◌ ◌ |
-            //                              | |      ||         |
-            //                              | |      |<-------->|--remainingTimeBeforeEndOfPeriod (for remainingTimeout)
-            //    durationBetweenTwoSteps-->|-|<     ||         
-            //   (for stepToStepInterval)   | |     >||<---durationFromInitialToNextStep (for initialStepToNextStepTimeout)
-            //                                        |
-            //                                        |
-            //                                    stepIndex
-
-            elapsedTimeInCurrentPeriod() {
-                return this.generatedAt % this.period
-            },
-
-            remainingTimeBeforeEndOfPeriod() {
-                return this.period - this.elapsedTimeInCurrentPeriod
-            },
-
-            durationBetweenTwoSteps() {
-                return this.period / this.step_count
-            },
-
-            initialStepIndex() {
-                let relativePosition = (this.elapsedTimeInCurrentPeriod * this.step_count) / this.period
-
-                return (Math.floor(relativePosition) + 0)
-            },
-        },
+    /**
+     * Resets all timers and internal vars
+     */
+    const clearLooper = () => {
+        clearTimeout(remainingTimeout.value)
+        clearTimeout(initialStepToNextStepTimeout.value)
+        clearInterval(stepToStepInterval.value)
+        stepIndex.value = generatedAt.value = null
+    }
 
-        mounted: function() {
-            if (this.autostart == true) {
-                this.startLoop()
-            }
-        },
+    onMounted(() => {
+        if (props.autostart == true) {
+            startLoop()
+        }
+    })
 
-        methods: {
-
-            startLoop: function() {
-
-                this.clearLooper()
-                this.generatedAt = this.generated_at
-
-                this.$emit('loop-started', this.initialStepIndex)
-
-                this.stepIndex = this.initialStepIndex
-                let self = this;
-
-                // Main timeout that run until the end of the period
-                this.remainingTimeout = setTimeout(function() {
-                    self.clearLooper()
-                    self.$emit('loop-ended')
-                }, this.remainingTimeBeforeEndOfPeriod*1000);
-
-                // During the remainingTimeout countdown we have to emit an event every durationBetweenTwoSteps seconds
-                // except for the first next dot
-                let durationFromInitialToNextStep =  (Math.ceil(this.elapsedTimeInCurrentPeriod / this.durationBetweenTwoSteps) * this.durationBetweenTwoSteps) - this.elapsedTimeInCurrentPeriod
-
-                this.initialStepToNextStepTimeout = setTimeout(function() {
-                    if( durationFromInitialToNextStep > 0 ) {
-                        // self.activateNextStep()
-                        self.stepIndex += 1
-                        self.$emit('stepped-up', self.stepIndex)
-                    }
-                    self.stepToStepInterval = setInterval(function() {
-                        // self.activateNextStep()
-                        self.stepIndex += 1
-                        self.$emit('stepped-up', self.stepIndex)
-                    }, self.durationBetweenTwoSteps*1000)
-                }, durationFromInitialToNextStep*1000)
-            },
-
-            clearLooper: function() {
-                clearTimeout(this.remainingTimeout)
-                clearTimeout(this.initialStepToNextStepTimeout)
-                clearInterval(this.stepToStepInterval)
-                this.stepIndex = this.generatedAt = null
-            },
+    onUnmounted(() => {
+        clearLooper()
+    })
 
-        },
+    defineExpose({
+        startLoop,
+        clearLooper,
+        props
+    })
 
-        beforeDestroy () {
-            this.clearLooper()
-        },
-    }
-</script>
+</script>
+
+<template>
+    <div>
+    </div>
+</template>

+ 0 - 73
resources/js/components/Twofaccount.vue

@@ -1,73 +0,0 @@
-<template>
-    <div :class="[$root.userPreferences.displayMode === 'grid' ? 'tfa-grid' : 'tfa-list']" class="column is-narrow">
-        <div class="tfa-container">
-            <transition name="slideCheckbox">
-                <div class="tfa-cell tfa-checkbox" v-if="isEditMode">
-                    <div class="field">
-                        <input class="is-checkradio is-small" :class="$root.showDarkMode ? 'is-white':'is-info'" :id="'ckb_' + account.id" :value="account.id" type="checkbox" :name="'ckb_' + account.id" @change="select(account.id)">
-                        <label tabindex="0" :for="'ckb_' + account.id" v-on:keypress.space.prevent="select(account.id)"></label>
-                    </div>
-                </div>
-            </transition>
-            <div tabindex="0" class="tfa-cell tfa-content is-size-3 is-size-4-mobile" @click="$emit('show', account)" @keyup.enter="$emit('show', account)" role="button">  
-                <div class="tfa-text has-ellipsis">
-                    <img :src="$root.appConfig.subdirectory + '/storage/icons/' + account.icon" v-if="account.icon && $root.userPreferences.showAccountsIcons" :alt="$t('twofaccounts.icon_for_account_x_at_service_y', {account: account.account, service: account.service})">
-                    {{ displayService(account.service) }}<font-awesome-icon class="has-text-danger is-size-5 ml-2" v-if="$root.appSettings.useEncryption && account.account === $t('errors.indecipherable')" :icon="['fas', 'exclamation-circle']" />
-                    <span class="is-family-primary is-size-6 is-size-7-mobile has-text-grey ">{{ account.account }}</span>
-                </div>
-            </div>
-            <transition name="fadeInOut">
-                <div class="tfa-cell tfa-edit has-text-grey" v-if="isEditMode">
-                    <!-- <div class="tags has-addons"> -->
-                        <router-link :to="{ name: 'editAccount', params: { twofaccountId: account.id }}" class="tag is-rounded mr-1" :class="$root.showDarkMode ? 'is-dark' : 'is-white'">
-                        {{ $t('commons.edit') }}
-                        </router-link>
-                        <router-link :to="{ name: 'showQRcode', params: { twofaccountId: account.id }}" class="tag is-rounded" :class="$root.showDarkMode ? 'is-dark' : 'is-white'" :title="$t('twofaccounts.show_qrcode')">
-                            <font-awesome-icon :icon="['fas', 'qrcode']" />
-                        </router-link>
-                    <!-- </div> -->
-                </div>
-            </transition>
-            <transition name="fadeInOut">
-                <div class="tfa-cell tfa-dots has-text-grey" v-if="isEditMode">
-                    <font-awesome-icon :icon="['fas', 'bars']" />
-                </div>
-            </transition>
-        </div>
-    </div>
-</template>
-
-<script>
-    export default {
-        name: 'Twofaccount',
-
-        data() {
-            return {
-            }
-        },
-
-        props: [
-            'account',
-            'isEditMode',
-        ],
-
-        methods: {
-
-            /**
-             * 
-             */
-            displayService(service) {
-                return service ? service : this.$t('twofaccounts.no_service')
-            },
-
-            /**
-             * 
-             */
-            select(accountId) {
-                this.$emit('selected', accountId)
-            },
-
-            
-        }
-    }
-</script>

+ 32 - 30
resources/js/components/VersionChecker.vue

@@ -1,42 +1,44 @@
+<script setup>
+    import systemService from '@/services/systemService'
+    import { useAppSettingsStore } from '@/stores/appSettings'
+
+    const appSettings = useAppSettingsStore()
+    const isScanning = ref(false)
+    const isUpToDate = ref()
+
+    async function getLatestRelease() {
+        isScanning.value = true;
+        isUpToDate.value = undefined
+
+        await systemService.getLastRelease({returnError: true})
+        .then(response => {
+            appSettings.latestRelease = response.data.newRelease
+            isUpToDate.value = response.data.newRelease === false
+        })
+        .catch(() => {
+            isUpToDate.value = null
+        })
+
+        isScanning.value = false;
+    }
+
+</script>
+
 <template>
     <div class="columns is-mobile is-vcentered">
         <div class="column is-narrow">
             <button type="button" :class="isScanning ? 'is-loading' : ''" class="button is-link is-rounded is-small" @click="getLatestRelease">Check now</button>
         </div>
         <div class="column">
-            <span v-if="$root.appSettings.latestRelease" class="mt-2 has-text-warning">
-                <span class="release-flag"></span>{{ $root.appSettings.latestRelease }} is available <a class="is-size-7" href="https://github.com/Bubka/2FAuth/releases">View on Github</a>
+            <span v-if="appSettings.latestRelease" class="mt-2 has-text-warning">
+                <span class="release-flag"></span>{{ appSettings.latestRelease }} is available <a class="is-size-7" href="https://github.com/Bubka/2FAuth/releases">View on Github</a>
             </span>
             <span v-if="isUpToDate" class="has-text-grey">
-                {{ $t('commons.you_are_up_to_date') }}
+                <FontAwesomeIcon :icon="['fas', 'check']" class="mr-1 has-text-success" /> {{ $t('commons.you_are_up_to_date') }}
+            </span>
+            <span v-else-if="isUpToDate === null" class="has-text-grey">
+                <FontAwesomeIcon :icon="['fas', 'times']" class="mr-1 has-text-danger" />{{ $t('errors.check_failed_try_later') }}
             </span>
         </div>
     </div>
 </template>
-
-<script>
-    export default {
-        name: 'VersionChecker',
-
-        data() {
-            return {
-                isScanning: false,
-                isUpToDate: null,
-            }
-        },
-
-        methods: {
-
-            async getLatestRelease() {
-                this.isScanning = true;
-
-                await this.axios.get('/latestRelease').then(response => {
-                    this.$root.appSettings['latestRelease'] = response.data.newRelease
-                    this.isUpToDate = response.data.newRelease === false
-                })
-
-                this.isScanning = false;
-            },
-        }
-    }
-</script>

+ 0 - 0
resources/js_vue3/components/formElements/Button.vue → resources/js/components/formElements/Button.vue


+ 0 - 0
resources/js_vue3/components/formElements/ButtonBackCloseCancel.vue → resources/js/components/formElements/ButtonBackCloseCancel.vue


+ 0 - 0
resources/js_vue3/components/formElements/FieldError.vue → resources/js/components/formElements/FieldError.vue


+ 0 - 0
resources/js_vue3/components/formElements/Form.js → resources/js/components/formElements/Form.js


+ 0 - 0
resources/js_vue3/components/formElements/FormButtons.vue → resources/js/components/formElements/FormButtons.vue


+ 0 - 0
resources/js_vue3/components/formElements/FormCheckbox.vue → resources/js/components/formElements/FormCheckbox.vue


+ 0 - 0
resources/js/components/FormErrors.js → resources/js/components/formElements/FormErrors.js


+ 0 - 0
resources/js_vue3/components/formElements/FormField.vue → resources/js/components/formElements/FormField.vue


+ 0 - 0
resources/js_vue3/components/formElements/FormLockField.vue → resources/js/components/formElements/FormLockField.vue


+ 0 - 0
resources/js_vue3/components/formElements/FormPasswordField.vue → resources/js/components/formElements/FormPasswordField.vue


+ 0 - 0
resources/js_vue3/components/formElements/FormSelect.vue → resources/js/components/formElements/FormSelect.vue


+ 0 - 0
resources/js_vue3/components/formElements/FormToggle.vue → resources/js/components/formElements/FormToggle.vue


+ 0 - 37
resources/js/components/index.js

@@ -1,37 +0,0 @@
-import Vue              from 'vue'
-import App              from './App'
-import Button           from './Button'
-import FieldError       from './FieldError'
-import FormWrapper      from './FormWrapper'
-import FormField        from './FormField'
-import FormPasswordField        from './FormPasswordField'
-import FormSelect       from './FormSelect'
-import FormSwitch       from './FormSwitch'
-import FormToggle       from './FormToggle'
-import FormCheckbox     from './FormCheckbox'
-import FormButtons      from './FormButtons'
-import VueFooter        from './Footer'
-import Kicker           from './Kicker'
-import SettingTabs      from './SettingTabs'
-import ResponsiveWidthWrapper from './ResponsiveWidthWrapper'
-
-// Components that are registered globaly.
-[
-    App,
-	Button,
-    FieldError,
-    FormWrapper,
-    FormField,
-    FormPasswordField,
-    FormSelect,
-    FormSwitch,
-    FormToggle,
-    FormCheckbox,
-    FormButtons,
-    VueFooter,
-    Kicker,
-    SettingTabs,
-    ResponsiveWidthWrapper
-].forEach(Component => {
-	Vue.component(Component.name, Component)
-})

+ 0 - 0
resources/js_vue3/composables/helpers.js → resources/js/composables/helpers.js


+ 0 - 0
resources/js_vue3/helpers.js → resources/js/helpers.js


+ 0 - 0
resources/js_vue3/icons.js → resources/js/icons.js


+ 0 - 14
resources/js/langs/i18n.js

@@ -1,14 +0,0 @@
-import Vue  from 'vue'
-import VueInternationalization from 'vue-i18n';
-import Locale from '@kirschbaum-development/laravel-translations-loader/php!@kirschbaum-development/laravel-translations-loader';
-
-Vue.use(VueInternationalization);
-
-const lang = document.documentElement.lang.substr(0, 2);
-
-const i18n = new VueInternationalization({
-    locale: lang,
-    messages: Locale
-});
-
-export default i18n

+ 0 - 0
resources/js_vue3/layouts/Footer.vue → resources/js/layouts/Footer.vue


+ 0 - 0
resources/js_vue3/layouts/FormWrapper.vue → resources/js/layouts/FormWrapper.vue


+ 0 - 0
resources/js_vue3/layouts/Modal.vue → resources/js/layouts/Modal.vue


+ 0 - 0
resources/js_vue3/layouts/ResponsiveWidthWrapper.vue → resources/js/layouts/ResponsiveWidthWrapper.vue


+ 0 - 0
resources/js_vue3/layouts/SettingTabs.vue → resources/js/layouts/SettingTabs.vue


+ 0 - 140
resources/js/mixins.js

@@ -1,140 +0,0 @@
-import Vue from 'vue'
-import i18n from './langs/i18n'
-
-Vue.mixin({
-
-    data: function () {
-        return {
-            appVersion: window.appVersion
-        }
-    },
-
-    methods: {
-
-        async appLogout(evt) {
-            if (this.$root.appConfig.proxyAuth) {
-                if (this.$root.appConfig.proxyLogoutUrl) {
-                    location.assign(this.$root.appConfig.proxyLogoutUrl)
-                }
-                else return false
-            }
-            else {
-                await this.axios.get('/user/logout')
-                this.clearStorage()
-                this.$router.push({ name: 'login', params: { forceRefresh: true } })
-            }
-        },
-
-        clearStorage() {
-            this.$storage.remove('accounts')
-            this.$storage.remove('groups')
-            this.$storage.remove('lastRoute')
-            this.$storage.remove('authenticated')
-        },
-
-        isUrl: function (url) {
-            var strRegex = /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/
-            var re = new RegExp(strRegex)
-
-            return re.test(url)
-        },
-
-        openInBrowser(uri) {
-            const a = document.createElement('a')
-            a.setAttribute('href', uri)
-            a.dispatchEvent(new MouseEvent("click", { 'view': window, 'bubbles': true, 'cancelable': true }))
-        },
-
-        /**
-         * 
-         */
-        inputId(fieldType, fieldName) {
-            let prefix
-            fieldName = fieldName.toString()
-
-            switch (fieldType) {
-                case 'text':
-                    prefix = 'txt'
-                    break
-                case 'button':
-                    prefix = 'btn'
-                    break
-                case 'email':
-                    prefix = 'eml'
-                    break
-                case 'password':
-                    prefix = 'pwd'
-                    break
-                case 'radio':
-                    prefix = 'rdo'
-                    break
-                case 'label':
-                    prefix = 'lbl'
-                    break
-                default:
-                    prefix = 'txt'
-                    break
-            }
-
-            return prefix + fieldName[0].toUpperCase() + fieldName.toLowerCase().slice(1);
-            // button
-            // checkbox
-            // color
-            // date 
-            // datetime-local
-            // file
-            // hidden
-            // image
-            // month
-            // number
-            // radio
-            // range
-            // reset
-            // search
-            // submit
-            // tel
-            // text
-            // time
-            // url
-            // week
-        },
-
-        setTheme(theme) {
-            document.documentElement.dataset.theme = theme;
-        },
-
-        applyPreferences(preferences) {
-            for (const preference in preferences) {
-                try {
-                    this.$root.userPreferences[preference] = preferences[preference]
-                 }
-                 catch (e) {
-                    console.log(e)
-                 }
-            }
-
-            if (this.$root.userPreferences.lang != 'browser') {
-                i18n.locale = this.$root.userPreferences.lang
-                document.documentElement.lang = this.$root.userPreferences.lang
-            }
-
-            this.setTheme(this.$root.userPreferences.theme)
-        },
-
-        displayPwd(pwd) {
-            if (this.$root.userPreferences.formatPassword && pwd.length > 0) {
-                const x = Math.ceil(this.$root.userPreferences.formatPasswordBy < 1 ? pwd.length * this.$root.userPreferences.formatPasswordBy : this.$root.userPreferences.formatPasswordBy)
-                const chunks = pwd.match(new RegExp(`.{1,${x}}`, 'g'));
-                if (chunks) {
-                    pwd = chunks.join(' ')
-                }
-            }
-            return this.$root.userPreferences.showOtpAsDot ? pwd.replace(/[0-9]/g, '●') : pwd
-        },
-        
-        strip_tags (str) {
-            return str.replace(/(<([^> ]+)>)/ig, "")
-        }
-    }
-
-})

+ 0 - 4
resources/js/packages/clipboard.js

@@ -1,4 +0,0 @@
-import Vue       from 'vue'
-import Clipboard from 'v-clipboard'
- 
-Vue.use(Clipboard)

+ 0 - 95
resources/js/packages/fontawesome.js

@@ -1,95 +0,0 @@
-import Vue from 'vue'
-
-import { library } from '@fortawesome/fontawesome-svg-core'
-import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
-
-import {
-    faPlus,
-    faPlusCircle,
-    faQrcode,
-    faImage,
-    faTrash,
-    faCheck,
-    faCheckSquare,
-    faTimes,
-    faLock,
-    faLockOpen,
-    faSearch,
-    faEllipsisH,
-    faBars,
-    faSpinner,
-    faCaretDown,
-    faLayerGroup,
-    faMinusCircle,
-    faExclamationCircle,
-    faPenSquare,
-    faTh,
-    faList,
-    faTimesCircle,
-    faUpload,
-    faGlobe,
-    faBook,
-    faFlask,
-    faCode,
-    faCopy,
-    faSortAlphaDown,
-    faSortAlphaUp,
-    faEye,
-    faEyeSlash,
-    faExternalLinkAlt,
-    faCamera,
-    faFileDownload,
-    faSun,
-    faMoon,
-    faDesktop,
-    faCircleNotch
-} from '@fortawesome/free-solid-svg-icons'
-
-import {
-    faGithubAlt
-} from '@fortawesome/free-brands-svg-icons'
-
-library.add(
-    faPlus,
-    faPlusCircle,
-    faQrcode,
-    faImage,
-    faTrash,
-    faCheck,
-    faCheckSquare,
-    faTimes,
-    faLock,
-    faLockOpen,
-    faSearch,
-    faEllipsisH,
-    faBars,
-    faSpinner,
-    faGithubAlt,
-    faCaretDown,
-    faLayerGroup,
-    faMinusCircle,
-    faExclamationCircle,
-    faPenSquare,
-    faTh,
-    faList,
-    faTimesCircle,
-    faUpload,
-    faGlobe,
-    faBook,
-    faFlask,
-    faCode,
-    faCopy,
-    faSortAlphaDown,
-    faSortAlphaUp,
-    faEye,
-    faEyeSlash,
-    faExternalLinkAlt,
-    faCamera,
-    faFileDownload,
-    faSun,
-    faMoon,
-    faDesktop,
-    faCircleNotch
-);
-
-Vue.component('font-awesome-icon', FontAwesomeIcon)

+ 0 - 10
resources/js/packages/vue-storage.js

@@ -1,10 +0,0 @@
-import Vue          from 'vue'
-import { Plugin } from 'vue2-storage'
-
-// You can specify the plug-in configuration when connecting, passing the second object to Vue.use
-Vue.use(Plugin, {
-    prefix: '',
-    driver: 'local',
-    ttl: 60 * 60 * 24 * 1000 * 122, // 4 month
-    replacer: (key, value) => value
-})

+ 0 - 0
resources/js_vue3/router/index.js → resources/js/router/index.js


+ 0 - 0
resources/js_vue3/router/middlewarePipeline.js → resources/js/router/middlewarePipeline.js


+ 0 - 0
resources/js_vue3/router/middlewares/authGuard.js → resources/js/router/middlewares/authGuard.js


+ 0 - 0
resources/js_vue3/router/middlewares/noEmptyError.js → resources/js/router/middlewares/noEmptyError.js


+ 0 - 0
resources/js_vue3/router/middlewares/noRegistration.js → resources/js/router/middlewares/noRegistration.js


+ 0 - 0
resources/js_vue3/router/middlewares/setReturnTo.js → resources/js/router/middlewares/setReturnTo.js


+ 0 - 0
resources/js_vue3/router/middlewares/starter.js → resources/js/router/middlewares/starter.js


+ 0 - 127
resources/js/routes.js

@@ -1,127 +0,0 @@
-import Vue from 'vue'
-import Router from 'vue-router'
-
-Vue.use(Router)
-
-import Start            from './views/Start'
-import Capture          from './views/Capture'
-import Accounts         from './views/Accounts'
-import CreateAccount    from './views/twofaccounts/Create'
-import EditAccount      from './views/twofaccounts/Edit'
-import ImportAccount    from './views/twofaccounts/Import'
-import QRcodeAccount    from './views/twofaccounts/QRcode'
-import Groups           from './views/Groups'
-import CreateGroup      from './views/groups/Create'
-import EditGroup        from './views/groups/Edit'
-import Login            from './views/auth/Login'
-import Register         from './views/auth/Register'
-import Autolock         from './views/auth/Autolock'
-import PasswordRequest  from './views/auth/password/Request'
-import PasswordReset    from './views/auth/password/Reset'
-import WebauthnLost     from './views/auth/webauthn/Lost'
-import WebauthnRecover  from './views/auth/webauthn/Recover'
-import SettingsOptions  from './views/settings/Options'
-import SettingsAccount  from './views/settings/Account'
-import SettingsOAuth    from './views/settings/OAuth'
-import SettingsWebAuthn from './views/settings/WebAuthn'
-import EditCredential   from './views/settings/Credentials/Edit'
-import GeneratePAT      from './views/settings/PATokens/Create'
-import Errors           from './views/Error'
-import About            from './views/About'
-
-const router = new Router({
-    mode: 'history',
-    base: window.appConfig.subdirectory ? window.appConfig.subdirectory : '/',
-    routes: [
-        { path: '/start', name: 'start', component: Start, meta: { requiresAuth: true }, props: true },
-        { path: '/capture', name: 'capture', component: Capture, meta: { requiresAuth: true }, props: true },
-
-        { path: '/accounts', name: 'accounts', component: Accounts, meta: { requiresAuth: true }, alias: '/', props: true },
-        { path: '/account/create', name: 'createAccount', component: CreateAccount, meta: { requiresAuth: true } },
-        { path: '/account/import', name: 'importAccounts', component: ImportAccount, meta: { requiresAuth: true } },
-        { path: '/account/:twofaccountId/edit', name: 'editAccount', component: EditAccount, meta: { requiresAuth: true } },
-        { path: '/account/:twofaccountId/qrcode', name: 'showQRcode', component: QRcodeAccount, meta: { requiresAuth: true } },
-
-        { path: '/groups', name: 'groups', component: Groups, meta: { requiresAuth: true }, props: true },
-        { path: '/group/create', name: 'createGroup', component: CreateGroup, meta: { requiresAuth: true } },
-        { path: '/group/:groupId/edit', name: 'editGroup', component: EditGroup, meta: { requiresAuth: true }, props: true },
-
-        { path: '/settings/options', name: 'settings.options', component: SettingsOptions, meta: { requiresAuth: true, showAbout: true } },
-        { path: '/settings/account', name: 'settings.account', component: SettingsAccount, meta: { requiresAuth: true, showAbout: true } },
-        { path: '/settings/oauth', name: 'settings.oauth.tokens', component: SettingsOAuth, meta: { requiresAuth: true, showAbout: true } },
-        { path: '/settings/oauth/pat/create', name: 'settings.oauth.generatePAT', component: GeneratePAT, meta: { requiresAuth: true, showAbout: true } },
-        { path: '/settings/webauthn/:credentialId/edit', name: 'settings.webauthn.editCredential', component: EditCredential, meta: { requiresAuth: true, showAbout: true }, props: true },
-        { path: '/settings/webauthn', name: 'settings.webauthn.devices', component: SettingsWebAuthn, meta: { requiresAuth: true, showAbout: true } },
-
-        { path: '/login', name: 'login', component: Login, meta: { disabledWithAuthProxy: true, showAbout: true } },
-        { path: '/register', name: 'register', component: Register, meta: { disabledWithAuthProxy: true, showAbout: true } },
-        { path: '/autolock', name: 'autolock',component: Autolock, meta: { disabledWithAuthProxy: true, showAbout: true } },
-        { path: '/password/request', name: 'password.request', component: PasswordRequest, meta: { disabledWithAuthProxy: true, showAbout: true } },
-        { path: '/user/password/reset', name: 'password.reset', component: PasswordReset, meta: { disabledWithAuthProxy: true, showAbout: true } },
-        { path: '/webauthn/lost', name: 'webauthn.lost', component: WebauthnLost, meta: { disabledWithAuthProxy: true, showAbout: true } },
-        { path: '/webauthn/recover', name: 'webauthn.recover', component: WebauthnRecover, meta: { disabledWithAuthProxy: true, showAbout: true } },
-
-        { path: '/about', name: 'about',component: About, meta: { showAbout: true } },
-        { path: '/error', name: 'genericError',component: Errors, props: true },
-        { path: '/404', name: '404',component: Errors, props: true },
-        { path: '*', redirect: { name: '404' } }
-    ],
-});
-
-let isFirstLoad = true;
-
-router.beforeEach((to, from, next) => {
-    
-    document.title = router.app.$options.i18n.t('titles.' + to.name)
-    
-    if( to.name === 'accounts') {
-        to.params.isFirstLoad = isFirstLoad ? true : false
-        isFirstLoad = false;
-    }
-
-    // See https://github.com/garethredfern/laravel-vue/ if one day the middleware pattern
-    // becomes relevant (i.e when some admin only pages are necessary)
-
-    if (to.meta.requiresAuth
-        && ! Vue.$storage.get('authenticated', false)
-        && ! window.appConfig.proxyAuth) {
-            next({ name: 'login' })
-    }
-    else if (to.matched.some(record => record.meta.disabledWithAuthProxy) && window.appConfig.proxyAuth) {
-        // The page is not relevant with auth proxy On so we push to the main view
-        next({ name: 'accounts' })
-    }
-    else if (to.name.startsWith('settings.')) {
-        if (to.params.returnTo == undefined) {
-            if (from.params.returnTo) {
-                next({name: to.name, params: { ...to.params, returnTo: from.params.returnTo }})
-            }
-            else if (from.name) {
-                next({name: to.name, params: { ...to.params, returnTo: from.path }})
-            }
-            else {
-                next({name: to.name, params: { ...to.params, returnTo: '/accounts' }})
-            }
-        }
-        else {
-            next()
-        }
-    }
-    else if (to.name == 'about' && to.params.goBackTo == undefined) {
-        if (from.name) {
-            next({ name: to.name, params: {goBackTo: from.path} })
-        }
-        else next({ name: to.name, params: {goBackTo: '/accounts'} })
-    }
-    else if (to.name === 'genericError' && to.params.err == undefined) {
-        // return to home if no err object is provided to prevent an empty error message
-        next({ name: 'accounts' });
-    }
-    else next()
-});
-
-router.afterEach(to => {
-    Vue.$storage.set('lastRoute', to.name)
-});
-
-export default router

+ 0 - 0
resources/js_vue3/services/appSettingService.js → resources/js/services/appSettingService.js


+ 0 - 0
resources/js_vue3/services/authService.js → resources/js/services/authService.js


+ 0 - 0
resources/js_vue3/services/groupService.js → resources/js/services/groupService.js


+ 0 - 0
resources/js_vue3/services/httpClientFactory.js → resources/js/services/httpClientFactory.js


+ 0 - 0
resources/js_vue3/services/systemService.js → resources/js/services/systemService.js


+ 0 - 0
resources/js_vue3/services/twofaccountService.js → resources/js/services/twofaccountService.js


+ 0 - 0
resources/js_vue3/services/userService.js → resources/js/services/userService.js


+ 0 - 0
resources/js/webauthn/identifyAuthenticationError.js → resources/js/services/webauthn/identifyAuthenticationError.js


+ 0 - 0
resources/js/webauthn/identifyRegistrationError.js → resources/js/services/webauthn/identifyRegistrationError.js


+ 0 - 0
resources/js/webauthn/isValidDomain.js → resources/js/services/webauthn/isValidDomain.js


+ 0 - 0
resources/js/webauthn/webauthnAbortService.js → resources/js/services/webauthn/webauthnAbortService.js


+ 0 - 0
resources/js_vue3/services/webauthn/webauthnService.js → resources/js/services/webauthn/webauthnService.js


+ 0 - 0
resources/js_vue3/stores/appSettings.js → resources/js/stores/appSettings.js


+ 0 - 0
resources/js_vue3/stores/bus.js → resources/js/stores/bus.js


+ 0 - 0
resources/js_vue3/stores/groups.js → resources/js/stores/groups.js


+ 0 - 0
resources/js_vue3/stores/notify.js → resources/js/stores/notify.js


+ 0 - 0
resources/js_vue3/stores/twofaccounts.js → resources/js/stores/twofaccounts.js


+ 0 - 0
resources/js_vue3/stores/user.js → resources/js/stores/user.js


+ 95 - 107
resources/js/views/About.vue

@@ -1,8 +1,52 @@
+<script setup>
+    import systemService from '@/services/systemService'
+    import { useNotifyStore } from '@/stores/notify'
+    import { UseColorMode } from '@vueuse/components'
+
+    const $2fauth = inject('2fauth')
+    const router = useRouter()
+    const notify = useNotifyStore()
+    const { copy } = useClipboard({ legacy: true })
+
+    const returnTo = router.options.history.state.back
+    const infos = ref()
+    const listInfos = ref(null)
+    const userPreferences = ref(false)
+    const listUserPreferences = ref(null)
+    const adminSettings = ref(false)
+    const listAdminSettings = ref(null)
+
+    onMounted(() => {
+        systemService.getSystemInfos({returnError: true}).then(response => {
+            infos.value = response.data.common
+
+            if (response.data.admin_settings) {
+                adminSettings.value = response.data.admin_settings
+            }
+
+            if (response.data.user_preferences) {
+                userPreferences.value = response.data.user_preferences
+            }
+        })
+        .catch(() => {
+            infos.value = null
+        })
+    })
+
+    function copyToClipboard(data) {
+        copy(data)
+        notify.success({ text: trans('commons.copied_to_clipboard') })
+    }
+</script>
+
 <template>
-    <responsive-width-wrapper>
-        <h1 class="title has-text-grey-dark">{{ pagetitle }}</h1>
+    <ResponsiveWidthWrapper>
+        <h1 class="title has-text-grey-dark">{{ $t('commons.about') }}</h1>
         <p class="block">
-            <span :class="$root.showDarkMode ? 'has-text-white':'has-text-black'"><span class="is-size-5">2FAuth</span> v{{ appVersion }}</span><br />
+            <UseColorMode v-slot="{ mode }">
+                <span :class="mode == 'dark' ? 'has-text-white':'has-text-black'"><span class="is-size-5">2FAuth</span> v{{ $2fauth.version }}</span>
+            </UseColorMode>
+            <br />
             {{ $t('commons.2fauth_teaser')}}
         </p>
         <img class="about-logo" src="logo.svg" alt="2FAuth logo" />
@@ -13,138 +57,82 @@
             {{ $t('commons.resources') }}
         </h2>
         <div class="buttons">
-            <a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://github.com/Bubka/2FAuth" target="_blank">
-                <span class="icon is-small">
-                    <font-awesome-icon :icon="['fab', 'github-alt']" />
-                </span>
-                <span>Github</span>
-            </a>
-            <a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://docs.2fauth.app/" target="_blank">
-                <span class="icon is-small">
-                    <font-awesome-icon :icon="['fas', 'book']" />
-                </span>
-                <span>Docs</span>
-            </a>
-            <a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://demo.2fauth.app/" target="_blank">
-                <span class="icon is-small">
-                    <font-awesome-icon :icon="['fas', 'flask']" />
-                </span>
-                <span>Demo</span>
-            </a>
-            <a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://docs.2fauth.app/resources/rapidoc.html" target="_blank">
-                <span class="icon is-small">
-                    <font-awesome-icon :icon="['fas', 'code']" />
-                </span>
-                <span>API</span>
-            </a>
+            <UseColorMode v-slot="{ mode }">
+                <a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://github.com/Bubka/2FAuth" target="_blank">
+                    <span class="icon is-small">
+                        <FontAwesomeIcon :icon="['fab', 'github-alt']" />
+                    </span>
+                    <span>Github</span>
+                </a>
+                <a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://docs.2fauth.app/" target="_blank">
+                    <span class="icon is-small">
+                        <FontAwesomeIcon :icon="['fas', 'book']" />
+                    </span>
+                    <span>Docs</span>
+                </a>
+                <a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://demo.2fauth.app/" target="_blank">
+                    <span class="icon is-small">
+                        <FontAwesomeIcon :icon="['fas', 'flask']" />
+                    </span>
+                    <span>Demo</span>
+                </a>
+                <a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://docs.2fauth.app/resources/rapidoc.html" target="_blank">
+                    <span class="icon is-small">
+                        <FontAwesomeIcon :icon="['fas', 'code']" />
+                    </span>
+                    <span>API</span>
+                </a>
+            </UseColorMode>
         </div>
         <h2 class="title is-5 has-text-grey-light">
             {{ $t('commons.credits') }}
         </h2>
         <p class="block">
             <ul>
-                <li>{{ $t('commons.made_with')}}&nbsp;<a href="https://docs.2fauth.app/credits/">Laravel, Bulma CSS, Vue.js and more</a></li>
-                <li>{{ $t('commons.ui_icons_by')}}&nbsp;<a href="https://fontawesome.com/">Font Awesome</a>&nbsp;<a class="is-size-7" href="https://fontawesome.com/license/free">(CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)</a></li>
-                <li>{{ $t('commons.logos_by')}}&nbsp;<a href="https://2fa.directory/">2FA Directory</a>&nbsp;<a class="is-size-7" href="https://github.com/2factorauth/twofactorauth/blob/master/LICENSE.md">(MIT License)</a></li>
+                <li>{{ $t('commons.made_with') }}&nbsp;<a href="https://docs.2fauth.app/credits/">Laravel, Bulma CSS, Vue.js and more</a></li>
+                <li>{{ $t('commons.ui_icons_by') }}&nbsp;<a href="https://fontawesome.com/">Font Awesome</a>&nbsp;<a class="is-size-7" href="https://fontawesome.com/license/free">(CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)</a></li>
+                <li>{{ $t('commons.logos_by') }}&nbsp;<a href="https://2fa.directory/">2FA Directory</a>&nbsp;<a class="is-size-7" href="https://github.com/2factorauth/twofactorauth/blob/master/LICENSE.md">(MIT License)</a></li>
             </ul>
         </p>
         <h2 class="title is-5 has-text-grey-light">
             {{ $t('commons.environment') }}
         </h2>
-        <div class="about-debug box is-family-monospace is-size-7">
-            <button id="btnCopyEnvVars" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" v-clipboard="() => this.$refs.listInfos.innerText" v-clipboard:success="clipboardSuccessHandler">
-                <font-awesome-icon :icon="['fas', 'copy']" />
+        <div v-if="infos" class="about-debug box is-family-monospace is-size-7">
+            <button id="btnCopyEnvVars" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" @click.stop="copyToClipboard(listInfos.innerText)">
+                <FontAwesomeIcon :icon="['fas', 'copy']" />
             </button>
             <ul ref="listInfos" id="listInfos">
                 <li v-for="(value, key) in infos" :value="value" :key="key"><b>{{key}}</b>: {{value}}</li>
             </ul>
         </div>
-        <h2 v-if="showAdminSettings" class="title is-5 has-text-grey-light">
+        <div v-else-if="infos === null" class="about-debug box is-family-monospace is-size-7 has-text-warning-dark">
+            {{ $t('errors.error_during_data_fetching') }}
+        </div>
+        <h2 v-if="adminSettings" class="title is-5 has-text-grey-light">
             {{ $t('settings.admin_settings') }}
         </h2>
-        <div v-if="showAdminSettings" class="about-debug box is-family-monospace is-size-7">
-            <button id="btnCopyAdminSettings" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" v-clipboard="() => this.$refs.listAdminSettings.innerText" v-clipboard:success="clipboardSuccessHandler">
-                <font-awesome-icon :icon="['fas', 'copy']" />
+        <div v-if="adminSettings" class="about-debug box is-family-monospace is-size-7">
+            <button id="btnCopyAdminSettings" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" @click.stop="copyToClipboard(listAdminSettings.innerText)">
+                <FontAwesomeIcon :icon="['fas', 'copy']" />
             </button>
             <ul ref="listAdminSettings" id="listAdminSettings">
                 <li v-for="(value, setting) in adminSettings" :value="value" :key="setting"><b>{{setting}}</b>: {{value}}</li>
             </ul>
         </div>
-        <h2 v-if="showUserPreferences" class="title is-5 has-text-grey-light">
+        <h2 v-if="userPreferences" class="title is-5 has-text-grey-light">
             {{ $t('settings.user_preferences') }}
         </h2>
-        <div v-if="showUserPreferences" class="about-debug box is-family-monospace is-size-7">
-            <button id="btnCopyUserPreferences" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" v-clipboard="() => this.$refs.listUserPreferences.innerText" v-clipboard:success="clipboardSuccessHandler">
-                <font-awesome-icon :icon="['fas', 'copy']" />
+        <div v-if="userPreferences" class="about-debug box is-family-monospace is-size-7">
+            <button id="btnCopyUserPreferences" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" @click.stop="copyToClipboard(listUserPreferences.innerText)">
+                <FontAwesomeIcon :icon="['fas', 'copy']" />
             </button>
             <ul ref="listUserPreferences" id="listUserPreferences">
                 <li v-for="(value, preference) in userPreferences" :value="value" :key="preference"><b>{{preference}}</b>: {{value}}</li>
             </ul>
         </div>
         <!-- footer -->
-        <vue-footer :showButtons="true">
-            <!-- close button -->
-            <p class="control">
-                <router-link
-                    id="lnkBack"
-                    :to="{ path: $route.params.goBackTo, params: { returnTo: $route.params.returnTo, toRefresh: true } }"
-                    :aria-label="$t('commons.close_the_x_page', {pagetitle: pagetitle})"
-                    class="button is-rounded"
-                    :class="{'is-dark' : $root.showDarkMode}">
-                    {{ $t('commons.back') }}
-                </router-link>
-            </p>
-        </vue-footer>
-    </responsive-width-wrapper>
-</template>
-
-<script>
-    export default {
-        data() {
-            return {
-                pagetitle: this.$t('commons.about'),
-                infos : null,
-                adminSettings : null,
-                userPreferences : null,
-                showUserPreferences: false,
-                showAdminSettings: false,
-            }
-        },
-
-        async mounted() {
-            await this.axios.get('infos').then(response => {
-                this.infos = response.data.common
-
-                if (response.data.admin_settings) {
-                    this.adminSettings = response.data.admin_settings
-                    this.showAdminSettings = true
-                }
-
-                if (response.data.user_preferences) {
-                    this.userPreferences = response.data.user_preferences
-                    this.showUserPreferences = true
-                }
-            })
-        },
-
-        methods: {
-            
-            clipboardSuccessHandler ({ value, event }) {
-                this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
-            },
-
-            clipboardErrorHandler ({ value, event }) {
-                console.log('error', value)
-            },
-        },
-
-        beforeRouteEnter(to, from, next) {
-            next(vm => {
-                if (from.params.returnTo) {
-                    to.params.returnTo = from.params.returnTo
-                }
-            })
-        },
-
-    }
-</script>
+        <VueFooter :showButtons="true">
+            <ButtonBackCloseCancel :returnTo="{ path: returnTo }" action="back" />
+        </VueFooter>
+    </ResponsiveWidthWrapper>
+</template>

+ 0 - 830
resources/js/views/Accounts.vue

@@ -1,830 +0,0 @@
-<template>
-    <div>
-        <!-- Group switch -->
-        <div id="groupSwitch" class="container groups" v-if="showGroupSwitch">
-            <div class="columns is-centered">
-                <div class="column is-one-third-tablet is-one-quarter-desktop is-one-quarter-widescreen is-one-quarter-fullhd">
-                    <div class="columns is-multiline">
-                        <div class="column is-full" v-for="group in groups" v-if="group.twofaccounts_count > 0" :key="group.id">
-                            <button class="button is-fullwidth" :class="{'is-dark has-text-light is-outlined':$root.showDarkMode}" @click="setActiveGroup(group.id)">{{ group.name }}</button>
-                        </div>
-                    </div>
-                    <div class="columns is-centered">
-                        <div class="column has-text-centered">
-                            <router-link :to="{ name: 'groups' }" >{{ $t('groups.manage_groups') }}</router-link>
-                        </div>
-                    </div>
-                </div>
-            </div>
-            <vue-footer :showButtons="true">
-                <!-- Close Group switch button -->
-                <p class="control">
-                    <button id="btnClose" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" @click="closeGroupSwitch()">{{ $t('commons.close') }}</button>
-                </p>
-            </vue-footer>
-        </div>
-        <!-- Group selector -->
-        <div class="container group-selector" v-if="showGroupSelector">
-            <div class="columns is-centered is-multiline">
-                <div class="column is-full has-text-centered">
-                    {{ $t('groups.move_selected_to') }}
-                </div>
-                <div class="column is-one-third-tablet is-one-quarter-desktop is-one-quarter-widescreen is-one-quarter-fullhd">
-                    <div class="columns is-multiline">
-                        <div class="column is-full" v-for="group in groups" :key="group.id">
-                            <button class="button is-fullwidth" :class="{'is-link' : moveAccountsTo === group.id, 'is-dark has-text-light is-outlined':$root.showDarkMode}" @click="moveAccountsTo = group.id">
-                                <span v-if="group.id === 0" class="is-italic">
-                                    {{ $t('groups.no_group') }}
-                                </span>
-                                <span v-else>
-                                    {{ group.name }}
-                                </span>
-                            </button>
-                        </div>
-                    </div>
-                    <div class="columns is-centered">
-                        <div class="column has-text-centered">
-                            <router-link :to="{ name: 'groups' }" >{{ $t('groups.manage_groups') }}</router-link>
-                        </div>
-                    </div>
-                </div>
-            </div>
-            <vue-footer :showButtons="true">
-                <!-- Move to selected group button -->
-                <p class="control">
-                    <button class="button is-link is-rounded" @click="moveAccounts()">{{ $t('commons.move') }}</button>
-                </p>
-                <!-- Cancel button -->
-                <p class="control">
-                    <button id="btnCancel" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" @click="showGroupSelector = false">{{ $t('commons.cancel') }}</button>
-                </p>
-            </vue-footer>
-        </div>
-        <!-- header -->
-        <div class="header" v-if="this.showAccounts || this.showGroupSwitch">
-            <div class="columns is-gapless is-mobile is-centered">
-                <div class="column is-three-quarters-mobile is-one-third-tablet is-one-quarter-desktop is-one-quarter-widescreen is-one-quarter-fullhd">
-                    <!-- search -->
-                    <div role="search" class="field">
-                        <div class="control has-icons-right">
-                            <input ref="searchBox" id="txtSearch" type="search" tabindex="1" :aria-label="$t('commons.search')" :title="$t('commons.search')" class="input is-rounded is-search" v-model="search">
-                            <span class="icon is-small is-right">
-                                <font-awesome-icon :icon="['fas', 'search']"  v-if="!search" />
-                                <button id="btnClearSearch" tabindex="1" :title="$t('commons.clear_search')" class="clear-selection delete" v-if="search" @click="search = '' "></button>
-                            </span>
-                        </div>
-                    </div>
-                    <!-- toolbar -->
-                    <div v-if="editMode" class="toolbar has-text-centered">
-                        <div class="columns">
-                            <div class="column">
-                                <!-- selected label -->
-                                <span class="has-text-grey mr-1">{{ selectedAccounts.length }}&nbsp;{{ $t('commons.selected') }}</span>
-                                <!-- deselect all -->
-                                <button id="btnUnselectAll" @click="clearSelected" class="clear-selection delete mr-4" :style="{visibility: selectedAccounts.length > 0 ? 'visible' : 'hidden'}" :title="$t('commons.clear_selection')"></button>
-                                <!-- select all button -->
-                                <button id="btnSelectAll" @click="selectAll" class="button mr-5 has-line-height p-1 is-ghost has-text-grey" :title="$t('commons.select_all')">
-                                    <span>{{ $t('commons.all') }}</span>
-                                    <font-awesome-icon class="ml-1" :icon="['fas', 'check-square']" />
-                                </button>
-                                <!-- sort asc/desc buttons -->
-                                <button id="btnSortAscending" @click="sortAsc" class="button has-line-height p-1 is-ghost has-text-grey" :title="$t('commons.sort_ascending')">
-                                    <font-awesome-icon :icon="['fas', 'sort-alpha-down']" />
-                                </button>
-                                <button id="btnSortDescending" @click="sortDesc" class="button has-line-height p-1 is-ghost has-text-grey" :title="$t('commons.sort_descending')">
-                                    <font-awesome-icon :icon="['fas', 'sort-alpha-up']" />
-                                </button>
-                            </div>
-                        </div>
-                    </div>
-                    <!-- group switch toggle -->
-                    <div v-else class="has-text-centered">
-                        <div class="columns">
-                            <div class="column" v-if="!showGroupSwitch">
-                                <button id="btnShowGroupSwitch" :title="$t('groups.show_group_selector')" tabindex="1" class="button is-text is-like-text" :class="{'has-text-grey' : !$root.showDarkMode}" @click.stop="toggleGroupSwitch">
-                                    {{ activeGroupName }} ({{ filteredAccounts.length }})&nbsp;
-                                    <font-awesome-icon  :icon="['fas', 'caret-down']" />
-                                </button>
-                            </div>
-                            <div class="column" v-else>
-                                <button id="btnHideGroupSwitch" :title="$t('groups.hide_group_selector')" tabindex="1" class="button is-text is-like-text" :class="{'has-text-grey' : !$root.showDarkMode}" @click.stop="toggleGroupSwitch">
-                                    {{ $t('groups.select_accounts_to_show') }}
-                                </button>
-                            </div>
-                        </div>
-                    </div>
-                </div>
-            </div>
-        </div>
-        <!-- modal -->
-        <modal v-model="showTwofaccountInModal">
-            <otp-displayer ref="OtpDisplayer"></otp-displayer>
-        </modal>
-        <!-- show accounts list -->
-        <div class="container" v-if="this.showAccounts" :class="editMode ? 'is-edit-mode' : ''">
-            <!-- accounts -->
-            <!-- <vue-pull-refresh :on-refresh="onRefresh" :config="{
-                errorLabel: 'error',
-                startLabel: '',
-                readyLabel: '',
-                loadingLabel: 'refreshing'
-                }" > -->
-                <draggable v-model="filteredAccounts" @start="drag = true" @end="saveOrder" ghost-class="ghost" handle=".tfa-dots" animation="200" class="accounts">
-                    <transition-group class="columns is-multiline" :class="{ 'is-centered': $root.userPreferences.displayMode === 'grid' }" type="transition" :name="!drag ? 'flip-list' : null">
-                        <div :class="[$root.userPreferences.displayMode === 'grid' ? 'tfa-grid' : 'tfa-list']" class="column is-narrow" v-for="account in filteredAccounts" :key="account.id">
-                            <div class="tfa-container">
-        	                    <transition name="slideCheckbox">
-        	                        <div class="tfa-cell tfa-checkbox" v-if="editMode">
-        	                            <div class="field">
-        	                                <input class="is-checkradio is-small" :class="$root.showDarkMode ? 'is-white':'is-info'" :id="'ckb_' + account.id" :value="account.id" type="checkbox" :name="'ckb_' + account.id" v-model="selectedAccounts">
-        	                                <label tabindex="0" :for="'ckb_' + account.id" v-on:keypress.space.prevent="selectAccount(account.id)"></label>
-        	                            </div>
-        	                        </div>
-        	                    </transition>
-                                <div tabindex="0" class="tfa-cell tfa-content is-size-3 is-size-4-mobile" @click.exact="showOrCopy(account)" @keyup.enter="showOrCopy(account)" @click.ctrl="getAndCopyOTP(account)" role="button">  
-                                    <div class="tfa-text has-ellipsis">
-                                        <img class="tfa-icon" :src="$root.appConfig.subdirectory + '/storage/icons/' + account.icon" v-if="account.icon && $root.userPreferences.showAccountsIcons" :alt="$t('twofaccounts.icon_for_account_x_at_service_y', {account: account.account, service: account.service})">
-                                        {{ displayService(account.service) }}<font-awesome-icon class="has-text-danger is-size-5 ml-2" v-if="$root.appSettings.useEncryption && account.account === $t('errors.indecipherable')" :icon="['fas', 'exclamation-circle']" />
-                                        <span class="has-ellipsis is-family-primary is-size-6 is-size-7-mobile has-text-grey ">{{ account.account }}</span>
-                                    </div>
-                                </div>
-        	                    <transition name="popLater">
-                                    <div v-show="$root.userPreferences.getOtpOnRequest == false && !editMode" class="has-text-right">
-                                        <span v-if="account.otp != undefined && isRenewingOTPs" class="has-nowrap has-text-grey has-text-centered is-size-5">
-                                            <font-awesome-icon :icon="['fas', 'circle-notch']" spin />
-                                        </span>
-                                        <span v-else-if="account.otp != undefined && isRenewingOTPs == false" class="always-on-otp is-clickable has-nowrap has-text-grey is-size-5 ml-4" @click="copyOTP(account.otp.password)" @keyup.enter="copyOTP(account.otp.password)" :title="$t('commons.copy_to_clipboard')">
-                                            {{ displayPwd(account.otp.password) }}
-                                        </span>
-                                        <span v-else>
-                                            <!-- get hotp button -->
-                                            <button class="button tag" :class="$root.showDarkMode ? 'is-dark' : 'is-white'" @click="showAccount(account)" :title="$t('twofaccounts.import.import_this_account')">
-                                                {{ $t('commons.generate') }}
-                                            </button>
-                                        </span>
-                                        <dots v-if="account.otp_type.includes('totp')" @hook:mounted="turnDotsOnFromCache(account.period)" :class="'condensed'" :ref="'dots_' + account.period"></dots>
-                                    </div>
-        	                    </transition>
-        	                    <transition name="fadeInOut">
-        	                        <div class="tfa-cell tfa-edit has-text-grey" v-if="editMode">
-                                        <router-link :to="{ name: 'editAccount', params: { twofaccountId: account.id }}" class="tag is-rounded mr-1" :class="$root.showDarkMode ? 'is-dark' : 'is-white'">
-                                        {{ $t('commons.edit') }}
-                                        </router-link>
-                                        <router-link :to="{ name: 'showQRcode', params: { twofaccountId: account.id }}" class="tag is-rounded" :class="$root.showDarkMode ? 'is-dark' : 'is-white'" :title="$t('twofaccounts.show_qrcode')">
-                                            <font-awesome-icon :icon="['fas', 'qrcode']" />
-                                        </router-link>
-        	                        </div>
-        	                    </transition>
-                                <transition name="fadeInOut">
-                                    <div class="tfa-cell tfa-dots has-text-grey" v-if="editMode">
-                                        <font-awesome-icon :icon="['fas', 'bars']" />
-                                    </div>
-                                </transition>
-                            </div>
-                        </div>
-                        <!-- <twofaccount v-for="account in filteredAccounts" :account="account" :key="account.id" :selectedAccounts="selectedAccounts" :isEditMode="editMode" v-on:selected="selectAccount" v-on:show="showAccount"></twofaccount> -->
-                    </transition-group>
-                </draggable>
-            <!-- </vue-pull-refresh> -->
-            <vue-footer :showButtons="true" :editMode="editMode" v-on:exit-edit="setEditModeTo(false)">
-                <!-- New item buttons -->
-                <p class="control" v-if="!editMode">
-                    <button class="button is-link is-rounded is-focus" @click="start">
-                        <span>{{ $t('commons.new') }}</span>
-                        <span class="icon is-small">
-                            <font-awesome-icon :icon="['fas', 'qrcode']" />
-                        </span>
-                    </button>
-                </p>
-                <!-- Manage button -->
-                <p class="control" v-if="!editMode">
-                    <button id="btnManage" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" @click="setEditModeTo(true)">{{ $t('commons.manage') }}</button>
-                </p>
-                <!-- move button -->
-                <p class="control" v-if="editMode">
-                    <button
-                        id="btnMove" 
-                        :disabled='selectedAccounts.length == 0' class="button is-rounded"
-                        :class="[{'is-outlined': $root.showDarkMode||selectedAccounts.length == 0}, selectedAccounts.length == 0 ? 'is-dark': 'is-link']"
-                        @click="showGroupSelector = true"
-                        :title="$t('groups.move_selected_to_group')" >
-                            {{ $t('commons.move') }}
-                    </button>
-                </p>
-                <!-- delete button -->
-                <p class="control" v-if="editMode">
-                    <button
-                        id="btnDelete" 
-                        :disabled='selectedAccounts.length == 0' class="button is-rounded"
-                        :class="[{'is-outlined': $root.showDarkMode||selectedAccounts.length == 0}, selectedAccounts.length == 0 ? 'is-dark': 'is-link']"
-                        @click="destroyAccounts" >
-                            {{ $t('commons.delete') }}
-                    </button>
-                </p>
-                <!-- export button -->
-                <p class="control" v-if="editMode">
-                    <button
-                        id="btnExport" 
-                        :disabled='selectedAccounts.length == 0' class="button is-rounded"
-                        :class="[{'is-outlined': $root.showDarkMode||selectedAccounts.length == 0}, selectedAccounts.length == 0 ? 'is-dark': 'is-link']"
-                        @click="exportAccounts"
-                        :title="$t('twofaccounts.export_selected_to_json')" >
-                            {{ $t('commons.export') }}
-                    </button>
-                </p>
-            </vue-footer>
-        </div>
-        <span v-if="!this.$root.userPreferences.getOtpOnRequest">
-            <totp-looper
-                v-for="period in periods"
-                :key="period.period"
-                :period="period.period"
-                :generated_at="period.generated_at"
-                v-on:loop-ended="updateTotps(period.period)"
-                v-on:loop-started="setCurrentStep(period.period, $event)"
-                v-on:stepped-up="setCurrentStep(period.period, $event)"
-                ref="loopers"
-            ></totp-looper>
-        </span>
-    </div>
-</template>
-
-
-<script>
-
-    /**
-     *  Accounts view
-     *
-     *  route: '/account' (alias: '/')
-     *
-     *  The main view of 2FAuth that list all existing account recorded in DB.
-     *  Available feature in this view :
-     *  - {{OTP}} generation
-     *  - Account fetching :
-     *    ~ Search
-     *    ~ Filtering (by group)
-     *  - Accounts management :
-     *    ~ Sorting
-     *    ~ QR code recovering
-     *    ~ Mass association to group
-     *    ~ Mass account deletion
-     *    ~ Access to account editing
-     *
-     *  Behavior :
-     *  - The view has 2 modes (toggle is done with the 'manage' button) :
-     *    ~ The View mode (the default one)
-     *    ~ The Edit mode
-     *  - User are automatically pushed to the start view if there is no account to list.
-     *  - The view is affected by :
-     *    ~ 'userPreferences.showAccountsIcons' toggle the icon visibility
-     *    ~ 'userPreferences.displayMode' change the account appearance
-     *
-     *  Input :
-     *  - The 'initialEditMode' props : allows to load the view directly in Edit mode
-     *
-     */
-
-    // import Twofaccount from '../components/Twofaccount'
-    import Modal from '../components/Modal'
-    import TotpLooper from '../components/TotpLooper'
-    import Dots from '../components/Dots'
-    import OtpDisplayer from '../components/OtpDisplayer'
-    import draggable from 'vuedraggable'
-    import Form from './../components/Form'
-    import objectEquals from 'object-equals'
-    import { saveAs } from 'file-saver';
-
-    export default {
-        data(){
-            return {
-                accounts : [],
-                groups : [],
-                selectedAccounts: [],
-                search: '',
-                editMode: this.initialEditMode,
-                drag: false,
-                showTwofaccountInModal : false,
-                showGroupSwitch: false,
-                showGroupSelector: false,
-                moveAccountsTo: false,
-                form: new Form({
-                    value: this.$root.userPreferences.activeGroup,
-                }),
-                stepIndexes: {},
-                isRenewingOTPs: false
-            }
-        },
-
-        computed: {
-            /**
-             * The actual list of displayed accounts
-             */
-            filteredAccounts: {
-                get: function() {
-
-                    return this.accounts.filter(
-                        item => {
-                            if( parseInt(this.$root.userPreferences.activeGroup) > 0 ) {
-                                return ((item.service ? item.service.toLowerCase().includes(this.search.toLowerCase()) : false) ||
-                                    item.account.toLowerCase().includes(this.search.toLowerCase())) &&
-                                    (item.group_id == parseInt(this.$root.userPreferences.activeGroup))
-                            }
-                            else {
-                                return ((item.service ? item.service.toLowerCase().includes(this.search.toLowerCase()) : false) ||
-                                    item.account.toLowerCase().includes(this.search.toLowerCase()))
-                            }
-                        }
-                    );
-                },
-                set: function(reorderedAccounts) {
-                    this.accounts = reorderedAccounts
-                }
-            },
-
-            /**
-             * Returns whether or not the accounts should be displayed
-            */
-            showAccounts() {
-                return this.accounts.length > 0 && !this.showGroupSwitch && !this.showGroupSelector ? true : false
-            },
-
-            /**
-             * Returns the name of a group
-             */
-            activeGroupName() {
-                let g = this.groups.find(el => el.id === parseInt(this.$root.userPreferences.activeGroup))
-
-                if(g) {
-                    return g.name
-                }
-                else {
-                    return this.$t('commons.all')
-                }
-            },
-
-            /**
-             * Returns an array of all totp periods present in the twofaccounts list
-             */
-            periods() {
-                return !this.$root.userPreferences.getOtpOnRequest ?
-                    this.accounts.filter(acc => acc.otp_type == 'totp').map(function(item) {
-                        return {period: item.period, generated_at: item.otp.generated_at}
-                        // return item.period
-                    }).filter((value, index, self) => index === self.findIndex((t) => (
-                        t.period === value.period
-                    ))).sort()
-                    : null
-            },
-
-        },
-
-        props: ['initialEditMode', 'toRefresh'],
-
-        mounted() {
-
-            document.addEventListener('keydown', this.keyListener)
-
-            // we don't have to fetch fresh data so we try to load them from localstorage to avoid display latency
-            if( this.$root.userPreferences.getOtpOnRequest && !this.toRefresh && !this.$route.params.isFirstLoad ) {
-                const accounts = this.$storage.get('accounts', null) // use null as fallback if localstorage is empty
-                if( accounts ) this.accounts = accounts
-
-                const groups = this.$storage.get('groups', null) // use null as fallback if localstorage is empty
-                if( groups ) this.groups = groups
-            }
-
-            // we fetch fresh data whatever. The user will be notified to reload the page if there are any data changes
-            this.fetchAccounts()
-
-            // stop OTP generation on modal close
-            this.$on('modalClose', function() {
-                this.$refs.OtpDisplayer.clearOTP()
-            });
-
-        },
-
-        destroyed () {
-            document.removeEventListener('keydown', this.keyListener)
-        },
-
-        components: {
-            // Twofaccount,
-            Modal,
-            OtpDisplayer,
-            TotpLooper,
-            Dots,
-            draggable,
-        },
-
-     
-        methods: {
-
-            /**
-             * 
-             */
-            showOrCopy(account) {
-                if (!this.$root.userPreferences.getOtpOnRequest && account.otp_type.includes('totp')) {
-                    this.copyOTP(account.otp.password)
-                }
-                else {
-                    this.showAccount(account)
-                }
-            },
-
-            /**
-             * 
-             */
-            async getAndCopyOTP(account) {
-                await this.axios.get('/api/v1/twofaccounts/' + account.id + '/otp').then(response => {
-                    let otp = response.data
-                    this.copyOTP(otp.password)
-
-                    if (otp.otp_type == 'hotp') {
-                        let hotpToIncrement = this.accounts.find((acc) => acc.id == account.id)
-                        
-                        if (hotpToIncrement != undefined) {
-                            hotpToIncrement.counter = otp.counter
-                        }
-                    }
-                })
-            },
-
-            /**
-             * 
-             */
-            copyOTP (password) {
-                // see https://web.dev/async-clipboard/ for futur Clipboard API usage.
-                // The API should allow to copy the password on each trip without user interaction.
-
-                // For now too many browsers don't support the clipboard-write permission
-                // (see https://developer.mozilla.org/en-US/docs/Web/API/Permissions#browser_support)
-
-                const success = this.$clipboard(password)
-
-                if (success == true) {
-                    if(this.$root.userPreferences.kickUserAfter == -1) {
-                        this.appLogout()
-                    }
-
-                    this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
-                }
-            },
-
-            /**
-             * 
-             */
-            setCurrentStep(period, stepIndex) {
-                this.stepIndexes[period] = stepIndex
-                this.turnDotsOn(period, stepIndex)
-            },
-
-            /**
-             * 
-             */
-            turnDotsOnFromCache(period, stepIndex) {
-                if (this.stepIndexes[period] != undefined) {
-                    this.turnDotsOn(period, this.stepIndexes[period])
-                }
-            },
-
-            /**
-             * 
-             */
-            turnDotsOn(period, stepIndex) {
-                this.$refs['dots_' + period].forEach((dots) => {
-                    dots.turnOn(stepIndex)
-                })
-            },
-
-            /**
-             * Fetch all accounts set with the given period to get fresh OTPs
-             */
-            async updateTotps(period) {
-                this.isRenewingOTPs = true
-                this.axios.get('api/v1/twofaccounts?withOtp=1&ids=' + this.accountIdsWithPeriod(period).join(',')).then(response => {
-                    response.data.forEach((account) => {
-                        const index = this.accounts.findIndex(acc => acc.id === account.id)
-                        this.accounts[index].otp = account.otp
-                        
-                        this.$refs.loopers.forEach((looper) => {
-                            if (looper.period == period) {
-                                looper.generatedAt = account.otp.generated_at
-                                this.$nextTick(() => {
-                                    looper.startLoop()
-                                })
-                            }
-                        })
-                    })
-                })
-                .finally(() => {
-                    this.isRenewingOTPs = false
-                })
-            },
-
-            /**
-             * Return an array of all accounts (ids) set with the given period
-             */
-            accountIdsWithPeriod(period) {
-                return this.accounts.filter(a => a.period == period).map(item => item.id)
-            },
-
-            /**
-             * Route user to the appropriate submitting view
-             */
-            start() {
-                if( this.$root.userPreferences.useDirectCapture && this.$root.userPreferences.defaultCaptureMode === 'advancedForm' ) {
-                    this.$router.push({ name: 'createAccount' })
-                }
-                else if( this.$root.userPreferences.useDirectCapture && this.$root.userPreferences.defaultCaptureMode === 'livescan' ) {
-                    this.$router.push({ name: 'capture' })
-                }
-                else {
-                    this.$router.push({ name: 'start' })
-                }
-            },
-
-            /**
-             * Fetch accounts from db
-             */
-            fetchAccounts(forceRefresh = false) {
-                let accounts = []
-                this.selectedAccounts = []
-                const queryParam = this.$root.userPreferences.getOtpOnRequest ? '' : '?withOtp=1'
-                // const queryParam = '?withOtp=1'
-
-                this.axios.get('api/v1/twofaccounts' + queryParam).then(response => {
-                    response.data.forEach((data) => {
-                        accounts.push(data)
-                    })
-
-                    if ( this.accounts.length > 0 && !objectEquals(accounts, this.accounts, {depth: 1}) && !forceRefresh ) {
-                        this.$notify({ type: 'is-dark', text: '<span class="is-size-7">' + this.$t('commons.some_data_have_changed') + '</span><br /><a href="." class="button is-rounded is-warning is-small">' + this.$t('commons.reload') + '</a>', duration:-1, closeOnClick: false })
-                    }
-                    else if( this.accounts.length === 0 && accounts.length === 0 ) {
-                        // No account yet, we force user to land on the start view.
-                        this.$storage.set('accounts', this.accounts)
-                        this.$router.push({ name: 'start' });
-                    }
-                    else {
-                        this.accounts = accounts
-                        this.$storage.set('accounts', this.accounts)
-                        this.fetchGroups()
-                    }
-                })
-            },
-
-            /**
-             * Show account with a generated {{OTP}} rotation
-             */
-            showAccount(account) {
-                // In Edit mode clicking an account do not show the otpDisplayer but select the account
-                if(this.editMode) {
-                    this.selectAccount(account.id)
-                }
-                else {
-                    this.$root.showSpinner(this.$t('commons.generating_otp'));
-                    this.$refs.OtpDisplayer.show(account.id);
-                }
-            },
-
-            /**
-             * Select an account while in edit mode
-             */
-            selectAccount(accountId) {
-                for (var i=0 ; i<this.selectedAccounts.length ; i++) {
-                    if ( this.selectedAccounts[i] === accountId ) {
-                        this.selectedAccounts.splice(i,1);
-                        return
-                    }
-                }
-
-                this.selectedAccounts.push(accountId)
-            },
-
-            /**
-             * Get a fresh OTP for the provided account
-             */
-            getOTP(accountId) {
-                this.axios.get('api/v1/twofaccounts/' + accountId + '/otp').then(response => {
-                    this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard')+ ' '+response.data })
-                })
-            },
-
-
-            /**
-             * Save the account order in db
-             */
-            saveOrder() {
-                this.drag = false
-                this.axios.post('/api/v1/twofaccounts/reorder', {orderedIds: this.accounts.map(a => a.id)})
-            },
-
-            /**
-             * Delete accounts selected from the Edit mode
-             */
-            async destroyAccounts() {
-                if(confirm(this.$t('twofaccounts.confirm.delete'))) {
-
-                    let ids = []
-                    this.selectedAccounts.forEach(id => ids.push(id))
-
-                    let that = this
-                    await this.axios.delete('/api/v1/twofaccounts?ids=' + ids.join())
-                        .then(response => {
-                            ids.forEach(function(id) {
-                                that.accounts = that.accounts.filter(a => a.id !== id)
-                            })
-                            this.$notify({ type: 'is-success', text: this.$t('twofaccounts.accounts_deleted') })
-                        })
-
-                    // we fetch the accounts again to prevent the js collection being
-                    // desynchronize from the backend php collection
-                    this.fetchAccounts(true)
-                }
-            },
-
-            /**
-             * Export selected accounts
-             */
-            exportAccounts() {
-                let ids = []
-                this.selectedAccounts.forEach(id => ids.push(id))
-
-                this.axios.get('/api/v1/twofaccounts/export?ids=' + ids.join(), {responseType: 'blob'})
-                    .then((response) => {
-                        var blob = new Blob([response.data], {type: "application/json;charset=utf-8"});
-                        saveAs.saveAs(blob, "2fauth_export.json");
-                    })
-            },
-
-            /**
-             * Move accounts selected from the Edit mode to another group or withdraw them
-             */
-            async moveAccounts() {
-
-                let accountsIds = []
-                this.selectedAccounts.forEach(id => accountsIds.push(id))
-
-                // Backend will associate all accounts with the selected group in the same move
-                // or withdraw the accounts if destination is 'no group' (id = 0)
-                if(this.moveAccountsTo === 0) {
-                    await this.axios.patch('/api/v1/twofaccounts/withdraw?ids=' + accountsIds.join() )
-                }
-                else await this.axios.post('/api/v1/groups/' + this.moveAccountsTo + '/assign', {ids: accountsIds} )
-
-                // we fetch the accounts again to prevent the js collection being
-                // desynchronize from the backend php collection
-                this.fetchAccounts(true)
-                this.showGroupSelector = false
-
-                this.$notify({ type: 'is-success', text: this.$t('twofaccounts.accounts_moved') })
-
-            },
-
-            /**
-             * Get the existing group list
-             */
-            fetchGroups() {
-                let groups = []
-
-                this.axios.get('api/v1/groups').then(response => {
-                    response.data.forEach((data) => {
-                        groups.push(data)
-                    })
-
-                    if ( !objectEquals(groups, this.groups) ) {
-                        this.groups = groups
-                    }
-
-                    this.$storage.set('groups', this.groups)
-                })
-            },
-
-            /**
-             * Set the provided group as the active group
-             */
-            setActiveGroup(id) {
-
-                // In memomry saving
-                this.form.value = this.$root.userPreferences.activeGroup = id
-
-                // In db saving if the user set 2FAuth to memorize the active group
-                if( this.$root.userPreferences.rememberActiveGroup ) {
-                    this.form.put('/api/v1/user/preferences/activeGroup', {returnError: true})
-                    .then(response => {
-                        // everything's fine
-                    })
-                    .catch(error => {
-
-                        this.$router.push({ name: 'genericError', params: { err: error.response } })
-                    });
-                }
-
-                this.closeGroupSwitch()
-            },
-
-            /**
-             * Toggle the group switch visibility
-             */
-            toggleGroupSwitch: function(event) {
-
-                if (event) {
-                    this.showGroupSwitch ? this.closeGroupSwitch() : this.openGroupSwitch()
-                }
-            },
-
-            /**
-             * show the group switch which allow to select a group to activate
-             */
-            openGroupSwitch: function(event) {
-
-                this.showGroupSwitch = true
-            },
-
-            /**
-             * hide the group switch
-             */
-            closeGroupSwitch: function(event) {
-
-                this.showGroupSwitch = false
-            },
-
-            /**
-             * Toggle the accounts list between View mode and Edit mode
-             */
-            setEditModeTo(state) {
-                this.selectedAccounts = []
-                this.editMode = state
-            },
-
-            /**
-             *
-             */
-            displayService(service) {
-                return service ? service : this.$t('twofaccounts.no_service')
-            },
-
-            /**
-             *
-             */
-            clearSelected() {
-                this.selectedAccounts = []
-            },
-
-            /**
-             *
-             */
-            selectAll() {
-                if(this.editMode) {
-                    let that = this
-                    this.accounts.forEach(function(account) {
-                        if ( !that.selectedAccounts.includes(account.id) ) {
-                            that.selectedAccounts.push(account.id)
-                        }
-                    })
-                }
-            },
-
-            /**
-             *
-             */
-            sortAsc() {
-                this.accounts.sort((a, b) => a.service > b.service ? 1 : -1)
-                this.saveOrder()
-            },
-
-            /**
-             *
-             */
-            sortDesc() {
-                this.accounts.sort((a, b) => a.service < b.service ? 1 : -1)
-                this.saveOrder()
-            },
-
-            /**
-             *
-             */
-            keyListener : function(e) {
-                if (e.key === "f" && (e.ctrlKey || e.metaKey)) {
-                    e.preventDefault();
-                    const searchBox = document.getElementById('txtSearch');
-                    if (searchBox != undefined) {
-                        searchBox.focus()
-                    }
-                }
-            },
-        }
-    };
-
-</script>
-
-<style>
-    .flip-list-move {
-      transition: transform 0.5s;
-    }
-
-    .ghost {
-      opacity: 1;
-      /*background: hsl(0, 0%, 21%);*/
-    }
-</style>

+ 0 - 127
resources/js/views/Capture.vue

@@ -1,127 +0,0 @@
-<template>
-    <div class="modal is-active">
-        <div class="modal-background"></div>
-        <div class="modal-content">
-            <section class="section">
-                <div class="columns is-centered">
-                    <div class="column is-three-quarters">
-                        <div class="modal-slot box has-text-centered is-shadowless">
-                            <div v-if="errorText">
-                                <p class="block is-size-5">{{ $t('twofaccounts.stream.live_scan_cant_start') }}</p>
-                                <p class="block" :class="{'has-text-light': $root.showDarkMode}">{{ $t('twofaccounts.stream.' + errorText + '.reason') }}</p>
-                                <p class="is-size-7">{{ $t('twofaccounts.stream.' + errorText + '.solution') }}</p>
-                            </div>
-                            <span v-else class="is-size-4" :class="$root.showDarkMode ? 'has-text-light':'has-text-grey-dark'">
-                                <font-awesome-icon :icon="['fas', 'spinner']" size="2x" spin />
-                            </span>
-                        </div>
-                    </div>
-                </div>
-            </section>
-        </div>
-        <div class="fullscreen-streamer">
-            <qrcode-stream @decode="submitUri" @init="onStreamerInit" camera="auto" />
-        </div>
-        <div class="fullscreen-footer">
-            <!-- Cancel button -->
-            <button id="btnCancel" class="button is-large is-warning is-rounded" @click="exitStream()">
-                {{ $t('commons.cancel') }}
-            </button>
-        </div>
-    </div>
-</template>
-
-<script>
-
-    import { QrcodeStream } from 'vue-qrcode-reader'
-    import Form from './../components/Form'
-
-    export default {
-        data(){
-            return {
-                showStream: true,
-                errorText: '',
-                form: new Form({
-                    qrcode: null,
-                    uri: '',
-                }),
-            }
-        },
-
-        components: {
-            QrcodeStream,
-        },
-
-        methods: {
-
-            exitStream() {
-
-                this.camera = 'off'
-                this.$router.go(-1)
-
-            },
-
-            async onStreamerInit (promise) {
-
-                try {
-                    await promise
-                }
-                catch (error) {
-
-                    if (error.name === 'NotAllowedError') {
-                        this.errorText = 'need_grant_permission'
-
-                    } else if (error.name === 'NotReadableError') {
-                        this.errorText = 'not_readable'
-
-                    } else if (error.name === 'NotFoundError') {
-                        this.errorText = 'no_cam_on_device'
-
-                    } else if (error.name === 'NotSupportedError' || error.name === 'InsecureContextError') {
-                        this.errorText = 'secured_context_required'
-
-                    } else if (error.name === 'OverconstrainedError') {
-                        this.errorText = 'camera_not_suitable'
-
-                    } else if (error.name === 'StreamApiNotSupportedError') {
-                        this.errorText = 'stream_api_not_supported'
-                    }
-                }
-            },
-
-            /**
-             * Push a decoded URI to the Create or Import form
-             * 
-             * The basicQRcodeReader option is Off, so qrcode decoding has already be done by vue-qrcode-reader, whether
-             * from livescan or file input.
-             * We simply check the uri validity to prevent useless push to the form, but the form will check uri validity too.
-             */
-            async submitUri(event) {
-                
-                this.form.uri = event
-
-                if( !this.form.uri ) {
-                    this.$notify({type: 'is-warning', text: this.$t('errors.qrcode_cannot_be_read') })
-                }
-                else if( this.form.uri.slice(0, 33).toLowerCase() == "otpauth-migration://offline?data=" ) {
-                    this.pushUriToImportForm(this.form.uri)
-                }
-                else if( this.form.uri.slice(0, 15).toLowerCase() !== "otpauth://totp/" && this.form.uri.slice(0, 15).toLowerCase() !== "otpauth://hotp/" ) {
-                    this.$notify({type: 'is-warning', text: this.$t('errors.no_valid_otp') })
-                }
-                else {
-                    this.pushUriToCreateForm(this.form.uri)
-                }
-            },
-
-            pushUriToCreateForm(data) {
-                this.$router.push({ name: 'createAccount', params: { decodedUri: data } });
-            },
-
-            pushUriToImportForm(data) {
-                this.$router.push({ name: 'importAccounts', params: { migrationUri: data } });
-            }
-        }
-    }
-
-</script>

+ 42 - 93
resources/js/views/Error.vue

@@ -1,102 +1,51 @@
+<script setup>
+    import { useNotifyStore } from '@/stores/notify'
+    
+    const errorHandler = useNotifyStore()
+    const router = useRouter()
+    const route = useRoute()
+
+    const showModal = ref(true)
+    const showDebug = computed(() => process.env.NODE_ENV === 'development')
+
+    const props = defineProps({
+        closable: {
+            type: Boolean,
+            default: true
+        }
+    })
+
+    watch(showModal, (val) => {
+        if (val == false) {
+            exit()
+        }
+    })
+
+    /**
+     * Exits the error view
+     */
+    function exit() {
+        window.history.length > 1 && route.name !== '404' && route.name !== 'notFound'
+            ? router.go(-1)
+            : router.push({ name: 'accounts' })
+    }
+
+</script>
+
 <template>
-    <div class="error-message">
-        <modal v-model="ShowModal" :closable="this.showcloseButton">
-            <div class="error-message" v-if="$route.name == '404'">
+    <div>
+        <modal v-model="showModal" :closable="props.closable">
+            <div class="error-message" v-if="$route.name == '404' || $route.name == 'notFound'">
                 <p class="error-404"></p>
                 <p>{{ $t('errors.resource_not_found') }}</p>
-                <p class=""><router-link :to="{ name: 'accounts' }" class="is-text">{{ $t('errors.refresh') }}</router-link></p>
             </div>
-            <div v-else>
+            <div v-else class="error-message" >
                 <p class="error-generic"></p>
                 <p>{{ $t('errors.error_occured') }} </p>
-                <p v-if="error.message" class="has-text-grey-lighter">{{ error.message }}</p>
-                <p v-if="error.originalMessage" class="has-text-grey-lighter">{{ error.originalMessage }}</p>
-                <p><router-link :to="{ name: 'accounts', params: { toRefresh: true } }" class="is-text">{{ $t('errors.refresh') }}</router-link></p>
-                <p v-if="debugMode == 'development' && error.debug">
-                    <br>
-                    {{ error.debug }}
-                </p>
+                <p v-if="errorHandler.message" class="has-text-grey-lighter">{{ errorHandler.message }}</p>
+                <p v-if="errorHandler.originalMessage" class="has-text-grey-lighter">{{ errorHandler.originalMessage }}</p>
+                <p v-if="showDebug && errorHandler.debug" class="is-size-7 is-family-code"><br>{{ errorHandler.debug }}</p>
             </div>
         </modal>
     </div>
-</template>
-
-
-<script>
-    import Modal from '../components/Modal'
-
-    export default {
-        data(){
-            return {
-                ShowModal : true,
-                showcloseButton: this.closable,
-            }
-        },
-
-        computed: {
-
-            debugMode: function() {
-                return process.env.NODE_ENV
-            },
-
-            error: function() {
-                if( this.err === null || this.err === undefined ) {
-                    return false
-                }
-                else
-                {
-                    if (this.err.status === 407) {
-                        return {
-                            'message' : this.$t('errors.auth_proxy_failed'),
-                            'originalMessage' : this.$t('errors.auth_proxy_failed_legend')
-                        }
-                    }
-                    else if (this.err.status === 403) {
-                        return {
-                            'message' : this.$t('errors.unauthorized'),
-                            'originalMessage' : this.$t('errors.unauthorized_legend')
-                        }
-                    }
-                    else if(this.err.data) {
-                        return this.err.data
-                    }
-                    else {
-                        return { 'message' : this.err }
-                    }
-
-                }
-            }
-
-        },
-
-        props: {
-            err: [String, Object], // on object (error.response) or a string
-            closable: {
-                type: Boolean,
-                default: true
-            }
-        }, 
-
-        components: {
-            Modal
-        },
-
-        mounted(){
-            // stop OTP generation on modal close
-            this.$on('modalClose', function() {
-                window.history.length > 1 && this.$route.name !== '404' ? this.$router.go(-1) : this.$router.push({ name: 'accounts' })
-            });
-
-        },
-
-        beforeRouteEnter(to, from, next) {
-            next(vm => {
-                if (from.params.returnTo) {
-                    to.params.returnTo = from.params.returnTo
-                }
-            })
-        },
-    }
-
-</script>
-
+</template>

+ 0 - 127
resources/js/views/Groups.vue

@@ -1,127 +0,0 @@
-<template>
-    <responsive-width-wrapper>
-        <h1 class="title has-text-grey-dark">
-            {{ $t('groups.groups') }}
-        </h1>
-        <div class="is-size-7-mobile">
-            {{ $t('groups.manage_groups_legend')}}
-        </div>
-        <div class="mt-3 mb-6">
-            <router-link class="is-link mt-5" :to="{ name: 'createGroup' }">
-                <font-awesome-icon :icon="['fas', 'plus-circle']" /> {{ $t('groups.create_group') }}
-            </router-link>
-        </div>
-        <div v-if="groups.length > 0">
-            <div v-for="group in groups" :key="group.id" class="group-item is-size-5 is-size-6-mobile">
-                {{ group.name }}
-                <!-- delete icon -->
-                <button class="button tag is-pulled-right" :class="$root.showDarkMode ? 'is-dark' : 'is-white'" @click="deleteGroup(group.id)"  :title="$t('commons.delete')">
-                    {{ $t('commons.delete') }}
-                </button>
-                <!-- edit link -->
-                <router-link :to="{ name: 'editGroup', params: { groupId: group.id, name: group.name }}" class="has-text-grey px-1" :title="$t('commons.rename')">
-                    <font-awesome-icon :icon="['fas', 'pen-square']" />
-                </router-link>
-                <span class="is-family-primary is-size-6 is-size-7-mobile has-text-grey">{{ group.twofaccounts_count }} {{ $t('twofaccounts.accounts') }}</span>
-            </div>
-            <div class="mt-2 is-size-7 is-pulled-right" v-if="groups.length > 0">
-                {{ $t('groups.deleting_group_does_not_delete_accounts')}}
-            </div>
-        </div>
-        <div v-if="isFetching && groups.length === 0" class="has-text-centered">
-            <span class="is-size-4">
-                <font-awesome-icon :icon="['fas', 'spinner']" spin />
-            </span>
-        </div>
-        <!-- footer -->
-        <vue-footer :showButtons="true">
-            <!-- close button -->
-            <p class="control">
-                <router-link id="btnClose" :to="{ name: 'accounts', params: { toRefresh: true } }" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}">{{ $t('commons.close') }}</router-link>
-            </p>
-        </vue-footer>
-    </responsive-width-wrapper>
-</template>
-
-<script>
-
-    export default {
-        data() {
-            return {
-                groups : [],
-                TheAllGroup : null,
-                isFetching: false,
-            }
-        },
-
-        mounted() {
-            // Load groups for localstorage at first to avoid latency
-            const groups = this.$storage.get('groups', null) // use null as fallback if localstorage is empty
-            
-            // We don't want the pseudo group 'All' to be managed so we shift it
-            if( groups ) {
-                this.groups = groups
-                this.TheAllGroup = this.groups.shift()
-            }
-
-            // we refresh the collection whatever
-            this.fetchGroups()
-        },
-
-        methods: {
-
-            /**
-             * Get all groups from backend
-             */
-            async fetchGroups() {
-
-                this.isFetching = true
-
-                await this.axios.get('api/v1/groups').then(response => {
-                    const groups = []
-
-                    response.data.forEach((data) => {
-                        groups.push(data)
-                    })
-
-                    // Remove the 'All' pseudo group from the collection
-                    // and push it the TheAllGroup
-                    this.TheAllGroup = groups.shift()
-
-                    this.groups = groups
-                })
-
-                this.isFetching = false
-            },
-
-            /**
-             * Delete a group (after confirmation)
-             */
-            async deleteGroup(id) {
-                if(confirm(this.$t('groups.confirm.delete'))) {
-                    await this.axios.delete('/api/v1/groups/' + id).then(response => {
-                        // Remove the deleted group from the collection
-                        this.groups = this.groups.filter(a => a.id !== id)
-                        this.$notify({ type: 'is-success', text: this.$t('groups.group_successfully_deleted') })
-
-                        // Reset persisted group filter to 'All' (groupId=0)
-                        // (backend will save to change automatically)
-                        if( parseInt(this.$root.userPreferences.activeGroup) === id ) {
-                            this.$root.userPreferences.activeGroup = 0
-                        }
-                    })
-                }
-            }
-
-        },
-
-        beforeRouteLeave(to, from, next) {
-            // reinject the 'All' pseudo group before refreshing the localstorage
-            this.groups.unshift(this.TheAllGroup)
-            this.$storage.set('groups', this.groups)
-
-            next()
-        }
-
-    }
-</script>

+ 78 - 112
resources/js/views/Start.vue

@@ -1,9 +1,70 @@
+<script setup>
+    import Form from '@/components/formElements/Form'
+    import { useUserStore } from '@/stores/user'
+    import { useBusStore } from '@/stores/bus'
+    import { useNotifyStore } from '@/stores/notify'
+    import { UseColorMode } from '@vueuse/components'
+    import { useTwofaccounts } from '@/stores/twofaccounts'
+
+    const router = useRouter()
+    const user = useUserStore()
+    const bus = useBusStore()
+    const notify = useNotifyStore()
+    const twofaccounts = useTwofaccounts()
+
+    const qrcodeInput = ref(null)
+    const qrcodeInputLabel = ref(null)
+    const form = reactive(new Form({
+        qrcode: null,
+        inputFormat: 'fileUpload',
+    }))
+    
+
+    /**
+     * Upload the submitted QR code file to the backend for decoding, then route the user
+     * to the Create or Import form with decoded URI to prefill the form
+     */
+    function submitQrCode() {
+        form.clear()
+        form.qrcode = qrcodeInput.value.files[0]
+
+        form.upload('/api/v1/qrcode/decode', { returnError: true }).then(response => {
+            if (response.data.data.slice(0, 33).toLowerCase() === "otpauth-migration://offline?data=") {
+                bus.migrationUri = response.data.data
+                router.push({ name: 'importAccounts' })
+            }
+            else {
+                bus.decodedUri = response.data.data
+                router.push({ name: 'createAccount' })
+            }
+        })
+        .catch(error => {
+            if (error.response.status !== 422) {
+                notify.alert({ text: error.response.data.message })
+            }
+        })
+    }
+
+    /**
+     * Push user to the dedicated capture view for live scan
+     */
+    function capture() {
+        router.push({ name: 'capture' });
+    }
+
+    onMounted(() => {
+        if( user.preferences.useDirectCapture && user.preferences.defaultCaptureMode === 'upload' ) {
+            qrcodeInputLabel.value.click()
+        }
+    })
+</script>
+
 <template>
     <!-- static landing UI -->
     <div class="container has-text-centered">
         <div class="columns quick-uploader">
             <!-- trailer phrase that invite to add an account -->
-            <div class="column is-full quick-uploader-header" :class="{ 'is-invisible' : accountCount !== 0 }">
+            <div class="column is-full quick-uploader-header" :class="{ 'is-invisible' : twofaccounts.count !== 0 }">
                 {{ $t('twofaccounts.no_account_here') }}<br>
                 {{ $t('twofaccounts.add_first_account') }}
             </div>
@@ -11,7 +72,7 @@
             <div class="column is-full quick-uploader-button" >
                 <div class="quick-uploader-centerer">
                     <!-- upload a qr code (with basic file field and backend decoding) -->
-                    <label role="button" tabindex="0" v-if="$root.userPreferences.useBasicQrcodeReader" class="button is-link is-medium is-rounded is-main" ref="qrcodeInputLabel" @keyup.enter="$refs.qrcodeInputLabel.click()">
+                    <label role="button" tabindex="0" v-if="user.preferences.useBasicQrcodeReader" class="button is-link is-medium is-rounded is-main" ref="qrcodeInputLabel" @keyup.enter="qrcodeInputLabel.click()">
                         <input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="submitQrCode" ref="qrcodeInput">
                         {{ $t('twofaccounts.forms.upload_qrcode') }}
                     </label>
@@ -20,132 +81,37 @@
                         {{ $t('twofaccounts.forms.scan_qrcode') }}
                     </button>
                 </div>
+                <FieldError v-if="form.errors.hasAny('qrcode')" :error="form.errors.get('qrcode')" :field="'qrcode'" />
             </div>
             <!-- alternative methods -->
             <div class="column is-full">
-                <div class="block" :class="$root.showDarkMode ? 'has-text-light':'has-text-grey-dark'">{{ $t('twofaccounts.forms.alternative_methods') }}</div>
+                <UseColorMode v-slot="{ mode }">
+                    <div class="block" :class="mode == 'dark' ? 'has-text-light':'has-text-grey-dark'">{{ $t('twofaccounts.forms.alternative_methods') }}</div>
+                </UseColorMode>
                 <!-- upload a qr code -->
-                <div class="block has-text-link" v-if="!$root.userPreferences.useBasicQrcodeReader">
-                    <label role="button" tabindex="0" class="button is-link is-outlined is-rounded" ref="qrcodeInputLabel" @keyup.enter="$refs.qrcodeInputLabel.click()">
+                <div class="block has-text-link" v-if="!user.preferences.useBasicQrcodeReader">
+                    <label role="button" tabindex="0" class="button is-link is-outlined is-rounded" ref="qrcodeInputLabel" @keyup.enter="qrcodeInputLabel.click()">
                         <input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="submitQrCode" ref="qrcodeInput">
                         {{ $t('twofaccounts.forms.upload_qrcode') }}
                     </label>
                 </div>
                 <!-- link to advanced form -->
-                <div v-if="showAdvancedFormButton" class="block has-text-link">
-                    <router-link class="button is-link is-outlined is-rounded" :to="{ name: 'createAccount' }" >
+                <div class="block has-text-link">
+                    <RouterLink class="button is-link is-outlined is-rounded" :to="{ name: 'createAccount' }" >
                         {{ $t('twofaccounts.forms.use_advanced_form') }}
-                    </router-link>
+                    </RouterLink>
                 </div>
                 <!-- link to import view -->
-                <div v-if="showImportButton" class="block has-text-link">
-                    <router-link id="btnImport" class="button is-link is-outlined is-rounded" :to="{ name: 'importAccounts' }" >
+                <div class="block has-text-link">
+                    <RouterLink id="btnImport" class="button is-link is-outlined is-rounded" :to="{ name: 'importAccounts' }" >
                         {{ $t('twofaccounts.import.import') }}
-                    </router-link>
+                    </RouterLink>
                 </div>
             </div>
         </div>
         <!-- Footer -->
-        <vue-footer :showButtons="true" >
-            <!-- back button -->
-            <p class="control" v-if="accountCount > 0">
-                <router-link id="lnkBack" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" :to="{ name: returnToView }" >
-                    {{ $t('commons.back') }}
-                </router-link>
-            </p>
-        </vue-footer>
+        <VueFooter :showButtons="true" >
+            <ButtonBackCloseCancel :returnTo="{ name: 'accounts' }" action="back" v-if="!twofaccounts.isEmpty" />
+        </VueFooter>
     </div>
 </template>
-
-<script>
-
-    /**
-     *  Start view
-     *  
-     *  route: '/start'
-     *  
-     *  Offer the user all available possibilities for capturing an account :
-     *  - By sending the user to the live scanner
-     *  - By decoding a QR code submitted with a form 'File' field
-     *  - By sending the user to the advanced form
-     *
-     */
-
-    import Form from './../components/Form'
-
-    export default {
-        name: 'Start',
-
-        data(){
-            return {
-                accountCount: null,
-                form: new Form(),
-                alternativeMethod: null,
-            }
-        },
-
-        props: {
-            showAdvancedFormButton: {
-                type: Boolean,
-                default: true
-            },
-            showImportButton: {
-                type: Boolean,
-                default: true
-            },
-            returnToView: {
-                type: String,
-                default: 'accounts'
-            },
-        }, 
-
-        mounted() {
-
-            this.axios.get('api/v1/twofaccounts/count').then(response => {
-                this.accountCount = response.data.count
-            })
-        },
-
-        created() {
-
-            this.$nextTick(() => {
-                if( this.$root.userPreferences.useDirectCapture && this.$root.userPreferences.defaultCaptureMode === 'upload' ) {
-                    this.$refs.qrcodeInputLabel.click()
-                }
-            })
-        },
-
-        methods: {
-
-            /**
-             * Upload the submitted QR code file to the backend for decoding, then route the user
-             * to the Create or Import form with decoded URI to prefill the form
-             */
-            submitQrCode() {
-
-                let imgdata = new FormData();
-                imgdata.append('qrcode', this.$refs.qrcodeInput.files[0]);
-                imgdata.append('inputFormat', 'fileUpload');
-
-                this.form.upload('/api/v1/qrcode/decode', imgdata, {returnError: true}).then(response => {
-                    if( response.data.data.slice(0, 33).toLowerCase() === "otpauth-migration://offline?data=" ) {
-                        this.$router.push({ name: 'importAccounts', params: { migrationUri: response.data.data } });
-                    }
-                    else this.$router.push({ name: 'createAccount', params: { decodedUri: response.data.data } });
-                })
-                .catch(error => {
-                    this.$notify({type: 'is-danger', text: this.$t(error.response.data.message) })
-                });
-            },
-
-            /**
-             * Push user to the dedicated capture view for live scan
-             */
-            capture() {
-                this.$router.push({ name: 'capture' });
-            },
-
-        }
-    };
-
-</script>

+ 0 - 27
resources/js/views/auth/Autolock.vue

@@ -1,27 +0,0 @@
-<template>
-    <form-wrapper :title="$t('auth.autolock_triggered')" :punchline="$t('auth.autolock_triggered_punchline')">
-        <p>{{ $t('auth.change_autolock_in_settings') }}</p>
-        <div class="nav-links">
-            <p><router-link :to="{ name: 'login', params: {forceRefresh : true} }" class="button is-link">{{ $t('auth.sign_in') }}</router-link></p>
-        </div>
-        <!-- footer -->
-        <vue-footer></vue-footer>
-    </form-wrapper>
-</template>
-
-<script>
-    export default {
-        data(){
-            return {
-            }
-        },
-
-        mounted() {
-            this.axios.get('/user/logout', {returnError: true}).catch(error => {
-                // there is nothing to do, we simply catch the error to avoid redondant navigation
-            });
-            
-            this.clearStorage()
-        },
-    }
-</script>

+ 146 - 177
resources/js/views/auth/Login.vue

@@ -1,187 +1,156 @@
-<template>
-    <div>
-        <!-- webauthn authentication -->
-        <form-wrapper v-if="showWebauthn" :title="$t('auth.forms.webauthn_login')" :punchline="$t('auth.welcome_to_2fauth')">
-            <div class="field">
-                {{ $t('auth.webauthn.use_security_device_to_sign_in') }}
-            </div>
-            <form id="frmWebauthnLogin" @submit.prevent="webauthnLogin" @keydown="form.onKeydown($event)">
-                <form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" autofocus />
-                <form-buttons :isBusy="form.isBusy" :caption="$t('commons.continue')" :submitId="'btnContinue'"/>
-            </form>
-            <div class="nav-links">
-                <p>{{ $t('auth.webauthn.lost_your_device') }}&nbsp;<router-link id="lnkRecoverAccount" :to="{ name: 'webauthn.lost' }" class="is-link">{{ $t('auth.webauthn.recover_your_account') }}</router-link></p>
-                <p v-if="!this.$root.userPreferences.useWebauthnOnly">{{ $t('auth.sign_in_using') }}&nbsp;
-                    <a id="lnkSignWithLegacy" role="button" class="is-link" @keyup.enter="toggleForm" @click="toggleForm" tabindex="0">{{ $t('auth.login_and_password') }}</a>
-                </p>
-            </div>
-        </form-wrapper>
-        <!-- login/password legacy form -->
-        <form-wrapper v-else :title="$t('auth.forms.login')" :punchline="$t('auth.welcome_to_2fauth')">
-            <div v-if="isDemo" class="notification is-info has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_demo_app_use_those_credentials')" />
-            <div v-if="isTesting" class="notification is-warning has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_testing_app_use_those_credentials')" />
-            <form id="frmLegacyLogin" @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
-                <form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" autofocus />
-                <form-password-field :form="form" fieldName="password" :label="$t('auth.forms.password')" />
-                <form-buttons :isBusy="form.isBusy" :caption="$t('auth.sign_in')" :submitId="'btnSignIn'"/>
-            </form>
-            <div class="nav-links">
-                <p>{{ $t('auth.forms.forgot_your_password') }}&nbsp;<router-link id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">{{ $t('auth.forms.request_password_reset') }}</router-link></p>
-                <p >{{ $t('auth.sign_in_using') }}&nbsp;
-                    <a id="lnkSignWithWebauthn" role="button" class="is-link" @keyup.enter="toggleForm" @click="toggleForm" tabindex="0" :aria-label="$t('auth.sign_in_using_security_device')">{{ $t('auth.webauthn.security_device') }}</a>
-                </p>
-                <p v-if="this.$root.appSettings.disableRegistration == false" class="mt-4">{{ $t('auth.forms.dont_have_account_yet') }}&nbsp;<router-link id="lnkRegister" :to="{ name: 'register' }" class="is-link">{{ $t('auth.register') }}</router-link></p>
-            </div>
-        </form-wrapper>
-        <!-- footer -->
-        <vue-footer></vue-footer>
-    </div>
-</template>
-
-<script>
+<script setup>
+    import Form from '@/components/formElements/Form'
+    import { useUserStore } from '@/stores/user'
+    import { useNotifyStore } from '@/stores/notify'
+    import { useAppSettingsStore } from '@/stores/appSettings'
+    import { webauthnService } from '@/services/webauthn/webauthnService'
+
+    const $2fauth = inject('2fauth')
+    const router = useRouter()
+    const user = useUserStore()
+    const notify = useNotifyStore()
+    const appSettings = useAppSettingsStore()
+    const showWebauthnForm = user.preferences.useWebauthnOnly ? true : useStorage($2fauth.prefix + 'showWebauthnForm', false) 
+    const form = reactive(new Form({
+        email: '',
+        password: ''
+    }))
+    const isBusy = ref(false)
+
+    /**
+     * Toggle the form between legacy and webauthn method
+     */
+    function toggleForm() {
+        form.clear()
+        showWebauthnForm.value = ! showWebauthnForm.value
+    }
 
-    import Form from './../../components/Form'
-    import WebauthnService from './../../webauthn/webauthnService'
-    import { webauthnAbortService } from './../../webauthn/webauthnAbortService'
-    import { identifyAuthenticationError }  from './../../webauthn/identifyAuthenticationError'
+    /**
+     * Sign in using the login/password form
+     */
+    function LegacysignIn(e) {
+        notify.clear()
+
+        form.post('/user/login', {returnError: true}).then(async (response) => {
+            await user.loginAs({
+                name: response.data.name,
+                email: response.data.email,
+                preferences: response.data.preferences,
+                isAdmin: response.data.is_admin,
+            })
 
-    export default {
-        data(){
-            return {
-                isDemo: this.$root.isDemoApp,
-                isTesting: this.$root.isTestingApp,
-                form: new Form({
-                    email: '',
-                    password: ''
-                }),
-                isBusy: false,
-                showWebauthn: this.$root.userPreferences.useWebauthnOnly,
-                csrfRefresher: null
+            router.push({ name: 'accounts' })
+        })
+        .catch(error => {
+            if( error.response.status === 401 ) {
+                notify.alert({text: trans('auth.forms.authentication_failed'), duration: 10000 })
             }
-        },
-
-        mounted: function() {
-            this.csrfRefresher = setInterval(this.refreshToken, 300000) // 5 min
-            this.showWebauthn = this.$storage.get('showWebauthnForm', false)
-        },
-
-        methods : {
-            /**
-             * Toggle the form between legacy and webauthn method
-             */
-            toggleForm() {
-                this.showWebauthn = ! this.showWebauthn
-                this.$storage.set('showWebauthnForm', this.showWebauthn)
-            },
-
-            /**
-             * Sign in using the login/password form
-             */
-            handleSubmit(e) {
-                e.preventDefault()
-
-                this.form.post('/user/login', {returnError: true})
-                .then(response => {
-                    this.$storage.set('authenticated', true)
-                    this.applyPreferences(response.data.preferences)
-                    this.$router.push({ name: 'accounts', params: { toRefresh: true } })
-                })
-                .catch(error => {
-                    this.$storage.set('authenticated', false)
-
-                    if( error.response.status === 401 ) {
-
-                        this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
-                    }
-                    else if( error.response.status !== 422 ) {
-
-                        this.$router.push({ name: 'genericError', params: { err: error.response } });
-                    }
-                });
-            },
-
-            /**
-             * Sign in using the WebAuthn API
-             */
-            async webauthnLogin() {
-                this.isBusy = false
-                let webauthnService = new WebauthnService()
+            else if( error.response.status !== 422 ) {
+                notify.error(error)
+            }
+        })
+    }
 
-                // Check https context
-                if (!window.isSecureContext) {
-                    this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') })
-                    return false
-                }
+    /**
+     * Sign in using webauthn
+     */
+    function webauthnLogin() {
+        notify.clear()
+        form.clear()
+        isBusy.value = true
+
+        webauthnService.authenticate(form.email).then(async (response) => {
+            await user.loginAs({
+                name: response.data.name,
+                email: response.data.email,
+                preferences: response.data.preferences,
+                isAdmin: response.data.is_admin,
+            })
 
-                // Check browser support
-                if (webauthnService.doesntSupportWebAuthn) {
-                    this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
-                    return false
+            router.push({ name: 'accounts' })
+        })
+        .catch(error => {
+            if ('webauthn' in error) {
+                if (error.name == 'is-warning') {
+                    notify.warn({ text: trans(error.message) })
                 }
-
-                const loginOptions = await this.form.post('/webauthn/login/options').then(res => res.data)
-                const publicKey = webauthnService.parseIncomingServerOptions(loginOptions)
-
-                let options = { publicKey }
-                options.signal = webauthnAbortService.createNewAbortSignal()
-
-                const credentials = await navigator.credentials.get(options)
-                .catch(error => {
-                    const webauthnError = identifyAuthenticationError(error, options)
-                    this.$notify({ type: webauthnError.type, text: this.$t(webauthnError.phrase) })
-                })
-
-                if (!credentials) return false
-
-                let publicKeyCredential = webauthnService.parseOutgoingCredentials(credentials)
-                publicKeyCredential.email = this.form.email
-
-                this.axios.post('/webauthn/login', publicKeyCredential, {returnError: true}).then(response => {
-                    this.$storage.set('authenticated', true)
-                    this.applyPreferences(response.data.preferences);
-                    this.$router.push({ name: 'accounts', params: { toRefresh: true } })
-                })
-                .catch(error => {
-                    this.$storage.set('authenticated', false)
-
-                    if( error.response.status === 401 ) {
-                        
-                        this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
-                    }
-                    else if( error.response.status !== 422 ) {
-
-                        this.$router.push({ name: 'genericError', params: { err: error.response } });
-                    }
-                });
-
-                this.isBusy = false
-            },
-
-            refreshToken(){
-                this.axios.get('/refresh-csrf')
+                else notify.alert({ text: trans(error.message) })
             }
-        },
-
-        beforeRouteEnter (to, from, next) {
-            if (to.params.forceRefresh && from.name !== null) {
-                window.location.href = "." + to.path;
-                return;
+            else if( error.response.status === 401 ) {
+                notify.alert({text: trans('auth.forms.authentication_failed'), duration: 10000 })
             }
-
-            next();
-        },
-
-        beforeRouteLeave (to, from, next) {
-            this.$notify({
-                clean: true
-            })
-            clearInterval(this.csrfRefresher);
-
-            if (this.$root.appSettings.disableRegistration && to.name == 'register') {
-                this.$router.push({name: 'genericError', params: { err: this.$t('errors.unauthorized_legend') } })
+            else if( error.response.status == 422 ) {
+                form.errors.set(form.extractErrors(error.response))
             }
-
-            next()
-        }
+            else {
+                notify.error(error)
+            }
+        })
+        .finally(() => {
+            isBusy.value = false
+        })
     }
-</script>
+
+</script>
+
+<template>
+    <!-- webauthn authentication -->
+    <FormWrapper v-if="showWebauthnForm" title="auth.forms.webauthn_login" punchline="auth.welcome_to_2fauth">
+        <div class="field">
+            {{ $t('auth.webauthn.use_security_device_to_sign_in') }}
+        </div>
+        <form id="frmWebauthnLogin" @submit.prevent="webauthnLogin" @keydown="form.onKeydown($event)">
+            <FormField v-model="form.email" fieldName="email" :fieldError="form.errors.get('email')" inputType="email" label="auth.forms.email" autofocus />
+            <FormButtons :isBusy="isBusy" caption="commons.continue" submitId="btnContinue"/>
+        </form>
+        <div class="nav-links">
+            <p>
+                {{ $t('auth.webauthn.lost_your_device') }}&nbsp;
+                <RouterLink id="lnkRecoverAccount" :to="{ name: 'webauthn.lost' }" class="is-link">
+                    {{ $t('auth.webauthn.recover_your_account') }}
+                </RouterLink>
+            </p>
+            <p v-if="!user.preferences.useWebauthnOnly">{{ $t('auth.sign_in_using') }}&nbsp;
+                <a id="lnkSignWithLegacy" role="button" class="is-link" @keyup.enter="toggleForm" @click="toggleForm" tabindex="0">
+                    {{ $t('auth.login_and_password') }}
+                </a>
+            </p>
+            <p v-if="appSettings.disableRegistration == false" class="mt-4">
+                {{ $t('auth.forms.dont_have_account_yet') }}&nbsp;
+                <RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link">
+                    {{ $t('auth.register') }}
+                </RouterLink>
+            </p>
+        </div>
+    </FormWrapper>
+    <!-- login/password legacy form -->
+    <FormWrapper v-else title="auth.forms.login" punchline="auth.welcome_to_2fauth">
+        <div v-if="$2fauth.isDemoApp" class="notification is-info has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_demo_app_use_those_credentials')" />
+        <div v-if="$2fauth.isTestingApp" class="notification is-warning has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_testing_app_use_those_credentials')" />
+        <form id="frmLegacyLogin" @submit.prevent="LegacysignIn" @keydown="form.onKeydown($event)">
+            <FormField v-model="form.email" fieldName="email" :fieldError="form.errors.get('email')" inputType="email" label="auth.forms.email" autofocus />
+            <FormPasswordField v-model="form.password" fieldName="password" :fieldError="form.errors.get('password')" label="auth.forms.password" />
+            <FormButtons :isBusy="form.isBusy" caption="auth.sign_in" submitId="btnSignIn"/>
+        </form>
+        <div class="nav-links">
+            <p>{{ $t('auth.forms.forgot_your_password') }}&nbsp;
+                <RouterLink id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">
+                    {{ $t('auth.forms.request_password_reset') }}
+                </RouterLink>
+            </p>
+            <p >{{ $t('auth.sign_in_using') }}&nbsp;
+                <a id="lnkSignWithWebauthn" role="button" class="is-link" @keyup.enter="toggleForm" @click="toggleForm" tabindex="0" :aria-label="$t('auth.sign_in_using_security_device')">
+                    {{ $t('auth.webauthn.security_device') }}
+                </a>
+            </p>
+            <p v-if="appSettings.disableRegistration == false" class="mt-4">
+                {{ $t('auth.forms.dont_have_account_yet') }}&nbsp;
+                <RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link">
+                    {{ $t('auth.register') }}
+                </RouterLink>
+            </p>
+        </div>
+    </FormWrapper>
+    <!-- footer -->
+    <VueFooter/>
+</template>

+ 93 - 140
resources/js/views/auth/Register.vue

@@ -1,12 +1,89 @@
+<script setup>
+    import Form from '@/components/formElements/Form'
+    import { useUserStore } from '@/stores/user'
+    import { webauthnService } from '@/services/webauthn/webauthnService'
+    import { useNotifyStore } from '@/stores/notify'
+
+    const user = useUserStore()
+    const notify = useNotifyStore()
+    const router = useRouter()
+    const showWebauthnRegistration = ref(false)
+    const deviceId = ref(null)
+    
+    const registerForm = reactive(new Form({
+        name : '',
+        email : '',
+        password : '',
+        password_confirmation : '',
+    }))
+
+    const renameDeviceForm = reactive(new Form({
+        name : ''
+    }))
+
+    /**
+     * Register a new user
+     */
+    async function register(e) {
+        registerForm.password_confirmation = registerForm.password
+
+        registerForm.post('/user').then(response => {
+            user.$patch({
+                name: response.data.name,
+                email: response.data.email,
+                preferences: response.data.preferences,
+                isAdmin: response.data.is_admin ?? false,
+            })
+            user.applyTheme()
+
+            showWebauthnRegistration.value = true
+        })
+    }    
+
+    /**
+     * Register a new security device
+     */
+    function registerWebauthnDevice() {
+        webauthnService.register().then((response) => {
+            const publicKeyCredential = JSON.parse(response.config.data)
+
+            deviceId.value = publicKeyCredential.id
+        })
+        .catch(error => {
+            if( error.response.status === 422 ) {
+                notify.alert({ text: error.response.data.message })
+            }
+            else {
+                notify.error(error);
+            }
+        })
+    }
+
+    /**
+     * Rename the registered device
+     */
+    function RenameDevice(e) {
+        renameDeviceForm.patch('/webauthn/credentials/' + deviceId.value + '/name')
+        .then(() => {
+            notify.success({ text: trans('auth.webauthn.device_successfully_registered') })
+            router.push({ name: 'accounts' })
+        })
+    }
+
+    onBeforeRouteLeave(() => {
+        notify.clear()
+    })
+</script>
+
 <template>
     <div>
         <!-- webauthn registration -->
-        <form-wrapper v-if="showWebauthnRegistration" :title="$t('auth.authentication')" :punchline="$t('auth.webauthn.enhance_security_using_webauthn')">
-            <div v-if="deviceRegistered" class="field">
+        <FormWrapper v-if="showWebauthnRegistration" title="auth.authentication" punchline="auth.webauthn.enhance_security_using_webauthn">
+            <div v-if="deviceId" class="field">
                 <label id="lblDeviceRegistrationSuccess" class="label mb-5">{{ $t('auth.webauthn.device_successfully_registered') }}&nbsp;<font-awesome-icon :icon="['fas', 'check']" /></label>
-                <form @submit.prevent="handleDeviceSubmit" @keydown="deviceForm.onKeydown($event)">
-                    <form-field :form="deviceForm" fieldName="name" inputType="text" placeholder="iPhone 12, TouchID, Yubikey 5C" :label="$t('auth.forms.name_this_device')" />
-                    <form-buttons :isBusy="deviceForm.isBusy" :isDisabled="deviceForm.isDisabled" :caption="$t('commons.continue')" />
+                <form @submit.prevent="RenameDevice" @keydown="renameDeviceForm.onKeydown($event)">
+                    <FormField v-model="renameDeviceForm.name" fieldName="name" :fieldError="renameDeviceForm.errors.get('name')" inputType="text" placeholder="iPhone 12, TouchID, Yubikey 5C" label="auth.forms.name_this_device" />
+                    <FormButtons :isBusy="renameDeviceForm.isBusy" :isDisabled="renameDeviceForm.isDisabled" caption="commons.continue" />
                 </form>
             </div>
             <div v-else class="field is-grouped">
@@ -16,147 +93,23 @@
                 </div>
                 <!-- dismiss button -->
                 <div class="control">
-                    <router-link id="btnMaybeLater" :to="{ name: 'accounts', params: { toRefresh: true } }" class="button is-text">{{ $t('auth.maybe_later') }}</router-link>
+                    <RouterLink id="btnMaybeLater" :to="{ name: 'accounts' }" class="button is-text">{{ $t('auth.maybe_later') }}</RouterLink>
                 </div>
             </div>
-        </form-wrapper>
+        </FormWrapper>
         <!-- User registration form -->
-        <form-wrapper v-else :title="$t('auth.register')" :punchline="$t('auth.forms.register_punchline')">
-            <form @submit.prevent="handleRegisterSubmit" @keydown="registerForm.onKeydown($event)">
-                <form-field :form="registerForm" fieldName="name" inputType="text" :label="$t('auth.forms.name')" :maxLength="255" autofocus />
-                <form-field :form="registerForm" fieldName="email" inputType="email" :label="$t('auth.forms.email')" :maxLength="255" />
-                <form-password-field :form="registerForm" fieldName="password" :showRules="true" :label="$t('auth.forms.password')" />
-                <form-buttons :isBusy="registerForm.isBusy" :isDisabled="registerForm.isDisabled" :caption="$t('auth.register')" :submitId="'btnRegister'" />
+        <FormWrapper v-else title="auth.register" punchline="auth.forms.register_punchline">
+            <form @submit.prevent="register" @keydown="registerForm.onKeydown($event)">
+                <FormField v-model="registerForm.name" fieldName="name" :fieldError="registerForm.errors.get('name')" inputType="text" label="auth.forms.name" :maxLength="255" autofocus />
+                <FormField v-model="registerForm.email" fieldName="email" :fieldError="registerForm.errors.get('email')" inputType="email" label="auth.forms.email" :maxLength="255" />
+                <FormPasswordField v-model="registerForm.password" fieldName="password" :fieldError="registerForm.errors.get('password')" :showRules="true" label="auth.forms.password" />
+                <FormButtons :isBusy="registerForm.isBusy" :isDisabled="registerForm.isDisabled" caption="auth.register" submitId="btnRegister" />
             </form>
             <div class="nav-links">
-                <p>{{ $t('auth.forms.already_register') }}&nbsp;<router-link id="lnkSignIn" :to="{ name: 'login' }" class="is-link">{{ $t('auth.sign_in') }}</router-link></p>
+                <p>{{ $t('auth.forms.already_register') }}&nbsp;<RouterLink id="lnkSignIn" :to="{ name: 'login' }" class="is-link">{{ $t('auth.sign_in') }}</RouterLink></p>
             </div>
-        </form-wrapper>
+        </FormWrapper>
         <!-- footer -->
-        <vue-footer></vue-footer>
+        <VueFooter />
     </div>
 </template>
-
-<script>
-
-    import Form from './../../components/Form'
-    import WebauthnService from './../../webauthn/webauthnService'
-    import { webauthnAbortService } from './../../webauthn/webauthnAbortService'
-    import { identifyRegistrationError }  from './../../webauthn/identifyRegistrationError'
-
-    export default {
-        data(){
-            return {
-                registerForm: new Form({
-                    name : '',
-                    email : '',
-                    password : '',
-                    password_confirmation : '',
-                }),
-                deviceForm: new Form({
-                    name : '',
-                }),
-                showWebauthnRegistration: false,
-                deviceRegistered: false,
-                deviceId : null
-            }
-        },
-
-        methods : {
-            /**
-             * Register a new user
-             */
-            async handleRegisterSubmit(e) {
-                e.preventDefault()
-                this.registerForm.password_confirmation = this.registerForm.password
-
-                this.registerForm.post('/user', {returnError: true})
-                .then(response => {
-                    this.$storage.set('authenticated', true)
-                    this.showWebauthnRegistration = true
-                })
-                .catch(error => {
-                    if( error.response.status !== 422 ) {
-
-                        this.$router.push({ name: 'genericError', params: { err: error.response } });
-                    }
-                });
-            },
-
-
-            /**
-             * Register a new security device
-             */
-            async registerWebauthnDevice() {
-                let webauthnService = new WebauthnService()
-
-                // Check https context
-                if (!window.isSecureContext) {
-                    this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') })
-                    return false
-                }
-
-                // Check browser support
-                if (webauthnService.doesntSupportWebAuthn) {
-                    this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
-                    return false
-                }
-
-                const registerOptions = await this.axios.post('/webauthn/register/options').then(res => res.data)
-                const publicKey = webauthnService.parseIncomingServerOptions(registerOptions)
-                
-                let options = { publicKey }
-                options.signal = webauthnAbortService.createNewAbortSignal()
-
-                let bufferedCredentials
-                try {
-                    bufferedCredentials = await navigator.credentials.create(options)
-                }
-                catch (error) {
-                    const webauthnError = identifyRegistrationError(error, options)
-                    this.$notify({ type: webauthnError.type, text: this.$t(webauthnError.phrase) })
-
-                    return false
-                }
-
-                const publicKeyCredential = webauthnService.parseOutgoingCredentials(bufferedCredentials);
-
-                this.axios.post('/webauthn/register', publicKeyCredential, {returnError: true})
-                .then(response => {
-                    this.deviceId = publicKeyCredential.id
-                    this.deviceRegistered = true
-                })
-                .catch(error => {
-                    if( error.response.status === 422 ) {
-                        this.$notify({ type: 'is-danger', text: error.response.data.message })
-                    }
-                    else {
-                        this.$router.push({ name: 'genericError', params: { err: error.response } });
-                    }
-                })
-            },
-
-
-            /**
-             * Rename the registered device
-             */
-            async handleDeviceSubmit(e) {
-
-                await this.deviceForm.patch('/webauthn/credentials/' + this.deviceId + '/name')
-
-                if( this.deviceForm.errors.any() === false ) {
-                    this.$router.push({name: 'accounts', params: { toRefresh: true }})
-                }
-            },
-
-        },
-
-        beforeRouteLeave (to, from, next) {
-            this.$notify({
-                clean: true
-            })
-
-            next()
-        }
-    }
-</script>

+ 0 - 0
resources/js_vue3/views/auth/RequestReset.vue → resources/js/views/auth/RequestReset.vue


+ 0 - 60
resources/js/views/auth/password/Request.vue

@@ -1,60 +0,0 @@
-<template>
-    <form-wrapper :title="$t('auth.forms.reset_password')" :punchline="$t('auth.forms.reset_punchline')">
-        <form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
-            <form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" autofocus />
-            <form-buttons
-                :submitId="'btnSendResetPwd'"
-                :isBusy="form.isBusy"
-                :caption="$t('auth.forms.send_password_reset_link')"
-                :showCancelButton="true"
-                cancelLandingView="login" />
-        </form>
-        <!-- footer -->
-        <vue-footer></vue-footer>
-    </form-wrapper>
-</template>
-
-<script>
-
-    import Form from './../../../components/Form'
-
-    export default {
-        data(){
-            return {
-                form: new Form({
-                    email: '',
-                })
-            }
-        },
-        methods : {
-            handleSubmit(e) {
-                e.preventDefault()
-
-                this.form.post('/user/password/lost', {returnError: true})
-                .then(response => {
-                    
-                    this.$notify({ type: 'is-success', text: response.data.message, duration:-1 })
-                })
-                .catch(error => {
-                    if( error.response.data.requestFailed ) {
-
-                        this.$notify({ type: 'is-danger', text: error.response.data.requestFailed, duration:-1 })
-                    }
-                    else if( error.response.status !== 422 ) {
-
-                        this.$router.push({ name: 'genericError', params: { err: error.response } });
-                    }
-                });
-
-            }
-        },
-
-        beforeRouteLeave (to, from, next) {
-            this.$notify({
-                clean: true
-            })
-
-            next()
-        }
-    }
-</script>

+ 58 - 66
resources/js/views/auth/password/Reset.vue

@@ -1,70 +1,62 @@
-<template>
-    <form-wrapper :title="$t('auth.forms.new_password')">
-        <form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
-            <form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" :isDisabled="true" readonly />
-            <form-password-field :form="form" fieldName="password" :autocomplete="'new-password'" :showRules="true" :label="$t('auth.forms.new_password')" />
-            <field-error :form="form" field="token" />
-            <form-buttons v-if="pending" :isBusy="form.isBusy" :caption="$t('auth.forms.change_password')" :showCancelButton="true" cancelLandingView="login" />
-            <router-link v-if="!pending" id="btnContinue" :to="{ name: 'accounts' }" class="button is-link">{{ $t('commons.continue') }}</router-link>
-        </form>
-        <!-- footer -->
-        <vue-footer></vue-footer>
-    </form-wrapper>
-</template>
-
-<script>
-
-    import Form from './../../../components/Form'
-
-    export default {
-        data(){
-            return {
-                pending: true,
-                form: new Form({
-                    email : '',
-                    password : '',
-                    password_confirmation : '',
-                    token: ''
-                })
+<script setup>
+    import Form from '@/components/formElements/Form'
+    import { useNotifyStore } from '@/stores/notify'
+
+    const notify = useNotifyStore()
+    const router = useRouter()
+    const route = useRoute()
+    
+    const isPending = ref(true)
+    const form = reactive(new Form({
+        email : route.query.email,
+        password : '',
+        password_confirmation : '',
+        token: route.query.token
+    }))
+
+    /**
+     * Submits the password reset to the backend
+     */
+    function resetPassword(e) {
+        form.password_confirmation = form.password
+
+        form.post('/user/password/reset', {returnError: true})
+        .then(response => {
+            form.password = ''
+            form.password_confirmation = ''
+            isPending.value = false
+            notify.success({ text: response.data.message, duration:-1 })
+        })
+        .catch(error => {
+            if( error.response.data.resetFailed ) {
+                notify.alert({ text: error.response.data.resetFailed, duration:-1 })
             }
-        },
-
-        created () {
-            this.form.email = this.$route.query.email
-            this.form.token = this.$route.query.token
-        },
-
-        methods : {
-            handleSubmit(e) {
-                e.preventDefault()
-
-                this.form.password_confirmation = this.form.password
-
-                this.form.post('/user/password/reset', {returnError: true})
-                .then(response => {
-                    this.pending = false
-                    this.$notify({ type: 'is-success', text: response.data.message, duration:-1 })
-
-                })
-                .catch(error => {
-                    if( error.response.data.resetFailed ) {
-
-                        this.$notify({ type: 'is-danger', text: error.response.data.resetFailed, duration:-1 })
-                    }
-                    else if( error.response.status !== 422 ) {
-                        
-                        this.$router.push({ name: 'genericError', params: { err: error.response } });
-                    }
-                });
+            else if( error.response.status !== 422 ) {
+                notify.error(error)
             }
-        },
+        })
+    }
 
-        beforeRouteLeave (to, from, next) {
-            this.$notify({
-                clean: true
-            })
+    onBeforeRouteLeave(() => {
+        notify.clear()
+    })
+</script>
 
-            next()
-        }
-    }
-</script>
+<template>
+    <FormWrapper :title="$t('auth.forms.new_password')">
+        <form @submit.prevent="resetPassword" @keydown="form.onKeydown($event)">
+            <FormField v-model="form.email" :isDisabled="true" fieldName="email" :fieldError="form.errors.get('email')" label="auth.forms.email" autofocus />
+            <FormPasswordField v-model="form.password" fieldName="password" :fieldError="form.errors.get('password')" :autocomplete="'new-password'" :showRules="true" label="auth.forms.new_password" />
+            <FieldError v-if="form.errors.get('token') != undefined" :error="form.errors.get('token')" :field="form.token" />
+            <FormButtons
+                v-if="isPending"
+                :submitId="'btnResetPwd'"
+                :isBusy="form.isBusy"
+                :caption="$t('auth.forms.change_password')"
+                :showCancelButton="true"
+                cancelLandingView="login" />
+            <RouterLink v-if="!isPending" id="btnContinue" :to="{ name: 'accounts' }" class="button is-link">{{ $t('commons.continue') }}</RouterLink>
+        </form>
+        <VueFooter />
+    </FormWrapper>
+</template>

+ 0 - 55
resources/js/views/auth/webauthn/Lost.vue

@@ -1,55 +0,0 @@
-<template>
-    <form-wrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recovery_punchline')">
-        <form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
-            <form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" autofocus />
-            <form-buttons :isBusy="form.isBusy" :caption="$t('auth.webauthn.send_recovery_link')" :showCancelButton="true" cancelLandingView="login" />
-        </form>
-        <!-- footer -->
-        <vue-footer></vue-footer>
-    </form-wrapper>
-</template>
-
-<script>
-
-    import Form from './../../../components/Form'
-
-    export default {
-        data(){
-            return {
-                form: new Form({
-                    email: '',
-                })
-            }
-        },
-        methods : {
-            handleSubmit(e) {
-                e.preventDefault()
-
-                this.form.post('/webauthn/lost', {returnError: true})
-                .then(response => {
-                    
-                    this.$notify({ type: 'is-success', text: response.data.message, duration:-1 })
-                })
-                .catch(error => {
-                    if( error.response.data.requestFailed ) {
-
-                        this.$notify({ type: 'is-danger', text: error.response.data.requestFailed, duration:-1 })
-                    }
-                    else if( error.response.status !== 422 ) {
-
-                        this.$router.push({ name: 'genericError', params: { err: error.response } });
-                    }
-                });
-
-            }
-        },
-
-        beforeRouteLeave (to, from, next) {
-            this.$notify({
-                clean: true
-            })
-
-            next()
-        }
-    }
-</script>

+ 63 - 67
resources/js/views/auth/webauthn/Recover.vue

@@ -1,75 +1,71 @@
+<script setup>
+    import Form from '@/components/formElements/Form'
+    import { useNotifyStore } from '@/stores/notify'
+
+    const $2fauth = inject('2fauth')
+    const notify = useNotifyStore()
+    const router = useRouter()
+    const route = useRoute()
+    const showWebauthnForm = useStorage($2fauth.prefix + 'showWebauthnForm', false)
+    
+    const form = reactive(new Form({
+        email : route.query.email,
+        password : '',
+        token: route.query.token,
+        revokeAll: false,
+    }))
+
+    /**
+     * Submits the recovery to the backend
+     */
+    function recover(e) {
+        notify.clear()
+        form.post('/webauthn/recover', {returnError: true})
+        .then(response => {
+            showWebauthnForm.value = false
+            router.push({ name: 'login' })
+        })
+        .catch(error => {
+            if ( error.response.status === 401 ) {
+                notify.alert({ text: trans('auth.forms.authentication_failed'), duration:-1 })
+            }
+            else if (error.response.status === 422) {
+                notify.alert({ text: error.response.data.message, duration:-1 })
+            }
+            else  {
+                notify.error(error)
+            }
+        })
+    }
+
+    onBeforeRouteLeave(() => {
+        notify.clear()
+    })
+</script>
+
 <template>
-    <form-wrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recover_account_instructions')" >
+    <FormWrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recover_account_instructions')" >
         <div>
             <form @submit.prevent="recover" @keydown="form.onKeydown($event)">
-                <form-checkbox :form="form" fieldName="revokeAll" :label="$t('auth.webauthn.disable_all_security_devices')" :help="$t('auth.webauthn.disable_all_security_devices_help')" />
-                <form-password-field :form="form" :autocomplete="'current-password'" fieldName="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" />
+                <FormCheckbox v-model="form.revokeAll" fieldName="revokeAll" label="auth.webauthn.disable_all_security_devices" help="auth.webauthn.disable_all_security_devices_help" />
+                <FormPasswordField v-model="form.password" fieldName="password" :fieldError="form.errors.get('password')" :autocomplete="'current-password'" :showRules="false" label="auth.forms.current_password.label" help="auth.forms.current_password.help" />
                 <div class="field">
-                    <p>{{ $t('auth.forms.forgot_your_password') }}&nbsp;<router-link id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">{{ $t('auth.forms.request_password_reset') }}</router-link></p>
+                    <p>
+                        {{ $t('auth.forms.forgot_your_password') }}&nbsp;
+                        <RouterLink id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">
+                            {{ $t('auth.forms.request_password_reset') }}
+                        </RouterLink>
+                    </p>
                 </div>
-                <form-buttons :caption="$t('commons.continue')" :cancelLandingView="'login'" :showCancelButton="true" :isBusy="form.isBusy" :isDisabled="form.isDisabled" :submitId="'btnRecover'" />
+                <FormButtons
+                    :submitId="'btnRecover'"
+                    :isBusy="form.isBusy"
+                    :isDisabled="form.isDisabled"
+                    :caption="$t('commons.continue')"
+                    :showCancelButton="true"
+                    cancelLandingView="login" />
             </form>
         </div>
-        <!-- footer -->
-        <vue-footer></vue-footer>
-    </form-wrapper>
+        <VueFooter />
+    </FormWrapper>
 </template>
-
-<script>
-
-    import Form from './../../../components/Form'
-
-    export default {
-        data(){
-            return {
-                currentPassword: '',
-                deviceRegistered: false,
-                deviceId : null,
-                form: new Form({
-                    email: '',
-                    password: '',
-                    token: '',
-                    revokeAll: false,
-                }),
-            }
-        },
-
-        created () {
-            this.form.email = this.$route.query.email
-            this.form.token = this.$route.query.token
-        },
-
-        methods : {
-
-            /**
-             * Register a new security device
-             */
-            recover() {
-                this.form.post('/webauthn/recover', {returnError: true})
-                .then(response => {
-                    this.$router.push({ name: 'login', params: { forceRefresh: true } })
-                })
-                .catch(error => {
-                    if( error.response.status === 401 ) {
-
-                        this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
-                    }
-                    else if (error.response.status === 422) {
-                        this.$notify({ type: 'is-danger', text: error.response.data.message })
-                    }
-                    else {
-                        this.$router.push({ name: 'genericError', params: { err: error.response } });
-                    }
-                });
-            }
-        },
-
-        beforeRouteLeave (to, from, next) {
-            this.$notify({
-                clean: true
-            })
-
-            next()
-        }
-    }
-</script>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff