123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543 |
- <template>
- <div>
- <!-- Group switch -->
- <div 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 :disabled="group.id == $root.appSettings.activeGroup" class="button is-fullwidth is-dark has-text-light is-outlined" @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">
- <a class="button is-dark is-rounded" @click="closeGroupSwitch()">{{ $t('commons.close') }}</a>
- </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 is-dark has-text-light is-outlined" :class="{ 'is-link' : moveAccountsTo === group.id}" @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">
- <a class="button is-link is-rounded" @click="moveAccounts()">{{ $t('commons.move') }}</a>
- </p>
- <!-- Cancel button -->
- <p class="control">
- <a class="button is-dark is-rounded" @click="showGroupSelector = false">{{ $t('commons.cancel') }}</a>
- </p>
- </vue-footer>
- </div>
- <!-- show accounts list -->
- <div class="container" v-if="this.showAccounts">
- <!-- 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 has-text-white" 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 is-white" :id="'ckb_' + account.id" :value="account.id" type="checkbox" :name="'ckb_' + account.id" v-model="selectedAccounts">
- <label :for="'ckb_' + account.id"></label>
- </div>
- </div>
- </transition>
- <div class="tfa-cell tfa-content is-size-3 is-size-4-mobile" @click.stop="showAccount(account)">
- <div class="tfa-text has-ellipsis">
- <img :src="'/storage/icons/' + account.icon" v-if="account.icon && $root.appSettings.showAccountsIcons">
- {{ 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-dark is-rounded mr-1">
- {{ $t('commons.edit') }}
- </router-link>
- <router-link :to="{ name: 'showQRcode', params: { twofaccountId: account.id }}" class="tag is-dark is-rounded" :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">
- <!-- New item buttons -->
- <p class="control" v-if="!editMode">
- <a 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>
- </a>
- </p>
- <!-- Manage button -->
- <p class="control" v-if="!editMode">
- <a class="button is-dark is-rounded" @click="setEditModeTo(true)">{{ $t('commons.manage') }}</a>
- </p>
- <!-- Done button -->
- <p class="control" v-if="editMode">
- <a class="button is-success is-rounded" @click="setEditModeTo(false)">
- <span>{{ $t('commons.done') }}</span>
- <span class="icon is-small">
- <font-awesome-icon :icon="['fas', 'check']" />
- </span>
- </a>
- </p>
- </vue-footer>
- </div>
- <!-- header -->
- <div class="header has-background-black-ter" 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">
- <!-- toolbar -->
- <div class="toolbar has-text-centered" v-if="editMode">
- <div class="manage-buttons tags has-addons are-medium">
- <span class="tag is-dark">{{ selectedAccounts.length }} {{ $t('commons.selected') }}</span>
- <a class="tag is-link" v-if="selectedAccounts.length > 0" @click="showGroupSelector = true">
- {{ $t('commons.move') }} <font-awesome-icon :icon="['fas', 'layer-group']" />
- </a>
- <a class="tag is-danger" v-if="selectedAccounts.length > 0" @click="destroyAccounts">
- {{ $t('commons.delete') }} <font-awesome-icon :icon="['fas', 'trash']" />
- </a>
- </div>
- </div>
- <!-- search -->
- <div class="field" v-else>
- <div class="control has-icons-right">
- <input type="text" 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" />
- <a class="delete" v-if="search" @click="search = '' "></a>
- </span>
- </div>
- </div>
- <!-- group switch toggle -->
- <div class="is-clickable has-text-centered" v-if="!editMode">
- <div class="columns" @click="toggleGroupSwitch">
- <div class="column" v-if="!showGroupSwitch">
- {{ activeGroupName }} ({{ filteredAccounts.length }})
- <font-awesome-icon :icon="['fas', 'caret-down']" />
- </div>
- <div class="column" v-else>
- {{ $t('groups.select_accounts_to_show') }}
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- modal -->
- <modal v-model="showTwofaccountInModal">
- <otp-displayer ref="OtpDisplayer"></otp-displayer>
- </modal>
- </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 :
- * ~ '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'
- import Form from './../components/Form'
- import objectEquals from 'object-equals'
- 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.appSettings.activeGroup,
- }),
- }
- },
- 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
- }
- },
- /**
- * 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.appSettings.activeGroup))
- if(g) {
- return g.name
- }
- else {
- return this.$t('commons.all')
- }
- }
- },
- 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
- }
- // we fetch fresh data whatever. The user will be notified to reload the page if there are any data changes
- this.fetchAccounts()
- this.fetchGroups()
- // stop OTP generation on modal close
- this.$on('modalClose', function() {
- console.log('modalClose triggered')
- this.$refs.OtpDisplayer.clearOTP()
- });
- },
- 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/twofaccounts').then(response => {
- response.data.forEach((data) => {
- accounts.push(data)
- })
- if ( this.accounts.length > 0 && !objectEquals(accounts, this.accounts) && !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)
- }
- })
- },
- /**
- * 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) {
- for (var i=0 ; i<this.selectedAccounts.length ; i++) {
- if ( this.selectedAccounts[i] === account.id ) {
- this.selectedAccounts.splice(i,1);
- return
- }
- }
- this.selectedAccounts.push(account.id)
- }
- else {
- this.$refs.OtpDisplayer.show(account.id)
- }
- },
- /**
- * Save the account order in db
- */
- saveOrder() {
- this.drag = false
- this.axios.post('/api/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))
- // Backend will delete all accounts at the same time
- await this.axios.delete('/api/twofaccounts?ids=' + ids.join())
- // we fetch the accounts again to prevent the js collection being
- // desynchronize from the backend php collection
- this.fetchAccounts(true)
- }
- },
- /**
- * 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/twofaccounts/withdraw?ids=' + accountsIds.join() )
- }
- else await this.axios.post('/api/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.fetchGroups()
- this.showGroupSelector = false
- },
- /**
- * Get the existing group list
- */
- fetchGroups() {
- let groups = []
- this.axios.get('api/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.appSettings.activeGroup = id
- // In db saving if the user set 2FAuth to memorize the active group
- if( this.$root.appSettings.rememberActiveGroup ) {
- this.form.put('/api/settings/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) {
- if( state === false ) {
- this.selectedAccounts = []
- }
- else {
- this.search = '';
- }
- this.editMode = state
- this.$parent.showToolbar = state
- },
- /**
- *
- */
- displayService(service) {
- return service ? service : this.$t('twofaccounts.no_service')
- }
- }
- };
- </script>
- <style>
- .flip-list-move {
- transition: transform 0.5s;
- }
- .ghost {
- opacity: 1;
- /*background: hsl(0, 0%, 21%);*/
- }
- </style>
|