Create.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. <template>
  2. <div>
  3. <!-- Quick form -->
  4. <form @submit.prevent="createAccount" @keydown="form.onKeydown($event)" v-if="showQuickForm">
  5. <div class="container preview has-text-centered">
  6. <div class="columns is-mobile">
  7. <div class="column">
  8. <label class="add-icon-button" v-if="!tempIcon">
  9. <input class="file-input" type="file" accept="image/*" v-on:change="uploadIcon" ref="iconInput">
  10. <font-awesome-icon :icon="['fas', 'image']" size="2x" />
  11. </label>
  12. <button class="delete delete-icon-button is-medium" v-if="tempIcon" @click.prevent="deleteIcon"></button>
  13. <otp-displayer ref="QuickFormOtpDisplayer" v-bind="form.data()" @increment-hotp="incrementHotp">
  14. </otp-displayer>
  15. </div>
  16. </div>
  17. <div class="columns is-mobile" role="alert">
  18. <div v-if="form.errors.any()" class="column">
  19. <p v-for="(field, index) in form.errors.errors" :key="index" class="help is-danger">
  20. <ul>
  21. <li v-for="(error, index) in field" :key="index">{{ error }}</li>
  22. </ul>
  23. </p>
  24. </div>
  25. </div>
  26. <div class="columns is-mobile">
  27. <div class="column quickform-footer">
  28. <div class="field is-grouped is-grouped-centered">
  29. <div class="control">
  30. <v-button :isLoading="form.isBusy" >{{ $t('commons.save') }}</v-button>
  31. </div>
  32. <div class="control">
  33. <button type="button" class="button is-text" @click="cancelCreation">{{ $t('commons.cancel') }}</button>
  34. </div>
  35. </div>
  36. </div>
  37. </div>
  38. </div>
  39. </form>
  40. <!-- Full form -->
  41. <form-wrapper :title="$t('twofaccounts.forms.new_account')" v-if="showAdvancedForm">
  42. <form @submit.prevent="createAccount" @keydown="form.onKeydown($event)">
  43. <!-- qcode fileupload -->
  44. <div class="field is-grouped">
  45. <div class="control">
  46. <div role="button" tabindex="0" class="file is-black is-small" @keyup.enter="$refs.qrcodeInputLabel.click()">
  47. <label class="file-label" :title="$t('twofaccounts.forms.use_qrcode.title')" ref="qrcodeInputLabel">
  48. <input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="uploadQrcode" ref="qrcodeInput">
  49. <span class="file-cta">
  50. <span class="file-icon">
  51. <font-awesome-icon :icon="['fas', 'qrcode']" size="lg" />
  52. </span>
  53. <span class="file-label">{{ $t('twofaccounts.forms.prefill_using_qrcode') }}</span>
  54. </span>
  55. </label>
  56. </div>
  57. </div>
  58. </div>
  59. <field-error :form="form" field="qrcode" class="help-for-file" />
  60. <!-- service -->
  61. <form-field :form="form" :isDisabled="form.otp_type === 'steamtotp'" fieldName="service" inputType="text" :label="$t('twofaccounts.service')" :placeholder="$t('twofaccounts.forms.service.placeholder')" autofocus />
  62. <!-- account -->
  63. <form-field :form="form" fieldName="account" inputType="text" :label="$t('twofaccounts.account')" :placeholder="$t('twofaccounts.forms.account.placeholder')" />
  64. <!-- icon upload -->
  65. <label class="label">{{ $t('twofaccounts.icon') }}</label>
  66. <div class="field is-grouped">
  67. <!-- i'm lucky button -->
  68. <div class="control" v-if="$root.appSettings.getOfficialIcons">
  69. <v-button @click="fetchLogo" :color="$root.showDarkMode ? 'is-dark' : ''" :nativeType="'button'" :isDisabled="form.service.length < 1">
  70. <span class="icon is-small">
  71. <font-awesome-icon :icon="['fas', 'globe']" />
  72. </span>
  73. <span>{{ $t('twofaccounts.forms.i_m_lucky') }}</span>
  74. </v-button>
  75. </div>
  76. <!-- upload button -->
  77. <div class="control">
  78. <div role="button" tabindex="0" class="file" :class="$root.showDarkMode ? 'is-dark' : 'is-white'" @keyup.enter="$refs.iconInputLabel.click()">
  79. <label class="file-label" ref="iconInputLabel">
  80. <input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="uploadIcon" ref="iconInput">
  81. <span class="file-cta">
  82. <span class="file-icon">
  83. <font-awesome-icon :icon="['fas', 'upload']" />
  84. </span>
  85. <span class="file-label">{{ $t('twofaccounts.forms.choose_image') }}</span>
  86. </span>
  87. </label>
  88. <span class="tag is-large" :class="$root.showDarkMode ? 'is-dark' : 'is-white'" v-if="tempIcon">
  89. <img class="icon-preview" :src="$root.appConfig.subdirectory + '/storage/icons/' + tempIcon" :alt="$t('twofaccounts.icon_to_illustrate_the_account')">
  90. <button class="clear-selection delete is-small" @click.prevent="deleteIcon" :aria-label="$t('twofaccounts.remove_icon')"></button>
  91. </span>
  92. </div>
  93. </div>
  94. </div>
  95. <div class="field">
  96. <field-error :form="form" field="icon" class="help-for-file" />
  97. <p v-if="$root.appSettings.getOfficialIcons" class="help" v-html="$t('twofaccounts.forms.i_m_lucky_legend')"></p>
  98. </div>
  99. <!-- otp type -->
  100. <form-toggle class="has-uppercased-button" :form="form" :choices="otp_types" fieldName="otp_type" :label="$t('twofaccounts.forms.otp_type.label')" :help="$t('twofaccounts.forms.otp_type.help')" :hasOffset="true" />
  101. <div v-if="form.otp_type">
  102. <!-- secret -->
  103. <label :for="this.inputId('text','secret')" class="label" v-html="$t('twofaccounts.forms.secret.label')"></label>
  104. <div class="field">
  105. <p class="control is-expanded">
  106. <input :id="this.inputId('text','secret')" class="input" type="text" v-model="form.secret">
  107. </p>
  108. </div>
  109. <div class="field">
  110. <field-error :form="form" field="secret" />
  111. <p class="help" v-html="$t('twofaccounts.forms.secret.help')"></p>
  112. </div>
  113. <div v-if="form.otp_type !== 'steamtotp'">
  114. <h2 class="title is-4 mt-5 mb-2">{{ $t('commons.options') }}</h2>
  115. <p class="help mb-4">
  116. {{ $t('twofaccounts.forms.options_help') }}
  117. </p>
  118. <!-- digits -->
  119. <form-toggle :form="form" :choices="digitsChoices" fieldName="digits" :label="$t('twofaccounts.forms.digits.label')" :help="$t('twofaccounts.forms.digits.help')" />
  120. <!-- algorithm -->
  121. <form-toggle :form="form" :choices="algorithms" fieldName="algorithm" :label="$t('twofaccounts.forms.algorithm.label')" :help="$t('twofaccounts.forms.algorithm.help')" />
  122. <!-- TOTP period -->
  123. <form-field v-if="form.otp_type === 'totp'" pattern="[0-9]{1,4}" :class="'is-third-width-field'" :form="form" fieldName="period" inputType="text" :label="$t('twofaccounts.forms.period.label')" :placeholder="$t('twofaccounts.forms.period.placeholder')" :help="$t('twofaccounts.forms.period.help')" />
  124. <!-- HOTP counter -->
  125. <form-field v-if="form.otp_type === 'hotp'" pattern="[0-9]{1,4}" :class="'is-third-width-field'" :form="form" fieldName="counter" inputType="text" :label="$t('twofaccounts.forms.counter.label')" :placeholder="$t('twofaccounts.forms.counter.placeholder')" :help="$t('twofaccounts.forms.counter.help')" />
  126. </div>
  127. </div>
  128. <vue-footer :showButtons="true">
  129. <p class="control">
  130. <v-button :isLoading="form.isBusy" class="is-rounded" >{{ $t('commons.create') }}</v-button>
  131. </p>
  132. <p class="control" v-if="form.otp_type && form.secret">
  133. <button type="button" class="button is-success is-rounded" @click="previewAccount">{{ $t('twofaccounts.forms.test') }}</button>
  134. </p>
  135. <p class="control">
  136. <button type="button" class="button is-text is-rounded" @click="cancelCreation">{{ $t('commons.cancel') }}</button>
  137. </p>
  138. </vue-footer>
  139. </form>
  140. <!-- modal -->
  141. <modal v-model="ShowTwofaccountInModal">
  142. <otp-displayer ref="AdvancedFormOtpDisplayer" v-bind="form.data()" @increment-hotp="incrementHotp" @validation-error="mapDisplayerErrors">
  143. </otp-displayer>
  144. </modal>
  145. </form-wrapper>
  146. <!-- alternatives -->
  147. <modal v-model="showAlternatives">
  148. <div class="too-bad"></div>
  149. <div class="block">
  150. {{ $t('errors.data_of_qrcode_is_not_valid_URI') }}
  151. </div>
  152. <div class="block mb-6" :class="$root.showDarkMode ? 'has-text-light':'has-text-grey-dark'" v-html="uri"></div>
  153. <!-- Copy to clipboard -->
  154. <div class="block has-text-link">
  155. <button class="button is-link is-outlined is-rounded" v-clipboard="() => uri" v-clipboard:success="clipboardSuccessHandler">
  156. {{ $t('commons.copy_to_clipboard') }}
  157. </button>
  158. </div>
  159. <!-- Open in browser -->
  160. <div class="block has-text-link" v-if="isUrl(uri)" @click="openInBrowser(uri)">
  161. <button class="button is-link is-outlined is-rounded">
  162. <span>{{ $t('commons.open_in_browser') }}</span>
  163. <span class="icon is-small">
  164. <font-awesome-icon :icon="['fas', 'external-link-alt']" />
  165. </span>
  166. </button>
  167. </div>
  168. </modal>
  169. </div>
  170. </template>
  171. <script>
  172. /**
  173. * Create form view
  174. *
  175. * route: '/account/create'
  176. *
  177. * Offer the user to define, preview and store an account. The form has 2 designs :
  178. * - The 'Quick Form', a read only design fed with $route.params.decodedUri passed by the Start view.
  179. * - The 'Advanced Form', a fully editable form, that let user define all field and OTP parameters.
  180. * ~ A qrcode can be used to automatically fill the form
  181. * ~ If an 'image' parameter is embeded in the qrcode, the remote image is downloaded and preset in the icon field
  182. *
  183. * Both design use the otpDisplayer component to preview the account with an otp rotation.
  184. *
  185. * input : [optional, for the Quick Form] an URI previously decoded by the Start view
  186. * submit : post account data to php backend to create the account
  187. */
  188. import Modal from '../../components/Modal'
  189. import Form from './../../components/Form'
  190. import OtpDisplayer from '../../components/OtpDisplayer'
  191. export default {
  192. data() {
  193. return {
  194. showQuickForm: false,
  195. showAdvancedForm: false,
  196. ShowTwofaccountInModal : false,
  197. showAlternatives : false,
  198. tempIcon: '',
  199. uri: '',
  200. form: new Form({
  201. service: '',
  202. account: '',
  203. otp_type: '',
  204. icon: '',
  205. secret: '',
  206. algorithm: '',
  207. digits: null,
  208. counter: null,
  209. period: null,
  210. image: '',
  211. qrcode: null,
  212. }),
  213. otp_types: [
  214. { text: 'TOTP', value: 'totp' },
  215. { text: 'HOTP', value: 'hotp' },
  216. { text: 'STEAM', value: 'steamtotp' },
  217. ],
  218. digitsChoices: [
  219. { text: 6, value: 6 },
  220. { text: 7, value: 7 },
  221. { text: 8, value: 8 },
  222. { text: 9, value: 9 },
  223. { text: 10, value: 10 },
  224. ],
  225. algorithms: [
  226. { text: 'sha1', value: 'sha1' },
  227. { text: 'sha256', value: 'sha256' },
  228. { text: 'sha512', value: 'sha512' },
  229. { text: 'md5', value: 'md5' },
  230. ],
  231. }
  232. },
  233. watch: {
  234. tempIcon: function(val) {
  235. if( this.showQuickForm ) {
  236. this.$refs.QuickFormOtpDisplayer.internal_icon = val
  237. }
  238. },
  239. 'form.otp_type' : function(to, from) {
  240. this.setFormState(from, to)
  241. },
  242. },
  243. mounted: function () {
  244. if( this.$route.params.decodedUri ) {
  245. this.uri = this.$route.params.decodedUri
  246. // the Start view provided an uri so we parse it and prefill the quick form
  247. this.axios.post('/api/v1/twofaccounts/preview', { uri: this.uri }).then(response => {
  248. this.form.fill(response.data)
  249. this.tempIcon = response.data.icon ? response.data.icon : null
  250. this.showQuickForm = true
  251. })
  252. .catch(error => {
  253. if( error.response.status === 422 ) {
  254. if( error.response.data.errors.uri ) {
  255. this.showAlternatives = true
  256. this.showAdvancedForm = true
  257. }
  258. }
  259. });
  260. } else {
  261. this.showAdvancedForm = true
  262. }
  263. this.$on('modalClose', function() {
  264. this.showAlternatives = false;
  265. if( this.showAdvancedForm ) {
  266. this.$refs.AdvancedFormOtpDisplayer.stopLoop()
  267. }
  268. });
  269. },
  270. components: {
  271. Modal,
  272. OtpDisplayer,
  273. },
  274. methods: {
  275. async createAccount() {
  276. // set current temp icon as account icon
  277. this.form.icon = this.tempIcon
  278. await this.form.post('/api/v1/twofaccounts')
  279. if( this.form.errors.any() === false ) {
  280. this.$notify({ type: 'is-success', text: this.$t('twofaccounts.account_created') })
  281. this.$router.push({name: 'accounts', params: { toRefresh: true }});
  282. }
  283. },
  284. previewAccount() {
  285. this.form.clear()
  286. this.$refs.AdvancedFormOtpDisplayer.show()
  287. },
  288. cancelCreation: function() {
  289. if( this.form.service ) {
  290. if( confirm(this.$t('twofaccounts.confirm.cancel')) === false ) {
  291. return
  292. }
  293. }
  294. // clean possible uploaded temp icon
  295. this.deleteIcon()
  296. this.$router.push({name: 'accounts'});
  297. },
  298. uploadQrcode(event) {
  299. let imgdata = new FormData();
  300. imgdata.append('qrcode', this.$refs.qrcodeInput.files[0]);
  301. imgdata.append('inputFormat', 'fileUpload');
  302. // First we get the uri encoded in the qrcode
  303. this.form.upload('/api/v1/qrcode/decode', imgdata, {returnError: true}).then(response => {
  304. this.uri = response.data.data
  305. // Then the otp described by the uri
  306. this.axios.post('/api/v1/twofaccounts/preview', { uri: this.uri }).then(response => {
  307. this.form.fill(response.data)
  308. this.tempIcon = response.data.icon ? response.data.icon : null
  309. })
  310. .catch(error => {
  311. if( error.response.status === 422 ) {
  312. if( error.response.data.errors.uri ) {
  313. this.showAlternatives = true
  314. }
  315. }
  316. });
  317. })
  318. .catch(error => {
  319. this.$notify({type: 'is-danger', text: this.$t(error.response.data.message) })
  320. return false
  321. });
  322. },
  323. uploadIcon(event) {
  324. // clean possible already uploaded temp icon
  325. this.deleteIcon()
  326. let imgdata = new FormData();
  327. imgdata.append('icon', this.$refs.iconInput.files[0]);
  328. this.form.upload('/api/v1/icons', imgdata, {returnError: true}).then(response => {
  329. this.tempIcon = response.data.filename;
  330. })
  331. .catch(error => {
  332. this.$notify({type: 'is-danger', text: this.$t(error.response.data.message) })
  333. });
  334. },
  335. fetchLogo() {
  336. if (this.$root.appSettings.getOfficialIcons) {
  337. this.axios.post('/api/v1/icons/default', {service: this.form.service}, {returnError: true}).then(response => {
  338. if (response.status === 201) {
  339. // clean possible already uploaded temp icon
  340. this.deleteIcon()
  341. this.tempIcon = response.data.filename;
  342. }
  343. else this.$notify({type: 'is-warning', text: this.$t('errors.no_logo_found_for_x', {service: this.form.service}) })
  344. })
  345. .catch(error => {
  346. this.$notify({type: 'is-warning', text: this.$t('errors.no_logo_found_for_x', {service: this.form.service}) })
  347. });
  348. }
  349. },
  350. deleteIcon(event) {
  351. if(this.tempIcon) {
  352. this.axios.delete('/api/v1/icons/' + this.tempIcon)
  353. this.tempIcon = ''
  354. }
  355. },
  356. incrementHotp(payload) {
  357. // The quick form or the preview feature has incremented the HOTP counter so we get the new value from
  358. // the component.
  359. // This could desynchronized the HOTP verification server and our local counter if the user never verified the HOTP but this
  360. // is acceptable (and HOTP counter can be edited by the way)
  361. this.form.counter = payload.nextHotpCounter
  362. },
  363. clipboardSuccessHandler ({ value, event }) {
  364. if(this.$root.appSettings.kickUserAfter == -1) {
  365. this.appLogout()
  366. }
  367. this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
  368. },
  369. clipboardErrorHandler ({ value, event }) {
  370. console.log('error', value)
  371. },
  372. setFormState (from, to) {
  373. this.form.otp_type = to
  374. if (to === 'steamtotp') {
  375. this.form.service = 'Steam'
  376. this.fetchLogo()
  377. }
  378. else if (from === 'steamtotp') {
  379. this.form.service = ''
  380. this.deleteIcon()
  381. }
  382. },
  383. mapDisplayerErrors (event) {
  384. this.form.errors.set(this.form.extractErrors(event))
  385. }
  386. },
  387. }
  388. </script>