Invite.vue 15 KB

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