mirror of
https://github.com/Bubka/2FAuth.git
synced 2025-06-19 19:28:08 +02: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 @@ namespace App\Api\v1\Controllers;
|
|||||||
use App\Api\v1\Requests\TwoFAccountBatchRequest;
|
use App\Api\v1\Requests\TwoFAccountBatchRequest;
|
||||||
use App\Api\v1\Requests\TwoFAccountDynamicRequest;
|
use App\Api\v1\Requests\TwoFAccountDynamicRequest;
|
||||||
use App\Api\v1\Requests\TwoFAccountImportRequest;
|
use App\Api\v1\Requests\TwoFAccountImportRequest;
|
||||||
|
use App\Api\v1\Requests\TwoFAccountIndexRequest;
|
||||||
use App\Api\v1\Requests\TwoFAccountReorderRequest;
|
use App\Api\v1\Requests\TwoFAccountReorderRequest;
|
||||||
use App\Api\v1\Requests\TwoFAccountStoreRequest;
|
use App\Api\v1\Requests\TwoFAccountStoreRequest;
|
||||||
use App\Api\v1\Requests\TwoFAccountUpdateRequest;
|
use App\Api\v1\Requests\TwoFAccountUpdateRequest;
|
||||||
@ -29,7 +30,7 @@ class TwoFAccountController extends Controller
|
|||||||
*
|
*
|
||||||
* @return \App\Api\v1\Resources\TwoFAccountCollection
|
* @return \App\Api\v1\Resources\TwoFAccountCollection
|
||||||
*/
|
*/
|
||||||
public function index(Request $request)
|
public function index(TwoFAccountIndexRequest $request)
|
||||||
{
|
{
|
||||||
// Quick fix for #176
|
// Quick fix for #176
|
||||||
if (config('auth.defaults.guard') === 'reverse-proxy-guard' && User::count() === 1) {
|
if (config('auth.defaults.guard') === 'reverse-proxy-guard' && User::count() === 1) {
|
||||||
@ -39,7 +40,15 @@ class TwoFAccountController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 @@ class TwoFAccountCollection extends ResourceCollection
|
|||||||
$request->merge(['withSecret' => false]);
|
$request->merge(['withSecret' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($request->has('withOtp')) {
|
||||||
|
$request->merge(['at' => time()]);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->collection;
|
return $this->collection;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,8 @@ namespace App\Api\v1\Resources;
|
|||||||
/**
|
/**
|
||||||
* @property mixed $id
|
* @property mixed $id
|
||||||
* @property mixed $group_id
|
* @property mixed $group_id
|
||||||
|
*
|
||||||
|
* @method App\Models\Dto\TotpDto|App\Models\Dto\HotpDto getOtp(int $time)
|
||||||
*/
|
*/
|
||||||
class TwoFAccountReadResource extends TwoFAccountStoreResource
|
class TwoFAccountReadResource extends TwoFAccountStoreResource
|
||||||
{
|
{
|
||||||
@ -21,7 +23,20 @@ class TwoFAccountReadResource extends TwoFAccountStoreResource
|
|||||||
'id' => (int) $this->id,
|
'id' => (int) $this->id,
|
||||||
'group_id' => is_null($this->group_id) ? null : (int) $this->group_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 @@ class TwoFAccount extends Model implements Sortable
|
|||||||
* @throws UnsupportedOtpTypeException The defined OTP type is not supported
|
* @throws UnsupportedOtpTypeException The defined OTP type is not supported
|
||||||
* @throws InvalidOtpParameterException One OTP parameter is invalid
|
* @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'));
|
Log::info(sprintf('OTP requested for TwoFAccount (%s)', $this->id ? 'id:' . $this->id : 'preview'));
|
||||||
|
|
||||||
@ -377,7 +377,7 @@ class TwoFAccount extends Model implements Sortable
|
|||||||
} else {
|
} else {
|
||||||
$OtpDto = new TotpDto();
|
$OtpDto = new TotpDto();
|
||||||
$OtpDto->otp_type = $this->otp_type;
|
$OtpDto->otp_type = $this->otp_type;
|
||||||
$OtpDto->generated_at = time();
|
$OtpDto->generated_at = $time ?: time();
|
||||||
$OtpDto->password = $this->otp_type === self::TOTP
|
$OtpDto->password = $this->otp_type === self::TOTP
|
||||||
? $this->generator->at($OtpDto->generated_at)
|
? $this->generator->at($OtpDto->generated_at)
|
||||||
: SteamTotp::getAuthCode(base64_encode(Base32::decodeUpper($this->secret)));
|
: SteamTotp::getAuthCode(base64_encode(Base32::decodeUpper($this->secret)));
|
||||||
|
@ -99,6 +99,7 @@ return [
|
|||||||
'formatPassword' => true,
|
'formatPassword' => true,
|
||||||
'formatPasswordBy' => 0.5,
|
'formatPasswordBy' => 0.5,
|
||||||
'lang' => 'browser',
|
'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"
|
:disabled="isDisabled"
|
||||||
/>
|
/>
|
||||||
<span v-if="choice.legend" v-html="choice.legend" class="is-block is-size-7"></span>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<field-error :form="form" :field="fieldName" />
|
<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 class="is-size-6 has-ellipsis" :class="$root.showDarkMode ? 'has-text-grey' : 'has-text-grey-light'">{{ internal_account }}</p>
|
||||||
<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')">
|
<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>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<ul class="dots" v-show="isTimeBased(internal_otp_type)">
|
<ul class="dots" v-show="isTimeBased(internal_otp_type)">
|
||||||
@ -61,17 +61,17 @@
|
|||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
|
||||||
displayedOtp() {
|
// displayedOtp() {
|
||||||
let pwd = this.internal_password
|
// let pwd = this.internal_password
|
||||||
if (this.$root.userPreferences.formatPassword && pwd.length > 0) {
|
// 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 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'));
|
// const chunks = pwd.match(new RegExp(`.{1,${x}}`, 'g'));
|
||||||
if (chunks) {
|
// if (chunks) {
|
||||||
pwd = chunks.join(' ')
|
// pwd = chunks.join(' ')
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
return this.$root.userPreferences.showOtpAsDot ? pwd.replace(/[0-9]/g, '●') : pwd
|
// return this.$root.userPreferences.showOtpAsDot ? pwd.replace(/[0-9]/g, '●') : pwd
|
||||||
},
|
// },
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted: function() {
|
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)
|
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>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</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">
|
<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})">
|
<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']" />
|
{{ 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>
|
||||||
</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">
|
<transition name="fadeInOut">
|
||||||
<div class="tfa-cell tfa-edit has-text-grey" v-if="editMode">
|
<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'">
|
||||||
<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') }}
|
||||||
{{ $t('commons.edit') }}
|
</router-link>
|
||||||
</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')">
|
||||||
<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']" />
|
||||||
<font-awesome-icon :icon="['fas', 'qrcode']" />
|
</router-link>
|
||||||
</router-link>
|
|
||||||
<!-- </div> -->
|
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
<transition name="fadeInOut">
|
<transition name="fadeInOut">
|
||||||
@ -167,6 +179,7 @@
|
|||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</transition-group>
|
||||||
</draggable>
|
</draggable>
|
||||||
<!-- </vue-pull-refresh> -->
|
<!-- </vue-pull-refresh> -->
|
||||||
@ -215,6 +228,19 @@
|
|||||||
</p>
|
</p>
|
||||||
</vue-footer>
|
</vue-footer>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -253,7 +279,10 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// import Twofaccount from '../components/Twofaccount'
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
|
import TotpLooper from '../components/TotpLooper'
|
||||||
|
import Dots from '../components/Dots'
|
||||||
import OtpDisplayer from '../components/OtpDisplayer'
|
import OtpDisplayer from '../components/OtpDisplayer'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import Form from './../components/Form'
|
import Form from './../components/Form'
|
||||||
@ -324,7 +353,21 @@
|
|||||||
else {
|
else {
|
||||||
return this.$t('commons.all')
|
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)
|
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
|
// 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
|
const accounts = this.$storage.get('accounts', null) // use null as fallback if localstorage is empty
|
||||||
if( accounts ) this.accounts = accounts
|
if( accounts ) this.accounts = accounts
|
||||||
|
|
||||||
@ -358,13 +401,85 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
|
// Twofaccount,
|
||||||
Modal,
|
Modal,
|
||||||
OtpDisplayer,
|
OtpDisplayer,
|
||||||
draggable
|
TotpLooper,
|
||||||
|
Dots,
|
||||||
|
draggable,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
methods: {
|
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
|
* Route user to the appropriate submitting view
|
||||||
*/
|
*/
|
||||||
@ -386,8 +501,10 @@
|
|||||||
fetchAccounts(forceRefresh = false) {
|
fetchAccounts(forceRefresh = false) {
|
||||||
let accounts = []
|
let accounts = []
|
||||||
this.selectedAccounts = []
|
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) => {
|
response.data.forEach((data) => {
|
||||||
accounts.push(data)
|
accounts.push(data)
|
||||||
})
|
})
|
||||||
|
@ -37,12 +37,14 @@
|
|||||||
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.security') }}</h4>
|
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.security') }}</h4>
|
||||||
<!-- auto lock -->
|
<!-- 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')" />
|
<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 -->
|
<!-- 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')" />
|
<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 -->
|
<!-- 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 -->
|
<!-- 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>
|
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.data_input') }}</h4>
|
||||||
<!-- basic qrcode -->
|
<!-- basic qrcode -->
|
||||||
@ -119,6 +121,7 @@
|
|||||||
theme: '',
|
theme: '',
|
||||||
formatPassword: null,
|
formatPassword: null,
|
||||||
formatPasswordBy: '',
|
formatPasswordBy: '',
|
||||||
|
getOtpOnRequest: null,
|
||||||
}),
|
}),
|
||||||
settingsForm: null,
|
settingsForm: null,
|
||||||
settings: {
|
settings: {
|
||||||
@ -160,6 +163,10 @@
|
|||||||
{ text: this.$t('settings.forms.upload'), value: 'upload' },
|
{ text: this.$t('settings.forms.upload'), value: 'upload' },
|
||||||
{ text: this.$t('settings.forms.advanced_form'), value: 'advancedForm' },
|
{ 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 @@ return [
|
|||||||
'label' => 'Disable registration',
|
'label' => 'Disable registration',
|
||||||
'help' => 'Prevent new user 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',
|
'never' => 'Never',
|
||||||
'on_otp_copy' => 'On security code copy',
|
'on_otp_copy' => 'On security code copy',
|
||||||
'1_minutes' => 'After 1 minute',
|
'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;
|
height: 4px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: hsl(348, 100%, 61%);
|
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 {
|
.dots li[data-is-active]~li {
|
||||||
@ -1229,12 +1238,19 @@ footer .field.is-grouped {
|
|||||||
.fadeInOut-enter-active {
|
.fadeInOut-enter-active {
|
||||||
animation: fadeIn 500ms
|
animation: fadeIn 500ms
|
||||||
}
|
}
|
||||||
|
|
||||||
.fadeInOut-leave-active {
|
.fadeInOut-leave-active {
|
||||||
animation: fadeOut 500ms
|
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 {
|
.tfa-grid .slideCheckbox-enter-active {
|
||||||
animation: enterFromTop 500ms
|
animation: enterFromTop 500ms
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user