Refactor QRcode handling using the brand new Start view

This commit is contained in:
Bubka 2020-11-20 14:11:32 +01:00
parent bcdc0b1435
commit 019d380cb2
10 changed files with 351 additions and 342 deletions

View File

@ -4,8 +4,6 @@
use Zxing\QrReader;
use App\TwoFAccount;
use App\Classes\Options;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use chillerlan\QRCode\{QRCode, QROptions};
@ -40,62 +38,22 @@ public function show(TwoFAccount $twofaccount)
*/
public function decode(Request $request)
{
// input validation
$this->validate($request, [
'qrcode' => 'required|image',
]);
if( Options::get('useBasicQrcodeReader') || $request->inputFormat === 'fileUpload') {
// qrcode analysis
$path = $request->file('qrcode')->store('qrcodes');
$qrcode = new QrReader(storage_path('app/' . $path));
// The frontend send an image resource of the QR code
$uri = urldecode($qrcode->text());
// input validation
$this->validate($request, [
'qrcode' => 'required|image',
]);
// delete uploaded file
Storage::delete($path);
// qrcode analysis
$path = $request->file('qrcode')->store('qrcodes');
$qrcode = new QrReader(storage_path('app/' . $path));
$uri = urldecode($qrcode->text());
// delete uploaded file
Storage::delete($path);
}
else {
// The QR code has been flashed and the URI is already decoded
$this->validate($request, [
'uri' => 'required|string',
]);
$uri = $request->uri;
}
// return the OTP object
$twofaccount = new TwoFAccount;
$twofaccount->uri = $uri;
// When present, use the imageLink parameter to prefill the icon field
if( $twofaccount->imageLink ) {
$chunks = explode('.', $twofaccount->imageLink);
$hashFilename = Str::random(40) . '.' . end($chunks);
try {
Storage::disk('local')->put('imagesLink/' . $hashFilename, file_get_contents($twofaccount->imageLink));
if( in_array(Storage::mimeType('imagesLink/' . $hashFilename), ['image/png', 'image/jpeg', 'image/webp', 'image/bmp']) ) {
if( getimagesize(storage_path() . '/app/imagesLink/' . $hashFilename) ) {
Storage::move('imagesLink/' . $hashFilename, 'public/icons/' . $hashFilename);
$twofaccount->icon = $hashFilename;
}
}
}
catch( Exception $e ) {
$twofaccount->imageLink = null;
}
}
return response()->json($twofaccount->makeVisible(['uri', 'secret', 'algorithm']), 200);
return response()->json(['uri' => $uri], 200);
}
}

View File

