Usernames.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. <template>
  2. <div>
  3. <div class="mb-6 flex flex-col md:flex-row justify-between md:items-center">
  4. <div class="relative">
  5. <input
  6. v-model="search"
  7. @keyup.esc="search = ''"
  8. tabindex="0"
  9. type="text"
  10. class="w-full md:w-64 appearance-none shadow bg-white text-grey-700 focus:outline-none rounded py-3 pl-3 pr-8"
  11. placeholder="Search Usernames"
  12. />
  13. <icon
  14. v-if="search"
  15. @click.native="search = ''"
  16. name="close-circle"
  17. class="absolute right-0 inset-y-0 w-5 h-full text-grey-300 fill-current mr-2 flex items-center cursor-pointer"
  18. />
  19. <icon
  20. v-else
  21. name="search"
  22. class="absolute right-0 inset-y-0 w-5 h-full text-grey-300 fill-current pointer-events-none mr-2 flex items-center"
  23. />
  24. </div>
  25. <div class="mt-4 md:mt-0">
  26. <button
  27. @click="addUsernameModalOpen = true"
  28. class="bg-cyan-400 hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none ml-auto"
  29. >
  30. Add Username
  31. </button>
  32. </div>
  33. </div>
  34. <vue-good-table
  35. v-if="initialUsernames.length"
  36. @on-search="debounceToolips"
  37. :columns="columns"
  38. :rows="rows"
  39. :search-options="{
  40. enabled: true,
  41. skipDiacritics: true,
  42. externalQuery: search,
  43. }"
  44. :sort-options="{
  45. enabled: true,
  46. initialSortBy: { field: 'created_at', type: 'desc' },
  47. }"
  48. styleClass="vgt-table"
  49. >
  50. <div slot="emptystate" class="flex items-center justify-center h-24 text-lg text-grey-700">
  51. No usernames found for that search!
  52. </div>
  53. <template slot="table-row" slot-scope="props">
  54. <span
  55. v-if="props.column.field == 'created_at'"
  56. class="tooltip outline-none text-sm"
  57. :data-tippy-content="props.row.created_at | formatDate"
  58. >{{ props.row.created_at | timeAgo }}
  59. </span>
  60. <span v-else-if="props.column.field == 'username'">
  61. <span
  62. class="tooltip cursor-pointer outline-none"
  63. data-tippy-content="Click to copy"
  64. v-clipboard="() => props.row.username"
  65. v-clipboard:success="clipboardSuccess"
  66. v-clipboard:error="clipboardError"
  67. >{{ props.row.username | truncate(30) }}</span
  68. >
  69. </span>
  70. <span v-else-if="props.column.field == 'description'">
  71. <div v-if="usernameIdToEdit === props.row.id" class="flex items-center">
  72. <input
  73. @keyup.enter="editUsername(rows[props.row.originalIndex])"
  74. @keyup.esc="usernameIdToEdit = usernameDescriptionToEdit = ''"
  75. v-model="usernameDescriptionToEdit"
  76. type="text"
  77. class="flex-grow appearance-none bg-grey-100 border text-grey-700 focus:outline-none rounded px-2 py-1"
  78. :class="
  79. usernameDescriptionToEdit.length > 100 ? 'border-red-500' : 'border-transparent'
  80. "
  81. placeholder="Add description"
  82. tabindex="0"
  83. autofocus
  84. />
  85. <icon
  86. name="close"
  87. class="inline-block w-6 h-6 text-red-300 fill-current cursor-pointer"
  88. @click.native="usernameIdToEdit = usernameDescriptionToEdit = ''"
  89. />
  90. <icon
  91. name="save"
  92. class="inline-block w-6 h-6 text-cyan-500 fill-current cursor-pointer"
  93. @click.native="editUsername(rows[props.row.originalIndex])"
  94. />
  95. </div>
  96. <div v-else-if="props.row.description" class="flex items-centers">
  97. <span class="tooltip outline-none" :data-tippy-content="props.row.description">{{
  98. props.row.description | truncate(60)
  99. }}</span>
  100. <icon
  101. name="edit"
  102. class="inline-block w-6 h-6 text-grey-200 fill-current cursor-pointer ml-2"
  103. @click.native="
  104. ;(usernameIdToEdit = props.row.id),
  105. (usernameDescriptionToEdit = props.row.description)
  106. "
  107. />
  108. </div>
  109. <div v-else class="flex justify-center">
  110. <icon
  111. name="plus"
  112. class="block w-6 h-6 text-grey-200 fill-current cursor-pointer"
  113. @click.native=";(usernameIdToEdit = props.row.id), (usernameDescriptionToEdit = '')"
  114. />
  115. </div>
  116. </span>
  117. <span v-else-if="props.column.field === 'default_recipient'">
  118. <div v-if="props.row.default_recipient">
  119. {{ props.row.default_recipient.email | truncate(30) }}
  120. <icon
  121. name="edit"
  122. class="ml-2 inline-block w-6 h-6 text-grey-200 fill-current cursor-pointer"
  123. @click.native="openUsernameDefaultRecipientModal(props.row)"
  124. />
  125. </div>
  126. <div class="flex justify-center" v-else>
  127. <icon
  128. name="plus"
  129. class="block w-6 h-6 text-grey-200 fill-current cursor-pointer"
  130. @click.native="openUsernameDefaultRecipientModal(props.row)"
  131. />
  132. </div>
  133. </span>
  134. <span v-else-if="props.column.field === 'aliases_count'">
  135. {{ props.row.aliases.length }}
  136. </span>
  137. <span v-else-if="props.column.field === 'active'" class="flex items-center">
  138. <Toggle
  139. v-model="rows[props.row.originalIndex].active"
  140. @on="activateUsername(props.row.id)"
  141. @off="deactivateUsername(props.row.id)"
  142. />
  143. </span>
  144. <span v-else class="flex items-center justify-center outline-none" tabindex="-1">
  145. <icon
  146. name="trash"
  147. class="block w-6 h-6 text-grey-200 fill-current cursor-pointer"
  148. @click.native="openDeleteModal(props.row.id)"
  149. />
  150. </span>
  151. </template>
  152. </vue-good-table>
  153. <div v-else class="bg-white rounded shadow overflow-x-auto">
  154. <div class="p-8 text-center text-lg text-grey-700">
  155. <h1 class="mb-6 text-xl text-indigo-800 font-semibold">
  156. This is where you can add and view additional usernames
  157. </h1>
  158. <div class="mx-auto mb-6 w-24 border-b-2 border-grey-200"></div>
  159. <p class="mb-4">
  160. When you add an additional username here you will be able to use it exactly like the
  161. username you signed up with!
  162. </p>
  163. <p class="mb-4">
  164. You can then separate aliases under your different usernames to reduce the chance of
  165. anyone linking ownership of them together. Great for compartmentalisation e.g. for work
  166. and personal emails.
  167. </p>
  168. <p>
  169. You can add a maximum of {{ usernameCount }} additional usernames. Deleted usernames still
  170. count towards your limit so please choose carefully.
  171. </p>
  172. </div>
  173. </div>
  174. <Modal :open="addUsernameModalOpen" @close="addUsernameModalOpen = false">
  175. <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl p-6">
  176. <h2
  177. class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
  178. >
  179. Add new username
  180. </h2>
  181. <p class="mt-4 text-grey-700">
  182. Please choose additional usernames carefully as you can only add a maximum of
  183. {{ usernameCount }}. You cannot login with these usernames, only the one you originally
  184. signed up with.
  185. </p>
  186. <div class="mt-6">
  187. <p v-show="errors.newUsername" class="mb-3 text-red-500 text-sm">
  188. {{ errors.newUsername }}
  189. </p>
  190. <input
  191. v-model="newUsername"
  192. type="text"
  193. class="w-full appearance-none bg-grey-100 border border-transparent text-grey-700 focus:outline-none rounded p-3 mb-6"
  194. :class="errors.newUsername ? 'border-red-500' : ''"
  195. placeholder="johndoe"
  196. autofocus
  197. />
  198. <button
  199. @click="validateNewUsername"
  200. class="bg-cyan-400 hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none"
  201. :class="addUsernameLoading ? 'cursor-not-allowed' : ''"
  202. :disabled="addUsernameLoading"
  203. >
  204. Add Username
  205. <loader v-if="addUsernameLoading" />
  206. </button>
  207. <button
  208. @click="addUsernameModalOpen = false"
  209. class="ml-4 px-4 py-3 text-grey-800 font-semibold bg-white hover:bg-grey-50 border border-grey-100 rounded focus:outline-none"
  210. >
  211. Cancel
  212. </button>
  213. </div>
  214. </div>
  215. </Modal>
  216. <Modal :open="usernameDefaultRecipientModalOpen" @close="closeUsernameDefaultRecipientModal">
  217. <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl px-6 py-6">
  218. <h2
  219. class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
  220. >
  221. Update Default Recipient
  222. </h2>
  223. <p class="my-4 text-grey-700">
  224. Select the default recipient for this username. This overrides the default recipient in
  225. your account settings. Leave it empty if you would like to use the default recipient in
  226. your account settings.
  227. </p>
  228. <multiselect
  229. v-model="defaultRecipient"
  230. :options="recipientOptions"
  231. :multiple="false"
  232. :close-on-select="true"
  233. :clear-on-select="false"
  234. :searchable="false"
  235. :allow-empty="true"
  236. placeholder="Select recipient"
  237. label="email"
  238. track-by="email"
  239. :preselect-first="false"
  240. :show-labels="false"
  241. >
  242. </multiselect>
  243. <div class="mt-6">
  244. <button
  245. type="button"
  246. @click="editDefaultRecipient()"
  247. class="px-4 py-3 text-cyan-900 font-semibold bg-cyan-400 hover:bg-cyan-300 border border-transparent rounded focus:outline-none"
  248. :class="editDefaultRecipientLoading ? 'cursor-not-allowed' : ''"
  249. :disabled="editDefaultRecipientLoading"
  250. >
  251. Update Default Recipient
  252. <loader v-if="editDefaultRecipientLoading" />
  253. </button>
  254. <button
  255. @click="closeUsernameDefaultRecipientModal()"
  256. class="ml-4 px-4 py-3 text-grey-800 font-semibold bg-white hover:bg-grey-50 border border-grey-100 rounded focus:outline-none"
  257. >
  258. Cancel
  259. </button>
  260. </div>
  261. </div>
  262. </Modal>
  263. <Modal :open="deleteUsernameModalOpen" @close="closeDeleteModal">
  264. <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl p-6">
  265. <h2
  266. class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
  267. >
  268. Delete username
  269. </h2>
  270. <p class="mt-4 text-grey-700">
  271. Are you sure you want to delete this username? You will no longer be able to receive any
  272. emails at this username subdomain. This will still count towards your additional username
  273. limit even once deleted.
  274. </p>
  275. <div class="mt-6">
  276. <button
  277. type="button"
  278. @click="deleteUsername(usernameIdToDelete)"
  279. class="px-4 py-3 text-white font-semibold bg-red-500 hover:bg-red-600 border border-transparent rounded focus:outline-none"
  280. :class="deleteUsernameLoading ? 'cursor-not-allowed' : ''"
  281. :disabled="deleteUsernameLoading"
  282. >
  283. Delete username
  284. <loader v-if="deleteUsernameLoading" />
  285. </button>
  286. <button
  287. @click="closeDeleteModal"
  288. class="ml-4 px-4 py-3 text-grey-800 font-semibold bg-white hover:bg-grey-50 border border-grey-100 rounded focus:outline-none"
  289. >
  290. Cancel
  291. </button>
  292. </div>
  293. </div>
  294. </Modal>
  295. </div>
  296. </template>
  297. <script>
  298. import Modal from './../components/Modal.vue'
  299. import Toggle from './../components/Toggle.vue'
  300. import tippy from 'tippy.js'
  301. import Multiselect from 'vue-multiselect'
  302. export default {
  303. props: {
  304. initialUsernames: {
  305. type: Array,
  306. required: true,
  307. },
  308. usernameCount: {
  309. type: Number,
  310. required: true,
  311. },
  312. recipientOptions: {
  313. type: Array,
  314. required: true,
  315. },
  316. },
  317. components: {
  318. Modal,
  319. Toggle,
  320. Multiselect,
  321. },
  322. mounted() {
  323. this.addTooltips()
  324. },
  325. data() {
  326. return {
  327. newUsername: '',
  328. search: '',
  329. addUsernameLoading: false,
  330. addUsernameModalOpen: false,
  331. usernameIdToDelete: null,
  332. usernameIdToEdit: '',
  333. usernameDescriptionToEdit: '',
  334. deleteUsernameLoading: false,
  335. deleteUsernameModalOpen: false,
  336. usernameDefaultRecipientModalOpen: false,
  337. defaultRecipientUsernameToEdit: {},
  338. defaultRecipient: {},
  339. editDefaultRecipientLoading: false,
  340. errors: {},
  341. columns: [
  342. {
  343. label: 'Created',
  344. field: 'created_at',
  345. globalSearchDisabled: true,
  346. },
  347. {
  348. label: 'Username',
  349. field: 'username',
  350. },
  351. {
  352. label: 'Description',
  353. field: 'description',
  354. },
  355. {
  356. label: 'Default Recipient',
  357. field: 'default_recipient',
  358. sortable: false,
  359. globalSearchDisabled: true,
  360. },
  361. {
  362. label: 'Alias Count',
  363. field: 'aliases_count',
  364. type: 'number',
  365. globalSearchDisabled: true,
  366. },
  367. {
  368. label: 'Active',
  369. field: 'active',
  370. type: 'boolean',
  371. globalSearchDisabled: true,
  372. },
  373. {
  374. label: '',
  375. field: 'actions',
  376. sortable: false,
  377. globalSearchDisabled: true,
  378. },
  379. ],
  380. rows: this.initialUsernames,
  381. }
  382. },
  383. watch: {
  384. usernameIdToEdit: _.debounce(function() {
  385. this.addTooltips()
  386. }, 50),
  387. },
  388. methods: {
  389. addTooltips() {
  390. tippy('.tooltip', {
  391. arrow: true,
  392. arrowType: 'round',
  393. })
  394. },
  395. debounceToolips: _.debounce(function() {
  396. this.addTooltips()
  397. }, 50),
  398. validateNewUsername(e) {
  399. this.errors = {}
  400. if (!this.newUsername) {
  401. this.errors.newUsername = 'Username is required'
  402. } else if (!this.validUsername(this.newUsername)) {
  403. this.errors.newUsername = 'Username must only contain letters and numbers'
  404. } else if (this.newUsername.length > 20) {
  405. this.errors.newUsername = 'Username cannot be greater than 20 characters'
  406. }
  407. if (!this.errors.newUsername) {
  408. this.addNewUsername()
  409. }
  410. e.preventDefault()
  411. },
  412. addNewUsername() {
  413. this.addUsernameLoading = true
  414. axios
  415. .post(
  416. '/api/v1/usernames',
  417. JSON.stringify({
  418. username: this.newUsername,
  419. }),
  420. {
  421. headers: { 'Content-Type': 'application/json' },
  422. }
  423. )
  424. .then(({ data }) => {
  425. this.addUsernameLoading = false
  426. this.rows.push(data.data)
  427. this.newUsername = ''
  428. this.addUsernameModalOpen = false
  429. this.success('Additional Username added')
  430. })
  431. .catch(error => {
  432. this.addUsernameLoading = false
  433. if (error.response.status === 403) {
  434. this.error('You have reached your additional username limit')
  435. } else if (error.response.status == 422) {
  436. this.error(error.response.data.errors.username[0])
  437. } else {
  438. this.error()
  439. }
  440. })
  441. },
  442. openDeleteModal(id) {
  443. this.deleteUsernameModalOpen = true
  444. this.usernameIdToDelete = id
  445. },
  446. closeDeleteModal() {
  447. this.deleteUsernameModalOpen = false
  448. this.usernameIdToDelete = null
  449. },
  450. openUsernameDefaultRecipientModal(username) {
  451. this.usernameDefaultRecipientModalOpen = true
  452. this.defaultRecipientUsernameToEdit = username
  453. this.defaultRecipient = username.default_recipient
  454. },
  455. closeUsernameDefaultRecipientModal() {
  456. this.usernameDefaultRecipientModalOpen = false
  457. this.defaultRecipientUsernameToEdit = {}
  458. this.defaultRecipient = {}
  459. },
  460. editUsername(username) {
  461. if (this.usernameDescriptionToEdit.length > 100) {
  462. return this.error('Description cannot be more than 100 characters')
  463. }
  464. axios
  465. .patch(
  466. `/api/v1/usernames/${username.id}`,
  467. JSON.stringify({
  468. description: this.usernameDescriptionToEdit,
  469. }),
  470. {
  471. headers: { 'Content-Type': 'application/json' },
  472. }
  473. )
  474. .then(response => {
  475. username.description = this.usernameDescriptionToEdit
  476. this.usernameIdToEdit = ''
  477. this.usernameDescriptionToEdit = ''
  478. this.success('Username description updated')
  479. })
  480. .catch(error => {
  481. this.usernameIdToEdit = ''
  482. this.usernameDescriptionToEdit = ''
  483. this.error()
  484. })
  485. },
  486. editDefaultRecipient() {
  487. this.editDefaultRecipientLoading = true
  488. axios
  489. .patch(
  490. `/api/v1/usernames/${this.defaultRecipientUsernameToEdit.id}/default-recipient`,
  491. JSON.stringify({
  492. default_recipient: this.defaultRecipient ? this.defaultRecipient.id : '',
  493. }),
  494. {
  495. headers: { 'Content-Type': 'application/json' },
  496. }
  497. )
  498. .then(response => {
  499. let username = _.find(this.rows, ['id', this.defaultRecipientUsernameToEdit.id])
  500. username.default_recipient = this.defaultRecipient
  501. this.usernameDefaultRecipientModalOpen = false
  502. this.editDefaultRecipientLoading = false
  503. this.defaultRecipient = {}
  504. this.success("Additional Username's default recipient updated")
  505. })
  506. .catch(error => {
  507. this.usernameDefaultRecipientModalOpen = false
  508. this.editDefaultRecipientLoading = false
  509. this.defaultRecipient = {}
  510. this.error()
  511. })
  512. },
  513. activateUsername(id) {
  514. axios
  515. .post(
  516. `/api/v1/active-usernames`,
  517. JSON.stringify({
  518. id: id,
  519. }),
  520. {
  521. headers: { 'Content-Type': 'application/json' },
  522. }
  523. )
  524. .then(response => {
  525. //
  526. })
  527. .catch(error => {
  528. this.error()
  529. })
  530. },
  531. deactivateUsername(id) {
  532. axios
  533. .delete(`/api/v1/active-usernames/${id}`)
  534. .then(response => {
  535. //
  536. })
  537. .catch(error => {
  538. this.error()
  539. })
  540. },
  541. deleteUsername(id) {
  542. this.deleteUsernameLoading = true
  543. axios
  544. .delete(`/api/v1/usernames/${id}`)
  545. .then(response => {
  546. this.rows = _.reject(this.rows, username => username.id === id)
  547. this.deleteUsernameModalOpen = false
  548. this.deleteUsernameLoading = false
  549. })
  550. .catch(error => {
  551. this.error()
  552. this.deleteUsernameLoading = false
  553. this.deleteUsernameModalOpen = false
  554. })
  555. },
  556. validUsername(username) {
  557. let re = /^[a-zA-Z0-9]*$/
  558. return re.test(username)
  559. },
  560. clipboardSuccess() {
  561. this.success('Copied to clipboard')
  562. },
  563. clipboardError() {
  564. this.error('Could not copy to clipboard')
  565. },
  566. success(text = '') {
  567. this.$notify({
  568. title: 'Success',
  569. text: text,
  570. type: 'success',
  571. })
  572. },
  573. error(text = 'An error has occurred, please try again later') {
  574. this.$notify({
  575. title: 'Error',
  576. text: text,
  577. type: 'error',
  578. })
  579. },
  580. },
  581. }
  582. </script>