Generate and Show all OTPs directly on the main view - Complete #153

This commit is contained in:
Bubka 2023-04-14 17:13:28 +02:00
parent 4f81b30fcd
commit b8c810f885
16 changed files with 504 additions and 36 deletions

View File

@ -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'));
}
/**

View 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',
];
}
}

View File

@ -29,6 +29,10 @@ public function toArray($request)
$request->merge(['withSecret' => false]);
}
if ($request->has('withOtp')) {
$request->merge(['at' => time()]);
}
return $this->collection;
}
}

View File

@ -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]);
}
),
],
);
}
}

View File

@ -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)));

View File

@ -99,6 +99,7 @@
'formatPassword' => true,
'formatPasswordBy' => 0.5,
'lang' => 'browser',
'getOtpOnRequest' => true,
],
];

View 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>

View File

@ -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" />

View File

@ -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() {

View 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>

View 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>

View File

@ -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
},
}
})

View File

@ -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)
})

View File

@ -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') },
],
}
},

View File

@ -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',

View File

@ -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
}