feat(workspaces): default workspace project role (#3013)
* feat(workspaces): enable default project role in workspace * fix(workspaces): not satisfying * chore(workspaces): phrasing * fix(workspaces): use new field in role mapping * chore(workspaces): use roles * fix(workspaces): update tests * fix(workspaces): more parallel role update errors * chore(workspaces): like this now * chore(workspaces): revert changes to deps * fix(workspaces): assert domain type conversion at gql layer * fix(workspaces): repair tests * fix(workspaces): fix more tests
This commit is contained in:
Родитель
ad780eb576
Коммит
ac6dd70d27
|
@ -32,6 +32,7 @@ input WorkspaceUpdateInput {
|
|||
"""
|
||||
logo: String
|
||||
defaultLogoIndex: Int
|
||||
defaultProjectRole: String
|
||||
domainBasedMembershipProtectionEnabled: Boolean
|
||||
discoverabilityEnabled: Boolean
|
||||
}
|
||||
|
@ -259,6 +260,10 @@ type Workspace {
|
|||
"""
|
||||
defaultLogoIndex: Int!
|
||||
"""
|
||||
The default role workspace members will receive for workspace projects.
|
||||
"""
|
||||
defaultProjectRole: String!
|
||||
"""
|
||||
Verified workspace domains
|
||||
"""
|
||||
domains: [WorkspaceDomain!] @hasWorkspaceRole(role: MEMBER)
|
||||
|
|
|
@ -3884,6 +3884,8 @@ export type Workspace = {
|
|||
createdAt: Scalars['DateTime']['output'];
|
||||
/** Selected fallback when `logo` not set */
|
||||
defaultLogoIndex: Scalars['Int']['output'];
|
||||
/** The default role workspace members will receive for workspace projects. */
|
||||
defaultProjectRole: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
/** Enable/Disable discovery of the workspace */
|
||||
discoverabilityEnabled: Scalars['Boolean']['output'];
|
||||
|
@ -4170,6 +4172,8 @@ export type WorkspaceTeamFilter = {
|
|||
|
||||
export type WorkspaceUpdateInput = {
|
||||
defaultLogoIndex?: InputMaybe<Scalars['Int']['input']>;
|
||||
/** stream:reviewer | stream:contributor */
|
||||
defaultProjectRole?: InputMaybe<Scalars['String']['input']>;
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
discoverabilityEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
domainBasedMembershipProtectionEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
|
@ -6102,6 +6106,7 @@ export type WorkspaceResolvers<ContextType = GraphQLContext, ParentType extends
|
|||
billing?: Resolver<Maybe<ResolversTypes['WorkspaceBilling']>, ParentType, ContextType>;
|
||||
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
defaultLogoIndex?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
defaultProjectRole?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
discoverabilityEnabled?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
domainBasedMembershipProtectionEnabled?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
|
|
|
@ -3870,6 +3870,8 @@ export type Workspace = {
|
|||
createdAt: Scalars['DateTime']['output'];
|
||||
/** Selected fallback when `logo` not set */
|
||||
defaultLogoIndex: Scalars['Int']['output'];
|
||||
/** The default role workspace members will receive for workspace projects. */
|
||||
defaultProjectRole: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
/** Enable/Disable discovery of the workspace */
|
||||
discoverabilityEnabled: Scalars['Boolean']['output'];
|
||||
|
@ -4156,6 +4158,8 @@ export type WorkspaceTeamFilter = {
|
|||
|
||||
export type WorkspaceUpdateInput = {
|
||||
defaultLogoIndex?: InputMaybe<Scalars['Int']['input']>;
|
||||
/** stream:reviewer | stream:contributor */
|
||||
defaultProjectRole?: InputMaybe<Scalars['String']['input']>;
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
discoverabilityEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
domainBasedMembershipProtectionEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
|
|
|
@ -14,6 +14,7 @@ const createFakeWorkspace = (): Omit<Workspace, 'domains'> => {
|
|||
name: cryptoRandomString({ length: 10 }),
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
defaultProjectRole: Roles.Stream.Contributor,
|
||||
domainBasedMembershipProtectionEnabled: false,
|
||||
discoverabilityEnabled: false
|
||||
}
|
||||
|
@ -158,8 +159,6 @@ describe('Event Bus', () => {
|
|||
case 'workspace.role-deleted':
|
||||
events.push(payload.userId)
|
||||
break
|
||||
default:
|
||||
events.push('default')
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -184,12 +183,7 @@ describe('Event Bus', () => {
|
|||
payload: workspaceAcl
|
||||
})
|
||||
|
||||
await eventBus.emit({
|
||||
eventName: WorkspaceEvents.RoleUpdated,
|
||||
payload: workspaceAcl
|
||||
})
|
||||
|
||||
expect([workspace.id, workspaceAcl.userId, 'default']).to.deep.equal(events)
|
||||
expect([workspace.id, workspaceAcl.userId]).to.deep.equal(events)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import { UserEmail } from '@/modules/core/domain/userEmails/types'
|
||||
import { GetWorkspaceRoleToDefaultProjectRoleMapping } from '@/modules/workspaces/domain/operations'
|
||||
import { WorkspaceDomainsInvalidState } from '@/modules/workspaces/errors/workspace'
|
||||
import { WorkspaceDomain } from '@/modules/workspacesCore/domain/types'
|
||||
import {
|
||||
WorkspaceDomainsInvalidState,
|
||||
WorkspaceInvalidUpdateError
|
||||
} from '@/modules/workspaces/errors/workspace'
|
||||
import {
|
||||
WorkspaceDefaultProjectRole,
|
||||
WorkspaceDomain
|
||||
} from '@/modules/workspacesCore/domain/types'
|
||||
import { Roles } from '@speckle/shared'
|
||||
|
||||
export const userEmailsCompliantWithWorkspaceDomains = ({
|
||||
|
@ -36,11 +41,21 @@ export const anyEmailCompliantWithWorkspaceDomains = ({
|
|||
}
|
||||
|
||||
/**
|
||||
* Given a user's workspace role, return the initial role they should have for workspace projects.
|
||||
* Given an optional string value, assert it is a valid default project role and return it.
|
||||
*/
|
||||
export const mapWorkspaceRoleToInitialProjectRole: GetWorkspaceRoleToDefaultProjectRoleMapping =
|
||||
async () => ({
|
||||
[Roles.Workspace.Guest]: null,
|
||||
[Roles.Workspace.Member]: Roles.Stream.Reviewer,
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner
|
||||
})
|
||||
export const parseDefaultProjectRole = (
|
||||
role?: string | null
|
||||
): WorkspaceDefaultProjectRole | null => {
|
||||
if (!role) return null
|
||||
|
||||
const isValidRole = (role: string): role is WorkspaceDefaultProjectRole => {
|
||||
const validRoles: string[] = [Roles.Stream.Reviewer, Roles.Stream.Contributor]
|
||||
return validRoles.includes(role)
|
||||
}
|
||||
|
||||
if (!isValidRole(role)) {
|
||||
throw new WorkspaceInvalidUpdateError('Provided default project role is invalid')
|
||||
}
|
||||
|
||||
return role
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
WorkspaceWithOptionalRole
|
||||
} from '@/modules/workspacesCore/domain/types'
|
||||
import { EventBusPayloads } from '@/modules/shared/services/eventBus'
|
||||
import { StreamRoles, WorkspaceRoles } from '@speckle/shared'
|
||||
import { PartialNullable, StreamRoles, WorkspaceRoles } from '@speckle/shared'
|
||||
import { WorkspaceRoleToDefaultProjectRoleMapping } from '@/modules/workspaces/domain/types'
|
||||
import { WorkspaceTeam } from '@/modules/workspaces/domain/types'
|
||||
|
||||
|
@ -187,14 +187,7 @@ export type GetUserIdsWithRoleInWorkspace = (
|
|||
|
||||
type WorkspaceUpdateArgs = {
|
||||
workspaceId: string
|
||||
workspaceInput: {
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
logo?: string | null
|
||||
defaultLogoIndex?: number | null
|
||||
discoverabilityEnabled?: boolean | null
|
||||
domainBasedMembershipProtectionEnabled?: boolean | null
|
||||
}
|
||||
workspaceInput: PartialNullable<Omit<Workspace, 'id' | 'createdAt' | 'updatedAt'>>
|
||||
}
|
||||
|
||||
export type UpdateWorkspace = ({
|
||||
|
|
|
@ -12,9 +12,9 @@ export class WorkspaceAdminRequiredError extends BaseError {
|
|||
static statusCode = 400
|
||||
}
|
||||
|
||||
export class WorkspaceInvalidDescriptionError extends BaseError {
|
||||
static defaultMessage = 'Provided description is too long'
|
||||
static code = 'WORKSPACE_INVALID_DESCRIPTION_ERROR'
|
||||
export class WorkspaceInvalidUpdateError extends BaseError {
|
||||
static defaultMessage = 'Provided workspace update input is invalid or malformed'
|
||||
static code = 'WORKSPACE_INVALID_UPDATE_ERROR'
|
||||
static statusCode = 400
|
||||
}
|
||||
|
||||
|
@ -24,12 +24,6 @@ export class WorkspaceInvalidRoleError extends BaseError {
|
|||
static statusCode = 400
|
||||
}
|
||||
|
||||
export class WorkspaceInvalidLogoError extends BaseError {
|
||||
static defaultMessage = 'Provided logo is not valid'
|
||||
static code = 'WORKSPACE_INVALID_LOGO_ERROR'
|
||||
static statusCode = 400
|
||||
}
|
||||
|
||||
export class WorkspaceInvalidProjectError extends BaseError {
|
||||
static defaultMessage = 'Provided project does not belong to a workspace'
|
||||
static code = 'WORKSPACE_INVALID_PROJECT_ERROR'
|
||||
|
|
|
@ -32,13 +32,16 @@ import {
|
|||
} from '@/modules/core/domain/projects/operations'
|
||||
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
|
||||
import { Knex } from 'knex'
|
||||
import { mapWorkspaceRoleToInitialProjectRole } from '@/modules/workspaces/domain/logic'
|
||||
import {
|
||||
getWorkspaceFactory,
|
||||
getWorkspaceRolesFactory,
|
||||
getWorkspaceWithDomainsFactory,
|
||||
upsertWorkspaceRoleFactory
|
||||
} from '@/modules/workspaces/repositories/workspaces'
|
||||
import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects'
|
||||
import {
|
||||
queryAllWorkspaceProjectsFactory,
|
||||
getWorkspaceRoleToDefaultProjectRoleMappingFactory
|
||||
} from '@/modules/workspaces/services/projects'
|
||||
import { getStreams } from '@/modules/core/services/streams'
|
||||
import { withTransaction } from '@/modules/shared/helpers/dbHelper'
|
||||
import { findVerifiedEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails'
|
||||
|
@ -47,11 +50,11 @@ export const onProjectCreatedFactory =
|
|||
({
|
||||
getWorkspaceRoles,
|
||||
upsertProjectRole,
|
||||
getDefaultWorkspaceProjectRoleMapping
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping
|
||||
}: {
|
||||
getWorkspaceRoles: GetWorkspaceRoles
|
||||
upsertProjectRole: UpsertProjectRole
|
||||
getDefaultWorkspaceProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
|
||||
}) =>
|
||||
async (payload: ProjectEventsPayloads[typeof ProjectEvents.Created]) => {
|
||||
const { id: projectId, workspaceId } = payload.project
|
||||
|
@ -62,7 +65,7 @@ export const onProjectCreatedFactory =
|
|||
|
||||
const workspaceMembers = await getWorkspaceRoles({ workspaceId })
|
||||
|
||||
const defaultRoleMapping = await getDefaultWorkspaceProjectRoleMapping({
|
||||
const defaultRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping({
|
||||
workspaceId
|
||||
})
|
||||
|
||||
|
@ -150,12 +153,12 @@ export const onWorkspaceRoleDeletedFactory =
|
|||
|
||||
export const onWorkspaceRoleUpdatedFactory =
|
||||
({
|
||||
getDefaultWorkspaceProjectRoleMapping,
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping,
|
||||
queryAllWorkspaceProjects,
|
||||
deleteProjectRole,
|
||||
upsertProjectRole
|
||||
}: {
|
||||
getDefaultWorkspaceProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
|
||||
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
|
||||
deleteProjectRole: DeleteProjectRole
|
||||
upsertProjectRole: UpsertProjectRole
|
||||
|
@ -173,9 +176,11 @@ export const onWorkspaceRoleUpdatedFactory =
|
|||
skipProjectRoleUpdatesFor: string[]
|
||||
}
|
||||
}) => {
|
||||
const defaultProjectRoleMapping = await getDefaultWorkspaceProjectRoleMapping({
|
||||
workspaceId
|
||||
})
|
||||
const defaultProjectRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping(
|
||||
{
|
||||
workspaceId
|
||||
}
|
||||
)
|
||||
|
||||
const nextProjectRole = defaultProjectRoleMapping[role]
|
||||
|
||||
|
@ -211,7 +216,10 @@ export const initializeEventListenersFactory =
|
|||
const quitCbs = [
|
||||
ProjectsEmitter.listen(ProjectEvents.Created, async (payload) => {
|
||||
const onProjectCreated = onProjectCreatedFactory({
|
||||
getDefaultWorkspaceProjectRoleMapping: mapWorkspaceRoleToInitialProjectRole,
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping:
|
||||
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
|
||||
getWorkspace: getWorkspaceFactory({ db })
|
||||
}),
|
||||
upsertProjectRole: upsertProjectRoleFactory({ db }),
|
||||
getWorkspaceRoles: getWorkspaceRolesFactory({ db })
|
||||
})
|
||||
|
@ -242,7 +250,10 @@ export const initializeEventListenersFactory =
|
|||
eventBus.listen(WorkspaceEvents.RoleUpdated, async ({ payload }) => {
|
||||
const trx = await db.transaction()
|
||||
const onWorkspaceRoleUpdated = onWorkspaceRoleUpdatedFactory({
|
||||
getDefaultWorkspaceProjectRoleMapping: mapWorkspaceRoleToInitialProjectRole,
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping:
|
||||
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
|
||||
getWorkspace: getWorkspaceFactory({ db: trx })
|
||||
}),
|
||||
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }),
|
||||
deleteProjectRole: deleteProjectRoleFactory({ db: trx }),
|
||||
upsertProjectRole: upsertProjectRoleFactory({ db: trx })
|
||||
|
|
|
@ -125,6 +125,7 @@ import { updateStreamRoleAndNotify } from '@/modules/core/services/streams/manag
|
|||
import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories'
|
||||
import { renderEmail } from '@/modules/emails/services/emailRendering'
|
||||
import { sendEmail } from '@/modules/emails/services/sending'
|
||||
import { parseDefaultProjectRole } from '@/modules/workspaces/domain/logic'
|
||||
|
||||
const requestNewEmailVerification = requestNewEmailVerificationFactory({
|
||||
findEmail: findEmailFactory({ db }),
|
||||
|
@ -331,7 +332,10 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
|||
|
||||
const workspace = await updateWorkspace({
|
||||
workspaceId,
|
||||
workspaceInput
|
||||
workspaceInput: {
|
||||
...workspaceInput,
|
||||
defaultProjectRole: parseDefaultProjectRole(args.input.defaultProjectRole)
|
||||
}
|
||||
})
|
||||
|
||||
return workspace
|
||||
|
|
|
@ -8,6 +8,7 @@ export const Workspaces = buildTableHelper('workspaces', [
|
|||
'updatedAt',
|
||||
'logo',
|
||||
'defaultLogoIndex',
|
||||
'defaultProjectRole',
|
||||
'domainBasedMembershipProtectionEnabled',
|
||||
'discoverabilityEnabled'
|
||||
])
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { WorkspaceInvalidLogoError } from '@/modules/workspaces/errors/workspace'
|
||||
import { WorkspaceInvalidUpdateError } from '@/modules/workspaces/errors/workspace'
|
||||
|
||||
export const validateImageString = (imageString: string): void => {
|
||||
// Validate string is a reasonable size
|
||||
if (new TextEncoder().encode(imageString).length > 1024 * 1024 * 10) {
|
||||
throw new WorkspaceInvalidLogoError('Provided logo must be smaller than 10 MB')
|
||||
throw new WorkspaceInvalidUpdateError('Provided logo must be smaller than 10 MB')
|
||||
}
|
||||
|
||||
// Validate string is base64 image
|
||||
|
@ -11,10 +11,10 @@ export const validateImageString = (imageString: string): void => {
|
|||
const imageData = rest.pop()
|
||||
|
||||
if (!prefix || !prefix.startsWith('data:image') || !imageData) {
|
||||
throw new WorkspaceInvalidLogoError('Provided logo is malformed')
|
||||
throw new WorkspaceInvalidUpdateError('Provided logo is malformed')
|
||||
}
|
||||
|
||||
if (Buffer.from(imageData, 'base64').toString('base64') !== imageData) {
|
||||
throw new WorkspaceInvalidLogoError('Provided logo is malformed')
|
||||
throw new WorkspaceInvalidUpdateError('Provided logo is malformed')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -144,6 +144,7 @@ export const upsertWorkspaceFactory =
|
|||
'description',
|
||||
'logo',
|
||||
'defaultLogoIndex',
|
||||
'defaultProjectRole',
|
||||
'name',
|
||||
'updatedAt',
|
||||
'domainBasedMembershipProtectionEnabled',
|
||||
|
|
|
@ -14,7 +14,8 @@ import {
|
|||
import {
|
||||
Workspace,
|
||||
WorkspaceAcl,
|
||||
WorkspaceDomain
|
||||
WorkspaceDomain,
|
||||
WorkspaceWithDomains
|
||||
} from '@/modules/workspacesCore/domain/types'
|
||||
import { MaybeNullOrUndefined, Roles } from '@speckle/shared'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
|
@ -30,8 +31,8 @@ import {
|
|||
WorkspaceNotFoundError,
|
||||
WorkspaceProtectedError,
|
||||
WorkspaceUnverifiedDomainError,
|
||||
WorkspaceInvalidDescriptionError,
|
||||
WorkspaceNoVerifiedDomainsError
|
||||
WorkspaceNoVerifiedDomainsError,
|
||||
WorkspaceInvalidUpdateError
|
||||
} from '@/modules/workspaces/errors/workspace'
|
||||
import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/helpers/roles'
|
||||
import { EventBus } from '@/modules/shared/services/eventBus'
|
||||
|
@ -95,6 +96,7 @@ export const createWorkspaceFactory =
|
|||
id: cryptoRandomString({ length: 10 }),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
defaultProjectRole: Roles.Stream.Contributor,
|
||||
domainBasedMembershipProtectionEnabled: false,
|
||||
discoverabilityEnabled: false
|
||||
}
|
||||
|
@ -116,6 +118,52 @@ export const createWorkspaceFactory =
|
|||
return { ...workspace }
|
||||
}
|
||||
|
||||
type WorkspaceUpdateInput = Parameters<UpdateWorkspace>[0]['workspaceInput']
|
||||
|
||||
const isValidInput = (input: WorkspaceUpdateInput): input is Partial<Workspace> => {
|
||||
if (!!input.logo) {
|
||||
validateImageString(input.logo)
|
||||
}
|
||||
|
||||
if (!!input.description) {
|
||||
if (input.description.length > 512)
|
||||
throw new WorkspaceInvalidUpdateError('Provided description is too long')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const isValidWorkspace = (
|
||||
input: WorkspaceUpdateInput,
|
||||
workspace: WorkspaceWithDomains
|
||||
): boolean => {
|
||||
const hasVerifiedDomains = workspace.domains.find((domain) => domain.verified)
|
||||
|
||||
if (input.discoverabilityEnabled && !workspace.discoverabilityEnabled) {
|
||||
if (!hasVerifiedDomains) throw new WorkspaceNoVerifiedDomainsError()
|
||||
}
|
||||
|
||||
if (
|
||||
input.domainBasedMembershipProtectionEnabled &&
|
||||
!workspace.domainBasedMembershipProtectionEnabled
|
||||
) {
|
||||
if (!hasVerifiedDomains) throw new WorkspaceNoVerifiedDomainsError()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const sanitizeInput = (input: Partial<Workspace>) => {
|
||||
const sanitizedInput = structuredClone(input)
|
||||
|
||||
if (isEmpty(sanitizedInput.name)) {
|
||||
// Do not allow setting an empty name (empty descriptions allowed)
|
||||
delete sanitizedInput.name
|
||||
}
|
||||
|
||||
return removeNullOrUndefinedKeys(sanitizedInput)
|
||||
}
|
||||
|
||||
export const updateWorkspaceFactory =
|
||||
({
|
||||
getWorkspace,
|
||||
|
@ -134,34 +182,16 @@ export const updateWorkspaceFactory =
|
|||
}
|
||||
|
||||
// Validate incoming changes
|
||||
if (!!workspaceInput.logo) {
|
||||
validateImageString(workspaceInput.logo)
|
||||
}
|
||||
if (isEmpty(workspaceInput.name)) {
|
||||
// Do not allow setting an empty name (empty descriptions allowed)
|
||||
delete workspaceInput.name
|
||||
}
|
||||
if (!!workspaceInput.description && workspaceInput.description.length > 512) {
|
||||
throw new WorkspaceInvalidDescriptionError()
|
||||
}
|
||||
|
||||
if (
|
||||
workspaceInput.discoverabilityEnabled &&
|
||||
!currentWorkspace.discoverabilityEnabled &&
|
||||
!currentWorkspace.domains.find((domain) => domain.verified)
|
||||
)
|
||||
throw new WorkspaceNoVerifiedDomainsError()
|
||||
|
||||
if (
|
||||
workspaceInput.domainBasedMembershipProtectionEnabled &&
|
||||
!currentWorkspace.domainBasedMembershipProtectionEnabled &&
|
||||
!currentWorkspace.domains.find((domain) => domain.verified)
|
||||
)
|
||||
throw new WorkspaceNoVerifiedDomainsError()
|
||||
!isValidInput(workspaceInput) ||
|
||||
!isValidWorkspace(workspaceInput, currentWorkspace)
|
||||
) {
|
||||
throw new WorkspaceInvalidUpdateError()
|
||||
}
|
||||
|
||||
const workspace = {
|
||||
...omit(currentWorkspace, 'domains'),
|
||||
...removeNullOrUndefinedKeys(workspaceInput),
|
||||
...sanitizeInput(workspaceInput),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,16 @@
|
|||
import { StreamRecord } from '@/modules/core/helpers/types'
|
||||
import { getStreams as serviceGetStreams } from '@/modules/core/services/streams'
|
||||
import { getUserStreams } from '@/modules/core/repositories/streams'
|
||||
import { QueryAllWorkspaceProjects } from '@/modules/workspaces/domain/operations'
|
||||
import { WorkspaceQueryError } from '@/modules/workspaces/errors/workspace'
|
||||
import {
|
||||
GetWorkspace,
|
||||
GetWorkspaceRoleToDefaultProjectRoleMapping,
|
||||
QueryAllWorkspaceProjects
|
||||
} from '@/modules/workspaces/domain/operations'
|
||||
import {
|
||||
WorkspaceNotFoundError,
|
||||
WorkspaceQueryError
|
||||
} from '@/modules/workspaces/errors/workspace'
|
||||
import { Roles } from '@speckle/shared'
|
||||
|
||||
export const queryAllWorkspaceProjectsFactory = ({
|
||||
getStreams
|
||||
|
@ -73,3 +81,23 @@ export const getWorkspaceProjectsFactory =
|
|||
cursor
|
||||
}
|
||||
}
|
||||
|
||||
export const getWorkspaceRoleToDefaultProjectRoleMappingFactory =
|
||||
({
|
||||
getWorkspace
|
||||
}: {
|
||||
getWorkspace: GetWorkspace
|
||||
}): GetWorkspaceRoleToDefaultProjectRoleMapping =>
|
||||
async ({ workspaceId }) => {
|
||||
const workspace = await getWorkspace({ workspaceId })
|
||||
|
||||
if (!workspace) {
|
||||
throw new WorkspaceNotFoundError()
|
||||
}
|
||||
|
||||
return {
|
||||
[Roles.Workspace.Guest]: null,
|
||||
[Roles.Workspace.Member]: workspace.defaultProjectRole,
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from '@/modules/serverinvites/repositories/serverInvites'
|
||||
import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation'
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { parseDefaultProjectRole } from '@/modules/workspaces/domain/logic'
|
||||
import {
|
||||
getWorkspaceRolesFactory,
|
||||
upsertWorkspaceFactory,
|
||||
|
@ -34,7 +35,12 @@ import {
|
|||
} from '@/modules/workspaces/services/management'
|
||||
import { BasicTestUser } from '@/test/authHelper'
|
||||
import { CreateWorkspaceInviteMutationVariables } from '@/test/graphql/generated/graphql'
|
||||
import { MaybeNullOrUndefined, Roles, WorkspaceRoles } from '@speckle/shared'
|
||||
import {
|
||||
MaybeNullOrUndefined,
|
||||
Roles,
|
||||
StreamRoles,
|
||||
WorkspaceRoles
|
||||
} from '@speckle/shared'
|
||||
|
||||
export type BasicTestWorkspace = {
|
||||
/**
|
||||
|
@ -48,6 +54,7 @@ export type BasicTestWorkspace = {
|
|||
name: string
|
||||
description?: string
|
||||
logo?: string
|
||||
defaultProjectRole?: StreamRoles
|
||||
discoverabilityEnabled?: boolean
|
||||
domainBasedMembershipProtectionEnabled?: boolean
|
||||
}
|
||||
|
@ -75,6 +82,8 @@ export const createTestWorkspace = async (
|
|||
})
|
||||
|
||||
workspace.id = newWorkspace.id
|
||||
workspace.ownerId = owner.id
|
||||
|
||||
if (domain) {
|
||||
await addDomainToWorkspaceFactory({
|
||||
findEmailsByUserId: findEmailsByUserIdFactory({ db }),
|
||||
|
@ -90,14 +99,14 @@ export const createTestWorkspace = async (
|
|||
})
|
||||
}
|
||||
|
||||
const updateWorkspace = updateWorkspaceFactory({
|
||||
getWorkspace: getWorkspaceWithDomainsFactory({ db }),
|
||||
upsertWorkspace: upsertWorkspaceFactory({ db }),
|
||||
emitWorkspaceEvent: getEventBus().emit
|
||||
})
|
||||
|
||||
if (workspace.discoverabilityEnabled) {
|
||||
if (!domain) throw new Error('Domain is needed for discoverability')
|
||||
const updateWorkspace = updateWorkspaceFactory({
|
||||
getWorkspace: getWorkspaceWithDomainsFactory({ db }),
|
||||
upsertWorkspace: upsertWorkspaceFactory({ db }),
|
||||
emitWorkspaceEvent: (...args) => getEventBus().emit(...args)
|
||||
})
|
||||
|
||||
await updateWorkspace({
|
||||
workspaceId: newWorkspace.id,
|
||||
workspaceInput: {
|
||||
|
@ -108,17 +117,20 @@ export const createTestWorkspace = async (
|
|||
|
||||
if (workspace.domainBasedMembershipProtectionEnabled) {
|
||||
if (!domain) throw new Error('Domain is needed for membership protection')
|
||||
await updateWorkspaceFactory({
|
||||
getWorkspace: getWorkspaceWithDomainsFactory({ db }),
|
||||
upsertWorkspace: upsertWorkspaceFactory({ db }),
|
||||
emitWorkspaceEvent: getEventBus().emit
|
||||
})({
|
||||
await updateWorkspace({
|
||||
workspaceId: newWorkspace.id,
|
||||
workspaceInput: { domainBasedMembershipProtectionEnabled: true }
|
||||
})
|
||||
}
|
||||
|
||||
workspace.ownerId = owner.id
|
||||
if (workspace.defaultProjectRole) {
|
||||
await updateWorkspace({
|
||||
workspaceId: newWorkspace.id,
|
||||
workspaceInput: {
|
||||
defaultProjectRole: parseDefaultProjectRole(workspace.defaultProjectRole)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const assignToWorkspace = async (
|
||||
|
|
|
@ -85,6 +85,7 @@ const createAndStoreTestWorkspace = async (
|
|||
domainBasedMembershipProtectionEnabled: false,
|
||||
discoverabilityEnabled: false,
|
||||
defaultLogoIndex: 0,
|
||||
defaultProjectRole: Roles.Stream.Contributor,
|
||||
...workspaceOverrides
|
||||
}
|
||||
|
||||
|
|
|
@ -187,7 +187,8 @@ describe('Workspaces Roles GQL', () => {
|
|||
const workspace: BasicTestWorkspace = {
|
||||
id: '',
|
||||
ownerId: '',
|
||||
name: 'Test Workspace'
|
||||
name: 'Test Workspace',
|
||||
defaultProjectRole: Roles.Stream.Reviewer
|
||||
}
|
||||
|
||||
const workspaceAdminUser: BasicTestUser = {
|
||||
|
|
|
@ -476,7 +476,7 @@ describe('Workspaces GQL CRUD', () => {
|
|||
}
|
||||
},
|
||||
{
|
||||
role: Roles.Stream.Reviewer,
|
||||
role: Roles.Stream.Contributor,
|
||||
project: {
|
||||
id: project2Id,
|
||||
name: project2Name
|
||||
|
@ -576,12 +576,14 @@ describe('Workspaces GQL CRUD', () => {
|
|||
createTestUser(viewer2)
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
assignToWorkspace(workspace, member, Roles.Workspace.Member),
|
||||
assignToWorkspace(workspace, guestWithWritePermission, Roles.Workspace.Guest),
|
||||
assignToWorkspace(workspace, viewer, Roles.Workspace.Guest),
|
||||
assignToWorkspace(workspace, viewer2, Roles.Workspace.Guest)
|
||||
])
|
||||
await assignToWorkspace(workspace, member, Roles.Workspace.Member)
|
||||
await assignToWorkspace(
|
||||
workspace,
|
||||
guestWithWritePermission,
|
||||
Roles.Workspace.Guest
|
||||
)
|
||||
await assignToWorkspace(workspace, viewer, Roles.Workspace.Guest)
|
||||
await assignToWorkspace(workspace, viewer2, Roles.Workspace.Guest)
|
||||
|
||||
const resProject1 = await apollo.execute(CreateProjectDocument, {
|
||||
input: {
|
||||
|
@ -894,6 +896,31 @@ describe('Workspaces GQL CRUD', () => {
|
|||
|
||||
expect(updateRes).to.haveGraphQLErrors('too long')
|
||||
})
|
||||
|
||||
it('should require default project role to be a valid role', async () => {
|
||||
const resA = await apollo.execute(UpdateWorkspaceDocument, {
|
||||
input: {
|
||||
id: workspace.id,
|
||||
defaultProjectRole: 'stream:contributor'
|
||||
}
|
||||
})
|
||||
const resB = await apollo.execute(UpdateWorkspaceDocument, {
|
||||
input: {
|
||||
id: workspace.id,
|
||||
defaultProjectRole: 'stream:reviewer'
|
||||
}
|
||||
})
|
||||
const resC = await apollo.execute(UpdateWorkspaceDocument, {
|
||||
input: {
|
||||
id: workspace.id,
|
||||
defaultProjectRole: 'stream:collaborator'
|
||||
}
|
||||
})
|
||||
|
||||
expect(resA).to.not.haveGraphQLErrors()
|
||||
expect(resB).to.not.haveGraphQLErrors()
|
||||
expect(resC).to.haveGraphQLErrors('Provided default project role is invalid')
|
||||
})
|
||||
})
|
||||
describe('mutation activeUserMutations.userWorkspaceMutations', () => {
|
||||
describe('leave', () => {
|
||||
|
|
|
@ -7,7 +7,6 @@ import {
|
|||
onWorkspaceRoleUpdatedFactory
|
||||
} from '@/modules/workspaces/events/eventListener'
|
||||
import { expect } from 'chai'
|
||||
import { mapWorkspaceRoleToInitialProjectRole } from '@/modules/workspaces/domain/logic'
|
||||
import { chunk } from 'lodash'
|
||||
|
||||
describe('Event handlers', () => {
|
||||
|
@ -41,7 +40,11 @@ describe('Event handlers', () => {
|
|||
|
||||
const onProjectCreated = onProjectCreatedFactory({
|
||||
getWorkspaceRoles: async () => workspaceRoles,
|
||||
getDefaultWorkspaceProjectRoleMapping: mapWorkspaceRoleToInitialProjectRole,
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner,
|
||||
[Roles.Workspace.Member]: Roles.Stream.Contributor,
|
||||
[Roles.Workspace.Guest]: null
|
||||
}),
|
||||
upsertProjectRole: async ({ projectId, userId, role }) => {
|
||||
projectRoles.push({
|
||||
resourceId: projectId,
|
||||
|
@ -66,7 +69,7 @@ describe('Event handlers', () => {
|
|||
let isDeleteCalled = false
|
||||
|
||||
await onWorkspaceRoleUpdatedFactory({
|
||||
getDefaultWorkspaceProjectRoleMapping: async () => ({
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner,
|
||||
[Roles.Workspace.Member]: Roles.Stream.Contributor,
|
||||
[Roles.Workspace.Guest]: null
|
||||
|
@ -101,7 +104,7 @@ describe('Event handlers', () => {
|
|||
|
||||
const storedRoles: { userId: string; role: StreamRoles; projectId: string }[] = []
|
||||
await onWorkspaceRoleUpdatedFactory({
|
||||
getDefaultWorkspaceProjectRoleMapping: async () => ({
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner,
|
||||
[Roles.Workspace.Member]: projectRole,
|
||||
[Roles.Workspace.Guest]: null
|
||||
|
|
|
@ -29,6 +29,7 @@ describe('workspace domain services', () => {
|
|||
description: '',
|
||||
discoverabilityEnabled: false,
|
||||
domainBasedMembershipProtectionEnabled: false,
|
||||
defaultProjectRole: 'stream:contributor',
|
||||
domains: [],
|
||||
id: cryptoRandomString({ length: 10 })
|
||||
}),
|
||||
|
@ -51,6 +52,7 @@ describe('workspace domain services', () => {
|
|||
description: '',
|
||||
discoverabilityEnabled: false,
|
||||
domainBasedMembershipProtectionEnabled: true,
|
||||
defaultProjectRole: 'stream:contributor',
|
||||
domains: [
|
||||
{
|
||||
createdAt: new Date(),
|
||||
|
|
|
@ -30,6 +30,7 @@ const createTestWorkspaceWithDomains = (
|
|||
domains: [],
|
||||
discoverabilityEnabled: false,
|
||||
domainBasedMembershipProtectionEnabled: false,
|
||||
defaultProjectRole: Roles.Stream.Contributor,
|
||||
defaultLogoIndex: 0
|
||||
}
|
||||
if (arg) assign(workspace, arg)
|
||||
|
|
|
@ -34,7 +34,6 @@ import { merge, omit } from 'lodash'
|
|||
import { GetWorkspaceWithDomains } from '@/modules/workspaces/domain/operations'
|
||||
import { FindVerifiedEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
|
||||
import { EventNames } from '@/modules/shared/services/eventBus'
|
||||
import { mapWorkspaceRoleToInitialProjectRole } from '@/modules/workspaces/domain/logic'
|
||||
|
||||
type WorkspaceTestContext = {
|
||||
storedWorkspaces: Omit<Workspace, 'domains'>[]
|
||||
|
@ -159,6 +158,7 @@ describe('Workspace services', () => {
|
|||
defaultLogoIndex: 0,
|
||||
discoverabilityEnabled: false,
|
||||
domainBasedMembershipProtectionEnabled: false,
|
||||
defaultProjectRole: 'stream:contributor',
|
||||
domains: []
|
||||
}
|
||||
return merge(workspace, input)
|
||||
|
@ -451,9 +451,12 @@ const buildUpdateWorkspaceRoleAndTestContext = (
|
|||
case 'workspace.role-updated': {
|
||||
const workspaceRole =
|
||||
payload as WorkspaceEventsPayloads['workspace.role-updated']
|
||||
const mapping = await mapWorkspaceRoleToInitialProjectRole({
|
||||
workspaceId: workspaceRole.workspaceId
|
||||
})
|
||||
const mapping = {
|
||||
[Roles.Workspace.Guest]: null,
|
||||
[Roles.Workspace.Member]:
|
||||
context.workspace.defaultProjectRole ?? Roles.Stream.Contributor,
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner
|
||||
}
|
||||
|
||||
for (const project of context.workspaceProjects) {
|
||||
const projectRole = mapping[workspaceRole.role]
|
||||
|
@ -935,6 +938,7 @@ describe('Workspace role services', () => {
|
|||
description: null,
|
||||
discoverabilityEnabled: false,
|
||||
domainBasedMembershipProtectionEnabled: false,
|
||||
defaultProjectRole: 'stream:contributor',
|
||||
defaultLogoIndex: 0
|
||||
}
|
||||
},
|
||||
|
@ -977,6 +981,7 @@ describe('Workspace role services', () => {
|
|||
description: null,
|
||||
discoverabilityEnabled: false,
|
||||
domainBasedMembershipProtectionEnabled: false,
|
||||
defaultProjectRole: 'stream:contributor',
|
||||
defaultLogoIndex: 0
|
||||
}
|
||||
|
||||
|
@ -1031,6 +1036,7 @@ describe('Workspace role services', () => {
|
|||
description: null,
|
||||
discoverabilityEnabled: false,
|
||||
domainBasedMembershipProtectionEnabled: false,
|
||||
defaultProjectRole: 'stream:contributor',
|
||||
defaultLogoIndex: 0
|
||||
}
|
||||
|
||||
|
@ -1092,6 +1098,7 @@ describe('Workspace role services', () => {
|
|||
discoverabilityEnabled: false,
|
||||
domainBasedMembershipProtectionEnabled: false,
|
||||
domains: [],
|
||||
defaultProjectRole: Roles.Stream.Contributor,
|
||||
defaultLogoIndex: 0
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { InviteResourceTarget } from '@/modules/serverinvites/domain/types'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
|
||||
import { WorkspaceRoles } from '@speckle/shared'
|
||||
import { StreamRoles, WorkspaceRoles } from '@speckle/shared'
|
||||
|
||||
declare module '@/modules/serverinvites/domain/types' {
|
||||
interface InviteResourceTargetTypeMap {
|
||||
|
@ -27,11 +27,15 @@ export type Workspace = {
|
|||
updatedAt: Date
|
||||
logo: string | null
|
||||
defaultLogoIndex: number
|
||||
defaultProjectRole: WorkspaceDefaultProjectRole
|
||||
domainBasedMembershipProtectionEnabled: boolean
|
||||
discoverabilityEnabled: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceWithDomains = Workspace & { domains: WorkspaceDomain[] }
|
||||
|
||||
export type WorkspaceDefaultProjectRole = Exclude<StreamRoles, 'stream:owner'>
|
||||
|
||||
export type WorkspaceDomain = {
|
||||
id: string
|
||||
workspaceId: string
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import { Knex } from 'knex'
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('workspaces', (table) => {
|
||||
table
|
||||
.enum('defaultProjectRole', ['stream:reviewer', 'stream:contributor'])
|
||||
.notNullable()
|
||||
.defaultTo('stream:contributor')
|
||||
})
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('workspaces', (table) => {
|
||||
table.dropColumn('defaultProjectRole')
|
||||
})
|
||||
}
|
|
@ -3871,6 +3871,8 @@ export type Workspace = {
|
|||
createdAt: Scalars['DateTime']['output'];
|
||||
/** Selected fallback when `logo` not set */
|
||||
defaultLogoIndex: Scalars['Int']['output'];
|
||||
/** The default role workspace members will receive for workspace projects. */
|
||||
defaultProjectRole: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
/** Enable/Disable discovery of the workspace */
|
||||
discoverabilityEnabled: Scalars['Boolean']['output'];
|
||||
|
@ -4157,6 +4159,8 @@ export type WorkspaceTeamFilter = {
|
|||
|
||||
export type WorkspaceUpdateInput = {
|
||||
defaultLogoIndex?: InputMaybe<Scalars['Int']['input']>;
|
||||
/** stream:reviewer | stream:contributor */
|
||||
defaultProjectRole?: InputMaybe<Scalars['String']['input']>;
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
discoverabilityEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
domainBasedMembershipProtectionEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
|
|
|
@ -12,6 +12,10 @@ export type MaybeFalsy<T> = T | null | undefined | false | '' | 0
|
|||
export const isUndefinedOrVoid = (val: unknown): val is void | undefined =>
|
||||
isUndefined(val)
|
||||
|
||||
export type PartialNullable<T> = {
|
||||
[K in keyof T]?: T[K] | null
|
||||
}
|
||||
|
||||
type NullableKeys<T> = {
|
||||
[K in keyof T]: T[K] extends NonNullable<T[K]> ? never : K
|
||||
}[keyof T]
|
||||
|
|
Загрузка…
Ссылка в новой задаче