<script setup> import Spinner from '@/components/Spinner.vue' import TotpLooper from '@/components/TotpLooper.vue' import Dots from '@/components/Dots.vue' import twofaccountService from '@/services/twofaccountService' import { useUserStore } from '@/stores/user' import { UseColorMode } from '@vueuse/components' import { useDisplayablePassword } from '@/composables/helpers' const user = useUserStore() const $2fauth = inject('2fauth') const { copy, copied } = useClipboard({ legacy: true }) const emit = defineEmits(['please-close-me', 'increment-hotp', 'validation-error']) const props = defineProps({ otp_type : String, account : String, service : String, icon : String, secret : String, digits : Number, algorithm : String, period : null, counter : null, image : String, qrcode : null, uri : String }) const id = ref(null) const uri = ref(null) const otpauthParams = reactive({ otp_type : String, account : String, service : String, icon : String, secret : String, digits : Number, algorithm : String, period : null, counter : null, image : String }) const password = ref('') const generated_at = ref(null) const hasTOTP = ref(false) const showInlineSpinner = ref(false) const dots = ref() const totpLooper = ref() const otpSpanTag = ref() /*** * */ const show = async (accountId) => { // 3 possible cases : // // Case 1 : When user asks for an otp of an existing account: the ID is provided so we fetch the account data // from db but without the uri. This prevent the uri (a sensitive data) to transit via http request unnecessarily. // In this case this.otp_type is sent by the backend. // // Case 2 : When user uses the Quick Uploader and preview the account: No ID but we have an URI. // // Case 3 : When user uses the Advanced form and preview the account: We should have all OTP parameter // to obtain an otp, including Secret and otp_type which are required otpauthParams.otp_type = props.otp_type otpauthParams.account = props.account otpauthParams.service = props.service otpauthParams.icon = props.icon otpauthParams.secret = props.secret otpauthParams.digits = props.digits otpauthParams.algorithm = props.algorithm otpauthParams.period = props.period otpauthParams.counter = props.counter setLoadingState() // Case 1 if (accountId) { id.value = accountId const { data } = await twofaccountService.get(id.value) otpauthParams.service = data.service otpauthParams.account = data.account otpauthParams.icon = data.icon otpauthParams.otp_type = data.otp_type if( isHMacBased(data.otp_type) && data.counter ) { otpauthParams.counter = data.counter } } // Case 2 else if(props.uri) { uri.value = props.uri otpauthParams.otp_type = props.uri.slice(0, 15 ).toLowerCase() === "otpauth://totp/" ? 'totp' : 'hotp' } // Case 3 else if (! props.secret) { notify.error(new Error(trans('errors.cannot_create_otp_without_secret'))) } else if (! isTimeBased(otpauthParams.otp_type) && ! isHMacBased(otpauthParams.otp_type)) { notify.error(new Error(trans('errors.not_a_supported_otp_type'))) } try { await getOtp() focusOnOTP() } catch(error) { clearOTP() } } /** * Requests and handles a fresh OTP */ async function getOtp() { setLoadingState() getOtpPromise().then(response => { let otp = response.data password.value = otp.password if(user.preferences.copyOtpOnDisplay) { copyOTP(otp.password) } if (isTimeBased(otp.otp_type)) { generated_at.value = otp.generated_at otpauthParams.period = otp.period hasTOTP.value = true nextTick().then(() => { totpLooper.value.startLoop() }) } else if (isHMacBased(otp.otp_type)) { otpauthParams.counter = otp.counter // returned counter & uri are incremented emit('increment-hotp', { nextHotpCounter: otp.counter, nextUri: otp.uri }) } }) .catch(error => { if (error.response.status === 422) { emit('validation-error', error.response) } throw error }) .finally(() => { showInlineSpinner.value = false }) } /** * Shows blacked dots and a loading spinner */ function setLoadingState() { showInlineSpinner.value = true dots.value.turnOff() } /** * Returns the appropriate promise to get a fresh OTP from backend */ function getOtpPromise() { if(id.value) { return twofaccountService.getOtpById(id.value) } else if(uri.value) { return twofaccountService.getOtpByUri(uri.value) } else { return twofaccountService.getOtpByParams(otpauthParams) } } /** * Reset component's refs */ function clearOTP() { id.value = otpauthParams.counter = generated_at.value = null otpauthParams.service = otpauthParams.account = otpauthParams.icon = otpauthParams.otp_type = otpauthParams.secret = '' password.value = '... ...' hasTOTP.value = false totpLooper.value?.clearLooper(); } /** * Put focus on the OTP html tag */ function focusOnOTP() { nextTick().then(() => { otpSpanTag.value?.focus() }) } /** * Copies to clipboard and notify * * @param {string} otp The password to copy * @param {*} permit_closing Toggle moddle closing On-Off */ function copyOTP(otp, permit_closing) { copy(otp.replace(/ /g, '')) if (copied) { if(user.preferences.kickUserAfter == -1 && (permit_closing || false) === true) { user.logout() } else if(user.preferences.closeOtpOnCopy && (permit_closing || false) === true) { emit("please-close-me"); clearOTP() } notify.success({ text: trans('commons.copied_to_clipboard') }) } } /** * Checks OTP type is Time based (TOTP) * * @param {string} otp_type */ function isTimeBased(otp_type) { return (otp_type === 'totp' || otp_type === 'steamtotp') } /** * Checks OTP type is HMAC based (HOTP) * * @param {string} otp_type */ function isHMacBased(otp_type) { return otp_type === 'hotp' } /** * Turns dots On from the first one to the provided one */ function turnDotOn(dotIndex) { dots.value.turnOn(dotIndex) } defineExpose({ show, clearOTP }) </script> <template> <div> <figure class="image is-64x64" :class="{ 'no-icon': !otpauthParams.icon }" style="display: inline-block"> <img :src="$2fauth.config.subdirectory + '/storage/icons/' + otpauthParams.icon" v-if="otpauthParams.icon" :alt="$t('twofaccounts.icon_to_illustrate_the_account')"> </figure> <UseColorMode v-slot="{ mode }"> <p class="is-size-4 has-ellipsis" :class="mode == 'dark' ? 'has-text-grey-light' : 'has-text-grey'">{{ otpauthParams.service }}</p> <p class="is-size-6 has-ellipsis" :class="mode == 'dark' ? 'has-text-grey' : 'has-text-grey-light'">{{ otpauthParams.account }}</p> <p> <span v-if="!showInlineSpinner" id="otp" role="log" ref="otpSpanTag" tabindex="0" class="otp is-size-1 is-clickable px-3" :class="mode == 'dark' ? 'has-text-white' : 'has-text-grey-dark'" @click="copyOTP(password, true)" @keyup.enter="copyOTP(password, true)" :title="$t('commons.copy_to_clipboard')" > {{ useDisplayablePassword(password) }} </span> <span v-else tabindex="0" class="otp is-size-1"> <Spinner :isVisible="showInlineSpinner" :type="'raw'" /> </span> </p> </UseColorMode> <Dots v-show="isTimeBased(otpauthParams.otp_type)" ref="dots"></Dots> <ul v-show="isHMacBased(otpauthParams.otp_type)"> <li>counter: {{ otpauthParams.counter }}</li> </ul> <TotpLooper v-if="hasTOTP" :period="otpauthParams.period" :generated_at="generated_at" :autostart="false" v-on:loop-ended="getOtp()" v-on:loop-started="turnDotOn($event)" v-on:stepped-up="turnDotOn($event)" ref="totpLooper" ></TotpLooper> </div> </template>