Domains.vue 21 KB

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