Enable the Vue 3 front-end

This commit is contained in:
Bubka 2023-12-01 15:29:26 +01:00
parent ffde1723d4
commit 9efb54adf4
146 changed files with 2000 additions and 9387 deletions

58
resources/js/api.js vendored
View File

@ -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
View File

@ -1,65 +1,95 @@
import Vue from 'vue'
import mixins from './mixins'
import VueStorage from './packages/vue-storage'
import i18n from './langs/i18n'
import router from './routes'
import api from './api'
import FontAwesome from './packages/fontawesome'
import Clipboard from './packages/clipboard'
import Notifications from 'vue-notification'
import '/resources/js/assets/app.scss';
import './components'
import Notifications from '@kyvg/vue3-notification'
import App from './App.vue'
import router from './router'
import FontAwesomeIcon from './icons'
// import helpers from './helpers'
Vue.use(Notifications)
const app = createApp(App)
const app = new Vue({
el: '#app',
data: {
appSettings: window.appSettings,
appConfig: window.appConfig,
userPreferences: window.userPreferences,
isDemoApp: window.isDemoApp,
isTestingApp: window.isTestingApp,
prefersDarkScheme: window.matchMedia('(prefers-color-scheme: dark)').matches,
spinner: {
active: false,
message: 'loading'
},
},
// Immutable app properties provided by the laravel blade view
const $2fauth = {
prefix: '2fauth_',
config: window.appConfig,
version: window.appVersion,
isDemoApp: window.isDemoApp,
isTestingApp: window.isTestingApp,
langs: window.appLocales,
}
app.provide('2fauth', readonly($2fauth))
computed: {
showDarkMode: function() {
return this.userPreferences.theme == 'dark' ||
(this.userPreferences.theme == 'system' && this.prefersDarkScheme)
}
},
mounted () {
this.mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)')
this.$nextTick(() => {
this.mediaQueryList.addEventListener('change', this.setDarkScheme)
})
},
beforeDestroy () {
this.mediaQueryList.removeEventListener('change', this.setDarkScheme)
},
methods: {
setDarkScheme ({ matches }) {
this.prefersDarkScheme = matches
},
showSpinner(message) {
this.spinner.message = message;
this.spinner.active = true;
},
hideSpinner() {
this.spinner.active = false;
this.spinner.message = 'loading';
}
},
i18n,
router,
// Stores
const pinia = createPinia()
pinia.use(({ store }) => {
store.$2fauth = $2fauth;
});
app.use(pinia)
// Router
app.use(router)
// Localization
app.use(i18nVue, {
lang: document.documentElement.lang.substring(0, 2),
resolve: async lang => {
const langs = import.meta.glob('../lang/*.json');
if (lang.includes('php_')) {
return await langs[`../lang/${lang}.json`]();
}
}
})
// Notifications
app.use(Notifications)
// Global components registration
import ResponsiveWidthWrapper from '@/layouts/ResponsiveWidthWrapper.vue'
import FormWrapper from '@/layouts/FormWrapper.vue'
import Footer from '@/layouts/Footer.vue'
import Modal from '@/layouts/Modal.vue'
import VueButton from '@/components/formElements/Button.vue'
import ButtonBackCloseCancel from '@/components/formElements/ButtonBackCloseCancel.vue'
import FieldError from '@/components/formElements/FieldError.vue'
import FormField from '@/components/formElements/FormField.vue'
import FormPasswordField from '@/components/formElements/FormPasswordField.vue'
import FormSelect from '@/components/formElements/FormSelect.vue'
import FormToggle from '@/components/formElements/FormToggle.vue'
import FormCheckbox from '@/components/formElements/FormCheckbox.vue'
import FormButtons from '@/components/formElements/FormButtons.vue'
import Kicker from '@/components/Kicker.vue'
app
.component('FontAwesomeIcon', FontAwesomeIcon)
.component('ResponsiveWidthWrapper', ResponsiveWidthWrapper)
.component('FormWrapper', FormWrapper)
.component('VueFooter', Footer)
.component('Modal', Modal)
.component('VueButton', VueButton)
.component('ButtonBackCloseCancel', ButtonBackCloseCancel)
.component('FieldError', FieldError)
.component('FormField', FormField)
.component('FormPasswordField', FormPasswordField)
.component('FormSelect', FormSelect)
.component('FormToggle', FormToggle)
.component('FormCheckbox', FormCheckbox)
.component('FormButtons', FormButtons)
.component('Kicker', Kicker)
// Global error handling
// import { useNotifyStore } from '@/stores/notify'
// if (process.env.NODE_ENV != 'development') {
// app.config.errorHandler = (err) => {
// useNotifyStore().error(err)
// }
// }
// Helpers
// app.config.globalProperties.$helpers = helpers
// App mounting
app.mount('#app')
// Theme
import { useUserStore } from '@/stores/user'
useUserStore().applyUserPrefs()

View File

@ -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>

View File

@ -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>

View File

@ -1,44 +1,54 @@
<script setup>
const props = defineProps({
stepCount: {
type: Number,
default: 10
},
initialIndex: {
type: Number,
default: null
},
period: { // Used only to identify the dots component in Accounts.vue
type: Number,
default: null
},
})
const activeDot = ref(0)
const isOff = computed(() => {
return activeDot.value == -1
})
/**
* Turns On dots
*/
function turnOn(index) {
activeDot.value = index < props.stepCount ? index + 1 : 1
}
/**
* Turns Off all dots
*/
function turnOff() {
activeDot.value = -1
}
onMounted(() => {
if (! isNaN(props.initialIndex)) {
turnOn(props.initialIndex)
}
})
defineExpose({
turnOn,
turnOff,
props
})
</script>
<template>
<ul class="dots">
<ul class="dots" :class="{'off' : isOff}">
<li v-for="n in stepCount" :key="n" :data-is-active="n == activeDot ? true : null"></li>
</ul>
</template>
<script>
export default {
name: 'Dots',
data() {
return {
activeDot: 0
}
},
mounted() {
if (this.initialIndex != null) {
this.turnOn(this.initialIndex)
}
},
props: {
stepCount: {
type: Number,
default: 10
},
initialIndex: {
type: Number,
default: null
},
},
methods: {
/**
*
*/
turnOn: function(index) {
this.activeDot = index < 10 ? index + 1 : 1
},
},
}
</script>
</template>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,58 +1,70 @@
<template>
<script setup>
import { useUserStore } from '@/stores/user'
</template>
const user = useUserStore()
const events = ref(['mousedown', 'scroll', 'keypress'])
const logoutTimer = ref(null)
// const elapsed = ref(0)
// const counter = ref(null)
<script>
export default {
name: 'Kicker',
data: function () {
return {
events: ['click', 'mousedown', 'scroll', 'keypress', 'load'],
logoutTimer: null
}
const props = defineProps({
kickAfter: {
type: Number,
required: true
},
})
mounted() {
this.events.forEach(function (event) {
window.addEventListener(event, this.resetTimer)
}, this);
this.setTimer()
},
destroyed() {
this.events.forEach(function (event) {
window.removeEventListener(event, this.resetTimer)
}, this);
clearTimeout(this.logoutTimer)
},
methods: {
setTimer: function() {
this.logoutTimer = setTimeout(this.logoutUser, this.$root.userPreferences.kickUserAfter * 60 * 1000)
},
logoutUser: function() {
clearTimeout(this.logoutTimer)
this.$router.push({ name: 'autolock' })
},
resetTimer: function() {
clearTimeout(this.logoutTimer)
this.setTimer()
}
watch(
() => props.kickAfter,
() => {
restartTimer()
}
)
onMounted(() => {
events.value.forEach(function (event) {
window.addEventListener(event, restartTimer)
}, this)
startTimer()
})
onUnmounted(() => {
events.value.forEach(function (event) {
window.removeEventListener(event, restartTimer)
}, this)
stopTimer()
})
function startTimer() {
logoutTimer.value = setTimeout(logoutUser, props.kickAfter * 60 * 1000)
// counter.value = setInterval(() => {
// elapsed.value += 1
// console.log(elapsed.value + '/' + props.kickAfter * 60)
// }, 1000)
}
</script>
// Triggers the user logout
function logoutUser() {
clearTimeout(logoutTimer.value)
user.logout({ kicked: true})
}
// Restarts the timer
function restartTimer() {
stopTimer()
startTimer()
}
// Stops the timer
function stopTimer() {
clearTimeout(logoutTimer.value)
// elapsed.value = 0
// clearInterval(counter.value)
}
</script>
<template>
</template>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,31 +1,49 @@
<script setup>
const props = defineProps({
isVisible: Boolean,
type: {
type: String,
default: 'inline'
},
message: {
type: String,
default: 'commons.generating_otp'
}
})
</script>
<template>
<div v-if="active" class="spinner-container">
<div class="spinner-wrapper">
<span class="is-size-1 spinner">
<font-awesome-icon :icon="['fas', 'spinner']" spin />
<div v-if="isVisible">
<div v-if="type == 'fullscreen'" class="spinner-container">
<div class="spinner-wrapper">
<span id="icnSpinnerFull" class="is-size-1 spinner">
<FontAwesomeIcon :icon="['fas', 'spinner']" spin />
</span>
<span>{{ $t(message) }}</span>
</div>
</div>
<div v-if="type == 'fullscreen-overlay'" class="spinner-overlay-container">
<div class="spinner-wrapper">
<span id="icnSpinnerFull" class="is-size-1 spinner">
<FontAwesomeIcon :icon="['fas', 'spinner']" spin />
</span>
<span>{{ $t(message) }}</span>
</div>
</div>
<FontAwesomeIcon v-else-if="type == 'raw'" :icon="['fas', 'spinner']" spin />
<div v-else class="has-text-centered mt-6">
<span id="icnSpinner" class="is-size-4">
<FontAwesomeIcon :icon="['fas', 'spinner']" spin />
</span>
<span>{{ message }}</span>
</div>
</div>
</template>
<script>
export default {
name: 'Spinner',
props: {
active: {
type: Boolean,
default: false
},
message: String,
}
}
</script>
<style scoped>
.spinner-container {
.spinner-container,
.spinner-overlay-container {
text-align: center;
z-index: 10000;
z-index: 100000;
position: absolute;
top: 0;
left: 0;
@ -35,6 +53,11 @@ export default {
align-items: center;
justify-content: center;
}
.spinner-container,
.spinner-overlay-container {
top: 25%;
height: 50%;
}
.spinner {
display: block;
}

View File

@ -1,126 +1,122 @@
<script setup>
const props = defineProps({
step_count: {
type: Number,
default: 10
},
period : Number,
generated_at: Number,
autostart: {
type: Boolean,
default: true
},
})
const generatedAt = ref(null)
const remainingTimeout = ref(null)
const initialStepToNextStepTimeout = ref(null)
const stepToStepInterval = ref(null)
const stepIndex = ref(null)
// |<----period p----->|
// | | |
// |------- ··· ------------|--------|----------|---------->
// | | | |
// unix T0 Tp.start Tgen_at Tp.end
// | | |
// elapsedTimeInCurrentPeriod--|<------>| |
// (in ms) | | |
// | |
// | | || |
// | | |<-------->|--remainingTimeBeforeEndOfPeriod (for remainingTimeout)
// durationBetweenTwoSteps-->|-|< ||
// (for stepToStepInterval) | | >||<---durationFromInitialToNextStep (for initialStepToNextStepTimeout)
// |
// |
// stepIndex
const elapsedTimeInCurrentPeriod = computed(() => {
return generatedAt.value % props.period
})
const remainingTimeBeforeEndOfPeriod = computed(() => {
return props.period - elapsedTimeInCurrentPeriod.value
})
const durationBetweenTwoSteps = computed(() => {
return props.period / props.step_count
})
const initialStepIndex = computed(() => {
let relativePosition = (elapsedTimeInCurrentPeriod.value * props.step_count) / props.period
return (Math.floor(relativePosition) + 0)
})
const emit = defineEmits(['loop-started', 'loop-ended', 'stepped-up'])
/**
* Starts looping
*/
const startLoop = (generated_at = null) => {
clearLooper()
generatedAt.value = generated_at != null ? generated_at : props.generated_at
emit('loop-started', initialStepIndex.value)
stepIndex.value = initialStepIndex.value
// Main timeout that runs until the end of the period
remainingTimeout.value = setTimeout(function() {
clearLooper()
emit('loop-ended')
}, remainingTimeBeforeEndOfPeriod.value * 1000);
// During the remainingTimeout countdown we emit an event every durationBetweenTwoSteps seconds,
// except for the first next dot
let durationFromInitialToNextStep = (Math.ceil(elapsedTimeInCurrentPeriod.value / durationBetweenTwoSteps.value) * durationBetweenTwoSteps.value) - elapsedTimeInCurrentPeriod.value
initialStepToNextStepTimeout.value = setTimeout(function() {
if( durationFromInitialToNextStep > 0 ) {
stepIndex.value += 1
emit('stepped-up', stepIndex.value)
}
stepToStepInterval.value = setInterval(function() {
stepIndex.value += 1
emit('stepped-up', stepIndex.value)
}, durationBetweenTwoSteps.value * 1000)
}, durationFromInitialToNextStep * 1000)
}
/**
* Resets all timers and internal vars
*/
const clearLooper = () => {
clearTimeout(remainingTimeout.value)
clearTimeout(initialStepToNextStepTimeout.value)
clearInterval(stepToStepInterval.value)
stepIndex.value = generatedAt.value = null
}
onMounted(() => {
if (props.autostart == true) {
startLoop()
}
})
onUnmounted(() => {
clearLooper()
})
defineExpose({
startLoop,
clearLooper,
props
})
</script>
<template>
<div>
</div>
</template>
<script>
export default {
name: 'TotpLooper',
data() {
return {
generatedAt: null,
remainingTimeout: null,
initialStepToNextStepTimeout: null,
stepToStepInterval: null,
stepIndex: null,
}
},
props: {
step_count: {
type: Number,
default: 10
},
period : Number,
generated_at: Number,
autostart: {
type: Boolean,
default: true
},
},
computed: {
// |<----period p----->|
// | | |
// |------- ··· ------------|--------|----------|---------->
// | | | |
// unix T0 Tp.start Tgen_at Tp.end
// | | |
// elapsedTimeInCurrentPeriod--|<------>| |
// (in ms) | | |
// | |
// | | || |
// | | |<-------->|--remainingTimeBeforeEndOfPeriod (for remainingTimeout)
// durationBetweenTwoSteps-->|-|< ||
// (for stepToStepInterval) | | >||<---durationFromInitialToNextStep (for initialStepToNextStepTimeout)
// |
// |
// stepIndex
elapsedTimeInCurrentPeriod() {
return this.generatedAt % this.period
},
remainingTimeBeforeEndOfPeriod() {
return this.period - this.elapsedTimeInCurrentPeriod
},
durationBetweenTwoSteps() {
return this.period / this.step_count
},
initialStepIndex() {
let relativePosition = (this.elapsedTimeInCurrentPeriod * this.step_count) / this.period
return (Math.floor(relativePosition) + 0)
},
},
mounted: function() {
if (this.autostart == true) {
this.startLoop()
}
},
methods: {
startLoop: function() {
this.clearLooper()
this.generatedAt = this.generated_at
this.$emit('loop-started', this.initialStepIndex)
this.stepIndex = this.initialStepIndex
let self = this;
// Main timeout that run until the end of the period
this.remainingTimeout = setTimeout(function() {
self.clearLooper()
self.$emit('loop-ended')
}, this.remainingTimeBeforeEndOfPeriod*1000);
// During the remainingTimeout countdown we have to emit an event every durationBetweenTwoSteps seconds
// except for the first next dot
let durationFromInitialToNextStep = (Math.ceil(this.elapsedTimeInCurrentPeriod / this.durationBetweenTwoSteps) * this.durationBetweenTwoSteps) - this.elapsedTimeInCurrentPeriod
this.initialStepToNextStepTimeout = setTimeout(function() {
if( durationFromInitialToNextStep > 0 ) {
// self.activateNextStep()
self.stepIndex += 1
self.$emit('stepped-up', self.stepIndex)
}
self.stepToStepInterval = setInterval(function() {
// self.activateNextStep()
self.stepIndex += 1
self.$emit('stepped-up', self.stepIndex)
}, self.durationBetweenTwoSteps*1000)
}, durationFromInitialToNextStep*1000)
},
clearLooper: function() {
clearTimeout(this.remainingTimeout)
clearTimeout(this.initialStepToNextStepTimeout)
clearInterval(this.stepToStepInterval)
this.stepIndex = this.generatedAt = null
},
},
beforeDestroy () {
this.clearLooper()
},
}
</script>
</template>

View File

@ -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>

View File

@ -1,42 +1,44 @@
<script setup>
import systemService from '@/services/systemService'
import { useAppSettingsStore } from '@/stores/appSettings'
const appSettings = useAppSettingsStore()
const isScanning = ref(false)
const isUpToDate = ref()
async function getLatestRelease() {
isScanning.value = true;
isUpToDate.value = undefined
await systemService.getLastRelease({returnError: true})
.then(response => {
appSettings.latestRelease = response.data.newRelease
isUpToDate.value = response.data.newRelease === false
})
.catch(() => {
isUpToDate.value = null
})
isScanning.value = false;
}
</script>
<template>
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow">
<button type="button" :class="isScanning ? 'is-loading' : ''" class="button is-link is-rounded is-small" @click="getLatestRelease">Check now</button>
</div>
<div class="column">
<span v-if="$root.appSettings.latestRelease" class="mt-2 has-text-warning">
<span class="release-flag"></span>{{ $root.appSettings.latestRelease }} is available <a class="is-size-7" href="https://github.com/Bubka/2FAuth/releases">View on Github</a>
<span v-if="appSettings.latestRelease" class="mt-2 has-text-warning">
<span class="release-flag"></span>{{ appSettings.latestRelease }} is available <a class="is-size-7" href="https://github.com/Bubka/2FAuth/releases">View on Github</a>
</span>
<span v-if="isUpToDate" class="has-text-grey">
{{ $t('commons.you_are_up_to_date') }}
<FontAwesomeIcon :icon="['fas', 'check']" class="mr-1 has-text-success" /> {{ $t('commons.you_are_up_to_date') }}
</span>
<span v-else-if="isUpToDate === null" class="has-text-grey">
<FontAwesomeIcon :icon="['fas', 'times']" class="mr-1 has-text-danger" />{{ $t('errors.check_failed_try_later') }}
</span>
</div>
</div>
</template>
<script>
export default {
name: 'VersionChecker',
data() {
return {
isScanning: false,
isUpToDate: null,
}
},
methods: {
async getLatestRelease() {
this.isScanning = true;
await this.axios.get('/latestRelease').then(response => {
this.$root.appSettings['latestRelease'] = response.data.newRelease
this.isUpToDate = response.data.newRelease === false
})
this.isScanning = false;
},
}
}
</script>

View File

@ -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)
})

View File

@ -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
View File

@ -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, "")
}
}
})

