mirror of
https://github.com/Bubka/2FAuth.git
synced 2024-11-22 08:13:11 +01:00
Enable the Vue 3 front-end
This commit is contained in:
parent
ffde1723d4
commit
9efb54adf4
58
resources/js/api.js
vendored
58
resources/js/api.js
vendored
@ -1,58 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import axios from 'axios'
|
||||
import VueAxios from 'vue-axios'
|
||||
import router from './routes.js'
|
||||
|
||||
Vue.use(VueAxios, axios)
|
||||
|
||||
Vue.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
Vue.axios.defaults.headers.common['Content-Type'] = 'application/json'
|
||||
|
||||
if (window.appConfig.subdirectory) {
|
||||
Vue.axios.defaults.baseURL = window.appConfig.subdirectory;
|
||||
}
|
||||
|
||||
Vue.axios.interceptors.response.use(response => response, error => {
|
||||
|
||||
// Whether or not the promise must be returned, if unauthenticated is received
|
||||
// we update the auth state of the front-end
|
||||
if ( error.response.status === 401 ) {
|
||||
Vue.$storage.remove('authenticated')
|
||||
}
|
||||
|
||||
// Return the error when we need to handle it at component level
|
||||
if( error.config.hasOwnProperty('returnError') && error.config.returnError === true ) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Return the error for form validation at component level
|
||||
if( error.response.status === 422 ) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Push to the login view and force the page to refresh to get a fresh CSRF token
|
||||
if ( error.response.status === 401 ) {
|
||||
router.push({ name: 'login', params: { forceRefresh: true } })
|
||||
return new Promise(() => {})
|
||||
}
|
||||
|
||||
if ( error.response.status === 407 ) {
|
||||
router.push({ name: 'genericError', params: { err: error.response, closable: false } })
|
||||
return new Promise(() => {})
|
||||
}
|
||||
|
||||
// we push to a specific or generic error view
|
||||
let routeName = 'genericError'
|
||||
|
||||
// api calls are stateless so when user inactivity is detected
|
||||
// by the backend middleware it cannot logout the user directly
|
||||
// so it returns a 418 response.
|
||||
// We catch the 418 response and push the user to the autolock view
|
||||
if ( error.response.status === 418 ) routeName = 'autolock'
|
||||
|
||||
if ( error.response.status === 404 ) routeName = '404'
|
||||
|
||||
router.push({ name: routeName, params: { err: error.response } })
|
||||
return new Promise(() => {})
|
||||
|
||||
})
|
150
resources/js/app.js
vendored
150
resources/js/app.js
vendored
@ -1,65 +1,95 @@
|
||||
import Vue from 'vue'
|
||||
import mixins from './mixins'
|
||||
import VueStorage from './packages/vue-storage'
|
||||
import i18n from './langs/i18n'
|
||||
import router from './routes'
|
||||
import api from './api'
|
||||
import FontAwesome from './packages/fontawesome'
|
||||
import Clipboard from './packages/clipboard'
|
||||
import Notifications from 'vue-notification'
|
||||
import '/resources/js/assets/app.scss';
|
||||
|
||||
import './components'
|
||||
import Notifications from '@kyvg/vue3-notification'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import FontAwesomeIcon from './icons'
|
||||
// import helpers from './helpers'
|
||||
|
||||
Vue.use(Notifications)
|
||||
const app = createApp(App)
|
||||
|
||||
const app = new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
appSettings: window.appSettings,
|
||||
appConfig: window.appConfig,
|
||||
userPreferences: window.userPreferences,
|
||||
isDemoApp: window.isDemoApp,
|
||||
isTestingApp: window.isTestingApp,
|
||||
prefersDarkScheme: window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
spinner: {
|
||||
active: false,
|
||||
message: 'loading'
|
||||
},
|
||||
},
|
||||
// Immutable app properties provided by the laravel blade view
|
||||
const $2fauth = {
|
||||
prefix: '2fauth_',
|
||||
config: window.appConfig,
|
||||
version: window.appVersion,
|
||||
isDemoApp: window.isDemoApp,
|
||||
isTestingApp: window.isTestingApp,
|
||||
langs: window.appLocales,
|
||||
}
|
||||
app.provide('2fauth', readonly($2fauth))
|
||||
|
||||
computed: {
|
||||
showDarkMode: function() {
|
||||
return this.userPreferences.theme == 'dark' ||
|
||||
(this.userPreferences.theme == 'system' && this.prefersDarkScheme)
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
this.$nextTick(() => {
|
||||
this.mediaQueryList.addEventListener('change', this.setDarkScheme)
|
||||
})
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
this.mediaQueryList.removeEventListener('change', this.setDarkScheme)
|
||||
},
|
||||
|
||||
methods: {
|
||||
setDarkScheme ({ matches }) {
|
||||
this.prefersDarkScheme = matches
|
||||
},
|
||||
|
||||
showSpinner(message) {
|
||||
this.spinner.message = message;
|
||||
this.spinner.active = true;
|
||||
},
|
||||
|
||||
hideSpinner() {
|
||||
this.spinner.active = false;
|
||||
this.spinner.message = 'loading';
|
||||
}
|
||||
},
|
||||
i18n,
|
||||
router,
|
||||
// Stores
|
||||
const pinia = createPinia()
|
||||
pinia.use(({ store }) => {
|
||||
store.$2fauth = $2fauth;
|
||||
});
|
||||
app.use(pinia)
|
||||
|
||||
// Router
|
||||
app.use(router)
|
||||
|
||||
// Localization
|
||||
app.use(i18nVue, {
|
||||
lang: document.documentElement.lang.substring(0, 2),
|
||||
resolve: async lang => {
|
||||
const langs = import.meta.glob('../lang/*.json');
|
||||
if (lang.includes('php_')) {
|
||||
return await langs[`../lang/${lang}.json`]();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Notifications
|
||||
app.use(Notifications)
|
||||
|
||||
// Global components registration
|
||||
import ResponsiveWidthWrapper from '@/layouts/ResponsiveWidthWrapper.vue'
|
||||
import FormWrapper from '@/layouts/FormWrapper.vue'
|
||||
import Footer from '@/layouts/Footer.vue'
|
||||
import Modal from '@/layouts/Modal.vue'
|
||||
import VueButton from '@/components/formElements/Button.vue'
|
||||
import ButtonBackCloseCancel from '@/components/formElements/ButtonBackCloseCancel.vue'
|
||||
import FieldError from '@/components/formElements/FieldError.vue'
|
||||
import FormField from '@/components/formElements/FormField.vue'
|
||||
import FormPasswordField from '@/components/formElements/FormPasswordField.vue'
|
||||
import FormSelect from '@/components/formElements/FormSelect.vue'
|
||||
import FormToggle from '@/components/formElements/FormToggle.vue'
|
||||
import FormCheckbox from '@/components/formElements/FormCheckbox.vue'
|
||||
import FormButtons from '@/components/formElements/FormButtons.vue'
|
||||
import Kicker from '@/components/Kicker.vue'
|
||||
|
||||
app
|
||||
.component('FontAwesomeIcon', FontAwesomeIcon)
|
||||
.component('ResponsiveWidthWrapper', ResponsiveWidthWrapper)
|
||||
.component('FormWrapper', FormWrapper)
|
||||
.component('VueFooter', Footer)
|
||||
.component('Modal', Modal)
|
||||
.component('VueButton', VueButton)
|
||||
.component('ButtonBackCloseCancel', ButtonBackCloseCancel)
|
||||
.component('FieldError', FieldError)
|
||||
.component('FormField', FormField)
|
||||
.component('FormPasswordField', FormPasswordField)
|
||||
.component('FormSelect', FormSelect)
|
||||
.component('FormToggle', FormToggle)
|
||||
.component('FormCheckbox', FormCheckbox)
|
||||
.component('FormButtons', FormButtons)
|
||||
.component('Kicker', Kicker)
|
||||
|
||||
// Global error handling
|
||||
// import { useNotifyStore } from '@/stores/notify'
|
||||
// if (process.env.NODE_ENV != 'development') {
|
||||
// app.config.errorHandler = (err) => {
|
||||
// useNotifyStore().error(err)
|
||||
// }
|
||||
// }
|
||||
|
||||
// Helpers
|
||||
// app.config.globalProperties.$helpers = helpers
|
||||
|
||||
// App mounting
|
||||
app.mount('#app')
|
||||
|
||||
// Theme
|
||||
import { useUserStore } from '@/stores/user'
|
||||
useUserStore().applyUserPrefs()
|
||||
|
@ -1,41 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<kicker v-if="kickInactiveUser"></kicker>
|
||||
<div v-if="this.$root.isDemoApp" class="demo has-background-warning has-text-centered is-size-7-mobile">
|
||||
{{ $t('commons.demo_do_not_post_sensitive_data') }}
|
||||
</div>
|
||||
<div v-if="this.$root.isTestingApp" class="demo has-background-warning has-text-centered is-size-7-mobile">
|
||||
{{ $t('commons.testing_do_not_post_sensitive_data') }}
|
||||
</div>
|
||||
<!-- Loading spinner -->
|
||||
<spinner :active="$root.spinner.active" :message="$root.spinner.message"/>
|
||||
<notifications id="vueNotification" role="alert" width="100%" position="top" :duration="4000" :speed="0" :max="1" classes="notification is-radiusless" />
|
||||
<main class="main-section">
|
||||
<router-view></router-view>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Spinner from "./Spinner.vue";
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
Spinner
|
||||
},
|
||||
|
||||
data(){
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
kickInactiveUser: function () {
|
||||
return parseInt(this.$root.userPreferences.kickUserAfter) > 0 && this.$route.meta.requiresAuth
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
:type="nativeType"
|
||||
:disabled="isLoading || isDisabled"
|
||||
:class="{
|
||||
'button': true,
|
||||
[`${color}`]: true,
|
||||
'is-loading': isLoading,
|
||||
}"
|
||||
v-on:click="$emit('click')">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'VButton',
|
||||
|
||||
props: {
|
||||
color: {
|
||||
type: String,
|
||||
default: 'is-link'
|
||||
},
|
||||
|
||||
nativeType: {
|
||||
type: String,
|
||||
default: 'submit'
|
||||
},
|
||||
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
@ -1,44 +1,54 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
stepCount: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
initialIndex: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
period: { // Used only to identify the dots component in Accounts.vue
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
})
|
||||
|
||||
const activeDot = ref(0)
|
||||
const isOff = computed(() => {
|
||||
return activeDot.value == -1
|
||||
})
|
||||
|
||||
/**
|
||||
* Turns On dots
|
||||
*/
|
||||
function turnOn(index) {
|
||||
activeDot.value = index < props.stepCount ? index + 1 : 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns Off all dots
|
||||
*/
|
||||
function turnOff() {
|
||||
activeDot.value = -1
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (! isNaN(props.initialIndex)) {
|
||||
turnOn(props.initialIndex)
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
turnOn,
|
||||
turnOff,
|
||||
props
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="dots">
|
||||
<ul class="dots" :class="{'off' : isOff}">
|
||||
<li v-for="n in stepCount" :key="n" :data-is-active="n == activeDot ? true : null"></li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Dots',
|
||||
|
||||
data() {
|
||||
return {
|
||||
activeDot: 0
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.initialIndex != null) {
|
||||
this.turnOn(this.initialIndex)
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
stepCount: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
initialIndex: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
turnOn: function(index) {
|
||||
this.activeDot = index < 10 ? index + 1 : 1
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
</template>
|
@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<div role="alert">
|
||||
<p :id="'valError' + field[0].toUpperCase() + field.toLowerCase().slice(1)" class="help is-danger" v-if="form.errors.has(field)" v-html="form.errors.get(field)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'field-error',
|
||||
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
field: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<footer>
|
||||
<div class="columns is-gapless" v-if="showButtons">
|
||||
<div class="column has-text-centered">
|
||||
<div class="field is-grouped">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editMode" class="content has-text-centered">
|
||||
<button id="lnkExitEdit" class="button is-ghost is-like-text" @click="exitEdit">{{ $t('commons.done') }}</button>
|
||||
</div>
|
||||
<div v-else class="content has-text-centered">
|
||||
<div v-if="$route.meta.showAbout === true" class="is-size-6">
|
||||
<router-link id="lnkAbout" :to="{ name: 'about' }" class="has-text-grey">
|
||||
2FAuth – <span class="has-text-weight-bold">v{{ appVersion }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else>
|
||||
<router-link id="lnkSettings" :to="{ name: 'settings.options' }" class="has-text-grey">
|
||||
{{ $t('settings.settings') }}<span v-if="$root.appSettings.latestRelease && $root.appSettings.checkForUpdate" class="release-flag"></span>
|
||||
</router-link>
|
||||
<span v-if="!this.$root.appConfig.proxyAuth || (this.$root.appConfig.proxyAuth && this.$root.appConfig.proxyLogoutUrl)">
|
||||
- <button id="lnkSignOut" class="button is-text is-like-text has-text-grey" @click="logout">{{ $t('auth.sign_out') }}</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'VueFooter',
|
||||
|
||||
data(){
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
showButtons: true,
|
||||
editMode: false,
|
||||
},
|
||||
|
||||
methods: {
|
||||
logout() {
|
||||
if(confirm(this.$t('auth.confirm.logout'))) {
|
||||
|
||||
this.appLogout()
|
||||
}
|
||||
},
|
||||
|
||||
exitEdit() {
|
||||
this.$emit('exit-edit')
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
317
resources/js/components/Form.js
vendored
317
resources/js/components/Form.js
vendored
@ -1,317 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import Errors from './FormErrors'
|
||||
|
||||
class Form {
|
||||
/**
|
||||
* Create a new form instance.
|
||||
*
|
||||
* @param {Object} data
|
||||
*/
|
||||
constructor (data = {}) {
|
||||
this.isBusy = false
|
||||
this.isDisabled = false
|
||||
// this.successful = false
|
||||
this.errors = new Errors()
|
||||
this.originalData = this.deepCopy(data)
|
||||
|
||||
Object.assign(this, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill form data.
|
||||
*
|
||||
* @param {Object} data
|
||||
*/
|
||||
fill (data) {
|
||||
this.keys().forEach(key => {
|
||||
this[key] = data[key]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update original form data.
|
||||
*/
|
||||
setOriginal () {
|
||||
Object.keys(this)
|
||||
.filter(key => !Form.ignore.includes(key))
|
||||
.forEach(key => {
|
||||
this.originalData[key] = this.deepCopy(this[key])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill form data.
|
||||
*
|
||||
* @param {Object} data
|
||||
*/
|
||||
fillWithKeyValueObject (data) {
|
||||
this.keys().forEach(key => {
|
||||
const keyValueObject = data.find(s => s.key === key.toString())
|
||||
if(keyValueObject != undefined) {
|
||||
this[key] = keyValueObject.value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the form data.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
data () {
|
||||
return this.keys().reduce((data, key) => (
|
||||
{ ...data, [key]: this[key] }
|
||||
), {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the form data keys.
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
keys () {
|
||||
return Object.keys(this)
|
||||
.filter(key => !Form.ignore.includes(key))
|
||||
}
|
||||
|
||||
/**
|
||||
* Start processing the form.
|
||||
*/
|
||||
startProcessing () {
|
||||
this.errors.clear()
|
||||
this.isBusy = true
|
||||
// this.successful = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish processing the form.
|
||||
*/
|
||||
finishProcessing () {
|
||||
this.isBusy = false
|
||||
// this.successful = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the form errors.
|
||||
*/
|
||||
clear () {
|
||||
this.errors.clear()
|
||||
// this.successful = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the form fields.
|
||||
*/
|
||||
reset () {
|
||||
Object.keys(this)
|
||||
.filter(key => !Form.ignore.includes(key))
|
||||
.forEach(key => {
|
||||
this[key] = this.deepCopy(this.originalData[key])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the form via a GET request.
|
||||
*
|
||||
* @param {String} url
|
||||
* @param {Object} config (axios config)
|
||||
* @return {Promise}
|
||||
*/
|
||||
get (url, config = {}) {
|
||||
return this.submit('get', url, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the form via a POST request.
|
||||
*
|
||||
* @param {String} url
|
||||
* @param {Object} config (axios config)
|
||||
* @return {Promise}
|
||||
*/
|
||||
post (url, config = {}) {
|
||||
return this.submit('post', url, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the form via a PATCH request.
|
||||
*
|
||||
* @param {String} url
|
||||
* @param {Object} config (axios config)
|
||||
* @return {Promise}
|
||||
*/
|
||||
patch (url, config = {}) {
|
||||
return this.submit('patch', url, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the form via a PUT request.
|
||||
*
|
||||
* @param {String} url
|
||||
* @param {Object} config (axios config)
|
||||
* @return {Promise}
|
||||
*/
|
||||
put (url, config = {}) {
|
||||
return this.submit('put', url, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the form via a DELETE request.
|
||||
*
|
||||
* @param {String} url
|
||||
* @param {Object} config (axios config)
|
||||
* @return {Promise}
|
||||
*/
|
||||
delete (url, config = {}) {
|
||||
return this.submit('delete', url, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the form data via an HTTP request.
|
||||
*
|
||||
* @param {String} method (get, post, patch, put)
|
||||
* @param {String} url
|
||||
* @param {Object} config (axios config)
|
||||
* @return {Promise}
|
||||
*/
|
||||
submit (method, url, config = {}) {
|
||||
this.startProcessing()
|
||||
|
||||
const data = method === 'get'
|
||||
? { params: this.data() }
|
||||
: this.data()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// (Form.axios || axios).request({ url: this.route(url), method, data, ...config })
|
||||
Vue.axios.request({ url: this.route(url), method, data, ...config })
|
||||
.then(response => {
|
||||
this.finishProcessing()
|
||||
|
||||
resolve(response)
|
||||
})
|
||||
.catch(error => {
|
||||
this.isBusy = false
|
||||
|
||||
if (error.response) {
|
||||
this.errors.set(this.extractErrors(error.response))
|
||||
}
|
||||
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the form data via an HTTP request.
|
||||
*
|
||||
* @param {String} method (get, post, patch, put)
|
||||
* @param {String} url
|
||||
* @param {Object} config (axios config)
|
||||
* @return {Promise}
|
||||
*/
|
||||
upload (url, formData, config = {}) {
|
||||
this.startProcessing()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// (Form.axios || axios).request({ url: this.route(url), method, data, ...config })
|
||||
Vue.axios.request({ url: this.route(url), method: 'post', data: formData, header: {'Content-Type' : 'multipart/form-data'}, ...config })
|
||||
.then(response => {
|
||||
this.finishProcessing()
|
||||
|
||||
resolve(response)
|
||||
})
|
||||
.catch(error => {
|
||||
this.isBusy = false
|
||||
|
||||
if (error.response) {
|
||||
this.errors.set(this.extractErrors(error.response))
|
||||
}
|
||||
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the errors from the response object.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @return {Object}
|
||||
*/
|
||||
extractErrors (response) {
|
||||
if (!response.data || typeof response.data !== 'object') {
|
||||
return { error: Form.errorMessage }
|
||||
}
|
||||
|
||||
if (response.data.errors) {
|
||||
return { ...response.data.errors }
|
||||
}
|
||||
|
||||
if (response.data.message) {
|
||||
return { error: response.data.message }
|
||||
}
|
||||
|
||||
return { ...response.data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a named route.
|
||||
*
|
||||
* @param {String} name
|
||||
* @return {Object} parameters
|
||||
* @return {String}
|
||||
*/
|
||||
route (name, parameters = {}) {
|
||||
let url = name
|
||||
|
||||
if (Form.routes.hasOwnProperty(name)) {
|
||||
url = decodeURI(Form.routes[name])
|
||||
}
|
||||
|
||||
if (typeof parameters !== 'object') {
|
||||
parameters = { id: parameters }
|
||||
}
|
||||
|
||||
Object.keys(parameters).forEach(key => {
|
||||
url = url.replace(`{${key}}`, parameters[key])
|
||||
})
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear errors on keydown.
|
||||
*
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
onKeydown (event) {
|
||||
if (event.target.name) {
|
||||
this.errors.clear(event.target.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep copy the given object.
|
||||
*
|
||||
* @param {Object} obj
|
||||
* @return {Object}
|
||||
*/
|
||||
deepCopy (obj) {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
const copy = Array.isArray(obj) ? [] : {}
|
||||
|
||||
Object.keys(obj).forEach(key => {
|
||||
copy[key] = this.deepCopy(obj[key])
|
||||
})
|
||||
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
Form.routes = {}
|
||||
Form.errorMessage = 'Something went wrong. Please try again.'
|
||||
Form.ignore = ['isBusy', 'isDisabled', 'errors', 'originalData']
|
||||
|
||||
export default Form
|
@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<v-button :id="submitId" :color="color" :isLoading="isBusy" :disabled="isDisabled" >{{ caption }}</v-button>
|
||||
</div>
|
||||
<div class="control" v-if="showCancelButton">
|
||||
<router-link :id="cancelId" :to="{ name: cancelLandingView }" class="button is-text">{{ $t('commons.cancel') }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormButtons',
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
showCancelButton: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
isBusy: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
caption: {
|
||||
type: String,
|
||||
default: 'Submit'
|
||||
},
|
||||
|
||||
cancelLandingView: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
color: {
|
||||
type: String,
|
||||
default: 'is-link'
|
||||
},
|
||||
|
||||
submitId: {
|
||||
type: String,
|
||||
default: 'btnSubmit'
|
||||
},
|
||||
|
||||
cancelId: {
|
||||
type: String,
|
||||
default: 'btnCancel'
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,57 +0,0 @@
|
||||
<template>
|
||||
<div class="field">
|
||||
<input :id="fieldName" type="checkbox" :name="fieldName" class="is-checkradio is-info" v-model="form[fieldName]" v-on:change="$emit(fieldName, form[fieldName])" v-bind="$attrs">
|
||||
<label tabindex="0" :for="fieldName" class="label" :class="labelClass" v-html="label" v-on:keypress.space.prevent="setCheckbox"></label>
|
||||
<p class="help" v-html="help" v-if="help"></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormCheckbox',
|
||||
inheritAttrs: false,
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
labelClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
fieldName: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true
|
||||
},
|
||||
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
help: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
setCheckbox(event) {
|
||||
if (this.$attrs['disabled'] == undefined) {
|
||||
this.form[this.fieldName] = !this.form[this.fieldName]
|
||||
this.$emit(this.fieldName, this.form[this.fieldName])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<div class="field" :class="{ 'pt-3' : hasOffset }">
|
||||
<label :for="this.inputId(inputType,fieldName)" class="label" v-html="label"></label>
|
||||
<div class="control">
|
||||
<input
|
||||
:disabled="isDisabled"
|
||||
:id="this.inputId(inputType,fieldName)"
|
||||
:type="inputType"
|
||||
class="input"
|
||||
v-model="form[fieldName]"
|
||||
:placeholder="placeholder"
|
||||
v-bind="$attrs"
|
||||
v-on:change="$emit('field-changed', form[fieldName])"
|
||||
:maxlength="this.maxLength"
|
||||
/>
|
||||
</div>
|
||||
<field-error :form="form" :field="fieldName" />
|
||||
<p class="help" v-html="help" v-if="help"></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormField',
|
||||
inheritAttrs: false,
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
fieldName: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true
|
||||
},
|
||||
|
||||
inputType: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
help: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
hasOffset: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
maxLength: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,132 +0,0 @@
|
||||
<template>
|
||||
<div class="field" :class="{ 'pt-3' : hasOffset }">
|
||||
<label :for="this.inputId('password',fieldName)" class="label" v-html="label"></label>
|
||||
<div class="control has-icons-right">
|
||||
<input
|
||||
:disabled="isDisabled"
|
||||
:id="this.inputId('password',fieldName)"
|
||||
:type="currentType"
|
||||
class="input"
|
||||
v-model="form[fieldName]"
|
||||
:placeholder="placeholder"
|
||||
v-bind="$attrs"
|
||||
v-on:change="$emit('field-changed', form[fieldName])"
|
||||
v-on:keyup="checkCapsLock"
|
||||
/>
|
||||
<span v-if="currentType == 'password'" role="button" id="btnTogglePassword" tabindex="0" class="icon is-small is-right is-clickable" @keyup.enter="setFieldType('text')" @click="setFieldType('text')" :title="$t('auth.forms.reveal_password')">
|
||||
<font-awesome-icon :icon="['fas', 'eye-slash']" />
|
||||
</span>
|
||||
<span v-else role="button" id="btnTogglePassword" tabindex="0" class="icon is-small is-right is-clickable" @keyup.enter="setFieldType('password')" @click="setFieldType('password')" :title="$t('auth.forms.hide_password')">
|
||||
<font-awesome-icon :icon="['fas', 'eye']" />
|
||||
</span>
|
||||
</div>
|
||||
<p class="help is-warning" v-if="hasCapsLockOn" v-html="$t('auth.forms.caps_lock_is_on')" />
|
||||
<field-error :form="form" :field="fieldName" />
|
||||
<p class="help" v-html="help" v-if="help"></p>
|
||||
<div v-if="showRules" class="columns is-mobile is-size-7 mt-0">
|
||||
<div class="column is-one-third">
|
||||
<span class="has-text-weight-semibold">{{ $t("auth.forms.mandatory_rules") }}</span><br />
|
||||
<span class="is-underscored" id="valPwdIsLongEnough" :class="{'is-dot' : IsLongEnough}"></span>{{ $t('auth.forms.is_long_enough') }}<br/>
|
||||
</div>
|
||||
<div class="column">
|
||||
<span class="has-text-weight-semibold">{{ $t("auth.forms.optional_rules_you_should_follow") }}</span><br />
|
||||
<span class="is-underscored" id="valPwdHasLowerCase" :class="{'is-dot' : hasLowerCase}"></span>{{ $t('auth.forms.has_lower_case') }}<br/>
|
||||
<span class="is-underscored" id="valPwdHasUpperCase" :class="{'is-dot' : hasUpperCase}"></span>{{ $t('auth.forms.has_upper_case') }}<br/>
|
||||
<span class="is-underscored" id="valPwdHasSpecialChar" :class="{'is-dot' : hasSpecialChar}"></span>{{ $t('auth.forms.has_special_char') }}<br/>
|
||||
<span class="is-underscored" id="valPwdHasNumber" :class="{'is-dot' : hasNumber}"></span>{{ $t('auth.forms.has_number') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormPasswordField',
|
||||
inheritAttrs: false,
|
||||
|
||||
data() {
|
||||
return {
|
||||
currentType: this.inputType,
|
||||
hasCapsLockOn: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasLowerCase() {
|
||||
return /[a-z]/.test(this.form[this.fieldName])
|
||||
},
|
||||
hasUpperCase() {
|
||||
return /[A-Z]/.test(this.form[this.fieldName])
|
||||
},
|
||||
hasNumber() {
|
||||
return /[0-9]/.test(this.form[this.fieldName])
|
||||
},
|
||||
hasSpecialChar() {
|
||||
return /[^A-Za-z0-9]/.test(this.form[this.fieldName])
|
||||
},
|
||||
IsLongEnough() {
|
||||
return this.form[this.fieldName].length >= 8
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
fieldName: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true
|
||||
},
|
||||
|
||||
inputType: {
|
||||
type: String,
|
||||
default: 'password'
|
||||
},
|
||||
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
help: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
hasOffset: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
showRules: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
checkCapsLock(event) {
|
||||
this.hasCapsLockOn = event.getModifierState('CapsLock') ? true : false
|
||||
},
|
||||
|
||||
setFieldType(event) {
|
||||
if (this.currentType != event) {
|
||||
this.currentType = event
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
@ -1,54 +0,0 @@
|
||||
<template>
|
||||
<div class="field">
|
||||
<label class="label" v-html="label"></label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select v-model="form[fieldName]" v-on:change="$emit(fieldName, form[fieldName])">
|
||||
<option v-for="option in options" :value="option.value">{{ option.text }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<field-error :form="form" :field="fieldName" />
|
||||
<p class="help" v-html="help" v-if="help"></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormSelect',
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
fieldName: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true
|
||||
},
|
||||
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
help: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<div class="field">
|
||||
<label :for="fieldName" class="label" v-html="label"></label>
|
||||
<input :id="fieldName" type="checkbox" :name="fieldName" class="switch is-thin is-info" v-model="form[fieldName]">
|
||||
<label :for="fieldName" class="label"></label>
|
||||
<p class="help" v-html="help" v-if="help"></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormSwitch',
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
fieldName: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true
|
||||
},
|
||||
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
help: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<div class="field" :class="{ 'pt-3' : hasOffset }" role="radiogroup" :aria-labelledby="inputId('label', fieldName)">
|
||||
<label v-if="label" :id="inputId('label', fieldName)" class="label" v-html="label"></label>
|
||||
<div class="is-toggle buttons">
|
||||
<button
|
||||
:id="inputId('button',fieldName + choice.value)"
|
||||
role="radio"
|
||||
type="button"
|
||||
class="button"
|
||||
:aria-checked="form[fieldName] === choice.value"
|
||||
:disabled="isDisabled"
|
||||
v-for="(choice, index) in choices"
|
||||
:key="index"
|
||||
:class="{
|
||||
'is-link' : form[fieldName] === choice.value,
|
||||
'is-dark' : $root.showDarkMode,
|
||||
'is-multiline' : choice.legend,
|
||||
}"
|
||||
v-on:click.stop="setRadio(choice.value)"
|
||||
:title="choice.title ? choice.title : ''"
|
||||
>
|
||||
<input
|
||||
:id="inputId(inputType, choice.value)"
|
||||
:type="inputType"
|
||||
class="is-hidden"
|
||||
:checked="form[fieldName] === choice.value"
|
||||
:value="choice.value"
|
||||
v-model="form[fieldName]"
|
||||
: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-2" /> {{ choice.text }}
|
||||
</button>
|
||||
</div>
|
||||
<field-error :form="form" :field="fieldName" />
|
||||
<p class="help" v-html="help" v-if="help"></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormToggle',
|
||||
|
||||
data() {
|
||||
return {
|
||||
inputType: 'radio'
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
fieldName: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true
|
||||
},
|
||||
|
||||
choices: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
help: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
hasOffset: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
setRadio(event) {
|
||||
this.form[this.fieldName] = event
|
||||
this.$emit(this.fieldName, this.form[this.fieldName])
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,31 +0,0 @@
|
||||
<template>
|
||||
<responsive-width-wrapper>
|
||||
<h1 class="title has-text-grey-dark" v-html="title" v-if="title"></h1>
|
||||
<div id="punchline" v-if="punchline" class="block" v-html="punchline"></div>
|
||||
<slot />
|
||||
</responsive-width-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormWrapper',
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
punchline: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,58 +1,70 @@
|
||||
<template>
|
||||
<script setup>
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
</template>
|
||||
const user = useUserStore()
|
||||
const events = ref(['mousedown', 'scroll', 'keypress'])
|
||||
const logoutTimer = ref(null)
|
||||
// const elapsed = ref(0)
|
||||
// const counter = ref(null)
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'Kicker',
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
events: ['click', 'mousedown', 'scroll', 'keypress', 'load'],
|
||||
logoutTimer: null
|
||||
}
|
||||
const props = defineProps({
|
||||
kickAfter: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
})
|
||||
|
||||
mounted() {
|
||||
|
||||
this.events.forEach(function (event) {
|
||||
window.addEventListener(event, this.resetTimer)
|
||||
}, this);
|
||||
|
||||
this.setTimer()
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
|
||||
this.events.forEach(function (event) {
|
||||
window.removeEventListener(event, this.resetTimer)
|
||||
}, this);
|
||||
|
||||
clearTimeout(this.logoutTimer)
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
setTimer: function() {
|
||||
|
||||
this.logoutTimer = setTimeout(this.logoutUser, this.$root.userPreferences.kickUserAfter * 60 * 1000)
|
||||
},
|
||||
|
||||
logoutUser: function() {
|
||||
|
||||
clearTimeout(this.logoutTimer)
|
||||
|
||||
this.$router.push({ name: 'autolock' })
|
||||
},
|
||||
|
||||
resetTimer: function() {
|
||||
|
||||
clearTimeout(this.logoutTimer)
|
||||
|
||||
this.setTimer()
|
||||
}
|
||||
watch(
|
||||
() => props.kickAfter,
|
||||
() => {
|
||||
restartTimer()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
events.value.forEach(function (event) {
|
||||
window.addEventListener(event, restartTimer)
|
||||
}, this)
|
||||
|
||||
startTimer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
events.value.forEach(function (event) {
|
||||
window.removeEventListener(event, restartTimer)
|
||||
}, this)
|
||||
|
||||
stopTimer()
|
||||
})
|
||||
|
||||
function startTimer() {
|
||||
logoutTimer.value = setTimeout(logoutUser, props.kickAfter * 60 * 1000)
|
||||
// counter.value = setInterval(() => {
|
||||
// elapsed.value += 1
|
||||
// console.log(elapsed.value + '/' + props.kickAfter * 60)
|
||||
// }, 1000)
|
||||
}
|
||||
|
||||
</script>
|
||||
// Triggers the user logout
|
||||
function logoutUser() {
|
||||
clearTimeout(logoutTimer.value)
|
||||
user.logout({ kicked: true})
|
||||
}
|
||||
|
||||
// Restarts the timer
|
||||
function restartTimer() {
|
||||
stopTimer()
|
||||
startTimer()
|
||||
}
|
||||
|
||||
// Stops the timer
|
||||
function stopTimer() {
|
||||
clearTimeout(logoutTimer.value)
|
||||
// elapsed.value = 0
|
||||
// clearInterval(counter.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<div class="modal modal-otp" v-bind:class="{ 'is-active': isActive }">
|
||||
<div class="modal-background" @click.stop="closeModal"></div>
|
||||
<div class="modal-content">
|
||||
<section class="section">
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-three-quarters">
|
||||
<div class="modal-slot box has-text-centered is-shadowless">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="this.showcloseButton" class="fullscreen-footer">
|
||||
<!-- Close button -->
|
||||
<button id="btnClose" ref="closeModalButton" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" @click.stop="closeModal">
|
||||
{{ $t('commons.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Modal',
|
||||
|
||||
data(){
|
||||
return {
|
||||
showcloseButton: this.closable,
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
value: Boolean,
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
isActive: {
|
||||
get () {
|
||||
return this.value
|
||||
},
|
||||
set (value) {
|
||||
this.$emit('input', value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
closeModal: function(event) {
|
||||
if (event) {
|
||||
this.isActive = false
|
||||
this.$notify({ clean: true })
|
||||
this.$parent.$emit('modalClose')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,294 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<figure class="image is-64x64" :class="{ 'no-icon': !internal_icon }" style="display: inline-block">
|
||||
<img :src="$root.appConfig.subdirectory + '/storage/icons/' + internal_icon" v-if="internal_icon" :alt="$t('twofaccounts.icon_to_illustrate_the_account')">
|
||||
</figure>
|
||||
<p class="is-size-4 has-ellipsis" :class="$root.showDarkMode ? 'has-text-grey-light' : 'has-text-grey'">{{ internal_service }}</p>
|
||||
<p class="is-size-6 has-ellipsis" :class="$root.showDarkMode ? 'has-text-grey' : 'has-text-grey-light'">{{ internal_account }}</p>
|
||||
<p>
|
||||
<span id="otp" 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')">
|
||||
{{ displayPwd(this.internal_password) }}
|
||||
</span>
|
||||
</p>
|
||||
<dots v-show="isTimeBased(internal_otp_type)" ref="dots"></dots>
|
||||
<ul v-show="isHMacBased(internal_otp_type)">
|
||||
<li>counter: {{ internal_counter }}</li>
|
||||
</ul>
|
||||
<totp-looper
|
||||
v-if="this.hasTOTP"
|
||||
:period="internal_period"
|
||||
:generated_at="internal_generated_at"
|
||||
:autostart="false"
|
||||
v-on:loop-ended="getOtp()"
|
||||
v-on:loop-started="turnDotsOn($event)"
|
||||
v-on:stepped-up="turnDotsOn($event)"
|
||||
ref="looper"
|
||||
></totp-looper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TotpLooper from './TotpLooper'
|
||||
import Dots from './Dots'
|
||||
|
||||
export default {
|
||||
name: 'OtpDisplayer',
|
||||
|
||||
data() {
|
||||
return {
|
||||
internal_id: null,
|
||||
internal_otp_type: '',
|
||||
internal_account: '',
|
||||
internal_service: '',
|
||||
internal_icon: '',
|
||||
internal_secret: null,
|
||||
internal_digits: null,
|
||||
internal_algorithm: null,
|
||||
internal_period: null,
|
||||
internal_counter: null,
|
||||
internal_password: '',
|
||||
internal_uri: '',
|
||||
internal_generated_at: null,
|
||||
hasTOTP: false
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
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
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
},
|
||||
|
||||
components: {
|
||||
TotpLooper,
|
||||
Dots,
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
turnDotsOn(stepIndex) {
|
||||
this.$refs.dots.turnOn(stepIndex)
|
||||
},
|
||||
|
||||
copyOTP (otp, permit_closing) {
|
||||
// see https://web.dev/async-clipboard/ for future 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 rawOTP = otp.replace(/ /g, '')
|
||||
const success = this.$clipboard(rawOTP)
|
||||
|
||||
if (success == true) {
|
||||
if(this.$root.userPreferences.kickUserAfter == -1) {
|
||||
this.appLogout()
|
||||
}
|
||||
else if(this.$root.userPreferences.closeOtpOnCopy && (permit_closing || false) === true) {
|
||||
this.$parent.isActive = false
|
||||
this.clearOTP()
|
||||
}
|
||||
|
||||
this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
|
||||
}
|
||||
},
|
||||
|
||||
isTimeBased: function(otp_type) {
|
||||
return (otp_type === 'totp' || otp_type === 'steamtotp')
|
||||
},
|
||||
|
||||
isHMacBased: function(otp_type) {
|
||||
return otp_type === 'hotp'
|
||||
},
|
||||
|
||||
async show(id) {
|
||||
|
||||
// 3 possible cases :
|
||||
// - Trigger when user ask 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.
|
||||
// - Trigger when user use the Quick Uploader and preview the account: No ID but we have an URI.
|
||||
// - Trigger when user use 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
|
||||
|
||||
this.internal_otp_type = this.otp_type
|
||||
this.internal_account = this.account
|
||||
this.internal_service = this.service
|
||||
this.internal_icon = this.icon
|
||||
this.internal_secret = this.secret
|
||||
this.internal_digits = this.digits
|
||||
this.internal_algorithm = this.algorithm
|
||||
this.internal_period = this.period
|
||||
this.internal_counter = this.counter
|
||||
|
||||
if( id ) {
|
||||
|
||||
this.internal_id = id
|
||||
const { data } = await this.axios.get('api/v1/twofaccounts/' + this.internal_id)
|
||||
|
||||
this.internal_service = data.service
|
||||
this.internal_account = data.account
|
||||
this.internal_icon = data.icon
|
||||
this.internal_otp_type = data.otp_type
|
||||
|
||||
if( this.isHMacBased(data.otp_type) && data.counter ) {
|
||||
this.internal_counter = data.counter
|
||||
}
|
||||
}
|
||||
|
||||
// We force the otp_type to be based on the uri
|
||||
if( this.uri ) {
|
||||
this.internal_uri = this.uri
|
||||
this.internal_otp_type = this.uri.slice(0, 15 ).toLowerCase() === "otpauth://totp/" ? 'totp' : 'hotp';
|
||||
}
|
||||
|
||||
if( this.internal_id || this.uri || this.secret ) { // minimun required vars to get an otp from the backend
|
||||
try {
|
||||
if( this.isTimeBased(this.internal_otp_type) || this.isHMacBased(this.internal_otp_type)) {
|
||||
await this.getOtp()
|
||||
}
|
||||
else this.$router.push({ name: 'genericError', params: { err: this.$t('errors.not_a_supported_otp_type') } });
|
||||
|
||||
this.$parent.isActive = true
|
||||
this.focusOnOTP()
|
||||
}
|
||||
catch(error) {
|
||||
this.clearOTP()
|
||||
}
|
||||
finally {
|
||||
this.$root.hideSpinner();
|
||||
}
|
||||
} else {
|
||||
this.$root.hideSpinner();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
getOtp: async function() {
|
||||
|
||||
await this.axios(this.getOtpRequest()).then(response => {
|
||||
|
||||
let otp = response.data
|
||||
|
||||
this.internal_password = otp.password
|
||||
|
||||
if(this.$root.userPreferences.copyOtpOnDisplay) {
|
||||
this.copyOTP(otp.password)
|
||||
}
|
||||
|
||||
if (this.isTimeBased(otp.otp_type)) {
|
||||
this.internal_generated_at = otp.generated_at
|
||||
this.internal_period = otp.period
|
||||
this.hasTOTP = true
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.looper.startLoop()
|
||||
})
|
||||
}
|
||||
else if (this.isHMacBased(otp.otp_type)) {
|
||||
|
||||
this.internal_counter = otp.counter
|
||||
|
||||
// returned counter & uri are incremented
|
||||
this.$emit('increment-hotp', { nextHotpCounter: otp.counter, nextUri: otp.uri })
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.response.status === 422) {
|
||||
this.$emit('validation-error', error.response)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
getOtpRequest() {
|
||||
|
||||
if(this.internal_id) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/v1/twofaccounts/' + this.internal_id + '/otp'
|
||||
}
|
||||
}
|
||||
else if(this.internal_uri) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/v1/twofaccounts/otp',
|
||||
data: {
|
||||
uri: this.internal_uri
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/v1/twofaccounts/otp',
|
||||
data: {
|
||||
service : this.internal_service,
|
||||
account : this.internal_account,
|
||||
icon : this.internal_icon,
|
||||
otp_type : this.internal_otp_type,
|
||||
secret : this.internal_secret,
|
||||
digits : this.internal_digits,
|
||||
algorithm : this.internal_algorithm,
|
||||
period : this.internal_period,
|
||||
counter : this.internal_counter,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
clearOTP: function() {
|
||||
|
||||
this.internal_id = this.internal_counter = this.internal_generated_at = null
|
||||
this.internal_service = this.internal_account = this.internal_icon = this.internal_otp_type = this.internal_secret = ''
|
||||
this.internal_password = '... ...'
|
||||
this.hasTOTP = false
|
||||
|
||||
this.$refs.looper?.clearLooper();
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
focusOnOTP() {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.otp.focus()
|
||||
})
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<div class="columns is-centered">
|
||||
<div class="form-column column is-two-thirds-tablet is-half-desktop is-half-widescreen is-one-third-fullhd">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ResponsiveWidthWrapper',
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div class="options-header">
|
||||
<responsive-width-wrapper>
|
||||
<div class="tabs is-centered is-fullwidth">
|
||||
<ul>
|
||||
<li v-for="tab in tabs" :key="tab.view" :class="{ 'is-active': tab.view === activeTab }">
|
||||
<router-link :id="tab.id" :to="{ name: tab.view, params: {returnTo: $route.params.returnTo} }">{{ tab.name }}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</responsive-width-wrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'SettingTabs',
|
||||
|
||||
data(){
|
||||
return {
|
||||
tabs: [
|
||||
{
|
||||
'name' : this.$t('settings.options'),
|
||||
'view' : 'settings.options',
|
||||
'id' : 'lnkTabOptions'
|
||||
},
|
||||
{
|
||||
'name' : this.$t('settings.account'),
|
||||
'view' : 'settings.account',
|
||||
'id' : 'lnkTabAccount'
|
||||
},
|
||||
{
|
||||
'name' : this.$t('settings.oauth'),
|
||||
'view' : 'settings.oauth.tokens',
|
||||
'id' : 'lnkTabOAuth'
|
||||
},
|
||||
{
|
||||
'name' : this.$t('settings.webauthn'),
|
||||
'view' : 'settings.webauthn.devices',
|
||||
'id' : 'lnkTabWebauthn'
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
activeTab: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
@ -1,31 +1,49 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
isVisible: Boolean,
|
||||
type: {
|
||||
type: String,
|
||||
default: 'inline'
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: 'commons.generating_otp'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="active" class="spinner-container">
|
||||
<div class="spinner-wrapper">
|
||||
<span class="is-size-1 spinner">
|
||||
<font-awesome-icon :icon="['fas', 'spinner']" spin />
|
||||
<div v-if="isVisible">
|
||||
<div v-if="type == 'fullscreen'" class="spinner-container">
|
||||
<div class="spinner-wrapper">
|
||||
<span id="icnSpinnerFull" class="is-size-1 spinner">
|
||||
<FontAwesomeIcon :icon="['fas', 'spinner']" spin />
|
||||
</span>
|
||||
<span>{{ $t(message) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="type == 'fullscreen-overlay'" class="spinner-overlay-container">
|
||||
<div class="spinner-wrapper">
|
||||
<span id="icnSpinnerFull" class="is-size-1 spinner">
|
||||
<FontAwesomeIcon :icon="['fas', 'spinner']" spin />
|
||||
</span>
|
||||
<span>{{ $t(message) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<FontAwesomeIcon v-else-if="type == 'raw'" :icon="['fas', 'spinner']" spin />
|
||||
<div v-else class="has-text-centered mt-6">
|
||||
<span id="icnSpinner" class="is-size-4">
|
||||
<FontAwesomeIcon :icon="['fas', 'spinner']" spin />
|
||||
</span>
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Spinner',
|
||||
props: {
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
message: String,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spinner-container {
|
||||
.spinner-container,
|
||||
.spinner-overlay-container {
|
||||
text-align: center;
|
||||
z-index: 10000;
|
||||
z-index: 100000;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@ -35,6 +53,11 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.spinner-container,
|
||||
.spinner-overlay-container {
|
||||
top: 25%;
|
||||
height: 50%;
|
||||
}
|
||||
.spinner {
|
||||
display: block;
|
||||
}
|
||||
|
@ -1,126 +1,122 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
step_count: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
period : Number,
|
||||
generated_at: Number,
|
||||
autostart: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
})
|
||||
|
||||
const generatedAt = ref(null)
|
||||
const remainingTimeout = ref(null)
|
||||
const initialStepToNextStepTimeout = ref(null)
|
||||
const stepToStepInterval = ref(null)
|
||||
const stepIndex = ref(null)
|
||||
|
||||
// |<----period p----->|
|
||||
// | | |
|
||||
// |------- ··· ------------|--------|----------|---------->
|
||||
// | | | |
|
||||
// unix T0 Tp.start Tgen_at Tp.end
|
||||
// | | |
|
||||
// elapsedTimeInCurrentPeriod--|<------>| |
|
||||
// (in ms) | | |
|
||||
// ● ● ● ● ●|● ◌ ◌ ◌ ◌ |
|
||||
// | | || |
|
||||
// | | |<-------->|--remainingTimeBeforeEndOfPeriod (for remainingTimeout)
|
||||
// durationBetweenTwoSteps-->|-|< ||
|
||||
// (for stepToStepInterval) | | >||<---durationFromInitialToNextStep (for initialStepToNextStepTimeout)
|
||||
// |
|
||||
// |
|
||||
// stepIndex
|
||||
|
||||
const elapsedTimeInCurrentPeriod = computed(() => {
|
||||
return generatedAt.value % props.period
|
||||
})
|
||||
|
||||
const remainingTimeBeforeEndOfPeriod = computed(() => {
|
||||
return props.period - elapsedTimeInCurrentPeriod.value
|
||||
})
|
||||
|
||||
const durationBetweenTwoSteps = computed(() => {
|
||||
return props.period / props.step_count
|
||||
})
|
||||
|
||||
const initialStepIndex = computed(() => {
|
||||
let relativePosition = (elapsedTimeInCurrentPeriod.value * props.step_count) / props.period
|
||||
|
||||
return (Math.floor(relativePosition) + 0)
|
||||
})
|
||||
|
||||
const emit = defineEmits(['loop-started', 'loop-ended', 'stepped-up'])
|
||||
|
||||
/**
|
||||
* Starts looping
|
||||
*/
|
||||
const startLoop = (generated_at = null) => {
|
||||
clearLooper()
|
||||
generatedAt.value = generated_at != null ? generated_at : props.generated_at
|
||||
|
||||
emit('loop-started', initialStepIndex.value)
|
||||
|
||||
stepIndex.value = initialStepIndex.value
|
||||
|
||||
// Main timeout that runs until the end of the period
|
||||
remainingTimeout.value = setTimeout(function() {
|
||||
clearLooper()
|
||||
emit('loop-ended')
|
||||
}, remainingTimeBeforeEndOfPeriod.value * 1000);
|
||||
|
||||
// During the remainingTimeout countdown we emit an event every durationBetweenTwoSteps seconds,
|
||||
// except for the first next dot
|
||||
let durationFromInitialToNextStep = (Math.ceil(elapsedTimeInCurrentPeriod.value / durationBetweenTwoSteps.value) * durationBetweenTwoSteps.value) - elapsedTimeInCurrentPeriod.value
|
||||
|
||||
initialStepToNextStepTimeout.value = setTimeout(function() {
|
||||
if( durationFromInitialToNextStep > 0 ) {
|
||||
stepIndex.value += 1
|
||||
emit('stepped-up', stepIndex.value)
|
||||
}
|
||||
stepToStepInterval.value = setInterval(function() {
|
||||
stepIndex.value += 1
|
||||
emit('stepped-up', stepIndex.value)
|
||||
}, durationBetweenTwoSteps.value * 1000)
|
||||
}, durationFromInitialToNextStep * 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all timers and internal vars
|
||||
*/
|
||||
const clearLooper = () => {
|
||||
clearTimeout(remainingTimeout.value)
|
||||
clearTimeout(initialStepToNextStepTimeout.value)
|
||||
clearInterval(stepToStepInterval.value)
|
||||
stepIndex.value = generatedAt.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autostart == true) {
|
||||
startLoop()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearLooper()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
startLoop,
|
||||
clearLooper,
|
||||
props
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TotpLooper',
|
||||
|
||||
data() {
|
||||
return {
|
||||
generatedAt: null,
|
||||
remainingTimeout: null,
|
||||
initialStepToNextStepTimeout: null,
|
||||
stepToStepInterval: null,
|
||||
stepIndex: null,
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
step_count: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
period : Number,
|
||||
generated_at: Number,
|
||||
autostart: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
},
|
||||
|
||||
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() {
|
||||
if (this.autostart == true) {
|
||||
this.startLoop()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
startLoop: function() {
|
||||
|
||||
this.clearLooper()
|
||||
this.generatedAt = this.generated_at
|
||||
|
||||
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.clearLooper()
|
||||
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() {
|
||||
clearTimeout(this.remainingTimeout)
|
||||
clearTimeout(this.initialStepToNextStepTimeout)
|
||||
clearInterval(this.stepToStepInterval)
|
||||
this.stepIndex = this.generatedAt = null
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
this.clearLooper()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
</template>
|
@ -1,73 +0,0 @@
|
||||
<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>
|
@ -1,42 +1,44 @@
|
||||
<script setup>
|
||||
import systemService from '@/services/systemService'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
const isScanning = ref(false)
|
||||
const isUpToDate = ref()
|
||||
|
||||
async function getLatestRelease() {
|
||||
isScanning.value = true;
|
||||
isUpToDate.value = undefined
|
||||
|
||||
await systemService.getLastRelease({returnError: true})
|
||||
.then(response => {
|
||||
appSettings.latestRelease = response.data.newRelease
|
||||
isUpToDate.value = response.data.newRelease === false
|
||||
})
|
||||
.catch(() => {
|
||||
isUpToDate.value = null
|
||||
})
|
||||
|
||||
isScanning.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="columns is-mobile is-vcentered">
|
||||
<div class="column is-narrow">
|
||||
<button type="button" :class="isScanning ? 'is-loading' : ''" class="button is-link is-rounded is-small" @click="getLatestRelease">Check now</button>
|
||||
</div>
|
||||
<div class="column">
|
||||
<span v-if="$root.appSettings.latestRelease" class="mt-2 has-text-warning">
|
||||
<span class="release-flag"></span>{{ $root.appSettings.latestRelease }} is available <a class="is-size-7" href="https://github.com/Bubka/2FAuth/releases">View on Github</a>
|
||||
<span v-if="appSettings.latestRelease" class="mt-2 has-text-warning">
|
||||
<span class="release-flag"></span>{{ appSettings.latestRelease }} is available <a class="is-size-7" href="https://github.com/Bubka/2FAuth/releases">View on Github</a>
|
||||
</span>
|
||||
<span v-if="isUpToDate" class="has-text-grey">
|
||||
{{ $t('commons.you_are_up_to_date') }}
|
||||
<FontAwesomeIcon :icon="['fas', 'check']" class="mr-1 has-text-success" /> {{ $t('commons.you_are_up_to_date') }}
|
||||
</span>
|
||||
<span v-else-if="isUpToDate === null" class="has-text-grey">
|
||||
<FontAwesomeIcon :icon="['fas', 'times']" class="mr-1 has-text-danger" />{{ $t('errors.check_failed_try_later') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'VersionChecker',
|
||||
|
||||
data() {
|
||||
return {
|
||||
isScanning: false,
|
||||
isUpToDate: null,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
async getLatestRelease() {
|
||||
this.isScanning = true;
|
||||
|
||||
await this.axios.get('/latestRelease').then(response => {
|
||||
this.$root.appSettings['latestRelease'] = response.data.newRelease
|
||||
this.isUpToDate = response.data.newRelease === false
|
||||
})
|
||||
|
||||
this.isScanning = false;
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
37
resources/js/components/index.js
vendored
37
resources/js/components/index.js
vendored
@ -1,37 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import App from './App'
|
||||
import Button from './Button'
|
||||
import FieldError from './FieldError'
|
||||
import FormWrapper from './FormWrapper'
|
||||
import FormField from './FormField'
|
||||
import FormPasswordField from './FormPasswordField'
|
||||
import FormSelect from './FormSelect'
|
||||
import FormSwitch from './FormSwitch'
|
||||
import FormToggle from './FormToggle'
|
||||
import FormCheckbox from './FormCheckbox'
|
||||
import FormButtons from './FormButtons'
|
||||
import VueFooter from './Footer'
|
||||
import Kicker from './Kicker'
|
||||
import SettingTabs from './SettingTabs'
|
||||
import ResponsiveWidthWrapper from './ResponsiveWidthWrapper'
|
||||
|
||||
// Components that are registered globaly.
|
||||
[
|
||||
App,
|
||||
Button,
|
||||
FieldError,
|
||||
FormWrapper,
|
||||
FormField,
|
||||
FormPasswordField,
|
||||
FormSelect,
|
||||
FormSwitch,
|
||||
FormToggle,
|
||||
FormCheckbox,
|
||||
FormButtons,
|
||||
VueFooter,
|
||||
Kicker,
|
||||
SettingTabs,
|
||||
ResponsiveWidthWrapper
|
||||
].forEach(Component => {
|
||||
Vue.component(Component.name, Component)
|
||||
})
|
14
resources/js/langs/i18n.js
vendored
14
resources/js/langs/i18n.js
vendored
@ -1,14 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import VueInternationalization from 'vue-i18n';
|
||||
import Locale from '@kirschbaum-development/laravel-translations-loader/php!@kirschbaum-development/laravel-translations-loader';
|
||||
|
||||
Vue.use(VueInternationalization);
|
||||
|
||||
const lang = document.documentElement.lang.substr(0, 2);
|
||||
|
||||
const i18n = new VueInternationalization({
|
||||
locale: lang,
|
||||
messages: Locale
|
||||
});
|
||||
|
||||
export default i18n
|
140
resources/js/mixins.js
vendored
140
resources/js/mixins.js
vendored
@ -1,140 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import i18n from './langs/i18n'
|
||||
|
||||
Vue.mixin({
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
appVersion: window.appVersion
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
async appLogout(evt) {
|
||||
if (this.$root.appConfig.proxyAuth) {
|
||||
if (this.$root.appConfig.proxyLogoutUrl) {
|
||||
location.assign(this.$root.appConfig.proxyLogoutUrl)
|
||||
}
|
||||
else return false
|
||||
}
|
||||
else {
|
||||
await this.axios.get('/user/logout')
|
||||
this.clearStorage()
|
||||
this.$router.push({ name: 'login', params: { forceRefresh: true } })
|
||||
}
|
||||
},
|
||||
|
||||
clearStorage() {
|
||||
this.$storage.remove('accounts')
|
||||
this.$storage.remove('groups')
|
||||
this.$storage.remove('lastRoute')
|
||||
this.$storage.remove('authenticated')
|
||||
},
|
||||
|
||||
isUrl: function (url) {
|
||||
var strRegex = /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/
|
||||
var re = new RegExp(strRegex)
|
||||
|
||||
return re.test(url)
|
||||
},
|
||||
|
||||
openInBrowser(uri) {
|
||||
const a = document.createElement('a')
|
||||
a.setAttribute('href', uri)
|
||||
a.dispatchEvent(new MouseEvent("click", { 'view': window, 'bubbles': true, 'cancelable': true }))
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
inputId(fieldType, fieldName) {
|
||||
let prefix
|
||||
fieldName = fieldName.toString()
|
||||
|
||||
switch (fieldType) {
|
||||
case 'text':
|
||||
prefix = 'txt'
|
||||
break
|
||||
case 'button':
|
||||
prefix = 'btn'
|
||||
break
|
||||
case 'email':
|
||||
prefix = 'eml'
|
||||
break
|
||||
case 'password':
|
||||
prefix = 'pwd'
|
||||
break
|
||||
case 'radio':
|
||||
prefix = 'rdo'
|
||||
break
|
||||
case 'label':
|
||||
prefix = 'lbl'
|
||||
break
|
||||
default:
|
||||
prefix = 'txt'
|
||||
break
|
||||
}
|
||||
|
||||
return prefix + fieldName[0].toUpperCase() + fieldName.toLowerCase().slice(1);
|
||||
// button
|
||||
// checkbox
|
||||
// color
|
||||
// date
|
||||
// datetime-local
|
||||
// file
|
||||
// hidden
|
||||
// image
|
||||
// month
|
||||
// number
|
||||
// radio
|
||||
// range
|
||||
// reset
|
||||
// search
|
||||
// submit
|
||||
// tel
|
||||
// text
|
||||
// time
|
||||
// url
|
||||
// week
|
||||
},
|
||||
|
||||
setTheme(theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
},
|
||||
|
||||
applyPreferences(preferences) {
|
||||
for (const preference in preferences) {
|
||||
try {
|
||||
this.$root.userPreferences[preference] = preferences[preference]
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.$root.userPreferences.lang != 'browser') {
|
||||
i18n.locale = this.$root.userPreferences.lang
|
||||
document.documentElement.lang = this.$root.userPreferences.lang
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
strip_tags (str) {
|
||||
return str.replace(/(<([^> ]+)>)/ig, "")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
4
resources/js/packages/clipboard.js
vendored
4
resources/js/packages/clipboard.js
vendored
@ -1,4 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import Clipboard from 'v-clipboard'
|
||||
|
||||
Vue.use(Clipboard)
|
95
resources/js/packages/fontawesome.js
vendored
95
resources/js/packages/fontawesome.js
vendored
@ -1,95 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
import {
|
||||
faPlus,
|
||||
faPlusCircle,
|
||||
faQrcode,
|
||||
faImage,
|
||||
faTrash,
|
||||
faCheck,
|
||||
faCheckSquare,
|
||||
faTimes,
|
||||
faLock,
|
||||
faLockOpen,
|
||||
faSearch,
|
||||
faEllipsisH,
|
||||
faBars,
|
||||
faSpinner,
|
||||
faCaretDown,
|
||||
faLayerGroup,
|
||||
faMinusCircle,
|
||||
faExclamationCircle,
|
||||
faPenSquare,
|
||||
faTh,
|
||||
faList,
|
||||
faTimesCircle,
|
||||
faUpload,
|
||||
faGlobe,
|
||||
faBook,
|
||||
faFlask,
|
||||
faCode,
|
||||
faCopy,
|
||||
faSortAlphaDown,
|
||||
faSortAlphaUp,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faExternalLinkAlt,
|
||||
faCamera,
|
||||
faFileDownload,
|
||||
faSun,
|
||||
faMoon,
|
||||
faDesktop,
|
||||
faCircleNotch
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import {
|
||||
faGithubAlt
|
||||
} from '@fortawesome/free-brands-svg-icons'
|
||||
|
||||
library.add(
|
||||
faPlus,
|
||||
faPlusCircle,
|
||||
faQrcode,
|
||||
faImage,
|
||||
faTrash,
|
||||
faCheck,
|
||||
faCheckSquare,
|
||||
faTimes,
|
||||
faLock,
|
||||
faLockOpen,
|
||||
faSearch,
|
||||
faEllipsisH,
|
||||
faBars,
|
||||
faSpinner,
|
||||
faGithubAlt,
|
||||
faCaretDown,
|
||||
faLayerGroup,
|
||||
faMinusCircle,
|
||||
faExclamationCircle,
|
||||
faPenSquare,
|
||||
faTh,
|
||||
faList,
|
||||
faTimesCircle,
|
||||
faUpload,
|
||||
faGlobe,
|
||||
faBook,
|
||||
faFlask,
|
||||
faCode,
|
||||
faCopy,
|
||||
faSortAlphaDown,
|
||||
faSortAlphaUp,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faExternalLinkAlt,
|
||||
faCamera,
|
||||
faFileDownload,
|
||||
faSun,
|
||||
faMoon,
|
||||
faDesktop,
|
||||
faCircleNotch
|
||||
);
|
||||
|
||||
Vue.component('font-awesome-icon', FontAwesomeIcon)
|
10
resources/js/packages/vue-storage.js
vendored
10
resources/js/packages/vue-storage.js
vendored
@ -1,10 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import { Plugin } from 'vue2-storage'
|
||||
|
||||
// You can specify the plug-in configuration when connecting, passing the second object to Vue.use
|
||||
Vue.use(Plugin, {
|
||||
prefix: '',
|
||||
driver: 'local',
|
||||
ttl: 60 * 60 * 24 * 1000 * 122, // 4 month
|
||||
replacer: (key, value) => value
|
||||
})
|
127
resources/js/routes.js
vendored
127
resources/js/routes.js
vendored
@ -1,127 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
import Start from './views/Start'
|
||||
import Capture from './views/Capture'
|
||||
import Accounts from './views/Accounts'
|
||||
import CreateAccount from './views/twofaccounts/Create'
|
||||
import EditAccount from './views/twofaccounts/Edit'
|
||||
import ImportAccount from './views/twofaccounts/Import'
|
||||
import QRcodeAccount from './views/twofaccounts/QRcode'
|
||||
import Groups from './views/Groups'
|
||||
import CreateGroup from './views/groups/Create'
|
||||
import EditGroup from './views/groups/Edit'
|
||||
import Login from './views/auth/Login'
|
||||
import Register from './views/auth/Register'
|
||||
import Autolock from './views/auth/Autolock'
|
||||
import PasswordRequest from './views/auth/password/Request'
|
||||
import PasswordReset from './views/auth/password/Reset'
|
||||
import WebauthnLost from './views/auth/webauthn/Lost'
|
||||
import WebauthnRecover from './views/auth/webauthn/Recover'
|
||||
import SettingsOptions from './views/settings/Options'
|
||||
import SettingsAccount from './views/settings/Account'
|
||||
import SettingsOAuth from './views/settings/OAuth'
|
||||
import SettingsWebAuthn from './views/settings/WebAuthn'
|
||||
import EditCredential from './views/settings/Credentials/Edit'
|
||||
import GeneratePAT from './views/settings/PATokens/Create'
|
||||
import Errors from './views/Error'
|
||||
import About from './views/About'
|
||||
|
||||
const router = new Router({
|
||||
mode: 'history',
|
||||
base: window.appConfig.subdirectory ? window.appConfig.subdirectory : '/',
|
||||
routes: [
|
||||
{ path: '/start', name: 'start', component: Start, meta: { requiresAuth: true }, props: true },
|
||||
{ path: '/capture', name: 'capture', component: Capture, meta: { requiresAuth: true }, props: true },
|
||||
|
||||
{ path: '/accounts', name: 'accounts', component: Accounts, meta: { requiresAuth: true }, alias: '/', props: true },
|
||||
{ path: '/account/create', name: 'createAccount', component: CreateAccount, meta: { requiresAuth: true } },
|
||||
{ path: '/account/import', name: 'importAccounts', component: ImportAccount, meta: { requiresAuth: true } },
|
||||
{ path: '/account/:twofaccountId/edit', name: 'editAccount', component: EditAccount, meta: { requiresAuth: true } },
|
||||
{ path: '/account/:twofaccountId/qrcode', name: 'showQRcode', component: QRcodeAccount, meta: { requiresAuth: true } },
|
||||
|
||||
{ path: '/groups', name: 'groups', component: Groups, meta: { requiresAuth: true }, props: true },
|
||||
{ path: '/group/create', name: 'createGroup', component: CreateGroup, meta: { requiresAuth: true } },
|
||||
{ path: '/group/:groupId/edit', name: 'editGroup', component: EditGroup, meta: { requiresAuth: true }, props: true },
|
||||
|
||||
{ path: '/settings/options', name: 'settings.options', component: SettingsOptions, meta: { requiresAuth: true, showAbout: true } },
|
||||
{ path: '/settings/account', name: 'settings.account', component: SettingsAccount, meta: { requiresAuth: true, showAbout: true } },
|
||||
{ path: '/settings/oauth', name: 'settings.oauth.tokens', component: SettingsOAuth, meta: { requiresAuth: true, showAbout: true } },
|
||||
{ path: '/settings/oauth/pat/create', name: 'settings.oauth.generatePAT', component: GeneratePAT, meta: { requiresAuth: true, showAbout: true } },
|
||||
{ path: '/settings/webauthn/:credentialId/edit', name: 'settings.webauthn.editCredential', component: EditCredential, meta: { requiresAuth: true, showAbout: true }, props: true },
|
||||
{ path: '/settings/webauthn', name: 'settings.webauthn.devices', component: SettingsWebAuthn, meta: { requiresAuth: true, showAbout: true } },
|
||||
|
||||
{ path: '/login', name: 'login', component: Login, meta: { disabledWithAuthProxy: true, showAbout: true } },
|
||||
{ path: '/register', name: 'register', component: Register, meta: { disabledWithAuthProxy: true, showAbout: true } },
|
||||
{ path: '/autolock', name: 'autolock',component: Autolock, meta: { disabledWithAuthProxy: true, showAbout: true } },
|
||||
{ path: '/password/request', name: 'password.request', component: PasswordRequest, meta: { disabledWithAuthProxy: true, showAbout: true } },
|
||||
{ path: '/user/password/reset', name: 'password.reset', component: PasswordReset, meta: { disabledWithAuthProxy: true, showAbout: true } },
|
||||
{ path: '/webauthn/lost', name: 'webauthn.lost', component: WebauthnLost, meta: { disabledWithAuthProxy: true, showAbout: true } },
|
||||
{ path: '/webauthn/recover', name: 'webauthn.recover', component: WebauthnRecover, meta: { disabledWithAuthProxy: true, showAbout: true } },
|
||||
|
||||
{ path: '/about', name: 'about',component: About, meta: { showAbout: true } },
|
||||
{ path: '/error', name: 'genericError',component: Errors, props: true },
|
||||
{ path: '/404', name: '404',component: Errors, props: true },
|
||||
{ path: '*', redirect: { name: '404' } }
|
||||
],
|
||||
});
|
||||
|
||||
let isFirstLoad = true;
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
|
||||
document.title = router.app.$options.i18n.t('titles.' + to.name)
|
||||
|
||||
if( to.name === 'accounts') {
|
||||
to.params.isFirstLoad = isFirstLoad ? true : false
|
||||
isFirstLoad = false;
|
||||
}
|
||||
|
||||
// See https://github.com/garethredfern/laravel-vue/ if one day the middleware pattern
|
||||
// becomes relevant (i.e when some admin only pages are necessary)
|
||||
|
||||
if (to.meta.requiresAuth
|
||||
&& ! Vue.$storage.get('authenticated', false)
|
||||
&& ! window.appConfig.proxyAuth) {
|
||||
next({ name: 'login' })
|
||||
}
|
||||
else if (to.matched.some(record => record.meta.disabledWithAuthProxy) && window.appConfig.proxyAuth) {
|
||||
// The page is not relevant with auth proxy On so we push to the main view
|
||||
next({ name: 'accounts' })
|
||||
}
|
||||
else if (to.name.startsWith('settings.')) {
|
||||
if (to.params.returnTo == undefined) {
|
||||
if (from.params.returnTo) {
|
||||
next({name: to.name, params: { ...to.params, returnTo: from.params.returnTo }})
|
||||
}
|
||||
else if (from.name) {
|
||||
next({name: to.name, params: { ...to.params, returnTo: from.path }})
|
||||
}
|
||||
else {
|
||||
next({name: to.name, params: { ...to.params, returnTo: '/accounts' }})
|
||||
}
|
||||
}
|
||||
else {
|
||||
next()
|
||||
}
|
||||
}
|
||||
else if (to.name == 'about' && to.params.goBackTo == undefined) {
|
||||
if (from.name) {
|
||||
next({ name: to.name, params: {goBackTo: from.path} })
|
||||
}
|
||||
else next({ name: to.name, params: {goBackTo: '/accounts'} })
|
||||
}
|
||||
else if (to.name === 'genericError' && to.params.err == undefined) {
|
||||
// return to home if no err object is provided to prevent an empty error message
|
||||
next({ name: 'accounts' });
|
||||
}
|
||||
else next()
|
||||
});
|
||||
|
||||
router.afterEach(to => {
|
||||
Vue.$storage.set('lastRoute', to.name)
|
||||
});
|
||||
|
||||
export default router
|
@ -1,8 +1,52 @@
|
||||
<script setup>
|
||||
import systemService from '@/services/systemService'
|
||||
import { useNotifyStore } from '@/stores/notify'
|
||||
import { UseColorMode } from '@vueuse/components'
|
||||
|
||||
const $2fauth = inject('2fauth')
|
||||
const router = useRouter()
|
||||
const notify = useNotifyStore()
|
||||
const { copy } = useClipboard({ legacy: true })
|
||||
|
||||
const returnTo = router.options.history.state.back
|
||||
const infos = ref()
|
||||
const listInfos = ref(null)
|
||||
const userPreferences = ref(false)
|
||||
const listUserPreferences = ref(null)
|
||||
const adminSettings = ref(false)
|
||||
const listAdminSettings = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
systemService.getSystemInfos({returnError: true}).then(response => {
|
||||
infos.value = response.data.common
|
||||
|
||||
if (response.data.admin_settings) {
|
||||
adminSettings.value = response.data.admin_settings
|
||||
}
|
||||
|
||||
if (response.data.user_preferences) {
|
||||
userPreferences.value = response.data.user_preferences
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
infos.value = null
|
||||
})
|
||||
})
|
||||
|
||||
function copyToClipboard(data) {
|
||||
copy(data)
|
||||
notify.success({ text: trans('commons.copied_to_clipboard') })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<responsive-width-wrapper>
|
||||
<h1 class="title has-text-grey-dark">{{ pagetitle }}</h1>
|
||||
<ResponsiveWidthWrapper>
|
||||
<h1 class="title has-text-grey-dark">{{ $t('commons.about') }}</h1>
|
||||
<p class="block">
|
||||
<span :class="$root.showDarkMode ? 'has-text-white':'has-text-black'"><span class="is-size-5">2FAuth</span> v{{ appVersion }}</span><br />
|
||||
<UseColorMode v-slot="{ mode }">
|
||||
<span :class="mode == 'dark' ? 'has-text-white':'has-text-black'"><span class="is-size-5">2FAuth</span> v{{ $2fauth.version }}</span>
|
||||
</UseColorMode>
|
||||
<br />
|
||||
{{ $t('commons.2fauth_teaser')}}
|
||||
</p>
|
||||
<img class="about-logo" src="logo.svg" alt="2FAuth logo" />
|
||||
@ -13,138 +57,82 @@
|
||||
{{ $t('commons.resources') }}
|
||||
</h2>
|
||||
<div class="buttons">
|
||||
<a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://github.com/Bubka/2FAuth" target="_blank">
|
||||
<span class="icon is-small">
|
||||
<font-awesome-icon :icon="['fab', 'github-alt']" />
|
||||
</span>
|
||||
<span>Github</span>
|
||||
</a>
|
||||
<a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://docs.2fauth.app/" target="_blank">
|
||||
<span class="icon is-small">
|
||||
<font-awesome-icon :icon="['fas', 'book']" />
|
||||
</span>
|
||||
<span>Docs</span>
|
||||
</a>
|
||||
<a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://demo.2fauth.app/" target="_blank">
|
||||
<span class="icon is-small">
|
||||
<font-awesome-icon :icon="['fas', 'flask']" />
|
||||
</span>
|
||||
<span>Demo</span>
|
||||
</a>
|
||||
<a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://docs.2fauth.app/resources/rapidoc.html" target="_blank">
|
||||
<span class="icon is-small">
|
||||
<font-awesome-icon :icon="['fas', 'code']" />
|
||||
</span>
|
||||
<span>API</span>
|
||||
</a>
|
||||
<UseColorMode v-slot="{ mode }">
|
||||
<a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://github.com/Bubka/2FAuth" target="_blank">
|
||||
<span class="icon is-small">
|
||||
<FontAwesomeIcon :icon="['fab', 'github-alt']" />
|
||||
</span>
|
||||
<span>Github</span>
|
||||
</a>
|
||||
<a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://docs.2fauth.app/" target="_blank">
|
||||
<span class="icon is-small">
|
||||
<FontAwesomeIcon :icon="['fas', 'book']" />
|
||||
</span>
|
||||
<span>Docs</span>
|
||||
</a>
|
||||
<a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://demo.2fauth.app/" target="_blank">
|
||||
<span class="icon is-small">
|
||||
<FontAwesomeIcon :icon="['fas', 'flask']" />
|
||||
</span>
|
||||
<span>Demo</span>
|
||||
</a>
|
||||
<a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://docs.2fauth.app/resources/rapidoc.html" target="_blank">
|
||||
<span class="icon is-small">
|
||||
<FontAwesomeIcon :icon="['fas', 'code']" />
|
||||
</span>
|
||||
<span>API</span>
|
||||
</a>
|
||||
</UseColorMode>
|
||||
</div>
|
||||
<h2 class="title is-5 has-text-grey-light">
|
||||
{{ $t('commons.credits') }}
|
||||
</h2>
|
||||
<p class="block">
|
||||
<ul>
|
||||
<li>{{ $t('commons.made_with')}} <a href="https://docs.2fauth.app/credits/">Laravel, Bulma CSS, Vue.js and more</a></li>
|
||||
<li>{{ $t('commons.ui_icons_by')}} <a href="https://fontawesome.com/">Font Awesome</a> <a class="is-size-7" href="https://fontawesome.com/license/free">(CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)</a></li>
|
||||
<li>{{ $t('commons.logos_by')}} <a href="https://2fa.directory/">2FA Directory</a> <a class="is-size-7" href="https://github.com/2factorauth/twofactorauth/blob/master/LICENSE.md">(MIT License)</a></li>
|
||||
<li>{{ $t('commons.made_with') }} <a href="https://docs.2fauth.app/credits/">Laravel, Bulma CSS, Vue.js and more</a></li>
|
||||
<li>{{ $t('commons.ui_icons_by') }} <a href="https://fontawesome.com/">Font Awesome</a> <a class="is-size-7" href="https://fontawesome.com/license/free">(CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)</a></li>
|
||||
<li>{{ $t('commons.logos_by') }} <a href="https://2fa.directory/">2FA Directory</a> <a class="is-size-7" href="https://github.com/2factorauth/twofactorauth/blob/master/LICENSE.md">(MIT License)</a></li>
|
||||
</ul>
|
||||
</p>
|
||||
<h2 class="title is-5 has-text-grey-light">
|
||||
{{ $t('commons.environment') }}
|
||||
</h2>
|
||||
<div class="about-debug box is-family-monospace is-size-7">
|
||||
<button id="btnCopyEnvVars" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" v-clipboard="() => this.$refs.listInfos.innerText" v-clipboard:success="clipboardSuccessHandler">
|
||||
<font-awesome-icon :icon="['fas', 'copy']" />
|
||||
<div v-if="infos" class="about-debug box is-family-monospace is-size-7">
|
||||
<button id="btnCopyEnvVars" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" @click.stop="copyToClipboard(listInfos.innerText)">
|
||||
<FontAwesomeIcon :icon="['fas', 'copy']" />
|
||||
</button>
|
||||
<ul ref="listInfos" id="listInfos">
|
||||
<li v-for="(value, key) in infos" :value="value" :key="key"><b>{{key}}</b>: {{value}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<h2 v-if="showAdminSettings" class="title is-5 has-text-grey-light">
|
||||
<div v-else-if="infos === null" class="about-debug box is-family-monospace is-size-7 has-text-warning-dark">
|
||||
{{ $t('errors.error_during_data_fetching') }}
|
||||
</div>
|
||||
<h2 v-if="adminSettings" class="title is-5 has-text-grey-light">
|
||||
{{ $t('settings.admin_settings') }}
|
||||
</h2>
|
||||
<div v-if="showAdminSettings" class="about-debug box is-family-monospace is-size-7">
|
||||
<button id="btnCopyAdminSettings" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" v-clipboard="() => this.$refs.listAdminSettings.innerText" v-clipboard:success="clipboardSuccessHandler">
|
||||
<font-awesome-icon :icon="['fas', 'copy']" />
|
||||
<div v-if="adminSettings" class="about-debug box is-family-monospace is-size-7">
|
||||
<button id="btnCopyAdminSettings" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" @click.stop="copyToClipboard(listAdminSettings.innerText)">
|
||||
<FontAwesomeIcon :icon="['fas', 'copy']" />
|
||||
</button>
|
||||
<ul ref="listAdminSettings" id="listAdminSettings">
|
||||
<li v-for="(value, setting) in adminSettings" :value="value" :key="setting"><b>{{setting}}</b>: {{value}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<h2 v-if="showUserPreferences" class="title is-5 has-text-grey-light">
|
||||
<h2 v-if="userPreferences" class="title is-5 has-text-grey-light">
|
||||
{{ $t('settings.user_preferences') }}
|
||||
</h2>
|
||||
<div v-if="showUserPreferences" class="about-debug box is-family-monospace is-size-7">
|
||||
<button id="btnCopyUserPreferences" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" v-clipboard="() => this.$refs.listUserPreferences.innerText" v-clipboard:success="clipboardSuccessHandler">
|
||||
<font-awesome-icon :icon="['fas', 'copy']" />
|
||||
<div v-if="userPreferences" class="about-debug box is-family-monospace is-size-7">
|
||||
<button id="btnCopyUserPreferences" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" @click.stop="copyToClipboard(listUserPreferences.innerText)">
|
||||
<FontAwesomeIcon :icon="['fas', 'copy']" />
|
||||
</button>
|
||||
<ul ref="listUserPreferences" id="listUserPreferences">
|
||||
<li v-for="(value, preference) in userPreferences" :value="value" :key="preference"><b>{{preference}}</b>: {{value}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- footer -->
|
||||
<vue-footer :showButtons="true">
|
||||
<!-- close button -->
|
||||
<p class="control">
|
||||
<router-link
|
||||
id="lnkBack"
|
||||
:to="{ path: $route.params.goBackTo, params: { returnTo: $route.params.returnTo, toRefresh: true } }"
|
||||
:aria-label="$t('commons.close_the_x_page', {pagetitle: pagetitle})"
|
||||
class="button is-rounded"
|
||||
:class="{'is-dark' : $root.showDarkMode}">
|
||||
{{ $t('commons.back') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</vue-footer>
|
||||
</responsive-width-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
pagetitle: this.$t('commons.about'),
|
||||
infos : null,
|
||||
adminSettings : null,
|
||||
userPreferences : null,
|
||||
showUserPreferences: false,
|
||||
showAdminSettings: false,
|
||||
}
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
await this.axios.get('infos').then(response => {
|
||||
this.infos = response.data.common
|
||||
|
||||
if (response.data.admin_settings) {
|
||||
this.adminSettings = response.data.admin_settings
|
||||
this.showAdminSettings = true
|
||||
}
|
||||
|
||||
if (response.data.user_preferences) {
|
||||
this.userPreferences = response.data.user_preferences
|
||||
this.showUserPreferences = true
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
clipboardSuccessHandler ({ value, event }) {
|
||||
this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
|
||||
},
|
||||
|
||||
clipboardErrorHandler ({ value, event }) {
|
||||
console.log('error', value)
|
||||
},
|
||||
},
|
||||
|
||||
beforeRouteEnter(to, from, next) {
|
||||
next(vm => {
|
||||
if (from.params.returnTo) {
|
||||
to.params.returnTo = from.params.returnTo
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
}
|
||||
</script>
|
||||
<VueFooter :showButtons="true">
|
||||
<ButtonBackCloseCancel :returnTo="{ path: returnTo }" action="back" />
|
||||
</VueFooter>
|
||||
</ResponsiveWidthWrapper>
|
||||
</template>
|
@ -1,830 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Group switch -->
|
||||
<div id="groupSwitch" class="container groups" v-if="showGroupSwitch">
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-one-third-tablet is-one-quarter-desktop is-one-quarter-widescreen is-one-quarter-fullhd">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-full" v-for="group in groups" v-if="group.twofaccounts_count > 0" :key="group.id">
|
||||
<button class="button is-fullwidth" :class="{'is-dark has-text-light is-outlined':$root.showDarkMode}" @click="setActiveGroup(group.id)">{{ group.name }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-centered">
|
||||
<div class="column has-text-centered">
|
||||
<router-link :to="{ name: 'groups' }" >{{ $t('groups.manage_groups') }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<vue-footer :showButtons="true">
|
||||
<!-- Close Group switch button -->
|
||||
<p class="control">
|
||||
<button id="btnClose" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" @click="closeGroupSwitch()">{{ $t('commons.close') }}</button>
|
||||
</p>
|
||||
</vue-footer>
|
||||
</div>
|
||||
<!-- Group selector -->
|
||||
<div class="container group-selector" v-if="showGroupSelector">
|
||||
<div class="columns is-centered is-multiline">
|
||||
<div class="column is-full has-text-centered">
|
||||
{{ $t('groups.move_selected_to') }}
|
||||
</div>
|
||||
<div class="column is-one-third-tablet is-one-quarter-desktop is-one-quarter-widescreen is-one-quarter-fullhd">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-full" v-for="group in groups" :key="group.id">
|
||||
<button class="button is-fullwidth" :class="{'is-link' : moveAccountsTo === group.id, 'is-dark has-text-light is-outlined':$root.showDarkMode}" @click="moveAccountsTo = group.id">
|
||||
<span v-if="group.id === 0" class="is-italic">
|
||||
{{ $t('groups.no_group') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ group.name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-centered">
|
||||
<div class="column has-text-centered">
|
||||
<router-link :to="{ name: 'groups' }" >{{ $t('groups.manage_groups') }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<vue-footer :showButtons="true">
|
||||
<!-- Move to selected group button -->
|
||||
<p class="control">
|
||||
<button class="button is-link is-rounded" @click="moveAccounts()">{{ $t('commons.move') }}</button>
|
||||
</p>
|
||||
<!-- Cancel button -->
|
||||
<p class="control">
|
||||
<button id="btnCancel" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" @click="showGroupSelector = false">{{ $t('commons.cancel') }}</button>
|
||||
</p>
|
||||
</vue-footer>
|
||||
</div>
|
||||
<!-- header -->
|
||||
<div class="header" v-if="this.showAccounts || this.showGroupSwitch">
|
||||
<div class="columns is-gapless is-mobile is-centered">
|
||||
<div class="column is-three-quarters-mobile is-one-third-tablet is-one-quarter-desktop is-one-quarter-widescreen is-one-quarter-fullhd">
|
||||
<!-- search -->
|
||||
<div role="search" class="field">
|
||||
<div class="control has-icons-right">
|
||||
<input ref="searchBox" id="txtSearch" type="search" tabindex="1" :aria-label="$t('commons.search')" :title="$t('commons.search')" class="input is-rounded is-search" v-model="search">
|
||||
<span class="icon is-small is-right">
|
||||
<font-awesome-icon :icon="['fas', 'search']" v-if="!search" />
|
||||
<button id="btnClearSearch" tabindex="1" :title="$t('commons.clear_search')" class="clear-selection delete" v-if="search" @click="search = '' "></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- toolbar -->
|
||||
<div v-if="editMode" class="toolbar has-text-centered">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<!-- selected label -->
|
||||
<span class="has-text-grey mr-1">{{ selectedAccounts.length }} {{ $t('commons.selected') }}</span>
|
||||
<!-- deselect all -->
|
||||
<button id="btnUnselectAll" @click="clearSelected" class="clear-selection delete mr-4" :style="{visibility: selectedAccounts.length > 0 ? 'visible' : 'hidden'}" :title="$t('commons.clear_selection')"></button>
|
||||
<!-- select all button -->
|
||||
<button id="btnSelectAll" @click="selectAll" class="button mr-5 has-line-height p-1 is-ghost has-text-grey" :title="$t('commons.select_all')">
|
||||
<span>{{ $t('commons.all') }}</span>
|
||||
<font-awesome-icon class="ml-1" :icon="['fas', 'check-square']" />
|
||||
</button>
|
||||
<!-- sort asc/desc buttons -->
|
||||
<button id="btnSortAscending" @click="sortAsc" class="button has-line-height p-1 is-ghost has-text-grey" :title="$t('commons.sort_ascending')">
|
||||
<font-awesome-icon :icon="['fas', 'sort-alpha-down']" />
|
||||
</button>
|
||||
<button id="btnSortDescending" @click="sortDesc" class="button has-line-height p-1 is-ghost has-text-grey" :title="$t('commons.sort_descending')">
|
||||
<font-awesome-icon :icon="['fas', 'sort-alpha-up']" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- group switch toggle -->
|
||||
<div v-else class="has-text-centered">
|
||||
<div class="columns">
|
||||
<div class="column" v-if="!showGroupSwitch">
|
||||
<button id="btnShowGroupSwitch" :title="$t('groups.show_group_selector')" tabindex="1" class="button is-text is-like-text" :class="{'has-text-grey' : !$root.showDarkMode}" @click.stop="toggleGroupSwitch">
|
||||
{{ activeGroupName }} ({{ filteredAccounts.length }})
|
||||
<font-awesome-icon :icon="['fas', 'caret-down']" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="column" v-else>
|
||||
<button id="btnHideGroupSwitch" :title="$t('groups.hide_group_selector')" tabindex="1" class="button is-text is-like-text" :class="{'has-text-grey' : !$root.showDarkMode}" @click.stop="toggleGroupSwitch">
|
||||
{{ $t('groups.select_accounts_to_show') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- modal -->
|
||||
<modal v-model="showTwofaccountInModal">
|
||||
<otp-displayer ref="OtpDisplayer"></otp-displayer>
|
||||
</modal>
|
||||
<!-- show accounts list -->
|
||||
<div class="container" v-if="this.showAccounts" :class="editMode ? 'is-edit-mode' : ''">
|
||||
<!-- accounts -->
|
||||
<!-- <vue-pull-refresh :on-refresh="onRefresh" :config="{
|
||||
errorLabel: 'error',
|
||||
startLabel: '',
|
||||
readyLabel: '',
|
||||
loadingLabel: 'refreshing'
|
||||
}" > -->
|
||||
<draggable v-model="filteredAccounts" @start="drag = true" @end="saveOrder" ghost-class="ghost" handle=".tfa-dots" animation="200" class="accounts">
|
||||
<transition-group class="columns is-multiline" :class="{ 'is-centered': $root.userPreferences.displayMode === 'grid' }" type="transition" :name="!drag ? 'flip-list' : null">
|
||||
<div :class="[$root.userPreferences.displayMode === 'grid' ? 'tfa-grid' : 'tfa-list']" class="column is-narrow" v-for="account in filteredAccounts" :key="account.id">
|
||||
<div class="tfa-container">
|
||||
<transition name="slideCheckbox">
|
||||
<div class="tfa-cell tfa-checkbox" v-if="editMode">
|
||||
<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" v-model="selectedAccounts">
|
||||
<label tabindex="0" :for="'ckb_' + account.id" v-on:keypress.space.prevent="selectAccount(account.id)"></label>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<div tabindex="0" class="tfa-cell tfa-content is-size-3 is-size-4-mobile" @click.exact="showOrCopy(account)" @keyup.enter="showOrCopy(account)" @click.ctrl="getAndCopyOTP(account)" role="button">
|
||||
<div class="tfa-text has-ellipsis">
|
||||
<img class="tfa-icon" :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="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 && isRenewingOTPs" class="has-nowrap has-text-grey has-text-centered is-size-5">
|
||||
<font-awesome-icon :icon="['fas', 'circle-notch']" spin />
|
||||
</span>
|
||||
<span v-else-if="account.otp != undefined && isRenewingOTPs == false" class="always-on-otp 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')" @hook:mounted="turnDotsOnFromCache(account.period)" :class="'condensed'" :ref="'dots_' + account.period"></dots>
|
||||
</div>
|
||||
</transition>
|
||||
<transition name="fadeInOut">
|
||||
<div class="tfa-cell tfa-edit has-text-grey" v-if="editMode">
|
||||
<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">
|
||||
<div class="tfa-cell tfa-dots has-text-grey" v-if="editMode">
|
||||
<font-awesome-icon :icon="['fas', 'bars']" />
|
||||
</div>
|
||||
</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> -->
|
||||
<vue-footer :showButtons="true" :editMode="editMode" v-on:exit-edit="setEditModeTo(false)">
|
||||
<!-- New item buttons -->
|
||||
<p class="control" v-if="!editMode">
|
||||
<button class="button is-link is-rounded is-focus" @click="start">
|
||||
<span>{{ $t('commons.new') }}</span>
|
||||
<span class="icon is-small">
|
||||
<font-awesome-icon :icon="['fas', 'qrcode']" />
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
<!-- Manage button -->
|
||||
<p class="control" v-if="!editMode">
|
||||
<button id="btnManage" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" @click="setEditModeTo(true)">{{ $t('commons.manage') }}</button>
|
||||
</p>
|
||||
<!-- move button -->
|
||||
<p class="control" v-if="editMode">
|
||||
<button
|
||||
id="btnMove"
|
||||
:disabled='selectedAccounts.length == 0' class="button is-rounded"
|
||||
:class="[{'is-outlined': $root.showDarkMode||selectedAccounts.length == 0}, selectedAccounts.length == 0 ? 'is-dark': 'is-link']"
|
||||
@click="showGroupSelector = true"
|
||||
:title="$t('groups.move_selected_to_group')" >
|
||||
{{ $t('commons.move') }}
|
||||
</button>
|
||||
</p>
|
||||
<!-- delete button -->
|
||||
<p class="control" v-if="editMode">
|
||||
<button
|
||||
id="btnDelete"
|
||||
:disabled='selectedAccounts.length == 0' class="button is-rounded"
|
||||
:class="[{'is-outlined': $root.showDarkMode||selectedAccounts.length == 0}, selectedAccounts.length == 0 ? 'is-dark': 'is-link']"
|
||||
@click="destroyAccounts" >
|
||||
{{ $t('commons.delete') }}
|
||||
</button>
|
||||
</p>
|
||||
<!-- export button -->
|
||||
<p class="control" v-if="editMode">
|
||||
<button
|
||||
id="btnExport"
|
||||
:disabled='selectedAccounts.length == 0' class="button is-rounded"
|
||||
:class="[{'is-outlined': $root.showDarkMode||selectedAccounts.length == 0}, selectedAccounts.length == 0 ? 'is-dark': 'is-link']"
|
||||
@click="exportAccounts"
|
||||
:title="$t('twofaccounts.export_selected_to_json')" >
|
||||
{{ $t('commons.export') }}
|
||||
</button>
|
||||
</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"
|
||||
v-on:loop-ended="updateTotps(period.period)"
|
||||
v-on:loop-started="setCurrentStep(period.period, $event)"
|
||||
v-on:stepped-up="setCurrentStep(period.period, $event)"
|
||||
ref="loopers"
|
||||
></totp-looper>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
|
||||
/**
|
||||
* Accounts view
|
||||
*
|
||||
* route: '/account' (alias: '/')
|
||||
*
|
||||
* The main view of 2FAuth that list all existing account recorded in DB.
|
||||
* Available feature in this view :
|
||||
* - {{OTP}} generation
|
||||
* - Account fetching :
|
||||
* ~ Search
|
||||
* ~ Filtering (by group)
|
||||
* - Accounts management :
|
||||
* ~ Sorting
|
||||
* ~ QR code recovering
|
||||
* ~ Mass association to group
|
||||
* ~ Mass account deletion
|
||||
* ~ Access to account editing
|
||||
*
|
||||
* Behavior :
|
||||
* - The view has 2 modes (toggle is done with the 'manage' button) :
|
||||
* ~ The View mode (the default one)
|
||||
* ~ The Edit mode
|
||||
* - User are automatically pushed to the start view if there is no account to list.
|
||||
* - The view is affected by :
|
||||
* ~ 'userPreferences.showAccountsIcons' toggle the icon visibility
|
||||
* ~ 'userPreferences.displayMode' change the account appearance
|
||||
*
|
||||
* Input :
|
||||
* - The 'initialEditMode' props : allows to load the view directly in Edit mode
|
||||
*
|
||||
*/
|
||||
|
||||
// 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'
|
||||
import objectEquals from 'object-equals'
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
accounts : [],
|
||||
groups : [],
|
||||
selectedAccounts: [],
|
||||
search: '',
|
||||
editMode: this.initialEditMode,
|
||||
drag: false,
|
||||
showTwofaccountInModal : false,
|
||||
showGroupSwitch: false,
|
||||
showGroupSelector: false,
|
||||
moveAccountsTo: false,
|
||||
form: new Form({
|
||||
value: this.$root.userPreferences.activeGroup,
|
||||
}),
|
||||
stepIndexes: {},
|
||||
isRenewingOTPs: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* The actual list of displayed accounts
|
||||
*/
|
||||
filteredAccounts: {
|
||||
get: function() {
|
||||
|
||||
return this.accounts.filter(
|
||||
item => {
|
||||
if( parseInt(this.$root.userPreferences.activeGroup) > 0 ) {
|
||||
return ((item.service ? item.service.toLowerCase().includes(this.search.toLowerCase()) : false) ||
|
||||
item.account.toLowerCase().includes(this.search.toLowerCase())) &&
|
||||
(item.group_id == parseInt(this.$root.userPreferences.activeGroup))
|
||||
}
|
||||
else {
|
||||
return ((item.service ? item.service.toLowerCase().includes(this.search.toLowerCase()) : false) ||
|
||||
item.account.toLowerCase().includes(this.search.toLowerCase()))
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
set: function(reorderedAccounts) {
|
||||
this.accounts = reorderedAccounts
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns whether or not the accounts should be displayed
|
||||
*/
|
||||
showAccounts() {
|
||||
return this.accounts.length > 0 && !this.showGroupSwitch && !this.showGroupSelector ? true : false
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the name of a group
|
||||
*/
|
||||
activeGroupName() {
|
||||
let g = this.groups.find(el => el.id === parseInt(this.$root.userPreferences.activeGroup))
|
||||
|
||||
if(g) {
|
||||
return g.name
|
||||
}
|
||||
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
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
props: ['initialEditMode', 'toRefresh'],
|
||||
|
||||
mounted() {
|
||||
|
||||
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.$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
|
||||
|
||||
const groups = this.$storage.get('groups', null) // use null as fallback if localstorage is empty
|
||||
if( groups ) this.groups = groups
|
||||
}
|
||||
|
||||
// we fetch fresh data whatever. The user will be notified to reload the page if there are any data changes
|
||||
this.fetchAccounts()
|
||||
|
||||
// stop OTP generation on modal close
|
||||
this.$on('modalClose', function() {
|
||||
this.$refs.OtpDisplayer.clearOTP()
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
destroyed () {
|
||||
document.removeEventListener('keydown', this.keyListener)
|
||||
},
|
||||
|
||||
components: {
|
||||
// Twofaccount,
|
||||
Modal,
|
||||
OtpDisplayer,
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async getAndCopyOTP(account) {
|
||||
await this.axios.get('/api/v1/twofaccounts/' + account.id + '/otp').then(response => {
|
||||
let otp = response.data
|
||||
this.copyOTP(otp.password)
|
||||
|
||||
if (otp.otp_type == 'hotp') {
|
||||
let hotpToIncrement = this.accounts.find((acc) => acc.id == account.id)
|
||||
|
||||
if (hotpToIncrement != undefined) {
|
||||
hotpToIncrement.counter = otp.counter
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
copyOTP (password) {
|
||||
// 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(password)
|
||||
|
||||
if (success == true) {
|
||||
if(this.$root.userPreferences.kickUserAfter == -1) {
|
||||
this.appLogout()
|
||||
}
|
||||
|
||||
this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
setCurrentStep(period, stepIndex) {
|
||||
this.stepIndexes[period] = stepIndex
|
||||
this.turnDotsOn(period, stepIndex)
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
turnDotsOnFromCache(period, stepIndex) {
|
||||
if (this.stepIndexes[period] != undefined) {
|
||||
this.turnDotsOn(period, this.stepIndexes[period])
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
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.isRenewingOTPs = true
|
||||
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
|
||||
this.$nextTick(() => {
|
||||
looper.startLoop()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.isRenewingOTPs = false
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
start() {
|
||||
if( this.$root.userPreferences.useDirectCapture && this.$root.userPreferences.defaultCaptureMode === 'advancedForm' ) {
|
||||
this.$router.push({ name: 'createAccount' })
|
||||
}
|
||||
else if( this.$root.userPreferences.useDirectCapture && this.$root.userPreferences.defaultCaptureMode === 'livescan' ) {
|
||||
this.$router.push({ name: 'capture' })
|
||||
}
|
||||
else {
|
||||
this.$router.push({ name: 'start' })
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch accounts from db
|
||||
*/
|
||||
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' + queryParam).then(response => {
|
||||
response.data.forEach((data) => {
|
||||
accounts.push(data)
|
||||
})
|
||||
|
||||
if ( this.accounts.length > 0 && !objectEquals(accounts, this.accounts, {depth: 1}) && !forceRefresh ) {
|
||||
this.$notify({ type: 'is-dark', text: '<span class="is-size-7">' + this.$t('commons.some_data_have_changed') + '</span><br /><a href="." class="button is-rounded is-warning is-small">' + this.$t('commons.reload') + '</a>', duration:-1, closeOnClick: false })
|
||||
}
|
||||
else if( this.accounts.length === 0 && accounts.length === 0 ) {
|
||||
// No account yet, we force user to land on the start view.
|
||||
this.$storage.set('accounts', this.accounts)
|
||||
this.$router.push({ name: 'start' });
|
||||
}
|
||||
else {
|
||||
this.accounts = accounts
|
||||
this.$storage.set('accounts', this.accounts)
|
||||
this.fetchGroups()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Show account with a generated {{OTP}} rotation
|
||||
*/
|
||||
showAccount(account) {
|
||||
// In Edit mode clicking an account do not show the otpDisplayer but select the account
|
||||
if(this.editMode) {
|
||||
this.selectAccount(account.id)
|
||||
}
|
||||
else {
|
||||
this.$root.showSpinner(this.$t('commons.generating_otp'));
|
||||
this.$refs.OtpDisplayer.show(account.id);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Select an account while in edit mode
|
||||
*/
|
||||
selectAccount(accountId) {
|
||||
for (var i=0 ; i<this.selectedAccounts.length ; i++) {
|
||||
if ( this.selectedAccounts[i] === accountId ) {
|
||||
this.selectedAccounts.splice(i,1);
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedAccounts.push(accountId)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a fresh OTP for the provided account
|
||||
*/
|
||||
getOTP(accountId) {
|
||||
this.axios.get('api/v1/twofaccounts/' + accountId + '/otp').then(response => {
|
||||
this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard')+ ' '+response.data })
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Save the account order in db
|
||||
*/
|
||||
saveOrder() {
|
||||
this.drag = false
|
||||
this.axios.post('/api/v1/twofaccounts/reorder', {orderedIds: this.accounts.map(a => a.id)})
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete accounts selected from the Edit mode
|
||||
*/
|
||||
async destroyAccounts() {
|
||||
if(confirm(this.$t('twofaccounts.confirm.delete'))) {
|
||||
|
||||
let ids = []
|
||||
this.selectedAccounts.forEach(id => ids.push(id))
|
||||
|
||||
let that = this
|
||||
await this.axios.delete('/api/v1/twofaccounts?ids=' + ids.join())
|
||||
.then(response => {
|
||||
ids.forEach(function(id) {
|
||||
that.accounts = that.accounts.filter(a => a.id !== id)
|
||||
})
|
||||
this.$notify({ type: 'is-success', text: this.$t('twofaccounts.accounts_deleted') })
|
||||
})
|
||||
|
||||
// we fetch the accounts again to prevent the js collection being
|
||||
// desynchronize from the backend php collection
|
||||
this.fetchAccounts(true)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Export selected accounts
|
||||
*/
|
||||
exportAccounts() {
|
||||
let ids = []
|
||||
this.selectedAccounts.forEach(id => ids.push(id))
|
||||
|
||||
this.axios.get('/api/v1/twofaccounts/export?ids=' + ids.join(), {responseType: 'blob'})
|
||||
.then((response) => {
|
||||
var blob = new Blob([response.data], {type: "application/json;charset=utf-8"});
|
||||
saveAs.saveAs(blob, "2fauth_export.json");
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Move accounts selected from the Edit mode to another group or withdraw them
|
||||
*/
|
||||
async moveAccounts() {
|
||||
|
||||
let accountsIds = []
|
||||
this.selectedAccounts.forEach(id => accountsIds.push(id))
|
||||
|
||||
// Backend will associate all accounts with the selected group in the same move
|
||||
// or withdraw the accounts if destination is 'no group' (id = 0)
|
||||
if(this.moveAccountsTo === 0) {
|
||||
await this.axios.patch('/api/v1/twofaccounts/withdraw?ids=' + accountsIds.join() )
|
||||
}
|
||||
else await this.axios.post('/api/v1/groups/' + this.moveAccountsTo + '/assign', {ids: accountsIds} )
|
||||
|
||||
// we fetch the accounts again to prevent the js collection being
|
||||
// desynchronize from the backend php collection
|
||||
this.fetchAccounts(true)
|
||||
this.showGroupSelector = false
|
||||
|
||||
this.$notify({ type: 'is-success', text: this.$t('twofaccounts.accounts_moved') })
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the existing group list
|
||||
*/
|
||||
fetchGroups() {
|
||||
let groups = []
|
||||
|
||||
this.axios.get('api/v1/groups').then(response => {
|
||||
response.data.forEach((data) => {
|
||||
groups.push(data)
|
||||
})
|
||||
|
||||
if ( !objectEquals(groups, this.groups) ) {
|
||||
this.groups = groups
|
||||
}
|
||||
|
||||
this.$storage.set('groups', this.groups)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the provided group as the active group
|
||||
*/
|
||||
setActiveGroup(id) {
|
||||
|
||||
// In memomry saving
|
||||
this.form.value = this.$root.userPreferences.activeGroup = id
|
||||
|
||||
// In db saving if the user set 2FAuth to memorize the active group
|
||||
if( this.$root.userPreferences.rememberActiveGroup ) {
|
||||
this.form.put('/api/v1/user/preferences/activeGroup', {returnError: true})
|
||||
.then(response => {
|
||||
// everything's fine
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
this.$router.push({ name: 'genericError', params: { err: error.response } })
|
||||
});
|
||||
}
|
||||
|
||||
this.closeGroupSwitch()
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the group switch visibility
|
||||
*/
|
||||
toggleGroupSwitch: function(event) {
|
||||
|
||||
if (event) {
|
||||
this.showGroupSwitch ? this.closeGroupSwitch() : this.openGroupSwitch()
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* show the group switch which allow to select a group to activate
|
||||
*/
|
||||
openGroupSwitch: function(event) {
|
||||
|
||||
this.showGroupSwitch = true
|
||||
},
|
||||
|
||||
/**
|
||||
* hide the group switch
|
||||
*/
|
||||
closeGroupSwitch: function(event) {
|
||||
|
||||
this.showGroupSwitch = false
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the accounts list between View mode and Edit mode
|
||||
*/
|
||||
setEditModeTo(state) {
|
||||
this.selectedAccounts = []
|
||||
this.editMode = state
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
displayService(service) {
|
||||
return service ? service : this.$t('twofaccounts.no_service')
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
clearSelected() {
|
||||
this.selectedAccounts = []
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
selectAll() {
|
||||
if(this.editMode) {
|
||||
let that = this
|
||||
this.accounts.forEach(function(account) {
|
||||
if ( !that.selectedAccounts.includes(account.id) ) {
|
||||
that.selectedAccounts.push(account.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
sortAsc() {
|
||||
this.accounts.sort((a, b) => a.service > b.service ? 1 : -1)
|
||||
this.saveOrder()
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
sortDesc() {
|
||||
this.accounts.sort((a, b) => a.service < b.service ? 1 : -1)
|
||||
this.saveOrder()
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
keyListener : function(e) {
|
||||
if (e.key === "f" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
const searchBox = document.getElementById('txtSearch');
|
||||
if (searchBox != undefined) {
|
||||
searchBox.focus()
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.flip-list-move {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 1;
|
||||
/*background: hsl(0, 0%, 21%);*/
|
||||
}
|
||||
</style>
|
@ -1,127 +0,0 @@
|
||||
<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">
|
||||
<div class="modal-slot box has-text-centered is-shadowless">
|
||||
<div v-if="errorText">
|
||||
<p class="block is-size-5">{{ $t('twofaccounts.stream.live_scan_cant_start') }}</p>
|
||||
<p class="block" :class="{'has-text-light': $root.showDarkMode}">{{ $t('twofaccounts.stream.' + errorText + '.reason') }}</p>
|
||||
<p class="is-size-7">{{ $t('twofaccounts.stream.' + errorText + '.solution') }}</p>
|
||||
</div>
|
||||
<span v-else class="is-size-4" :class="$root.showDarkMode ? 'has-text-light':'has-text-grey-dark'">
|
||||
<font-awesome-icon :icon="['fas', 'spinner']" size="2x" spin />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="fullscreen-streamer">
|
||||
<qrcode-stream @decode="submitUri" @init="onStreamerInit" camera="auto" />
|
||||
</div>
|
||||
<div class="fullscreen-footer">
|
||||
<!-- Cancel button -->
|
||||
<button id="btnCancel" class="button is-large is-warning is-rounded" @click="exitStream()">
|
||||
{{ $t('commons.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { QrcodeStream } from 'vue-qrcode-reader'
|
||||
import Form from './../components/Form'
|
||||
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
showStream: true,
|
||||
errorText: '',
|
||||
form: new Form({
|
||||
qrcode: null,
|
||||
uri: '',
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
components: {
|
||||
QrcodeStream,
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
exitStream() {
|
||||
|
||||
this.camera = 'off'
|
||||
this.$router.go(-1)
|
||||
|
||||
},
|
||||
|
||||
async onStreamerInit (promise) {
|
||||
|
||||
try {
|
||||
await promise
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
if (error.name === 'NotAllowedError') {
|
||||
this.errorText = 'need_grant_permission'
|
||||
|
||||
} else if (error.name === 'NotReadableError') {
|
||||
this.errorText = 'not_readable'
|
||||
|
||||
} else if (error.name === 'NotFoundError') {
|
||||
this.errorText = 'no_cam_on_device'
|
||||
|
||||
} else if (error.name === 'NotSupportedError' || error.name === 'InsecureContextError') {
|
||||
this.errorText = 'secured_context_required'
|
||||
|
||||
} else if (error.name === 'OverconstrainedError') {
|
||||
this.errorText = 'camera_not_suitable'
|
||||
|
||||
} else if (error.name === 'StreamApiNotSupportedError') {
|
||||
this.errorText = 'stream_api_not_supported'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Push 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.
|
||||
*/
|
||||
async submitUri(event) {
|
||||
|
||||
this.form.uri = event
|
||||
|
||||
if( !this.form.uri ) {
|
||||
this.$notify({type: 'is-warning', text: this.$t('errors.qrcode_cannot_be_read') })
|
||||
}
|
||||
else if( this.form.uri.slice(0, 33).toLowerCase() == "otpauth-migration://offline?data=" ) {
|
||||
this.pushUriToImportForm(this.form.uri)
|
||||
}
|
||||
else if( this.form.uri.slice(0, 15).toLowerCase() !== "otpauth://totp/" && this.form.uri.slice(0, 15).toLowerCase() !== "otpauth://hotp/" ) {
|
||||
this.$notify({type: 'is-warning', text: this.$t('errors.no_valid_otp') })
|
||||
}
|
||||
else {
|
||||
this.pushUriToCreateForm(this.form.uri)
|
||||
}
|
||||
},
|
||||
|
||||
pushUriToCreateForm(data) {
|
||||
this.$router.push({ name: 'createAccount', params: { decodedUri: data } });
|
||||
},
|
||||
|
||||
pushUriToImportForm(data) {
|
||||
this.$router.push({ name: 'importAccounts', params: { migrationUri: data } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
@ -1,102 +1,51 @@
|
||||
<template>
|
||||
<div class="error-message">
|
||||
<modal v-model="ShowModal" :closable="this.showcloseButton">
|
||||
<div class="error-message" v-if="$route.name == '404'">
|
||||
<p class="error-404"></p>
|
||||
<p>{{ $t('errors.resource_not_found') }}</p>
|
||||
<p class=""><router-link :to="{ name: 'accounts' }" class="is-text">{{ $t('errors.refresh') }}</router-link></p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="error-generic"></p>
|
||||
<p>{{ $t('errors.error_occured') }} </p>
|
||||
<p v-if="error.message" class="has-text-grey-lighter">{{ error.message }}</p>
|
||||
<p v-if="error.originalMessage" class="has-text-grey-lighter">{{ error.originalMessage }}</p>
|
||||
<p><router-link :to="{ name: 'accounts', params: { toRefresh: true } }" class="is-text">{{ $t('errors.refresh') }}</router-link></p>
|
||||
<p v-if="debugMode == 'development' && error.debug">
|
||||
<br>
|
||||
{{ error.debug }}
|
||||
</p>
|
||||
</div>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { useNotifyStore } from '@/stores/notify'
|
||||
|
||||
const errorHandler = useNotifyStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const showModal = ref(true)
|
||||
const showDebug = computed(() => process.env.NODE_ENV === 'development')
|
||||
|
||||
<script>
|
||||
import Modal from '../components/Modal'
|
||||
const props = defineProps({
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
ShowModal : true,
|
||||
showcloseButton: this.closable,
|
||||
}
|
||||
},
|
||||
watch(showModal, (val) => {
|
||||
if (val == false) {
|
||||
exit()
|
||||
}
|
||||
})
|
||||
|
||||
computed: {
|
||||
|
||||
debugMode: function() {
|
||||
return process.env.NODE_ENV
|
||||
},
|
||||
|
||||
error: function() {
|
||||
if( this.err === null || this.err === undefined ) {
|
||||
return false
|
||||
}
|
||||
else
|
||||
{
|
||||
if (this.err.status === 407) {
|
||||
return {
|
||||
'message' : this.$t('errors.auth_proxy_failed'),
|
||||
'originalMessage' : this.$t('errors.auth_proxy_failed_legend')
|
||||
}
|
||||
}
|
||||
else if (this.err.status === 403) {
|
||||
return {
|
||||
'message' : this.$t('errors.unauthorized'),
|
||||
'originalMessage' : this.$t('errors.unauthorized_legend')
|
||||
}
|
||||
}
|
||||
else if(this.err.data) {
|
||||
return this.err.data
|
||||
}
|
||||
else {
|
||||
return { 'message' : this.err }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
props: {
|
||||
err: [String, Object], // on object (error.response) or a string
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
components: {
|
||||
Modal
|
||||
},
|
||||
|
||||
mounted(){
|
||||
// stop OTP generation on modal close
|
||||
this.$on('modalClose', function() {
|
||||
window.history.length > 1 && this.$route.name !== '404' ? this.$router.go(-1) : this.$router.push({ name: 'accounts' })
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
beforeRouteEnter(to, from, next) {
|
||||
next(vm => {
|
||||
if (from.params.returnTo) {
|
||||
to.params.returnTo = from.params.returnTo
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Exits the error view
|
||||
*/
|
||||
function exit() {
|
||||
window.history.length > 1 && route.name !== '404' && route.name !== 'notFound'
|
||||
? router.go(-1)
|
||||
: router.push({ name: 'accounts' })
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<modal v-model="showModal" :closable="props.closable">
|
||||
<div class="error-message" v-if="$route.name == '404' || $route.name == 'notFound'">
|
||||
<p class="error-404"></p>
|
||||
<p>{{ $t('errors.resource_not_found') }}</p>
|
||||
</div>
|
||||
<div v-else class="error-message" >
|
||||
<p class="error-generic"></p>
|
||||
<p>{{ $t('errors.error_occured') }} </p>
|
||||
<p v-if="errorHandler.message" class="has-text-grey-lighter">{{ errorHandler.message }}</p>
|
||||
<p v-if="errorHandler.originalMessage" class="has-text-grey-lighter">{{ errorHandler.originalMessage }}</p>
|
||||
<p v-if="showDebug && errorHandler.debug" class="is-size-7 is-family-code"><br>{{ errorHandler.debug }}</p>
|
||||
</div>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
@ -1,127 +0,0 @@
|
||||
<template>
|
||||
<responsive-width-wrapper>
|
||||
<h1 class="title has-text-grey-dark">
|
||||
{{ $t('groups.groups') }}
|
||||
</h1>
|
||||
<div class="is-size-7-mobile">
|
||||
{{ $t('groups.manage_groups_legend')}}
|
||||
</div>
|
||||
<div class="mt-3 mb-6">
|
||||
<router-link class="is-link mt-5" :to="{ name: 'createGroup' }">
|
||||
<font-awesome-icon :icon="['fas', 'plus-circle']" /> {{ $t('groups.create_group') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-if="groups.length > 0">
|
||||
<div v-for="group in groups" :key="group.id" class="group-item is-size-5 is-size-6-mobile">
|
||||
{{ group.name }}
|
||||
<!-- delete icon -->
|
||||
<button class="button tag is-pulled-right" :class="$root.showDarkMode ? 'is-dark' : 'is-white'" @click="deleteGroup(group.id)" :title="$t('commons.delete')">
|
||||
{{ $t('commons.delete') }}
|
||||
</button>
|
||||
<!-- edit link -->
|
||||
<router-link :to="{ name: 'editGroup', params: { groupId: group.id, name: group.name }}" class="has-text-grey px-1" :title="$t('commons.rename')">
|
||||
<font-awesome-icon :icon="['fas', 'pen-square']" />
|
||||
</router-link>
|
||||
<span class="is-family-primary is-size-6 is-size-7-mobile has-text-grey">{{ group.twofaccounts_count }} {{ $t('twofaccounts.accounts') }}</span>
|
||||
</div>
|
||||
<div class="mt-2 is-size-7 is-pulled-right" v-if="groups.length > 0">
|
||||
{{ $t('groups.deleting_group_does_not_delete_accounts')}}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isFetching && groups.length === 0" class="has-text-centered">
|
||||
<span class="is-size-4">
|
||||
<font-awesome-icon :icon="['fas', 'spinner']" spin />
|
||||
</span>
|
||||
</div>
|
||||
<!-- footer -->
|
||||
<vue-footer :showButtons="true">
|
||||
<!-- close button -->
|
||||
<p class="control">
|
||||
<router-link id="btnClose" :to="{ name: 'accounts', params: { toRefresh: true } }" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}">{{ $t('commons.close') }}</router-link>
|
||||
</p>
|
||||
</vue-footer>
|
||||
</responsive-width-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
groups : [],
|
||||
TheAllGroup : null,
|
||||
isFetching: false,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Load groups for localstorage at first to avoid latency
|
||||
const groups = this.$storage.get('groups', null) // use null as fallback if localstorage is empty
|
||||
|
||||
// We don't want the pseudo group 'All' to be managed so we shift it
|
||||
if( groups ) {
|
||||
this.groups = groups
|
||||
this.TheAllGroup = this.groups.shift()
|
||||
}
|
||||
|
||||
// we refresh the collection whatever
|
||||
this.fetchGroups()
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
/**
|
||||
* Get all groups from backend
|
||||
*/
|
||||
async fetchGroups() {
|
||||
|
||||
this.isFetching = true
|
||||
|
||||
await this.axios.get('api/v1/groups').then(response => {
|
||||
const groups = []
|
||||
|
||||
response.data.forEach((data) => {
|
||||
groups.push(data)
|
||||
})
|
||||
|
||||
// Remove the 'All' pseudo group from the collection
|
||||
// and push it the TheAllGroup
|
||||
this.TheAllGroup = groups.shift()
|
||||
|
||||
this.groups = groups
|
||||
})
|
||||
|
||||
this.isFetching = false
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a group (after confirmation)
|
||||
*/
|
||||
async deleteGroup(id) {
|
||||
if(confirm(this.$t('groups.confirm.delete'))) {
|
||||
await this.axios.delete('/api/v1/groups/' + id).then(response => {
|
||||
// Remove the deleted group from the collection
|
||||
this.groups = this.groups.filter(a => a.id !== id)
|
||||
this.$notify({ type: 'is-success', text: this.$t('groups.group_successfully_deleted') })
|
||||
|
||||
// Reset persisted group filter to 'All' (groupId=0)
|
||||
// (backend will save to change automatically)
|
||||
if( parseInt(this.$root.userPreferences.activeGroup) === id ) {
|
||||
this.$root.userPreferences.activeGroup = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
beforeRouteLeave(to, from, next) {
|
||||
// reinject the 'All' pseudo group before refreshing the localstorage
|
||||
this.groups.unshift(this.TheAllGroup)
|
||||
this.$storage.set('groups', this.groups)
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
@ -1,9 +1,70 @@
|
||||
<script setup>
|
||||
import Form from '@/components/formElements/Form'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useBusStore } from '@/stores/bus'
|
||||
import { useNotifyStore } from '@/stores/notify'
|
||||
import { UseColorMode } from '@vueuse/components'
|
||||
import { useTwofaccounts } from '@/stores/twofaccounts'
|
||||
|
||||
const router = useRouter()
|
||||
const user = useUserStore()
|
||||
const bus = useBusStore()
|
||||
const notify = useNotifyStore()
|
||||
const twofaccounts = useTwofaccounts()
|
||||
|
||||
const qrcodeInput = ref(null)
|
||||
const qrcodeInputLabel = ref(null)
|
||||
const form = reactive(new Form({
|
||||
qrcode: null,
|
||||
inputFormat: 'fileUpload',
|
||||
}))
|
||||
|
||||
|
||||
/**
|
||||
* Upload the submitted QR code file to the backend for decoding, then route the user
|
||||
* to the Create or Import form with decoded URI to prefill the form
|
||||
*/
|
||||
function submitQrCode() {
|
||||
form.clear()
|
||||
form.qrcode = qrcodeInput.value.files[0]
|
||||
|
||||
form.upload('/api/v1/qrcode/decode', { returnError: true }).then(response => {
|
||||
if (response.data.data.slice(0, 33).toLowerCase() === "otpauth-migration://offline?data=") {
|
||||
bus.migrationUri = response.data.data
|
||||
router.push({ name: 'importAccounts' })
|
||||
}
|
||||
else {
|
||||
bus.decodedUri = response.data.data
|
||||
router.push({ name: 'createAccount' })
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.response.status !== 422) {
|
||||
notify.alert({ text: error.response.data.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Push user to the dedicated capture view for live scan
|
||||
*/
|
||||
function capture() {
|
||||
router.push({ name: 'capture' });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if( user.preferences.useDirectCapture && user.preferences.defaultCaptureMode === 'upload' ) {
|
||||
qrcodeInputLabel.value.click()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- static landing UI -->
|
||||
<div class="container has-text-centered">
|
||||
<div class="columns quick-uploader">
|
||||
<!-- trailer phrase that invite to add an account -->
|
||||
<div class="column is-full quick-uploader-header" :class="{ 'is-invisible' : accountCount !== 0 }">
|
||||
<div class="column is-full quick-uploader-header" :class="{ 'is-invisible' : twofaccounts.count !== 0 }">
|
||||
{{ $t('twofaccounts.no_account_here') }}<br>
|
||||
{{ $t('twofaccounts.add_first_account') }}
|
||||
</div>
|
||||
@ -11,7 +72,7 @@
|
||||
<div class="column is-full quick-uploader-button" >
|
||||
<div class="quick-uploader-centerer">
|
||||
<!-- upload a qr code (with basic file field and backend decoding) -->
|
||||
<label role="button" tabindex="0" v-if="$root.userPreferences.useBasicQrcodeReader" class="button is-link is-medium is-rounded is-main" ref="qrcodeInputLabel" @keyup.enter="$refs.qrcodeInputLabel.click()">
|
||||
<label role="button" tabindex="0" v-if="user.preferences.useBasicQrcodeReader" class="button is-link is-medium is-rounded is-main" ref="qrcodeInputLabel" @keyup.enter="qrcodeInputLabel.click()">
|
||||
<input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="submitQrCode" ref="qrcodeInput">
|
||||
{{ $t('twofaccounts.forms.upload_qrcode') }}
|
||||
</label>
|
||||
@ -20,132 +81,37 @@
|
||||
{{ $t('twofaccounts.forms.scan_qrcode') }}
|
||||
</button>
|
||||
</div>
|
||||
<FieldError v-if="form.errors.hasAny('qrcode')" :error="form.errors.get('qrcode')" :field="'qrcode'" />
|
||||
</div>
|
||||
<!-- alternative methods -->
|
||||
<div class="column is-full">
|
||||
<div class="block" :class="$root.showDarkMode ? 'has-text-light':'has-text-grey-dark'">{{ $t('twofaccounts.forms.alternative_methods') }}</div>
|
||||
<UseColorMode v-slot="{ mode }">
|
||||
<div class="block" :class="mode == 'dark' ? 'has-text-light':'has-text-grey-dark'">{{ $t('twofaccounts.forms.alternative_methods') }}</div>
|
||||
</UseColorMode>
|
||||
<!-- upload a qr code -->
|
||||
<div class="block has-text-link" v-if="!$root.userPreferences.useBasicQrcodeReader">
|
||||
<label role="button" tabindex="0" class="button is-link is-outlined is-rounded" ref="qrcodeInputLabel" @keyup.enter="$refs.qrcodeInputLabel.click()">
|
||||
<div class="block has-text-link" v-if="!user.preferences.useBasicQrcodeReader">
|
||||
<label role="button" tabindex="0" class="button is-link is-outlined is-rounded" ref="qrcodeInputLabel" @keyup.enter="qrcodeInputLabel.click()">
|
||||
<input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="submitQrCode" ref="qrcodeInput">
|
||||
{{ $t('twofaccounts.forms.upload_qrcode') }}
|
||||
</label>
|
||||
</div>
|
||||
<!-- link to advanced form -->
|
||||
<div v-if="showAdvancedFormButton" class="block has-text-link">
|
||||
<router-link class="button is-link is-outlined is-rounded" :to="{ name: 'createAccount' }" >
|
||||
<div class="block has-text-link">
|
||||
<RouterLink class="button is-link is-outlined is-rounded" :to="{ name: 'createAccount' }" >
|
||||
{{ $t('twofaccounts.forms.use_advanced_form') }}
|
||||
</router-link>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<!-- link to import view -->
|
||||
<div v-if="showImportButton" class="block has-text-link">
|
||||
<router-link id="btnImport" class="button is-link is-outlined is-rounded" :to="{ name: 'importAccounts' }" >
|
||||
<div class="block has-text-link">
|
||||
<RouterLink id="btnImport" class="button is-link is-outlined is-rounded" :to="{ name: 'importAccounts' }" >
|
||||
{{ $t('twofaccounts.import.import') }}
|
||||
</router-link>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<vue-footer :showButtons="true" >
|
||||
<!-- back button -->
|
||||
<p class="control" v-if="accountCount > 0">
|
||||
<router-link id="lnkBack" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" :to="{ name: returnToView }" >
|
||||
{{ $t('commons.back') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</vue-footer>
|
||||
<VueFooter :showButtons="true" >
|
||||
<ButtonBackCloseCancel :returnTo="{ name: 'accounts' }" action="back" v-if="!twofaccounts.isEmpty" />
|
||||
</VueFooter>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
/**
|
||||
* Start view
|
||||
*
|
||||
* route: '/start'
|
||||
*
|
||||
* Offer the user all available possibilities for capturing an account :
|
||||
* - By sending the user to the live scanner
|
||||
* - By decoding a QR code submitted with a form 'File' field
|
||||
* - By sending the user to the advanced form
|
||||
*
|
||||
*/
|
||||
|
||||
import Form from './../components/Form'
|
||||
|
||||
export default {
|
||||
name: 'Start',
|
||||
|
||||
data(){
|
||||
return {
|
||||
accountCount: null,
|
||||
form: new Form(),
|
||||
alternativeMethod: null,
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
showAdvancedFormButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showImportButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
returnToView: {
|
||||
type: String,
|
||||
default: 'accounts'
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
|
||||
this.axios.get('api/v1/twofaccounts/count').then(response => {
|
||||
this.accountCount = response.data.count
|
||||
})
|
||||
},
|
||||
|
||||
created() {
|
||||
|
||||
this.$nextTick(() => {
|
||||
if( this.$root.userPreferences.useDirectCapture && this.$root.userPreferences.defaultCaptureMode === 'upload' ) {
|
||||
this.$refs.qrcodeInputLabel.click()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
/**
|
||||
* Upload the submitted QR code file to the backend for decoding, then route the user
|
||||
* to the Create or Import form with decoded URI to prefill the form
|
||||
*/
|
||||
submitQrCode() {
|
||||
|
||||
let imgdata = new FormData();
|
||||
imgdata.append('qrcode', this.$refs.qrcodeInput.files[0]);
|
||||
imgdata.append('inputFormat', 'fileUpload');
|
||||
|
||||
this.form.upload('/api/v1/qrcode/decode', imgdata, {returnError: true}).then(response => {
|
||||
if( response.data.data.slice(0, 33).toLowerCase() === "otpauth-migration://offline?data=" ) {
|
||||
this.$router.push({ name: 'importAccounts', params: { migrationUri: response.data.data } });
|
||||
}
|
||||
else this.$router.push({ name: 'createAccount', params: { decodedUri: response.data.data } });
|
||||
})
|
||||
.catch(error => {
|
||||
this.$notify({type: 'is-danger', text: this.$t(error.response.data.message) })
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Push user to the dedicated capture view for live scan
|
||||
*/
|
||||
capture() {
|
||||
this.$router.push({ name: 'capture' });
|
||||
},
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<form-wrapper :title="$t('auth.autolock_triggered')" :punchline="$t('auth.autolock_triggered_punchline')">
|
||||
<p>{{ $t('auth.change_autolock_in_settings') }}</p>
|
||||
<div class="nav-links">
|
||||
<p><router-link :to="{ name: 'login', params: {forceRefresh : true} }" class="button is-link">{{ $t('auth.sign_in') }}</router-link></p>
|
||||
</div>
|
||||
<!-- footer -->
|
||||
<vue-footer></vue-footer>
|
||||
</form-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.axios.get('/user/logout', {returnError: true}).catch(error => {
|
||||
// there is nothing to do, we simply catch the error to avoid redondant navigation
|
||||
});
|
||||
|
||||
this.clearStorage()
|
||||
},
|
||||
}
|
||||
</script>
|
@ -1,187 +1,156 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- webauthn authentication -->
|
||||
<form-wrapper v-if="showWebauthn" :title="$t('auth.forms.webauthn_login')" :punchline="$t('auth.welcome_to_2fauth')">
|
||||
<div class="field">
|
||||
{{ $t('auth.webauthn.use_security_device_to_sign_in') }}
|
||||
</div>
|
||||
<form id="frmWebauthnLogin" @submit.prevent="webauthnLogin" @keydown="form.onKeydown($event)">
|
||||
<form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" autofocus />
|
||||
<form-buttons :isBusy="form.isBusy" :caption="$t('commons.continue')" :submitId="'btnContinue'"/>
|
||||
</form>
|
||||
<div class="nav-links">
|
||||
<p>{{ $t('auth.webauthn.lost_your_device') }} <router-link id="lnkRecoverAccount" :to="{ name: 'webauthn.lost' }" class="is-link">{{ $t('auth.webauthn.recover_your_account') }}</router-link></p>
|
||||
<p v-if="!this.$root.userPreferences.useWebauthnOnly">{{ $t('auth.sign_in_using') }}
|
||||
<a id="lnkSignWithLegacy" role="button" class="is-link" @keyup.enter="toggleForm" @click="toggleForm" tabindex="0">{{ $t('auth.login_and_password') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</form-wrapper>
|
||||
<!-- login/password legacy form -->
|
||||
<form-wrapper v-else :title="$t('auth.forms.login')" :punchline="$t('auth.welcome_to_2fauth')">
|
||||
<div v-if="isDemo" class="notification is-info has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_demo_app_use_those_credentials')" />
|
||||
<div v-if="isTesting" class="notification is-warning has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_testing_app_use_those_credentials')" />
|
||||
<form id="frmLegacyLogin" @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
|
||||
<form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" autofocus />
|
||||
<form-password-field :form="form" fieldName="password" :label="$t('auth.forms.password')" />
|
||||
<form-buttons :isBusy="form.isBusy" :caption="$t('auth.sign_in')" :submitId="'btnSignIn'"/>
|
||||
</form>
|
||||
<div class="nav-links">
|
||||
<p>{{ $t('auth.forms.forgot_your_password') }} <router-link id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">{{ $t('auth.forms.request_password_reset') }}</router-link></p>
|
||||
<p >{{ $t('auth.sign_in_using') }}
|
||||
<a id="lnkSignWithWebauthn" role="button" class="is-link" @keyup.enter="toggleForm" @click="toggleForm" tabindex="0" :aria-label="$t('auth.sign_in_using_security_device')">{{ $t('auth.webauthn.security_device') }}</a>
|
||||
</p>
|
||||
<p v-if="this.$root.appSettings.disableRegistration == false" class="mt-4">{{ $t('auth.forms.dont_have_account_yet') }} <router-link id="lnkRegister" :to="{ name: 'register' }" class="is-link">{{ $t('auth.register') }}</router-link></p>
|
||||
</div>
|
||||
</form-wrapper>
|
||||
<!-- footer -->
|
||||
<vue-footer></vue-footer>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Form from '@/components/formElements/Form'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useNotifyStore } from '@/stores/notify'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { webauthnService } from '@/services/webauthn/webauthnService'
|
||||
|
||||
<script>
|
||||
const $2fauth = inject('2fauth')
|
||||
const router = useRouter()
|
||||
const user = useUserStore()
|
||||
const notify = useNotifyStore()
|
||||
const appSettings = useAppSettingsStore()
|
||||
const showWebauthnForm = user.preferences.useWebauthnOnly ? true : useStorage($2fauth.prefix + 'showWebauthnForm', false)
|
||||
const form = reactive(new Form({
|
||||
email: '',
|
||||
password: ''
|
||||
}))
|
||||
const isBusy = ref(false)
|
||||
|
||||
import Form from './../../components/Form'
|
||||
import WebauthnService from './../../webauthn/webauthnService'
|
||||
import { webauthnAbortService } from './../../webauthn/webauthnAbortService'
|
||||
import { identifyAuthenticationError } from './../../webauthn/identifyAuthenticationError'
|
||||
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
isDemo: this.$root.isDemoApp,
|
||||
isTesting: this.$root.isTestingApp,
|
||||
form: new Form({
|
||||
email: '',
|
||||
password: ''
|
||||
}),
|
||||
isBusy: false,
|
||||
showWebauthn: this.$root.userPreferences.useWebauthnOnly,
|
||||
csrfRefresher: null
|
||||
}
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
this.csrfRefresher = setInterval(this.refreshToken, 300000) // 5 min
|
||||
this.showWebauthn = this.$storage.get('showWebauthnForm', false)
|
||||
},
|
||||
|
||||
methods : {
|
||||
/**
|
||||
* Toggle the form between legacy and webauthn method
|
||||
*/
|
||||
toggleForm() {
|
||||
this.showWebauthn = ! this.showWebauthn
|
||||
this.$storage.set('showWebauthnForm', this.showWebauthn)
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in using the login/password form
|
||||
*/
|
||||
handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.form.post('/user/login', {returnError: true})
|
||||
.then(response => {
|
||||
this.$storage.set('authenticated', true)
|
||||
this.applyPreferences(response.data.preferences)
|
||||
this.$router.push({ name: 'accounts', params: { toRefresh: true } })
|
||||
})
|
||||
.catch(error => {
|
||||
this.$storage.set('authenticated', false)
|
||||
|
||||
if( error.response.status === 401 ) {
|
||||
|
||||
this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
|
||||
}
|
||||
else if( error.response.status !== 422 ) {
|
||||
|
||||
this.$router.push({ name: 'genericError', params: { err: error.response } });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in using the WebAuthn API
|
||||
*/
|
||||
async webauthnLogin() {
|
||||
this.isBusy = false
|
||||
let webauthnService = new WebauthnService()
|
||||
|
||||
// Check https context
|
||||
if (!window.isSecureContext) {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') })
|
||||
return false
|
||||
}
|
||||
|
||||
// Check browser support
|
||||
if (webauthnService.doesntSupportWebAuthn) {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
|
||||
return false
|
||||
}
|
||||
|
||||
const loginOptions = await this.form.post('/webauthn/login/options').then(res => res.data)
|
||||
const publicKey = webauthnService.parseIncomingServerOptions(loginOptions)
|
||||
|
||||
let options = { publicKey }
|
||||
options.signal = webauthnAbortService.createNewAbortSignal()
|
||||
|
||||
const credentials = await navigator.credentials.get(options)
|
||||
.catch(error => {
|
||||
const webauthnError = identifyAuthenticationError(error, options)
|
||||
this.$notify({ type: webauthnError.type, text: this.$t(webauthnError.phrase) })
|
||||
})
|
||||
|
||||
if (!credentials) return false
|
||||
|
||||
let publicKeyCredential = webauthnService.parseOutgoingCredentials(credentials)
|
||||
publicKeyCredential.email = this.form.email
|
||||
|
||||
this.axios.post('/webauthn/login', publicKeyCredential, {returnError: true}).then(response => {
|
||||
this.$storage.set('authenticated', true)
|
||||
this.applyPreferences(response.data.preferences);
|
||||
this.$router.push({ name: 'accounts', params: { toRefresh: true } })
|
||||
})
|
||||
.catch(error => {
|
||||
this.$storage.set('authenticated', false)
|
||||
|
||||
if( error.response.status === 401 ) {
|
||||
|
||||
this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
|
||||
}
|
||||
else if( error.response.status !== 422 ) {
|
||||
|
||||
this.$router.push({ name: 'genericError', params: { err: error.response } });
|
||||
}
|
||||
});
|
||||
|
||||
this.isBusy = false
|
||||
},
|
||||
|
||||
refreshToken(){
|
||||
this.axios.get('/refresh-csrf')
|
||||
}
|
||||
},
|
||||
|
||||
beforeRouteEnter (to, from, next) {
|
||||
if (to.params.forceRefresh && from.name !== null) {
|
||||
window.location.href = "." + to.path;
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
},
|
||||
|
||||
beforeRouteLeave (to, from, next) {
|
||||
this.$notify({
|
||||
clean: true
|
||||
})
|
||||
clearInterval(this.csrfRefresher);
|
||||
|
||||
if (this.$root.appSettings.disableRegistration && to.name == 'register') {
|
||||
this.$router.push({name: 'genericError', params: { err: this.$t('errors.unauthorized_legend') } })
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
/**
|
||||
* Toggle the form between legacy and webauthn method
|
||||
*/
|
||||
function toggleForm() {
|
||||
form.clear()
|
||||
showWebauthnForm.value = ! showWebauthnForm.value
|
||||
}
|
||||
</script>
|
||||
|
||||
/**
|
||||
* Sign in using the login/password form
|
||||
*/
|
||||
function LegacysignIn(e) {
|
||||
notify.clear()
|
||||
|
||||
form.post('/user/login', {returnError: true}).then(async (response) => {
|
||||
await user.loginAs({
|
||||
name: response.data.name,
|
||||
email: response.data.email,
|
||||
preferences: response.data.preferences,
|
||||
isAdmin: response.data.is_admin,
|
||||
})
|
||||
|
||||
router.push({ name: 'accounts' })
|
||||
})
|
||||
.catch(error => {
|
||||
if( error.response.status === 401 ) {
|
||||
notify.alert({text: trans('auth.forms.authentication_failed'), duration: 10000 })
|
||||
}
|
||||
else if( error.response.status !== 422 ) {
|
||||
notify.error(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in using webauthn
|
||||
*/
|
||||
function webauthnLogin() {
|
||||
notify.clear()
|
||||
form.clear()
|
||||
isBusy.value = true
|
||||
|
||||
webauthnService.authenticate(form.email).then(async (response) => {
|
||||
await user.loginAs({
|
||||
name: response.data.name,
|
||||
email: response.data.email,
|
||||
preferences: response.data.preferences,
|
||||
isAdmin: response.data.is_admin,
|
||||
})
|
||||
|
||||
router.push({ name: 'accounts' })
|
||||
})
|
||||
.catch(error => {
|
||||
if ('webauthn' in error) {
|
||||
if (error.name == 'is-warning') {
|
||||
notify.warn({ text: trans(error.message) })
|
||||
}
|
||||
else notify.alert({ text: trans(error.message) })
|
||||
}
|
||||
else if( error.response.status === 401 ) {
|
||||
notify.alert({text: trans('auth.forms.authentication_failed'), duration: 10000 })
|
||||
}
|
||||
else if( error.response.status == 422 ) {
|
||||
form.errors.set(form.extractErrors(error.response))
|
||||
}
|
||||
else {
|
||||
notify.error(error)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
isBusy.value = false
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- webauthn authentication -->
|
||||
<FormWrapper v-if="showWebauthnForm" title="auth.forms.webauthn_login" punchline="auth.welcome_to_2fauth">
|
||||
<div class="field">
|
||||
{{ $t('auth.webauthn.use_security_device_to_sign_in') }}
|
||||
</div>
|
||||
<form id="frmWebauthnLogin" @submit.prevent="webauthnLogin" @keydown="form.onKeydown($event)">
|
||||
<FormField v-model="form.email" fieldName="email" :fieldError="form.errors.get('email')" inputType="email" label="auth.forms.email" autofocus />
|
||||
<FormButtons :isBusy="isBusy" caption="commons.continue" submitId="btnContinue"/>
|
||||
</form>
|
||||
<div class="nav-links">
|
||||
<p>
|
||||
{{ $t('auth.webauthn.lost_your_device') }}
|
||||
<RouterLink id="lnkRecoverAccount" :to="{ name: 'webauthn.lost' }" class="is-link">
|
||||
{{ $t('auth.webauthn.recover_your_account') }}
|
||||
</RouterLink>
|
||||
</p>
|
||||
<p v-if="!user.preferences.useWebauthnOnly">{{ $t('auth.sign_in_using') }}
|
||||
<a id="lnkSignWithLegacy" role="button" class="is-link" @keyup.enter="toggleForm" @click="toggleForm" tabindex="0">
|
||||
{{ $t('auth.login_and_password') }}
|
||||
</a>
|
||||
</p>
|
||||
<p v-if="appSettings.disableRegistration == false" class="mt-4">
|
||||
{{ $t('auth.forms.dont_have_account_yet') }}
|
||||
<RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link">
|
||||
{{ $t('auth.register') }}
|
||||
</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</FormWrapper>
|
||||
<!-- login/password legacy form -->
|
||||
<FormWrapper v-else title="auth.forms.login" punchline="auth.welcome_to_2fauth">
|
||||
<div v-if="$2fauth.isDemoApp" class="notification is-info has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_demo_app_use_those_credentials')" />
|
||||
<div v-if="$2fauth.isTestingApp" class="notification is-warning has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_testing_app_use_those_credentials')" />
|
||||
<form id="frmLegacyLogin" @submit.prevent="LegacysignIn" @keydown="form.onKeydown($event)">
|
||||
<FormField v-model="form.email" fieldName="email" :fieldError="form.errors.get('email')" inputType="email" label="auth.forms.email" autofocus />
|
||||
<FormPasswordField v-model="form.password" fieldName="password" :fieldError="form.errors.get('password')" label="auth.forms.password" />
|
||||
<FormButtons :isBusy="form.isBusy" caption="auth.sign_in" submitId="btnSignIn"/>
|
||||
</form>
|
||||
<div class="nav-links">
|
||||
<p>{{ $t('auth.forms.forgot_your_password') }}
|
||||
<RouterLink id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">
|
||||
{{ $t('auth.forms.request_password_reset') }}
|
||||
</RouterLink>
|
||||
</p>
|
||||
<p >{{ $t('auth.sign_in_using') }}
|
||||
<a id="lnkSignWithWebauthn" role="button" class="is-link" @keyup.enter="toggleForm" @click="toggleForm" tabindex="0" :aria-label="$t('auth.sign_in_using_security_device')">
|
||||
{{ $t('auth.webauthn.security_device') }}
|
||||
</a>
|
||||
</p>
|
||||
<p v-if="appSettings.disableRegistration == false" class="mt-4">
|
||||
{{ $t('auth.forms.dont_have_account_yet') }}
|
||||
<RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link">
|
||||
{{ $t('auth.register') }}
|
||||
</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</FormWrapper>
|
||||
<!-- footer -->
|
||||
<VueFooter/>
|
||||
</template>
|
@ -1,12 +1,89 @@
|
||||
<script setup>
|
||||
import Form from '@/components/formElements/Form'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { webauthnService } from '@/services/webauthn/webauthnService'
|
||||
import { useNotifyStore } from '@/stores/notify'
|
||||
|
||||
const user = useUserStore()
|
||||
const notify = useNotifyStore()
|
||||
const router = useRouter()
|
||||
const showWebauthnRegistration = ref(false)
|
||||
const deviceId = ref(null)
|
||||
|
||||
const registerForm = reactive(new Form({
|
||||
name : '',
|
||||
email : '',
|
||||
password : '',
|
||||
password_confirmation : '',
|
||||
}))
|
||||
|
||||
const renameDeviceForm = reactive(new Form({
|
||||
name : ''
|
||||
}))
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
async function register(e) {
|
||||
registerForm.password_confirmation = registerForm.password
|
||||
|
||||
registerForm.post('/user').then(response => {
|
||||
user.$patch({
|
||||
name: response.data.name,
|
||||
email: response.data.email,
|
||||
preferences: response.data.preferences,
|
||||
isAdmin: response.data.is_admin ?? false,
|
||||
})
|
||||
user.applyTheme()
|
||||
|
||||
showWebauthnRegistration.value = true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new security device
|
||||
*/
|
||||
function registerWebauthnDevice() {
|
||||
webauthnService.register().then((response) => {
|
||||
const publicKeyCredential = JSON.parse(response.config.data)
|
||||
|
||||
deviceId.value = publicKeyCredential.id
|
||||
})
|
||||
.catch(error => {
|
||||
if( error.response.status === 422 ) {
|
||||
notify.alert({ text: error.response.data.message })
|
||||
}
|
||||
else {
|
||||
notify.error(error);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the registered device
|
||||
*/
|
||||
function RenameDevice(e) {
|
||||
renameDeviceForm.patch('/webauthn/credentials/' + deviceId.value + '/name')
|
||||
.then(() => {
|
||||
notify.success({ text: trans('auth.webauthn.device_successfully_registered') })
|
||||
router.push({ name: 'accounts' })
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
notify.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- webauthn registration -->
|
||||
<form-wrapper v-if="showWebauthnRegistration" :title="$t('auth.authentication')" :punchline="$t('auth.webauthn.enhance_security_using_webauthn')">
|
||||
<div v-if="deviceRegistered" class="field">
|
||||
<FormWrapper v-if="showWebauthnRegistration" title="auth.authentication" punchline="auth.webauthn.enhance_security_using_webauthn">
|
||||
<div v-if="deviceId" class="field">
|
||||
<label id="lblDeviceRegistrationSuccess" class="label mb-5">{{ $t('auth.webauthn.device_successfully_registered') }} <font-awesome-icon :icon="['fas', 'check']" /></label>
|
||||
<form @submit.prevent="handleDeviceSubmit" @keydown="deviceForm.onKeydown($event)">
|
||||
<form-field :form="deviceForm" fieldName="name" inputType="text" placeholder="iPhone 12, TouchID, Yubikey 5C" :label="$t('auth.forms.name_this_device')" />
|
||||
<form-buttons :isBusy="deviceForm.isBusy" :isDisabled="deviceForm.isDisabled" :caption="$t('commons.continue')" />
|
||||
<form @submit.prevent="RenameDevice" @keydown="renameDeviceForm.onKeydown($event)">
|
||||
<FormField v-model="renameDeviceForm.name" fieldName="name" :fieldError="renameDeviceForm.errors.get('name')" inputType="text" placeholder="iPhone 12, TouchID, Yubikey 5C" label="auth.forms.name_this_device" />
|
||||
<FormButtons :isBusy="renameDeviceForm.isBusy" :isDisabled="renameDeviceForm.isDisabled" caption="commons.continue" />
|
||||
</form>
|
||||
</div>
|
||||
<div v-else class="field is-grouped">
|
||||
@ -16,147 +93,23 @@
|
||||
</div>
|
||||
<!-- dismiss button -->
|
||||
<div class="control">
|
||||
<router-link id="btnMaybeLater" :to="{ name: 'accounts', params: { toRefresh: true } }" class="button is-text">{{ $t('auth.maybe_later') }}</router-link>
|
||||
<RouterLink id="btnMaybeLater" :to="{ name: 'accounts' }" class="button is-text">{{ $t('auth.maybe_later') }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</form-wrapper>
|
||||
</FormWrapper>
|
||||
<!-- User registration form -->
|
||||
<form-wrapper v-else :title="$t('auth.register')" :punchline="$t('auth.forms.register_punchline')">
|
||||
<form @submit.prevent="handleRegisterSubmit" @keydown="registerForm.onKeydown($event)">
|
||||
<form-field :form="registerForm" fieldName="name" inputType="text" :label="$t('auth.forms.name')" :maxLength="255" autofocus />
|
||||
<form-field :form="registerForm" fieldName="email" inputType="email" :label="$t('auth.forms.email')" :maxLength="255" />
|
||||
<form-password-field :form="registerForm" fieldName="password" :showRules="true" :label="$t('auth.forms.password')" />
|
||||
<form-buttons :isBusy="registerForm.isBusy" :isDisabled="registerForm.isDisabled" :caption="$t('auth.register')" :submitId="'btnRegister'" />
|
||||
<FormWrapper v-else title="auth.register" punchline="auth.forms.register_punchline">
|
||||
<form @submit.prevent="register" @keydown="registerForm.onKeydown($event)">
|
||||
<FormField v-model="registerForm.name" fieldName="name" :fieldError="registerForm.errors.get('name')" inputType="text" label="auth.forms.name" :maxLength="255" autofocus />
|
||||
<FormField v-model="registerForm.email" fieldName="email" :fieldError="registerForm.errors.get('email')" inputType="email" label="auth.forms.email" :maxLength="255" />
|
||||
<FormPasswordField v-model="registerForm.password" fieldName="password" :fieldError="registerForm.errors.get('password')" :showRules="true" label="auth.forms.password" />
|
||||
<FormButtons :isBusy="registerForm.isBusy" :isDisabled="registerForm.isDisabled" caption="auth.register" submitId="btnRegister" />
|
||||
</form>
|
||||
<div class="nav-links">
|
||||
<p>{{ $t('auth.forms.already_register') }} <router-link id="lnkSignIn" :to="{ name: 'login' }" class="is-link">{{ $t('auth.sign_in') }}</router-link></p>
|
||||
<p>{{ $t('auth.forms.already_register') }} <RouterLink id="lnkSignIn" :to="{ name: 'login' }" class="is-link">{{ $t('auth.sign_in') }}</RouterLink></p>
|
||||
</div>
|
||||
</form-wrapper>
|
||||
</FormWrapper>
|
||||
<!-- footer -->
|
||||
<vue-footer></vue-footer>
|
||||
<VueFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Form from './../../components/Form'
|
||||
import WebauthnService from './../../webauthn/webauthnService'
|
||||
import { webauthnAbortService } from './../../webauthn/webauthnAbortService'
|
||||
import { identifyRegistrationError } from './../../webauthn/identifyRegistrationError'
|
||||
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
registerForm: new Form({
|
||||
name : '',
|
||||
email : '',
|
||||
password : '',
|
||||
password_confirmation : '',
|
||||
}),
|
||||
deviceForm: new Form({
|
||||
name : '',
|
||||
}),
|
||||
showWebauthnRegistration: false,
|
||||
deviceRegistered: false,
|
||||
deviceId : null
|
||||
}
|
||||
},
|
||||
|
||||
methods : {
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
async handleRegisterSubmit(e) {
|
||||
e.preventDefault()
|
||||
this.registerForm.password_confirmation = this.registerForm.password
|
||||
|
||||
this.registerForm.post('/user', {returnError: true})
|
||||
.then(response => {
|
||||
this.$storage.set('authenticated', true)
|
||||
this.showWebauthnRegistration = true
|
||||
})
|
||||
.catch(error => {
|
||||
if( error.response.status !== 422 ) {
|
||||
|
||||
this.$router.push({ name: 'genericError', params: { err: error.response } });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Register a new security device
|
||||
*/
|
||||
async registerWebauthnDevice() {
|
||||
let webauthnService = new WebauthnService()
|
||||
|
||||
// Check https context
|
||||
if (!window.isSecureContext) {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') })
|
||||
return false
|
||||
}
|
||||
|
||||
// Check browser support
|
||||
if (webauthnService.doesntSupportWebAuthn) {
|
||||
this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
|
||||
return false
|
||||
}
|
||||
|
||||
const registerOptions = await this.axios.post('/webauthn/register/options').then(res => res.data)
|
||||
const publicKey = webauthnService.parseIncomingServerOptions(registerOptions)
|
||||
|
||||
let options = { publicKey }
|
||||
options.signal = webauthnAbortService.createNewAbortSignal()
|
||||
|
||||
let bufferedCredentials
|
||||
try {
|
||||
bufferedCredentials = await navigator.credentials.create(options)
|
||||
}
|
||||
catch (error) {
|
||||
const webauthnError = identifyRegistrationError(error, options)
|
||||
this.$notify({ type: webauthnError.type, text: this.$t(webauthnError.phrase) })
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const publicKeyCredential = webauthnService.parseOutgoingCredentials(bufferedCredentials);
|
||||
|
||||
this.axios.post('/webauthn/register', publicKeyCredential, {returnError: true})
|
||||
.then(response => {
|
||||
this.deviceId = publicKeyCredential.id
|
||||
this.deviceRegistered = true
|
||||
})
|
||||
.catch(error => {
|
||||
if( error.response.status === 422 ) {
|
||||
this.$notify({ type: 'is-danger', text: error.response.data.message })
|
||||
}
|
||||
else {
|
||||
this.$router.push({ name: 'genericError', params: { err: error.response } });
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Rename the registered device
|
||||
*/
|
||||
async handleDeviceSubmit(e) {
|
||||
|
||||
await this.deviceForm.patch('/webauthn/credentials/' + this.deviceId + '/name')
|
||||
|
||||
if( this.deviceForm.errors.any() === false ) {
|
||||
this.$router.push({name: 'accounts', params: { toRefresh: true }})
|
||||
}
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
beforeRouteLeave (to, from, next) {
|
||||
this.$notify({
|
||||
clean: true
|
||||
})
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,60 +0,0 @@
|
||||
<template>
|
||||
<form-wrapper :title="$t('auth.forms.reset_password')" :punchline="$t('auth.forms.reset_punchline')">
|
||||
<form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
|
||||
<form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" autofocus />
|
||||
<form-buttons
|
||||
:submitId="'btnSendResetPwd'"
|
||||
:isBusy="form.isBusy"
|
||||
:caption="$t('auth.forms.send_password_reset_link')"
|
||||
:showCancelButton="true"
|
||||
cancelLandingView="login" />
|
||||
</form>
|
||||
<!-- footer -->
|
||||
<vue-footer></vue-footer>
|
||||
</form-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Form from './../../../components/Form'
|
||||
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
form: new Form({
|
||||
email: '',
|
||||
})
|
||||
}
|
||||
},
|
||||
methods : {
|
||||
handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.form.post('/user/password/lost', {returnError: true})
|
||||
.then(response => {
|
||||
|
||||
this.$notify({ type: 'is-success', text: response.data.message, duration:-1 })
|
||||
})
|
||||
.catch(error => {
|
||||
if( error.response.data.requestFailed ) {
|
||||
|
||||
this.$notify({ type: 'is-danger', text: error.response.data.requestFailed, duration:-1 })
|
||||
}
|
||||
else if( error.response.status !== 422 ) {
|
||||
|
||||
this.$router.push({ name: 'genericError', params: { err: error.response } });
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
beforeRouteLeave (to, from, next) {
|
||||
this.$notify({
|
||||
clean: true
|
||||
})
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,70 +1,62 @@
|
||||
<template>
|
||||
<form-wrapper :title="$t('auth.forms.new_password')">
|
||||
<form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
|
||||
<form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" :isDisabled="true" readonly />
|
||||
<form-password-field :form="form" fieldName="password" :autocomplete="'new-password'" :showRules="true" :label="$t('auth.forms.new_password')" />
|
||||
<field-error :form="form" field="token" />
|
||||
<form-buttons v-if="pending" :isBusy="form.isBusy" :caption="$t('auth.forms.change_password')" :showCancelButton="true" cancelLandingView="login" />
|
||||
<router-link v-if="!pending" id="btnContinue" :to="{ name: 'accounts' }" class="button is-link">{{ $t('commons.continue') }}</router-link>
|
||||
</form>
|
||||
<!-- footer -->
|
||||
<vue-footer></vue-footer>
|
||||
</form-wrapper>
|
||||
</template>
|
||||
<script setup>
|
||||
import Form from '@/components/formElements/Form'
|
||||
import { useNotifyStore } from '@/stores/notify'
|
||||
|
||||
<script>
|
||||
const notify = useNotifyStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const isPending = ref(true)
|
||||
const form = reactive(new Form({
|
||||
email : route.query.email,
|
||||
password : '',
|
||||
password_confirmation : '',
|
||||
token: route.query.token
|
||||
}))
|
||||
|
||||
import Form from './../../../components/Form'
|
||||
/**
|
||||
* Submits the password reset to the backend
|
||||
*/
|
||||
function resetPassword(e) {
|
||||
form.password_confirmation = form.password
|
||||
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
pending: true,
|
||||
form: new Form({
|
||||
email : '',
|
||||
password : '',
|
||||
password_confirmation : '',
|
||||
token: ''
|
||||
})
|
||||
form.post('/user/password/reset', {returnError: true})
|
||||
.then(response => {
|
||||
form.password = ''
|
||||
form.password_confirmation = ''
|
||||
isPending.value = false
|
||||
notify.success({ text: response.data.message, duration:-1 })
|
||||
})
|
||||
.catch(error => {
|
||||
if( error.response.data.resetFailed ) {
|
||||
notify.alert({ text: error.response.data.resetFailed, duration:-1 })
|
||||
}
|
||||
},
|
||||
|
||||
created () {
|
||||
this.form.email = this.$route.query.email
|
||||
this.form.token = this.$route.query.token
|
||||
},
|
||||
|
||||
methods : {
|
||||
handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.form.password_confirmation = this.form.password
|
||||
|
||||
this.form.post('/user/password/reset', {returnError: true})
|
||||
.then(response => {
|
||||
this.pending = false
|
||||
this.$notify({ type: 'is-success', text: response.data.message, duration:-1 })
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
if( error.response.data.resetFailed ) {
|
||||
|
||||
this.$notify({ type: 'is-danger', text: error.response.data.resetFailed, duration:-1 })
|
||||
}
|
||||
else if( error.response.status !== 422 ) {
|
||||
|
||||
this.$router.push({ name: 'genericError', params: { err: error.response } });
|
||||
}
|
||||
});
|
||||
else if( error.response.status !== 422 ) {
|
||||
notify.error(error)
|
||||
}
|
||||
},
|
||||
|
||||
beforeRouteLeave (to, from, next) {
|
||||
this.$notify({
|
||||
clean: true
|
||||
})
|
||||
|
||||
next()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
notify.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormWrapper :title="$t('auth.forms.new_password')">
|
||||
<form @submit.prevent="resetPassword" @keydown="form.onKeydown($event)">
|
||||
<FormField v-model="form.email" :isDisabled="true" fieldName="email" :fieldError="form.errors.get('email')" label="auth.forms.email" autofocus />
|
||||
<FormPasswordField v-model="form.password" fieldName="password" :fieldError="form.errors.get('password')" :autocomplete="'new-password'" :showRules="true" label="auth.forms.new_password" />
|
||||
<FieldError v-if="form.errors.get('token') != undefined" :error="form.errors.get('token')" :field="form.token" />
|
||||
<FormButtons
|
||||
v-if="isPending"
|
||||
:submitId="'btnResetPwd'"
|
||||
:isBusy="form.isBusy"
|
||||
:caption="$t('auth.forms.change_password')"
|
||||
:showCancelButton="true"
|
||||
cancelLandingView="login" />
|
||||
<RouterLink v-if="!isPending" id="btnContinue" :to="{ name: 'accounts' }" class="button is-link">{{ $t('commons.continue') }}</RouterLink>
|
||||
</form>
|
||||
<VueFooter />
|
||||
</FormWrapper>
|
||||
</template>
|
||||
|
@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<form-wrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recovery_punchline')">
|
||||
<form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
|
||||
<form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" autofocus />
|
||||
<form-buttons :isBusy="form.isBusy" :caption="$t('auth.webauthn.send_recovery_link')" :showCancelButton="true" cancelLandingView="login" />
|
||||
</form>
|
||||
<!-- footer -->
|
||||
<vue-footer></vue-footer>
|
||||
</form-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Form from './../../../components/Form'
|
||||
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
form: new Form({
|
||||
email: '',
|
||||
})
|
||||
}
|
||||
},
|
||||
methods : {
|
||||
handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.form.post('/webauthn/lost', {returnError: true})
|
||||
.then(response => {
|
||||
|
||||
this.$notify({ type: 'is-success', text: response.data.message, duration:-1 })
|
||||
})
|
||||
.catch(error => {
|
||||
if( error.response.data.requestFailed ) {
|
||||
|
||||
this.$notify({ type: 'is-danger', text: error.response.data.requestFailed, duration:-1 })
|
||||
}
|
||||
else if( error.response.status !== 422 ) {
|
||||
|
||||
this.$router.push({ name: 'genericError', params: { err: error.response } });
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
beforeRouteLeave (to, from, next) {
|
||||
this.$notify({
|
||||
clean: true
|
||||
})
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,75 +1,71 @@
|
||||
<script setup>
|
||||
import Form from '@/components/formElements/Form'
|
||||
import { useNotifyStore } from '@/stores/notify'
|
||||
|
||||
const $2fauth = inject('2fauth')
|
||||
const notify = useNotifyStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const showWebauthnForm = useStorage($2fauth.prefix + 'showWebauthnForm', false)
|
||||
|
||||
const form = reactive(new Form({
|
||||
email : route.query.email,
|
||||
password : '',
|
||||
token: route.query.token,
|
||||
revokeAll: false,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Submits the recovery to the backend
|
||||
*/
|
||||
function recover(e) {
|
||||
notify.clear()
|
||||
form.post('/webauthn/recover', {returnError: true})
|
||||
.then(response => {
|
||||
showWebauthnForm.value = false
|
||||
router.push({ name: 'login' })
|
||||
})
|
||||
.catch(error => {
|
||||
if ( error.response.status === 401 ) {
|
||||
notify.alert({ text: trans('auth.forms.authentication_failed'), duration:-1 })
|
||||
}
|
||||
else if (error.response.status === 422) {
|
||||
notify.alert({ text: error.response.data.message, duration:-1 })
|
||||
}
|
||||
else {
|
||||
notify.error(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
notify.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form-wrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recover_account_instructions')" >
|
||||
<FormWrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recover_account_instructions')" >
|
||||
<div>
|
||||
<form @submit.prevent="recover" @keydown="form.onKeydown($event)">
|
||||
<form-checkbox :form="form" fieldName="revokeAll" :label="$t('auth.webauthn.disable_all_security_devices')" :help="$t('auth.webauthn.disable_all_security_devices_help')" />
|
||||
<form-password-field :form="form" :autocomplete="'current-password'" fieldName="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" />
|
||||
<FormCheckbox v-model="form.revokeAll" fieldName="revokeAll" label="auth.webauthn.disable_all_security_devices" help="auth.webauthn.disable_all_security_devices_help" />
|
||||
<FormPasswordField v-model="form.password" fieldName="password" :fieldError="form.errors.get('password')" :autocomplete="'current-password'" :showRules="false" label="auth.forms.current_password.label" help="auth.forms.current_password.help" />
|
||||
<div class="field">
|
||||
<p>{{ $t('auth.forms.forgot_your_password') }} <router-link id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">{{ $t('auth.forms.request_password_reset') }}</router-link></p>
|
||||
<p>
|
||||
{{ $t('auth.forms.forgot_your_password') }}
|
||||
<RouterLink id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">
|
||||
{{ $t('auth.forms.request_password_reset') }}
|
||||
</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
<form-buttons :caption="$t('commons.continue')" :cancelLandingView="'login'" :showCancelButton="true" :isBusy="form.isBusy" :isDisabled="form.isDisabled" :submitId="'btnRecover'" />
|
||||
<FormButtons
|
||||
:submitId="'btnRecover'"
|
||||
:isBusy="form.isBusy"
|
||||
:isDisabled="form.isDisabled"
|
||||
:caption="$t('commons.continue')"
|
||||
:showCancelButton="true"
|
||||
cancelLandingView="login" />
|
||||
</form>
|
||||
</div>
|
||||
<!-- footer -->
|
||||
<vue-footer></vue-footer>
|
||||
</form-wrapper>
|
||||
<VueFooter />
|
||||
</FormWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Form from './../../../components/Form'
|
||||
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
currentPassword: '',
|
||||
deviceRegistered: false,
|
||||
deviceId : null,
|
||||
form: new Form({
|
||||
email: '',
|
||||
password: '',
|
||||
token: '',
|
||||
revokeAll: false,
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
created () {
|
||||
this.form.email = this.$route.query.email
|
||||
this.form.token = this.$route.query.token
|
||||
},
|
||||
|
||||
methods : {
|
||||
|
||||
/**
|
||||
* Register a new security device
|
||||
*/
|
||||
recover() {
|
||||
this.form.post('/webauthn/recover', {returnError: true})
|
||||
.then(response => {
|
||||
this.$router.push({ name: 'login', params: { forceRefresh: true } })
|
||||
})
|
||||
.catch(error => {
|
||||
if( error.response.status === 401 ) {
|
||||
|
||||
this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
|
||||
}
|
||||
else if (error.response.status === 422) {
|
||||
this.$notify({ type: 'is-danger', text: error.response.data.message })
|
||||
}
|
||||
else {
|
||||
this.$router.push({ name: 'genericError', params: { err: error.response } });
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
beforeRouteLeave (to, from, next) {
|
||||
this.$notify({
|
||||
clean: true
|
||||
})
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
</script>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user