Files
speckle-server/packages/frontend-2/lib/workspaces/composables/management.ts
T

695 lines
20 KiB
TypeScript

import type { RouteLocationNormalized } from 'vue-router'
import {
SeatTypes,
waitForever,
type MaybeAsync,
type MaybeNullOrUndefined,
type Optional,
type WorkspaceSeatType
} from '@speckle/shared'
import {
useApolloClient,
useMutation,
useSubscription,
useQuery
} from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import type {
OnWorkspaceUpdatedSubscription,
UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragment,
Workspace,
WorkspaceCreateInput,
WorkspaceInviteCreateInput,
WorkspaceInvitedTeamArgs,
WorkspaceInviteUseInput,
WorkspaceRoleUpdateInput
} from '~/lib/common/generated/gql/graphql'
import {
evictObjectFields,
getCacheId,
getFirstErrorMessage,
getObjectReference,
modifyObjectField,
modifyObjectFields,
ROOT_QUERY
} from '~/lib/common/helpers/graphql'
import { useNavigateToHome, workspaceRoute } from '~/lib/common/helpers/route'
import { useMixpanel } from '~/lib/core/composables/mp'
import {
createWorkspaceMutation,
inviteToWorkspaceMutation,
processWorkspaceInviteMutation,
setDefaultRegionMutation,
workspaceUpdateRoleMutation,
workspacesUpdateSeatTypeMutation
} from '~/lib/workspaces/graphql/mutations'
import { isFunction } from 'lodash-es'
import type { GraphQLError, GraphQLFormattedError } from 'graphql'
import { onWorkspaceUpdatedSubscription } from '~/lib/workspaces/graphql/subscriptions'
import { useLock } from '~/lib/common/composables/singleton'
import type { Get } from 'type-fest'
import type { ApolloCache } from '@apollo/client/core'
import { workspaceLastAdminCheckQuery } from '../graphql/queries'
import { useNavigation } from '~/lib/navigation/composables/navigation'
export const useInviteUserToWorkspace = () => {
const { activeUser } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const { mutate } = useMutation(inviteToWorkspaceMutation)
const isWorkspacesEnabled = useIsWorkspacesEnabled()
return async (args: {
workspaceId: string
inputs: WorkspaceInviteCreateInput[]
hideNotifications?: boolean
}) => {
const { workspaceId, inputs, hideNotifications } = args
const userId = activeUser.value?.id
if (!userId) return
if (!isWorkspacesEnabled.value) return
const { data, errors } =
(await mutate(
{ workspaceId, input: inputs },
{
update: (cache, { data }) => {
if (!data?.workspaceMutations.invites.batchCreate.id) return
const invitedTeam = data.workspaceMutations.invites.batchCreate.invitedTeam
if (!invitedTeam) return
modifyObjectFields<WorkspaceInvitedTeamArgs, Workspace['invitedTeam']>(
cache,
getCacheId('Workspace', workspaceId),
(_fieldName, vars) => {
if (vars.filter?.search?.length) return
return invitedTeam.map((i) =>
getObjectReference('PendingWorkspaceCollaborator', i.id)
)
},
{
fieldNameWhitelist: ['invitedTeam']
}
)
// Evict the cache for the invited team if the search filter is active
evictObjectFields<WorkspaceInvitedTeamArgs, Workspace['invitedTeam']>(
cache,
getCacheId('Workspace', workspaceId),
(fieldName, vars) => {
if (fieldName !== 'invitedTeam') return false
return vars.filter?.search?.length !== 0
}
)
}
}
).catch(convertThrowIntoFetchResult)) || {}
if (!data?.workspaceMutations.invites.batchCreate.id && !hideNotifications) {
const err = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Invitation failed',
description: err
})
} else {
if (!hideNotifications) {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Invite successfully sent'
})
}
}
return data?.workspaceMutations.invites.batchCreate
}
}
export const useProcessWorkspaceInvite = () => {
const { mutate } = useMutation(processWorkspaceInviteMutation)
const { activeUser } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const mp = useMixpanel()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
return async (
params: {
input: WorkspaceInviteUseInput
workspaceId: string
inviteId: string
},
options?: Partial<{
/**
* Do something once mutation has finished, before all cache updates
*/
callback: () => MaybeAsync<void>
preventErrorToasts?:
| boolean
| ((
errors: GraphQLError[] | GraphQLFormattedError[],
errMsg: string
) => boolean)
}>
) => {
if (!isWorkspacesEnabled.value) return
const userId = activeUser.value?.id
if (!userId) return
const { input, workspaceId, inviteId } = params
const { data, errors } =
(await mutate(
{ input },
{
update: async (cache, { data, errors }) => {
if (errors?.length) return
const accepted = data?.workspaceMutations.invites.use
if (accepted) {
// Evict Query.workspace
modifyObjectField(
cache,
ROOT_QUERY,
'workspace',
({ variables, helpers: { evict } }) => {
if (variables.id === workspaceId) return evict()
}
)
// Evict all User.workspaces
modifyObjectField(
cache,
getCacheId('User', userId),
'workspaces',
({ helpers: { evict } }) => evict()
)
}
// Set Query.workspaceInvite(id) = null (no invite)
modifyObjectField(
cache,
ROOT_QUERY,
'workspaceInvite',
({ value, variables, helpers: { readField } }) => {
if (value) {
const workspace = readField(value, 'workspace')
if (workspace) {
const inviteWorkspaceId = workspace.id
if (inviteWorkspaceId === workspaceId) return null
}
} else {
if (variables.workspaceId === workspaceId) return null
}
}
)
// Evict invite itself
cache.evict({
id: getCacheId('PendingWorkspaceCollaborator', inviteId)
})
if (options?.callback) await options.callback()
}
}
).catch(convertThrowIntoFetchResult)) || {}
if (data?.workspaceMutations.invites.use) {
triggerNotification({
type: ToastNotificationType.Success,
title: input.accept ? 'Workspace invite accepted' : 'Workspace invite dismissed'
})
mp.track('Workspace Joined', {
// eslint-disable-next-line camelcase
workspace_id: workspaceId
})
mp.track('Invite Action', {
type: 'workspace invite',
accepted: input.accept,
// eslint-disable-next-line camelcase
workspace_id: workspaceId
})
} else {
const err = getFirstErrorMessage(errors)
const preventErrorToasts = isFunction(options?.preventErrorToasts)
? options?.preventErrorToasts(errors?.slice() || [], err)
: options?.preventErrorToasts
if (!preventErrorToasts) {
const err = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to process invite',
description: err
})
}
}
return !!data?.workspaceMutations.invites.use
}
}
graphql(`
fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {
id
token
workspace {
...WorkspaceInviteCard_LimitedWorkspace
}
user {
id
}
}
`)
export const useWorkspaceInviteManager = <
Invite extends UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragment = UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragment
>(
params: {
invite: Ref<Optional<Invite>>
},
options?: Partial<{
/**
* Whether to prevent any reloads/redirects on successful processing of the invite
*/
preventRedirect: boolean
route: RouteLocationNormalized
preventErrorToasts:
| boolean
| ((errors: GraphQLError[] | GraphQLFormattedError[], errMsg: string) => boolean)
}>
) => {
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { invite } = params
const { preventRedirect, preventErrorToasts } = options || {}
const useInvite = useProcessWorkspaceInvite()
const route = options?.route || useRoute()
const goHome = useNavigateToHome()
const { activeUser } = useActiveUser()
const { mutateActiveWorkspaceSlug } = useNavigation()
const loading = ref(false)
const token = computed(
() => (route.query.token as Optional<string>) || invite.value?.token
)
const isCurrentUserTarget = computed(
() =>
activeUser.value &&
invite.value?.user &&
activeUser.value.id === invite.value.user.id
)
const targetUser = computed((): Invite['user'] => invite.value?.user)
const needsToAddNewEmail = computed(
() => !isCurrentUserTarget.value && !targetUser.value
)
const canAddNewEmail = computed(() => needsToAddNewEmail.value && token.value)
const processInvite = async (
accept: boolean,
options?: Partial<{
/**
* If invite is attached to an unregistered email, the invite can only be used if this is set to true.
* Upon accepting such an invite, the unregistered email will be added to the user's account as well.
*/
addNewEmail: boolean
}>
) => {
const { addNewEmail } = options || {}
if (!isWorkspacesEnabled.value) return false
if (!token.value || !invite.value) return false
const workspaceId = invite.value.workspace.id
const workspaceSlug = invite.value.workspace.slug
const shouldAddNewEmail = canAddNewEmail.value && addNewEmail
loading.value = true
const success = await useInvite(
{
workspaceId,
input: {
accept,
token: token.value,
...(shouldAddNewEmail ? { addNewEmail: shouldAddNewEmail } : {})
},
inviteId: invite.value.id
},
{
callback: async () => {
if (!preventRedirect) {
// Redirect
if (accept) {
if (workspaceSlug) {
navigateTo(workspaceRoute(workspaceSlug))
mutateActiveWorkspaceSlug(workspaceSlug)
} else {
window.location.reload()
}
await waitForever() // to prevent UI changes while reload is happening
} else {
await goHome()
}
}
},
preventErrorToasts
}
)
loading.value = false
return !!success
}
return {
loading: computed(() => loading.value),
token,
isCurrentUserTarget,
targetUser,
accept: (options?: Parameters<typeof processInvite>[1]) =>
processInvite(true, options),
decline: (options?: Parameters<typeof processInvite>[1]) =>
processInvite(false, options)
}
}
export function useCreateWorkspace() {
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const { activeUser } = useActiveUser()
const router = useRouter()
const { mutateActiveWorkspaceSlug } = useNavigation()
return async (
input: WorkspaceCreateInput,
options?: Partial<{
/**
* Determines whether to navigate to the new workspace upon creation.
* Defaults to false.
*/
navigateOnSuccess: boolean
hideNotifications: boolean
}>
) => {
const userId = activeUser.value?.id
if (!userId) return
const res = await apollo
.mutate({
mutation: createWorkspaceMutation,
variables: { input },
update: (cache, { data }) => {
const workspaceId = data?.workspaceMutations.create.id
if (!workspaceId) return
// Navigation to workspace is gonna fetch everything needed for the page, so we only
// really need to update workspace fields used in sidebar & settings: User.workspaces
modifyObjectField(
cache,
getCacheId('User', userId),
'workspaces',
({ helpers: { createUpdatedValue, ref } }) => {
return createUpdatedValue(({ update }) => {
update('totalCount', (totalCount) => totalCount + 1)
update('items', (items) => [...items, ref('Workspace', workspaceId)])
})
},
{
autoEvictFiltered: true
}
)
}
})
.catch(convertThrowIntoFetchResult)
if (res.data?.workspaceMutations.create.id) {
if (!options?.hideNotifications) {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Workspace successfully created'
})
}
if (options?.navigateOnSuccess === true) {
router.push(workspaceRoute(res.data?.workspaceMutations.create.slug))
mutateActiveWorkspaceSlug(res.data?.workspaceMutations.create.slug)
}
} else {
const err = getFirstErrorMessage(res.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Workspace creation failed',
description: err
})
}
return res
}
}
export const useWorkspaceUpdateRole = () => {
const { mutate } = useMutation(workspaceUpdateRoleMutation)
const { triggerNotification } = useGlobalToast()
const mixpanel = useMixpanel()
return async (input: WorkspaceRoleUpdateInput) => {
const result = await mutate(
{ input },
{
update: (cache) => {
if (!input.role) {
cache.evict({
id: getCacheId('WorkspaceCollaborator', input.userId)
})
modifyObjectField(
cache,
getCacheId('Workspace', input.workspaceId),
'team',
({ helpers: { createUpdatedValue } }) => {
return createUpdatedValue(({ update }) => {
update('totalCount', (totalCount) => totalCount - 1)
})
},
{
autoEvictFiltered: true
}
)
}
modifyObjectField(
cache,
getCacheId('Workspace', input.workspaceId),
'teamByRole',
({ helpers: { evict } }) => {
return evict()
}
)
modifyObjectField(
cache,
getCacheId('WorkspaceCollaborator', input.userId),
'seatType',
() => SeatTypes.Editor
)
if (input.role) {
modifyObjectField(
cache,
getCacheId('WorkspaceCollaborator', input.userId),
'role',
() => input.role!
)
}
}
}
).catch(convertThrowIntoFetchResult)
if (result?.data) {
triggerNotification({
type: ToastNotificationType.Success,
title: input.role ? 'User role updated' : 'User removed',
description: input.role
? 'The user role has been updated'
: 'The user has been removed from the workspace'
})
if (input.role) {
mixpanel.track('Workspace User Role Updated', {
newRole: input.role,
// eslint-disable-next-line camelcase
workspace_id: input.workspaceId
})
} else {
mixpanel.track('Workspace User Removed', {
// eslint-disable-next-line camelcase
workspace_id: input.workspaceId
})
}
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: input.role ? 'Failed to update role' : 'Failed to remove user',
description: errorMessage
})
}
}
}
export const useWorkspaceUpdateSeatType = () => {
const { mutate } = useMutation(workspacesUpdateSeatTypeMutation)
const { triggerNotification } = useGlobalToast()
const mixpanel = useMixpanel()
return async (
input: {
userId: string
workspaceId: string
seatType: WorkspaceSeatType
},
options?: { hideNotifications: boolean }
) => {
const { hideNotifications } = options ?? {}
const result = await mutate(
{ input },
{
update: (cache) => {
// Update the team member's seat type in the cache
modifyObjectField(
cache,
getCacheId('WorkspaceCollaborator', input.userId),
'seatType',
() => input.seatType
)
}
}
).catch(convertThrowIntoFetchResult)
if (result?.data) {
if (!hideNotifications) {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Seat updated',
description: `The user's seat has been updated to ${input.seatType}`
})
}
mixpanel.track('Workspace User Seat Type Updated', {
newSeatType: input.seatType,
// eslint-disable-next-line camelcase
workspace_id: input.workspaceId
})
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to update seat type',
description: errorMessage
})
}
}
}
export const copyWorkspaceLink = async (slug: string) => {
const { copy } = useClipboard()
const url = new URL(workspaceRoute(slug), window.location.toString()).toString()
await copy(url, {
successMessage: 'Copied workspace link to clipboard'
})
}
export const useSetDefaultWorkspaceRegion = () => {
const { mutate } = useMutation(setDefaultRegionMutation)
const { triggerNotification } = useGlobalToast()
return async (params: { workspaceId: string; regionKey: string }) => {
const { workspaceId, regionKey } = params
const res = await mutate({ workspaceId, regionKey }).catch(
convertThrowIntoFetchResult
)
if (res?.data?.workspaceMutations.setDefaultRegion) {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Default region set successfully'
})
} else {
const err = getFirstErrorMessage(res?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to set default region',
description: err
})
}
return res?.data?.workspaceMutations.setDefaultRegion
}
}
export const useOnWorkspaceUpdated = (params: {
workspaceSlug: Ref<string>
/**
* Optionally do extra work on each message, besides the main cache update
*/
handler?: (
data: NonNullable<Get<OnWorkspaceUpdatedSubscription, 'workspaceUpdated'>>,
cache: ApolloCache<unknown>
) => void
}) => {
const { workspaceSlug, handler } = params
const apollo = useApolloClient().client
const { hasLock } = useLock(
computed(() => `useOnWorkspaceUpdated-${unref(workspaceSlug.value)}`)
)
const enabled = computed(() => !!(hasLock.value || handler))
const { onResult } = useSubscription(
onWorkspaceUpdatedSubscription,
() => ({
workspaceSlug: params.workspaceSlug.value
}),
() => ({
enabled: enabled.value,
errorPolicy: 'all'
})
)
// Main, locked cache update
onResult((result) => {
if (!result.data?.workspaceUpdated || !hasLock.value) return
})
// Optional handler
if (handler) {
onResult((result) => {
if (!result.data?.workspaceUpdated) return
handler(result.data.workspaceUpdated, apollo.cache)
})
}
}
export const useWorkspaceLastAdminCheck = (params: {
workspaceSlug: Ref<MaybeNullOrUndefined<string>>
}) => {
const { workspaceSlug } = params
const { result } = useQuery(
workspaceLastAdminCheckQuery,
() => ({
slug: workspaceSlug.value || ''
}),
() => ({
enabled: !!workspaceSlug.value
})
)
const isLastAdmin = computed(
() => result.value?.workspaceBySlug?.teamByRole?.admins?.totalCount === 1
)
return {
isLastAdmin
}
}