Invite.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. <template>
  2. <HoppSmartModal
  3. v-if="show"
  4. dialog
  5. :title="t('team.invite')"
  6. @close="hideModal"
  7. >
  8. <template #body>
  9. <div v-if="sendInvitesResult.length" class="flex flex-col px-4">
  10. <div class="mb-8 flex max-w-md flex-col items-center justify-center">
  11. <icon-lucide-users class="h-6 w-6 text-accent" />
  12. <h3 class="my-2 text-center text-lg">
  13. {{
  14. inviteMethod === "email"
  15. ? t("team.we_sent_invite_link")
  16. : t("team.invite_sent_smtp_disabled")
  17. }}
  18. </h3>
  19. <p class="text-center">
  20. {{
  21. inviteMethod === "email"
  22. ? t("team.we_sent_invite_link_description")
  23. : t("team.invite_sent_smtp_disabled_description")
  24. }}
  25. </p>
  26. </div>
  27. <div v-if="successInvites.length">
  28. <label class="mb-4 block">
  29. {{ t("team.success_invites") }}
  30. </label>
  31. <div
  32. class="flex flex-col space-y-6 rounded border border-dividerLight p-4"
  33. >
  34. <div
  35. v-for="(invitee, index) in successInvites"
  36. :key="`invitee-${index}`"
  37. class="flex items-center"
  38. >
  39. <p class="flex items-center flex-1">
  40. <component
  41. :is="IconMailCheck"
  42. class="svg-icons mr-4 text-green-500"
  43. />
  44. <span class="truncate">{{ invitee.email }}</span>
  45. <span class="flex items-center gap-1 ml-auto">
  46. <HoppButtonSecondary
  47. outline
  48. filled
  49. :icon="getCopyIcon(invitee.invitationID).value"
  50. class="rounded-md"
  51. :label="t('team.copy_invite_link')"
  52. @click="
  53. () => {
  54. copyInviteLink(invitee.invitationID)
  55. }
  56. "
  57. />
  58. </span>
  59. </p>
  60. </div>
  61. </div>
  62. </div>
  63. <div v-if="failedInvites.length" class="mt-6">
  64. <label class="mb-4 block">
  65. {{ t("team.failed_invites") }}
  66. </label>
  67. <div
  68. class="flex flex-col space-y-6 rounded border border-dividerLight p-4"
  69. >
  70. <div
  71. v-for="(invitee, index) in failedInvites"
  72. :key="`invitee-${index}`"
  73. class="flex flex-col"
  74. >
  75. <p class="flex items-center">
  76. <component
  77. :is="IconAlertTriangle"
  78. class="svg-icons mr-4 text-red-500"
  79. />
  80. <span class="truncate">{{ invitee.email }}</span>
  81. </p>
  82. <p class="ml-8 mt-1 text-secondaryLight text-tiny">
  83. {{ getErrorMessage(invitee.error) }}
  84. </p>
  85. </div>
  86. </div>
  87. </div>
  88. </div>
  89. <div
  90. v-else-if="sendingInvites"
  91. class="flex items-center justify-center p-4"
  92. >
  93. <HoppSmartSpinner />
  94. </div>
  95. <div v-else class="flex flex-col">
  96. <div class="flex flex-1 items-center justify-between">
  97. <label for="memberList" class="px-4 pb-4">
  98. {{ t("team.pending_invites") }}
  99. </label>
  100. </div>
  101. <div class="divide-y divide-dividerLight rounded border border-divider">
  102. <div
  103. v-if="pendingInvites.loading"
  104. class="flex items-center justify-center p-4"
  105. >
  106. <HoppSmartSpinner />
  107. </div>
  108. <div v-else>
  109. <div
  110. v-if="!pendingInvites.loading && E.isRight(pendingInvites.data)"
  111. class="divide-y divide-dividerLight"
  112. >
  113. <div
  114. v-for="(invitee, index) in pendingInvites.data.right.team
  115. ?.teamInvitations"
  116. :key="`invitee-${index}`"
  117. class="flex divide-x divide-dividerLight"
  118. >
  119. <input
  120. v-if="invitee"
  121. class="flex flex-1 bg-transparent px-4 py-2 text-secondaryLight"
  122. :placeholder="`${t('team.email')}`"
  123. :name="'param' + index"
  124. :value="invitee.inviteeEmail"
  125. readonly
  126. />
  127. <input
  128. class="flex flex-1 bg-transparent px-4 py-2 text-secondaryLight"
  129. :placeholder="`${t('team.permissions')}`"
  130. :name="'value' + index"
  131. :value="invitee.inviteeRole"
  132. readonly
  133. />
  134. <div class="flex">
  135. <HoppButtonSecondary
  136. v-tippy="{ theme: 'tooltip' }"
  137. outline
  138. :icon="getCopyIcon(invitee.id).value"
  139. class="rounded-md"
  140. :title="t('team.copy_invite_link')"
  141. @click="
  142. () => {
  143. copyInviteLink(invitee.id)
  144. }
  145. "
  146. />
  147. </div>
  148. <div class="flex">
  149. <HoppButtonSecondary
  150. v-tippy="{ theme: 'tooltip' }"
  151. :title="t('action.remove')"
  152. :icon="IconTrash"
  153. color="red"
  154. :loading="isLoadingIndex === index"
  155. @click="removeInvitee(invitee.id, index)"
  156. />
  157. </div>
  158. </div>
  159. </div>
  160. <HoppSmartPlaceholder
  161. v-if="
  162. E.isRight(pendingInvites.data) &&
  163. pendingInvites.data.right.team?.teamInvitations.length === 0
  164. "
  165. :src="`/images/states/${colorMode.value}/add_group.svg`"
  166. :alt="t('empty.pending_invites')"
  167. :text="t('empty.pending_invites')"
  168. />
  169. <div
  170. v-if="!pendingInvites.loading && E.isLeft(pendingInvites.data)"
  171. class="flex flex-col items-center p-4"
  172. >
  173. <icon-lucide-help-circle class="svg-icons mb-4" />
  174. {{ t("error.something_went_wrong") }}
  175. </div>
  176. </div>
  177. </div>
  178. <div class="flex flex-1 items-center justify-between pt-4">
  179. <label for="memberList" class="p-4">
  180. {{ t("team.invite_tooltip") }}
  181. </label>
  182. <div class="flex">
  183. <HoppButtonSecondary
  184. :icon="IconPlus"
  185. :label="t('add.new')"
  186. filled
  187. @click="addNewInvitee"
  188. />
  189. </div>
  190. </div>
  191. <div class="divide-y divide-dividerLight rounded border border-divider">
  192. <div
  193. v-for="(invitee, index) in newInvites"
  194. :key="`new-invitee-${index}`"
  195. class="flex divide-x divide-dividerLight"
  196. >
  197. <input
  198. v-model="invitee.key"
  199. class="flex flex-1 bg-transparent px-4 py-2"
  200. :placeholder="`${t('team.email')}`"
  201. :name="'invitee' + index"
  202. autofocus
  203. />
  204. <span>
  205. <tippy
  206. interactive
  207. trigger="click"
  208. theme="popover"
  209. :on-shown="() => tippyActions![index].focus()"
  210. >
  211. <HoppSmartSelectWrapper>
  212. <input
  213. class="flex flex-1 cursor-pointer bg-transparent px-4 py-2"
  214. :placeholder="`${t('team.permissions')}`"
  215. :name="'value' + index"
  216. :value="invitee.value"
  217. readonly
  218. />
  219. </HoppSmartSelectWrapper>
  220. <template #content="{ hide }">
  221. <div
  222. ref="tippyActions"
  223. class="flex flex-col focus:outline-none"
  224. tabindex="0"
  225. @keyup.escape="hide()"
  226. >
  227. <HoppSmartItem
  228. label="OWNER"
  229. :icon="
  230. invitee.value === 'OWNER' ? IconCircleDot : IconCircle
  231. "
  232. :active="invitee.value === 'OWNER'"
  233. @click="
  234. () => {
  235. updateNewInviteeRole(index, TeamMemberRole.Owner)
  236. hide()
  237. }
  238. "
  239. />
  240. <HoppSmartItem
  241. label="EDITOR"
  242. :icon="
  243. invitee.value === 'EDITOR' ? IconCircleDot : IconCircle
  244. "
  245. :active="invitee.value === 'EDITOR'"
  246. @click="
  247. () => {
  248. updateNewInviteeRole(index, TeamMemberRole.Editor)
  249. hide()
  250. }
  251. "
  252. />
  253. <HoppSmartItem
  254. label="VIEWER"
  255. :icon="
  256. invitee.value === 'VIEWER' ? IconCircleDot : IconCircle
  257. "
  258. :active="invitee.value === 'VIEWER'"
  259. @click="
  260. () => {
  261. updateNewInviteeRole(index, TeamMemberRole.Viewer)
  262. hide()
  263. }
  264. "
  265. />
  266. </div>
  267. </template>
  268. </tippy>
  269. </span>
  270. <div class="flex">
  271. <HoppButtonSecondary
  272. id="member"
  273. v-tippy="{ theme: 'tooltip' }"
  274. :title="t('action.remove')"
  275. :icon="IconTrash"
  276. color="red"
  277. @click="removeNewInvitee(index)"
  278. />
  279. </div>
  280. </div>
  281. <HoppSmartPlaceholder
  282. v-if="newInvites.length === 0"
  283. :src="`/images/states/${colorMode.value}/add_group.svg`"
  284. :alt="`${t('empty.invites')}`"
  285. :text="`${t('empty.invites')}`"
  286. >
  287. <template #body>
  288. <HoppButtonSecondary
  289. :label="t('add.new')"
  290. filled
  291. @click="addNewInvitee"
  292. />
  293. </template>
  294. </HoppSmartPlaceholder>
  295. </div>
  296. <div
  297. v-if="newInvites.length"
  298. class="mt-4 flex flex-col items-start rounded border border-dividerLight px-4 py-4"
  299. >
  300. <span
  301. class="mb-4 flex items-center justify-center rounded-full border border-divider bg-primaryDark px-2 py-1 font-semibold"
  302. >
  303. <icon-lucide-help-circle
  304. class="svg-icons mr-2 text-secondaryLight"
  305. />
  306. {{ t("profile.roles") }}
  307. </span>
  308. <p>
  309. <span class="text-secondaryLight">
  310. {{ t("profile.roles_description") }}
  311. </span>
  312. </p>
  313. <ul class="mt-4 space-y-4">
  314. <li class="flex">
  315. <span
  316. class="max-w-[4rem] w-1/4 truncate font-semibold uppercase text-secondaryDark"
  317. >
  318. {{ t("profile.owner") }}
  319. </span>
  320. <span class="flex flex-1">
  321. {{ t("profile.owner_description") }}
  322. </span>
  323. </li>
  324. <li class="flex">
  325. <span
  326. class="max-w-[4rem] w-1/4 truncate font-semibold uppercase text-secondaryDark"
  327. >
  328. {{ t("profile.editor") }}
  329. </span>
  330. <span class="flex flex-1">
  331. {{ t("profile.editor_description") }}
  332. </span>
  333. </li>
  334. <li class="flex">
  335. <span
  336. class="max-w-[4rem] w-1/4 truncate font-semibold uppercase text-secondaryDark"
  337. >
  338. {{ t("profile.viewer") }}
  339. </span>
  340. <span class="flex flex-1">
  341. {{ t("profile.viewer_description") }}
  342. </span>
  343. </li>
  344. </ul>
  345. </div>
  346. </div>
  347. </template>
  348. <template #footer>
  349. <p
  350. v-if="sendInvitesResult.length"
  351. class="flex flex-1 justify-between text-secondaryLight"
  352. >
  353. <HoppButtonSecondary
  354. class="link !p-0"
  355. :label="t('team.invite_more')"
  356. :icon="IconArrowLeft"
  357. @click="
  358. () => {
  359. sendInvitesResult = []
  360. newInvites = [
  361. {
  362. key: '',
  363. value: TeamMemberRole.Viewer,
  364. },
  365. ]
  366. }
  367. "
  368. />
  369. <HoppButtonSecondary
  370. class="link !p-0"
  371. :label="`${t('action.dismiss')}`"
  372. @click="hideModal"
  373. />
  374. </p>
  375. <span v-else class="flex space-x-2">
  376. <HoppButtonPrimary
  377. :label="t('team.invite')"
  378. outline
  379. @click="sendInvites"
  380. />
  381. <HoppButtonSecondary
  382. :label="t('action.cancel')"
  383. outline
  384. filled
  385. @click="hideModal"
  386. />
  387. </span>
  388. </template>
  389. </HoppSmartModal>
  390. </template>
  391. <script setup lang="ts">
  392. import { watch, ref, reactive, computed, Ref, onMounted } from "vue"
  393. import * as T from "fp-ts/Task"
  394. import * as E from "fp-ts/Either"
  395. import * as A from "fp-ts/Array"
  396. import * as O from "fp-ts/Option"
  397. import { flow, pipe } from "fp-ts/function"
  398. import { Email, EmailCodec } from "../../helpers/backend/types/Email"
  399. import {
  400. TeamInvitationAddedDocument,
  401. TeamInvitationRemovedDocument,
  402. TeamMemberRole,
  403. GetPendingInvitesDocument,
  404. GetPendingInvitesQuery,
  405. GetPendingInvitesQueryVariables,
  406. } from "../../helpers/backend/graphql"
  407. import {
  408. createTeamInvitation,
  409. CreateTeamInvitationErrors,
  410. revokeTeamInvitation,
  411. } from "../../helpers/backend/mutations/TeamInvitation"
  412. import { GQLError } from "~/helpers/backend/GQLClient"
  413. import { useGQLQuery } from "@composables/graphql"
  414. import { useI18n } from "@composables/i18n"
  415. import { useToast } from "@composables/toast"
  416. import { useColorMode } from "~/composables/theming"
  417. import IconTrash from "~icons/lucide/trash"
  418. import IconPlus from "~icons/lucide/plus"
  419. import IconAlertTriangle from "~icons/lucide/alert-triangle"
  420. import IconMailCheck from "~icons/lucide/mail-check"
  421. import IconCircleDot from "~icons/lucide/circle-dot"
  422. import IconCircle from "~icons/lucide/circle"
  423. import IconArrowLeft from "~icons/lucide/arrow-left"
  424. import IconCopy from "~icons/lucide/copy"
  425. import IconCheck from "~icons/lucide/check"
  426. import { TippyComponent } from "vue-tippy"
  427. import { refAutoReset } from "@vueuse/core"
  428. import { copyToClipboard } from "~/helpers/utils/clipboard"
  429. import { platform } from "~/platform"
  430. const copyIcons: Record<string, Ref<typeof IconCopy | typeof IconCheck>> = {}
  431. const getCopyIcon = (id: string) => {
  432. if (!copyIcons[id]) {
  433. copyIcons[id] = refAutoReset<typeof IconCopy | typeof IconCheck>(
  434. IconCopy,
  435. 1000
  436. )
  437. }
  438. return copyIcons[id]
  439. }
  440. const t = useI18n()
  441. const toast = useToast()
  442. const colorMode = useColorMode()
  443. // Template refs
  444. const tippyActions = ref<TippyComponent[] | null>(null)
  445. const props = defineProps({
  446. show: Boolean,
  447. editingTeamID: { type: String, default: null },
  448. })
  449. const emit = defineEmits<{
  450. (e: "hide-modal"): void
  451. }>()
  452. const inviteMethod = ref<"email" | "link">("email")
  453. onMounted(async () => {
  454. const getIsSMTPEnabled = platform.infra?.getIsSMTPEnabled
  455. if (getIsSMTPEnabled) {
  456. const res = await getIsSMTPEnabled()
  457. if (E.isRight(res)) {
  458. inviteMethod.value = res.right ? "email" : "link"
  459. }
  460. }
  461. })
  462. const pendingInvites = useGQLQuery<
  463. GetPendingInvitesQuery,
  464. GetPendingInvitesQueryVariables,
  465. ""
  466. >({
  467. query: GetPendingInvitesDocument,
  468. variables: reactive({
  469. teamID: props.editingTeamID,
  470. }),
  471. pollDuration: 10000,
  472. updateSubs: computed(() =>
  473. !props.editingTeamID
  474. ? []
  475. : [
  476. {
  477. key: 4,
  478. query: TeamInvitationAddedDocument,
  479. variables: {
  480. teamID: props.editingTeamID,
  481. },
  482. },
  483. {
  484. key: 5,
  485. query: TeamInvitationRemovedDocument,
  486. variables: {
  487. teamID: props.editingTeamID,
  488. },
  489. },
  490. ]
  491. ),
  492. defer: true,
  493. })
  494. watch(
  495. () => props.show,
  496. (show) => {
  497. if (!show) {
  498. pendingInvites.pause()
  499. } else {
  500. pendingInvites.unpause()
  501. }
  502. }
  503. )
  504. watch(
  505. () => props.editingTeamID,
  506. () => {
  507. if (props.editingTeamID) {
  508. pendingInvites.execute({
  509. teamID: props.editingTeamID,
  510. })
  511. }
  512. }
  513. )
  514. const isLoadingIndex = ref<null | number>(null)
  515. const removeInvitee = async (id: string, index: number) => {
  516. isLoadingIndex.value = index
  517. const result = await revokeTeamInvitation(id)()
  518. if (E.isLeft(result)) {
  519. toast.error(`${t("error.something_went_wrong")}`)
  520. } else {
  521. toast.success(`${t("team.member_removed")}`)
  522. }
  523. isLoadingIndex.value = null
  524. }
  525. const newInvites = ref<Array<{ key: string; value: TeamMemberRole }>>([
  526. {
  527. key: "",
  528. value: TeamMemberRole.Viewer,
  529. },
  530. ])
  531. const addNewInvitee = () => {
  532. newInvites.value.push({
  533. key: "",
  534. value: TeamMemberRole.Viewer,
  535. })
  536. }
  537. const updateNewInviteeRole = (index: number, role: TeamMemberRole) => {
  538. newInvites.value[index].value = role
  539. }
  540. const removeNewInvitee = (id: number) => {
  541. newInvites.value.splice(id, 1)
  542. }
  543. const copyInviteLink = (invitationID: string) => {
  544. copyToClipboard(
  545. `${import.meta.env.VITE_BASE_URL}/join-team?id=${invitationID}`
  546. )
  547. getCopyIcon(invitationID).value = IconCheck
  548. }
  549. type SendInvitesErrorType =
  550. | {
  551. email: Email
  552. status: "error"
  553. error: GQLError<CreateTeamInvitationErrors>
  554. }
  555. | {
  556. email: Email
  557. status: "success"
  558. invitationID: string
  559. }
  560. const sendInvitesResult = ref<Array<SendInvitesErrorType>>([])
  561. const successInvites = computed(() =>
  562. sendInvitesResult.value.filter((invitee) => invitee.status === "success")
  563. )
  564. const failedInvites = computed(() =>
  565. sendInvitesResult.value.filter((invitee) => invitee.status === "error")
  566. )
  567. const sendingInvites = ref<boolean>(false)
  568. const sendInvites = async () => {
  569. const validationResult = pipe(
  570. newInvites.value,
  571. O.fromPredicate(
  572. (invites): invites is Array<{ key: Email; value: TeamMemberRole }> =>
  573. pipe(
  574. invites,
  575. A.every((invitee) => EmailCodec.is(invitee.key))
  576. )
  577. ),
  578. O.map(
  579. A.map((invitee) =>
  580. createTeamInvitation(invitee.key, invitee.value, props.editingTeamID)
  581. )
  582. )
  583. )
  584. if (O.isNone(validationResult)) {
  585. // Error handling for no validation
  586. toast.error(`${t("error.incorrect_email")}`)
  587. return
  588. }
  589. sendingInvites.value = true
  590. sendInvitesResult.value = await pipe(
  591. A.sequence(T.task)(validationResult.value),
  592. T.chain(
  593. flow(
  594. A.mapWithIndex((i, el) =>
  595. pipe(
  596. el,
  597. E.foldW(
  598. (err) => ({
  599. status: "error" as const,
  600. email: newInvites.value[i].key as Email,
  601. error: err,
  602. }),
  603. (invitation) => ({
  604. status: "success" as const,
  605. email: newInvites.value[i].key as Email,
  606. invitationID: invitation.id,
  607. })
  608. )
  609. )
  610. ),
  611. T.of
  612. )
  613. )
  614. )()
  615. sendingInvites.value = false
  616. }
  617. const getErrorMessage = (error: SendInvitesErrorType) => {
  618. if (error.type === "network_error") {
  619. return t("error.network_error")
  620. }
  621. switch (error.error) {
  622. case "team/invalid_id":
  623. return t("team.invalid_id")
  624. case "team/member_not_found":
  625. return t("team.member_not_found")
  626. case "team_invite/already_member":
  627. return t("team.already_member")
  628. case "team_invite/member_has_invite":
  629. return t("team.member_has_invite")
  630. case "user/not_found":
  631. return t("team.user_not_found")
  632. }
  633. }
  634. const hideModal = () => {
  635. sendingInvites.value = false
  636. sendInvitesResult.value = []
  637. newInvites.value = [
  638. {
  639. key: "",
  640. value: TeamMemberRole.Viewer,
  641. },
  642. ]
  643. emit("hide-modal")
  644. }
  645. </script>