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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user