2023-10-31 15:32:01 +01:00
|
|
|
<script setup>
|
|
|
|
import Form from '@/components/formElements/Form'
|
|
|
|
import Spinner from '@/components/Spinner.vue'
|
2023-11-28 14:08:25 +01:00
|
|
|
import QrContentDisplay from '@/components/QrContentDisplay.vue'
|
2023-10-31 15:32:01 +01:00
|
|
|
import { useBusStore } from '@/stores/bus'
|
|
|
|
import { UseColorMode } from '@vueuse/components'
|
|
|
|
import { useNotifyStore } from '@/stores/notify'
|
|
|
|
import { QrcodeStream } from 'vue-qrcode-reader'
|
|
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
const bus = useBusStore()
|
|
|
|
const notify = useNotifyStore()
|
|
|
|
|
|
|
|
const cameraIsOn = ref(false)
|
2024-01-17 14:27:45 +01:00
|
|
|
const selectedCamera = ref(null)
|
|
|
|
const cameras = ref([])
|
2023-10-31 15:32:01 +01:00
|
|
|
const errorPhrase = ref('')
|
|
|
|
const form = reactive(new Form({
|
|
|
|
qrcode: null,
|
|
|
|
uri: '',
|
|
|
|
}))
|
2023-11-28 14:08:25 +01:00
|
|
|
const showQrContent = ref(false)
|
2023-10-31 15:32:01 +01:00
|
|
|
|
|
|
|
onMounted(async () => {
|
2024-01-17 14:27:45 +01:00
|
|
|
if (!navigator.mediaDevices?.enumerateDevices) {
|
|
|
|
errorPhrase.value = 'secured_context_required'
|
|
|
|
} else {
|
|
|
|
await navigator.mediaDevices.enumerateDevices().then((devices) => {
|
|
|
|
cameras.value = devices.filter(({ kind }) => kind === 'videoinput')
|
2023-10-31 15:32:01 +01:00
|
|
|
|
2024-01-17 14:27:45 +01:00
|
|
|
if (cameras.value.length > 0) {
|
|
|
|
selectedCamera.value = cameras.value[0]
|
|
|
|
}
|
2024-01-17 14:40:26 +01:00
|
|
|
else errorPhrase.value = 'no_cam_on_device'
|
2024-01-17 14:27:45 +01:00
|
|
|
})
|
|
|
|
.catch((err) => {
|
|
|
|
onError(err)
|
|
|
|
})
|
2023-10-31 15:32:01 +01:00
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
const onError = error => {
|
|
|
|
if (error.name === 'NotAllowedError') {
|
|
|
|
errorPhrase.value = 'need_grant_permission'
|
|
|
|
} else if (error.name === 'NotFoundError') {
|
|
|
|
errorPhrase.value = 'no_cam_on_device'
|
|
|
|
} else if (error.name === 'NotSupportedError' || error.name === 'InsecureContextError') {
|
|
|
|
errorPhrase.value = 'secured_context_required'
|
|
|
|
} else if (error.name === 'NotReadableError') {
|
|
|
|
errorPhrase.value = 'not_readable'
|
|
|
|
} else if (error.name === 'OverconstrainedError') {
|
|
|
|
errorPhrase.value = 'camera_not_suitable'
|
|
|
|
} else if (error.name === 'StreamApiNotSupportedError') {
|
|
|
|
errorPhrase.value = 'stream_api_not_supported'
|
|
|
|
} else {
|
|
|
|
notify.error(error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Pushes a decoded URI to the Create or Import form
|
|
|
|
*
|
|
|
|
* The basicQRcodeReader option is Off, so qrcode decoding has already be done by vue-qrcode-reader, whether
|
|
|
|
* from livescan or file input.
|
|
|
|
* We simply check the uri validity to prevent useless push to the form, but the form will check uri validity too.
|
|
|
|
*/
|
|
|
|
const onDetect = async (detectedCodes) => {
|
|
|
|
const [ firstCode ] = detectedCodes
|
|
|
|
form.uri = firstCode.rawValue
|
|
|
|
|
|
|
|
if (! form.uri) {
|
|
|
|
notify.warn({ text: trans('errors.qrcode_cannot_be_read') })
|
|
|
|
}
|
|
|
|
else if (form.uri.slice(0, 33).toLowerCase() == "otpauth-migration://offline?data=") {
|
|
|
|
bus.migrationUri = form.uri
|
|
|
|
router.push({ name: 'importAccounts' })
|
|
|
|
}
|
|
|
|
else if (form.uri.slice(0, 15).toLowerCase() !== "otpauth://totp/" && form.uri.slice(0, 15).toLowerCase() !== "otpauth://hotp/") {
|
2023-11-28 14:08:25 +01:00
|
|
|
showQrContent.value = true
|
2023-10-31 15:32:01 +01:00
|
|
|
notify.warn({ text: trans('errors.no_valid_otp') })
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
bus.decodedUri = form.uri
|
|
|
|
router.push({ name: 'createAccount' })
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Triggered when camera goes On
|
|
|
|
*/
|
|
|
|
function cameraOn(event) {
|
|
|
|
cameraIsOn.value = true
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Triggered when camera goes Off
|
|
|
|
*/
|
|
|
|
function cameraOff(event) {
|
|
|
|
cameraIsOn.value = false
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Exits the stream view
|
|
|
|
*/
|
|
|
|
function exitStream() {
|
|
|
|
// this.camera = 'off'
|
|
|
|
router.go(-1)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Paints the red outline during scan
|
|
|
|
*/
|
|
|
|
const paintOutline = (detectedCodes, ctx) => {
|
|
|
|
for (const detectedCode of detectedCodes) {
|
|
|
|
const [firstPoint, ...otherPoints] = detectedCode.cornerPoints
|
|
|
|
|
|
|
|
ctx.strokeStyle = 'red'
|
|
|
|
|
|
|
|
ctx.beginPath()
|
|
|
|
ctx.moveTo(firstPoint.x, firstPoint.y)
|
|
|
|
for (const { x, y } of otherPoints) {
|
|
|
|
ctx.lineTo(x, y)
|
|
|
|
}
|
|
|
|
ctx.lineTo(firstPoint.x, firstPoint.y)
|
|
|
|
ctx.closePath()
|
|
|
|
ctx.stroke()
|
|
|
|
}
|
|
|
|
}
|
2023-11-24 10:57:50 +01:00
|
|
|
|
|
|
|
function reloadLocation() {
|
|
|
|
location.reload()
|
|
|
|
}
|
2023-10-31 15:32:01 +01:00
|
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
|
|
<div class="modal is-active">
|
|
|
|
<div class="modal-background"></div>
|
|
|
|
<div class="modal-content">
|
|
|
|
<section class="section">
|
|
|
|
<div class="columns is-centered">
|
|
|
|
<div class="column is-three-quarters">
|
2023-11-24 10:33:09 +01:00
|
|
|
<div class="modal-slot has-text-centered is-shadowless">
|
2023-10-31 15:32:01 +01:00
|
|
|
<div v-if="errorPhrase">
|
|
|
|
<p class="block is-size-5">{{ $t('twofaccounts.stream.live_scan_cant_start') }}</p>
|
|
|
|
<UseColorMode v-slot="{ mode }">
|
|
|
|
<p class="block" :class="{'has-text-light': mode == 'dark'}">{{ $t('twofaccounts.stream.' + errorPhrase + '.reason') }}</p>
|
|
|
|
</UseColorMode>
|
2023-11-24 10:33:09 +01:00
|
|
|
<div v-if="errorPhrase == 'need_grant_permission'" >
|
|
|
|
<p class="is-size-7 mb-3">{{ $t('twofaccounts.stream.need_grant_permission.solution') }}</p>
|
|
|
|
<p class="is-size-7 mb-3">{{ $t('twofaccounts.stream.need_grant_permission.click_camera_icon') }}</p>
|
|
|
|
|
|
|
|
<div class="addressbar columns is-mobile is-gapless">
|
|
|
|
<div class="column is-narrow has-text-left circled">
|
|
|
|
<FontAwesomeIcon :icon="['fas', 'lock']" class="ml-1" size="xs" />
|
|
|
|
<FontAwesomeIcon :icon="['fas', 'video-slash']" class="ml-3" size="xs" />
|
|
|
|
</div>
|
|
|
|
<div class="column has-text-left ml-3">
|
|
|
|
http://my.2fauth.app/...
|
|
|
|
</div>
|
|
|
|
<div class="column is-narrow has-text-right">
|
|
|
|
<FontAwesomeIcon :icon="['far', 'star']" class="mr-1" />
|
|
|
|
</div>
|
|
|
|
</div>
|
2023-11-24 10:57:50 +01:00
|
|
|
<p>
|
|
|
|
<a @click.stop="reloadLocation">{{ $t('commons.refresh') }}</a>
|
|
|
|
</p>
|
2023-11-24 10:33:09 +01:00
|
|
|
|
|
|
|
<!-- <div class="addressbar">
|
|
|
|
<FontAwesomeIcon :icon="['fas', 'video-slash']" class="mr-3 ml-2" size="xs" />
|
|
|
|
<span class="circled">http://my.2fauth.app/...</span>
|
|
|
|
<FontAwesomeIcon :icon="['far', 'star']" class="mr-3" size="xs" />
|
|
|
|
</div> -->
|
|
|
|
</div>
|
|
|
|
<p v-else class="is-size-7">{{ $t('twofaccounts.stream.' + errorPhrase + '.solution') }}</p>
|
2023-10-31 15:32:01 +01:00
|
|
|
</div>
|
|
|
|
<UseColorMode v-else v-slot="{ mode }">
|
|
|
|
<span class="is-size-4" :class="mode == 'dark' ? 'has-text-light':'has-text-grey-dark'">
|
|
|
|
<Spinner :isVisible="true" :type="'raw'" class="is-size-1" />
|
|
|
|
</span>
|
|
|
|
</UseColorMode>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</section>
|
|
|
|
</div>
|
2023-11-24 10:57:50 +01:00
|
|
|
<div v-show="!errorPhrase" class="fullscreen-streamer">
|
2023-10-31 15:32:01 +01:00
|
|
|
<qrcode-stream
|
2024-01-17 14:27:45 +01:00
|
|
|
v-if="selectedCamera !== null"
|
2023-10-31 15:32:01 +01:00
|
|
|
:track="paintOutline"
|
|
|
|
@detect="onDetect"
|
|
|
|
@error="onError"
|
|
|
|
@camera-on="cameraOn"
|
|
|
|
@camera-off="cameraOff"
|
|
|
|
></qrcode-stream>
|
|
|
|
<!-- device selector -->
|
2024-01-17 14:27:45 +01:00
|
|
|
<div v-if="cameraIsOn && cameras.length > 1" class="field has-addons has-addons-centered mt-3">
|
2023-10-31 15:32:01 +01:00
|
|
|
<p class="control has-icons-left">
|
|
|
|
<span class="select">
|
2024-01-17 14:27:45 +01:00
|
|
|
<select v-model="selectedCamera">
|
|
|
|
<option v-for="camera in cameras" :key="camera.label" :value="camera">
|
|
|
|
{{ camera.label ? camera.label : $t('commons.default') }}
|
2023-10-31 15:32:01 +01:00
|
|
|
</option>
|
|
|
|
</select>
|
|
|
|
</span>
|
|
|
|
<span class="icon is-small is-left">
|
|
|
|
<FontAwesomeIcon :icon="['fas', 'camera']" />
|
|
|
|
</span>
|
|
|
|
</p>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="fullscreen-footer">
|
2023-11-03 13:20:40 +01:00
|
|
|
<ButtonBackCloseCancel action="cancel" :isCapture="true" :useLinkTag="false" @canceled="exitStream()" />
|
2023-10-31 15:32:01 +01:00
|
|
|
</div>
|
|
|
|
</div>
|
2023-11-28 14:08:25 +01:00
|
|
|
<modal v-model="showQrContent">
|
|
|
|
<QrContentDisplay :qrContent="form.uri" />
|
|
|
|
</modal>
|
2023-11-24 10:33:09 +01:00
|
|
|
</template>
|