Bladeren bron

Convert the standard Create form to an advanced form

Bubka 4 jaren geleden
bovenliggende
commit
207ee2d3fb

+ 6 - 4
app/Classes/OTP.php

@@ -31,19 +31,21 @@ class OTP
             // $remainingTime = $nextOtpAt - time()
 
             return $totp = [
-                'otp' => $twofaccount->token(),
+                'token' => $twofaccount->token(),
                 'position' => $positionInCurrentPeriod
             ];
         }
         else {
             // It's a HOTP
             $hotp = [
-                'otp' => $twofaccount->token(),
-                'counter' => $twofaccount->hotpCounter
+                'token' => $twofaccount->token(),
+                'hotpCounter' => $twofaccount->hotpCounter
             ];
 
             // now we update the counter for the next OTP generation
-            $twofaccount->increaseCounter();
+            $twofaccount->increaseHotpCounter();
+
+            $hotp['nextHotpCounter'] = $twofaccount->hotpCounter;
             $hotp['nextUri'] = $twofaccount->uri;
 
             if( !$isPreview ) {

+ 35 - 11
app/Http/Controllers/TwoFAccountController.php

@@ -34,19 +34,36 @@ class TwoFAccountController extends Controller
 
         // see https://github.com/google/google-authenticator/wiki/Key-Uri-Format
         // for otpauth uri format validation
+
         $this->validate($request, [
-            'service' => 'required',
-            'uri' => 'required|regex:/^otpauth:\/\/[h,t]otp\//i',
+            'service' => 'required|string',
+            'account' => 'nullable|string',
+            'icon' => 'nullable|string',
+            'uri' => 'nullable|string|regex:/^otpauth:\/\/[h,t]otp\//i',
+            'otpType' => 'required_without:uri|in:TOTP,HOTP',
+            'secret' => 'required_without:uri|string',
+            'digits' => 'nullable|integer|between:6,10',
+            'algorithm' => 'nullable|in:sha1,sha256,sha512,md5',
+            'totpPeriod' => 'nullable|integer|min:1',
+            'hotpCounter' => 'nullable|integer|min:0',
+            'imageLink' => 'nullable|url',
         ]);
 
-        OTP::get($request->uri);
+        // Two possible cases :
+        // - The most common case, the uri is provided thanks to a QR code live scan or file upload
+        //     -> We use this uri to populate the account
+        // - The advanced form has been used and provide no uri but all individual parameters
+        //     -> We use the parameters collection to populate the account
+        $twofaccount = new TwoFAccount;
 
-        $twofaccount = TwoFAccount::create([
-            'service' => $request->service,
-            'account' => $request->account,
-            'uri' => $request->uri,
-            'icon' => $request->icon
-        ]);
+        if( $request->uri ) {
+            $twofaccount->populateFromUri($request->uri);
+        }
+        else {
+            $twofaccount->populate($request->all());
+        }
+
+        $twofaccount->save();
 
         // Possible group association
         $groupId = Options::get('defaultGroup') === '-1' ? (int) Options::get('activeGroup') : (int) Options::get('defaultGroup');
@@ -103,10 +120,17 @@ class TwoFAccountController extends Controller
             // The request data is the Id of the account
             $twofaccount = TwoFAccount::FindOrFail($request->id);
         }
+        else if( $request->otp['uri'] ) {
+            // The request data contain an uri
+            $twofaccount = new TwoFAccount;
+            $twofaccount->populateFromUri($request->otp['uri']);
+
+            $isPreview = true;  // HOTP generated for preview (in the Create form) will not have its counter updated
+        }
         else {
-            // The request data is supposed to be a valid uri
+            // The request data should contain all otp parameter
             $twofaccount = new TwoFAccount;
-            $twofaccount->populateFromUri($request->uri);
+            $twofaccount->populate($request->otp);
 
             $isPreview = true;  // HOTP generated for preview (in the Create form) will not have its counter updated
         }

+ 23 - 10
app/TwoFAccount.php

@@ -286,7 +286,7 @@ class TwoFAccount extends Model implements Sortable
     {
         // The Type and Secret attributes are mandatory
         // All other attributes have default value set by OTPHP
-        
+
         if( strcasecmp($attrib['otpType'], 'totp') == 0 && strcasecmp($attrib['otpType'], 'hotp') == 0 ) {
             throw \Illuminate\Validation\ValidationException::withMessages([
                 'otpType' => __('errors.not_a_supported_otp_type')
@@ -306,20 +306,33 @@ class TwoFAccount extends Model implements Sortable
             $this->otp = strtolower($attrib['otpType']) === 'totp' ? TOTP::create($secret) : HOTP::create($secret);
 
             // and we change parameters if needed
-            if ($attrib['service']) {
+            if (array_key_exists('service', $attrib) && $attrib['service']) {
                 $this->service = $attrib['service'];
                 $this->otp->setIssuer( $attrib['service'] );
             }
-            if ($attrib['account']) {
+
+            if (array_key_exists('account', $attrib) && $attrib['account']) {
                 $this->account = $attrib['account'];
                 $this->otp->setLabel( $attrib['account'] );
             }
-            if ($attrib['icon']) { $this->account = $attrib['icon']; }
-            if ($attrib['digits'] > 0) { $this->otp->setParameter( 'digits', (int) $attrib['digits'] ); }
-            if ($attrib['algorithm']) { $this->otp->setParameter( 'digest', $attrib['algorithm'] ); }
-            if ($attrib['totpPeriod'] && $attrib['otpType'] !== 'totp') { $this->otp->setParameter( 'period', (int) $attrib['totpPeriod'] ); }
-            if ($attrib['hotpCounter'] && $attrib['otpType'] !== 'hotp') { $this->otp->setParameter( 'counter', (int) $attrib['hotpCounter'] ); }
-            if ($attrib['imageLink']) { $this->otp->setParameter( 'image', $attrib['imageLink'] ); }
+
+            if (array_key_exists('icon', $attrib) && $attrib['icon'])
+                { $this->icon = $attrib['icon']; }
+
+            if (array_key_exists('digits', $attrib) && $attrib['digits'] > 0)
+                { $this->otp->setParameter( 'digits', (int) $attrib['digits'] ); }
+
+            if (array_key_exists('digest', $attrib) && $attrib['algorithm'])
+                { $this->otp->setParameter( 'digest', $attrib['algorithm'] ); }
+
+            if (array_key_exists('totpPeriod', $attrib) && $attrib['totpPeriod'] && $attrib['otpType'] !== 'totp')
+                { $this->otp->setParameter( 'period', (int) $attrib['totpPeriod'] ); }
+
+            if (array_key_exists('hotpCounter', $attrib) && $attrib['hotpCounter'] && $attrib['otpType'] !== 'hotp')
+                { $this->otp->setParameter( 'counter', (int) $attrib['hotpCounter'] ); }
+
+            if (array_key_exists('imageLink', $attrib) && $attrib['imageLink'])
+                { $this->otp->setParameter( 'image', $attrib['imageLink'] ); }
 
             // We can now generate a fresh URI
             $this->uri = $this->otp->getProvisioningUri();
@@ -327,7 +340,7 @@ class TwoFAccount extends Model implements Sortable
         }
         catch (\Exception $e) {
             throw \Illuminate\Validation\ValidationException::withMessages([
-                'qrcode' => __('errors.cannot_create_otp_without_parameters')
+                'qrcode' => __('errors.cannot_create_otp_with_those_parameters')
             ]);
         }
 

+ 84 - 52
resources/js/components/TwofaccountShow.vue → resources/js/components/TokenDisplayer.vue

@@ -5,84 +5,107 @@
         </figure>
         <p class="is-size-4 has-text-grey-light has-ellipsis">{{ internal_service }}</p>
         <p class="is-size-6 has-text-grey has-ellipsis">{{ internal_account }}</p>
-        <p id="otp" class="is-size-1 has-text-white" :title="$t('commons.copy_to_clipboard')" v-clipboard="() => otp.replace(/ /g, '')" v-clipboard:success="clipboardSuccessHandler">{{ displayedOtp }}</p>
-        <ul class="dots" v-if="otpType === 'totp'">
+        <p class="is-size-1 has-text-white is-clickable" :title="$t('commons.copy_to_clipboard')" v-clipboard="() => token.replace(/ /g, '')" v-clipboard:success="clipboardSuccessHandler">{{ displayedToken }}</p>
+        <ul class="dots" v-if="internal_otpType === 'totp'">
             <li v-for="n in 30"></li>
         </ul>
-        <ul v-else-if="otpType === 'hotp'">
-            <li>counter: {{ counter }}</li>
+        <ul v-else-if="internal_otpType === 'hotp'">
+            <li>counter: {{ internal_hotpCounter }}</li>
         </ul>
     </div>
 </template>
 
 <script>
     export default {
+        name: 'TokenDisplayer',
+
         data() {
             return {
                 id: null,
-                internal_service: '',
-                internal_account: '',
-                internal_uri: '',
                 next_uri: '',
-                internal_icon: '',
-                otpType: '',
-                otp : '',
+                nextHotpCounter: null,
+                token : '',
                 timerID: null,
                 position: null,
-                counter: null,
+                internal_otpType: '',
+                internal_account: '',
+                internal_service: '',
+                internal_icon: '',
+                internal_hotpCounter: null,
             }
         },
 
         props: {
-            service: '',
-            account: '',
-            uri : '',
-            icon: ''
+            account : String,
+            algorithm : String,
+            digits : Number,
+            hotpCounter : Number,
+            icon : String,
+            imageLink : String,
+            otpType : String,
+            qrcode : null,
+            secret : String,
+            secretIsBase32Encoded : Number,
+            service : String,
+            totpPeriod : Number,
+            uri : String
         },
 
         computed: {
-            displayedOtp() {
-                return this.$root.appSettings.showTokenAsDot ? this.otp.replace(/[0-9]/g, '●') : this.otp
+            displayedToken() {
+                return this.$root.appSettings.showTokenAsDot ? this.token.replace(/[0-9]/g, '●') : this.token
             }
         },
 
         mounted: function() {
-            this.showAccount()
+            this.getToken()
         },
 
         methods: {
 
-            async showAccount(id) {
+            async getToken(id) {
 
-                // 2 possible cases :
-                //   - ID is provided so we fetch the account data from db but without the uri.
+                // 3 possible cases :
+                //   - Trigger when user ask for a token 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.otpType is sent by the backend.
-                //   - the URI prop has been set via the create form, we need to preview some OTP before storing the account.
-                //     So this.otpType is set on client side from the provided URI
-                
-                this.id = id
+                //   - 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 a token, including Secret and otpType which are required
 
-                if( this.id || this.uri ) {
-                    if( this.id ) {
+                try {
+                    this.internal_otpType = this.otpType.toLowerCase()
+                }
+                catch(e) {
+                    //do nothing
+                }
+                finally {
+                    this.internal_account = this.account
+                    this.internal_service = this.service
+                    this.internal_icon = this.icon
+                    this.internal_hotpCounter = this.hotpCounter
+                }
 
-                        const { data } = await this.axios.get('api/twofaccounts/' + this.id)
+                if( id ) {
 
-                        this.internal_service = data.service
-                        this.internal_account = data.account
-                        this.internal_icon = data.icon
-                        this.otpType = data.otpType
-                    }
-                    else {
+                    this.id = id
+                    const { data } = await this.axios.get('api/twofaccounts/' + this.id)
 
-                        this.internal_service = this.service
-                        this.internal_account = this.account
-                        this.internal_icon = this.icon
-                        this.internal_uri = this.uri
-                        this.otpType = this.internal_uri.slice(0, 15 ) === "otpauth://totp/" ? 'totp' : 'hotp';
-                    }
+                    this.internal_service = data.service
+                    this.internal_account = data.account
+                    this.internal_icon = data.icon
+                    this.internal_otpType = data.otpType
+                }
+
+                // We force the otpType to be based on the uri
+                if( this.uri ) {
+                    this.internal_otpType = this.uri.slice(0, 15 ).toLowerCase() === "otpauth://totp/" ? 'totp' : 'hotp';
+                }
 
-                    switch(this.otpType) {
+                if( this.id || this.uri || this.secret ) { // minimun required vars to get a token from the backend
+                    
+                    switch(this.internal_otpType) {
                         case 'totp':
                             await this.getTOTP()
                             break;
@@ -97,12 +120,13 @@
                 }
             },
 
+
             getTOTP: function() {
 
-                this.axios.post('/api/twofaccounts/otp', { id: this.id, uri: this.internal_uri }).then(response => {
-                    let spacePosition = Math.ceil(response.data.otp.length / 2);
+                this.axios.post('/api/twofaccounts/otp', { id: this.id, otp: this.$props }).then(response => {
+                    let spacePosition = Math.ceil(response.data.token.length / 2);
                     
-                    this.otp = response.data.otp.substr(0, spacePosition) + " " + response.data.otp.substr(spacePosition);
+                    this.token = response.data.token.substr(0, spacePosition) + " " + response.data.token.substr(spacePosition);
                     this.position = response.data.position;
 
                     let dots = this.$el.querySelector('.dots');
@@ -141,26 +165,31 @@
                 });
             },
 
+
             getHOTP: function() {
 
-                this.axios.post('/api/twofaccounts/otp', { id: this.id, uri: this.internal_uri }).then(response => {
-                    let spacePosition = Math.ceil(response.data.otp.length / 2);
+                this.axios.post('/api/twofaccounts/otp', { id: this.id, otp: this.$props }).then(response => {
+                    let spacePosition = Math.ceil(response.data.token.length / 2);
                     
-                    this.otp = response.data.otp.substr(0, spacePosition) + " " + response.data.otp.substr(spacePosition)
-                    this.counter = response.data.counter
+                    this.token = response.data.token.substr(0, spacePosition) + " " + response.data.token.substr(spacePosition)
+                    this.internal_hotpCounter = response.data.hotpCounter
+                    this.nextHotpCounter = response.data.nextHotpCounter
                     this.next_uri = response.data.nextUri
 
+                    this.$emit('update-hotp-counter', { nextHotpCounter: this.nextHotpCounter })
+
                 })
                 .catch(error => {
                     this.$router.push({ name: 'genericError', params: { err: error.response } });
                 });
             },
 
+
             clearOTP: function() {
                 this.stopLoop()
-                this.id = this.timerID = this.position = this.counter = null
-                this.internal_service = this.internal_account = this.internal_icon = this.internal_uri = ''
-                this.otp = '... ...'
+                this.id = this.timerID = this.position = this.internal_hotpCounter = null
+                this.internal_service = this.internal_account = this.internal_icon = this.internal_otpType = ''
+                this.token = '... ...'
 
                 try {
                     this.$el.querySelector('[data-is-active]').removeAttribute('data-is-active');
@@ -171,12 +200,14 @@
                 }
             },
 
+
             stopLoop: function() {
-                if( this.otpType === 'totp' ) {
+                if( this.internal_otpType === 'totp' ) {
                     clearInterval(this.timerID)
                 }
             },
 
+
             clipboardSuccessHandler ({ value, event }) {
 
                 if(this.$root.appSettings.kickUserAfter == -1) {
@@ -190,6 +221,7 @@
                 this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
             },
 
+
             clipboardErrorHandler ({ value, event }) {
                 console.log('error', value)
             }

+ 5 - 5
resources/js/views/Accounts.vue

@@ -132,7 +132,7 @@
         <quick-uploader v-if="showUploader" :directStreaming="accounts.length > 0" :showTrailer="accounts.length === 0" ref="QuickUploader"></quick-uploader>
         <!-- modal -->
         <modal v-model="showTwofaccountInModal">
-            <twofaccount-show ref="TwofaccountShow" ></twofaccount-show>
+            <token-displayer ref="TokenDisplayer" ></token-displayer>
         </modal>
         <!-- footer -->
         <vue-footer v-if="showFooter && !showGroupSwitch" :showButtons="accounts.length > 0">
@@ -188,7 +188,7 @@
 <script>
 
     import Modal from '../components/Modal'
-    import TwofaccountShow from '../components/TwofaccountShow'
+    import TokenDisplayer from '../components/TokenDisplayer'
     import QuickUploader from './../components/QuickUploader'
     // import vuePullRefresh from 'vue-pull-refresh';
     import draggable from 'vuedraggable'
@@ -257,7 +257,7 @@
             // stop OTP generation on modal close
             this.$on('modalClose', function() {
                 console.log('modalClose triggered')
-                this.$refs.TwofaccountShow.clearOTP()
+                this.$refs.TokenDisplayer.clearOTP()
             });
 
             // hide Footer when stream is on
@@ -281,7 +281,7 @@
 
         components: {
             Modal,
-            TwofaccountShow,
+            TokenDisplayer,
             // 'vue-pull-refresh': vuePullRefresh,
             QuickUploader,
             draggable,
@@ -321,7 +321,7 @@
                     this.selectedAccounts.push(account.id)
                 }
                 else {
-                    this.$refs.TwofaccountShow.showAccount(account.id)
+                    this.$refs.TokenDisplayer.showAccount(account.id)
                 }
             },
 

+ 91 - 62
resources/js/views/twofaccounts/Create.vue

@@ -9,11 +9,8 @@
                         <font-awesome-icon :icon="['fas', 'image']" size="2x" />
                     </label>
                     <button class="delete delete-icon-button is-medium" v-if="tempIcon" @click.prevent="deleteIcon"></button>
-                    <twofaccount-show ref="TwofaccountShow"
-                        :service="form.service"
-                        :account="form.account"
-                        :uri="form.uri">
-                    </twofaccount-show>
+                    <token-displayer ref="QuickFormTokenDisplayer" v-bind="form.data()">
+                    </token-displayer>
                 </div>
             </div>
             <div class="columns is-mobile">
@@ -33,45 +30,26 @@
     <!-- Full form -->
     <form-wrapper :title="$t('twofaccounts.forms.new_account')" v-else>
         <form @submit.prevent="createAccount" @keydown="form.onKeydown($event)">
+            <!-- qcode fileupload -->
             <div class="field">
-                <div class="file is-dark is-boxed">
+                <div class="file is-black is-small">
                     <label class="file-label" :title="$t('twofaccounts.forms.use_qrcode.title')">
                         <input class="file-input" type="file" accept="image/*" v-on:change="uploadQrcode" ref="qrcodeInput">
                         <span class="file-cta">
                             <span class="file-icon">
                                 <font-awesome-icon :icon="['fas', 'qrcode']" size="lg" />
                             </span>
-                            <span class="file-label">{{ $t('twofaccounts.forms.use_qrcode.val') }}</span>
+                            <span class="file-label">{{ $t('twofaccounts.forms.prefill_using_qrcode') }}</span>
                         </span>
                     </label>
                 </div>
             </div>
             <field-error :form="form" field="qrcode" class="help-for-file" />
+            <!-- service -->
             <form-field :form="form" fieldName="service" inputType="text" :label="$t('twofaccounts.service')" :placeholder="$t('twofaccounts.forms.service.placeholder')" autofocus />
+            <!-- account -->
             <form-field :form="form" fieldName="account" inputType="text" :label="$t('twofaccounts.account')" :placeholder="$t('twofaccounts.forms.account.placeholder')" />
-            <div class="field" style="margin-bottom: 0.5rem;">
-                <label class="label">{{ $t('twofaccounts.forms.otp_uri') }}</label>
-            </div>
-            <div class="field has-addons">
-                <div class="control is-expanded">
-                    <input class="input" type="text" placeholder="otpauth://totp/..." v-model="form.uri" :disabled="uriIsLocked" />
-                </div>
-                <div class="control" v-if="uriIsLocked">
-                    <a class="button is-dark field-lock" @click="uriIsLocked = false" :title="$t('twofaccounts.forms.unlock.title')">
-                        <span class="icon">
-                            <font-awesome-icon :icon="['fas', 'lock']" />
-                        </span>
-                    </a>
-                </div>
-                <div class="control" v-else>
-                    <a class="button is-dark field-unlock"  @click="uriIsLocked = true" :title="$t('twofaccounts.forms.lock.title')">
-                        <span class="icon has-text-danger">
-                            <font-awesome-icon :icon="['fas', 'lock-open']" />
-                        </span>
-                    </a>
-                </div>
-            </div>
-            <field-error :form="form" field="uri" class="help-for-file" />
+            <!-- icon upload -->
             <div class="field">
                 <label class="label">{{ $t('twofaccounts.icon') }}</label>
                 <div class="file is-dark">
@@ -91,26 +69,58 @@
                 </div>
             </div>
             <field-error :form="form" field="icon" class="help-for-file" />
-            <div class="field is-grouped">
-                <div class="control">
-                    <v-button :isLoading="form.isBusy" >{{ $t('commons.create') }}</v-button>
-                </div>
-                <div class="control" v-if="form.uri">
-                    <button type="button" class="button is-success" @click="previewAccount">{{ $t('twofaccounts.forms.test') }}</button>
+            <!-- otp type -->
+            <form-toggle :form="form" :choices="otpTypes" fieldName="otpType" :label="$t('twofaccounts.forms.otp_type.label')" :help="$t('twofaccounts.forms.otp_type.help')" :hasOffset="true" />
+            <div v-if="form.otpType">
+                <!-- secret -->
+                <label class="label" v-html="$t('twofaccounts.forms.secret.label')"></label>
+                <div class="field has-addons">
+                    <p class="control">
+                        <span class="select">
+                            <select v-model="form.secretIsBase32Encoded">
+                                <option v-for="format in secretFormats" :value="format.value">{{ format.text }}</option>
+                            </select>
+                        </span>
+                    </p>
+                    <p class="control is-expanded">
+                        <input class="input" type="text" v-model="form.secret">
+                    </p>
                 </div>
-                <div class="control">
-                    <button type="button" class="button is-text" @click="cancelCreation">{{ $t('commons.cancel') }}</button>
+                <div class="field">
+                    <field-error :form="form" field="secret" class="help-for-file" />
+                    <p class="help" v-html="$t('twofaccounts.forms.secret.help')"></p>
                 </div>
+                <h2 class="title is-4 mt-5 mb-2">{{ $t('commons.options') }}</h2>
+                <p class="help mb-4">
+                    {{ $t('twofaccounts.forms.options_help') }}
+                </p>
+                <!-- digits -->
+                <form-toggle :form="form" :choices="digitsChoices" fieldName="digits" :label="$t('twofaccounts.forms.digits.label')" :help="$t('twofaccounts.forms.digits.help')" />
+                <!-- algorithm -->
+                <form-toggle :form="form" :choices="algorithms" fieldName="algorithm" :label="$t('twofaccounts.forms.algorithm.label')" :help="$t('twofaccounts.forms.algorithm.help')" />
+                <!-- TOTP period -->
+                <form-field v-if="form.otpType === 'TOTP'" :form="form" fieldName="totpPeriod" inputType="text" :label="$t('twofaccounts.forms.totpPeriod.label')" :placeholder="$t('twofaccounts.forms.totpPeriod.placeholder')" :help="$t('twofaccounts.forms.totpPeriod.help')" />
+                <!-- HOTP counter -->
+                <form-field v-if="form.otpType === 'HOTP'" :form="form" fieldName="hotpCounter" inputType="text" :label="$t('twofaccounts.forms.hotpCounter.label')" :placeholder="$t('twofaccounts.forms.hotpCounter.placeholder')" :help="$t('twofaccounts.forms.hotpCounter.help')" />
+                <!-- image link -->
+                <form-field :form="form" fieldName="imageLink" inputType="text" :label="$t('twofaccounts.forms.image_link.label')" :placeholder="$t('twofaccounts.forms.image_link.placeholder')" :help="$t('twofaccounts.forms.image_link.help')" />
             </div>
+            <vue-footer :showButtons="true">
+                <p class="control">
+                    <v-button :isLoading="form.isBusy" class="is-rounded" >{{ $t('commons.create') }}</v-button>
+                </p>
+                <p class="control" v-if="form.otpType && form.secret">
+                    <button type="button" class="button is-success is-rounded" @click="previewAccount">{{ $t('twofaccounts.forms.test') }}</button>
+                </p>
+                <p class="control">
+                    <button type="button" class="button is-text is-rounded" @click="cancelCreation">{{ $t('commons.cancel') }}</button>
+                </p>
+            </vue-footer>
         </form>
         <!-- modal -->
         <modal v-model="ShowTwofaccountInModal">
-            <twofaccount-show ref="TwofaccountPreview" 
-                :service="form.service"
-                :account="form.account"
-                :uri="form.uri"
-                :icon="tempIcon">
-            </twofaccount-show>
+            <token-displayer ref="AdvancedFormTokenDisplayer" v-bind="form.data()" @update-hotp-counter="updateHotpCounter">
+            </token-displayer>
         </modal>
     </form-wrapper>
 </template>
@@ -119,29 +129,43 @@
 
     import Modal from '../../components/Modal'
     import Form from './../../components/Form'
-    import TwofaccountShow from '../../components/TwofaccountShow'
+    import TokenDisplayer from '../../components/TokenDisplayer'
 
     export default {
         data() {
             return {
                 isQuickForm: false,
                 ShowTwofaccountInModal : false,
-                uriIsLocked: true,
                 tempIcon: '',
                 form: new Form({
                     service: '',
                     account: '',
+                    otpType: '',
                     uri: '',
                     icon: '',
-                    qrcode: null
-                })
+                    secret: '',
+                    secretIsBase32Encoded: 0,
+                    algorithm: '',
+                    digits: null,
+                    hotpCounter: null,
+                    totpPeriod: null,
+                    imageLink: '',
+                    qrcode: null,
+                }),
+                otpTypes: ['TOTP', 'HOTP'],
+                digitsChoices: [6,7,8,9,10],
+                secretFormats: [
+                    { text: this.$t('twofaccounts.forms.plain_text'), value: 0 },
+                    { text: 'Base32', value: 1 }
+                ],
+                algorithms: ['sha1', 'sha256', 'sha512', 'md5'],
             }
         },
 
         watch: {
             tempIcon: function(val) {
                 if( this.isQuickForm ) {
-                    this.$refs.TwofaccountShow.internal_icon = val
+                    this.$refs.QuickFormTokenDisplayer.internal_icon = val
                 }
             },
         },
@@ -149,20 +173,21 @@
         mounted: function () {
             if( this.$route.params.qrAccount ) {
 
-                this.isQuickForm = true
                 this.form.fill(this.$route.params.qrAccount)
+                this.isQuickForm = true
 
             }
 
             // stop TOTP generation on modal close
             this.$on('modalClose', function() {
-                this.$refs.TwofaccountPreview.stopLoop()
+
+                this.$refs.AdvancedFormTokenDisplayer.stopLoop()
             });
         },
 
         components: {
             Modal,
-            TwofaccountShow,
+            TokenDisplayer,
         },
 
         methods: {
@@ -171,15 +196,15 @@
                 // set current temp icon as account icon
                 this.form.icon = this.tempIcon
 
-                // The quick form (possibly the preview feature too) has incremented the HOTP counter so the next_uri property
-                // must be used as the uri to store
+                // The quick form or the preview feature has incremented the HOTP counter so the next_uri property
+                // must be used as the uri to store.
                 // This could desynchronized the HOTP verification server and our local counter if the user never verified the HOTP but this
                 // is acceptable (and HOTP counter can be edited by the way)
-                if( this.isQuickForm && this.$refs.TwofaccountShow.next_uri ) {
-                    this.form.uri = this.$refs.TwofaccountShow.next_uri
+                if( this.isQuickForm && this.$refs.QuickFormTokenDisplayer.next_uri ) {
+                    this.form.uri = this.$refs.QuickFormTokenDisplayer.next_uri
                 }
-                else if( this.$refs.TwofaccountPreview && this.$refs.TwofaccountPreview.next_uri ) {
-                    this.form.uri = this.$refs.TwofaccountPreview.next_uri
+                else if( this.$refs.AdvancedFormTokenDisplayer && this.$refs.AdvancedFormTokenDisplayer.next_uri ) {
+                    this.form.uri = this.$refs.AdvancedFormTokenDisplayer.next_uri
                 }
 
                 await this.form.post('/api/twofaccounts')
@@ -191,10 +216,7 @@
             },
 
             previewAccount() {
-                // preview is possible only if we have an uri
-                if( this.form.uri ) {
-                    this.$refs.TwofaccountPreview.showAccount()
-                }
+                this.$refs.AdvancedFormTokenDisplayer.getToken()
             },
 
             cancelCreation: function() {
@@ -220,6 +242,9 @@
                 const { data } = await this.form.upload('/api/qrcode/decode', imgdata)
 
                 this.form.fill(data)
+                this.form.otpType = this.form.otpType.toUpperCase()
+                this.form.secretIsBase32Encoded = 1
+                this.form.uri = '' // we don't want an uri now because the user can change any otp parameter in the form
 
             },
 
@@ -243,6 +268,10 @@
                     this.tempIcon = ''
                 }
             },
+
+            updateHotpCounter(payload) {
+                this.form.hotpCounter = payload.nextHotpCounter
+            },
             
         },
 

+ 1 - 0
resources/lang/en/commons.php

@@ -33,4 +33,5 @@ return [
     'move' => 'Move',
     'all' => 'All',
     'rename' => 'Rename',
+    'options' => 'Options',
 ];

+ 1 - 1
resources/lang/en/errors.php

@@ -26,7 +26,7 @@ return [
     'Unable_to_decrypt_uri' => 'Unable to decrypt uri',
     'not_a_supported_otp_type' => 'This OTP format is not currently supported',
     'cannot_create_otp_without_secret' => 'Cannot create an OTP without a secret',
-    'cannot_create_otp_without_parameters' => 'Cannot create an OTP with those parameters',
+    'cannot_create_otp_with_those_parameters' => 'Cannot create an OTP with those parameters',
     'wrong_current_password' => 'Wrong current password, nothing has changed',
     'error_during_encryption' => 'Encryption failed, your database remains unprotected.',
     'error_during_decryption' => 'Decryption failed, your database is still protected. This is mainly caused by an integrity issue of encrypted data for one or more accounts.',

+ 34 - 0
resources/lang/en/twofaccounts.php

@@ -34,6 +34,7 @@ return [
         'otp_uri' => 'OTP Uri',
         'hotp_counter' => 'HOTP Counter',
         'scan_qrcode' => 'Scan a qrcode',
+        'prefill_using_qrcode' => 'Prefill using a QR Code',
         'use_qrcode' => [
             'val' => 'Use a qrcode',
             'title' => 'Use a QR code to fill the form magically',
@@ -48,6 +49,39 @@ return [
         ],
         'choose_image' => 'Choose an image…',
         'test' => 'Test',
+        'secret' => [
+            'label' => 'Secret',
+            'help' => 'The key used to generate your security codes'
+        ],
+        'plain_text' => 'Plain text',
+        'otp_type' => [
+            'label' => 'Choose the type of OTP to create',
+            'help' => 'Time-based OTP or HMAC-based OTP'
+        ],
+        'digits' => [
+            'label' => 'Digits',
+            'help' => 'The number of digits of the generated security codes'
+        ],
+        'algorithm' => [
+            'label' => 'Algorithm',
+            'help' => 'The algorithm used to secure your security codes'
+        ],
+        'totpPeriod' => [
+            'label' => 'Period',
+            'placeholder' => 'Default is 30',
+            'help' => 'The period of validity of the generated security codes in second'
+        ],
+        'hotpCounter' => [
+            'label' => 'Counter',
+            'placeholder' => 'Default is 0',
+            'help' => 'The initial counter value'
+        ],
+        'image_link' => [
+            'label' => 'Image',
+            'placeholder' => 'http://...',
+            'help' => 'The url of an external image to use as the account icon'
+        ],
+        'options_help' => 'You can leave the following options blank if you don\'t know how to set them. The most commonly used values will be applied.',
     ],
     'stream' => [
         'need_grant_permission' => 'You need to grant camera access permission',

+ 8 - 2
resources/lang/en/validation.php

@@ -115,7 +115,7 @@ return [
     'timezone' => 'The :attribute must be a valid zone.',
     'unique' => 'The :attribute has already been taken.',
     'uploaded' => 'The :attribute failed to upload.',
-    'url' => 'The :attribute format is invalid.',
+    'url' => 'The :attribute must be a valid url.',
     'uuid' => 'The :attribute must be a valid UUID.',
 
     /*
@@ -141,7 +141,13 @@ return [
         ],
         'email' => [
             'exists' => 'No account found using this email',
-        ]
+        ],
+        'otpType' => [
+            'required_without' => 'The :attribute field is required.',
+        ],
+        'secret' => [
+            'required_without' => 'The :attribute field is required.',
+        ],
     ],
 
     /*