mirror of
https://github.com/Bubka/2FAuth.git
synced 2024-12-02 13:14:23 +01:00
562 lines
24 KiB
Vue
562 lines
24 KiB
Vue
<script setup>
|
|
import Form from '@/components/formElements/Form'
|
|
import OtpDisplay from '@/components/OtpDisplay.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 { copy } = useClipboard({ legacy: true })
|
|
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 || 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) {
|
|
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, "")
|
|
}
|
|
|
|
/**
|
|
* Checks if a string is an url
|
|
*
|
|
* @param {string} str
|
|
*/
|
|
function isUrl(str) {
|
|
var strRegex = /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/
|
|
var re = new RegExp(strRegex)
|
|
|
|
return re.test(str)
|
|
}
|
|
|
|
/**
|
|
* Opens an uri in a new browser window
|
|
*
|
|
* @param {string} uri
|
|
*/
|
|
function openInBrowser(uri) {
|
|
const a = document.createElement('a')
|
|
a.setAttribute('href', uri)
|
|
a.dispatchEvent(new MouseEvent("click", { 'view': window, 'bubbles': true, 'cancelable': true }))
|
|
}
|
|
|
|
/**
|
|
* Copies to clipboard and notify
|
|
*
|
|
* @param {*} data
|
|
*/
|
|
function copyToClipboard(data) {
|
|
copy(data)
|
|
notify.success({ text: trans('commons.copied_to_clipboard') })
|
|
}
|
|
|
|
</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.length < 1">
|
|
<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="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">
|
|
<div class="too-bad"></div>
|
|
<div class="block">
|
|
{{ $t('errors.data_of_qrcode_is_not_valid_URI') }}
|
|
</div>
|
|
<UseColorMode v-slot="{ mode }">
|
|
<div class="block mb-6" :class="mode == 'dark' ? 'has-text-light':'has-text-grey-dark'">{{ uri }}</div>
|
|
</UseColorMode>
|
|
<!-- Copy to clipboard -->
|
|
<div class="block has-text-link">
|
|
<button class="button is-link is-outlined is-rounded" @click.stop="copyToClipboard(uri)">
|
|
{{ $t('commons.copy_to_clipboard') }}
|
|
</button>
|
|
</div>
|
|
<!-- Open in browser -->
|
|
<div class="block has-text-link" v-if="isUrl(uri)" @click="openInBrowser(uri)">
|
|
<button class="button is-link is-outlined is-rounded">
|
|
<span>{{ $t('commons.open_in_browser') }}</span>
|
|
<span class="icon is-small">
|
|
<FontAwesomeIcon :icon="['fas', 'external-link-alt']" />
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</modal>
|
|
</div>
|
|
</template>
|