Переглянути джерело

Merge branch 'feature/vueQrcodeReader' into dev

Bubka 5 роки тому
батько
коміт
1acedf5e28

+ 4 - 4
app/Classes/Options.php

@@ -6,11 +6,11 @@ class Options
 {
 
     /**
-     * Build a collection of options to apply
+     * Compile both default and user options
      *
-     * @return Options collection
+     * @return Options collection or a signle
      */
-    public static function get()
+    public static function get($option = null)
     {
         // Get a collection of user saved options
         $userOptions = \Illuminate\Support\Facades\DB::table('options')->pluck('value', 'key');
@@ -32,7 +32,7 @@ class Options
         // fallback values for every options
         $options = collect(config('app.options'))->merge($userOptions);
 
-        return $options;
+        return !is_null($option) ? $options[$option] : $options;
     }
 
 

+ 22 - 10
app/Http/Controllers/QrCodeController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use Zxing\QrReader;
 use OTPHP\TOTP;
 use OTPHP\Factory;
+use App\Classes\Options;
 use Assert\AssertionFailedException;
 use Illuminate\Http\File;
 use Illuminate\Http\Request;
@@ -21,19 +22,30 @@ class QrCodecontroller extends Controller
     public function decode(Request $request)
     {
 
-        // input validation
-        $this->validate($request, [
-            'qrcode' => 'required|image',
-        ]);
+        if(Options::get('useBasicQrcodeReader')) {
 
-        // qrcode analysis
-        $path = $request->file('qrcode')->store('qrcodes');
-        $qrcode = new QrReader(storage_path('app/' . $path));
+            // input validation
+            $this->validate($request, [
+                'qrcode' => 'required|image',
+            ]);
+
+            // qrcode analysis
+            $path = $request->file('qrcode')->store('qrcodes');
+            $qrcode = new QrReader(storage_path('app/' . $path));
 
-        $uri = urldecode($qrcode->text());
+            $uri = urldecode($qrcode->text());
+
+            // delete uploaded file
+            Storage::delete($path);
+        }
+        else {
 
-        // delete uploaded file
-        Storage::delete($path);
+            $this->validate($request, [
+                'uri' => 'required|string',
+            ]);
+
+            $uri = $request->uri;
+        }
 
         // return the OTP object
         try {

+ 1 - 0
config/app.php

@@ -35,6 +35,7 @@ return [
         'isDemoApp' => env('IS_DEMO_APP', false),
         'showTokenAsDot' => false,
         'closeTokenOnCopy' => false,
+        'useBasicQrcodeReader' => false,
     ],
 
     /*

+ 64 - 0
package-lock.json

@@ -1570,6 +1570,22 @@
                 "object.assign": "^4.1.0"
             }
         },
+        "babel-runtime": {
+            "version": "6.26.0",
+            "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
+            "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
+            "requires": {
+                "core-js": "^2.4.0",
+                "regenerator-runtime": "^0.11.0"
+            },
+            "dependencies": {
+                "regenerator-runtime": {
+                    "version": "0.11.1",
+                    "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
+                    "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
+                }
+            }
+        },
         "balanced-match": {
             "version": "1.0.0",
             "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
@@ -1991,6 +2007,11 @@
                 "caller-callsite": "^2.0.0"
             }
         },
+        "callforth": {
+            "version": "0.3.1",
+            "resolved": "https://registry.npmjs.org/callforth/-/callforth-0.3.1.tgz",
+            "integrity": "sha512-Q2zPfqnwoKsb1DTVCr4lmhe49wKNBsMmNlbudjleu3/co+Nw1pOqFHYJHrW3VZ253ou9AAr+xauQR0C55NPdzA=="
+        },
         "callsites": {
             "version": "2.0.0",
             "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz",
@@ -2501,6 +2522,11 @@
             "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=",
             "dev": true
         },
+        "core-js": {
+            "version": "2.6.11",
+            "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz",
+            "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg=="
+        },
         "core-js-compat": {
             "version": "3.6.2",
             "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.6.2.tgz",
@@ -5510,6 +5536,11 @@
                 "graceful-fs": "^4.1.6"
             }
         },
+        "jsqr": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.2.0.tgz",
+            "integrity": "sha512-wKcQS9QC2VHGk7aphWCp1RrFyC0CM6fMgC5prZZ2KV/Lk6OKNoCod9IR6bao+yx3KPY0gZFC5dc+h+KFzCI0Wg=="
+        },
         "killable": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
@@ -7908,6 +7939,14 @@
                 "inherits": "^2.0.1"
             }
         },
+        "rtcpeerconnection-shim": {
+            "version": "1.2.15",
+            "resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz",
+            "integrity": "sha512-C6DxhXt7bssQ1nHb154lqeL0SXz5Dx4RczXZu2Aa/L1NJFnEVDxFwCBo3fqtuljhHIGceg5JKBV4XJ0gW5JKyw==",
+            "requires": {
+                "sdp": "^2.6.0"
+            }
+        },
         "run-queue": {
             "version": "1.0.3",
             "resolved": "https://registry.npmjs.org/run-queue/-/run-queue-1.0.3.tgz",
@@ -7984,6 +8023,11 @@
                 "ajv-keywords": "^3.1.0"
             }
         },
+        "sdp": {
+            "version": "2.12.0",
+            "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz",
+            "integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw=="
+        },
         "select-hose": {
             "version": "2.0.0",
             "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@@ -9289,6 +9333,17 @@
                 "vue": "^2.0.1"
             }
         },
+        "vue-qrcode-reader": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-2.1.1.tgz",
+            "integrity": "sha512-rIxV0RAuiomNi4n03L7XVbCKRtq4sT1LYtIk3osuBdJA/1W6y8yDtP4SvGpBdRCLaurfHaicpAZxQB98mejSCg==",
+            "requires": {
+                "babel-runtime": "^6.26.0",
+                "callforth": "^0.3.0",
+                "jsqr": "^1.2.0",
+                "webrtc-adapter": "^6.2.1"
+            }
+        },
         "vue-router": {
             "version": "3.1.6",
             "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.1.6.tgz",
@@ -9664,6 +9719,15 @@
                 }
             }
         },
+        "webrtc-adapter": {
+            "version": "6.4.8",
+            "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-6.4.8.tgz",
+            "integrity": "sha512-YM8yl545c/JhYcjGHgaCoA7jRK/KZuMwEDFeP2AcP0Auv5awEd+gZE0hXy9z7Ed3p9HvAXp8jdbe+4ESb1zxAw==",
+            "requires": {
+                "rtcpeerconnection-shim": "^1.2.14",
+                "sdp": "^2.9.0"
+            }
+        },
         "websocket-driver": {
             "version": "0.7.3",
             "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.3.tgz",

+ 1 - 0
package.json

@@ -34,6 +34,7 @@
         "vue-axios": "^2.1.5",
         "vue-i18n": "^8.16.0",
         "vue-pull-refresh": "^0.2.7",
+        "vue-qrcode-reader": "^2.1.1",
         "vue-router": "^3.1.6",
         "vuedraggable": "^2.23.2"
     }

+ 1 - 0
resources/js/app.js

@@ -4,6 +4,7 @@ import api          from './api'
 import i18n         from './langs/i18n'
 import FontAwesome  from './packages/fontawesome'
 import Clipboard    from './packages/clipboard'
+import QrcodeReader from './packages/qrcodeReader'
 import App          from './components/App'
 
 import './components'

+ 218 - 0
resources/js/components/QuickUploader.vue

@@ -0,0 +1,218 @@
+<template>
+    <div id="quick-uploader">
+        <!-- static landing UI -->
+        <div v-show="!(showStream && canStream)" 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' : !showTrailer }">
+                    {{ $t('twofaccounts.no_account_here') }}<br>
+                    {{ $t('twofaccounts.add_first_account') }}
+                </div>
+                <!-- add button -->
+                <div class="column is-full quick-uploader-button" >
+                    <div class="quick-uploader-centerer">
+                        <!-- scan button that launch camera stream -->
+                        <label v-if="canStream" class="button is-link is-medium is-rounded is-focused" @click="enableStream()">
+                            {{ $t('twofaccounts.forms.scan_qrcode') }}
+                        </label>
+                        <!-- or classic input field -->
+                        <form v-else @submit.prevent="createAccount" @keydown="form.onKeydown($event)">
+                            <label :class="{'is-loading' : form.isBusy}" class="button is-link is-medium is-rounded is-focused">
+                                <input v-if="$root.appSettings.useBasicQrcodeReader" class="file-input" type="file" accept="image/*" v-on:change="uploadQrcode" ref="qrcodeInput">
+                                <qrcode-capture v-else @decode="uploadQrcode" class="file-input" ref="qrcodeInput" />
+                                {{ $t('twofaccounts.forms.use_qrcode.val') }}
+                            </label>
+                            <field-error :form="form" field="qrcode" />
+                            <field-error :form="form" field="uri" />
+                        </form>
+                    </div>
+                </div>
+                <!-- Fallback link to classic form -->
+                <div class="column is-full quick-uploader-footer">
+                    <router-link :to="{ name: 'create' }" class="is-link">{{ $t('twofaccounts.use_full_form') }}</router-link>
+                </div>
+                <div v-if="showError" class="column is-full quick-uploader-footer">
+                    <notification :message="errorText" :isDeletable="false" type="is-danger" />
+                </div>
+            </div>
+        </div>
+        <!-- camera stream fullscreen scanner -->
+        <div v-show="showStream && canStream">
+            <div class="fullscreen-alert has-text-centered">
+                <span class="is-size-4 has-text-light">
+                    <font-awesome-icon :icon="['fas', 'spinner']" size="2x" spin />
+                </span>
+            </div>
+            <div class="fullscreen-streamer">
+                <qrcode-stream @decode="uploadQrcode" @init="onStreamerInit" :camera="camera" />
+            </div>
+            <div class="fullscreen-footer">
+                <!-- Cancel button -->
+                <label class="button is-large is-warning is-rounded" @click="disableStream()">
+                    {{ $t('commons.cancel') }}
+                </label>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+
+    import Form from './Form'
+
+    export default {
+        name: 'QuickUploader',
+
+        data(){
+            return {
+                form: new Form({
+                    qrcode: null,
+                    uri: '',
+                }),
+                errorName: '',
+                errorText: '',
+                showStream: false,
+                canStream: true,
+                camera: 'auto',
+            }
+        },
+
+        computed: {
+
+            debugMode: function() {
+                return process.env.NODE_ENV
+            },
+
+            showError: function() {
+                return this.debugMode == 'development' && this.errorName == 'NotAllowedError'
+            },
+        },
+
+        props: {
+            showTrailer: {
+                type: Boolean,
+                default: false
+            },
+
+            directStreaming: {
+                type: Boolean,
+                default: true
+            },
+        },
+
+        created() {
+            if( this.$root.appSettings.useBasicQrcodeReader ) {
+                // User has set the basic QR code reader so we disable the modern one
+                this.canStream = this.showStream = false
+            }
+            else {
+                if( this.directStreaming ) {
+                    this.enableStream()
+                }
+            }
+        },
+
+        beforeDestroy() {
+            this.form.clear()
+        },
+
+        methods: {
+
+            async enableStream() {
+
+                this.$parent.$emit('initStreaming')
+
+                this.camera = 'auto'
+                this.showStream = true
+
+                console.log('stream enabled')
+            },
+
+            async disableStream() {
+
+                this.camera = 'off'
+                this.showStream = false
+
+                this.$parent.$emit('stopStreaming')
+            },
+
+            async onStreamerInit (promise) {
+
+                this.errorText = ''
+                this.errorName = ''
+
+                try {
+                    await promise
+                }
+                catch (error) {
+
+                    this.errorName = error.name
+
+                    if (error.name === 'NotAllowedError') {
+                        this.errorText = this.$t('twofaccounts.stream.need_grant_permission')
+
+                    } else if (error.name === 'NotReadableError') {
+                        this.errorText = this.$t('twofaccounts.stream.not_readable')
+
+                    } else if (error.name === 'NotFoundError') {
+                        this.errorText = this.$t('twofaccounts.stream.no_cam_on_device')
+
+                    } else if (error.name === 'NotSupportedError' || error.name === 'InsecureContextError') {
+                        this.errorText = this.$t('twofaccounts.stream.secured_context_required')
+
+                    } else if (error.name === 'OverconstrainedError') {
+                        this.errorText = this.$t('twofaccounts.stream.camera_not_suitable')
+
+                    } else if (error.name === 'StreamApiNotSupportedError') {
+                        this.errorText = this.$t('twofaccounts.stream.stream_api_not_supported')
+                    }
+                }
+
+                this.setUploader()
+            },
+
+            setUploader() {
+
+                if( this.errorName ) {
+                    this.canStream = false
+                    console.log(this.errorText)
+                }
+
+                if( !this.errorName && !this.showStream ) {
+                    this.camera = 'off'
+                }
+
+                if( this.canStream && this.showStream) {
+                    this.$parent.$emit('startStreaming')
+                }
+            },
+
+            async uploadQrcode(event) {
+
+                var response
+
+                if(this.$root.appSettings.useBasicQrcodeReader) {
+                    let imgdata = new FormData();
+                    imgdata.append('qrcode', this.$refs.qrcodeInput.files[0]);
+
+                    response = await this.form.upload('/api/qrcode/decode', imgdata)
+                }
+                else {
+                    // We post the decoded URI instead of an image to decode
+                    this.form.uri = event
+
+                    if( !this.form.uri ) {
+                        return false
+                    }
+
+                    response = await this.form.post('/api/qrcode/decode')
+                }
+
+                this.$router.push({ name: 'create', params: { qrAccount: response.data } });
+
+            },
+
+        }
+    };
+
+</script>

+ 28 - 2
resources/js/langs/locales.js

@@ -97,6 +97,10 @@ export default {
                 "close_token_on_copy": {
                     "label": "Close token after copy",
                     "help": "Automatically close the popup showing the generated token after it has been copied"
+                },
+                "use_basic_qrcode_reader": {
+                    "label": "Use basic qrcode reader",
+                    "help": "If you experiences issues when capturing qrCodes enables this option to switch to a more basic but more reliable qrcode reader"
                 }
             }
         },
@@ -107,7 +111,7 @@ export default {
             "new": "New",
             "no_account_here": "No 2FA here!",
             "add_first_account": "Add your first account",
-            "use_full_form": "Use the full form",
+            "use_full_form": "Or use the full form",
             "add_one": "Add one",
             "manage": "Manage",
             "done": "Done",
@@ -122,6 +126,7 @@ export default {
                 "edit_account": "Edit account",
                 "otp_uri": "OTP Uri",
                 "hotp_counter": "HOTP Counter",
+                "scan_qrcode": "Scan a qrcode",
                 "use_qrcode": {
                     "val": "Use a qrcode",
                     "title": "Use a QR code to fill the form magically"
@@ -139,6 +144,14 @@ export default {
                 "save": "Save",
                 "test": "Test"
             },
+            "stream": {
+                "need_grant_permission": "You need to grant camera access permission",
+                "not_readable": "Fail to load scanner. Is the camera already in use?",
+                "no_cam_on_device": "No camera on this device",
+                "secured_context_required": "Secure context required (HTTPS or localhost)",
+                "camera_not_suitable": "Installed cameras are not suitable",
+                "stream_api_not_supported": "Stream API is not supported in this browser"
+            },
             "confirm": {
                 "delete": "Are you sure you want to delete this account?",
                 "cancel": "The account will be lost. Are you sure?"
@@ -366,6 +379,10 @@ export default {
                 "close_token_on_copy": {
                     "label": "Ne plus afficher les codes copiés",
                     "help": "Ferme automatiquement le popup affichant le code généré dès que ce dernier a été copié."
+                },
+                "use_basic_qrcode_reader": {
+                    "label": "Utiliser le lecteur de qrcode basique",
+                    "help": "Si vous rencontrez des problèmes lors de la lecture des qrCodes activez cette option pour utiliser un lecteur de qrcode moins évolué mais plus largement compatible"
                 }
             }
         },
@@ -376,7 +393,7 @@ export default {
             "new": "Nouveau",
             "no_account_here": "Aucun compte 2FA !",
             "add_first_account": "Ajouter votre premier compte",
-            "use_full_form": "Utiliser le formulaire détaillé",
+            "use_full_form": "Ou utiliser le formulaire détaillé",
             "add_one": "Add one",
             "manage": "Gérer",
             "done": "Terminé",
@@ -391,6 +408,7 @@ export default {
                 "edit_account": "Modifier le compte",
                 "otp_uri": "OTP Uri",
                 "hotp_counter": "Compteur HOTP",
+                "scan_qrcode": "Scanner un QR code",
                 "use_qrcode": {
                     "val": "Utiliser un QR code",
                     "title": "Utiliser un QR code pour renseigner le formulaire d'un seul coup d'un seul"
@@ -408,6 +426,14 @@ export default {
                 "save": "Enregistrer",
                 "test": "Tester"
             },
+            "stream": {
+                "need_grant_permission": "Vous devez autoriser l'utilisation de votre caméra",
+                "not_readable": "Le scanner ne se charge pas. La caméra est-elle déjà utilisée ?",
+                "no_cam_on_device": "Votre équipement ne dispose pas de caméra",
+                "secured_context_required": "Contexte sécurisé requis (HTTPS ou localhost)",
+                "camera_not_suitable": "Votre équipement ne dispose pas d'une caméra adaptée",
+                "stream_api_not_supported": "L'API Stream n'est pas supportée par votre navigateur"
+            },
             "confirm": {
                 "delete": "Etes-vous sûrs de vouloir supprimer le compte ?",
                 "cancel": "Les données seront perdues, êtes-vous sûrs ?"

+ 4 - 2
resources/js/packages/fontawesome.js

@@ -15,7 +15,8 @@ import {
     faLockOpen,
     faSearch,
     faEllipsisH,
-    faBars
+    faBars,
+    faSpinner
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
@@ -29,7 +30,8 @@ library.add(
     faLockOpen,
     faSearch,
     faEllipsisH,
-    faBars
+    faBars,
+    faSpinner
 );
 
 Vue.component('font-awesome-icon', FontAwesomeIcon)

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

@@ -0,0 +1,4 @@
+import Vue              from 'vue'
+import QrcodeReader  from 'vue-qrcode-reader'
+
+Vue.use(QrcodeReader)

+ 38 - 75
resources/js/views/Accounts.vue

@@ -1,5 +1,6 @@
 <template>
     <div>
+        <!-- show accounts list -->
         <div class="container" v-if="this.showAccounts">
             <!-- header -->
             <div class="columns is-gapless is-mobile is-centered">
@@ -32,8 +33,8 @@
                 readyLabel: '',
                 loadingLabel: 'refreshing'
                 }" > -->
-                <draggable v-model="filteredAccounts" @start="drag = true" @end="saveOrder" ghost-class="ghost" handle=".tfa-dots" animation="200" class="accounts columns is-multiline is-centered">
-                    <transition-group type="transition" :name="!drag ? 'flip-list' : null">
+                <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 is-centered" type="transition" :name="!drag ? 'flip-list' : null">
                         <div class="tfa column is-narrow has-text-white" v-for="account in filteredAccounts" :key="account.id">
                             <div class="tfa-container">
         	                    <transition name="slideCheckbox">
@@ -69,42 +70,17 @@
                 </draggable>
             <!-- </vue-pull-refresh> -->
         </div>
-        <!-- No account -->
-        <div class="container has-text-centered" v-show="showQuickForm">
-            <div class="columns is-mobile" :class="{ 'is-invisible' : this.accounts.length > 0}">
-                <div class="column quickform-header">
-                    {{ $t('twofaccounts.no_account_here') }}<br>
-                    {{ $t('twofaccounts.add_first_account') }}
-                </div>
-            </div>
-            <div class="container">
-                <form @submit.prevent="createAccount" @keydown="form.onKeydown($event)">
-                    <div class="columns is-mobile no-account is-vcentered">
-                        <div class="column has-text-centered">
-                            <label :class="{'is-loading' : form.isBusy}" class="button is-link is-medium is-rounded is-focused">
-                                <input class="file-input" type="file" accept="image/*" v-on:change="uploadQrcode" ref="qrcodeInput">
-                                {{ $t('twofaccounts.forms.use_qrcode.val') }}
-                            </label>
-                        </div>
-                    </div>
-                    <field-error :form="form" field="qrcode" />
-                </form>
-            </div>
-            <div class="columns is-mobile">
-                <div class="column quickform-footer">
-                    <router-link :to="{ name: 'create' }" class="is-link">{{ $t('twofaccounts.use_full_form') }}</router-link>
-                </div>
-            </div>
-        </div>
+        <!-- Show uploader (because no account) -->
+        <quick-uploader v-if="showUploader" :directStreaming="accounts.length > 0" :showTrailer="accounts.length === 0" ref="QuickUploader"></quick-uploader>
         <!-- modal -->
-        <modal v-model="ShowTwofaccountInModal">
+        <modal v-model="showTwofaccountInModal">
             <twofaccount-show ref="TwofaccountShow" ></twofaccount-show>
         </modal>
         <!-- footer -->
-        <vue-footer :showButtons="this.accounts.length > 0">
+        <vue-footer v-if="showFooter" :showButtons="accounts.length > 0">
             <!-- New item buttons -->
-            <p class="control" v-if="!showQuickForm && !editMode">
-                <a class="button is-link is-rounded is-focus" @click="showQuickForm = true">
+            <p class="control" v-if="!showUploader && !editMode">
+                <a class="button is-link is-rounded is-focus" @click="showUploader = true">
                     <span>{{ $t('twofaccounts.new') }}</span>
                     <span class="icon is-small">
                         <font-awesome-icon :icon="['fas', 'qrcode']" />
@@ -112,11 +88,11 @@
                 </a>
             </p>
             <!-- Manage button -->
-            <p class="control" v-if="!showQuickForm && !editMode">
+            <p class="control" v-if="!showUploader && !editMode">
                 <a class="button is-dark is-rounded" @click="setEditModeTo(true)">{{ $t('twofaccounts.manage') }}</a>
             </p>
             <!-- Done button -->
-            <p class="control" v-if="!showQuickForm && editMode">
+            <p class="control" v-if="!showUploader && editMode">
                 <a class="button is-success is-rounded" @click="setEditModeTo(false)">
                     <span>{{ $t('twofaccounts.done') }}</span>
                     <span class="icon is-small">
@@ -125,8 +101,8 @@
                 </a>
             </p>
             <!-- Cancel QuickFormButton -->
-            <p class="control" v-if="showQuickForm">
-                <a class="button is-dark is-rounded" @click="cancelQuickForm">
+            <p class="control" v-if="showUploader && showFooter">
+                <a class="button is-dark is-rounded" @click="showUploader = false">
                     {{ $t('commons.cancel') }}
                 </a>
             </p>
@@ -139,22 +115,21 @@
 
     import Modal from '../components/Modal'
     import TwofaccountShow from '../components/TwofaccountShow'
-    import Form from './../components/Form'
-    import vuePullRefresh from 'vue-pull-refresh';
+    import QuickUploader from './../components/QuickUploader'
+    // import vuePullRefresh from 'vue-pull-refresh';
     import draggable from 'vuedraggable'
 
+
     export default {
         data(){
             return {
                 accounts : [],
                 selectedAccounts: [],
-                ShowTwofaccountInModal : false,
+                showTwofaccountInModal : false,
                 search: '',
                 editMode: this.InitialEditMode,
-                showQuickForm: false,
-                form: new Form({
-                    qrcode: null
-                }),
+                showUploader: false,
+                showFooter: true,
                 drag: false,
             }
         },
@@ -174,13 +149,14 @@
             },
 
             showAccounts() {
-                return this.accounts.length > 0 && !this.showQuickForm ? true : false
+                return this.accounts.length > 0 && !this.showUploader ? true : false
             },
+
         },
 
         props: ['InitialEditMode'],
 
-        created() {
+        mounted() {
 
             this.fetchAccounts()
 
@@ -190,39 +166,30 @@
                 this.$refs.TwofaccountShow.clearOTP()
             });
 
+            // hide Footer when stream is on
+            this.$on('initStreaming', function() {
+                // this.showFooter = this.accounts.length > 0 ? false : true
+                this.showFooter = false
+            });
+
+            this.$on('stopStreaming', function() {
+
+                this.showUploader = this.accounts.length > 0 ? false : true
+                this.showFooter = true
+            });
+
         },
 
         components: {
             Modal,
             TwofaccountShow,
-            'vue-pull-refresh': vuePullRefresh,
+            // 'vue-pull-refresh': vuePullRefresh,
+            QuickUploader,
             draggable,
         },
 
         methods: {
 
-            onRefresh() {
-                var that = this
-
-                return new Promise(function (resolve, reject) {
-                    setTimeout(function () {
-                        that.fetchAccounts()
-                        resolve();
-                    }, 1000);
-                });
-            },
-
-            async uploadQrcode(event) {
-
-                let imgdata = new FormData();
-                imgdata.append('qrcode', this.$refs.qrcodeInput.files[0]);
-
-                const { data } = await this.form.upload('/api/qrcode/decode', imgdata)
-
-                this.$router.push({ name: 'create', params: { qrAccount: data } });
-
-            },
-
             fetchAccounts() {
                 this.accounts = []
                 this.selectedAccounts = []
@@ -237,7 +204,7 @@
                         })
                     })
                     
-                    this.showQuickForm = response.data.length === 0 ? true: false
+                    this.showUploader = response.data.length === 0 ? true : false
                 })
             },
 
@@ -263,7 +230,7 @@
                 this.axios.patch('/api/twofaccounts/reorder', {orderedIds: this.accounts.map(a => a.id)})
             },
 
-            deleteAccount:  function (id) {
+            deleteAccount(id) {
                 if(confirm(this.$t('twofaccounts.confirm.delete'))) {
                     this.axios.delete('/api/twofaccounts/' + id)
 
@@ -299,10 +266,6 @@
                 this.$parent.showToolbar = state
             },
 
-            cancelQuickForm() {
-                this.form.clear()
-                this.showQuickForm = false
-            }
         },
         
         beforeRouteEnter (to, from, next) {

+ 2 - 0
resources/js/views/settings/Options.vue

@@ -8,6 +8,7 @@
             <form-select :options="options" :form="form" fieldName="lang" :label="$t('settings.forms.language.label')"  :help="$t('settings.forms.language.help')" />
             <form-switch :form="form" fieldName="showTokenAsDot" :label="$t('settings.forms.show_token_as_dot.label')" :help="$t('settings.forms.show_token_as_dot.help')" />
             <form-switch :form="form" fieldName="closeTokenOnCopy" :label="$t('settings.forms.close_token_on_copy.label')" :help="$t('settings.forms.close_token_on_copy.help')" />
+            <form-switch :form="form" fieldName="useBasicQrcodeReader" :label="$t('settings.forms.use_basic_qrcode_reader.label')" :help="$t('settings.forms.use_basic_qrcode_reader.help')" />
         </form>
     </form-wrapper>
 </template>
@@ -25,6 +26,7 @@
                     lang: this.$root.$i18n.locale,
                     showTokenAsDot: this.$root.appSettings.showTokenAsDot,
                     closeTokenOnCopy: this.$root.appSettings.closeTokenOnCopy,
+                    useBasicQrcodeReader: this.$root.appSettings.useBasicQrcodeReader,
                 }),
                 options: [
                     { text: this.$t('languages.en'), value: 'en' },

+ 4 - 0
resources/lang/en/settings.php

@@ -35,6 +35,10 @@ return [
             'label' => 'Close token after copy',
             'help' => 'Automatically close the popup showing the generated token after it has been copied'
         ],
+        'use_basic_qrcode_reader' => [
+            'label' => 'Use basic qrcode reader',
+            'help' => 'If you experiences issues when capturing qrCodes enables this option to switch to a more basic but more reliable qrcode reader'
+        ],
     ],
     
 

+ 10 - 1
resources/lang/en/twofaccounts.php

@@ -19,7 +19,7 @@ return [
     'new' => 'New',
     'no_account_here' => 'No 2FA here!',
     'add_first_account' => 'Add your first account',
-    'use_full_form' => 'Use the full form',
+    'use_full_form' => 'Or use the full form',
     'add_one' => 'Add one',
     'manage' => 'Manage',
     'done' => 'Done',
@@ -34,6 +34,7 @@ return [
         'edit_account' => 'Edit account',
         'otp_uri' => 'OTP Uri',
         'hotp_counter' => 'HOTP Counter',
+        'scan_qrcode' => 'Scan a qrcode',
         'use_qrcode' => [
             'val' => 'Use a qrcode',
             'title' => 'Use a QR code to fill the form magically',
@@ -51,6 +52,14 @@ return [
         'save' => 'Save',
         'test' => 'Test',
     ],
+    'stream' => [
+        'need_grant_permission' => 'You need to grant camera access permission',
+        'not_readable' => 'Fail to load scanner. Is the camera already in use?',
+        'no_cam_on_device' => 'No camera on this device',
+        'secured_context_required' => 'Secure context required (HTTPS or localhost)',
+        'camera_not_suitable' => 'Installed cameras are not suitable',
+        'stream_api_not_supported' => 'Stream API is not supported in this browser'
+    ],
     'confirm' => [
         'delete' => 'Are you sure you want to delete this account?',
         'cancel' => 'The account will be lost. Are you sure?'

+ 4 - 0
resources/lang/fr/settings.php

@@ -35,6 +35,10 @@ return [
             'label' => 'Ne plus afficher les codes copiés',
             'help' => 'Ferme automatiquement le popup affichant le code généré dès que ce dernier a été copié.'
         ],
+        'use_basic_qrcode_reader' => [
+            'label' => 'Utiliser le lecteur de qrcode basique',
+            'help' => 'Si vous rencontrez des problèmes lors de la lecture des qrCodes activez cette option pour utiliser un lecteur de qrcode moins évolué mais plus largement compatible'
+        ],
 
     ],
     

+ 10 - 1
resources/lang/fr/twofaccounts.php

@@ -19,7 +19,7 @@ return [
     'new' => 'Nouveau',
     'no_account_here' => 'Aucun compte 2FA !',
     'add_first_account' => 'Ajouter votre premier compte',
-    'use_full_form' => 'Utiliser le formulaire détaillé',
+    'use_full_form' => 'Ou utiliser le formulaire détaillé',
     'add_one' => 'Add one',
     'manage' => 'Gérer',
     'done' => 'Terminé',
@@ -34,6 +34,7 @@ return [
         'edit_account' => 'Modifier le compte',
         'otp_uri' => 'OTP Uri',
         'hotp_counter' => 'Compteur HOTP',
+        'scan_qrcode' => 'Scanner un QR code',
         'use_qrcode' => [
             'val' => 'Utiliser un QR code',
             'title' => 'Utiliser un QR code pour renseigner le formulaire d\'un seul coup d\'un seul'
@@ -51,6 +52,14 @@ return [
         'save' => 'Enregistrer',
         'test' => 'Tester',
     ],
+    'stream' => [
+        'need_grant_permission' => 'Vous devez autoriser l\'utilisation de votre caméra',
+        'not_readable' => 'Le scanner ne se charge pas. La caméra est-elle déjà utilisée ?',
+        'no_cam_on_device' => 'Votre équipement ne dispose pas de caméra',
+        'secured_context_required' => 'Contexte sécurisé requis (HTTPS ou localhost)',
+        'camera_not_suitable' => 'Votre équipement ne dispose pas d\'une caméra adaptée',
+        'stream_api_not_supported' => 'L\'API Stream n\'est pas supportée par votre navigateur'
+    ],
     'confirm' => [
         'delete' => 'Etes-vous sûrs de vouloir supprimer le compte ?',
         'cancel' => 'Les données seront perdues, êtes-vous sûrs ?'

+ 44 - 8
resources/sass/app.scss

@@ -1,6 +1,7 @@
 @import '~bulma';
 @import '~bulma-checkradio';
 @import '~bulma-switch';
+@import "~vue-qrcode-reader/dist/vue-qrcode-reader.css";
 
 a:hover {
   color: hsl(204, 86%, 53%);
@@ -166,6 +167,30 @@ a:hover {
   display: block;
 }
 
+.fullscreen-streamer {
+  position: fixed;
+  top: 0;
+  left: 0;
+  height: 100vh;
+  width: 100%;
+}
+
+.fullscreen-alert {
+  position: fixed;
+  top: 25vh;
+  left: 0;
+  width: 100%;
+  padding: 0.75rem;
+}
+
+.fullscreen-footer {
+  position: fixed;
+  top: calc(100vh - 8rem );
+  left: 0;
+  width: 100%;
+  text-align: center;
+}
+
 .has-ellipsis {
   text-overflow: ellipsis;
   overflow: hidden;
@@ -319,27 +344,38 @@ footer .field.is-grouped {
   margin-bottom: 0.5rem;
 }
 
-.quickform-header {
-  height: 20vh;
-  padding-top: 2rem;
+.quick-uploader {
+  flex-direction: column
 }
 
-.quickform-footer {
-  padding-top: 3rem;
+.quick-uploader-header {
+  padding-top: 7vh;
+  padding-bottom: 7vh;
 }
 
 .preview {
   margin-top: 20vh;
 }
 
-.no-account {
+.quick-uploader-button {
+  height: 256px;
+  padding-top: 0;
+  padding-bottom: 0;
+  margin-bottom: 2rem;
+}
+
+.quick-uploader-centerer {
+  display: flex;
+  justify-content: center;
+  flex-direction: column;
+  align-items: center;
   height: 256px;
+  width: 100%;
 }
 
-.no-account::before {
+.quick-uploader-button::before {
   content: "";
   position: absolute;
-  top: 0; 
   left: 0;
   width: 100%; 
   opacity: 0.05;