2FAuth/resources/js/views/Accounts.vue

646 lines
29 KiB
Vue
Raw Normal View History

<template>
<div>
2020-10-28 17:51:32 +01:00
<!-- Group switch -->
<div class="container groups" v-if="showGroupSwitch">
2020-10-25 23:59:03 +01:00
<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>
2020-10-25 23:59:03 +01:00
</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 class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" @click="closeGroupSwitch()">{{ $t('commons.close') }}</button>
</p>
</vue-footer>
2020-10-25 23:59:03 +01:00
</div>
2020-10-28 17:51:32 +01:00
<!-- Group selector -->
2020-11-25 21:56:04 +01:00
<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>
2020-10-28 17:51:32 +01:00
<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">
2020-11-25 21:56:04 +01:00
<span v-if="group.id === 0" class="is-italic">
2020-10-28 17:51:32 +01:00
{{ $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>
2020-10-28 17:51:32 +01:00
</div>
</div>
<vue-footer :showButtons="true">
<!-- Move to selected group button -->
<p class="control">
2022-09-17 19:31:11 +02:00
<button class="button is-link is-rounded" @click="moveAccounts()">{{ $t('commons.move') }}</button>
</p>
<!-- Cancel button -->
<p class="control">
<button class="button is-rounded" :class="{'is-dark' : $root.showDarkMode}" @click="showGroupSelector = false">{{ $t('commons.cancel') }}</button>
</p>
</vue-footer>
2020-10-28 17:51:32 +01:00
</div>
2022-10-12 11:30:20 +02:00
<!-- header -->
<div class="header" v-if="this.showAccounts || this.showGroupSwitch">
2022-10-12 11:30:20 +02:00
<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 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 tabindex="1" :title="$t('commons.clear_search')" class="clear-selection delete" v-if="search" @click="search = '' "></button>
</span>
</div>
</div>
2022-10-12 11:30:20 +02:00
<!-- 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 @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 @click="selectAll" class="button mr-5 has-line-height p-1 is-ghost has-text-grey" :title="$t('commons.select_all')">
2022-10-12 11:30:20 +02:00
<span>{{ $t('commons.all') }}</span>
<font-awesome-icon class="ml-1" :icon="['fas', 'check-square']" />
</button>
<!-- sort asc/desc buttons -->
<button @click="sortAsc" class="button has-line-height p-1 is-ghost has-text-grey" :title="$t('commons.sort_ascending')">
2022-10-12 11:30:20 +02:00
<font-awesome-icon :icon="['fas', 'sort-alpha-down']" />
</button>
<button @click="sortDesc" class="button has-line-height p-1 is-ghost has-text-grey" :title="$t('commons.sort_descending')">
2022-10-12 11:30:20 +02:00
<font-awesome-icon :icon="['fas', 'sort-alpha-up']" />
</button>
</div>
</div>
</div>
<!-- group switch toggle -->
<div v-else class="has-text-centered">
2022-10-12 11:30:20 +02:00
<div class="columns">
<div class="column" v-if="!showGroupSwitch">
<button :title="$t('groups.show_group_selector')" tabindex="1" class="button is-text is-like-text" :class="{'has-text-grey' : !$root.showDarkMode}" @click.stop="toggleGroupSwitch">
2022-10-12 11:30:20 +02:00
{{ activeGroupName }} ({{ filteredAccounts.length }})&nbsp;
<font-awesome-icon :icon="['fas', 'caret-down']" />
</button>
</div>
<div class="column" v-else>
<button :title="$t('groups.hide_group_selector')" tabindex="1" class="button is-text is-like-text" :class="{'has-text-grey' : !$root.showDarkMode}" @click.stop="toggleGroupSwitch">
2022-10-12 11:30:20 +02:00
{{ $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 -->
2022-10-12 11:30:20 +02:00
<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.appSettings.displayMode === 'grid' }" type="transition" :name="!drag ? 'flip-list' : null">
<div :class="[$root.appSettings.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">
2022-10-12 11:30:20 +02:00
<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="showAccount(account)" @keyup.enter="showAccount(account)" role="button">
<div class="tfa-text has-ellipsis">
<img :src="$root.appConfig.subdirectory + '/storage/icons/' + account.icon" v-if="account.icon && $root.appSettings.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="editMode">
<!-- <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="editMode">
<font-awesome-icon :icon="['fas', 'bars']" />
</div>
</transition>
</div>
</div>
</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">
2022-09-17 19:31:11 +02:00
<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>
2022-09-17 19:31:11 +02:00
</button>
</p>
<!-- Manage button -->
<p class="control" v-if="!editMode">
<button 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
: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
: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
: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') }}
2022-09-17 19:31:11 +02:00
</button>
</p>
</vue-footer>
</div>
</div>
</template>
<script>
2020-01-19 22:31:23 +01:00
/**
* 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 :
* ~ 'appSettings.showAccountsIcons' toggle the icon visibility
* ~ 'appSettings.displayMode' change the account appearance
*
* Input :
* - The 'initialEditMode' props : allows to load the view directly in Edit mode
*
*/
import Modal from '../components/Modal'
import OtpDisplayer from '../components/OtpDisplayer'
import draggable from 'vuedraggable'
2020-10-25 23:59:03 +01:00
import Form from './../components/Form'
import objectEquals from 'object-equals'
import { saveAs } from 'file-saver';
export default {
data(){
return {
accounts : [],
2020-10-25 23:59:03 +01:00
groups : [],
selectedAccounts: [],
2020-01-11 16:46:59 +01:00
search: '',
editMode: this.initialEditMode,
drag: false,
showTwofaccountInModal : false,
2020-10-28 17:51:32 +01:00
showGroupSwitch: false,
2020-10-25 23:59:03 +01:00
showGroupSelector: false,
2020-10-28 17:51:32 +01:00
moveAccountsTo: false,
form: new Form({
value: this.$root.appSettings.activeGroup,
}),
}
},
2020-01-11 16:46:59 +01:00
computed: {
/**
* The actual list of displayed accounts
*/
filteredAccounts: {
get: function() {
return this.accounts.filter(
item => {
if( parseInt(this.$root.appSettings.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.appSettings.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
}
},
2020-02-04 17:06:11 +01:00
/**
* Returns whether or not the accounts should be displayed
*/
2020-02-04 17:06:11 +01:00
showAccounts() {
return this.accounts.length > 0 && !this.showGroupSwitch && !this.showGroupSelector ? true : false
2020-02-04 17:06:11 +01:00
},
/**
* Returns the name of a group
*/
2020-10-25 23:59:03 +01:00
activeGroupName() {
let g = this.groups.find(el => el.id === parseInt(this.$root.appSettings.activeGroup))
if(g) {
return g.name
}
else {
return this.$t('commons.all')
}
}
2020-01-11 16:46:59 +01:00
},
props: ['initialEditMode', 'toRefresh'],
mounted() {
// we don't have to fetch fresh data so we try to load them from localstorage to avoid display latency
if( !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
}
2019-07-02 00:48:48 +02:00
// we fetch fresh data whatever. The user will be notified to reload the page if there are any data changes
this.fetchAccounts()
2020-01-07 17:05:13 +01:00
// stop OTP generation on modal close
2019-07-02 00:48:48 +02:00
this.$on('modalClose', function() {
this.$refs.OtpDisplayer.clearOTP()
2019-07-02 00:48:48 +02:00
});
2020-01-07 17:05:13 +01:00
},
components: {
Modal,
OtpDisplayer,
draggable,
},
methods: {
/**
* Route user to the appropriate submitting view
*/
start() {
if( this.$root.appSettings.useDirectCapture && this.$root.appSettings.defaultCaptureMode === 'advancedForm' ) {
this.$router.push({ name: 'createAccount' })
}
else if( this.$root.appSettings.useDirectCapture && this.$root.appSettings.defaultCaptureMode === 'livescan' ) {
this.$router.push({ name: 'capture' })
}
else {
this.$router.push({ name: 'start' })
}
},
/**
* Fetch accounts from db
*/
fetchAccounts(forceRefresh = false) {
let accounts = []
this.selectedAccounts = []
this.axios.get('api/v1/twofaccounts').then(response => {
response.data.forEach((data) => {
accounts.push(data)
})
if ( this.accounts.length > 0 && !objectEquals(accounts, this.accounts) && !forceRefresh ) {
2020-11-27 20:17:01 +01:00
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) {
2022-10-14 14:16:16 +02:00
this.selectAccount(account.id)
}
else {
this.$refs.OtpDisplayer.show(account.id)
}
2020-01-02 00:09:52 +01:00
},
2022-09-21 21:50:41 +02:00
2022-10-12 11:30:20 +02:00
/**
* 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)
},
2022-09-21 21:50:41 +02:00
/**
* 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
*/
2020-10-25 23:59:03 +01:00
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} )
2020-10-25 23:59:03 +01:00
// we fetch the accounts again to prevent the js collection being
// desynchronize from the backend php collection
this.fetchAccounts(true)
2020-10-28 17:51:32 +01:00
this.showGroupSelector = false
2020-10-25 23:59:03 +01:00
this.$notify({ type: 'is-success', text: this.$t('twofaccounts.accounts_moved') })
2020-10-25 23:59:03 +01:00
},
/**
* Get the existing group list
*/
2020-10-25 23:59:03 +01:00
fetchGroups() {
let groups = []
2020-10-25 23:59:03 +01:00
this.axios.get('api/v1/groups').then(response => {
2020-10-25 23:59:03 +01:00
response.data.forEach((data) => {
groups.push(data)
2020-10-25 23:59:03 +01:00
})
if ( !objectEquals(groups, this.groups) ) {
this.groups = groups
}
this.$storage.set('groups', this.groups)
2020-10-25 23:59:03 +01:00
})
},
/**
* Set the provided group as the active group
*/
setActiveGroup(id) {
2020-10-25 23:59:03 +01:00
// In memomry saving
this.form.value = this.$root.appSettings.activeGroup = id
2020-10-25 23:59:03 +01:00
// In db saving if the user set 2FAuth to memorize the active group
2020-11-23 13:01:56 +01:00
if( this.$root.appSettings.rememberActiveGroup ) {
this.form.put('/api/v1/settings/activeGroup', {returnError: true})
2020-11-23 13:01:56 +01:00
.then(response => {
// everything's fine
})
.catch(error => {
this.$router.push({ name: 'genericError', params: { err: error.response } })
});
}
2020-10-25 23:59:03 +01:00
this.closeGroupSwitch()
2020-10-25 23:59:03 +01:00
},
/**
* Toggle the group switch visibility
*/
2020-10-28 17:51:32 +01:00
toggleGroupSwitch: function(event) {
2020-10-25 23:59:03 +01:00
if (event) {
2020-10-28 17:51:32 +01:00
this.showGroupSwitch ? this.closeGroupSwitch() : this.openGroupSwitch()
2020-10-25 23:59:03 +01:00
}
},
/**
* show the group switch which allow to select a group to activate
*/
2020-10-28 17:51:32 +01:00
openGroupSwitch: function(event) {
2020-10-25 23:59:03 +01:00
2020-10-28 17:51:32 +01:00
this.showGroupSwitch = true
2020-10-25 23:59:03 +01:00
},
/**
* hide the group switch
*/
2020-10-28 17:51:32 +01:00
closeGroupSwitch: function(event) {
2020-10-25 23:59:03 +01:00
2020-10-28 17:51:32 +01:00
this.showGroupSwitch = false
2020-10-25 23:59:03 +01:00
},
/**
* Toggle the accounts list between View mode and Edit mode
*/
2020-01-31 11:44:45 +01:00
setEditModeTo(state) {
this.selectedAccounts = []
2020-01-31 11:44:45 +01:00
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()
},
2020-01-27 21:13:21 +01:00
}
};
</script>
<style>
.flip-list-move {
transition: transform 0.5s;
}
.ghost {
opacity: 1;
/*background: hsl(0, 0%, 21%);*/
}
</style>