Accounts.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. <script setup>
  2. import twofaccountService from '@/services/twofaccountService'
  3. import TotpLooper from '@/components/TotpLooper.vue'
  4. import GroupSwitch from '@/components/GroupSwitch.vue'
  5. import DestinationGroupSelector from '@/components/DestinationGroupSelector.vue'
  6. import SearchBox from '@/components/SearchBox.vue'
  7. import Toolbar from '@/components/Toolbar.vue'
  8. import OtpDisplay from '@/components/OtpDisplay.vue'
  9. import ActionButtons from '@/components/ActionButtons.vue'
  10. import ExportButtons from '@/components/ExportButtons.vue'
  11. import Dots from '@/components/Dots.vue'
  12. import { UseColorMode } from '@vueuse/components'
  13. import { useUserStore } from '@/stores/user'
  14. import { useNotifyStore } from '@/stores/notify'
  15. import { useBusStore } from '@/stores/bus'
  16. import { useTwofaccounts } from '@/stores/twofaccounts'
  17. import { useGroups } from '@/stores/groups'
  18. import { useAppSettingsStore } from '@/stores/appSettings'
  19. import { useDisplayablePassword } from '@/composables/helpers'
  20. import { useSortable, moveArrayElement } from '@vueuse/integrations/useSortable'
  21. const $2fauth = inject('2fauth')
  22. const router = useRouter()
  23. const notify = useNotifyStore()
  24. const user = useUserStore()
  25. const bus = useBusStore()
  26. const appSettings = useAppSettingsStore()
  27. const { copy, copied } = useClipboard({ legacy: true })
  28. const twofaccounts = useTwofaccounts()
  29. const groups = useGroups()
  30. const showOtpInModal = ref(false)
  31. const showExportFormatSelector = ref(false)
  32. const showGroupSwitch = ref(false)
  33. const showDestinationGroupSelector = ref(false)
  34. const isDragging = ref(false)
  35. const isRenewingOTPs = ref(false)
  36. const renewedPeriod = ref(null)
  37. const revealPassword = ref(null)
  38. const otpDisplay = ref(null)
  39. const otpDisplayProps = ref({
  40. otp_type: '',
  41. account : '',
  42. service : '',
  43. icon : '',
  44. })
  45. const looperRefs = ref([])
  46. const dotsRefs = ref([])
  47. let stopSortable
  48. watch(showOtpInModal, (val) => {
  49. if (val == false) {
  50. otpDisplay.value?.clearOTP()
  51. }
  52. })
  53. watch(
  54. () => twofaccounts.items,
  55. (val) => {
  56. stopSortable
  57. if (bus.inManagementMode) {
  58. setSortable()
  59. }
  60. }
  61. )
  62. watch(
  63. () => bus.inManagementMode,
  64. (val) => {
  65. stopSortable
  66. if (val) {
  67. setSortable()
  68. }
  69. }
  70. )
  71. /**
  72. * Returns whether or not the accounts should be displayed
  73. */
  74. const showAccounts = computed(() => {
  75. return !twofaccounts.isEmpty && !showGroupSwitch.value && !showDestinationGroupSelector.value
  76. })
  77. onMounted(async () => {
  78. // This SFC is reached only if the user has some twofaccounts (see the starter middleware).
  79. // This allows to display accounts without latency.
  80. //
  81. // We sync the store with the backend again to
  82. if (! user.preferences.getOtpOnRequest) {
  83. updateTotps()
  84. }
  85. else {
  86. twofaccounts.fetch().then(() => {
  87. if (twofaccounts.backendWasNewer) {
  88. notify.info({ text: trans('commons.data_refreshed_to_reflect_server_changes'), duration: 10000 })
  89. }
  90. })
  91. }
  92. groups.fetch()
  93. })
  94. // Enables the sortable behaviour of the twofaccounts list
  95. function setSortable() {
  96. const { stop } = useSortable('#dv', twofaccounts.filtered, {
  97. animation: 200,
  98. handle: '.drag-handle',
  99. onUpdate: (e) => {
  100. const movedId = twofaccounts.filtered[e.oldIndex].id
  101. const inItemsIndex = twofaccounts.items.findIndex(item => item.id == movedId)
  102. moveArrayElement(twofaccounts.items, inItemsIndex, e.newIndex)
  103. nextTick(() => {
  104. twofaccounts.saveOrder()
  105. })
  106. }
  107. })
  108. stopSortable = stop
  109. }
  110. /**
  111. * Runs some updates after accounts assignement/withdrawal
  112. */
  113. function postGroupAssignementUpdate() {
  114. // we fetch the accounts again to prevent the js collection being
  115. // desynchronize from the backend php collection
  116. twofaccounts.fetch()
  117. twofaccounts.selectNone()
  118. showDestinationGroupSelector.value = false
  119. notify.success({ text: trans('twofaccounts.accounts_moved') })
  120. }
  121. /**
  122. * Shows rotating OTP for the provided account
  123. */
  124. function showOTP(account) {
  125. // Data that should be displayed quickly by the OtpDisplay
  126. // component are passed using props.
  127. otpDisplayProps.value.otp_type = account.otp_type
  128. otpDisplayProps.value.service = account.service
  129. otpDisplayProps.value.account = account.account
  130. otpDisplayProps.value.icon = account.icon
  131. nextTick().then(() => {
  132. showOtpInModal.value = true
  133. otpDisplay.value.show(account.id);
  134. })
  135. }
  136. /**
  137. * Shows an OTP in a modal or directly copies it to the clipboard
  138. */
  139. function showOrCopy(account) {
  140. // In Management mode, clicking an account does not show/copy, it selects the account
  141. if(bus.inManagementMode) {
  142. twofaccounts.select(account.id)
  143. }
  144. else {
  145. if (!user.preferences.getOtpOnRequest && account.otp_type.includes('totp')) {
  146. copyToClipboard(account.otp.password)
  147. }
  148. else {
  149. showOTP(account)
  150. }
  151. }
  152. }
  153. /**
  154. * Copies a string to the clipboard
  155. */
  156. function copyToClipboard (password) {
  157. copy(password)
  158. if (copied) {
  159. if (user.preferences.kickUserAfter == -1) {
  160. user.logout({ kicked: true})
  161. }
  162. if (user.preferences.clearSearchOnCopy) {
  163. twofaccounts.filter = ''
  164. }
  165. if (user.preferences.viewDefaultGroupOnCopy) {
  166. user.preferences.activeGroup = user.preferences.defaultGroup == -1 ?
  167. user.preferences.activeGroup
  168. : user.preferences.defaultGroup
  169. }
  170. notify.success({ text: trans('commons.copied_to_clipboard') })
  171. }
  172. }
  173. /**
  174. * Gets a fresh OTP from backend and copies it
  175. */
  176. async function getAndCopyOTP(account) {
  177. twofaccountService.getOtpById(account.id).then(response => {
  178. let otp = response.data
  179. copyToClipboard(otp.password)
  180. if (otp.otp_type == 'hotp') {
  181. let hotpToIncrement = accounts.value.find((acc) => acc.id == account.id)
  182. // TODO : à koi ça sert ?
  183. if (hotpToIncrement != undefined) {
  184. hotpToIncrement.counter = otp.counter
  185. }
  186. }
  187. })
  188. }
  189. /**
  190. * Dragging start
  191. */
  192. function onStart() {
  193. isDragging.value = true
  194. }
  195. /**
  196. * Dragging end
  197. */
  198. function onEnd() {
  199. isDragging.value = false
  200. twofaccounts.saveOrder()
  201. }
  202. /**
  203. * Turns dots On for all dots components that match the provided period
  204. */
  205. function turnDotsOn(period, stepIndex) {
  206. dotsRefs.value
  207. .filter((dots) => dots.props.period == period || period == undefined)
  208. .forEach((dot) => {
  209. dot.turnOn(stepIndex)
  210. })
  211. }
  212. /**
  213. * Turns dots Off for all dots components that match the provided period
  214. */
  215. function turnDotsOff(period) {
  216. dotsRefs.value
  217. .filter((dots) => dots.props.period == period || period == undefined)
  218. .forEach((dot) => {
  219. dot.turnOff()
  220. })
  221. }
  222. /**
  223. * Updates "Always On" OTPs for all TOTP accounts and (re)starts loopers
  224. */
  225. async function updateTotps(period) {
  226. isRenewingOTPs.value = true
  227. turnDotsOff(period)
  228. let fetchPromise
  229. if (period == undefined) {
  230. renewedPeriod.value = -1
  231. fetchPromise = twofaccountService.getAll(true)
  232. } else {
  233. renewedPeriod.value = period
  234. fetchPromise = twofaccountService.getByIds(twofaccounts.accountIdsWithPeriod(period).join(','), true)
  235. }
  236. fetchPromise.then(response => {
  237. let generatedAt = 0
  238. // twofaccounts TOTP updates
  239. response.data.forEach((account) => {
  240. if (account.otp_type === 'totp') {
  241. const index = twofaccounts.items.findIndex(acc => acc.id === account.id)
  242. if (twofaccounts.items[index] == undefined) {
  243. twofaccounts.items.push(account)
  244. }
  245. else twofaccounts.items[index].otp = account.otp
  246. generatedAt = account.otp.generated_at
  247. }
  248. })
  249. // Loopers restart at new timestamp
  250. looperRefs.value.forEach((looper) => {
  251. if (looper.props.period == period || period == undefined) {
  252. nextTick().then(() => {
  253. looper.startLoop(generatedAt)
  254. })
  255. }
  256. })
  257. })
  258. .finally(() => {
  259. isRenewingOTPs.value = false
  260. renewedPeriod.value = null
  261. })
  262. }
  263. /**
  264. * Deletes selected accounts
  265. */
  266. async function deleteAccounts() {
  267. await twofaccounts.deleteSelected()
  268. if (twofaccounts.isEmpty) {
  269. bus.inManagementMode = false
  270. router.push({ name: 'start' })
  271. }
  272. }
  273. /**
  274. * Exits from the Management mode
  275. */
  276. function exitManagementMode()
  277. {
  278. bus.inManagementMode = false
  279. twofaccounts.selectNone()
  280. }
  281. </script>
  282. <template>
  283. <div>
  284. <GroupSwitch v-if="showGroupSwitch" v-model:showGroupSwitch="showGroupSwitch" v-model:groups="groups.items" />
  285. <DestinationGroupSelector
  286. v-if="showDestinationGroupSelector"
  287. v-model:showDestinationGroupSelector="showDestinationGroupSelector"
  288. v-model:selectedAccountsIds="twofaccounts.selectedIds"
  289. :groups="groups.items"
  290. @accounts-moved="postGroupAssignementUpdate">
  291. </DestinationGroupSelector>
  292. <!-- header -->
  293. <div class="header" v-if="showAccounts || showGroupSwitch">
  294. <div class="columns is-gapless is-mobile is-centered">
  295. <div class="column is-three-quarters-mobile is-one-third-tablet is-one-quarter-desktop is-one-quarter-widescreen is-one-quarter-fullhd">
  296. <!-- search -->
  297. <SearchBox v-model:keyword="twofaccounts.filter"/>
  298. <!-- toolbar -->
  299. <Toolbar v-if="bus.inManagementMode"
  300. :selectedCount="twofaccounts.selectedCount"
  301. @clear-selected="twofaccounts.selectNone()"
  302. @select-all="twofaccounts.selectAll()"
  303. @sort-asc="twofaccounts.sortAsc()"
  304. @sort-desc="twofaccounts.sortDesc()">
  305. </Toolbar>
  306. <!-- group switch toggle -->
  307. <div v-else class="has-text-centered">
  308. <div class="columns">
  309. <UseColorMode v-slot="{ mode }">
  310. <div class="column" v-if="showGroupSwitch">
  311. <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">
  312. {{ $t('groups.select_accounts_to_show') }}
  313. </button>
  314. </div>
  315. <div class="column" v-else>
  316. <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">
  317. {{ groups.current }} ({{ twofaccounts.filteredCount }})&nbsp;
  318. <FontAwesomeIcon :icon="['fas', 'caret-down']" />
  319. </button>
  320. </div>
  321. </UseColorMode>
  322. </div>
  323. </div>
  324. </div>
  325. </div>
  326. </div>
  327. <!-- export modal -->
  328. <Modal v-model="showExportFormatSelector" :isFullHeight="true">
  329. <ExportButtons
  330. @export-twofauth-format="twofaccounts.export()"
  331. @export-otpauth-format="twofaccounts.export('otpauth')">
  332. </ExportButtons>
  333. </Modal>
  334. <!-- otp modal -->
  335. <Modal v-model="showOtpInModal">
  336. <OtpDisplay
  337. ref="otpDisplay"
  338. v-bind="otpDisplayProps"
  339. @please-close-me="showOtpInModal = false"
  340. @please-clear-search="twofaccounts.filter = ''">
  341. </OtpDisplay>
  342. </Modal>
  343. <!-- totp loopers -->
  344. <span v-if="!user.preferences.getOtpOnRequest">
  345. <TotpLooper
  346. v-for="period in twofaccounts.periods"
  347. :key="period.period"
  348. :autostart="false"
  349. :period="period.period"
  350. :generated_at="period.generated_at"
  351. v-on:loop-ended="updateTotps(period.period)"
  352. v-on:loop-started="turnDotsOn(period.period, $event)"
  353. v-on:stepped-up="turnDotsOn(period.period, $event)"
  354. ref="looperRefs"
  355. ></TotpLooper>
  356. </span>
  357. <!-- show accounts list -->
  358. <div class="container" v-if="showAccounts" :class="bus.inManagementMode ? 'is-edit-mode' : ''">
  359. <!-- accounts -->
  360. <div class="accounts">
  361. <span id="dv" class="columns is-multiline" :class="{ 'is-centered': user.preferences.displayMode === 'grid' }">
  362. <div :class="[user.preferences.displayMode === 'grid' ? 'tfa-grid' : 'tfa-list']" class="column is-narrow" v-for="account in twofaccounts.filtered" :key="account.id">
  363. <div class="tfa-container">
  364. <transition name="slideCheckbox">
  365. <div class="tfa-cell tfa-checkbox" v-if="bus.inManagementMode">
  366. <div class="field">
  367. <UseColorMode v-slot="{ mode }">
  368. <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="twofaccounts.selectedIds">
  369. </UseColorMode>
  370. <label tabindex="0" :for="'ckb_' + account.id" v-on:keypress.space.prevent="twofaccounts.select(account.id)"></label>
  371. </div>
  372. </div>
  373. </transition>
  374. <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">
  375. <div class="tfa-text has-ellipsis">
  376. <img v-if="account.icon && user.preferences.showAccountsIcons" role="presentation" class="tfa-icon" :src="$2fauth.config.subdirectory + '/storage/icons/' + account.icon" alt="">
  377. <img v-else-if="account.icon == null && user.preferences.showAccountsIcons" role="presentation" class="tfa-icon" :src="$2fauth.config.subdirectory + '/storage/noicon.svg'" alt="">
  378. {{ 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']" />
  379. <span class="has-ellipsis is-family-primary is-size-6 is-size-7-mobile has-text-grey ">{{ account.account }}</span>
  380. </div>
  381. </div>
  382. <transition name="popLater">
  383. <div v-show="user.preferences.getOtpOnRequest == false && !bus.inManagementMode" class="has-text-right">
  384. <span v-if="account.otp != undefined">
  385. <span v-if="isRenewingOTPs == true && (renewedPeriod == -1 || renewedPeriod == account.period)" class="has-nowrap has-text-grey has-text-centered is-size-5">
  386. <FontAwesomeIcon :icon="['fas', 'circle-notch']" spin />
  387. </span>
  388. <span v-else class="always-on-otp is-clickable has-nowrap has-text-grey is-size-5 ml-4" @click="copyToClipboard(account.otp.password)" @keyup.enter="copyToClipboard(account.otp.password)" :title="$t('commons.copy_to_clipboard')">
  389. {{ useDisplayablePassword(account.otp.password, user.preferences.showOtpAsDot && user.preferences.revealDottedOTP && revealPassword == account.id) }}
  390. </span>
  391. <Dots
  392. v-if="account.otp_type.includes('totp')"
  393. :class="'condensed'"
  394. ref="dotsRefs"
  395. :period="account.period" />
  396. </span>
  397. <span v-else>
  398. <!-- get hotp button -->
  399. <UseColorMode v-slot="{ mode }">
  400. <button class="button tag" :class="mode == 'dark' ? 'is-dark' : 'is-white'" @click="showOTP(account)" :title="$t('twofaccounts.import.import_this_account')">
  401. {{ $t('commons.generate') }}
  402. </button>
  403. </UseColorMode>
  404. </span>
  405. </div>
  406. </transition>
  407. <transition name="popLater" v-if="user.preferences.showOtpAsDot && user.preferences.revealDottedOTP">
  408. <div v-show="user.preferences.getOtpOnRequest == false && !bus.inManagementMode" class="has-text-right">
  409. <button v-if="revealPassword == account.id" class="pr-0 button is-ghost has-text-grey-dark" @click.stop="revealPassword = null">
  410. <font-awesome-icon :icon="['fas', 'eye']" />
  411. </button>
  412. <button v-else class="pr-0 button is-ghost has-text-grey-dark" @click.stop="revealPassword = account.id">
  413. <font-awesome-icon :icon="['fas', 'eye-slash']" />
  414. </button>
  415. </div>
  416. </transition>
  417. <transition name="fadeInOut">
  418. <div class="tfa-cell tfa-edit has-text-grey" v-if="bus.inManagementMode">
  419. <UseColorMode v-slot="{ mode }">
  420. <RouterLink :to="{ name: 'editAccount', params: { twofaccountId: account.id }}" class="tag is-rounded mr-1" :class="mode == 'dark' ? 'is-dark' : 'is-white'">
  421. {{ $t('commons.edit') }}
  422. </RouterLink>
  423. <RouterLink :to="{ name: 'showQRcode', params: { twofaccountId: account.id }}" class="tag is-rounded" :class="mode == 'dark' ? 'is-dark' : 'is-white'" :title="$t('twofaccounts.show_qrcode')">
  424. <FontAwesomeIcon :icon="['fas', 'qrcode']" />
  425. </RouterLink>
  426. </UseColorMode>
  427. </div>
  428. </transition>
  429. <transition name="fadeInOut">
  430. <div class="drag-handle tfa-cell tfa-dots has-text-grey" v-if="bus.inManagementMode">
  431. <FontAwesomeIcon :icon="['fas', 'bars']" />
  432. </div>
  433. </transition>
  434. </div>
  435. </div>
  436. </span>
  437. </div>
  438. <VueFooter :showButtons="true" :internalFooterType="bus.inManagementMode && !showDestinationGroupSelector ? 'doneButton' : 'navLinks'" @done-button-clicked="exitManagementMode">
  439. <ActionButtons
  440. v-model:inManagementMode="bus.inManagementMode"
  441. :areDisabled="twofaccounts.hasNoneSelected"
  442. @move-button-clicked="showDestinationGroupSelector = true"
  443. @delete-button-clicked="deleteAccounts"
  444. @export-button-clicked="showExportFormatSelector = true">
  445. </ActionButtons>
  446. </VueFooter>
  447. </div>
  448. </div>
  449. </template>