Accounts.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. <template>
  2. <div>
  3. <!-- Group switch -->
  4. <div class="container groups" v-if="showGroupSwitch">
  5. <div class="columns is-centered">
  6. <div class="column is-one-third-tablet is-one-quarter-desktop is-one-quarter-widescreen is-one-quarter-fullhd">
  7. <div class="columns is-multiline">
  8. <div class="column is-full" v-for="group in groups" v-if="group.twofaccounts_count > 0" :key="group.id">
  9. <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>
  10. </div>
  11. </div>
  12. <div class="columns is-centered">
  13. <div class="column has-text-centered">
  14. <router-link :to="{ name: 'groups' }" >{{ $t('groups.manage_groups') }}</router-link>
  15. </div>
  16. </div>
  17. </div>
  18. </div>
  19. <vue-footer :showButtons="true">
  20. <!-- Close Group switch button -->
  21. <p class="control">
  22. <a class="button is-dark is-rounded" @click="closeGroupSwitch()">{{ $t('commons.close') }}</a>
  23. </p>
  24. </vue-footer>
  25. </div>
  26. <!-- Group selector -->
  27. <div class="container group-selector" v-if="showGroupSelector">
  28. <div class="columns is-centered is-multiline">
  29. <div class="column is-full has-text-centered">
  30. {{ $t('groups.move_selected_to') }}
  31. </div>
  32. <div class="column is-one-third-tablet is-one-quarter-desktop is-one-quarter-widescreen is-one-quarter-fullhd">
  33. <div class="columns is-multiline">
  34. <div class="column is-full" v-for="group in groups" :key="group.id">
  35. <button class="button is-fullwidth is-dark has-text-light is-outlined" :class="{ 'is-link' : moveAccountsTo === group.id}" @click="moveAccountsTo = group.id">
  36. <span v-if="group.id === 0" class="is-italic">
  37. {{ $t('groups.no_group') }}
  38. </span>
  39. <span v-else>
  40. {{ group.name }}
  41. </span>
  42. </button>
  43. </div>
  44. </div>
  45. <div class="columns is-centered">
  46. <div class="column has-text-centered">
  47. <router-link :to="{ name: 'groups' }" >{{ $t('groups.manage_groups') }}</router-link>
  48. </div>
  49. </div>
  50. </div>
  51. </div>
  52. <vue-footer :showButtons="true">
  53. <!-- Move to selected group button -->
  54. <p class="control">
  55. <a class="button is-link is-rounded" @click="moveAccounts()">{{ $t('commons.move') }}</a>
  56. </p>
  57. <!-- Cancel button -->
  58. <p class="control">
  59. <a class="button is-dark is-rounded" @click="showGroupSelector = false">{{ $t('commons.cancel') }}</a>
  60. </p>
  61. </vue-footer>
  62. </div>
  63. <!-- show accounts list -->
  64. <div class="container" v-if="this.showAccounts">
  65. <!-- accounts -->
  66. <!-- <vue-pull-refresh :on-refresh="onRefresh" :config="{
  67. errorLabel: 'error',
  68. startLabel: '',
  69. readyLabel: '',
  70. loadingLabel: 'refreshing'
  71. }" > -->
  72. <draggable v-model="filteredAccounts" @start="drag = true" @end="saveOrder" ghost-class="ghost" handle=".tfa-dots" animation="200" class="accounts">
  73. <transition-group class="columns is-multiline" :class="{ 'is-centered': $root.appSettings.displayMode === 'grid' }" type="transition" :name="!drag ? 'flip-list' : null">
  74. <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">
  75. <div class="tfa-container">
  76. <transition name="slideCheckbox">
  77. <div class="tfa-cell tfa-checkbox" v-if="editMode">
  78. <div class="field">
  79. <input class="is-checkradio is-small is-white" :id="'ckb_' + account.id" :value="account.id" type="checkbox" :name="'ckb_' + account.id" v-model="selectedAccounts">
  80. <label :for="'ckb_' + account.id"></label>
  81. </div>
  82. </div>
  83. </transition>
  84. <div class="tfa-cell tfa-content is-size-3 is-size-4-mobile" @click.stop="showAccount(account)">
  85. <div class="tfa-text has-ellipsis">
  86. <img :src="'/storage/icons/' + account.icon" v-if="account.icon && $root.appSettings.showAccountsIcons">
  87. {{ 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']" />
  88. <span class="is-family-primary is-size-6 is-size-7-mobile has-text-grey ">{{ account.account }}</span>
  89. </div>
  90. </div>
  91. <transition name="fadeInOut">
  92. <div class="tfa-cell tfa-edit has-text-grey" v-if="editMode">
  93. <!-- <div class="tags has-addons"> -->
  94. <router-link :to="{ name: 'editAccount', params: { twofaccountId: account.id }}" class="tag is-dark is-rounded mr-1">
  95. {{ $t('commons.edit') }}
  96. </router-link>
  97. <router-link :to="{ name: 'showQRcode', params: { twofaccountId: account.id }}" class="tag is-dark is-rounded" :title="$t('twofaccounts.show_qrcode')">
  98. <font-awesome-icon :icon="['fas', 'qrcode']" />
  99. </router-link>
  100. <!-- </div> -->
  101. </div>
  102. </transition>
  103. <transition name="fadeInOut">
  104. <div class="tfa-cell tfa-dots has-text-grey" v-if="editMode">
  105. <font-awesome-icon :icon="['fas', 'bars']" />
  106. </div>
  107. </transition>
  108. </div>
  109. </div>
  110. </transition-group>
  111. </draggable>
  112. <!-- </vue-pull-refresh> -->
  113. <vue-footer :showButtons="true">
  114. <!-- New item buttons -->
  115. <p class="control" v-if="!editMode">
  116. <a class="button is-link is-rounded is-focus" @click="start">
  117. <span>{{ $t('commons.new') }}</span>
  118. <span class="icon is-small">
  119. <font-awesome-icon :icon="['fas', 'qrcode']" />
  120. </span>
  121. </a>
  122. </p>
  123. <!-- Manage button -->
  124. <p class="control" v-if="!editMode">
  125. <a class="button is-dark is-rounded" @click="setEditModeTo(true)">{{ $t('commons.manage') }}</a>
  126. </p>
  127. <!-- Done button -->
  128. <p class="control" v-if="editMode">
  129. <a class="button is-success is-rounded" @click="setEditModeTo(false)">
  130. <span>{{ $t('commons.done') }}</span>
  131. <span class="icon is-small">
  132. <font-awesome-icon :icon="['fas', 'check']" />
  133. </span>
  134. </a>
  135. </p>
  136. </vue-footer>
  137. </div>
  138. <!-- header -->
  139. <div class="header has-background-black-ter" v-if="this.showAccounts || this.showGroupSwitch">
  140. <div class="columns is-gapless is-mobile is-centered">
  141. <div class="column is-three-quarters-mobile is-one-third-tablet is-one-quarter-desktop is-one-quarter-widescreen is-one-quarter-fullhd">
  142. <!-- toolbar -->
  143. <div class="toolbar has-text-centered" v-if="editMode">
  144. <div class="manage-buttons tags has-addons are-medium">
  145. <span class="tag is-dark">{{ selectedAccounts.length }}&nbsp;{{ $t('commons.selected') }}</span>
  146. <a class="tag is-link" v-if="selectedAccounts.length > 0" @click="showGroupSelector = true">
  147. {{ $t('commons.move') }}&nbsp;<font-awesome-icon :icon="['fas', 'layer-group']" />
  148. </a>
  149. <a class="tag is-danger" v-if="selectedAccounts.length > 0" @click="destroyAccounts">
  150. {{ $t('commons.delete') }}&nbsp;<font-awesome-icon :icon="['fas', 'trash']" />
  151. </a>
  152. </div>
  153. </div>
  154. <!-- search -->
  155. <div class="field" v-else>
  156. <div class="control has-icons-right">
  157. <input type="text" class="input is-rounded is-search" v-model="search">
  158. <span class="icon is-small is-right">
  159. <font-awesome-icon :icon="['fas', 'search']" v-if="!search" />
  160. <a class="delete" v-if="search" @click="search = '' "></a>
  161. </span>
  162. </div>
  163. </div>
  164. <!-- group switch toggle -->
  165. <div class="is-clickable has-text-centered" v-if="!editMode">
  166. <div class="columns" @click="toggleGroupSwitch">
  167. <div class="column" v-if="!showGroupSwitch">
  168. {{ activeGroupName }} ({{ filteredAccounts.length }})
  169. <font-awesome-icon :icon="['fas', 'caret-down']" />
  170. </div>
  171. <div class="column" v-else>
  172. {{ $t('groups.select_accounts_to_show') }}
  173. </div>
  174. </div>
  175. </div>
  176. </div>
  177. </div>
  178. </div>
  179. <!-- modal -->
  180. <modal v-model="showTwofaccountInModal">
  181. <otp-displayer ref="OtpDisplayer"></otp-displayer>
  182. </modal>
  183. </div>
  184. </template>
  185. <script>
  186. /**
  187. * Accounts view
  188. *
  189. * route: '/account' (alias: '/')
  190. *
  191. * The main view of 2FAuth that list all existing account recorded in DB.
  192. * Available feature in this view :
  193. * - {{OTP}} generation
  194. * - Account fetching :
  195. * ~ Search
  196. * ~ Filtering (by group)
  197. * - Accounts management :
  198. * ~ Sorting
  199. * ~ QR code recovering
  200. * ~ Mass association to group
  201. * ~ Mass account deletion
  202. * ~ Access to account editing
  203. *
  204. * Behavior :
  205. * - The view has 2 modes (toggle is done with the 'manage' button) :
  206. * ~ The View mode (the default one)
  207. * ~ The Edit mode
  208. * - User are automatically pushed to the start view if there is no account to list.
  209. * - The view is affected by :
  210. * ~ 'appSettings.showAccountsIcons' toggle the icon visibility
  211. * ~ 'appSettings.displayMode' change the account appearance
  212. *
  213. * Input :
  214. * - The 'InitialEditMode' props : allows to load the view directly in Edit mode
  215. *
  216. */
  217. import Modal from '../components/Modal'
  218. import OtpDisplayer from '../components/OtpDisplayer'
  219. import draggable from 'vuedraggable'
  220. import Form from './../components/Form'
  221. import objectEquals from 'object-equals'
  222. export default {
  223. data(){
  224. return {
  225. accounts : [],
  226. groups : [],
  227. selectedAccounts: [],
  228. search: '',
  229. editMode: this.InitialEditMode,
  230. drag: false,
  231. showTwofaccountInModal : false,
  232. showGroupSwitch: false,
  233. showGroupSelector: false,
  234. moveAccountsTo: false,
  235. form: new Form({
  236. value: this.$root.appSettings.activeGroup,
  237. }),
  238. }
  239. },
  240. computed: {
  241. /**
  242. * The actual list of displayed accounts
  243. */
  244. filteredAccounts: {
  245. get: function() {
  246. return this.accounts.filter(
  247. item => {
  248. if( parseInt(this.$root.appSettings.activeGroup) > 0 ) {
  249. return ((item.service ? item.service.toLowerCase().includes(this.search.toLowerCase()) : false) ||
  250. item.account.toLowerCase().includes(this.search.toLowerCase())) &&
  251. (item.group_id == parseInt(this.$root.appSettings.activeGroup))
  252. }
  253. else {
  254. return ((item.service ? item.service.toLowerCase().includes(this.search.toLowerCase()) : false) ||
  255. item.account.toLowerCase().includes(this.search.toLowerCase()))
  256. }
  257. }
  258. );
  259. },
  260. set: function(reorderedAccounts) {
  261. this.accounts = reorderedAccounts
  262. }
  263. },
  264. /**
  265. * Returns whether or not the accounts should be displayed
  266. */
  267. showAccounts() {
  268. return this.accounts.length > 0 && !this.showGroupSwitch && !this.showGroupSelector ? true : false
  269. },
  270. /**
  271. * Returns the name of a group
  272. */
  273. activeGroupName() {
  274. let g = this.groups.find(el => el.id === parseInt(this.$root.appSettings.activeGroup))
  275. if(g) {
  276. return g.name
  277. }
  278. else {
  279. return this.$t('commons.all')
  280. }
  281. }
  282. },
  283. props: ['initialEditMode', 'toRefresh'],
  284. mounted() {
  285. // we don't have to fetch fresh data so we try to load them from localstorage to avoid display latency
  286. if( !this.toRefresh && !this.$route.params.isFirstLoad ) {
  287. const accounts = this.$storage.get('accounts', null) // use null as fallback if localstorage is empty
  288. if( accounts ) this.accounts = accounts
  289. const groups = this.$storage.get('groups', null) // use null as fallback if localstorage is empty
  290. if( groups ) this.groups = groups
  291. }
  292. // we fetch fresh data whatever. The user will be notified to reload the page if there are any data changes
  293. this.fetchAccounts()
  294. this.fetchGroups()
  295. // stop OTP generation on modal close
  296. this.$on('modalClose', function() {
  297. console.log('modalClose triggered')
  298. this.$refs.OtpDisplayer.clearOTP()
  299. });
  300. },
  301. components: {
  302. Modal,
  303. OtpDisplayer,
  304. draggable,
  305. },
  306. methods: {
  307. /**
  308. * Route user to the appropriate submitting view
  309. */
  310. start() {
  311. if( this.$root.appSettings.useDirectCapture && this.$root.appSettings.defaultCaptureMode === 'advancedForm' ) {
  312. this.$router.push({ name: 'createAccount' })
  313. }
  314. else if( this.$root.appSettings.useDirectCapture && this.$root.appSettings.defaultCaptureMode === 'livescan' ) {
  315. this.$router.push({ name: 'capture' })
  316. }
  317. else {
  318. this.$router.push({ name: 'start' })
  319. }
  320. },
  321. /**
  322. * Fetch accounts from db
  323. */
  324. fetchAccounts(forceRefresh = false) {
  325. let accounts = []
  326. this.selectedAccounts = []
  327. this.axios.get('api/twofaccounts').then(response => {
  328. response.data.forEach((data) => {
  329. accounts.push(data)
  330. })
  331. if ( this.accounts.length > 0 && !objectEquals(accounts, this.accounts) && !forceRefresh ) {
  332. 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 })
  333. }
  334. else if( this.accounts.length === 0 && accounts.length === 0 ) {
  335. // No account yet, we force user to land on the start view.
  336. this.$storage.set('accounts', this.accounts)
  337. this.$router.push({ name: 'start' });
  338. }
  339. else {
  340. this.accounts = accounts
  341. this.$storage.set('accounts', this.accounts)
  342. }
  343. })
  344. },
  345. /**
  346. * Show account with a generated {{OTP}} rotation
  347. */
  348. showAccount(account) {
  349. // In Edit mode clicking an account do not show the otpDisplayer but select the account
  350. if(this.editMode) {
  351. for (var i=0 ; i<this.selectedAccounts.length ; i++) {
  352. if ( this.selectedAccounts[i] === account.id ) {
  353. this.selectedAccounts.splice(i,1);
  354. return
  355. }
  356. }
  357. this.selectedAccounts.push(account.id)
  358. }
  359. else {
  360. this.$refs.OtpDisplayer.show(account.id)
  361. }
  362. },
  363. /**
  364. * Save the account order in db
  365. */
  366. saveOrder() {
  367. this.drag = false
  368. this.axios.post('/api/twofaccounts/reorder', {orderedIds: this.accounts.map(a => a.id)})
  369. },
  370. /**
  371. * Delete accounts selected from the Edit mode
  372. */
  373. async destroyAccounts() {
  374. if(confirm(this.$t('twofaccounts.confirm.delete'))) {
  375. let ids = []
  376. this.selectedAccounts.forEach(id => ids.push(id))
  377. // Backend will delete all accounts at the same time
  378. await this.axios.delete('/api/twofaccounts?ids=' + ids.join())
  379. // we fetch the accounts again to prevent the js collection being
  380. // desynchronize from the backend php collection
  381. this.fetchAccounts(true)
  382. }
  383. },
  384. /**
  385. * Move accounts selected from the Edit mode to another group or withdraw them
  386. */
  387. async moveAccounts() {
  388. let accountsIds = []
  389. this.selectedAccounts.forEach(id => accountsIds.push(id))
  390. // Backend will associate all accounts with the selected group in the same move
  391. // or withdraw the accounts if destination is 'no group' (id = 0)
  392. if(this.moveAccountsTo === 0) {
  393. await this.axios.patch('/api/twofaccounts/withdraw?ids=' + accountsIds.join() )
  394. }
  395. else await this.axios.post('/api/groups/' + this.moveAccountsTo + '/assign', {ids: accountsIds} )
  396. // we fetch the accounts again to prevent the js collection being
  397. // desynchronize from the backend php collection
  398. this.fetchAccounts(true)
  399. this.fetchGroups()
  400. this.showGroupSelector = false
  401. },
  402. /**
  403. * Get the existing group list
  404. */
  405. fetchGroups() {
  406. let groups = []
  407. this.axios.get('api/groups').then(response => {
  408. response.data.forEach((data) => {
  409. groups.push(data)
  410. })
  411. if ( !objectEquals(groups, this.groups) ) {
  412. this.groups = groups
  413. }
  414. this.$storage.set('groups', this.groups)
  415. })
  416. },
  417. /**
  418. * Set the provided group as the active group
  419. */
  420. setActiveGroup(id) {
  421. // In memomry saving
  422. this.form.value = this.$root.appSettings.activeGroup = id
  423. // In db saving if the user set 2FAuth to memorize the active group
  424. if( this.$root.appSettings.rememberActiveGroup ) {
  425. this.form.put('/api/settings/activeGroup', {returnError: true})
  426. .then(response => {
  427. // everything's fine
  428. })
  429. .catch(error => {
  430. this.$router.push({ name: 'genericError', params: { err: error.response } })
  431. });
  432. }
  433. this.closeGroupSwitch()
  434. },
  435. /**
  436. * Toggle the group switch visibility
  437. */
  438. toggleGroupSwitch: function(event) {
  439. if (event) {
  440. this.showGroupSwitch ? this.closeGroupSwitch() : this.openGroupSwitch()
  441. }
  442. },
  443. /**
  444. * show the group switch which allow to select a group to activate
  445. */
  446. openGroupSwitch: function(event) {
  447. this.showGroupSwitch = true
  448. },
  449. /**
  450. * hide the group switch
  451. */
  452. closeGroupSwitch: function(event) {
  453. this.showGroupSwitch = false
  454. },
  455. /**
  456. * Toggle the accounts list between View mode and Edit mode
  457. */
  458. setEditModeTo(state) {
  459. if( state === false ) {
  460. this.selectedAccounts = []
  461. }
  462. else {
  463. this.search = '';
  464. }
  465. this.editMode = state
  466. this.$parent.showToolbar = state
  467. },
  468. /**
  469. *
  470. */
  471. displayService(service) {
  472. return service ? service : this.$t('twofaccounts.no_service')
  473. }
  474. }
  475. };
  476. </script>
  477. <style>
  478. .flip-list-move {
  479. transition: transform 0.5s;
  480. }
  481. .ghost {
  482. opacity: 1;
  483. /*background: hsl(0, 0%, 21%);*/
  484. }
  485. </style>