08e941f8af
* 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>
474 lines
15 KiB
TypeScript
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?
|
|
}
|
|
}
|