Merge branch 'main' into andrew/placeholder-updates-to-workspace-settings

This commit is contained in:
andrewwallacespeckle
2025-05-30 11:01:09 +02:00
48 changed files with 941 additions and 128 deletions
+2 -1
View File
@@ -1105,7 +1105,8 @@ jobs:
command: yarn workspaces foreach -tvW version $IMAGE_VERSION_TAG
- run:
name: publish to npm
command: 'yarn workspaces foreach -pvW --no-private npm publish --access public'
# TODO: publish dry run while we figure out build/bundle issue
command: 'yarn workspaces foreach -pvW --no-private npm publish --access public --dry-run'
publish-helm-chart:
docker:
+16
View File
@@ -0,0 +1,16 @@
[
{
"adapter": "JavaScript",
"label": "yarn (JavaScript)",
"request": "launch",
"console": "integratedTerminal",
"program": "dev",
"runtimeExecutable": "yarn",
"args": [],
"type": "pwa-node",
"cwd": "$ZED_WORKTREE_ROOT/packages/server",
"skipFiles": ["<node_internals>/**"],
"env": {},
"stop_on_entry": false
}
]
+1 -1
View File
@@ -20,7 +20,7 @@ coverage:
default:
target: 90% #overall project/ repo coverage
server:
target: 90%
target: 70% #TODO This is low and should be increased
flags:
- server
shared:
@@ -54,7 +54,6 @@ import {
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { isUndefined } from 'lodash-es'
import { useMixpanel } from '~/lib/core/composables/mp'
import { homeRoute, defaultZapierWebhookUrl } from '~/lib/common/helpers/route'
import { useZapier } from '~/lib/core/composables/zapier'
import { useForm } from 'vee-validate'
@@ -79,7 +78,6 @@ const { triggerNotification } = useGlobalToast()
const { activeUser } = useActiveUser()
const router = useRouter()
const apollo = useApolloClient().client
const mixpanel = useMixpanel()
const { sendWebhook } = useZapier()
const { resetForm } = useForm<{ feedback: string }>()
const { mutateActiveWorkspaceSlug } = useNavigation()
@@ -123,12 +121,6 @@ const onDelete = async () => {
)
}
mixpanel.track('Workspace Deleted', {
// eslint-disable-next-line camelcase
workspace_id: workspaceId,
feedback: feedback.value
})
if (feedback.value) {
await sendWebhook(defaultZapierWebhookUrl, {
feedback: [
@@ -14,6 +14,7 @@ export const useNavigation = () => {
const state = useNavigationState()
const { mutate } = useMutation(setActiveWorkspaceMutation)
const { $intercom } = useNuxtApp()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const activeWorkspaceSlug = computed({
get: () => state.value.activeWorkspaceSlug,
@@ -28,6 +29,8 @@ export const useNavigation = () => {
const mutateActiveWorkspaceSlug = async (newVal: string | null) => {
state.value.activeWorkspaceSlug = newVal
state.value.isProjectsActive = false
if (!isWorkspacesEnabled.value) return
await mutate({ slug: newVal, isProjectsActive: false })
$intercom.updateCompany()
}
@@ -35,6 +38,8 @@ export const useNavigation = () => {
const mutateIsProjectsActive = async (isActive: boolean) => {
state.value.isProjectsActive = isActive
state.value.activeWorkspaceSlug = null
if (!isWorkspacesEnabled.value) return
await mutate({ isProjectsActive: state.value.isProjectsActive, slug: null })
}
@@ -52,3 +52,42 @@ extend type User {
@hasScope(scope: "workspace:read")
@isOwner
}
extend type Workspace {
"""
Get all join requests for all the workspaces the user is an admin of
"""
adminWorkspacesJoinRequests(
filter: AdminWorkspaceJoinRequestFilter
cursor: String
limit: Int! = 25
): WorkspaceJoinRequestCollection
@hasServerRole(role: SERVER_USER)
@hasScope(scope: "workspace:read")
@hasWorkspaceRole(role: ADMIN)
}
type WorkspaceJoinRequestCollection {
totalCount: Int!
cursor: String
items: [WorkspaceJoinRequest!]!
}
type WorkspaceJoinRequest {
id: String!
workspace: Workspace!
user: LimitedUser!
email: String
status: WorkspaceJoinRequestStatus!
createdAt: DateTime!
}
enum WorkspaceJoinRequestStatus {
pending
approved
denied
}
input AdminWorkspaceJoinRequestFilter {
status: WorkspaceJoinRequestStatus
}
@@ -318,17 +318,6 @@ type Workspace {
Workspace-level configuration for models in embedded viewer
"""
embedOptions: WorkspaceEmbedOptions!
"""
Get all join requests for all the workspaces the user is an admin of
"""
adminWorkspacesJoinRequests(
filter: AdminWorkspaceJoinRequestFilter
cursor: String
limit: Int! = 25
): WorkspaceJoinRequestCollection
@hasServerRole(role: SERVER_USER)
@hasScope(scope: "workspace:read")
@hasWorkspaceRole(role: ADMIN)
}
type WorkspaceEmbedOptions {
@@ -465,6 +454,7 @@ type WorkspaceCollaborator {
id: ID!
role: String!
user: LimitedUser!
email: String
projectRoles: [ProjectRole!]!
"""
Date that the user joined the workspace.
@@ -510,30 +500,6 @@ type WorkspaceCollection {
items: [Workspace!]!
}
type WorkspaceJoinRequestCollection {
totalCount: Int!
cursor: String
items: [WorkspaceJoinRequest!]!
}
type WorkspaceJoinRequest {
id: String!
workspace: Workspace!
user: LimitedUser!
status: WorkspaceJoinRequestStatus!
createdAt: DateTime!
}
enum WorkspaceJoinRequestStatus {
pending
approved
denied
}
input AdminWorkspaceJoinRequestFilter {
status: WorkspaceJoinRequestStatus
}
extend type User {
"""
Get discoverable workspaces with verified domains that match the active user's
@@ -625,7 +591,7 @@ extend type Project {
workspaceId: String
}
# case of using userSearch, and we alway expose this
# case of using userSearch, and we always expose this
extend type LimitedUser {
workspaceDomainPolicyCompliant(workspaceSlug: String): Boolean
workspaceRole(workspaceId: String): String
@@ -10,7 +10,12 @@ import {
getFunctionReleaseFactory,
getFunctionReleasesFactory
} from '@/modules/automate/clients/executionEngine'
import { Automate, Roles, removeNullOrUndefinedKeys } from '@speckle/shared'
import {
Automate,
Roles,
ensureError,
removeNullOrUndefinedKeys
} from '@speckle/shared'
import { AuthCodePayloadAction } from '@/modules/automate/services/authCode'
import {
ProjectAutomationCreateInput,
@@ -52,6 +57,7 @@ import { GetBranchesByIds } from '@/modules/core/domain/branches/operations'
import { ValidateStreamAccess } from '@/modules/core/domain/streams/operations'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { AutomationEvents } from '@/modules/automate/domain/events'
import { UnformattableTriggerDefinitionSchemaError } from '@speckle/shared/dist/commonjs/automate/index.js'
export type CreateAutomationDeps = {
createAuthCode: CreateStoredAuthCode
@@ -395,9 +401,20 @@ export const createAutomationRevisionFactory =
userResourceAccessRules
)
const triggers = Automate.AutomateTypes.formatTriggerDefinitionSchema(
input.triggerDefinitions
)
let triggers: Automate.AutomateTypes.TriggerDefinitionsSchema
try {
triggers = Automate.AutomateTypes.formatTriggerDefinitionSchema(
input.triggerDefinitions
)
} catch (e) {
if (e instanceof UnformattableTriggerDefinitionSchemaError) {
throw new AutomationRevisionCreationError(
'One or more trigger definitions are not valid',
{ cause: ensureError(e, 'Unknown error when formatting trigger definition') }
)
}
throw e
}
const triggerDefinitions = triggers.definitions.map((d) => {
if (Automate.AutomateTypes.isVersionCreatedTriggerDefinition(d)) {
const triggerDef: InsertableAutomationRevisionTrigger = {
@@ -1,5 +1,6 @@
import {
AutomationCreationError,
AutomationRevisionCreationError,
AutomationUpdateError
} from '@/modules/automate/errors/management'
import {
@@ -406,10 +407,7 @@ const buildAutomationUpdate = () => {
})
)
expect(
e instanceof Automate.UnformattableTriggerDefinitionSchemaError,
e.toString()
).to.be.true
expect(e instanceof AutomationRevisionCreationError, e.toString()).to.be.true
})
})
@@ -4612,6 +4612,7 @@ export type WorkspaceBillingMutationsUpgradePlanArgs = {
/** Overridden by `WorkspaceCollaboratorGraphQLReturn` */
export type WorkspaceCollaborator = {
__typename?: 'WorkspaceCollaborator';
email?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** Date that the user joined the workspace. */
joinDate: Scalars['DateTime']['output'];
@@ -4756,6 +4757,7 @@ export type WorkspaceInviteUseInput = {
export type WorkspaceJoinRequest = {
__typename?: 'WorkspaceJoinRequest';
createdAt: Scalars['DateTime']['output'];
email?: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
status: WorkspaceJoinRequestStatus;
user: LimitedUser;
@@ -7515,6 +7517,7 @@ export type WorkspaceBillingMutationsResolvers<ContextType = GraphQLContext, Par
};
export type WorkspaceCollaboratorResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceCollaborator'] = ResolversParentTypes['WorkspaceCollaborator']> = {
email?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
joinDate?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
projectRoles?: Resolver<Array<ResolversTypes['ProjectRole']>, ParentType, ContextType>;
@@ -7566,6 +7569,7 @@ export type WorkspaceInviteMutationsResolvers<ContextType = GraphQLContext, Pare
export type WorkspaceJoinRequestResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceJoinRequest'] = ResolversParentTypes['WorkspaceJoinRequest']> = {
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
email?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
status?: Resolver<ResolversTypes['WorkspaceJoinRequestStatus'], ParentType, ContextType>;
user?: Resolver<ResolversTypes['LimitedUser'], ParentType, ContextType>;
@@ -114,6 +114,7 @@ import { renderEmail } from '@/modules/emails/services/emailRendering'
import { sendEmail } from '@/modules/emails/services/sending'
import { ProjectRecordVisibility } from '@/modules/core/helpers/types'
import { mapDbToGqlProjectVisibility } from '@/modules/core/helpers/project'
import { StreamNotFoundError } from '@/modules/core/errors/stream'
const getServerInfo = getServerInfoFactory({ db })
const getUsers = getUsersFactory({ db })
@@ -249,7 +250,7 @@ const throwIfRateLimited = throwIfRateLimitedFactory({
rateLimiterEnabled: isRateLimiterEnabled()
})
export = {
const resolvers: Resolvers = {
Query: {
async project(_parent, args, context) {
throwIfResourceAccessNotAllowed({
@@ -265,6 +266,29 @@ export = {
throwIfAuthNotOk(canQuery)
const project = await getStream({ streamId: args.id })
if (!project) {
// This should not be happening, because canQuery should've thrown
// And yet it does...so extra logging is necessary
// Test canQuery again - is it a cache issue?
context.clearCache()
const canQueryAgain = await context.authPolicies.project.canRead({
projectId: args.id,
userId: context.userId
})
context.log.error(
{
projectId: args.id,
userId: context.userId,
project,
canQuery,
canQueryAgain
},
'Unexpected project not found'
)
throw new StreamNotFoundError('Project not found')
}
if (project?.visibility !== ProjectRecordVisibility.Public) {
await validateScopes(context.scopes, Scopes.Streams.Read)
@@ -494,7 +518,7 @@ export = {
// Reset loader cache
ctx.clearCache()
return ret
return ret!
},
async leave(_parent, args, context) {
const projectId = args.id
@@ -546,7 +570,8 @@ export = {
return {
totalCount: await ctx.loaders.users.getOwnStreamCount.load(ctx.userId!),
items: [],
cursor: null
cursor: null,
numberOfHidden: 0
}
}
@@ -599,7 +624,7 @@ export = {
Project: {
async role(parent, _args, ctx) {
// If role already resolved, return that
if (has(parent, 'role')) return parent.role
if (has(parent, 'role')) return parent.role || null
return await ctx.loaders.streams.getRole.load(parent.id)
},
@@ -676,4 +701,6 @@ export = {
)
}
}
} as Resolvers
}
export default resolvers
@@ -4592,6 +4592,7 @@ export type WorkspaceBillingMutationsUpgradePlanArgs = {
/** Overridden by `WorkspaceCollaboratorGraphQLReturn` */
export type WorkspaceCollaborator = {
__typename?: 'WorkspaceCollaborator';
email?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** Date that the user joined the workspace. */
joinDate: Scalars['DateTime']['output'];
@@ -4736,6 +4737,7 @@ export type WorkspaceInviteUseInput = {
export type WorkspaceJoinRequest = {
__typename?: 'WorkspaceJoinRequest';
createdAt: Scalars['DateTime']['output'];
email?: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
status: WorkspaceJoinRequestStatus;
user: LimitedUser;
@@ -29,6 +29,7 @@ import {
} from '@/observability/domain/fields'
import { withOperationLogging } from '@/observability/domain/businessLogging'
import { asOperation } from '@/modules/shared/command'
import { getEventBus } from '@/modules/shared/services/eventBus'
export const getBillingRouter = (): Router => {
const router = Router()
@@ -200,7 +201,8 @@ export const getBillingRouter = (): Router => {
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
getWorkspaceSubscriptionBySubscriptionId:
getWorkspaceSubscriptionBySubscriptionIdFactory({ db }),
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db })
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }),
emitEvent: getEventBus().emit
})({ subscriptionData: parseSubscriptionData(event.data.object) }),
{
logger,
@@ -222,7 +224,8 @@ export const getBillingRouter = (): Router => {
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
getWorkspaceSubscriptionBySubscriptionId:
getWorkspaceSubscriptionBySubscriptionIdFactory({ db }),
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db })
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }),
emitEvent: getEventBus().emit
})({ subscriptionData }),
{
logger,
@@ -94,9 +94,11 @@ export const completeCheckoutSessionFactory =
workspacePlan: {
workspaceId: checkoutSession.workspaceId,
status: workspacePlan.status,
name: workspacePlan.name,
previousPlanName: previousPlan?.name
}
name: workspacePlan.name
},
...(previousPlan && {
previousPlan: { name: previousPlan.name }
})
}
})
}
@@ -23,18 +23,21 @@ import {
} from '@speckle/shared'
import { cloneDeep } from 'lodash'
import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
export const handleSubscriptionUpdateFactory =
({
upsertPaidWorkspacePlan,
getWorkspacePlan,
getWorkspaceSubscriptionBySubscriptionId,
upsertWorkspaceSubscription
upsertWorkspaceSubscription,
emitEvent
}: {
getWorkspacePlan: GetWorkspacePlan
upsertPaidWorkspacePlan: UpsertPaidWorkspacePlan
getWorkspaceSubscriptionBySubscriptionId: GetWorkspaceSubscriptionBySubscriptionId
upsertWorkspaceSubscription: UpsertWorkspaceSubscription
emitEvent: EventBusEmit
}) =>
async ({ subscriptionData }: { subscriptionData: SubscriptionData }) => {
// we're only handling marking the sub scheduled for cancelation right now
@@ -101,6 +104,14 @@ export const handleSubscriptionUpdateFactory =
subscriptionData
}
})
await emitEvent({
eventName: 'gatekeeper.workspace-subscription-updated',
payload: {
workspaceId: subscription.workspaceId,
status
}
})
}
}
@@ -200,9 +200,11 @@ export const upgradeWorkspaceSubscriptionFactory =
workspacePlan: {
workspaceId,
status: workspacePlan.status,
name: targetPlan,
previousPlanName: workspacePlan.name
}
name: targetPlan
},
...(workspacePlan && {
previousPlan: { name: workspacePlan.name }
})
}
})
}
@@ -75,16 +75,17 @@ export const updateWorkspacePlanFactory =
default:
throwUncoveredError(name)
}
await emitEvent({
eventName: 'gatekeeper.workspace-plan-updated',
payload: {
workspacePlan: {
name,
status,
workspaceId,
previousPlanName: previousPlan?.name
}
workspaceId
},
...(previousPlan && {
previousPlan: { name: previousPlan.name }
})
}
})
}
@@ -1,6 +1,10 @@
import { WorkspacePlan } from '@speckle/shared'
import cryptoRandomString from 'crypto-random-string'
import { assign } from 'lodash'
import {
SubscriptionData,
WorkspaceSubscription
} from '@/modules/gatekeeper/domain/billing'
export const buildTestWorkspacePlan = (
overrides?: Partial<WorkspacePlan>
@@ -15,3 +19,34 @@ export const buildTestWorkspacePlan = (
},
overrides
)
export const buildTestWorkspaceSubscription = (
overrides?: Partial<WorkspaceSubscription>
): WorkspaceSubscription =>
assign(
{
workspaceId: cryptoRandomString({ length: 10 }),
createdAt: new Date(),
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
billingInterval: 'monthly',
currency: 'usd',
subscriptionData: buildTestSubscriptionData()
},
overrides
)
export const buildTestSubscriptionData = (
overrides?: Partial<SubscriptionData>
): SubscriptionData =>
assign(
{
subscriptionId: cryptoRandomString({ length: 10 }),
customerId: cryptoRandomString({ length: 10 }),
cancelAt: new Date(),
status: 'active',
products: [],
currentPeriodEnd: new Date()
},
overrides
)
@@ -162,8 +162,10 @@ describe('checkout @gatekeeper', () => {
workspacePlan: {
workspaceId,
name: storedCheckoutSession.workspacePlan,
status: 'valid',
previousPlanName: 'free'
status: 'valid'
},
previousPlan: {
name: 'free'
}
})
expect(storedWorkspaceSubscriptionData!.billingInterval).to.equal(
@@ -27,6 +27,7 @@ import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import { omit } from 'lodash'
import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
describe('subscriptions @gatekeeper', () => {
describe('handleSubscriptionUpdateFactory creates a function, that', () => {
@@ -43,6 +44,9 @@ describe('subscriptions @gatekeeper', () => {
},
upsertPaidWorkspacePlan: async () => {
expect.fail()
},
emitEvent: async () => {
expect.fail()
}
})({ subscriptionData })
})
@@ -61,6 +65,9 @@ describe('subscriptions @gatekeeper', () => {
},
upsertPaidWorkspacePlan: async () => {
expect.fail()
},
emitEvent: async () => {
expect.fail()
}
})({ subscriptionData })
})
@@ -76,6 +83,9 @@ describe('subscriptions @gatekeeper', () => {
},
upsertPaidWorkspacePlan: async () => {
expect.fail()
},
emitEvent: async () => {
expect.fail()
}
})({ subscriptionData })
})
@@ -104,6 +114,9 @@ describe('subscriptions @gatekeeper', () => {
},
upsertPaidWorkspacePlan: async () => {
expect.fail()
},
emitEvent: async () => {
expect.fail()
}
})({ subscriptionData })
})
@@ -123,6 +136,12 @@ describe('subscriptions @gatekeeper', () => {
let updatedSubscription: WorkspaceSubscription | undefined = undefined
let updatedPlan: WorkspacePlan | undefined = undefined
let emittedEventName: string | undefined = undefined
let emittedEventPayload: unknown = undefined
const emitEvent: EventBusEmit = async ({ eventName, payload }) => {
emittedEventName = eventName
emittedEventPayload = payload
}
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
@@ -138,7 +157,8 @@ describe('subscriptions @gatekeeper', () => {
},
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
updatedPlan = workspacePlan
}
},
emitEvent
})({ subscriptionData })
expect(updatedPlan!.status).to.be.equal('cancelationScheduled')
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
@@ -147,6 +167,11 @@ describe('subscriptions @gatekeeper', () => {
expect(omit(updatedSubscription!, 'updatedAt')).deep.equal(
omit(workspaceSubscription, 'updatedAt')
)
expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated')
expect(emittedEventPayload).to.deep.eq({
workspaceId,
status: 'cancelationScheduled'
})
})
it('sets the status to valid', async () => {
const subscriptionData = createTestSubscriptionData({
@@ -166,6 +191,12 @@ describe('subscriptions @gatekeeper', () => {
let updatedSubscription: WorkspaceSubscription | undefined = undefined
let updatedPlan: WorkspacePlan | undefined = undefined
let emittedEventName: string | undefined = undefined
let emittedEventPayload: unknown = undefined
const emitEvent: EventBusEmit = async ({ eventName, payload }) => {
emittedEventName = eventName
emittedEventPayload = payload
}
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
@@ -181,7 +212,8 @@ describe('subscriptions @gatekeeper', () => {
},
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
updatedPlan = workspacePlan
}
},
emitEvent
})({ subscriptionData })
expect(updatedPlan!.status).to.be.equal('valid')
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
@@ -190,6 +222,11 @@ describe('subscriptions @gatekeeper', () => {
expect(omit(updatedSubscription!, 'updatedAt')).deep.equal(
omit(workspaceSubscription, 'updatedAt')
)
expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated')
expect(emittedEventPayload).to.deep.eq({
workspaceId,
status: 'valid'
})
})
it('sets the state to paymentFailed', async () => {
const subscriptionData = createTestSubscriptionData({
@@ -204,6 +241,12 @@ describe('subscriptions @gatekeeper', () => {
let updatedSubscription: WorkspaceSubscription | undefined = undefined
let updatedPlan: WorkspacePlan | undefined = undefined
let emittedEventName: string | undefined = undefined
let emittedEventPayload: unknown = undefined
const emitEvent: EventBusEmit = async ({ eventName, payload }) => {
emittedEventName = eventName
emittedEventPayload = payload
}
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
@@ -219,7 +262,8 @@ describe('subscriptions @gatekeeper', () => {
},
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
updatedPlan = workspacePlan
}
},
emitEvent
})({ subscriptionData })
expect(updatedPlan!.status).to.be.equal('paymentFailed')
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
@@ -228,6 +272,11 @@ describe('subscriptions @gatekeeper', () => {
expect(omit(updatedSubscription!, 'updatedAt')).deep.equal(
omit(workspaceSubscription, 'updatedAt')
)
expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated')
expect(emittedEventPayload).to.deep.eq({
workspaceId,
status: 'paymentFailed'
})
})
it('sets the state to canceled', async () => {
const subscriptionData = createTestSubscriptionData({
@@ -246,6 +295,12 @@ describe('subscriptions @gatekeeper', () => {
let updatedSubscription: WorkspaceSubscription | undefined = undefined
let updatedPlan: WorkspacePlan | undefined = undefined
let emittedEventName: string | undefined = undefined
let emittedEventPayload: unknown = undefined
const emitEvent: EventBusEmit = async ({ eventName, payload }) => {
emittedEventName = eventName
emittedEventPayload = payload
}
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
@@ -261,7 +316,8 @@ describe('subscriptions @gatekeeper', () => {
},
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
updatedPlan = workspacePlan
}
},
emitEvent
})({ subscriptionData })
expect(updatedPlan!.status).to.be.equal('canceled')
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
@@ -270,6 +326,11 @@ describe('subscriptions @gatekeeper', () => {
expect(omit(updatedSubscription!, 'updatedAt')).deep.equal(
omit(workspaceSubscription, 'updatedAt')
)
expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated')
expect(emittedEventPayload).to.deep.eq({
workspaceId,
status: 'canceled'
})
})
;(
['incomplete', 'incomplete_expired', 'trialing', 'unpaid', 'paused'] as const
@@ -299,6 +360,9 @@ describe('subscriptions @gatekeeper', () => {
},
upsertPaidWorkspacePlan: async () => {
expect.fail()
},
emitEvent: async () => {
expect.fail()
}
})({ subscriptionData })
})
@@ -1431,8 +1495,10 @@ describe('subscriptions @gatekeeper', () => {
workspacePlan: {
workspaceId,
status: 'valid',
name: 'pro',
previousPlanName: 'team'
name: 'pro'
},
previousPlan: {
name: 'team'
}
})
})
@@ -204,8 +204,7 @@ describe('workspacePlan services @gatekeeper', () => {
expect(emittedEventName).to.equal('gatekeeper.workspace-plan-updated')
expect(eventPayload).to.deep.equal({
workspacePlan: {
...expectedPlan,
...{ previousPlanName: undefined }
...expectedPlan
}
})
}
@@ -246,8 +245,10 @@ describe('workspacePlan services @gatekeeper', () => {
workspacePlan: {
workspaceId,
status: WorkspacePlanStatuses.Valid,
name: PaidWorkspacePlans.ProUnlimited,
previousPlanName: PaidWorkspacePlans.Team
name: PaidWorkspacePlans.ProUnlimited
},
previousPlan: {
name: PaidWorkspacePlans.Team
}
})
})
@@ -7,14 +7,18 @@ const eventPrefix = `${gatekeeperEventNamespace}.` as const
export const GatekeeperEvents = {
WorkspaceTrialExpired: `${eventPrefix}workspace-trial-expired`,
WorkspacePlanUpdated: `${eventPrefix}workspace-plan-updated`,
WorkspaceSubscriptionUpdated: `${eventPrefix}workspace-subscription-updated`,
WorkspaceSeatUpdated: `${eventPrefix}workspace-seat-updated`
} as const
export type GatekeeperEventPayloads = {
[GatekeeperEvents.WorkspaceTrialExpired]: { workspaceId: string }
[GatekeeperEvents.WorkspacePlanUpdated]: {
workspacePlan: Pick<WorkspacePlan, 'name' | 'status' | 'workspaceId'> & {
previousPlanName: WorkspacePlan['name'] | undefined
}
workspacePlan: Pick<WorkspacePlan, 'name' | 'status' | 'workspaceId'>
previousPlan?: Pick<WorkspacePlan, 'name'>
}
[GatekeeperEvents.WorkspaceSubscriptionUpdated]: Pick<
WorkspacePlan,
'workspaceId' | 'status'
>
}
@@ -1,12 +1,16 @@
/* eslint-disable camelcase */
import { Mixpanel } from 'mixpanel'
export type MixpanelFakeEventRecord = Array<{ event: string; payload: unknown }>
type MixpanelFakeStorage = {
events?: MixpanelFakeEventRecord
people?: Record<string, object | string>
groups?: Record<string, object | string>
}
export const buildMixpanelFake = ({
events,
people,
groups
}: MixpanelFakeStorage = {}): Mixpanel => {
@@ -16,7 +20,11 @@ export const buildMixpanelFake = ({
return {
init: notImplemented,
track: notImplemented,
track: (event, payload) => {
if (events) {
events.push({ event, payload })
}
},
track_batch: notImplemented,
import: notImplemented,
import_batch: notImplemented,
@@ -35,7 +35,11 @@ export function initialize() {
export const MixpanelEvents = {
WorkspaceUpgraded: 'Workspace Upgraded',
WorkspaceCreated: 'Workspace Created'
WorkspaceCreated: 'Workspace Created',
WorkspaceDeleted: 'Workspace Deleted',
WorkspaceSubscriptionCanceled: 'Workspace Subscription Canceled',
WorkspaceSubscriptionCancelationScheduled:
'Workspace Subscription Cancelation Scheduled'
} as const
/**
@@ -143,6 +143,7 @@ export type GetWorkspaceCollaboratorsArgs = {
*/
excludeUserIds?: string[]
}
hasAccessToEmail?: boolean
}
export type GetWorkspaceCollaborators = (
@@ -439,7 +440,7 @@ export type UpdateWorkspaceJoinRequestStatus = (params: {
}) => Promise<number[]>
export type CreateWorkspaceJoinRequest = (params: {
workspaceJoinRequest: Omit<WorkspaceJoinRequest, 'createdAt' | 'updatedAt'>
workspaceJoinRequest: Omit<WorkspaceJoinRequest, 'createdAt' | 'updatedAt' | 'email'>
}) => Promise<WorkspaceJoinRequest>
export type SendWorkspaceJoinRequestReceivedEmail = (params: {
@@ -3,6 +3,7 @@ import { LimitedUserRecord, UserWithRole } from '@/modules/core/helpers/types'
import { WorkspaceRoles } from '@speckle/shared'
export type WorkspaceTeamMember = UserWithRole<LimitedUserRecord> & {
email: string | null
workspaceRole: WorkspaceRoles
workspaceRoleCreatedAt: Date
workspaceId: string
@@ -134,6 +134,9 @@ import {
WORKSPACE_TRACKING_ID_KEY
} from '@/modules/workspaces/services/tracking'
import { assign } from 'lodash'
import { WorkspacePlanStatuses } from '@/modules/cross-server-sync/graph/generated/graphql'
import { Mixpanel } from 'mixpanel'
import { GatekeeperEvents } from '@/modules/gatekeeperCore/domain/events'
const { FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags()
@@ -510,7 +513,9 @@ export const workspaceTrackingFactory =
getUserEmails,
getWorkspaceModelCount,
getWorkspacesProjectCount,
getWorkspaceSeatCount
getWorkspaceSeatCount,
mixpanel = getClient(),
getServerTrackingProperties = getBaseTrackingProperties
}: {
getWorkspace: GetWorkspace
countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole
@@ -522,14 +527,15 @@ export const workspaceTrackingFactory =
getWorkspaceModelCount: GetWorkspaceModelCount
getWorkspacesProjectCount: GetWorkspacesProjectsCounts
getWorkspaceSeatCount: GetWorkspaceSeatCount
mixpanel?: Mixpanel
getServerTrackingProperties?: typeof getBaseTrackingProperties
}) =>
async (params: EventPayload<'workspace.*'> | EventPayload<'gatekeeper.*'>) => {
// temp ignoring tracking for this, if billing is not enabled
// this should be sorted with a better separation between workspaces and the gatekeeper module
if (!FF_BILLING_INTEGRATION_ENABLED) return
const { eventName, payload } = params
const mixpanel = getClient()
if (!mixpanel) return
const { eventName, payload } = params
const buildWorkspaceTrackingProperties = buildWorkspaceTrackingPropertiesFactory({
countWorkspaceRole,
@@ -569,7 +575,7 @@ export const workspaceTrackingFactory =
}
switch (eventName) {
case 'gatekeeper.workspace-plan-updated':
case GatekeeperEvents.WorkspacePlanUpdated:
const updatedPlanWorkspace = await getWorkspace({
workspaceId: payload.workspacePlan.workspaceId
})
@@ -589,17 +595,38 @@ export const workspaceTrackingFactory =
[WORKSPACE_TRACKING_ID_KEY]: payload.workspacePlan.workspaceId,
plan: payload.workspacePlan.name,
cycle: subscription?.billingInterval,
previousPlan: payload.workspacePlan.previousPlanName
previousPlan: payload.previousPlan?.name
},
getBaseTrackingProperties()
getServerTrackingProperties()
)
)
break
case 'gatekeeper.workspace-trial-expired':
case GatekeeperEvents.WorkspaceSubscriptionUpdated:
if (payload.status === WorkspacePlanStatuses.Canceled) {
mixpanel.track(
MixpanelEvents.WorkspaceSubscriptionCanceled,
assign(
{ [WORKSPACE_TRACKING_ID_KEY]: payload.workspaceId },
getServerTrackingProperties()
)
)
}
if (payload.status === WorkspacePlanStatuses.CancelationScheduled) {
mixpanel.track(
MixpanelEvents.WorkspaceSubscriptionCancelationScheduled,
assign(
{ [WORKSPACE_TRACKING_ID_KEY]: payload.workspaceId },
getServerTrackingProperties()
)
)
}
break
case GatekeeperEvents.WorkspaceTrialExpired:
break
case WorkspaceEvents.Authorizing:
break
case 'workspace.created':
case WorkspaceEvents.Created:
// we're setting workspace props and attributing to speckle users
mixpanel.groups.set(
WORKSPACE_TRACKING_ID_KEY,
@@ -614,11 +641,11 @@ export const workspaceTrackingFactory =
assign(
{ [WORKSPACE_TRACKING_ID_KEY]: payload.workspace.id },
await getUserTrackingProperties({ userId: payload.createdByUserId }),
getBaseTrackingProperties()
getServerTrackingProperties()
)
)
break
case 'workspace.updated':
case WorkspaceEvents.Updated:
// just updating workspace props
mixpanel.groups.set(
WORKSPACE_TRACKING_ID_KEY,
@@ -626,16 +653,23 @@ export const workspaceTrackingFactory =
await buildWorkspaceTrackingProperties(payload.workspace)
)
break
case 'workspace.deleted':
case WorkspaceEvents.Deleted:
// just marking workspace deleted
mixpanel.groups.set(
WORKSPACE_TRACKING_ID_KEY,
payload.workspaceId,
assign({ isDeleted: true }, getBaseTrackingProperties())
assign({ isDeleted: true }, getServerTrackingProperties())
)
mixpanel.track(
MixpanelEvents.WorkspaceDeleted,
assign(
{ [WORKSPACE_TRACKING_ID_KEY]: payload.workspaceId },
getServerTrackingProperties()
)
)
break
case 'workspace.role-deleted':
case 'workspace.role-updated':
case WorkspaceEvents.RoleDeleted:
case WorkspaceEvents.RoleUpdated:
case WorkspaceEvents.SeatUpdated:
const entity = 'acl' in payload ? payload.acl : payload.seat
const workspace = await getWorkspace({ workspaceId: entity.workspaceId })
@@ -88,6 +88,14 @@ export default FF_WORKSPACES_MODULE_ENABLED
user: async (parent, _args, ctx) => {
return await ctx.loaders.users.getUser.load(parent.userId)
},
email: async (parent, _args, ctx) => {
const hasAccessToEmail = await ctx.authPolicies.workspace.canReadMemberEmail({
workspaceId: parent.workspaceId,
userId: ctx.userId
})
if (!hasAccessToEmail.isOk) return null
return parent.email
},
workspace: async (parent, _args, ctx) => {
return await ctx.loaders.workspaces!.getWorkspace.load(parent.workspaceId)
}
@@ -1565,7 +1565,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
})
return acl?.role || null
},
team: async (parent, args) => {
team: async (parent, args, ctx) => {
const roles = args.filter?.roles?.map((r) => {
const role = r as WorkspaceRoles
if (!Object.values(Roles.Workspace).includes(role)) {
@@ -1575,6 +1575,10 @@ export = FF_WORKSPACES_MODULE_ENABLED
}
return role
})
const hasAccessToEmail = await ctx.authPolicies.workspace.canReadMemberEmail({
workspaceId: parent.id,
userId: ctx.userId
})
const filter = removeNullOrUndefinedKeys({
...args?.filter,
roles
@@ -1586,7 +1590,8 @@ export = FF_WORKSPACES_MODULE_ENABLED
workspaceId: parent.id,
filter,
limit: args.limit,
cursor: args.cursor ?? undefined
cursor: args.cursor ?? undefined,
hasAccessToEmail: hasAccessToEmail.isOk
})
return team
},
@@ -1809,13 +1814,19 @@ export = FF_WORKSPACES_MODULE_ENABLED
const token = parent.token
const authedUserId = ctx.userId
const canReadMemberEmail =
await ctx.authPolicies.workspace.canReadMemberEmail({
workspaceId: parent.workspaceId,
userId: ctx.userId
})
const targetUserId = parent.user?.id
// Only returning it for the user that is the pending stream collaborator
// OR if the token was specified
// OR if the policy allows
if (
(!authedUserId || !targetUserId || authedUserId !== targetUserId) &&
!token
!token &&
!canReadMemberEmail.isOk
) {
return null
}
@@ -1,3 +1,4 @@
import { UserEmails } from '@/modules/core/dbSchema'
import {
CreateWorkspaceJoinRequest,
GetWorkspaceJoinRequest,
@@ -70,6 +71,12 @@ const adminWorkspaceJoinRequestsBaseQueryFactory =
WorkspaceAcl.col.workspaceId,
WorkspaceJoinRequests.col.workspaceId
)
.join(UserEmails.name, UserEmails.col.userId, WorkspaceJoinRequests.col.userId)
// returning the primary here as a shortcut
// should be doing an intersection with the workspace domains, the users emails
// and be distincted to a single user
// but for now, multi email usage is low enough to not warrant that here
.where(UserEmails.col.primary, '=', true)
.where(WorkspaceAcl.col.role, Roles.Workspace.Admin)
.where(WorkspaceAcl.col.userId, filter.userId)
.where(WorkspaceJoinRequests.col.workspaceId, filter.workspaceId)
@@ -95,7 +102,10 @@ export const getAdminWorkspaceJoinRequestsFactory =
query.andWhere(WorkspaceJoinRequests.col.createdAt, '<', cursor)
}
return await query
.select<WorkspaceJoinRequest[]>(WorkspaceJoinRequests.cols)
.select<WorkspaceJoinRequest[]>([
...WorkspaceJoinRequests.cols,
UserEmails.col.email
])
.orderBy(WorkspaceJoinRequests.col.createdAt, 'desc')
.limit(limit)
}
@@ -58,7 +58,14 @@ import {
Workspaces,
WorkspaceSeats
} from '@/modules/workspaces/helpers/db'
import { knex, ServerAcl, StreamAcl, Streams, Users } from '@/modules/core/dbSchema'
import {
knex,
ServerAcl,
StreamAcl,
Streams,
UserEmails,
Users
} from '@/modules/core/dbSchema'
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import { clamp, has, isObjectLike } from 'lodash'
@@ -408,7 +415,7 @@ export const getWorkspaceCollaboratorsTotalCountFactory =
export const getWorkspaceCollaboratorsFactory =
({ db }: { db: Knex }): GetWorkspaceCollaborators =>
async ({ workspaceId, filter = {}, cursor, limit = 25 }) => {
async ({ workspaceId, filter = {}, cursor, limit = 25, hasAccessToEmail }) => {
const query = db
.from(Users.name)
.select<Array<WorkspaceTeamMember & { workspaceRoleCreatedAt: Date }>>(
@@ -419,8 +426,14 @@ export const getWorkspaceCollaboratorsFactory =
DbWorkspaceAcl.colAs('createdAt', 'workspaceRoleCreatedAt')
)
.join(DbWorkspaceAcl.name, DbWorkspaceAcl.col.userId, Users.col.id)
.join(UserEmails.name, Users.col.id, UserEmails.col.userId)
.join(ServerAcl.name, ServerAcl.col.userId, Users.col.id)
.where(DbWorkspaceAcl.col.workspaceId, workspaceId)
// this will only get the primary email of a user
// if the user has a secondary email matching the workspace's domain
// it will not be surfaced by this query
//
.andWhere(UserEmails.col.primary, true)
.orderBy('workspaceRoleCreatedAt', 'desc')
const { search, roles, seatType, excludeUserIds } = filter || {}
@@ -462,6 +475,7 @@ export const getWorkspaceCollaboratorsFactory =
const items = (await query).map((i) => ({
...removePrivateFields(i),
email: hasAccessToEmail ? i.email : null,
workspaceRole: i.workspaceRole,
workspaceRoleCreatedAt: i.workspaceRoleCreatedAt,
workspaceId: i.workspaceId,
@@ -518,7 +518,6 @@ export const getPendingWorkspaceCollaboratorsFactory =
results.push(buildPendingWorkspaceCollaboratorModel(invite, user))
}
return results
}
@@ -137,6 +137,7 @@ export const buildWorkspaceTrackingPropertiesFactory =
createdAt: workspace.createdAt,
projectCount: workspacesProjectCount[workspace.id] || 0,
modelCount,
lastSyncAt: new Date(),
...getBaseTrackingProperties()
}
}
@@ -128,8 +128,9 @@ import {
validateStreamAccessFactory
} from '@/modules/core/services/streams/access'
import { authorizeResolver } from '@/modules/shared'
import { createRandomString } from '@/modules/core/helpers/testHelpers'
import { WorkspaceCreationState } from '@/modules/workspaces/domain/types'
import { WorkspaceWithOptionalRole } from '@/modules/workspacesCore/domain/types'
import { WorkspaceRole } from '@/modules/cross-server-sync/graph/generated/graphql'
const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
@@ -353,14 +354,35 @@ export const buildBasicTestWorkspace = (
): BasicTestWorkspace =>
assign(
{
id: createRandomString(),
name: createRandomString(),
slug: createRandomString(),
id: cryptoRandomString({ length: 10 }),
name: cryptoRandomString({ length: 10 }),
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
},
overrides
)
export const buildTestWorkspaceWithOptionalRole = (
overrides?: Partial<WorkspaceWithOptionalRole>
): WorkspaceWithOptionalRole =>
assign(
{
id: cryptoRandomString({ length: 10 }),
name: cryptoRandomString({ length: 10 }),
slug: cryptoRandomString({ length: 10 }),
description: cryptoRandomString({ length: 10 }),
createdAt: new Date(),
updatedAt: new Date(),
logo: cryptoRandomString({ length: 10 }),
domainBasedMembershipProtectionEnabled: false,
discoverabilityEnabled: true,
discoverabilityAutoJoinEnabled: true,
isEmbedSpeckleBrandingHidden: true,
role: WorkspaceRole.Member
},
overrides
)
export const assignToWorkspace = async (
workspace: BasicTestWorkspace,
user: BasicTestUser,
@@ -0,0 +1,238 @@
import { workspaceTrackingFactory } from '@/modules/workspaces/events/eventListener'
import { buildBasicTestUser } from '@/test/authHelper'
import { buildTestWorkspaceWithOptionalRole } from '@/modules/workspaces/tests/helpers/creation'
import {
CountWorkspaceRoleWithOptionalProjectRole,
GetDefaultRegion,
GetWorkspace,
GetWorkspaceModelCount,
GetWorkspaceSeatCount,
GetWorkspacesProjectsCounts
} from '@/modules/workspaces/domain/operations'
import {
buildTestWorkspacePlan,
buildTestWorkspaceSubscription
} from '@/modules/gatekeeper/tests/helpers/workspacePlan'
import {
GetWorkspacePlan,
GetWorkspaceSubscription
} from '@/modules/gatekeeper/domain/billing'
import {
FindEmailsByUserId,
FindPrimaryEmailForUser
} from '@/modules/core/domain/userEmails/operations'
import {
buildMixpanelFake,
MixpanelFakeEventRecord
} from '@/modules/shared/test/helpers/mixpanel'
import { getFeatureFlags } from '@speckle/shared/environment'
import { GatekeeperEvents } from '@/modules/gatekeeperCore/domain/events'
import { MixpanelEvents } from '@/modules/shared/utils/mixpanel'
import { expect } from 'chai'
import { WORKSPACE_TRACKING_ID_KEY } from '@/modules/workspaces/services/tracking'
import { WorkspacePlanStatuses } from '@speckle/shared'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
const { FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags()
;(FF_BILLING_INTEGRATION_ENABLED ? describe : describe.skip)(
'workspaceTrackingFactory creates a function, that @workspaceEventListener',
() => {
const workspace = buildTestWorkspaceWithOptionalRole()
const user = buildBasicTestUser()
const email = {
id: user.id,
email: user.email,
primary: true,
verified: true,
userId: user.id,
createdAt: new Date(),
updatedAt: new Date()
}
const region = {
key: 'reg-1',
name: 'Region',
description: '',
createdAt: new Date(),
updatedAt: new Date()
}
const baseTrackingProperties = {
// eslint-disable-next-line camelcase
server_id: 'tracking_server_id',
speckleVersion: 'test',
hostApp: 'serverside'
}
const workspacePlan = buildTestWorkspacePlan({ workspaceId: workspace.id })
const workspaceSubscribtion = buildTestWorkspaceSubscription({
workspaceId: workspace.id
})
const getWorkspace: GetWorkspace = async () => workspace
const countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole = async () => 0
const getDefaultRegion: GetDefaultRegion = async () => region
const getWorkspacePlan: GetWorkspacePlan = async () => workspacePlan
const getWorkspaceSubscription: GetWorkspaceSubscription = async () =>
workspaceSubscribtion
const findPrimaryEmailForUser: FindPrimaryEmailForUser = async () => email
const getUserEmails: FindEmailsByUserId = async () => [email]
const getWorkspaceModelCount: GetWorkspaceModelCount = async () => 20
const getWorkspacesProjectCount: GetWorkspacesProjectsCounts = async () => ({
[workspace.id]: 10
})
const getWorkspaceSeatCount: GetWorkspaceSeatCount = async () => 5
const defaults = {
getWorkspace,
countWorkspaceRole,
getDefaultRegion,
getWorkspacePlan,
getWorkspaceSubscription,
findPrimaryEmailForUser,
getUserEmails,
getWorkspaceModelCount,
getWorkspacesProjectCount,
getWorkspaceSeatCount,
getServerTrackingProperties: () => baseTrackingProperties
}
it('pushes a Mixpanel Upgrade event when workspace plan was upgraded', async () => {
const events: MixpanelFakeEventRecord = []
const workspaceTracking = workspaceTrackingFactory({
...defaults,
mixpanel: buildMixpanelFake({ events })
})
await workspaceTracking({
eventName: GatekeeperEvents.WorkspacePlanUpdated,
payload: {
workspacePlan: {
workspaceId: workspacePlan.workspaceId,
name: workspacePlan.name,
status: workspacePlan.status
},
previousPlan: {
name: 'free'
}
}
})
const event = events[0]
expect(events).to.have.lengthOf(1)
expect(event.event).to.be.eq(MixpanelEvents.WorkspaceUpgraded)
expect(event.payload).to.be.deep.eq({
[WORKSPACE_TRACKING_ID_KEY]: workspace.id,
plan: workspacePlan.name,
cycle: workspaceSubscribtion.billingInterval,
previousPlan: 'free',
hostApp: 'serverside',
speckleVersion: 'test',
// eslint-disable-next-line camelcase
server_id: 'tracking_server_id'
})
}),
[WorkspacePlanStatuses.PaymentFailed, WorkspacePlanStatuses.Valid].forEach(
(status) => {
it(`does not send anything to mixpanel on subscription update regarding the status ${status}`, async () => {
const events: MixpanelFakeEventRecord = []
const workspaceTracking = workspaceTrackingFactory({
...defaults,
mixpanel: buildMixpanelFake({ events })
})
await workspaceTracking({
eventName: GatekeeperEvents.WorkspaceSubscriptionUpdated,
payload: {
workspaceId: workspace.id,
status
}
})
expect(events).to.have.lengthOf(0)
})
}
)
it(`sends a canceled event to mixpanel on subscription cancelation`, async () => {
const events: MixpanelFakeEventRecord = []
const workspaceTracking = workspaceTrackingFactory({
...defaults,
mixpanel: buildMixpanelFake({ events })
})
await workspaceTracking({
eventName: GatekeeperEvents.WorkspaceSubscriptionUpdated,
payload: {
workspaceId: workspace.id,
status: WorkspacePlanStatuses.Canceled
}
})
const event = events[0]
expect(events).to.have.lengthOf(1)
expect(event.event).to.be.eq(MixpanelEvents.WorkspaceSubscriptionCanceled)
expect(event.payload).to.be.deep.eq({
[WORKSPACE_TRACKING_ID_KEY]: workspace.id,
hostApp: 'serverside',
speckleVersion: 'test',
// eslint-disable-next-line camelcase
server_id: 'tracking_server_id'
})
})
it(`sends a CancelSchedule event to mixpanel when a subscription is scheduled to be canceled`, async () => {
const events: MixpanelFakeEventRecord = []
const workspaceTracking = workspaceTrackingFactory({
...defaults,
mixpanel: buildMixpanelFake({ events })
})
await workspaceTracking({
eventName: GatekeeperEvents.WorkspaceSubscriptionUpdated,
payload: {
workspaceId: workspace.id,
status: WorkspacePlanStatuses.CancelationScheduled
}
})
const event = events[0]
expect(events).to.have.lengthOf(1)
expect(event.event).to.be.eq(
MixpanelEvents.WorkspaceSubscriptionCancelationScheduled
)
expect(event.payload).to.be.deep.eq({
[WORKSPACE_TRACKING_ID_KEY]: workspace.id,
hostApp: 'serverside',
speckleVersion: 'test',
// eslint-disable-next-line camelcase
server_id: 'tracking_server_id'
})
})
it('sends a custom delete mixpanel event on Workspace Delete', async () => {
const events: MixpanelFakeEventRecord = []
const workspaceTracking = workspaceTrackingFactory({
...defaults,
mixpanel: buildMixpanelFake({ events })
})
await workspaceTracking({
eventName: WorkspaceEvents.Deleted,
payload: {
workspaceId: workspace.id
}
})
const event = events[0]
expect(events).to.have.lengthOf(1)
expect(event.event).to.be.eq(MixpanelEvents.WorkspaceDeleted)
expect(event.payload).to.be.deep.eq({
[WORKSPACE_TRACKING_ID_KEY]: workspace.id,
hostApp: 'serverside',
speckleVersion: 'test',
// eslint-disable-next-line camelcase
server_id: 'tracking_server_id'
})
})
}
)
@@ -71,6 +71,7 @@ export type WorkspaceJoinRequestStatus = 'pending' | 'approved' | 'denied' | 'di
export type WorkspaceJoinRequest = {
workspaceId: string
userId: string
email: string
status: WorkspaceJoinRequestStatus
createdAt: Date
updatedAt: Date
@@ -24,6 +24,11 @@ export = !FF_WORKSPACES_MODULE_ENABLED
Mutation: {
workspaceMutations: () => ({})
},
ActiveUserMutations: {
setActiveWorkspace: async () => {
throw new WorkspacesModuleDisabledError()
}
},
WorkspaceMutations: {
create: async () => {
throw new WorkspacesModuleDisabledError()
@@ -4593,6 +4593,7 @@ export type WorkspaceBillingMutationsUpgradePlanArgs = {
/** Overridden by `WorkspaceCollaboratorGraphQLReturn` */
export type WorkspaceCollaborator = {
__typename?: 'WorkspaceCollaborator';
email?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** Date that the user joined the workspace. */
joinDate: Scalars['DateTime']['output'];
@@ -4737,6 +4738,7 @@ export type WorkspaceInviteUseInput = {
export type WorkspaceJoinRequest = {
__typename?: 'WorkspaceJoinRequest';
createdAt: Scalars['DateTime']['output'];
email?: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
status: WorkspaceJoinRequestStatus;
user: LimitedUser;
+3 -1
View File
@@ -30,6 +30,7 @@ import { canDeleteAutomationPolicy } from './project/automation/canDelete.js'
import { canPublishPolicy } from './project/canPublish.js'
import { canLoadPolicy } from './project/canLoad.js'
import { canUpdateEmbedOptionsPolicy } from './workspace/canUpdateEmbedOptions.js'
import { canReadMemberEmailPolicy } from './workspace/canReadMemberEmail.js'
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
project: {
@@ -74,7 +75,8 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
canInvite: canInviteToWorkspacePolicy(loaders),
canReceiveProjectsUpdatedMessage:
canReceiveWorkspaceProjectsUpdatedMessagePolicy(loaders),
canUpdateEmbedOptions: canUpdateEmbedOptionsPolicy(loaders)
canUpdateEmbedOptions: canUpdateEmbedOptionsPolicy(loaders),
canReadMemberEmail: canReadMemberEmailPolicy(loaders)
}
})
@@ -0,0 +1,137 @@
import cryptoRandomString from 'crypto-random-string'
import { Roles } from '../../../core/constants.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { Workspace } from '../../domain/workspaces/types.js'
import { WorkspacePlan } from '../../../workspaces/index.js'
import { describe, expect, it } from 'vitest'
import {
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceNotEnoughPermissionsError,
WorkspacesNotEnabledError
} from '../../domain/authErrors.js'
import { canReadMemberEmailPolicy } from './canReadMemberEmail.js'
const buildCanReadMemberEmailPolicy = (
overrides?: Partial<Parameters<typeof canReadMemberEmailPolicy>[0]>
) => {
const workspaceId = cryptoRandomString({ length: 9 })
return canReadMemberEmailPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getServerRole: async () => Roles.Server.Admin,
getWorkspace: async () => {
return {
id: workspaceId,
slug: cryptoRandomString({ length: 9 })
} as Workspace
},
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getWorkspacePlan: async () => {
return {
workspaceId,
name: 'unlimited',
status: 'valid',
createdAt: new Date()
} as WorkspacePlan
},
...overrides
})
}
const getPolicyArgs = () => ({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 })
})
describe('canReadMemberEmailPolicy', () => {
it('returns error if workspaces is not enabled', async () => {
const policy = buildCanReadMemberEmailPolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' })
})
const result = await policy({
...getPolicyArgs(),
userId: undefined
})
expect(result).toBeAuthErrorResult({
code: WorkspacesNotEnabledError.code
})
})
it('returns error if user is not logged in', async () => {
const policy = buildCanReadMemberEmailPolicy()
const result = await policy({
...getPolicyArgs(),
userId: undefined
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('returns error if user is not found', async () => {
const policy = buildCanReadMemberEmailPolicy({
getServerRole: async () => null
})
const result = await policy(getPolicyArgs())
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('returns error if user is a server guest', async () => {
const policy = buildCanReadMemberEmailPolicy({
getServerRole: async () => Roles.Server.Guest
})
const result = await policy(getPolicyArgs())
expect(result).toBeAuthErrorResult({
code: ServerNotEnoughPermissionsError.code
})
})
it('returns error if workspace does not exist', async () => {
const policy = buildCanReadMemberEmailPolicy({
getWorkspace: async () => null
})
const result = await policy(getPolicyArgs())
expect(result).toBeAuthErrorResult({
code: WorkspaceNoAccessError.code
})
})
it('returns error if user is not workspace admin', async () => {
const policy = buildCanReadMemberEmailPolicy({
getWorkspaceRole: async () => Roles.Workspace.Member
})
const result = await policy(getPolicyArgs())
expect(result).toBeAuthErrorResult({
code: WorkspaceNotEnoughPermissionsError.code
})
})
it('returns ok if user is workspace admin', async () => {
const policy = buildCanReadMemberEmailPolicy()
const result = await policy(getPolicyArgs())
expect(result).toBeAuthOKResult()
})
})
@@ -0,0 +1,67 @@
import { err, ok } from 'true-myth/result'
import { MaybeUserContext, WorkspaceContext } from '../../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureWorkspaceRoleAndSessionFragment,
ensureWorkspacesEnabledFragment
} from '../../fragments/workspaces.js'
import {
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceNotEnoughPermissionsError,
WorkspacesNotEnabledError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import { Roles } from '../../../core/constants.js'
type PolicyArgs = MaybeUserContext & WorkspaceContext
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getServerRole
| typeof AuthCheckContextLoaderKeys.getWorkspace
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
type PolicyErrors =
| InstanceType<typeof WorkspaceNoAccessError>
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
| InstanceType<typeof WorkspacesNotEnabledError>
| InstanceType<typeof ServerNoSessionError>
| InstanceType<typeof ServerNoAccessError>
| InstanceType<typeof ServerNotEnoughPermissionsError>
| InstanceType<typeof WorkspaceNotEnoughPermissionsError>
export const canReadMemberEmailPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, workspaceId }) => {
const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({})
if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error)
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId,
role: Roles.Server.User
})
if (ensuredServerRole.isErr) return err(ensuredServerRole.error)
const ensuredWorkspaceAccess = await ensureWorkspaceRoleAndSessionFragment(loaders)(
{
userId: userId!,
workspaceId,
role: Roles.Workspace.Admin
}
)
if (ensuredWorkspaceAccess.isErr) return err(ensuredWorkspaceAccess.error)
return ok()
}
@@ -92,14 +92,17 @@ export class MeasurementPointGizmo extends Group {
}
private getNormalIndicatorMaterial() {
const material = new SpeckleLineMaterial({
color: 0x047efb,
linewidth: 1,
worldUnits: false,
vertexColors: false,
alphaToCoverage: false,
resolution: new Vector2(1, 1)
})
const material = new SpeckleLineMaterial(
{
color: 0x047efb,
linewidth: 1,
worldUnits: false,
vertexColors: false,
alphaToCoverage: false,
resolution: new Vector2(1, 1)
},
['USE_RTE']
)
material.color = new Color(this._style.normalIndicatorColor)
material.color.convertSRGBToLinear()
material.toneMapped = false
@@ -11,6 +11,20 @@ server {
proxy_set_header Connection "upgrade";
}
location ~* ^/(graphql|explorer|(auth/.*)|(objects/.*)|(preview/.*)|(api/.*)|(static/.*)) {
resolver 127.0.0.11 valid=30s;
set $upstream_speckle_server speckle-server;
client_max_body_size 10m;
proxy_pass http://127.0.0.1:3000;
proxy_buffering off;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location ~* ^/api/file/.* {
resolver 127.0.0.11 valid=30s;
set $upstream_speckle_server speckle-server;
client_max_body_size 300m;
@@ -0,0 +1,28 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: speckle-server-minion-api-file
namespace: {{ .Values.namespace }}
labels:
{{ include "speckle.labels" . | indent 4 }}
annotations:
nginx.org/mergeable-ingress-type: "minion"
{{- if .Values.cert_manager_issuer }}
cert-manager.io/cluster-issuer: {{ .Values.cert_manager_issuer }}
{{- end }}
nginx.ingress.kubernetes.io/proxy-body-size: {{ (printf "%dm" (int .Values.file_size_limit_mb)) | quote }}
spec:
ingressClassName: nginx
rules:
- host: {{ .Values.domain }}
http:
paths:
- pathType: Prefix
path: "/api/file/"
backend:
service:
name: speckle-objects
port:
name: web
{{- end }}
@@ -11,8 +11,6 @@ metadata:
{{- if .Values.cert_manager_issuer }}
cert-manager.io/cluster-issuer: {{ .Values.cert_manager_issuer }}
{{- end }}
nginx.ingress.kubernetes.io/proxy-body-size: {{ (printf "%dm" (int .Values.file_size_limit_mb)) | quote }}
nginx.org/client-max-body-size: {{ (printf "%dm" (int .Values.file_size_limit_mb)) | quote }}
spec:
ingressClassName: nginx
{{- if .Values.cert_manager_issuer }}
@@ -11,7 +11,7 @@ metadata:
{{- if .Values.cert_manager_issuer }}
cert-manager.io/cluster-issuer: {{ .Values.cert_manager_issuer }}
{{- end }}
nginx.ingress.kubernetes.io/proxy-body-size: {{ (printf "%dm" (int .Values.file_size_limit_mb)) | quote }}
nginx.ingress.kubernetes.io/proxy-body-size: {{ (printf "%dm" (int .Values.ingress.client_max_body_size_mb)) | quote }}
spec:
ingressClassName: nginx
rules:
@@ -61,6 +61,7 @@ spec:
port:
name: web
- pathType: Prefix
# for /api/file/ see file.minion.ingress.yml
path: "/api/"
backend:
service:
@@ -154,6 +154,11 @@
"type": "string",
"description": "The name of the Kubernetes pod in which the ingress controller is deployed.",
"default": "ingress-nginx"
},
"client_max_body_size_mb": {
"type": "number",
"description": "This maximum size of the body of any request (except file uploads) that can be sent to Speckle. For file uploads, the maximum size is defined by the `.file_size_limit_mb` parameter.",
"default": 10
}
}
},
+5
View File
@@ -94,9 +94,14 @@ ingress:
##
enabled: true
## @param ingress.namespace The namespace in which the ingress controller is deployed.
##
namespace: ingress-nginx
## @param ingress.controllerName The name of the Kubernetes pod in which the ingress controller is deployed.
##
controllerName: ingress-nginx
## @param ingress.client_max_body_size_mb This maximum size of the body of any request (except file uploads) that can be sent to Speckle. For file uploads, the maximum size is defined by the `.file_size_limit_mb` parameter.
##
client_max_body_size_mb: 10
## @section Common parameters
##