Files
speckle-server/packages/server/modules/workspaces/services/invites.ts
T
Kristaps Fabians Geikins 4dae1569cd feat(fe2): invite + list workspace invites (#2629)
* list invites table

* invites list works

* update last reminded date on resend

* fix FE

* WIP invitedialog + updated debounced utility

* invite create works

* exclude users correctly

* more adjustments

* minor cleanup

* using workspace invite server role

* test fix

* fixed multiple root eslint issues

* minor adjustments
2024-08-12 11:30:01 +03:00

436 lines
14 KiB
TypeScript

import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
import {
PendingWorkspaceCollaboratorsFilter,
TokenResourceIdentifierType,
WorkspaceInviteCreateInput
} from '@/modules/core/graph/generated/graphql'
import { mapServerRoleToValue } from '@/modules/core/helpers/graphTypes'
import { getWorkspaceRoute } from '@/modules/core/helpers/routeHelper'
import { isResourceAllowed } from '@/modules/core/helpers/token'
import { LimitedUserRecord } from '@/modules/core/helpers/types'
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import { getUser } from '@/modules/core/repositories/users'
import { ServerInviteResourceType } from '@/modules/serverinvites/domain/constants'
import {
FindInvite,
QueryAllResourceInvites,
QueryAllUserResourceInvites
} from '@/modules/serverinvites/domain/operations'
import {
InviteResourceTarget,
PrimaryInviteResourceTarget,
ServerInviteRecord
} from '@/modules/serverinvites/domain/types'
import {
InviteCreateValidationError,
InviteFinalizingError,
NoInviteFoundError
} from '@/modules/serverinvites/errors'
import {
buildUserTarget,
resolveInviteTargetTitle,
resolveTarget
} from '@/modules/serverinvites/helpers/core'
import {
buildCoreInviteEmailContentsFactory,
BuildInviteContentsFactoryDeps
} from '@/modules/serverinvites/services/coreEmailContents'
import {
collectAndValidateCoreTargetsFactory,
CollectAndValidateCoreTargetsFactoryDeps
} from '@/modules/serverinvites/services/coreResourceCollection'
import {
BuildInviteEmailContents,
CollectAndValidateResourceTargets,
CreateAndSendInvite,
GetInvitationTargetUsers,
InviteFinalizationAction,
ProcessFinalizedResourceInvite,
ValidateResourceInviteBeforeFinalization
} from '@/modules/serverinvites/services/operations'
import { authorizeResolver } from '@/modules/shared'
import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
import { GetWorkspace } from '@/modules/workspaces/domain/operations'
import { WorkspaceInviteResourceTarget } from '@/modules/workspaces/domain/types'
import { mapGqlWorkspaceRoleToMainRole } from '@/modules/workspaces/helpers/roles'
import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management'
import { PendingWorkspaceCollaboratorGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes'
import { MaybeNullOrUndefined, Nullable, Roles, WorkspaceRoles } from '@speckle/shared'
const isWorkspaceResourceTarget = (
target: InviteResourceTarget
): target is WorkspaceInviteResourceTarget =>
target.resourceType === WorkspaceInviteResourceType
export const createWorkspaceInviteFactory =
(deps: { createAndSendInvite: CreateAndSendInvite }) =>
async (params: {
workspaceId: string
inviterId: string
input: WorkspaceInviteCreateInput
inviterResourceAccessRules: MaybeNullOrUndefined<TokenResourceIdentifier[]>
}) => {
const { workspaceId, inviterId, input, inviterResourceAccessRules } = params
if (!input.email?.length && !input.userId?.length) {
throw new InviteCreateValidationError('Either email or userId must be specified')
}
const target = (input.userId ? buildUserTarget(input.userId) : input.email)!
const primaryResourceTarget: PrimaryInviteResourceTarget<WorkspaceInviteResourceTarget> =
{
resourceType: WorkspaceInviteResourceType,
resourceId: workspaceId,
role:
(input.role ? mapGqlWorkspaceRoleToMainRole(input.role) : null) ||
Roles.Workspace.Member,
primary: true,
secondaryResourceRoles: {
...(input.serverRole
? { [ServerInviteResourceType]: mapServerRoleToValue(input.serverRole) }
: {})
}
}
return await deps.createAndSendInvite(
{
target,
inviterId,
message: undefined,
primaryResourceTarget
},
inviterResourceAccessRules
)
}
type CollectAndValidateWorkspaceTargetsFactoryDeps =
CollectAndValidateCoreTargetsFactoryDeps & {
getWorkspace: GetWorkspace
}
export const collectAndValidateWorkspaceTargetsFactory =
(
deps: CollectAndValidateWorkspaceTargetsFactoryDeps
): CollectAndValidateResourceTargets =>
async (params) => {
const coreCollector = collectAndValidateCoreTargetsFactory(deps)
const baseTargets = (await coreCollector(params)).map((t) => ({
...t,
primary: false
}))
const { input, inviter, targetUser, inviterResourceAccessLimits } = params
const primaryResourceTarget = input.primaryResourceTarget
const primaryWorkspaceResourceTarget = isWorkspaceResourceTarget(
primaryResourceTarget
)
? primaryResourceTarget
: null
if (!primaryWorkspaceResourceTarget) {
return [...baseTargets]
}
const { role, resourceId } = primaryWorkspaceResourceTarget
// Validate that inviter has access to this project
try {
await authorizeResolver(
inviter.id,
resourceId,
Roles.Workspace.Admin,
inviterResourceAccessLimits
)
} catch (e) {
throw new InviteCreateValidationError(
"Inviter doesn't have proper access to the resource",
{ cause: e as Error }
)
}
const workspace = await deps.getWorkspace({
workspaceId: resourceId,
userId: targetUser?.id
})
if (!workspace) {
throw new InviteCreateValidationError(
'Attempting to invite into a non-existant workspace'
)
}
if (workspace.role) {
throw new InviteCreateValidationError(
'The target user is already a member of the specified workspace'
)
}
if (!Object.values(Roles.Workspace).includes(role)) {
throw new InviteCreateValidationError('Unexpected workspace invite role')
}
if (targetUser?.role === Roles.Server.Guest && role === Roles.Workspace.Admin) {
throw new InviteCreateValidationError(
'Guest users cannot be admins of workspaces'
)
}
return [...baseTargets, { ...primaryWorkspaceResourceTarget, primary: true }]
}
type BuildWorkspaceInviteEmailContentsFactoryDeps = BuildInviteContentsFactoryDeps & {
getWorkspace: GetWorkspace
}
export const buildWorkspaceInviteEmailContentsFactory =
(deps: BuildWorkspaceInviteEmailContentsFactoryDeps): BuildInviteEmailContents =>
async (params) => {
const { invite, inviter } = params
const primaryResourceTarget = invite.resource
const coreEmailBuilder = buildCoreInviteEmailContentsFactory(deps)
if (!isWorkspaceResourceTarget(primaryResourceTarget)) {
return await coreEmailBuilder(params)
}
// Build workspace invite email contents
const workspace = await deps.getWorkspace({
workspaceId: primaryResourceTarget.resourceId
})
if (!workspace) {
throw new InviteCreateValidationError(
'Attempting to invite into a non-existant workspace'
)
}
const subject = `${inviter.name} has invited you to the "${workspace.name}" Speckle workspace`
const inviteLink = new URL(
`${getWorkspaceRoute(workspace.id)}?token=${invite.token}&accept=true`,
getFrontendOrigin()
).toString()
const mjml = {
bodyStart: `
<mj-text>
Hello!
<br />
<br />
${inviter.name} has just sent you this invitation to join the <b>${workspace.name}</b> workspace!
</mj-text>
`,
bodyEnd:
'<mj-text>Feel free to ignore this invite if you do not know the person sending it.</mj-text>'
}
const text = {
bodyStart: `Hello!
${inviter.name} has just sent you this invitation to join the "${workspace.name}" workspace!`,
bodyEnd:
'Feel free to ignore this invite if you do not know the person sending it.'
}
return {
emailParams: {
mjml,
text,
cta: {
title: 'Accept the invitation',
url: inviteLink
}
},
subject
}
}
function buildPendingWorkspaceCollaboratorModel(
invite: ServerInviteRecord<WorkspaceInviteResourceTarget>,
targetUser: Nullable<LimitedUserRecord>
): PendingWorkspaceCollaboratorGraphQLReturn {
return {
id: `invite:${invite.id}`,
inviteId: invite.id,
workspaceId: invite.resource.resourceId,
title: resolveInviteTargetTitle(invite, targetUser),
role: invite.resource.role || Roles.Workspace.Member,
invitedById: invite.inviterId,
user: targetUser,
updatedAt: invite.updatedAt
}
}
export const getUserPendingWorkspaceInviteFactory =
(deps: { findInvite: FindInvite; getUser: typeof getUser }) =>
async (params: {
workspaceId: string
userId: MaybeNullOrUndefined<string>
token: MaybeNullOrUndefined<string>
}) => {
const { workspaceId, userId, token } = params
if (!userId && !token) return null
const userTarget = userId ? buildUserTarget(userId) : undefined
const invite = await deps.findInvite<
typeof WorkspaceInviteResourceType,
WorkspaceRoles
>({
target: !token ? userTarget : undefined,
token: token || undefined,
resourceFilter: {
resourceType: WorkspaceInviteResourceType,
resourceId: workspaceId
}
})
if (!invite) return null
const targetUserId = resolveTarget(invite.target).userId
const targetUser = targetUserId ? await deps.getUser(targetUserId) : null
return buildPendingWorkspaceCollaboratorModel(invite, targetUser)
}
export const getUserPendingWorkspaceInvitesFactory =
(deps: {
getUserResourceInvites: QueryAllUserResourceInvites
getUser: typeof getUser
}) =>
async (userId: string): Promise<PendingWorkspaceCollaboratorGraphQLReturn[]> => {
if (!userId) return []
const targetUser = await deps.getUser(userId)
if (!targetUser) {
throw new NoInviteFoundError('Nonexistant user specified')
}
const invites = await deps.getUserResourceInvites<
typeof WorkspaceInviteResourceType,
WorkspaceRoles
>({
userId,
resourceType: WorkspaceInviteResourceType
})
return invites.map((i) => buildPendingWorkspaceCollaboratorModel(i, targetUser))
}
export const getPendingWorkspaceCollaboratorsFactory =
(deps: {
queryAllResourceInvites: QueryAllResourceInvites
getInvitationTargetUsers: GetInvitationTargetUsers
}) =>
async (params: {
workspaceId: string
filter?: MaybeNullOrUndefined<PendingWorkspaceCollaboratorsFilter>
}): Promise<PendingWorkspaceCollaboratorGraphQLReturn[]> => {
const { workspaceId, filter } = params
// Get all pending invites
const invites = await deps.queryAllResourceInvites<
typeof WorkspaceInviteResourceType,
WorkspaceRoles
>({
resourceId: workspaceId,
resourceType: WorkspaceInviteResourceType,
search: filter?.search || undefined
})
// Get all target users, if any
const usersById = await deps.getInvitationTargetUsers({ invites })
// Build results
const results = []
for (const invite of invites) {
let user: LimitedUserRecord | null = null
const { userId } = resolveTarget(invite.target)
if (userId && usersById[userId]) {
user = removePrivateFields(usersById[userId])
}
results.push(buildPendingWorkspaceCollaboratorModel(invite, user))
}
return results
}
export const validateWorkspaceInviteBeforeFinalizationFactory =
(deps: { getWorkspace: GetWorkspace }): ValidateResourceInviteBeforeFinalization =>
async (params) => {
const { invite, finalizerUserId, action, finalizerResourceAccessLimits } = params
if (invite.resource.resourceType !== WorkspaceInviteResourceType) {
throw new InviteFinalizingError(
'Attempting to finalize non-workspace invite as workspace invite',
{ info: { invite, finalizerUserId } }
)
}
const workspace = await deps.getWorkspace({
workspaceId: invite.resource.resourceId,
userId: finalizerUserId
})
if (!workspace) {
throw new InviteFinalizingError(
'Attempting to finalize invite to a non-existant workspace'
)
}
if (action === InviteFinalizationAction.CANCEL) {
if (workspace.role !== Roles.Workspace.Admin) {
throw new InviteFinalizingError(
'Attempting to cancel invite to a workspace that the user does not own'
)
}
} else {
if (workspace.role) {
throw new InviteFinalizingError(
'Attempting to finalize invite to a workspace that the user already has access to'
)
}
}
if (
!isResourceAllowed({
resourceId: workspace.id,
resourceType: TokenResourceIdentifierType.Workspace,
resourceAccessRules: finalizerResourceAccessLimits
})
) {
throw new InviteFinalizingError(
'You are not allowed to process an invite for this workspace'
)
}
}
export const processFinalizedWorkspaceInviteFactory =
(deps: {
getWorkspace: GetWorkspace
updateWorkspaceRole: ReturnType<typeof updateWorkspaceRoleFactory>
}): ProcessFinalizedResourceInvite =>
async (params) => {
const { invite, finalizerUserId, action } = params
if (!isWorkspaceResourceTarget(invite.resource)) {
throw new InviteFinalizingError(
'Attempting to finalize non-workspace invite as workspace invite',
{ info: params }
)
}
const workspace = await deps.getWorkspace({
workspaceId: invite.resource.resourceId,
userId: finalizerUserId
})
if (!workspace) {
throw new InviteFinalizingError(
'Attempting to finalize invite to a non-existant workspace'
)
}
const target = resolveTarget(invite.target)
if (action === InviteFinalizationAction.ACCEPT) {
await deps.updateWorkspaceRole({
userId: target.userId!,
workspaceId: workspace.id,
role: invite.resource.role || Roles.Workspace.Member
})
} else if (action === InviteFinalizationAction.DECLINE) {
// TODO: Emit activityStream event?
}
}