Domains.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  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 Domains"
  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="addDomainModalOpen = 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 Custom Domain
  31. </button>
  32. </div>
  33. </div>
  34. <vue-good-table
  35. v-if="initialDomains.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 domains 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 == 'domain'">
  61. <span
  62. class="tooltip cursor-pointer outline-none"
  63. data-tippy-content="Click to copy"
  64. v-clipboard="() => props.row.domain"
  65. v-clipboard:success="clipboardSuccess"
  66. v-clipboard:error="clipboardError"
  67. >{{ props.row.domain | truncate(30) }}</span
  68. >
  69. </span>
  70. <span v-else-if="props.column.field == 'description'">
  71. <div v-if="domainIdToEdit === props.row.id" class="flex items-center">
  72. <input
  73. @keyup.enter="editDomain(rows[props.row.originalIndex])"
  74. @keyup.esc="domainIdToEdit = domainDescriptionToEdit = ''"
  75. v-model="domainDescriptionToEdit"
  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. domainDescriptionToEdit.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="domainIdToEdit = domainDescriptionToEdit = ''"
  89. />
  90. <icon
  91. name="save"
  92. class="inline-block w-6 h-6 text-cyan-500 fill-current cursor-pointer"
  93. @click.native="editDomain(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. ;(domainIdToEdit = props.row.id), (domainDescriptionToEdit = props.row.description)
  105. "
  106. />
  107. </div>
  108. <div v-else class="flex justify-center">
  109. <icon
  110. name="plus"
  111. class="block w-6 h-6 text-grey-200 fill-current cursor-pointer"
  112. @click.native=";(domainIdToEdit = props.row.id), (domainDescriptionToEdit = '')"
  113. />
  114. </div>
  115. </span>
  116. <span v-else-if="props.column.field === 'default_recipient'">
  117. <div v-if="props.row.default_recipient">
  118. {{ props.row.default_recipient.email | truncate(30) }}
  119. <icon
  120. name="edit"
  121. class="ml-2 inline-block w-6 h-6 text-grey-200 fill-current cursor-pointer"
  122. @click.native="openDomainDefaultRecipientModal(props.row)"
  123. />
  124. </div>
  125. <div class="flex justify-center" v-else>
  126. <icon
  127. name="plus"
  128. class="block w-6 h-6 text-grey-200 fill-current cursor-pointer"
  129. @click.native="openDomainDefaultRecipientModal(props.row)"
  130. />
  131. </div>
  132. </span>
  133. <span v-else-if="props.column.field === 'aliases_count'">
  134. {{ props.row.aliases.length }}
  135. </span>
  136. <span v-else-if="props.column.field === 'active'" class="flex items-center">
  137. <Toggle
  138. v-model="rows[props.row.originalIndex].active"
  139. @on="activateDomain(props.row.id)"
  140. @off="deactivateDomain(props.row.id)"
  141. />
  142. </span>
  143. <span v-else-if="props.column.field === 'domain_verified_at'">
  144. <span
  145. name="check"
  146. v-if="props.row.domain_verified_at"
  147. class="py-1 px-2 bg-green-200 text-green-900 rounded-full text-sm"
  148. >
  149. verified
  150. </span>
  151. <button
  152. v-else
  153. @click="recheckRecords(rows[props.row.originalIndex])"
  154. class="focus:outline-none text-sm"
  155. :class="recheckRecordsLoading ? 'cursor-not-allowed' : ''"
  156. :disabled="recheckRecordsLoading"
  157. >
  158. Recheck domain
  159. </button>
  160. </span>
  161. <span v-else class="flex items-center justify-center outline-none" tabindex="-1">
  162. <icon
  163. name="trash"
  164. class="block w-6 h-6 text-grey-200 fill-current cursor-pointer"
  165. @click.native="openDeleteModal(props.row.id)"
  166. />
  167. </span>
  168. </template>
  169. </vue-good-table>
  170. <div v-else class="bg-white rounded shadow overflow-x-auto">
  171. <div class="p-8 text-center text-lg text-grey-700">
  172. <h1 class="mb-6 text-xl text-indigo-800 font-semibold">
  173. This is where you can set up and view custom domains
  174. </h1>
  175. <div class="mx-auto mb-6 w-24 border-b-2 border-grey-200"></div>
  176. <p class="mb-4">
  177. To get started all you have to do is add an MX record to your domain and then add the
  178. domain here by clicking the button above.
  179. </p>
  180. <p class="mb-4">
  181. The new record needs to have the following values:
  182. </p>
  183. <p class="mb-4">
  184. Host: <b>@</b><br />
  185. Value: <b>{{ hostname }}</b
  186. ><br />
  187. Priority: <b>10</b><br />
  188. TTL: <b>3600</b>
  189. </p>
  190. <p>
  191. Once the DNS changes propagate you will be able to recieve emails at your own domain.
  192. </p>
  193. </div>
  194. </div>
  195. <Modal :open="addDomainModalOpen" @close="addDomainModalOpen = false">
  196. <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl p-6">
  197. <h2
  198. class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
  199. >
  200. Add new domain
  201. </h2>
  202. <p class="mt-4 text-grey-700">
  203. Make sure you add the following MX record to your domain.<br /><br />
  204. Host: <b>@</b><br />
  205. Value: <b>{{ hostname }}</b
  206. ><br />
  207. Priority: <b>10</b><br />
  208. TTL: <b>3600</b><br /><br />
  209. Just include the domain/subdomain e.g. example.com without any http protocol.
  210. </p>
  211. <div class="mt-6">
  212. <p v-show="errors.newDomain" class="mb-3 text-red-500">
  213. {{ errors.newDomain }}
  214. </p>
  215. <input
  216. v-model="newDomain"
  217. type="text"
  218. class="w-full appearance-none bg-grey-100 border border-transparent text-grey-700 focus:outline-none rounded p-3 mb-6"
  219. :class="errors.newDomain ? 'border-red-500' : ''"
  220. placeholder="example.com"
  221. autofocus
  222. />
  223. <button
  224. @click="validateNewDomain"
  225. class="bg-cyan-400 hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none"
  226. :class="addDomainLoading ? 'cursor-not-allowed' : ''"
  227. :disabled="addDomainLoading"
  228. >
  229. Add Domain
  230. <loader v-if="addDomainLoading" />
  231. </button>
  232. <button
  233. @click="addDomainModalOpen = false"
  234. 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"
  235. >
  236. Cancel
  237. </button>
  238. </div>
  239. </div>
  240. </Modal>
  241. <Modal :open="domainDefaultRecipientModalOpen" @close="closeDomainDefaultRecipientModal">
  242. <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl px-6 py-6">
  243. <h2
  244. class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
  245. >
  246. Update Default Recipient
  247. </h2>
  248. <p class="my-4 text-grey-700">
  249. Select the default recipient for this domain. This overrides the default recipient in your
  250. account settings. Leave it empty if you would like to use the default recipient in your
  251. account settings.
  252. </p>
  253. <multiselect
  254. v-model="defaultRecipient"
  255. :options="recipientOptions"
  256. :multiple="false"
  257. :close-on-select="true"
  258. :clear-on-select="false"
  259. :searchable="false"
  260. :allow-empty="true"
  261. placeholder="Select recipient"
  262. label="email"
  263. track-by="email"
  264. :preselect-first="false"
  265. :show-labels="false"
  266. >
  267. </multiselect>
  268. <div class="mt-6">
  269. <button
  270. type="button"
  271. @click="editDefaultRecipient()"
  272. class="px-4 py-3 text-cyan-900 font-semibold bg-cyan-400 hover:bg-cyan-300 border border-transparent rounded focus:outline-none"
  273. :class="editDefaultRecipientLoading ? 'cursor-not-allowed' : ''"
  274. :disabled="editDefaultRecipientLoading"
  275. >
  276. Update Default Recipient
  277. <loader v-if="editDefaultRecipientLoading" />
  278. </button>
  279. <button
  280. @click="closeDomainDefaultRecipientModal()"
  281. 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"
  282. >
  283. Cancel
  284. </button>
  285. </div>
  286. </div>
  287. </Modal>
  288. <Modal :open="deleteDomainModalOpen" @close="closeDeleteModal">
  289. <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl p-6">
  290. <h2
  291. class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
  292. >
  293. Delete domain
  294. </h2>
  295. <p class="mt-4 text-grey-700">
  296. Are you sure you want to delete this domain? You will no longer be able to receive any
  297. emails at this domain.
  298. </p>
  299. <div class="mt-6">
  300. <button
  301. type="button"
  302. @click="deleteDomain(domainIdToDelete)"
  303. class="px-4 py-3 text-white font-semibold bg-red-500 hover:bg-red-600 border border-transparent rounded focus:outline-none"
  304. :class="deleteDomainLoading ? 'cursor-not-allowed' : ''"
  305. :disabled="deleteDomainLoading"
  306. >
  307. Delete domain
  308. <loader v-if="deleteDomainLoading" />
  309. </button>
  310. <button
  311. @click="closeDeleteModal"
  312. 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"
  313. >
  314. Cancel
  315. </button>
  316. </div>
  317. </div>
  318. </Modal>
  319. </div>
  320. </template>
  321. <script>
  322. import Modal from './../components/Modal.vue'
  323. import Toggle from './../components/Toggle.vue'
  324. import tippy from 'tippy.js'
  325. import Multiselect from 'vue-multiselect'
  326. export default {
  327. props: {
  328. initialDomains: {
  329. type: Array,
  330. required: true,
  331. },
  332. hostname: {
  333. type: String,
  334. required: true,
  335. },
  336. recipientOptions: {
  337. type: Array,
  338. required: true,
  339. },
  340. },
  341. components: {
  342. Modal,
  343. Toggle,
  344. Multiselect,
  345. },
  346. mounted() {
  347. this.addTooltips()
  348. },
  349. data() {
  350. return {
  351. newDomain: '',
  352. search: '',
  353. addDomainLoading: false,
  354. addDomainModalOpen: false,
  355. domainIdToDelete: null,
  356. domainIdToEdit: '',
  357. domainDescriptionToEdit: '',
  358. deleteDomainLoading: false,
  359. deleteDomainModalOpen: false,
  360. recheckRecordsLoading: false,
  361. domainDefaultRecipientModalOpen: false,
  362. defaultRecipientDomainToEdit: {},
  363. defaultRecipient: {},
  364. editDefaultRecipientLoading: false,
  365. errors: {},
  366. columns: [
  367. {
  368. label: 'Created',
  369. field: 'created_at',
  370. globalSearchDisabled: true,
  371. },
  372. {
  373. label: 'Domain',
  374. field: 'domain',
  375. },
  376. {
  377. label: 'Description',
  378. field: 'description',
  379. },
  380. {
  381. label: 'Default Recipient',
  382. field: 'default_recipient',
  383. sortable: false,
  384. globalSearchDisabled: true,
  385. },
  386. {
  387. label: 'Alias Count',
  388. field: 'aliases_count',
  389. type: 'number',
  390. globalSearchDisabled: true,
  391. },
  392. {
  393. label: 'Active',
  394. field: 'active',
  395. type: 'boolean',
  396. globalSearchDisabled: true,
  397. },
  398. {
  399. label: 'Verified',
  400. field: 'domain_verified_at',
  401. globalSearchDisabled: true,
  402. },
  403. {
  404. label: '',
  405. field: 'actions',
  406. sortable: false,
  407. globalSearchDisabled: true,
  408. },
  409. ],
  410. rows: this.initialDomains,
  411. }
  412. },
  413. watch: {
  414. domainIdToEdit: _.debounce(function() {
  415. this.addTooltips()
  416. }, 50),
  417. },
  418. methods: {
  419. addTooltips() {
  420. tippy('.tooltip', {
  421. arrow: true,
  422. arrowType: 'round',
  423. })
  424. },
  425. debounceToolips: _.debounce(function() {
  426. this.addTooltips()
  427. }, 50),
  428. validateNewDomain(e) {
  429. this.errors = {}
  430. if (!this.newDomain) {
  431. this.errors.newDomain = 'Domain name required'
  432. } else if (!this.validDomain(this.newDomain)) {
  433. this.errors.newDomain = 'Please enter a valid domain name'
  434. }
  435. if (!this.errors.newDomain) {
  436. this.addNewDomain()
  437. }
  438. e.preventDefault()
  439. },
  440. addNewDomain() {
  441. this.addDomainLoading = true
  442. axios
  443. .post(
  444. '/domains',
  445. JSON.stringify({
  446. domain: this.newDomain,
  447. }),
  448. {
  449. headers: { 'Content-Type': 'application/json' },
  450. }
  451. )
  452. .then(({ data }) => {
  453. this.addDomainLoading = false
  454. this.rows.push(data.data)
  455. this.newDomain = ''
  456. this.addDomainModalOpen = false
  457. this.success('Custom domain added')
  458. })
  459. .catch(error => {
  460. this.addDomainLoading = false
  461. if (error.response.status === 422) {
  462. this.error(error.response.data.errors.domain[0])
  463. } else {
  464. this.error()
  465. }
  466. })
  467. },
  468. recheckRecords(domain) {
  469. this.recheckRecordsLoading = true
  470. axios
  471. .get(`/domains/${domain.id}/recheck`)
  472. .then(({ data }) => {
  473. this.recheckRecordsLoading = false
  474. if (data.data.domain_verified_at === null) {
  475. this.warn('MX record not found, please try again later')
  476. } else {
  477. this.success('Domain verified successfully')
  478. domain.domain_verified_at = data.data.domain_verified_at
  479. }
  480. })
  481. .catch(error => {
  482. this.recheckRecordsLoading = false
  483. if (error.response.status === 429) {
  484. this.error('You can only recheck the records once per minute')
  485. } else {
  486. this.error()
  487. }
  488. })
  489. },
  490. openDeleteModal(id) {
  491. this.deleteDomainModalOpen = true
  492. this.domainIdToDelete = id
  493. },
  494. closeDeleteModal() {
  495. this.deleteDomainModalOpen = false
  496. this.domainIdToDelete = null
  497. },
  498. openDomainDefaultRecipientModal(domain) {
  499. this.domainDefaultRecipientModalOpen = true
  500. this.defaultRecipientDomainToEdit = domain
  501. this.defaultRecipient = domain.default_recipient
  502. },
  503. closeDomainDefaultRecipientModal() {
  504. this.domainDefaultRecipientModalOpen = false
  505. this.defaultRecipientDomainToEdit = {}
  506. this.defaultRecipient = {}
  507. },
  508. editDomain(domain) {
  509. if (this.domainDescriptionToEdit.length > 100) {
  510. return this.error('Description cannot be more than 100 characters')
  511. }
  512. axios
  513. .patch(
  514. `/domains/${domain.id}`,
  515. JSON.stringify({
  516. description: this.domainDescriptionToEdit,
  517. }),
  518. {
  519. headers: { 'Content-Type': 'application/json' },
  520. }
  521. )
  522. .then(response => {
  523. domain.description = this.domainDescriptionToEdit
  524. this.domainIdToEdit = ''
  525. this.domainDescriptionToEdit = ''
  526. this.success('Domain description updated')
  527. })
  528. .catch(error => {
  529. this.domainIdToEdit = ''
  530. this.domainDescriptionToEdit = ''
  531. this.error()
  532. })
  533. },
  534. editDefaultRecipient() {
  535. this.editDefaultRecipientLoading = true
  536. axios
  537. .patch(
  538. `/domains/${this.defaultRecipientDomainToEdit.id}/default-recipient`,
  539. JSON.stringify({
  540. default_recipient: this.defaultRecipient ? this.defaultRecipient.id : '',
  541. }),
  542. {
  543. headers: { 'Content-Type': 'application/json' },
  544. }
  545. )
  546. .then(response => {
  547. let domain = _.find(this.rows, ['id', this.defaultRecipientDomainToEdit.id])
  548. domain.default_recipient = this.defaultRecipient
  549. this.domainDefaultRecipientModalOpen = false
  550. this.editDefaultRecipientLoading = false
  551. this.defaultRecipient = {}
  552. this.success("Domain's default recipient updated")
  553. })
  554. .catch(error => {
  555. this.domainDefaultRecipientModalOpen = false
  556. this.editDefaultRecipientLoading = false
  557. this.defaultRecipient = {}
  558. this.error()
  559. })
  560. },
  561. activateDomain(id) {
  562. axios
  563. .post(
  564. `/active-domains`,
  565. JSON.stringify({
  566. id: id,
  567. }),
  568. {
  569. headers: { 'Content-Type': 'application/json' },
  570. }
  571. )
  572. .then(response => {
  573. //
  574. })
  575. .catch(error => {
  576. this.error()
  577. })
  578. },
  579. deactivateDomain(id) {
  580. axios
  581. .delete(`/active-domains/${id}`)
  582. .then(response => {
  583. //
  584. })
  585. .catch(error => {
  586. this.error()
  587. })
  588. },
  589. deleteDomain(id) {
  590. this.deleteDomainLoading = true
  591. axios
  592. .delete(`/domains/${id}`)
  593. .then(response => {
  594. this.rows = _.reject(this.rows, domain => domain.id === id)
  595. this.deleteDomainModalOpen = false
  596. this.deleteDomainLoading = false
  597. })
  598. .catch(error => {
  599. this.error()
  600. this.deleteDomainLoading = false
  601. this.deleteDomainModalOpen = false
  602. })
  603. },
  604. validDomain(domain) {
  605. let re = /(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]\.)+[a-zA-Z]{2,63}$)/
  606. return re.test(domain)
  607. },
  608. clipboardSuccess() {
  609. this.success('Copied to clipboard')
  610. },
  611. clipboardError() {
  612. this.error('Could not copy to clipboard')
  613. },
  614. warn(text = '') {
  615. this.$notify({
  616. title: 'Information',
  617. text: text,
  618. type: 'warn',
  619. })
  620. },
  621. success(text = '') {
  622. this.$notify({
  623. title: 'Success',
  624. text: text,
  625. type: 'success',
  626. })
  627. },
  628. error(text = 'An error has occurred, please try again later') {
  629. this.$notify({
  630. title: 'Error',
  631. text: text,
  632. type: 'error',
  633. })
  634. },
  635. },
  636. }
  637. </script>