Merge branch 'main' of github.com:specklesystems/speckle-server into alessandro/web-1172-change-the-users-repository-to-abstract-the-email-field-in

This commit is contained in:
Alessandro Magionami
2024-07-22 10:37:07 +02:00
39 changed files with 1956 additions and 530 deletions
+1
View File
@@ -1,4 +1,5 @@
/* eslint-disable camelcase */
/* eslint-disable no-restricted-imports */
/* istanbul ignore file */
import './bootstrap'
import http from 'http'
@@ -1,90 +0,0 @@
type Workspace {
id: ID!
name: String!
description: String
createdAt: DateTime!
updatedAt: DateTime!
"""
Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you.
"""
role: String
team: [WorkspaceCollaborator!]!
invitedTeam: [PendingWorkspaceCollaborator!]
projects(
limit: Int! = 25
cursor: String
filter: UserProjectsFilter
): ProjectCollection!
}
type WorkspaceCollaborator {
id: ID!
role: String!
user: LimitedUser!
}
type PendingWorkspaceCollaborator {
id: ID!
inviteId: String!
workspaceId: String!
workspaceName: String!
"""
E-mail address or name of the invited user
"""
title: String!
role: String!
invitedBy: LimitedUser!
"""
Set only if user is registered
"""
user: LimitedUser
"""
Only available if the active user is the pending workspace collaborator
"""
token: String
}
type WorkspaceCollection {
totalCount: Int!
cursor: String
items: [Workspace!]!
}
extend type User {
"""
Get the workspaces for the user
"""
workspaces(
limit: Int! = 25
cursor: String = null
filter: UserWorkspacesFilter
): WorkspaceCollection! @isOwner
}
extend type Project {
workspace: Workspace
}
type ServerWorkspacesInfo {
"""
This is a backend control variable for the workspaces feature set.
Since workspaces need a backend logic to be enabled, this is not enough as a feature flag.
"""
workspacesEnabled: Boolean!
}
extend type ServerInfo {
workspaces: ServerWorkspacesInfo!
}
extend type AdminQueries {
workspaceList(
query: String
limit: Int! = 25
cursor: String = null
): WorkspaceCollection!
}
input UserWorkspacesFilter {
search: String
}
@@ -0,0 +1,176 @@
extend type Query {
workspace(id: String!): Workspace! @hasScope(scope: "workspace:read")
}
input WorkspaceCreateInput {
name: String!
description: String
logoUrl: String
}
input WorkspaceUpdateInput {
id: String!
name: String
description: String
logoUrl: String
}
input WorkspaceRoleUpdateInput {
userId: String!
workspaceId: String!
role: WorkspaceRole!
}
input WorkspaceRoleDeleteInput {
userId: String!
workspaceId: String!
}
extend type Mutation {
workspaceMutations: WorkspaceMutations! @hasServerRole(role: SERVER_USER)
}
type WorkspaceMutations {
create(input: WorkspaceCreateInput!): Workspace! @hasScope(scope: "workspace:create")
delete(workspaceId: String!): Workspace! @hasScope(scope: "workspace:delete")
update(input: WorkspaceUpdateInput!): Workspace! @hasScope(scope: "workspace:update")
"""
TODO: `@hasWorkspaceRole(role: WORKSPACE_ADMIN)` for role changes
"""
updateRole(input: WorkspaceRoleUpdateInput!): Boolean!
@hasScope(scope: "workspace:update")
deleteRole(input: WorkspaceRoleDeleteInput!): Boolean!
@hasScope(scope: "workspace:update")
invites: WorkspaceInviteMutations!
}
input WorkspaceInviteCreateInput {
"""
Either this or userId must be filled
"""
email: String
"""
Either this or email must be filled
"""
userId: String
"""
Defaults to the member role, if not specified
"""
role: WorkspaceRole
}
input WorkspaceInviteUseInput {
workspaceId: String!
token: String!
accept: Boolean!
}
type WorkspaceInviteMutations {
create(workspaceId: String!, input: WorkspaceInviteCreateInput!): Workspace!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
batchCreate(workspaceId: String!, input: [WorkspaceInviteCreateInput!]!): Workspace!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
use(input: WorkspaceInviteUseInput!): Boolean!
cancel(workspaceId: String!, inviteId: String!): Workspace!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
}
type Workspace {
id: ID!
name: String!
description: String
createdAt: DateTime!
updatedAt: DateTime!
"""
Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you.
"""
role: String
team: [WorkspaceCollaborator!]!
invitedTeam: [PendingWorkspaceCollaborator!]
projects(
limit: Int! = 25
cursor: String
filter: UserProjectsFilter
): ProjectCollection!
}
type WorkspaceCollaborator {
id: ID!
role: String!
user: LimitedUser!
}
type PendingWorkspaceCollaborator {
id: ID!
inviteId: String!
workspaceId: String!
workspaceName: String!
"""
E-mail address or name of the invited user
"""
title: String!
role: String!
invitedBy: LimitedUser!
"""
Set only if user is registered
"""
user: LimitedUser
"""
Only available if the active user is the pending workspace collaborator
"""
token: String
}
type WorkspaceCollection {
totalCount: Int!
cursor: String
items: [Workspace!]!
}
extend type User {
"""
Get the workspaces for the user
"""
workspaces(
limit: Int! = 25
cursor: String = null
filter: UserWorkspacesFilter
): WorkspaceCollection! @isOwner
}
extend type Project {
workspace: Workspace
}
type ServerWorkspacesInfo {
"""
This is a backend control variable for the workspaces feature set.
Since workspaces need a backend logic to be enabled, this is not enough as a feature flag.
"""
workspacesEnabled: Boolean!
}
extend type ServerInfo {
workspaces: ServerWorkspacesInfo!
}
extend type AdminQueries {
workspaceList(
query: String
limit: Int! = 25
cursor: String = null
): WorkspaceCollection!
}
input UserWorkspacesFilter {
search: String
}
enum WorkspaceRole {
ADMIN
MEMBER
GUEST
}
+6
View File
@@ -38,6 +38,12 @@ const configs = [
}
},
rules: {
'no-restricted-imports': [
'error',
{
patterns: ['.*']
}
],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-base-to-string': 'off',
+1 -1
View File
@@ -1,4 +1,4 @@
import { logger } from './logging'
import { logger } from '@/logging/logging'
import { randomUUID } from 'crypto'
import HttpLogger from 'pino-http'
import { IncomingMessage } from 'http'
@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { describe, it } from 'mocha'
import { getNameFromUserInfo } from '../../domain/logic'
import { getNameFromUserInfo } from '@/modules/auth/domain/logic'
/* eslint-disable camelcase */
describe('getNameFromUserInfo', () => {
@@ -17,7 +17,6 @@ import { getCommit } from '@/modules/core/repositories/commits'
import { getUserById } from '@/modules/core/services/users'
import { mixpanel } from '@/modules/shared/utils/mixpanel'
import { throwUncoveredError } from '@speckle/shared'
import dayjs from 'dayjs'
const isFinished = (runStatus: AutomationRunStatus) => {
const finishedStatuses: AutomationRunStatus[] = [
@@ -80,10 +79,8 @@ const onAutomationRunStatusUpdated =
runId: run.id,
functionRunId: functionRun.id,
status: functionRun.status,
durationInSeconds: dayjs(functionRun.updatedAt).diff(
functionRun.createdAt,
'second'
)
durationInSeconds: functionRun.elapsed / 1000,
durationInMilliseconds: functionRun.elapsed
})
}
@@ -76,6 +76,7 @@ export type AdminQueries = {
projectList: ProjectCollection;
serverStatistics: ServerStatistics;
userList: AdminUserList;
workspaceList: WorkspaceCollection;
};
@@ -102,6 +103,13 @@ export type AdminQueriesUserListArgs = {
role?: InputMaybe<ServerRole>;
};
export type AdminQueriesWorkspaceListArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: Scalars['Int']['input'];
query?: InputMaybe<Scalars['String']['input']>;
};
export type AdminUserList = {
__typename?: 'AdminUserList';
cursor?: Maybe<Scalars['String']['output']>;
@@ -1318,6 +1326,7 @@ export type Mutation = {
webhookDelete: Scalars['String']['output'];
/** Updates an existing webhook */
webhookUpdate: Scalars['String']['output'];
workspaceMutations: WorkspaceMutations;
};
@@ -1699,6 +1708,22 @@ export type PendingStreamCollaborator = {
user?: Maybe<LimitedUser>;
};
export type PendingWorkspaceCollaborator = {
__typename?: 'PendingWorkspaceCollaborator';
id: Scalars['ID']['output'];
inviteId: Scalars['String']['output'];
invitedBy: LimitedUser;
role: Scalars['String']['output'];
/** E-mail address or name of the invited user */
title: Scalars['String']['output'];
/** Only available if the active user is the pending workspace collaborator */
token?: Maybe<Scalars['String']['output']>;
/** Set only if user is registered */
user?: Maybe<LimitedUser>;
workspaceId: Scalars['String']['output'];
workspaceName: Scalars['String']['output'];
};
export type Project = {
__typename?: 'Project';
allowPublicComments: Scalars['Boolean']['output'];
@@ -1750,6 +1775,7 @@ export type Project = {
viewerResources: Array<ViewerResourceGroup>;
visibility: ProjectVisibility;
webhooks: WebhookCollection;
workspace?: Maybe<Workspace>;
};
@@ -2396,6 +2422,7 @@ export type Query = {
* The query looks for matches in name & email
*/
userSearch: UserSearchResultCollection;
workspace: Workspace;
};
@@ -2521,6 +2548,11 @@ export type QueryUserSearchArgs = {
query: Scalars['String']['input'];
};
export type QueryWorkspaceArgs = {
id: Scalars['String']['input'];
};
/** Deprecated: Used by old stream-based mutations */
export type ReplyCreateInput = {
/** IDs of uploaded blobs that should be attached to this reply */
@@ -2623,6 +2655,7 @@ export type ServerInfo = {
serverRoles: Array<ServerRoleItem>;
termsOfService?: Maybe<Scalars['String']['output']>;
version?: Maybe<Scalars['String']['output']>;
workspaces: ServerWorkspacesInfo;
};
export type ServerInfoUpdateInput = {
@@ -2691,6 +2724,15 @@ export type ServerStats = {
userHistory?: Maybe<Array<Maybe<Scalars['JSONObject']['output']>>>;
};
export type ServerWorkspacesInfo = {
__typename?: 'ServerWorkspacesInfo';
/**
* This is a backend control variable for the workspaces feature set.
* Since workspaces need a backend logic to be enabled, this is not enough as a feature flag.
*/
workspacesEnabled: Scalars['Boolean']['output'];
};
export type SmartTextEditorValue = {
__typename?: 'SmartTextEditorValue';
/** File attachments, if any */
@@ -3311,6 +3353,8 @@ export type User = {
* Note: Only count resolution is currently implemented
*/
versions: CountOnlyCollection;
/** Get the workspaces for the user */
workspaces: WorkspaceCollection;
};
@@ -3398,6 +3442,17 @@ export type UserVersionsArgs = {
limit?: Scalars['Int']['input'];
};
/**
* Full user type, should only be used in the context of admin operations or
* when a user is reading/writing info about himself
*/
export type UserWorkspacesArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<UserWorkspacesFilter>;
limit?: Scalars['Int']['input'];
};
export type UserAutomateInfo = {
__typename?: 'UserAutomateInfo';
availableGithubOrgs: Array<Scalars['String']['output']>;
@@ -3448,6 +3503,10 @@ export type UserUpdateInput = {
name?: InputMaybe<Scalars['String']['input']>;
};
export type UserWorkspacesFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
export type Version = {
__typename?: 'Version';
authorUser?: Maybe<LimitedUser>;
@@ -3660,6 +3719,152 @@ export type WebhookUpdateInput = {
url?: InputMaybe<Scalars['String']['input']>;
};
export type Workspace = {
__typename?: 'Workspace';
createdAt: Scalars['DateTime']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
invitedTeam?: Maybe<Array<PendingWorkspaceCollaborator>>;
name: Scalars['String']['output'];
projects: ProjectCollection;
/** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */
role?: Maybe<Scalars['String']['output']>;
team: Array<WorkspaceCollaborator>;
updatedAt: Scalars['DateTime']['output'];
};
export type WorkspaceProjectsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<UserProjectsFilter>;
limit?: Scalars['Int']['input'];
};
export type WorkspaceCollaborator = {
__typename?: 'WorkspaceCollaborator';
id: Scalars['ID']['output'];
role: Scalars['String']['output'];
user: LimitedUser;
};
export type WorkspaceCollection = {
__typename?: 'WorkspaceCollection';
cursor?: Maybe<Scalars['String']['output']>;
items: Array<Workspace>;
totalCount: Scalars['Int']['output'];
};
export type WorkspaceCreateInput = {
description?: InputMaybe<Scalars['String']['input']>;
logoUrl?: InputMaybe<Scalars['String']['input']>;
name: Scalars['String']['input'];
};
export type WorkspaceInviteCreateInput = {
/** Either this or userId must be filled */
email?: InputMaybe<Scalars['String']['input']>;
/** Defaults to the member role, if not specified */
role?: InputMaybe<WorkspaceRole>;
/** Either this or email must be filled */
userId?: InputMaybe<Scalars['String']['input']>;
};
export type WorkspaceInviteMutations = {
__typename?: 'WorkspaceInviteMutations';
batchCreate: Workspace;
cancel: Workspace;
create: Workspace;
use: Scalars['Boolean']['output'];
};
export type WorkspaceInviteMutationsBatchCreateArgs = {
input: Array<WorkspaceInviteCreateInput>;
workspaceId: Scalars['String']['input'];
};
export type WorkspaceInviteMutationsCancelArgs = {
inviteId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
};
export type WorkspaceInviteMutationsCreateArgs = {
input: WorkspaceInviteCreateInput;
workspaceId: Scalars['String']['input'];
};
export type WorkspaceInviteMutationsUseArgs = {
input: WorkspaceInviteUseInput;
};
export type WorkspaceInviteUseInput = {
accept: Scalars['Boolean']['input'];
token: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
};
export type WorkspaceMutations = {
__typename?: 'WorkspaceMutations';
create: Workspace;
delete: Workspace;
deleteRole: Scalars['Boolean']['output'];
invites: WorkspaceInviteMutations;
update: Workspace;
updateRole: Scalars['Boolean']['output'];
};
export type WorkspaceMutationsCreateArgs = {
input: WorkspaceCreateInput;
};
export type WorkspaceMutationsDeleteArgs = {
workspaceId: Scalars['String']['input'];
};
export type WorkspaceMutationsDeleteRoleArgs = {
input: WorkspaceRoleDeleteInput;
};
export type WorkspaceMutationsUpdateArgs = {
input: WorkspaceUpdateInput;
};
export type WorkspaceMutationsUpdateRoleArgs = {
input: WorkspaceRoleUpdateInput;
};
export enum WorkspaceRole {
Admin = 'ADMIN',
Guest = 'GUEST',
Member = 'MEMBER'
}
export type WorkspaceRoleDeleteInput = {
userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
};
export type WorkspaceRoleUpdateInput = {
role: WorkspaceRole;
userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
};
export type WorkspaceUpdateInput = {
description?: InputMaybe<Scalars['String']['input']>;
id: Scalars['String']['input'];
logoUrl?: InputMaybe<Scalars['String']['input']>;
name?: InputMaybe<Scalars['String']['input']>;
};
export type ResolverTypeWrapper<T> = Promise<T> | T;
@@ -3837,6 +4042,7 @@ export type ResolversTypes = {
PasswordStrengthCheckFeedback: ResolverTypeWrapper<PasswordStrengthCheckFeedback>;
PasswordStrengthCheckResults: ResolverTypeWrapper<PasswordStrengthCheckResults>;
PendingStreamCollaborator: ResolverTypeWrapper<PendingStreamCollaboratorGraphQLReturn>;
PendingWorkspaceCollaborator: ResolverTypeWrapper<Omit<PendingWorkspaceCollaborator, 'invitedBy' | 'user'> & { invitedBy: ResolversTypes['LimitedUser'], user?: Maybe<ResolversTypes['LimitedUser']> }>;
Project: ResolverTypeWrapper<ProjectGraphQLReturn>;
ProjectAccessRequest: ResolverTypeWrapper<ProjectAccessRequestGraphQLReturn>;
ProjectAccessRequestMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
@@ -3897,6 +4103,7 @@ export type ResolversTypes = {
ServerRoleItem: ResolverTypeWrapper<ServerRoleItem>;
ServerStatistics: ResolverTypeWrapper<GraphQLEmptyReturn>;
ServerStats: ResolverTypeWrapper<ServerStats>;
ServerWorkspacesInfo: ResolverTypeWrapper<ServerWorkspacesInfo>;
SmartTextEditorValue: ResolverTypeWrapper<SmartTextEditorValue>;
SortDirection: SortDirection;
Stream: ResolverTypeWrapper<StreamGraphQLReturn>;
@@ -3921,7 +4128,7 @@ export type ResolversTypes = {
UpdateAutomateFunctionInput: UpdateAutomateFunctionInput;
UpdateModelInput: UpdateModelInput;
UpdateVersionInput: UpdateVersionInput;
User: ResolverTypeWrapper<Omit<User, 'automateInfo' | 'commits' | 'favoriteStreams' | 'projectAccessRequest' | 'projectInvites' | 'projects' | 'streams'> & { automateInfo: ResolversTypes['UserAutomateInfo'], commits?: Maybe<ResolversTypes['CommitCollection']>, favoriteStreams: ResolversTypes['StreamCollection'], projectAccessRequest?: Maybe<ResolversTypes['ProjectAccessRequest']>, projectInvites: Array<ResolversTypes['PendingStreamCollaborator']>, projects: ResolversTypes['ProjectCollection'], streams: ResolversTypes['StreamCollection'] }>;
User: ResolverTypeWrapper<Omit<User, 'automateInfo' | 'commits' | 'favoriteStreams' | 'projectAccessRequest' | 'projectInvites' | 'projects' | 'streams' | 'workspaces'> & { automateInfo: ResolversTypes['UserAutomateInfo'], commits?: Maybe<ResolversTypes['CommitCollection']>, favoriteStreams: ResolversTypes['StreamCollection'], projectAccessRequest?: Maybe<ResolversTypes['ProjectAccessRequest']>, projectInvites: Array<ResolversTypes['PendingStreamCollaborator']>, projects: ResolversTypes['ProjectCollection'], streams: ResolversTypes['StreamCollection'], workspaces: ResolversTypes['WorkspaceCollection'] }>;
UserAutomateInfo: ResolverTypeWrapper<UserAutomateInfoGraphQLReturn>;
UserDeleteInput: UserDeleteInput;
UserProjectsFilter: UserProjectsFilter;
@@ -3930,6 +4137,7 @@ export type ResolversTypes = {
UserRoleInput: UserRoleInput;
UserSearchResultCollection: ResolverTypeWrapper<Omit<UserSearchResultCollection, 'items'> & { items: Array<ResolversTypes['LimitedUser']> }>;
UserUpdateInput: UserUpdateInput;
UserWorkspacesFilter: UserWorkspacesFilter;
Version: ResolverTypeWrapper<VersionGraphQLReturn>;
VersionCollection: ResolverTypeWrapper<Omit<VersionCollection, 'items'> & { items: Array<ResolversTypes['Version']> }>;
VersionCreatedTrigger: ResolverTypeWrapper<AutomationRunTriggerGraphQLReturn>;
@@ -3948,6 +4156,18 @@ export type ResolversTypes = {
WebhookEvent: ResolverTypeWrapper<WebhookEvent>;
WebhookEventCollection: ResolverTypeWrapper<WebhookEventCollection>;
WebhookUpdateInput: WebhookUpdateInput;
Workspace: ResolverTypeWrapper<Omit<Workspace, 'invitedTeam' | 'projects' | 'team'> & { invitedTeam?: Maybe<Array<ResolversTypes['PendingWorkspaceCollaborator']>>, projects: ResolversTypes['ProjectCollection'], team: Array<ResolversTypes['WorkspaceCollaborator']> }>;
WorkspaceCollaborator: ResolverTypeWrapper<Omit<WorkspaceCollaborator, 'user'> & { user: ResolversTypes['LimitedUser'] }>;
WorkspaceCollection: ResolverTypeWrapper<Omit<WorkspaceCollection, 'items'> & { items: Array<ResolversTypes['Workspace']> }>;
WorkspaceCreateInput: WorkspaceCreateInput;
WorkspaceInviteCreateInput: WorkspaceInviteCreateInput;
WorkspaceInviteMutations: ResolverTypeWrapper<Omit<WorkspaceInviteMutations, 'batchCreate' | 'cancel' | 'create'> & { batchCreate: ResolversTypes['Workspace'], cancel: ResolversTypes['Workspace'], create: ResolversTypes['Workspace'] }>;
WorkspaceInviteUseInput: WorkspaceInviteUseInput;
WorkspaceMutations: ResolverTypeWrapper<Omit<WorkspaceMutations, 'create' | 'delete' | 'invites' | 'update'> & { create: ResolversTypes['Workspace'], delete: ResolversTypes['Workspace'], invites: ResolversTypes['WorkspaceInviteMutations'], update: ResolversTypes['Workspace'] }>;
WorkspaceRole: WorkspaceRole;
WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput;
WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput;
WorkspaceUpdateInput: WorkspaceUpdateInput;
};
/** Mapping between all available schema types and the resolvers parents */
@@ -4054,6 +4274,7 @@ export type ResolversParentTypes = {
PasswordStrengthCheckFeedback: PasswordStrengthCheckFeedback;
PasswordStrengthCheckResults: PasswordStrengthCheckResults;
PendingStreamCollaborator: PendingStreamCollaboratorGraphQLReturn;
PendingWorkspaceCollaborator: Omit<PendingWorkspaceCollaborator, 'invitedBy' | 'user'> & { invitedBy: ResolversParentTypes['LimitedUser'], user?: Maybe<ResolversParentTypes['LimitedUser']> };
Project: ProjectGraphQLReturn;
ProjectAccessRequest: ProjectAccessRequestGraphQLReturn;
ProjectAccessRequestMutations: MutationsObjectGraphQLReturn;
@@ -4102,6 +4323,7 @@ export type ResolversParentTypes = {
ServerRoleItem: ServerRoleItem;
ServerStatistics: GraphQLEmptyReturn;
ServerStats: ServerStats;
ServerWorkspacesInfo: ServerWorkspacesInfo;
SmartTextEditorValue: SmartTextEditorValue;
Stream: StreamGraphQLReturn;
StreamAccessRequest: StreamAccessRequestGraphQLReturn;
@@ -4123,7 +4345,7 @@ export type ResolversParentTypes = {
UpdateAutomateFunctionInput: UpdateAutomateFunctionInput;
UpdateModelInput: UpdateModelInput;
UpdateVersionInput: UpdateVersionInput;
User: Omit<User, 'automateInfo' | 'commits' | 'favoriteStreams' | 'projectAccessRequest' | 'projectInvites' | 'projects' | 'streams'> & { automateInfo: ResolversParentTypes['UserAutomateInfo'], commits?: Maybe<ResolversParentTypes['CommitCollection']>, favoriteStreams: ResolversParentTypes['StreamCollection'], projectAccessRequest?: Maybe<ResolversParentTypes['ProjectAccessRequest']>, projectInvites: Array<ResolversParentTypes['PendingStreamCollaborator']>, projects: ResolversParentTypes['ProjectCollection'], streams: ResolversParentTypes['StreamCollection'] };
User: Omit<User, 'automateInfo' | 'commits' | 'favoriteStreams' | 'projectAccessRequest' | 'projectInvites' | 'projects' | 'streams' | 'workspaces'> & { automateInfo: ResolversParentTypes['UserAutomateInfo'], commits?: Maybe<ResolversParentTypes['CommitCollection']>, favoriteStreams: ResolversParentTypes['StreamCollection'], projectAccessRequest?: Maybe<ResolversParentTypes['ProjectAccessRequest']>, projectInvites: Array<ResolversParentTypes['PendingStreamCollaborator']>, projects: ResolversParentTypes['ProjectCollection'], streams: ResolversParentTypes['StreamCollection'], workspaces: ResolversParentTypes['WorkspaceCollection'] };
UserAutomateInfo: UserAutomateInfoGraphQLReturn;
UserDeleteInput: UserDeleteInput;
UserProjectsFilter: UserProjectsFilter;
@@ -4131,6 +4353,7 @@ export type ResolversParentTypes = {
UserRoleInput: UserRoleInput;
UserSearchResultCollection: Omit<UserSearchResultCollection, 'items'> & { items: Array<ResolversParentTypes['LimitedUser']> };
UserUpdateInput: UserUpdateInput;
UserWorkspacesFilter: UserWorkspacesFilter;
Version: VersionGraphQLReturn;
VersionCollection: Omit<VersionCollection, 'items'> & { items: Array<ResolversParentTypes['Version']> };
VersionCreatedTrigger: AutomationRunTriggerGraphQLReturn;
@@ -4148,6 +4371,17 @@ export type ResolversParentTypes = {
WebhookEvent: WebhookEvent;
WebhookEventCollection: WebhookEventCollection;
WebhookUpdateInput: WebhookUpdateInput;
Workspace: Omit<Workspace, 'invitedTeam' | 'projects' | 'team'> & { invitedTeam?: Maybe<Array<ResolversParentTypes['PendingWorkspaceCollaborator']>>, projects: ResolversParentTypes['ProjectCollection'], team: Array<ResolversParentTypes['WorkspaceCollaborator']> };
WorkspaceCollaborator: Omit<WorkspaceCollaborator, 'user'> & { user: ResolversParentTypes['LimitedUser'] };
WorkspaceCollection: Omit<WorkspaceCollection, 'items'> & { items: Array<ResolversParentTypes['Workspace']> };
WorkspaceCreateInput: WorkspaceCreateInput;
WorkspaceInviteCreateInput: WorkspaceInviteCreateInput;
WorkspaceInviteMutations: Omit<WorkspaceInviteMutations, 'batchCreate' | 'cancel' | 'create'> & { batchCreate: ResolversParentTypes['Workspace'], cancel: ResolversParentTypes['Workspace'], create: ResolversParentTypes['Workspace'] };
WorkspaceInviteUseInput: WorkspaceInviteUseInput;
WorkspaceMutations: Omit<WorkspaceMutations, 'create' | 'delete' | 'invites' | 'update'> & { create: ResolversParentTypes['Workspace'], delete: ResolversParentTypes['Workspace'], invites: ResolversParentTypes['WorkspaceInviteMutations'], update: ResolversParentTypes['Workspace'] };
WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput;
WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput;
WorkspaceUpdateInput: WorkspaceUpdateInput;
};
export type HasScopeDirectiveArgs = {
@@ -4216,6 +4450,7 @@ export type AdminQueriesResolvers<ContextType = GraphQLContext, ParentType exten
projectList?: Resolver<ResolversTypes['ProjectCollection'], ParentType, ContextType, RequireFields<AdminQueriesProjectListArgs, 'cursor' | 'limit'>>;
serverStatistics?: Resolver<ResolversTypes['ServerStatistics'], ParentType, ContextType>;
userList?: Resolver<ResolversTypes['AdminUserList'], ParentType, ContextType, RequireFields<AdminQueriesUserListArgs, 'cursor' | 'limit' | 'query' | 'role'>>;
workspaceList?: Resolver<ResolversTypes['WorkspaceCollection'], ParentType, ContextType, RequireFields<AdminQueriesWorkspaceListArgs, 'cursor' | 'limit'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@@ -4756,6 +4991,7 @@ export type MutationResolvers<ContextType = GraphQLContext, ParentType extends R
webhookCreate?: Resolver<ResolversTypes['String'], ParentType, ContextType, RequireFields<MutationWebhookCreateArgs, 'webhook'>>;
webhookDelete?: Resolver<ResolversTypes['String'], ParentType, ContextType, RequireFields<MutationWebhookDeleteArgs, 'webhook'>>;
webhookUpdate?: Resolver<ResolversTypes['String'], ParentType, ContextType, RequireFields<MutationWebhookUpdateArgs, 'webhook'>>;
workspaceMutations?: Resolver<ResolversTypes['WorkspaceMutations'], ParentType, ContextType>;
};
export type ObjectResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['Object'] = ResolversParentTypes['Object']> = {
@@ -4804,6 +5040,19 @@ export type PendingStreamCollaboratorResolvers<ContextType = GraphQLContext, Par
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type PendingWorkspaceCollaboratorResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['PendingWorkspaceCollaborator'] = ResolversParentTypes['PendingWorkspaceCollaborator']> = {
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
inviteId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
invitedBy?: Resolver<ResolversTypes['LimitedUser'], ParentType, ContextType>;
role?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
token?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
user?: Resolver<Maybe<ResolversTypes['LimitedUser']>, ParentType, ContextType>;
workspaceId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
workspaceName?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ProjectResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['Project'] = ResolversParentTypes['Project']> = {
allowPublicComments?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
automation?: Resolver<ResolversTypes['Automation'], ParentType, ContextType, RequireFields<ProjectAutomationArgs, 'id'>>;
@@ -4834,6 +5083,7 @@ export type ProjectResolvers<ContextType = GraphQLContext, ParentType extends Re
viewerResources?: Resolver<Array<ResolversTypes['ViewerResourceGroup']>, ParentType, ContextType, RequireFields<ProjectViewerResourcesArgs, 'loadedVersionsOnly' | 'resourceIdString'>>;
visibility?: Resolver<ResolversTypes['ProjectVisibility'], ParentType, ContextType>;
webhooks?: Resolver<ResolversTypes['WebhookCollection'], ParentType, ContextType, Partial<ProjectWebhooksArgs>>;
workspace?: Resolver<Maybe<ResolversTypes['Workspace']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@@ -5010,6 +5260,7 @@ export type QueryResolvers<ContextType = GraphQLContext, ParentType extends Reso
user?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType, Partial<QueryUserArgs>>;
userPwdStrength?: Resolver<ResolversTypes['PasswordStrengthCheckResults'], ParentType, ContextType, RequireFields<QueryUserPwdStrengthArgs, 'pwd'>>;
userSearch?: Resolver<ResolversTypes['UserSearchResultCollection'], ParentType, ContextType, RequireFields<QueryUserSearchArgs, 'archived' | 'emailOnly' | 'limit' | 'query'>>;
workspace?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<QueryWorkspaceArgs, 'id'>>;
};
export type ResourceIdentifierResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ResourceIdentifier'] = ResolversParentTypes['ResourceIdentifier']> = {
@@ -5083,6 +5334,7 @@ export type ServerInfoResolvers<ContextType = GraphQLContext, ParentType extends
serverRoles?: Resolver<Array<ResolversTypes['ServerRoleItem']>, ParentType, ContextType>;
termsOfService?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
version?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
workspaces?: Resolver<ResolversTypes['ServerWorkspacesInfo'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@@ -5124,6 +5376,11 @@ export type ServerStatsResolvers<ContextType = GraphQLContext, ParentType extend
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ServerWorkspacesInfoResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ServerWorkspacesInfo'] = ResolversParentTypes['ServerWorkspacesInfo']> = {
workspacesEnabled?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SmartTextEditorValueResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['SmartTextEditorValue'] = ResolversParentTypes['SmartTextEditorValue']> = {
attachments?: Resolver<Maybe<Array<ResolversTypes['BlobMetadata']>>, ParentType, ContextType>;
doc?: Resolver<Maybe<ResolversTypes['JSONObject']>, ParentType, ContextType>;
@@ -5282,6 +5539,7 @@ export type UserResolvers<ContextType = GraphQLContext, ParentType extends Resol
totalOwnedStreamsFavorites?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
verified?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
versions?: Resolver<ResolversTypes['CountOnlyCollection'], ParentType, ContextType, RequireFields<UserVersionsArgs, 'authoredOnly' | 'limit'>>;
workspaces?: Resolver<ResolversTypes['WorkspaceCollection'], ParentType, ContextType, RequireFields<UserWorkspacesArgs, 'cursor' | 'limit'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@@ -5411,6 +5669,51 @@ export type WebhookEventCollectionResolvers<ContextType = GraphQLContext, Parent
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspaceResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['Workspace'] = ResolversParentTypes['Workspace']> = {
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
invitedTeam?: Resolver<Maybe<Array<ResolversTypes['PendingWorkspaceCollaborator']>>, ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
projects?: Resolver<ResolversTypes['ProjectCollection'], ParentType, ContextType, RequireFields<WorkspaceProjectsArgs, 'limit'>>;
role?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
team?: Resolver<Array<ResolversTypes['WorkspaceCollaborator']>, ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspaceCollaboratorResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceCollaborator'] = ResolversParentTypes['WorkspaceCollaborator']> = {
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
role?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
user?: Resolver<ResolversTypes['LimitedUser'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspaceCollectionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceCollection'] = ResolversParentTypes['WorkspaceCollection']> = {
cursor?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
items?: Resolver<Array<ResolversTypes['Workspace']>, ParentType, ContextType>;
totalCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspaceInviteMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceInviteMutations'] = ResolversParentTypes['WorkspaceInviteMutations']> = {
batchCreate?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceInviteMutationsBatchCreateArgs, 'input' | 'workspaceId'>>;
cancel?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceInviteMutationsCancelArgs, 'inviteId' | 'workspaceId'>>;
create?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceInviteMutationsCreateArgs, 'input' | 'workspaceId'>>;
use?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceInviteMutationsUseArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspaceMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceMutations'] = ResolversParentTypes['WorkspaceMutations']> = {
create?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsCreateArgs, 'input'>>;
delete?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsDeleteArgs, 'workspaceId'>>;
deleteRole?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceMutationsDeleteRoleArgs, 'input'>>;
invites?: Resolver<ResolversTypes['WorkspaceInviteMutations'], ParentType, ContextType>;
update?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsUpdateArgs, 'input'>>;
updateRole?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceMutationsUpdateRoleArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type Resolvers<ContextType = GraphQLContext> = {
ActiveUserMutations?: ActiveUserMutationsResolvers<ContextType>;
Activity?: ActivityResolvers<ContextType>;
@@ -5474,6 +5777,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
PasswordStrengthCheckFeedback?: PasswordStrengthCheckFeedbackResolvers<ContextType>;
PasswordStrengthCheckResults?: PasswordStrengthCheckResultsResolvers<ContextType>;
PendingStreamCollaborator?: PendingStreamCollaboratorResolvers<ContextType>;
PendingWorkspaceCollaborator?: PendingWorkspaceCollaboratorResolvers<ContextType>;
Project?: ProjectResolvers<ContextType>;
ProjectAccessRequest?: ProjectAccessRequestResolvers<ContextType>;
ProjectAccessRequestMutations?: ProjectAccessRequestMutationsResolvers<ContextType>;
@@ -5506,6 +5810,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
ServerRoleItem?: ServerRoleItemResolvers<ContextType>;
ServerStatistics?: ServerStatisticsResolvers<ContextType>;
ServerStats?: ServerStatsResolvers<ContextType>;
ServerWorkspacesInfo?: ServerWorkspacesInfoResolvers<ContextType>;
SmartTextEditorValue?: SmartTextEditorValueResolvers<ContextType>;
Stream?: StreamResolvers<ContextType>;
StreamAccessRequest?: StreamAccessRequestResolvers<ContextType>;
@@ -5533,6 +5838,11 @@ export type Resolvers<ContextType = GraphQLContext> = {
WebhookCollection?: WebhookCollectionResolvers<ContextType>;
WebhookEvent?: WebhookEventResolvers<ContextType>;
WebhookEventCollection?: WebhookEventCollectionResolvers<ContextType>;
Workspace?: WorkspaceResolvers<ContextType>;
WorkspaceCollaborator?: WorkspaceCollaboratorResolvers<ContextType>;
WorkspaceCollection?: WorkspaceCollectionResolvers<ContextType>;
WorkspaceInviteMutations?: WorkspaceInviteMutationsResolvers<ContextType>;
WorkspaceMutations?: WorkspaceMutationsResolvers<ContextType>;
};
export type DirectiveResolvers<ContextType = GraphQLContext> = {
@@ -4,7 +4,7 @@ import {
createObjects,
getObjectChildren,
getObjectChildrenQuery
} from '../../services/objects'
} from '@/modules/core/services/objects'
import { Roles } from '@speckle/shared'
import { Resolvers } from '@/modules/core/graph/generated/graphql'
@@ -49,6 +49,7 @@ export type StreamRecord = {
updatedAt: Date
allowPublicComments: boolean
isDiscoverable: boolean
workspaceId: Nullable<string>
}
export type StreamAclRecord = {
@@ -106,7 +106,8 @@ export const adminProjectList = async (
...args,
searchQuery: args.query,
cursor: parsedCursor,
streamIdWhitelist: args.streamIdWhitelist
streamIdWhitelist: args.streamIdWhitelist,
workspaceIdWhitelist: null
})
const cursor = cursorDate ? convertDateToCursor(cursorDate) : null
return {
@@ -83,7 +83,8 @@ module.exports = {
orderBy,
visibility,
searchQuery,
streamIdWhitelist
streamIdWhitelist,
workspaceIdWhitelist
}) {
const query = knex.select().from('streams')
@@ -116,6 +117,11 @@ module.exports = {
countQuery.whereIn('id', streamIdWhitelist)
}
if (workspaceIdWhitelist?.length) {
query.whereIn('workspaceId', workspaceIdWhitelist)
countQuery.whereIn('workspaceId', workspaceIdWhitelist)
}
const [res] = await countQuery.count()
const count = parseInt(res.count)
@@ -8,8 +8,8 @@ import { StreamRecord } from '@/modules/core/helpers/types'
import { logger } from '@/logging/logging'
import { createStreamReturnRecord } from '@/modules/core/services/streams/management'
import { getOnboardingBaseProject } from '@/modules/cross-server-sync/services/onboardingProject'
import { updateStream } from '../../repositories/streams'
import { getUser } from '../users'
import { updateStream } from '@/modules/core/repositories/streams'
import { getUser } from '@/modules/core/services/users'
import {
ContextResourceAccessRules,
isNewResourceAllowed
@@ -6,15 +6,15 @@ import {
deleteStream,
getStreamUsers,
grantPermissionsStream
} from '../services/streams'
} from '@/modules/core/services/streams'
import {
createBranch,
getBranchByNameAndStreamId,
deleteBranchById
} from '../services/branches'
import { createObject } from '../services/objects'
import { createCommitByBranchName } from '../services/commits'
} from '@/modules/core/services/branches'
import { createObject } from '@/modules/core/services/objects'
import { createCommitByBranchName } from '@/modules/core/services/commits'
import { beforeEachContext, truncateTables } from '@/test/hooks'
import {
@@ -66,6 +66,7 @@ export type AdminQueries = {
projectList: ProjectCollection;
serverStatistics: ServerStatistics;
userList: AdminUserList;
workspaceList: WorkspaceCollection;
};
@@ -92,6 +93,13 @@ export type AdminQueriesUserListArgs = {
role?: InputMaybe<ServerRole>;
};
export type AdminQueriesWorkspaceListArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: Scalars['Int']['input'];
query?: InputMaybe<Scalars['String']['input']>;
};
export type AdminUserList = {
__typename?: 'AdminUserList';
cursor?: Maybe<Scalars['String']['output']>;
@@ -1308,6 +1316,7 @@ export type Mutation = {
webhookDelete: Scalars['String']['output'];
/** Updates an existing webhook */
webhookUpdate: Scalars['String']['output'];
workspaceMutations: WorkspaceMutations;
};
@@ -1689,6 +1698,22 @@ export type PendingStreamCollaborator = {
user?: Maybe<LimitedUser>;
};
export type PendingWorkspaceCollaborator = {
__typename?: 'PendingWorkspaceCollaborator';
id: Scalars['ID']['output'];
inviteId: Scalars['String']['output'];
invitedBy: LimitedUser;
role: Scalars['String']['output'];
/** E-mail address or name of the invited user */
title: Scalars['String']['output'];
/** Only available if the active user is the pending workspace collaborator */
token?: Maybe<Scalars['String']['output']>;
/** Set only if user is registered */
user?: Maybe<LimitedUser>;
workspaceId: Scalars['String']['output'];
workspaceName: Scalars['String']['output'];
};
export type Project = {
__typename?: 'Project';
allowPublicComments: Scalars['Boolean']['output'];
@@ -1740,6 +1765,7 @@ export type Project = {
viewerResources: Array<ViewerResourceGroup>;
visibility: ProjectVisibility;
webhooks: WebhookCollection;
workspace?: Maybe<Workspace>;
};
@@ -2386,6 +2412,7 @@ export type Query = {
* The query looks for matches in name & email
*/
userSearch: UserSearchResultCollection;
workspace: Workspace;
};
@@ -2511,6 +2538,11 @@ export type QueryUserSearchArgs = {
query: Scalars['String']['input'];
};
export type QueryWorkspaceArgs = {
id: Scalars['String']['input'];
};
/** Deprecated: Used by old stream-based mutations */
export type ReplyCreateInput = {
/** IDs of uploaded blobs that should be attached to this reply */
@@ -2613,6 +2645,7 @@ export type ServerInfo = {
serverRoles: Array<ServerRoleItem>;
termsOfService?: Maybe<Scalars['String']['output']>;
version?: Maybe<Scalars['String']['output']>;
workspaces: ServerWorkspacesInfo;
};
export type ServerInfoUpdateInput = {
@@ -2681,6 +2714,15 @@ export type ServerStats = {
userHistory?: Maybe<Array<Maybe<Scalars['JSONObject']['output']>>>;
};
export type ServerWorkspacesInfo = {
__typename?: 'ServerWorkspacesInfo';
/**
* This is a backend control variable for the workspaces feature set.
* Since workspaces need a backend logic to be enabled, this is not enough as a feature flag.
*/
workspacesEnabled: Scalars['Boolean']['output'];
};
export type SmartTextEditorValue = {
__typename?: 'SmartTextEditorValue';
/** File attachments, if any */
@@ -3301,6 +3343,8 @@ export type User = {
* Note: Only count resolution is currently implemented
*/
versions: CountOnlyCollection;
/** Get the workspaces for the user */
workspaces: WorkspaceCollection;
};
@@ -3388,6 +3432,17 @@ export type UserVersionsArgs = {
limit?: Scalars['Int']['input'];
};
/**
* Full user type, should only be used in the context of admin operations or
* when a user is reading/writing info about himself
*/
export type UserWorkspacesArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<UserWorkspacesFilter>;
limit?: Scalars['Int']['input'];
};
export type UserAutomateInfo = {
__typename?: 'UserAutomateInfo';
availableGithubOrgs: Array<Scalars['String']['output']>;
@@ -3438,6 +3493,10 @@ export type UserUpdateInput = {
name?: InputMaybe<Scalars['String']['input']>;
};
export type UserWorkspacesFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
export type Version = {
__typename?: 'Version';
authorUser?: Maybe<LimitedUser>;
@@ -3650,6 +3709,152 @@ export type WebhookUpdateInput = {
url?: InputMaybe<Scalars['String']['input']>;
};
export type Workspace = {
__typename?: 'Workspace';
createdAt: Scalars['DateTime']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
invitedTeam?: Maybe<Array<PendingWorkspaceCollaborator>>;
name: Scalars['String']['output'];
projects: ProjectCollection;
/** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */
role?: Maybe<Scalars['String']['output']>;
team: Array<WorkspaceCollaborator>;
updatedAt: Scalars['DateTime']['output'];
};
export type WorkspaceProjectsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<UserProjectsFilter>;
limit?: Scalars['Int']['input'];
};
export type WorkspaceCollaborator = {
__typename?: 'WorkspaceCollaborator';
id: Scalars['ID']['output'];
role: Scalars['String']['output'];
user: LimitedUser;
};
export type WorkspaceCollection = {
__typename?: 'WorkspaceCollection';
cursor?: Maybe<Scalars['String']['output']>;
items: Array<Workspace>;
totalCount: Scalars['Int']['output'];
};
export type WorkspaceCreateInput = {
description?: InputMaybe<Scalars['String']['input']>;
logoUrl?: InputMaybe<Scalars['String']['input']>;
name: Scalars['String']['input'];
};
export type WorkspaceInviteCreateInput = {
/** Either this or userId must be filled */
email?: InputMaybe<Scalars['String']['input']>;
/** Defaults to the member role, if not specified */
role?: InputMaybe<WorkspaceRole>;
/** Either this or email must be filled */
userId?: InputMaybe<Scalars['String']['input']>;
};
export type WorkspaceInviteMutations = {
__typename?: 'WorkspaceInviteMutations';
batchCreate: Workspace;
cancel: Workspace;
create: Workspace;
use: Scalars['Boolean']['output'];
};
export type WorkspaceInviteMutationsBatchCreateArgs = {
input: Array<WorkspaceInviteCreateInput>;
workspaceId: Scalars['String']['input'];
};
export type WorkspaceInviteMutationsCancelArgs = {
inviteId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
};
export type WorkspaceInviteMutationsCreateArgs = {
input: WorkspaceInviteCreateInput;
workspaceId: Scalars['String']['input'];
};
export type WorkspaceInviteMutationsUseArgs = {
input: WorkspaceInviteUseInput;
};
export type WorkspaceInviteUseInput = {
accept: Scalars['Boolean']['input'];
token: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
};
export type WorkspaceMutations = {
__typename?: 'WorkspaceMutations';
create: Workspace;
delete: Workspace;
deleteRole: Scalars['Boolean']['output'];
invites: WorkspaceInviteMutations;
update: Workspace;
updateRole: Scalars['Boolean']['output'];
};
export type WorkspaceMutationsCreateArgs = {
input: WorkspaceCreateInput;
};
export type WorkspaceMutationsDeleteArgs = {
workspaceId: Scalars['String']['input'];
};
export type WorkspaceMutationsDeleteRoleArgs = {
input: WorkspaceRoleDeleteInput;
};
export type WorkspaceMutationsUpdateArgs = {
input: WorkspaceUpdateInput;
};
export type WorkspaceMutationsUpdateRoleArgs = {
input: WorkspaceRoleUpdateInput;
};
export enum WorkspaceRole {
Admin = 'ADMIN',
Guest = 'GUEST',
Member = 'MEMBER'
}
export type WorkspaceRoleDeleteInput = {
userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
};
export type WorkspaceRoleUpdateInput = {
role: WorkspaceRole;
userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
};
export type WorkspaceUpdateInput = {
description?: InputMaybe<Scalars['String']['input']>;
id: Scalars['String']['input'];
logoUrl?: InputMaybe<Scalars['String']['input']>;
name?: InputMaybe<Scalars['String']['input']>;
};
export type CrossSyncCommitBranchMetadataQueryVariables = Exact<{
streamId: Scalars['String']['input'];
commitId: Scalars['String']['input'];
@@ -111,7 +111,8 @@ describe('Activity digest notifications @notifications', () => {
createdAt: new Date(),
updatedAt: new Date(),
allowPublicComments: true,
isDiscoverable: true
isDiscoverable: true,
workspaceId: null
},
activity: activities ?? [createActivity()]
})
@@ -1,7 +1,11 @@
import { UserRecord } from '@/modules/core/helpers/types'
import { CreateInviteParams } from '@/modules/serverinvites/domain/operations'
import { InviteCreateValidationError } from '@/modules/serverinvites/errors'
import { ResourceTargets, isServerInvite, resolveTarget } from '../helpers/inviteHelper'
import {
ResourceTargets,
isServerInvite,
resolveTarget
} from '@/modules/serverinvites/helpers/inviteHelper'
import { UserWithOptionalRole } from '@/modules/core/repositories/users'
import { authorizeResolver } from '@/modules/shared'
import { Roles } from '@speckle/shared'
@@ -2,6 +2,7 @@ import {
WorkspaceEvents,
WorkspaceEventsPayloads
} from '@/modules/workspacesCore/domain/events'
import { StreamRecord } from '@/modules/core/helpers/types'
import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
/** Workspace */
@@ -18,7 +19,7 @@ type GetWorkspaceArgs = {
export type GetWorkspace = (args: GetWorkspaceArgs) => Promise<Workspace | null>
/** WorkspaceRole */
/** Workspace Roles */
type DeleteWorkspaceRoleArgs = {
workspaceId: string
@@ -63,6 +64,28 @@ export type GetWorkspaceRolesForUser = (
export type UpsertWorkspaceRole = (args: WorkspaceAcl) => Promise<void>
/** Workspace Projects */
type GetAllWorkspaceProjectsForUserArgs = {
userId: string
workspaceId: string
}
export type GetAllWorkspaceProjectsForUser = (
args: GetAllWorkspaceProjectsForUserArgs
) => Promise<StreamRecord[]>
/** Workspace Project Roles */
type GrantWorkspaceProjectRolesArgs = {
projectId: string
workspaceId: string
}
export type GrantWorkspaceProjectRoles = (
args: GrantWorkspaceProjectRolesArgs
) => Promise<void>
/** Blob */
export type StoreBlob = (args: string) => Promise<string>
@@ -0,0 +1,16 @@
import { Roles, StreamRoles, WorkspaceRoles } from '@speckle/shared'
/**
* Given a user's workspace role, return the role they should have for workspace projects.
*/
export const mapWorkspaceRoleToProjectRole = (
workspaceRole: WorkspaceRoles
): StreamRoles => {
switch (workspaceRole) {
case Roles.Workspace.Guest:
case Roles.Workspace.Member:
return Roles.Stream.Reviewer
case Roles.Workspace.Admin:
return Roles.Stream.Owner
}
}
@@ -5,3 +5,18 @@ export class WorkspaceAdminRequiredError extends BaseError {
static code = 'WORKSPACE_ADMIN_REQUIRED_ERROR'
static statusCode = 400
}
export class WorkspaceInvalidRoleError extends BaseError {
static defaultMessage = 'Invalid workspace role provided'
static code = 'WORKSPACE_INVALID_ROLE_ERROR'
}
export class WorkspaceQueryError extends BaseError {
static defaultMessage = 'Unexpected error during query operation'
static code = 'WORKSPACE_QUERY_ERROR'
}
export class WorkspacesNotYetImplementedError extends BaseError {
static defaultMessage = 'Not yet implemented'
static code = 'WORKSPACES_NOT_YET_IMPLEMENTED_ERROR'
}
@@ -0,0 +1,52 @@
import {
ProjectsEmitter,
ProjectEvents,
ProjectEventsPayloads
} from '@/modules/core/events/projectsEmitter'
import { getWorkspaceRolesFactory } from '@/modules/workspaces/repositories/workspaces'
import { grantStreamPermissions as repoGrantStreamPermissions } from '@/modules/core/repositories/streams'
import { Knex } from 'knex'
import { GetWorkspaceRoles } from '@/modules/workspaces/domain/operations'
import { mapWorkspaceRoleToProjectRole } from '@/modules/workspaces/domain/roles'
export const onProjectCreatedFactory =
({
getWorkspaceRoles,
grantStreamPermissions
}: {
getWorkspaceRoles: GetWorkspaceRoles
grantStreamPermissions: typeof repoGrantStreamPermissions
}) =>
async (payload: ProjectEventsPayloads[typeof ProjectEvents.Created]) => {
const { id: projectId, workspaceId } = payload.project
if (!workspaceId) {
return
}
const workspaceMembers = await getWorkspaceRoles({ workspaceId })
await Promise.all(
workspaceMembers.map(({ userId, role: workspaceRole }) =>
grantStreamPermissions({
streamId: projectId,
userId,
role: mapWorkspaceRoleToProjectRole(workspaceRole)
})
)
)
}
export const initializeEventListenersFactory =
({ db }: { db: Knex }) =>
() => {
const onProjectCreated = onProjectCreatedFactory({
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
// TODO: Instantiate via factory function
grantStreamPermissions: repoGrantStreamPermissions
})
const quitCbs = [ProjectsEmitter.listen(ProjectEvents.Created, onProjectCreated)]
return () => quitCbs.forEach((quit) => quit())
}
@@ -0,0 +1,82 @@
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { WorkspacesNotYetImplementedError } from '@/modules/workspaces/errors/workspace'
const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
export = FF_WORKSPACES_MODULE_ENABLED
? ({
Query: {
workspace: async () => {
// Get workspace by id
throw new WorkspacesNotYetImplementedError()
}
},
WorkspaceMutations: {
create: async () => {
throw new WorkspacesNotYetImplementedError()
},
delete: async () => {
throw new WorkspacesNotYetImplementedError()
},
update: async () => {
throw new WorkspacesNotYetImplementedError()
},
updateRole: async () => {
throw new WorkspacesNotYetImplementedError()
},
deleteRole: async () => {
throw new WorkspacesNotYetImplementedError()
}
},
WorkspaceInviteMutations: {
create: async () => {
throw new WorkspacesNotYetImplementedError()
},
batchCreate: async () => {
throw new WorkspacesNotYetImplementedError()
},
use: async () => {
throw new WorkspacesNotYetImplementedError()
},
cancel: async () => {
throw new WorkspacesNotYetImplementedError()
}
},
Workspace: {
role: async () => {
// Get user id from parent, get role and return
throw new WorkspacesNotYetImplementedError()
},
team: async () => {
// Get roles for workspace
throw new WorkspacesNotYetImplementedError()
},
invitedTeam: async () => {
// Get invites
throw new WorkspacesNotYetImplementedError()
},
projects: async () => {
// Get projects in workspace
throw new WorkspacesNotYetImplementedError()
}
},
User: {
workspaces: async () => {
// Get roles for user, get workspaces
throw new WorkspacesNotYetImplementedError()
}
},
Project: {
workspace: async () => {
// Get workspaceId from project, get and return workspace data
throw new WorkspacesNotYetImplementedError()
}
},
AdminQueries: {
workspaceList: async () => {
throw new WorkspacesNotYetImplementedError()
}
}
} as Resolvers)
: {}
+5 -1
View File
@@ -6,6 +6,7 @@ import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import { workspaceRoles } from '@/modules/workspaces/roles'
import { workspaceScopes } from '@/modules/workspaces/scopes'
import { registerOrUpdateRole } from '@/modules/shared/repositories/roles'
import { initializeEventListenersFactory } from '@/modules/workspaces/events/eventListener'
const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
@@ -20,9 +21,12 @@ const initRoles = () => {
}
const workspacesModule: SpeckleModule = {
async init() {
async init(_, isInitial) {
if (!FF_WORKSPACES_MODULE_ENABLED) return
moduleLogger.info('⚒️ Init workspaces module')
if (isInitial) {
initializeEventListenersFactory({ db })()
}
await Promise.all([initScopes(), initRoles()])
}
}
@@ -10,8 +10,11 @@ import {
} from '@/modules/workspaces/domain/operations'
import { Knex } from 'knex'
import { Roles } from '@speckle/shared'
import { StreamRecord } from '@/modules/core/helpers/types'
import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace'
const tables = {
streams: (db: Knex) => db<StreamRecord>('streams'),
workspaces: (db: Knex) => db<Workspace>('workspaces'),
workspacesAcl: (db: Knex) => db<WorkspaceAcl>('workspace_acl')
}
@@ -93,7 +96,7 @@ export const upsertWorkspaceRoleFactory =
// Verify requested role is valid workspace role
const validRoles = Object.values(Roles.Workspace)
if (!validRoles.includes(role)) {
throw new Error(`Unexpected workspace role provided: ${role}`)
throw new WorkspaceInvalidRoleError()
}
await tables
@@ -12,6 +12,11 @@ export const workspaceScopes: TokenScopeData[] = [
description: 'Required for editing workspace information',
public: true
},
{
name: Scopes.Workspaces.Read,
description: 'Required for reading workspace data',
public: true
},
{
name: Scopes.Workspaces.Delete,
description: 'Required for deleting workspaces',
@@ -0,0 +1,190 @@
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import {
EmitWorkspaceEvent,
StoreBlob,
UpsertWorkspace,
UpsertWorkspaceRole
} from '@/modules/workspaces/domain/operations'
import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import { Roles } from '@speckle/shared'
import cryptoRandomString from 'crypto-random-string'
import {
grantStreamPermissions as repoGrantStreamPermissions,
revokeStreamPermissions as repoRevokeStreamPermissions
} from '@/modules/core/repositories/streams'
import { getStreams as repoGetStreams } from '@/modules/core/services/streams'
import {
DeleteWorkspaceRole,
GetWorkspaceRoleForUser,
GetWorkspaceRoles
} from '@/modules/workspaces/domain/operations'
import { WorkspaceAdminRequiredError } from '@/modules/workspaces/errors/workspace'
import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/roles'
import { mapWorkspaceRoleToProjectRole } from '@/modules/workspaces/domain/roles'
import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects'
type WorkspaceCreateArgs = {
workspaceInput: { name: string; description: string | null; logo: string | null }
userId: string
}
export const createWorkspaceFactory =
({
upsertWorkspace,
upsertWorkspaceRole,
emitWorkspaceEvent,
storeBlob
}: {
upsertWorkspace: UpsertWorkspace
upsertWorkspaceRole: UpsertWorkspaceRole
storeBlob: StoreBlob
emitWorkspaceEvent: EmitWorkspaceEvent
}) =>
async ({ userId, workspaceInput }: WorkspaceCreateArgs): Promise<Workspace> => {
let logoUrl: string | null = null
if (workspaceInput.logo) {
logoUrl = await storeBlob(workspaceInput.logo)
}
const workspace = {
...workspaceInput,
id: cryptoRandomString({ length: 10 }),
createdAt: new Date(),
updatedAt: new Date(),
logoUrl
}
await upsertWorkspace({ workspace })
// assign the creator as workspace administrator
await upsertWorkspaceRole({
userId,
role: Roles.Workspace.Admin,
workspaceId: workspace.id
})
// emit a workspace created event
await emitWorkspaceEvent({
eventName: WorkspaceEvents.Created,
payload: { ...workspace, createdByUserId: userId }
})
return workspace
}
type WorkspaceRoleDeleteArgs = {
userId: string
workspaceId: string
}
export const deleteWorkspaceRoleFactory =
({
getWorkspaceRoles,
deleteWorkspaceRole,
emitWorkspaceEvent,
getStreams,
revokeStreamPermissions
}: {
getWorkspaceRoles: GetWorkspaceRoles
deleteWorkspaceRole: DeleteWorkspaceRole
emitWorkspaceEvent: EmitWorkspaceEvent
getStreams: typeof repoGetStreams
revokeStreamPermissions: typeof repoRevokeStreamPermissions
}) =>
async ({
userId,
workspaceId
}: WorkspaceRoleDeleteArgs): Promise<WorkspaceAcl | null> => {
// Protect against removing last admin
const workspaceRoles = await getWorkspaceRoles({ workspaceId })
if (isUserLastWorkspaceAdmin(workspaceRoles, userId)) {
throw new WorkspaceAdminRequiredError()
}
// Perform delete
const deletedRole = await deleteWorkspaceRole({ userId, workspaceId })
if (!deletedRole) {
return null
}
// Delete workspace project roles
const queryAllWorkspaceProjectsGenerator = queryAllWorkspaceProjectsFactory({
getStreams
})
for await (const projectsPage of queryAllWorkspaceProjectsGenerator(workspaceId)) {
await Promise.all(
projectsPage.map(({ id: streamId }) =>
revokeStreamPermissions({ streamId, userId })
)
)
}
// Emit deleted role
await emitWorkspaceEvent({
eventName: WorkspaceEvents.RoleDeleted,
payload: deletedRole
})
return deletedRole
}
type WorkspaceRoleGetArgs = {
userId: string
workspaceId: string
}
export const getWorkspaceRoleFactory =
({ getWorkspaceRoleForUser }: { getWorkspaceRoleForUser: GetWorkspaceRoleForUser }) =>
async ({
userId,
workspaceId
}: WorkspaceRoleGetArgs): Promise<WorkspaceAcl | null> => {
return await getWorkspaceRoleForUser({ userId, workspaceId })
}
export const setWorkspaceRoleFactory =
({
getWorkspaceRoles,
upsertWorkspaceRole,
emitWorkspaceEvent,
getStreams,
grantStreamPermissions
}: {
getWorkspaceRoles: GetWorkspaceRoles
upsertWorkspaceRole: UpsertWorkspaceRole
emitWorkspaceEvent: EmitWorkspaceEvent
// TODO: Create `core` domain and import type from there
getStreams: typeof repoGetStreams
grantStreamPermissions: typeof repoGrantStreamPermissions
}) =>
async ({ userId, workspaceId, role }: WorkspaceAcl): Promise<void> => {
// Protect against removing last admin
const workspaceRoles = await getWorkspaceRoles({ workspaceId })
if (
isUserLastWorkspaceAdmin(workspaceRoles, userId) &&
role !== 'workspace:admin'
) {
throw new WorkspaceAdminRequiredError()
}
// Perform upsert
await upsertWorkspaceRole({ userId, workspaceId, role })
// Update user role in all workspace projects
// TODO: Should these be in a transaction with the workspace role change?
const queryAllWorkspaceProjectsGenerator = queryAllWorkspaceProjectsFactory({
getStreams
})
const projectRole = mapWorkspaceRoleToProjectRole(role)
for await (const projectsPage of queryAllWorkspaceProjectsGenerator(workspaceId)) {
await Promise.all(
projectsPage.map(({ id: streamId }) =>
grantStreamPermissions({ streamId, userId, role: projectRole })
)
)
}
// Emit new role
await emitWorkspaceEvent({
eventName: WorkspaceEvents.RoleUpdated,
payload: { userId, workspaceId, role }
})
}
@@ -0,0 +1,35 @@
import { StreamRecord } from '@/modules/core/helpers/types'
import { getStreams as repoGetStreams } from '@/modules/core/services/streams'
import { WorkspaceQueryError } from '@/modules/workspaces/errors/workspace'
export const queryAllWorkspaceProjectsFactory = ({
getStreams
}: {
// TODO: Core service factory functions
getStreams: typeof repoGetStreams
}) =>
async function* queryAllWorkspaceProjects(
workspaceId: string
): AsyncGenerator<StreamRecord[], void, unknown> {
let cursor: Date | null = null
let iterationCount = 0
do {
if (iterationCount > 500) throw new WorkspaceQueryError()
const { streams, cursorDate } = await getStreams({
cursor,
orderBy: null,
limit: 1000,
visibility: null,
searchQuery: null,
streamIdWhitelist: null,
workspaceIdWhitelist: [workspaceId]
})
yield streams
cursor = cursorDate
iterationCount++
} while (!!cursor)
}
@@ -1,57 +0,0 @@
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import {
EmitWorkspaceEvent,
StoreBlob,
UpsertWorkspace,
UpsertWorkspaceRole
} from '@/modules/workspaces/domain/operations'
import { Workspace } from '@/modules/workspacesCore/domain/types'
import { Roles } from '@speckle/shared'
import cryptoRandomString from 'crypto-random-string'
type WorkspaceCreateArgs = {
workspaceInput: { name: string; description: string | null; logo: string | null }
userId: string
}
export const createWorkspaceFactory =
({
upsertWorkspace,
upsertWorkspaceRole,
emitWorkspaceEvent,
storeBlob
}: {
upsertWorkspace: UpsertWorkspace
upsertWorkspaceRole: UpsertWorkspaceRole
storeBlob: StoreBlob
emitWorkspaceEvent: EmitWorkspaceEvent
}) =>
async ({ userId, workspaceInput }: WorkspaceCreateArgs): Promise<Workspace> => {
let logoUrl: string | null = null
if (workspaceInput.logo) {
logoUrl = await storeBlob(workspaceInput.logo)
}
const workspace = {
...workspaceInput,
id: cryptoRandomString({ length: 10 }),
createdAt: new Date(),
updatedAt: new Date(),
logoUrl
}
await upsertWorkspace({ workspace })
// assign the creator as workspace administrator
await upsertWorkspaceRole({
userId,
role: Roles.Workspace.Admin,
workspaceId: workspace.id
})
await emitWorkspaceEvent({
eventName: WorkspaceEvents.Created,
payload: { ...workspace, createdByUserId: userId }
})
// emit a workspace created event
return workspace
}
@@ -1,89 +0,0 @@
import {
DeleteWorkspaceRole,
EmitWorkspaceEvent,
GetWorkspaceRoleForUser,
GetWorkspaceRoles,
UpsertWorkspaceRole
} from '@/modules/workspaces/domain/operations'
import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import { WorkspaceAdminRequiredError } from '@/modules/workspaces/errors/workspace'
import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/isUserLastWorkspaceAdmin'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
type WorkspaceRoleDeleteArgs = {
userId: string
workspaceId: string
}
export const deleteWorkspaceRoleFactory =
({
getWorkspaceRoles,
deleteWorkspaceRole,
emitWorkspaceEvent
}: {
getWorkspaceRoles: GetWorkspaceRoles
deleteWorkspaceRole: DeleteWorkspaceRole
emitWorkspaceEvent: EmitWorkspaceEvent
}) =>
async ({
userId,
workspaceId
}: WorkspaceRoleDeleteArgs): Promise<WorkspaceAcl | null> => {
const workspaceRoles = await getWorkspaceRoles({ workspaceId })
if (isUserLastWorkspaceAdmin(workspaceRoles, userId)) {
throw new WorkspaceAdminRequiredError()
}
const deletedRole = await deleteWorkspaceRole({ userId, workspaceId })
if (!deletedRole) {
return null
}
emitWorkspaceEvent({ eventName: WorkspaceEvents.RoleDeleted, payload: deletedRole })
return deletedRole
}
type WorkspaceRoleGetArgs = {
userId: string
workspaceId: string
}
export const getWorkspaceRoleFactory =
({ getWorkspaceRoleForUser }: { getWorkspaceRoleForUser: GetWorkspaceRoleForUser }) =>
async ({
userId,
workspaceId
}: WorkspaceRoleGetArgs): Promise<WorkspaceAcl | null> => {
return await getWorkspaceRoleForUser({ userId, workspaceId })
}
export const setWorkspaceRoleFactory =
({
getWorkspaceRoles,
upsertWorkspaceRole,
emitWorkspaceEvent
}: {
getWorkspaceRoles: GetWorkspaceRoles
upsertWorkspaceRole: UpsertWorkspaceRole
emitWorkspaceEvent: EmitWorkspaceEvent
}) =>
async ({ userId, workspaceId, role }: WorkspaceAcl): Promise<void> => {
const workspaceRoles = await getWorkspaceRoles({ workspaceId })
if (
isUserLastWorkspaceAdmin(workspaceRoles, userId) &&
role !== 'workspace:admin'
) {
throw new WorkspaceAdminRequiredError()
}
await upsertWorkspaceRole({ userId, workspaceId, role })
await emitWorkspaceEvent({
eventName: WorkspaceEvents.RoleUpdated,
payload: { userId, workspaceId, role }
})
}
@@ -0,0 +1,54 @@
import cryptoRandomString from 'crypto-random-string'
import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import { Roles } from '@speckle/shared'
import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
import { onProjectCreatedFactory } from '@/modules/workspaces/events/eventListener'
import { expect } from 'chai'
describe('Event handlers', () => {
describe('onProjectCreatedFactory creates a function, that', () => {
it('grants project roles for all workspace members', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const projectId = cryptoRandomString({ length: 10 })
const workspaceRoles: WorkspaceAcl[] = [
{
workspaceId,
userId: cryptoRandomString({ length: 10 }),
role: Roles.Workspace.Admin
},
{
workspaceId,
userId: cryptoRandomString({ length: 10 }),
role: Roles.Workspace.Member
},
{
workspaceId,
userId: cryptoRandomString({ length: 10 }),
role: Roles.Workspace.Guest
}
]
const projectRoles: StreamAclRecord[] = []
const onProjectCreated = onProjectCreatedFactory({
getWorkspaceRoles: async () => workspaceRoles,
grantStreamPermissions: async ({ streamId, userId, role }) => {
projectRoles.push({
resourceId: streamId,
userId,
role
})
return {} as StreamRecord
}
})
await onProjectCreated({
project: { workspaceId, id: projectId } as StreamRecord
})
expect(projectRoles.length).to.equal(3)
})
})
})
@@ -0,0 +1,384 @@
import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import {
createWorkspaceFactory,
deleteWorkspaceRoleFactory,
setWorkspaceRoleFactory
} from '@/modules/workspaces/services/management'
import { Roles } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
import { expectToThrow } from '@/test/assertionHelper'
type WorkspaceTestContext = {
storedWorkspaces: Workspace[]
storedRoles: WorkspaceAcl[]
eventData: {
isCalled: boolean
eventName: string
payload: unknown
}
}
const buildCreateWorkspaceWithTestContext = (
dependecyOverrides: Partial<Parameters<typeof createWorkspaceFactory>[0]> = {}
) => {
const context: WorkspaceTestContext = {
storedWorkspaces: [],
storedRoles: [],
eventData: {
isCalled: false,
eventName: '',
payload: {}
}
}
const deps: Parameters<typeof createWorkspaceFactory>[0] = {
upsertWorkspace: async ({ workspace }: { workspace: Workspace }) => {
context.storedWorkspaces.push(workspace)
},
upsertWorkspaceRole: async (workspaceAcl: WorkspaceAcl) => {
context.storedRoles.push(workspaceAcl)
},
emitWorkspaceEvent: async ({ eventName, payload }) => {
context.eventData.isCalled = true
context.eventData.eventName = eventName
context.eventData.payload = payload
return []
},
storeBlob: async () => cryptoRandomString({ length: 10 }),
...dependecyOverrides
}
const createWorkspace = createWorkspaceFactory(deps)
return { context, createWorkspace }
}
const getCreateWorkspaceInput = () => {
return {
userId: cryptoRandomString({ length: 10 }),
workspaceInput: {
description: 'foobar',
logo: null,
name: cryptoRandomString({ length: 6 })
}
}
}
describe('Workspace services', () => {
describe('createWorkspaceFactory creates a function, that', () => {
it('stores the workspace', async () => {
const { context, createWorkspace } = buildCreateWorkspaceWithTestContext()
const { userId, workspaceInput } = getCreateWorkspaceInput()
const workspace = await createWorkspace({ userId, workspaceInput })
expect(context.storedWorkspaces.length).to.equal(1)
expect(context.storedWorkspaces[0]).to.deep.equal(workspace)
})
it('makes the workspace creator becomes a workspace:admin', async () => {
const { context, createWorkspace } = buildCreateWorkspaceWithTestContext()
const { userId, workspaceInput } = getCreateWorkspaceInput()
const workspace = await createWorkspace({ userId, workspaceInput })
expect(context.storedRoles.length).to.equal(1)
expect(context.storedRoles[0]).to.deep.equal({
userId,
workspaceId: workspace.id,
role: Roles.Workspace.Admin
})
})
it('emits a workspace created event', async () => {
const { context, createWorkspace } = buildCreateWorkspaceWithTestContext()
const { userId, workspaceInput } = getCreateWorkspaceInput()
const workspace = await createWorkspace({ userId, workspaceInput })
expect(context.eventData.isCalled).to.equal(true)
expect(context.eventData.eventName).to.equal(WorkspaceEvents.Created)
expect(context.eventData.payload).to.deep.equal({
...workspace,
createdByUserId: userId
})
})
})
})
type WorkspaceRoleTestContext = {
workspaceId: string
workspaceRoles: WorkspaceAcl[]
workspaceProjects: StreamRecord[]
workspaceProjectRoles: StreamAclRecord[]
eventData: {
isCalled: boolean
eventName: string
payload: unknown
}
}
const getDefaultWorkspaceRoleTestContext = (): WorkspaceRoleTestContext => {
return {
workspaceId: cryptoRandomString({ length: 10 }),
workspaceRoles: [],
workspaceProjects: [],
workspaceProjectRoles: [],
eventData: {
isCalled: false,
eventName: '',
payload: {}
}
}
}
const buildDeleteWorkspaceRoleAndTestContext = (
contextOverrides: Partial<WorkspaceRoleTestContext> = {},
dependencyOverrides: Partial<Parameters<typeof deleteWorkspaceRoleFactory>[0]> = {}
) => {
const context: WorkspaceRoleTestContext = {
...getDefaultWorkspaceRoleTestContext(),
...contextOverrides
}
const deps: Parameters<typeof deleteWorkspaceRoleFactory>[0] = {
getWorkspaceRoles: async () => context.workspaceRoles,
deleteWorkspaceRole: async (role) => {
const isMatch = (acl: WorkspaceAcl): boolean => {
return acl.workspaceId === role.workspaceId && acl.userId === role.userId
}
const deletedRoleIndex = context.workspaceRoles.findIndex(isMatch)
if (deletedRoleIndex < 0) {
return null
}
const deletedRole = structuredClone(context.workspaceRoles[deletedRoleIndex])
context.workspaceRoles = context.workspaceRoles.filter((acl) => !isMatch(acl))
return deletedRole
},
emitWorkspaceEvent: async ({ eventName, payload }) => {
context.eventData.isCalled = true
context.eventData.eventName = eventName
context.eventData.payload = payload
return []
},
getStreams: async () => ({
streams: context.workspaceProjects,
totalCount: context.workspaceProjects.length,
cursorDate: null
}),
revokeStreamPermissions: async ({ streamId, userId }) => {
context.workspaceProjectRoles = context.workspaceProjectRoles.filter(
(role) => role.resourceId !== streamId && role.userId !== userId
)
return {} as StreamRecord
},
...dependencyOverrides
}
const deleteWorkspaceRole = deleteWorkspaceRoleFactory(deps)
return { deleteWorkspaceRole, context }
}
const buildSetWorkspaceRoleAndTestContext = (
contextOverrides: Partial<WorkspaceRoleTestContext> = {},
dependencyOverrides: Partial<Parameters<typeof setWorkspaceRoleFactory>[0]> = {}
) => {
const context = {
...getDefaultWorkspaceRoleTestContext(),
...contextOverrides
}
const deps: Parameters<typeof setWorkspaceRoleFactory>[0] = {
getWorkspaceRoles: async () => context.workspaceRoles,
upsertWorkspaceRole: async (role) => {
const currentRoleIndex = context.workspaceRoles.findIndex(
(acl) => acl.userId === role.userId && acl.workspaceId === role.workspaceId
)
if (currentRoleIndex >= 0) {
context.workspaceRoles[currentRoleIndex] = role
} else {
context.workspaceRoles.push(role)
}
},
emitWorkspaceEvent: async ({ eventName, payload }) => {
context.eventData.isCalled = true
context.eventData.eventName = eventName
context.eventData.payload = payload
return []
},
getStreams: async () => ({
streams: context.workspaceProjects,
totalCount: context.workspaceProjects.length,
cursorDate: null
}),
grantStreamPermissions: async (role) => {
const currentRoleIndex = context.workspaceProjectRoles.findIndex(
(acl) => acl.userId === role.userId && acl.resourceId === role.streamId
)
const streamAcl: StreamAclRecord = {
userId: role.userId,
role: role.role,
resourceId: role.streamId
}
if (currentRoleIndex > 0) {
context.workspaceProjectRoles[currentRoleIndex] = streamAcl
} else {
context.workspaceProjectRoles.push(streamAcl)
}
return {} as StreamRecord
},
...dependencyOverrides
}
const setWorkspaceRole = setWorkspaceRoleFactory(deps)
return { setWorkspaceRole, context }
}
describe('Workspace role services', () => {
describe('deleteWorkspaceRoleFactory creates a function, that', () => {
it('deletes the workspace role', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member }
const { deleteWorkspaceRole, context } = buildDeleteWorkspaceRoleAndTestContext({
workspaceId,
workspaceRoles: [role]
})
const deletedRole = await deleteWorkspaceRole({ userId, workspaceId })
expect(context.workspaceRoles.length).to.equal(0)
expect(deletedRole).to.deep.equal(role)
})
it('emits a role-deleted event', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member }
const { deleteWorkspaceRole, context } = buildDeleteWorkspaceRoleAndTestContext({
workspaceId,
workspaceRoles: [role]
})
await deleteWorkspaceRole({ userId, workspaceId })
expect(context.eventData.isCalled).to.be.true
expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleDeleted)
expect(context.eventData.payload).to.deep.equal(role)
})
it('throws if attempting to delete the last admin from a workspace', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Admin }
const { deleteWorkspaceRole } = buildDeleteWorkspaceRoleAndTestContext({
workspaceId,
workspaceRoles: [role]
})
await expectToThrow(() => deleteWorkspaceRole({ userId, workspaceId }))
})
it('deletes workspace project roles', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const projectId = cryptoRandomString({ length: 10 })
const { deleteWorkspaceRole, context } = buildDeleteWorkspaceRoleAndTestContext({
workspaceId,
workspaceRoles: [{ userId, workspaceId, role: Roles.Workspace.Member }],
workspaceProjects: [{ id: projectId } as StreamRecord],
workspaceProjectRoles: [
{ userId, role: Roles.Stream.Contributor, resourceId: projectId }
]
})
await deleteWorkspaceRole({ userId, workspaceId })
expect(context.workspaceProjectRoles.length).to.equal(0)
})
})
describe('setWorkspaceRoleFactory creates a function, that', () => {
it('sets the workspace role', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member }
const { setWorkspaceRole, context } = buildSetWorkspaceRoleAndTestContext({
workspaceId
})
await setWorkspaceRole(role)
expect(context.workspaceRoles.length).to.equal(1)
expect(context.workspaceRoles[0]).to.deep.equal(role)
})
it('emits a role-updated event', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member }
const { setWorkspaceRole, context } = buildSetWorkspaceRoleAndTestContext({
workspaceId
})
await setWorkspaceRole(role)
expect(context.eventData.isCalled).to.be.true
expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated)
expect(context.eventData.payload).to.deep.equal(role)
})
it('throws if attempting to remove the last admin in a workspace', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Admin }
const { setWorkspaceRole } = buildSetWorkspaceRoleAndTestContext({
workspaceId,
workspaceRoles: [role]
})
await expectToThrow(() =>
setWorkspaceRole({ ...role, role: Roles.Workspace.Member })
)
})
it('sets roles on workspace projects', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const projectId = cryptoRandomString({ length: 10 })
const workspaceRole: WorkspaceAcl = {
userId,
workspaceId,
role: Roles.Workspace.Admin
}
const { setWorkspaceRole, context } = buildSetWorkspaceRoleAndTestContext({
workspaceId,
workspaceProjects: [{ id: projectId } as StreamRecord]
})
await setWorkspaceRole(workspaceRole)
expect(context.workspaceProjectRoles.length).to.equal(1)
expect(context.workspaceProjectRoles[0].userId).to.equal(userId)
expect(context.workspaceProjectRoles[0].resourceId).to.equal(projectId)
expect(context.workspaceProjectRoles[0].role).to.equal(Roles.Stream.Owner)
})
})
})
@@ -0,0 +1,77 @@
import { StreamRecord } from '@/modules/core/helpers/types'
import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
describe('Project retrieval services', () => {
describe('queryAllWorkspaceProjectFactory returns a generator, that', () => {
it('returns all streams for a workspace', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const foundProjects: StreamRecord[] = []
const storedProjects: StreamRecord[] = [{ workspaceId } as StreamRecord]
const queryAllWorkspaceProjectsGenerator = queryAllWorkspaceProjectsFactory({
getStreams: async () => {
return {
streams: storedProjects,
totalCount: storedProjects.length,
cursorDate: null
}
}
})
for await (const projectsPage of queryAllWorkspaceProjectsGenerator(
workspaceId
)) {
foundProjects.push(...projectsPage)
}
expect(foundProjects.length).to.equal(1)
})
it('returns all streams for a workspace if the query requires multiple pages of results', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const foundProjects: StreamRecord[] = []
const storedProjects: StreamRecord[] = [
{ workspaceId } as StreamRecord,
{ workspaceId } as StreamRecord
]
const queryAllWorkspaceProjectsGenerator = queryAllWorkspaceProjectsFactory({
getStreams: async ({ cursor }) => {
return cursor
? { streams: [storedProjects[1]], totalCount: 1, cursorDate: null }
: { streams: [storedProjects[0]], totalCount: 1, cursorDate: new Date() }
}
})
for await (const projectsPage of queryAllWorkspaceProjectsGenerator(
workspaceId
)) {
foundProjects.push(...projectsPage)
}
expect(foundProjects.length).to.equal(2)
})
it('exits if no results are found', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const foundProjects: StreamRecord[] = []
const queryAllWorkspaceProjectsGenerator = queryAllWorkspaceProjectsFactory({
getStreams: async () => {
return { streams: [], totalCount: 0, cursorDate: null }
}
})
for await (const projectsPage of queryAllWorkspaceProjectsGenerator(
workspaceId
)) {
foundProjects.push(...projectsPage)
}
expect(foundProjects.length).to.equal(0)
})
})
})
@@ -1,96 +0,0 @@
import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import { createWorkspaceFactory } from '@/modules/workspaces/services/workspaceCreation'
import { Roles } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
describe('Workspace services', () => {
describe('createWorkspaceFactory creates a function, that', () => {
it('stores the workspace', async () => {
const storedWorkspaces: Workspace[] = []
const createWorkspace = createWorkspaceFactory({
upsertWorkspace: async ({ workspace }: { workspace: Workspace }) => {
storedWorkspaces.push(workspace)
},
upsertWorkspaceRole: async () => {},
emitWorkspaceEvent: async () => [],
storeBlob: async () => cryptoRandomString({ length: 10 })
})
const workspaceInput = {
description: 'foobar',
logo: null,
name: cryptoRandomString({ length: 6 })
}
const workspace = await createWorkspace({
userId: cryptoRandomString({ length: 10 }),
workspaceInput
})
expect(storedWorkspaces.length).to.equal(1)
expect(storedWorkspaces[0]).to.deep.equal(workspace)
})
it('makes the workspace creator becomes a workspace:admin', async () => {
const storedRole: WorkspaceAcl[] = []
const createWorkspace = createWorkspaceFactory({
upsertWorkspace: async () => {},
upsertWorkspaceRole: async (workspaceAcl: WorkspaceAcl) => {
storedRole.push(workspaceAcl)
},
emitWorkspaceEvent: async () => [],
storeBlob: async () => cryptoRandomString({ length: 10 })
})
const workspaceInput = {
description: 'foobar',
logo: null,
name: cryptoRandomString({ length: 6 })
}
const userId = cryptoRandomString({ length: 10 })
const workspace = await createWorkspace({
userId,
workspaceInput
})
expect(storedRole.length).to.equal(1)
expect(storedRole[0]).to.deep.equal({
userId,
workspaceId: workspace.id,
role: Roles.Workspace.Admin
})
})
it('emits a workspace created event', async () => {
const eventData = {
isCalled: false,
eventName: '',
payload: {}
}
const createWorkspace = createWorkspaceFactory({
upsertWorkspace: async () => {},
upsertWorkspaceRole: async () => {},
emitWorkspaceEvent: async ({ eventName, payload }) => {
eventData.isCalled = true
eventData.eventName = eventName
eventData.payload = payload
return []
},
storeBlob: async () => cryptoRandomString({ length: 10 })
})
const workspaceInput = {
description: 'foobar',
logo: null,
name: cryptoRandomString({ length: 6 })
}
const userId = cryptoRandomString({ length: 10 })
const workspace = await createWorkspace({
userId,
workspaceInput
})
expect(eventData.isCalled).to.equal(true)
expect(eventData.eventName).to.equal(WorkspaceEvents.Created)
expect(eventData.payload).to.deep.equal({ ...workspace, createdByUserId: userId })
})
})
})
@@ -1,172 +0,0 @@
import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import {
deleteWorkspaceRoleFactory,
setWorkspaceRoleFactory
} from '@/modules/workspaces/services/workspaceRoleCreation'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import { expectToThrow } from '@/test/assertionHelper'
import { Roles } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
describe('Workspace role services', () => {
describe('deleteWorkspaceRoleFactory creates a function, that', () => {
it('deletes the workspace role', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member }
let storedRoles: WorkspaceAcl[] = [role]
const deleteWorkspaceRole = deleteWorkspaceRoleFactory({
getWorkspaceRoles: async () => storedRoles,
deleteWorkspaceRole: async ({ userId, workspaceId }) => {
const role = storedRoles.find(
(r) => r.userId === userId && r.workspaceId === workspaceId
)
storedRoles = storedRoles.filter((r) => r.userId !== userId)
return role ?? null
},
emitWorkspaceEvent: async () => []
})
const deletedRole = await deleteWorkspaceRole({ userId, workspaceId })
expect(storedRoles.length).to.equal(0)
expect(deletedRole).to.deep.equal(role)
})
it('emits a role-deleted event', async () => {
const eventData = {
isCalled: false,
eventName: '',
payload: {}
}
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member }
const storedRoles: WorkspaceAcl[] = [role]
const deleteWorkspaceRole = deleteWorkspaceRoleFactory({
getWorkspaceRoles: async () => storedRoles,
deleteWorkspaceRole: async () => {
return storedRoles[0]
},
emitWorkspaceEvent: async ({ eventName, payload }) => {
eventData.isCalled = true
eventData.eventName = eventName
eventData.payload = payload
return []
}
})
await deleteWorkspaceRole({ userId, workspaceId })
expect(eventData.isCalled).to.be.true
expect(eventData.eventName).to.equal(WorkspaceEvents.RoleDeleted)
expect(eventData.payload).to.deep.equal(role)
})
it('throws if attempting to delete the last admin from a workspace', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Admin }
let storedRoles: WorkspaceAcl[] = [role]
const deleteWorkspaceRole = deleteWorkspaceRoleFactory({
getWorkspaceRoles: async () => storedRoles,
deleteWorkspaceRole: async ({ userId, workspaceId }) => {
const role = storedRoles.find(
(r) => r.userId === userId && r.workspaceId === workspaceId
)
storedRoles = storedRoles.filter((r) => r.userId !== userId)
return role ?? null
},
emitWorkspaceEvent: async () => []
})
await expectToThrow(() => deleteWorkspaceRole({ userId, workspaceId }))
})
})
describe('setWorkspaceRoleFactory creates a function, that', () => {
it('sets the workspace role', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member }
const storedRoles: WorkspaceAcl[] = []
const setWorkspaceRole = setWorkspaceRoleFactory({
getWorkspaceRoles: async () => storedRoles,
upsertWorkspaceRole: async (role) => {
storedRoles.push(role)
},
emitWorkspaceEvent: async () => []
})
await setWorkspaceRole(role)
expect(storedRoles.length).to.equal(1)
expect(storedRoles[0]).to.deep.equal(role)
})
it('emits a role-updated event', async () => {
const eventData = {
isCalled: false,
eventName: '',
payload: {}
}
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member }
const setWorkspaceRole = setWorkspaceRoleFactory({
getWorkspaceRoles: async () => [],
upsertWorkspaceRole: async () => {},
emitWorkspaceEvent: async ({ eventName, payload }) => {
eventData.isCalled = true
eventData.eventName = eventName
eventData.payload = payload
return []
}
})
await setWorkspaceRole(role)
expect(eventData.isCalled).to.be.true
expect(eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated)
expect(eventData.payload).to.deep.equal(role)
})
it('throws if attempting to remove the last admin in a workspace', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Admin }
const storedRoles: WorkspaceAcl[] = [role]
const setWorkspaceRole = setWorkspaceRoleFactory({
getWorkspaceRoles: async () => storedRoles,
upsertWorkspaceRole: async () => {},
emitWorkspaceEvent: async () => []
})
await expectToThrow(() =>
setWorkspaceRole({ ...role, role: Roles.Workspace.Member })
)
})
})
})
@@ -1,4 +1,4 @@
import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/isUserLastWorkspaceAdmin'
import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/roles'
import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import { expect } from 'chai'
import { Roles } from '@speckle/shared'
@@ -1,3 +1,4 @@
import { Roles } from '@speckle/shared'
import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
export const isUserLastWorkspaceAdmin = (
@@ -5,7 +6,7 @@ export const isUserLastWorkspaceAdmin = (
userId: string
): boolean => {
const workspaceAdmins = workspaceRoles.filter(
({ role }) => role === 'workspace:admin'
({ role }) => role === Roles.Workspace.Admin
)
const isUserAdmin = workspaceAdmins.some((role) => role.userId === userId)
@@ -0,0 +1,75 @@
import { WorkspacesModuleDisabledError } from '@/modules/core/errors/workspaces'
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
export = !FF_WORKSPACES_MODULE_ENABLED
? ({
Query: {
workspace: async () => {
throw new WorkspacesModuleDisabledError()
}
},
WorkspaceMutations: {
create: async () => {
throw new WorkspacesModuleDisabledError()
},
delete: async () => {
throw new WorkspacesModuleDisabledError()
},
update: async () => {
throw new WorkspacesModuleDisabledError()
},
updateRole: async () => {
throw new WorkspacesModuleDisabledError()
},
deleteRole: async () => {
throw new WorkspacesModuleDisabledError()
}
},
WorkspaceInviteMutations: {
create: async () => {
throw new WorkspacesModuleDisabledError()
},
batchCreate: async () => {
throw new WorkspacesModuleDisabledError()
},
use: async () => {
throw new WorkspacesModuleDisabledError()
},
cancel: async () => {
throw new WorkspacesModuleDisabledError()
}
},
Workspace: {
role: async () => {
throw new WorkspacesModuleDisabledError()
},
team: async () => {
throw new WorkspacesModuleDisabledError()
},
invitedTeam: async () => {
throw new WorkspacesModuleDisabledError()
},
projects: async () => {
throw new WorkspacesModuleDisabledError()
}
},
User: {
workspaces: async () => {
throw new WorkspacesModuleDisabledError()
}
},
Project: {
workspace: async () => {
throw new WorkspacesModuleDisabledError()
}
},
AdminQueries: {
workspaceList: async () => {
throw new WorkspacesModuleDisabledError()
}
}
} as Resolvers)
: {}
@@ -67,6 +67,7 @@ export type AdminQueries = {
projectList: ProjectCollection;
serverStatistics: ServerStatistics;
userList: AdminUserList;
workspaceList: WorkspaceCollection;
};
@@ -93,6 +94,13 @@ export type AdminQueriesUserListArgs = {
role?: InputMaybe<ServerRole>;
};
export type AdminQueriesWorkspaceListArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: Scalars['Int']['input'];
query?: InputMaybe<Scalars['String']['input']>;
};
export type AdminUserList = {
__typename?: 'AdminUserList';
cursor?: Maybe<Scalars['String']['output']>;
@@ -1309,6 +1317,7 @@ export type Mutation = {
webhookDelete: Scalars['String']['output'];
/** Updates an existing webhook */
webhookUpdate: Scalars['String']['output'];
workspaceMutations: WorkspaceMutations;
};
@@ -1690,6 +1699,22 @@ export type PendingStreamCollaborator = {
user?: Maybe<LimitedUser>;
};
export type PendingWorkspaceCollaborator = {
__typename?: 'PendingWorkspaceCollaborator';
id: Scalars['ID']['output'];
inviteId: Scalars['String']['output'];
invitedBy: LimitedUser;
role: Scalars['String']['output'];
/** E-mail address or name of the invited user */
title: Scalars['String']['output'];
/** Only available if the active user is the pending workspace collaborator */
token?: Maybe<Scalars['String']['output']>;
/** Set only if user is registered */
user?: Maybe<LimitedUser>;
workspaceId: Scalars['String']['output'];
workspaceName: Scalars['String']['output'];
};
export type Project = {
__typename?: 'Project';
allowPublicComments: Scalars['Boolean']['output'];
@@ -1741,6 +1766,7 @@ export type Project = {
viewerResources: Array<ViewerResourceGroup>;
visibility: ProjectVisibility;
webhooks: WebhookCollection;
workspace?: Maybe<Workspace>;
};
@@ -2387,6 +2413,7 @@ export type Query = {
* The query looks for matches in name & email
*/
userSearch: UserSearchResultCollection;
workspace: Workspace;
};
@@ -2512,6 +2539,11 @@ export type QueryUserSearchArgs = {
query: Scalars['String']['input'];
};
export type QueryWorkspaceArgs = {
id: Scalars['String']['input'];
};
/** Deprecated: Used by old stream-based mutations */
export type ReplyCreateInput = {
/** IDs of uploaded blobs that should be attached to this reply */
@@ -2614,6 +2646,7 @@ export type ServerInfo = {
serverRoles: Array<ServerRoleItem>;
termsOfService?: Maybe<Scalars['String']['output']>;
version?: Maybe<Scalars['String']['output']>;
workspaces: ServerWorkspacesInfo;
};
export type ServerInfoUpdateInput = {
@@ -2682,6 +2715,15 @@ export type ServerStats = {
userHistory?: Maybe<Array<Maybe<Scalars['JSONObject']['output']>>>;
};
export type ServerWorkspacesInfo = {
__typename?: 'ServerWorkspacesInfo';
/**
* This is a backend control variable for the workspaces feature set.
* Since workspaces need a backend logic to be enabled, this is not enough as a feature flag.
*/
workspacesEnabled: Scalars['Boolean']['output'];
};
export type SmartTextEditorValue = {
__typename?: 'SmartTextEditorValue';
/** File attachments, if any */
@@ -3302,6 +3344,8 @@ export type User = {
* Note: Only count resolution is currently implemented
*/
versions: CountOnlyCollection;
/** Get the workspaces for the user */
workspaces: WorkspaceCollection;
};
@@ -3389,6 +3433,17 @@ export type UserVersionsArgs = {
limit?: Scalars['Int']['input'];
};
/**
* Full user type, should only be used in the context of admin operations or
* when a user is reading/writing info about himself
*/
export type UserWorkspacesArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<UserWorkspacesFilter>;
limit?: Scalars['Int']['input'];
};
export type UserAutomateInfo = {
__typename?: 'UserAutomateInfo';
availableGithubOrgs: Array<Scalars['String']['output']>;
@@ -3439,6 +3494,10 @@ export type UserUpdateInput = {
name?: InputMaybe<Scalars['String']['input']>;
};
export type UserWorkspacesFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
export type Version = {
__typename?: 'Version';
authorUser?: Maybe<LimitedUser>;
@@ -3651,6 +3710,152 @@ export type WebhookUpdateInput = {
url?: InputMaybe<Scalars['String']['input']>;
};
export type Workspace = {
__typename?: 'Workspace';
createdAt: Scalars['DateTime']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
invitedTeam?: Maybe<Array<PendingWorkspaceCollaborator>>;
name: Scalars['String']['output'];
projects: ProjectCollection;
/** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */
role?: Maybe<Scalars['String']['output']>;
team: Array<WorkspaceCollaborator>;
updatedAt: Scalars['DateTime']['output'];
};
export type WorkspaceProjectsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<UserProjectsFilter>;
limit?: Scalars['Int']['input'];
};
export type WorkspaceCollaborator = {
__typename?: 'WorkspaceCollaborator';
id: Scalars['ID']['output'];
role: Scalars['String']['output'];
user: LimitedUser;
};
export type WorkspaceCollection = {
__typename?: 'WorkspaceCollection';
cursor?: Maybe<Scalars['String']['output']>;
items: Array<Workspace>;
totalCount: Scalars['Int']['output'];
};
export type WorkspaceCreateInput = {
description?: InputMaybe<Scalars['String']['input']>;
logoUrl?: InputMaybe<Scalars['String']['input']>;
name: Scalars['String']['input'];
};
export type WorkspaceInviteCreateInput = {
/** Either this or userId must be filled */
email?: InputMaybe<Scalars['String']['input']>;
/** Defaults to the member role, if not specified */
role?: InputMaybe<WorkspaceRole>;
/** Either this or email must be filled */
userId?: InputMaybe<Scalars['String']['input']>;
};
export type WorkspaceInviteMutations = {
__typename?: 'WorkspaceInviteMutations';
batchCreate: Workspace;
cancel: Workspace;
create: Workspace;
use: Scalars['Boolean']['output'];
};
export type WorkspaceInviteMutationsBatchCreateArgs = {
input: Array<WorkspaceInviteCreateInput>;
workspaceId: Scalars['String']['input'];
};
export type WorkspaceInviteMutationsCancelArgs = {
inviteId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
};
export type WorkspaceInviteMutationsCreateArgs = {
input: WorkspaceInviteCreateInput;
workspaceId: Scalars['String']['input'];
};
export type WorkspaceInviteMutationsUseArgs = {
input: WorkspaceInviteUseInput;
};
export type WorkspaceInviteUseInput = {
accept: Scalars['Boolean']['input'];
token: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
};
export type WorkspaceMutations = {
__typename?: 'WorkspaceMutations';
create: Workspace;
delete: Workspace;
deleteRole: Scalars['Boolean']['output'];
invites: WorkspaceInviteMutations;
update: Workspace;
updateRole: Scalars['Boolean']['output'];
};
export type WorkspaceMutationsCreateArgs = {
input: WorkspaceCreateInput;
};
export type WorkspaceMutationsDeleteArgs = {
workspaceId: Scalars['String']['input'];
};
export type WorkspaceMutationsDeleteRoleArgs = {
input: WorkspaceRoleDeleteInput;
};
export type WorkspaceMutationsUpdateArgs = {
input: WorkspaceUpdateInput;
};
export type WorkspaceMutationsUpdateRoleArgs = {
input: WorkspaceRoleUpdateInput;
};
export enum WorkspaceRole {
Admin = 'ADMIN',
Guest = 'GUEST',
Member = 'MEMBER'
}
export type WorkspaceRoleDeleteInput = {
userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
};
export type WorkspaceRoleUpdateInput = {
role: WorkspaceRole;
userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
};
export type WorkspaceUpdateInput = {
description?: InputMaybe<Scalars['String']['input']>;
id: Scalars['String']['input'];
logoUrl?: InputMaybe<Scalars['String']['input']>;
name?: InputMaybe<Scalars['String']['input']>;
};
export type BasicStreamAccessRequestFieldsFragment = { __typename?: 'StreamAccessRequest', id: string, requesterId: string, streamId: string, createdAt: string, requester: { __typename?: 'LimitedUser', id: string, name: string } };
export type CreateStreamAccessRequestMutationVariables = Exact<{
+2 -1
View File
@@ -113,6 +113,7 @@ export const Scopes = Object.freeze(<const>{
},
Workspaces: {
Create: 'workspace:create',
Read: 'workspace:read',
Update: 'workspace:update',
Delete: 'workspace:delete'
}
@@ -140,7 +141,7 @@ export type AvailableScopes =
| AppScopes
| AutomateScopes
| AutomateFunctionScopes
| WorkspaceRoles
| WorkspaceScopes
/**
* All scopes