Invite.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. <template>
  2. <SmartModal v-if="show" :title="$t('team.invite')" @close="hideModal">
  3. <template #body>
  4. <div class="flex flex-col px-2">
  5. <div class="flex flex-1 justify-between items-center">
  6. <label for="memberList" class="pb-4 px-4">
  7. {{ $t("team.pending_invites") }}
  8. </label>
  9. </div>
  10. <div class="divide-y divide-dividerLight border-divider border rounded">
  11. <div
  12. v-if="pendingInvites.loading"
  13. class="flex p-4 items-center justify-center"
  14. >
  15. <SmartSpinner />
  16. </div>
  17. <div v-else>
  18. <div
  19. v-if="!pendingInvites.loading && E.isRight(pendingInvites.data)"
  20. >
  21. <div
  22. v-for="(invitee, index) in pendingInvites.data.right.team
  23. .teamInvitations"
  24. :key="`invitee-${index}`"
  25. class="divide-x divide-dividerLight flex"
  26. >
  27. <input
  28. v-if="invitee"
  29. class="
  30. bg-transparent
  31. flex flex-1
  32. text-secondaryLight
  33. py-2
  34. px-4
  35. "
  36. :placeholder="`${$t('team.email')}`"
  37. :name="'param' + index"
  38. :value="invitee.inviteeEmail"
  39. readonly
  40. />
  41. <input
  42. class="
  43. bg-transparent
  44. flex flex-1
  45. text-secondaryLight
  46. py-2
  47. px-4
  48. "
  49. :placeholder="`${$t('team.permissions')}`"
  50. :name="'value' + index"
  51. :value="
  52. typeof invitee.inviteeRole === 'string'
  53. ? invitee.inviteeRole
  54. : JSON.stringify(invitee.inviteeRole)
  55. "
  56. readonly
  57. />
  58. <div class="flex">
  59. <ButtonSecondary
  60. v-tippy="{ theme: 'tooltip' }"
  61. :title="$t('action.remove')"
  62. svg="trash"
  63. color="red"
  64. @click.native="removeInvitee(invitee.id)"
  65. />
  66. </div>
  67. </div>
  68. </div>
  69. <div
  70. v-if="
  71. E.isRight(pendingInvites.data) &&
  72. pendingInvites.data.right.team.teamInvitations.length === 0
  73. "
  74. class="
  75. flex flex-col
  76. text-secondaryLight
  77. p-4
  78. items-center
  79. justify-center
  80. "
  81. >
  82. <SmartIcon class="opacity-75 pb-2" name="users" />
  83. <span class="text-center">
  84. {{ $t("empty.pending_invites") }}
  85. </span>
  86. </div>
  87. <div
  88. v-if="!pendingInvites.loading && E.isLeft(pendingInvites.data)"
  89. class="flex flex-col p-4 items-center"
  90. >
  91. <i class="mb-4 material-icons">help_outline</i>
  92. {{ $t("error.something_went_wrong") }}
  93. </div>
  94. </div>
  95. </div>
  96. <div class="flex pt-4 flex-1 justify-between items-center">
  97. <label for="memberList" class="p-4">
  98. {{ $t("team.invite_tooltip") }}
  99. </label>
  100. <div class="flex">
  101. <ButtonSecondary
  102. svg="plus"
  103. :label="$t('add.new')"
  104. filled
  105. @click.native="addNewInvitee"
  106. />
  107. </div>
  108. </div>
  109. <div class="divide-y divide-dividerLight border-divider border rounded">
  110. <div
  111. v-for="(invitee, index) in newInvites"
  112. :key="`new-invitee-${index}`"
  113. class="divide-x divide-dividerLight flex"
  114. >
  115. <input
  116. v-model="invitee.key"
  117. class="bg-transparent flex flex-1 py-2 px-4"
  118. :placeholder="$t('team.email')"
  119. :name="'invitee' + index"
  120. autofocus
  121. />
  122. <span>
  123. <tippy
  124. :ref="`newMemberOptions-${index}`"
  125. interactive
  126. trigger="click"
  127. theme="popover"
  128. arrow
  129. >
  130. <template #trigger>
  131. <span class="select-wrapper">
  132. <input
  133. class="
  134. bg-transparent
  135. cursor-pointer
  136. flex flex-1
  137. py-2
  138. px-4
  139. "
  140. :placeholder="$t('team.permissions')"
  141. :name="'value' + index"
  142. :value="
  143. typeof invitee.value === 'string'
  144. ? invitee.value
  145. : JSON.stringify(invitee.value)
  146. "
  147. readonly
  148. />
  149. </span>
  150. </template>
  151. <SmartItem
  152. label="OWNER"
  153. @click.native="updateNewInviteeRole(index, 'OWNER')"
  154. />
  155. <SmartItem
  156. label="EDITOR"
  157. @click.native="updateNewInviteeRole(index, 'EDITOR')"
  158. />
  159. <SmartItem
  160. label="VIEWER"
  161. @click.native="updateNewInviteeRole(index, 'VIEWER')"
  162. />
  163. </tippy>
  164. </span>
  165. <div class="flex">
  166. <ButtonSecondary
  167. id="member"
  168. v-tippy="{ theme: 'tooltip' }"
  169. :title="$t('action.remove')"
  170. svg="trash"
  171. color="red"
  172. @click.native="removeNewInvitee(index)"
  173. />
  174. </div>
  175. </div>
  176. <div
  177. v-if="newInvites.length === 0"
  178. class="
  179. flex flex-col
  180. text-secondaryLight
  181. p-4
  182. items-center
  183. justify-center
  184. "
  185. >
  186. <SmartIcon class="opacity-75 pb-2" name="user-plus" />
  187. <span class="text-center pb-4">
  188. {{ $t("empty.invites") }}
  189. </span>
  190. <ButtonSecondary
  191. :label="$t('add.new')"
  192. filled
  193. @click.native="addNewInvitee"
  194. />
  195. </div>
  196. </div>
  197. </div>
  198. </template>
  199. <template #footer>
  200. <span>
  201. <ButtonPrimary :label="$t('team.invite')" @click.native="sendInvites" />
  202. <ButtonSecondary
  203. :label="$t('action.cancel')"
  204. @click.native="hideModal"
  205. />
  206. </span>
  207. </template>
  208. </SmartModal>
  209. </template>
  210. <script setup lang="ts">
  211. import { watch, ref, reactive, useContext } from "@nuxtjs/composition-api"
  212. import * as T from "fp-ts/Task"
  213. import * as E from "fp-ts/Either"
  214. import * as A from "fp-ts/Array"
  215. import * as O from "fp-ts/Option"
  216. import { flow, pipe } from "fp-ts/function"
  217. import { Email, EmailCodec } from "../../helpers/backend/types/Email"
  218. import { TeamMemberRole } from "../../helpers/backend/graphql"
  219. import {
  220. createTeamInvitation,
  221. revokeTeamInvitation,
  222. } from "../../helpers/backend/mutations/TeamInvitation"
  223. import { useGQLQuery } from "~/helpers/backend/GQLClient"
  224. import {
  225. GetPendingInvitesDocument,
  226. GetPendingInvitesQuery,
  227. GetPendingInvitesQueryVariables,
  228. } from "~/helpers/backend/graphql"
  229. const {
  230. $toast,
  231. app: { i18n },
  232. } = useContext()
  233. const t = i18n.t.bind(i18n)
  234. const props = defineProps({
  235. show: Boolean,
  236. editingteamID: { type: String, default: null },
  237. })
  238. const emit = defineEmits<{
  239. (e: "hide-modal"): void
  240. }>()
  241. const pendingInvites = useGQLQuery<
  242. GetPendingInvitesQuery,
  243. GetPendingInvitesQueryVariables,
  244. ""
  245. >({
  246. query: GetPendingInvitesDocument,
  247. variables: reactive({
  248. teamID: props.editingteamID,
  249. }),
  250. defer: true,
  251. })
  252. watch(
  253. () => props.editingteamID,
  254. () => {
  255. if (props.editingteamID) {
  256. pendingInvites.execute({
  257. teamID: props.editingteamID,
  258. })
  259. }
  260. }
  261. )
  262. watch(
  263. () => pendingInvites,
  264. () => {
  265. console.log(pendingInvites)
  266. }
  267. )
  268. const removeInvitee = async (id: string) => {
  269. const result = await revokeTeamInvitation(id)()
  270. if (E.isLeft(result)) {
  271. $toast.error(`${t("error.something_went_wrong")}`, {
  272. icon: "error_outline",
  273. })
  274. } else {
  275. $toast.success(`${t("team.member_removed")}`, {
  276. icon: "person",
  277. })
  278. }
  279. }
  280. const newInvites = ref<Array<{ key: string; value: TeamMemberRole }>>([])
  281. const addNewInvitee = () => {
  282. newInvites.value.push({
  283. key: "",
  284. value: TeamMemberRole.Viewer,
  285. })
  286. }
  287. const updateNewInviteeRole = (index: number, role: TeamMemberRole) => {
  288. newInvites.value[index].value = role
  289. }
  290. const removeNewInvitee = (id: number) => {
  291. newInvites.value.splice(id, 1)
  292. }
  293. const result = ref<
  294. Array<{
  295. email: Email
  296. status: "error" | "success"
  297. }>
  298. >([])
  299. const sendInvites = async () => {
  300. const validationResult = pipe(
  301. newInvites.value,
  302. O.fromPredicate(
  303. (invites): invites is Array<{ key: Email; value: TeamMemberRole }> =>
  304. pipe(
  305. invites,
  306. A.every((invitee) => EmailCodec.is(invitee.key))
  307. )
  308. ),
  309. O.map(
  310. A.map((invitee) =>
  311. createTeamInvitation(invitee.key, invitee.value, props.editingteamID)
  312. )
  313. )
  314. )
  315. if (O.isNone(validationResult)) {
  316. // Error handling for no validation
  317. $toast.error(`${t("error.incorrect_email")}`, {
  318. icon: "error_outline",
  319. })
  320. return
  321. }
  322. result.value = await pipe(
  323. A.sequence(T.task)(validationResult.value),
  324. T.chain(
  325. flow(
  326. A.mapWithIndex((i, el) =>
  327. pipe(
  328. el,
  329. E.foldW(
  330. () => ({
  331. status: "error" as const,
  332. email: newInvites.value[i].key as Email,
  333. }),
  334. () => ({
  335. status: "success" as const,
  336. email: newInvites.value[i].key as Email,
  337. })
  338. )
  339. )
  340. ),
  341. T.of
  342. )
  343. )
  344. )()
  345. }
  346. const hideModal = () => {
  347. emit("hide-modal")
  348. }
  349. </script>