mirror of
https://github.com/Bubka/2FAuth.git
synced 2024-11-22 16:23:18 +01:00
Set up the Accounts view with components
This commit is contained in:
parent
4055a52daf
commit
5f83b5d70b
73
resources/js_vue3/components/DestinationGroupSelector.vue
Normal file
73
resources/js_vue3/components/DestinationGroupSelector.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import twofaccountService from '@/services/twofaccountService'
|
||||
import groupService from '@/services/groupService'
|
||||
import { UseColorMode } from '@vueuse/components'
|
||||
|
||||
const props = defineProps({
|
||||
showDestinationGroupSelector: Boolean,
|
||||
selectedAccountsIds: Array,
|
||||
groups: Array
|
||||
})
|
||||
const destinationGroupId = ref(null)
|
||||
|
||||
const emit = defineEmits(['update:showDestinationGroupSelector, accounts-moved'])
|
||||
|
||||
/**
|
||||
* Move accounts selected from the Edit mode to another group or withdraw them
|
||||
*/
|
||||
async function moveAccounts() {
|
||||
// Backend will associate all provided accounts with the selected group in the same move
|
||||
// or withdraw the accounts if destination is 'no group' (id = 0)
|
||||
if (destinationGroupId.value === 0) {
|
||||
await twofaccountService.withdraw(props.selectedAccountsIds)
|
||||
}
|
||||
else await groupService.assign(props.selectedAccountsIds, destinationGroupId.value)
|
||||
|
||||
emit('accounts-moved')
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Group selector -->
|
||||
<div class="container group-selector">
|
||||
<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">
|
||||
<UseColorMode v-slot="{ mode }">
|
||||
<button class="button is-fullwidth" :class="{'is-link' : destinationGroupId === group.id, 'is-dark has-text-light is-outlined': mode == 'dark'}" @click="destinationGroupId = group.id">
|
||||
<span v-if="group.id === 0" class="is-italic">
|
||||
{{ $t('groups.no_group') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ group.name }}
|
||||
</span>
|
||||
</button>
|
||||
</UseColorMode>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-centered">
|
||||
<div class="column has-text-centered">
|
||||
<RouterLink :to="{ name: 'groups' }" >{{ $t('groups.manage_groups') }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VueFooter :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">
|
||||
<UseColorMode v-slot="{ mode }">
|
||||
<button id="btnCancel" class="button is-rounded" :class="{'is-dark' : mode == 'dark'}" @click="$emit('update:showDestinationGroupSelector', false)">{{ $t('commons.cancel') }}</button>
|
||||
</UseColorMode>
|
||||
</p>
|
||||
</VueFooter>
|
||||
</div>
|
||||
</template>
|
56
resources/js_vue3/components/GroupSwitch.vue
Normal file
56
resources/js_vue3/components/GroupSwitch.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import userService from '@/services/userService'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { UseColorMode } from '@vueuse/components'
|
||||
|
||||
const user = useUserStore()
|
||||
const props = defineProps({
|
||||
showGroupSwitch: Boolean,
|
||||
groups: Array
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:showGroupSwitch'])
|
||||
|
||||
/**
|
||||
* Sets the selected group as the active group
|
||||
*/
|
||||
function setActiveGroup(id) {
|
||||
user.preferences.activeGroup = id
|
||||
|
||||
if( user.preferences.rememberActiveGroup ) {
|
||||
userService.updatePreference('activeGroup', id)
|
||||
}
|
||||
|
||||
emit('update:showGroupSwitch', false)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="groupSwitch" class="container groups">
|
||||
<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" :key="group.id">
|
||||
<UseColorMode v-slot="{ mode }">
|
||||
<button class="button is-fullwidth" :class="{'is-dark has-text-light is-outlined': mode == 'dark'}" @click="setActiveGroup(group.id)">{{ group.name }}</button>
|
||||
</UseColorMode>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-centered">
|
||||
<div class="column has-text-centered">
|
||||
<RouterLink :to="{ name: 'groups' }" >{{ $t('groups.manage_groups') }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VueFooter :showButtons="true">
|
||||
<!-- Close Group switch button -->
|
||||
<p class="control">
|
||||
<UseColorMode v-slot="{ mode }">
|
||||
<button id="btnClose" class="button is-rounded" :class="{'is-dark' : mode == 'dark'}" @click="$emit('update:showGroupSwitch', false)">{{ $t('commons.close') }}</button>
|
||||
</UseColorMode>
|
||||
</p>
|
||||
</VueFooter>
|
||||
</div>
|
||||
</template>
|
45
resources/js_vue3/components/SearchBox.vue
Normal file
45
resources/js_vue3/components/SearchBox.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
keyword: String
|
||||
})
|
||||
const searchInput = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', keyListener)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', keyListener)
|
||||
})
|
||||
|
||||
/**
|
||||
* Attach an event listen for ctrl+F
|
||||
*/
|
||||
function keyListener(e) {
|
||||
if (e.key === "f" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="search" class="field">
|
||||
<div class="control has-icons-right">
|
||||
<input
|
||||
ref="searchInput"
|
||||
id="txtSearch"
|
||||
type="search"
|
||||
tabindex="1"
|
||||
:aria-label="$t('commons.search')"
|
||||
:title="$t('commons.search')"
|
||||
class="input is-rounded is-search"
|
||||
:value="keyword"
|
||||
v-on:keyup="$emit('update:keyword', $event.target.value)">
|
||||
<span class="icon is-small is-right">
|
||||
<button v-if="keyword != ''" id="btnClearSearch" tabindex="1" :title="$t('commons.clear_search')" class="clear-selection delete" @click="$emit('update:keyword','')"></button>
|
||||
<FontAwesomeIcon v-else :icon="['fas', 'search']" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
30
resources/js_vue3/components/Toolbar.vue
Normal file
30
resources/js_vue3/components/Toolbar.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
selectedCount: Number
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar has-text-centered">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<!-- selected label -->
|
||||
<span class="has-text-grey mr-1">{{ selectedCount }} {{ $t('commons.selected') }}</span>
|
||||
<!-- deselect all -->
|
||||
<button id="btnUnselectAll" @click="$emit('clear-selected')" class="clear-selection delete mr-4" :style="{visibility: selectedCount > 0 ? 'visible' : 'hidden'}" :title="$t('commons.clear_selection')"></button>
|
||||
<!-- select all button -->
|
||||
<button id="btnSelectAll" @click="$emit('select-all')" class="button mr-5 has-line-height p-1 is-ghost has-text-grey" :title="$t('commons.select_all')">
|
||||
<span>{{ $t('commons.all') }}</span>
|
||||
<FontAwesomeIcon class="ml-1" :icon="['fas', 'check-square']" />
|
||||
</button>
|
||||
<!-- sort asc/desc buttons -->
|
||||
<button id="btnSortAscending" @click="$emit('sort-asc')" class="button has-line-height p-1 is-ghost has-text-grey" :title="$t('commons.sort_ascending')">
|
||||
<FontAwesomeIcon :icon="['fas', 'sort-alpha-down']" />
|
||||
</button>
|
||||
<button id="btnSortDescending" @click="$emit('sort-desc')" class="button has-line-height p-1 is-ghost has-text-grey" :title="$t('commons.sort_descending')">
|
||||
<FontAwesomeIcon :icon="['fas', 'sort-alpha-up']" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,15 +1,24 @@
|
||||
<script setup>
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useBusStore } from '@/stores/bus'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
const user = useUserStore()
|
||||
const bus = useBusStore()
|
||||
const $2fauth = inject('2fauth')
|
||||
|
||||
const props = defineProps({
|
||||
showButtons: true,
|
||||
editMode: false,
|
||||
showButtons: true
|
||||
})
|
||||
|
||||
const emit = defineEmits(['management-mode-exited'])
|
||||
|
||||
function exitManagementMode() {
|
||||
bus.inManagementMode = false
|
||||
emit('management-mode-exited')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
if(confirm(trans('auth.confirm.logout'))) {
|
||||
user.logout()
|
||||
@ -26,8 +35,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editMode" class="content has-text-centered">
|
||||
<button id="lnkExitEdit" class="button is-ghost is-like-text" @click="$emit('exit-edit')">{{ $t('commons.done') }}</button>
|
||||
<div v-if="bus.inManagementMode" class="content has-text-centered">
|
||||
<button id="lnkExitEdit" class="button is-ghost is-like-text" @click="exitManagementMode">{{ $t('commons.done') }}</button>
|
||||
</div>
|
||||
<div v-else class="content has-text-centered">
|
||||
<div v-if="$route.meta.showAbout === true" class="is-size-6">
|
||||
|
12
resources/js_vue3/router/index.js
vendored
12
resources/js_vue3/router/index.js
vendored
@ -6,10 +6,10 @@ import Start from '../views/Start.vue'
|
||||
import Accounts from '../views/Accounts.vue'
|
||||
import Capture from '../views/twofaccounts/Capture.vue'
|
||||
import CreateAccount from '../views/twofaccounts/Create.vue'
|
||||
// import EditAccount from './views/twofaccounts/Edit.vue'
|
||||
import EditAccount from '../views/twofaccounts/Edit.vue'
|
||||
import ImportAccount from '../views/twofaccounts/Import.vue'
|
||||
// import QRcodeAccount from './views/twofaccounts/QRcode.vue'
|
||||
// import Groups from './views/Groups.vue'
|
||||
import QRcodeAccount from '../views/twofaccounts/QRcode.vue'
|
||||
import Groups from '../views/Groups.vue'
|
||||
// import CreateGroup from './views/groups/Create.vue'
|
||||
// import EditGroup from './views/groups/Edit.vue'
|
||||
import Login from '../views/auth/Login.vue'
|
||||
@ -39,10 +39,10 @@ const router = createRouter({
|
||||
{ path: '/accounts', name: 'accounts', component: Accounts, meta: { middlewares: [authGuard] }, alias: '/' },
|
||||
{ path: '/account/create', name: 'createAccount', component: CreateAccount, meta: { middlewares: [authGuard] } },
|
||||
{ path: '/account/import', name: 'importAccounts', component: ImportAccount, meta: { middlewares: [authGuard] } },
|
||||
// { path: '/account/:twofaccountId/edit', name: 'editAccount', component: EditAccount, meta: { middlewares: [authGuard] } },
|
||||
// { path: '/account/:twofaccountId/qrcode', name: 'showQRcode', component: QRcodeAccount, meta: { middlewares: [authGuard] } },
|
||||
{ path: '/account/:twofaccountId/edit', name: 'editAccount', component: EditAccount, meta: { middlewares: [authGuard] } },
|
||||
{ path: '/account/:twofaccountId/qrcode', name: 'showQRcode', component: QRcodeAccount, meta: { middlewares: [authGuard] } },
|
||||
|
||||
// { path: '/groups', name: 'groups', component: Groups, meta: { middlewares: [authGuard] }, props: true },
|
||||
{ path: '/groups', name: 'groups', component: Groups, meta: { middlewares: [authGuard] }, props: true },
|
||||
// { path: '/group/create', name: 'createGroup', component: CreateGroup, meta: { middlewares: [authGuard] } },
|
||||
// { path: '/group/:groupId/edit', name: 'editGroup', component: EditGroup, meta: { middlewares: [authGuard] }, props: true },
|
||||
|
||||
|
4
resources/js_vue3/services/groupService.js
vendored
4
resources/js_vue3/services/groupService.js
vendored
@ -10,5 +10,9 @@ export default {
|
||||
getAll() {
|
||||
return apiClient.get('groups')
|
||||
},
|
||||
|
||||
assign(accountsIds, groupId, config = {}) {
|
||||
return apiClient.post('/groups/' + groupId + '/assign', {ids: accountsIds})
|
||||
}
|
||||
|
||||
}
|
24
resources/js_vue3/services/twofaccountService.js
vendored
24
resources/js_vue3/services/twofaccountService.js
vendored
@ -3,12 +3,8 @@ import { httpClientFactory } from '@/services/httpClientFactory'
|
||||
const apiClient = httpClientFactory('api')
|
||||
|
||||
export default {
|
||||
/**
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
getAll() {
|
||||
return apiClient.get('/twofaccounts')
|
||||
getAll(withOtp = false) {
|
||||
return apiClient.get('/twofaccounts' + (withOtp ? '?withOtp=1' : ''))
|
||||
},
|
||||
|
||||
get(id, config = {}) {
|
||||
@ -38,5 +34,21 @@ export default {
|
||||
getOtpByParams(params, config = {}) {
|
||||
return apiClient.post('/twofaccounts/otp', params, { ...config })
|
||||
},
|
||||
|
||||
withdraw(ids, config = {}) {
|
||||
return apiClient.patch('/twofaccounts/withdraw?ids=' + ids.join())
|
||||
},
|
||||
|
||||
saveOrder(orderedIds, config = {}) {
|
||||
return apiClient.post('/api/v1/twofaccounts/reorder', { orderedIds: orderedIds })
|
||||
},
|
||||
|
||||
batchDelete(ids, config = {}) {
|
||||
return apiClient.delete('/twofaccounts?ids=' + ids, { ...config })
|
||||
},
|
||||
|
||||
export(ids, config = {}) {
|
||||
return apiClient.delete('/twofaccounts/export?ids=' + ids, { ...config })
|
||||
},
|
||||
|
||||
}
|
2
resources/js_vue3/stores/bus.js
vendored
2
resources/js_vue3/stores/bus.js
vendored
@ -9,7 +9,7 @@ export const useBusStore = defineStore({
|
||||
decodedUri: null,
|
||||
goBackTo: null,
|
||||
returnTo: null,
|
||||
initialEditMode: null,
|
||||
inManagementMode: false,
|
||||
}
|
||||
},
|
||||
|
||||
|
4
resources/js_vue3/stores/notify.js
vendored
4
resources/js_vue3/stores/notify.js
vendored
@ -63,6 +63,10 @@ export const useNotifyStore = defineStore({
|
||||
notify({ type: 'is-danger', ...notification})
|
||||
},
|
||||
|
||||
action(notification) {
|
||||
notify({ type: 'is-dark', ...notification})
|
||||
},
|
||||
|
||||
clear() {
|
||||
notify({ clean: true })
|
||||
}
|
||||
|
@ -1,8 +1,665 @@
|
||||
<script setup>
|
||||
import twofaccountService from '@/services/twofaccountService'
|
||||
import groupService from '@/services/groupService'
|
||||
import Spinner from '@/components/Spinner.vue'
|
||||
import TotpLooper from '@/components/TotpLooper.vue'
|
||||
import GroupSwitch from '@/components/GroupSwitch.vue'
|
||||
import DestinationGroupSelector from '@/components/DestinationGroupSelector.vue'
|
||||
import SearchBox from '@/components/SearchBox.vue'
|
||||
import Toolbar from '@/components/Toolbar.vue'
|
||||
import OtpDisplay from '@/components/OtpDisplay.vue'
|
||||
import { UseColorMode } from '@vueuse/components'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useNotifyStore } from '@/stores/notify'
|
||||
import { useBusStore } from '@/stores/bus'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { useDisplayablePassword } from '@/composables/helpers'
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
const $2fauth = inject('2fauth')
|
||||
const notify = useNotifyStore()
|
||||
const user = useUserStore()
|
||||
const bus = useBusStore()
|
||||
const router = useRouter()
|
||||
const appSettings = useAppSettingsStore()
|
||||
const { copy, copied } = useClipboard({ legacy: true })
|
||||
|
||||
const search = ref('')
|
||||
const accounts = ref([])
|
||||
const groups = ref([])
|
||||
const selectedAccounts = ref([])
|
||||
const showOtpInModal = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const showGroupSwitch = ref(false)
|
||||
const showDestinationGroupSelector = ref(false)
|
||||
const otpDisplay = ref(null)
|
||||
|
||||
const selectedAccountsIds = computed(() => {
|
||||
return selectedAccounts.value.forEach(id => ids.push(id))
|
||||
})
|
||||
|
||||
/**
|
||||
* Returns the name of a group
|
||||
*/
|
||||
const activeGroupName = computed(() => {
|
||||
const g = groups.value.find(el => el.id === parseInt(user.preferences.activeGroup))
|
||||
|
||||
return g ? g.name : trans('commons.all')
|
||||
})
|
||||
|
||||
/**
|
||||
* Returns whether or not the accounts should be displayed
|
||||
*/
|
||||
const showAccounts = computed(() => {
|
||||
return accounts.value.length > 0 && !showGroupSwitch.value && !showDestinationGroupSelector.value
|
||||
})
|
||||
|
||||
watch(showOtpInModal, (val) => {
|
||||
if (val == false) {
|
||||
otpDisplay.value?.clearOTP()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// we don't have to fetch fresh data so we try to load them from localstorage to avoid display latency
|
||||
// if( user.preferences.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
|
||||
fetchAccounts()
|
||||
|
||||
// stop OTP generation on modal close
|
||||
// this.$on('modalClose', function() {
|
||||
// this.$refs.OtpDisplayer.clearOTP()
|
||||
// })
|
||||
})
|
||||
|
||||
/**
|
||||
* The actual list of displayed accounts
|
||||
*/
|
||||
const filteredAccounts = computed({
|
||||
get() {
|
||||
return accounts.value.filter(
|
||||
item => {
|
||||
if (parseInt(user.preferences.activeGroup) > 0 ) {
|
||||
return ((item.service ? item.service.toLowerCase().includes(search.value.toLowerCase()) : false) ||
|
||||
item.account.toLowerCase().includes(search.value.toLowerCase())) &&
|
||||
(item.group_id == parseInt(user.preferences.activeGroup))
|
||||
}
|
||||
else {
|
||||
return ((item.service ? item.service.toLowerCase().includes(search.value.toLowerCase()) : false) ||
|
||||
item.account.toLowerCase().includes(search.value.toLowerCase()))
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
set(newValue) {
|
||||
accounts.value = newValue
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Fetch accounts from db
|
||||
*/
|
||||
function fetchAccounts(forceRefresh = false) {
|
||||
let _accounts = []
|
||||
selectedAccounts.value = []
|
||||
|
||||
twofaccountService.getAll(!user.preferences.getOtpOnRequest).then(response => {
|
||||
response.data.forEach((data) => {
|
||||
_accounts.push(data)
|
||||
})
|
||||
|
||||
// if ( accounts.value.length > 0 && !objectEquals(_accounts, accounts.value, { depth: 1 }) && !forceRefresh ) {
|
||||
// notify.action({
|
||||
// text: '<span class="is-size-7">' + trans('commons.some_data_have_changed') + '</span><br /><a href="." class="button is-rounded is-warning is-small">' + trans('commons.reload') + '</a>',
|
||||
// duration:-1,
|
||||
// closeOnClick: false
|
||||
// })
|
||||
// }
|
||||
if( accounts.value.length === 0 && _accounts.length === 0 ) {
|
||||
// No account yet, we force user to land on the start view.
|
||||
//this.$storage.set('accounts', this.accounts)
|
||||
router.push({ name: 'start' });
|
||||
}
|
||||
else {
|
||||
accounts.value = _accounts
|
||||
//this.$storage.set('accounts', accounts.value)
|
||||
fetchGroups()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an account while in edit mode
|
||||
*/
|
||||
function selectAccount(accountId) {
|
||||
for (var i=0 ; i < selectedAccounts.value.length ; i++) {
|
||||
if ( selectedAccounts.value[i] === accountId ) {
|
||||
selectedAccounts.value.splice(i,1);
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
selectedAccounts.value.push(accountId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete accounts selected from the Edit mode
|
||||
*/
|
||||
async function destroyAccounts() {
|
||||
if(confirm(trans('twofaccounts.confirm.delete'))) {
|
||||
await twofaccountService.batchDelete(selectedAccountsIds.join())
|
||||
.then(response => {
|
||||
ids.forEach(function(id) {
|
||||
accounts.value = accounts.filter(a => a.id !== id)
|
||||
})
|
||||
notify.info({ text: trans('twofaccounts.accounts_deleted') })
|
||||
})
|
||||
|
||||
// we fetch the accounts again to prevent the js collection being
|
||||
// desynchronize from the backend php collection
|
||||
fetchAccounts(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export selected accounts to a downloadable file
|
||||
*/
|
||||
function exportAccounts() {
|
||||
twofaccountService.export(selectedAccountsIds.join(), {responseType: 'blob'})
|
||||
.then((response) => {
|
||||
var blob = new Blob([response.data], {type: "application/json;charset=utf-8"});
|
||||
saveAs.saveAs(blob, "2fauth_export.json");
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs some updates after accounts assignement/withdraw
|
||||
*/
|
||||
function postGroupAssignementUpdate() {
|
||||
// we fetch the accounts again to prevent the js collection being
|
||||
// desynchronize from the backend php collection
|
||||
fetchAccounts(true)
|
||||
notify.info({ text: trans('twofaccounts.accounts_moved') })
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows rotating OTP for the provided account
|
||||
*/
|
||||
function showOTP(account) {
|
||||
// In Edit mode, clicking an account does not show the otpDisplay, it selects the account
|
||||
if(bus.inManagementMode) {
|
||||
selectAccount(account.id)
|
||||
}
|
||||
else {
|
||||
showOtpInModal.value = true
|
||||
otpDisplay.value.show(account.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets groups list from backend
|
||||
*/
|
||||
function fetchGroups() {
|
||||
groupService.getAll().then(response => {
|
||||
let _groups = []
|
||||
|
||||
response.data.forEach((data) => {
|
||||
_groups.push(data)
|
||||
})
|
||||
|
||||
// if ( !objectEquals(_groups, groups.value) ) {
|
||||
groups.value = _groups
|
||||
// }
|
||||
|
||||
//this.$storage.set('groups', this.groups)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes user to the appropriate submitting view
|
||||
*/
|
||||
function start() {
|
||||
if( user.preferences.useDirectCapture && user.preferences.defaultCaptureMode === 'advancedForm' ) {
|
||||
router.push({ name: 'createAccount' })
|
||||
}
|
||||
else if( user.preferences.useDirectCapture && user.preferences.defaultCaptureMode === 'livescan' ) {
|
||||
router.push({ name: 'capture' })
|
||||
}
|
||||
else {
|
||||
router.push({ name: 'start' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an OTP in a modal or directly copies it to the clipboard
|
||||
*/
|
||||
function showOrCopy(account) {
|
||||
if (!user.preferences.getOtpOnRequest && account.otp_type.includes('totp')) {
|
||||
copyOTP(account.otp.password)
|
||||
}
|
||||
else {
|
||||
showOTP(account)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies an OTP
|
||||
*/
|
||||
function copyOTP (password) {
|
||||
copy(password)
|
||||
|
||||
if (copied) {
|
||||
if(user.preferences.kickUserAfter == -1) {
|
||||
user.logout()
|
||||
}
|
||||
notify.info({ text: trans('commons.copied_to_clipboard') })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a fresh OTP from backend and copies it
|
||||
*/
|
||||
async function getAndCopyOTP(account) {
|
||||
twofaccountService.getOtpById(account.id).then(response => {
|
||||
let otp = response.data
|
||||
copyOTP(otp.password)
|
||||
|
||||
if (otp.otp_type == 'hotp') {
|
||||
let hotpToIncrement = accounts.value.find((acc) => acc.id == account.id)
|
||||
|
||||
// TODO : à koi ça sert ?
|
||||
if (hotpToIncrement != undefined) {
|
||||
hotpToIncrement.counter = otp.counter
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the account order in db
|
||||
*/
|
||||
function saveOrder() {
|
||||
isDragging.value = false
|
||||
const orderedIds = accounts.value.map(a => a.id)
|
||||
twofaccountService.saveOrder(orderedIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort accounts ascending
|
||||
*/
|
||||
function sortAsc() {
|
||||
accounts.value.sort((a, b) => a.service > b.service ? 1 : -1)
|
||||
saveOrder()
|
||||
}
|
||||
|
||||
/**
|
||||
*Sort accounts descending
|
||||
*/
|
||||
function sortDesc() {
|
||||
accounts.value.sort((a, b) => a.service < b.service ? 1 : -1)
|
||||
saveOrder()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all accounts while in edit mode
|
||||
*/
|
||||
function selectAll() {
|
||||
if(bus.inManagementMode) {
|
||||
accounts.value.forEach(function(account) {
|
||||
if ( !selectedAccounts.value.includes(account.id) ) {
|
||||
selectedAccounts.value.push(account.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unselect all accounts
|
||||
*/
|
||||
function unselectAll() {
|
||||
selectedAccounts.value = []
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>On Accounts view</div>
|
||||
<router-link :to="{ name: 'about' }">Go to About</router-link>
|
||||
<div>
|
||||
<GroupSwitch v-if="showGroupSwitch" v-model:showGroupSwitch="showGroupSwitch" />
|
||||
<DestinationGroupSelector
|
||||
v-if="showDestinationGroupSelector"
|
||||
v-model:showDestinationGroupSelector="showDestinationGroupSelector"
|
||||
v-model:selectedAccountsIds="selectedAccountsIds"
|
||||
@account-moved="postGroupAssignementUpdate">
|
||||
</DestinationGroupSelector>
|
||||
<!-- header -->
|
||||
<div class="header" v-if="showAccounts || 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 -->
|
||||
<SearchBox v-model:keyword="search"/>
|
||||
<!-- toolbar -->
|
||||
<Toolbar v-if="bus.inManagementMode"
|
||||
:selectedCount="selectedAccounts.length"
|
||||
@clear-selected="selectedAccounts = []"
|
||||
@select-all="selectAll"
|
||||
@sort-asc="sortAsc"
|
||||
@sort-desc="sortDesc">
|
||||
</Toolbar>
|
||||
<!-- group switch toggle -->
|
||||
<div v-else class="has-text-centered">
|
||||
<div class="columns">
|
||||
<UseColorMode v-slot="{ mode }">
|
||||
<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' : mode != 'dark'}" @click.stop="showGroupSwitch = !showGroupSwitch">
|
||||
{{ activeGroupName }} ({{ filteredAccounts.length }})
|
||||
<FontAwesomeIcon :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' : mode != 'dark'}" @click.stop="showGroupSwitch = !showGroupSwitch">
|
||||
{{ $t('groups.select_accounts_to_show') }}
|
||||
</button>
|
||||
</div>
|
||||
</UseColorMode>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- modal -->
|
||||
<Modal v-model="showOtpInModal">
|
||||
<OtpDisplay
|
||||
ref="otpDisplay"
|
||||
@please-close-me="showOtpInModal = false">
|
||||
</OtpDisplay>
|
||||
</Modal>
|
||||
<!-- show accounts list -->
|
||||
<div class="container" v-if="showAccounts" :class="bus.inManagementMode ? 'is-edit-mode' : ''">
|
||||
<!-- accounts -->
|
||||
<!-- <vue-pull-refresh :on-refresh="onRefresh" :config="{
|
||||
errorLabel: 'error',
|
||||
startLabel: '',
|
||||
readyLabel: '',
|
||||
loadingLabel: 'refreshing'
|
||||
}" > -->
|
||||
<!-- <draggable v-model="filteredAccounts" @start="isDragging = true" @end="saveOrder" ghost-class="ghost" handle=".tfa-dots" animation="200" class="accounts"> -->
|
||||
<div class="accounts">
|
||||
<!-- <transition-group class="columns is-multiline" :class="{ 'is-centered': user.preferences.displayMode === 'grid' }" type="transition" :name="!isDragging ? 'flip-list' : null"> -->
|
||||
<span class="columns is-multiline">
|
||||
<div :class="[user.preferences.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="bus.inManagementMode">
|
||||
<div class="field">
|
||||
<UseColorMode v-slot="{ mode }">
|
||||
<input class="is-checkradio is-small" :class="mode == 'dark' ? 'is-white':'is-info'" :id="'ckb_' + account.id" :value="account.id" type="checkbox" :name="'ckb_' + account.id" v-model="selectedAccounts">
|
||||
</UseColorMode>
|
||||
<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="$2fauth.config.subdirectory + '/storage/icons/' + account.icon" v-if="account.icon && user.preferences.showAccountsIcons" :alt="$t('twofaccounts.icon_for_account_x_at_service_y', {account: account.account, service: account.service})">
|
||||
{{ account.service ? account.service : $t('twofaccounts.no_service') }}<FontAwesomeIcon class="has-text-danger is-size-5 ml-2" v-if="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="user.preferences.getOtpOnRequest == false && !bus.inManagementMode" class="has-text-right">
|
||||
<span v-if="account.otp != undefined && isRenewingOTPs" class="has-nowrap has-text-grey has-text-centered is-size-5">
|
||||
<FontAwesomeIcon :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')">
|
||||
{{ useDisplayablePassword(account.otp.password) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<!-- get hotp button -->
|
||||
<UseColorMode v-slot="{ mode }">
|
||||
<button class="button tag" :class="mode == 'dark' ? 'is-dark' : 'is-white'" @click="showOTP(account)" :title="$t('twofaccounts.import.import_this_account')">
|
||||
{{ $t('commons.generate') }}
|
||||
</button>
|
||||
</UseColorMode>
|
||||
</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="bus.inManagementMode">
|
||||
<UseColorMode v-slot="{ mode }">
|
||||
<RouterLink :to="{ name: 'editAccount', params: { twofaccountId: account.id }}" class="tag is-rounded mr-1" :class="mode == 'dark' ? 'is-dark' : 'is-white'">
|
||||
{{ $t('commons.edit') }}
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'showQRcode', params: { twofaccountId: account.id }}" class="tag is-rounded" :class="mode == 'dark' ? 'is-dark' : 'is-white'" :title="$t('twofaccounts.show_qrcode')">
|
||||
<FontAwesomeIcon :icon="['fas', 'qrcode']" />
|
||||
</RouterLink>
|
||||
</UseColorMode>
|
||||
</div>
|
||||
<!-- </transition> -->
|
||||
<!-- <transition name="fadeInOut"> -->
|
||||
<div class="tfa-cell tfa-dots has-text-grey" v-if="bus.inManagementMode">
|
||||
<FontAwesomeIcon :icon="['fas', 'bars']" />
|
||||
</div>
|
||||
<!-- </transition> -->
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<!-- </transition-group> -->
|
||||
</div>
|
||||
<!-- </draggable> -->
|
||||
<!-- </vue-pull-refresh> -->
|
||||
<VueFooter :showButtons="true" v-on:management-mode-exited="unselectAll">
|
||||
<UseColorMode v-slot="{ mode }">
|
||||
<!-- New item buttons -->
|
||||
<p class="control" v-if="!bus.inManagementMode">
|
||||
<button class="button is-link is-rounded is-focus" @click="start">
|
||||
<span>{{ $t('commons.new') }}</span>
|
||||
<span class="icon is-small">
|
||||
<FontAwesomeIcon :icon="['fas', 'qrcode']" />
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
<!-- Manage button -->
|
||||
<p class="control" v-if="!bus.inManagementMode">
|
||||
<button id="btnManage" class="button is-rounded" :class="{'is-dark' : mode == 'dark'}" @click="bus.inManagementMode = true">{{ $t('commons.manage') }}</button>
|
||||
</p>
|
||||
<!-- move button -->
|
||||
<p class="control" v-if="bus.inManagementMode">
|
||||
<button
|
||||
id="btnMove"
|
||||
:disabled='selectedAccounts.length == 0' class="button is-rounded"
|
||||
:class="[{ 'is-outlined': mode == 'dark' || selectedAccounts.length == 0 }, selectedAccounts.length == 0 ? 'is-dark': 'is-link']"
|
||||
@click="showDestinationGroupSelector = true"
|
||||
:title="$t('groups.move_selected_to_group')" >
|
||||
{{ $t('commons.move') }}
|
||||
</button>
|
||||
</p>
|
||||
<!-- delete button -->
|
||||
<p class="control" v-if="bus.inManagementMode">
|
||||
<button
|
||||
id="btnDelete"
|
||||
:disabled='selectedAccounts.length == 0' class="button is-rounded"
|
||||
:class="[{ 'is-outlined': mode == 'dark' || selectedAccounts.length == 0 }, selectedAccounts.length == 0 ? 'is-dark': 'is-link']"
|
||||
@click="destroyAccounts" >
|
||||
{{ $t('commons.delete') }}
|
||||
</button>
|
||||
</p>
|
||||
<!-- export button -->
|
||||
<p class="control" v-if="bus.inManagementMode">
|
||||
<button
|
||||
id="btnExport"
|
||||
:disabled='selectedAccounts.length == 0' class="button is-rounded"
|
||||
:class="[{ 'is-outlined': mode == 'dark' || selectedAccounts.length == 0 }, selectedAccounts.length == 0 ? 'is-dark': 'is-link']"
|
||||
@click="exportAccounts"
|
||||
:title="$t('twofaccounts.export_selected_to_json')" >
|
||||
{{ $t('commons.export') }}
|
||||
</button>
|
||||
</p>
|
||||
</UseColorMode>
|
||||
</VueFooter>
|
||||
</div>
|
||||
<!-- totp loopers -->
|
||||
<!-- <span v-if="!user.preferences.getOtpOnRequest">
|
||||
<TotpLooper
|
||||
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"
|
||||
></TotpLooper>
|
||||
</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
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
|
||||
// export default {
|
||||
// data(){
|
||||
// return {
|
||||
// stepIndexes: {},
|
||||
// isRenewingOTPs: false
|
||||
// }
|
||||
// },
|
||||
|
||||
// computed: {
|
||||
/**
|
||||
* Returns an array of all totp periods present in the twofaccounts list
|
||||
*/
|
||||
// periods() {
|
||||
// return !user.preferences.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: ['toRefresh'],
|
||||
|
||||
// methods: {
|
||||
/**
|
||||
*
|
||||
*/
|
||||
// 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)
|
||||
// },
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
// })
|
||||
// },
|
||||
|
||||
// }
|
||||
// };
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flip-list-move {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 1;
|
||||
/*background: hsl(0, 0%, 21%);*/
|
||||
}
|
||||
</style>
|
||||
|
Loading…
Reference in New Issue
Block a user