Files
speckle-server/packages/server/modules/workspaces/services/invites.ts
T
Gergő Jedlicska 08e941f8af Poor man's SSO (#2641)
* Implemented workspace general page

* Added notifications to user input

* Allowed non-admins to view but not edit

* Added redirect to homeroute

* Fixed validation

* Squashed commit of the following:

commit 7bf14ab8af0f76b4c9d0aa87fc08085af7c34959
Author: Chuck Driesler <chuck@speckle.systems>
Date:   Tue Aug 6 19:40:50 2024 +0200

    mob next [ci-skip] [ci skip] [skip ci]

    lastFile:packages/server/modules/workspacesCore/migrations/20240806160740_workspace_domains.ts

commit 8aa3fb0cb052c10eeeb83bf9874ae0d1c065e480
Author: Alessandro Magionami <alessandro.magionami@gmail.com>
Date:   Tue Aug 6 18:54:15 2024 +0200

    mob next [ci-skip] [ci skip] [skip ci]

    lastFile:packages/server/modules/core/domain/userEmails/operations.ts

commit 66dfd0cf6c15a789c8f96a65a3168323e83a7b9e
Author: Chuck Driesler <chuck@speckle.systems>
Date:   Tue Aug 6 18:30:22 2024 +0200

    mob next [ci-skip] [ci skip] [skip ci]

    lastFile:packages/server/modules/workspacesCore/domain/types.ts

Co-authored-by: Alessandro Magionami <alessandro.magionami@gmail.com>

* Move General to workspaces folder

* feat(workspaces): inputs on security section

* feat(workspaces): add domain to workspace mutation

* chore(workspaces): add blocked domains list

* fix(workspaces): modals with buttons

* feat(workspaceDomains): delete domain

* fix(workspaces): use  mutation

* fix(workspaces): present user verified domains as options

* Moved sidebar menu to a composable

* Added coming soon tag back

* feat(workspaces): create domains resolver for workspace

* chore(workspaces): fix tests

* chore(workspaces): fix types

* chore(workspaces): fix linter

* fix(workspaces): do some delete I think

* chore(workspaces): add domainBasedMembershipProtectionEnabled field to workspace

* chore(workspaces): improve validation for email domain

* fix(workspace): query and do the thing

* chore(workspaces): add graphql schema for domainBasedMembershipProtection

* chore(workspaces): lint and test failures

* fix(workspaces): test issues w new field

* feat(workspaces): add discoverability flag

* chore(workspaces): they made me do it

* feat(workspaces): enable toggling domain protection

* feat(workspaces): add discoverability toggle to workspace settings

* feat(workspace): auto enable discoverability on first domain registration

* feat(workspace): discoverability toggle fixes

* fix(eventBus): fix tests

* feat(workspaces): user discoverable workspaces (#2620)

* feat(workspaces): it works just trust me

* fix(workspaces): don't worry about it

* fix(workspaces); happy path success

* fix(workspaces): almost there

* fix(workspaces): successful tests!

* fix(workspaces): we have DISCOVERED (#2621)

* Fixed linting issue

* Updated query

* Updated validation rules

* Updated validation rules

* Fix unsaved file with type export

* Addressed PR comments

* Updated cache

* Updated item classes, add fragment back

* Gergo/web 1574 join workspaces via discovery (#2623)

* chore(useremails): add find verified emails by user function

* chore(workspace): table helper for workspace domains

* chore(workspace): get workspace with domains function

* chore(workspace): test get workspace with domains function

* feat(workspace): restrict workspace membership when updating workspace role

* chore(workspaces): fix types

* feat(workspaces): WIP join

* feat(workspaces): join button makes u join

* chore(useremails): fix type for find verified emails function

* feat(workspaces): join

* feat(workspace): prevent inviting user without email matching domain

* chore(workspaces): fix linter

* fix(workspaces): invoke join (gergo wrote this)

* fuck

* fix(workspaces): properly get discoverable workspaces

* fix(workspaces): test

---------

Co-authored-by: Gergő Jedlicska <gergo@jedlicska.com>
Co-authored-by: Chuck Driesler <chuck@speckle.systems>

* fix(workspaces): some query stuff

* fix(workspaces): mutate cache instead of refetch

* fix(workspaces): more adjustments to gql query and fragment structure

* fix(workspaces): queries, style, structure

* fix(workspaces): match discoverability with current styles

* chore(workspaces): lint lint lint

* fix(workspaces): got it twisted

* chore(workspaces): fix test

* fix(workspaces): route to joined workspace on join

---------

Co-authored-by: Mike Tasset <mike.tasset@gmail.com>
Co-authored-by: Chuck Driesler <chuck@speckle.systems>
Co-authored-by: Alessandro Magionami <alessandro.magionami@gmail.com>
2024-08-26 13:33:16 +02:00

474 lines
15 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 { UserRecord } 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,
GetWorkspaceDomains
} 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'
import { WorkspaceProtectedError } from '@/modules/workspaces/errors/workspace'
import { FindVerifiedEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
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
getWorkspaceDomains: GetWorkspaceDomains
findVerifiedEmailsByUserId: FindVerifiedEmailsByUserId
}
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'
)
}
if (role !== Roles.Workspace.Guest && targetUser) {
const domains = await deps.getWorkspaceDomains({ workspaceIds: [resourceId] })
const verifiedDomains = domains.filter((domain) => domain?.verified)
if (
workspace &&
verifiedDomains &&
workspace?.domainBasedMembershipProtectionEnabled &&
verifiedDomains.length > 0
) {
const domains = new Set<string>(verifiedDomains.map((vd) => vd.domain))
const verifiedUserEmails = await deps.findVerifiedEmailsByUserId({
userId: targetUser.id
})
const domainMatching = verifiedUserEmails.find((userEmail) =>
domains.has(userEmail.email.split('@')[1])
)
if (!domainMatching) {
throw new WorkspaceProtectedError()
}
}
}
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<UserRecord>,
token?: string
): PendingWorkspaceCollaboratorGraphQLReturn {
const { userEmail } = resolveTarget(invite.target)
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 ? removePrivateFields(targetUser) : null,
updatedAt: invite.updatedAt,
email: targetUser?.email || userEmail || '',
token
}
}
export const getUserPendingWorkspaceInviteFactory =
(deps: { findInvite: FindInvite; getUser: typeof getUser }) =>
async (params: {
workspaceId: MaybeNullOrUndefined<string>
userId: MaybeNullOrUndefined<string>
token: MaybeNullOrUndefined<string>
}) => {
const { workspaceId, userId, token } = params
if (!userId?.length && !token?.length) return null
if (!token?.length && !workspaceId?.length) return null
// TODO: Test w/o token & workspace, or w/ just token
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 || undefined
}
})
if (!invite) return null
const targetUserId = resolveTarget(invite.target).userId
const targetUser = targetUserId ? await deps.getUser(targetUserId) : null
return buildPendingWorkspaceCollaboratorModel(
invite,
targetUser,
token || undefined
)
}
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: UserRecord | null = null
const { userId } = resolveTarget(invite.target)
if (userId && usersById[userId]) {
user = 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'
)
}
if (action === InviteFinalizationAction.ACCEPT) {
await deps.updateWorkspaceRole({
userId: finalizerUserId,
workspaceId: workspace.id,
role: invite.resource.role || Roles.Workspace.Member
})
} else if (action === InviteFinalizationAction.DECLINE) {
// TODO: Emit activityStream event?
}
}