diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index c057bea2a..3d38bf48d 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -2029,6 +2029,8 @@ export type Project = { commentThreads: ProjectCommentCollection; createdAt: Scalars['DateTime']['output']; description?: Maybe; + /** Public project-level configuration for embedded viewer */ + embedOptions: ProjectEmbedOptions; id: Scalars['ID']['output']; invitableCollaborators: WorkspaceCollaboratorCollection; /** Collaborators who have been invited, but not yet accepted. */ @@ -2362,6 +2364,11 @@ export type ProjectCreateInput = { visibility?: InputMaybe; }; +export type ProjectEmbedOptions = { + __typename?: 'ProjectEmbedOptions'; + hideSpeckleBranding: Scalars['Boolean']['output']; +}; + export type ProjectFileImportUpdatedMessage = { __typename?: 'ProjectFileImportUpdatedMessage'; /** Upload ID */ @@ -4469,6 +4476,8 @@ export type Workspace = { domainBasedMembershipProtectionEnabled: Scalars['Boolean']['output']; /** Verified workspace domains */ domains?: Maybe>; + /** Workspace-level configuration for models in embedded viewer */ + embedOptions: WorkspaceEmbedOptions; hasAccessToFeature: Scalars['Boolean']['output']; id: Scalars['ID']['output']; /** Only available to workspace owners/members */ @@ -4620,6 +4629,11 @@ export type WorkspaceDomainDeleteInput = { workspaceId: Scalars['ID']['input']; }; +export type WorkspaceEmbedOptions = { + __typename?: 'WorkspaceEmbedOptions'; + hideSpeckleBranding: Scalars['Boolean']['output']; +}; + export const WorkspaceFeatureName = { DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies', OidcSso: 'oidcSso', @@ -4756,6 +4770,7 @@ export type WorkspaceMutations = { setDefaultRegion: Workspace; update: Workspace; updateCreationState: Scalars['Boolean']['output']; + updateEmbedOptions: WorkspaceEmbedOptions; updateRole: Workspace; updateSeatType: Workspace; }; @@ -4817,6 +4832,11 @@ export type WorkspaceMutationsUpdateCreationStateArgs = { }; +export type WorkspaceMutationsUpdateEmbedOptionsArgs = { + input: WorkspaceUpdateEmbedOptionsInput; +}; + + export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; @@ -5081,6 +5101,11 @@ export type WorkspaceTeamFilter = { seatType?: InputMaybe; }; +export type WorkspaceUpdateEmbedOptionsInput = { + hideSpeckleBranding: Scalars['Boolean']['input']; + workspaceId: Scalars['String']['input']; +}; + export type WorkspaceUpdateInput = { description?: InputMaybe; discoverabilityEnabled?: InputMaybe; @@ -7594,6 +7619,7 @@ export type AllObjectTypes = { ProjectCollection: ProjectCollection, ProjectCommentCollection: ProjectCommentCollection, ProjectCommentsUpdatedMessage: ProjectCommentsUpdatedMessage, + ProjectEmbedOptions: ProjectEmbedOptions, ProjectFileImportUpdatedMessage: ProjectFileImportUpdatedMessage, ProjectInviteMutations: ProjectInviteMutations, ProjectModelsUpdatedMessage: ProjectModelsUpdatedMessage, @@ -7669,6 +7695,7 @@ export type AllObjectTypes = { WorkspaceCollection: WorkspaceCollection, WorkspaceCreationState: WorkspaceCreationState, WorkspaceDomain: WorkspaceDomain, + WorkspaceEmbedOptions: WorkspaceEmbedOptions, WorkspaceInviteMutations: WorkspaceInviteMutations, WorkspaceJoinRequest: WorkspaceJoinRequest, WorkspaceJoinRequestCollection: WorkspaceJoinRequestCollection, @@ -8292,6 +8319,7 @@ export type ProjectFieldArgs = { commentThreads: ProjectCommentThreadsArgs, createdAt: {}, description: {}, + embedOptions: {}, id: {}, invitableCollaborators: ProjectInvitableCollaboratorsArgs, invitedTeam: {}, @@ -8368,6 +8396,9 @@ export type ProjectCommentsUpdatedMessageFieldArgs = { id: {}, type: {}, } +export type ProjectEmbedOptionsFieldArgs = { + hideSpeckleBranding: {}, +} export type ProjectFileImportUpdatedMessageFieldArgs = { id: {}, type: {}, @@ -8919,6 +8950,7 @@ export type WorkspaceFieldArgs = { discoverabilityEnabled: {}, domainBasedMembershipProtectionEnabled: {}, domains: {}, + embedOptions: {}, hasAccessToFeature: WorkspaceHasAccessToFeatureArgs, id: {}, invitedTeam: WorkspaceInvitedTeamArgs, @@ -8970,6 +9002,9 @@ export type WorkspaceDomainFieldArgs = { domain: {}, id: {}, } +export type WorkspaceEmbedOptionsFieldArgs = { + hideSpeckleBranding: {}, +} export type WorkspaceInviteMutationsFieldArgs = { batchCreate: WorkspaceInviteMutationsBatchCreateArgs, cancel: WorkspaceInviteMutationsCancelArgs, @@ -9008,6 +9043,7 @@ export type WorkspaceMutationsFieldArgs = { setDefaultRegion: WorkspaceMutationsSetDefaultRegionArgs, update: WorkspaceMutationsUpdateArgs, updateCreationState: WorkspaceMutationsUpdateCreationStateArgs, + updateEmbedOptions: WorkspaceMutationsUpdateEmbedOptionsArgs, updateRole: WorkspaceMutationsUpdateRoleArgs, updateSeatType: WorkspaceMutationsUpdateSeatTypeArgs, } @@ -9179,6 +9215,7 @@ export type AllObjectFieldArgTypes = { ProjectCollection: ProjectCollectionFieldArgs, ProjectCommentCollection: ProjectCommentCollectionFieldArgs, ProjectCommentsUpdatedMessage: ProjectCommentsUpdatedMessageFieldArgs, + ProjectEmbedOptions: ProjectEmbedOptionsFieldArgs, ProjectFileImportUpdatedMessage: ProjectFileImportUpdatedMessageFieldArgs, ProjectInviteMutations: ProjectInviteMutationsFieldArgs, ProjectModelsUpdatedMessage: ProjectModelsUpdatedMessageFieldArgs, @@ -9254,6 +9291,7 @@ export type AllObjectFieldArgTypes = { WorkspaceCollection: WorkspaceCollectionFieldArgs, WorkspaceCreationState: WorkspaceCreationStateFieldArgs, WorkspaceDomain: WorkspaceDomainFieldArgs, + WorkspaceEmbedOptions: WorkspaceEmbedOptionsFieldArgs, WorkspaceInviteMutations: WorkspaceInviteMutationsFieldArgs, WorkspaceJoinRequest: WorkspaceJoinRequestFieldArgs, WorkspaceJoinRequestCollection: WorkspaceJoinRequestCollectionFieldArgs, diff --git a/packages/server/assets/workspacesCore/typedefs/permissions.graphql b/packages/server/assets/workspacesCore/typedefs/permissions.graphql index 41e9923e3..c0265710a 100644 --- a/packages/server/assets/workspacesCore/typedefs/permissions.graphql +++ b/packages/server/assets/workspacesCore/typedefs/permissions.graphql @@ -6,4 +6,5 @@ type WorkspacePermissionChecks { canCreateProject: PermissionCheckResult! canInvite: PermissionCheckResult! canMoveProjectToWorkspace(projectId: String): PermissionCheckResult! + canEditEmbedOptions: PermissionCheckResult! } diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index 7032e154a..a7f4591d6 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -149,6 +149,7 @@ type WorkspaceMutations { invites: WorkspaceInviteMutations! projects: WorkspaceProjectMutations! @hasServerRole(role: SERVER_USER) updateCreationState(input: WorkspaceCreationStateInput!): Boolean! + updateEmbedOptions(input: WorkspaceUpdateEmbedOptionsInput!): WorkspaceEmbedOptions! """ Dismiss a workspace from the discoverable list, behind the scene a join request is created with the status "dismissed" """ @@ -157,6 +158,11 @@ type WorkspaceMutations { @hasServerRole(role: SERVER_USER) } +input WorkspaceUpdateEmbedOptionsInput { + workspaceId: String! + hideSpeckleBranding: Boolean! +} + input WorkspaceDismissInput { workspaceId: ID! } @@ -304,6 +310,10 @@ type Workspace { """ creationState: WorkspaceCreationState """ + Workspace-level configuration for models in embedded viewer + """ + embedOptions: WorkspaceEmbedOptions! + """ Get all join requests for all the workspaces the user is an admin of """ adminWorkspacesJoinRequests( @@ -316,6 +326,10 @@ type Workspace { @hasWorkspaceRole(role: ADMIN) } +type WorkspaceEmbedOptions { + hideSpeckleBranding: Boolean! +} + type WorkspaceTeamByRole { admins: WorkspaceRoleCollection members: WorkspaceRoleCollection @@ -551,11 +565,19 @@ extend type User { extend type Project { workspace: Workspace """ + Public project-level configuration for embedded viewer + """ + embedOptions: ProjectEmbedOptions! + """ Returns information about the potential effects of moving a project to a given workspace. """ moveToWorkspaceDryRun(workspaceId: String!): ProjectMoveToWorkspaceDryRun! } +type ProjectEmbedOptions { + hideSpeckleBranding: Boolean! +} + type ProjectMoveToWorkspaceDryRun { addedToWorkspace(limit: Int): [LimitedUser!]! addedToWorkspaceTotalCount: Int! diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index d98a0018f..e84941aea 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -2052,6 +2052,8 @@ export type Project = { commentThreads: ProjectCommentCollection; createdAt: Scalars['DateTime']['output']; description?: Maybe; + /** Public project-level configuration for embedded viewer */ + embedOptions: ProjectEmbedOptions; id: Scalars['ID']['output']; invitableCollaborators: WorkspaceCollaboratorCollection; /** Collaborators who have been invited, but not yet accepted. */ @@ -2385,6 +2387,11 @@ export type ProjectCreateInput = { visibility?: InputMaybe; }; +export type ProjectEmbedOptions = { + __typename?: 'ProjectEmbedOptions'; + hideSpeckleBranding: Scalars['Boolean']['output']; +}; + export type ProjectFileImportUpdatedMessage = { __typename?: 'ProjectFileImportUpdatedMessage'; /** Upload ID */ @@ -4492,6 +4499,8 @@ export type Workspace = { domainBasedMembershipProtectionEnabled: Scalars['Boolean']['output']; /** Verified workspace domains */ domains?: Maybe>; + /** Workspace-level configuration for models in embedded viewer */ + embedOptions: WorkspaceEmbedOptions; hasAccessToFeature: Scalars['Boolean']['output']; id: Scalars['ID']['output']; /** Only available to workspace owners/members */ @@ -4643,6 +4652,11 @@ export type WorkspaceDomainDeleteInput = { workspaceId: Scalars['ID']['input']; }; +export type WorkspaceEmbedOptions = { + __typename?: 'WorkspaceEmbedOptions'; + hideSpeckleBranding: Scalars['Boolean']['output']; +}; + export const WorkspaceFeatureName = { DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies', OidcSso: 'oidcSso', @@ -4779,6 +4793,7 @@ export type WorkspaceMutations = { setDefaultRegion: Workspace; update: Workspace; updateCreationState: Scalars['Boolean']['output']; + updateEmbedOptions: WorkspaceEmbedOptions; updateRole: Workspace; updateSeatType: Workspace; }; @@ -4840,6 +4855,11 @@ export type WorkspaceMutationsUpdateCreationStateArgs = { }; +export type WorkspaceMutationsUpdateEmbedOptionsArgs = { + input: WorkspaceUpdateEmbedOptionsInput; +}; + + export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; @@ -4867,6 +4887,7 @@ export type WorkspacePaymentMethod = typeof WorkspacePaymentMethod[keyof typeof export type WorkspacePermissionChecks = { __typename?: 'WorkspacePermissionChecks'; canCreateProject: PermissionCheckResult; + canEditEmbedOptions: PermissionCheckResult; canInvite: PermissionCheckResult; canMoveProjectToWorkspace: PermissionCheckResult; }; @@ -5104,6 +5125,11 @@ export type WorkspaceTeamFilter = { seatType?: InputMaybe; }; +export type WorkspaceUpdateEmbedOptionsInput = { + hideSpeckleBranding: Scalars['Boolean']['input']; + workspaceId: Scalars['String']['input']; +}; + export type WorkspaceUpdateInput = { /** @deprecated Always the reviewer role. Will be removed in the future. */ defaultProjectRole?: InputMaybe; @@ -5361,6 +5387,7 @@ export type ResolversTypes = { ProjectCommentsUpdatedMessage: ResolverTypeWrapper & { comment?: Maybe }>; ProjectCommentsUpdatedMessageType: ProjectCommentsUpdatedMessageType; ProjectCreateInput: ProjectCreateInput; + ProjectEmbedOptions: ResolverTypeWrapper; ProjectFileImportUpdatedMessage: ResolverTypeWrapper & { upload: ResolversTypes['FileUpload'] }>; ProjectFileImportUpdatedMessageType: ProjectFileImportUpdatedMessageType; ProjectInviteCreateInput: ProjectInviteCreateInput; @@ -5492,6 +5519,7 @@ export type ResolversTypes = { WorkspaceDismissInput: WorkspaceDismissInput; WorkspaceDomain: ResolverTypeWrapper; WorkspaceDomainDeleteInput: WorkspaceDomainDeleteInput; + WorkspaceEmbedOptions: ResolverTypeWrapper; WorkspaceFeatureName: WorkspaceFeatureName; WorkspaceInviteCreateInput: WorkspaceInviteCreateInput; WorkspaceInviteLookupOptions: WorkspaceInviteLookupOptions; @@ -5534,6 +5562,7 @@ export type ResolversTypes = { WorkspaceSubscriptionSeats: ResolverTypeWrapper; WorkspaceTeamByRole: ResolverTypeWrapper; WorkspaceTeamFilter: WorkspaceTeamFilter; + WorkspaceUpdateEmbedOptionsInput: WorkspaceUpdateEmbedOptionsInput; WorkspaceUpdateInput: WorkspaceUpdateInput; WorkspaceUpdateSeatTypeInput: WorkspaceUpdateSeatTypeInput; WorkspaceUpdatedMessage: ResolverTypeWrapper & { workspace: ResolversTypes['Workspace'] }>; @@ -5691,6 +5720,7 @@ export type ResolversParentTypes = { ProjectCommentsFilter: ProjectCommentsFilter; ProjectCommentsUpdatedMessage: Omit & { comment?: Maybe }; ProjectCreateInput: ProjectCreateInput; + ProjectEmbedOptions: ProjectEmbedOptions; ProjectFileImportUpdatedMessage: Omit & { upload: ResolversParentTypes['FileUpload'] }; ProjectInviteCreateInput: ProjectInviteCreateInput; ProjectInviteMutations: MutationsObjectGraphQLReturn; @@ -5806,6 +5836,7 @@ export type ResolversParentTypes = { WorkspaceDismissInput: WorkspaceDismissInput; WorkspaceDomain: WorkspaceDomain; WorkspaceDomainDeleteInput: WorkspaceDomainDeleteInput; + WorkspaceEmbedOptions: WorkspaceEmbedOptions; WorkspaceInviteCreateInput: WorkspaceInviteCreateInput; WorkspaceInviteLookupOptions: WorkspaceInviteLookupOptions; WorkspaceInviteMutations: WorkspaceInviteMutationsGraphQLReturn; @@ -5840,6 +5871,7 @@ export type ResolversParentTypes = { WorkspaceSubscriptionSeats: WorkspaceSubscriptionSeatsGraphQLReturn; WorkspaceTeamByRole: WorkspaceTeamByRole; WorkspaceTeamFilter: WorkspaceTeamFilter; + WorkspaceUpdateEmbedOptionsInput: WorkspaceUpdateEmbedOptionsInput; WorkspaceUpdateInput: WorkspaceUpdateInput; WorkspaceUpdateSeatTypeInput: WorkspaceUpdateSeatTypeInput; WorkspaceUpdatedMessage: Omit & { workspace: ResolversParentTypes['Workspace'] }; @@ -6639,6 +6671,7 @@ export type ProjectResolvers>; createdAt?: Resolver; description?: Resolver, ParentType, ContextType>; + embedOptions?: Resolver; id?: Resolver; invitableCollaborators?: Resolver>; invitedTeam?: Resolver>, ParentType, ContextType>; @@ -6733,6 +6766,11 @@ export type ProjectCommentsUpdatedMessageResolvers; }; +export type ProjectEmbedOptionsResolvers = { + hideSpeckleBranding?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ProjectFileImportUpdatedMessageResolvers = { id?: Resolver; type?: Resolver; @@ -7418,6 +7456,7 @@ export type WorkspaceResolvers; domainBasedMembershipProtectionEnabled?: Resolver; domains?: Resolver>, ParentType, ContextType>; + embedOptions?: Resolver; hasAccessToFeature?: Resolver>; id?: Resolver; invitedTeam?: Resolver>, ParentType, ContextType, Partial>; @@ -7483,6 +7522,11 @@ export type WorkspaceDomainResolvers; }; +export type WorkspaceEmbedOptionsResolvers = { + hideSpeckleBranding?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type WorkspaceInviteMutationsResolvers = { batchCreate?: Resolver>; cancel?: Resolver>; @@ -7529,6 +7573,7 @@ export type WorkspaceMutationsResolvers>; update?: Resolver>; updateCreationState?: Resolver>; + updateEmbedOptions?: Resolver>; updateRole?: Resolver>; updateSeatType?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; @@ -7544,6 +7589,7 @@ export type WorkspacePaidPlanPricesResolvers = { canCreateProject?: Resolver; + canEditEmbedOptions?: Resolver; canInvite?: Resolver; canMoveProjectToWorkspace?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; @@ -7743,6 +7789,7 @@ export type Resolvers = { ProjectCollection?: ProjectCollectionResolvers; ProjectCommentCollection?: ProjectCommentCollectionResolvers; ProjectCommentsUpdatedMessage?: ProjectCommentsUpdatedMessageResolvers; + ProjectEmbedOptions?: ProjectEmbedOptionsResolvers; ProjectFileImportUpdatedMessage?: ProjectFileImportUpdatedMessageResolvers; ProjectInviteMutations?: ProjectInviteMutationsResolvers; ProjectModelsUpdatedMessage?: ProjectModelsUpdatedMessageResolvers; @@ -7818,6 +7865,7 @@ export type Resolvers = { WorkspaceCollection?: WorkspaceCollectionResolvers; WorkspaceCreationState?: WorkspaceCreationStateResolvers; WorkspaceDomain?: WorkspaceDomainResolvers; + WorkspaceEmbedOptions?: WorkspaceEmbedOptionsResolvers; WorkspaceInviteMutations?: WorkspaceInviteMutationsResolvers; WorkspaceJoinRequest?: WorkspaceJoinRequestResolvers; WorkspaceJoinRequestCollection?: WorkspaceJoinRequestCollectionResolvers; diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index cef428105..e1928c1bb 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -2032,6 +2032,8 @@ export type Project = { commentThreads: ProjectCommentCollection; createdAt: Scalars['DateTime']['output']; description?: Maybe; + /** Public project-level configuration for embedded viewer */ + embedOptions: ProjectEmbedOptions; id: Scalars['ID']['output']; invitableCollaborators: WorkspaceCollaboratorCollection; /** Collaborators who have been invited, but not yet accepted. */ @@ -2365,6 +2367,11 @@ export type ProjectCreateInput = { visibility?: InputMaybe; }; +export type ProjectEmbedOptions = { + __typename?: 'ProjectEmbedOptions'; + hideSpeckleBranding: Scalars['Boolean']['output']; +}; + export type ProjectFileImportUpdatedMessage = { __typename?: 'ProjectFileImportUpdatedMessage'; /** Upload ID */ @@ -4472,6 +4479,8 @@ export type Workspace = { domainBasedMembershipProtectionEnabled: Scalars['Boolean']['output']; /** Verified workspace domains */ domains?: Maybe>; + /** Workspace-level configuration for models in embedded viewer */ + embedOptions: WorkspaceEmbedOptions; hasAccessToFeature: Scalars['Boolean']['output']; id: Scalars['ID']['output']; /** Only available to workspace owners/members */ @@ -4623,6 +4632,11 @@ export type WorkspaceDomainDeleteInput = { workspaceId: Scalars['ID']['input']; }; +export type WorkspaceEmbedOptions = { + __typename?: 'WorkspaceEmbedOptions'; + hideSpeckleBranding: Scalars['Boolean']['output']; +}; + export const WorkspaceFeatureName = { DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies', OidcSso: 'oidcSso', @@ -4759,6 +4773,7 @@ export type WorkspaceMutations = { setDefaultRegion: Workspace; update: Workspace; updateCreationState: Scalars['Boolean']['output']; + updateEmbedOptions: WorkspaceEmbedOptions; updateRole: Workspace; updateSeatType: Workspace; }; @@ -4820,6 +4835,11 @@ export type WorkspaceMutationsUpdateCreationStateArgs = { }; +export type WorkspaceMutationsUpdateEmbedOptionsArgs = { + input: WorkspaceUpdateEmbedOptionsInput; +}; + + export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; @@ -4847,6 +4867,7 @@ export type WorkspacePaymentMethod = typeof WorkspacePaymentMethod[keyof typeof export type WorkspacePermissionChecks = { __typename?: 'WorkspacePermissionChecks'; canCreateProject: PermissionCheckResult; + canEditEmbedOptions: PermissionCheckResult; canInvite: PermissionCheckResult; canMoveProjectToWorkspace: PermissionCheckResult; }; @@ -5084,6 +5105,11 @@ export type WorkspaceTeamFilter = { seatType?: InputMaybe; }; +export type WorkspaceUpdateEmbedOptionsInput = { + hideSpeckleBranding: Scalars['Boolean']['input']; + workspaceId: Scalars['String']['input']; +}; + export type WorkspaceUpdateInput = { /** @deprecated Always the reviewer role. Will be removed in the future. */ defaultProjectRole?: InputMaybe; diff --git a/packages/server/modules/shared/helpers/errorHelper.ts b/packages/server/modules/shared/helpers/errorHelper.ts index b442d113a..40f10f8c6 100644 --- a/packages/server/modules/shared/helpers/errorHelper.ts +++ b/packages/server/modules/shared/helpers/errorHelper.ts @@ -41,6 +41,7 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => { case Authz.WorkspaceProjectMoveInvalidError.code: case Authz.CommentNoAccessError.code: case Authz.ProjectNotEnoughPermissionsError.code: + case Authz.WorkspaceNoFeatureAccessError.code: return new ForbiddenError(e.message) case Authz.WorkspaceSsoSessionNoAccessError.code: throw new SsoSessionMissingOrExpiredError(e.message, { diff --git a/packages/server/modules/workspaces/graph/resolvers/permissions.ts b/packages/server/modules/workspaces/graph/resolvers/permissions.ts index fe776c30a..acf60ff76 100644 --- a/packages/server/modules/workspaces/graph/resolvers/permissions.ts +++ b/packages/server/modules/workspaces/graph/resolvers/permissions.ts @@ -30,6 +30,14 @@ export default { workspaceId: parent.workspaceId }) return Authz.toGraphqlResult(canMoveProjectToWorkspace) + }, + canEditEmbedOptions: async (parent, _args, ctx) => { + const canEditEmbedOptions = + await ctx.authPolicies.workspace.canUpdateEmbedOptions({ + userId: ctx.userId, + workspaceId: parent.workspaceId + }) + return Authz.toGraphqlResult(canEditEmbedOptions) } } } as Resolvers diff --git a/packages/server/modules/workspaces/graph/resolvers/projects.ts b/packages/server/modules/workspaces/graph/resolvers/projects.ts index 6b8809b82..c82b23ba6 100644 --- a/packages/server/modules/workspaces/graph/resolvers/projects.ts +++ b/packages/server/modules/workspaces/graph/resolvers/projects.ts @@ -10,6 +10,7 @@ import { countInvitableCollaboratorsByProjectIdFactory, getInvitableCollaboratorsByProjectIdFactory } from '@/modules/workspaces/repositories/users' +import { getWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' import { getMoveProjectToWorkspaceDryRunFactory } from '@/modules/workspaces/services/projects' const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() @@ -61,6 +62,21 @@ export default FF_WORKSPACES_MODULE_ENABLED })({ projectId, workspaceId }) return addedToWorkspace + }, + embedOptions: async (parent) => { + const { workspaceId } = parent + + if (!workspaceId) { + return { + hideSpeckleBranding: false + } + } + + const workspace = await getWorkspaceFactory({ db })({ workspaceId }) + + return { + hideSpeckleBranding: workspace?.isEmbedSpeckleBrandingHidden ?? false + } } }, ProjectMoveToWorkspaceDryRun: { diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 424ffb86f..86c1eb550 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -1042,6 +1042,44 @@ export = FF_WORKSPACES_MODULE_ENABLED ) return true }, + updateEmbedOptions: async (parent, args, context) => { + const { workspaceId, hideSpeckleBranding } = args.input + + const logger = context.log.child({ workspaceId }) + + return await asOperation( + async ({ db, emit }) => { + const workspace = await updateWorkspaceFactory({ + validateSlug: validateSlugFactory({ + getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }) + }), + getWorkspace: getWorkspaceWithDomainsFactory({ db }), + getWorkspaceSsoProviderRecord: getWorkspaceSsoProviderFactory({ + db, + decrypt: getDecryptor() + }), + upsertWorkspace: upsertWorkspaceFactory({ db }), + emitWorkspaceEvent: emit + })({ + workspaceId, + workspaceInput: { + isEmbedSpeckleBrandingHidden: hideSpeckleBranding + } + }) + + return { + hideSpeckleBranding: workspace.isEmbedSpeckleBrandingHidden + } + }, + { + logger, + name: 'updateWorkspaceEmbedOptions', + description: + 'Update workspace-level configuration for the embedded viewer', + transaction: true + } + ) + }, invites: () => ({}), projects: () => ({}), dismiss: async (_parent, args, ctx) => { @@ -1652,6 +1690,11 @@ export = FF_WORKSPACES_MODULE_ENABLED return await getWorkspaceSsoProviderRecordFactory({ db })({ workspaceId: parent.id }) + }, + embedOptions: async (parent) => { + return { + hideSpeckleBranding: parent.isEmbedSpeckleBrandingHidden + } } }, WorkspaceSso: { diff --git a/packages/server/modules/workspaces/repositories/workspaces.ts b/packages/server/modules/workspaces/repositories/workspaces.ts index 09212bce8..306d13059 100644 --- a/packages/server/modules/workspaces/repositories/workspaces.ts +++ b/packages/server/modules/workspaces/repositories/workspaces.ts @@ -262,7 +262,8 @@ export const upsertWorkspaceFactory = 'name', 'updatedAt', 'domainBasedMembershipProtectionEnabled', - 'discoverabilityEnabled' + 'discoverabilityEnabled', + 'isEmbedSpeckleBrandingHidden' ]) } diff --git a/packages/server/modules/workspaces/services/management.ts b/packages/server/modules/workspaces/services/management.ts index 53362e8f0..e678d4a38 100644 --- a/packages/server/modules/workspaces/services/management.ts +++ b/packages/server/modules/workspaces/services/management.ts @@ -163,7 +163,8 @@ export const createWorkspaceFactory = createdAt: new Date(), updatedAt: new Date(), domainBasedMembershipProtectionEnabled: false, - discoverabilityEnabled: false + discoverabilityEnabled: false, + isEmbedSpeckleBrandingHidden: false } await upsertWorkspace({ workspace }) diff --git a/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts index 043ae83fb..50a60c6c2 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts @@ -30,7 +30,10 @@ import { CreateWorkspaceProjectDocument, DismissWorkspaceDocument, GetActiveUserDiscoverableWorkspacesDocument, - GetWorkspaceWithMembersByRoleDocument + GetWorkspaceWithMembersByRoleDocument, + UpdateEmbedOptionsDocument, + WorkspaceEmbedOptionsDocument, + ProjectEmbedOptionsDocument } from '@/test/graphql/generated/graphql' import { beforeEachContext } from '@/test/hooks' import { AllScopes } from '@/modules/core/helpers/mainConstants' @@ -1359,5 +1362,72 @@ describe('Workspaces GQL CRUD', () => { ).to.false }) }) + + describe('mutation workspaceMutations.updateEmbedOptions', () => { + const workspace: BasicTestWorkspace = { + id: '', + ownerId: '', + slug: cryptoRandomString({ length: 10 }), + name: 'My Test Workspace' + } + + const workspaceProject: BasicTestStream = { + id: '', + ownerId: '', + name: 'My Test Project', + isPublic: false + } + + before(async () => { + await createTestWorkspace(workspace, testAdminUser, { addPlan: false }) + workspaceProject.workspaceId = workspace.id + await createTestStream(workspaceProject, testAdminUser) + }) + + beforeEach(async () => { + await apollo.execute(UpdateEmbedOptionsDocument, { + input: { + workspaceId: workspace.id, + hideSpeckleBranding: false + } + }) + }) + + it('should update options at workspace level', async () => { + const resA = await apollo.execute(UpdateEmbedOptionsDocument, { + input: { + workspaceId: workspace.id, + hideSpeckleBranding: true + } + }) + + expect(resA).to.not.haveGraphQLErrors() + + const resB = await apollo.execute(WorkspaceEmbedOptionsDocument, { + workspaceId: workspace.id + }) + + expect(resB).to.not.haveGraphQLErrors() + expect(resB?.data?.workspace.embedOptions.hideSpeckleBranding).to.equal(true) + }) + + it('should update options at workspace project level', async () => { + const resA = await apollo.execute(UpdateEmbedOptionsDocument, { + input: { + workspaceId: workspace.id, + hideSpeckleBranding: true + } + }) + + expect(resA).to.not.haveGraphQLErrors() + + const resB = await apollo.execute(ProjectEmbedOptionsDocument, { + projectId: workspaceProject.id + }) + + expect(resB).to.not.haveGraphQLErrors() + expect(resB.data?.project.embedOptions.hideSpeckleBranding).to.equal(true) + }) + }) }) }) diff --git a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts index eda636e2e..5396511d4 100644 --- a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts @@ -260,6 +260,7 @@ describe('Workspace services', () => { logo: null, discoverabilityEnabled: false, domainBasedMembershipProtectionEnabled: false, + isEmbedSpeckleBrandingHidden: false, domains: [] } return merge(workspace, input) @@ -1139,7 +1140,8 @@ describe('Workspace role services', () => { updatedAt: new Date(), description: null, discoverabilityEnabled: false, - domainBasedMembershipProtectionEnabled: false + domainBasedMembershipProtectionEnabled: false, + isEmbedSpeckleBrandingHidden: false } }, getDomains: async () => { @@ -1178,7 +1180,8 @@ describe('Workspace role services', () => { updatedAt: new Date(), description: null, discoverabilityEnabled: false, - domainBasedMembershipProtectionEnabled: false + domainBasedMembershipProtectionEnabled: false, + isEmbedSpeckleBrandingHidden: false } await addDomainToWorkspaceFactory({ diff --git a/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts b/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts index 1d3ad22dd..abf6d1fbf 100644 --- a/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts @@ -383,6 +383,7 @@ describe('Workspace SSO services', () => { logo: null, domainBasedMembershipProtectionEnabled: false, discoverabilityEnabled: false, + isEmbedSpeckleBrandingHidden: false, createdAt: new Date(), updatedAt: new Date() } diff --git a/packages/server/modules/workspacesCore/domain/types.ts b/packages/server/modules/workspacesCore/domain/types.ts index 186f8ae44..ee89aa794 100644 --- a/packages/server/modules/workspacesCore/domain/types.ts +++ b/packages/server/modules/workspacesCore/domain/types.ts @@ -29,6 +29,8 @@ export type Workspace = { logo: string | null domainBasedMembershipProtectionEnabled: boolean discoverabilityEnabled: boolean + // TODO: Create new table/structure if embeds get more configuration + isEmbedSpeckleBrandingHidden: boolean } export type LimitedWorkspace = Pick< diff --git a/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts b/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts index 899dd8942..cfa32a8ef 100644 --- a/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts +++ b/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts @@ -14,7 +14,6 @@ export = !FF_WORKSPACES_MODULE_ENABLED workspace: async () => { throw new WorkspacesModuleDisabledError() }, - workspaceBySlug: async () => { throw new WorkspacesModuleDisabledError() }, @@ -109,6 +108,11 @@ export = !FF_WORKSPACES_MODULE_ENABLED workspace: async () => { // Return type is always workspace or null, to make the FE implementation easier we force return null in this case return null + }, + embedOptions: async () => { + return { + hideSpeckleBranding: false + } } }, AdminQueries: { diff --git a/packages/server/modules/workspacesCore/helpers/db.ts b/packages/server/modules/workspacesCore/helpers/db.ts index 1e482b869..0c83e5ceb 100644 --- a/packages/server/modules/workspacesCore/helpers/db.ts +++ b/packages/server/modules/workspacesCore/helpers/db.ts @@ -9,7 +9,8 @@ export const Workspaces = buildTableHelper('workspaces', [ 'updatedAt', 'logo', 'domainBasedMembershipProtectionEnabled', - 'discoverabilityEnabled' + 'discoverabilityEnabled', + 'isEmbedSpeckleBrandingHidden' ]) export const WorkspaceAcl = buildTableHelper('workspace_acl', [ diff --git a/packages/server/modules/workspacesCore/migrations/20250516130608_add_workspace_embed_options.ts b/packages/server/modules/workspacesCore/migrations/20250516130608_add_workspace_embed_options.ts new file mode 100644 index 000000000..7aba2e1b5 --- /dev/null +++ b/packages/server/modules/workspacesCore/migrations/20250516130608_add_workspace_embed_options.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('workspaces', (table) => { + table.boolean('isEmbedSpeckleBrandingHidden').notNullable().defaultTo(false) + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('workspaces', (table) => { + table.dropColumn('isEmbedSpeckleBrandingHidden') + }) +} diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 9f7463623..951a92b4e 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -2033,6 +2033,8 @@ export type Project = { commentThreads: ProjectCommentCollection; createdAt: Scalars['DateTime']['output']; description?: Maybe; + /** Public project-level configuration for embedded viewer */ + embedOptions: ProjectEmbedOptions; id: Scalars['ID']['output']; invitableCollaborators: WorkspaceCollaboratorCollection; /** Collaborators who have been invited, but not yet accepted. */ @@ -2366,6 +2368,11 @@ export type ProjectCreateInput = { visibility?: InputMaybe; }; +export type ProjectEmbedOptions = { + __typename?: 'ProjectEmbedOptions'; + hideSpeckleBranding: Scalars['Boolean']['output']; +}; + export type ProjectFileImportUpdatedMessage = { __typename?: 'ProjectFileImportUpdatedMessage'; /** Upload ID */ @@ -4473,6 +4480,8 @@ export type Workspace = { domainBasedMembershipProtectionEnabled: Scalars['Boolean']['output']; /** Verified workspace domains */ domains?: Maybe>; + /** Workspace-level configuration for models in embedded viewer */ + embedOptions: WorkspaceEmbedOptions; hasAccessToFeature: Scalars['Boolean']['output']; id: Scalars['ID']['output']; /** Only available to workspace owners/members */ @@ -4624,6 +4633,11 @@ export type WorkspaceDomainDeleteInput = { workspaceId: Scalars['ID']['input']; }; +export type WorkspaceEmbedOptions = { + __typename?: 'WorkspaceEmbedOptions'; + hideSpeckleBranding: Scalars['Boolean']['output']; +}; + export const WorkspaceFeatureName = { DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies', OidcSso: 'oidcSso', @@ -4760,6 +4774,7 @@ export type WorkspaceMutations = { setDefaultRegion: Workspace; update: Workspace; updateCreationState: Scalars['Boolean']['output']; + updateEmbedOptions: WorkspaceEmbedOptions; updateRole: Workspace; updateSeatType: Workspace; }; @@ -4821,6 +4836,11 @@ export type WorkspaceMutationsUpdateCreationStateArgs = { }; +export type WorkspaceMutationsUpdateEmbedOptionsArgs = { + input: WorkspaceUpdateEmbedOptionsInput; +}; + + export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; @@ -4848,6 +4868,7 @@ export type WorkspacePaymentMethod = typeof WorkspacePaymentMethod[keyof typeof export type WorkspacePermissionChecks = { __typename?: 'WorkspacePermissionChecks'; canCreateProject: PermissionCheckResult; + canEditEmbedOptions: PermissionCheckResult; canInvite: PermissionCheckResult; canMoveProjectToWorkspace: PermissionCheckResult; }; @@ -5085,6 +5106,11 @@ export type WorkspaceTeamFilter = { seatType?: InputMaybe; }; +export type WorkspaceUpdateEmbedOptionsInput = { + hideSpeckleBranding: Scalars['Boolean']['input']; + workspaceId: Scalars['String']['input']; +}; + export type WorkspaceUpdateInput = { /** @deprecated Always the reviewer role. Will be removed in the future. */ defaultProjectRole?: InputMaybe; @@ -6277,6 +6303,27 @@ export type MoveProjectToWorkspaceMutationVariables = Exact<{ export type MoveProjectToWorkspaceMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', projects: { __typename?: 'WorkspaceProjectMutations', moveToWorkspace: { __typename?: 'Project', id: string, workspaceId?: string | null, visibility: ProjectVisibility, team: Array<{ __typename?: 'ProjectCollaborator', id: string, role: string }> } } } }; +export type UpdateEmbedOptionsMutationVariables = Exact<{ + input: WorkspaceUpdateEmbedOptionsInput; +}>; + + +export type UpdateEmbedOptionsMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', updateEmbedOptions: { __typename?: 'WorkspaceEmbedOptions', hideSpeckleBranding: boolean } } }; + +export type WorkspaceEmbedOptionsQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; +}>; + + +export type WorkspaceEmbedOptionsQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', id: string, embedOptions: { __typename?: 'WorkspaceEmbedOptions', hideSpeckleBranding: boolean } } }; + +export type ProjectEmbedOptionsQueryVariables = Exact<{ + projectId: Scalars['String']['input']; +}>; + + +export type ProjectEmbedOptionsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, embedOptions: { __typename?: 'ProjectEmbedOptions', hideSpeckleBranding: boolean } } }; + export const BasicWorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}}]} as unknown as DocumentNode; export const BasicPendingWorkspaceCollaboratorFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode; export const WorkspaceProjectsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceProjects"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCollection"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]} as unknown as DocumentNode; @@ -6454,4 +6501,7 @@ export const GetWorkspaceTeamDocument = {"kind":"Document","definitions":[{"kind export const ActiveUserLeaveWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ActiveUserLeaveWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"leave"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]}}]} as unknown as DocumentNode; export const ActiveUserProjectsWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ActiveUserProjectsWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UserProjectsFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ActiveUserExpiredSsoSessionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ActiveUserExpiredSsoSessions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"expiredSsoSessions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]}}]}}]} as unknown as DocumentNode; -export const MoveProjectToWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"MoveProjectToWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projects"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"moveToWorkspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"projectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const MoveProjectToWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"MoveProjectToWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projects"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"moveToWorkspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"projectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const UpdateEmbedOptionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateEmbedOptions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUpdateEmbedOptionsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateEmbedOptions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hideSpeckleBranding"}}]}}]}}]}}]} as unknown as DocumentNode; +export const WorkspaceEmbedOptionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkspaceEmbedOptions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"embedOptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hideSpeckleBranding"}}]}}]}}]}}]} as unknown as DocumentNode; +export const ProjectEmbedOptionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ProjectEmbedOptions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"embedOptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hideSpeckleBranding"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/packages/server/test/graphql/workspaces.ts b/packages/server/test/graphql/workspaces.ts index 26105796c..1c381e68a 100644 --- a/packages/server/test/graphql/workspaces.ts +++ b/packages/server/test/graphql/workspaces.ts @@ -282,3 +282,35 @@ export const moveProjectToWorkspaceMutation = gql` } } ` + +export const updateWorkspaceEmbedOptionsMutation = gql` + mutation UpdateEmbedOptions($input: WorkspaceUpdateEmbedOptionsInput!) { + workspaceMutations { + updateEmbedOptions(input: $input) { + hideSpeckleBranding + } + } + } +` + +export const getWorkspaceEmbedOptions = gql` + query WorkspaceEmbedOptions($workspaceId: String!) { + workspace(id: $workspaceId) { + id + embedOptions { + hideSpeckleBranding + } + } + } +` + +export const getProjectEmbedOptions = gql` + query ProjectEmbedOptions($projectId: String!) { + project(id: $projectId) { + id + embedOptions { + hideSpeckleBranding + } + } + } +` diff --git a/packages/server/test/speckle-helpers/workspaces.ts b/packages/server/test/speckle-helpers/workspaces.ts index 0d55ccd12..2392ef198 100644 --- a/packages/server/test/speckle-helpers/workspaces.ts +++ b/packages/server/test/speckle-helpers/workspaces.ts @@ -15,6 +15,7 @@ export const createAndStoreTestWorkspaceFactory = logo: null, domainBasedMembershipProtectionEnabled: false, discoverabilityEnabled: false, + isEmbedSpeckleBrandingHidden: false, ...workspaceOverrides } diff --git a/packages/shared/src/authz/domain/authErrors.ts b/packages/shared/src/authz/domain/authErrors.ts index d8f889bf6..3b7204179 100644 --- a/packages/shared/src/authz/domain/authErrors.ts +++ b/packages/shared/src/authz/domain/authErrors.ts @@ -102,6 +102,11 @@ export const WorkspaceLimitsReachedError = defineAuthError< message: 'Workspace limits have been reached' }) +export const WorkspaceNoFeatureAccessError = defineAuthError({ + code: 'WorkspaceNoFeatureAccess', + message: 'Your workspace plan does not have access to this feature.' +}) + export const WorkspaceProjectMoveInvalidError = defineAuthError({ code: 'WorkspaceProjectMoveInvalid', message: 'Projects already in a workspace cannot be moved to another workspace.' diff --git a/packages/shared/src/authz/policies/index.ts b/packages/shared/src/authz/policies/index.ts index bf65cde68..93f8cfae2 100644 --- a/packages/shared/src/authz/policies/index.ts +++ b/packages/shared/src/authz/policies/index.ts @@ -29,6 +29,7 @@ import { canDeleteProjectPolicy } from './project/canDelete.js' import { canDeleteAutomationPolicy } from './project/automation/canDelete.js' import { canPublishPolicy } from './project/canPublish.js' import { canLoadPolicy } from './project/canLoad.js' +import { canUpdateEmbedOptionsPolicy } from './workspace/canUpdateEmbedOptions.js' export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ project: { @@ -72,7 +73,8 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ canCreateProject: canCreateWorkspaceProjectPolicy(loaders), canInvite: canInviteToWorkspacePolicy(loaders), canReceiveProjectsUpdatedMessage: - canReceiveWorkspaceProjectsUpdatedMessagePolicy(loaders) + canReceiveWorkspaceProjectsUpdatedMessagePolicy(loaders), + canUpdateEmbedOptions: canUpdateEmbedOptionsPolicy(loaders) } }) diff --git a/packages/shared/src/authz/policies/project/canMoveToWorkspace.ts b/packages/shared/src/authz/policies/project/canMoveToWorkspace.ts index 19a74f7db..ac5a0a910 100644 --- a/packages/shared/src/authz/policies/project/canMoveToWorkspace.ts +++ b/packages/shared/src/authz/policies/project/canMoveToWorkspace.ts @@ -64,10 +64,7 @@ type PolicyErrors = | InstanceType | InstanceType | InstanceType - | InstanceType< - | typeof WorkspaceNotEnoughPermissionsError - | typeof ProjectNotEnoughPermissionsError - > + | InstanceType export const canMoveToWorkspacePolicy: AuthPolicy< PolicyLoaderKeys, diff --git a/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.spec.ts b/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.spec.ts new file mode 100644 index 000000000..a06d09f8f --- /dev/null +++ b/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.spec.ts @@ -0,0 +1,155 @@ +import cryptoRandomString from 'crypto-random-string' +import { Roles } from '../../../core/constants.js' +import { parseFeatureFlags } from '../../../environment/index.js' +import { Workspace } from '../../domain/workspaces/types.js' +import { canUpdateEmbedOptionsPolicy } from './canUpdateEmbedOptions.js' +import { WorkspacePlan } from '../../../workspaces/index.js' +import { describe, expect, it } from 'vitest' +import { + ServerNoAccessError, + ServerNoSessionError, + ServerNotEnoughPermissionsError, + WorkspaceNoAccessError, + WorkspaceNoFeatureAccessError, + WorkspaceNotEnoughPermissionsError, + WorkspaceReadOnlyError +} from '../../domain/authErrors.js' + +const buildCanUpdateEmbedOptionsPolicy = ( + overrides?: Partial[0]> +) => { + const workspaceId = cryptoRandomString({ length: 9 }) + + return canUpdateEmbedOptionsPolicy({ + getEnv: async () => parseFeatureFlags({}), + getServerRole: async () => Roles.Server.Admin, + getWorkspace: async () => { + return { + id: workspaceId, + slug: cryptoRandomString({ length: 9 }) + } as Workspace + }, + getWorkspaceRole: async () => Roles.Workspace.Admin, + getWorkspaceSsoProvider: async () => null, + getWorkspaceSsoSession: async () => null, + getWorkspacePlan: async () => { + return { + workspaceId, + name: 'unlimited', + status: 'valid', + createdAt: new Date() + } as WorkspacePlan + }, + ...overrides + }) +} + +const getPolicyArgs = () => ({ + userId: cryptoRandomString({ length: 9 }), + workspaceId: cryptoRandomString({ length: 9 }) +}) + +describe('canUpdateEmbedOptions', () => { + it('returns error if user is not logged in', async () => { + const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy() + + const result = await canUpdateEmbedOptions({ + ...getPolicyArgs(), + userId: undefined + }) + + expect(result).toBeAuthErrorResult({ + code: ServerNoSessionError.code + }) + }) + + it('returns error if user is not found', async () => { + const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy({ + getServerRole: async () => null + }) + + const result = await canUpdateEmbedOptions(getPolicyArgs()) + + expect(result).toBeAuthErrorResult({ + code: ServerNoAccessError.code + }) + }) + + it('returns error if user is a server guest', async () => { + const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy({ + getServerRole: async () => Roles.Server.Guest + }) + + const result = await canUpdateEmbedOptions(getPolicyArgs()) + + expect(result).toBeAuthErrorResult({ + code: ServerNotEnoughPermissionsError.code + }) + }) + + it('returns error if workspace does not exist', async () => { + const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy({ + getWorkspace: async () => null + }) + + const result = await canUpdateEmbedOptions(getPolicyArgs()) + + expect(result).toBeAuthErrorResult({ + code: WorkspaceNoAccessError.code + }) + }) + + it('returns error if user is not workspace admin', async () => { + const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy({ + getWorkspaceRole: async () => Roles.Workspace.Member + }) + + const result = await canUpdateEmbedOptions(getPolicyArgs()) + + expect(result).toBeAuthErrorResult({ + code: WorkspaceNotEnoughPermissionsError.code + }) + }) + + it('returns error if workspace is read only', async () => { + const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy({ + getWorkspacePlan: async () => ({ + workspaceId: cryptoRandomString({ length: 9 }), + name: 'proUnlimited', + status: 'canceled', + createdAt: new Date() + }) + }) + + const result = await canUpdateEmbedOptions(getPolicyArgs()) + + expect(result).toBeAuthErrorResult({ + code: WorkspaceReadOnlyError.code + }) + }) + + it('returns error if workspace has invalid plan', async () => { + const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy({ + getWorkspacePlan: async () => ({ + workspaceId: cryptoRandomString({ length: 9 }), + name: 'free', + status: 'valid', + createdAt: new Date() + }) + }) + + const result = await canUpdateEmbedOptions(getPolicyArgs()) + + expect(result).toBeAuthErrorResult({ + code: WorkspaceNoFeatureAccessError.code + }) + }) + + it('returns ok if workspace has valid plan', async () => { + const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy() + + const result = await canUpdateEmbedOptions(getPolicyArgs()) + + expect(result).toBeAuthOKResult() + }) +}) diff --git a/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.ts b/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.ts new file mode 100644 index 000000000..c1a5d78fc --- /dev/null +++ b/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.ts @@ -0,0 +1,89 @@ +import { err, ok } from 'true-myth/result' +import { + ServerNoAccessError, + ServerNoSessionError, + ServerNotEnoughPermissionsError, + WorkspaceNoAccessError, + WorkspaceNoFeatureAccessError, + WorkspaceNotEnoughPermissionsError, + WorkspaceReadOnlyError, + WorkspacesNotEnabledError, + WorkspaceSsoSessionNoAccessError +} from '../../domain/authErrors.js' +import { MaybeUserContext, WorkspaceContext } from '../../domain/context.js' +import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js' +import { AuthPolicy } from '../../domain/policies.js' +import { + ensureWorkspaceNotReadOnlyFragment, + ensureWorkspaceRoleAndSessionFragment, + ensureWorkspacesEnabledFragment +} from '../../fragments/workspaces.js' +import { ensureMinimumServerRoleFragment } from '../../fragments/server.js' +import { Roles } from '../../../core/constants.js' +import { WorkspacePlans } from '../../../workspaces/index.js' + +type PolicyLoaderKeys = + | typeof AuthCheckContextLoaderKeys.getEnv + | typeof AuthCheckContextLoaderKeys.getServerRole + | typeof AuthCheckContextLoaderKeys.getWorkspace + | typeof AuthCheckContextLoaderKeys.getWorkspaceRole + | typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider + | typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession + | typeof AuthCheckContextLoaderKeys.getWorkspacePlan + +type PolicyArgs = MaybeUserContext & WorkspaceContext + +type PolicyErrors = + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + +export const canUpdateEmbedOptionsPolicy: AuthPolicy< + PolicyLoaderKeys, + PolicyArgs, + PolicyErrors +> = + (loaders) => + async ({ userId, workspaceId }) => { + const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({}) + if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error) + + const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({ + userId, + role: Roles.Server.User + }) + if (ensuredServerRole.isErr) return err(ensuredServerRole.error) + + const ensuredWorkspaceAccess = await ensureWorkspaceRoleAndSessionFragment(loaders)( + { + userId: userId!, + workspaceId, + role: Roles.Workspace.Admin + } + ) + if (ensuredWorkspaceAccess.isErr) return err(ensuredWorkspaceAccess.error) + + const ensuredNotReadOnly = await ensureWorkspaceNotReadOnlyFragment(loaders)({ + workspaceId + }) + if (ensuredNotReadOnly.isErr) return err(ensuredNotReadOnly.error) + + const validPlans: WorkspacePlans[] = [ + 'academia', + 'unlimited', + 'pro', + 'proUnlimited', + 'proUnlimitedInvoiced' + ] + const workspacePlan = await loaders.getWorkspacePlan({ workspaceId }) + if (!workspacePlan || !validPlans.includes(workspacePlan.name)) + return err(new WorkspaceNoFeatureAccessError()) + + return ok() + } diff --git a/packages/shared/src/workspaces/helpers/features.ts b/packages/shared/src/workspaces/helpers/features.ts index 24e561c8e..caccdcbaf 100644 --- a/packages/shared/src/workspaces/helpers/features.ts +++ b/packages/shared/src/workspaces/helpers/features.ts @@ -19,7 +19,8 @@ export const WorkspacePlanFeatures = { // Optional/plan specific DomainSecurity: 'domainBasedSecurityPolicies', SSO: 'oidcSso', - CustomDataRegion: 'workspaceDataRegionSpecificity' + CustomDataRegion: 'workspaceDataRegionSpecificity', + CustomViewerEmbed: 'customViewerEmbed' } export type WorkspacePlanFeatures = @@ -46,6 +47,10 @@ export const WorkspacePlanFeaturesMetadata = ({ [WorkspacePlanFeatures.CustomDataRegion]: { displayName: 'Custom data residency', description: 'Store your data in EU, UK, North America, or Asia Pacific' + }, + [WorkspacePlanFeatures.CustomViewerEmbed]: { + displayName: 'Customised viewer', + description: 'Configure the branding of the embedded Speckle viewer' } }) satisfies Record< WorkspacePlanFeatures, @@ -112,7 +117,8 @@ export const WorkspacePaidPlanConfigs: { ...baseFeatures, WorkspacePlanFeatures.DomainSecurity, WorkspacePlanFeatures.SSO, - WorkspacePlanFeatures.CustomDataRegion + WorkspacePlanFeatures.CustomDataRegion, + WorkspacePlanFeatures.CustomViewerEmbed ], limits: { projectCount: 10, @@ -127,7 +133,8 @@ export const WorkspacePaidPlanConfigs: { ...baseFeatures, WorkspacePlanFeatures.DomainSecurity, WorkspacePlanFeatures.SSO, - WorkspacePlanFeatures.CustomDataRegion + WorkspacePlanFeatures.CustomDataRegion, + WorkspacePlanFeatures.CustomViewerEmbed ], limits: { projectCount: null, @@ -147,7 +154,8 @@ export const WorkspaceUnpaidPlanConfigs: { ...baseFeatures, WorkspacePlanFeatures.DomainSecurity, WorkspacePlanFeatures.SSO, - WorkspacePlanFeatures.CustomDataRegion + WorkspacePlanFeatures.CustomDataRegion, + WorkspacePlanFeatures.CustomViewerEmbed ], limits: unlimited }, @@ -157,7 +165,8 @@ export const WorkspaceUnpaidPlanConfigs: { ...baseFeatures, WorkspacePlanFeatures.DomainSecurity, WorkspacePlanFeatures.SSO, - WorkspacePlanFeatures.CustomDataRegion + WorkspacePlanFeatures.CustomDataRegion, + WorkspacePlanFeatures.CustomViewerEmbed ], limits: unlimited },