Invite.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. <template>
  2. <SmartModal v-if="show" dialog :title="t('team.invite')" @close="hideModal">
  3. <template #body>
  4. <div v-if="sendInvitesResult.length" class="flex flex-col px-4">
  5. <div class="flex flex-col items-center justify-center max-w-md">
  6. <SmartIcon class="w-6 h-6 text-accent" name="users" />
  7. <h3 class="my-2 text-lg text-center">
  8. {{ t("team.we_sent_invite_link") }}
  9. </h3>
  10. <p class="text-center">
  11. {{ t("team.we_sent_invite_link_description") }}
  12. </p>
  13. </div>
  14. <div
  15. class="flex flex-col p-4 mt-8 border rounded space-y-6 border-dividerLight"
  16. >
  17. <div
  18. v-for="(invitee, index) in sendInvitesResult"
  19. :key="`invitee-${index}`"
  20. >
  21. <p class="flex items-center">
  22. <i
  23. class="mr-4 material-icons"
  24. :class="
  25. invitee.status === 'error' ? 'text-red-500' : 'text-green-500'
  26. "
  27. >
  28. {{
  29. invitee.status === "error"
  30. ? "error_outline"
  31. : "mark_email_read"
  32. }}
  33. </i>
  34. <span class="truncate">{{ invitee.email }}</span>
  35. </p>
  36. <p v-if="invitee.status === 'error'" class="mt-2 ml-8 text-red-500">
  37. {{ getErrorMessage(invitee.error) }}
  38. </p>
  39. </div>
  40. </div>
  41. </div>
  42. <div
  43. v-else-if="sendingInvites"
  44. class="flex items-center justify-center p-4"
  45. >
  46. <SmartSpinner />
  47. </div>
  48. <div v-else class="flex flex-col px-2">
  49. <div class="flex items-center justify-between flex-1">
  50. <label for="memberList" class="px-4 pb-4">
  51. {{ t("team.pending_invites") }}
  52. </label>
  53. </div>
  54. <div class="border rounded divide-y divide-dividerLight border-divider">
  55. <div
  56. v-if="pendingInvites.loading"
  57. class="flex items-center justify-center p-4"
  58. >
  59. <SmartSpinner />
  60. </div>
  61. <div v-else>
  62. <div
  63. v-if="!pendingInvites.loading && E.isRight(pendingInvites.data)"
  64. >
  65. <div
  66. v-for="(invitee, index) in pendingInvites.data.right.team
  67. .teamInvitations"
  68. :key="`invitee-${index}`"
  69. class="flex divide-x divide-dividerLight"
  70. >
  71. <input
  72. v-if="invitee"
  73. class="flex flex-1 px-4 py-2 bg-transparent text-secondaryLight"
  74. :placeholder="`${t('team.email')}`"
  75. :name="'param' + index"
  76. :value="invitee.inviteeEmail"
  77. readonly
  78. />
  79. <input
  80. class="flex flex-1 px-4 py-2 bg-transparent text-secondaryLight"
  81. :placeholder="`${t('team.permissions')}`"
  82. :name="'value' + index"
  83. :value="invitee.inviteeRole"
  84. readonly
  85. />
  86. <div class="flex">
  87. <ButtonSecondary
  88. v-tippy="{ theme: 'tooltip' }"
  89. :title="t('action.remove')"
  90. svg="trash"
  91. color="red"
  92. :loading="isLoadingIndex === index"
  93. @click.native="removeInvitee(invitee.id, index)"
  94. />
  95. </div>
  96. </div>
  97. </div>
  98. <div
  99. v-if="
  100. E.isRight(pendingInvites.data) &&
  101. pendingInvites.data.right.team.teamInvitations.length === 0
  102. "
  103. class="flex flex-col items-center justify-center p-4 text-secondaryLight"
  104. >
  105. <span class="text-center">
  106. {{ t("empty.pending_invites") }}
  107. </span>
  108. </div>
  109. <div
  110. v-if="!pendingInvites.loading && E.isLeft(pendingInvites.data)"
  111. class="flex flex-col items-center p-4"
  112. >
  113. <i class="mb-4 material-icons">help_outline</i>
  114. {{ t("error.something_went_wrong") }}
  115. </div>
  116. </div>
  117. </div>
  118. <div class="flex items-center justify-between flex-1 pt-4">
  119. <label for="memberList" class="p-4">
  120. {{ t("team.invite_tooltip") }}
  121. </label>
  122. <div class="flex">
  123. <ButtonSecondary
  124. svg="plus"
  125. :label="t('add.new')"
  126. filled
  127. @click.native="addNewInvitee"
  128. />
  129. </div>
  130. </div>
  131. <div class="border rounded divide-y divide-dividerLight border-divider">
  132. <div
  133. v-for="(invitee, index) in newInvites"
  134. :key="`new-invitee-${index}`"
  135. class="flex divide-x divide-dividerLight"
  136. >
  137. <input
  138. v-model="invitee.key"
  139. class="flex flex-1 px-4 py-2 bg-transparent"
  140. :placeholder="`${t('team.email')}`"
  141. :name="'invitee' + index"
  142. autofocus
  143. />
  144. <span>
  145. <tippy
  146. ref="newInviteeOptions"
  147. interactive
  148. trigger="click"
  149. theme="popover"
  150. arrow
  151. >
  152. <template #trigger>
  153. <span class="select-wrapper">
  154. <input
  155. class="flex flex-1 px-4 py-2 bg-transparent cursor-pointer"
  156. :placeholder="`${t('team.permissions')}`"
  157. :name="'value' + index"
  158. :value="invitee.value"
  159. readonly
  160. />
  161. </span>
  162. </template>
  163. <div class="flex flex-col" role="menu">
  164. <SmartItem
  165. label="OWNER"
  166. :icon="
  167. invitee.value === 'OWNER'
  168. ? 'radio_button_checked'
  169. : 'radio_button_unchecked'
  170. "
  171. :active="invitee.value === 'OWNER'"
  172. @click.native="
  173. () => {
  174. updateNewInviteeRole(index, 'OWNER')
  175. newInviteeOptions[index].tippy().hide()
  176. }
  177. "
  178. />
  179. <SmartItem
  180. label="EDITOR"
  181. :icon="
  182. invitee.value === 'EDITOR'
  183. ? 'radio_button_checked'
  184. : 'radio_button_unchecked'
  185. "
  186. :active="invitee.value === 'EDITOR'"
  187. @click.native="
  188. () => {
  189. updateNewInviteeRole(index, 'EDITOR')
  190. newInviteeOptions[index].tippy().hide()
  191. }
  192. "
  193. />
  194. <SmartItem
  195. label="VIEWER"
  196. :icon="
  197. invitee.value === 'VIEWER'
  198. ? 'radio_button_checked'
  199. : 'radio_button_unchecked'
  200. "
  201. :active="invitee.value === 'VIEWER'"
  202. @click.native="
  203. () => {
  204. updateNewInviteeRole(index, 'VIEWER')
  205. newInviteeOptions[index].tippy().hide()
  206. }
  207. "
  208. />
  209. </div>
  210. </tippy>
  211. </span>
  212. <div class="flex">
  213. <ButtonSecondary
  214. id="member"
  215. v-tippy="{ theme: 'tooltip' }"
  216. :title="t('action.remove')"
  217. svg="trash"
  218. color="red"
  219. @click.native="removeNewInvitee(index)"
  220. />
  221. </div>
  222. </div>
  223. <div
  224. v-if="newInvites.length === 0"
  225. class="flex flex-col items-center justify-center p-4 text-secondaryLight"
  226. >
  227. <img
  228. :src="`/images/states/${$colorMode.value}/add_group.svg`"
  229. loading="lazy"
  230. class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
  231. :alt="`${t('empty.invites')}`"
  232. />
  233. <span class="pb-4 text-center">
  234. {{ t("empty.invites") }}
  235. </span>
  236. <ButtonSecondary
  237. :label="t('add.new')"
  238. filled
  239. @click.native="addNewInvitee"
  240. />
  241. </div>
  242. </div>
  243. <div
  244. v-if="newInvites.length"
  245. class="flex flex-col items-start px-4 py-4 mt-4 border rounded border-dividerLight"
  246. >
  247. <span
  248. class="flex items-center justify-center px-2 py-1 mb-4 font-semibold border rounded-full bg-primaryDark border-divider"
  249. >
  250. <i class="mr-2 text-secondaryLight material-icons">help_outline</i>
  251. {{ t("profile.roles") }}
  252. </span>
  253. <p>
  254. <span class="text-secondaryLight">
  255. {{ t("profile.roles_description") }}
  256. </span>
  257. </p>
  258. <ul class="mt-4 space-y-4">
  259. <li class="flex">
  260. <span
  261. class="w-1/4 font-semibold uppercase truncate text-secondaryDark max-w-16"
  262. >
  263. {{ t("profile.owner") }}
  264. </span>
  265. <span class="flex flex-1">
  266. {{ t("profile.owner_description") }}
  267. </span>
  268. </li>
  269. <li class="flex">
  270. <span
  271. class="w-1/4 font-semibold uppercase truncate text-secondaryDark max-w-16"
  272. >
  273. {{ t("profile.editor") }}
  274. </span>
  275. <span class="flex flex-1">
  276. {{ t("profile.editor_description") }}
  277. </span>
  278. </li>
  279. <li class="flex">
  280. <span
  281. class="w-1/4 font-semibold uppercase truncate text-secondaryDark max-w-16"
  282. >
  283. {{ t("profile.viewer") }}
  284. </span>
  285. <span class="flex flex-1">
  286. {{ t("profile.viewer_description") }}
  287. </span>
  288. </li>
  289. </ul>
  290. </div>
  291. </div>
  292. </template>
  293. <template #footer>
  294. <p
  295. v-if="sendInvitesResult.length"
  296. class="flex justify-between flex-1 text-secondaryLight"
  297. >
  298. <SmartAnchor
  299. class="link"
  300. :label="`← \xA0 ${t('team.invite_more')}`"
  301. @click.native="
  302. () => {
  303. sendInvitesResult = []
  304. newInvites = [
  305. {
  306. key: '',
  307. value: TeamMemberRole.Viewer,
  308. },
  309. ]
  310. }
  311. "
  312. />
  313. <SmartAnchor
  314. class="link"
  315. :label="`${t('action.dismiss')}`"
  316. @click.native="hideModal"
  317. />
  318. </p>
  319. <span v-else>
  320. <ButtonPrimary :label="t('team.invite')" @click.native="sendInvites" />
  321. <ButtonSecondary
  322. :label="t('action.cancel')"
  323. @click.native="hideModal"
  324. />
  325. </span>
  326. </template>
  327. </SmartModal>
  328. </template>
  329. <script setup lang="ts">
  330. import { watch, ref, reactive, computed } from "@nuxtjs/composition-api"
  331. import * as T from "fp-ts/Task"
  332. import * as E from "fp-ts/Either"
  333. import * as A from "fp-ts/Array"
  334. import * as O from "fp-ts/Option"
  335. import { flow, pipe } from "fp-ts/function"
  336. import { Email, EmailCodec } from "../../helpers/backend/types/Email"
  337. import {
  338. TeamInvitationAddedDocument,
  339. TeamInvitationRemovedDocument,
  340. TeamMemberRole,
  341. GetPendingInvitesDocument,
  342. GetPendingInvitesQuery,
  343. GetPendingInvitesQueryVariables,
  344. } from "../../helpers/backend/graphql"
  345. import {
  346. createTeamInvitation,
  347. CreateTeamInvitationErrors,
  348. revokeTeamInvitation,
  349. } from "../../helpers/backend/mutations/TeamInvitation"
  350. import { GQLError, useGQLQuery } from "~/helpers/backend/GQLClient"
  351. import { useI18n, useToast } from "~/helpers/utils/composables"
  352. const t = useI18n()
  353. const toast = useToast()
  354. const newInviteeOptions = ref<any | null>(null)
  355. const props = defineProps({
  356. show: Boolean,
  357. editingTeamID: { type: String, default: null },
  358. })
  359. const emit = defineEmits<{
  360. (e: "hide-modal"): void
  361. }>()
  362. const pendingInvites = useGQLQuery<
  363. GetPendingInvitesQuery,
  364. GetPendingInvitesQueryVariables,
  365. ""
  366. >({
  367. query: GetPendingInvitesDocument,
  368. variables: reactive({
  369. teamID: props.editingTeamID,
  370. }),
  371. pollDuration: 10000,
  372. updateSubs: computed(() =>
  373. !props.editingTeamID
  374. ? []
  375. : [
  376. {
  377. key: 4,
  378. query: TeamInvitationAddedDocument,
  379. variables: {
  380. teamID: props.editingTeamID,
  381. },
  382. },
  383. {
  384. key: 5,
  385. query: TeamInvitationRemovedDocument,
  386. variables: {
  387. teamID: props.editingTeamID,
  388. },
  389. },
  390. ]
  391. ),
  392. defer: true,
  393. })
  394. watch(
  395. () => props.show,
  396. (show) => {
  397. if (!show) {
  398. pendingInvites.pause()
  399. } else {
  400. pendingInvites.unpause()
  401. }
  402. }
  403. )
  404. watch(
  405. () => props.editingTeamID,
  406. () => {
  407. if (props.editingTeamID) {
  408. pendingInvites.execute({
  409. teamID: props.editingTeamID,
  410. })
  411. }
  412. }
  413. )
  414. const isLoadingIndex = ref<null | number>(null)
  415. const removeInvitee = async (id: string, index: number) => {
  416. isLoadingIndex.value = index
  417. const result = await revokeTeamInvitation(id)()
  418. if (E.isLeft(result)) {
  419. toast.error(`${t("error.something_went_wrong")}`)
  420. } else {
  421. toast.success(`${t("team.member_removed")}`)
  422. }
  423. isLoadingIndex.value = null
  424. }
  425. const newInvites = ref<Array<{ key: string; value: TeamMemberRole }>>([
  426. {
  427. key: "",
  428. value: TeamMemberRole.Viewer,
  429. },
  430. ])
  431. const addNewInvitee = () => {
  432. newInvites.value.push({
  433. key: "",
  434. value: TeamMemberRole.Viewer,
  435. })
  436. }
  437. const updateNewInviteeRole = (index: number, role: TeamMemberRole) => {
  438. newInvites.value[index].value = role
  439. }
  440. const removeNewInvitee = (id: number) => {
  441. newInvites.value.splice(id, 1)
  442. }
  443. type SendInvitesErrorType =
  444. | {
  445. email: Email
  446. status: "error"
  447. error: GQLError<CreateTeamInvitationErrors>
  448. }
  449. | {
  450. email: Email
  451. status: "success"
  452. }
  453. const sendInvitesResult = ref<Array<SendInvitesErrorType>>([])
  454. const sendingInvites = ref<boolean>(false)
  455. const sendInvites = async () => {
  456. const validationResult = pipe(
  457. newInvites.value,
  458. O.fromPredicate(
  459. (invites): invites is Array<{ key: Email; value: TeamMemberRole }> =>
  460. pipe(
  461. invites,
  462. A.every((invitee) => EmailCodec.is(invitee.key))
  463. )
  464. ),
  465. O.map(
  466. A.map((invitee) =>
  467. createTeamInvitation(invitee.key, invitee.value, props.editingTeamID)
  468. )
  469. )
  470. )
  471. if (O.isNone(validationResult)) {
  472. // Error handling for no validation
  473. toast.error(`${t("error.incorrect_email")}`)
  474. return
  475. }
  476. sendingInvites.value = true
  477. sendInvitesResult.value = await pipe(
  478. A.sequence(T.task)(validationResult.value),
  479. T.chain(
  480. flow(
  481. A.mapWithIndex((i, el) =>
  482. pipe(
  483. el,
  484. E.foldW(
  485. (err) => ({
  486. status: "error" as const,
  487. email: newInvites.value[i].key as Email,
  488. error: err,
  489. }),
  490. () => ({
  491. status: "success" as const,
  492. email: newInvites.value[i].key as Email,
  493. })
  494. )
  495. )
  496. ),
  497. T.of
  498. )
  499. )
  500. )()
  501. sendingInvites.value = false
  502. }
  503. const getErrorMessage = (error: SendInvitesErrorType) => {
  504. if (error.type === "network_error") {
  505. return t("error.network_error")
  506. } else {
  507. switch (error.error) {
  508. case "team/invalid_id":
  509. return t("team.invalid_id")
  510. case "team/member_not_found":
  511. return t("team.member_not_found")
  512. case "team_invite/already_member":
  513. return t("team.already_member")
  514. case "team_invite/member_has_invite":
  515. return t("team.member_has_invite")
  516. }
  517. }
  518. }
  519. const hideModal = () => {
  520. sendingInvites.value = false
  521. sendInvitesResult.value = []
  522. newInvites.value = [
  523. {
  524. key: "",
  525. value: TeamMemberRole.Viewer,
  526. },
  527. ]
  528. emit("hide-modal")
  529. }
  530. </script>