123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507 |
- <script setup>
- import Form from '@/components/formElements/Form'
- import OtpDisplay from '@/components/OtpDisplay.vue'
- import QrContentDisplay from '@/components/QrContentDisplay.vue'
- import FormLockField from '@/components/formElements/FormLockField.vue'
- import twofaccountService from '@/services/twofaccountService'
- import { useUserStore } from '@/stores/user'
- import { useTwofaccounts } from '@/stores/twofaccounts'
- import { useBusStore } from '@/stores/bus'
- import { useNotifyStore } from '@/stores/notify'
- import { UseColorMode } from '@vueuse/components'
-
- const $2fauth = inject('2fauth')
- const router = useRouter()
- const route = useRoute()
- const user = useUserStore()
- const twofaccounts = useTwofaccounts()
- const bus = useBusStore()
- const notify = useNotifyStore()
- const form = reactive(new Form({
- service: '',
- account: '',
- otp_type: '',
- icon: '',
- secret: '',
- algorithm: '',
- digits: null,
- counter: null,
- period: null,
- image: '',
- }))
- const qrcodeForm = reactive(new Form({
- qrcode: null
- }))
- const iconForm = reactive(new Form({
- icon: null
- }))
- const otp_types = [
- { text: 'TOTP', value: 'totp' },
- { text: 'HOTP', value: 'hotp' },
- { text: 'STEAM', value: 'steamtotp' },
- ]
- const digitsChoices = [
- { text: '6', value: 6 },
- { text: '7', value: 7 },
- { text: '8', value: 8 },
- { text: '9', value: 9 },
- { text: '10', value: 10 },
- ]
- const algorithms = [
- { text: 'sha1', value: 'sha1' },
- { text: 'sha256', value: 'sha256' },
- { text: 'sha512', value: 'sha512' },
- { text: 'md5', value: 'md5' },
- ]
- const uri = ref()
- const tempIcon = ref('')
- const showQuickForm = ref(false)
- const showAlternatives = ref(false)
- const showAdvancedForm = ref(false)
- const ShowTwofaccountInModal = ref(false)
- const fetchingLogo = ref(false)
- // $refs
- const iconInput = ref(null)
- const OtpDisplayForQuickForm = ref(null)
- const OtpDisplayForAdvancedForm = ref(null)
- const qrcodeInputLabel = ref(null)
- const qrcodeInput = ref(null)
- const iconInputLabel = ref(null)
-
- const props = defineProps({
- twofaccountId: [Number, String]
- })
- const isEditMode = computed(() => {
- return props.twofaccountId != undefined
- })
- onMounted(() => {
- if (route.name == 'editAccount') {
- twofaccountService.get(props.twofaccountId).then(response => {
- form.fill(response.data)
- form.setOriginal()
- // set account icon as temp icon
- tempIcon.value = form.icon
- showAdvancedForm.value = true
- })
- }
- else if( bus.decodedUri ) {
- // the Start view provided an uri via the bus store so we parse it and prefill the quick form
- uri.value = bus.decodedUri
- bus.decodedUri = null
- twofaccountService.preview(uri.value).then(response => {
- form.fill(response.data)
- tempIcon.value = response.data.icon ? response.data.icon : ''
- showQuickForm.value = true
- nextTick().then(() => {
- OtpDisplayForQuickForm.value.show()
- })
- })
- .catch(error => {
- if( error.response.data.errors.uri ) {
- showAlternatives.value = true
- showAdvancedForm.value = true
- }
- })
- } else {
- showAdvancedForm.value = true
- }
- })
- watch(tempIcon, (val) => {
- if( showQuickForm.value ) {
- nextTick().then(() => {
- OtpDisplayForQuickForm.value.icon = val
- })
- }
- })
- watch(ShowTwofaccountInModal, (val) => {
- if (val == false) {
- OtpDisplayForAdvancedForm.value?.clearOTP()
- OtpDisplayForQuickForm.value?.clearOTP()
- }
- })
- watch(
- () => form.otp_type,
- (to, from) => {
- if (to === 'steamtotp') {
- form.service = 'Steam'
- fetchLogo()
- }
- else if (from === 'steamtotp') {
- form.service = ''
- deleteTempIcon()
- }
- }
- )
- /**
- * Wrapper to call the appropriate function at form submit
- */
- function handleSubmit() {
- isEditMode.value ? updateAccount() : createAccount()
- }
- /**
- * Submits the form to the backend to store the new account
- */
- async function createAccount() {
- // set current temp icon as account icon
- form.icon = tempIcon.value
- const { data } = await form.post('/api/v1/twofaccounts')
- if (form.errors.any() === false) {
- twofaccounts.items.push(data)
- notify.success({ text: trans('twofaccounts.account_created') })
- router.push({ name: 'accounts' });
- }
- }
- /**
- * Submits the form to the backend to save the edited account
- */
- async function updateAccount() {
- // Set new icon and delete old one
- if( tempIcon.value !== form.icon ) {
- let oldIcon = ''
- oldIcon = form.icon
- form.icon = tempIcon.value
- tempIcon.value = oldIcon
- deleteTempIcon()
- }
- const { data } = await form.put('/api/v1/twofaccounts/' + props.twofaccountId)
- if( form.errors.any() === false ) {
- const index = twofaccounts.items.findIndex(acc => acc.id === data.id)
- twofaccounts.items.splice(index, 1, data)
- notify.success({ text: trans('twofaccounts.account_updated') })
- router.push({ name: 'accounts' })
- }
- }
- /**
- * Shows an OTP generated with the infos filled in the form
- * in order to preview or validated the password/the form data
- */
- function previewOTP() {
- form.clear()
- ShowTwofaccountInModal.value = true
- OtpDisplayForAdvancedForm.value.show()
- }
- /**
- * Exits the view with user confirmation
- */
- function cancelCreation() {
- if (form.hasChanged() || tempIcon.value != form.icon) {
- if (confirm(trans('twofaccounts.confirm.cancel')) === true) {
- if (!isEditMode.value || tempIcon.value != form.icon) {
- deleteTempIcon()
- }
- router.push({name: 'accounts'})
- }
- }
- else router.push({name: 'accounts'})
- }
- /**
- * Uploads the submited image resource to the backend
- */
- function uploadIcon() {
- // clean possible already uploaded temp icon
- deleteTempIcon()
- iconForm.icon = iconInput.value.files[0]
- iconForm.upload('/api/v1/icons', { returnError: true })
- .then(response => {
- tempIcon.value = response.data.filename
- if (showQuickForm.value) {
- form.icon = tempIcon.value
- }
- })
- .catch(error => {
- if (error.response.status !== 422) {
- notify.alert({ text: error.response.data.message})
- }
- })
- }
- /**
- * Deletes the temp icon from backend
- */
- function deleteTempIcon() {
- if (isEditMode.value) {
- if (tempIcon.value) {
- if (tempIcon.value !== form.icon) {
- twofaccountService.deleteIcon(tempIcon.value)
- }
- tempIcon.value = ''
- }
- }
- else if (tempIcon.value) {
- twofaccountService.deleteIcon(tempIcon.value)
- tempIcon.value = ''
- if (showQuickForm.value) {
- form.icon = ''
- }
- }
- }
- /**
- * Increments the HOTP counter of the form after a preview
- *
- * @param {object} payload
- */
- function incrementHotp(payload) {
- // The quick form or the preview feature has incremented the HOTP counter so we get the new value from
- // the OtpDisplay component.
- // This could desynchronized the HOTP verification server and our local counter if the user never verified the HOTP but this
- // is acceptable (and HOTP counter can be edited by the way)
- form.counter = payload.nextHotpCounter
-
- //form.uri = payload.nextUri
- }
-
- /**
- * Maps errors received by the OtpDisplay to the form errors instance
- *
- * @param {object} errorResponse
- */
- function mapDisplayerErrors(errorResponse) {
- form.errors.set(form.extractErrors(errorResponse))
- }
- /**
- * Sends a QR code to backend for decoding and prefill the form with the qr data
- */
- function uploadQrcode() {
- qrcodeForm.qrcode = qrcodeInput.value.files[0]
- // First we get the uri encoded in the qrcode
- qrcodeForm.upload('/api/v1/qrcode/decode', { returnError: true })
- .then(response => {
- uri.value = response.data.data
-
- // Then the otp described by the uri
- twofaccountService.preview(uri.value, { returnError: true }).then(response => {
- form.fill(response.data)
- tempIcon.value = response.data.icon ? response.data.icon : null
- })
- .catch(error => {
- if( error.response.status === 422 ) {
- if( error.response.data.errors.uri ) {
- showAlternatives.value = true
- }
- else notify.alert({ text: trans(error.response.data.message) })
- } else {
- notify.error(error)
- }
- })
- })
- .catch(error => {
- if (error.response.status !== 422) {
- notify.alert({ text: error.response.data.message})
- }
- })
- }
- /**
- * Tries to get the official logo/icon of the Service filled in the form
- */
- function fetchLogo() {
- if (user.preferences.getOfficialIcons) {
- fetchingLogo.value = true
- twofaccountService.getLogo(form.service, { returnError: true })
- .then(response => {
- if (response.status === 201) {
- // clean possible already uploaded temp icon
- deleteTempIcon()
- tempIcon.value = response.data.filename;
- }
- else notify.warn( {text: trans('errors.no_logo_found_for_x', {service: strip_tags(form.service)}) })
- })
- .catch(() => {
- notify.warn({ text: trans('errors.no_logo_found_for_x', {service: strip_tags(form.service)}) })
- })
- .finally(() => {
- fetchingLogo.value = false
- })
- }
- }
- /**
- * Strips html tags to prevent code injection
- *
- * @param {*} str
- */
- function strip_tags(str) {
- return str.replace(/(<([^> ]+)>)/ig, "")
- }
- </script>
- <template>
- <div>
- <!-- Quick form -->
- <form @submit.prevent="createAccount" @keydown="form.onKeydown($event)" v-if="!isEditMode && showQuickForm">
- <div class="container preview has-text-centered">
- <div class="columns is-mobile">
- <div class="column">
- <FieldError v-if="iconForm.errors.hasAny('icon')" :error="iconForm.errors.get('icon')" :field="'icon'" class="help-for-file" />
- <label class="add-icon-button" v-if="!tempIcon">
- <input class="file-input" type="file" accept="image/*" v-on:change="uploadIcon" ref="iconInput">
- <FontAwesomeIcon :icon="['fas', 'image']" size="2x" />
- </label>
- <button class="delete delete-icon-button is-medium" v-if="tempIcon" @click.prevent="deleteTempIcon"></button>
- <OtpDisplay
- ref="OtpDisplayForQuickForm"
- v-bind="form.data()"
- @increment-hotp="incrementHotp"
- @validation-error="mapDisplayerErrors"
- @please-close-me="ShowTwofaccountInModal = false">
- </OtpDisplay>
- </div>
- </div>
- <div class="columns is-mobile" role="alert">
- <div v-if="form.errors.any()" class="column">
- <p v-for="(field, index) in form.errors.errors" :key="index" class="help is-danger">
- <ul>
- <li v-for="(error, index) in field" :key="index">{{ error }}</li>
- </ul>
- </p>
- </div>
- </div>
- <div class="columns is-mobile">
- <div class="column quickform-footer">
- <div class="field is-grouped is-grouped-centered">
- <div class="control">
- <VueButton :isLoading="form.isBusy" >{{ $t('commons.save') }}</VueButton>
- </div>
- <ButtonBackCloseCancel action="cancel" :isText="true" :useLinkTag="false" @canceled="cancelCreation" />
- </div>
- </div>
- </div>
- </div>
- </form>
- <!-- Full form -->
- <FormWrapper :title="$t(isEditMode ? 'twofaccounts.forms.edit_account' : 'twofaccounts.forms.new_account')" v-if="showAdvancedForm">
- <form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
- <!-- qcode fileupload -->
- <div v-if="!isEditMode" class="field is-grouped">
- <div class="control">
- <UseColorMode v-slot="{ mode }">
- <div role="button" tabindex="0" class="file is-small" :class="{ 'is-black': mode == 'dark' }" @keyup.enter="qrcodeInputLabel.click()">
- <label class="file-label" :title="$t('twofaccounts.forms.use_qrcode.title')" ref="qrcodeInputLabel">
- <input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="uploadQrcode" ref="qrcodeInput">
- <span class="file-cta">
- <span class="file-icon">
- <FontAwesomeIcon :icon="['fas', 'qrcode']" size="lg" />
- </span>
- <span class="file-label">{{ $t('twofaccounts.forms.prefill_using_qrcode') }}</span>
- </span>
- </label>
- </div>
- </UseColorMode>
- </div>
- </div>
- <FieldError v-if="qrcodeForm.errors.hasAny('qrcode')" :error="qrcodeForm.errors.get('qrcode')" :field="'qrcode'" class="help-for-file" />
- <!-- service -->
- <FormField v-model="form.service" fieldName="service" :fieldError="form.errors.get('email')" :isDisabled="form.otp_type === 'steamtotp'" label="twofaccounts.service" :placeholder="$t('twofaccounts.forms.service.placeholder')" autofocus />
- <!-- account -->
- <FormField v-model="form.account" fieldName="account" :fieldError="form.errors.get('account')" label="twofaccounts.account" :placeholder="$t('twofaccounts.forms.account.placeholder')" />
- <!-- icon upload -->
- <label class="label">{{ $t('twofaccounts.icon') }}</label>
- <div class="field is-grouped">
- <!-- Try my luck button -->
- <div class="control" v-if="user.preferences.getOfficialIcons">
- <UseColorMode v-slot="{ mode }">
- <VueButton @click="fetchLogo" :color="mode == 'dark' ? 'is-dark' : ''" :nativeType="'button'" :is-loading="fetchingLogo" :isDisabled="!form.service">
- <span class="icon is-small">
- <FontAwesomeIcon :icon="['fas', 'globe']" />
- </span>
- <span>{{ $t('twofaccounts.forms.i_m_lucky') }}</span>
- </VueButton>
- </UseColorMode>
- </div>
- <!-- upload icon button -->
- <div class="control is-flex">
- <UseColorMode v-slot="{ mode }">
- <div role="button" tabindex="0" class="file mr-3" :class="mode == 'dark' ? 'is-dark' : 'is-white'" @keyup.enter="iconInputLabel.click()">
- <label class="file-label" ref="iconInputLabel">
- <input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="uploadIcon" ref="iconInput">
- <span class="file-cta">
- <span class="file-icon">
- <FontAwesomeIcon :icon="['fas', 'upload']" />
- </span>
- <span class="file-label">{{ $t('twofaccounts.forms.choose_image') }}</span>
- </span>
- </label>
- </div>
- <span class="tag is-large" :class="mode =='dark' ? 'is-dark' : 'is-white'" v-if="tempIcon">
- <img class="icon-preview" :src="$2fauth.config.subdirectory + '/storage/icons/' + tempIcon" :alt="$t('twofaccounts.icon_to_illustrate_the_account')">
- <button class="clear-selection delete is-small" @click.prevent="deleteTempIcon" :aria-label="$t('twofaccounts.remove_icon')"></button>
- </span>
- </UseColorMode>
- </div>
- </div>
- <div class="field">
- <FieldError v-if="iconForm.errors.hasAny('icon')" :error="iconForm.errors.get('icon')" :field="'icon'" class="help-for-file" />
- <p v-if="user.preferences.getOfficialIcons" class="help" v-html="$t('twofaccounts.forms.i_m_lucky_legend')"></p>
- </div>
- <!-- otp type -->
- <FormToggle v-model="form.otp_type" :isDisabled="isEditMode" :choices="otp_types" fieldName="otp_type" :fieldError="form.errors.get('otp_type')" label="twofaccounts.forms.otp_type.label" help="twofaccounts.forms.otp_type.help" :hasOffset="true" />
- <div v-if="form.otp_type != ''">
- <!-- secret -->
- <FormLockField :isEditMode="isEditMode" v-model.trimAll="form.secret" fieldName="secret" :fieldError="form.errors.get('secret')" label="twofaccounts.forms.secret.label" help="twofaccounts.forms.secret.help" />
- <!-- Options -->
- <div v-if="form.otp_type !== 'steamtotp'">
- <h2 class="title is-4 mt-5 mb-2">{{ $t('commons.options') }}</h2>
- <p class="help mb-4">
- {{ $t('twofaccounts.forms.options_help') }}
- </p>
- <!-- digits -->
- <FormToggle v-model="form.digits" :choices="digitsChoices" fieldName="digits" :fieldError="form.errors.get('digits')" label="twofaccounts.forms.digits.label" help="twofaccounts.forms.digits.help" />
- <!-- algorithm -->
- <FormToggle v-model="form.algorithm" :choices="algorithms" fieldName="algorithm" :fieldError="form.errors.get('algorithm')" label="twofaccounts.forms.algorithm.label" help="twofaccounts.forms.algorithm.help" />
- <!-- TOTP period -->
- <FormField v-if="form.otp_type === 'totp'" pattern="[0-9]{1,4}" :class="'is-third-width-field'" v-model="form.period" fieldName="period" :fieldError="form.errors.get('period')" label="twofaccounts.forms.period.label" help="twofaccounts.forms.period.help" :placeholder="$t('twofaccounts.forms.period.placeholder')" />
- <!-- HOTP counter -->
- <FormLockField v-if="form.otp_type === 'hotp'" pattern="[0-9]{1,4}" :isEditMode="isEditMode" :isExpanded="false" v-model="form.counter" fieldName="counter" :fieldError="form.errors.get('counter')" label="twofaccounts.forms.counter.label" :placeholder="$t('twofaccounts.forms.counter.placeholder')" :help="isEditMode ? 'twofaccounts.forms.counter.help_lock' : 'twofaccounts.forms.counter.help'" />
- </div>
- </div>
- <VueFooter :showButtons="true">
- <p class="control">
- <VueButton :id="isEditMode ? 'btnUpdate' : 'btnCreate'" :isLoading="form.isBusy" class="is-rounded" >{{ isEditMode ? $t('commons.save') : $t('commons.create') }}</VueButton>
- </p>
- <p class="control" v-if="form.otp_type && form.secret">
- <button id="btnPreview" type="button" class="button is-success is-rounded" @click="previewOTP">{{ $t('twofaccounts.forms.test') }}</button>
- </p>
- <ButtonBackCloseCancel action="cancel" :useLinkTag="false" @canceled="cancelCreation" />
- </VueFooter>
- </form>
- <!-- modal -->
- <modal v-model="ShowTwofaccountInModal">
- <OtpDisplay
- ref="OtpDisplayForAdvancedForm"
- v-bind="form.data()"
- @increment-hotp="incrementHotp"
- @validation-error="mapDisplayerErrors"
- @please-close-me="ShowTwofaccountInModal = false">
- </OtpDisplay>
- </modal>
- </FormWrapper>
- <!-- alternatives -->
- <modal v-model="showAlternatives">
- <QrContentDisplay :qrContent="uri" />
- </modal>
- </div>
- </template>
|