View File

@ -1,4 +0,0 @@
import Vue from 'vue'
import Clipboard from 'v-clipboard'
Vue.use(Clipboard)

View File

@ -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)

View File

@ -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
View File

@ -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

View File

@ -1,8 +1,52 @@
<script setup>
import systemService from '@/services/systemService'
import { useNotifyStore } from '@/stores/notify'
import { UseColorMode } from '@vueuse/components'
const $2fauth = inject('2fauth')
const router = useRouter()
const notify = useNotifyStore()
const { copy } = useClipboard({ legacy: true })
const returnTo = router.options.history.state.back
const infos = ref()
const listInfos = ref(null)
const userPreferences = ref(false)
const listUserPreferences = ref(null)
const adminSettings = ref(false)
const listAdminSettings = ref(null)
onMounted(() => {
systemService.getSystemInfos({returnError: true}).then(response => {
infos.value = response.data.common
if (response.data.admin_settings) {
adminSettings.value = response.data.admin_settings
}
if (response.data.user_preferences) {
userPreferences.value = response.data.user_preferences
}
})
.catch(() => {
infos.value = null
})
})
function copyToClipboard(data) {
copy(data)
notify.success({ text: trans('commons.copied_to_clipboard') })
}
</script>
<template>
<responsive-width-wrapper>
<h1 class="title has-text-grey-dark">{{ pagetitle }}</h1>
<ResponsiveWidthWrapper>
<h1 class="title has-text-grey-dark">{{ $t('commons.about') }}</h1>
<p class="block">
<span :class="$root.showDarkMode ? 'has-text-white':'has-text-black'"><span class="is-size-5">2FAuth</span> v{{ appVersion }}</span><br />
<UseColorMode v-slot="{ mode }">
<span :class="mode == 'dark' ? 'has-text-white':'has-text-black'"><span class="is-size-5">2FAuth</span> v{{ $2fauth.version }}</span>
</UseColorMode>
<br />
{{ $t('commons.2fauth_teaser')}}
</p>
<img class="about-logo" src="logo.svg" alt="2FAuth logo" />
@ -13,138 +57,82 @@
{{ $t('commons.resources') }}
</h2>
<div class="buttons">
<a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://github.com/Bubka/2FAuth" target="_blank">
<span class="icon is-small">
<font-awesome-icon :icon="['fab', 'github-alt']" />
</span>
<span>Github</span>
</a>
<a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://docs.2fauth.app/" target="_blank">
<span class="icon is-small">
<font-awesome-icon :icon="['fas', 'book']" />
</span>
<span>Docs</span>
</a>
<a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://demo.2fauth.app/" target="_blank">
<span class="icon is-small">
<font-awesome-icon :icon="['fas', 'flask']" />
</span>
<span>Demo</span>
</a>
<a class="button" :class="{'is-dark' : $root.showDarkMode}" href="https://docs.2fauth.app/resources/rapidoc.html" target="_blank">
<span class="icon is-small">
<font-awesome-icon :icon="['fas', 'code']" />
</span>
<span>API</span>
</a>
<UseColorMode v-slot="{ mode }">
<a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://github.com/Bubka/2FAuth" target="_blank">
<span class="icon is-small">
<FontAwesomeIcon :icon="['fab', 'github-alt']" />
</span>
<span>Github</span>
</a>
<a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://docs.2fauth.app/" target="_blank">
<span class="icon is-small">
<FontAwesomeIcon :icon="['fas', 'book']" />
</span>
<span>Docs</span>
</a>
<a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://demo.2fauth.app/" target="_blank">
<span class="icon is-small">
<FontAwesomeIcon :icon="['fas', 'flask']" />
</span>
<span>Demo</span>
</a>
<a class="button" :class="{'is-dark' : mode == 'dark'}" href="https://docs.2fauth.app/resources/rapidoc.html" target="_blank">
<span class="icon is-small">
<FontAwesomeIcon :icon="['fas', 'code']" />
</span>
<span>API</span>
</a>
</UseColorMode>
</div>
<h2 class="title is-5 has-text-grey-light">
{{ $t('commons.credits') }}
</h2>
<p class="block">
<ul>
<li>{{ $t('commons.made_with')}}&nbsp;<a href="https://docs.2fauth.app/credits/">Laravel, Bulma CSS, Vue.js and more</a></li>
<li>{{ $t('commons.ui_icons_by')}}&nbsp;<a href="https://fontawesome.com/">Font Awesome</a>&nbsp;<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')}}&nbsp;<a href="https://2fa.directory/">2FA Directory</a>&nbsp;<a class="is-size-7" href="https://github.com/2factorauth/twofactorauth/blob/master/LICENSE.md">(MIT License)</a></li>
<li>{{ $t('commons.made_with') }}&nbsp;<a href="https://docs.2fauth.app/credits/">Laravel, Bulma CSS, Vue.js and more</a></li>
<li>{{ $t('commons.ui_icons_by') }}&nbsp;<a href="https://fontawesome.com/">Font Awesome</a>&nbsp;<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') }}&nbsp;<a href="https://2fa.directory/">2FA Directory</a>&nbsp;<a class="is-size-7" href="https://github.com/2factorauth/twofactorauth/blob/master/LICENSE.md">(MIT License)</a></li>
</ul>
</p>
<h2 class="title is-5 has-text-grey-light">
{{ $t('commons.environment') }}
</h2>
<div class="about-debug box is-family-monospace is-size-7">
<button id="btnCopyEnvVars" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" v-clipboard="() => this.$refs.listInfos.innerText" v-clipboard:success="clipboardSuccessHandler">
<font-awesome-icon :icon="['fas', 'copy']" />
<div v-if="infos" class="about-debug box is-family-monospace is-size-7">
<button id="btnCopyEnvVars" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" @click.stop="copyToClipboard(listInfos.innerText)">
<FontAwesomeIcon :icon="['fas', 'copy']" />
</button>
<ul ref="listInfos" id="listInfos">
<li v-for="(value, key) in infos" :value="value" :key="key"><b>{{key}}</b>: {{value}}</li>
</ul>
</div>
<h2 v-if="showAdminSettings" class="title is-5 has-text-grey-light">
<div v-else-if="infos === null" class="about-debug box is-family-monospace is-size-7 has-text-warning-dark">
{{ $t('errors.error_during_data_fetching') }}
</div>
<h2 v-if="adminSettings" class="title is-5 has-text-grey-light">
{{ $t('settings.admin_settings') }}
</h2>
<div v-if="showAdminSettings" class="about-debug box is-family-monospace is-size-7">
<button id="btnCopyAdminSettings" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" v-clipboard="() => this.$refs.listAdminSettings.innerText" v-clipboard:success="clipboardSuccessHandler">
<font-awesome-icon :icon="['fas', 'copy']" />
<div v-if="adminSettings" class="about-debug box is-family-monospace is-size-7">
<button id="btnCopyAdminSettings" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" @click.stop="copyToClipboard(listAdminSettings.innerText)">
<FontAwesomeIcon :icon="['fas', 'copy']" />
</button>
<ul ref="listAdminSettings" id="listAdminSettings">
<li v-for="(value, setting) in adminSettings" :value="value" :key="setting"><b>{{setting}}</b>: {{value}}</li>
</ul>
</div>
<h2 v-if="showUserPreferences" class="title is-5 has-text-grey-light">
<h2 v-if="userPreferences" class="title is-5 has-text-grey-light">
{{ $t('settings.user_preferences') }}
</h2>
<div v-if="showUserPreferences" class="about-debug box is-family-monospace is-size-7">
<button id="btnCopyUserPreferences" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" v-clipboard="() => this.$refs.listUserPreferences.innerText" v-clipboard:success="clipboardSuccessHandler">
<font-awesome-icon :icon="['fas', 'copy']" />
<div v-if="userPreferences" class="about-debug box is-family-monospace is-size-7">
<button id="btnCopyUserPreferences" :aria-label="$t('commons.copy_to_clipboard')" class="button is-like-text is-pulled-right is-small is-text" @click.stop="copyToClipboard(listUserPreferences.innerText)">
<FontAwesomeIcon :icon="['fas', 'copy']" />
</button>
<ul ref="listUserPreferences" id="listUserPreferences">
<li v-for="(value, preference) in userPreferences" :value="value" :key="preference"><b>{{preference}}</b>: {{value}}</li>
</ul>
</div>
<!-- footer -->
<vue-footer :showButtons="true">
<!-- close button -->
<p class="control">
<router-link
id="lnkBack"
:to="{ path: $route.params.goBackTo, params: { returnTo: $route.params.returnTo, toRefresh: true } }"
:aria-label="$t('commons.close_the_x_page', {pagetitle: pagetitle})"
class="button is-rounded"
:class="{'is-dark' : $root.showDarkMode}">
{{ $t('commons.back') }}
</router-link>
</p>
</vue-footer>
</responsive-width-wrapper>
</template>
<script>
export default {
data() {
return {
pagetitle: this.$t('commons.about'),
infos : null,
adminSettings : null,
userPreferences : null,
showUserPreferences: false,
showAdminSettings: false,
}
},
async mounted() {
await this.axios.get('infos').then(response => {
this.infos = response.data.common
if (response.data.admin_settings) {
this.adminSettings = response.data.admin_settings
this.showAdminSettings = true
}
if (response.data.user_preferences) {
this.userPreferences = response.data.user_preferences
this.showUserPreferences = true
}
})
},
methods: {
clipboardSuccessHandler ({ value, event }) {
this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
},
clipboardErrorHandler ({ value, event }) {
console.log('error', value)
},
},
beforeRouteEnter(to, from, next) {
next(vm => {
if (from.params.returnTo) {
to.params.returnTo = from.params.returnTo
}
})
},
}
</script>
<VueFooter :showButtons="true">
<ButtonBackCloseCancel :returnTo="{ path: returnTo }" action="back" />
</VueFooter>
</ResponsiveWidthWrapper>
</template>

