feat(server): support editor -> viewer seat downgrades (#4181)
* new seat based project role checks implemented * everything done * minor bugfix
This commit is contained in:
committed by
GitHub
parent
50fd05afe8
commit
d903e8ffc4
@@ -34,7 +34,15 @@ export type LegacyGetStreams = (params: {
|
||||
workspaceIdWhitelist?: string[] | null | undefined
|
||||
offset?: MaybeNullOrUndefined<number>
|
||||
publicOnly?: MaybeNullOrUndefined<boolean>
|
||||
}) => Promise<{ streams: Stream[]; totalCount: number; cursorDate: Nullable<Date> }>
|
||||
/**
|
||||
* For filling in stream.role for the specified user
|
||||
*/
|
||||
userId?: string
|
||||
}) => Promise<{
|
||||
streams: StreamWithOptionalRole[]
|
||||
totalCount: number
|
||||
cursorDate: Nullable<Date>
|
||||
}>
|
||||
|
||||
export type GetStreams = (
|
||||
streamIds: string[],
|
||||
@@ -76,6 +84,17 @@ export type GetStreamsCollaborators = (params: { streamIds: string[] }) => Promi
|
||||
[streamId: string]: Array<LimitedUserWithStreamRole>
|
||||
}>
|
||||
|
||||
export type GetStreamsCollaboratorCounts = (params: {
|
||||
streamIds: string[]
|
||||
type?: StreamRoles
|
||||
}) => Promise<{
|
||||
[streamId: string]:
|
||||
| {
|
||||
[role in StreamRoles]?: number
|
||||
}
|
||||
| undefined
|
||||
}>
|
||||
|
||||
export type GetUserDeletableStreams = (userId: string) => Promise<Array<string>>
|
||||
|
||||
export type StoreStream = (
|
||||
@@ -234,10 +253,18 @@ export type GetFavoritedStreamsCount = (
|
||||
streamIdWhitelist?: Optional<string[]>
|
||||
) => Promise<number>
|
||||
|
||||
export type RevokeStreamPermissions = (params: {
|
||||
streamId: string
|
||||
userId: string
|
||||
}) => Promise<Optional<Stream>>
|
||||
export type RevokeStreamPermissions = (
|
||||
params: {
|
||||
streamId: string
|
||||
userId: string
|
||||
},
|
||||
options?: Partial<{
|
||||
/**
|
||||
* Whether to mark project record as updated
|
||||
*/
|
||||
trackProjectUpdate: boolean
|
||||
}>
|
||||
) => Promise<Optional<Stream>>
|
||||
|
||||
export type GrantStreamPermissions = (
|
||||
params: {
|
||||
@@ -311,6 +338,14 @@ export type AddOrUpdateStreamCollaborator = (
|
||||
adderResourceAccessRules?: MaybeNullOrUndefined<TokenResourceIdentifier[]>,
|
||||
options?: Partial<{
|
||||
fromInvite: ServerInviteRecord
|
||||
/**
|
||||
* Whether to mark project record as updated
|
||||
*/
|
||||
trackProjectUpdate: boolean
|
||||
/**
|
||||
* Whether to skipp checking if setByUserId has access to the stream
|
||||
*/
|
||||
skipAuthorization: boolean
|
||||
}>
|
||||
) => Promise<Stream>
|
||||
|
||||
@@ -318,7 +353,40 @@ export type RemoveStreamCollaborator = (
|
||||
streamId: string,
|
||||
userId: string,
|
||||
removedById: string,
|
||||
removerResourceAccessRules?: MaybeNullOrUndefined<TokenResourceIdentifier[]>
|
||||
removerResourceAccessRules?: MaybeNullOrUndefined<TokenResourceIdentifier[]>,
|
||||
options?: Partial<{
|
||||
/**
|
||||
* Whether to mark project record as updated
|
||||
*/
|
||||
trackProjectUpdate: boolean
|
||||
/**
|
||||
* Whether to skipp checking if setByUserId has access to the stream
|
||||
*/
|
||||
skipAuthorization: boolean
|
||||
}>
|
||||
) => Promise<Stream>
|
||||
|
||||
export type SetStreamCollaborator = (
|
||||
params: {
|
||||
streamId: string
|
||||
userId: string
|
||||
/**
|
||||
* Null/undefined means - remove collaborator
|
||||
*/
|
||||
role: MaybeNullOrUndefined<StreamRoles>
|
||||
setByUserId: string
|
||||
setterResourceAccessRules?: MaybeNullOrUndefined<TokenResourceIdentifier[]>
|
||||
},
|
||||
options?: Partial<{
|
||||
/**
|
||||
* Whether to mark project record as updated
|
||||
*/
|
||||
trackProjectUpdate: boolean
|
||||
/**
|
||||
* Whether to skipp checking if setByUserId has access to the stream
|
||||
*/
|
||||
skipAuthorization: boolean
|
||||
}>
|
||||
) => Promise<Stream>
|
||||
|
||||
export type CloneStream = (userId: string, sourceStreamId: string) => Promise<Stream>
|
||||
|
||||
@@ -96,7 +96,8 @@ import {
|
||||
MarkCommitStreamUpdated,
|
||||
MarkOnboardingBaseStream,
|
||||
GetUserDeletableStreams,
|
||||
GetStreamsCollaborators
|
||||
GetStreamsCollaborators,
|
||||
GetStreamsCollaboratorCounts
|
||||
} from '@/modules/core/domain/streams/operations'
|
||||
import { generateProjectName } from '@/modules/core/domain/projects/logic'
|
||||
export type { StreamWithOptionalRole, StreamWithCommitId }
|
||||
@@ -585,6 +586,33 @@ export const getDiscoverableStreamsPageFactory =
|
||||
return await q
|
||||
}
|
||||
|
||||
export const getStreamsCollaboratorCountsFactory =
|
||||
(deps: { db: Knex }): GetStreamsCollaboratorCounts =>
|
||||
async ({ streamIds, type }) => {
|
||||
if (!streamIds.length) return {}
|
||||
|
||||
const q = tables
|
||||
.streamAcl(deps.db)
|
||||
.whereIn(StreamAcl.col.resourceId, streamIds)
|
||||
.groupBy(StreamAcl.col.resourceId, StreamAcl.col.role)
|
||||
.select<Array<{ streamId: string; role: StreamRoles; count: string }>>([
|
||||
StreamAcl.colAs('resourceId', 'streamId'),
|
||||
StreamAcl.col.role,
|
||||
knex.raw('COUNT(*) as count')
|
||||
])
|
||||
|
||||
if (type) {
|
||||
q.andWhere(StreamAcl.col.role, type)
|
||||
}
|
||||
|
||||
const res = await q
|
||||
return res.reduce((acc, { streamId, role, count }) => {
|
||||
acc[streamId] = acc[streamId] || {}
|
||||
acc[streamId][role] = parseInt(count)
|
||||
return acc
|
||||
}, {} as Awaited<ReturnType<GetStreamsCollaboratorCounts>>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stream collaborators for multiple streams at a time
|
||||
*/
|
||||
@@ -1087,8 +1115,9 @@ export const deleteProjectRoleFactory =
|
||||
|
||||
export const revokeStreamPermissionsFactory =
|
||||
(deps: { db: Knex }): RevokeStreamPermissions =>
|
||||
async (params: { streamId: string; userId: string }) => {
|
||||
async (params, options) => {
|
||||
const { streamId, userId } = params
|
||||
const { trackProjectUpdate = true } = options || {}
|
||||
|
||||
const existingPermission = await tables
|
||||
.streamAcl(deps.db)
|
||||
@@ -1147,12 +1176,14 @@ export const revokeStreamPermissionsFactory =
|
||||
})
|
||||
}
|
||||
|
||||
// update stream updated at
|
||||
const [stream] = await tables
|
||||
.streams(deps.db)
|
||||
.where({ id: streamId })
|
||||
.update({ updatedAt: knex.fn.now() }, '*')
|
||||
// update stream updated at, if enabled
|
||||
const streamQ = tables.streams(deps.db).where({ id: streamId })
|
||||
|
||||
if (trackProjectUpdate) {
|
||||
streamQ.update({ updatedAt: knex.fn.now() }, '*')
|
||||
}
|
||||
|
||||
const [stream] = await streamQ
|
||||
return stream
|
||||
}
|
||||
|
||||
@@ -1219,10 +1250,10 @@ export const legacyGetStreamsFactory =
|
||||
streamIdWhitelist,
|
||||
workspaceIdWhitelist,
|
||||
offset,
|
||||
publicOnly
|
||||
publicOnly,
|
||||
userId
|
||||
}) => {
|
||||
const query = tables.streams(deps.db)
|
||||
const countQuery = tables.streams(deps.db)
|
||||
|
||||
if (searchQuery) {
|
||||
const whereFunc: Knex.QueryCallback = function () {
|
||||
@@ -1233,7 +1264,6 @@ export const legacyGetStreamsFactory =
|
||||
)
|
||||
}
|
||||
query.where(whereFunc)
|
||||
countQuery.where(whereFunc)
|
||||
}
|
||||
|
||||
if (publicOnly) {
|
||||
@@ -1250,20 +1280,33 @@ export const legacyGetStreamsFactory =
|
||||
this.where({ isPublic })
|
||||
}
|
||||
query.andWhere(publicFunc)
|
||||
countQuery.andWhere(publicFunc)
|
||||
}
|
||||
|
||||
if (streamIdWhitelist?.length) {
|
||||
query.whereIn('id', streamIdWhitelist)
|
||||
countQuery.whereIn('id', streamIdWhitelist)
|
||||
}
|
||||
|
||||
if (workspaceIdWhitelist?.length) {
|
||||
query.whereIn('workspaceId', workspaceIdWhitelist)
|
||||
countQuery.whereIn('workspaceId', workspaceIdWhitelist)
|
||||
}
|
||||
|
||||
const [res] = await countQuery.count()
|
||||
if (userId) {
|
||||
query.select<StreamWithOptionalRole[]>([
|
||||
...Object.values(Streams.col),
|
||||
// Getting first role from grouped results
|
||||
knex.raw(`(array_agg("stream_acl"."role"))[1] as role`)
|
||||
])
|
||||
query.leftJoin(StreamAcl.name, function () {
|
||||
this.on(StreamAcl.col.resourceId, Streams.col.id).andOnVal(
|
||||
StreamAcl.col.userId,
|
||||
userId
|
||||
)
|
||||
})
|
||||
query.groupBy(Streams.col.id)
|
||||
}
|
||||
|
||||
const countQ = deps.db.from(query.clone().as('t1')).count()
|
||||
const [res] = await countQ
|
||||
const count = parseInt(res.count + '')
|
||||
|
||||
if (!count) return { streams: [], totalCount: 0, cursorDate: null }
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IsStreamCollaborator,
|
||||
RemoveStreamCollaborator,
|
||||
RevokeStreamPermissions,
|
||||
SetStreamCollaborator,
|
||||
ValidateStreamAccess
|
||||
} from '@/modules/core/domain/streams/operations'
|
||||
import { GetUser } from '@/modules/core/domain/users/operations'
|
||||
@@ -17,6 +18,7 @@ import { StreamRecord } from '@/modules/core/helpers/types'
|
||||
import { ServerInvitesEvents } from '@/modules/serverinvites/domain/events'
|
||||
import { AuthorizeResolver } from '@/modules/shared/domain/operations'
|
||||
import { BadRequestError, ForbiddenError, LogicError } from '@/modules/shared/errors'
|
||||
import { DependenciesOf } from '@/modules/shared/helpers/factory'
|
||||
import { EventBusEmit } from '@/modules/shared/services/eventBus'
|
||||
import { ensureError, Roles, StreamRoles } from '@speckle/shared'
|
||||
|
||||
@@ -93,24 +95,26 @@ export const removeStreamCollaboratorFactory =
|
||||
revokeStreamPermissions: RevokeStreamPermissions
|
||||
emitEvent: EventBusEmit
|
||||
}): RemoveStreamCollaborator =>
|
||||
async (streamId, userId, removedById, removerResourceAccessRules) => {
|
||||
if (userId !== removedById) {
|
||||
// User must be a stream owner to remove others
|
||||
await deps.validateStreamAccess(
|
||||
removedById,
|
||||
streamId,
|
||||
Roles.Stream.Owner,
|
||||
removerResourceAccessRules
|
||||
)
|
||||
} else {
|
||||
// User must have any kind of role to remove himself
|
||||
const isCollaborator = await deps.isStreamCollaborator(userId, streamId)
|
||||
if (!isCollaborator) {
|
||||
throw new StreamAccessUpdateError('User is not a stream collaborator')
|
||||
async (streamId, userId, removedById, removerResourceAccessRules, options) => {
|
||||
if (!options?.skipAuthorization) {
|
||||
if (userId !== removedById) {
|
||||
// User must be a stream owner to remove others
|
||||
await deps.validateStreamAccess(
|
||||
removedById,
|
||||
streamId,
|
||||
Roles.Stream.Owner,
|
||||
removerResourceAccessRules
|
||||
)
|
||||
} else {
|
||||
// User must have any kind of role to remove himself
|
||||
const isCollaborator = await deps.isStreamCollaborator(userId, streamId)
|
||||
if (!isCollaborator) {
|
||||
throw new StreamAccessUpdateError('User is not a stream collaborator')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stream = await deps.revokeStreamPermissions({ streamId, userId })
|
||||
const stream = await deps.revokeStreamPermissions({ streamId, userId }, options)
|
||||
if (!stream) {
|
||||
throw new LogicError('Stream not found')
|
||||
}
|
||||
@@ -150,26 +154,28 @@ export const addOrUpdateStreamCollaboratorFactory =
|
||||
role,
|
||||
addedById,
|
||||
adderResourceAccessRules,
|
||||
{ fromInvite } = {}
|
||||
{ fromInvite, trackProjectUpdate, skipAuthorization } = {}
|
||||
) => {
|
||||
const validRoles = Object.values(Roles.Stream) as string[]
|
||||
if (!validRoles.includes(role)) {
|
||||
throw new LogicError('Unexpected stream role')
|
||||
}
|
||||
|
||||
if (userId === addedById) {
|
||||
throw new StreamInvalidAccessError(
|
||||
'User cannot change their own stream access level'
|
||||
if (!skipAuthorization) {
|
||||
if (userId === addedById) {
|
||||
throw new StreamInvalidAccessError(
|
||||
'User cannot change their own stream access level'
|
||||
)
|
||||
}
|
||||
|
||||
await deps.validateStreamAccess(
|
||||
addedById,
|
||||
streamId,
|
||||
Roles.Stream.Owner,
|
||||
adderResourceAccessRules
|
||||
)
|
||||
}
|
||||
|
||||
await deps.validateStreamAccess(
|
||||
addedById,
|
||||
streamId,
|
||||
Roles.Stream.Owner,
|
||||
adderResourceAccessRules
|
||||
)
|
||||
|
||||
// make sure server guests cannot be stream owners
|
||||
if (role === Roles.Stream.Owner) {
|
||||
const user = await deps.getUser(userId, { withRole: true })
|
||||
@@ -188,11 +194,14 @@ export const addOrUpdateStreamCollaboratorFactory =
|
||||
}
|
||||
})
|
||||
|
||||
const stream = (await deps.grantStreamPermissions({
|
||||
streamId,
|
||||
userId,
|
||||
role: role as StreamRoles
|
||||
})) as StreamRecord // validateStreamAccess already checked that it exists
|
||||
const stream = (await deps.grantStreamPermissions(
|
||||
{
|
||||
streamId,
|
||||
userId,
|
||||
role: role as StreamRoles
|
||||
},
|
||||
{ trackProjectUpdate }
|
||||
)) as StreamRecord // validateStreamAccess already checked that it exists
|
||||
|
||||
if (fromInvite) {
|
||||
await deps.emitEvent({
|
||||
@@ -217,3 +226,33 @@ export const addOrUpdateStreamCollaboratorFactory =
|
||||
|
||||
return stream
|
||||
}
|
||||
|
||||
export const setStreamCollaboratorFactory =
|
||||
(
|
||||
deps: DependenciesOf<typeof addOrUpdateStreamCollaboratorFactory> &
|
||||
DependenciesOf<typeof removeStreamCollaboratorFactory>
|
||||
): SetStreamCollaborator =>
|
||||
async (params, options) => {
|
||||
const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory(deps)
|
||||
const removeStreamCollaborator = removeStreamCollaboratorFactory(deps)
|
||||
|
||||
const { streamId, userId, role, setterResourceAccessRules, setByUserId } = params
|
||||
if (role) {
|
||||
return await addOrUpdateStreamCollaborator(
|
||||
streamId,
|
||||
userId,
|
||||
role,
|
||||
setByUserId,
|
||||
setterResourceAccessRules,
|
||||
options
|
||||
)
|
||||
} else {
|
||||
return await removeStreamCollaborator(
|
||||
streamId,
|
||||
userId,
|
||||
setByUserId,
|
||||
setterResourceAccessRules,
|
||||
options
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import {
|
||||
WorkspacePlanProductPrices,
|
||||
WorkspacePricingProducts
|
||||
} from '@/modules/gatekeeperCore/domain/billing'
|
||||
import { PaidWorkspacePlans, WorkspacePlanBillingIntervals } from '@speckle/shared'
|
||||
import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
|
||||
import {
|
||||
Nullable,
|
||||
Optional,
|
||||
PaidWorkspacePlans,
|
||||
WorkspacePlanBillingIntervals
|
||||
} from '@speckle/shared'
|
||||
import { OverrideProperties } from 'type-fest'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -14,6 +20,10 @@ export type GetWorkspacePlan = (args: {
|
||||
workspaceId: string
|
||||
}) => Promise<WorkspacePlan | null>
|
||||
|
||||
export type GetWorkspaceWithPlan = (args: {
|
||||
workspaceId: string
|
||||
}) => Promise<Optional<Workspace & { plan: Nullable<WorkspacePlan> }>>
|
||||
|
||||
export type UpsertTrialWorkspacePlan = (args: {
|
||||
workspacePlan: TrialWorkspacePlan
|
||||
}) => Promise<void>
|
||||
@@ -214,3 +224,11 @@ export type GetRecurringPrices = () => Promise<
|
||||
>
|
||||
|
||||
export type GetWorkspacePlanProductPrices = () => Promise<WorkspacePlanProductPrices>
|
||||
|
||||
export type GetWorkspaceRolesAndSeats = (params: { workspaceId: string }) => Promise<{
|
||||
[userId: string]: {
|
||||
role: WorkspaceAcl
|
||||
seat: Nullable<WorkspaceSeat>
|
||||
userId: string
|
||||
}
|
||||
}>
|
||||
|
||||
@@ -201,7 +201,12 @@ export = FF_GATEKEEPER_MODULE_ENABLED
|
||||
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }),
|
||||
emit: getEventBus().emit
|
||||
})
|
||||
await assignSeat({ workspaceId, userId, type: seatType })
|
||||
await assignSeat({
|
||||
workspaceId,
|
||||
userId,
|
||||
type: seatType,
|
||||
assignedByUserId: ctx.userId!
|
||||
})
|
||||
|
||||
return ctx.loaders.workspaces!.getWorkspace.load(workspaceId)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Streams } from '@/modules/core/dbSchema'
|
||||
import { buildTableHelper, Streams } from '@/modules/core/dbSchema'
|
||||
import {
|
||||
CheckoutSession,
|
||||
GetCheckoutSession,
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
GetWorkspaceSubscriptionBySubscriptionId,
|
||||
GetWorkspaceSubscriptions,
|
||||
UpsertTrialWorkspacePlan,
|
||||
UpsertUnpaidWorkspacePlan
|
||||
UpsertUnpaidWorkspacePlan,
|
||||
GetWorkspaceWithPlan
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
import {
|
||||
ChangeExpiredTrialWorkspacePlanStatuses,
|
||||
@@ -23,19 +24,52 @@ import {
|
||||
GetWorkspacePlanByProjectId
|
||||
} from '@/modules/gatekeeper/domain/operations'
|
||||
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
|
||||
import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper'
|
||||
import { Workspace } from '@/modules/workspacesCore/domain/types'
|
||||
import { Workspaces } from '@/modules/workspacesCore/helpers/db'
|
||||
import { Knex } from 'knex'
|
||||
import { omit } from 'lodash'
|
||||
|
||||
const WorkspacePlans = buildTableHelper('workspace_plans', [
|
||||
'workspaceId',
|
||||
'name',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt'
|
||||
])
|
||||
|
||||
const tables = {
|
||||
workspaces: (db: Knex) => db<Workspace>('workspaces'),
|
||||
workspacePlans: (db: Knex) => db<WorkspacePlan>('workspace_plans'),
|
||||
workspacePlans: (db: Knex) => db<WorkspacePlan>(WorkspacePlans.name),
|
||||
workspaceCheckoutSessions: (db: Knex) =>
|
||||
db<CheckoutSession>('workspace_checkout_sessions'),
|
||||
workspaceSubscriptions: (db: Knex) =>
|
||||
db<WorkspaceSubscription>('workspace_subscriptions')
|
||||
}
|
||||
|
||||
export const getWorkspaceWithPlanFactory =
|
||||
(deps: { db: Knex }): GetWorkspaceWithPlan =>
|
||||
async ({ workspaceId }) => {
|
||||
const q = tables
|
||||
.workspaces(deps.db)
|
||||
.select<Workspace & { plans: WorkspacePlan[] }>([
|
||||
...Workspaces.cols,
|
||||
WorkspacePlans.groupArray('plans')
|
||||
])
|
||||
.leftJoin(WorkspacePlans.name, WorkspacePlans.col.workspaceId, Workspaces.col.id)
|
||||
.where(Workspaces.col.id, workspaceId)
|
||||
.groupBy(Workspaces.col.id)
|
||||
.first()
|
||||
|
||||
const workspace = await q
|
||||
if (!workspace) return undefined
|
||||
|
||||
return {
|
||||
...omit(workspace, 'plans'),
|
||||
plan: formatJsonArrayRecords(workspace.plans || [])[0] || null
|
||||
}
|
||||
}
|
||||
|
||||
export const getWorkspacePlanFactory =
|
||||
({ db }: { db: Knex }): GetWorkspacePlan =>
|
||||
async ({ workspaceId }) => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { buildTableHelper } from '@/modules/core/dbSchema'
|
||||
import { WorkspaceSeat } from '@/modules/gatekeeper/domain/billing'
|
||||
import {
|
||||
GetWorkspaceRolesAndSeats,
|
||||
WorkspaceSeat
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
import {
|
||||
CountSeatsByTypeInWorkspace,
|
||||
CreateWorkspaceSeat,
|
||||
@@ -7,6 +10,9 @@ import {
|
||||
GetWorkspaceUserSeat,
|
||||
GetWorkspaceUserSeats
|
||||
} from '@/modules/gatekeeper/domain/operations'
|
||||
import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper'
|
||||
import { WorkspaceAcl as WorkspaceAclRecord } from '@/modules/workspacesCore/domain/types'
|
||||
import { WorkspaceAcl } from '@/modules/workspacesCore/helpers/db'
|
||||
import { Knex } from 'knex'
|
||||
|
||||
const WorkspaceSeats = buildTableHelper('workspace_seats', [
|
||||
@@ -18,7 +24,8 @@ const WorkspaceSeats = buildTableHelper('workspace_seats', [
|
||||
])
|
||||
|
||||
const tables = {
|
||||
workspaceSeats: (db: Knex) => db<WorkspaceSeat>(WorkspaceSeats.name)
|
||||
workspaceSeats: (db: Knex) => db<WorkspaceSeat>(WorkspaceSeats.name),
|
||||
workspaceAcl: (db: Knex) => db<WorkspaceAclRecord>(WorkspaceAcl.name)
|
||||
}
|
||||
|
||||
export const countSeatsByTypeInWorkspaceFactory =
|
||||
@@ -79,3 +86,37 @@ export const getWorkspaceUserSeatFactory =
|
||||
})
|
||||
return seats[userId]
|
||||
}
|
||||
|
||||
export const getWorkspaceRolesAndSeatsFactory =
|
||||
(deps: { db: Knex }): GetWorkspaceRolesAndSeats =>
|
||||
async ({ workspaceId }) => {
|
||||
const q = tables
|
||||
.workspaceAcl(deps.db)
|
||||
.select<Array<{ seats: WorkspaceSeat[]; roles: WorkspaceAclRecord[] }>>([
|
||||
// There's only ever gonna be 1 role and seat per user, but this way we can avoid having to group
|
||||
// by many columns and we can get everything in 1 query
|
||||
WorkspaceAcl.groupArray('roles'),
|
||||
WorkspaceSeats.groupArray('seats')
|
||||
])
|
||||
.leftJoin(WorkspaceSeats.name, (j1) => {
|
||||
j1.on(WorkspaceSeats.col.userId, WorkspaceAcl.col.userId).andOnVal(
|
||||
WorkspaceSeats.col.workspaceId,
|
||||
workspaceId
|
||||
)
|
||||
})
|
||||
.where(WorkspaceAcl.col.workspaceId, workspaceId)
|
||||
.groupBy(WorkspaceAcl.col.userId)
|
||||
|
||||
const res = await q
|
||||
return res.reduce((acc, row) => {
|
||||
const role = formatJsonArrayRecords(row.roles)[0]
|
||||
if (!role) return acc
|
||||
|
||||
acc[role.userId] = {
|
||||
role,
|
||||
seat: formatJsonArrayRecords(row.seats || [])[0] || null,
|
||||
userId: role.userId
|
||||
}
|
||||
return acc
|
||||
}, {} as Awaited<ReturnType<GetWorkspaceRolesAndSeats>>)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { UserRoleData } from '@/modules/shared/domain/rolesAndScopes/types'
|
||||
import { AvailableRoles } from '@speckle/shared'
|
||||
import { isUndefined } from 'lodash'
|
||||
|
||||
/**
|
||||
* Order roles by weight in descending order (meaning - highest permission roles come first)
|
||||
*/
|
||||
export const orderByWeight = <T extends AvailableRoles>(
|
||||
roles: T[],
|
||||
definitions: UserRoleData<T>[]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
|
||||
import { StreamRecord } from '@/modules/core/helpers/types'
|
||||
import {
|
||||
Workspace,
|
||||
WorkspaceAcl,
|
||||
@@ -20,12 +19,9 @@ import {
|
||||
StreamRoles,
|
||||
WorkspaceRoles
|
||||
} from '@speckle/shared'
|
||||
import {
|
||||
WorkspaceCreationState,
|
||||
WorkspaceRoleToDefaultProjectRoleMapping
|
||||
} from '@/modules/workspaces/domain/types'
|
||||
import { WorkspaceCreationState } from '@/modules/workspaces/domain/types'
|
||||
import { WorkspaceTeam } from '@/modules/workspaces/domain/types'
|
||||
import { Stream } from '@/modules/core/domain/streams/types'
|
||||
import { Stream, StreamWithOptionalRole } from '@/modules/core/domain/streams/types'
|
||||
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
|
||||
import { ServerRegion } from '@/modules/multiregion/domain/types'
|
||||
import { SetOptional } from 'type-fest'
|
||||
@@ -199,22 +195,37 @@ export type UpdateWorkspaceRole = (
|
||||
* Only add or upgrade role, prevent downgrades
|
||||
*/
|
||||
preventRoleDowngrade?: boolean
|
||||
|
||||
updatedByUserId: string
|
||||
}
|
||||
) => Promise<void>
|
||||
|
||||
export type GetWorkspaceRoleToDefaultProjectRoleMapping = (args: {
|
||||
export type GetWorkspaceRolesAllowedProjectRolesFactory = (params: {
|
||||
workspaceId: string
|
||||
}) => Promise<WorkspaceRoleToDefaultProjectRoleMapping>
|
||||
}) => Promise<{
|
||||
defaultProjectRole: (args: {
|
||||
workspaceRole: WorkspaceRoles
|
||||
seatType: MaybeNullOrUndefined<WorkspaceSeatType>
|
||||
}) => StreamRoles | null
|
||||
allowedProjectRoles: (args: {
|
||||
workspaceRole: WorkspaceRoles
|
||||
seatType: MaybeNullOrUndefined<WorkspaceSeatType>
|
||||
}) => StreamRoles[]
|
||||
}>
|
||||
|
||||
/** Workspace Projects */
|
||||
|
||||
type QueryAllWorkspaceProjectsArgs = {
|
||||
workspaceId: string
|
||||
/**
|
||||
* Optionally get project roles for a specific user
|
||||
*/
|
||||
userId?: string
|
||||
}
|
||||
|
||||
export type QueryAllWorkspaceProjects = (
|
||||
args: QueryAllWorkspaceProjectsArgs
|
||||
) => AsyncGenerator<StreamRecord[], void, unknown>
|
||||
) => AsyncGenerator<StreamWithOptionalRole[], void, unknown>
|
||||
|
||||
/** Workspace Project Roles */
|
||||
|
||||
@@ -337,7 +348,9 @@ export type GetWorkspaceJoinRequest = (
|
||||
) => Promise<WorkspaceJoinRequest | undefined>
|
||||
|
||||
export type ApproveWorkspaceJoinRequest = (
|
||||
params: Pick<WorkspaceJoinRequest, 'workspaceId' | 'userId'>
|
||||
params: Pick<WorkspaceJoinRequest, 'workspaceId' | 'userId'> & {
|
||||
approvedByUserId: string
|
||||
}
|
||||
) => Promise<boolean>
|
||||
|
||||
export type DenyWorkspaceJoinRequest = (
|
||||
@@ -388,7 +401,10 @@ export type CopyProjectAutomations = (params: {
|
||||
}) => Promise<Record<string, number>>
|
||||
|
||||
export type AssignWorkspaceSeat = (
|
||||
params: Pick<WorkspaceSeat, 'userId' | 'workspaceId'> & { type: WorkspaceSeatType }
|
||||
params: Pick<WorkspaceSeat, 'userId' | 'workspaceId'> & {
|
||||
type: WorkspaceSeatType
|
||||
assignedByUserId: string
|
||||
}
|
||||
) => Promise<WorkspaceSeat>
|
||||
|
||||
export type EnsureValidWorkspaceRoleSeat = (params: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { WorkspaceInviteResourceTarget } from '@/modules/workspacesCore/domain/types'
|
||||
import { LimitedUserRecord, UserWithRole } from '@/modules/core/helpers/types'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
|
||||
import { StreamRoles, WorkspaceRoles } from '@speckle/shared'
|
||||
import { WorkspaceRoles } from '@speckle/shared'
|
||||
|
||||
declare module '@/modules/serverinvites/domain/types' {
|
||||
interface InviteResourceTargetTypeMap {
|
||||
@@ -22,10 +22,6 @@ export type WorkspaceTeamMember = UserWithRole<LimitedUserRecord> & {
|
||||
|
||||
export type WorkspaceTeam = WorkspaceTeamMember[]
|
||||
|
||||
export type WorkspaceRoleToDefaultProjectRoleMapping = {
|
||||
[key in WorkspaceRoles]: StreamRoles | null
|
||||
}
|
||||
|
||||
export type WorkspaceCreationState = {
|
||||
workspaceId: string
|
||||
completed: boolean
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
deleteProjectRoleFactory,
|
||||
getStreamFactory,
|
||||
getStreamsCollaboratorCountsFactory,
|
||||
grantStreamPermissionsFactory,
|
||||
legacyGetStreamsFactory,
|
||||
revokeStreamPermissionsFactory,
|
||||
upsertProjectRoleFactory
|
||||
} from '@/modules/core/repositories/streams'
|
||||
import {
|
||||
@@ -9,8 +12,7 @@ import {
|
||||
GetDefaultRegion,
|
||||
GetWorkspace,
|
||||
GetWorkspaceRoleForUser,
|
||||
GetWorkspaceRoles,
|
||||
GetWorkspaceRoleToDefaultProjectRoleMapping,
|
||||
GetWorkspaceRolesAllowedProjectRolesFactory,
|
||||
QueryAllWorkspaceProjects
|
||||
} from '@/modules/workspaces/domain/operations'
|
||||
import {
|
||||
@@ -25,12 +27,7 @@ import { logger, moduleLogger } from '@/observability/logging'
|
||||
import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management'
|
||||
import { EventPayload, getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
|
||||
import {
|
||||
PaidWorkspacePlansNew,
|
||||
Roles,
|
||||
throwUncoveredError,
|
||||
WorkspaceRoles
|
||||
} from '@speckle/shared'
|
||||
import { Roles, throwUncoveredError, WorkspaceRoles } from '@speckle/shared'
|
||||
import {
|
||||
DeleteProjectRole,
|
||||
UpsertProjectRole
|
||||
@@ -47,14 +44,18 @@ import {
|
||||
} from '@/modules/workspaces/repositories/workspaces'
|
||||
import {
|
||||
queryAllWorkspaceProjectsFactory,
|
||||
getWorkspaceRoleToDefaultProjectRoleMappingFactory
|
||||
getWorkspaceRolesAllowedProjectRolesFactory
|
||||
} from '@/modules/workspaces/services/projects'
|
||||
import { withTransaction } from '@/modules/shared/helpers/dbHelper'
|
||||
import {
|
||||
findEmailsByUserIdFactory,
|
||||
findVerifiedEmailsByUserIdFactory
|
||||
} from '@/modules/core/repositories/userEmails'
|
||||
import { GetStream } from '@/modules/core/domain/streams/operations'
|
||||
import {
|
||||
GetStream,
|
||||
GetStreamsCollaboratorCounts,
|
||||
SetStreamCollaborator
|
||||
} from '@/modules/core/domain/streams/operations'
|
||||
import {
|
||||
GetUserSsoSession,
|
||||
GetWorkspaceSsoProviderRecord
|
||||
@@ -66,7 +67,6 @@ import {
|
||||
getWorkspaceSsoProviderRecordFactory
|
||||
} from '@/modules/workspaces/repositories/sso'
|
||||
import {
|
||||
WorkspaceAdminError,
|
||||
WorkspaceInvalidRoleError,
|
||||
WorkspacesNotAuthorizedError
|
||||
} from '@/modules/workspaces/errors/workspace'
|
||||
@@ -80,6 +80,7 @@ import { getBaseTrackingProperties, getClient } from '@/modules/shared/utils/mix
|
||||
import {
|
||||
calculateSubscriptionSeats,
|
||||
GetWorkspacePlan,
|
||||
GetWorkspaceRolesAndSeats,
|
||||
GetWorkspaceSubscription,
|
||||
WorkspaceSeatType
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
@@ -89,28 +90,37 @@ import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
|
||||
import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions'
|
||||
import {
|
||||
getWorkspacePlanFactory,
|
||||
getWorkspaceSubscriptionFactory
|
||||
getWorkspaceSubscriptionFactory,
|
||||
getWorkspaceWithPlanFactory
|
||||
} from '@/modules/gatekeeper/repositories/billing'
|
||||
import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat'
|
||||
import {
|
||||
createWorkspaceSeatFactory,
|
||||
deleteWorkspaceSeatFactory,
|
||||
getWorkspaceRolesAndSeatsFactory,
|
||||
getWorkspaceUserSeatFactory
|
||||
} from '@/modules/gatekeeper/repositories/workspaceSeat'
|
||||
import {
|
||||
DeleteWorkspaceSeat,
|
||||
GetWorkspaceUserSeat
|
||||
} from '@/modules/gatekeeper/domain/operations'
|
||||
import {
|
||||
isStreamCollaboratorFactory,
|
||||
setStreamCollaboratorFactory,
|
||||
validateStreamAccessFactory
|
||||
} from '@/modules/core/services/streams/access'
|
||||
import { getUserFactory } from '@/modules/core/repositories/users'
|
||||
import { authorizeResolver } from '@/modules/shared'
|
||||
|
||||
export const onProjectCreatedFactory =
|
||||
({
|
||||
getWorkspaceRoles,
|
||||
getWorkspaceRolesAndSeats,
|
||||
upsertProjectRole,
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping
|
||||
getWorkspaceRolesAllowedProjectRoles
|
||||
}: {
|
||||
getWorkspaceRoles: GetWorkspaceRoles
|
||||
getWorkspaceRolesAndSeats: GetWorkspaceRolesAndSeats
|
||||
upsertProjectRole: UpsertProjectRole
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
|
||||
getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory
|
||||
}) =>
|
||||
async (payload: ProjectEventsPayloads[typeof ProjectEvents.Created]) => {
|
||||
const { id: projectId, workspaceId } = payload.project
|
||||
@@ -119,20 +129,21 @@ export const onProjectCreatedFactory =
|
||||
return
|
||||
}
|
||||
|
||||
const workspaceMembers = await getWorkspaceRoles({ workspaceId })
|
||||
const workspaceMembers = Object.values(
|
||||
await getWorkspaceRolesAndSeats({ workspaceId })
|
||||
)
|
||||
|
||||
const defaultRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping({
|
||||
const { defaultProjectRole } = await getWorkspaceRolesAllowedProjectRoles({
|
||||
workspaceId
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
workspaceMembers.map(({ userId, role: workspaceRole }) => {
|
||||
const projectRole = defaultRoleMapping[workspaceRole]
|
||||
workspaceMembers.map(({ userId, role: { role: workspaceRole }, seat }) => {
|
||||
const projectRole = defaultProjectRole({ workspaceRole, seatType: seat?.type })
|
||||
if (!projectRole) return
|
||||
|
||||
// we do not need to assign new roles to the project owner
|
||||
if (userId === payload.ownerId) return
|
||||
// Guests do not get roles on project create
|
||||
if (!projectRole || workspaceRole === Roles.Workspace.Guest) return
|
||||
|
||||
return upsertProjectRole({
|
||||
projectId,
|
||||
@@ -182,7 +193,8 @@ export const onInviteFinalizedFactory =
|
||||
role: workspaceRole,
|
||||
userId: targetUserId,
|
||||
workspaceId: project.workspaceId,
|
||||
skipProjectRoleUpdatesFor: [project.id]
|
||||
skipProjectRoleUpdatesFor: [project.id],
|
||||
updatedByUserId: invite.inviterId
|
||||
})
|
||||
}
|
||||
|
||||
@@ -244,56 +256,81 @@ export const onWorkspaceRoleDeletedFactory =
|
||||
|
||||
export const onWorkspaceRoleUpdatedFactory =
|
||||
({
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping,
|
||||
getWorkspaceRolesAllowedProjectRoles,
|
||||
queryAllWorkspaceProjects,
|
||||
deleteProjectRole,
|
||||
upsertProjectRole
|
||||
setStreamCollaborator,
|
||||
getStreamsCollaboratorCounts
|
||||
}: {
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
|
||||
getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory
|
||||
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
|
||||
deleteProjectRole: DeleteProjectRole
|
||||
upsertProjectRole: UpsertProjectRole
|
||||
setStreamCollaborator: SetStreamCollaborator
|
||||
getStreamsCollaboratorCounts: GetStreamsCollaboratorCounts
|
||||
}) =>
|
||||
async ({
|
||||
acl,
|
||||
flags
|
||||
flags,
|
||||
seatType,
|
||||
updatedByUserId
|
||||
}: {
|
||||
acl: { userId: string; role: WorkspaceRoles; workspaceId: string }
|
||||
seatType: WorkspaceSeatType
|
||||
flags?: {
|
||||
skipProjectRoleUpdatesFor: string[]
|
||||
}
|
||||
updatedByUserId: string
|
||||
}) => {
|
||||
const { userId, role, workspaceId } = acl
|
||||
const defaultProjectRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping(
|
||||
{
|
||||
workspaceId
|
||||
}
|
||||
)
|
||||
const { defaultProjectRole } = await getWorkspaceRolesAllowedProjectRoles({
|
||||
workspaceId
|
||||
})
|
||||
|
||||
const nextProjectRole = defaultProjectRoleMapping[role]
|
||||
const nextUserRole = defaultProjectRole({ workspaceRole: role, seatType })
|
||||
|
||||
// Keep user's project roles in sync with their workspace role & seat type
|
||||
for await (const projectsPage of queryAllWorkspaceProjects({
|
||||
workspaceId,
|
||||
userId
|
||||
})) {
|
||||
const projectsOldOwnerCounts = await getStreamsCollaboratorCounts({
|
||||
streamIds: projectsPage.map((p) => p.id),
|
||||
type: Roles.Stream.Owner
|
||||
})
|
||||
|
||||
for await (const projectsPage of queryAllWorkspaceProjects({ workspaceId })) {
|
||||
await Promise.all(
|
||||
projectsPage.map(async ({ id: projectId }) => {
|
||||
projectsPage.map(async ({ id: projectId, role: originalProjectRole }) => {
|
||||
if (flags?.skipProjectRoleUpdatesFor.includes(projectId)) {
|
||||
// Skip assignment (used during invite flow)
|
||||
// TODO: Can we refactor this special case away?
|
||||
return
|
||||
}
|
||||
|
||||
if (!nextProjectRole) {
|
||||
// User is being demoted to a workspace role without project access
|
||||
await deleteProjectRole({ projectId, userId })
|
||||
return
|
||||
// If downgraded from owner & last owner, transfer ownership to admin causing the role update (updatedByUserId)
|
||||
const isNoLongerOwner =
|
||||
originalProjectRole === Roles.Stream.Owner &&
|
||||
(!nextUserRole || nextUserRole !== Roles.Stream.Owner)
|
||||
const wasLastOwner =
|
||||
projectsOldOwnerCounts[projectId]?.[Roles.Stream.Owner] === 1
|
||||
if (isNoLongerOwner && wasLastOwner) {
|
||||
await setStreamCollaborator(
|
||||
{
|
||||
streamId: projectId,
|
||||
userId: updatedByUserId,
|
||||
role: Roles.Stream.Owner,
|
||||
setByUserId: updatedByUserId
|
||||
},
|
||||
{ trackProjectUpdate: false, skipAuthorization: true }
|
||||
)
|
||||
}
|
||||
|
||||
await upsertProjectRole(
|
||||
// Finally change target role
|
||||
await setStreamCollaborator(
|
||||
{
|
||||
projectId,
|
||||
streamId: projectId,
|
||||
userId,
|
||||
role: nextProjectRole
|
||||
role: nextUserRole,
|
||||
setByUserId: updatedByUserId
|
||||
},
|
||||
{ trackProjectUpdate: false }
|
||||
{ trackProjectUpdate: false, skipAuthorization: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
@@ -481,7 +518,7 @@ const blockInvalidWorkspaceProjectRoleUpdatesFactory =
|
||||
getStream: GetStream
|
||||
getWorkspaceRoleForUser: GetWorkspaceRoleForUser
|
||||
getWorkspaceUserSeat: GetWorkspaceUserSeat
|
||||
getWorkspacePlan: GetWorkspacePlan
|
||||
getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory
|
||||
}) =>
|
||||
async ({ payload }: EventPayload<typeof ProjectEvents.PermissionsBeingAdded>) => {
|
||||
const project = await deps.getStream({ streamId: payload.projectId })
|
||||
@@ -491,41 +528,23 @@ const blockInvalidWorkspaceProjectRoleUpdatesFactory =
|
||||
workspaceId: project.workspaceId,
|
||||
userId: payload.targetUserId
|
||||
}
|
||||
const [currentWorkspaceRole, seat, plan] = await Promise.all([
|
||||
const [currentWorkspaceRole, seat, { allowedProjectRoles }] = await Promise.all([
|
||||
deps.getWorkspaceRoleForUser(roleSeatParams),
|
||||
deps.getWorkspaceUserSeat(roleSeatParams),
|
||||
deps.getWorkspacePlan({ workspaceId: project.workspaceId })
|
||||
deps.getWorkspaceRolesAllowedProjectRoles({ workspaceId: project.workspaceId })
|
||||
])
|
||||
|
||||
// Workspace role checks
|
||||
if (currentWorkspaceRole?.role === Roles.Workspace.Admin) {
|
||||
// User is workspace admin and cannot have their project roles changed
|
||||
throw new WorkspaceAdminError()
|
||||
}
|
||||
if (!currentWorkspaceRole) return
|
||||
|
||||
if (
|
||||
currentWorkspaceRole?.role === Roles.Workspace.Guest &&
|
||||
payload.role === Roles.Stream.Owner
|
||||
) {
|
||||
// Workspace guests cannot be project owners
|
||||
throw new WorkspaceInvalidRoleError('Workspace guests cannot be project owners.')
|
||||
}
|
||||
const allowedRoles = allowedProjectRoles({
|
||||
workspaceRole: currentWorkspaceRole.role,
|
||||
seatType: seat?.type
|
||||
})
|
||||
|
||||
// Workspace seat checks
|
||||
if (
|
||||
!plan ||
|
||||
!seat ||
|
||||
!(Object.values(PaidWorkspacePlansNew) as string[]).includes(plan.name)
|
||||
) {
|
||||
return // Doesn't apply
|
||||
}
|
||||
|
||||
if (
|
||||
seat.type === WorkspaceSeatType.Viewer &&
|
||||
payload.role !== Roles.Stream.Reviewer
|
||||
) {
|
||||
if (!allowedRoles.includes(payload.role)) {
|
||||
// User's workspace role does not allow the requested project role
|
||||
throw new WorkspaceInvalidRoleError(
|
||||
'Workspace viewers can only be project reviewers.'
|
||||
`User's workspace role '${currentWorkspaceRole.role}' and seat type '${seat?.type}' does not allow project role '${payload.role}'.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -548,7 +567,10 @@ export const initializeEventListenersFactory =
|
||||
getStream,
|
||||
getWorkspaceRoleForUser,
|
||||
getWorkspaceUserSeat,
|
||||
getWorkspacePlan
|
||||
getWorkspaceRolesAllowedProjectRoles:
|
||||
getWorkspaceRolesAllowedProjectRolesFactory({
|
||||
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
|
||||
})
|
||||
})
|
||||
const createWorkspaceSeat = createWorkspaceSeatFactory({ db })
|
||||
const ensureValidWorkspaceRoleSeat = ensureValidWorkspaceRoleSeatFactory({
|
||||
@@ -559,12 +581,12 @@ export const initializeEventListenersFactory =
|
||||
const quitCbs = [
|
||||
eventBus.listen(ProjectEvents.Created, async ({ payload }) => {
|
||||
const onProjectCreated = onProjectCreatedFactory({
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping:
|
||||
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
|
||||
getWorkspace
|
||||
getWorkspaceRolesAllowedProjectRoles:
|
||||
getWorkspaceRolesAllowedProjectRolesFactory({
|
||||
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
|
||||
}),
|
||||
upsertProjectRole: upsertProjectRoleFactory({ db }),
|
||||
getWorkspaceRoles: getWorkspaceRolesFactory({ db })
|
||||
getWorkspaceRolesAndSeats: getWorkspaceRolesAndSeatsFactory({ db })
|
||||
})
|
||||
await onProjectCreated(payload)
|
||||
}),
|
||||
@@ -624,13 +646,22 @@ export const initializeEventListenersFactory =
|
||||
eventBus.listen(WorkspaceEvents.RoleUpdated, async ({ payload }) => {
|
||||
const trx = await db.transaction()
|
||||
const onWorkspaceRoleUpdated = onWorkspaceRoleUpdatedFactory({
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping:
|
||||
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
|
||||
getWorkspace
|
||||
getWorkspaceRolesAllowedProjectRoles:
|
||||
getWorkspaceRolesAllowedProjectRolesFactory({
|
||||
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
|
||||
}),
|
||||
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }),
|
||||
deleteProjectRole: deleteProjectRoleFactory({ db: trx }),
|
||||
upsertProjectRole: upsertProjectRoleFactory({ db: trx })
|
||||
setStreamCollaborator: setStreamCollaboratorFactory({
|
||||
getUser: getUserFactory({ db }),
|
||||
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
|
||||
emitEvent: eventBus.emit,
|
||||
grantStreamPermissions: grantStreamPermissionsFactory({ db: trx }),
|
||||
isStreamCollaborator: isStreamCollaboratorFactory({
|
||||
getStream: getStreamFactory({ db })
|
||||
}),
|
||||
revokeStreamPermissions: revokeStreamPermissionsFactory({ db: trx })
|
||||
}),
|
||||
getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db })
|
||||
})
|
||||
await withTransaction(onWorkspaceRoleUpdated(payload), trx)
|
||||
}),
|
||||
|
||||
@@ -127,7 +127,7 @@ export default FF_WORKSPACES_MODULE_ENABLED
|
||||
workspaceJoinRequestMutations: () => ({})
|
||||
},
|
||||
WorkspaceJoinRequestMutations: {
|
||||
approve: async (_parent, args) => {
|
||||
approve: async (_parent, args, ctx) => {
|
||||
const approveWorkspaceJoinRequest =
|
||||
commandFactory<ApproveWorkspaceJoinRequest>({
|
||||
db,
|
||||
@@ -163,7 +163,8 @@ export default FF_WORKSPACES_MODULE_ENABLED
|
||||
})
|
||||
return await approveWorkspaceJoinRequest({
|
||||
userId: args.input.userId,
|
||||
workspaceId: args.input.workspaceId
|
||||
workspaceId: args.input.workspaceId,
|
||||
approvedByUserId: ctx.userId!
|
||||
})
|
||||
},
|
||||
deny: async (_parent, args) => {
|
||||
|
||||
@@ -98,7 +98,7 @@ import {
|
||||
import {
|
||||
createWorkspaceProjectFactory,
|
||||
getWorkspaceProjectsFactory,
|
||||
getWorkspaceRoleToDefaultProjectRoleMappingFactory,
|
||||
getWorkspaceRolesAllowedProjectRolesFactory,
|
||||
moveProjectToWorkspaceFactory,
|
||||
queryAllWorkspaceProjectsFactory
|
||||
} from '@/modules/workspaces/services/projects'
|
||||
@@ -177,6 +177,7 @@ import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regio
|
||||
import { convertFunctionToGraphQLReturn } from '@/modules/automate/services/functionManagement'
|
||||
import {
|
||||
getWorkspacePlanFactory,
|
||||
getWorkspaceWithPlanFactory,
|
||||
upsertWorkspacePlanFactory
|
||||
} from '@/modules/gatekeeper/repositories/billing'
|
||||
import { Knex } from 'knex'
|
||||
@@ -206,6 +207,7 @@ import {
|
||||
import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat'
|
||||
import {
|
||||
createWorkspaceSeatFactory,
|
||||
getWorkspaceRolesAndSeatsFactory,
|
||||
getWorkspaceUserSeatFactory
|
||||
} from '@/modules/gatekeeper/repositories/workspaceSeat'
|
||||
|
||||
@@ -606,7 +608,12 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
})
|
||||
})
|
||||
})
|
||||
await updateWorkspaceRole({ userId, workspaceId, role })
|
||||
await updateWorkspaceRole({
|
||||
userId,
|
||||
workspaceId,
|
||||
role,
|
||||
updatedByUserId: context.userId!
|
||||
})
|
||||
}
|
||||
|
||||
return await getWorkspaceFactory({ db })({
|
||||
@@ -1004,10 +1011,10 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
updateProject: updateProjectFactory({ db }),
|
||||
upsertProjectRole: upsertProjectRoleFactory({ db }),
|
||||
getProjectCollaborators: getProjectCollaboratorsFactory({ db }),
|
||||
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping:
|
||||
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
|
||||
getWorkspace: getWorkspaceFactory({ db })
|
||||
getWorkspaceRolesAndSeats: getWorkspaceRolesAndSeatsFactory({ db }),
|
||||
getWorkspaceRolesAllowedProjectRoles:
|
||||
getWorkspaceRolesAllowedProjectRolesFactory({
|
||||
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
|
||||
}),
|
||||
updateWorkspaceRole: updateWorkspaceRoleFactory({
|
||||
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
|
||||
@@ -1025,7 +1032,11 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
})
|
||||
})
|
||||
|
||||
return await moveProjectToWorkspace({ projectId, workspaceId })
|
||||
return await moveProjectToWorkspace({
|
||||
projectId,
|
||||
workspaceId,
|
||||
movedByUserId: context.userId!
|
||||
})
|
||||
}
|
||||
},
|
||||
Workspace: {
|
||||
|
||||
@@ -575,7 +575,8 @@ export const processFinalizedWorkspaceInviteFactory =
|
||||
userId: finalizerUserId,
|
||||
workspaceId: workspace.id,
|
||||
role: invite.resource.role || Roles.Workspace.Member,
|
||||
preventRoleDowngrade: true
|
||||
preventRoleDowngrade: true,
|
||||
updatedByUserId: invite.inviterId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ export const joinWorkspaceFactory =
|
||||
})
|
||||
await emitWorkspaceEvent({
|
||||
eventName: WorkspaceEvents.RoleUpdated,
|
||||
payload: { acl: { userId, workspaceId, role }, seatType: type }
|
||||
payload: {
|
||||
acl: { userId, workspaceId, role },
|
||||
seatType: type,
|
||||
updatedByUserId: userId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -421,7 +421,8 @@ export const updateWorkspaceRoleFactory =
|
||||
userId,
|
||||
role: nextWorkspaceRole,
|
||||
skipProjectRoleUpdatesFor,
|
||||
preventRoleDowngrade
|
||||
preventRoleDowngrade,
|
||||
updatedByUserId
|
||||
}): Promise<void> => {
|
||||
const workspaceRoles = await getWorkspaceRoles({ workspaceId })
|
||||
|
||||
@@ -494,7 +495,8 @@ export const updateWorkspaceRoleFactory =
|
||||
seatType: type,
|
||||
flags: {
|
||||
skipProjectRoleUpdatesFor: skipProjectRoleUpdatesFor ?? []
|
||||
}
|
||||
},
|
||||
updatedByUserId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { StreamRecord } from '@/modules/core/helpers/types'
|
||||
import {
|
||||
GetDefaultRegion,
|
||||
GetWorkspace,
|
||||
GetWorkspaceRoles,
|
||||
GetWorkspaceRoleToDefaultProjectRoleMapping,
|
||||
GetWorkspaceRolesAllowedProjectRolesFactory,
|
||||
QueryAllWorkspaceProjects,
|
||||
UpdateWorkspaceRole
|
||||
} from '@/modules/workspaces/domain/operations'
|
||||
@@ -18,8 +16,14 @@ import {
|
||||
UpdateProject,
|
||||
UpsertProjectRole
|
||||
} from '@/modules/core/domain/projects/operations'
|
||||
import { chunk } from 'lodash'
|
||||
import { Roles, StreamRoles } from '@speckle/shared'
|
||||
import { chunk, intersection } from 'lodash'
|
||||
import {
|
||||
MaybeNullOrUndefined,
|
||||
Roles,
|
||||
StreamRoles,
|
||||
throwUncoveredError,
|
||||
WorkspaceRoles
|
||||
} from '@speckle/shared'
|
||||
import { orderByWeight } from '@/modules/shared/domain/rolesAndScopes/logic'
|
||||
import coreUserRoles from '@/modules/core/roles'
|
||||
import {
|
||||
@@ -46,6 +50,13 @@ import {
|
||||
getWorkspaceFactory,
|
||||
upsertWorkspaceFactory
|
||||
} from '@/modules/workspaces/repositories/workspaces'
|
||||
import {
|
||||
GetWorkspaceRolesAndSeats,
|
||||
GetWorkspaceWithPlan,
|
||||
WorkspaceSeatType
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
import { isNewPaidPlanType } from '@/modules/gatekeeper/helpers/plans'
|
||||
import { LogicError } from '@/modules/shared/errors'
|
||||
|
||||
export const queryAllWorkspaceProjectsFactory = ({
|
||||
getStreams
|
||||
@@ -53,7 +64,8 @@ export const queryAllWorkspaceProjectsFactory = ({
|
||||
getStreams: LegacyGetStreams
|
||||
}): QueryAllWorkspaceProjects =>
|
||||
async function* queryAllWorkspaceProjects({
|
||||
workspaceId
|
||||
workspaceId,
|
||||
userId
|
||||
}): AsyncGenerator<StreamRecord[], void, unknown> {
|
||||
let cursor: Date | null = null
|
||||
let iterationCount = 0
|
||||
@@ -64,11 +76,12 @@ export const queryAllWorkspaceProjectsFactory = ({
|
||||
const { streams, cursorDate } = await getStreams({
|
||||
cursor,
|
||||
orderBy: null,
|
||||
limit: 1000,
|
||||
limit: 100,
|
||||
visibility: null,
|
||||
searchQuery: null,
|
||||
streamIdWhitelist: null,
|
||||
workspaceIdWhitelist: [workspaceId]
|
||||
workspaceIdWhitelist: [workspaceId],
|
||||
userId
|
||||
})
|
||||
|
||||
yield streams
|
||||
@@ -119,6 +132,7 @@ export const getWorkspaceProjectsFactory =
|
||||
type MoveProjectToWorkspaceArgs = {
|
||||
projectId: string
|
||||
workspaceId: string
|
||||
movedByUserId: string
|
||||
}
|
||||
|
||||
export const moveProjectToWorkspaceFactory =
|
||||
@@ -127,21 +141,22 @@ export const moveProjectToWorkspaceFactory =
|
||||
updateProject,
|
||||
upsertProjectRole,
|
||||
getProjectCollaborators,
|
||||
getWorkspaceRoles,
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping,
|
||||
getWorkspaceRolesAndSeats,
|
||||
getWorkspaceRolesAllowedProjectRoles,
|
||||
updateWorkspaceRole
|
||||
}: {
|
||||
getProject: GetProject
|
||||
updateProject: UpdateProject
|
||||
upsertProjectRole: UpsertProjectRole
|
||||
getProjectCollaborators: GetProjectCollaborators
|
||||
getWorkspaceRoles: GetWorkspaceRoles
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
|
||||
getWorkspaceRolesAndSeats: GetWorkspaceRolesAndSeats
|
||||
getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory
|
||||
updateWorkspaceRole: UpdateWorkspaceRole
|
||||
}) =>
|
||||
async ({
|
||||
projectId,
|
||||
workspaceId
|
||||
workspaceId,
|
||||
movedByUserId
|
||||
}: MoveProjectToWorkspaceArgs): Promise<StreamRecord> => {
|
||||
const project = await getProject({ projectId })
|
||||
|
||||
@@ -155,37 +170,56 @@ export const moveProjectToWorkspaceFactory =
|
||||
|
||||
// Update roles for current project members
|
||||
const projectTeam = await getProjectCollaborators({ projectId })
|
||||
const workspaceTeam = await getWorkspaceRoles({ workspaceId })
|
||||
const defaultProjectRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping(
|
||||
{ workspaceId }
|
||||
)
|
||||
const workspaceTeam = await getWorkspaceRolesAndSeats({ workspaceId })
|
||||
const {
|
||||
defaultProjectRole: getDefaultProjectRole,
|
||||
allowedProjectRoles: getAllowedProjectRoles
|
||||
} = await getWorkspaceRolesAllowedProjectRoles({
|
||||
workspaceId
|
||||
})
|
||||
|
||||
for (const projectMembers of chunk(projectTeam, 5)) {
|
||||
await Promise.all(
|
||||
projectMembers.map(
|
||||
async ({ id: userId, role: serverRole, streamRole: currentProjectRole }) => {
|
||||
// Update workspace role. Prefer existing workspace role if there is one.
|
||||
const currentWorkspaceRole = workspaceTeam.find(
|
||||
(role) => role.userId === userId
|
||||
)
|
||||
const nextWorkspaceRole = currentWorkspaceRole ?? {
|
||||
userId,
|
||||
workspaceId,
|
||||
role:
|
||||
serverRole === Roles.Server.Guest
|
||||
? Roles.Workspace.Guest
|
||||
: Roles.Workspace.Member,
|
||||
createdAt: new Date()
|
||||
}
|
||||
await updateWorkspaceRole(nextWorkspaceRole)
|
||||
const currentWorkspaceRole = workspaceTeam[userId]?.role
|
||||
const currentWorkspaceSeat = workspaceTeam[userId]?.seat
|
||||
|
||||
const nextWorkspaceRole = currentWorkspaceRole
|
||||
? currentWorkspaceRole
|
||||
: {
|
||||
userId,
|
||||
workspaceId,
|
||||
role:
|
||||
serverRole === Roles.Server.Guest
|
||||
? Roles.Workspace.Guest
|
||||
: Roles.Workspace.Member,
|
||||
createdAt: new Date()
|
||||
}
|
||||
|
||||
await updateWorkspaceRole({
|
||||
...nextWorkspaceRole,
|
||||
updatedByUserId: movedByUserId
|
||||
})
|
||||
|
||||
// Update project role. Prefer default workspace project role if more permissive.
|
||||
const defaultProjectRole =
|
||||
defaultProjectRoleMapping[nextWorkspaceRole.role] ?? Roles.Stream.Reviewer
|
||||
const nextProjectRole = orderByWeight(
|
||||
getDefaultProjectRole({
|
||||
workspaceRole: nextWorkspaceRole.role,
|
||||
seatType: currentWorkspaceSeat?.type
|
||||
}) ?? Roles.Stream.Reviewer
|
||||
const allowedProjectRoles = getAllowedProjectRoles({
|
||||
workspaceRole: nextWorkspaceRole.role,
|
||||
seatType: currentWorkspaceSeat?.type
|
||||
})
|
||||
const rolePicks = intersection(
|
||||
[currentProjectRole, defaultProjectRole],
|
||||
coreUserRoles
|
||||
)[0]
|
||||
allowedProjectRoles
|
||||
)
|
||||
const nextProjectRole = orderByWeight(rolePicks, coreUserRoles)[0]
|
||||
|
||||
// TODO: Shouldn't this be the service call that also fires events?
|
||||
await upsertProjectRole({
|
||||
userId,
|
||||
projectId,
|
||||
@@ -200,23 +234,76 @@ export const moveProjectToWorkspaceFactory =
|
||||
return await updateProject({ projectUpdate: { id: projectId, workspaceId } })
|
||||
}
|
||||
|
||||
export const getWorkspaceRoleToDefaultProjectRoleMappingFactory =
|
||||
({
|
||||
getWorkspace
|
||||
}: {
|
||||
getWorkspace: GetWorkspace
|
||||
}): GetWorkspaceRoleToDefaultProjectRoleMapping =>
|
||||
export const getWorkspaceRolesAllowedProjectRolesFactory =
|
||||
(deps: {
|
||||
getWorkspaceWithPlan: GetWorkspaceWithPlan
|
||||
}): GetWorkspaceRolesAllowedProjectRolesFactory =>
|
||||
async ({ workspaceId }) => {
|
||||
const workspace = await getWorkspace({ workspaceId })
|
||||
const workspace = await deps.getWorkspaceWithPlan({ workspaceId })
|
||||
|
||||
if (!workspace) {
|
||||
throw new WorkspaceNotFoundError()
|
||||
}
|
||||
|
||||
const isNewPlan = workspace.plan && isNewPaidPlanType(workspace.plan.name)
|
||||
|
||||
const allowedProjectRoles = (args: {
|
||||
workspaceRole: WorkspaceRoles
|
||||
seatType: MaybeNullOrUndefined<WorkspaceSeatType>
|
||||
}) => {
|
||||
const { workspaceRole, seatType = WorkspaceSeatType.Viewer } = args
|
||||
|
||||
switch (workspaceRole) {
|
||||
case Roles.Workspace.Guest:
|
||||
if (isNewPlan && seatType === WorkspaceSeatType.Viewer) {
|
||||
return [Roles.Stream.Reviewer]
|
||||
} else {
|
||||
return [Roles.Stream.Reviewer, Roles.Stream.Contributor]
|
||||
}
|
||||
case Roles.Workspace.Member:
|
||||
if (isNewPlan && seatType === WorkspaceSeatType.Viewer) {
|
||||
return [Roles.Stream.Reviewer]
|
||||
} else {
|
||||
return [Roles.Stream.Reviewer, Roles.Stream.Contributor, Roles.Stream.Owner]
|
||||
}
|
||||
case Roles.Workspace.Admin:
|
||||
return [Roles.Stream.Owner]
|
||||
default:
|
||||
throwUncoveredError(workspaceRole)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProjectRole = (args: {
|
||||
workspaceRole: WorkspaceRoles
|
||||
seatType: MaybeNullOrUndefined<WorkspaceSeatType>
|
||||
}) => {
|
||||
const { workspaceRole, seatType = WorkspaceSeatType.Viewer } = args
|
||||
const allowedRoles = allowedProjectRoles({ workspaceRole, seatType })
|
||||
|
||||
const role = (() => {
|
||||
switch (workspaceRole) {
|
||||
case Roles.Workspace.Guest:
|
||||
return null // No default role
|
||||
case Roles.Workspace.Member:
|
||||
if (isNewPlan && seatType === WorkspaceSeatType.Viewer)
|
||||
return Roles.Stream.Reviewer
|
||||
return workspace.defaultProjectRole
|
||||
case Roles.Workspace.Admin:
|
||||
return Roles.Stream.Owner
|
||||
default:
|
||||
throwUncoveredError(workspaceRole)
|
||||
}
|
||||
})()
|
||||
if (role && !allowedRoles.includes(role)) {
|
||||
throw new LogicError('Invalid default project role')
|
||||
}
|
||||
|
||||
return role
|
||||
}
|
||||
|
||||
return {
|
||||
[Roles.Workspace.Guest]: null,
|
||||
[Roles.Workspace.Member]: workspace.defaultProjectRole,
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner
|
||||
defaultProjectRole,
|
||||
allowedProjectRoles
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { GetUser } from '@/modules/core/domain/users/operations'
|
||||
import { NotFoundError } from '@/modules/shared/errors'
|
||||
import {
|
||||
ApproveWorkspaceJoinRequest,
|
||||
CreateWorkspaceJoinRequest,
|
||||
DenyWorkspaceJoinRequest,
|
||||
EnsureValidWorkspaceRoleSeat,
|
||||
@@ -119,8 +120,8 @@ export const approveWorkspaceJoinRequestFactory =
|
||||
upsertWorkspaceRole: UpsertWorkspaceRole
|
||||
emit: EventBus['emit']
|
||||
ensureValidWorkspaceRoleSeat: EnsureValidWorkspaceRoleSeat
|
||||
}) =>
|
||||
async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => {
|
||||
}): ApproveWorkspaceJoinRequest =>
|
||||
async ({ userId, workspaceId, approvedByUserId }) => {
|
||||
const requester = await getUserById(userId)
|
||||
if (!requester) {
|
||||
throw new NotFoundError('User not found')
|
||||
@@ -153,7 +154,11 @@ export const approveWorkspaceJoinRequestFactory =
|
||||
await emit({ eventName: WorkspaceEvents.Updated, payload: { workspace } })
|
||||
await emit({
|
||||
eventName: WorkspaceEvents.RoleUpdated,
|
||||
payload: { acl: { workspaceId, userId, role }, seatType: type }
|
||||
payload: {
|
||||
acl: { workspaceId, userId, role },
|
||||
seatType: type,
|
||||
updatedByUserId: approvedByUserId
|
||||
}
|
||||
})
|
||||
|
||||
await sendWorkspaceJoinRequestApprovedEmail({
|
||||
|
||||
@@ -96,7 +96,7 @@ export const assignWorkspaceSeatFactory =
|
||||
getWorkspaceRoleForUser: GetWorkspaceRoleForUser
|
||||
emit: EventBusEmit
|
||||
}): AssignWorkspaceSeat =>
|
||||
async ({ workspaceId, userId, type }) => {
|
||||
async ({ workspaceId, userId, type, assignedByUserId }) => {
|
||||
const workspaceAcl = await getWorkspaceRoleForUser({ workspaceId, userId })
|
||||
if (!workspaceAcl) {
|
||||
throw new NotFoundError('User does not have a role in the workspace')
|
||||
@@ -127,7 +127,11 @@ export const assignWorkspaceSeatFactory =
|
||||
|
||||
await emit({
|
||||
eventName: WorkspaceEvents.RoleUpdated,
|
||||
payload: { acl: workspaceAcl, seatType: seat.type }
|
||||
payload: {
|
||||
acl: workspaceAcl,
|
||||
seatType: seat.type,
|
||||
updatedByUserId: assignedByUserId
|
||||
}
|
||||
})
|
||||
|
||||
return seat
|
||||
|
||||
@@ -308,14 +308,16 @@ export const assignToWorkspace = async (
|
||||
await updateWorkspaceRole({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
role
|
||||
role,
|
||||
updatedByUserId: workspace.ownerId
|
||||
})
|
||||
|
||||
if (seatType) {
|
||||
await assignWorkspaceSeat({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
type: seatType
|
||||
type: seatType,
|
||||
assignedByUserId: workspace.ownerId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createWebhookConfigFactory,
|
||||
createWebhookEventFactory
|
||||
} from '@/modules/webhooks/repositories/webhooks'
|
||||
import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace'
|
||||
import {
|
||||
assignToWorkspace,
|
||||
BasicTestWorkspace,
|
||||
@@ -281,9 +282,7 @@ describe('Workspace project GQL CRUD', () => {
|
||||
})
|
||||
const newRole = await getUserStreamRole(workspaceGuest.id, roleProject.id)
|
||||
|
||||
expect(res).to.haveGraphQLErrors(
|
||||
'Workspace guests cannot be project owners'
|
||||
)
|
||||
expect(res).to.haveGraphQLErrors({ code: WorkspaceInvalidRoleError.code })
|
||||
expect(newRole).to.eq(Roles.Stream.Reviewer)
|
||||
})
|
||||
|
||||
@@ -310,12 +309,12 @@ describe('Workspace project GQL CRUD', () => {
|
||||
expect(resB).to.not.haveGraphQLErrors()
|
||||
expect(newRole).to.eq(Roles.Stream.Owner)
|
||||
} else {
|
||||
expect(resA).to.haveGraphQLErrors(
|
||||
'Workspace viewers can only be project reviewers.'
|
||||
)
|
||||
expect(resB).to.haveGraphQLErrors(
|
||||
'Workspace viewers can only be project reviewers.'
|
||||
)
|
||||
expect(resA).to.haveGraphQLErrors({
|
||||
code: WorkspaceInvalidRoleError.code
|
||||
})
|
||||
expect(resB).to.haveGraphQLErrors({
|
||||
code: WorkspaceInvalidRoleError.code
|
||||
})
|
||||
expect(newRole).to.eq(Roles.Stream.Reviewer)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -250,7 +250,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
|
||||
ensureValidWorkspaceRoleSeat: async () => {
|
||||
throw new Error('Should not happen')
|
||||
}
|
||||
})({ workspaceId: createRandomString(), userId: createRandomString() })
|
||||
})({
|
||||
workspaceId: createRandomString(),
|
||||
userId: createRandomString(),
|
||||
approvedByUserId: createRandomString()
|
||||
})
|
||||
)
|
||||
|
||||
expect(err.message).to.equal('User not found')
|
||||
@@ -270,7 +274,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
|
||||
ensureValidWorkspaceRoleSeat: async () => {
|
||||
throw new Error('Should not happen')
|
||||
}
|
||||
})({ workspaceId: createRandomString(), userId: createRandomString() })
|
||||
})({
|
||||
workspaceId: createRandomString(),
|
||||
userId: createRandomString(),
|
||||
approvedByUserId: createRandomString()
|
||||
})
|
||||
)
|
||||
|
||||
expect(err.message).to.equal(WorkspaceNotFoundError.defaultMessage)
|
||||
@@ -298,7 +306,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
|
||||
ensureValidWorkspaceRoleSeat: async () => {
|
||||
throw new Error('Should not happen')
|
||||
}
|
||||
})({ workspaceId: createRandomString(), userId: createRandomString() })
|
||||
})({
|
||||
workspaceId: createRandomString(),
|
||||
userId: createRandomString(),
|
||||
approvedByUserId: createRandomString()
|
||||
})
|
||||
)
|
||||
|
||||
expect(err.message).to.equal('Workspace join request not found')
|
||||
@@ -364,7 +376,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
})({ workspaceId: workspace.id, userId: user.id })
|
||||
})({ workspaceId: workspace.id, userId: user.id, approvedByUserId: user.id })
|
||||
).to.equal(true)
|
||||
|
||||
expect(
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createRandomEmail,
|
||||
createRandomString
|
||||
} from '@/modules/core/helpers/testHelpers'
|
||||
import { deleteProjectRoleFactory } from '@/modules/core/repositories/streams'
|
||||
import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing'
|
||||
import { getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
|
||||
import {
|
||||
@@ -12,12 +13,14 @@ import {
|
||||
} from '@/modules/workspaces/tests/helpers/creation'
|
||||
import { BasicTestUser, createTestUser } from '@/test/authHelper'
|
||||
import {
|
||||
GetProjectCollaboratorsDocument,
|
||||
UpdateWorkspaceSeatTypeDocument,
|
||||
WorkspaceUpdateSeatTypeInput
|
||||
} from '@/test/graphql/generated/graphql'
|
||||
import { testApolloServer, TestApolloServer } from '@/test/graphqlHelper'
|
||||
import { beforeEachContext } from '@/test/hooks'
|
||||
import { StripeClientMock } from '@/test/mocks/global'
|
||||
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
|
||||
import { Roles } from '@speckle/shared'
|
||||
import { expect } from 'chai'
|
||||
|
||||
@@ -112,7 +115,7 @@ describe('Workspace Seats @graphql', () => {
|
||||
expect(res.data?.workspaceMutations.updateSeatType).to.not.be.ok
|
||||
})
|
||||
|
||||
it('should assign a workspace seat with the provided type and reconcile subscription', async () => {
|
||||
it('should upgrade a workspace seat and reconcile subscription', async () => {
|
||||
const user: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
@@ -151,5 +154,107 @@ describe('Workspace Seats @graphql', () => {
|
||||
expect(reconcileArgs.prorationBehavior).to.eq('always_invoice') // new plan
|
||||
expect(reconcileArgs.subscriptionData.products.length).to.be.ok
|
||||
})
|
||||
|
||||
it('should downgrade a workspace seat', async () => {
|
||||
const user: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail(),
|
||||
role: Roles.Server.User,
|
||||
verified: true
|
||||
}
|
||||
await createTestUser(user)
|
||||
await assignToWorkspace(
|
||||
testWorkspace1,
|
||||
user,
|
||||
Roles.Workspace.Member,
|
||||
WorkspaceSeatType.Editor
|
||||
)
|
||||
|
||||
const res = await updateSeatType({
|
||||
workspaceId: testWorkspace1.id,
|
||||
userId: user.id,
|
||||
seatType: WorkspaceSeatType.Viewer
|
||||
})
|
||||
|
||||
expect(res).to.not.haveGraphQLErrors()
|
||||
expect(
|
||||
res.data?.workspaceMutations.updateSeatType.team.items.find(
|
||||
(i) => i.id === user.id
|
||||
)?.seatType
|
||||
).to.eq(WorkspaceSeatType.Viewer)
|
||||
})
|
||||
|
||||
it('should assign away project ownership on downgrade to viewer seat', async () => {
|
||||
const testWorkspace2: BasicTestWorkspace = {
|
||||
id: '',
|
||||
slug: '',
|
||||
ownerId: '',
|
||||
name: 'Test Workspace 2'
|
||||
}
|
||||
await createTestWorkspace(testWorkspace2, workspaceAdmin, {
|
||||
addPlan: { name: 'pro', status: 'valid' }
|
||||
})
|
||||
|
||||
const user: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail(),
|
||||
role: Roles.Server.User,
|
||||
verified: true
|
||||
}
|
||||
await createTestUser(user)
|
||||
await assignToWorkspace(
|
||||
testWorkspace2,
|
||||
user,
|
||||
Roles.Workspace.Member,
|
||||
WorkspaceSeatType.Editor
|
||||
)
|
||||
|
||||
const userOwnedProject: BasicTestStream = {
|
||||
name: 'User Owned Project',
|
||||
isPublic: false,
|
||||
id: '',
|
||||
ownerId: '',
|
||||
workspaceId: testWorkspace2.id
|
||||
}
|
||||
await createTestStream(userOwnedProject, user)
|
||||
|
||||
// Manually remove admin stream role, to test that it's being added
|
||||
await deleteProjectRoleFactory({ db })({
|
||||
projectId: userOwnedProject.id,
|
||||
userId: workspaceAdmin.id
|
||||
})
|
||||
|
||||
const res1 = await updateSeatType({
|
||||
workspaceId: testWorkspace2.id,
|
||||
userId: user.id,
|
||||
seatType: WorkspaceSeatType.Viewer
|
||||
})
|
||||
|
||||
expect(res1).to.not.haveGraphQLErrors()
|
||||
expect(
|
||||
res1.data?.workspaceMutations.updateSeatType.team.items.find(
|
||||
(i) => i.id === user.id
|
||||
)?.seatType
|
||||
).to.eq(WorkspaceSeatType.Viewer)
|
||||
|
||||
// Check project ownership
|
||||
const res2 = await apollo.execute(
|
||||
GetProjectCollaboratorsDocument,
|
||||
{
|
||||
projectId: userOwnedProject.id
|
||||
},
|
||||
{ assertNoErrors: true }
|
||||
)
|
||||
|
||||
expect(res2.data?.project.id).to.eq(userOwnedProject.id)
|
||||
expect(res2.data?.project.team.length).to.greaterThanOrEqual(2)
|
||||
|
||||
const adminRes = res2.data?.project.team.find((t) => t.id === workspaceAdmin.id)
|
||||
const userRes = res2.data?.project.team.find((t) => t.id === user.id)
|
||||
expect(adminRes?.role).to.eq(Roles.Stream.Owner)
|
||||
expect(userRes?.role).to.eq(Roles.Stream.Reviewer)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
} from '@/modules/workspaces/events/eventListener'
|
||||
import { expect } from 'chai'
|
||||
import { chunk } from 'lodash'
|
||||
import {
|
||||
GetWorkspaceRolesAndSeats,
|
||||
WorkspaceSeatType
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
|
||||
describe('Event handlers', () => {
|
||||
describe('onProjectCreatedFactory creates a function, that', () => {
|
||||
@@ -38,13 +42,28 @@ describe('Event handlers', () => {
|
||||
|
||||
const projectRoles: StreamAclRecord[] = []
|
||||
|
||||
// TODO: New plan support
|
||||
const onProjectCreated = onProjectCreatedFactory({
|
||||
getWorkspaceRoles: async () => workspaceRoles,
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner,
|
||||
[Roles.Workspace.Member]: Roles.Stream.Contributor,
|
||||
[Roles.Workspace.Guest]: null
|
||||
}),
|
||||
getWorkspaceRolesAndSeats: async () =>
|
||||
workspaceRoles.reduce((acc, role) => {
|
||||
acc[role.userId] = { role, seat: null, userId: role.userId }
|
||||
return acc
|
||||
}, {} as Awaited<ReturnType<GetWorkspaceRolesAndSeats>>),
|
||||
getWorkspaceRolesAllowedProjectRoles: async () => {
|
||||
const mapping = {
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner,
|
||||
[Roles.Workspace.Member]: Roles.Stream.Contributor,
|
||||
[Roles.Workspace.Guest]: null
|
||||
}
|
||||
return {
|
||||
defaultProjectRole: ({ workspaceRole }) => {
|
||||
return mapping[workspaceRole]
|
||||
},
|
||||
allowedProjectRoles: ({ workspaceRole }) => {
|
||||
return [mapping[workspaceRole] || Roles.Stream.Reviewer]
|
||||
}
|
||||
}
|
||||
},
|
||||
upsertProjectRole: async ({ projectId, userId, role }) => {
|
||||
projectRoles.push({
|
||||
resourceId: projectId,
|
||||
@@ -68,29 +87,47 @@ describe('Event handlers', () => {
|
||||
describe('onWorkspaceRoleUpdatedFactory creates a function, that', () => {
|
||||
it('assigns no project roles if the role mapping returns null', async () => {
|
||||
let isDeleteCalled = false
|
||||
const fakeProject = { id: 'test' } as StreamRecord
|
||||
|
||||
await onWorkspaceRoleUpdatedFactory({
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner,
|
||||
[Roles.Workspace.Member]: Roles.Stream.Contributor,
|
||||
[Roles.Workspace.Guest]: null
|
||||
}),
|
||||
getWorkspaceRolesAllowedProjectRoles: async () => {
|
||||
const mapping = {
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner,
|
||||
[Roles.Workspace.Member]: Roles.Stream.Contributor,
|
||||
[Roles.Workspace.Guest]: null
|
||||
}
|
||||
return {
|
||||
defaultProjectRole: ({ workspaceRole }) => {
|
||||
return mapping[workspaceRole]
|
||||
},
|
||||
allowedProjectRoles: ({ workspaceRole }) => {
|
||||
return [mapping[workspaceRole] || Roles.Stream.Reviewer]
|
||||
}
|
||||
}
|
||||
},
|
||||
async *queryAllWorkspaceProjects() {
|
||||
yield [{ id: 'test' } as StreamRecord]
|
||||
yield [fakeProject as StreamRecord]
|
||||
},
|
||||
deleteProjectRole: async () => {
|
||||
isDeleteCalled = true
|
||||
return undefined
|
||||
getStreamsCollaboratorCounts: async () => {
|
||||
return {}
|
||||
},
|
||||
upsertProjectRole: async () => {
|
||||
expect.fail()
|
||||
setStreamCollaborator: async ({ role }) => {
|
||||
if (!role) {
|
||||
isDeleteCalled = true
|
||||
} else {
|
||||
expect.fail()
|
||||
}
|
||||
|
||||
return fakeProject
|
||||
}
|
||||
})({
|
||||
acl: {
|
||||
role: Roles.Workspace.Guest,
|
||||
userId: cryptoRandomString({ length: 10 }),
|
||||
workspaceId: cryptoRandomString({ length: 10 })
|
||||
}
|
||||
},
|
||||
seatType: WorkspaceSeatType.Editor,
|
||||
updatedByUserId: cryptoRandomString({ length: 10 })
|
||||
})
|
||||
|
||||
expect(isDeleteCalled).to.be.true
|
||||
@@ -108,30 +145,50 @@ describe('Event handlers', () => {
|
||||
const storedRoles: { userId: string; role: StreamRoles; projectId: string }[] = []
|
||||
let trackProjectUpdate: boolean | undefined = false
|
||||
await onWorkspaceRoleUpdatedFactory({
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner,
|
||||
[Roles.Workspace.Member]: projectRole,
|
||||
[Roles.Workspace.Guest]: null
|
||||
}),
|
||||
getWorkspaceRolesAllowedProjectRoles: async () => {
|
||||
const mapping = {
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner,
|
||||
[Roles.Workspace.Member]: projectRole,
|
||||
[Roles.Workspace.Guest]: null
|
||||
}
|
||||
return {
|
||||
defaultProjectRole: ({ workspaceRole }) => {
|
||||
return mapping[workspaceRole]
|
||||
},
|
||||
allowedProjectRoles: ({ workspaceRole }) => {
|
||||
return [mapping[workspaceRole] || Roles.Stream.Reviewer]
|
||||
}
|
||||
}
|
||||
},
|
||||
async *queryAllWorkspaceProjects() {
|
||||
for (const projIds of chunk(projectIds, 3)) {
|
||||
yield projIds.map((projId) => ({ id: projId } as unknown as StreamRecord))
|
||||
}
|
||||
},
|
||||
deleteProjectRole: async () => {
|
||||
expect.fail()
|
||||
getStreamsCollaboratorCounts: async () => {
|
||||
return {}
|
||||
},
|
||||
upsertProjectRole: async (args, options) => {
|
||||
storedRoles.push(args)
|
||||
trackProjectUpdate = trackProjectUpdate || options?.trackProjectUpdate
|
||||
return {} as StreamRecord
|
||||
setStreamCollaborator: async (params, options) => {
|
||||
if (!params.role) {
|
||||
return expect.fail()
|
||||
} else {
|
||||
storedRoles.push({
|
||||
userId: params.userId,
|
||||
role: params.role,
|
||||
projectId: params.streamId
|
||||
})
|
||||
trackProjectUpdate = trackProjectUpdate || options?.trackProjectUpdate
|
||||
return {} as StreamRecord
|
||||
}
|
||||
}
|
||||
})({
|
||||
acl: {
|
||||
role: Roles.Workspace.Member,
|
||||
userId,
|
||||
workspaceId: cryptoRandomString({ length: 10 })
|
||||
}
|
||||
},
|
||||
seatType: WorkspaceSeatType.Editor,
|
||||
updatedByUserId: cryptoRandomString({ length: 10 })
|
||||
})
|
||||
expect(storedRoles).deep.equals(
|
||||
projectIds.map((projectId) => ({ projectId, role: projectRole, userId }))
|
||||
|
||||
@@ -770,10 +770,12 @@ describe('Workspace role services', () => {
|
||||
it('sets the workspace role', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const workspaceOwnerId = cryptoRandomString({ length: 10 })
|
||||
const role = {
|
||||
userId,
|
||||
workspaceId,
|
||||
role: Roles.Workspace.Member
|
||||
role: Roles.Workspace.Member,
|
||||
updatedByUserId: workspaceOwnerId
|
||||
}
|
||||
|
||||
const { updateWorkspaceRole, context } = buildUpdateWorkspaceRoleAndTestContext({
|
||||
@@ -791,6 +793,7 @@ describe('Workspace role services', () => {
|
||||
it('emits a role-updated event', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const workspaceOwnerId = cryptoRandomString({ length: 10 })
|
||||
const role: Pick<WorkspaceAcl, 'userId' | 'workspaceId' | 'role'> = {
|
||||
userId,
|
||||
workspaceId,
|
||||
@@ -801,7 +804,7 @@ describe('Workspace role services', () => {
|
||||
workspaceId
|
||||
})
|
||||
|
||||
await updateWorkspaceRole(role)
|
||||
await updateWorkspaceRole({ ...role, updatedByUserId: workspaceOwnerId })
|
||||
|
||||
const payload = {
|
||||
...(context.eventData
|
||||
@@ -811,11 +814,16 @@ describe('Workspace role services', () => {
|
||||
|
||||
expect(context.eventData.isCalled).to.be.true
|
||||
expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated)
|
||||
expect(payload).to.deep.equal({ acl: role, seatType: WorkspaceSeatType.Editor })
|
||||
expect(payload).to.deep.equal({
|
||||
acl: role,
|
||||
seatType: WorkspaceSeatType.Editor,
|
||||
updatedByUserId: workspaceOwnerId
|
||||
})
|
||||
})
|
||||
it('throws if attempting to remove the last admin in a workspace', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const workspaceOwnerId = cryptoRandomString({ length: 10 })
|
||||
const role: WorkspaceAcl = {
|
||||
userId,
|
||||
workspaceId,
|
||||
@@ -829,7 +837,11 @@ describe('Workspace role services', () => {
|
||||
})
|
||||
|
||||
await expectToThrow(() =>
|
||||
updateWorkspaceRole({ ...role, role: Roles.Workspace.Member })
|
||||
updateWorkspaceRole({
|
||||
...role,
|
||||
role: Roles.Workspace.Member,
|
||||
updatedByUserId: workspaceOwnerId
|
||||
})
|
||||
)
|
||||
})
|
||||
it('throws if attempting to set user role to more than GUEST and workspace domain protection is enabled and user has not an email matching a workspace domain', async () => {
|
||||
@@ -880,7 +892,8 @@ describe('Workspace role services', () => {
|
||||
updateWorkspaceRole({
|
||||
workspaceId,
|
||||
userId: guestId,
|
||||
role: Roles.Workspace.Member
|
||||
role: Roles.Workspace.Member,
|
||||
updatedByUserId: adminId
|
||||
})
|
||||
)
|
||||
expect(err.message).to.eq(new WorkspaceProtectedError().message)
|
||||
@@ -888,6 +901,7 @@ describe('Workspace role services', () => {
|
||||
it('sets roles on workspace projects when user added to workspace as admin', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const workspaceOwnerId = cryptoRandomString({ length: 10 })
|
||||
const projectId = cryptoRandomString({ length: 10 })
|
||||
|
||||
const workspaceRole: WorkspaceAcl = {
|
||||
@@ -902,7 +916,7 @@ describe('Workspace role services', () => {
|
||||
workspaceProjects: [{ id: projectId } as StreamRecord]
|
||||
})
|
||||
|
||||
await updateWorkspaceRole(workspaceRole)
|
||||
await updateWorkspaceRole({ ...workspaceRole, updatedByUserId: workspaceOwnerId })
|
||||
|
||||
expect(context.workspaceProjectRoles.length).to.equal(1)
|
||||
expect(context.workspaceProjectRoles[0].userId).to.equal(userId)
|
||||
@@ -912,6 +926,7 @@ describe('Workspace role services', () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const projectId = cryptoRandomString({ length: 10 })
|
||||
const workspaceOwnerId = cryptoRandomString({ length: 10 })
|
||||
|
||||
const workspaceRole: WorkspaceAcl = {
|
||||
userId,
|
||||
@@ -925,7 +940,7 @@ describe('Workspace role services', () => {
|
||||
workspaceProjects: [{ id: projectId } as StreamRecord]
|
||||
})
|
||||
|
||||
await updateWorkspaceRole(workspaceRole)
|
||||
await updateWorkspaceRole({ ...workspaceRole, updatedByUserId: workspaceOwnerId })
|
||||
|
||||
expect(context.workspaceProjectRoles.length).to.equal(1)
|
||||
expect(context.workspaceProjectRoles[0].userId).to.equal(userId)
|
||||
@@ -935,6 +950,7 @@ describe('Workspace role services', () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const projectId = cryptoRandomString({ length: 10 })
|
||||
const workspaceOwnerId = cryptoRandomString({ length: 10 })
|
||||
|
||||
const workspaceRole: WorkspaceAcl = {
|
||||
userId,
|
||||
@@ -948,7 +964,7 @@ describe('Workspace role services', () => {
|
||||
workspaceProjects: [{ id: projectId } as StreamRecord]
|
||||
})
|
||||
|
||||
await updateWorkspaceRole(workspaceRole)
|
||||
await updateWorkspaceRole({ ...workspaceRole, updatedByUserId: workspaceOwnerId })
|
||||
|
||||
expect(context.workspaceProjectRoles.find((role) => role.userId === userId)).to
|
||||
.not.exist
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ProjectTeamMember } from '@/modules/core/domain/projects/types'
|
||||
import { ProjectNotFoundError } from '@/modules/core/errors/projects'
|
||||
import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
|
||||
import { GetWorkspaceRolesAllowedProjectRolesFactory } from '@/modules/workspaces/domain/operations'
|
||||
import { WorkspaceInvalidProjectError } from '@/modules/workspaces/errors/workspace'
|
||||
import {
|
||||
moveProjectToWorkspaceFactory,
|
||||
@@ -12,11 +13,22 @@ import { Roles } from '@speckle/shared'
|
||||
import { expect } from 'chai'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
|
||||
const getWorkspaceRoleToDefaultProjectRoleMapping = async () => ({
|
||||
'workspace:admin': Roles.Stream.Owner,
|
||||
'workspace:guest': null,
|
||||
'workspace:member': Roles.Stream.Contributor
|
||||
})
|
||||
const getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory =
|
||||
async () => {
|
||||
const mapping = {
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner,
|
||||
[Roles.Workspace.Member]: Roles.Stream.Contributor,
|
||||
[Roles.Workspace.Guest]: null
|
||||
}
|
||||
return {
|
||||
defaultProjectRole: ({ workspaceRole }) => {
|
||||
return mapping[workspaceRole]
|
||||
},
|
||||
allowedProjectRoles: () => {
|
||||
return Object.values(Roles.Stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Project retrieval services', () => {
|
||||
describe('queryAllWorkspaceProjectFactory returns a generator, that', () => {
|
||||
@@ -105,10 +117,10 @@ describe('Project management services', () => {
|
||||
getProjectCollaborators: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspaceRoles: async () => {
|
||||
getWorkspaceRolesAndSeats: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: async () => {
|
||||
getWorkspaceRolesAllowedProjectRoles: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
updateWorkspaceRole: async () => {
|
||||
@@ -119,7 +131,8 @@ describe('Project management services', () => {
|
||||
const err = await expectToThrow(() =>
|
||||
moveProjectToWorkspace({
|
||||
projectId: cryptoRandomString({ length: 6 }),
|
||||
workspaceId: cryptoRandomString({ length: 6 })
|
||||
workspaceId: cryptoRandomString({ length: 6 }),
|
||||
movedByUserId: cryptoRandomString({ length: 10 })
|
||||
})
|
||||
)
|
||||
expect(err.message).to.equal(new ProjectNotFoundError().message)
|
||||
@@ -140,10 +153,10 @@ describe('Project management services', () => {
|
||||
getProjectCollaborators: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspaceRoles: async () => {
|
||||
getWorkspaceRolesAndSeats: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: async () => {
|
||||
getWorkspaceRolesAllowedProjectRoles: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
updateWorkspaceRole: async () => {
|
||||
@@ -154,7 +167,8 @@ describe('Project management services', () => {
|
||||
const err = await expectToThrow(() =>
|
||||
moveProjectToWorkspace({
|
||||
projectId: cryptoRandomString({ length: 6 }),
|
||||
workspaceId: cryptoRandomString({ length: 6 })
|
||||
workspaceId: cryptoRandomString({ length: 6 }),
|
||||
movedByUserId: cryptoRandomString({ length: 10 })
|
||||
})
|
||||
)
|
||||
expect(err instanceof WorkspaceInvalidProjectError).to.be.true
|
||||
@@ -185,27 +199,27 @@ describe('Project management services', () => {
|
||||
} as unknown as ProjectTeamMember
|
||||
]
|
||||
},
|
||||
getWorkspaceRoles: async () => {
|
||||
return [
|
||||
{
|
||||
userId,
|
||||
role: Roles.Workspace.Admin,
|
||||
workspaceId,
|
||||
createdAt: new Date()
|
||||
getWorkspaceRolesAndSeats: async () => {
|
||||
return {
|
||||
[userId]: {
|
||||
role: {
|
||||
userId,
|
||||
role: Roles.Workspace.Admin,
|
||||
workspaceId,
|
||||
createdAt: new Date()
|
||||
},
|
||||
seat: null,
|
||||
userId
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
|
||||
'workspace:admin': Roles.Stream.Owner,
|
||||
'workspace:guest': null,
|
||||
'workspace:member': Roles.Stream.Contributor
|
||||
}),
|
||||
getWorkspaceRolesAllowedProjectRoles,
|
||||
updateWorkspaceRole: async (role) => {
|
||||
updatedRoles.push(role)
|
||||
}
|
||||
})
|
||||
|
||||
await moveProjectToWorkspace({ projectId, workspaceId })
|
||||
await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId })
|
||||
|
||||
expect(updatedRoles.length).to.equal(1)
|
||||
expect(updatedRoles[0].role).to.equal(Roles.Workspace.Admin)
|
||||
@@ -236,16 +250,16 @@ describe('Project management services', () => {
|
||||
} as unknown as ProjectTeamMember
|
||||
]
|
||||
},
|
||||
getWorkspaceRoles: async () => {
|
||||
return []
|
||||
getWorkspaceRolesAndSeats: async () => {
|
||||
return {}
|
||||
},
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping,
|
||||
getWorkspaceRolesAllowedProjectRoles,
|
||||
updateWorkspaceRole: async (role) => {
|
||||
updatedRoles.push(role)
|
||||
}
|
||||
})
|
||||
|
||||
await moveProjectToWorkspace({ projectId, workspaceId })
|
||||
await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId })
|
||||
|
||||
expect(updatedRoles.length).to.equal(1)
|
||||
expect(updatedRoles[0].role).to.equal(Roles.Workspace.Member)
|
||||
@@ -277,16 +291,16 @@ describe('Project management services', () => {
|
||||
} as unknown as ProjectTeamMember
|
||||
]
|
||||
},
|
||||
getWorkspaceRoles: async () => {
|
||||
return []
|
||||
getWorkspaceRolesAndSeats: async () => {
|
||||
return {}
|
||||
},
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping,
|
||||
getWorkspaceRolesAllowedProjectRoles,
|
||||
updateWorkspaceRole: async (role) => {
|
||||
updatedRoles.push(role)
|
||||
}
|
||||
})
|
||||
|
||||
await moveProjectToWorkspace({ projectId, workspaceId })
|
||||
await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId })
|
||||
|
||||
expect(updatedRoles.length).to.equal(1)
|
||||
expect(updatedRoles[0].role).to.equal(Roles.Workspace.Guest)
|
||||
@@ -319,14 +333,14 @@ describe('Project management services', () => {
|
||||
} as unknown as ProjectTeamMember
|
||||
]
|
||||
},
|
||||
getWorkspaceRoles: async () => {
|
||||
return []
|
||||
getWorkspaceRolesAndSeats: async () => {
|
||||
return {}
|
||||
},
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping,
|
||||
getWorkspaceRolesAllowedProjectRoles,
|
||||
updateWorkspaceRole: async () => {}
|
||||
})
|
||||
|
||||
await moveProjectToWorkspace({ projectId, workspaceId })
|
||||
await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId })
|
||||
|
||||
expect(updatedRoles.length).to.equal(1)
|
||||
expect(updatedRoles[0].role).to.equal(Roles.Stream.Owner)
|
||||
@@ -359,18 +373,14 @@ describe('Project management services', () => {
|
||||
} as unknown as ProjectTeamMember
|
||||
]
|
||||
},
|
||||
getWorkspaceRoles: async () => {
|
||||
return []
|
||||
getWorkspaceRolesAndSeats: async () => {
|
||||
return {}
|
||||
},
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
|
||||
[Roles.Workspace.Guest]: null,
|
||||
[Roles.Workspace.Member]: Roles.Stream.Contributor,
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner
|
||||
}),
|
||||
getWorkspaceRolesAllowedProjectRoles,
|
||||
updateWorkspaceRole: async () => {}
|
||||
})
|
||||
|
||||
await moveProjectToWorkspace({ projectId, workspaceId })
|
||||
await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId })
|
||||
|
||||
expect(updatedRoles.length).to.equal(1)
|
||||
expect(updatedRoles[0].role).to.equal(Roles.Stream.Contributor)
|
||||
@@ -403,25 +413,25 @@ describe('Project management services', () => {
|
||||
} as unknown as ProjectTeamMember
|
||||
]
|
||||
},
|
||||
getWorkspaceRoles: async () => {
|
||||
return [
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
role: Roles.Workspace.Admin,
|
||||
createdAt: new Date()
|
||||
getWorkspaceRolesAndSeats: async () => {
|
||||
return {
|
||||
[userId]: {
|
||||
role: {
|
||||
userId,
|
||||
workspaceId,
|
||||
role: Roles.Workspace.Admin,
|
||||
createdAt: new Date()
|
||||
},
|
||||
seat: null,
|
||||
userId
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
|
||||
[Roles.Workspace.Guest]: null,
|
||||
[Roles.Workspace.Member]: Roles.Stream.Contributor,
|
||||
[Roles.Workspace.Admin]: Roles.Stream.Owner
|
||||
}),
|
||||
getWorkspaceRolesAllowedProjectRoles,
|
||||
updateWorkspaceRole: async () => {}
|
||||
})
|
||||
|
||||
await moveProjectToWorkspace({ projectId, workspaceId })
|
||||
await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId })
|
||||
|
||||
expect(updatedRoles.length).to.equal(1)
|
||||
expect(updatedRoles[0].role).to.equal(Roles.Stream.Owner)
|
||||
|
||||
@@ -34,6 +34,7 @@ type WorkspaceRoleUpdatedPayload = {
|
||||
acl: Pick<WorkspaceAcl, 'userId' | 'workspaceId' | 'role'>
|
||||
seatType: WorkspaceSeatType
|
||||
flags?: { skipProjectRoleUpdatesFor: string[] }
|
||||
updatedByUserId: string
|
||||
}
|
||||
type WorkspaceJoinedFromDiscoveryPayload = {
|
||||
userId: string
|
||||
|
||||
@@ -5577,6 +5577,13 @@ export type UpdateProjectRoleMutationVariables = Exact<{
|
||||
|
||||
export type UpdateProjectRoleMutation = { __typename?: 'Mutation', projectMutations: { __typename?: 'ProjectMutations', updateRole: { __typename?: 'Project', id: string, name: string, description?: string | null, visibility: SimpleProjectVisibility, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string } } };
|
||||
|
||||
export type GetProjectCollaboratorsQueryVariables = Exact<{
|
||||
projectId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetProjectCollaboratorsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, team: Array<{ __typename?: 'ProjectCollaborator', id: string, role: string }> } };
|
||||
|
||||
export type CreateServerInviteMutationVariables = Exact<{
|
||||
input: ServerInviteCreateInput;
|
||||
}>;
|
||||
@@ -6058,6 +6065,7 @@ export const GetProjectDocument = {"kind":"Document","definitions":[{"kind":"Ope
|
||||
export const CreateProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<CreateProjectMutation, CreateProjectMutationVariables>;
|
||||
export const BatchDeleteProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BatchDeleteProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"batchDelete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}]}]}}]}}]} as unknown as DocumentNode<BatchDeleteProjectsMutation, BatchDeleteProjectsMutationVariables>;
|
||||
export const UpdateProjectRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateProjectRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectUpdateRoleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<UpdateProjectRoleMutation, UpdateProjectRoleMutationVariables>;
|
||||
export const GetProjectCollaboratorsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectCollaborators"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}}]} as unknown as DocumentNode<GetProjectCollaboratorsQuery, GetProjectCollaboratorsQueryVariables>;
|
||||
export const CreateServerInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServerInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInviteCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<CreateServerInviteMutation, CreateServerInviteMutationVariables>;
|
||||
export const CreateStreamInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStreamInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"StreamInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamInviteCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<CreateStreamInviteMutation, CreateStreamInviteMutationVariables>;
|
||||
export const ResendInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResendInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteResend"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}]}]}}]} as unknown as DocumentNode<ResendInviteMutation, ResendInviteMutationVariables>;
|
||||
|
||||
@@ -93,3 +93,15 @@ export const updateProjectRoleMutation = gql`
|
||||
|
||||
${basicProjectFieldsFragment}
|
||||
`
|
||||
|
||||
export const getProjectCollaboratorsQuery = gql`
|
||||
query GetProjectCollaborators($projectId: String!) {
|
||||
project(id: $projectId) {
|
||||
id
|
||||
team {
|
||||
id
|
||||
role
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
MaybeNullOrUndefined,
|
||||
Nullable,
|
||||
Optional,
|
||||
retry,
|
||||
wait
|
||||
} from '@speckle/shared'
|
||||
import * as mocha from 'mocha'
|
||||
@@ -233,8 +234,11 @@ const truncateTablesFactory = (deps: { db: Knex }) => async (tableNames?: string
|
||||
if (!tableNames.length) return // Nothing to truncate
|
||||
|
||||
// We're deleting everything, so lets turn off triggers to avoid deadlocks/slowdowns
|
||||
await deps.db.transaction(async (trx) => {
|
||||
await trx.raw(`
|
||||
// This still seems to randomly cause deadlocks, so adding a retry
|
||||
await retry(
|
||||
async () =>
|
||||
await deps.db.transaction(async (trx) => {
|
||||
await trx.raw(`
|
||||
-- Disable triggers and foreign key constraints for this session
|
||||
SET session_replication_role = replica;
|
||||
|
||||
@@ -243,7 +247,10 @@ const truncateTablesFactory = (deps: { db: Knex }) => async (tableNames?: string
|
||||
-- Re-enable triggers and foreign key constraints
|
||||
SET session_replication_role = DEFAULT;
|
||||
`)
|
||||
})
|
||||
}),
|
||||
3,
|
||||
200
|
||||
)
|
||||
} else {
|
||||
await deps.db.raw(`truncate table ${tableNames.join(',')} cascade`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user