Usernames.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  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="rows[props.row.originalIndex].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="() => rows[props.row.originalIndex].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_count }}
  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? This will also delete all aliases
  272. associated with this username. You will no longer be able to receive any emails at this
  273. username subdomain. This will <b>still count</b> towards your additional username limit
  274. <b>even once deleted</b>.
  275. </p>
  276. <div class="mt-6">
  277. <button
  278. type="button"
  279. @click="deleteUsername(usernameIdToDelete)"
  280. class="px-4 py-3 text-white font-semibold bg-red-500 hover:bg-red-600 border border-transparent rounded focus:outline-none"
  281. :class="deleteUsernameLoading ? 'cursor-not-allowed' : ''"
  282. :disabled="deleteUsernameLoading"
  283. >
  284. Delete username
  285. <loader v-if="deleteUsernameLoading" />
  286. </button>
  287. <button
  288. @click="closeDeleteModal"
  289. 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"
  290. >
  291. Cancel
  292. </button>
  293. </div>
  294. </div>
  295. </Modal>
  296. </div>
  297. </template>
  298. <script>
  299. import Modal from './../components/Modal.vue'
  300. import Toggle from './../components/Toggle.vue'
  301. import tippy from 'tippy.js'
  302. import Multiselect from 'vue-multiselect'
  303. export default {
  304. props: {
  305. initialUsernames: {
  306. type: Array,
  307. required: true,
  308. },
  309. usernameCount: {
  310. type: Number,
  311. required: true,
  312. },
  313. recipientOptions: {
  314. type: Array,
  315. required: true,
  316. },
  317. },
  318. components: {
  319. Modal,
  320. Toggle,
  321. Multiselect,
  322. },
  323. mounted() {
  324. this.addTooltips()
  325. },
  326. data() {
  327. return {
  328. newUsername: '',
  329. search: '',
  330. addUsernameLoading: false,
  331. addUsernameModalOpen: false,
  332. usernameIdToDelete: null,
  333. usernameIdToEdit: '',
  334. usernameDescriptionToEdit: '',
  335. deleteUsernameLoading: false,
  336. deleteUsernameModalOpen: false,
  337. usernameDefaultRecipientModalOpen: false,
  338. defaultRecipientUsernameToEdit: {},
  339. defaultRecipient: {},
  340. editDefaultRecipientLoading: false,
  341. errors: {},
  342. columns: [
  343. {
  344. label: 'Created',
  345. field: 'created_at',
  346. globalSearchDisabled: true,
  347. },
  348. {
  349. label: 'Username',
  350. field: 'username',
  351. },
  352. {
  353. label: 'Description',
  354. field: 'description',
  355. },
  356. {
  357. label: 'Default Recipient',
  358. field: 'default_recipient',
  359. sortable: false,
  360. globalSearchDisabled: true,
  361. },
  362. {
  363. label: 'Alias Count',
  364. field: 'aliases_count',
  365. type: 'number',
  366. globalSearchDisabled: true,
  367. },
  368. {
  369. label: 'Active',
  370. field: 'active',
  371. type: 'boolean',
  372. globalSearchDisabled: true,
  373. },
  374. {
  375. label: '',
  376. field: 'actions',
  377. sortable: false,
  378. globalSearchDisabled: true,
  379. },
  380. ],
  381. rows: this.initialUsernames,
  382. }
  383. },
  384. watch: {
  385. usernameIdToEdit: _.debounce(function() {
  386. this.addTooltips()
  387. }, 50),
  388. },
  389. methods: {
  390. addTooltips() {
  391. tippy('.tooltip', {
  392. arrow: true,
  393. arrowType: 'round',
  394. })
  395. },
  396. debounceToolips: _.debounce(function() {
  397. this.addTooltips()
  398. }, 50),
  399. validateNewUsername(e) {
  400. this.errors = {}
  401. if (!this.newUsername) {
  402. this.errors.newUsername = 'Username is required'
  403. } else if (!this.validUsername(this.newUsername)) {
  404. this.errors.newUsername = 'Username must only contain letters and numbers'
  405. } else if (this.newUsername.length > 20) {
  406. this.errors.newUsername = 'Username cannot be greater than 20 characters'
  407. }
  408. if (!this.errors.newUsername) {
  409. this.addNewUsername()
  410. }
  411. e.preventDefault()
  412. },
  413. addNewUsername() {
  414. this.addUsernameLoading = true
  415. axios
  416. .post(
  417. '/api/v1/usernames',
  418. JSON.stringify({
  419. username: this.newUsername,
  420. }),
  421. {
  422. headers: { 'Content-Type': 'application/json' },
  423. }
  424. )
  425. .then(({ data }) => {
  426. this.addUsernameLoading = false
  427. this.rows.push(data.data)
  428. this.newUsername = ''
  429. this.addUsernameModalOpen = false
  430. this.success('Additional Username added')
  431. })
  432. .catch(error => {
  433. this.addUsernameLoading = false
  434. if (error.response.status === 403) {
  435. this.error('You have reached your additional username limit')
  436. } else if (error.response.status == 422) {
  437. this.error(error.response.data.errors.username[0])
  438. } else {
  439. this.error()
  440. }
  441. })
  442. },
  443. openDeleteModal(id) {
  444. this.deleteUsernameModalOpen = true
  445. this.usernameIdToDelete = id
  446. },
  447. closeDeleteModal() {
  448. this.deleteUsernameModalOpen = false
  449. this.usernameIdToDelete = null
  450. },
  451. openUsernameDefaultRecipientModal(username) {
  452. this.usernameDefaultRecipientModalOpen = true
  453. this.defaultRecipientUsernameToEdit = username
  454. this.defaultRecipient = username.default_recipient
  455. },
  456. closeUsernameDefaultRecipientModal() {
  457. this.usernameDefaultRecipientModalOpen = false
  458. this.defaultRecipientUsernameToEdit = {}
  459. this.defaultRecipient = {}
  460. },
  461. editUsername(username) {
  462. if (this.usernameDescriptionToEdit.length > 100) {
  463. return this.error('Description cannot be more than 100 characters')
  464. }
  465. axios
  466. .patch(
  467. `/api/v1/usernames/${username.id}`,
  468. JSON.stringify({
  469. description: this.usernameDescriptionToEdit,
  470. }),
  471. {
  472. headers: { 'Content-Type': 'application/json' },
  473. }
  474. )
  475. .then(response => {
  476. username.description = this.usernameDescriptionToEdit
  477. this.usernameIdToEdit = ''
  478. this.usernameDescriptionToEdit = ''
  479. this.success('Username description updated')
  480. })
  481. .catch(error => {
  482. this.usernameIdToEdit = ''
  483. this.usernameDescriptionToEdit = ''
  484. this.error()
  485. })
  486. },
  487. editDefaultRecipient() {
  488. this.editDefaultRecipientLoading = true
  489. axios
  490. .patch(
  491. `/api/v1/usernames/${this.defaultRecipientUsernameToEdit.id}/default-recipient`,
  492. JSON.stringify({
  493. default_recipient: this.defaultRecipient ? this.defaultRecipient.id : '',
  494. }),
  495. {
  496. headers: { 'Content-Type': 'application/json' },
  497. }
  498. )
  499. .then(response => {
  500. let username = _.find(this.rows, ['id', this.defaultRecipientUsernameToEdit.id])
  501. username.default_recipient = this.defaultRecipient
  502. this.usernameDefaultRecipientModalOpen = false
  503. this.editDefaultRecipientLoading = false
  504. this.defaultRecipient = {}
  505. this.success("Additional Username's default recipient updated")
  506. })
  507. .catch(error => {
  508. this.usernameDefaultRecipientModalOpen = false
  509. this.editDefaultRecipientLoading = false
  510. this.defaultRecipient = {}
  511. this.error()
  512. })
  513. },
  514. activateUsername(id) {
  515. axios
  516. .post(
  517. `/api/v1/active-usernames`,
  518. JSON.stringify({
  519. id: id,
  520. }),
  521. {
  522. headers: { 'Content-Type': 'application/json' },
  523. }
  524. )
  525. .then(response => {
  526. //
  527. })
  528. .catch(error => {
  529. this.error()
  530. })
  531. },
  532. deactivateUsername(id) {
  533. axios
  534. .delete(`/api/v1/active-usernames/${id}`)
  535. .then(response => {
  536. //
  537. })
  538. .catch(error => {
  539. this.error()
  540. })
  541. },
  542. deleteUsername(id) {
  543. this.deleteUsernameLoading = true
  544. axios
  545. .delete(`/api/v1/usernames/${id}`)
  546. .then(response => {
  547. this.rows = _.reject(this.rows, username => username.id === id)
  548. this.deleteUsernameModalOpen = false
  549. this.deleteUsernameLoading = false
  550. })
  551. .catch(error => {
  552. this.error()
  553. this.deleteUsernameLoading = false
  554. this.deleteUsernameModalOpen = false
  555. })
  556. },
  557. validUsername(username) {
  558. let re = /^[a-zA-Z0-9]*$/
  559. return re.test(username)
  560. },
  561. clipboardSuccess() {
  562. this.success('Copied to clipboard')
  563. },
  564. clipboardError() {
  565. this.error('Could not copy to clipboard')
  566. },
  567. success(text = '') {
  568. this.$notify({
  569. title: 'Success',
  570. text: text,
  571. type: 'success',
  572. })
  573. },
  574. error(text = 'An error has occurred, please try again later') {
  575. this.$notify({
  576. title: 'Error',
  577. text: text,
  578. type: 'error',
  579. })
  580. },
  581. },
  582. }
  583. </script>