diff --git a/.circleci/config.yml b/.circleci/config.yml index 2009c2a203..7f3518a129 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -494,7 +494,7 @@ jobs: S3_CREATE_BUCKET: 'true' REDIS_URL: 'redis://127.0.0.1:6379' S3_REGION: '' # optional, defaults to 'us-east-1' - AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json' + ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json' FF_BILLING_INTEGRATION_ENABLED: 'true' steps: - checkout @@ -543,8 +543,8 @@ jobs: working_directory: 'packages/server' - run: - name: Checking for GQL schema breakages against speckle.xyz - command: 'yarn rover graph check Speckle-Server@speckle-xyz --schema ./introspected-schema.graphql' + name: Checking for GQL schema breakages against app.speckle.systems + command: 'yarn rover graph check Speckle-Server@app-speckle-systems --schema ./introspected-schema.graphql' working_directory: 'packages/server' - run: @@ -576,7 +576,7 @@ jobs: S3_CREATE_BUCKET: 'true' REDIS_URL: 'redis://127.0.0.1:6379' S3_REGION: '' # optional, defaults to 'us-east-1' - AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json' + ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json' DISABLE_ALL_FFS: 'true' test-server-multiregion: @@ -615,7 +615,7 @@ jobs: S3_CREATE_BUCKET: 'true' REDIS_URL: 'redis://127.0.0.1:6379' S3_REGION: '' # optional, defaults to 'us-east-1' - AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json' + ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json' FF_BILLING_INTEGRATION_ENABLED: 'true' # These are the only different env keys: MULTI_REGION_CONFIG_PATH: '../../.circleci/multiregion.test-ci.json' diff --git a/packages/dui3/lib/common/generated/gql/graphql.ts b/packages/dui3/lib/common/generated/gql/graphql.ts index 731249d3c2..dec757f627 100644 --- a/packages/dui3/lib/common/generated/gql/graphql.ts +++ b/packages/dui3/lib/common/generated/gql/graphql.ts @@ -215,11 +215,11 @@ export type AutomateAuthCodePayloadTest = { action: Scalars['String']['input']; code: Scalars['String']['input']; userId: Scalars['String']['input']; + workspaceId?: InputMaybe; }; export type AutomateFunction = { __typename?: 'AutomateFunction'; - automationCount: Scalars['Int']['output']; /** Only returned if user is a part of this speckle server */ creator?: Maybe; description: Scalars['String']['output']; @@ -232,6 +232,7 @@ export type AutomateFunction = { /** SourceAppNames values from @speckle/shared. Empty array means - all of them */ supportedSourceApps: Array; tags: Array; + workspaceIds?: Maybe>; }; @@ -290,6 +291,7 @@ export type AutomateFunctionRun = { export type AutomateFunctionRunStatusReportInput = { contextView?: InputMaybe; functionRunId: Scalars['String']['input']; + projectId: Scalars['String']['input']; /** AutomateTypes.ResultsSchema type from @speckle/shared */ results?: InputMaybe; status: AutomateRunStatus; @@ -305,15 +307,18 @@ export type AutomateFunctionTemplate = { }; export enum AutomateFunctionTemplateLanguage { - Demonstration = 'DEMONSTRATION', - Demonstrationpython = 'DEMONSTRATIONPYTHON', DotNet = 'DOT_NET', Python = 'PYTHON', Typescript = 'TYPESCRIPT' } +export type AutomateFunctionToken = { + __typename?: 'AutomateFunctionToken'; + functionId: Scalars['String']['output']; + functionToken: Scalars['String']['output']; +}; + export type AutomateFunctionsFilter = { - featuredFunctionsOnly?: InputMaybe; /** By default we skip functions without releases. Set this to true to include them. */ functionsWithoutReleases?: InputMaybe; search?: InputMaybe; @@ -322,6 +327,7 @@ export type AutomateFunctionsFilter = { export type AutomateMutations = { __typename?: 'AutomateMutations'; createFunction: AutomateFunction; + createFunctionWithoutVersion: AutomateFunctionToken; updateFunction: AutomateFunction; }; @@ -331,6 +337,11 @@ export type AutomateMutationsCreateFunctionArgs = { }; +export type AutomateMutationsCreateFunctionWithoutVersionArgs = { + input: CreateAutomateFunctionWithoutVersionInput; +}; + + export type AutomateMutationsUpdateFunctionArgs = { input: UpdateAutomateFunctionInput; }; @@ -529,6 +540,7 @@ export type CheckoutSession = { export type CheckoutSessionInput = { billingInterval: BillingInterval; + isCreateFlow?: InputMaybe; workspaceId: Scalars['ID']['input']; workspacePlan: PaidWorkspacePlans; }; @@ -816,6 +828,11 @@ export type CreateAutomateFunctionInput = { template: AutomateFunctionTemplateLanguage; }; +export type CreateAutomateFunctionWithoutVersionInput = { + description: Scalars['String']['input']; + name: Scalars['String']['input']; +}; + export type CreateCommentInput = { content: CommentContentInput; projectId: Scalars['String']['input']; @@ -1006,7 +1023,7 @@ export type LimitedUser = { * Returns all discoverable streams that the user is a collaborator on * @deprecated Part of the old API surface and will be removed in the future. */ - streams: StreamCollection; + streams: UserStreamCollection; /** * The user's timeline in chronological order * @deprecated Part of the old API surface and will be removed in the future. @@ -2525,7 +2542,7 @@ export type Query = { * Pass in the `query` parameter to search by name, description or ID. * @deprecated Part of the old API surface and will be removed in the future. Use User.projects instead. */ - streams?: Maybe; + streams?: Maybe; /** * Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). * @deprecated To be removed in the near future! Use 'activeUser' to get info about the active user or 'otherUser' to get info about another user. @@ -2818,7 +2835,7 @@ export type ServerInfo = { inviteOnly?: Maybe; /** Server relocation / migration info */ migration?: Maybe; - /** Available to server admins only */ + /** Info about server regions */ multiRegion: ServerMultiRegionConfiguration; name: Scalars['String']['output']; /** @deprecated Use role constants from the @speckle/shared npm package instead */ @@ -2872,10 +2889,7 @@ export type ServerMultiRegionConfiguration = { * be filtered out from the result. */ availableKeys: Array; - /** - * List of regions that are currently enabled on the server using the available region keys - * set in the multi region config file. - */ + /** Regions available for project data residency */ regions: Array; }; @@ -3245,6 +3259,11 @@ export type Subscription = { * @deprecated Part of the old API surface and will be removed in the future. Use 'projectVersionsUpdated' instead. */ commitUpdated?: Maybe; + /** + * Cyclically sends a message to the client, used for testing + * Note: Only works in test environment + */ + ping: Scalars['String']['output']; /** Subscribe to updates to automations in the project */ projectAutomationsUpdated: ProjectAutomationsUpdatedMessage; /** @@ -3304,6 +3323,16 @@ export type Subscription = { userViewerActivity?: Maybe; /** Track user activities in the viewer relating to the specified resources */ viewerUserActivityBroadcasted: ViewerUserActivityMessage; + /** + * Track newly added or deleted projects in a specific workspace. + * Either slug or id must be set. + */ + workspaceProjectsUpdated: WorkspaceProjectsUpdatedMessage; + /** + * Track updates to a specific workspace. + * Either slug or id must be set. + */ + workspaceUpdated: WorkspaceUpdatedMessage; }; @@ -3435,6 +3464,18 @@ export type SubscriptionViewerUserActivityBroadcastedArgs = { target: ViewerUpdateTrackingTarget; }; + +export type SubscriptionWorkspaceProjectsUpdatedArgs = { + workspaceId?: InputMaybe; + workspaceSlug?: InputMaybe; +}; + + +export type SubscriptionWorkspaceUpdatedArgs = { + workspaceId?: InputMaybe; + workspaceSlug?: InputMaybe; +}; + export type TestAutomationRun = { __typename?: 'TestAutomationRun'; automationRunId: Scalars['String']['output']; @@ -3487,6 +3528,7 @@ export type UpdateAutomateFunctionInput = { /** SourceAppNames values from @speckle/shared */ supportedSourceApps?: InputMaybe>; tags?: InputMaybe>; + workspaceIds?: InputMaybe>; }; export type UpdateModelInput = { @@ -3509,6 +3551,12 @@ export type UpdateVersionInput = { versionId: Scalars['ID']['input']; }; +export type UpgradePlanInput = { + billingInterval: BillingInterval; + workspaceId: Scalars['ID']['input']; + workspacePlan: PaidWorkspacePlans; +}; + /** * Full user type, should only be used in the context of admin operations or * when a user is reading/writing info about himself @@ -3524,6 +3572,7 @@ export type User = { apiTokens: Array; /** Returns the apps you have authorized. */ authorizedApps?: Maybe>; + automateFunctions: AutomateFunctionCollection; automateInfo: UserAutomateInfo; avatar?: Maybe; bio?: Maybe; @@ -3555,6 +3604,7 @@ export type User = { * @deprecated Part of the old API surface and will be removed in the future. */ favoriteStreams: StreamCollection; + gendoAICredits: UserGendoAiCredits; /** Whether the user has a pending/active email verification token */ hasPendingVerification?: Maybe; id: Scalars['ID']['output']; @@ -3568,14 +3618,14 @@ export type User = { /** Get all invitations to projects that the active user has */ projectInvites: Array; /** Get projects that the user participates in */ - projects: ProjectCollection; + projects: UserProjectCollection; role?: Maybe; /** * Returns all streams that the user is a collaborator on. If requested for a user, who isn't the * authenticated user, then this will only return discoverable streams. * @deprecated Part of the old API surface and will be removed in the future. Use User.projects instead. */ - streams: StreamCollection; + streams: UserStreamCollection; /** * The user's timeline in chronological order * @deprecated Part of the old API surface and will be removed in the future. @@ -3614,6 +3664,17 @@ export type UserActivityArgs = { }; +/** + * 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 UserAutomateFunctionsArgs = { + cursor?: InputMaybe; + filter?: InputMaybe; + limit?: InputMaybe; +}; + + /** * Full user type, should only be used in the context of admin operations or * when a user is reading/writing info about himself @@ -3743,6 +3804,21 @@ export type UserEmailMutationsSetPrimaryArgs = { input: SetPrimaryUserEmailInput; }; +export type UserGendoAiCredits = { + __typename?: 'UserGendoAICredits'; + limit: Scalars['Int']['output']; + resetDate: Scalars['DateTime']['output']; + used: Scalars['Int']['output']; +}; + +export type UserProjectCollection = { + __typename?: 'UserProjectCollection'; + cursor?: Maybe; + items: Array; + numberOfHidden: Scalars['Int']['output']; + totalCount: Scalars['Int']['output']; +}; + export type UserProjectsFilter = { /** Only include projects where user has the specified roles */ onlyWithRoles?: InputMaybe>; @@ -3776,6 +3852,14 @@ export type UserSearchResultCollection = { items: Array; }; +export type UserStreamCollection = { + __typename?: 'UserStreamCollection'; + cursor?: Maybe; + items?: Maybe>; + numberOfHidden: Scalars['Int']['output']; + totalCount: Scalars['Int']['output']; +}; + export type UserUpdateInput = { avatar?: InputMaybe; bio?: InputMaybe; @@ -4001,9 +4085,10 @@ export type WebhookUpdateInput = { export type Workspace = { __typename?: 'Workspace'; - /** Regions available to the workspace for project data residency */ - availableRegions: Array; + automateFunctions: AutomateFunctionCollection; createdAt: Scalars['DateTime']['output']; + /** Info about the workspace creation state */ + creationState?: Maybe; customerPortalUrl?: Maybe; /** Selected fallback when `logo` not set */ defaultLogoIndex: Scalars['Int']['output']; @@ -4041,6 +4126,13 @@ export type Workspace = { }; +export type WorkspaceAutomateFunctionsArgs = { + cursor?: InputMaybe; + filter?: InputMaybe; + limit?: Scalars['Int']['input']; +}; + + export type WorkspaceHasAccessToFeatureArgs = { featureName: WorkspaceFeatureName; }; @@ -4068,6 +4160,7 @@ export type WorkspaceBillingMutations = { __typename?: 'WorkspaceBillingMutations'; cancelCheckoutSession: Scalars['Boolean']['output']; createCheckoutSession: CheckoutSession; + upgradePlan: Scalars['Boolean']['output']; }; @@ -4080,6 +4173,11 @@ export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = { input: CheckoutSessionInput; }; + +export type WorkspaceBillingMutationsUpgradePlanArgs = { + input: UpgradePlanInput; +}; + /** Overridden by `WorkspaceCollaboratorGraphQLReturn` */ export type WorkspaceCollaborator = { __typename?: 'WorkspaceCollaborator'; @@ -4112,6 +4210,18 @@ export type WorkspaceCreateInput = { slug?: InputMaybe; }; +export type WorkspaceCreationState = { + __typename?: 'WorkspaceCreationState'; + completed: Scalars['Boolean']['output']; + state: Scalars['JSONObject']['output']; +}; + +export type WorkspaceCreationStateInput = { + completed: Scalars['Boolean']['input']; + state: Scalars['JSONObject']['input']; + workspaceId: Scalars['ID']['input']; +}; + export type WorkspaceDomain = { __typename?: 'WorkspaceDomain'; domain: Scalars['String']['output']; @@ -4212,6 +4322,7 @@ export type WorkspaceMutations = { /** Set the default region where project data will be stored. Only available to admins. */ setDefaultRegion: Workspace; update: Workspace; + updateCreationState: Scalars['Boolean']['output']; updateRole: Workspace; }; @@ -4262,12 +4373,18 @@ export type WorkspaceMutationsUpdateArgs = { }; +export type WorkspaceMutationsUpdateCreationStateArgs = { + input: WorkspaceCreationStateInput; +}; + + export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; export type WorkspacePlan = { __typename?: 'WorkspacePlan'; + createdAt: Scalars['DateTime']['output']; name: WorkspacePlans; status: WorkspacePlanStatuses; }; @@ -4337,6 +4454,23 @@ export type WorkspaceProjectsFilter = { search?: InputMaybe; }; +export type WorkspaceProjectsUpdatedMessage = { + __typename?: 'WorkspaceProjectsUpdatedMessage'; + /** Project entity, null if project was deleted */ + project?: Maybe; + /** Project ID */ + projectId: Scalars['String']['output']; + /** Message type */ + type: WorkspaceProjectsUpdatedMessageType; + /** Workspace ID */ + workspaceId: Scalars['String']['output']; +}; + +export enum WorkspaceProjectsUpdatedMessageType { + Added = 'ADDED', + Removed = 'REMOVED' +} + export enum WorkspaceRole { Admin = 'ADMIN', Guest = 'GUEST', @@ -4404,6 +4538,14 @@ export type WorkspaceUpdateInput = { slug?: InputMaybe; }; +export type WorkspaceUpdatedMessage = { + __typename?: 'WorkspaceUpdatedMessage'; + /** Workspace ID */ + id: Scalars['String']['output']; + /** Workspace itself */ + workspace: Workspace; +}; + export type AcccountTestQueryQueryVariables = Exact<{ [key: string]: never; }>; diff --git a/packages/dui3/lib/core/configs/apollo.ts b/packages/dui3/lib/core/configs/apollo.ts index 4ccbf4af77..164e82cf87 100644 --- a/packages/dui3/lib/core/configs/apollo.ts +++ b/packages/dui3/lib/core/configs/apollo.ts @@ -80,7 +80,7 @@ function createCache(): InMemoryCache { }, streams: { keyArgs: ['query'], - merge: buildAbstractCollectionMergeFunction('StreamCollection', { + merge: buildAbstractCollectionMergeFunction('UserStreamCollection', { checkIdentity: true }) }, diff --git a/packages/frontend-2/components/billing/Alert.vue b/packages/frontend-2/components/billing/Alert.vue index c2e8c2e86f..63a54edf77 100644 --- a/packages/frontend-2/components/billing/Alert.vue +++ b/packages/frontend-2/components/billing/Alert.vue @@ -51,6 +51,9 @@ const isTrial = computed( const isPaymentFailed = computed( () => planStatus.value === WorkspacePlanStatuses.PaymentFailed ) +const isScheduledForCancelation = computed( + () => planStatus.value === WorkspacePlanStatuses.CancelationScheduled +) const trialDaysLeft = computed(() => { const createdAt = props.workspace.plan?.createdAt const trialEndDate = dayjs(createdAt).add(31, 'days') @@ -65,11 +68,11 @@ const title = computed(() => { } switch (planStatus.value) { case WorkspacePlanStatuses.CancelationScheduled: - return `Your ${props.workspace.plan?.name} plan subscription is scheduled for cancellation` + return `Your workspace subscription is scheduled for cancellation` case WorkspacePlanStatuses.Canceled: - return `Your ${props.workspace.plan?.name} plan subscription has been cancelled` + return `Your workspace subscription has been cancelled` case WorkspacePlanStatuses.Expired: - return `Your free ${props.workspace.plan?.name} plan trial has ended` + return `Your free trial has ended` case WorkspacePlanStatuses.PaymentFailed: return "Your last payment didn't go through" default: @@ -84,11 +87,11 @@ const description = computed(() => { } switch (planStatus.value) { case WorkspacePlanStatuses.CancelationScheduled: - return 'Your workspace subscription is scheduled for cancellation. After the cancellation, your workspace will be in read-only mode.' + return 'Once the current billing cycle ends your workspace will enter read-only mode. Renew your subscription to undo.' case WorkspacePlanStatuses.Canceled: - return 'Your workspace has been cancelled and is in read-only mode. Upgrade your plan to continue.' + return 'Your workspace has been cancelled and is in read-only mode. Subscribe to a plan to regain full access.' case WorkspacePlanStatuses.Expired: - return "The workspace is in a read-only locked state until there's an active subscription. Upgrade your plan to continue." + return "The workspace is in a read-only locked state until there's an active subscription. Subscribe to a plan to regain full access." case WorkspacePlanStatuses.PaymentFailed: return "Update your payment information now to ensure your workspace doesn't go into maintenance mode." default: @@ -116,6 +119,12 @@ const actions = computed((): AlertAction[] => { onClick: () => billingPortalRedirect(props.workspace.id), disabled: !props.workspace.id }) + } else if (isScheduledForCancelation.value) { + actions.push({ + title: 'Renew subscription', + onClick: () => billingPortalRedirect(props.workspace.id), + disabled: !props.workspace.id + }) } return actions diff --git a/packages/frontend-2/components/common/tiptap/EmailMentionPopup.vue b/packages/frontend-2/components/common/tiptap/EmailMentionPopup.vue deleted file mode 100644 index cd32cf64af..0000000000 --- a/packages/frontend-2/components/common/tiptap/EmailMentionPopup.vue +++ /dev/null @@ -1,55 +0,0 @@ - - diff --git a/packages/frontend-2/components/common/tiptap/TextEditor.vue b/packages/frontend-2/components/common/tiptap/TextEditor.vue index 506933c64d..d1af117ad2 100644 --- a/packages/frontend-2/components/common/tiptap/TextEditor.vue +++ b/packages/frontend-2/components/common/tiptap/TextEditor.vue @@ -44,13 +44,9 @@ const props = defineProps<{ placeholder?: string readonly?: boolean /** - * Used to invite users to project when their emails are mentioned + * Used to scope things like user mentions to project collaborators etc. */ projectId?: string - /** - * Disable invitation CTA, e.g. if user doesn't have the required accesses - */ - disableInvitationCta?: boolean }>() const editorContentRef = ref(null as Nullable) @@ -65,8 +61,7 @@ const editor = new Editor({ editable: isEditable.value, extensions: getEditorExtensions(props.schemaOptions, { placeholder: props.placeholder, - projectId: - props.projectId && !props.disableInvitationCta ? props.projectId : undefined + projectId: props.projectId }), onUpdate: () => { const data = getData() diff --git a/packages/frontend-2/components/dashboard/Sidebar.vue b/packages/frontend-2/components/dashboard/Sidebar.vue index c098b0a862..cab1f5b010 100644 --- a/packages/frontend-2/components/dashboard/Sidebar.vue +++ b/packages/frontend-2/components/dashboard/Sidebar.vue @@ -72,32 +72,33 @@ - - + - - - + + + + + @@ -163,12 +164,6 @@ - - diff --git a/packages/frontend-2/components/projects/HiddenProjectWarning.vue b/packages/frontend-2/components/projects/HiddenProjectWarning.vue new file mode 100644 index 0000000000..e3b73a7b8c --- /dev/null +++ b/packages/frontend-2/components/projects/HiddenProjectWarning.vue @@ -0,0 +1,54 @@ + + + diff --git a/packages/frontend-2/components/projects/MoveToWorkspaceDialog.vue b/packages/frontend-2/components/projects/MoveToWorkspaceDialog.vue index 1182c0de0b..2deb8a6286 100644 --- a/packages/frontend-2/components/projects/MoveToWorkspaceDialog.vue +++ b/packages/frontend-2/components/projects/MoveToWorkspaceDialog.vue @@ -48,6 +48,12 @@ + @@ -57,12 +63,15 @@ import type { ProjectsMoveToWorkspaceDialog_WorkspaceFragment, ProjectsMoveToWorkspaceDialog_ProjectFragment } from '~~/lib/common/generated/gql/graphql' -import { projectWorkspaceSelectQuery } from '~/lib/projects/graphql/queries' -import { useQuery } from '@vue/apollo-composable' +import { useMutationLoading, useQuery } from '@vue/apollo-composable' import { type LayoutDialogButton } from '@speckle/ui-components' import { useMoveProjectToWorkspace } from '~/lib/projects/composables/projectManagement' import { Roles } from '@speckle/shared' import { workspacesRoute } from '~/lib/common/helpers/route' +import { + useWorkspaceCustomDataResidencyDisclaimer, + RegionStaticDataDisclaimerVariant +} from '~/lib/workspaces/composables/region' graphql(` fragment ProjectsMoveToWorkspaceDialog_Workspace on Workspace { @@ -71,6 +80,8 @@ graphql(` name defaultLogoIndex logo + ...WorkspaceHasCustomDataResidency_Workspace + ...ProjectsWorkspaceSelect_Workspace } `) @@ -97,6 +108,15 @@ graphql(` } `) +const query = graphql(` + query ProjectsMoveToWorkspaceDialog { + activeUser { + id + ...ProjectsMoveToWorkspaceDialog_User + } + } +`) + const props = defineProps<{ project: ProjectsMoveToWorkspaceDialog_ProjectFragment workspace?: ProjectsMoveToWorkspaceDialog_WorkspaceFragment @@ -105,9 +125,10 @@ const props = defineProps<{ const open = defineModel('open', { required: true }) const isWorkspacesEnabled = useIsWorkspacesEnabled() -const { result } = useQuery(projectWorkspaceSelectQuery, null, () => ({ +const { result } = useQuery(query, null, () => ({ enabled: isWorkspacesEnabled.value })) +const loading = useMutationLoading() const moveProject = useMoveProjectToWorkspace() const selectedWorkspace = ref() @@ -134,9 +155,9 @@ const dialogButtons = computed(() => { text: 'Move', props: { color: 'primary', - disabled: !selectedWorkspace.value && !props.workspace + disabled: (!selectedWorkspace.value && !props.workspace) || loading.value }, - onClick: () => onMoveProject() + onClick: () => triggerAction() } ] : [ @@ -153,27 +174,31 @@ const dialogButtons = computed(() => { const onMoveProject = async () => { const workspaceId = selectedWorkspace.value?.id ?? props.workspace?.id const workspaceName = selectedWorkspace.value?.name ?? props.workspace?.name + if (!workspaceId || !workspaceName) return - if (workspaceId && workspaceName) { - try { - await moveProject({ - projectId: props.project.id, - workspaceId, - workspaceName, - eventSource: props.eventSource - }) - open.value = false - } catch { - // Do nothing on error, composable already shows notification - } + const res = await moveProject({ + projectId: props.project.id, + workspaceId, + workspaceName, + eventSource: props.eventSource + }) + if (res?.id) { + open.value = false } } +const { showRegionStaticDataDisclaimer, triggerAction, onConfirmHandler } = + useWorkspaceCustomDataResidencyDisclaimer({ + workspace: computed(() => selectedWorkspace.value ?? props.workspace), + onConfirmAction: onMoveProject + }) + watch( () => open.value, (isOpen, oldIsOpen) => { if (isOpen && isOpen !== oldIsOpen) { selectedWorkspace.value = undefined + showRegionStaticDataDisclaimer.value = false } } ) diff --git a/packages/frontend-2/components/settings/Dialog.vue b/packages/frontend-2/components/settings/Dialog.vue index 1d257b5656..eb8eaf6201 100644 --- a/packages/frontend-2/components/settings/Dialog.vue +++ b/packages/frontend-2/components/settings/Dialog.vue @@ -82,15 +82,14 @@ ? 'Log in with your SSO provider to access this page' : workspaceMenuItem.tooltipText " - :disabled=" - workspaceMenuItem.disabled || needsSsoSession(workspaceItem, itemKey as string) - " + :disabled="!isAdmin && (workspaceMenuItem.disabled || needsSsoSession(workspaceItem, itemKey as string))" extra-padding @click=" - () => - workspaceMenuItem.disabled - ? noop - : onWorkspaceMenuItemClick(workspaceItem.id, `${itemKey}`) + handleMenuItemClick( + workspaceMenuItem, + workspaceItem, + itemKey as string + ) " /> @@ -121,11 +120,6 @@ @close="isOpen = false" /> - - @@ -163,6 +157,9 @@ graphql(` plan { status } + creationState { + completed + } } `) @@ -191,10 +188,12 @@ const { result: workspaceResult } = useQuery(settingsSidebarQuery, null, { const { userMenuItems, serverMenuItems, workspaceMenuItems } = useSettingsMenu() const isMobile = breakpoints.smaller('md') -const showWorkspaceCreateDialog = ref(false) const workspaceItems = computed( - () => workspaceResult.value?.activeUser?.workspaces.items ?? [] + () => + workspaceResult.value?.activeUser?.workspaces.items.filter( + (item) => item.creationState?.completed !== false // Removed workspaces that are not completely created + ) ?? [] ) const isAdmin = computed(() => user.value?.role === Roles.Server.Admin) const canCreateWorkspace = computed( @@ -241,6 +240,19 @@ const needsSsoSession = (workspace: SettingsMenu_WorkspaceFragment, key: string) ? !workspace.sso?.session?.validUntil : false } + +const handleMenuItemClick = ( + workspaceMenuItem: SettingsMenuItem, + workspaceItem: SettingsMenu_WorkspaceFragment, + itemKey: string +) => { + const isDisabled = + !isAdmin.value && + (workspaceMenuItem.disabled || needsSsoSession(workspaceItem, itemKey)) + if (isDisabled) return + onWorkspaceMenuItemClick(workspaceItem.id, `${itemKey}`) +} + // not ideal, but it works temporarily while this is still a modal useSetupMenuState({ goToWorkspaceMenuItem: onWorkspaceMenuItemClick diff --git a/packages/frontend-2/components/settings/server/Regions.vue b/packages/frontend-2/components/settings/server/Regions.vue index 194e86e01d..9b82494a9f 100644 --- a/packages/frontend-2/components/settings/server/Regions.vue +++ b/packages/frontend-2/components/settings/server/Regions.vue @@ -8,7 +8,7 @@
- + Create
@@ -55,10 +55,12 @@ const tableItems = computed(() => result.value?.serverInfo?.multiRegion?.regions const availableKeys = computed( () => result.value?.serverInfo?.multiRegion?.availableKeys || [] ) + const canCreateRegion = computed(() => availableKeys.value.length > 0) -const disabledMessage = computed(() => { - if (canCreateRegion.value) return undefined +const isCreateDisabled = computed(() => !canCreateRegion.value) +const disabledMessage = computed(() => { + if (!isCreateDisabled.value) return undefined if (!availableKeys.value.length) return 'No available region keys' return undefined diff --git a/packages/frontend-2/components/settings/server/regions/AddEditDialog.vue b/packages/frontend-2/components/settings/server/regions/AddEditDialog.vue index 1906fea9cb..900b61f028 100644 --- a/packages/frontend-2/components/settings/server/regions/AddEditDialog.vue +++ b/packages/frontend-2/components/settings/server/regions/AddEditDialog.vue @@ -89,7 +89,8 @@ const dialogButtons = computed((): LayoutDialogButton[] => { text: isEditMode.value ? 'Update' : 'Create', props: { submit: true, - disabled: loading.value + disabled: loading.value, + loading: loading.value }, onClick: noop } diff --git a/packages/frontend-2/components/settings/workspaces/Billing.vue b/packages/frontend-2/components/settings/workspaces/Billing.vue index e5f56622c8..c74f279ca1 100644 --- a/packages/frontend-2/components/settings/workspaces/Billing.vue +++ b/packages/frontend-2/components/settings/workspaces/Billing.vue @@ -17,7 +17,7 @@ class="grid grid-cols-1 lg:grid-cols-3 divide-y divide-outline-3 lg:divide-y-0 lg:divide-x" >
-

+

{{ statusIsTrial ? 'Trial plan' : 'Current plan' }}

@@ -32,27 +32,35 @@

- £{{ seatPrice[Roles.Workspace.Member] }} per seat/month, billed - {{ - subscription?.billingInterval === BillingInterval.Yearly - ? 'yearly' - : 'monthly' - }} + + + £{{ seatPrice[Roles.Workspace.Member] }} per seat/month + + Free + + + £{{ seatPrice[Roles.Workspace.Member] }} per seat/month, billed + {{ + subscription?.billingInterval === BillingInterval.Yearly + ? 'annually' + : 'monthly' + }} +

-

+

{{ statusIsTrial ? 'Expected bill' : subscription?.billingInterval === BillingInterval.Yearly - ? 'Yearly bill' + ? 'Annual bill' : 'Monthly bill' }}

@@ -76,12 +85,38 @@
+ + + + Confirm that you want to update your workspace's region to + {{ defaultRegion.name }}. + This cannot be undone. + + + diff --git a/packages/frontend-2/components/settings/workspaces/billing/PricingTable/Plan.vue b/packages/frontend-2/components/settings/workspaces/billing/PricingTable/Plan.vue index 9679833b50..306df48572 100644 --- a/packages/frontend-2/components/settings/workspaces/billing/PricingTable/Plan.vue +++ b/packages/frontend-2/components/settings/workspaces/billing/PricingTable/Plan.vue @@ -1,14 +1,16 @@ diff --git a/packages/frontend-2/pages/workspaces/[slug]/index.vue b/packages/frontend-2/pages/workspaces/[slug]/index.vue index bdf6935256..2260cd8f70 100644 --- a/packages/frontend-2/pages/workspaces/[slug]/index.vue +++ b/packages/frontend-2/pages/workspaces/[slug]/index.vue @@ -1,26 +1,5 @@ + + diff --git a/packages/frontend/src/graphql/generated/graphql.ts b/packages/frontend/src/graphql/generated/graphql.ts index eec68b00b6..53400008c9 100644 --- a/packages/frontend/src/graphql/generated/graphql.ts +++ b/packages/frontend/src/graphql/generated/graphql.ts @@ -215,11 +215,11 @@ export type AutomateAuthCodePayloadTest = { action: Scalars['String']['input']; code: Scalars['String']['input']; userId: Scalars['String']['input']; + workspaceId?: InputMaybe; }; export type AutomateFunction = { __typename?: 'AutomateFunction'; - automationCount: Scalars['Int']['output']; /** Only returned if user is a part of this speckle server */ creator?: Maybe; description: Scalars['String']['output']; @@ -232,6 +232,7 @@ export type AutomateFunction = { /** SourceAppNames values from @speckle/shared. Empty array means - all of them */ supportedSourceApps: Array; tags: Array; + workspaceIds?: Maybe>; }; @@ -290,6 +291,7 @@ export type AutomateFunctionRun = { export type AutomateFunctionRunStatusReportInput = { contextView?: InputMaybe; functionRunId: Scalars['String']['input']; + projectId: Scalars['String']['input']; /** AutomateTypes.ResultsSchema type from @speckle/shared */ results?: InputMaybe; status: AutomateRunStatus; @@ -305,15 +307,18 @@ export type AutomateFunctionTemplate = { }; export enum AutomateFunctionTemplateLanguage { - Demonstration = 'DEMONSTRATION', - Demonstrationpython = 'DEMONSTRATIONPYTHON', DotNet = 'DOT_NET', Python = 'PYTHON', Typescript = 'TYPESCRIPT' } +export type AutomateFunctionToken = { + __typename?: 'AutomateFunctionToken'; + functionId: Scalars['String']['output']; + functionToken: Scalars['String']['output']; +}; + export type AutomateFunctionsFilter = { - featuredFunctionsOnly?: InputMaybe; /** By default we skip functions without releases. Set this to true to include them. */ functionsWithoutReleases?: InputMaybe; search?: InputMaybe; @@ -322,6 +327,7 @@ export type AutomateFunctionsFilter = { export type AutomateMutations = { __typename?: 'AutomateMutations'; createFunction: AutomateFunction; + createFunctionWithoutVersion: AutomateFunctionToken; updateFunction: AutomateFunction; }; @@ -331,6 +337,11 @@ export type AutomateMutationsCreateFunctionArgs = { }; +export type AutomateMutationsCreateFunctionWithoutVersionArgs = { + input: CreateAutomateFunctionWithoutVersionInput; +}; + + export type AutomateMutationsUpdateFunctionArgs = { input: UpdateAutomateFunctionInput; }; @@ -529,6 +540,7 @@ export type CheckoutSession = { export type CheckoutSessionInput = { billingInterval: BillingInterval; + isCreateFlow?: InputMaybe; workspaceId: Scalars['ID']['input']; workspacePlan: PaidWorkspacePlans; }; @@ -831,6 +843,11 @@ export type CreateAutomateFunctionInput = { template: AutomateFunctionTemplateLanguage; }; +export type CreateAutomateFunctionWithoutVersionInput = { + description: Scalars['String']['input']; + name: Scalars['String']['input']; +}; + export type CreateCommentInput = { content: CommentContentInput; projectId: Scalars['String']['input']; @@ -1021,7 +1038,7 @@ export type LimitedUser = { * Returns all discoverable streams that the user is a collaborator on * @deprecated Part of the old API surface and will be removed in the future. */ - streams: StreamCollection; + streams: UserStreamCollection; /** * The user's timeline in chronological order * @deprecated Part of the old API surface and will be removed in the future. @@ -2542,7 +2559,7 @@ export type Query = { * Pass in the `query` parameter to search by name, description or ID. * @deprecated Part of the old API surface and will be removed in the future. Use User.projects instead. */ - streams?: Maybe; + streams?: Maybe; /** * Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). * @deprecated To be removed in the near future! Use 'activeUser' to get info about the active user or 'otherUser' to get info about another user. @@ -2841,7 +2858,7 @@ export type ServerInfo = { inviteOnly?: Maybe; /** Server relocation / migration info */ migration?: Maybe; - /** Available to server admins only */ + /** Info about server regions */ multiRegion: ServerMultiRegionConfiguration; name: Scalars['String']['output']; /** @deprecated Use role constants from the @speckle/shared npm package instead */ @@ -2895,10 +2912,7 @@ export type ServerMultiRegionConfiguration = { * be filtered out from the result. */ availableKeys: Array; - /** - * List of regions that are currently enabled on the server using the available region keys - * set in the multi region config file. - */ + /** Regions available for project data residency */ regions: Array; }; @@ -3268,6 +3282,11 @@ export type Subscription = { * @deprecated Part of the old API surface and will be removed in the future. Use 'projectVersionsUpdated' instead. */ commitUpdated?: Maybe; + /** + * Cyclically sends a message to the client, used for testing + * Note: Only works in test environment + */ + ping: Scalars['String']['output']; /** Subscribe to updates to automations in the project */ projectAutomationsUpdated: ProjectAutomationsUpdatedMessage; /** @@ -3327,6 +3346,16 @@ export type Subscription = { userViewerActivity?: Maybe; /** Track user activities in the viewer relating to the specified resources */ viewerUserActivityBroadcasted: ViewerUserActivityMessage; + /** + * Track newly added or deleted projects in a specific workspace. + * Either slug or id must be set. + */ + workspaceProjectsUpdated: WorkspaceProjectsUpdatedMessage; + /** + * Track updates to a specific workspace. + * Either slug or id must be set. + */ + workspaceUpdated: WorkspaceUpdatedMessage; }; @@ -3458,6 +3487,18 @@ export type SubscriptionViewerUserActivityBroadcastedArgs = { target: ViewerUpdateTrackingTarget; }; + +export type SubscriptionWorkspaceProjectsUpdatedArgs = { + workspaceId?: InputMaybe; + workspaceSlug?: InputMaybe; +}; + + +export type SubscriptionWorkspaceUpdatedArgs = { + workspaceId?: InputMaybe; + workspaceSlug?: InputMaybe; +}; + export type TestAutomationRun = { __typename?: 'TestAutomationRun'; automationRunId: Scalars['String']['output']; @@ -3510,6 +3551,7 @@ export type UpdateAutomateFunctionInput = { /** SourceAppNames values from @speckle/shared */ supportedSourceApps?: InputMaybe>; tags?: InputMaybe>; + workspaceIds?: InputMaybe>; }; export type UpdateModelInput = { @@ -3532,6 +3574,12 @@ export type UpdateVersionInput = { versionId: Scalars['ID']['input']; }; +export type UpgradePlanInput = { + billingInterval: BillingInterval; + workspaceId: Scalars['ID']['input']; + workspacePlan: PaidWorkspacePlans; +}; + /** * Full user type, should only be used in the context of admin operations or * when a user is reading/writing info about himself @@ -3547,6 +3595,7 @@ export type User = { apiTokens: Array; /** Returns the apps you have authorized. */ authorizedApps?: Maybe>; + automateFunctions: AutomateFunctionCollection; automateInfo: UserAutomateInfo; avatar?: Maybe; bio?: Maybe; @@ -3578,6 +3627,7 @@ export type User = { * @deprecated Part of the old API surface and will be removed in the future. */ favoriteStreams: StreamCollection; + gendoAICredits: UserGendoAiCredits; /** Whether the user has a pending/active email verification token */ hasPendingVerification?: Maybe; id: Scalars['ID']['output']; @@ -3591,14 +3641,14 @@ export type User = { /** Get all invitations to projects that the active user has */ projectInvites: Array; /** Get projects that the user participates in */ - projects: ProjectCollection; + projects: UserProjectCollection; role?: Maybe; /** * Returns all streams that the user is a collaborator on. If requested for a user, who isn't the * authenticated user, then this will only return discoverable streams. * @deprecated Part of the old API surface and will be removed in the future. Use User.projects instead. */ - streams: StreamCollection; + streams: UserStreamCollection; /** * The user's timeline in chronological order * @deprecated Part of the old API surface and will be removed in the future. @@ -3637,6 +3687,17 @@ export type UserActivityArgs = { }; +/** + * 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 UserAutomateFunctionsArgs = { + cursor?: InputMaybe; + filter?: InputMaybe; + limit?: InputMaybe; +}; + + /** * Full user type, should only be used in the context of admin operations or * when a user is reading/writing info about himself @@ -3766,6 +3827,21 @@ export type UserEmailMutationsSetPrimaryArgs = { input: SetPrimaryUserEmailInput; }; +export type UserGendoAiCredits = { + __typename?: 'UserGendoAICredits'; + limit: Scalars['Int']['output']; + resetDate: Scalars['DateTime']['output']; + used: Scalars['Int']['output']; +}; + +export type UserProjectCollection = { + __typename?: 'UserProjectCollection'; + cursor?: Maybe; + items: Array; + numberOfHidden: Scalars['Int']['output']; + totalCount: Scalars['Int']['output']; +}; + export type UserProjectsFilter = { /** Only include projects where user has the specified roles */ onlyWithRoles?: InputMaybe>; @@ -3799,6 +3875,14 @@ export type UserSearchResultCollection = { items: Array; }; +export type UserStreamCollection = { + __typename?: 'UserStreamCollection'; + cursor?: Maybe; + items?: Maybe>; + numberOfHidden: Scalars['Int']['output']; + totalCount: Scalars['Int']['output']; +}; + export type UserUpdateInput = { avatar?: InputMaybe; bio?: InputMaybe; @@ -4024,9 +4108,10 @@ export type WebhookUpdateInput = { export type Workspace = { __typename?: 'Workspace'; - /** Regions available to the workspace for project data residency */ - availableRegions: Array; + automateFunctions: AutomateFunctionCollection; createdAt: Scalars['DateTime']['output']; + /** Info about the workspace creation state */ + creationState?: Maybe; customerPortalUrl?: Maybe; /** Selected fallback when `logo` not set */ defaultLogoIndex: Scalars['Int']['output']; @@ -4064,6 +4149,13 @@ export type Workspace = { }; +export type WorkspaceAutomateFunctionsArgs = { + cursor?: InputMaybe; + filter?: InputMaybe; + limit?: Scalars['Int']['input']; +}; + + export type WorkspaceHasAccessToFeatureArgs = { featureName: WorkspaceFeatureName; }; @@ -4091,6 +4183,7 @@ export type WorkspaceBillingMutations = { __typename?: 'WorkspaceBillingMutations'; cancelCheckoutSession: Scalars['Boolean']['output']; createCheckoutSession: CheckoutSession; + upgradePlan: Scalars['Boolean']['output']; }; @@ -4103,6 +4196,11 @@ export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = { input: CheckoutSessionInput; }; + +export type WorkspaceBillingMutationsUpgradePlanArgs = { + input: UpgradePlanInput; +}; + /** Overridden by `WorkspaceCollaboratorGraphQLReturn` */ export type WorkspaceCollaborator = { __typename?: 'WorkspaceCollaborator'; @@ -4135,6 +4233,18 @@ export type WorkspaceCreateInput = { slug?: InputMaybe; }; +export type WorkspaceCreationState = { + __typename?: 'WorkspaceCreationState'; + completed: Scalars['Boolean']['output']; + state: Scalars['JSONObject']['output']; +}; + +export type WorkspaceCreationStateInput = { + completed: Scalars['Boolean']['input']; + state: Scalars['JSONObject']['input']; + workspaceId: Scalars['ID']['input']; +}; + export type WorkspaceDomain = { __typename?: 'WorkspaceDomain'; domain: Scalars['String']['output']; @@ -4235,6 +4345,7 @@ export type WorkspaceMutations = { /** Set the default region where project data will be stored. Only available to admins. */ setDefaultRegion: Workspace; update: Workspace; + updateCreationState: Scalars['Boolean']['output']; updateRole: Workspace; }; @@ -4285,12 +4396,18 @@ export type WorkspaceMutationsUpdateArgs = { }; +export type WorkspaceMutationsUpdateCreationStateArgs = { + input: WorkspaceCreationStateInput; +}; + + export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; export type WorkspacePlan = { __typename?: 'WorkspacePlan'; + createdAt: Scalars['DateTime']['output']; name: WorkspacePlans; status: WorkspacePlanStatuses; }; @@ -4360,6 +4477,23 @@ export type WorkspaceProjectsFilter = { search?: InputMaybe; }; +export type WorkspaceProjectsUpdatedMessage = { + __typename?: 'WorkspaceProjectsUpdatedMessage'; + /** Project entity, null if project was deleted */ + project?: Maybe; + /** Project ID */ + projectId: Scalars['String']['output']; + /** Message type */ + type: WorkspaceProjectsUpdatedMessageType; + /** Workspace ID */ + workspaceId: Scalars['String']['output']; +}; + +export enum WorkspaceProjectsUpdatedMessageType { + Added = 'ADDED', + Removed = 'REMOVED' +} + export enum WorkspaceRole { Admin = 'ADMIN', Guest = 'GUEST', @@ -4427,6 +4561,14 @@ export type WorkspaceUpdateInput = { slug?: InputMaybe; }; +export type WorkspaceUpdatedMessage = { + __typename?: 'WorkspaceUpdatedMessage'; + /** Workspace ID */ + id: Scalars['String']['output']; + /** Workspace itself */ + workspace: Workspace; +}; + export type GetStreamAccessRequestQueryVariables = Exact<{ streamId: Scalars['String']['input']; }>; @@ -4637,7 +4779,7 @@ export type StreamsQueryVariables = Exact<{ }>; -export type StreamsQuery = { __typename?: 'Query', streams?: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: string, updatedAt: string, commentCount: number, favoritedDate?: string | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, company?: string | null, avatar?: string | null, role: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, createdAt?: string | null, message?: string | null, authorId?: string | null, branchName?: string | null, authorName?: string | null, authorAvatar?: string | null, referencedObject: string }> | null } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }> | null } | null }; +export type StreamsQuery = { __typename?: 'Query', streams?: { __typename?: 'UserStreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: string, updatedAt: string, commentCount: number, favoritedDate?: string | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, company?: string | null, avatar?: string | null, role: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, createdAt?: string | null, message?: string | null, authorId?: string | null, branchName?: string | null, authorName?: string | null, authorAvatar?: string | null, referencedObject: string }> | null } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }> | null } | null }; export type CommonStreamFieldsFragment = { __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: string, updatedAt: string, commentCount: number, favoritedDate?: string | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, role: string, company?: string | null, avatar?: string | null, serverRole: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }; @@ -4704,7 +4846,7 @@ export type SearchStreamsQueryVariables = Exact<{ }>; -export type SearchStreamsQuery = { __typename?: 'Query', streams?: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, updatedAt: string }> | null } | null }; +export type SearchStreamsQuery = { __typename?: 'Query', streams?: { __typename?: 'UserStreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, updatedAt: string }> | null } | null }; export type UpdateStreamSettingsMutationVariables = Exact<{ input: StreamUpdateInput; @@ -4734,24 +4876,24 @@ export type StreamFileUploadsUpdatedSubscriptionVariables = Exact<{ export type StreamFileUploadsUpdatedSubscription = { __typename?: 'Subscription', projectFileImportUpdated: { __typename?: 'ProjectFileImportUpdatedMessage', type: ProjectFileImportUpdatedMessageType, id: string, upload: { __typename?: 'FileUpload', id: string, convertedCommitId?: string | null, userId: string, convertedStatus: number, convertedMessage?: string | null, fileName: string, fileType: string, uploadComplete: boolean, uploadDate: string, convertedLastUpdate: string } } }; -export type CommonUserFieldsFragment = { __typename?: 'User', id: string, email?: string | null, name: string, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams: { __typename?: 'StreamCollection', totalCount: number }, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, createdAt?: string | null }> | null } | null }; +export type CommonUserFieldsFragment = { __typename?: 'User', id: string, email?: string | null, name: string, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams: { __typename?: 'UserStreamCollection', totalCount: number }, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, createdAt?: string | null }> | null } | null }; export type UserFavoriteStreamsQueryVariables = Exact<{ cursor?: InputMaybe; }>; -export type UserFavoriteStreamsQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, email?: string | null, name: string, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, favoriteStreams: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: string, updatedAt: string, commentCount: number, favoritedDate?: string | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, role: string, company?: string | null, avatar?: string | null, serverRole: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }> | null }, streams: { __typename?: 'StreamCollection', totalCount: number }, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, createdAt?: string | null }> | null } | null } | null }; +export type UserFavoriteStreamsQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, email?: string | null, name: string, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, favoriteStreams: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: string, updatedAt: string, commentCount: number, favoritedDate?: string | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, role: string, company?: string | null, avatar?: string | null, serverRole: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }> | null }, streams: { __typename?: 'UserStreamCollection', totalCount: number }, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, createdAt?: string | null }> | null } | null } | null }; export type MainUserDataQueryVariables = Exact<{ [key: string]: never; }>; -export type MainUserDataQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, email?: string | null, name: string, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams: { __typename?: 'StreamCollection', totalCount: number }, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, createdAt?: string | null }> | null } | null } | null }; +export type MainUserDataQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, email?: string | null, name: string, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams: { __typename?: 'UserStreamCollection', totalCount: number }, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, createdAt?: string | null }> | null } | null } | null }; export type ProfileSelfQueryVariables = Exact<{ [key: string]: never; }>; -export type ProfileSelfQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', totalOwnedStreamsFavorites: number, notificationPreferences: Record, id: string, email?: string | null, name: string, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams: { __typename?: 'StreamCollection', totalCount: number }, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, createdAt?: string | null }> | null } | null } | null }; +export type ProfileSelfQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', totalOwnedStreamsFavorites: number, notificationPreferences: Record, id: string, email?: string | null, name: string, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams: { __typename?: 'UserStreamCollection', totalCount: number }, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, createdAt?: string | null }> | null } | null } | null }; export type UserSearchQueryVariables = Exact<{ query: Scalars['String']['input']; diff --git a/packages/server/.env-example b/packages/server/.env-example index 2982b08b9e..b59e451600 100644 --- a/packages/server/.env-example +++ b/packages/server/.env-example @@ -139,7 +139,7 @@ OIDC_CLIENT_SECRET="gLb9IEutYQ0npyvA8iHxPsObY3duGB0w" # SPECKLE_AUTOMATE_URL="http://127.0.0.1:3030" # # Automation input encryption keys file location. Structure: Array<{publicKey: string, privateKey: string}> -# AUTOMATE_ENCRYPTION_KEYS_PATH='test/assets/automate/encryptionKeys.json' +# ENCRYPTION_KEYS_PATH='test/assets/automate/encryptionKeys.json' ############################################################ ############################################################ diff --git a/packages/server/assets/core/typedefs/admin.graphql b/packages/server/assets/core/typedefs/admin.graphql index 2e9c61e0c7..48eb7b8304 100644 --- a/packages/server/assets/core/typedefs/admin.graphql +++ b/packages/server/assets/core/typedefs/admin.graphql @@ -26,6 +26,12 @@ type ServerStatistics { totalPendingInvites: Int! } +type ProjectCollection { + totalCount: Int! + cursor: String + items: [Project!]! +} + type AdminQueries { userList( limit: Int! = 25 diff --git a/packages/server/assets/core/typedefs/projects.graphql b/packages/server/assets/core/typedefs/projects.graphql index d52b300ee8..3cf504e2f4 100644 --- a/packages/server/assets/core/typedefs/projects.graphql +++ b/packages/server/assets/core/typedefs/projects.graphql @@ -150,11 +150,12 @@ extend type User { limit: Int! = 25 cursor: String filter: UserProjectsFilter - ): ProjectCollection! @isOwner + ): UserProjectCollection! @isOwner } -type ProjectCollection { +type UserProjectCollection { totalCount: Int! + numberOfHidden: Int! cursor: String items: [Project!]! } diff --git a/packages/server/assets/core/typedefs/streams.graphql b/packages/server/assets/core/typedefs/streams.graphql index 57f425cfdf..d179e527c3 100644 --- a/packages/server/assets/core/typedefs/streams.graphql +++ b/packages/server/assets/core/typedefs/streams.graphql @@ -12,7 +12,7 @@ extend type Query { Returns all streams that the active user is a collaborator on. Pass in the `query` parameter to search by name, description or ID. """ - streams(query: String, limit: Int = 25, cursor: String): StreamCollection + streams(query: String, limit: Int = 25, cursor: String): UserStreamCollection @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") @deprecated( @@ -87,7 +87,7 @@ extend type User { Returns all streams that the user is a collaborator on. If requested for a user, who isn't the authenticated user, then this will only return discoverable streams. """ - streams(limit: Int! = 25, cursor: String): StreamCollection! + streams(limit: Int! = 25, cursor: String): UserStreamCollection! @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") @deprecated( @@ -118,7 +118,7 @@ extend type LimitedUser { """ Returns all discoverable streams that the user is a collaborator on """ - streams(limit: Int! = 25, cursor: String): StreamCollection! + streams(limit: Int! = 25, cursor: String): UserStreamCollection! @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "streams:read") @deprecated( @@ -166,6 +166,13 @@ type PendingStreamCollaborator { token: String } +type UserStreamCollection { + numberOfHidden: Int! + totalCount: Int! + cursor: String + items: [Stream!] +} + type StreamCollection { totalCount: Int! cursor: String diff --git a/packages/server/assets/core/typedefs/user.graphql b/packages/server/assets/core/typedefs/user.graphql index 475ea712ce..d8911f09e1 100644 --- a/packages/server/assets/core/typedefs/user.graphql +++ b/packages/server/assets/core/typedefs/user.graphql @@ -42,7 +42,14 @@ extend type Query { cursor: String archived: Boolean = false emailOnly: Boolean = false - ): UserSearchResultCollection! + ): UserSearchResultCollection! @deprecated(reason: "Use users() instead.") + + """ + Look up server users + """ + users(input: UsersRetrievalInput!): UserSearchResultCollection! + @hasServerRole(role: SERVER_GUEST) + @hasScopes(scopes: ["users:read", "profile:read"]) """ Validate password strength @@ -53,6 +60,26 @@ extend type Query { ) } +input UsersRetrievalInput { + """ + The query looks for matches in user name & email + """ + query: String! + cursor: String + """ + Limit defaults to 10 + """ + limit: Int + """ + Only find users with directly matching emails + """ + emailOnly: Boolean + """ + Only find users that are collaborators of the specified project + """ + projectId: String +} + type PasswordStrengthCheckResults { """ Integer from 0-4 (useful for implementing a strength bar): diff --git a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql index 4c8ffe3af3..b779693fa8 100644 --- a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql +++ b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql @@ -26,6 +26,7 @@ input CheckoutSessionInput { workspaceId: ID! workspacePlan: PaidWorkspacePlans! billingInterval: BillingInterval! + isCreateFlow: Boolean } type CheckoutSession { diff --git a/packages/server/logging/logging.ts b/packages/server/logging/logging.ts index 34390451ec..3878375d27 100644 --- a/packages/server/logging/logging.ts +++ b/packages/server/logging/logging.ts @@ -31,6 +31,7 @@ export const crossServerSyncLogger = extendLoggerComponent(logger, 'cross-server export const automateLogger = extendLoggerComponent(logger, 'automate') export const subscriptionLogger = extendLoggerComponent(logger, 'subscription') export const healthCheckLogger = extendLoggerComponent(logger, 'healthcheck') +export const testLogger = extendLoggerComponent(logger, 'test') export type Logger = typeof logger export { extendLoggerComponent, Observability } diff --git a/packages/server/modules/automate/migrations/20241203212110_cascade_delete_automations.ts b/packages/server/modules/automate/migrations/20241203212110_cascade_delete_automations.ts new file mode 100644 index 0000000000..145a98eeef --- /dev/null +++ b/packages/server/modules/automate/migrations/20241203212110_cascade_delete_automations.ts @@ -0,0 +1,27 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('automation_revision_functions', (table) => { + table.dropPrimary() + table.dropForeign('automationRevisionId') + table + .foreign('automationRevisionId') + .references('id') + .inTable('automation_revisions') + .onDelete('cascade') + table.text('id').primary().notNullable().defaultTo(knex.raw('gen_random_uuid()')) + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('automation_revision_functions', (table) => { + table.dropPrimary() + table.dropForeign('automationRevisionId') + table + .foreign('automationRevisionId') + .references('id') + .inTable('automation_revisions') + .onDelete('no action') + table.primary(['automationRevisionId', 'functionId', 'functionReleaseId']) + }) +} diff --git a/packages/server/modules/automate/services/encryption.ts b/packages/server/modules/automate/services/encryption.ts index aceb5aab40..3b8228c8bf 100644 --- a/packages/server/modules/automate/services/encryption.ts +++ b/packages/server/modules/automate/services/encryption.ts @@ -1,4 +1,4 @@ -import { getAutomateEncryptionKeysPath } from '@/modules/shared/helpers/envHelper' +import { getEncryptionKeysPath } from '@/modules/shared/helpers/envHelper' import { packageRoot } from '@/bootstrap' import path from 'node:path' import fs from 'node:fs/promises' @@ -36,7 +36,7 @@ const isKeysFileContents = ( const getEncryptionKeys = async () => { if (keys) return keys - const relativePath = getAutomateEncryptionKeysPath() + const relativePath = getEncryptionKeysPath() const fullPath = path.resolve(packageRoot, relativePath) const file = await fs.readFile(fullPath, 'utf-8') diff --git a/packages/server/modules/cli/commands/workspaces/set-plan.ts b/packages/server/modules/cli/commands/workspaces/set-plan.ts index 3f18f6e514..301e1c414c 100644 --- a/packages/server/modules/cli/commands/workspaces/set-plan.ts +++ b/packages/server/modules/cli/commands/workspaces/set-plan.ts @@ -25,7 +25,7 @@ const command: CommandModule< describe: 'Plan to set the status for', type: 'string', default: 'business', - choices: ['business', 'starter', 'pro'] + choices: ['business', 'starter', 'plus'] }, status: { describe: 'Status to set for the workspace plan', diff --git a/packages/server/modules/core/domain/streams/operations.ts b/packages/server/modules/core/domain/streams/operations.ts index 18b95575dd..055066a354 100644 --- a/packages/server/modules/core/domain/streams/operations.ts +++ b/packages/server/modules/core/domain/streams/operations.ts @@ -64,7 +64,10 @@ export type GetCommitStream = (params: { export type GetStreamCollaborators = ( streamId: string, - type?: StreamRoles + type?: StreamRoles, + options?: Partial<{ + limit: number + }> ) => Promise> export type GetUserDeletableStreams = (userId: string) => Promise> @@ -161,6 +164,11 @@ export type BaseUserStreamsQueryParams = { */ streamIdWhitelist?: string[] workspaceId?: string + + /** + * Only with active sso session + */ + onlyWithActiveSsoSession?: boolean } export type UserStreamsQueryParams = BaseUserStreamsQueryParams & { diff --git a/packages/server/modules/core/domain/users/operations.ts b/packages/server/modules/core/domain/users/operations.ts index 58175e99c8..8d35419d0e 100644 --- a/packages/server/modules/core/domain/users/operations.ts +++ b/packages/server/modules/core/domain/users/operations.ts @@ -8,6 +8,7 @@ import { UserUpdateInput } from '@/modules/core/graph/generated/graphql' import { ServerInviteGraphQLReturnType } from '@/modules/core/helpers/graphTypes' import { ServerAclRecord, UserWithRole } from '@/modules/core/helpers/types' import { + MaybeNullOrUndefined, Nullable, NullableKeysToOptional, Optional, @@ -173,6 +174,27 @@ export type SearchLimitedUsers = ( cursor: Nullable }> +export type LookupUsers = (filter: { + query: string + /** + * Only find user with directly matching email + */ + emailOnly?: MaybeNullOrUndefined + /** + * Only find users that are collaborators of the specified project + */ + projectId?: MaybeNullOrUndefined + /** + * Defaults to 10 + */ + limit?: MaybeNullOrUndefined + cursor?: MaybeNullOrUndefined + archived?: MaybeNullOrUndefined +}) => Promise<{ + users: User[] + cursor: Nullable +}> + type AdminUserListArgs = { cursor: string | null query: string | null diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 2fa5c474a8..547f7840bd 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -557,6 +557,7 @@ export type CheckoutSession = { export type CheckoutSessionInput = { billingInterval: BillingInterval; + isCreateFlow?: InputMaybe; workspaceId: Scalars['ID']['input']; workspacePlan: PaidWorkspacePlans; }; @@ -1044,7 +1045,7 @@ export type LimitedUser = { * Returns all discoverable streams that the user is a collaborator on * @deprecated Part of the old API surface and will be removed in the future. */ - streams: StreamCollection; + streams: UserStreamCollection; /** * The user's timeline in chronological order * @deprecated Part of the old API surface and will be removed in the future. @@ -2563,7 +2564,7 @@ export type Query = { * Pass in the `query` parameter to search by name, description or ID. * @deprecated Part of the old API surface and will be removed in the future. Use User.projects instead. */ - streams?: Maybe; + streams?: Maybe; /** * Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). * @deprecated To be removed in the near future! Use 'activeUser' to get info about the active user or 'otherUser' to get info about another user. @@ -2577,8 +2578,11 @@ export type Query = { /** * Search for users and return limited metadata about them, if you have the server:user role. * The query looks for matches in name & email + * @deprecated Use users() instead. */ userSearch: UserSearchResultCollection; + /** Look up server users */ + users: UserSearchResultCollection; /** Validates the slug, to make sure it contains only valid characters and its not taken. */ validateWorkspaceSlug: Scalars['Boolean']['output']; workspace: Workspace; @@ -2720,6 +2724,11 @@ export type QueryUserSearchArgs = { }; +export type QueryUsersArgs = { + input: UsersRetrievalInput; +}; + + export type QueryValidateWorkspaceSlugArgs = { slug: Scalars['String']['input']; }; @@ -3639,14 +3648,14 @@ export type User = { /** Get all invitations to projects that the active user has */ projectInvites: Array; /** Get projects that the user participates in */ - projects: ProjectCollection; + projects: UserProjectCollection; role?: Maybe; /** * Returns all streams that the user is a collaborator on. If requested for a user, who isn't the * authenticated user, then this will only return discoverable streams. * @deprecated Part of the old API surface and will be removed in the future. Use User.projects instead. */ - streams: StreamCollection; + streams: UserStreamCollection; /** * The user's timeline in chronological order * @deprecated Part of the old API surface and will be removed in the future. @@ -3832,6 +3841,14 @@ export type UserGendoAiCredits = { used: Scalars['Int']['output']; }; +export type UserProjectCollection = { + __typename?: 'UserProjectCollection'; + cursor?: Maybe; + items: Array; + numberOfHidden: Scalars['Int']['output']; + totalCount: Scalars['Int']['output']; +}; + export type UserProjectsFilter = { /** Only include projects where user has the specified roles */ onlyWithRoles?: InputMaybe>; @@ -3865,6 +3882,14 @@ export type UserSearchResultCollection = { items: Array; }; +export type UserStreamCollection = { + __typename?: 'UserStreamCollection'; + cursor?: Maybe; + items?: Maybe>; + numberOfHidden: Scalars['Int']['output']; + totalCount: Scalars['Int']['output']; +}; + export type UserUpdateInput = { avatar?: InputMaybe; bio?: InputMaybe; @@ -3876,6 +3901,18 @@ export type UserWorkspacesFilter = { search?: InputMaybe; }; +export type UsersRetrievalInput = { + cursor?: InputMaybe; + /** Only find users with directly matching emails */ + emailOnly?: InputMaybe; + /** Limit defaults to 10 */ + limit?: InputMaybe; + /** Only find users that are collaborators of the specified project */ + projectId?: InputMaybe; + /** The query looks for matches in user name & email */ + query: Scalars['String']['input']; +}; + export type Version = { __typename?: 'Version'; authorUser?: Maybe; @@ -4846,13 +4883,16 @@ export type ResolversTypes = { UserEmail: ResolverTypeWrapper; UserEmailMutations: ResolverTypeWrapper; UserGendoAICredits: ResolverTypeWrapper; + UserProjectCollection: ResolverTypeWrapper & { items: Array }>; UserProjectsFilter: UserProjectsFilter; UserProjectsUpdatedMessage: ResolverTypeWrapper & { project?: Maybe }>; UserProjectsUpdatedMessageType: UserProjectsUpdatedMessageType; UserRoleInput: UserRoleInput; UserSearchResultCollection: ResolverTypeWrapper & { items: Array }>; + UserStreamCollection: ResolverTypeWrapper & { items?: Maybe> }>; UserUpdateInput: UserUpdateInput; UserWorkspacesFilter: UserWorkspacesFilter; + UsersRetrievalInput: UsersRetrievalInput; Version: ResolverTypeWrapper; VersionCollection: ResolverTypeWrapper & { items: Array }>; VersionCreatedTrigger: ResolverTypeWrapper; @@ -5113,12 +5153,15 @@ export type ResolversParentTypes = { UserEmail: UserEmail; UserEmailMutations: MutationsObjectGraphQLReturn; UserGendoAICredits: UserGendoAiCredits; + UserProjectCollection: Omit & { items: Array }; UserProjectsFilter: UserProjectsFilter; UserProjectsUpdatedMessage: Omit & { project?: Maybe }; UserRoleInput: UserRoleInput; UserSearchResultCollection: Omit & { items: Array }; + UserStreamCollection: Omit & { items?: Maybe> }; UserUpdateInput: UserUpdateInput; UserWorkspacesFilter: UserWorkspacesFilter; + UsersRetrievalInput: UsersRetrievalInput; Version: VersionGraphQLReturn; VersionCollection: Omit & { items: Array }; VersionCreatedTrigger: AutomationRunTriggerGraphQLReturn; @@ -5674,7 +5717,7 @@ export type LimitedUserResolvers; name?: Resolver; role?: Resolver, ParentType, ContextType>; - streams?: Resolver>; + streams?: Resolver>; timeline?: Resolver, ParentType, ContextType, RequireFields>; totalOwnedStreamsFavorites?: Resolver; verified?: Resolver, ParentType, ContextType>; @@ -6082,10 +6125,11 @@ export type QueryResolvers, ParentType, ContextType, RequireFields>; streamInvite?: Resolver, ParentType, ContextType, RequireFields>; streamInvites?: Resolver, ParentType, ContextType>; - streams?: Resolver, ParentType, ContextType, RequireFields>; + streams?: Resolver, ParentType, ContextType, RequireFields>; user?: Resolver, ParentType, ContextType, Partial>; userPwdStrength?: Resolver>; userSearch?: Resolver>; + users?: Resolver>; validateWorkspaceSlug?: Resolver>; workspace?: Resolver>; workspaceBySlug?: Resolver>; @@ -6405,9 +6449,9 @@ export type UserResolvers, ParentType, ContextType>; projectAccessRequest?: Resolver, ParentType, ContextType, RequireFields>; projectInvites?: Resolver, ParentType, ContextType>; - projects?: Resolver>; + projects?: Resolver>; role?: Resolver, ParentType, ContextType>; - streams?: Resolver>; + streams?: Resolver>; timeline?: Resolver, ParentType, ContextType, RequireFields>; totalOwnedStreamsFavorites?: Resolver; verified?: Resolver, ParentType, ContextType>; @@ -6447,6 +6491,14 @@ export type UserGendoAiCreditsResolvers; }; +export type UserProjectCollectionResolvers = { + cursor?: Resolver, ParentType, ContextType>; + items?: Resolver, ParentType, ContextType>; + numberOfHidden?: Resolver; + totalCount?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type UserProjectsUpdatedMessageResolvers = { id?: Resolver; project?: Resolver, ParentType, ContextType>; @@ -6460,6 +6512,14 @@ export type UserSearchResultCollectionResolvers; }; +export type UserStreamCollectionResolvers = { + cursor?: Resolver, ParentType, ContextType>; + items?: Resolver>, ParentType, ContextType>; + numberOfHidden?: Resolver; + totalCount?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type VersionResolvers = { authorUser?: Resolver, ParentType, ContextType>; automationsStatus?: Resolver, ParentType, ContextType>; @@ -6840,8 +6900,10 @@ export type Resolvers = { UserEmail?: UserEmailResolvers; UserEmailMutations?: UserEmailMutationsResolvers; UserGendoAICredits?: UserGendoAiCreditsResolvers; + UserProjectCollection?: UserProjectCollectionResolvers; UserProjectsUpdatedMessage?: UserProjectsUpdatedMessageResolvers; UserSearchResultCollection?: UserSearchResultCollectionResolvers; + UserStreamCollection?: UserStreamCollectionResolvers; Version?: VersionResolvers; VersionCollection?: VersionCollectionResolvers; VersionCreatedTrigger?: VersionCreatedTriggerResolvers; diff --git a/packages/server/modules/core/graph/resolvers/projects.ts b/packages/server/modules/core/graph/resolvers/projects.ts index b4876f0e49..04b0bb4510 100644 --- a/packages/server/modules/core/graph/resolvers/projects.ts +++ b/packages/server/modules/core/graph/resolvers/projects.ts @@ -324,25 +324,40 @@ export = { } } - const totalCount = await getUserStreamsCount({ - userId: ctx.userId!, - forOtherUser: false, - searchQuery: args.filter?.search || undefined, - withRoles: (args.filter?.onlyWithRoles || []) as StreamRoles[], - streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules) - }) - - const { cursor, streams } = await getUserStreams({ - userId: ctx.userId!, - limit: args.limit, - cursor: args.cursor || undefined, - searchQuery: args.filter?.search || undefined, - forOtherUser: false, - withRoles: (args.filter?.onlyWithRoles || []) as StreamRoles[], - streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules) - }) + const [totalCount, visibleCount, { cursor, streams }] = await Promise.all([ + getUserStreamsCount({ + userId: ctx.userId!, + forOtherUser: false, + searchQuery: args.filter?.search || undefined, + withRoles: (args.filter?.onlyWithRoles || []) as StreamRoles[], + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules) + }), + getUserStreamsCount({ + userId: ctx.userId!, + forOtherUser: false, + searchQuery: args.filter?.search || undefined, + withRoles: (args.filter?.onlyWithRoles || []) as StreamRoles[], + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules), + onlyWithActiveSsoSession: true + }), + getUserStreams({ + userId: ctx.userId!, + limit: args.limit, + cursor: args.cursor || undefined, + searchQuery: args.filter?.search || undefined, + forOtherUser: false, + withRoles: (args.filter?.onlyWithRoles || []) as StreamRoles[], + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules), + onlyWithActiveSsoSession: true + }) + ]) - return { totalCount, cursor, items: streams } + return { + totalCount, + numberOfHidden: totalCount - visibleCount, + cursor, + items: streams + } } }, Project: { diff --git a/packages/server/modules/core/graph/resolvers/streams.ts b/packages/server/modules/core/graph/resolvers/streams.ts index a2878cd5e9..06110ea80c 100644 --- a/packages/server/modules/core/graph/resolvers/streams.ts +++ b/packages/server/modules/core/graph/resolvers/streams.ts @@ -39,7 +39,6 @@ import { updateStreamAndNotifyFactory, updateStreamRoleAndNotifyFactory } from '@/modules/core/services/streams/management' -import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' import { Roles, Scopes } from '@speckle/shared' import { StreamNotFoundError } from '@/modules/core/errors/stream' import { throwForNotHavingServerRole } from '@/modules/shared/authz' @@ -48,8 +47,7 @@ import { RateLimitError } from '@/modules/core/errors/ratelimit' import { toProjectIdWhitelist, isResourceAllowed } from '@/modules/core/helpers/token' import { Resolvers, - TokenResourceIdentifierType, - UserStreamsArgs + TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql' import { deleteAllResourceInvitesFactory, @@ -87,6 +85,7 @@ import { } from '@/modules/core/services/streams/favorite' import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users' import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' const getServerInfo = getServerInfoFactory({ db }) const getUsers = getUsersFactory({ db }) @@ -183,29 +182,6 @@ const getStreamUsers = legacyGetStreamUsersFactory({ db }) const getUserStreams = getUserStreamsPageFactory({ db }) const getUserStreamsCount = getUserStreamsCountFactory({ db }) -const getUserStreamsCore = async ( - forOtherUser: boolean, - parent: { id: string }, - args: UserStreamsArgs, - streamIdWhitelist?: string[] -) => { - const totalCount = await getUserStreamsCount({ - userId: parent.id, - forOtherUser, - streamIdWhitelist - }) - - const { cursor, streams } = await getUserStreams({ - userId: parent.id, - limit: args.limit, - cursor: args.cursor, - forOtherUser, - streamIdWhitelist - }) - - return { totalCount, cursor, items: streams } -} - /** * @type {import('@/modules/core/graph/generated/graphql').Resolvers} */ @@ -232,21 +208,38 @@ export = { return stream }, - async streams(_, args, context) { - const totalCount = await getUserStreamsCount({ - userId: context.userId!, - searchQuery: args.query || undefined, - streamIdWhitelist: toProjectIdWhitelist(context.resourceAccessRules) - }) + async streams(_, args, ctx) { + const [totalCount, visibleCount, { cursor, streams }] = await Promise.all([ + getUserStreamsCount({ + userId: ctx.userId!, + forOtherUser: false, + searchQuery: args.query || undefined, + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules) + }), + getUserStreamsCount({ + userId: ctx.userId!, + forOtherUser: false, + searchQuery: args.query || undefined, + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules), + onlyWithActiveSsoSession: true + }), + getUserStreams({ + userId: ctx.userId!, + limit: args.limit, + cursor: args.cursor || undefined, + searchQuery: args.query || undefined, + forOtherUser: false, + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules), + onlyWithActiveSsoSession: true + }) + ]) - const { cursor, streams } = await getUserStreams({ - userId: context.userId!, - limit: args.limit, - cursor: args.cursor, - searchQuery: args.query || undefined, - streamIdWhitelist: toProjectIdWhitelist(context.resourceAccessRules) - }) - return { totalCount, cursor, items: streams } + return { + totalCount, + numberOfHidden: totalCount - visibleCount, + cursor, + items: streams + } }, async discoverableStreams(_, args, ctx) { @@ -332,15 +325,38 @@ export = { } }, User: { - async streams(parent, args, context) { + async streams(parent, args, ctx) { // Return only the user's public streams if parent.id !== context.userId - const forOtherUser = parent.id !== context.userId - return await getUserStreamsCore( - forOtherUser, - parent, - args, - toProjectIdWhitelist(context.resourceAccessRules) - ) + const forOtherUser = parent.id !== ctx.userId + + const [totalCount, visibleCount, { cursor, streams }] = await Promise.all([ + getUserStreamsCount({ + userId: parent.id, + forOtherUser, + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules) + }), + getUserStreamsCount({ + userId: parent.id, + forOtherUser, + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules), + onlyWithActiveSsoSession: true + }), + getUserStreams({ + userId: parent.id, + limit: args.limit, + cursor: args.cursor || undefined, + forOtherUser, + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules), + onlyWithActiveSsoSession: true + }) + ]) + + return { + totalCount, + numberOfHidden: totalCount - visibleCount, + cursor, + items: streams + } }, async favoriteStreams(parent, args, context) { @@ -369,16 +385,42 @@ export = { } }, LimitedUser: { - async streams(parent, args, context) { + async streams(parent, args, ctx) { // a little escape hatch for admins to look into users streams + const isAdminOverride = adminOverrideEnabled() && ctx.role === Roles.Server.Admin + + // if isAdminOverride, then the ctx.user has to be treaded as the parent user + // to give the admin full view into the parent user's project streams + const forOtherUser = parent.id === ctx.userId ? false : !isAdminOverride + const userId = parent.id + const [totalCount, visibleCount, { cursor, streams }] = await Promise.all([ + getUserStreamsCount({ + userId, + forOtherUser, + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules) + }), + getUserStreamsCount({ + userId, + forOtherUser, + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules), + onlyWithActiveSsoSession: true + }), + getUserStreams({ + userId, + limit: args.limit, + cursor: args.cursor || undefined, + forOtherUser, + streamIdWhitelist: toProjectIdWhitelist(ctx.resourceAccessRules), + onlyWithActiveSsoSession: true + }) + ]) - const isAdmin = adminOverrideEnabled() && context.role === Roles.Server.Admin - return await getUserStreamsCore( - !isAdmin, - parent, - args, - toProjectIdWhitelist(context.resourceAccessRules) - ) + return { + totalCount, + numberOfHidden: totalCount - visibleCount, + cursor, + items: streams + } }, async totalOwnedStreamsFavorites(parent, _args, ctx) { const { id: userId } = parent diff --git a/packages/server/modules/core/graph/resolvers/users.ts b/packages/server/modules/core/graph/resolvers/users.ts index 4eafc84c41..2bab5a4e9d 100644 --- a/packages/server/modules/core/graph/resolvers/users.ts +++ b/packages/server/modules/core/graph/resolvers/users.ts @@ -14,7 +14,8 @@ import { searchUsersFactory, markOnboardingCompleteFactory, legacyGetPaginatedUsersCountFactory, - legacyGetPaginatedUsersFactory + legacyGetPaginatedUsersFactory, + lookupUsersFactory } from '@/modules/core/repositories/users' import { UsersMeta } from '@/modules/core/dbSchema' import { throwForNotHavingServerRole } from '@/modules/shared/authz' @@ -68,6 +69,7 @@ const changeUserRole = changeUserRoleFactory({ updateUserServerRole: updateUserServerRoleFactory({ db }) }) const searchUsers = searchUsersFactory({ db }) +const lookupUsers = lookupUsersFactory({ db }) const markOnboardingComplete = markOnboardingCompleteFactory({ db }) const getAdminUsersListCollection = getAdminUsersListCollectionFactory({ countUsers: legacyGetPaginatedUsersCountFactory({ db }), @@ -93,7 +95,7 @@ export = { if (!id) return null return await getUser(id) }, - async user(parent, args, context) { + async user(_parent, args, context) { // User wants info about himself and he's not authenticated - just return null if (!context.auth && !args.id) return null @@ -136,14 +138,27 @@ export = { return { cursor, items: users } }, - async userPwdStrength(parent, args) { + async users(_parent, args) { + if (args.input.query.length < 3) + throw new BadRequestError('Search query must be at least 3 characters.') + + if ((args.input.limit || 0) > 100) + throw new BadRequestError( + 'Cannot return more than 100 items, please use pagination.' + ) + + const { cursor, users } = await lookupUsers(args.input) + return { cursor, items: users } + }, + + async userPwdStrength(_parent, args) { const res = zxcvbn(args.pwd) return { score: res.score, feedback: res.feedback } } }, User: { - async email(parent, args, context) { + async email(parent, _args, context) { // NOTE: we're redacting the field (returning null) rather than throwing a full error which would invalidate the request. if (context.userId === parent.id) { try { diff --git a/packages/server/modules/core/repositories/streams.ts b/packages/server/modules/core/repositories/streams.ts index 528bc85738..a2cbf98e68 100644 --- a/packages/server/modules/core/repositories/streams.ts +++ b/packages/server/modules/core/repositories/streams.ts @@ -602,7 +602,9 @@ export const getDiscoverableStreamsPageFactory = */ export const getStreamCollaboratorsFactory = (deps: { db: Knex }): GetStreamCollaborators => - async (streamId: string, type?: StreamRoles) => { + async (streamId: string, type?: StreamRoles, options?) => { + const { limit } = options || {} + const q = tables .streamAcl(deps.db) .select>([ @@ -619,6 +621,10 @@ export const getStreamCollaboratorsFactory = q.andWhere(StreamAcl.col.role, type) } + if (limit) { + q.limit(limit) + } + const items = (await q).map((i) => ({ ...removePrivateFields(i), streamRole: i.streamRole, @@ -671,13 +677,42 @@ const getUserStreamsQueryBaseFactory = ownedOnly, withRoles, streamIdWhitelist, - workspaceId + workspaceId, + onlyWithActiveSsoSession }: BaseUserStreamsQueryParams) => { const query = tables .streamAcl(deps.db) .where(StreamAcl.col.userId, userId) .join(Streams.name, StreamAcl.col.resourceId, Streams.col.id) + // select * from stream_acl sa + // join streams s on s.id = sa."resourceId" + // left join workspace_sso_providers wp on wp."workspaceId" = s."workspaceId" + // left join user_sso_sessions us on (us."userId" = sa."userId" and us."providerId" = wp."providerId") + // where sa."userId" = '7f1f8aa286' + // and ((wp."providerId" is not null and us."validUntil" > now()) or wp."providerId" is null) + + if (onlyWithActiveSsoSession) { + query + .leftJoin( + 'workspace_sso_providers', + 'workspace_sso_providers.workspaceId', + Streams.col.workspaceId + ) + .leftJoin('user_sso_sessions', function () { + this.on('user_sso_sessions.userId', '=', StreamAcl.col.userId).andOn( + 'workspace_sso_providers.providerId', + '=', + 'user_sso_sessions.providerId' + ) + }) + .andWhere(function () { + this.whereRaw( + 'workspace_sso_providers."providerId" is not null and user_sso_sessions."validUntil" > now()' + ).orWhere('workspace_sso_providers.providerId', 'is', null) + }) + } + if (workspaceId) { query.andWhere(Streams.col.workspaceId, workspaceId) } diff --git a/packages/server/modules/core/repositories/users.ts b/packages/server/modules/core/repositories/users.ts index 104814293d..abf8c4af16 100644 --- a/packages/server/modules/core/repositories/users.ts +++ b/packages/server/modules/core/repositories/users.ts @@ -1,4 +1,4 @@ -import { ServerAcl, UserEmails, Users, knex } from '@/modules/core/dbSchema' +import { ServerAcl, StreamAcl, UserEmails, Users, knex } from '@/modules/core/dbSchema' import { ServerAclRecord, UserRecord, UserWithRole } from '@/modules/core/helpers/types' import { Nullable } from '@/modules/shared/helpers/typeHelper' import { clamp, isArray, omit } from 'lodash' @@ -25,6 +25,7 @@ import { LegacyGetUser, LegacyGetUserByEmail, ListPaginatedUsersPage, + LookupUsers, MarkOnboardingComplete, MarkUserAsVerified, SearchLimitedUsers, @@ -33,7 +34,7 @@ import { UpdateUser, UpdateUserServerRole } from '@/modules/core/domain/users/operations' -import { LIMITED_USER_FIELDS } from '@/modules/core/helpers/userHelper' +import { removePrivateFields } from '@/modules/core/helpers/userHelper' export type { UserWithOptionalRole, GetUserParams } const tables = { @@ -449,39 +450,84 @@ export const getUserRoleFactory = } /** - * User search available for normal server users. It's more limited because of the lower access level. + * Used for (Limited)User search. No need to convert users to Limited here, because non-limited fields + * cannot be leaked out from the GQL API. */ -export const searchUsersFactory = - (deps: { db: Knex }): SearchLimitedUsers => - async (searchQuery, limit, cursor, archived = false, emailOnly = false) => { - const prefixedLimitedUserFields = LIMITED_USER_FIELDS.map( - (field) => `users.${field}` - ) +export const lookupUsersFactory = + (deps: { db: Knex }): LookupUsers => + async (filter) => { + const { + query: searchQuery, + limit = 10, + cursor, + archived = false, + emailOnly = false, + projectId + } = filter + const query = tables .users(deps.db) - .join('server_acl', 'users.id', 'server_acl.userId') + .join(ServerAcl.name, Users.col.id, ServerAcl.col.userId) .leftJoin(UserEmails.name, UserEmails.col.userId, Users.col.id) .columns([ - ...Object.values(omit(Users.col, ['email', 'verified'])).filter((col) => - prefixedLimitedUserFields.includes(col) + ...Object.values( + omit(Users.col, [ + Users.col.email, + Users.col.verified, + Users.col.passwordDigest + ]) ), - knex.raw(`(array_agg("user_emails"."verified"))[1] as verified`) + knex.raw(`(array_agg(??))[1] as "verified"`, [UserEmails.col.verified]), + knex.raw(`(array_agg(??))[1] as "email"`, [UserEmails.col.email]) ]) .groupBy(Users.col.id) .where((queryBuilder) => { queryBuilder.where({ [UserEmails.col.email]: searchQuery }) //match full email or partial name - if (!emailOnly) queryBuilder.orWhere('name', 'ILIKE', `%${searchQuery}%`) - if (!archived) queryBuilder.andWhere('role', '!=', Roles.Server.ArchivedUser) + if (!emailOnly) + queryBuilder.orWhere(Users.col.name, 'ILIKE', `%${searchQuery}%`) + if (!archived) + queryBuilder.andWhere(ServerAcl.col.role, '!=', Roles.Server.ArchivedUser) }) - if (cursor) query.andWhere('users.createdAt', '<', cursor) + if (projectId) { + query + .innerJoin(StreamAcl.name, StreamAcl.col.userId, Users.col.id) + .andWhere(StreamAcl.col.resourceId, projectId) + } + + if (cursor) query.andWhere(Users.col.createdAt, '<', cursor) + + const finalLimit = clamp(limit || 10, 1, 100) + query.orderBy(Users.col.createdAt, 'desc').limit(finalLimit) + const rows = (await query) as UserRecord[] + const users = rows.map((u) => sanitizeUserRecord(u)) // pw shouldnt be there, but just making sure + + return { + users, + cursor: users.length > 0 ? users[users.length - 1].createdAt.toISOString() : null + } + } + +/** + * User search available for normal server users. It's more limited because of the lower access level. + * @deprecated Use lookupUsers instead + */ +export const searchUsersFactory = + (deps: { db: Knex }): SearchLimitedUsers => + async (searchQuery, limit, cursor, archived = false, emailOnly = false) => { + const lookupUsers = lookupUsersFactory(deps) const defaultLimit = 25 - query.orderBy('users.createdAt', 'desc').limit(limit || defaultLimit) + const res = await lookupUsers({ + query: searchQuery, + limit: limit || defaultLimit, + cursor, + archived, + emailOnly + }) - const rows = await query return { - users: rows, - cursor: rows.length > 0 ? rows[rows.length - 1].createdAt.toISOString() : null + users: res.users.map(removePrivateFields), + cursor: res.cursor } } diff --git a/packages/server/modules/core/tests/helpers/graphql.ts b/packages/server/modules/core/tests/helpers/graphql.ts index d5597f8b09..5043845c0b 100644 --- a/packages/server/modules/core/tests/helpers/graphql.ts +++ b/packages/server/modules/core/tests/helpers/graphql.ts @@ -118,3 +118,15 @@ export const onBranchDeletedSubscription = gql` branchDeleted(streamId: $streamId) } ` + +export const usersRetrievalQuery = gql` + query UsersRetrieval($input: UsersRetrievalInput!) { + users(input: $input) { + cursor + items { + id + name + } + } + } +` diff --git a/packages/server/modules/core/tests/integration/subs.graph.spec.ts b/packages/server/modules/core/tests/integration/subs.graph.spec.ts index 115c6de1b2..854abbc147 100644 --- a/packages/server/modules/core/tests/integration/subs.graph.spec.ts +++ b/packages/server/modules/core/tests/integration/subs.graph.spec.ts @@ -106,7 +106,7 @@ import { BasicTestCommit, createTestCommits } from '@/test/speckle-helpers/commi import { TestError } from '@/test/speckle-helpers/error' import { isMultiRegionTestMode, - waitForRegionUser + waitForRegionUsers } from '@/test/speckle-helpers/regions' import { BasicTestStream, createTestStreams } from '@/test/speckle-helpers/streamHelper' import { faker } from '@faker-js/faker' @@ -297,12 +297,7 @@ describe('Core GraphQL Subscriptions (New)', () => { }) ]) - if (isMultiRegion) { - await Promise.all([ - waitForRegionUser({ userId: me.id }), - waitForRegionUser({ userId: otherGuy.id }) - ]) - } + await waitForRegionUsers([me, otherGuy]) }) describe('Project Subs', () => { @@ -506,9 +501,6 @@ describe('Core GraphQL Subscriptions (New)', () => { } ) await meSubClient.waitForReadiness() - await meSubClient.waitForReadiness() - await meSubClient.waitForReadiness() - await meSubClient.waitForReadiness() await addOrUpdateStreamCollaborator( otherGuysProj.id, diff --git a/packages/server/modules/core/tests/integration/users.graph.spec.ts b/packages/server/modules/core/tests/integration/users.graph.spec.ts new file mode 100644 index 0000000000..32cc784cc1 --- /dev/null +++ b/packages/server/modules/core/tests/integration/users.graph.spec.ts @@ -0,0 +1,306 @@ +import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' +import { + assignToWorkspaces, + BasicTestWorkspace, + createTestWorkspace +} from '@/modules/workspaces/tests/helpers/creation' +import { BasicTestUser, createTestUser, createTestUsers } from '@/test/authHelper' +import { + UsersRetrievalDocument, + UsersRetrievalInput +} from '@/test/graphql/generated/graphql' +import { + ExecuteOperationOptions, + testApolloServer, + TestApolloServer +} from '@/test/graphqlHelper' +import { beforeEachContext, getMainTestRegionKeyIfMultiRegion } from '@/test/hooks' +import { waitForRegionUsers } from '@/test/speckle-helpers/regions' +import { + addAllToStream, + BasicTestStream, + createTestStream +} from '@/test/speckle-helpers/streamHelper' +import { Roles } from '@speckle/shared' +import { expect } from 'chai' + +const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() + +describe('Users @graphql', () => { + let me: BasicTestUser = { + name: 'Itsame mario!', + email: 'itsame@hehehe.com', + id: '' + } + let apollo: TestApolloServer + + before(async () => { + await beforeEachContext() + me = await createTestUser() + apollo = await testApolloServer({ authUserId: me.id }) + await waitForRegionUsers([me]) + }) + + describe('search', () => { + const RANDOMIZED_USER_COUNT = 20 + const BASIC_COLLABORATOR_PROJECT_USER_COUNT = Math.floor(RANDOMIZED_USER_COUNT / 2) + const WORKSPACE_COLLABORATOR_USER_COUNT = + RANDOMIZED_USER_COUNT - BASIC_COLLABORATOR_PROJECT_USER_COUNT + const WORKSPACE_GUEST_USER_COUNT = FF_WORKSPACES_MODULE_ENABLED + ? Math.floor(WORKSPACE_COLLABORATOR_USER_COUNT / 3) + : 0 + + const firstSpecificUser: BasicTestUser = { + name: 'first specific user', + email: 'lookatmyepicemail@email.com', + id: '' + } + const secondSpecificUser: BasicTestUser = { + name: 'second specific user', + email: 'alsolookatmyepicemail@email.com', + id: '' + } + let randomizedUsers: BasicTestUser[] + + const myWorkspace: BasicTestWorkspace = { + name: 'My Workspace #1', + ownerId: '', + id: '', + slug: '' + } + const myWorkspaceCollaboratorProject: BasicTestStream = { + name: 'My Workspace Collaborator Project #1', + ownerId: '', + id: '', + isPublic: true + } + + const myBasicCollaboratorProject: BasicTestStream = { + name: 'My Basic Collaborator Project #1', + ownerId: '', + id: '', + isPublic: true + } + + const getBasicRandomizedUsers = () => + randomizedUsers.filter((u) => u.name.includes('Basic')) + const getWorkspaceRandomizedUsers = () => + randomizedUsers.filter((u) => u.name.includes('Workspace')) + const getWorkspaceNonGuestRandomizedUsers = () => + getWorkspaceRandomizedUsers().filter((u) => !u.name.includes('Guest')) + + const search = (input: UsersRetrievalInput, options?: ExecuteOperationOptions) => + apollo.execute(UsersRetrievalDocument, { input }, options) + + before(async () => { + await Promise.all([ + createTestStream(myBasicCollaboratorProject, me), + createTestWorkspace(myWorkspace, me, { + regionKey: getMainTestRegionKeyIfMultiRegion() + }).then(() => (myWorkspaceCollaboratorProject.workspaceId = myWorkspace.id)) + ]) + await createTestStream(myWorkspaceCollaboratorProject, me) + + // Seed in users + let remainingBasicProjectCollaborators = BASIC_COLLABORATOR_PROJECT_USER_COUNT + let remainingWorkspaceGuests = WORKSPACE_GUEST_USER_COUNT + + randomizedUsers = await createTestUsers({ + count: RANDOMIZED_USER_COUNT, + mapper: ({ user, idx }) => { + const isBasicProjectCollaborator = remainingBasicProjectCollaborators-- > 0 + const isWorkspaceGuest = !isBasicProjectCollaborator + ? remainingWorkspaceGuests-- > 0 + : false + + return { + ...user, + name: `Randomized ${ + isBasicProjectCollaborator + ? 'Basic' + : 'Workspace ' + (isWorkspaceGuest ? 'Guest' : 'Member') + } User #${idx + 1}` + } + }, + serial: true + }) + + // Seed in specific users + await createTestUsers({ + users: [firstSpecificUser, secondSpecificUser], + serial: true + }) + + // Assign to projects & workspaces + const basicProjectCollaborators = [ + ...randomizedUsers.slice(0, BASIC_COLLABORATOR_PROJECT_USER_COUNT), + firstSpecificUser + ] + await addAllToStream(myBasicCollaboratorProject, basicProjectCollaborators, { + owner: me + }) + + const workspaceCollaborators = [ + ...randomizedUsers.slice(BASIC_COLLABORATOR_PROJECT_USER_COUNT).map((u) => { + const isGuest = u.name.includes('Guest') + const role = isGuest ? Roles.Workspace.Guest : Roles.Workspace.Member + + return { user: u, role } + }), + { user: secondSpecificUser, role: Roles.Workspace.Member } + ] + + // Assign to workspaces (or attach to other project) + if (FF_WORKSPACES_MODULE_ENABLED) { + await assignToWorkspaces( + workspaceCollaborators.map(({ user, role }) => [myWorkspace, user, role]) + ) + } else { + await addAllToStream( + myWorkspaceCollaboratorProject, + workspaceCollaborators.map(({ user }) => user), + { + owner: me + } + ) + } + }) + + it('works with basic query', async () => { + const res1 = await search( + { + query: 'first' + }, + { assertNoErrors: true } + ) + + expect(res1.data?.users.items || []).to.have.lengthOf(1) + expect(res1.data?.users.items[0]?.id).to.equal(firstSpecificUser.id) + + const res2 = await search( + { + query: 'second' + }, + { assertNoErrors: true } + ) + expect(res2.data?.users.items || []).to.have.lengthOf(1) + expect(res2.data?.users.items[0]?.id).to.equal(secondSpecificUser.id) + + const res3 = await search( + { + query: 'specific user' + }, + { assertNoErrors: true } + ) + expect(res3.data?.users.items || []).to.have.lengthOf(2) + expect((res3.data?.users.items || []).map((u) => u.id)).to.have.members([ + firstSpecificUser.id, + secondSpecificUser.id + ]) + }) + + it('doesnt work with less than 3 characters', async () => { + const res = await search({ + query: 'fi' + }) + expect(res).to.haveGraphQLErrors('Search query must be at least 3 characters') + }) + + it('doesnt work with more than 100 items', async () => { + const res = await search({ + query: 'user', + limit: 101 + }) + expect(res).to.haveGraphQLErrors( + 'Cannot return more than 100 items, please use pagination' + ) + }) + + it('works with projectId set', async () => { + const res = await search( + { + query: 'user', + projectId: myBasicCollaboratorProject.id, + limit: 100 + }, + { assertNoErrors: true } + ) + + expect(res.data?.users.items || []).to.have.lengthOf( + BASIC_COLLABORATOR_PROJECT_USER_COUNT + 1 // +1 for the firstSpecificUser + ) + expect((res.data?.users.items || []).map((u) => u.id)).to.have.members([ + ...getBasicRandomizedUsers().map((u) => u.id), + firstSpecificUser.id + ]) + }) + + it('works with a projectId from a workspace', async () => { + const res = await search( + { + query: 'user', + projectId: myWorkspaceCollaboratorProject.id, + limit: 100 + }, + { assertNoErrors: true } + ) + + expect(res.data?.users.items || []).to.have.lengthOf( + WORKSPACE_COLLABORATOR_USER_COUNT - WORKSPACE_GUEST_USER_COUNT + 1 // +1 for the secondSpecificUser + ) + expect((res.data?.users.items || []).map((u) => u.id)).to.have.members([ + ...getWorkspaceNonGuestRandomizedUsers().map((u) => u.id), + secondSpecificUser.id + ]) + }) + + it('works with pagination', async () => { + const totalUserCount = RANDOMIZED_USER_COUNT + 2 // +2 for the specific users + const firstLimit = Math.floor(totalUserCount / 2) + const res1 = await search( + { + query: 'user', + limit: firstLimit + }, + { assertNoErrors: true } + ) + + const firstCursor = res1.data?.users.cursor + const firstBatch = res1.data?.users.items || [] + expect(firstBatch).to.have.lengthOf(firstLimit) + expect(firstCursor).to.be.ok + + const secondLimit = totalUserCount - firstLimit + const res2 = await search( + { + query: 'user', + limit: secondLimit, + cursor: firstCursor + }, + { assertNoErrors: true } + ) + + const secondCursor = res2.data?.users.cursor + const secondBatch = res2.data?.users.items || [] + expect(secondBatch).to.have.lengthOf(secondLimit) + expect(secondCursor).to.be.ok + expect([...firstBatch, ...secondBatch].map((u) => u.id)).to.have.members([ + ...randomizedUsers.map((u) => u.id), + firstSpecificUser.id, + secondSpecificUser.id + ]) + + const res3 = await search( + { + query: 'user', + limit: 100, + cursor: secondCursor + }, + { assertNoErrors: true } + ) + + expect(res3.data?.users.items || []).to.have.lengthOf(0) + expect(res3.data?.users.cursor).to.be.not.ok + }) + }) +}) 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 8a2655b7bd..66e1179ae8 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -538,6 +538,7 @@ export type CheckoutSession = { export type CheckoutSessionInput = { billingInterval: BillingInterval; + isCreateFlow?: InputMaybe; workspaceId: Scalars['ID']['input']; workspacePlan: PaidWorkspacePlans; }; @@ -1025,7 +1026,7 @@ export type LimitedUser = { * Returns all discoverable streams that the user is a collaborator on * @deprecated Part of the old API surface and will be removed in the future. */ - streams: StreamCollection; + streams: UserStreamCollection; /** * The user's timeline in chronological order * @deprecated Part of the old API surface and will be removed in the future. @@ -2544,7 +2545,7 @@ export type Query = { * Pass in the `query` parameter to search by name, description or ID. * @deprecated Part of the old API surface and will be removed in the future. Use User.projects instead. */ - streams?: Maybe; + streams?: Maybe; /** * Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). * @deprecated To be removed in the near future! Use 'activeUser' to get info about the active user or 'otherUser' to get info about another user. @@ -2558,8 +2559,11 @@ export type Query = { /** * Search for users and return limited metadata about them, if you have the server:user role. * The query looks for matches in name & email + * @deprecated Use users() instead. */ userSearch: UserSearchResultCollection; + /** Look up server users */ + users: UserSearchResultCollection; /** Validates the slug, to make sure it contains only valid characters and its not taken. */ validateWorkspaceSlug: Scalars['Boolean']['output']; workspace: Workspace; @@ -2701,6 +2705,11 @@ export type QueryUserSearchArgs = { }; +export type QueryUsersArgs = { + input: UsersRetrievalInput; +}; + + export type QueryValidateWorkspaceSlugArgs = { slug: Scalars['String']['input']; }; @@ -3620,14 +3629,14 @@ export type User = { /** Get all invitations to projects that the active user has */ projectInvites: Array; /** Get projects that the user participates in */ - projects: ProjectCollection; + projects: UserProjectCollection; role?: Maybe; /** * Returns all streams that the user is a collaborator on. If requested for a user, who isn't the * authenticated user, then this will only return discoverable streams. * @deprecated Part of the old API surface and will be removed in the future. Use User.projects instead. */ - streams: StreamCollection; + streams: UserStreamCollection; /** * The user's timeline in chronological order * @deprecated Part of the old API surface and will be removed in the future. @@ -3813,6 +3822,14 @@ export type UserGendoAiCredits = { used: Scalars['Int']['output']; }; +export type UserProjectCollection = { + __typename?: 'UserProjectCollection'; + cursor?: Maybe; + items: Array; + numberOfHidden: Scalars['Int']['output']; + totalCount: Scalars['Int']['output']; +}; + export type UserProjectsFilter = { /** Only include projects where user has the specified roles */ onlyWithRoles?: InputMaybe>; @@ -3846,6 +3863,14 @@ export type UserSearchResultCollection = { items: Array; }; +export type UserStreamCollection = { + __typename?: 'UserStreamCollection'; + cursor?: Maybe; + items?: Maybe>; + numberOfHidden: Scalars['Int']['output']; + totalCount: Scalars['Int']['output']; +}; + export type UserUpdateInput = { avatar?: InputMaybe; bio?: InputMaybe; @@ -3857,6 +3882,18 @@ export type UserWorkspacesFilter = { search?: InputMaybe; }; +export type UsersRetrievalInput = { + cursor?: InputMaybe; + /** Only find users with directly matching emails */ + emailOnly?: InputMaybe; + /** Limit defaults to 10 */ + limit?: InputMaybe; + /** Only find users that are collaborators of the specified project */ + projectId?: InputMaybe; + /** The query looks for matches in user name & email */ + query: Scalars['String']['input']; +}; + export type Version = { __typename?: 'Version'; authorUser?: Maybe; diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index 8cca5ac1d9..d01123033a 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -42,9 +42,9 @@ export const createCheckoutSessionFactory = workspacePlan, billingInterval, workspaceSlug, - workspaceId + workspaceId, + isCreateFlow }) => { - //?settings=workspace/security& const resultUrl = getResultUrl({ frontendOrigin, workspaceId, workspaceSlug }) const price = getWorkspacePlanPrice({ billingInterval, workspacePlan }) const costLineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = [ @@ -59,13 +59,17 @@ export const createCheckoutSessionFactory = quantity: guestCount }) + const cancel_url = isCreateFlow + ? `${frontendOrigin}/workspaces/create?workspaceId=${workspaceId}&payment_status=canceled&session_id={CHECKOUT_SESSION_ID}` + : `${resultUrl.toString()}&payment_status=canceled&session_id={CHECKOUT_SESSION_ID}` + const session = await stripe.checkout.sessions.create({ mode: 'subscription', line_items: costLineItems, success_url: `${resultUrl.toString()}&payment_status=success&session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${resultUrl.toString()}&payment_status=canceled&session_id={CHECKOUT_SESSION_ID}` + cancel_url }) if (!session.url) throw new Error('Failed to create an active checkout session') diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index d3be141cd5..8efcc91e6d 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -106,6 +106,7 @@ export type CreateCheckoutSession = (args: { guestCount: number workspacePlan: PaidWorkspacePlans billingInterval: WorkspacePlanBillingIntervals + isCreateFlow: boolean }) => Promise export type WorkspaceSubscription = { diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index b198a9122e..6027fe615e 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -94,7 +94,8 @@ export = FF_GATEKEEPER_MODULE_ENABLED return true }, createCheckoutSession: async (parent, args, ctx) => { - const { workspaceId, workspacePlan, billingInterval } = args.input + const { workspaceId, workspacePlan, billingInterval, isCreateFlow } = + args.input const workspace = await getWorkspaceFactory({ db })({ workspaceId }) if (!workspace) throw new WorkspaceNotFoundError() @@ -125,7 +126,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED workspacePlan, workspaceId, workspaceSlug: workspace.slug, - + isCreateFlow: isCreateFlow || false, billingInterval }) diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index ddec622daf..a042d9c126 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -1,40 +1,17 @@ import { Router } from 'express' -import { validateRequest } from 'zod-express' -import { z } from 'zod' -import { authorizeResolver, validateScopes } from '@/modules/shared' -import { ensureError, Roles, Scopes } from '@speckle/shared' +import { ensureError } from '@speckle/shared' import { Stripe } from 'stripe' -import { - getFrontendOrigin, - getStripeEndpointSigningKey -} from '@/modules/shared/helpers/envHelper' -import { - paidWorkspacePlans, - workspacePlanBillingIntervals -} from '@/modules/gatekeeper/domain/workspacePricing' -import { - countWorkspaceRoleWithOptionalProjectRoleFactory, - getWorkspaceFactory -} from '@/modules/workspaces/repositories/workspaces' +import { getStripeEndpointSigningKey } from '@/modules/shared/helpers/envHelper' import { db } from '@/db/knex' +import { completeCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout' import { - completeCheckoutSessionFactory, - startCheckoutSessionFactory -} from '@/modules/gatekeeper/services/checkout' -import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' -import { - createCheckoutSessionFactory, - createCustomerPortalUrlFactory, getSubscriptionDataFactory, parseSubscriptionData } from '@/modules/gatekeeper/clients/stripe' import { deleteCheckoutSessionFactory, getCheckoutSessionFactory, - getWorkspaceCheckoutSessionFactory, getWorkspacePlanFactory, - getWorkspaceSubscriptionFactory, - saveCheckoutSessionFactory, upsertWorkspaceSubscriptionFactory, updateCheckoutSessionStatusFactory, upsertPaidWorkspacePlanFactory, @@ -42,92 +19,12 @@ import { } from '@/modules/gatekeeper/repositories/billing' import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing' import { withTransaction } from '@/modules/shared/helpers/dbHelper' -import { getStripeClient, getWorkspacePlanPrice } from '@/modules/gatekeeper/stripe' +import { getStripeClient } from '@/modules/gatekeeper/stripe' import { handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions' export const getBillingRouter = (): Router => { const router = Router() - // this prob needs to be turned into a GQL resolver for better frontend integration for errors - router.get( - '/api/v1/billing/workspaces/:workspaceId/checkout-session/:workspacePlan/:billingInterval', - validateRequest({ - params: z.object({ - workspaceId: z.string().min(1), - workspacePlan: paidWorkspacePlans, - billingInterval: workspacePlanBillingIntervals - }) - }), - async (req) => { - const { workspaceId, workspacePlan, billingInterval } = req.params - const workspace = await getWorkspaceFactory({ db })({ workspaceId }) - - if (!workspace) throw new WorkspaceNotFoundError() - - await validateScopes(req.context.scopes, Scopes.Gatekeeper.WorkspaceBilling) - await authorizeResolver( - req.context.userId, - workspaceId, - Roles.Workspace.Admin, - req.context.resourceAccessRules - ) - - const createCheckoutSession = createCheckoutSessionFactory({ - stripe: getStripeClient(), - frontendOrigin: getFrontendOrigin(), - getWorkspacePlanPrice - }) - - const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db }) - - const session = await startCheckoutSessionFactory({ - getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }), - getWorkspacePlan: getWorkspacePlanFactory({ db }), - countRole, - createCheckoutSession, - saveCheckoutSession: saveCheckoutSessionFactory({ db }), - deleteCheckoutSession: deleteCheckoutSessionFactory({ db }) - })({ workspacePlan, workspaceId, workspaceSlug: workspace.slug, billingInterval }) - - req.res?.redirect(session.url) - } - ) - - router.get( - '/api/v1/billing/workspaces/:workspaceId/customer-portal', - validateRequest({ - params: z.object({ - workspaceId: z.string().min(1) - }) - }), - async (req) => { - const { workspaceId } = req.params - await authorizeResolver( - req.context.userId, - workspaceId, - Roles.Workspace.Admin, - req.context.resourceAccessRules - ) - const workspaceSubscription = await getWorkspaceSubscriptionFactory({ db })({ - workspaceId - }) - if (!workspaceSubscription) return null - const workspace = await getWorkspaceFactory({ db })({ workspaceId }) - if (!workspace) - throw new Error('This cannot be, if there is a sub, there is a workspace') - const stripe = getStripeClient() - const url = await createCustomerPortalUrlFactory({ - stripe, - frontendOrigin: getFrontendOrigin() - })({ - workspaceId: workspaceSubscription.workspaceId, - workspaceSlug: workspace.slug, - customerId: workspaceSubscription.subscriptionData.customerId - }) - return req.res?.redirect(url) - } - ) - router.post('/api/v1/billing/webhooks', async (req, res) => { const endpointSecret = getStripeEndpointSigningKey() const sig = req.headers['stripe-signature'] diff --git a/packages/server/modules/gatekeeper/services/checkout.ts b/packages/server/modules/gatekeeper/services/checkout.ts index 92b497e67c..a5bd6f3779 100644 --- a/packages/server/modules/gatekeeper/services/checkout.ts +++ b/packages/server/modules/gatekeeper/services/checkout.ts @@ -43,12 +43,14 @@ export const startCheckoutSessionFactory = workspaceId, workspaceSlug, workspacePlan, - billingInterval + billingInterval, + isCreateFlow }: { workspaceId: string workspaceSlug: string workspacePlan: PaidWorkspacePlans billingInterval: WorkspacePlanBillingIntervals + isCreateFlow: boolean }): Promise => { // get workspace plan, if we're already on a paid plan, do not allow checkout // paid plans should use a subscription modification @@ -95,7 +97,8 @@ export const startCheckoutSessionFactory = throw new WorkspaceAlreadyPaidError() if ( new Date().getTime() - workspaceCheckoutSession.createdAt.getTime() > - 10 * 60 * 1000 + 1000 + // 10 * 60 * 1000 ) { await deleteCheckoutSession({ checkoutSessionId: workspaceCheckoutSession.id @@ -118,7 +121,8 @@ export const startCheckoutSessionFactory = billingInterval, workspacePlan, guestCount, - seatCount: adminCount + memberCount + seatCount: adminCount + memberCount, + isCreateFlow }) await saveCheckoutSession({ checkoutSession }) diff --git a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts index e3ec323a08..86de6ece31 100644 --- a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts @@ -53,7 +53,8 @@ describe('checkout @gatekeeper', () => { workspaceId, billingInterval: 'monthly', workspacePlan: 'business', - workspaceSlug: cryptoRandomString({ length: 10 }) + workspaceSlug: cryptoRandomString({ length: 10 }), + isCreateFlow: false }) ) expect(err.message).to.be.equal(new WorkspaceAlreadyPaidError().message) @@ -87,7 +88,8 @@ describe('checkout @gatekeeper', () => { workspaceId, billingInterval: 'monthly', workspacePlan: 'business', - workspaceSlug: cryptoRandomString({ length: 10 }) + workspaceSlug: cryptoRandomString({ length: 10 }), + isCreateFlow: false }) ) expect(err.message).to.be.equal(new WorkspaceAlreadyPaidError().message) @@ -129,7 +131,8 @@ describe('checkout @gatekeeper', () => { workspaceId, billingInterval: 'monthly', workspacePlan: 'business', - workspaceSlug: cryptoRandomString({ length: 10 }) + workspaceSlug: cryptoRandomString({ length: 10 }), + isCreateFlow: false }) ) expect(err.message).to.be.equal( @@ -174,7 +177,8 @@ describe('checkout @gatekeeper', () => { workspaceId, billingInterval: 'monthly', workspacePlan: 'business', - workspaceSlug: cryptoRandomString({ length: 10 }) + workspaceSlug: cryptoRandomString({ length: 10 }), + isCreateFlow: false }) ) expect(err.message).to.be.equal( @@ -211,7 +215,8 @@ describe('checkout @gatekeeper', () => { workspaceId, billingInterval, workspacePlan, - workspaceSlug: cryptoRandomString({ length: 10 }) + workspaceSlug: cryptoRandomString({ length: 10 }), + isCreateFlow: false }) expect(checkoutSession).deep.equal(storedCheckoutSession) expect(checkoutSession).deep.equal(createdCheckoutSession) @@ -246,7 +251,8 @@ describe('checkout @gatekeeper', () => { workspaceId, billingInterval, workspacePlan, - workspaceSlug: cryptoRandomString({ length: 10 }) + workspaceSlug: cryptoRandomString({ length: 10 }), + isCreateFlow: false }) expect(checkoutSession).deep.equal(storedCheckoutSession) expect(checkoutSession).deep.equal(createdCheckoutSession) @@ -287,7 +293,8 @@ describe('checkout @gatekeeper', () => { workspaceId, billingInterval, workspacePlan, - workspaceSlug: cryptoRandomString({ length: 10 }) + workspaceSlug: cryptoRandomString({ length: 10 }), + isCreateFlow: false }) expect(checkoutSession).deep.equal(storedCheckoutSession) expect(checkoutSession).deep.equal(createdCheckoutSession) @@ -338,7 +345,8 @@ describe('checkout @gatekeeper', () => { workspaceId, billingInterval, workspacePlan, - workspaceSlug: cryptoRandomString({ length: 10 }) + workspaceSlug: cryptoRandomString({ length: 10 }), + isCreateFlow: false }) expect(existingCheckoutSession).to.be.undefined expect(checkoutSession).deep.equal(storedCheckoutSession) @@ -380,7 +388,8 @@ describe('checkout @gatekeeper', () => { workspaceId, billingInterval, workspacePlan, - workspaceSlug: cryptoRandomString({ length: 10 }) + workspaceSlug: cryptoRandomString({ length: 10 }), + isCreateFlow: false }) }) expect(err.message).to.equal(new WorkspaceAlreadyPaidError().message) @@ -421,7 +430,8 @@ describe('checkout @gatekeeper', () => { workspaceId, billingInterval, workspacePlan, - workspaceSlug: cryptoRandomString({ length: 10 }) + workspaceSlug: cryptoRandomString({ length: 10 }), + isCreateFlow: false }) }) expect(err.message).to.equal( @@ -474,7 +484,8 @@ describe('checkout @gatekeeper', () => { workspaceId, billingInterval, workspacePlan, - workspaceSlug: cryptoRandomString({ length: 10 }) + workspaceSlug: cryptoRandomString({ length: 10 }), + isCreateFlow: false }) expect(existingCheckoutSession).to.be.undefined expect(checkoutSession).deep.equal(storedCheckoutSession) diff --git a/packages/server/modules/multiregion/dbSelector.ts b/packages/server/modules/multiregion/dbSelector.ts index e86292eb31..89a3a8407f 100644 --- a/packages/server/modules/multiregion/dbSelector.ts +++ b/packages/server/modules/multiregion/dbSelector.ts @@ -15,15 +15,15 @@ import { import { getGenericRedis } from '@/modules/shared/redis/redis' import { Knex } from 'knex' import { getRegionFactory, getRegionsFactory } from '@/modules/multiregion/repositories' -import { MisconfiguredEnvironmentError } from '@/modules/shared/errors' +import { DatabaseError, MisconfiguredEnvironmentError } from '@/modules/shared/errors' import { configureClient } from '@/knexfile' import { InitializeRegion } from '@/modules/multiregion/domain/operations' import { getAvailableRegionConfig, getMainRegionConfig } from '@/modules/multiregion/regionConfig' -import { MaybeNullOrUndefined } from '@speckle/shared' -import { isTestEnv } from '@/modules/shared/helpers/envHelper' +import { ensureError, MaybeNullOrUndefined } from '@speckle/shared' +import { isDevOrTestEnv, isTestEnv } from '@/modules/shared/helpers/envHelper' import { migrateDbToLatest } from '@/db/migrations' let getter: GetProjectDb | undefined = undefined @@ -119,9 +119,12 @@ export const initializeRegisteredRegionClients = async (): Promise initializeRegion({ regionKey })) - ) + // (disabled in prod cause there's too many DBs and connections and the load is too hard to handle) + if (isDevOrTestEnv()) { + await Promise.all( + Object.keys(ret).map((regionKey) => initializeRegion({ regionKey })) + ) + } registeredRegionClients = ret return ret @@ -212,7 +215,14 @@ const setUpUserReplication = async ({ try { await from.public.raw(`CREATE PUBLICATION ${pubName} FOR TABLE users;`) } catch (err) { - if (!(err instanceof Error)) throw err + if (!(err instanceof Error)) + throw new DatabaseError( + 'Could not create publication {pubName} when setting up user replication for region {regionName}', + { + cause: ensureError(err, 'Unknown database error when creating publication'), + info: { pubName, regionName } + } + ) if (!err.message.includes('already exists')) throw err } @@ -240,7 +250,14 @@ const setUpUserReplication = async ({ subName ]) } catch (err) { - if (!(err instanceof Error)) throw err + if (!(err instanceof Error)) + throw new DatabaseError( + 'Could not create subscription {subName} to {pubName} when setting up user replication for region {regionName}', + { + cause: ensureError(err, 'Unknown database error when creating subscription'), + info: { subName, pubName, regionName } + } + ) if (!err.message.includes('already exists')) throw err } } @@ -257,7 +274,14 @@ const setUpProjectReplication = async ({ try { await from.public.raw(`CREATE PUBLICATION ${pubName} FOR TABLE streams;`) } catch (err) { - if (!(err instanceof Error)) throw err + if (!(err instanceof Error)) + throw new DatabaseError( + 'Could not create publication {pubName} when setting up project replication for region {regionName}', + { + cause: ensureError(err, 'Unknown database error when creating publication'), + info: { pubName, regionName } + } + ) if (!err.message.includes('already exists')) throw err } @@ -285,7 +309,14 @@ const setUpProjectReplication = async ({ subName ]) } catch (err) { - if (!(err instanceof Error)) throw err + if (!(err instanceof Error)) + throw new DatabaseError( + 'Could not create subscription {subName} to {pubName} when setting up project replication for region {regionName}', + { + cause: ensureError(err, 'Unknown database error when creating subscription'), + info: { subName, pubName, regionName } + } + ) if (!err.message.includes('already exists')) throw err } } diff --git a/packages/server/modules/notifications/services/handlers/mentionedInComment.ts b/packages/server/modules/notifications/services/handlers/mentionedInComment.ts index eee354344a..8aa7ce7e00 100644 --- a/packages/server/modules/notifications/services/handlers/mentionedInComment.ts +++ b/packages/server/modules/notifications/services/handlers/mentionedInComment.ts @@ -23,7 +23,7 @@ import { NotificationHandler, MentionedInCommentMessage } from '@/modules/notifications/helpers/types' -import { getBaseUrl } from '@/modules/shared/helpers/envHelper' +import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper' import { MaybeFalsy, Nullable } from '@/modules/shared/helpers/typeHelper' import { Knex } from 'knex' @@ -151,7 +151,7 @@ function buildEmailTemplateParams( objectId, commitId }) - const url = new URL(commentRoute, getBaseUrl()).toString() + const url = new URL(commentRoute, getFrontendOrigin()).toString() return { mjml: buildEmailTemplateMjml(state), diff --git a/packages/server/modules/previews/services/management.ts b/packages/server/modules/previews/services/management.ts index 626ef3ceee..516789015c 100644 --- a/packages/server/modules/previews/services/management.ts +++ b/packages/server/modules/previews/services/management.ts @@ -26,6 +26,7 @@ export const getObjectPreviewBufferOrFilepathFactory = }): GetObjectPreviewBufferOrFilepath => async ({ streamId, objectId, angle }) => { angle = angle || defaultAngle + const boundLogger = logger.child({ streamId, objectId, angle }) if (process.env.DISABLE_PREVIEWS) { return { @@ -57,8 +58,8 @@ export const getObjectPreviewBufferOrFilepathFactory = const previewImgId = previewInfo.preview[angle] if (!previewImgId) { - logger.warn( - `Preview angle '${angle}' not found for object ${streamId}:${objectId}` + boundLogger.warn( + "Preview angle '{angle}' not found for object {streamId}:{objectId}" ) return { type: 'file', @@ -69,7 +70,10 @@ export const getObjectPreviewBufferOrFilepathFactory = } const previewImg = await deps.getPreviewImage({ previewId: previewImgId }) if (!previewImg) { - logger.warn(`Preview image not found: ${previewImgId}`) + boundLogger.warn( + { previewImageId: previewImgId }, + 'Preview image not found: {previewImageId}' + ) return { type: 'file', file: previewErrorImage, diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index 5db03dcad7..c279a695af 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -167,15 +167,6 @@ export function getMailchimpNewsletterIds() { return { listId: process.env.MAILCHIMP_NEWSLETTER_LIST_ID } } -/** - * Get app base url / canonical url / origin - * TODO: Go over all getBaseUrl() usages and move them to getXOrigin() instead - * @deprecated Since the new FE both apps (Server & FE) have different base urls, so use `getFrontendOrigin()` or `getServerOrigin()` instead - */ -export function getBaseUrl() { - return getServerOrigin() -} - /** * Whether notification job consumption & handling should be disabled */ @@ -241,7 +232,7 @@ export function getPort() { * Check whether we're running an SSL server */ export function isSSLServer() { - return /^https:\/\//.test(getBaseUrl()) + return /^https:\/\//.test(getServerOrigin()) } function parseUrlVar(value: string, name: string) { @@ -342,8 +333,8 @@ export function delayGraphqlResponsesBy() { return getIntFromEnv('DELAY_GQL_RESPONSES_BY', '0') } -export function getAutomateEncryptionKeysPath() { - return getStringFromEnv('AUTOMATE_ENCRYPTION_KEYS_PATH') +export function getEncryptionKeysPath() { + return getStringFromEnv('ENCRYPTION_KEYS_PATH') } export function getGendoAIKey() { diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index e9abe5fa05..6055834117 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -517,6 +517,10 @@ export = FF_WORKSPACES_MODULE_ENABLED getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }) }), getWorkspace: getWorkspaceWithDomainsFactory({ db }), + getWorkspaceSsoProviderRecord: getWorkspaceSsoProviderFactory({ + db, + decrypt: getDecryptor() + }), upsertWorkspace: upsertWorkspaceFactory({ db }), emitWorkspaceEvent: getEventBus().emit }) @@ -589,7 +593,6 @@ export = FF_WORKSPACES_MODULE_ENABLED getWorkspace: getWorkspaceFactory({ db }), findEmailsByUserId: findEmailsByUserIdFactory({ db }), storeWorkspaceDomain: storeWorkspaceDomainFactory({ db }), - upsertWorkspace: upsertWorkspaceFactory({ db }), getDomains: getWorkspaceDomainsFactory({ db }), emitWorkspaceEvent: getEventBus().emit })({ @@ -620,6 +623,10 @@ export = FF_WORKSPACES_MODULE_ENABLED getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }) }), getWorkspace: getWorkspaceWithDomainsFactory({ db }), + getWorkspaceSsoProviderRecord: getWorkspaceSsoProviderFactory({ + db, + decrypt: getDecryptor() + }), upsertWorkspace: upsertWorkspaceFactory({ db }), emitWorkspaceEvent: getEventBus().emit }) diff --git a/packages/server/modules/workspaces/services/management.ts b/packages/server/modules/workspaces/services/management.ts index 98c2c3f9ba..6150c70343 100644 --- a/packages/server/modules/workspaces/services/management.ts +++ b/packages/server/modules/workspaces/services/management.ts @@ -64,7 +64,10 @@ import { userEmailsCompliantWithWorkspaceDomains } from '@/modules/workspaces/do import { workspaceRoles as workspaceRoleDefinitions } from '@/modules/workspaces/roles' import { blockedDomains } from '@speckle/shared' import { DeleteStreamRecord } from '@/modules/core/domain/streams/operations' -import { DeleteSsoProvider } from '@/modules/workspaces/domain/sso/operations' +import { + DeleteSsoProvider, + GetWorkspaceSsoProviderRecord +} from '@/modules/workspaces/domain/sso/operations' type WorkspaceCreateArgs = { userId: string @@ -230,11 +233,13 @@ const sanitizeInput = (input: Partial) => { export const updateWorkspaceFactory = ({ getWorkspace, + getWorkspaceSsoProviderRecord, validateSlug, upsertWorkspace, emitWorkspaceEvent }: { getWorkspace: GetWorkspaceWithDomains + getWorkspaceSsoProviderRecord: GetWorkspaceSsoProviderRecord validateSlug: ValidateWorkspaceSlug upsertWorkspace: UpsertWorkspace emitWorkspaceEvent: EventBus['emit'] @@ -253,7 +258,14 @@ export const updateWorkspaceFactory = throw new WorkspaceInvalidUpdateError() } - if (workspaceInput.slug) await validateSlug({ slug: workspaceInput.slug }) + if (workspaceInput.slug) { + const ssoProvider = await getWorkspaceSsoProviderRecord({ workspaceId }) + if (ssoProvider) + throw new WorkspaceInvalidUpdateError( + 'Cannot update workspace slug if SSO is configured.' + ) + await validateSlug({ slug: workspaceInput.slug }) + } const workspace = { ...omit(currentWorkspace, 'domains'), @@ -467,14 +479,12 @@ export const addDomainToWorkspaceFactory = findEmailsByUserId, storeWorkspaceDomain, getWorkspace, - upsertWorkspace, emitWorkspaceEvent, getDomains }: { findEmailsByUserId: FindEmailsByUserId storeWorkspaceDomain: StoreWorkspaceDomain getWorkspace: GetWorkspace - upsertWorkspace: UpsertWorkspace getDomains: GetWorkspaceDomains emitWorkspaceEvent: EventBus['emit'] }) => @@ -533,12 +543,6 @@ export const addDomainToWorkspaceFactory = await storeWorkspaceDomain({ workspaceDomain }) - if (domains.length === 0) { - await upsertWorkspace({ - workspace: { ...workspace, discoverabilityEnabled: true } - }) - } - await emitWorkspaceEvent({ eventName: WorkspaceEvents.Updated, payload: { workspace } diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index 6b0d31356a..368011dd5a 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -150,7 +150,6 @@ export const createTestWorkspace = async ( findEmailsByUserId: findEmailsByUserIdFactory({ db }), storeWorkspaceDomain: storeWorkspaceDomainFactory({ db }), getWorkspace: getWorkspaceFactory({ db }), - upsertWorkspace: upsertWorkspaceFactory({ db }), emitWorkspaceEvent: getEventBus().emit, getDomains: getWorkspaceDomainsFactory({ db }) })({ @@ -196,6 +195,7 @@ export const createTestWorkspace = async ( getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }) }), getWorkspace: getWorkspaceWithDomainsFactory({ db }), + getWorkspaceSsoProviderRecord: getWorkspaceSsoProviderRecordFactory({ db }), upsertWorkspace: upsertWorkspaceFactory({ db }), emitWorkspaceEvent: (...args) => getEventBus().emit(...args) }) @@ -234,6 +234,10 @@ export const assignToWorkspace = async ( user: BasicTestUser, role?: WorkspaceRoles ) => { + if (!FF_WORKSPACES_MODULE_ENABLED) { + return // Just skip + } + const updateWorkspaceRole = updateWorkspaceRoleFactory({ getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }), findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }), @@ -253,6 +257,10 @@ export const unassignFromWorkspace = async ( workspace: BasicTestWorkspace, user: BasicTestUser ) => { + if (!FF_WORKSPACES_MODULE_ENABLED) { + return // Just skip + } + const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ getWorkspaceRoles: getWorkspaceRolesFactory({ db }), deleteWorkspaceRole: dbDeleteWorkspaceRoleFactory({ db }), @@ -274,7 +282,11 @@ export const unassignFromWorkspaces = async ( export const assignToWorkspaces = async ( pairs: [BasicTestWorkspace, BasicTestUser, MaybeNullOrUndefined][] ) => { - await Promise.all(pairs.map((p) => assignToWorkspace(p[0], p[1], p[2] || undefined))) + // Serial execution is somehow faster with bigger batch sizes, assignToWorkspace + // may be quite heavy on the DB + for (const [workspace, user, role] of pairs) { + await assignToWorkspace(workspace, user, role || undefined) + } } export const createTestWorkspaces = async ( diff --git a/packages/server/modules/workspaces/tests/integration/subs.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/subs.graph.spec.ts index af501e7afc..f3beae25ed 100644 --- a/packages/server/modules/workspaces/tests/integration/subs.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/subs.graph.spec.ts @@ -2,6 +2,7 @@ import { db } from '@/db/knex' import { ServerInvites } from '@/modules/core/dbSchema' import { getEventBus } from '@/modules/shared/services/eventBus' import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions' +import { getWorkspaceSsoProviderRecordFactory } from '@/modules/workspaces/repositories/sso' import { getWorkspaceBySlugFactory, getWorkspaceWithDomainsFactory, @@ -40,7 +41,7 @@ import { captureCreatedInvite } from '@/test/speckle-helpers/inviteHelper' import { getMainTestRegionKey, isMultiRegionTestMode, - waitForRegionUser + waitForRegionUsers } from '@/test/speckle-helpers/regions' import { faker } from '@faker-js/faker' import { expect } from 'chai' @@ -59,6 +60,7 @@ const updateWorkspace = updateWorkspaceFactory({ getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }) }), getWorkspace: getWorkspaceWithDomainsFactory({ db }), + getWorkspaceSsoProviderRecord: getWorkspaceSsoProviderRecordFactory({ db }), upsertWorkspace: upsertWorkspaceFactory({ db }), emitWorkspaceEvent: getEventBus().emit }) @@ -100,13 +102,7 @@ describe('Workspace GQL Subscriptions', () => { } before(async () => { - if (isMultiRegion) { - await Promise.all([ - waitForRegionUser({ userId: me.id }), - waitForRegionUser({ userId: otherGuy.id }) - ]) - } - + await waitForRegionUsers([me, otherGuy]) await createTestWorkspace(myMainWorkspace, me, { regionKey: isMultiRegion ? getMainTestRegionKey() : undefined }) 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 767f3f211b..8cb9e81206 100644 --- a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts @@ -40,7 +40,6 @@ import { UpsertWorkspaceArgs } from '@/modules/workspaces/domain/operations' import { FindVerifiedEmailsByUserId } from '@/modules/core/domain/userEmails/operations' -import { EventNames } from '@/modules/shared/services/eventBus' type WorkspaceTestContext = { storedWorkspaces: UpsertWorkspaceArgs['workspace'][] @@ -271,6 +270,7 @@ describe('Workspace services', () => { const err = await expectToThrow(async () => { await updateWorkspaceFactory({ getWorkspace: async () => null, + getWorkspaceSsoProviderRecord: async () => null, validateSlug: async () => { expect.fail() }, @@ -292,6 +292,7 @@ describe('Workspace services', () => { const err = await expectToThrow(async () => { await updateWorkspaceFactory({ getWorkspace: async () => workspace, + getWorkspaceSsoProviderRecord: async () => null, emitWorkspaceEvent: async () => { expect.fail() }, @@ -315,6 +316,7 @@ describe('Workspace services', () => { const err = await expectToThrow(async () => { await updateWorkspaceFactory({ getWorkspace: async () => workspace, + getWorkspaceSsoProviderRecord: async () => null, emitWorkspaceEvent: async () => { expect.fail() }, @@ -338,6 +340,7 @@ describe('Workspace services', () => { const err = await expectToThrow(async () => { await updateWorkspaceFactory({ getWorkspace: async () => workspace, + getWorkspaceSsoProviderRecord: async () => null, emitWorkspaceEvent: async () => { expect.fail() }, @@ -361,6 +364,7 @@ describe('Workspace services', () => { const err = await expectToThrow(async () => { await updateWorkspaceFactory({ getWorkspace: async () => workspace, + getWorkspaceSsoProviderRecord: async () => null, emitWorkspaceEvent: async () => { expect.fail() }, @@ -383,6 +387,7 @@ describe('Workspace services', () => { const err = await expectToThrow(async () => { await updateWorkspaceFactory({ getWorkspace: async () => workspace, + getWorkspaceSsoProviderRecord: async () => null, emitWorkspaceEvent: async () => { expect.fail() }, @@ -406,6 +411,7 @@ describe('Workspace services', () => { let newWorkspaceName await updateWorkspaceFactory({ getWorkspace: async () => workspace, + getWorkspaceSsoProviderRecord: async () => null, emitWorkspaceEvent: async () => {}, validateSlug: async () => {}, @@ -418,6 +424,36 @@ describe('Workspace services', () => { }) expect(newWorkspaceName).to.be.equal(workspace.name) }) + + it('does not allow updating the workspace slug if SSO is enabled', async () => { + const workspace = createTestWorkspaceWithDomainsData() + + const err = await expectToThrow(async () => { + await updateWorkspaceFactory({ + getWorkspace: async () => workspace, + getWorkspaceSsoProviderRecord: async () => ({ + workspaceId: 'foo', + providerId: 'bar' + }), + emitWorkspaceEvent: async () => { + expect.fail() + }, + validateSlug: async () => {}, + upsertWorkspace: async () => { + expect.fail() + } + })({ + workspaceId: workspace.id, + workspaceInput: { + slug: 'new-slug' + } + }) + }) + expect(err.message).to.be.equal( + 'Cannot update workspace slug if SSO is configured.' + ) + }) + it('updates the workspace and emits the correct event payload', async () => { const workspaceId = cryptoRandomString({ length: 10 }) const workspace = createTestWorkspaceWithDomainsData({ @@ -444,6 +480,7 @@ describe('Workspace services', () => { await updateWorkspaceFactory({ getWorkspace: async () => workspace, + getWorkspaceSsoProviderRecord: async () => null, emitWorkspaceEvent: async () => {}, validateSlug: async () => {}, upsertWorkspace: async ({ workspace }) => { @@ -919,9 +956,6 @@ describe('Workspace role services', () => { storeWorkspaceDomain: async () => { return }, - upsertWorkspace: async () => { - expect.fail() - }, emitWorkspaceEvent: async () => { expect.fail() } @@ -948,9 +982,6 @@ describe('Workspace role services', () => { storeWorkspaceDomain: async () => { return }, - upsertWorkspace: async () => { - expect.fail() - }, emitWorkspaceEvent: async () => { expect.fail() } @@ -978,9 +1009,6 @@ describe('Workspace role services', () => { storeWorkspaceDomain: async () => { return }, - upsertWorkspace: async () => { - expect.fail() - }, emitWorkspaceEvent: async () => { expect.fail() } @@ -1008,9 +1036,6 @@ describe('Workspace role services', () => { storeWorkspaceDomain: async () => { return }, - upsertWorkspace: async () => { - expect.fail() - }, emitWorkspaceEvent: async () => { expect.fail() } @@ -1038,9 +1063,6 @@ describe('Workspace role services', () => { storeWorkspaceDomain: async () => { return }, - upsertWorkspace: async () => { - expect.fail() - }, emitWorkspaceEvent: async () => { expect.fail() } @@ -1082,9 +1104,6 @@ describe('Workspace role services', () => { storeWorkspaceDomain: async () => { return }, - upsertWorkspace: async () => { - expect.fail() - }, emitWorkspaceEvent: async () => { expect.fail() } @@ -1134,9 +1153,6 @@ describe('Workspace role services', () => { getDomains: async () => { return [{ domain }] as WorkspaceDomain[] }, - upsertWorkspace: async () => { - expect.fail() - }, emitWorkspaceEvent: async () => { expect.fail() }, @@ -1147,139 +1163,6 @@ describe('Workspace role services', () => { expect(storedDomains).to.be.undefined }) - it('stores the verified workspace domain, toggles workspace discoverability for first domain, emits update event', async () => { - const userId = createRandomPassword() - const workspaceId = createRandomPassword() - const domain = 'example.org' - - const domainRequest = { - userId, - workspaceId, - domain - } - - let storedDomains: WorkspaceDomain | undefined = undefined - let storedWorkspace: UpsertWorkspaceArgs['workspace'] | undefined = undefined - let omittedEventName: EventNames | undefined = undefined - - const workspace: Workspace = { - id: workspaceId, - name: cryptoRandomString({ length: 10 }), - slug: cryptoRandomString({ length: 10 }), - logo: null, - createdAt: new Date(), - updatedAt: new Date(), - description: null, - discoverabilityEnabled: false, - domainBasedMembershipProtectionEnabled: false, - defaultProjectRole: 'stream:contributor', - defaultLogoIndex: 0 - } - - await addDomainToWorkspaceFactory({ - findEmailsByUserId: async () => - [{ email: `foo@${domain}`, verified: true }] as UserEmail[], - getWorkspace: async () => { - return { - role: Roles.Workspace.Admin, - userId, - ...workspace - } - }, - - getDomains: async () => { - return [] - }, - upsertWorkspace: async ({ workspace }) => { - storedWorkspace = workspace - }, - emitWorkspaceEvent: async ({ eventName }) => { - omittedEventName = eventName - }, - storeWorkspaceDomain: async ({ workspaceDomain }) => { - storedDomains = workspaceDomain - } - })(domainRequest) - - expect(storedDomains).to.not.be.undefined - expect(storedDomains!.createdByUserId).to.be.equal(userId) - expect(storedDomains!.domain).to.be.equal(domain) - expect(storedDomains!.workspaceId).to.be.equal(workspaceId) - expect(storedDomains!.verified).to.be.true - - expect(storedWorkspace!.discoverabilityEnabled).to.be.true - - expect(omittedEventName).to.be.equal(WorkspaceEvents.Updated) - }) - it('stores the second verified domain, does NOT toggle workspace discoverability for subsequent domains', async () => { - const userId = createRandomPassword() - const workspaceId = createRandomPassword() - const domain = 'example.org' - const domain2 = 'example2.org' - - const domainRequest = { - userId, - workspaceId, - domain - } - - const workspaceWithoutDomains = { - id: workspaceId, - name: cryptoRandomString({ length: 10 }), - slug: cryptoRandomString({ length: 10 }), - logo: null, - createdAt: new Date(), - updatedAt: new Date(), - description: null, - discoverabilityEnabled: false, - domainBasedMembershipProtectionEnabled: false, - domains: [], - defaultProjectRole: Roles.Stream.Contributor, - defaultLogoIndex: 0 - } - - let workspaceData: Workspace = { - ...workspaceWithoutDomains - } - const insertedDomains: WorkspaceDomain[] = [] - let storedDomains: WorkspaceDomain[] = [] - - const addDomainToWorkspace = addDomainToWorkspaceFactory({ - findEmailsByUserId: async () => - [ - { email: `foo@${domain}`, verified: true }, - { email: `foo@${domain2}`, verified: true } - ] as UserEmail[], - getWorkspace: async () => { - return { - role: Roles.Workspace.Admin, - userId, - ...workspaceData - } - }, - getDomains: async () => storedDomains, - upsertWorkspace: async ({ workspace }) => { - workspaceData = { ...workspaceData, ...workspace } - }, - emitWorkspaceEvent: async () => {}, - storeWorkspaceDomain: async ({ workspaceDomain }) => { - insertedDomains.push(workspaceDomain) - } - }) - await addDomainToWorkspace(domainRequest) - - expect(insertedDomains).to.have.lengthOf(1) - expect(workspaceData.discoverabilityEnabled).to.be.true - - // dirty hack, im post fact storing the domain on the test object - storedDomains = insertedDomains - - //faking user interaction disabling discoverability - workspaceData.discoverabilityEnabled = false - await addDomainToWorkspace({ ...domainRequest, domain: domain2 }) - - expect(workspaceData.discoverabilityEnabled).to.be.false - }) }) }) }) diff --git a/packages/server/modules/workspacesCore/migrations/20241202183039_workspace_start_trial.ts b/packages/server/modules/workspacesCore/migrations/20241202183039_workspace_start_trial.ts new file mode 100644 index 0000000000..84dfb96c85 --- /dev/null +++ b/packages/server/modules/workspacesCore/migrations/20241202183039_workspace_start_trial.ts @@ -0,0 +1,18 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + // there aren't that many workspaces. + const workspaceIds = await knex<{ id: string }>('workspaces').select('id') + const createdAt = new Date() + const workspacePlans = workspaceIds.map(({ id }) => ({ + workspaceId: id, + name: 'starter', + status: 'trial', + createdAt + })) + + if (workspaceIds.length) + await knex('workspace_plans').insert(workspacePlans).onConflict().ignore() +} + +export async function down(): Promise {} diff --git a/packages/server/test/authHelper.ts b/packages/server/test/authHelper.ts index 3725630678..eb5994123b 100644 --- a/packages/server/test/authHelper.ts +++ b/packages/server/test/authHelper.ts @@ -33,8 +33,8 @@ import { } from '@/modules/serverinvites/repositories/serverInvites' import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' import { faker } from '@faker-js/faker' -import { ServerScope } from '@speckle/shared' -import { kebabCase, omit } from 'lodash' +import { ServerScope, wait } from '@speckle/shared' +import { isArray, isNumber, kebabCase, omit, times } from 'lodash' const getServerInfo = getServerInfoFactory({ db }) const findEmail = findEmailFactory({ db }) @@ -123,11 +123,58 @@ export async function createTestUser(userObj?: Partial) { return baseUser } +export type CreateTestUsersParams = { + /** + * Number of users to create. Either this or `users` must be set + */ + count?: number + /** + * The users to create. Either this or `count` must be set + */ + users?: BasicTestUser[] + /** + * Optional mapper to run on each user obj before insertion + */ + mapper?: (params: { user: BasicTestUser; idx: number }) => BasicTestUser + /** + * For pagination purposes it might be imperative that users are serially created to ensure different timestamps + * and avoid flaky pagination bugs + */ + serial?: boolean +} + /** * Create multiple users for tests and update them to include their ID */ -export async function createTestUsers(userObjs: BasicTestUser[]) { - await Promise.all(userObjs.map((o) => createTestUser(o))) +export async function createTestUsers( + usersOrParams: BasicTestUser[] | CreateTestUsersParams +) { + const params: CreateTestUsersParams = isArray(usersOrParams) + ? { users: usersOrParams } + : usersOrParams + if (!params.users && !isNumber(params.count)) { + throw new Error('Either count or users must be set') + } + + let finalUsers = params.users + ? params.users + : times(params.count || 1, () => initTestUser({})) + + const mapper = params.mapper + if (mapper) { + finalUsers = finalUsers.map((user, idx) => mapper({ user, idx })) + } + + if (params.serial) { + const results: BasicTestUser[] = [] + for (const finalUser of finalUsers) { + results.push(await createTestUser(finalUser)) + await wait(1) + } + return results + } else { + return await Promise.all(finalUsers.map((o) => createTestUser(o))) + } } /** diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 087dab21d0..273d3e9a73 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -539,6 +539,7 @@ export type CheckoutSession = { export type CheckoutSessionInput = { billingInterval: BillingInterval; + isCreateFlow?: InputMaybe; workspaceId: Scalars['ID']['input']; workspacePlan: PaidWorkspacePlans; }; @@ -1026,7 +1027,7 @@ export type LimitedUser = { * Returns all discoverable streams that the user is a collaborator on * @deprecated Part of the old API surface and will be removed in the future. */ - streams: StreamCollection; + streams: UserStreamCollection; /** * The user's timeline in chronological order * @deprecated Part of the old API surface and will be removed in the future. @@ -2545,7 +2546,7 @@ export type Query = { * Pass in the `query` parameter to search by name, description or ID. * @deprecated Part of the old API surface and will be removed in the future. Use User.projects instead. */ - streams?: Maybe; + streams?: Maybe; /** * Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). * @deprecated To be removed in the near future! Use 'activeUser' to get info about the active user or 'otherUser' to get info about another user. @@ -2559,8 +2560,11 @@ export type Query = { /** * Search for users and return limited metadata about them, if you have the server:user role. * The query looks for matches in name & email + * @deprecated Use users() instead. */ userSearch: UserSearchResultCollection; + /** Look up server users */ + users: UserSearchResultCollection; /** Validates the slug, to make sure it contains only valid characters and its not taken. */ validateWorkspaceSlug: Scalars['Boolean']['output']; workspace: Workspace; @@ -2702,6 +2706,11 @@ export type QueryUserSearchArgs = { }; +export type QueryUsersArgs = { + input: UsersRetrievalInput; +}; + + export type QueryValidateWorkspaceSlugArgs = { slug: Scalars['String']['input']; }; @@ -3621,14 +3630,14 @@ export type User = { /** Get all invitations to projects that the active user has */ projectInvites: Array; /** Get projects that the user participates in */ - projects: ProjectCollection; + projects: UserProjectCollection; role?: Maybe; /** * Returns all streams that the user is a collaborator on. If requested for a user, who isn't the * authenticated user, then this will only return discoverable streams. * @deprecated Part of the old API surface and will be removed in the future. Use User.projects instead. */ - streams: StreamCollection; + streams: UserStreamCollection; /** * The user's timeline in chronological order * @deprecated Part of the old API surface and will be removed in the future. @@ -3814,6 +3823,14 @@ export type UserGendoAiCredits = { used: Scalars['Int']['output']; }; +export type UserProjectCollection = { + __typename?: 'UserProjectCollection'; + cursor?: Maybe; + items: Array; + numberOfHidden: Scalars['Int']['output']; + totalCount: Scalars['Int']['output']; +}; + export type UserProjectsFilter = { /** Only include projects where user has the specified roles */ onlyWithRoles?: InputMaybe>; @@ -3847,6 +3864,14 @@ export type UserSearchResultCollection = { items: Array; }; +export type UserStreamCollection = { + __typename?: 'UserStreamCollection'; + cursor?: Maybe; + items?: Maybe>; + numberOfHidden: Scalars['Int']['output']; + totalCount: Scalars['Int']['output']; +}; + export type UserUpdateInput = { avatar?: InputMaybe; bio?: InputMaybe; @@ -3858,6 +3883,18 @@ export type UserWorkspacesFilter = { search?: InputMaybe; }; +export type UsersRetrievalInput = { + cursor?: InputMaybe; + /** Only find users with directly matching emails */ + emailOnly?: InputMaybe; + /** Limit defaults to 10 */ + limit?: InputMaybe; + /** Only find users that are collaborators of the specified project */ + projectId?: InputMaybe; + /** The query looks for matches in user name & email */ + query: Scalars['String']['input']; +}; + export type Version = { __typename?: 'Version'; authorUser?: Maybe; @@ -4633,6 +4670,13 @@ export type OnBranchDeletedSubscriptionVariables = Exact<{ export type OnBranchDeletedSubscription = { __typename?: 'Subscription', branchDeleted?: Record | null }; +export type UsersRetrievalQueryVariables = Exact<{ + input: UsersRetrievalInput; +}>; + + +export type UsersRetrievalQuery = { __typename?: 'Query', users: { __typename?: 'UserSearchResultCollection', cursor?: string | null, items: Array<{ __typename?: 'LimitedUser', id: string, name: string }> } }; + export type BasicWorkspaceFragment = { __typename?: 'Workspace', id: string, name: string, slug: string, updatedAt: string, createdAt: string, role?: string | null }; export type BasicPendingWorkspaceCollaboratorFragment = { __typename?: 'PendingWorkspaceCollaborator', id: string, inviteId: string, workspaceId: string, workspaceName: string, title: string, role: string, token?: string | null, invitedBy: { __typename?: 'LimitedUser', id: string, name: string }, user?: { __typename?: 'LimitedUser', id: string, name: string } | null }; @@ -5194,7 +5238,7 @@ export type ReadStreamQuery = { __typename?: 'Query', stream?: { __typename?: 'S export type ReadStreamsQueryVariables = Exact<{ [key: string]: never; }>; -export type ReadStreamsQuery = { __typename?: 'Query', streams?: { __typename?: 'StreamCollection', cursor?: string | null, totalCount: number, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, isPublic: boolean, isDiscoverable: boolean, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string }> | null } | null }; +export type ReadStreamsQuery = { __typename?: 'Query', streams?: { __typename?: 'UserStreamCollection', cursor?: string | null, totalCount: number, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, isPublic: boolean, isDiscoverable: boolean, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string }> | null } | null }; export type ReadDiscoverableStreamsQueryVariables = Exact<{ limit?: Scalars['Int']['input']; @@ -5212,7 +5256,7 @@ export type GetUserStreamsQueryVariables = Exact<{ }>; -export type GetUserStreamsQuery = { __typename?: 'Query', user?: { __typename?: 'User', streams: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, isPublic: boolean, isDiscoverable: boolean, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string }> | null } } | null }; +export type GetUserStreamsQuery = { __typename?: 'Query', user?: { __typename?: 'User', streams: { __typename?: 'UserStreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, isPublic: boolean, isDiscoverable: boolean, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string }> | null } } | null }; export type GetLimitedUserStreamsQueryVariables = Exact<{ userId: Scalars['String']['input']; @@ -5221,7 +5265,7 @@ export type GetLimitedUserStreamsQueryVariables = Exact<{ }>; -export type GetLimitedUserStreamsQuery = { __typename?: 'Query', otherUser?: { __typename?: 'LimitedUser', streams: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, isPublic: boolean, isDiscoverable: boolean, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string }> | null } } | null }; +export type GetLimitedUserStreamsQuery = { __typename?: 'Query', otherUser?: { __typename?: 'LimitedUser', streams: { __typename?: 'UserStreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, isPublic: boolean, isDiscoverable: boolean, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string }> | null } } | null }; export type UserWithEmailsFragment = { __typename?: 'User', id: string, name: string, createdAt?: string | null, role?: string | null, emails: Array<{ __typename?: 'UserEmail', id: string, email: string, verified: boolean, primary: boolean }> }; @@ -5404,7 +5448,7 @@ export type ActiveUserLeaveWorkspaceMutation = { __typename?: 'Mutation', worksp export type ActiveUserProjectsWorkspaceQueryVariables = Exact<{ [key: string]: never; }>; -export type ActiveUserProjectsWorkspaceQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', projects: { __typename?: 'ProjectCollection', items: Array<{ __typename?: 'Project', id: string, workspace?: { __typename?: 'Workspace', id: string, name: string } | null }> } } | null }; +export type ActiveUserProjectsWorkspaceQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', projects: { __typename?: 'UserProjectCollection', items: Array<{ __typename?: 'Project', id: string, workspace?: { __typename?: 'Workspace', id: string, name: string } | null }> } } | null }; export type ActiveUserExpiredSsoSessionsQueryVariables = Exact<{ [key: string]: never; }>; @@ -5453,6 +5497,7 @@ export const OnProjectModelsUpdatedDocument = {"kind":"Document","definitions":[ export const OnBranchCreatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnBranchCreated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"branchCreated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}]}]}}]} as unknown as DocumentNode; export const OnBranchUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnBranchUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"branchId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"branchUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"branchId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"branchId"}}}]}]}}]} as unknown as DocumentNode; export const OnBranchDeletedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnBranchDeleted"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"branchDeleted"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}]}]}}]} as unknown as DocumentNode; +export const UsersRetrievalDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UsersRetrieval"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UsersRetrievalInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"users"},"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":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateWorkspaceInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateWorkspaceInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}}]}}]}},{"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":"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 BatchCreateWorkspaceInvitesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BatchCreateWorkspaceInvites"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceInviteCreateInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"batchCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}}]}}]}},{"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":"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 GetWorkspaceWithTeamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceWithTeam"},"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":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}},{"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":"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; diff --git a/packages/server/test/hooks.ts b/packages/server/test/hooks.ts index 1e7f07ac65..ab4597e01e 100644 --- a/packages/server/test/hooks.ts +++ b/packages/server/test/hooks.ts @@ -77,6 +77,11 @@ export const getMainTestRegionKey = () => { return key } +export const getMainTestRegionKeyIfMultiRegion = () => { + const isMultiRegionMode = isMultiRegionTestMode() + return isMultiRegionMode ? getMainTestRegionKey() : undefined +} + export const getMainTestRegionClient = () => { const key = getMainTestRegionKey() const client = regionClients[key] diff --git a/packages/server/test/speckle-helpers/regions.ts b/packages/server/test/speckle-helpers/regions.ts index 065b6232da..c293d17f33 100644 --- a/packages/server/test/speckle-helpers/regions.ts +++ b/packages/server/test/speckle-helpers/regions.ts @@ -6,12 +6,14 @@ import { isTestEnv, shouldRunTestsInMultiregionMode } from '@/modules/shared/helpers/envHelper' +import { BasicTestUser } from '@/test/authHelper' import { getRegionKeys, getMainTestRegionClient, getMainTestRegionKey } from '@/test/hooks' import { wait } from '@speckle/shared' +import { isString } from 'lodash' /** * Delete all regions entries that are not part of the main multi region mode @@ -44,17 +46,24 @@ const waitForPredicate = async (params: { /** * Wait for user to exist in region db */ -export const waitForRegionUser = async (params: { userId: string }) => { +export const waitForRegionUser = async (userOrId: string | BasicTestUser) => { + if (!isMultiRegionTestMode()) return + + const userId = isString(userOrId) ? userOrId : userOrId.id const client = getMainTestRegionClient() const getUser = getUserFactory({ db: client }) await waitForPredicate({ predicate: async () => { - const user = await getUser(params.userId) + const user = await getUser(userId) return !!user }, - errMsg: `User ${params.userId} not found in region db` + errMsg: `User ${userId} not found in region db` }) } +export const waitForRegionUsers = async (userOrIds: Array) => { + await Promise.all(userOrIds.map((userOrId) => waitForRegionUser(userOrId))) +} + export { getMainTestRegionClient, getMainTestRegionKey } diff --git a/packages/server/test/speckle-helpers/streamHelper.ts b/packages/server/test/speckle-helpers/streamHelper.ts index 2e452629bd..5c24e049f5 100644 --- a/packages/server/test/speckle-helpers/streamHelper.ts +++ b/packages/server/test/speckle-helpers/streamHelper.ts @@ -1,6 +1,10 @@ import { db } from '@/db/knex' import { saveActivityFactory } from '@/modules/activitystream/repositories' -import { addStreamPermissionsRevokedActivityFactory } from '@/modules/activitystream/services/streamActivity' +import { + addStreamInviteAcceptedActivityFactory, + addStreamPermissionsAddedActivityFactory, + addStreamPermissionsRevokedActivityFactory +} from '@/modules/activitystream/services/streamActivity' import { StreamAcl } from '@/modules/core/dbSchema' import { ProjectsEmitter } from '@/modules/core/events/projectsEmitter' import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types' @@ -8,11 +12,14 @@ import { createBranchFactory } from '@/modules/core/repositories/branches' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { createStreamFactory, + getStreamCollaboratorsFactory, getStreamFactory, + grantStreamPermissionsFactory, revokeStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users' import { + addOrUpdateStreamCollaboratorFactory, isStreamCollaboratorFactory, removeStreamCollaboratorFactory, validateStreamAccessFactory @@ -38,7 +45,7 @@ import { createWorkspaceProjectFactory } from '@/modules/workspaces/services/pro import { BasicTestUser } from '@/test/authHelper' import { ProjectVisibility } from '@/test/graphql/generated/graphql' import { faker } from '@faker-js/faker' -import { ensureError } from '@speckle/shared' +import { ensureError, Roles, StreamRoles } from '@speckle/shared' import { omit } from 'lodash' const getServerInfo = getServerInfoFactory({ db }) @@ -88,6 +95,20 @@ const removeStreamCollaborator = removeStreamCollaboratorFactory({ }) }) +const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess, + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + addStreamInviteAcceptedActivity: addStreamInviteAcceptedActivityFactory({ + saveActivity, + publish + }), + addStreamPermissionsAddedActivity: addStreamPermissionsAddedActivityFactory({ + saveActivity, + publish + }) +}) + export type BasicTestStream = { name: string isPublic: boolean @@ -155,6 +176,68 @@ export async function leaveStream(streamObj: BasicTestStream, user: BasicTestUse }) } +export async function addToStream( + streamObj: BasicTestStream, + user: BasicTestUser, + role: StreamRoles, + options?: Partial<{ + owner: BasicTestUser + }> +) { + const { owner } = options || {} + let ownerId = owner?.id + if (!ownerId) { + const getStreamCollaborators = getStreamCollaboratorsFactory({ db }) + const collaborators = await getStreamCollaborators( + streamObj.id, + Roles.Stream.Owner, + { + limit: 1 + } + ) + ownerId = collaborators[0]?.id + } + if (!ownerId) { + throw new Error('Attempted to add a collaborator to a stream without an owner') + } + + await addOrUpdateStreamCollaborator(streamObj.id, user.id, role, ownerId, null) +} + +export async function addAllToStream( + streamObj: BasicTestStream, + users: BasicTestUser[] | { user: BasicTestUser; role: StreamRoles }[], + options?: Partial<{ + owner: BasicTestUser + }> +) { + const { owner } = options || {} + let ownerId = owner?.id + if (!ownerId) { + const getStreamCollaborators = getStreamCollaboratorsFactory({ db }) + const collaborators = await getStreamCollaborators( + streamObj.id, + Roles.Stream.Owner, + { + limit: 1 + } + ) + ownerId = collaborators[0]?.id + } + if (!ownerId) { + throw new Error('Attempted to add a collaborator to a stream without an owner') + } + + const usersWithRoles = users.map((u) => + 'user' in u ? u : { user: u, role: Roles.Stream.Contributor } + ) + await Promise.all( + usersWithRoles.map(({ user, role }) => + addOrUpdateStreamCollaborator(streamObj.id, user.id, role, ownerId!, null) + ) + ) +} + /** * Get the role user has for the specified stream */ diff --git a/packages/shared/src/core/helpers/batch.ts b/packages/shared/src/core/helpers/batch.ts index 26d0d96986..9abee7203b 100644 --- a/packages/shared/src/core/helpers/batch.ts +++ b/packages/shared/src/core/helpers/batch.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { range } from '#lodash' /** @@ -28,7 +29,7 @@ export async function batchAsyncOperations( /** * Optionally override the logger with a custom one */ - logger: (...args: unknown[]) => void + logger: (...args: any[]) => void /** * If set to true, the function won't collect all of the returns of each operation in an effort to reduce diff --git a/packages/ui-components/src/helpers/common/validation.ts b/packages/ui-components/src/helpers/common/validation.ts index 24dc000783..eb433e9bcb 100644 --- a/packages/ui-components/src/helpers/common/validation.ts +++ b/packages/ui-components/src/helpers/common/validation.ts @@ -17,6 +17,14 @@ export const VALID_EMAIL = /^[\w-_.+]+@[\w-_.+]+$/ export const isEmail: GenericValidateFunction = (val) => (val || '').match(VALID_EMAIL) ? true : 'Value should be a valid e-mail address' +/** + * Used for placeholders inputs where the user can leave the field empty + */ +export const isEmailOrEmpty: GenericValidateFunction = (val) => + (val || '').match(VALID_EMAIL) || !val + ? true + : 'Value should be a valid e-mail address' + export const isOneOrMultipleEmails: GenericValidateFunction = (val) => { const emails = (val || '').split(',').map((i) => i.trim()) const valid = emails.every((e) => e.match(VALID_EMAIL)) diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index b164839e1b..532f331047 100644 --- a/utils/helm/speckle-server/templates/_helpers.tpl +++ b/utils/helm/speckle-server/templates/_helpers.tpl @@ -675,12 +675,14 @@ Generate the environment variables for Speckle server and Speckle objects deploy key: {{ .Values.server.billing.workspaceYearlyBusinessSeatStripePriceId.secretKey }} {{- end }} +{{- if (or .Values.featureFlags.automateModuleEnabled .Values.featureFlags.workspacesSsoEnabled) }} +- name: ENCRYPTION_KEYS_PATH + value: {{ .Values.server.encryptionKeys.path }} +{{- end }} + {{- if .Values.featureFlags.automateModuleEnabled }} - name: SPECKLE_AUTOMATE_URL value: {{ .Values.server.speckleAutomateUrl }} - -- name: AUTOMATE_ENCRYPTION_KEYS_PATH - value: {{ .Values.server.encryptionKeys.path }} {{- end }} - name: ONBOARDING_STREAM_URL diff --git a/yarn.lock b/yarn.lock index 86ef2104ff..6b9a839592 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12297,13 +12297,6 @@ __metadata: languageName: node linkType: hard -"@linaria/core@npm:3.0.0-beta.13": - version: 3.0.0-beta.13 - resolution: "@linaria/core@npm:3.0.0-beta.13" - checksum: 10/9b4865187c26997769258206d7caf27c2093b68adf0424d39a5d72e1121e43b57c739ad91dda1bafcd428ab6c54b65ea79f61e880a1827f7b9251751878bddd4 - languageName: node - linkType: hard - "@mailchimp/mailchimp_marketing@npm:^3.0.80": version: 3.0.80 resolution: "@mailchimp/mailchimp_marketing@npm:3.0.80" @@ -14533,44 +14526,10 @@ __metadata: languageName: node linkType: hard -"@remirror/core-constants@npm:^2.0.0": - version: 2.0.0 - resolution: "@remirror/core-constants@npm:2.0.0" - dependencies: - "@babel/runtime": "npm:^7.13.10" - checksum: 10/8c5c2e42da66d9a9a8da91c1288d0be5038c56644e860c012203d00e9839cdd8e616b482b95a05dc7b9dcc9cd066923aab5458286e6f8b5aa688c4be73b7479b - languageName: node - linkType: hard - -"@remirror/core-helpers@npm:^2.0.1": - version: 2.0.1 - resolution: "@remirror/core-helpers@npm:2.0.1" - dependencies: - "@babel/runtime": "npm:^7.13.10" - "@linaria/core": "npm:3.0.0-beta.13" - "@remirror/core-constants": "npm:^2.0.0" - "@remirror/types": "npm:^1.0.0" - "@types/object.omit": "npm:^3.0.0" - "@types/object.pick": "npm:^1.3.1" - "@types/throttle-debounce": "npm:^2.1.0" - case-anything: "npm:^2.1.10" - dash-get: "npm:^1.0.2" - deepmerge: "npm:^4.2.2" - fast-deep-equal: "npm:^3.1.3" - make-error: "npm:^1.3.6" - object.omit: "npm:^3.0.0" - object.pick: "npm:^1.3.0" - throttle-debounce: "npm:^3.0.1" - checksum: 10/4737280aaa7e756f4b03a9fa9cff43a9fe52c1cee861a1652a3b18df2c8f16bd1dcb00a8e988ef9abc65d5fdfd3193f37b1bc4d6ead8d98d11e33777d06aa7da - languageName: node - linkType: hard - -"@remirror/types@npm:^1.0.0": - version: 1.0.0 - resolution: "@remirror/types@npm:1.0.0" - dependencies: - type-fest: "npm:^2.0.0" - checksum: 10/9514b67bbc576c9e80520b7644a105d3a627856d6b2fa6e2fa5b3204a7eb8426ba14d3fe7b9ea79fe7ec642ec429422f0f22007ef0a38fb67235b3c87168a751 +"@remirror/core-constants@npm:3.0.0": + version: 3.0.0 + resolution: "@remirror/core-constants@npm:3.0.0" + checksum: 10/de15b1df099a7646739e5fb6bb55195618a8ac4fa938db7c719e867eefd72ebc5a05865591788ade449613141619cc1002fb6c0f824de4468dfefa951fbf19a2 languageName: node linkType: hard @@ -16774,22 +16733,22 @@ __metadata: "@tailwindcss/line-clamp": "npm:^0.4.2" "@tailwindcss/typography": "npm:^0.5.12" "@testing-library/vue": "npm:^6.6.1" - "@tiptap/core": "npm:2.0.0-beta.220" - "@tiptap/extension-bold": "npm:2.0.0-beta.220" - "@tiptap/extension-document": "npm:2.0.0-beta.220" - "@tiptap/extension-hard-break": "npm:2.0.0-beta.220" - "@tiptap/extension-history": "npm:2.0.0-beta.220" - "@tiptap/extension-italic": "npm:2.0.0-beta.220" - "@tiptap/extension-link": "npm:2.0.0-beta.220" - "@tiptap/extension-mention": "npm:2.0.0-beta.220" - "@tiptap/extension-paragraph": "npm:2.0.0-beta.220" - "@tiptap/extension-placeholder": "npm:2.0.0-beta.220" - "@tiptap/extension-strike": "npm:2.0.0-beta.220" - "@tiptap/extension-text": "npm:2.0.0-beta.220" - "@tiptap/extension-underline": "npm:2.0.0-beta.220" - "@tiptap/pm": "npm:2.0.0-beta.220" - "@tiptap/suggestion": "npm:2.0.0-beta.220" - "@tiptap/vue-3": "npm:2.0.0-beta.220" + "@tiptap/core": "npm:2.10.3" + "@tiptap/extension-bold": "npm:2.10.3" + "@tiptap/extension-document": "npm:2.10.3" + "@tiptap/extension-hard-break": "npm:2.10.3" + "@tiptap/extension-history": "npm:2.10.3" + "@tiptap/extension-italic": "npm:2.10.3" + "@tiptap/extension-link": "npm:2.10.3" + "@tiptap/extension-mention": "npm:2.10.3" + "@tiptap/extension-paragraph": "npm:2.10.3" + "@tiptap/extension-placeholder": "npm:2.10.3" + "@tiptap/extension-strike": "npm:2.10.3" + "@tiptap/extension-text": "npm:2.10.3" + "@tiptap/extension-underline": "npm:2.10.3" + "@tiptap/pm": "npm:2.10.3" + "@tiptap/suggestion": "npm:2.10.3" + "@tiptap/vue-3": "npm:2.10.3" "@tryghost/content-api": "npm:^1.11.21" "@types/apollo-upload-client": "npm:^18.0.0" "@types/dompurify": "npm:^3.0.2" @@ -18975,12 +18934,12 @@ __metadata: languageName: node linkType: hard -"@tiptap/core@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/core@npm:2.0.0-beta.220" +"@tiptap/core@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/core@npm:2.10.3" peerDependencies: - "@tiptap/pm": ^2.0.0-beta.209 - checksum: 10/d18557536f8ddc3e9ff8d1a11ceda98648a40d1898aa5126d5b9df09f7f153b047bd954050984aab58119231960d655aab79cdbe8509bab8f7f6825ce85c20b6 + "@tiptap/pm": ^2.7.0 + checksum: 10/0a9f81046885b26180da293d9281cd4bbfb45d1e80539aaea279082fccb93b712e852aba1d2baebba777582acc1cf9e747e3b383715d44499a0c627fb8c40236 languageName: node linkType: hard @@ -19006,12 +18965,12 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-bold@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-bold@npm:2.0.0-beta.220" +"@tiptap/extension-bold@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-bold@npm:2.10.3" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - checksum: 10/a47884dcb2261383ced64a2be342bb7d10b950bfee546f1ac3bc23de69d2320159f5e5659cb61d0802baa0c5f1322223f0c45641c85aa9bd42c20de8e534d882 + "@tiptap/core": ^2.7.0 + checksum: 10/810b717293eba8a6473fb13be100329d010d71eb338d5bb00d72811122bd4b9f16f6238d346ff52e3ffc3038717692c06d0365f4c623d05ff25ad532c5ce482d languageName: node linkType: hard @@ -19024,19 +18983,6 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-bubble-menu@npm:^2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-bubble-menu@npm:2.0.0-beta.220" - dependencies: - lodash: "npm:^4.17.21" - tippy.js: "npm:^6.3.7" - peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - "@tiptap/pm": ^2.0.0-beta.209 - checksum: 10/ccb17e5d6729c506fc62831fba540aeffe114ad3a50475d76b60bdbb6eeab1130388bc5a28263809fcb734ebea052e9e7ca3b3fad58baecc7003444fa1072e25 - languageName: node - linkType: hard - "@tiptap/extension-bubble-menu@npm:^2.0.0-beta.56": version: 2.0.0-beta.56 resolution: "@tiptap/extension-bubble-menu@npm:2.0.0-beta.56" @@ -19050,12 +18996,24 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-document@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-document@npm:2.0.0-beta.220" +"@tiptap/extension-bubble-menu@npm:^2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-bubble-menu@npm:2.10.3" + dependencies: + tippy.js: "npm:^6.3.7" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - checksum: 10/fff651a6b45586fbad7ca0aea45b6d57f86263dd1d8839dabf55617b58f204ea8fcce6e9a6d9916b0e301b32d7aea9f1a7c10b4c167618b3a0c12b2f2bb5dfb6 + "@tiptap/core": ^2.7.0 + "@tiptap/pm": ^2.7.0 + checksum: 10/c1c0d53277d7f91418a5b040744c34f75dcdae105c719637a02688cb19fc2aa515dcb2f21245762f4868de31a62d6c25b95c7d44284e6a2c9b1d21dbf7aea9a0 + languageName: node + linkType: hard + +"@tiptap/extension-document@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-document@npm:2.10.3" + peerDependencies: + "@tiptap/core": ^2.7.0 + checksum: 10/63d4a981981596ff97d825cf8e6c49c2af06b9cc87cb9817c4a7ba76894e71200c4f55bcdc1909dd3d12517e8f1473dcad04fc508865e846524bb269b66dae5c languageName: node linkType: hard @@ -19068,18 +19026,6 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-floating-menu@npm:^2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-floating-menu@npm:2.0.0-beta.220" - dependencies: - tippy.js: "npm:^6.3.7" - peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - "@tiptap/pm": ^2.0.0-beta.209 - checksum: 10/d9d280a232e552fe820b5f5cbf6011e1c275c37dea492e2bb92100d09a4a4c904b8eab68a5981b5d7435ec3be09094d22df9b0dcad6047486072042903922e34 - languageName: node - linkType: hard - "@tiptap/extension-floating-menu@npm:^2.0.0-beta.51": version: 2.0.0-beta.51 resolution: "@tiptap/extension-floating-menu@npm:2.0.0-beta.51" @@ -19093,12 +19039,24 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-hard-break@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-hard-break@npm:2.0.0-beta.220" +"@tiptap/extension-floating-menu@npm:^2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-floating-menu@npm:2.10.3" + dependencies: + tippy.js: "npm:^6.3.7" + peerDependencies: + "@tiptap/core": ^2.7.0 + "@tiptap/pm": ^2.7.0 + checksum: 10/120c4377e5a56a4389830f8bed0ded15eff3ba04e904e5a097c5cea6e11d96c54d9d9f7132c37e79f0ef037d41014af38636ade4eb25a560ffeb87d11f628eb4 + languageName: node + linkType: hard + +"@tiptap/extension-hard-break@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-hard-break@npm:2.10.3" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - checksum: 10/b80a7d25fbec3a8948a622796d3ed5a4f459dd50527a27406153aa40b64190bffd53f65aa87d9382197aabcecf077e890e8cf03e7e10788cdf5f83cdb73719dd + "@tiptap/core": ^2.7.0 + checksum: 10/f542cc89f27d063bae4ce13c0839d5ac72519434befc325367af4421191f77a33f7e599a6de5d5c18884ac6044d0155ed2edbf4c5378f26f188580257c3639a9 languageName: node linkType: hard @@ -19111,13 +19069,13 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-history@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-history@npm:2.0.0-beta.220" +"@tiptap/extension-history@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-history@npm:2.10.3" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - "@tiptap/pm": ^2.0.0-beta.209 - checksum: 10/f9628f84d7c08c71170c19b559f2d690a1bf056bb620c6aebd7c2d44e70ba2e19f2b21776379606db9bd4fbbc6ce7c60661175e36de8d1dcf3b213b5d986ac5d + "@tiptap/core": ^2.7.0 + "@tiptap/pm": ^2.7.0 + checksum: 10/11b0be291144d59ccf3a8b3e86f443c5d16d0b5167294b8192fed1397e54ce1890695eff3129142d65b9f1462f6f4a4cde459b3dc2a70ca4291e71b2d8a136fa languageName: node linkType: hard @@ -19133,12 +19091,12 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-italic@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-italic@npm:2.0.0-beta.220" +"@tiptap/extension-italic@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-italic@npm:2.10.3" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - checksum: 10/21d72e7ec645367a058fbf032c4ab333831280cda97ff5da6f070bca6b0998c5a39b5b18392c9cef472f9fb0c1354440699bba7acd324a53ec46598914fe6f89 + "@tiptap/core": ^2.7.0 + checksum: 10/b0095308dcc9fbe29862be7ac21fb8f8a4edd5b403117368976af16704f9e452a2cc04eb22d3bb91298c5bc2a669b53a4d1e26fee333c5a20828017f2e2ac1ea languageName: node linkType: hard @@ -19151,15 +19109,15 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-link@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-link@npm:2.0.0-beta.220" +"@tiptap/extension-link@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-link@npm:2.10.3" dependencies: linkifyjs: "npm:^4.1.0" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - "@tiptap/pm": ^2.0.0-beta.209 - checksum: 10/344094d5d586eb41b1eb3e001a1ff72328dd32982865634ab4e05a246ba92ec15fe2f92f92c8d99be08e1feb07516dbc2636cea5e68714d56eed3c20989b0930 + "@tiptap/core": ^2.7.0 + "@tiptap/pm": ^2.7.0 + checksum: 10/7ac1359c1241901af5868f339761907ed6bb1fb46e36d2bd8fb79227271e19e3639538971260f8927825db9a1eea038e0ad41511279a9034bf8f56c9f1918487 languageName: node linkType: hard @@ -19176,14 +19134,14 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-mention@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-mention@npm:2.0.0-beta.220" +"@tiptap/extension-mention@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-mention@npm:2.10.3" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - "@tiptap/pm": ^2.0.0-beta.209 - "@tiptap/suggestion": ^2.0.0-beta.209 - checksum: 10/f618ddffc9c2e3fc7d4a98f4e4cd5ef103c47fbcf63e06f3c69a07ab943a6f59d91f32adc610601d8c4a8a5dff4f2ee9ac08d8b2eab48d3d08ce6de23fdbba65 + "@tiptap/core": ^2.7.0 + "@tiptap/pm": ^2.7.0 + "@tiptap/suggestion": ^2.7.0 + checksum: 10/77816b90158b7ddaa954d161e753131f0e2b9e1683817161876910a9df31f7cdb427c8b73962d8dd1793a23328f8f2869f6c3f927df88ceb6fe94d4612f1c9f7 languageName: node linkType: hard @@ -19200,12 +19158,12 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-paragraph@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-paragraph@npm:2.0.0-beta.220" +"@tiptap/extension-paragraph@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-paragraph@npm:2.10.3" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - checksum: 10/e0eb62b4f47877444e7bc3781d1a8187f83cf0a4d00ae2145f922a88097defc57663594d89e78995cb22d4d5087bfe83edad13a9181c2b2df392ffb2eaf1c6cb + "@tiptap/core": ^2.7.0 + checksum: 10/907fd6287d1f281368e55fea904ee3e5f363d33292ec037ef7649a2ec07456c32bb02d555b8b5412ac09d0e237cf99f919e8572635ee2aa5d35d9cfe7d717162 languageName: node linkType: hard @@ -19218,13 +19176,13 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-placeholder@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-placeholder@npm:2.0.0-beta.220" +"@tiptap/extension-placeholder@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-placeholder@npm:2.10.3" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - "@tiptap/pm": ^2.0.0-beta.209 - checksum: 10/d3674dad1f4f45678f27bb7c4b5a0fb48812ca9fadf009a77ac00a5df69953ba947e8de15f085530db531453d225c586414ce529742fc5ecb325b383efdaf541 + "@tiptap/core": ^2.7.0 + "@tiptap/pm": ^2.7.0 + checksum: 10/aed0fa260069d4ef1bdd8b92581b0e3a83b7ff04fa1457c3ebcede68abfdd98c97288569b2b395b67bc60607cda7fdcba3fdbe432c7e439662ea7d3de732897b languageName: node linkType: hard @@ -19241,12 +19199,12 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-strike@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-strike@npm:2.0.0-beta.220" +"@tiptap/extension-strike@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-strike@npm:2.10.3" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - checksum: 10/d71b309c1b9e2364b31e97fef819553efb29312ed504ad56e23269deb43069990bd351621a9077059419d9987acaa26fc2eb7797180ab56e16d4d4e4da903c56 + "@tiptap/core": ^2.7.0 + checksum: 10/4830a5e3236051e4a75a056117bf91ef4a76a3a5cdc0806acf765ca949cbbbacb37a237b92a5156c3a0ad79086b1cebd729d49a39181fd9c1b8fd9eb37ce0e83 languageName: node linkType: hard @@ -19259,12 +19217,12 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-text@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-text@npm:2.0.0-beta.220" +"@tiptap/extension-text@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-text@npm:2.10.3" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - checksum: 10/8b28d70eb8141ce13247f719c025e62189745be7229659ff0e57d9b796b5d042fe396cb8b0fee4bcdad82054d0d707f0e0616f0c9c263e6a22777d0c67269ba3 + "@tiptap/core": ^2.7.0 + checksum: 10/b56ce656fc7467ba81541bf4c7214a410a97193a0faebf9ba4fe52c3e4444ab63de8408550ea776546722d7b0d7d2f5da0551227e267f73c708c2435596f7161 languageName: node linkType: hard @@ -19277,12 +19235,12 @@ __metadata: languageName: node linkType: hard -"@tiptap/extension-underline@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/extension-underline@npm:2.0.0-beta.220" +"@tiptap/extension-underline@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/extension-underline@npm:2.10.3" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - checksum: 10/57f7bad2902638ee11bfcc83b24964b74379d61c00da27b07bcc4818ed5406581c96b2291ae12edd9de98def903a74cdb294206158d2d9647fd327e60f93e97c + "@tiptap/core": ^2.7.0 + checksum: 10/ca5fb009f342162a1d2323cae577f2996c4a01e0f199b143074249b7912552ad7d0a97119aacce1f2664cb0cf91a3d1b9336fddc1e87d1a59f1abda9c95d3c9d languageName: node linkType: hard @@ -19295,41 +19253,39 @@ __metadata: languageName: node linkType: hard -"@tiptap/pm@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/pm@npm:2.0.0-beta.220" - dependencies: - prosemirror-changeset: "npm:^2.2.0" - prosemirror-collab: "npm:^1.3.0" - prosemirror-commands: "npm:^1.3.1" - prosemirror-dropcursor: "npm:^1.5.0" - prosemirror-gapcursor: "npm:^1.3.1" - prosemirror-history: "npm:^1.3.0" - prosemirror-inputrules: "npm:^1.2.0" - prosemirror-keymap: "npm:^1.2.0" - prosemirror-markdown: "npm:^1.10.1" - prosemirror-menu: "npm:^1.2.1" - prosemirror-model: "npm:^1.18.1" - prosemirror-schema-basic: "npm:^1.2.0" - prosemirror-schema-list: "npm:^1.2.2" - prosemirror-state: "npm:^1.4.1" - prosemirror-tables: "npm:^1.3.0" - prosemirror-trailing-node: "npm:^2.0.2" - prosemirror-transform: "npm:^1.7.0" - prosemirror-view: "npm:^1.28.2" - peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - checksum: 10/e5cce529d558ed4c6bab820446dae42a783fb8645560d75ada5114a722eb5819fc06d0038000830cf1e329906405acb7891b827d19d00b017e8b41afaa0a58d3 - languageName: node - linkType: hard - -"@tiptap/suggestion@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/suggestion@npm:2.0.0-beta.220" +"@tiptap/pm@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/pm@npm:2.10.3" + dependencies: + prosemirror-changeset: "npm:^2.2.1" + prosemirror-collab: "npm:^1.3.1" + prosemirror-commands: "npm:^1.6.2" + prosemirror-dropcursor: "npm:^1.8.1" + prosemirror-gapcursor: "npm:^1.3.2" + prosemirror-history: "npm:^1.4.1" + prosemirror-inputrules: "npm:^1.4.0" + prosemirror-keymap: "npm:^1.2.2" + prosemirror-markdown: "npm:^1.13.1" + prosemirror-menu: "npm:^1.2.4" + prosemirror-model: "npm:^1.23.0" + prosemirror-schema-basic: "npm:^1.2.3" + prosemirror-schema-list: "npm:^1.4.1" + prosemirror-state: "npm:^1.4.3" + prosemirror-tables: "npm:^1.6.1" + prosemirror-trailing-node: "npm:^3.0.0" + prosemirror-transform: "npm:^1.10.2" + prosemirror-view: "npm:^1.37.0" + checksum: 10/dd61ef7becf82a5a36fc404d58287f72a7076215c7331b4451c37f9968c0fc8772af2c7764ac3d08dfa9cd03d30ede450ec0d4d8a84924a13a1b7bd86288144d + languageName: node + linkType: hard + +"@tiptap/suggestion@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/suggestion@npm:2.10.3" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - "@tiptap/pm": ^2.0.0-beta.209 - checksum: 10/44df0e53440f50f75fc1b18facd53e905ec288e238f692839953da2a9dfe65ec46f3d24cd3596245b7fca4a39b7ce162677096a5e2d85e281fe2ce822acce231 + "@tiptap/core": ^2.7.0 + "@tiptap/pm": ^2.7.0 + checksum: 10/de08fa1388105e459a363a675b3331267ce19db742be28c435ab1ad87995c49286f5f9ee1de9361056271a932f34632bcbc036ae788f21f9cff590e6e37bac0a languageName: node linkType: hard @@ -19360,17 +19316,17 @@ __metadata: languageName: node linkType: hard -"@tiptap/vue-3@npm:2.0.0-beta.220": - version: 2.0.0-beta.220 - resolution: "@tiptap/vue-3@npm:2.0.0-beta.220" +"@tiptap/vue-3@npm:2.10.3": + version: 2.10.3 + resolution: "@tiptap/vue-3@npm:2.10.3" dependencies: - "@tiptap/extension-bubble-menu": "npm:^2.0.0-beta.220" - "@tiptap/extension-floating-menu": "npm:^2.0.0-beta.220" + "@tiptap/extension-bubble-menu": "npm:^2.10.3" + "@tiptap/extension-floating-menu": "npm:^2.10.3" peerDependencies: - "@tiptap/core": ^2.0.0-beta.209 - "@tiptap/pm": ^2.0.0-beta.209 + "@tiptap/core": ^2.7.0 + "@tiptap/pm": ^2.7.0 vue: ^3.0.0 - checksum: 10/acc86a1150681a64c02ce4a252a9d63e86a82d70b78c2eb37d1235ae9d939160ce96fb3da1f7af02c287e73ce64c8bf5d6c96147e3178a79a6f0dc653a2bf8f8 + checksum: 10/f2180b7f286a3f9bdefec1c8565d7d72997542d113f5a837c701ad8e16b59c2d9e1e85ac16a8e0420269fbdf130f7fa29521d1bbc24bd831462c60b9adece436 languageName: node linkType: hard @@ -20147,6 +20103,13 @@ __metadata: languageName: node linkType: hard +"@types/linkify-it@npm:^5": + version: 5.0.0 + resolution: "@types/linkify-it@npm:5.0.0" + checksum: 10/c3919044d4876f9d71d037e861745cd2485c95ac8c36a4fa67b132d4e60eb1d067e123cc7965c9cf5110eea351517d767f0d306af5e9147d6d0af87bc374ddcf + languageName: node + linkType: hard + "@types/lockfile@npm:^1.0.2": version: 1.0.2 resolution: "@types/lockfile@npm:1.0.2" @@ -20221,6 +20184,16 @@ __metadata: languageName: node linkType: hard +"@types/markdown-it@npm:^14.0.0": + version: 14.1.2 + resolution: "@types/markdown-it@npm:14.1.2" + dependencies: + "@types/linkify-it": "npm:^5" + "@types/mdurl": "npm:^2" + checksum: 10/ca2f239c8d59610b9f936fd40261a6ccf2fa1ae27a21816c031e5712542dcf9ee01e2fe29b31118df90716e11ade54e47d92a498e9b6488800e77ca8827255a2 + languageName: node + linkType: hard + "@types/marked@npm:^5.0.0": version: 5.0.2 resolution: "@types/marked@npm:5.0.2" @@ -20228,6 +20201,13 @@ __metadata: languageName: node linkType: hard +"@types/mdurl@npm:^2": + version: 2.0.0 + resolution: "@types/mdurl@npm:2.0.0" + checksum: 10/78746e96c655ceed63db06382da466fd52c7e9dc54d60b12973dfdd110cae06b9439c4b90e17bb8d4461109184b3ea9f3e9f96b3e4bf4aa9fe18b6ac35f283c8 + languageName: node + linkType: hard + "@types/mdx@npm:^2.0.0": version: 2.0.3 resolution: "@types/mdx@npm:2.0.3" @@ -20447,20 +20427,6 @@ __metadata: languageName: node linkType: hard -"@types/object.omit@npm:^3.0.0": - version: 3.0.0 - resolution: "@types/object.omit@npm:3.0.0" - checksum: 10/bddcf21a89590b3129e3ae21007e70ccbc9b036a1479b78450546f2caf97223dfe13603c78a0af6a70a94afcd9d554bb7bb593f6362e04e3ca457991e0292397 - languageName: node - linkType: hard - -"@types/object.pick@npm:^1.3.1": - version: 1.3.2 - resolution: "@types/object.pick@npm:1.3.2" - checksum: 10/71053ec6849142e934a1f2232e004f103e26a7df4782a83e83d976c74f1804c3ac29ed96223a6eef7b08f36ee019d1675f0d752fd79c9f444a080646d438c87d - languageName: node - linkType: hard - "@types/orderedmap@npm:*": version: 1.0.0 resolution: "@types/orderedmap@npm:1.0.0" @@ -20881,13 +20847,6 @@ __metadata: languageName: node linkType: hard -"@types/throttle-debounce@npm:^2.1.0": - version: 2.1.0 - resolution: "@types/throttle-debounce@npm:2.1.0" - checksum: 10/678dbd8a3c158e49f057a2e8898ba3c29e06cda95f6ca393a7a7785298d48758dc62c5c49090ec0bdea572a6d51a60a9ba5c95e17de2ca24d57a28de63ca25c7 - languageName: node - linkType: hard - "@types/tough-cookie@npm:*": version: 4.0.5 resolution: "@types/tough-cookie@npm:4.0.5" @@ -26522,13 +26481,6 @@ __metadata: languageName: node linkType: hard -"case-anything@npm:^2.1.10": - version: 2.1.10 - resolution: "case-anything@npm:2.1.10" - checksum: 10/cb5e3be2c77af23682930dcd11b3b9324e4ad908b6329b1b556de01f0e4731fc5c930a7d25f07c2d335a11bac6ee4cfc1a067669822d6b846d4e91c3611fd2db - languageName: node - linkType: hard - "caseless@npm:~0.12.0": version: 0.12.0 resolution: "caseless@npm:0.12.0" @@ -28750,13 +28702,6 @@ __metadata: languageName: node linkType: hard -"dash-get@npm:^1.0.2": - version: 1.0.2 - resolution: "dash-get@npm:1.0.2" - checksum: 10/5aa0bc487f7c66ae25fc2ee1a8e85fbbd4da1b4616c7b386399813355b8464615ac73a239d3549ec299c23e06e1a0f30eb4fedc1e52e51ea2b9154ad6210deee - languageName: node - linkType: hard - "dashdash@npm:^1.12.0": version: 1.14.1 resolution: "dashdash@npm:1.14.1" @@ -30275,13 +30220,6 @@ __metadata: languageName: node linkType: hard -"entities@npm:~3.0.1": - version: 3.0.1 - resolution: "entities@npm:3.0.1" - checksum: 10/3706e0292ea3f3679720b3d3b1ed6290b164aaeb11116691a922a3acea144503871e0de2170b47671c3b735549b8b7f4741d0d3c2987e8f985ccaa0dd3762eba - languageName: node - linkType: hard - "env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -35432,15 +35370,6 @@ __metadata: languageName: node linkType: hard -"is-extendable@npm:^1.0.0": - version: 1.0.1 - resolution: "is-extendable@npm:1.0.1" - dependencies: - is-plain-object: "npm:^2.0.4" - checksum: 10/db07bc1e9de6170de70eff7001943691f05b9d1547730b11be01c0ebfe67362912ba743cf4be6fd20a5e03b4180c685dad80b7c509fe717037e3eee30ad8e84f - languageName: node - linkType: hard - "is-extglob@npm:^2.1.1": version: 2.1.1 resolution: "is-extglob@npm:2.1.1" @@ -37892,12 +37821,12 @@ __metadata: languageName: node linkType: hard -"linkify-it@npm:^4.0.1": - version: 4.0.1 - resolution: "linkify-it@npm:4.0.1" +"linkify-it@npm:^5.0.0": + version: 5.0.0 + resolution: "linkify-it@npm:5.0.0" dependencies: - uc.micro: "npm:^1.0.1" - checksum: 10/d0a786d2e3f02f46b6f4a9b466af9eb936fb68e86b7cd305933d5457b12fdc53a4d0e0b697b02dc2e7d84a51d2425d719598bb7b47af7e01911e492e07a97957 + uc.micro: "npm:^2.0.0" + checksum: 10/ef3b7609dda6ec0c0be8a7b879cea195f0d36387b0011660cd6711bba0ad82137f59b458b7e703ec74f11d88e7c1328e2ad9b855a8500c0ded67461a8c4519e6 languageName: node linkType: hard @@ -38828,7 +38757,7 @@ __metadata: languageName: node linkType: hard -"make-error@npm:^1.1.1, make-error@npm:^1.3.6": +"make-error@npm:^1.1.1": version: 1.3.6 resolution: "make-error@npm:1.3.6" checksum: 10/b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 @@ -38915,18 +38844,19 @@ __metadata: languageName: node linkType: hard -"markdown-it@npm:^13.0.1": - version: 13.0.1 - resolution: "markdown-it@npm:13.0.1" +"markdown-it@npm:^14.0.0": + version: 14.1.0 + resolution: "markdown-it@npm:14.1.0" dependencies: argparse: "npm:^2.0.1" - entities: "npm:~3.0.1" - linkify-it: "npm:^4.0.1" - mdurl: "npm:^1.0.1" - uc.micro: "npm:^1.0.5" + entities: "npm:^4.4.0" + linkify-it: "npm:^5.0.0" + mdurl: "npm:^2.0.0" + punycode.js: "npm:^2.3.1" + uc.micro: "npm:^2.1.0" bin: - markdown-it: bin/markdown-it.js - checksum: 10/ebe2cfd515c23d2d88692efac41e4070fcd2e932af15e5455e73e1a47f1f86298a4aacb2de3b5cc647a409bed5951739980c02dbf7d34f78e25554d8742751db + markdown-it: bin/markdown-it.mjs + checksum: 10/f34f921be178ed0607ba9e3e27c733642be445e9bb6b1dba88da7aafe8ba1bc5d2f1c3aa8f3fc33b49a902da4e4c08c2feadfafb290b8c7dda766208bb6483a9 languageName: node linkType: hard @@ -38998,10 +38928,10 @@ __metadata: languageName: node linkType: hard -"mdurl@npm:^1.0.1": - version: 1.0.1 - resolution: "mdurl@npm:1.0.1" - checksum: 10/ada367d01c9e81d07328101f187d5bd8641b71f33eab075df4caed935a24fa679e625f07108801d8250a5e4a99e5cd4be7679957a11424a3aa3e740d2bb2d5cb +"mdurl@npm:^2.0.0": + version: 2.0.0 + resolution: "mdurl@npm:2.0.0" + checksum: 10/1720349d4a53e401aa993241368e35c0ad13d816ad0b28388928c58ca9faa0cf755fa45f18ccbf64f4ce54a845a50ddce5c84e4016897b513096a68dac4b0158 languageName: node linkType: hard @@ -42085,24 +42015,6 @@ __metadata: languageName: node linkType: hard -"object.omit@npm:^3.0.0": - version: 3.0.0 - resolution: "object.omit@npm:3.0.0" - dependencies: - is-extendable: "npm:^1.0.0" - checksum: 10/1feb3a5891e3892451e34d3b460caae06f2a8a829854eced577cd30654a2ed37750e3d7f0d462e692b3b6623ad74777457c0ad32a9e622136b8171209d356890 - languageName: node - linkType: hard - -"object.pick@npm:^1.3.0": - version: 1.3.0 - resolution: "object.pick@npm:1.3.0" - dependencies: - isobject: "npm:^3.0.1" - checksum: 10/92d7226a6b581d0d62694a5632b6a1594c81b3b5a4eb702a7662e0b012db532557067d6f773596c577f75322eba09cdca37ca01ea79b6b29e3e17365f15c615e - languageName: node - linkType: hard - "obuf@npm:^1.0.0, obuf@npm:^1.1.2": version: 1.1.2 resolution: "obuf@npm:1.1.2" @@ -45024,25 +44936,25 @@ __metadata: languageName: node linkType: hard -"prosemirror-changeset@npm:^2.2.0": - version: 2.2.0 - resolution: "prosemirror-changeset@npm:2.2.0" +"prosemirror-changeset@npm:^2.2.1": + version: 2.2.1 + resolution: "prosemirror-changeset@npm:2.2.1" dependencies: prosemirror-transform: "npm:^1.0.0" - checksum: 10/ebf5e391b3b872ebd7a4c548e11cc7f669e05d3d8729c672c97d6c71c471e462a7e800cbd38abaa72c7807fd037949fc6e7b367e0103d1ed12b2e13c3c66ddf6 + checksum: 10/39c70af95e2a9be8d414d546188656e5d6db4034e2640c887ff72c7ff8e638817ff061b5c08590e8ba8027b5b0c1954dc60b62d2d2a8e3a7b7d6a05453a283ad languageName: node linkType: hard -"prosemirror-collab@npm:^1.3.0": - version: 1.3.0 - resolution: "prosemirror-collab@npm:1.3.0" +"prosemirror-collab@npm:^1.3.1": + version: 1.3.1 + resolution: "prosemirror-collab@npm:1.3.1" dependencies: prosemirror-state: "npm:^1.0.0" - checksum: 10/6f9959e0007483f894d2de3cd03034df55c3a617aa62481cd349e163aab80cd0c8ac6e91f9a45d5b61744e7f3843e78c494c3a244cadf6429738b3aecbf8a401 + checksum: 10/6b1ccc52841fbb62a39ef0fb8da2d731381030609ea7a0ba7d533b1937d56fe4b91344e79c023e790bed5392efe9f917c41c8434e0a379dc1dc842ba83594e34 languageName: node linkType: hard -"prosemirror-commands@npm:^1.0.0, prosemirror-commands@npm:^1.3.1": +"prosemirror-commands@npm:^1.0.0": version: 1.5.1 resolution: "prosemirror-commands@npm:1.5.1" dependencies: @@ -45064,30 +44976,41 @@ __metadata: languageName: node linkType: hard -"prosemirror-dropcursor@npm:^1.5.0": - version: 1.7.1 - resolution: "prosemirror-dropcursor@npm:1.7.1" +"prosemirror-commands@npm:^1.6.2": + version: 1.6.2 + resolution: "prosemirror-commands@npm:1.6.2" + dependencies: + prosemirror-model: "npm:^1.0.0" + prosemirror-state: "npm:^1.0.0" + prosemirror-transform: "npm:^1.10.2" + checksum: 10/048422039a91dc1ba1a661698c110967a14744fb606ef958ca63f58e7efe4f64023ab63c4a5029ccef0159d0e2580224c158ce60a27323ea814989d6301dad16 + languageName: node + linkType: hard + +"prosemirror-dropcursor@npm:^1.8.1": + version: 1.8.1 + resolution: "prosemirror-dropcursor@npm:1.8.1" dependencies: prosemirror-state: "npm:^1.0.0" prosemirror-transform: "npm:^1.1.0" prosemirror-view: "npm:^1.1.0" - checksum: 10/873381e776b211a37cc31407cadd0ef20c0b66ea6ea4fc5027f14c7c5aadea3aded49a516d7b1afb3c978a195720663361156adf5f4756d023d77baa226d275b + checksum: 10/a2584dfbbeed3d7f703c1f7f174396ebf85f29e4f31a43e08cb9c7596d6d0b82f1bc73d0bbcd7c25e421d875ef0dc7f0c9918287fffa5267b6c65e89659444d0 languageName: node linkType: hard -"prosemirror-gapcursor@npm:^1.3.1": - version: 1.3.1 - resolution: "prosemirror-gapcursor@npm:1.3.1" +"prosemirror-gapcursor@npm:^1.3.2": + version: 1.3.2 + resolution: "prosemirror-gapcursor@npm:1.3.2" dependencies: prosemirror-keymap: "npm:^1.0.0" prosemirror-model: "npm:^1.0.0" prosemirror-state: "npm:^1.0.0" prosemirror-view: "npm:^1.0.0" - checksum: 10/658c0822bf10908626046ee38aba036030c5c03a6b3200440867135c5b77399e4902b48903128b7811e011ebe7aa6762126b9b58f04353d7e5eabcc95bcd1d3d + checksum: 10/8de9e9c0f7f69ae74a3189a86b3e8a2bd8dd5bd693c18efc5271ad4a4c6777092906c8ac7722ab5d1428de04b13404c676fddef54966ed3606b3040602eb1325 languageName: node linkType: hard -"prosemirror-history@npm:^1.0.0, prosemirror-history@npm:^1.2.0, prosemirror-history@npm:^1.3.0": +"prosemirror-history@npm:^1.0.0, prosemirror-history@npm:^1.2.0": version: 1.3.0 resolution: "prosemirror-history@npm:1.3.0" dependencies: @@ -45098,17 +45021,29 @@ __metadata: languageName: node linkType: hard -"prosemirror-inputrules@npm:^1.2.0": - version: 1.2.0 - resolution: "prosemirror-inputrules@npm:1.2.0" +"prosemirror-history@npm:^1.4.1": + version: 1.4.1 + resolution: "prosemirror-history@npm:1.4.1" + dependencies: + prosemirror-state: "npm:^1.2.2" + prosemirror-transform: "npm:^1.0.0" + prosemirror-view: "npm:^1.31.0" + rope-sequence: "npm:^1.3.0" + checksum: 10/7ac68fc8233dcd159bb15c2aaf542fd9aa0524b50523b24de6c8209b1f5eae9545f7fa82d584c93e68b1e910bcae5e07bee1085094aca4c565c607cf737c39b8 + languageName: node + linkType: hard + +"prosemirror-inputrules@npm:^1.4.0": + version: 1.4.0 + resolution: "prosemirror-inputrules@npm:1.4.0" dependencies: prosemirror-state: "npm:^1.0.0" prosemirror-transform: "npm:^1.0.0" - checksum: 10/dfd10074001440d3650d49332abf7fafd4402b0ccdcfcdfe9bb650ccf42e9fca4c5f436a8c097e0be7b22957871d198af124dfe314668990e42b810d0c5e27be + checksum: 10/04453d06a4bb3540e3df254e46bcafd4faaf73cd72f4486a804af598a084ee9cddb9fedd23a09c34c79ed98182b151461f447e4f5920bdd7465327975c773bca languageName: node linkType: hard -"prosemirror-keymap@npm:^1.0.0, prosemirror-keymap@npm:^1.1.2, prosemirror-keymap@npm:^1.2.0": +"prosemirror-keymap@npm:^1.0.0, prosemirror-keymap@npm:^1.1.2": version: 1.2.1 resolution: "prosemirror-keymap@npm:1.2.1" dependencies: @@ -45128,25 +45063,36 @@ __metadata: languageName: node linkType: hard -"prosemirror-markdown@npm:^1.10.1": - version: 1.10.1 - resolution: "prosemirror-markdown@npm:1.10.1" +"prosemirror-keymap@npm:^1.2.2": + version: 1.2.2 + resolution: "prosemirror-keymap@npm:1.2.2" dependencies: - markdown-it: "npm:^13.0.1" - prosemirror-model: "npm:^1.0.0" - checksum: 10/37b672cbb956d59737e492bf1f054278e9474932b5dd158bedc94e6e1f1492edb2d236f72f8a374c94edd517abcfd89cdfdf18694d88e05b66ae281260c3476b + prosemirror-state: "npm:^1.0.0" + w3c-keyname: "npm:^2.2.0" + checksum: 10/75a6db1a8f899918a25606fa5824ffe9dea9729b8fb4346854aa6f0e37a0f8bad40cc1b01eef1046ee442f76afc234c8eb2b1c50a03c8b6062f3cd348694b8be languageName: node linkType: hard -"prosemirror-menu@npm:^1.2.1": - version: 1.2.1 - resolution: "prosemirror-menu@npm:1.2.1" +"prosemirror-markdown@npm:^1.13.1": + version: 1.13.1 + resolution: "prosemirror-markdown@npm:1.13.1" + dependencies: + "@types/markdown-it": "npm:^14.0.0" + markdown-it: "npm:^14.0.0" + prosemirror-model: "npm:^1.20.0" + checksum: 10/91af650e17e92c1c911c8bbf7dd4c6d5ba56ee140b0fe493e4e4bab85de1121cece3736eeabb7f789ef2b40afd1f6abe870f908213ce1918fd590257a8932c6c + languageName: node + linkType: hard + +"prosemirror-menu@npm:^1.2.4": + version: 1.2.4 + resolution: "prosemirror-menu@npm:1.2.4" dependencies: crelt: "npm:^1.0.0" prosemirror-commands: "npm:^1.0.0" prosemirror-history: "npm:^1.0.0" prosemirror-state: "npm:^1.0.0" - checksum: 10/6c9c8058159636b53d97594575ab30ceea6e6b91f4d437a7953a84f7f22aee2f3df511be36bfcd3023fb3598d9c353916d4a5dae5adee3d3b14ab156b30e202b + checksum: 10/1148419fd115e86d43096d1e7574fd657547ac8875ee34ae9a2056b15f3bcc6ca104aaa305cde5ff72f19d4c4cfc9989909271d6cd317549d749a15aa4c2e2a1 languageName: node linkType: hard @@ -45159,7 +45105,7 @@ __metadata: languageName: node linkType: hard -"prosemirror-model@npm:^1.18.1, prosemirror-model@npm:^1.19.0, prosemirror-model@npm:^1.8.1": +"prosemirror-model@npm:^1.19.0, prosemirror-model@npm:^1.8.1": version: 1.19.0 resolution: "prosemirror-model@npm:1.19.0" dependencies: @@ -45168,12 +45114,21 @@ __metadata: languageName: node linkType: hard -"prosemirror-schema-basic@npm:^1.2.0": - version: 1.2.1 - resolution: "prosemirror-schema-basic@npm:1.2.1" +"prosemirror-model@npm:^1.20.0, prosemirror-model@npm:^1.21.0, prosemirror-model@npm:^1.23.0": + version: 1.24.0 + resolution: "prosemirror-model@npm:1.24.0" + dependencies: + orderedmap: "npm:^2.0.0" + checksum: 10/ce5e2e9b3aa5d5bfe5bc4090dd7113df34bd037546465305023bddd9d2ede4450f1cd38717fac286e2dda41d240d419a80916e73ec22a67226171e8e319dc5ba + languageName: node + linkType: hard + +"prosemirror-schema-basic@npm:^1.2.3": + version: 1.2.3 + resolution: "prosemirror-schema-basic@npm:1.2.3" dependencies: prosemirror-model: "npm:^1.19.0" - checksum: 10/1fd59c47c1e05d037cd030acde4b406f62d8d5f87bd6cf51c877307ab02d61a39871da53cadd07746cbb8ca4fe87c317710d00caa39bbac16774d96fe42213cc + checksum: 10/af129408a6625707ff001077565d609b1d6b4e9393e86cc628c58bc2d995f5144921f83b6123db4210b48cf26c7899dd38a5de9fdc0c288a2d40650c8e018092 languageName: node linkType: hard @@ -45188,18 +45143,18 @@ __metadata: languageName: node linkType: hard -"prosemirror-schema-list@npm:^1.2.2": - version: 1.2.2 - resolution: "prosemirror-schema-list@npm:1.2.2" +"prosemirror-schema-list@npm:^1.4.1": + version: 1.5.0 + resolution: "prosemirror-schema-list@npm:1.5.0" dependencies: prosemirror-model: "npm:^1.0.0" prosemirror-state: "npm:^1.0.0" - prosemirror-transform: "npm:^1.0.0" - checksum: 10/9957ffa98d384a348427ca20d762ec3c533ed53c56be1633fad8cbeb8acf35c362255bcfc4a587ae27828af9fa3199305227b9834f2f716b9216c232030ddb85 + prosemirror-transform: "npm:^1.7.3" + checksum: 10/754c7ba13e356783642597b59838bd5ea43fa74f43aede80181d38d3cbd2086d8ea86c50d172b8c2a1f5cc8b8c6e1b7386082265628db4f941d27495102d65bc languageName: node linkType: hard -"prosemirror-state@npm:^1.0.0, prosemirror-state@npm:^1.2.2, prosemirror-state@npm:^1.3.1, prosemirror-state@npm:^1.3.4, prosemirror-state@npm:^1.4.1": +"prosemirror-state@npm:^1.0.0, prosemirror-state@npm:^1.2.2, prosemirror-state@npm:^1.3.1, prosemirror-state@npm:^1.3.4": version: 1.4.2 resolution: "prosemirror-state@npm:1.4.2" dependencies: @@ -45210,32 +45165,41 @@ __metadata: languageName: node linkType: hard -"prosemirror-tables@npm:^1.3.0": - version: 1.3.2 - resolution: "prosemirror-tables@npm:1.3.2" +"prosemirror-state@npm:^1.4.3": + version: 1.4.3 + resolution: "prosemirror-state@npm:1.4.3" + dependencies: + prosemirror-model: "npm:^1.0.0" + prosemirror-transform: "npm:^1.0.0" + prosemirror-view: "npm:^1.27.0" + checksum: 10/57eb0cc7d7412b9d87430b5a9e489d60aed3a3eb9deffff4314b76ad6a5ae019618be658303796285877c9c535392b97b5a4d4d173485b0dd0403671ada9f2a1 + languageName: node + linkType: hard + +"prosemirror-tables@npm:^1.6.1": + version: 1.6.1 + resolution: "prosemirror-tables@npm:1.6.1" dependencies: prosemirror-keymap: "npm:^1.1.2" prosemirror-model: "npm:^1.8.1" prosemirror-state: "npm:^1.3.1" prosemirror-transform: "npm:^1.2.1" prosemirror-view: "npm:^1.13.3" - checksum: 10/df7b7aaf813fae62c5cf72b098c715cde1a380df41e7dd9f6c9341df00b7bb84326984d643ec6f8c292aa0a0d88a8078b5d804e4cb44d77649fe80369fbfc050 + checksum: 10/8ab343220594ba8e1d788d760c7234607b9ebc9d9fc44434828fb84ff990fac990ec6ce25708445e3203792d471b52b3dc1a73564863eef050fc805bc65abb22 languageName: node linkType: hard -"prosemirror-trailing-node@npm:^2.0.2": - version: 2.0.3 - resolution: "prosemirror-trailing-node@npm:2.0.3" +"prosemirror-trailing-node@npm:^3.0.0": + version: 3.0.0 + resolution: "prosemirror-trailing-node@npm:3.0.0" dependencies: - "@babel/runtime": "npm:^7.13.10" - "@remirror/core-constants": "npm:^2.0.0" - "@remirror/core-helpers": "npm:^2.0.1" + "@remirror/core-constants": "npm:3.0.0" escape-string-regexp: "npm:^4.0.0" peerDependencies: - prosemirror-model: ^1 - prosemirror-state: ^1 - prosemirror-view: ^1 - checksum: 10/122a8d4f7a57f40c0abd9b87aa9b6f17f58e5ed654adcc16528dbee16b25cacc13baafedc2e0411b3619ba93742345b19d61cd7adf1df4c3e623338f1a264e72 + prosemirror-model: ^1.22.1 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.33.8 + checksum: 10/044b199b8001373c1bd4c1573876597840df89e66c1f02497a8bb4f2885ebe830faa9764e1269ed6c24bf2fde06ad5f40322afde648ae331d4663f531000adaa languageName: node linkType: hard @@ -45248,7 +45212,16 @@ __metadata: languageName: node linkType: hard -"prosemirror-transform@npm:^1.2.1, prosemirror-transform@npm:^1.7.0": +"prosemirror-transform@npm:^1.10.2, prosemirror-transform@npm:^1.7.3": + version: 1.10.2 + resolution: "prosemirror-transform@npm:1.10.2" + dependencies: + prosemirror-model: "npm:^1.21.0" + checksum: 10/be0feb6ec2a775cf00cfb251f7a261c919c8a4ba6d3217e21679631ee27a9f52757417a72301703e01b635513793019c9ad58752dd4a48929bf4a234dc094a53 + languageName: node + linkType: hard + +"prosemirror-transform@npm:^1.2.1": version: 1.7.1 resolution: "prosemirror-transform@npm:1.7.1" dependencies: @@ -45279,7 +45252,7 @@ __metadata: languageName: node linkType: hard -"prosemirror-view@npm:^1.27.0, prosemirror-view@npm:^1.28.2": +"prosemirror-view@npm:^1.27.0": version: 1.30.0 resolution: "prosemirror-view@npm:1.30.0" dependencies: @@ -45290,6 +45263,17 @@ __metadata: languageName: node linkType: hard +"prosemirror-view@npm:^1.31.0, prosemirror-view@npm:^1.37.0": + version: 1.37.0 + resolution: "prosemirror-view@npm:1.37.0" + dependencies: + prosemirror-model: "npm:^1.20.0" + prosemirror-state: "npm:^1.0.0" + prosemirror-transform: "npm:^1.1.0" + checksum: 10/6455646ae3c9093fb754bf6e65a722682065f66d432cebe81c9d1fd84c1e8ccf106abc78a3a0cb52c1c5b34c8a69fde169de6639ddcb9381c8036c5fac83262d + languageName: node + linkType: hard + "proto-list@npm:~1.2.1": version: 1.2.4 resolution: "proto-list@npm:1.2.4" @@ -45631,6 +45615,13 @@ __metadata: languageName: node linkType: hard +"punycode.js@npm:^2.3.1": + version: 2.3.1 + resolution: "punycode.js@npm:2.3.1" + checksum: 10/f0e946d1edf063f9e3d30a32ca86d8ff90ed13ca40dad9c75d37510a04473340cfc98db23a905cc1e517b1e9deb0f6021dce6f422ace235c60d3c9ac47c5a16a + languageName: node + linkType: hard + "punycode@npm:^1.3.2": version: 1.4.1 resolution: "punycode@npm:1.4.1" @@ -50844,7 +50835,7 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^2.0.0, type-fest@npm:^2.11.2, type-fest@npm:^2.12.2, type-fest@npm:^2.19.0, type-fest@npm:~2.19": +"type-fest@npm:^2.11.2, type-fest@npm:^2.12.2, type-fest@npm:^2.19.0, type-fest@npm:~2.19": version: 2.19.0 resolution: "type-fest@npm:2.19.0" checksum: 10/7bf9e8fdf34f92c8bb364c0af14ca875fac7e0183f2985498b77be129dc1b3b1ad0a6b3281580f19e48c6105c037fb966ad9934520c69c6434d17fd0af4eed78 @@ -50983,10 +50974,10 @@ __metadata: languageName: node linkType: hard -"uc.micro@npm:^1.0.1, uc.micro@npm:^1.0.5": - version: 1.0.6 - resolution: "uc.micro@npm:1.0.6" - checksum: 10/6898bb556319a38e9cf175e3628689347bd26fec15fc6b29fa38e0045af63075ff3fea4cf1fdba9db46c9f0cbf07f2348cd8844889dd31ebd288c29fe0d27e7a +"uc.micro@npm:^2.0.0, uc.micro@npm:^2.1.0": + version: 2.1.0 + resolution: "uc.micro@npm:2.1.0" + checksum: 10/37197358242eb9afe367502d4638ac8c5838b78792ab218eafe48287b0ed28aaca268ec0392cc5729f6c90266744de32c06ae938549aee041fc93b0f9672d6b2 languageName: node linkType: hard