View File

@ -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 }}&nbsp;{{ $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 }})&nbsp;
<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>

View File

@ -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>

View File

@ -1,102 +1,51 @@
<template>
<div class="error-message">
<modal v-model="ShowModal" :closable="this.showcloseButton">
<div class="error-message" v-if="$route.name == '404'">
<p class="error-404"></p>
<p>{{ $t('errors.resource_not_found') }}</p>
<p class=""><router-link :to="{ name: 'accounts' }" class="is-text">{{ $t('errors.refresh') }}</router-link></p>
</div>
<div v-else>
<p class="error-generic"></p>
<p>{{ $t('errors.error_occured') }} </p>
<p v-if="error.message" class="has-text-grey-lighter">{{ error.message }}</p>
<p v-if="error.originalMessage" class="has-text-grey-lighter">{{ error.originalMessage }}</p>
<p><router-link :to="{ name: 'accounts', params: { toRefresh: true } }" class="is-text">{{ $t('errors.refresh') }}</router-link></p>
<p v-if="debugMode == 'development' && error.debug">
<br>
{{ error.debug }}
</p>
</div>
</modal>
</div>
</template>
<script setup>
import { useNotifyStore } from '@/stores/notify'
const errorHandler = useNotifyStore()
const router = useRouter()
const route = useRoute()
const showModal = ref(true)
const showDebug = computed(() => process.env.NODE_ENV === 'development')
<script>
import Modal from '../components/Modal'
const props = defineProps({
closable: {
type: Boolean,
default: true
}
})
export default {
data(){
return {
ShowModal : true,
showcloseButton: this.closable,
}
},
watch(showModal, (val) => {
if (val == false) {
exit()
}
})
computed: {
debugMode: function() {
return process.env.NODE_ENV
},
error: function() {
if( this.err === null || this.err === undefined ) {
return false
}
else
{
if (this.err.status === 407) {
return {
'message' : this.$t('errors.auth_proxy_failed'),
'originalMessage' : this.$t('errors.auth_proxy_failed_legend')
}
}
else if (this.err.status === 403) {
return {
'message' : this.$t('errors.unauthorized'),
'originalMessage' : this.$t('errors.unauthorized_legend')
}
}
else if(this.err.data) {
return this.err.data
}
else {
return { 'message' : this.err }
}
}
}
},
props: {
err: [String, Object], // on object (error.response) or a string
closable: {
type: Boolean,
default: true
}
},
components: {
Modal
},
mounted(){
// stop OTP generation on modal close
this.$on('modalClose', function() {
window.history.length > 1 && this.$route.name !== '404' ? this.$router.go(-1) : this.$router.push({ name: 'accounts' })
});
},
beforeRouteEnter(to, from, next) {
next(vm => {
if (from.params.returnTo) {
to.params.returnTo = from.params.returnTo
}
})
},
/**
* Exits the error view
*/
function exit() {
window.history.length > 1 && route.name !== '404' && route.name !== 'notFound'
? router.go(-1)
: router.push({ name: 'accounts' })
}
</script>
<template>
<div>
<modal v-model="showModal" :closable="props.closable">
<div class="error-message" v-if="$route.name == '404' || $route.name == 'notFound'">
<p class="error-404"></p>
<p>{{ $t('errors.resource_not_found') }}</p>
</div>
<div v-else class="error-message" >
<p class="error-generic"></p>
<p>{{ $t('errors.error_occured') }} </p>
<p v-if="errorHandler.message" class="has-text-grey-lighter">{{ errorHandler.message }}</p>
<p v-if="errorHandler.originalMessage" class="has-text-grey-lighter">{{ errorHandler.originalMessage }}</p>
<p v-if="showDebug && errorHandler.debug" class="is-size-7 is-family-code"><br>{{ errorHandler.debug }}</p>
</div>
</modal>
</div>
</template>

View File

@ -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>

View File

@ -1,9 +1,70 @@
<script setup>
import Form from '@/components/formElements/Form'
import { useUserStore } from '@/stores/user'
import { useBusStore } from '@/stores/bus'
import { useNotifyStore } from '@/stores/notify'
import { UseColorMode } from '@vueuse/components'
import { useTwofaccounts } from '@/stores/twofaccounts'
const router = useRouter()
const user = useUserStore()
const bus = useBusStore()
const notify = useNotifyStore()
const twofaccounts = useTwofaccounts()
const qrcodeInput = ref(null)
const qrcodeInputLabel = ref(null)
const form = reactive(new Form({
qrcode: null,
inputFormat: 'fileUpload',
}))
/**
* Upload the submitted QR code file to the backend for decoding, then route the user
* to the Create or Import form with decoded URI to prefill the form
*/
function submitQrCode() {
form.clear()
form.qrcode = qrcodeInput.value.files[0]
form.upload('/api/v1/qrcode/decode', { returnError: true }).then(response => {
if (response.data.data.slice(0, 33).toLowerCase() === "otpauth-migration://offline?data=") {
bus.migrationUri = response.data.data
router.push({ name: 'importAccounts' })
}
else {
bus.decodedUri = response.data.data
router.push({ name: 'createAccount' })
}
})
.catch(error => {
if (error.response.status !== 422) {
notify.alert({ text: error.response.data.message })
}
})
}
/**
* Push user to the dedicated capture view for live scan
*/
function capture() {
router.push({ name: 'capture' });
}
onMounted(() => {
if( user.preferences.useDirectCapture && user.preferences.defaultCaptureMode === 'upload' ) {
qrcodeInputLabel.value.click()
}
})
</script>
<template>
<!-- static landing UI -->
<div class="container has-text-centered">
<div class="columns quick-uploader">
<!-- trailer phrase that invite to add an account -->
<div class="column is-full quick-uploader-header" :class="{ 'is-invisible' : accountCount !== 0 }">
<div class="column is-full quick-uploader-header" :class="{ 'is-invisible' : twofaccounts.count !== 0 }">
{{ $t('twofaccounts.no_account_here') }}<br>
{{ $t('twofaccounts.add_first_account') }}
</div>
@ -11,7 +72,7 @@
<div class="column is-full quick-uploader-button" >
<div class="quick-uploader-centerer">
<!-- upload a qr code (with basic file field and backend decoding) -->
<label role="button" tabindex="0" v-if="$root.userPreferences.useBasicQrcodeReader" class="button is-link is-medium is-rounded is-main" ref="qrcodeInputLabel" @keyup.enter="$refs.qrcodeInputLabel.click()">
<label role="button" tabindex="0" v-if="user.preferences.useBasicQrcodeReader" class="button is-link is-medium is-rounded is-main" ref="qrcodeInputLabel" @keyup.enter="qrcodeInputLabel.click()">
<input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="submitQrCode" ref="qrcodeInput">
{{ $t('twofaccounts.forms.upload_qrcode') }}
</label>
@ -20,132 +81,37 @@
{{ $t('twofaccounts.forms.scan_qrcode') }}
</button>
</div>
<FieldError v-if="form.errors.hasAny('qrcode')" :error="form.errors.get('qrcode')" :field="'qrcode'" />
</div>
<!-- alternative methods -->
<div class="column is-full">
<div class="block" :class="$root.showDarkMode ? 'has-text-light':'has-text-grey-dark'">{{ $t('twofaccounts.forms.alternative_methods') }}</div>
<UseColorMode v-slot="{ mode }">
<div class="block" :class="mode == 'dark' ? 'has-text-light':'has-text-grey-dark'">{{ $t('twofaccounts.forms.alternative_methods') }}</div>
</UseColorMode>
<!-- upload a qr code -->
<div class="block has-text-link" v-if="!$root.userPreferences.useBasicQrcodeReader">
<label role="button" tabindex="0" class="button is-link is-outlined is-rounded" ref="qrcodeInputLabel" @keyup.enter="$refs.qrcodeInputLabel.click()">
<div class="block has-text-link" v-if="!user.preferences.useBasicQrcodeReader">
<label role="button" tabindex="0" class="button is-link is-outlined is-rounded" ref="qrcodeInputLabel" @keyup.enter="qrcodeInputLabel.click()">
<input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="submitQrCode" ref="qrcodeInput">
{{ $t('twofaccounts.forms.upload_qrcode') }}
</label>
</div>
<!-- link to advanced form -->
<div v-if="showAdvancedFormButton" class="block has-text-link">
<router-link class="button is-link is-outlined is-rounded" :to="{ name: 'createAccount' }" >
<div class="block has-text-link">
<RouterLink class="button is-link is-outlined is-rounded" :to="{ name: 'createAccount' }" >
{{ $t('twofaccounts.forms.use_advanced_form') }}
</router-link>
</RouterLink>
</div>
<!-- link to import view -->
<div v-if="showImportButton" class="block has-text-link">
<router-link id="btnImport" class="button is-link is-outlined is-rounded" :to="{ name: 'importAccounts' }" >
<div class="block has-text-link">
<RouterLink id="btnImport" class="button is-link is-outlined is-rounded" :to="{ name: 'importAccounts' }" >
{{ $t('twofaccounts.import.import') }}
</router-link>
</RouterLink>
</div>
</div>
</div>
<!-- Footer -->
<vue-footer :showButtons="true" >
<!-- back button -->
<p class="control" v-if="accountCount > 0">
<router-link id="lnkBack" class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" :to="{ name: returnToView }" >
{{ $t('commons.back') }}
</router-link>
</p>
</vue-footer>
<VueFooter :showButtons="true" >
<ButtonBackCloseCancel :returnTo="{ name: 'accounts' }" action="back" v-if="!twofaccounts.isEmpty" />
</VueFooter>
</div>
</template>
<script>
/**
* Start view
*
* route: '/start'
*
* Offer the user all available possibilities for capturing an account :
* - By sending the user to the live scanner
* - By decoding a QR code submitted with a form 'File' field
* - By sending the user to the advanced form
*
*/
import Form from './../components/Form'
export default {
name: 'Start',
data(){
return {
accountCount: null,
form: new Form(),
alternativeMethod: null,
}
},
props: {
showAdvancedFormButton: {
type: Boolean,
default: true
},
showImportButton: {
type: Boolean,
default: true
},
returnToView: {
type: String,
default: 'accounts'
},
},
mounted() {
this.axios.get('api/v1/twofaccounts/count').then(response => {
this.accountCount = response.data.count
})
},
created() {
this.$nextTick(() => {
if( this.$root.userPreferences.useDirectCapture && this.$root.userPreferences.defaultCaptureMode === 'upload' ) {
this.$refs.qrcodeInputLabel.click()
}
})
},
methods: {
/**
* Upload the submitted QR code file to the backend for decoding, then route the user
* to the Create or Import form with decoded URI to prefill the form
*/
submitQrCode() {
let imgdata = new FormData();
imgdata.append('qrcode', this.$refs.qrcodeInput.files[0]);
imgdata.append('inputFormat', 'fileUpload');
this.form.upload('/api/v1/qrcode/decode', imgdata, {returnError: true}).then(response => {
if( response.data.data.slice(0, 33).toLowerCase() === "otpauth-migration://offline?data=" ) {
this.$router.push({ name: 'importAccounts', params: { migrationUri: response.data.data } });
}
else this.$router.push({ name: 'createAccount', params: { decodedUri: response.data.data } });
})
.catch(error => {
this.$notify({type: 'is-danger', text: this.$t(error.response.data.message) })
});
},
/**
* Push user to the dedicated capture view for live scan
*/
capture() {
this.$router.push({ name: 'capture' });
},
}
};
</script>

View File

@ -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>

View File

@ -1,187 +1,156 @@
<template>
<div>
<!-- webauthn authentication -->
<form-wrapper v-if="showWebauthn" :title="$t('auth.forms.webauthn_login')" :punchline="$t('auth.welcome_to_2fauth')">
<div class="field">
{{ $t('auth.webauthn.use_security_device_to_sign_in') }}
</div>
<form id="frmWebauthnLogin" @submit.prevent="webauthnLogin" @keydown="form.onKeydown($event)">
<form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" autofocus />
<form-buttons :isBusy="form.isBusy" :caption="$t('commons.continue')" :submitId="'btnContinue'"/>
</form>
<div class="nav-links">
<p>{{ $t('auth.webauthn.lost_your_device') }}&nbsp;<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') }}&nbsp;
<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') }}&nbsp;<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') }}&nbsp;
<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') }}&nbsp;<router-link id="lnkRegister" :to="{ name: 'register' }" class="is-link">{{ $t('auth.register') }}</router-link></p>
</div>
</form-wrapper>
<!-- footer -->
<vue-footer></vue-footer>
</div>
</template>
<script setup>
import Form from '@/components/formElements/Form'
import { useUserStore } from '@/stores/user'
import { useNotifyStore } from '@/stores/notify'
import { useAppSettingsStore } from '@/stores/appSettings'
import { webauthnService } from '@/services/webauthn/webauthnService'
<script>
const $2fauth = inject('2fauth')
const router = useRouter()
const user = useUserStore()
const notify = useNotifyStore()
const appSettings = useAppSettingsStore()
const showWebauthnForm = user.preferences.useWebauthnOnly ? true : useStorage($2fauth.prefix + 'showWebauthnForm', false)
const form = reactive(new Form({
email: '',
password: ''
}))
const isBusy = ref(false)
import Form from './../../components/Form'
import WebauthnService from './../../webauthn/webauthnService'
import { webauthnAbortService } from './../../webauthn/webauthnAbortService'
import { identifyAuthenticationError } from './../../webauthn/identifyAuthenticationError'
export default {
data(){
return {
isDemo: this.$root.isDemoApp,
isTesting: this.$root.isTestingApp,
form: new Form({
email: '',
password: ''
}),
isBusy: false,
showWebauthn: this.$root.userPreferences.useWebauthnOnly,
csrfRefresher: null
}
},
mounted: function() {
this.csrfRefresher = setInterval(this.refreshToken, 300000) // 5 min
this.showWebauthn = this.$storage.get('showWebauthnForm', false)
},
methods : {
/**
* Toggle the form between legacy and webauthn method
*/
toggleForm() {
this.showWebauthn = ! this.showWebauthn
this.$storage.set('showWebauthnForm', this.showWebauthn)
},
/**
* Sign in using the login/password form
*/
handleSubmit(e) {
e.preventDefault()
this.form.post('/user/login', {returnError: true})
.then(response => {
this.$storage.set('authenticated', true)
this.applyPreferences(response.data.preferences)
this.$router.push({ name: 'accounts', params: { toRefresh: true } })
})
.catch(error => {
this.$storage.set('authenticated', false)
if( error.response.status === 401 ) {
this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
}
else if( error.response.status !== 422 ) {
this.$router.push({ name: 'genericError', params: { err: error.response } });
}
});
},
/**
* Sign in using the WebAuthn API
*/
async webauthnLogin() {
this.isBusy = false
let webauthnService = new WebauthnService()
// Check https context
if (!window.isSecureContext) {
this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') })
return false
}
// Check browser support
if (webauthnService.doesntSupportWebAuthn) {
this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
return false
}
const loginOptions = await this.form.post('/webauthn/login/options').then(res => res.data)
const publicKey = webauthnService.parseIncomingServerOptions(loginOptions)
let options = { publicKey }
options.signal = webauthnAbortService.createNewAbortSignal()
const credentials = await navigator.credentials.get(options)
.catch(error => {
const webauthnError = identifyAuthenticationError(error, options)
this.$notify({ type: webauthnError.type, text: this.$t(webauthnError.phrase) })
})
if (!credentials) return false
let publicKeyCredential = webauthnService.parseOutgoingCredentials(credentials)
publicKeyCredential.email = this.form.email
this.axios.post('/webauthn/login', publicKeyCredential, {returnError: true}).then(response => {
this.$storage.set('authenticated', true)
this.applyPreferences(response.data.preferences);
this.$router.push({ name: 'accounts', params: { toRefresh: true } })
})
.catch(error => {
this.$storage.set('authenticated', false)
if( error.response.status === 401 ) {
this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
}
else if( error.response.status !== 422 ) {
this.$router.push({ name: 'genericError', params: { err: error.response } });
}
});
this.isBusy = false
},
refreshToken(){
this.axios.get('/refresh-csrf')
}
},
beforeRouteEnter (to, from, next) {
if (to.params.forceRefresh && from.name !== null) {
window.location.href = "." + to.path;
return;
}
next();
},
beforeRouteLeave (to, from, next) {
this.$notify({
clean: true
})
clearInterval(this.csrfRefresher);
if (this.$root.appSettings.disableRegistration && to.name == 'register') {
this.$router.push({name: 'genericError', params: { err: this.$t('errors.unauthorized_legend') } })
}
next()
}
/**
* Toggle the form between legacy and webauthn method
*/
function toggleForm() {
form.clear()
showWebauthnForm.value = ! showWebauthnForm.value
}
</script>
/**
* Sign in using the login/password form
*/
function LegacysignIn(e) {
notify.clear()
form.post('/user/login', {returnError: true}).then(async (response) => {
await user.loginAs({
name: response.data.name,
email: response.data.email,
preferences: response.data.preferences,
isAdmin: response.data.is_admin,
})
router.push({ name: 'accounts' })
})
.catch(error => {
if( error.response.status === 401 ) {
notify.alert({text: trans('auth.forms.authentication_failed'), duration: 10000 })
}
else if( error.response.status !== 422 ) {
notify.error(error)
}
})
}
/**
* Sign in using webauthn
*/
function webauthnLogin() {
notify.clear()
form.clear()
isBusy.value = true
webauthnService.authenticate(form.email).then(async (response) => {
await user.loginAs({
name: response.data.name,
email: response.data.email,
preferences: response.data.preferences,
isAdmin: response.data.is_admin,
})
router.push({ name: 'accounts' })
})
.catch(error => {
if ('webauthn' in error) {
if (error.name == 'is-warning') {
notify.warn({ text: trans(error.message) })
}
else notify.alert({ text: trans(error.message) })
}
else if( error.response.status === 401 ) {
notify.alert({text: trans('auth.forms.authentication_failed'), duration: 10000 })
}
else if( error.response.status == 422 ) {
form.errors.set(form.extractErrors(error.response))
}
else {
notify.error(error)
}
})
.finally(() => {
isBusy.value = false
})
}
</script>
<template>
<!-- webauthn authentication -->
<FormWrapper v-if="showWebauthnForm" title="auth.forms.webauthn_login" punchline="auth.welcome_to_2fauth">
<div class="field">
{{ $t('auth.webauthn.use_security_device_to_sign_in') }}
</div>
<form id="frmWebauthnLogin" @submit.prevent="webauthnLogin" @keydown="form.onKeydown($event)">
<FormField v-model="form.email" fieldName="email" :fieldError="form.errors.get('email')" inputType="email" label="auth.forms.email" autofocus />
<FormButtons :isBusy="isBusy" caption="commons.continue" submitId="btnContinue"/>
</form>
<div class="nav-links">
<p>
{{ $t('auth.webauthn.lost_your_device') }}&nbsp;
<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') }}&nbsp;
<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') }}&nbsp;
<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') }}&nbsp;
<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') }}&nbsp;
<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') }}&nbsp;
<RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link">
{{ $t('auth.register') }}
</RouterLink>
</p>
</div>
</FormWrapper>
<!-- footer -->
<VueFooter/>
</template>

View File

@ -1,12 +1,89 @@
<script setup>
import Form from '@/components/formElements/Form'
import { useUserStore } from '@/stores/user'
import { webauthnService } from '@/services/webauthn/webauthnService'
import { useNotifyStore } from '@/stores/notify'
const user = useUserStore()
const notify = useNotifyStore()
const router = useRouter()
const showWebauthnRegistration = ref(false)
const deviceId = ref(null)
const registerForm = reactive(new Form({
name : '',
email : '',
password : '',
password_confirmation : '',
}))
const renameDeviceForm = reactive(new Form({
name : ''
}))
/**
* Register a new user
*/
async function register(e) {
registerForm.password_confirmation = registerForm.password
registerForm.post('/user').then(response => {
user.$patch({
name: response.data.name,
email: response.data.email,
preferences: response.data.preferences,
isAdmin: response.data.is_admin ?? false,
})
user.applyTheme()
showWebauthnRegistration.value = true
})
}
/**
* Register a new security device
*/
function registerWebauthnDevice() {
webauthnService.register().then((response) => {
const publicKeyCredential = JSON.parse(response.config.data)
deviceId.value = publicKeyCredential.id
})
.catch(error => {
if( error.response.status === 422 ) {
notify.alert({ text: error.response.data.message })
}
else {
notify.error(error);
}
})
}
/**
* Rename the registered device
*/
function RenameDevice(e) {
renameDeviceForm.patch('/webauthn/credentials/' + deviceId.value + '/name')
.then(() => {
notify.success({ text: trans('auth.webauthn.device_successfully_registered') })
router.push({ name: 'accounts' })
})
}
onBeforeRouteLeave(() => {
notify.clear()
})
</script>
<template>
<div>
<!-- webauthn registration -->
<form-wrapper v-if="showWebauthnRegistration" :title="$t('auth.authentication')" :punchline="$t('auth.webauthn.enhance_security_using_webauthn')">
<div v-if="deviceRegistered" class="field">
<FormWrapper v-if="showWebauthnRegistration" title="auth.authentication" punchline="auth.webauthn.enhance_security_using_webauthn">
<div v-if="deviceId" class="field">
<label id="lblDeviceRegistrationSuccess" class="label mb-5">{{ $t('auth.webauthn.device_successfully_registered') }}&nbsp;<font-awesome-icon :icon="['fas', 'check']" /></label>
<form @submit.prevent="handleDeviceSubmit" @keydown="deviceForm.onKeydown($event)">
<form-field :form="deviceForm" fieldName="name" inputType="text" placeholder="iPhone 12, TouchID, Yubikey 5C" :label="$t('auth.forms.name_this_device')" />
<form-buttons :isBusy="deviceForm.isBusy" :isDisabled="deviceForm.isDisabled" :caption="$t('commons.continue')" />
<form @submit.prevent="RenameDevice" @keydown="renameDeviceForm.onKeydown($event)">
<FormField v-model="renameDeviceForm.name" fieldName="name" :fieldError="renameDeviceForm.errors.get('name')" inputType="text" placeholder="iPhone 12, TouchID, Yubikey 5C" label="auth.forms.name_this_device" />
<FormButtons :isBusy="renameDeviceForm.isBusy" :isDisabled="renameDeviceForm.isDisabled" caption="commons.continue" />
</form>
</div>
<div v-else class="field is-grouped">
@ -16,147 +93,23 @@
</div>
<!-- dismiss button -->
<div class="control">
<router-link id="btnMaybeLater" :to="{ name: 'accounts', params: { toRefresh: true } }" class="button is-text">{{ $t('auth.maybe_later') }}</router-link>
<RouterLink id="btnMaybeLater" :to="{ name: 'accounts' }" class="button is-text">{{ $t('auth.maybe_later') }}</RouterLink>
</div>
</div>
</form-wrapper>
</FormWrapper>
<!-- User registration form -->
<form-wrapper v-else :title="$t('auth.register')" :punchline="$t('auth.forms.register_punchline')">
<form @submit.prevent="handleRegisterSubmit" @keydown="registerForm.onKeydown($event)">
<form-field :form="registerForm" fieldName="name" inputType="text" :label="$t('auth.forms.name')" :maxLength="255" autofocus />
<form-field :form="registerForm" fieldName="email" inputType="email" :label="$t('auth.forms.email')" :maxLength="255" />
<form-password-field :form="registerForm" fieldName="password" :showRules="true" :label="$t('auth.forms.password')" />
<form-buttons :isBusy="registerForm.isBusy" :isDisabled="registerForm.isDisabled" :caption="$t('auth.register')" :submitId="'btnRegister'" />
<FormWrapper v-else title="auth.register" punchline="auth.forms.register_punchline">
<form @submit.prevent="register" @keydown="registerForm.onKeydown($event)">
<FormField v-model="registerForm.name" fieldName="name" :fieldError="registerForm.errors.get('name')" inputType="text" label="auth.forms.name" :maxLength="255" autofocus />
<FormField v-model="registerForm.email" fieldName="email" :fieldError="registerForm.errors.get('email')" inputType="email" label="auth.forms.email" :maxLength="255" />
<FormPasswordField v-model="registerForm.password" fieldName="password" :fieldError="registerForm.errors.get('password')" :showRules="true" label="auth.forms.password" />
<FormButtons :isBusy="registerForm.isBusy" :isDisabled="registerForm.isDisabled" caption="auth.register" submitId="btnRegister" />
</form>
<div class="nav-links">
<p>{{ $t('auth.forms.already_register') }}&nbsp;<router-link id="lnkSignIn" :to="{ name: 'login' }" class="is-link">{{ $t('auth.sign_in') }}</router-link></p>
<p>{{ $t('auth.forms.already_register') }}&nbsp;<RouterLink id="lnkSignIn" :to="{ name: 'login' }" class="is-link">{{ $t('auth.sign_in') }}</RouterLink></p>
</div>
</form-wrapper>
</FormWrapper>
<!-- footer -->
<vue-footer></vue-footer>
<VueFooter />
</div>
</template>
<script>
import Form from './../../components/Form'
import WebauthnService from './../../webauthn/webauthnService'
import { webauthnAbortService } from './../../webauthn/webauthnAbortService'
import { identifyRegistrationError } from './../../webauthn/identifyRegistrationError'
export default {
data(){
return {
registerForm: new Form({
name : '',
email : '',
password : '',
password_confirmation : '',
}),
deviceForm: new Form({
name : '',
}),
showWebauthnRegistration: false,
deviceRegistered: false,
deviceId : null
}
},
methods : {
/**
* Register a new user
*/
async handleRegisterSubmit(e) {
e.preventDefault()
this.registerForm.password_confirmation = this.registerForm.password
this.registerForm.post('/user', {returnError: true})
.then(response => {
this.$storage.set('authenticated', true)
this.showWebauthnRegistration = true
})
.catch(error => {
if( error.response.status !== 422 ) {
this.$router.push({ name: 'genericError', params: { err: error.response } });
}
});
},
/**
* Register a new security device
*/
async registerWebauthnDevice() {
let webauthnService = new WebauthnService()
// Check https context
if (!window.isSecureContext) {
this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') })
return false
}
// Check browser support
if (webauthnService.doesntSupportWebAuthn) {
this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
return false
}
const registerOptions = await this.axios.post('/webauthn/register/options').then(res => res.data)
const publicKey = webauthnService.parseIncomingServerOptions(registerOptions)
let options = { publicKey }
options.signal = webauthnAbortService.createNewAbortSignal()
let bufferedCredentials
try {
bufferedCredentials = await navigator.credentials.create(options)
}
catch (error) {
const webauthnError = identifyRegistrationError(error, options)
this.$notify({ type: webauthnError.type, text: this.$t(webauthnError.phrase) })
return false
}
const publicKeyCredential = webauthnService.parseOutgoingCredentials(bufferedCredentials);
this.axios.post('/webauthn/register', publicKeyCredential, {returnError: true})
.then(response => {
this.deviceId = publicKeyCredential.id
this.deviceRegistered = true
})
.catch(error => {
if( error.response.status === 422 ) {
this.$notify({ type: 'is-danger', text: error.response.data.message })
}
else {
this.$router.push({ name: 'genericError', params: { err: error.response } });
}
})
},
/**
* Rename the registered device
*/
async handleDeviceSubmit(e) {
await this.deviceForm.patch('/webauthn/credentials/' + this.deviceId + '/name')
if( this.deviceForm.errors.any() === false ) {
this.$router.push({name: 'accounts', params: { toRefresh: true }})
}
},
},
beforeRouteLeave (to, from, next) {
this.$notify({
clean: true
})
next()
}
}
</script>

View File

@ -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>

View File

@ -1,70 +1,62 @@
<template>
<form-wrapper :title="$t('auth.forms.new_password')">
<form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
<form-field :form="form" fieldName="email" inputType="email" :label="$t('auth.forms.email')" :isDisabled="true" readonly />
<form-password-field :form="form" fieldName="password" :autocomplete="'new-password'" :showRules="true" :label="$t('auth.forms.new_password')" />
<field-error :form="form" field="token" />
<form-buttons v-if="pending" :isBusy="form.isBusy" :caption="$t('auth.forms.change_password')" :showCancelButton="true" cancelLandingView="login" />
<router-link v-if="!pending" id="btnContinue" :to="{ name: 'accounts' }" class="button is-link">{{ $t('commons.continue') }}</router-link>
</form>
<!-- footer -->
<vue-footer></vue-footer>
</form-wrapper>
</template>
<script setup>
import Form from '@/components/formElements/Form'
import { useNotifyStore } from '@/stores/notify'
<script>
const notify = useNotifyStore()
const router = useRouter()
const route = useRoute()
const isPending = ref(true)
const form = reactive(new Form({
email : route.query.email,
password : '',
password_confirmation : '',
token: route.query.token
}))
import Form from './../../../components/Form'
/**
* Submits the password reset to the backend
*/
function resetPassword(e) {
form.password_confirmation = form.password
export default {
data(){
return {
pending: true,
form: new Form({
email : '',
password : '',
password_confirmation : '',
token: ''
})
form.post('/user/password/reset', {returnError: true})
.then(response => {
form.password = ''
form.password_confirmation = ''
isPending.value = false
notify.success({ text: response.data.message, duration:-1 })
})
.catch(error => {
if( error.response.data.resetFailed ) {
notify.alert({ text: error.response.data.resetFailed, duration:-1 })
}
},
created () {
this.form.email = this.$route.query.email
this.form.token = this.$route.query.token
},
methods : {
handleSubmit(e) {
e.preventDefault()
this.form.password_confirmation = this.form.password
this.form.post('/user/password/reset', {returnError: true})
.then(response => {
this.pending = false
this.$notify({ type: 'is-success', text: response.data.message, duration:-1 })
})
.catch(error => {
if( error.response.data.resetFailed ) {
this.$notify({ type: 'is-danger', text: error.response.data.resetFailed, duration:-1 })
}
else if( error.response.status !== 422 ) {
this.$router.push({ name: 'genericError', params: { err: error.response } });
}
});
else if( error.response.status !== 422 ) {
notify.error(error)
}
},
beforeRouteLeave (to, from, next) {
this.$notify({
clean: true
})
next()
}
})
}
</script>
onBeforeRouteLeave(() => {
notify.clear()
})
</script>
<template>
<FormWrapper :title="$t('auth.forms.new_password')">
<form @submit.prevent="resetPassword" @keydown="form.onKeydown($event)">
<FormField v-model="form.email" :isDisabled="true" fieldName="email" :fieldError="form.errors.get('email')" label="auth.forms.email" autofocus />
<FormPasswordField v-model="form.password" fieldName="password" :fieldError="form.errors.get('password')" :autocomplete="'new-password'" :showRules="true" label="auth.forms.new_password" />
<FieldError v-if="form.errors.get('token') != undefined" :error="form.errors.get('token')" :field="form.token" />
<FormButtons
v-if="isPending"
:submitId="'btnResetPwd'"
:isBusy="form.isBusy"
:caption="$t('auth.forms.change_password')"
:showCancelButton="true"
cancelLandingView="login" />
<RouterLink v-if="!isPending" id="btnContinue" :to="{ name: 'accounts' }" class="button is-link">{{ $t('commons.continue') }}</RouterLink>
</form>
<VueFooter />
</FormWrapper>
</template>

View File

@ -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>

View File

@ -1,75 +1,71 @@
<script setup>
import Form from '@/components/formElements/Form'
import { useNotifyStore } from '@/stores/notify'
const $2fauth = inject('2fauth')
const notify = useNotifyStore()
const router = useRouter()
const route = useRoute()
const showWebauthnForm = useStorage($2fauth.prefix + 'showWebauthnForm', false)
const form = reactive(new Form({
email : route.query.email,
password : '',
token: route.query.token,
revokeAll: false,
}))
/**
* Submits the recovery to the backend
*/
function recover(e) {
notify.clear()
form.post('/webauthn/recover', {returnError: true})
.then(response => {
showWebauthnForm.value = false
router.push({ name: 'login' })
})
.catch(error => {
if ( error.response.status === 401 ) {
notify.alert({ text: trans('auth.forms.authentication_failed'), duration:-1 })
}
else if (error.response.status === 422) {
notify.alert({ text: error.response.data.message, duration:-1 })
}
else {
notify.error(error)
}
})
}
onBeforeRouteLeave(() => {
notify.clear()
})
</script>
<template>
<form-wrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recover_account_instructions')" >
<FormWrapper :title="$t('auth.webauthn.account_recovery')" :punchline="$t('auth.webauthn.recover_account_instructions')" >
<div>
<form @submit.prevent="recover" @keydown="form.onKeydown($event)">
<form-checkbox :form="form" fieldName="revokeAll" :label="$t('auth.webauthn.disable_all_security_devices')" :help="$t('auth.webauthn.disable_all_security_devices_help')" />
<form-password-field :form="form" :autocomplete="'current-password'" fieldName="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" />
<FormCheckbox v-model="form.revokeAll" fieldName="revokeAll" label="auth.webauthn.disable_all_security_devices" help="auth.webauthn.disable_all_security_devices_help" />
<FormPasswordField v-model="form.password" fieldName="password" :fieldError="form.errors.get('password')" :autocomplete="'current-password'" :showRules="false" label="auth.forms.current_password.label" help="auth.forms.current_password.help" />
<div class="field">
<p>{{ $t('auth.forms.forgot_your_password') }}&nbsp;<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') }}&nbsp;
<RouterLink id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link" :aria-label="$t('auth.forms.reset_your_password')">
{{ $t('auth.forms.request_password_reset') }}
</RouterLink>
</p>
</div>
<form-buttons :caption="$t('commons.continue')" :cancelLandingView="'login'" :showCancelButton="true" :isBusy="form.isBusy" :isDisabled="form.isDisabled" :submitId="'btnRecover'" />
<FormButtons
:submitId="'btnRecover'"
:isBusy="form.isBusy"
:isDisabled="form.isDisabled"
:caption="$t('commons.continue')"
:showCancelButton="true"
cancelLandingView="login" />
</form>
</div>
<!-- footer -->
<vue-footer></vue-footer>
</form-wrapper>
<VueFooter />
</FormWrapper>
</template>
<script>
import Form from './../../../components/Form'
export default {
data(){
return {
currentPassword: '',
deviceRegistered: false,
deviceId : null,
form: new Form({
email: '',
password: '',
token: '',
revokeAll: false,
}),
}
},
created () {
this.form.email = this.$route.query.email
this.form.token = this.$route.query.token
},
methods : {
/**
* Register a new security device
*/
recover() {
this.form.post('/webauthn/recover', {returnError: true})
.then(response => {
this.$router.push({ name: 'login', params: { forceRefresh: true } })
})
.catch(error => {
if( error.response.status === 401 ) {
this.$notify({ type: 'is-danger', text: this.$t('auth.forms.authentication_failed'), duration:-1 })
}
else if (error.response.status === 422) {
this.$notify({ type: 'is-danger', text: error.response.data.message })
}
else {
this.$router.push({ name: 'genericError', params: { err: error.response } });
}
});
}
},
beforeRouteLeave (to, from, next) {
this.$notify({
clean: true
})
next()
}
}
</script>

Some files were not shown because too many files have changed in this diff Show More