mirror of
https://github.com/Bubka/2FAuth.git
synced 2025-03-30 18:26:14 +02: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 '/resources/js/assets/app.scss';
|
||||||
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 './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({
|
// Immutable app properties provided by the laravel blade view
|
||||||
el: '#app',
|
const $2fauth = {
|
||||||
data: {
|
prefix: '2fauth_',
|
||||||
appSettings: window.appSettings,
|
config: window.appConfig,
|
||||||
appConfig: window.appConfig,
|
version: window.appVersion,
|
||||||
userPreferences: window.userPreferences,
|
isDemoApp: window.isDemoApp,
|
||||||
isDemoApp: window.isDemoApp,
|
isTestingApp: window.isTestingApp,
|
||||||
isTestingApp: window.isTestingApp,
|
langs: window.appLocales,
|
||||||
prefersDarkScheme: window.matchMedia('(prefers-color-scheme: dark)').matches,
|
}
|
||||||
spinner: {
|
app.provide('2fauth', readonly($2fauth))
|
||||||
active: false,
|
|
||||||
message: 'loading'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
// Stores
|
||||||
showDarkMode: function() {
|
const pinia = createPinia()
|
||||||
return this.userPreferences.theme == 'dark' ||
|
pinia.use(({ store }) => {
|
||||||
(this.userPreferences.theme == 'system' && this.prefersDarkScheme)
|
store.$2fauth = $2fauth;
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
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>
|
<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>
|
<li v-for="n in stepCount" :key="n" :data-is-active="n == activeDot ? true : null"></li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</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>
|
|
@ -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>
|
const props = defineProps({
|
||||||
|
kickAfter: {
|
||||||
export default {
|
type: Number,
|
||||||
name: 'Kicker',
|
required: true
|
||||||
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
events: ['click', 'mousedown', 'scroll', 'keypress', 'load'],
|
|
||||||
logoutTimer: null
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
|
||||||
mounted() {
|
watch(
|
||||||
|
() => props.kickAfter,
|
||||||
this.events.forEach(function (event) {
|
() => {
|
||||||
window.addEventListener(event, this.resetTimer)
|
restartTimer()
|
||||||
}, 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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>
|
<template>
|
||||||
<div v-if="active" class="spinner-container">
|
<div v-if="isVisible">
|
||||||
<div class="spinner-wrapper">
|
<div v-if="type == 'fullscreen'" class="spinner-container">
|
||||||
<span class="is-size-1 spinner">
|
<div class="spinner-wrapper">
|
||||||
<font-awesome-icon :icon="['fas', 'spinner']" spin />
|
<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>
|
||||||
<span>{{ message }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'Spinner',
|
|
||||||
props: {
|
|
||||||
active: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
message: String,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.spinner-container {
|
.spinner-container,
|
||||||
|
.spinner-overlay-container {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
z-index: 10000;
|
z-index: 100000;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -35,6 +53,11 @@ export default {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
.spinner-container,
|
||||||
|
.spinner-overlay-container {
|
||||||
|
top: 25%;
|
||||||
|
height: 50%;
|
||||||
|
}
|
||||||
.spinner {
|
.spinner {
|
||||||
display: block;
|
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>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
|
@ -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>
|
<template>
|
||||||
<div class="columns is-mobile is-vcentered">
|
<div class="columns is-mobile is-vcentered">
|
||||||
<div class="column is-narrow">
|
<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>
|
<button type="button" :class="isScanning ? 'is-loading' : ''" class="button is-link is-rounded is-small" @click="getLatestRelease">Check now</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<span v-if="$root.appSettings.latestRelease" class="mt-2 has-text-warning">
|
<span v-if="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 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>
|
||||||
<span v-if="isUpToDate" class="has-text-grey">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<responsive-width-wrapper>
|
<ResponsiveWidthWrapper>
|
||||||
<h1 class="title has-text-grey-dark">{{ pagetitle }}</h1>
|
<h1 class="title has-text-grey-dark">{{ $t('commons.about') }}</h1>
|
||||||
<p class="block">
|
<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')}}
|
{{ $t('commons.2fauth_teaser')}}
|
||||||
</p>
|
</p>
|
||||||
<img class="about-logo" src="logo.svg" alt="2FAuth logo" />
|
<img class="about-logo" src="logo.svg" alt="2FAuth logo" />
|
||||||
@ -13,138 +57,82 @@
|
|||||||
{{ $t('commons.resources') }}
|
{{ $t('commons.resources') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://github.com/Bubka/2FAuth" target="_blank">
|
<UseColorMode v-slot="{ mode }">
|
||||||
<span class="icon is-small">
|
<a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://github.com/Bubka/2FAuth" target="_blank">
|
||||||
<font-awesome-icon :icon="['fab', 'github-alt']" />
|
<span class="icon is-small">
|
||||||
</span>
|
<FontAwesomeIcon :icon="['fab', 'github-alt']" />
|
||||||
<span>Github</span>
|
</span>
|
||||||
</a>
|
<span>Github</span>
|
||||||
<a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://docs.2fauth.app/" target="_blank">
|
</a>
|
||||||
<span class="icon is-small">
|
<a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://docs.2fauth.app/" target="_blank">
|
||||||
<font-awesome-icon :icon="['fas', 'book']" />
|
<span class="icon is-small">
|
||||||
</span>
|
<FontAwesomeIcon :icon="['fas', 'book']" />
|
||||||
<span>Docs</span>
|
</span>
|
||||||
</a>
|
<span>Docs</span>
|
||||||
<a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://demo.2fauth.app/" target="_blank">
|
</a>
|
||||||
<span class="icon is-small">
|
<a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://demo.2fauth.app/" target="_blank">
|
||||||
<font-awesome-icon :icon="['fas', 'flask']" />
|
<span class="icon is-small">
|
||||||
</span>
|
<FontAwesomeIcon :icon="['fas', 'flask']" />
|
||||||
<span>Demo</span>
|
</span>
|
||||||
</a>
|
<span>Demo</span>
|
||||||
<a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://docs.2fauth.app/resources/rapidoc.html" target="_blank">
|
</a>
|
||||||
<span class="icon is-small">
|
<a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://docs.2fauth.app/resources/rapidoc.html" target="_blank">
|
||||||
<font-awesome-icon :icon="['fas', 'code']" />
|
<span class="icon is-small">
|
||||||
</span>
|
<FontAwesomeIcon :icon="['fas', 'code']" />
|
||||||
<span>API</span>
|
</span>
|
||||||
</a>
|
<span>API</span>
|
||||||
|
</a>
|
||||||
|
</UseColorMode>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="title is-5 has-text-grey-light">
|
<h2 class="title is-5 has-text-grey-light">
|
||||||
{{ $t('commons.credits') }}
|
{{ $t('commons.credits') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="block">
|
<p class="block">
|
||||||
<ul>
|
<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.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.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.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>
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
<h2 class="title is-5 has-text-grey-light">
|
<h2 class="title is-5 has-text-grey-light">
|
||||||
{{ $t('commons.environment') }}
|
{{ $t('commons.environment') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="about-debug box is-family-monospace is-size-7">
|
<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" v-clipboard="() => this.$refs.listInfos.innerText" v-clipboard:success="clipboardSuccessHandler">
|
<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)">
|
||||||
<font-awesome-icon :icon="['fas', 'copy']" />
|
<FontAwesomeIcon :icon="['fas', 'copy']" />
|
||||||
</button>
|
</button>
|
||||||
<ul ref="listInfos" id="listInfos">
|
<ul ref="listInfos" id="listInfos">
|
||||||
<li v-for="(value, key) in infos" :value="value" :key="key"><b>{{key}}</b>: {{value}}</li>
|
<li v-for="(value, key) in infos" :value="value" :key="key"><b>{{key}}</b>: {{value}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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') }}
|
{{ $t('settings.admin_settings') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div v-if="showAdminSettings" class="about-debug box is-family-monospace is-size-7">
|
<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" v-clipboard="() => this.$refs.listAdminSettings.innerText" v-clipboard:success="clipboardSuccessHandler">
|
<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)">
|
||||||
<font-awesome-icon :icon="['fas', 'copy']" />
|
<FontAwesomeIcon :icon="['fas', 'copy']" />
|
||||||
</button>
|
</button>
|
||||||
<ul ref="listAdminSettings" id="listAdminSettings">
|
<ul ref="listAdminSettings" id="listAdminSettings">
|
||||||
<li v-for="(value, setting) in adminSettings" :value="value" :key="setting"><b>{{setting}}</b>: {{value}}</li>
|
<li v-for="(value, setting) in adminSettings" :value="value" :key="setting"><b>{{setting}}</b>: {{value}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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') }}
|
{{ $t('settings.user_preferences') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div v-if="showUserPreferences" class="about-debug box is-family-monospace is-size-7">
|
<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" v-clipboard="() => this.$refs.listUserPreferences.innerText" v-clipboard:success="clipboardSuccessHandler">
|
<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)">
|
||||||
<font-awesome-icon :icon="['fas', 'copy']" />
|
<FontAwesomeIcon :icon="['fas', 'copy']" />
|
||||||
</button>
|
</button>
|
||||||
<ul ref="listUserPreferences" id="listUserPreferences">
|
<ul ref="listUserPreferences" id="listUserPreferences">
|
||||||
<li v-for="(value, preference) in userPreferences" :value="value" :key="preference"><b>{{preference}}</b>: {{value}}</li>
|
<li v-for="(value, preference) in userPreferences" :value="value" :key="preference"><b>{{preference}}</b>: {{value}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<!-- footer -->
|
<!-- footer -->
|
||||||
<vue-footer :showButtons="true">
|
<VueFooter :showButtons="true">
|
||||||
<!-- close button -->
|
<ButtonBackCloseCancel :returnTo="{ path: returnTo }" action="back" />
|
||||||
<p class="control">
|
</VueFooter>
|
||||||
<router-link
|
</ResponsiveWidthWrapper>
|
||||||
id="lnkBack"
|
</template>
|
||||||
: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>
|
|
@ -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>
|
<script setup>
|
||||||
<div class="error-message">
|
import { useNotifyStore } from '@/stores/notify'
|
||||||
<modal v-model="ShowModal" :closable="this.showcloseButton">
|
|
||||||
<div class="error-message" v-if="$route.name == '404'">
|
const errorHandler = useNotifyStore()
|
||||||
<p class="error-404"></p>
|
const router = useRouter()
|
||||||
<p>{{ $t('errors.resource_not_found') }}</p>
|
const route = useRoute()
|
||||||
<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>
|
|
||||||
|
|
||||||
|
const showModal = ref(true)
|
||||||
|
const showDebug = computed(() => process.env.NODE_ENV === 'development')
|
||||||
|
|
||||||
<script>
|
const props = defineProps({
|
||||||
import Modal from '../components/Modal'
|
closable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default {
|
watch(showModal, (val) => {
|
||||||
data(){
|
if (val == false) {
|
||||||
return {
|
exit()
|
||||||
ShowModal : true,
|
}
|
||||||
showcloseButton: this.closable,
|
})
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
/**
|
||||||
|
* Exits the error view
|
||||||
debugMode: function() {
|
*/
|
||||||
return process.env.NODE_ENV
|
function exit() {
|
||||||
},
|
window.history.length > 1 && route.name !== '404' && route.name !== 'notFound'
|
||||||
|
? router.go(-1)
|
||||||
error: function() {
|
: router.push({ name: 'accounts' })
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</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>
|
<template>
|
||||||
<!-- static landing UI -->
|
<!-- static landing UI -->
|
||||||
<div class="container has-text-centered">
|
<div class="container has-text-centered">
|
||||||
<div class="columns quick-uploader">
|
<div class="columns quick-uploader">
|
||||||
<!-- trailer phrase that invite to add an account -->
|
<!-- 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.no_account_here') }}<br>
|
||||||
{{ $t('twofaccounts.add_first_account') }}
|
{{ $t('twofaccounts.add_first_account') }}
|
||||||
</div>
|
</div>
|
||||||
@ -11,7 +72,7 @@
|
|||||||
<div class="column is-full quick-uploader-button" >
|
<div class="column is-full quick-uploader-button" >
|
||||||
<div class="quick-uploader-centerer">
|
<div class="quick-uploader-centerer">
|
||||||
<!-- upload a qr code (with basic file field and backend decoding) -->
|
<!-- 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">
|
<input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="submitQrCode" ref="qrcodeInput">
|
||||||
{{ $t('twofaccounts.forms.upload_qrcode') }}
|
{{ $t('twofaccounts.forms.upload_qrcode') }}
|
||||||
</label>
|
</label>
|
||||||
@ -20,132 +81,37 @@
|
|||||||
{{ $t('twofaccounts.forms.scan_qrcode') }}
|
{{ $t('twofaccounts.forms.scan_qrcode') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<FieldError v-if="form.errors.hasAny('qrcode')" :error="form.errors.get('qrcode')" :field="'qrcode'" />
|
||||||
</div>
|
</div>
|
||||||
<!-- alternative methods -->
|
<!-- alternative methods -->
|
||||||
<div class="column is-full">
|
<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 -->
|
<!-- upload a qr code -->
|
||||||
<div class="block has-text-link" v-if="!$root.userPreferences.useBasicQrcodeReader">
|
<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="$refs.qrcodeInputLabel.click()">
|
<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">
|
<input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="submitQrCode" ref="qrcodeInput">
|
||||||
{{ $t('twofaccounts.forms.upload_qrcode') }}
|
{{ $t('twofaccounts.forms.upload_qrcode') }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<!-- link to advanced form -->
|
<!-- link to advanced form -->
|
||||||
<div v-if="showAdvancedFormButton" class="block has-text-link">
|
<div class="block has-text-link">
|
||||||
<router-link class="button is-link is-outlined is-rounded" :to="{ name: 'createAccount' }" >
|
<RouterLink class="button is-link is-outlined is-rounded" :to="{ name: 'createAccount' }" >
|
||||||
{{ $t('twofaccounts.forms.use_advanced_form') }}
|
{{ $t('twofaccounts.forms.use_advanced_form') }}
|
||||||
</router-link>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<!-- link to import view -->
|
<!-- link to import view -->
|
||||||
<div v-if="showImportButton" class="block has-text-link">
|
<div class="block has-text-link">
|
||||||
<router-link id="btnImport" class="button is-link is-outlined is-rounded" :to="{ name: 'importAccounts' }" >
|
<RouterLink id="btnImport" class="button is-link is-outlined is-rounded" :to="{ name: 'importAccounts' }" >
|
||||||
{{ $t('twofaccounts.import.import') }}
|
{{ $t('twofaccounts.import.import') }}
|
||||||
</router-link>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<vue-footer :showButtons="true" >
|
<VueFooter :showButtons="true" >
|
||||||
<!-- back button -->
|
<ButtonBackCloseCancel :returnTo="{ name: 'accounts' }" action="back" v-if="!twofaccounts.isEmpty" />
|
||||||
<p class="control" v-if="accountCount > 0">
|
</VueFooter>
|
||||||
<router-link id="lnkBack" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" :to="{ name: returnToView }" >
|
|
||||||
{{ $t('commons.back') }}
|
|
||||||
</router-link>
|
|
||||||
</p>
|
|
||||||
</vue-footer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<script setup>
|
||||||
<div>
|
import Form from '@/components/formElements/Form'
|
||||||
<!-- webauthn authentication -->
|
import { useUserStore } from '@/stores/user'
|
||||||
<form-wrapper v-if="showWebauthn" :title="$t('auth.forms.webauthn_login')" :punchline="$t('auth.welcome_to_2fauth')">
|
import { useNotifyStore } from '@/stores/notify'
|
||||||
<div class="field">
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
{{ $t('auth.webauthn.use_security_device_to_sign_in') }}
|
import { webauthnService } from '@/services/webauthn/webauthnService'
|
||||||
</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>
|
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'
|
* Toggle the form between legacy and webauthn method
|
||||||
import { webauthnAbortService } from './../../webauthn/webauthnAbortService'
|
*/
|
||||||
import { identifyAuthenticationError } from './../../webauthn/identifyAuthenticationError'
|
function toggleForm() {
|
||||||
|
form.clear()
|
||||||
export default {
|
showWebauthnForm.value = ! showWebauthnForm.value
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</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>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- webauthn registration -->
|
<!-- webauthn registration -->
|
||||||
<form-wrapper v-if="showWebauthnRegistration" :title="$t('auth.authentication')" :punchline="$t('auth.webauthn.enhance_security_using_webauthn')">
|
<FormWrapper v-if="showWebauthnRegistration" title="auth.authentication" punchline="auth.webauthn.enhance_security_using_webauthn">
|
||||||
<div v-if="deviceRegistered" class="field">
|
<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>
|
<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 @submit.prevent="RenameDevice" @keydown="renameDeviceForm.onKeydown($event)">
|
||||||
<form-field :form="deviceForm" fieldName="name" inputType="text" placeholder="iPhone 12, TouchID, Yubikey 5C" :label="$t('auth.forms.name_this_device')" />
|
<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" />
|
||||||
<form-buttons :isBusy="deviceForm.isBusy" :isDisabled="deviceForm.isDisabled" :caption="$t('commons.continue')" />
|
<FormButtons :isBusy="renameDeviceForm.isBusy" :isDisabled="renameDeviceForm.isDisabled" caption="commons.continue" />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="field is-grouped">
|
<div v-else class="field is-grouped">
|
||||||
@ -16,147 +93,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- dismiss button -->
|
<!-- dismiss button -->
|
||||||
<div class="control">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</form-wrapper>
|
</FormWrapper>
|
||||||
<!-- User registration form -->
|
<!-- User registration form -->
|
||||||
<form-wrapper v-else :title="$t('auth.register')" :punchline="$t('auth.forms.register_punchline')">
|
<FormWrapper v-else title="auth.register" punchline="auth.forms.register_punchline">
|
||||||
<form @submit.prevent="handleRegisterSubmit" @keydown="registerForm.onKeydown($event)">
|
<form @submit.prevent="register" @keydown="registerForm.onKeydown($event)">
|
||||||
<form-field :form="registerForm" fieldName="name" inputType="text" :label="$t('auth.forms.name')" :maxLength="255" autofocus />
|
<FormField v-model="registerForm.name" fieldName="name" :fieldError="registerForm.errors.get('name')" inputType="text" label="auth.forms.name" :maxLength="255" autofocus />
|
||||||
<form-field :form="registerForm" fieldName="email" inputType="email" :label="$t('auth.forms.email')" :maxLength="255" />
|
<FormField v-model="registerForm.email" fieldName="email" :fieldError="registerForm.errors.get('email')" inputType="email" label="auth.forms.email" :maxLength="255" />
|
||||||
<form-password-field :form="registerForm" fieldName="password" :showRules="true" :label="$t('auth.forms.password')" />
|
<FormPasswordField v-model="registerForm.password" fieldName="password" :fieldError="registerForm.errors.get('password')" :showRules="true" label="auth.forms.password" />
|
||||||
<form-buttons :isBusy="registerForm.isBusy" :isDisabled="registerForm.isDisabled" :caption="$t('auth.register')" :submitId="'btnRegister'" />
|
<FormButtons :isBusy="registerForm.isBusy" :isDisabled="registerForm.isDisabled" caption="auth.register" submitId="btnRegister" />
|
||||||
</form>
|
</form>
|
||||||
<div class="nav-links">
|
<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>
|
</div>
|
||||||
</form-wrapper>
|
</FormWrapper>
|
||||||
<!-- footer -->
|
<!-- footer -->
|
||||||
<vue-footer></vue-footer>
|
<VueFooter />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<script setup>
|
||||||
<form-wrapper :title="$t('auth.forms.new_password')">
|
import Form from '@/components/formElements/Form'
|
||||||
<form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
|
import { useNotifyStore } from '@/stores/notify'
|
||||||
<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>
|
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 {
|
form.post('/user/password/reset', {returnError: true})
|
||||||
data(){
|
.then(response => {
|
||||||
return {
|
form.password = ''
|
||||||
pending: true,
|
form.password_confirmation = ''
|
||||||
form: new Form({
|
isPending.value = false
|
||||||
email : '',
|
notify.success({ text: response.data.message, duration:-1 })
|
||||||
password : '',
|
})
|
||||||
password_confirmation : '',
|
.catch(error => {
|
||||||
token: ''
|
if( error.response.data.resetFailed ) {
|
||||||
})
|
notify.alert({ text: error.response.data.resetFailed, duration:-1 })
|
||||||
}
|
}
|
||||||
},
|
else if( error.response.status !== 422 ) {
|
||||||
|
notify.error(error)
|
||||||
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 } });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
|
|
||||||
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>
|
<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>
|
<div>
|
||||||
<form @submit.prevent="recover" @keydown="form.onKeydown($event)">
|
<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')" />
|
<FormCheckbox v-model="form.revokeAll" fieldName="revokeAll" label="auth.webauthn.disable_all_security_devices" help="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')" />
|
<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">
|
<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>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<!-- footer -->
|
<VueFooter />
|
||||||
<vue-footer></vue-footer>
|
</FormWrapper>
|
||||||
</form-wrapper>
|
|
||||||
</template>
|
</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