@ -5,6 +5,7 @@
use App\Group;
use App\TwoFAccount;
use App\Classes\Options;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@ -118,6 +119,50 @@ public function reorder(Request $request)
}
/**
* Preview account using an uri, without any db moves
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function preview(Request $request)
{
$this->validate($request, [
'uri' => 'required|string|regex:/^otpauth:\/\/[h,t]otp\//i',
]);
$twofaccount = new TwoFAccount;
$twofaccount->uri = $request->uri;
// If present, use the imageLink parameter to prefill the icon field
if( $twofaccount->imageLink ) {
$chunks = explode('.', $twofaccount->imageLink);
$hashFilename = Str::random(40) . '.' . end($chunks);
try {
Storage::disk('local')->put('imagesLink/' . $hashFilename, file_get_contents($twofaccount->imageLink));
if( in_array(Storage::mimeType('imagesLink/' . $hashFilename), ['image/png', 'image/jpeg', 'image/webp', 'image/bmp']) ) {
if( getimagesize(storage_path() . '/app/imagesLink/' . $hashFilename) ) {
Storage::move('imagesLink/' . $hashFilename, 'public/icons/' . $hashFilename);
$twofaccount->icon = $hashFilename;
}
}
}
catch( Exception $e ) {
$twofaccount->imageLink = null;
}
}
return response()->json($twofaccount->makeVisible(['uri', 'secret', 'algorithm']), 200);
}
/**
* Generate a TOTP
*

View File

@ -218,7 +218,7 @@ private function populateFromUri($uri)
}
catch (\Exception $e) {
throw \Illuminate\Validation\ValidationException::withMessages([
'qrcode' => __('errors.response.no_valid_otp')
'qrcode' => __('errors.no_valid_otp')
]);
}
}

View File

@ -3,10 +3,11 @@ import Router from 'vue-router'
Vue.use(Router)
import Start from './views/Start'
import Accounts from './views/Accounts'
import CreateAccount from './views/twofaccounts/Create'
import EditAccount from './views/twofaccounts/Edit'
import QRcodeAccount from './views/twofaccounts/QRcode'
import QRcodeAccount from './views/twofaccounts/QRcode'
import Groups from './views/Groups'
import CreateGroup from './views/groups/Create'
import EditGroup from './views/groups/Edit'
@ -20,6 +21,8 @@ import Errors from './views/Error'
const router = new Router({
mode: 'history',
routes: [
{ path: '/start', name: 'start', component: Start, 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/edit/:twofaccountId', name: 'editAccount', component: EditAccount, meta: { requiresAuth: true } },

View File

@ -16,6 +16,12 @@
</div>
</div>
</div>
<vue-footer :showButtons="true">
<!-- Close Group switch button -->
<p class="control">
<a class="button is-dark is-rounded" @click="closeGroupSwitch()">{{ $t('commons.close') }}</a>
</p>
</vue-footer>
</div>
<!-- Group selector -->
<div class="container groups with-heading" v-if="showGroupSelector">
@ -35,6 +41,16 @@
</div>
</div>
</div>
<vue-footer :showButtons="true">
<!-- Move to selected group button -->
<p class="control">
<a class="button is-link is-rounded" @click="moveAccounts()">{{ $t('commons.move') }}</a>
</p>
<!-- Cancel button -->
<p class="control">
<a class="button is-dark is-rounded" @click="showGroupSelector = false">{{ $t('commons.cancel') }}</a>
</p>
</vue-footer>
</div>
<!-- show accounts list -->
<div class="container" v-if="this.showAccounts">
@ -86,6 +102,30 @@
</transition-group>
</draggable>
<!-- </vue-pull-refresh> -->
<vue-footer :showButtons="true">
<!-- New item buttons -->
<p class="control" v-if="!editMode">
<a 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>
</a>
</p>
<!-- Manage button -->
<p class="control" v-if="!editMode">
<a class="button is-dark is-rounded" @click="setEditModeTo(true)">{{ $t('commons.manage') }}</a>
</p>
<!-- Done button -->
<p class="control" v-if="editMode">
<a class="button is-success is-rounded" @click="setEditModeTo(false)">
<span>{{ $t('commons.done') }}</span>
<span class="icon is-small">
<font-awesome-icon :icon="['fas', 'check']" />
</span>
</a>
</p>
</vue-footer>
</div>
<!-- header -->
<div class="header has-background-black-ter" v-if="this.showAccounts || this.showGroupSwitch">
@ -128,61 +168,10 @@
</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">
<token-displayer ref="TokenDisplayer" ></token-displayer>
</modal>
<!-- footers -->
<div v-if="showFooter">
<vue-footer v-if="showGroupSwitch" :showButtons="true">
<!-- Close Group switch button -->
<p class="control">
<a class="button is-dark is-rounded" @click="closeGroupSwitch()">{{ $t('commons.close') }}</a>
</p>
</vue-footer>
<vue-footer v-else :showButtons="accounts.length > 0">
<!-- New item buttons -->
<p class="control" v-if="!showUploader && !editMode">
<a class="button is-link is-rounded is-focus" @click="showUploader = true">
<span>{{ $t('commons.new') }}</span>
<span class="icon is-small">
<font-awesome-icon :icon="['fas', 'qrcode']" />
</span>
</a>
</p>
<!-- Manage button -->
<p class="control" v-if="!showUploader && !editMode">
<a class="button is-dark is-rounded" @click="setEditModeTo(true)">{{ $t('commons.manage') }}</a>
</p>
<!-- Done button -->
<p class="control" v-if="!showUploader && editMode">
<a class="button is-success is-rounded" @click="setEditModeTo(false)">
<span>{{ $t('commons.done') }}</span>
<span class="icon is-small">
<font-awesome-icon :icon="['fas', 'check']" />
</span>
</a>
</p>
<!-- Cancel QuickFormButton -->
<p class="control" v-if="showUploader && showFooter">
<a class="button is-dark is-rounded" @click="showUploader = false">
{{ $t('commons.cancel') }}
</a>
</p>
</vue-footer>
<vue-footer v-if="showGroupSelector" :showButtons="true">
<!-- Move to selected group button -->
<p class="control">
<a class="button is-link is-rounded" @click="moveAccounts()">{{ $t('commons.move') }}</a>
</p>
<!-- Cancel button -->
<p class="control">
<a class="button is-dark is-rounded" @click="showGroupSelector = false">{{ $t('commons.cancel') }}</a>
</p>
</vue-footer>
</div>
</div>
</template>
@ -191,30 +180,25 @@
import Modal from '../components/Modal'
import TokenDisplayer from '../components/TokenDisplayer'
import QuickUploader from './../components/QuickUploader'
// import vuePullRefresh from 'vue-pull-refresh';
import draggable from 'vuedraggable'
import Form from './../components/Form'
export default {
data(){
return {
accounts : [],
groups : [],
selectedAccounts: [],
search: '',
editMode: this.InitialEditMode,
drag: false,
showTwofaccountInModal : false,
showGroupSwitch: false,
showGroupSelector: false,
moveAccountsTo: false,
form: new Form({
activeGroup: this.$root.appSettings.activeGroup,
}),
showTwofaccountInModal : false,
search: '',
editMode: this.InitialEditMode,
showUploader: false,
showFooter: true,
showGroupSwitch: false,
showGroupSelector: false,
drag: false,
moveAccountsTo: false,
}
},
@ -233,7 +217,7 @@
},
showAccounts() {
return this.accounts.length > 0 && !this.showUploader && !this.showGroupSwitch && !this.showGroupSelector ? true : false
return this.accounts.length > 0 && !this.showGroupSwitch && !this.showGroupSelector ? true : false
},
activeGroupName() {
@ -262,35 +246,20 @@
this.$refs.TokenDisplayer.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
});
this.$on('cannotStream', function() {
this.showFooter = true
});
},
components: {
Modal,
TokenDisplayer,
// 'vue-pull-refresh': vuePullRefresh,
QuickUploader,
draggable,
},
methods: {
start() {
this.$router.push({ name: 'start', params: { accountCount: this.accounts.length } });
},
fetchAccounts() {
this.accounts = []
this.selectedAccounts = []
@ -306,7 +275,10 @@
})
})
this.showUploader = response.data.length === 0 ? true : false
// No account yet, we push user to the start view
if( this.accounts.length === 0 ) {
this.$router.push({ name: 'start', params: { accountCount: 0 } });
}
})
},

View File

@ -4,34 +4,55 @@
<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 }">
<div class="column is-full quick-uploader-header" :class="{ 'is-invisible' : accountCount > 0 }">
{{ $t('twofaccounts.no_account_here') }}<br>
{{ $t('twofaccounts.add_first_account') }}
</div>
<!-- add button -->
<!-- Livescan button -->
<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 v-if="$root.appSettings.useBasicQrcodeReader" class="button is-link is-medium is-rounded is-focused" >
<input class="file-input" type="file" accept="image/*" v-on:change="submitQrCode" ref="qrcodeInput">
{{ $t('twofaccounts.forms.upload_qrcode') }}
</label>
<!-- scan button that launch camera stream -->
<label v-if="canStream" class="button is-link is-medium is-rounded is-focused" @click="enableStream()">
<label v-else 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') }}
</div>
</div>
<!-- alternative methods -->
<div class="column is-full">
<div class="block has-text-light">Alternative methods</div>
<!-- upload a qr code (with qrcode-capture component) -->
<div class="block has-text-link" v-if="!$root.appSettings.useBasicQrcodeReader">
<form @submit.prevent="createAccount" @keydown="form.onKeydown($event)">
<label :class="{'is-loading' : form.isBusy}" class="button is-link is-outlined is-rounded">
<qrcode-capture @decode="submitUri" class="file-input" ref="qrcodeInput" />
{{ $t('twofaccounts.forms.upload_qrcode') }}
</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: 'createAccount' }" class="is-link">{{ $t('twofaccounts.use_full_form') }}</router-link>
<!-- link to advanced form -->
<div class="block has-text-link">
<router-link class="button is-link is-outlined is-rounded" :to="{ name: 'createAccount' }" >
{{ $t('twofaccounts.forms.use_advanced_form') }}
</router-link>
</div>
</div>
</div>
<!-- Footer -->
<vue-footer :showButtons="true">
<!-- back button -->
<p class="control">
<router-link class="button is-dark is-rounded" :to="{ name: '/' }" >
{{ $t('commons.back') }}
</router-link>
</p>
</vue-footer>
</div>
<!-- camera stream fullscreen scanner -->
<div v-show="showStream && canStream">
@ -41,7 +62,7 @@
</span>
</div>
<div class="fullscreen-streamer">
<qrcode-stream @decode="uploadQrcode" @init="onStreamerInit" :camera="camera" />
<qrcode-stream @decode="submitUri" @init="onStreamerInit" :camera="camera" />
</div>
<div class="fullscreen-footer">
<!-- Cancel button -->
@ -55,10 +76,10 @@
<script>
import Form from './Form'
import Form from './../components/Form'
export default {
name: 'QuickUploader',
name: 'Start',
data(){
return {
@ -74,25 +95,15 @@
}
},
props: {
showTrailer: {
type: Boolean,
default: false
},
directStreaming: {
type: Boolean,
default: true
},
},
props: ['accountCount'],
created() {
if( this.$root.appSettings.useBasicQrcodeReader ) {
// User has set the basic QR code reader so we disable the modern one
// User has set the basic QR code reader (run by backend) so we disable the other one (run by js)
this.canStream = this.showStream = false
}
else {
if( this.directStreaming ) {
if( this.accountCount > 0 && this.$root.appSettings.useDirectCapture ) {
this.enableStream()
}
}
@ -106,7 +117,7 @@
async enableStream() {
this.$parent.$emit('initStreaming')
this.setUploader()
this.camera = 'auto'
this.showStream = true
@ -155,65 +166,70 @@
this.errorText = this.$t('twofaccounts.stream.stream_api_not_supported')
}
}
this.setUploader()
},
setUploader() {
if( this.errorName ) {
this.canStream = false
this.$parent.$emit('cannotStream')
console.log('fail to stream : ' + this.errorText)
if (this.errorName === 'NotAllowedError') {
this.$notify({ type: 'is-danger', text: this.errorText })
}
if (this.errorName === 'InsecureContextError') {
this.$notify({ type: 'is-warning', text: "HTTPS required for camera streaming" })
}
this.canStream = false
this.$notify({ type: 'is-warning', text: this.errorText })
}
else
{
if( !this.errorName && !this.showStream ) {
this.camera = 'off'
if( !this.errorName && !this.showStream ) {
this.camera = 'off'
console.log('stream stopped')
}
if( this.canStream && this.showStream) {
this.$parent.$emit('startStreaming')
console.log('stream stopped')
}
console.log('stream started')
}
},
async uploadQrcode(event) {
var response
/**
* the basicQRcodeReader option is On, so qrcode decoding has to be done by the backend, which in return
* send the corresponding URI
*/
async submitQrCode() {
if(this.$root.appSettings.useBasicQrcodeReader) {
let imgdata = new FormData();
imgdata.append('qrcode', this.$refs.qrcodeInput.files[0]);
let imgdata = new FormData();
imgdata.append('qrcode', this.$refs.qrcodeInput.files[0]);
imgdata.append('inputFormat', 'fileUpload');
response = await this.form.upload('/api/qrcode/decode', imgdata)
const { data } = await this.form.upload('/api/qrcode/decode', imgdata)
this.pushUriToCreateForm(data.uri)
},
/**
* 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 Create form, but the form will check uri validity too.
*/
async submitUri(event) {
// We post the URI automatically decoded by vue-qrcode-reader
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, 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 {
// 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.pushUriToCreateForm(this.form.uri)
}
this.$router.push({ name: 'createAccount', params: { qrAccount: response.data } });
},
pushUriToCreateForm(data) {
this.$router.push({ name: 'createAccount', params: { decodedUri: data } });
}
}
};

