CreateUpdate.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. <script setup>
  2. import Form from '@/components/formElements/Form'
  3. import OtpDisplay from '@/components/OtpDisplay.vue'
  4. import FormLockField from '@/components/formelements/FormLockField.vue'
  5. import twofaccountService from '@/services/twofaccountService'
  6. import { useUserStore } from '@/stores/user'
  7. import { useBusStore } from '@/stores/bus'
  8. import { useNotifyStore } from '@/stores/notify'
  9. import { UseColorMode } from '@vueuse/components'
  10. import { useIdGenerator } from '@/composables/helpers'
  11. const { copy } = useClipboard({ legacy: true })
  12. const $2fauth = inject('2fauth')
  13. const router = useRouter()
  14. const route = useRoute()
  15. const user = useUserStore()
  16. const bus = useBusStore()
  17. const notify = useNotifyStore()
  18. const form = reactive(new Form({
  19. service: '',
  20. account: '',
  21. otp_type: '',
  22. icon: '',
  23. secret: '',
  24. algorithm: '',
  25. digits: null,
  26. counter: null,
  27. period: null,
  28. image: '',
  29. qrcode: null,
  30. }))
  31. const otp_types = [
  32. { text: 'TOTP', value: 'totp' },
  33. { text: 'HOTP', value: 'hotp' },
  34. { text: 'STEAM', value: 'steamtotp' },
  35. ]
  36. const digitsChoices = [
  37. { text: '6', value: 6 },
  38. { text: '7', value: 7 },
  39. { text: '8', value: 8 },
  40. { text: '9', value: 9 },
  41. { text: '10', value: 10 },
  42. ]
  43. const algorithms = [
  44. { text: 'sha1', value: 'sha1' },
  45. { text: 'sha256', value: 'sha256' },
  46. { text: 'sha512', value: 'sha512' },
  47. { text: 'md5', value: 'md5' },
  48. ]
  49. const uri = ref()
  50. const tempIcon = ref()
  51. const showQuickForm = ref(false)
  52. const showAlternatives = ref(false)
  53. const showAdvancedForm = ref(false)
  54. const ShowTwofaccountInModal = ref(false)
  55. const fetchingLogo = ref(false)
  56. const secretIsLocked = ref(false)
  57. // $refs
  58. const iconInput = ref(null)
  59. const OtpDisplayForQuickForm = ref(null)
  60. const OtpDisplayForAdvancedForm = ref(null)
  61. const qrcodeInputLabel = ref(null)
  62. const qrcodeInput = ref(null)
  63. const iconInputLabel = ref(null)
  64. const props = defineProps({
  65. twofaccountId: [Number, String]
  66. })
  67. const isEditMode = computed(() => {
  68. return props.twofaccountId != undefined
  69. })
  70. onMounted(() => {
  71. if (route.name == 'editAccount') {
  72. twofaccountService.get(props.twofaccountId).then(response => {
  73. form.fill(response.data)
  74. // set account icon as temp icon
  75. tempIcon.value = form.icon
  76. showAdvancedForm.value = true
  77. })
  78. }
  79. else if( bus.decodedUri ) {
  80. // the Start view provided an uri via the bus store so we parse it and prefill the quick form
  81. uri.value = bus.decodedUri
  82. bus.decodedUri = null
  83. twofaccountService.preview(uri.value).then(response => {
  84. form.fill(response.data)
  85. tempIcon.value = response.data.icon ? response.data.icon : null
  86. showQuickForm.value = true
  87. nextTick().then(() => {
  88. OtpDisplayForQuickForm.value.show()
  89. })
  90. })
  91. .catch(error => {
  92. if( error.response.data.errors.uri ) {
  93. showAlternatives.value = true
  94. showAdvancedForm.value = true
  95. }
  96. })
  97. } else {
  98. showAdvancedForm.value = true
  99. }
  100. })
  101. watch(tempIcon, (val) => {
  102. if( showQuickForm.value ) {
  103. nextTick().then(() => {
  104. OtpDisplayForQuickForm.value.icon = val
  105. })
  106. }
  107. })
  108. watch(ShowTwofaccountInModal, (val) => {
  109. if (val == false) {
  110. OtpDisplayForAdvancedForm.value?.clearOTP()
  111. OtpDisplayForQuickForm.value?.clearOTP()
  112. }
  113. })
  114. watch(
  115. () => form.otp_type,
  116. (to, from) => {
  117. if (to === 'steamtotp') {
  118. form.service = 'Steam'
  119. fetchLogo()
  120. }
  121. else if (from === 'steamtotp') {
  122. form.service = ''
  123. deleteIcon()
  124. }
  125. }
  126. )
  127. /**
  128. * Wrapper to call the appropriate function at form submit
  129. */
  130. function handleSubmit() {
  131. isEditMode.value ? updateAccount() : createAccount()
  132. }
  133. /**
  134. * Submits the form to the backend to store the new account
  135. */
  136. async function createAccount() {
  137. // set current temp icon as account icon
  138. form.icon = tempIcon.value
  139. await form.post('/api/v1/twofaccounts')
  140. if (form.errors.any() === false) {
  141. notify.success({ text: trans('twofaccounts.account_created') })
  142. router.push({ name: 'accounts' });
  143. }
  144. }
  145. /**
  146. * Submits the form to the backend to save the edited account
  147. */
  148. async function updateAccount() {
  149. // Set new icon and delete old one
  150. if( tempIcon.value !== form.icon ) {
  151. let oldIcon = ''
  152. oldIcon = form.icon
  153. form.icon = tempIcon.value
  154. tempIcon.value = oldIcon
  155. deleteIcon()
  156. }
  157. await form.put('/api/v1/twofaccounts/' + props.twofaccountId)
  158. if( form.errors.any() === false ) {
  159. notify.success({ text: trans('twofaccounts.account_updated') })
  160. router.push({ name: 'accounts' })
  161. }
  162. }
  163. /**
  164. * Shows an OTP generated with the infos filled in the form
  165. * in order to preview or validated the password/the form data
  166. */
  167. function previewOTP() {
  168. form.clear()
  169. ShowTwofaccountInModal.value = true
  170. OtpDisplayForAdvancedForm.value.show()
  171. }
  172. /**
  173. * Exits the view with user confirmation
  174. */
  175. function cancelCreation() {
  176. if( form.hasChanged() ) {
  177. if( confirm(trans('twofaccounts.confirm.cancel')) === false ) {
  178. return
  179. }
  180. }
  181. // clean possible uploaded temp icon
  182. deleteIcon()
  183. router.push({name: 'accounts'});
  184. }
  185. /**
  186. * Uploads the submited image resource to the backend
  187. */
  188. function uploadIcon() {
  189. // clean possible already uploaded temp icon
  190. deleteIcon()
  191. const iconForm = new Form({
  192. icon: iconInput.value.files[0]
  193. })
  194. iconForm.upload('/api/v1/icons', { returnError: true })
  195. .then(response => {
  196. tempIcon.value = response.data.filename;
  197. })
  198. .catch(error => {
  199. notify.alert({ text: trans(error.response.data.message) })
  200. })
  201. }
  202. /**
  203. * Deletes the temp icon from backend
  204. */
  205. function deleteIcon() {
  206. if (tempIcon.value) {
  207. twofaccountService.deleteIcon(tempIcon.value)
  208. tempIcon.value = ''
  209. }
  210. }
  211. /**
  212. * Increments the HOTP counter of the form after a preview
  213. *
  214. * @param {object} payload
  215. */
  216. function incrementHotp(payload) {
  217. // The quick form or the preview feature has incremented the HOTP counter so we get the new value from
  218. // the OtpDisplay component.
  219. // This could desynchronized the HOTP verification server and our local counter if the user never verified the HOTP but this
  220. // is acceptable (and HOTP counter can be edited by the way)
  221. form.counter = payload.nextHotpCounter
  222. //form.uri = payload.nextUri
  223. }
  224. /**
  225. * Maps errors received by the OtpDisplay to the form errors instance
  226. *
  227. * @param {object} errorResponse
  228. */
  229. function mapDisplayerErrors(errorResponse) {
  230. form.errors.set(form.extractErrors(errorResponse))
  231. }
  232. /**
  233. * Sends a QR code to backend for decoding and prefill the form with the qr data
  234. */
  235. function uploadQrcode() {
  236. const qrcodeForm = new Form({
  237. qrcode: qrcodeInput.value.files[0]
  238. })
  239. // First we get the uri encoded in the qrcode
  240. qrcodeForm.upload('/api/v1/qrcode/decode', { returnError: true })
  241. .then(response => {
  242. uri.value = response.data.data
  243. // Then the otp described by the uri
  244. twofaccountService.preview(uri.value, { returnError: true }).then(response => {
  245. form.fill(response.data)
  246. tempIcon.value = response.data.icon ? response.data.icon : null
  247. })
  248. .catch(error => {
  249. if( error.response.status === 422 ) {
  250. if( error.response.data.errors.uri ) {
  251. showAlternatives.value = true
  252. }
  253. else notify.alert({ text: trans(error.response.data.message) })
  254. } else {
  255. notify.error(error)
  256. }
  257. })
  258. })
  259. .catch(error => {
  260. notify.alert({ text: trans(error.response.data.message) })
  261. return false
  262. })
  263. }
  264. /**
  265. * Tries to get the official logo/icon of the Service filled in the form
  266. */
  267. function fetchLogo() {
  268. if (user.preferences.getOfficialIcons) {
  269. fetchingLogo.value = true
  270. twofaccountService.getLogo(form.service, { returnError: true })
  271. .then(response => {
  272. console.log('enter fetchLogo response')
  273. if (response.status === 201) {
  274. // clean possible already uploaded temp icon
  275. deleteIcon()
  276. tempIcon.value = response.data.filename;
  277. }
  278. else notify.warn( {text: trans('errors.no_logo_found_for_x', {service: strip_tags(form.service)}) })
  279. })
  280. .catch(() => {
  281. notify.warn({ text: trans('errors.no_logo_found_for_x', {service: strip_tags(form.service)}) })
  282. })
  283. .finally(() => {
  284. fetchingLogo.value = false
  285. })
  286. }
  287. }
  288. /**
  289. * Strips html tags to prevent code injection
  290. *
  291. * @param {*} str
  292. */
  293. function strip_tags(str) {
  294. return str.replace(/(<([^> ]+)>)/ig, "")
  295. }
  296. /**
  297. * Checks if a string is an url
  298. *
  299. * @param {string} str
  300. */
  301. function isUrl(str) {
  302. var strRegex = /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/
  303. var re = new RegExp(strRegex)
  304. return re.test(str)
  305. }
  306. /**
  307. * Opens an uri in a new browser window
  308. *
  309. * @param {string} uri
  310. */
  311. function openInBrowser(uri) {
  312. const a = document.createElement('a')
  313. a.setAttribute('href', uri)
  314. a.dispatchEvent(new MouseEvent("click", { 'view': window, 'bubbles': true, 'cancelable': true }))
  315. }
  316. /**
  317. * Copies to clipboard and notify
  318. *
  319. * @param {*} data
  320. */
  321. function copyToClipboard(data) {
  322. copy(data)
  323. notify.success({ text: trans('commons.copied_to_clipboard') })
  324. }
  325. </script>
  326. <template>
  327. <div>
  328. <!-- Quick form -->
  329. <form @submit.prevent="createAccount" @keydown="form.onKeydown($event)" v-if="!isEditMode && showQuickForm">
  330. <div class="container preview has-text-centered">
  331. <div class="columns is-mobile">
  332. <div class="column">
  333. <label class="add-icon-button" v-if="!tempIcon">
  334. <input class="file-input" type="file" accept="image/*" v-on:change="uploadIcon" ref="iconInput">
  335. <FontAwesomeIcon :icon="['fas', 'image']" size="2x" />
  336. </label>
  337. <button class="delete delete-icon-button is-medium" v-if="tempIcon" @click.prevent="deleteIcon"></button>
  338. <OtpDisplay
  339. ref="OtpDisplayForQuickForm"
  340. v-bind="form.data()"
  341. @increment-hotp="incrementHotp"
  342. @validation-error="mapDisplayerErrors"
  343. @please-close-me="ShowTwofaccountInModal = false">
  344. </OtpDisplay>
  345. </div>
  346. </div>
  347. <div class="columns is-mobile" role="alert">
  348. <div v-if="form.errors.any()" class="column">
  349. <p v-for="(field, index) in form.errors.errors" :key="index" class="help is-danger">
  350. <ul>
  351. <li v-for="(error, index) in field" :key="index">{{ error }}</li>
  352. </ul>
  353. </p>
  354. </div>
  355. </div>
  356. <div class="columns is-mobile">
  357. <div class="column quickform-footer">
  358. <div class="field is-grouped is-grouped-centered">
  359. <div class="control">
  360. <VueButton :isLoading="form.isBusy" >{{ $t('commons.save') }}</VueButton>
  361. </div>
  362. <div class="control">
  363. <button id="btnCancel" type="button" class="button is-text" @click="cancelCreation">{{ $t('commons.cancel') }}</button>
  364. </div>
  365. </div>
  366. </div>
  367. </div>
  368. </div>
  369. </form>
  370. <!-- Full form -->
  371. <FormWrapper :title="$t(isEditMode ? 'twofaccounts.forms.edit_account' : 'twofaccounts.forms.new_account')" v-if="showAdvancedForm">
  372. <form @submit.prevent="handleSubmit" @keydown="form.onKeydown($event)">
  373. <!-- qcode fileupload -->
  374. <div v-if="!isEditMode" class="field is-grouped">
  375. <div class="control">
  376. <div role="button" tabindex="0" class="file is-black is-small" @keyup.enter="qrcodeInputLabel.click()">
  377. <label class="file-label" :title="$t('twofaccounts.forms.use_qrcode.title')" ref="qrcodeInputLabel">
  378. <input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="uploadQrcode" ref="qrcodeInput">
  379. <span class="file-cta">
  380. <span class="file-icon">
  381. <FontAwesomeIcon :icon="['fas', 'qrcode']" size="lg" />
  382. </span>
  383. <span class="file-label">{{ $t('twofaccounts.forms.prefill_using_qrcode') }}</span>
  384. </span>
  385. </label>
  386. </div>
  387. </div>
  388. </div>
  389. <FieldError v-if="!isEditMode && form.errors.hasAny('qrcode')" :error="form.errors.get('qrcode')" :field="'qrcode'" class="help-for-file" />
  390. <!-- service -->
  391. <FormField v-model="form.service" fieldName="service" :fieldError="form.errors.get('email')" :isDisabled="form.otp_type === 'steamtotp'" label="twofaccounts.service" :placeholder="$t('twofaccounts.forms.service.placeholder')" autofocus />
  392. <!-- account -->
  393. <FormField v-model="form.account" fieldName="account" :fieldError="form.errors.get('account')" :isDisabled="form.otp_type === 'steamtotp'" label="twofaccounts.account" :placeholder="$t('twofaccounts.forms.account.placeholder')" />
  394. <!-- icon upload -->
  395. <label class="label">{{ $t('twofaccounts.icon') }}</label>
  396. <div class="field is-grouped">
  397. <!-- Try my luck button -->
  398. <div class="control" v-if="user.preferences.getOfficialIcons">
  399. <UseColorMode v-slot="{ mode }">
  400. <VueButton @click="fetchLogo" :color="mode == 'dark' ? 'is-dark' : ''" :nativeType="'button'" :is-loading="fetchingLogo" :isDisabled="form.service.length < 1">
  401. <span class="icon is-small">
  402. <FontAwesomeIcon :icon="['fas', 'globe']" />
  403. </span>
  404. <span>{{ $t('twofaccounts.forms.i_m_lucky') }}</span>
  405. </VueButton>
  406. </UseColorMode>
  407. </div>
  408. <!-- upload button -->
  409. <div class="control">
  410. <UseColorMode v-slot="{ mode }">
  411. <div role="button" tabindex="0" class="file" :class="mode == 'dark' ? 'is-dark' : 'is-white'" @keyup.enter="iconInputLabel.click()">
  412. <label class="file-label" ref="iconInputLabel">
  413. <input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="uploadIcon" ref="iconInput">
  414. <span class="file-cta">
  415. <span class="file-icon">
  416. <FontAwesomeIcon :icon="['fas', 'upload']" />
  417. </span>
  418. <span class="file-label">{{ $t('twofaccounts.forms.choose_image') }}</span>
  419. </span>
  420. </label>
  421. <span class="tag is-large" :class="mode =='dark' ? 'is-dark' : 'is-white'" v-if="tempIcon">
  422. <img class="icon-preview" :src="$2fauth.config.subdirectory + '/storage/icons/' + tempIcon" :alt="$t('twofaccounts.icon_to_illustrate_the_account')">
  423. <button class="clear-selection delete is-small" @click.prevent="deleteIcon" :aria-label="$t('twofaccounts.remove_icon')"></button>
  424. </span>
  425. </div>
  426. </UseColorMode>
  427. </div>
  428. </div>
  429. <div class="field">
  430. <FieldError v-if="form.errors.hasAny('icon')" :error="form.errors.get('icon')" :field="'icon'" class="help-for-file" />
  431. <p v-if="user.preferences.getOfficialIcons" class="help" v-html="$t('twofaccounts.forms.i_m_lucky_legend')"></p>
  432. </div>
  433. <!-- otp type -->
  434. <FormToggle v-model="form.otp_type" :isDisabled="isEditMode" :choices="otp_types" fieldName="otp_type" :fieldError="form.errors.get('otp_type')" label="twofaccounts.forms.otp_type.label" help="twofaccounts.forms.otp_type.help" :hasOffset="true" />
  435. <div v-if="form.otp_type != ''">
  436. <!-- secret -->
  437. <FormLockField :isEditMode="isEditMode" v-model="form.secret" fieldName="secret" :fieldError="form.errors.get('secret')" label="twofaccounts.forms.secret.label" help="twofaccounts.forms.secret.help" />
  438. <!-- Options -->
  439. <div v-if="form.otp_type !== 'steamtotp'">
  440. <h2 class="title is-4 mt-5 mb-2">{{ $t('commons.options') }}</h2>
  441. <p class="help mb-4">
  442. {{ $t('twofaccounts.forms.options_help') }}
  443. </p>
  444. <!-- digits -->
  445. <FormToggle v-model="form.digits" :choices="digitsChoices" fieldName="digits" :fieldError="form.errors.get('digits')" label="twofaccounts.forms.digits.label" help="twofaccounts.forms.digits.help" />
  446. <!-- algorithm -->
  447. <FormToggle v-model="form.algorithm" :choices="algorithms" fieldName="algorithm" :fieldError="form.errors.get('algorithm')" label="twofaccounts.forms.algorithm.label" help="twofaccounts.forms.algorithm.help" />
  448. <!-- TOTP period -->
  449. <FormField v-if="form.otp_type === 'totp'" pattern="[0-9]{1,4}" :class="'is-third-width-field'" v-model="form.period" fieldName="period" :fieldError="form.errors.get('period')" label="twofaccounts.forms.period.label" help="twofaccounts.forms.period.help" :placeholder="$t('twofaccounts.forms.period.placeholder')" />
  450. <!-- HOTP counter -->
  451. <FormLockField v-if="form.otp_type === 'hotp'" pattern="[0-9]{1,4}" :isEditMode="isEditMode" :isExpanded="false" v-model="form.counter" fieldName="counter" :fieldError="form.errors.get('counter')" label="twofaccounts.forms.counter.label" :placeholder="$t('twofaccounts.forms.counter.placeholder')" :help="isEditMode ? 'twofaccounts.forms.counter.help_lock' : 'twofaccounts.forms.counter.help'" />
  452. </div>
  453. </div>
  454. <VueFooter :showButtons="true">
  455. <p class="control">
  456. <VueButton :id="isEditMode ? 'btnUpdate' : 'btnCreate'" :isLoading="form.isBusy" class="is-rounded" >{{ isEditMode ? $t('commons.save') : $t('commons.create') }}</VueButton>
  457. </p>
  458. <p class="control" v-if="form.otp_type && form.secret">
  459. <button id="btnPreview" type="button" class="button is-success is-rounded" @click="previewOTP">{{ $t('twofaccounts.forms.test') }}</button>
  460. </p>
  461. <p class="control">
  462. <button id="btnCancel" type="button" class="button is-text is-rounded" @click="cancelCreation">{{ $t('commons.cancel') }}</button>
  463. </p>
  464. </VueFooter>
  465. </form>
  466. <!-- modal -->
  467. <modal v-model="ShowTwofaccountInModal">
  468. <OtpDisplay
  469. ref="OtpDisplayForAdvancedForm"
  470. v-bind="form.data()"
  471. @increment-hotp="incrementHotp"
  472. @validation-error="mapDisplayerErrors"
  473. @please-close-me="ShowTwofaccountInModal = false">
  474. </OtpDisplay>
  475. </modal>
  476. </FormWrapper>
  477. <!-- alternatives -->
  478. <modal v-model="showAlternatives">
  479. <div class="too-bad"></div>
  480. <div class="block">
  481. {{ $t('errors.data_of_qrcode_is_not_valid_URI') }}
  482. </div>
  483. <UseColorMode v-slot="{ mode }">
  484. <div class="block mb-6" :class="mode == 'dark' ? 'has-text-light':'has-text-grey-dark'">{{ uri }}</div>
  485. </UseColorMode>
  486. <!-- Copy to clipboard -->
  487. <div class="block has-text-link">
  488. <button class="button is-link is-outlined is-rounded" @click.stop="copyToClipboard(uri)">
  489. {{ $t('commons.copy_to_clipboard') }}
  490. </button>
  491. </div>
  492. <!-- Open in browser -->
  493. <div class="block has-text-link" v-if="isUrl(uri)" @click="openInBrowser(uri)">
  494. <button class="button is-link is-outlined is-rounded">
  495. <span>{{ $t('commons.open_in_browser') }}</span>
  496. <span class="icon is-small">
  497. <FontAwesomeIcon :icon="['fas', 'external-link-alt']" />
  498. </span>
  499. </button>
  500. </div>
  501. </modal>
  502. </div>
  503. </template>