Invite.vue 16 KB

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