View File

@ -1,135 +1,137 @@
<template>
<!-- Quick form -->
<form @submit.prevent="createAccount" @keydown="form.onKeydown($event)" v-if="isQuickForm">
<div class="container preview has-text-centered">
<div class="columns is-mobile">
<div class="column">
<label class="add-icon-button" v-if="!tempIcon">
<input class="file-input" type="file" accept="image/*" v-on:change="uploadIcon" ref="iconInput">
<font-awesome-icon :icon="['fas', 'image']" size="2x" />
</label>
<button class="delete delete-icon-button is-medium" v-if="tempIcon" @click.prevent="deleteIcon"></button>
<token-displayer ref="QuickFormTokenDisplayer" v-bind="form.data()" @increment-hotp="incrementHotp">
</token-displayer>
<div>
<!-- Quick form -->
<form @submit.prevent="createAccount" @keydown="form.onKeydown($event)" v-if="showQuickForm">
<div class="container preview has-text-centered">
<div class="columns is-mobile">
<div class="column">
<label class="add-icon-button" v-if="!tempIcon">
<input class="file-input" type="file" accept="image/*" v-on:change="uploadIcon" ref="iconInput">
<font-awesome-icon :icon="['fas', 'image']" size="2x" />
</label>
<button class="delete delete-icon-button is-medium" v-if="tempIcon" @click.prevent="deleteIcon"></button>
<token-displayer ref="QuickFormTokenDisplayer" v-bind="form.data()" @increment-hotp="incrementHotp">
</token-displayer>
</div>
</div>
</div>
<div class="columns is-mobile" v-if="form.errors.any()">
<div class="column">
<p v-for="field in form.errors.errors" class="help is-danger">
<ul>
<li v-for="(error, index) in field">{{ error }}</li>
</ul>
</p>
<div class="columns is-mobile" v-if="form.errors.any()">
<div class="column">
<p v-for="field in form.errors.errors" class="help is-danger">
<ul>
<li v-for="(error, index) in field">{{ error }}</li>
</ul>
</p>
</div>
</div>
</div>
<div class="columns is-mobile">
<div class="column quickform-footer">
<div class="field is-grouped is-grouped-centered">
<div class="control">
<v-button :isLoading="form.isBusy" >{{ $t('commons.save') }}</v-button>
</div>
<div class="control">
<button type="button" class="button is-text" @click="cancelCreation">{{ $t('commons.cancel') }}</button>
<div class="columns is-mobile">
<div class="column quickform-footer">
<div class="field is-grouped is-grouped-centered">
<div class="control">
<v-button :isLoading="form.isBusy" >{{ $t('commons.save') }}</v-button>
</div>
<div class="control">
<button type="button" class="button is-text" @click="cancelCreation">{{ $t('commons.cancel') }}</button>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
<!-- 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-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.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')" />
<!-- icon upload -->
<div class="field">
<label class="label">{{ $t('twofaccounts.icon') }}</label>
<div class="file is-dark">
<label class="file-label">
<input class="file-input" type="file" accept="image/*" v-on:change="uploadIcon" ref="iconInput">
<span class="file-cta">
<span class="file-icon">
<font-awesome-icon :icon="['fas', 'image']" />
</span>
<span class="file-label">{{ $t('twofaccounts.forms.choose_image') }}</span>
</span>
</label>
<span class="tag is-black is-large" v-if="tempIcon">
<img class="icon-preview" :src="'/storage/icons/' + tempIcon" >
<button class="delete is-small" @click.prevent="deleteIcon"></button>
</span>
</div>
</div>
<field-error :form="form" field="icon" class="help-for-file" />
<!-- otp type -->
<form-toggle class="has-uppercased-button" :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="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')" />
</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">
<token-displayer ref="AdvancedFormTokenDisplayer" v-bind="form.data()" @increment-hotp="incrementHotp">
</token-displayer>
</modal>
</form-wrapper>
<!-- Full form -->
<form-wrapper :title="$t('twofaccounts.forms.new_account')" v-if="showAdvancedForm">
<form @submit.prevent="createAccount" @keydown="form.onKeydown($event)">
<!-- qcode fileupload -->
<div class="field">
<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.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')" />
<!-- icon upload -->
<div class="field">
<label class="label">{{ $t('twofaccounts.icon') }}</label>
<div class="file is-dark">
<label class="file-label">
<input class="file-input" type="file" accept="image/*" v-on:change="uploadIcon" ref="iconInput">
<span class="file-cta">
<span class="file-icon">
<font-awesome-icon :icon="['fas', 'image']" />
</span>
<span class="file-label">{{ $t('twofaccounts.forms.choose_image') }}</span>
</span>
</label>
<span class="tag is-black is-large" v-if="tempIcon">
<img class="icon-preview" :src="'/storage/icons/' + tempIcon" >
<button class="delete is-small" @click.prevent="deleteIcon"></button>
</span>
</div>
</div>
<field-error :form="form" field="icon" class="help-for-file" />
<!-- otp type -->
<form-toggle class="has-uppercased-button" :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="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')" />
</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">
<token-displayer ref="AdvancedFormTokenDisplayer" v-bind="form.data()" @increment-hotp="incrementHotp">
</token-displayer>
</modal>
</form-wrapper>
</div>
</template>
<script>
@ -141,7 +143,8 @@
export default {
data() {
return {
isQuickForm: false,
showQuickForm: false,
showAdvancedForm: false,
ShowTwofaccountInModal : false,
tempIcon: '',
form: new Form({
@ -185,19 +188,31 @@
watch: {
tempIcon: function(val) {
if( this.isQuickForm ) {
if( this.showQuickForm ) {
this.$refs.QuickFormTokenDisplayer.internal_icon = val
}
},
},
mounted: function () {
if( this.$route.params.qrAccount ) {
if( this.$route.params.decodedUri ) {
this.form.fill(this.$route.params.qrAccount)
this.tempIcon = this.$route.params.qrAccount.icon ? this.$route.params.qrAccount.icon : null
this.isQuickForm = true
// the Start view provided an uri so we parse it and prefill the quick form
this.axios.post('/api/twofaccounts/preview', { uri: this.$route.params.decodedUri }).then(response => {
this.form.fill(response.data)
this.tempIcon = response.data.icon ? response.data.icon : null
this.showQuickForm = true
})
.catch(error => {
if( error.response.status === 422 ) {
this.$router.push({ name: 'genericError', params: { err: this.$t('errors.cannot_create_otp_with_those_parameters') } });
}
});
} else {
this.showAdvancedForm = true
}
// stop TOTP generation on modal close

View File

@ -18,10 +18,7 @@
'already_one_user_registered' => 'There is already a registered user.',
'cannot_register_more_user' => 'You cannot register more than one user.',
'refresh' => 'Refresh',
'response' => [
'no_valid_otp' => 'No valid OTP resource in this QR code',
''
],
'no_valid_otp' => 'No valid OTP resource in this QR code',
'something_wrong_with_server' => 'Something is wrong with your server',
'Unable_to_decrypt_uri' => 'Unable to decrypt uri',
'not_a_supported_otp_type' => 'This OTP format is not currently supported',
@ -30,5 +27,5 @@
'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.',
'qrcode_cannot_be_read' => 'This QR code is unreadable',
];

View File

@ -33,6 +33,8 @@
'edit_account' => 'Edit account',
'otp_uri' => 'OTP Uri',
'scan_qrcode' => 'Scan a qrcode',
'upload_qrcode' => 'Upload a qrcode',
'use_advanced_form' => 'Use the advanced form',
'prefill_using_qrcode' => 'Prefill using a QR Code',
'use_qrcode' => [
'val' => 'Use a qrcode',

View File

@ -38,6 +38,7 @@
Route::delete('twofaccounts/batch', 'TwoFAccountController@batchDestroy');
Route::patch('twofaccounts/reorder', 'TwoFAccountController@reorder');
Route::post('twofaccounts/preview', 'TwoFAccountController@preview');
Route::get('twofaccounts/{twofaccount}/withSensitive', 'TwoFAccountController@showWithSensitive');
Route::apiResource('twofaccounts', 'TwoFAccountController');
Route::patch('group/accounts', 'GroupController@associateAccounts');