Domains.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  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. </button>
  253. </div>
  254. </td>
  255. <td class="border-grey-200 border-t w-px">
  256. <div
  257. class="px-4 flex items-center cursor-pointer outline-none focus:text-indigo-500"
  258. @click="openDeleteModal(domain.id)"
  259. tabindex="-1"
  260. >
  261. <icon name="trash" class="block w-6 h-6 text-grey-200 fill-current" />
  262. </div>
  263. </td>
  264. </tr>
  265. <tr v-if="queriedDomains.length === 0">
  266. <td
  267. class="border-grey-200 border-t p-4 text-center h-24 text-lg text-grey-700"
  268. colspan="6"
  269. >
  270. No domains found for that search!
  271. </td>
  272. </tr>
  273. </table>
  274. <div v-else class="p-8 text-center text-lg text-grey-700">
  275. <h1 class="mb-6 text-xl text-indigo-800 font-semibold">
  276. This is where you can set up and view custom domains
  277. </h1>
  278. <div class="mx-auto mb-6 w-24 border-b-2 border-grey-200"></div>
  279. <p class="mb-4">
  280. To get started all you have to do is add an MX record to your domain and then add the
  281. domain here by clicking the button above.
  282. </p>
  283. <p class="mb-4">
  284. The new record needs to have the following values:
  285. </p>
  286. <p class="mb-4">
  287. Host: <b>@</b><br />
  288. Value: <b>mail.anonaddy.me</b><br />
  289. Priority: <b>10</b><br />
  290. TTL: <b>3600</b>
  291. </p>
  292. <p>
  293. Once the DNS changes propagate you will be able to recieve emails at your own domain.
  294. </p>
  295. </div>
  296. </div>
  297. <Modal :open="addDomainModalOpen" @close="addDomainModalOpen = false">
  298. <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl p-6">
  299. <h2
  300. class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
  301. >
  302. Add new domain
  303. </h2>
  304. <p class="mt-4 text-grey-700">
  305. Make sure you add the following MX record to your domain.<br /><br />
  306. Host: <b>@</b><br />
  307. Value: <b>mail.anonaddy.me</b><br />
  308. Priority: <b>10</b><br />
  309. TTL: <b>3600</b><br /><br />
  310. Just include the domain/subdomain e.g. example.com without any http protocol.
  311. </p>
  312. <div class="mt-6">
  313. <p v-show="errors.newDomain" class="mb-3 text-red-500">
  314. {{ errors.newDomain }}
  315. </p>
  316. <input
  317. v-model="newDomain"
  318. type="text"
  319. class="w-full appearance-none bg-grey-100 border border-transparent text-grey-700 focus:outline-none rounded p-3 mb-6"
  320. :class="errors.newDomain ? 'border-red-500' : ''"
  321. placeholder="example.com"
  322. autofocus
  323. />
  324. <button
  325. @click="validateNewDomain"
  326. class="bg-cyan-400 hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none"
  327. :class="addDomainLoading ? 'cursor-not-allowed' : ''"
  328. :disabled="addDomainLoading"
  329. >
  330. Add Domain
  331. </button>
  332. <button
  333. @click="addDomainModalOpen = false"
  334. 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"
  335. >
  336. Cancel
  337. </button>
  338. </div>
  339. </div>
  340. </Modal>
  341. <Modal :open="deleteDomainModalOpen" @close="closeDeleteModal">
  342. <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl p-6">
  343. <h2
  344. class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
  345. >
  346. Delete domain
  347. </h2>
  348. <p class="mt-4 text-grey-700">
  349. Are you sure you want to delete this domain? You will no longer be able to receive any
  350. emails at this domain.
  351. </p>
  352. <div class="mt-6">
  353. <button
  354. type="button"
  355. @click="deleteDomain(domainIdToDelete)"
  356. class="px-4 py-3 text-white font-semibold bg-red-500 hover:bg-red-600 border border-transparent rounded focus:outline-none"
  357. :class="deleteDomainLoading ? 'cursor-not-allowed' : ''"
  358. :disabled="deleteDomainLoading"
  359. >
  360. Delete domain
  361. </button>
  362. <button
  363. @click="closeDeleteModal"
  364. 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"
  365. >
  366. Cancel
  367. </button>
  368. </div>
  369. </div>
  370. </Modal>
  371. </div>
  372. </template>
  373. <script>
  374. import Modal from './../components/Modal.vue'
  375. import Toggle from './../components/Toggle.vue'
  376. import tippy from 'tippy.js'
  377. export default {
  378. props: {
  379. initialDomains: {
  380. type: Array,
  381. required: true,
  382. },
  383. },
  384. components: {
  385. Modal,
  386. Toggle,
  387. },
  388. mounted() {
  389. this.addTooltips()
  390. },
  391. data() {
  392. return {
  393. domains: this.initialDomains,
  394. newDomain: '',
  395. search: '',
  396. addDomainLoading: false,
  397. addDomainModalOpen: false,
  398. domainIdToDelete: null,
  399. domainIdToEdit: '',
  400. domainDescriptionToEdit: '',
  401. deleteDomainLoading: false,
  402. deleteDomainModalOpen: false,
  403. recheckRecordsLoading: false,
  404. currentSort: 'created_at',
  405. currentSortDir: 'desc',
  406. errors: {},
  407. }
  408. },
  409. watch: {
  410. queriedDomains: _.debounce(function() {
  411. this.addTooltips()
  412. }, 50),
  413. domainIdToEdit: _.debounce(function() {
  414. this.addTooltips()
  415. }, 50),
  416. },
  417. computed: {
  418. queriedDomains() {
  419. return _.filter(this.domains, domain => domain.domain.includes(this.search))
  420. },
  421. },
  422. methods: {
  423. addTooltips() {
  424. tippy('.tooltip', {
  425. arrow: true,
  426. arrowType: 'round',
  427. })
  428. },
  429. isCurrentSort(col, dir) {
  430. return this.currentSort === col && this.currentSortDir === dir
  431. },
  432. validateNewDomain(e) {
  433. this.errors = {}
  434. if (!this.newDomain) {
  435. this.errors.newDomain = 'Domain name required'
  436. } else if (!this.validDomain(this.newDomain)) {
  437. this.errors.newDomain = 'Please enter a valid domain name without http:// or https://'
  438. }
  439. if (!this.errors.newDomain) {
  440. this.addNewDomain()
  441. }
  442. e.preventDefault()
  443. },
  444. addNewDomain() {
  445. this.addDomainLoading = true
  446. axios
  447. .post(
  448. '/domains',
  449. JSON.stringify({
  450. domain: this.newDomain,
  451. }),
  452. {
  453. headers: { 'Content-Type': 'application/json' },
  454. }
  455. )
  456. .then(({ data }) => {
  457. this.addDomainLoading = false
  458. this.domains.push(data.data)
  459. this.reSort()
  460. this.newDomain = ''
  461. this.addDomainModalOpen = false
  462. this.success('Custom domain added')
  463. })
  464. .catch(error => {
  465. this.addDomainLoading = false
  466. if (error.response.status === 422) {
  467. this.error(error.response.data.errors.domain[0])
  468. } else {
  469. this.error()
  470. }
  471. })
  472. },
  473. recheckRecords(domain) {
  474. this.recheckRecordsLoading = true
  475. axios
  476. .get(`/domains/${domain.id}/recheck`)
  477. .then(({ data }) => {
  478. this.recheckRecordsLoading = false
  479. if (data.data.domain_verified_at === null) {
  480. this.warn('MX record not found, please try again later')
  481. } else {
  482. this.success('Domain verified successfully')
  483. domain.domain_verified_at = data.data.domain_verified_at
  484. }
  485. })
  486. .catch(error => {
  487. this.recheckRecordsLoading = false
  488. if (error.response.status === 429) {
  489. this.error('You can only recheck the records once a minute')
  490. } else {
  491. this.error()
  492. }
  493. })
  494. },
  495. openDeleteModal(id) {
  496. this.deleteDomainModalOpen = true
  497. this.domainIdToDelete = id
  498. },
  499. closeDeleteModal() {
  500. this.deleteDomainModalOpen = false
  501. this.domainIdToDelete = null
  502. },
  503. editDomain(domain) {
  504. if (this.domainDescriptionToEdit.length > 100) {
  505. return this.error('Description cannot be more than 100 characters')
  506. }
  507. axios
  508. .patch(
  509. `/domains/${domain.id}`,
  510. JSON.stringify({
  511. description: this.domainDescriptionToEdit,
  512. }),
  513. {
  514. headers: { 'Content-Type': 'application/json' },
  515. }
  516. )
  517. .then(response => {
  518. domain.description = this.domainDescriptionToEdit
  519. this.domainIdToEdit = ''
  520. this.domainDescriptionToEdit = ''
  521. this.success('Domain description updated')
  522. })
  523. .catch(error => {
  524. this.domainIdToEdit = ''
  525. this.domainDescriptionToEdit = ''
  526. this.error()
  527. })
  528. },
  529. activateDomain(domain) {
  530. axios
  531. .post(
  532. `/active-domains`,
  533. JSON.stringify({
  534. id: domain.id,
  535. }),
  536. {
  537. headers: { 'Content-Type': 'application/json' },
  538. }
  539. )
  540. .then(response => {
  541. //
  542. })
  543. .catch(error => {
  544. this.error()
  545. })
  546. },
  547. deactivateDomain(domain) {
  548. axios
  549. .delete(`/active-domains/${domain.id}`)
  550. .then(response => {
  551. //
  552. })
  553. .catch(error => {
  554. this.error()
  555. })
  556. },
  557. deleteDomain(id) {
  558. this.deleteDomainLoading = true
  559. axios
  560. .delete(`/domains/${id}`)
  561. .then(response => {
  562. this.domains = _.filter(this.domains, domain => domain.id !== id)
  563. this.deleteDomainModalOpen = false
  564. this.deleteDomainLoading = false
  565. })
  566. .catch(error => {
  567. this.error()
  568. this.deleteDomainLoading = false
  569. this.deleteDomainModalOpen = false
  570. })
  571. },
  572. sort(col, dir) {
  573. if (this.currentSort === col && this.currentSortDir === dir) {
  574. this.currentSort = 'created_at'
  575. this.currentSortDir = 'desc'
  576. } else {
  577. this.currentSort = col
  578. this.currentSortDir = dir
  579. }
  580. this.reSort()
  581. },
  582. reSort() {
  583. this.domains = _.orderBy(this.domains, [this.currentSort], [this.currentSortDir])
  584. },
  585. validDomain(domain) {
  586. let re = /(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]\.)+[a-zA-Z]{2,63}$)/
  587. return re.test(domain)
  588. },
  589. clipboardSuccess() {
  590. this.success('Copied to clipboard')
  591. },
  592. clipboardError() {
  593. this.error('Could not copy to clipboard')
  594. },
  595. warn(text = '') {
  596. this.$notify({
  597. title: 'Information',
  598. text: text,
  599. type: 'warn',
  600. })
  601. },
  602. success(text = '') {
  603. this.$notify({
  604. title: 'Success',
  605. text: text,
  606. type: 'success',
  607. })
  608. },
  609. error(text = 'An error has occurred, please try again later') {
  610. this.$notify({
  611. title: 'Error',
  612. text: text,
  613. type: 'error',
  614. })
  615. },
  616. },
  617. }
  618. </script>