Merge branch 'main' into andrew/web-3060-create-model-button-should-trigger-modal

This commit is contained in:
andrewwallacespeckle
2025-04-18 13:58:33 +01:00
36 changed files with 1308 additions and 395 deletions
@@ -21,6 +21,7 @@
:rules="[isEmailOrEmpty]"
/>
<FormTextInput
v-else
v-model="input"
:name="`input-${item.key}`"
color="foundation"
@@ -298,7 +298,7 @@ const buttonText = computed(() => {
}
// Billing interval and lower plan case
if (isDowngrade.value) {
return `Downgrade to ${props.plan}`
return `Downgrade to ${formatName(props.plan)}`
}
// Billing interval change and current plan
if (isAnnualToMonthly.value) {
@@ -60,7 +60,7 @@ import {
} from '~/lib/projects/graphql/queries'
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import { Roles, type Nullable } from '@speckle/shared'
import type { Nullable } from '@speckle/shared'
import type { AutomateOnboardingAction } from '~/components/project/page/automations/EmptyState.vue'
const route = useRoute()
@@ -88,12 +88,27 @@ const workspace = computed(() => result.value?.project?.workspace ?? undefined)
const workspaceFunctionCount = computed(
() => result.value?.project.workspace?.automateFunctions.totalCount ?? 0
)
const canCreateAutomation = computed(
() => result.value?.project?.permissions.canCreateAutomation
)
const hiddenActions = computed<AutomateOnboardingAction[]>(() => {
return workspaceFunctionCount.value > 0 ? [] : ['view-functions']
})
const disabledActions = computed<
{ action: AutomateOnboardingAction; reason: string }[]
>(() => {
if (!canCreateAutomation.value?.authorized) {
return [
{
action: 'create-automation',
reason:
canCreateAutomation.value?.message ??
'You are not authorized to create an automation.'
}
]
}
if (workspaceFunctionCount.value === 0) {
return [
{
@@ -103,14 +118,6 @@ const disabledActions = computed<
}
]
}
if (result.value?.project?.role !== Roles.Stream.Owner) {
return [
{
action: 'create-automation',
reason: 'Only project owners can create new automations.'
}
]
}
if ((result.value?.project?.models?.items.length || 0) === 0) {
return [
{
@@ -128,7 +128,6 @@ import {
import { graphql } from '~~/lib/common/generated/gql'
import type { WorkspaceRoles } from '@speckle/shared'
import {
homeRoute,
projectsRoute,
settingsWorkspaceRoutes,
workspaceRoute
@@ -209,7 +208,6 @@ const needsSsoSession = (
}
const exitSettingsRoute = computed(() => {
if (import.meta.server) return homeRoute
if (!settingsMenuState.value.previousRoute) {
return activeWorkspaceSlug.value
? workspaceRoute(activeWorkspaceSlug.value)
@@ -79,6 +79,7 @@
:items="actionItems[item.id]"
mount-menu-on-body
:menu-position="HorizontalDirection.Left"
:menu-id="menuId"
@chosen="({ item: actionItem }) => onActionChosen(actionItem, item)"
>
<FormButton
@@ -163,6 +164,7 @@ const props = defineProps<{
const search = defineModel<string>('search')
const { on, bind } = useDebouncedTextInput({ model: search })
const router = useRouter()
const menuId = useId()
const projectToModify = ref<ProjectsDeleteDialog_ProjectFragment | null>(null)
const showProjectDeleteDialog = ref(false)
@@ -111,7 +111,7 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
},
{
text: isUpgrading.value
? isFreePlan.value || hasAvailableEditorSeats.value || isUnlimitedPlan.value
? hasAvailableEditorSeats.value || !isPaidPlan.value
? 'Upgrade seat'
: 'Confirm and pay'
: 'Downgrade seat',
@@ -8,6 +8,7 @@
:hide-closer="preventClose"
:prevent-close-on-click-outside="preventClose"
:title="condensed ? 'Plan limit reached' : undefined"
closer-classes="hover:!bg-transparent !text-white hover:opacity-65"
>
<div class="flex flex-col">
<div v-if="!condensed" class="relative bg-primary h-32 md:h-48 select-none">
@@ -266,7 +266,7 @@ type Documents = {
"\n query ProjectModelVersions(\n $projectId: String!\n $modelId: String!\n $versionsCursor: String\n ) {\n project(id: $projectId) {\n id\n ...ProjectModelPageVersionsPagination\n }\n }\n": typeof types.ProjectModelVersionsDocument,
"\n query ProjectModelsPage($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectModelsPageHeader_Project\n ...ProjectModelsPageResults_Project\n }\n }\n": typeof types.ProjectModelsPageDocument,
"\n query ProjectDiscussionsPage($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectDiscussionsPageHeader_Project\n ...ProjectDiscussionsPageResults_Project\n }\n }\n": typeof types.ProjectDiscussionsPageDocument,
"\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n": typeof types.ProjectAutomationsTabDocument,
"\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n permissions {\n canCreateAutomation {\n ...FullPermissionCheckResult\n }\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n": typeof types.ProjectAutomationsTabDocument,
"\n query ProjectAutomationsTabAutomationsPagination(\n $projectId: String!\n $search: String = null\n $cursor: String = null\n ) {\n project(id: $projectId) {\n id\n automations(filter: $search, cursor: $cursor, limit: 5) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n }\n }\n }\n": typeof types.ProjectAutomationsTabAutomationsPaginationDocument,
"\n query ProjectAutomationPage($projectId: String!, $automationId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageAutomationPage_Project\n automation(id: $automationId) {\n id\n ...ProjectPageAutomationPage_Automation\n }\n }\n }\n": typeof types.ProjectAutomationPageDocument,
"\n query ProjectAutomationPagePaginatedRuns(\n $projectId: String!\n $automationId: String!\n $cursor: String = null\n ) {\n project(id: $projectId) {\n id\n automation(id: $automationId) {\n id\n runs(cursor: $cursor, limit: 10) {\n totalCount\n cursor\n items {\n id\n ...AutomationRunDetails\n }\n }\n }\n }\n }\n": typeof types.ProjectAutomationPagePaginatedRunsDocument,
@@ -420,7 +420,7 @@ type Documents = {
"\n query AutomateFunctionPage($functionId: ID!) {\n automateFunction(id: $functionId) {\n ...AutomateFunctionPage_AutomateFunction\n }\n activeUser {\n workspaces {\n items {\n ...AutomateFunctionCreateDialog_Workspace\n ...AutomateFunctionEditDialog_Workspace\n }\n }\n }\n }\n": typeof types.AutomateFunctionPageDocument,
"\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n": typeof types.AutomateFunctionPageWorkspaceDocument,
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...WorkspaceMoveProjectManager_ProjectBase\n ...ProjectPageSettingsTab_Project\n }\n": typeof types.ProjectPageProjectFragmentDoc,
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": typeof types.ProjectPageAutomationPage_AutomationFragmentDoc,
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": typeof types.ProjectPageAutomationPage_AutomationFragmentDoc,
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": typeof types.ProjectPageAutomationPage_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageSettingsTab_ProjectFragmentDoc,
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": typeof types.SettingsServerProjects_ProjectCollectionFragmentDoc,
@@ -687,7 +687,7 @@ const documents: Documents = {
"\n query ProjectModelVersions(\n $projectId: String!\n $modelId: String!\n $versionsCursor: String\n ) {\n project(id: $projectId) {\n id\n ...ProjectModelPageVersionsPagination\n }\n }\n": types.ProjectModelVersionsDocument,
"\n query ProjectModelsPage($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectModelsPageHeader_Project\n ...ProjectModelsPageResults_Project\n }\n }\n": types.ProjectModelsPageDocument,
"\n query ProjectDiscussionsPage($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectDiscussionsPageHeader_Project\n ...ProjectDiscussionsPageResults_Project\n }\n }\n": types.ProjectDiscussionsPageDocument,
"\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n": types.ProjectAutomationsTabDocument,
"\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n permissions {\n canCreateAutomation {\n ...FullPermissionCheckResult\n }\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n": types.ProjectAutomationsTabDocument,
"\n query ProjectAutomationsTabAutomationsPagination(\n $projectId: String!\n $search: String = null\n $cursor: String = null\n ) {\n project(id: $projectId) {\n id\n automations(filter: $search, cursor: $cursor, limit: 5) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n }\n }\n }\n": types.ProjectAutomationsTabAutomationsPaginationDocument,
"\n query ProjectAutomationPage($projectId: String!, $automationId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageAutomationPage_Project\n automation(id: $automationId) {\n id\n ...ProjectPageAutomationPage_Automation\n }\n }\n }\n": types.ProjectAutomationPageDocument,
"\n query ProjectAutomationPagePaginatedRuns(\n $projectId: String!\n $automationId: String!\n $cursor: String = null\n ) {\n project(id: $projectId) {\n id\n automation(id: $automationId) {\n id\n runs(cursor: $cursor, limit: 10) {\n totalCount\n cursor\n items {\n id\n ...AutomationRunDetails\n }\n }\n }\n }\n }\n": types.ProjectAutomationPagePaginatedRunsDocument,
@@ -841,7 +841,7 @@ const documents: Documents = {
"\n query AutomateFunctionPage($functionId: ID!) {\n automateFunction(id: $functionId) {\n ...AutomateFunctionPage_AutomateFunction\n }\n activeUser {\n workspaces {\n items {\n ...AutomateFunctionCreateDialog_Workspace\n ...AutomateFunctionEditDialog_Workspace\n }\n }\n }\n }\n": types.AutomateFunctionPageDocument,
"\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n": types.AutomateFunctionPageWorkspaceDocument,
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...WorkspaceMoveProjectManager_ProjectBase\n ...ProjectPageSettingsTab_Project\n }\n": types.ProjectPageProjectFragmentDoc,
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": types.ProjectPageAutomationPage_AutomationFragmentDoc,
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": types.ProjectPageAutomationPage_AutomationFragmentDoc,
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": types.ProjectPageAutomationPage_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageSettingsTab_ProjectFragmentDoc,
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsServerProjects_ProjectCollectionFragmentDoc,
@@ -1881,7 +1881,7 @@ export function graphql(source: "\n query ProjectDiscussionsPage($projectId: St
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n"): (typeof documents)["\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n"];
export function graphql(source: "\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n permissions {\n canCreateAutomation {\n ...FullPermissionCheckResult\n }\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n"): (typeof documents)["\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n automateFunctions(limit: 0) {\n totalCount\n }\n ...AutomateFunctionCreateDialog_Workspace\n }\n permissions {\n canCreateAutomation {\n ...FullPermissionCheckResult\n }\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2497,7 +2497,7 @@ export function graphql(source: "\n fragment ProjectPageProject on Project {\n
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n"): (typeof documents)["\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n"];
export function graphql(source: "\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n"): (typeof documents)["\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -240,6 +240,11 @@ export const projectAutomationsTabQuery = graphql(`
}
...AutomateFunctionCreateDialog_Workspace
}
permissions {
canCreateAutomation {
...FullPermissionCheckResult
}
}
...FormSelectProjects_Project
}
...AutomateFunctionsPageHeader_Query
@@ -30,7 +30,6 @@
<div v-else />
</template>
<script setup lang="ts">
import { Roles } from '@speckle/shared'
import { useQuery } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import { projectAutomationPageQuery } from '~/lib/projects/graphql/queries'
@@ -38,6 +37,11 @@ import { projectAutomationPageQuery } from '~/lib/projects/graphql/queries'
graphql(`
fragment ProjectPageAutomationPage_Automation on Automation {
id
permissions {
canUpdate {
...FullPermissionCheckResult
}
}
...ProjectPageAutomationHeader_Automation
...ProjectPageAutomationFunctions_Automation
...ProjectPageAutomationRuns_Automation
@@ -71,7 +75,6 @@ const automation = computed(() => result.value?.project.automation || null)
const project = computed(() => result.value?.project)
const workspaceId = computed(() => project.value?.workspaceId ?? undefined)
const isEditable = computed(() => {
const allowedRoles: string[] = [Roles.Stream.Owner]
return allowedRoles.includes(result.value?.project.role ?? '')
return result?.value?.project?.automation?.permissions?.canUpdate.authorized ?? false
})
</script>
@@ -338,11 +338,10 @@ type AutomateMutations {
extend type Project {
automations(filter: String, cursor: String, limit: Int): AutomationCollection!
@hasStreamRole(role: STREAM_REVIEWER)
"""
Get a single automation by id. Error will be thrown if automation is not found or inaccessible.
"""
automation(id: String!): Automation! @hasStreamRole(role: STREAM_REVIEWER)
automation(id: String!): Automation!
}
input AutomateAuthCodePayloadTest {
@@ -0,0 +1,12 @@
extend type Automation {
permissions: AutomationPermissionChecks!
}
type AutomationPermissionChecks {
canRead: PermissionCheckResult!
canUpdate: PermissionCheckResult!
}
extend type ProjectPermissionChecks {
canCreateAutomation: PermissionCheckResult!
}
@@ -13,7 +13,7 @@ enum ProjectVisibility {
}
"""
Visbility without the "discoverable" option
Visibility without the "discoverable" option
"""
enum SimpleProjectVisibility {
PRIVATE
+1
View File
@@ -47,6 +47,7 @@ generates:
AutomateFunction: '@/modules/automate/helpers/graphTypes#AutomateFunctionGraphQLReturn'
AutomateFunctionRelease: '@/modules/automate/helpers/graphTypes#AutomateFunctionReleaseGraphQLReturn'
Automation: '@/modules/automate/helpers/graphTypes#AutomationGraphQLReturn'
AutomationPermissionChecks: '@/modules/automate/helpers/graphTypes#AutomationPermissionChecksGraphQLReturn'
AutomationRevision: '@/modules/automate/helpers/graphTypes#AutomationRevisionGraphQLReturn'
AutomationRevisionFunction: '@/modules/automate/helpers/graphTypes#AutomationRevisionFunctionGraphQLReturn'
AutomateRun: '@/modules/automate/helpers/graphTypes#AutomateRunGraphQLReturn'
@@ -122,6 +122,7 @@ import {
import { getEventBus } from '@/modules/shared/services/eventBus'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { BranchNotFoundError } from '@/modules/core/errors/branch'
import { mapAuthToServerError } from '@/modules/shared/helpers/errorHelper'
import { withOperationLogging } from '@/observability/domain/businessLogging'
const { FF_AUTOMATE_MODULE_ENABLED } = getFeatureFlags()
@@ -209,6 +210,14 @@ export = (FF_AUTOMATE_MODULE_ENABLED
},
Project: {
async automation(parent, args, ctx) {
const canReadAutomation = await ctx.authPolicies.project.automation.canRead({
userId: ctx.userId,
projectId: parent.id
})
if (!canReadAutomation.isOk) {
throw mapAuthToServerError(canReadAutomation.error)
}
const projectDb = await getProjectDbClient({ projectId: parent.id })
const res = ctx.loaders
@@ -224,7 +233,15 @@ export = (FF_AUTOMATE_MODULE_ENABLED
return res
},
async automations(parent, args) {
async automations(parent, args, ctx) {
const canReadAutomation = await ctx.authPolicies.project.automation.canRead({
userId: ctx.userId,
projectId: parent.id
})
if (!canReadAutomation.isOk) {
throw mapAuthToServerError(canReadAutomation.error)
}
const projectDb = await getProjectDbClient({ projectId: parent.id })
const retrievalArgs: GetProjectAutomationsParams = {
@@ -607,6 +624,14 @@ export = (FF_AUTOMATE_MODULE_ENABLED
},
ProjectAutomationMutations: {
async create(parent, { input }, ctx) {
const canCreate = await ctx.authPolicies.project.automation.canCreate({
userId: ctx.userId,
projectId: parent.projectId
})
if (!canCreate.isOk) {
throw mapAuthToServerError(canCreate.error)
}
const projectId = parent.projectId
const logger = ctx.log.child({
@@ -621,7 +646,6 @@ export = (FF_AUTOMATE_MODULE_ENABLED
automateCreateAutomation: clientCreateAutomation,
storeAutomation: storeAutomationFactory({ db: projectDb }),
storeAutomationToken: storeAutomationTokenFactory({ db: projectDb }),
validateStreamAccess,
eventEmit: getEventBus().emit
})
@@ -643,6 +667,14 @@ export = (FF_AUTOMATE_MODULE_ENABLED
return automation
},
async update(parent, { input }, ctx) {
const canUpdate = await ctx.authPolicies.project.automation.canUpdate({
userId: ctx.userId,
projectId: parent.projectId
})
if (!canUpdate.isOk) {
throw mapAuthToServerError(canUpdate.error)
}
const projectId = parent.projectId
const automationId = input.id
@@ -657,7 +689,6 @@ export = (FF_AUTOMATE_MODULE_ENABLED
const update = validateAndUpdateAutomationFactory({
getAutomation: getAutomationFactory({ db: projectDb }),
updateAutomation: updateAutomationFactory({ db: projectDb }),
validateStreamAccess,
eventEmit: getEventBus().emit
})
@@ -0,0 +1,35 @@
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import { Authz } from '@speckle/shared'
export default {
Automation: {
permissions: (parent) => ({ projectId: parent.projectId })
},
AutomationPermissionChecks: {
canRead: async (parent, _args, context) => {
const canReadAutomation = await context.authPolicies.project.automation.canRead({
userId: context.userId,
projectId: parent.projectId
})
return Authz.toGraphqlResult(canReadAutomation)
},
canUpdate: async (parent, _args, context) => {
const canUpdateAutomation =
await context.authPolicies.project.automation.canUpdate({
userId: context.userId,
projectId: parent.projectId
})
return Authz.toGraphqlResult(canUpdateAutomation)
}
},
ProjectPermissionChecks: {
canCreateAutomation: async (parent, _args, context) => {
const canCreateAutomation =
await context.authPolicies.project.automation.canCreate({
userId: context.userId,
projectId: parent.projectId
})
return Authz.toGraphqlResult(canCreateAutomation)
}
}
} as Resolvers
@@ -95,3 +95,5 @@ export type ProjectAutomationsUpdatedMessageGraphQLReturn = Merge<
>
export type UserAutomateInfoGraphQLReturn = { userId: string }
export type AutomationPermissionChecksGraphQLReturn = { projectId: string }
@@ -60,7 +60,6 @@ export type CreateAutomationDeps = {
automateCreateAutomation: typeof clientCreateAutomation
storeAutomation: StoreAutomation
storeAutomationToken: StoreAutomationToken
validateStreamAccess: ValidateStreamAccess
eventEmit: EventBusEmit
}
@@ -75,27 +74,18 @@ export const createAutomationFactory =
const {
input: { name, enabled },
projectId,
userId,
userResourceAccessRules
userId
} = params
const {
createAuthCode,
automateCreateAutomation,
storeAutomation,
storeAutomationToken,
validateStreamAccess,
eventEmit
} = deps
validateAutomationName(name)
await validateStreamAccess(
userId,
projectId,
Roles.Stream.Owner,
userResourceAccessRules
)
const authCode = await createAuthCode({
userId,
action: AuthCodePayloadAction.CreateAutomation
@@ -253,7 +243,6 @@ export const createTestAutomationFactory =
export type ValidateAndUpdateAutomationDeps = {
getAutomation: GetAutomation
updateAutomation: UpdateAutomation
validateStreamAccess: ValidateStreamAccess
eventEmit: EventBusEmit
}
@@ -268,8 +257,8 @@ export const validateAndUpdateAutomationFactory =
*/
projectId?: string
}) => {
const { getAutomation, updateAutomation, validateStreamAccess, eventEmit } = deps
const { input, userId, userResourceAccessRules, projectId } = params
const { getAutomation, updateAutomation, eventEmit } = deps
const { input, projectId } = params
const existingAutomation = await getAutomation({
automationId: input.id,
@@ -279,13 +268,6 @@ export const validateAndUpdateAutomationFactory =
throw new AutomationUpdateError('Automation not found')
}
await validateStreamAccess(
userId,
existingAutomation.projectId,
Roles.Stream.Owner,
userResourceAccessRules
)
// Filter out empty (null) values from input
const updates = removeNullOrUndefinedKeys(input)
@@ -75,7 +75,6 @@ const buildAutomationUpdate = () => {
const update = validateAndUpdateAutomationFactory({
getAutomation,
updateAutomation: updateDbAutomation,
validateStreamAccess,
eventEmit: getEventBus().emit
})
@@ -136,38 +135,6 @@ const buildAutomationUpdate = () => {
})
})
it('fails if refering to a project that doesnt exist', async () => {
const create = buildAutomationCreate()
const e = await expectToThrow(
async () =>
await create({
input: { name: 'Automation', enabled: true },
projectId: 'non-existent',
userId: me.id
})
)
expect(e)
.to.have.property('message')
.match(/^User does not have required access to stream/)
})
it('fails if user does not have access to the project', async () => {
const create = buildAutomationCreate()
const e = await expectToThrow(
async () =>
await create({
input: { name: 'Automation', enabled: true },
projectId: myStream.id,
userId: otherGuy.id
})
)
expect(e)
.to.have.property('message')
.match(/^User does not have required access to stream/)
})
it('creates an automation', async () => {
let eventFired = false
const name = 'My Super Automation #1'
@@ -222,22 +189,6 @@ const buildAutomationUpdate = () => {
expect(e).to.have.property('message', 'Automation not found')
})
it('fails if refering to an automation in a project owned by someone else', async () => {
const update = buildAutomationUpdate()
const e = await expectToThrow(
async () =>
await update({
input: { id: createdAutomation.automation.id, enabled: false },
userId: otherGuy.id,
projectId: myStream.id
})
)
expect(e)
.to.have.property('message')
.match(/^User does not have required access to stream/)
})
it('fails if automation is mismatched with specified project id', async () => {
const update = buildAutomationUpdate()
@@ -4,7 +4,7 @@ import { StreamAccessRequestGraphQLReturn, ProjectAccessRequestGraphQLReturn } f
import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn, CommentPermissionChecksGraphQLReturn } from '@/modules/comments/helpers/graphTypes';
import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/helpers/graphTypes';
import { FileUploadGraphQLReturn } from '@/modules/fileuploads/helpers/types';
import { AutomateFunctionGraphQLReturn, AutomateFunctionReleaseGraphQLReturn, AutomationGraphQLReturn, AutomationRevisionGraphQLReturn, AutomationRevisionFunctionGraphQLReturn, AutomateRunGraphQLReturn, AutomationRunTriggerGraphQLReturn, AutomationRevisionTriggerDefinitionGraphQLReturn, AutomateFunctionRunGraphQLReturn, TriggeredAutomationsStatusGraphQLReturn, ProjectAutomationMutationsGraphQLReturn, ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn, UserAutomateInfoGraphQLReturn } from '@/modules/automate/helpers/graphTypes';
import { AutomateFunctionGraphQLReturn, AutomateFunctionReleaseGraphQLReturn, AutomationGraphQLReturn, AutomationPermissionChecksGraphQLReturn, AutomationRevisionGraphQLReturn, AutomationRevisionFunctionGraphQLReturn, AutomateRunGraphQLReturn, AutomationRunTriggerGraphQLReturn, AutomationRevisionTriggerDefinitionGraphQLReturn, AutomateFunctionRunGraphQLReturn, TriggeredAutomationsStatusGraphQLReturn, ProjectAutomationMutationsGraphQLReturn, ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn, UserAutomateInfoGraphQLReturn } from '@/modules/automate/helpers/graphTypes';
import { WorkspaceGraphQLReturn, WorkspaceSsoGraphQLReturn, WorkspaceMutationsGraphQLReturn, WorkspaceJoinRequestMutationsGraphQLReturn, WorkspaceInviteMutationsGraphQLReturn, WorkspaceProjectMutationsGraphQLReturn, PendingWorkspaceCollaboratorGraphQLReturn, WorkspaceCollaboratorGraphQLReturn, WorkspaceJoinRequestGraphQLReturn, LimitedWorkspaceJoinRequestGraphQLReturn, ProjectMoveToWorkspaceDryRunGraphQLReturn, ProjectRoleGraphQLReturn, WorkspacePermissionChecksGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes';
import { WorkspacePlanGraphQLReturn, WorkspacePlanUsageGraphQLReturn, PriceGraphQLReturn } from '@/modules/gatekeeperCore/helpers/graphTypes';
import { WorkspaceBillingMutationsGraphQLReturn, WorkspaceSubscriptionSeatsGraphQLReturn, WorkspaceSubscriptionGraphQLReturn } from '@/modules/gatekeeper/helpers/graphTypes';
@@ -454,6 +454,7 @@ export type Automation = {
id: Scalars['ID']['output'];
isTestAutomation: Scalars['Boolean']['output'];
name: Scalars['String']['output'];
permissions: AutomationPermissionChecks;
runs: AutomateRunCollection;
updatedAt: Scalars['DateTime']['output'];
};
@@ -471,6 +472,12 @@ export type AutomationCollection = {
totalCount: Scalars['Int']['output'];
};
export type AutomationPermissionChecks = {
__typename?: 'AutomationPermissionChecks';
canRead: PermissionCheckResult;
canUpdate: PermissionCheckResult;
};
export type AutomationRevision = {
__typename?: 'AutomationRevision';
functions: Array<AutomationRevisionFunction>;
@@ -2589,6 +2596,7 @@ export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVers
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canBroadcastActivity: PermissionCheckResult;
canCreateAutomation: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canDelete: PermissionCheckResult;
@@ -3244,7 +3252,7 @@ export type SetPrimaryUserEmailInput = {
id: Scalars['ID']['input'];
};
/** Visbility without the "discoverable" option */
/** Visibility without the "discoverable" option */
export const SimpleProjectVisibility = {
Private: 'PRIVATE',
Unlisted: 'UNLISTED'
@@ -5232,6 +5240,7 @@ export type ResolversTypes = {
AutomateRunTriggerType: AutomateRunTriggerType;
Automation: ResolverTypeWrapper<AutomationGraphQLReturn>;
AutomationCollection: ResolverTypeWrapper<Omit<AutomationCollection, 'items'> & { items: Array<ResolversTypes['Automation']> }>;
AutomationPermissionChecks: ResolverTypeWrapper<AutomationPermissionChecksGraphQLReturn>;
AutomationRevision: ResolverTypeWrapper<AutomationRevisionGraphQLReturn>;
AutomationRevisionCreateFunctionInput: AutomationRevisionCreateFunctionInput;
AutomationRevisionFunction: ResolverTypeWrapper<AutomationRevisionFunctionGraphQLReturn>;
@@ -5568,6 +5577,7 @@ export type ResolversParentTypes = {
AutomateRunCollection: Omit<AutomateRunCollection, 'items'> & { items: Array<ResolversParentTypes['AutomateRun']> };
Automation: AutomationGraphQLReturn;
AutomationCollection: Omit<AutomationCollection, 'items'> & { items: Array<ResolversParentTypes['Automation']> };
AutomationPermissionChecks: AutomationPermissionChecksGraphQLReturn;
AutomationRevision: AutomationRevisionGraphQLReturn;
AutomationRevisionCreateFunctionInput: AutomationRevisionCreateFunctionInput;
AutomationRevisionFunction: AutomationRevisionFunctionGraphQLReturn;
@@ -6077,6 +6087,7 @@ export type AutomationResolvers<ContextType = GraphQLContext, ParentType extends
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
isTestAutomation?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
permissions?: Resolver<ResolversTypes['AutomationPermissionChecks'], ParentType, ContextType>;
runs?: Resolver<ResolversTypes['AutomateRunCollection'], ParentType, ContextType, Partial<AutomationRunsArgs>>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@@ -6089,6 +6100,12 @@ export type AutomationCollectionResolvers<ContextType = GraphQLContext, ParentTy
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AutomationPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomationPermissionChecks'] = ResolversParentTypes['AutomationPermissionChecks']> = {
canRead?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canUpdate?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AutomationRevisionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomationRevision'] = ResolversParentTypes['AutomationRevision']> = {
functions?: Resolver<Array<ResolversTypes['AutomationRevisionFunction']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
@@ -6768,6 +6785,7 @@ export type ProjectPendingVersionsUpdatedMessageResolvers<ContextType = GraphQLC
export type ProjectPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ProjectPermissionChecks'] = ResolversParentTypes['ProjectPermissionChecks']> = {
canBroadcastActivity?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateAutomation?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateComment?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateModel?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canDelete?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
@@ -7654,6 +7672,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
AutomateRunCollection?: AutomateRunCollectionResolvers<ContextType>;
Automation?: AutomationResolvers<ContextType>;
AutomationCollection?: AutomationCollectionResolvers<ContextType>;
AutomationPermissionChecks?: AutomationPermissionChecksResolvers<ContextType>;
AutomationRevision?: AutomationRevisionResolvers<ContextType>;
AutomationRevisionFunction?: AutomationRevisionFunctionResolvers<ContextType>;
AutomationRevisionTriggerDefinition?: AutomationRevisionTriggerDefinitionResolvers<ContextType>;
@@ -434,6 +434,7 @@ export type Automation = {
id: Scalars['ID']['output'];
isTestAutomation: Scalars['Boolean']['output'];
name: Scalars['String']['output'];
permissions: AutomationPermissionChecks;
runs: AutomateRunCollection;
updatedAt: Scalars['DateTime']['output'];
};
@@ -451,6 +452,12 @@ export type AutomationCollection = {
totalCount: Scalars['Int']['output'];
};
export type AutomationPermissionChecks = {
__typename?: 'AutomationPermissionChecks';
canRead: PermissionCheckResult;
canUpdate: PermissionCheckResult;
};
export type AutomationRevision = {
__typename?: 'AutomationRevision';
functions: Array<AutomationRevisionFunction>;
@@ -2569,6 +2576,7 @@ export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVers
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canBroadcastActivity: PermissionCheckResult;
canCreateAutomation: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canDelete: PermissionCheckResult;
@@ -3224,7 +3232,7 @@ export type SetPrimaryUserEmailInput = {
id: Scalars['ID']['input'];
};
/** Visbility without the "discoverable" option */
/** Visibility without the "discoverable" option */
export const SimpleProjectVisibility = {
Private: 'PRIVATE',
Unlisted: 'UNLISTED'
@@ -41,8 +41,8 @@ const buildInvitableCollaboratorsByProjectIdQueryFactory =
Users.col.id,
tables
.streamAcl(db)
.select(StreamAcl.col.resourceId)
.whereNot(StreamAcl.col.resourceId, projectId)
.select(StreamAcl.col.userId)
.where(StreamAcl.col.resourceId, projectId)
)
if (search) {
query
@@ -104,5 +104,5 @@ export const countInvitableCollaboratorsByProjectIdFactory =
search
})
const [res] = await query.count()
return parseInt(res.count.toString())
return parseInt(res?.count?.toString() ?? '0')
}
@@ -6,275 +6,140 @@ import {
import { getInvitableCollaboratorsByProjectIdFactory } from '@/modules/workspaces/repositories/users'
import {
assignToWorkspace,
BasicTestWorkspace,
createTestWorkspace
} from '@/modules/workspaces/tests/helpers/creation'
import { createTestUser } from '@/test/authHelper'
import { createTestStream } from '@/test/speckle-helpers/streamHelper'
import { Roles } from '@speckle/shared'
import { BasicTestUser, createTestUser, createTestUsers } from '@/test/authHelper'
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import { expect } from 'chai'
import { pick } from 'lodash'
describe('Workspace repositories', () => {
describe('users repository', () => {
describe('getInvitableCollaboratorsByProjectIdFactory returns a function that ', () => {
describe('getInvitableCollaboratorsByProjectIdFactory returns a function, that', () => {
const getInvitableCollaboratorsByProjectId =
getInvitableCollaboratorsByProjectIdFactory({ db })
const adminUser: BasicTestUser = {
id: '',
name: createRandomString(),
email: createRandomEmail()
}
const workspaceMemberA: BasicTestUser = {
id: '',
name: createRandomString() + 'foo',
email: 'baz' + createRandomEmail()
}
const workspaceMemberB: BasicTestUser = {
id: '',
name: createRandomString() + 'baz',
email: 'bar' + createRandomEmail()
}
const nonWorkspaceMember: BasicTestUser = {
id: '',
name: createRandomString(),
email: createRandomEmail()
}
const testWorkspace: BasicTestWorkspace = {
id: createRandomString(),
name: createRandomString(),
slug: createRandomString(),
ownerId: ''
}
// The project we will run the test suite search against
const testProject: BasicTestStream = {
id: '',
ownerId: '',
name: createRandomString(),
isPublic: true,
workspaceId: ''
}
// An extra project for test comprehensiveness
const testOtherProject: BasicTestStream = {
id: '',
ownerId: '',
name: createRandomString(),
isPublic: true,
workspaceId: ''
}
before(async () => {
await createTestUser(adminUser)
await createTestUsers([workspaceMemberA, workspaceMemberB, nonWorkspaceMember])
await createTestWorkspace(testWorkspace, adminUser, {
addPlan: {
name: 'unlimited',
status: 'valid'
}
})
await assignToWorkspace(testWorkspace, workspaceMemberA)
await assignToWorkspace(testWorkspace, workspaceMemberB)
testProject.workspaceId = testWorkspace.id
testOtherProject.workspaceId = testWorkspace.id
await createTestStream(testProject, adminUser)
await createTestStream(testOtherProject, workspaceMemberA)
})
it('should return all workspace collaborators not members of the project', async () => {
const admin = await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const workspace = {
id: createRandomString(),
name: createRandomString(),
slug: createRandomString(),
ownerId: admin.id
}
await createTestWorkspace(workspace, admin)
const member = await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
await assignToWorkspace(workspace, member, Roles.Workspace.Member)
// Non workspace member
await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const projectMember = await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const project = {
id: createRandomString(),
workspaceId: workspace.id
}
await createTestStream(project, projectMember)
// User in another project should still be invitable
const otherProject = {
id: createRandomString(),
workspaceId: workspace.id
}
await createTestStream(otherProject, admin)
const invitable = await getInvitableCollaboratorsByProjectId({
filter: {
workspaceId: workspace.id,
projectId: project.id
workspaceId: testWorkspace.id,
projectId: testProject.id
},
limit: 10
})
expect(invitable).to.have.length(2)
expect(invitable.map((i) => pick(i, ['id', 'name']))).to.deep.equalInAnyOrder([
{ id: admin.id, name: admin.name },
{ id: member.id, name: member.name }
{ id: workspaceMemberA.id, name: workspaceMemberA.name },
{ id: workspaceMemberB.id, name: workspaceMemberB.name }
])
})
it('should should filter by user name', async () => {
const admin = await createTestUser({
name: createRandomString() + 'fixed' + createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const workspace = {
id: createRandomString(),
name: createRandomString(),
slug: createRandomString(),
ownerId: admin.id
}
await createTestWorkspace(workspace, admin)
const member = await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
await assignToWorkspace(workspace, member, Roles.Workspace.Member)
// Non workspace member
await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const projectMember = await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const project = {
id: createRandomString(),
workspaceId: workspace.id
}
await createTestStream(project, projectMember)
// User in another project should still be invitable
const otherProject = {
id: createRandomString(),
workspaceId: workspace.id
}
await createTestStream(otherProject, admin)
const invitable = await getInvitableCollaboratorsByProjectId({
filter: {
workspaceId: workspace.id,
projectId: project.id,
search: 'fixed'
workspaceId: testWorkspace.id,
projectId: testProject.id,
search: 'foo'
},
limit: 10
})
expect(invitable).to.have.length(1)
expect(invitable.map((i) => pick(i, ['id', 'name']))).to.deep.equalInAnyOrder([
{ id: admin.id, name: admin.name }
{ id: workspaceMemberA.id, name: workspaceMemberA.name }
])
})
it('should should filter by user email', async () => {
const admin = await createTestUser({
name: createRandomString(),
email: createRandomString() + 'fixed' + createRandomString(),
role: Roles.Server.User,
verified: true
})
const workspace = {
id: createRandomString(),
name: createRandomString(),
slug: createRandomString(),
ownerId: admin.id
}
await createTestWorkspace(workspace, admin)
const member = await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
await assignToWorkspace(workspace, member, Roles.Workspace.Member)
// Non workspace member
await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const projectMember = await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const project = {
id: createRandomString(),
workspaceId: workspace.id
}
await createTestStream(project, projectMember)
// User in another project should still be invitable
const otherProject = {
id: createRandomString(),
workspaceId: workspace.id
}
await createTestStream(otherProject, admin)
const invitable = await getInvitableCollaboratorsByProjectId({
filter: {
workspaceId: workspace.id,
projectId: project.id,
search: 'fixed'
workspaceId: testWorkspace.id,
projectId: testProject.id,
search: 'bar'
},
limit: 10
})
expect(invitable).to.have.length(1)
expect(invitable.map((i) => pick(i, ['id', 'name']))).to.deep.equalInAnyOrder([
{ id: admin.id, name: admin.name }
{ id: workspaceMemberB.id, name: workspaceMemberB.name }
])
})
it('should should filter by user name and email', async () => {
const admin = await createTestUser({
name: createRandomString(),
email: createRandomString() + 'fixed' + createRandomString(),
role: Roles.Server.User,
verified: true
})
const workspace = {
id: createRandomString(),
name: createRandomString(),
slug: createRandomString(),
ownerId: admin.id
}
await createTestWorkspace(workspace, admin)
const member = await createTestUser({
name: createRandomString() + 'fixed' + createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
await assignToWorkspace(workspace, member, Roles.Workspace.Member)
// Non workspace member
await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const projectMember = await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const project = {
id: createRandomString(),
workspaceId: workspace.id
}
await createTestStream(project, projectMember)
// User in another project should still be invitable
const otherProject = {
id: createRandomString(),
workspaceId: workspace.id
}
await createTestStream(otherProject, admin)
const invitable = await getInvitableCollaboratorsByProjectId({
filter: {
workspaceId: workspace.id,
projectId: project.id,
search: 'fixed'
workspaceId: testWorkspace.id,
projectId: testProject.id,
search: 'baz'
},
limit: 10
})
expect(invitable).to.have.length(2)
expect(invitable.map((i) => pick(i, ['id', 'name']))).to.deep.equalInAnyOrder([
{ id: admin.id, name: admin.name },
{ id: member.id, name: member.name }
{ id: workspaceMemberA.id, name: workspaceMemberA.name },
{ id: workspaceMemberB.id, name: workspaceMemberB.name }
])
})
})
@@ -7,7 +7,12 @@ import {
BasicTestWorkspace,
createTestWorkspace
} from '@/modules/workspaces/tests/helpers/creation'
import { BasicTestUser, createTestUser, login } from '@/test/authHelper'
import {
BasicTestUser,
createTestUser,
createTestUsers,
login
} from '@/test/authHelper'
import {
GetProjectInvitableCollaboratorsDocument,
SetUserActiveWorkspaceDocument,
@@ -16,7 +21,6 @@ import {
import { testApolloServer, TestApolloServer } from '@/test/graphqlHelper'
import { beforeEachContext } from '@/test/hooks'
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import { Roles } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
@@ -105,61 +109,76 @@ describe('ActiveUserMutations.setActiveWorkspace', () => {
})
describe('Project.invitableCollaborators', () => {
const adminUser: BasicTestUser = {
id: '',
name: createRandomString(),
email: createRandomEmail()
}
const workspaceMemberA: BasicTestUser = {
id: '',
name: createRandomString() + 'foo',
email: 'baz' + createRandomEmail()
}
const workspaceMemberB: BasicTestUser = {
id: '',
name: createRandomString() + 'baz',
email: 'bar' + createRandomEmail()
}
const nonWorkspaceMember: BasicTestUser = {
id: '',
name: createRandomString(),
email: createRandomEmail()
}
const testWorkspace: BasicTestWorkspace = {
id: createRandomString(),
name: createRandomString(),
slug: createRandomString(),
ownerId: ''
}
// The project we will run the test suite search against
const testProject: BasicTestStream = {
id: '',
ownerId: '',
name: createRandomString(),
isPublic: true,
workspaceId: ''
}
// An extra project for test comprehensiveness
const testOtherProject: BasicTestStream = {
id: '',
ownerId: '',
name: createRandomString(),
isPublic: true,
workspaceId: ''
}
before(async () => {
await createTestUser(adminUser)
await createTestUsers([workspaceMemberA, workspaceMemberB, nonWorkspaceMember])
await createTestWorkspace(testWorkspace, adminUser, {
addPlan: {
name: 'unlimited',
status: 'valid'
}
})
await assignToWorkspace(testWorkspace, workspaceMemberA)
await assignToWorkspace(testWorkspace, workspaceMemberB)
testProject.workspaceId = testWorkspace.id
testOtherProject.workspaceId = testWorkspace.id
await createTestStream(testProject, adminUser)
await createTestStream(testOtherProject, workspaceMemberA)
})
it('should return invitable collaborators', async () => {
const admin = await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const workspace = {
id: createRandomString(),
name: createRandomString(),
slug: createRandomString(),
ownerId: admin.id
}
await createTestWorkspace(workspace, admin)
const member = await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
await assignToWorkspace(workspace, member, Roles.Workspace.Member)
// Non workspace member
await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const projectMember = await createTestUser({
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
})
const project = {
id: createRandomString(),
workspaceId: workspace.id
}
await createTestStream(project, projectMember)
// User in another project should still be invitable
const otherProject = {
id: createRandomString(),
workspaceId: workspace.id
}
await createTestStream(otherProject, admin)
const session = await login(admin)
const session = await login(adminUser)
const res = await session.execute(GetProjectInvitableCollaboratorsDocument, {
projectId: project.id
projectId: testProject.id
})
expect(res).not.haveGraphQLErrors()
@@ -167,8 +186,8 @@ describe('Project.invitableCollaborators', () => {
expect(invitable?.totalCount).to.eq(2)
expect(invitable?.items).to.have.length(2)
expect(invitable?.items).to.deep.equalInAnyOrder([
{ id: admin.id, user: { name: admin.name } },
{ id: member.id, user: { name: member.name } }
{ id: workspaceMemberA.id, user: { name: workspaceMemberA.name } },
{ id: workspaceMemberB.id, user: { name: workspaceMemberB.name } }
])
})
})
@@ -435,6 +435,7 @@ export type Automation = {
id: Scalars['ID']['output'];
isTestAutomation: Scalars['Boolean']['output'];
name: Scalars['String']['output'];
permissions: AutomationPermissionChecks;
runs: AutomateRunCollection;
updatedAt: Scalars['DateTime']['output'];
};
@@ -452,6 +453,12 @@ export type AutomationCollection = {
totalCount: Scalars['Int']['output'];
};
export type AutomationPermissionChecks = {
__typename?: 'AutomationPermissionChecks';
canRead: PermissionCheckResult;
canUpdate: PermissionCheckResult;
};
export type AutomationRevision = {
__typename?: 'AutomationRevision';
functions: Array<AutomationRevisionFunction>;
@@ -2570,6 +2577,7 @@ export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVers
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canBroadcastActivity: PermissionCheckResult;
canCreateAutomation: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canDelete: PermissionCheckResult;
@@ -3225,7 +3233,7 @@ export type SetPrimaryUserEmailInput = {
id: Scalars['ID']['input'];
};
/** Visbility without the "discoverable" option */
/** Visibility without the "discoverable" option */
export const SimpleProjectVisibility = {
Private: 'PRIVATE',
Unlisted: 'UNLISTED'
@@ -80,7 +80,6 @@ export const buildAutomationCreate = (
})),
storeAutomation: storeAutomationFactory({ db: dbClient }),
storeAutomationToken: storeAutomationTokenFactory({ db: dbClient }),
validateStreamAccess,
eventEmit: getEventBus().emit
})
@@ -21,11 +21,19 @@ import { canCreateProjectVersionPolicy } from './project/version/canCreate.js'
import { canUpdateProjectVersionPolicy } from './project/version/canUpdate.js'
import { canReceiveProjectVersionPolicy } from './project/version/canReceive.js'
import { canRequestProjectVersionRenderPolicy } from './project/version/canRequestRender.js'
import { canCreateAutomationPolicy } from './project/automation/canCreate.js'
import { canUpdateAutomationPolicy } from './project/automation/canUpdate.js'
import { canReadAutomationPolicy } from './project/automation/canRead.js'
import { canReceiveWorkspaceProjectsUpdatedMessagePolicy } from './workspace/canReceiveProjectsUpdatedMessage.js'
import { canDeleteProjectPolicy } from './project/canDelete.js'
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
project: {
automation: {
canCreate: canCreateAutomationPolicy(loaders),
canRead: canReadAutomationPolicy(loaders),
canUpdate: canUpdateAutomationPolicy(loaders)
},
model: {
canCreate: canCreateModelPolicy(loaders),
canUpdate: canUpdateModelPolicy(loaders),
@@ -0,0 +1,255 @@
import { describe, expect, it } from 'vitest'
import { Roles } from '../../../../core/constants.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { canCreateAutomationPolicy } from './canCreate.js'
import {
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
const buildCanCreatePolicy = (
overrides?: Partial<Parameters<typeof canCreateAutomationPolicy>[0]>
) =>
canCreateAutomationPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getProjectRole: async () => Roles.Stream.Owner,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
describe('canCreateAutomation', () => {
it('returns error if user is not logged in', async () => {
const canCreateAutomation = buildCanCreatePolicy()
const result = await canCreateAutomation({
userId: undefined,
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('returns error if user is not found', async () => {
const canCreateAutomation = buildCanCreatePolicy({
getServerRole: async () => null
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('returns error if user is a server guest', async () => {
const canCreateAutomation = buildCanCreatePolicy({
getServerRole: async () => Roles.Server.Guest
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNotEnoughPermissionsError.code
})
})
it('returns error project not found', async () => {
const canCreateAutomation = buildCanCreatePolicy({
getProject: async () => null
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
it('returns error if no role at all', async () => {
const canCreateAutomation = buildCanCreatePolicy({
getProjectRole: async () => null
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('returns error if not owner', async () => {
const canCreateAutomation = buildCanCreatePolicy({
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotEnoughPermissionsError.code
})
})
it('returns ok if permissible', async () => {
const canCreateAutomation = buildCanCreatePolicy()
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
describe('with workspace project', () => {
const overrides = {
getProject: async () => ({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => {
const validUntil = new Date()
validUntil.setDate(validUntil.getDate() + 7)
return {
userId: 'user-id',
providerId: 'provider-id',
validUntil
}
}
}
it('returns ok if permissible', async () => {
const canCreateAutomation = buildCanCreatePolicy(overrides)
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns ok with implicit owner role', async () => {
const canCreateAutomation = buildCanCreatePolicy({
...overrides,
getWorkspaceRole: async () => Roles.Workspace.Admin,
getProjectRole: async () => null
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns error if no workspace role, even w/ valid project role', async () => {
const canCreateAutomation = buildCanCreatePolicy({
...overrides,
getWorkspaceRole: async () => null,
getProjectRole: async () => Roles.Stream.Owner
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceNoAccessError.code
})
})
it('returns error if invalid workspace and project role', async () => {
const canCreateAutomation = buildCanCreatePolicy({
...overrides,
getWorkspaceRole: async () => Roles.Workspace.Member,
getProjectRole: async () => Roles.Stream.Contributor
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotEnoughPermissionsError.code
})
})
it('returns ok if no sso configured', async () => {
const canCreateAutomation = buildCanCreatePolicy({
...overrides,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns error if no sso session', async () => {
const canCreateAutomation = buildCanCreatePolicy({
...overrides,
getWorkspaceSsoSession: async () => null
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('returns error if sso expired', async () => {
const canCreateAutomation = buildCanCreatePolicy({
...overrides,
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(new Date().getTime() - 1000)
})
})
const result = await canCreateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,63 @@
import { err, ok } from 'true-myth/result'
import { MaybeUserContext, ProjectContext } from '../../../domain/context.js'
import { AuthPolicy } from '../../../domain/policies.js'
import { Roles } from '../../../../core/constants.js'
import { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../../fragments/projects.js'
import { Loaders } from '../../../domain/loaders.js'
import {
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceNotEnoughPermissionsError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
type PolicyLoaderKeys =
| typeof Loaders.getProject
| typeof Loaders.getServerRole
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole
type PolicyArgs = ProjectContext & MaybeUserContext
type PolicyErrors = InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof ServerNotEnoughPermissionsError
| typeof WorkspaceSsoSessionNoAccessError
| typeof WorkspaceNotEnoughPermissionsError
| typeof ProjectNotEnoughPermissionsError
>
export const canCreateAutomationPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, projectId }) => {
// Project owners may create automations
const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
projectId,
role: Roles.Stream.Owner
})
if (ensuredWriteAccess.isErr) {
return err(ensuredWriteAccess.error)
}
return ok()
}
@@ -0,0 +1,240 @@
import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { canReadAutomationPolicy } from './canRead.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getProjectFake, getWorkspaceFake } from '../../../../tests/fakes.js'
import { Roles } from '../../../../core/constants.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
const buildCanReadAutomationPolicy = (
overrides?: OverridesOf<typeof canReadAutomationPolicy>
) =>
canReadAutomationPolicy({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isPublic: false,
isDiscoverable: false
}),
getProjectRole: async () => Roles.Stream.Reviewer,
getAdminOverrideEnabled: async () => false,
getEnv: async () => parseFeatureFlags({}),
getServerRole: async () => Roles.Server.Guest,
getWorkspaceRole: async () => null,
getWorkspace: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
describe('canReadAutomationPolicy', () => {
it('should allow for reviewers+', async () => {
const canReadAutomation = buildCanReadAutomationPolicy()
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('should allow for admin w/ override', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
getAdminOverrideEnabled: async () => true,
getServerRole: async () => Roles.Server.Admin,
getProjectRole: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails without user', async () => {
const canReadAutomation = buildCanReadAutomationPolicy()
const result = await canReadAutomation({
userId: undefined,
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('fails if user not found', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
getServerRole: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if user has no project role', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
getProjectRole: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails if project not found', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
getProject: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
describe('with workspace project', () => {
const overrides = {
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isPublic: false,
isDiscoverable: false
}),
getWorkspace: getWorkspaceFake({
id: 'workspace-id'
}),
getProjectRole: async () => null,
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(Date.now() + 1000 * 60 * 60)
}),
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
})
}
it('succeeds w/ implicit project role', async () => {
const canReadAutomation = buildCanReadAutomationPolicy(overrides)
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails w/o workspace role, even w/ project role', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
...overrides,
getProjectRole: async () => Roles.Stream.Reviewer,
getWorkspaceRole: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceNoAccessError.code
})
})
it('fails w/o workspace role', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
...overrides,
getWorkspaceRole: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('succeeds w/o sso, if not needed', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
...overrides,
getWorkspaceSsoSession: async () => null,
getWorkspaceSsoProvider: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails w/o sso', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
...overrides,
getWorkspaceSsoSession: async () => null
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('fails if sso session expired', async () => {
const canReadAutomation = buildCanReadAutomationPolicy({
...overrides,
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(Date.now() - 1000 * 60 * 60)
})
})
const result = await canReadAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,58 @@
import { err, ok } from 'true-myth/result'
import { AuthPolicy } from '../../../domain/policies.js'
import { MaybeUserContext, ProjectContext } from '../../../domain/context.js'
import { ensureImplicitProjectMemberWithReadAccessFragment } from '../../../fragments/projects.js'
import {} from '../../../fragments/server.js'
import { Loaders } from '../../../domain/loaders.js'
import {
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceNotEnoughPermissionsError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { Roles } from '../../../../core/constants.js'
export const canReadAutomationPolicy: AuthPolicy<
| typeof Loaders.getProject
| typeof Loaders.getEnv
| typeof Loaders.getServerRole
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole
| typeof Loaders.getAdminOverrideEnabled,
MaybeUserContext & ProjectContext,
InstanceType<
| typeof ProjectNotFoundError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof ServerNotEnoughPermissionsError
| typeof ProjectNoAccessError
| typeof WorkspaceNoAccessError
| typeof WorkspaceSsoSessionNoAccessError
| typeof WorkspaceNotEnoughPermissionsError
| typeof ProjectNotEnoughPermissionsError
>
> =
(loaders) =>
async ({ userId, projectId }) => {
// Project reviewers may read automations
const hasReadAccess = await ensureImplicitProjectMemberWithReadAccessFragment(
loaders
)({
userId,
projectId,
role: Roles.Stream.Reviewer
})
if (hasReadAccess.isErr) {
return err(hasReadAccess.error)
}
return ok()
}
@@ -0,0 +1,255 @@
import { describe, expect, it } from 'vitest'
import { Roles } from '../../../../core/constants.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { canUpdateAutomationPolicy } from './canUpdate.js'
import {
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
const buildCanUpdatePolicy = (
overrides?: Partial<Parameters<typeof canUpdateAutomationPolicy>[0]>
) =>
canUpdateAutomationPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getProjectRole: async () => Roles.Stream.Owner,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
describe('canUpdateAutomation', () => {
it('returns error if user is not logged in', async () => {
const canUpdateAutomation = buildCanUpdatePolicy()
const result = await canUpdateAutomation({
userId: undefined,
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('returns error if user is not found', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
getServerRole: async () => null
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('returns error if user is a server guest', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
getServerRole: async () => Roles.Server.Guest
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNotEnoughPermissionsError.code
})
})
it('returns error project not found', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
getProject: async () => null
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
it('returns error if no role at all', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
getProjectRole: async () => null
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('returns error if not owner', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotEnoughPermissionsError.code
})
})
it('returns ok if permissible', async () => {
const canUpdateAutomation = buildCanUpdatePolicy()
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
describe('with workspace project', () => {
const overrides = {
getProject: async () => ({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => {
const validUntil = new Date()
validUntil.setDate(validUntil.getDate() + 7)
return {
userId: 'user-id',
providerId: 'provider-id',
validUntil
}
}
}
it('returns ok if permissible', async () => {
const canUpdateAutomation = buildCanUpdatePolicy(overrides)
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns ok with implicit owner role', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
...overrides,
getWorkspaceRole: async () => Roles.Workspace.Admin,
getProjectRole: async () => null
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns error if no workspace role, even w/ valid project role', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
...overrides,
getWorkspaceRole: async () => null,
getProjectRole: async () => Roles.Stream.Owner
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceNoAccessError.code
})
})
it('returns error if invalid workspace and project role', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
...overrides,
getWorkspaceRole: async () => Roles.Workspace.Member,
getProjectRole: async () => Roles.Stream.Contributor
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotEnoughPermissionsError.code
})
})
it('returns ok if no sso configured', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
...overrides,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns error if no sso session', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
...overrides,
getWorkspaceSsoSession: async () => null
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('returns error if sso expired', async () => {
const canUpdateAutomation = buildCanUpdatePolicy({
...overrides,
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(new Date().getTime() - 1000)
})
})
const result = await canUpdateAutomation({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,63 @@
import { err, ok } from 'true-myth/result'
import { MaybeUserContext, ProjectContext } from '../../../domain/context.js'
import { AuthPolicy } from '../../../domain/policies.js'
import { Roles } from '../../../../core/constants.js'
import { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../../fragments/projects.js'
import { Loaders } from '../../../domain/loaders.js'
import {
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceNotEnoughPermissionsError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
type PolicyLoaderKeys =
| typeof Loaders.getProject
| typeof Loaders.getServerRole
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole
type PolicyArgs = ProjectContext & MaybeUserContext
type PolicyErrors = InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof ServerNotEnoughPermissionsError
| typeof WorkspaceSsoSessionNoAccessError
| typeof WorkspaceNotEnoughPermissionsError
| typeof ProjectNotEnoughPermissionsError
>
export const canUpdateAutomationPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, projectId }) => {
// Project owners may update automations
const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
projectId,
role: Roles.Stream.Owner
})
if (ensuredWriteAccess.isErr) {
return err(ensuredWriteAccess.error)
}
return ok()
}
@@ -80,10 +80,11 @@
v-if="!hideCloser"
color="subtle"
size="sm"
class="absolute z-20 top-4 right-5 shrink-0 !w-6 !h-6 !p-0"
class="absolute z-20 top-4 right-5 shrink-0 !w-6 !h-6 !p-0 text-foreground-2"
:class="closerClasses"
@click="open = false"
>
<XMarkIcon class="h-6 w-6 text-foreground-2" />
<XMarkIcon class="h-6 w-6" />
</FormButton>
<div ref="slotContainer" :class="slotContainerClasses" @scroll="onScroll">
<slot>Put your content here!</slot>
@@ -167,6 +168,7 @@ const props = withDefaults(
*/
onSubmit?: (e: SubmitEvent) => void
isTransparent?: boolean
closerClasses?: string
}>(),
{
fullscreen: 'mobile'
@@ -705,7 +705,12 @@ export class CameraController extends Extension implements SpeckleCamera {
break
case '3d':
case '3D':
this._activeControls.fromPositionAndTarget(
new Vector3().copy(this.viewer.World.worldBox.max),
canonicalTarget
)
this.zoomExtents()
break
default: {
this.enableRotations()
break