CreateUpdate.vue 22 KB

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