mirror of
https://github.com/Bubka/2FAuth.git
synced 2024-11-07 17:04:34 +01:00
Generate and Show all OTPs directly on the main view - Complete #153
This commit is contained in:
parent
4f81b30fcd
commit
b8c810f885
@ -5,6 +5,7 @@
|
||||
use App\Api\v1\Requests\TwoFAccountBatchRequest;
|
||||
use App\Api\v1\Requests\TwoFAccountDynamicRequest;
|
||||
use App\Api\v1\Requests\TwoFAccountImportRequest;
|
||||
use App\Api\v1\Requests\TwoFAccountIndexRequest;
|
||||
use App\Api\v1\Requests\TwoFAccountReorderRequest;
|
||||
use App\Api\v1\Requests\TwoFAccountStoreRequest;
|
||||
use App\Api\v1\Requests\TwoFAccountUpdateRequest;
|
||||
@ -29,7 +30,7 @@ class TwoFAccountController extends Controller
|
||||
*
|
||||
* @return \App\Api\v1\Resources\TwoFAccountCollection
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(TwoFAccountIndexRequest $request)
|
||||
{
|
||||
// Quick fix for #176
|
||||
if (config('auth.defaults.guard') === 'reverse-proxy-guard' && User::count() === 1) {
|
||||
@ -39,7 +40,15 @@ public function index(Request $request)
|
||||
}
|
||||
}
|
||||
|
||||
return new TwoFAccountCollection($request->user()->twofaccounts->sortBy('order_column'));
|
||||
$validated = $request->validated();
|
||||
|
||||
// if ($request->has('withOtp')) {
|
||||
// $request->merge(['at' => time()]);
|
||||
// }
|
||||
|
||||
return Arr::has($validated, 'ids')
|
||||
? new TwoFAccountCollection($request->user()->twofaccounts()->whereIn('id', Helpers::commaSeparatedToArray($validated['ids']))->get()->sortBy('order_column'))
|
||||
: new TwoFAccountCollection($request->user()->twofaccounts->sortBy('order_column'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
31
app/Api/v1/Requests/TwoFAccountIndexRequest.php
Normal file
31
app/Api/v1/Requests/TwoFAccountIndexRequest.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Api\v1\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class TwoFAccountIndexRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
return Auth::check();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'ids' => 'sometimes|required|string|regex:/^\d+(,{1}\d+)*$/i',
|
||||
];
|
||||
}
|
||||
}
|
@ -29,6 +29,10 @@ public function toArray($request)
|
||||
$request->merge(['withSecret' => false]);
|
||||
}
|
||||
|
||||
if ($request->has('withOtp')) {
|
||||
$request->merge(['at' => time()]);
|
||||
}
|
||||
|
||||
return $this->collection;
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,8 @@
|
||||
/**
|
||||
* @property mixed $id
|
||||
* @property mixed $group_id
|
||||
*
|
||||
* @method App\Models\Dto\TotpDto|App\Models\Dto\HotpDto getOtp(int $time)
|
||||
*/
|
||||
class TwoFAccountReadResource extends TwoFAccountStoreResource
|
||||
{
|
||||
@ -21,7 +23,20 @@ public function toArray($request)
|
||||
'id' => (int) $this->id,
|
||||
'group_id' => is_null($this->group_id) ? null : (int) $this->group_id,
|
||||
],
|
||||
parent::toArray($request)
|
||||
parent::toArray($request),
|
||||
[
|
||||
'otp' => $this->when(
|
||||
$this->otp_type != 'hotp' && ($request->has('withOtp') || (int) filter_var($request->input('withOtp'), FILTER_VALIDATE_BOOLEAN) == 1),
|
||||
function () use ($request) {
|
||||
/**
|
||||
* @var \App\Models\Dto\TotpDto|\App\Models\Dto\HotpDto
|
||||
*/
|
||||
$otp = $this->getOtp($request->at);
|
||||
|
||||
return collect(['password' => $otp->password, 'generated_at' => $otp->generated_at]);
|
||||
}
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -349,7 +349,7 @@ public function setCounterAttribute($value)
|
||||
* @throws UnsupportedOtpTypeException The defined OTP type is not supported
|
||||
* @throws InvalidOtpParameterException One OTP parameter is invalid
|
||||
*/
|
||||
public function getOTP()
|
||||
public function getOTP(?int $time = null)
|
||||
{
|
||||
Log::info(sprintf('OTP requested for TwoFAccount (%s)', $this->id ? 'id:' . $this->id : 'preview'));
|
||||
|
||||
@ -377,7 +377,7 @@ public function getOTP()
|
||||
} else {
|
||||
$OtpDto = new TotpDto();
|
||||
$OtpDto->otp_type = $this->otp_type;
|
||||
$OtpDto->generated_at = time();
|
||||
$OtpDto->generated_at = $time ?: time();
|
||||
$OtpDto->password = $this->otp_type === self::TOTP
|
||||
? $this->generator->at($OtpDto->generated_at)
|
||||
: SteamTotp::getAuthCode(base64_encode(Base32::decodeUpper($this->secret)));
|
||||
|
@ -99,6 +99,7 @@
|
||||
'formatPassword' => true,
|
||||
'formatPasswordBy' => 0.5,
|
||||
'lang' => 'browser',
|
||||
'getOtpOnRequest' => true,
|
||||
],
|
||||
|
||||
];
|
38
resources/js/components/Dots.vue
Normal file
38
resources/js/components/Dots.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<ul class="dots">
|
||||
<li v-for="n in 10" :key="n"></li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Dots',
|
||||
|
||||
data() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
*
|
||||
*/
|
||||
turnOn: function(index) {
|
||||
const dots = this.$el.querySelectorAll('[data-is-active]')
|
||||
dots.forEach((dot) => {
|
||||
dot.removeAttribute('data-is-active')
|
||||
});
|
||||
|
||||
if (index < 10) {
|
||||
const dot = this.$el.querySelector('.dots li:nth-child(' + (index + 1) + ')')
|
||||
if (dot) {
|
||||
dot.setAttribute('data-is-active', true)
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.$el.querySelector('.dots li:nth-child(1)').setAttribute('data-is-active', true)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
@ -28,7 +28,7 @@
|
||||
:disabled="isDisabled"
|
||||
/>
|
||||
<span v-if="choice.legend" v-html="choice.legend" class="is-block is-size-7"></span>
|
||||
<font-awesome-icon :icon="['fas', choice.icon]" v-if="choice.icon" class="mr-3" /> {{ choice.text }}
|
||||
<font-awesome-icon :icon="['fas', choice.icon]" v-if="choice.icon" class="mr-2" /> {{ choice.text }}
|
||||
</button>
|
||||
</div>
|
||||
<field-error :form="form" :field="fieldName" />
|
||||
|
@ -7,7 +7,7 @@
|
||||
<p class="is-size-6 has-ellipsis" :class="$root.showDarkMode ? 'has-text-grey' : 'has-text-grey-light'">{{ internal_account }}</p>
|
||||
<p>
|
||||
<span role="log" ref="otp" tabindex="0" class="otp is-size-1 is-clickable px-3" :class="$root.showDarkMode ? 'has-text-white' : 'has-text-grey-dark'" @click="copyOTP(internal_password, true)" @keyup.enter="copyOTP(internal_password, true)" :title="$t('commons.copy_to_clipboard')">
|
||||
{{ displayedOtp }}
|
||||
{{ displayPwd(this.internal_password) }}
|
||||
</span>
|
||||
</p>
|
||||
<ul class="dots" v-show="isTimeBased(internal_otp_type)">
|
||||
@ -61,17 +61,17 @@
|
||||
|
||||
computed: {
|
||||
|
||||
displayedOtp() {
|
||||
let pwd = this.internal_password
|
||||
if (this.$root.userPreferences.formatPassword && pwd.length > 0) {
|
||||
const x = Math.ceil(this.$root.userPreferences.formatPasswordBy < 1 ? pwd.length * this.$root.userPreferences.formatPasswordBy : this.$root.userPreferences.formatPasswordBy)
|
||||
const chunks = pwd.match(new RegExp(`.{1,${x}}`, 'g'));
|
||||
if (chunks) {
|
||||
pwd = chunks.join(' ')
|
||||
}
|
||||
}
|
||||
return this.$root.userPreferences.showOtpAsDot ? pwd.replace(/[0-9]/g, '●') : pwd
|
||||
},
|
||||
// displayedOtp() {
|
||||
// let pwd = this.internal_password
|
||||
// if (this.$root.userPreferences.formatPassword && pwd.length > 0) {
|
||||
// const x = Math.ceil(this.$root.userPreferences.formatPasswordBy < 1 ? pwd.length * this.$root.userPreferences.formatPasswordBy : this.$root.userPreferences.formatPasswordBy)
|
||||
// const chunks = pwd.match(new RegExp(`.{1,${x}}`, 'g'));
|
||||
// if (chunks) {
|
||||
// pwd = chunks.join(' ')
|
||||
// }
|
||||
// }
|
||||
// return this.$root.userPreferences.showOtpAsDot ? pwd.replace(/[0-9]/g, '●') : pwd
|
||||
// },
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
|
136
resources/js/components/TotpLooper.vue
Normal file
136
resources/js/components/TotpLooper.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="has-text-light">
|
||||
<!-- <span>period = {{ period }}</span><br />
|
||||
<span>Started_at = {{ generatedAt }}</span><br />
|
||||
<span>active step = {{ stepIndex }}/{{ step_count }}</span><br /><br />
|
||||
<span>elapsedTimeInCurrentPeriod = {{ elapsedTimeInCurrentPeriod }}</span><br />
|
||||
<span>remainingTimeBeforeEndOfPeriod = {{ remainingTimeBeforeEndOfPeriod }}</span><br />
|
||||
<span>durationBetweenTwoSteps = {{ durationBetweenTwoSteps }}</span><br />
|
||||
<hr /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TotpLooper',
|
||||
|
||||
data() {
|
||||
return {
|
||||
generatedAt: null,
|
||||
remainingTimeout: null,
|
||||
initialStepToNextStepTimeout: null,
|
||||
stepToStepInterval: null,
|
||||
stepIndex: null,
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
step_count: Number,
|
||||
period : null,
|
||||
generated_at: Number,
|
||||
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
// |<----period p----->|
|
||||
// | | |
|
||||
// |------- ··· ------------|--------|----------|---------->
|
||||
// | | | |
|
||||
// unix T0 Tp.start Tgen_at Tp.end
|
||||
// | | |
|
||||
// elapsedTimeInCurrentPeriod--|<------>| |
|
||||
// (in ms) | | |
|
||||
// ● ● ● ● ●|● ◌ ◌ ◌ ◌ |
|
||||
// | | || |
|
||||
// | | |<-------->|--remainingTimeBeforeEndOfPeriod (for remainingTimeout)
|
||||
// durationBetweenTwoSteps-->|-|< ||
|
||||
// (for stepToStepInterval) | | >||<---durationFromInitialToNextStep (for initialStepToNextStepTimeout)
|
||||
// |
|
||||
// |
|
||||
// stepIndex
|
||||
|
||||
elapsedTimeInCurrentPeriod() {
|
||||
return this.generatedAt % this.period
|
||||
},
|
||||
|
||||
remainingTimeBeforeEndOfPeriod() {
|
||||
return this.period - this.elapsedTimeInCurrentPeriod
|
||||
},
|
||||
|
||||
durationBetweenTwoSteps() {
|
||||
return this.period / this.step_count
|
||||
},
|
||||
|
||||
initialStepIndex() {
|
||||
let relativePosition = (this.elapsedTimeInCurrentPeriod * this.step_count) / this.period
|
||||
|
||||
return (Math.floor(relativePosition) + 0)
|
||||
},
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
this.generatedAt = this.generated_at
|
||||
this.startLoop()
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
startLoop: function() {
|
||||
|
||||
this.clearLooper()
|
||||
this.$emit('loop-started', this.initialStepIndex)
|
||||
|
||||
this.stepIndex = this.initialStepIndex
|
||||
let self = this;
|
||||
|
||||
// Main timeout that run until the end of the period
|
||||
this.remainingTimeout = setTimeout(function() {
|
||||
self.$emit('loop-ended')
|
||||
}, this.remainingTimeBeforeEndOfPeriod*1000);
|
||||
|
||||
// During the remainingTimeout countdown we have to emit an event every durationBetweenTwoSteps seconds
|
||||
// except for the first next dot
|
||||
let durationFromInitialToNextStep = (Math.ceil(this.elapsedTimeInCurrentPeriod / this.durationBetweenTwoSteps) * this.durationBetweenTwoSteps) - this.elapsedTimeInCurrentPeriod
|
||||
|
||||
this.initialStepToNextStepTimeout = setTimeout(function() {
|
||||
if( durationFromInitialToNextStep > 0 ) {
|
||||
// self.activateNextStep()
|
||||
self.stepIndex += 1
|
||||
self.$emit('stepped-up', self.stepIndex)
|
||||
}
|
||||
self.stepToStepInterval = setInterval(function() {
|
||||
// self.activateNextStep()
|
||||
self.stepIndex += 1
|
||||
self.$emit('stepped-up', self.stepIndex)
|
||||
}, self.durationBetweenTwoSteps*1000)
|
||||
}, durationFromInitialToNextStep*1000)
|
||||
},
|
||||
|
||||
|
||||
|
||||
clearLooper: function() {
|
||||
// if( this.isTimeBased(this.internal_otp_type) ) {
|
||||
clearTimeout(this.remainingTimeout)
|
||||
clearTimeout(this.initialStepToNextStepTimeout)
|
||||
clearInterval(this.stepToStepInterval)
|
||||
this.stepIndex = null
|
||||
// }
|
||||
},
|
||||
|
||||
|
||||
// activateNextStep: function() {
|
||||
// if(this.lastActiveStep.nextSibling !== null) {
|
||||
// this.lastActiveStep.removeAttribute('data-is-active')
|
||||
// this.lastActiveStep.nextSibling.setAttribute('data-is-active', true)
|
||||
// this.lastActiveStep = this.lastActiveStep.nextSibling
|
||||
// }
|
||||
// },
|
||||
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
this.clearLooper()
|
||||
},
|
||||
}
|
||||
</script>
|
73
resources/js/components/Twofaccount.vue
Normal file
73
resources/js/components/Twofaccount.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div :class="[$root.userPreferences.displayMode === 'grid' ? 'tfa-grid' : 'tfa-list']" class="column is-narrow">
|
||||
<div class="tfa-container">
|
||||
<transition name="slideCheckbox">
|
||||
<div class="tfa-cell tfa-checkbox" v-if="isEditMode">
|
||||
<div class="field">
|
||||
<input class="is-checkradio is-small" :class="$root.showDarkMode ? 'is-white':'is-info'" :id="'ckb_' + account.id" :value="account.id" type="checkbox" :name="'ckb_' + account.id" @change="select(account.id)">
|
||||
<label tabindex="0" :for="'ckb_' + account.id" v-on:keypress.space.prevent="select(account.id)"></label>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<div tabindex="0" class="tfa-cell tfa-content is-size-3 is-size-4-mobile" @click="$emit('show', account)" @keyup.enter="$emit('show', account)" role="button">
|
||||
<div class="tfa-text has-ellipsis">
|
||||
<img :src="$root.appConfig.subdirectory + '/storage/icons/' + account.icon" v-if="account.icon && $root.userPreferences.showAccountsIcons" :alt="$t('twofaccounts.icon_for_account_x_at_service_y', {account: account.account, service: account.service})">
|
||||
{{ displayService(account.service) }}<font-awesome-icon class="has-text-danger is-size-5 ml-2" v-if="$root.appSettings.useEncryption && account.account === $t('errors.indecipherable')" :icon="['fas', 'exclamation-circle']" />
|
||||
<span class="is-family-primary is-size-6 is-size-7-mobile has-text-grey ">{{ account.account }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="fadeInOut">
|
||||
<div class="tfa-cell tfa-edit has-text-grey" v-if="isEditMode">
|
||||
<!-- <div class="tags has-addons"> -->
|
||||
<router-link :to="{ name: 'editAccount', params: { twofaccountId: account.id }}" class="tag is-rounded mr-1" :class="$root.showDarkMode ? 'is-dark' : 'is-white'">
|
||||
{{ $t('commons.edit') }}
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'showQRcode', params: { twofaccountId: account.id }}" class="tag is-rounded" :class="$root.showDarkMode ? 'is-dark' : 'is-white'" :title="$t('twofaccounts.show_qrcode')">
|
||||
<font-awesome-icon :icon="['fas', 'qrcode']" />
|
||||
</router-link>
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
</transition>
|
||||
<transition name="fadeInOut">
|
||||
<div class="tfa-cell tfa-dots has-text-grey" v-if="isEditMode">
|
||||
<font-awesome-icon :icon="['fas', 'bars']" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Twofaccount',
|
||||
|
||||
data() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
props: [
|
||||
'account',
|
||||
'isEditMode',
|
||||
],
|
||||
|
||||
methods: {
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
displayService(service) {
|
||||
return service ? service : this.$t('twofaccounts.no_service')
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
select(accountId) {
|
||||
this.$emit('selected', accountId)
|
||||
},
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
11
resources/js/mixins.js
vendored
11
resources/js/mixins.js
vendored
@ -126,6 +126,17 @@ Vue.mixin({
|
||||
|
||||
this.setTheme(this.$root.userPreferences.theme)
|
||||
},
|
||||
|
||||
displayPwd(pwd) {
|
||||
if (this.$root.userPreferences.formatPassword && pwd.length > 0) {
|
||||
const x = Math.ceil(this.$root.userPreferences.formatPasswordBy < 1 ? pwd.length * this.$root.userPreferences.formatPasswordBy : this.$root.userPreferences.formatPasswordBy)
|
||||
const chunks = pwd.match(new RegExp(`.{1,${x}}`, 'g'));
|
||||
if (chunks) {
|
||||
pwd = chunks.join(' ')
|
||||
}
|
||||
}
|
||||
return this.$root.userPreferences.showOtpAsDot ? pwd.replace(/[0-9]/g, '●') : pwd
|
||||
},
|
||||
}
|
||||
|
||||
})
|
@ -141,23 +141,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<div tabindex="0" class="tfa-cell tfa-content is-size-3 is-size-4-mobile" @click="showAccount(account)" @keyup.enter="showAccount(account)" role="button">
|
||||
<div tabindex="0" class="tfa-cell tfa-content is-size-3 is-size-4-mobile" @click="showOrCopy(account)" @keyup.enter="showOrCopy(account)" role="button">
|
||||
<div class="tfa-text has-ellipsis">
|
||||
<img :src="$root.appConfig.subdirectory + '/storage/icons/' + account.icon" v-if="account.icon && $root.userPreferences.showAccountsIcons" :alt="$t('twofaccounts.icon_for_account_x_at_service_y', {account: account.account, service: account.service})">
|
||||
{{ displayService(account.service) }}<font-awesome-icon class="has-text-danger is-size-5 ml-2" v-if="$root.appSettings.useEncryption && account.account === $t('errors.indecipherable')" :icon="['fas', 'exclamation-circle']" />
|
||||
<span class="is-family-primary is-size-6 is-size-7-mobile has-text-grey ">{{ account.account }}</span>
|
||||
<span class="has-ellipsis is-family-primary is-size-6 is-size-7-mobile has-text-grey ">{{ account.account }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="popLater">
|
||||
<div v-show="$root.userPreferences.getOtpOnRequest == false && !editMode" class="has-text-right">
|
||||
<span v-if="account.otp != undefined" class="is-clickable has-nowrap has-text-grey is-size-5 ml-4" @click="copyOTP(account.otp.password)" @keyup.enter="copyOTP(account.otp.password)" :title="$t('commons.copy_to_clipboard')">
|
||||
{{ displayPwd(account.otp.password) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<!-- get hotp button -->
|
||||
<button class="button tag" :class="$root.showDarkMode ? 'is-dark' : 'is-white'" @click="showAccount(account)" :title="$t('twofaccounts.import.import_this_account')">
|
||||
{{ $t('commons.generate') }}
|
||||
</button>
|
||||
</span>
|
||||
<dots v-if="account.otp_type.includes('totp')" :class="'condensed'" :ref="'dots_' + account.period"></dots>
|
||||
</div>
|
||||
</transition>
|
||||
<transition name="fadeInOut">
|
||||
<div class="tfa-cell tfa-edit has-text-grey" v-if="editMode">
|
||||
<!-- <div class="tags has-addons"> -->
|
||||
<router-link :to="{ name: 'editAccount', params: { twofaccountId: account.id }}" class="tag is-rounded mr-1" :class="$root.showDarkMode ? 'is-dark' : 'is-white'">
|
||||
{{ $t('commons.edit') }}
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'showQRcode', params: { twofaccountId: account.id }}" class="tag is-rounded" :class="$root.showDarkMode ? 'is-dark' : 'is-white'" :title="$t('twofaccounts.show_qrcode')">
|
||||
<font-awesome-icon :icon="['fas', 'qrcode']" />
|
||||
</router-link>
|
||||
<!-- </div> -->
|
||||
<router-link :to="{ name: 'editAccount', params: { twofaccountId: account.id }}" class="tag is-rounded mr-1" :class="$root.showDarkMode ? 'is-dark' : 'is-white'">
|
||||
{{ $t('commons.edit') }}
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'showQRcode', params: { twofaccountId: account.id }}" class="tag is-rounded" :class="$root.showDarkMode ? 'is-dark' : 'is-white'" :title="$t('twofaccounts.show_qrcode')">
|
||||
<font-awesome-icon :icon="['fas', 'qrcode']" />
|
||||
</router-link>
|
||||
</div>
|
||||
</transition>
|
||||
<transition name="fadeInOut">
|
||||
@ -167,6 +179,7 @@
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <twofaccount v-for="account in filteredAccounts" :account="account" :key="account.id" :selectedAccounts="selectedAccounts" :isEditMode="editMode" v-on:selected="selectAccount" v-on:show="showAccount"></twofaccount> -->
|
||||
</transition-group>
|
||||
</draggable>
|
||||
<!-- </vue-pull-refresh> -->
|
||||
@ -215,6 +228,19 @@
|
||||
</p>
|
||||
</vue-footer>
|
||||
</div>
|
||||
<span v-if="!this.$root.userPreferences.getOtpOnRequest">
|
||||
<totp-looper
|
||||
v-for="period in periods"
|
||||
:key="period.period"
|
||||
:period="period.period"
|
||||
:generated_at="period.generated_at"
|
||||
:step_count="10"
|
||||
v-on:loop-ended="updateTotps(period.period)"
|
||||
v-on:loop-started="turnDotsOn(period.period, $event)"
|
||||
v-on:stepped-up="turnDotsOn(period.period, $event)"
|
||||
ref="loopers"
|
||||
></totp-looper>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -253,7 +279,10 @@
|
||||
*
|
||||
*/
|
||||
|
||||
// import Twofaccount from '../components/Twofaccount'
|
||||
import Modal from '../components/Modal'
|
||||
import TotpLooper from '../components/TotpLooper'
|
||||
import Dots from '../components/Dots'
|
||||
import OtpDisplayer from '../components/OtpDisplayer'
|
||||
import draggable from 'vuedraggable'
|
||||
import Form from './../components/Form'
|
||||
@ -324,7 +353,21 @@
|
||||
else {
|
||||
return this.$t('commons.all')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns an array of all totp periods present in the twofaccounts list
|
||||
*/
|
||||
periods() {
|
||||
return !this.$root.userPreferences.getOtpOnRequest ?
|
||||
this.accounts.filter(acc => acc.otp_type == 'totp').map(function(item) {
|
||||
return {period: item.period, generated_at: item.otp.generated_at}
|
||||
// return item.period
|
||||
}).filter((value, index, self) => index === self.findIndex((t) => (
|
||||
t.period === value.period
|
||||
))).sort()
|
||||
: null
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
@ -335,7 +378,7 @@
|
||||
document.addEventListener('keydown', this.keyListener)
|
||||
|
||||
// we don't have to fetch fresh data so we try to load them from localstorage to avoid display latency
|
||||
if( !this.toRefresh && !this.$route.params.isFirstLoad ) {
|
||||
if( this.$root.userPreferences.getOtpOnRequest && !this.toRefresh && !this.$route.params.isFirstLoad ) {
|
||||
const accounts = this.$storage.get('accounts', null) // use null as fallback if localstorage is empty
|
||||
if( accounts ) this.accounts = accounts
|
||||
|
||||
@ -358,13 +401,85 @@
|
||||
},
|
||||
|
||||
components: {
|
||||
// Twofaccount,
|
||||
Modal,
|
||||
OtpDisplayer,
|
||||
draggable
|
||||
TotpLooper,
|
||||
Dots,
|
||||
draggable,
|
||||
},
|
||||
|
||||
|
||||
methods: {
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
showOrCopy(account) {
|
||||
if (!this.$root.userPreferences.getOtpOnRequest && account.otp_type.includes('totp')) {
|
||||
this.copyOTP(account.otp.password)
|
||||
}
|
||||
else {
|
||||
this.showAccount(account)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
copyOTP (otp) {
|
||||
// see https://web.dev/async-clipboard/ for futur Clipboard API usage.
|
||||
// The API should allow to copy the password on each trip without user interaction.
|
||||
|
||||
// For now too many browsers don't support the clipboard-write permission
|
||||
// (see https://developer.mozilla.org/en-US/docs/Web/API/Permissions#browser_support)
|
||||
|
||||
const success = this.$clipboard(otp)
|
||||
|
||||
if (success == true) {
|
||||
if(this.$root.userPreferences.kickUserAfter == -1) {
|
||||
this.appLogout()
|
||||
}
|
||||
|
||||
this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
turnDotsOn(period, stepIndex) {
|
||||
this.$refs['dots_' + period].forEach((dots) => {
|
||||
dots.turnOn(stepIndex)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all accounts set with the given period to get fresh OTPs
|
||||
*/
|
||||
async updateTotps(period) {
|
||||
this.axios.get('api/v1/twofaccounts?withOtp=1&ids=' + this.accountIdsWithPeriod(period).join(',')).then(response => {
|
||||
response.data.forEach((account) => {
|
||||
const index = this.accounts.findIndex(acc => acc.id === account.id)
|
||||
this.accounts[index].otp = account.otp
|
||||
|
||||
this.$refs.loopers.forEach((looper) => {
|
||||
if (looper.period == period) {
|
||||
looper.generatedAt = account.otp.generated_at
|
||||
looper.startLoop();
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Return an array of all accounts (ids) set with the given period
|
||||
*/
|
||||
accountIdsWithPeriod(period) {
|
||||
return this.accounts.filter(a => a.period == period).map(item => item.id)
|
||||
},
|
||||
|
||||
/**
|
||||
* Route user to the appropriate submitting view
|
||||
*/
|
||||
@ -386,8 +501,10 @@
|
||||
fetchAccounts(forceRefresh = false) {
|
||||
let accounts = []
|
||||
this.selectedAccounts = []
|
||||
const queryParam = this.$root.userPreferences.getOtpOnRequest ? '' : '?withOtp=1'
|
||||
// const queryParam = '?withOtp=1'
|
||||
|
||||
this.axios.get('api/v1/twofaccounts').then(response => {
|
||||
this.axios.get('api/v1/twofaccounts' + queryParam).then(response => {
|
||||
response.data.forEach((data) => {
|
||||
accounts.push(data)
|
||||
})
|
||||
|
@ -37,12 +37,14 @@
|
||||
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.security') }}</h4>
|
||||
<!-- auto lock -->
|
||||
<form-select v-on:kickUserAfter="savePreference('kickUserAfter', $event)" :options="kickUserAfters" :form="preferencesForm" fieldName="kickUserAfter" :label="$t('settings.forms.auto_lock.label')" :help="$t('settings.forms.auto_lock.help')" />
|
||||
<!-- get OTP on request -->
|
||||
<form-toggle v-on:getOtpOnRequest="savePreference('getOtpOnRequest', $event)" :choices="getOtpTriggers" :form="preferencesForm" fieldName="getOtpOnRequest" :label="$t('settings.forms.otp_generation.label')" :help="$t('settings.forms.otp_generation.help')" />
|
||||
<!-- otp as dot -->
|
||||
<form-checkbox v-on:showOtpAsDot="savePreference('showOtpAsDot', $event)" :form="preferencesForm" fieldName="showOtpAsDot" :label="$t('settings.forms.show_otp_as_dot.label')" :help="$t('settings.forms.show_otp_as_dot.help')" />
|
||||
<!-- close otp on copy -->
|
||||
<form-checkbox v-on:closeOtpOnCopy="savePreference('closeOtpOnCopy', $event)" :form="preferencesForm" fieldName="closeOtpOnCopy" :label="$t('settings.forms.close_otp_on_copy.label')" :help="$t('settings.forms.close_otp_on_copy.help')" />
|
||||
<form-checkbox v-on:closeOtpOnCopy="savePreference('closeOtpOnCopy', $event)" :form="preferencesForm" fieldName="closeOtpOnCopy" :label="$t('settings.forms.close_otp_on_copy.label')" :help="$t('settings.forms.close_otp_on_copy.help')" :disabled="!preferencesForm.getOtpOnRequest" />
|
||||
<!-- copy otp on get -->
|
||||
<form-checkbox v-on:copyOtpOnDisplay="savePreference('copyOtpOnDisplay', $event)" :form="preferencesForm" fieldName="copyOtpOnDisplay" :label="$t('settings.forms.copy_otp_on_display.label')" :help="$t('settings.forms.copy_otp_on_display.help')" />
|
||||
<form-checkbox v-on:copyOtpOnDisplay="savePreference('copyOtpOnDisplay', $event)" :form="preferencesForm" fieldName="copyOtpOnDisplay" :label="$t('settings.forms.copy_otp_on_display.label')" :help="$t('settings.forms.copy_otp_on_display.help')" :disabled="!preferencesForm.getOtpOnRequest" />
|
||||
|
||||
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.data_input') }}</h4>
|
||||
<!-- basic qrcode -->
|
||||
@ -119,6 +121,7 @@
|
||||
theme: '',
|
||||
formatPassword: null,
|
||||
formatPasswordBy: '',
|
||||
getOtpOnRequest: null,
|
||||
}),
|
||||
settingsForm: null,
|
||||
settings: {
|
||||
@ -160,6 +163,10 @@
|
||||
{ text: this.$t('settings.forms.upload'), value: 'upload' },
|
||||
{ text: this.$t('settings.forms.advanced_form'), value: 'advancedForm' },
|
||||
],
|
||||
getOtpTriggers: [
|
||||
{ text: this.$t('settings.forms.otp_generation_on_request'), value: true, legend: this.$t('settings.forms.otp_generation_on_request_legend'), title: this.$t('settings.forms.otp_generation_on_request_title') },
|
||||
{ text: this.$t('settings.forms.otp_generation_on_home'), value: false, legend: this.$t('settings.forms.otp_generation_on_home_legend'), title: this.$t('settings.forms.otp_generation_on_home_title') },
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -128,6 +128,16 @@
|
||||
'label' => 'Disable registration',
|
||||
'help' => 'Prevent new user registration',
|
||||
],
|
||||
'otp_generation' => [
|
||||
'label' => 'Show Password',
|
||||
'help' => 'Set how and when <abbr title="One-Time Passwords">OTPs</abbr> are displayed.<br/>',
|
||||
],
|
||||
'otp_generation_on_request' => 'After a click/tap',
|
||||
'otp_generation_on_request_legend' => 'Single, in its own view',
|
||||
'otp_generation_on_request_title' => 'Click an account to get a password in a dedicated view',
|
||||
'otp_generation_on_home' => 'Constently',
|
||||
'otp_generation_on_home_legend' => 'All, on home',
|
||||
'otp_generation_on_home_title' => 'Show all passwords in the main view, without doing anything',
|
||||
'never' => 'Never',
|
||||
'on_otp_copy' => 'On security code copy',
|
||||
'1_minutes' => 'After 1 minute',
|
||||
|
20
resources/sass/app.scss
vendored
20
resources/sass/app.scss
vendored
@ -466,7 +466,16 @@ figure.no-icon {
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: hsl(348, 100%, 61%);
|
||||
/* red */
|
||||
}
|
||||
|
||||
.dots.condensed {
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.dots.condensed li {
|
||||
margin: 0 2px;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.dots li[data-is-active]~li {
|
||||
@ -1229,12 +1238,19 @@ footer .field.is-grouped {
|
||||
.fadeInOut-enter-active {
|
||||
animation: fadeIn 500ms
|
||||
}
|
||||
|
||||
.fadeInOut-leave-active {
|
||||
animation: fadeOut 500ms
|
||||
}
|
||||
|
||||
|
||||
.popLater-enter-active {
|
||||
transition: opacity .2s linear .5s
|
||||
}
|
||||
.popLater-enter, .popLater-leave-active, .popLater-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
.tfa-grid .slideCheckbox-enter-active {
|
||||
animation: enterFromTop 500ms
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user