Merge branch 'main' into andrew/placeholder-updates-to-workspace-settings
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
##
|
||||
|
||||
Reference in New Issue
Block a user