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:
Chuck Driesler 2024-09-17 21:17:10 +01:00 коммит произвёл GitHub
Родитель ad780eb576
Коммит ac6dd70d27
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
26 изменённых файлов: 279 добавлений и 112 удалений

Просмотреть файл

@ -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]