Merge branch 'main' into andrew/web-4364-applicationids-are-treated-as-numerical

This commit is contained in:
andrewwallacespeckle
2025-10-03 15:03:25 +01:00
132 changed files with 4232 additions and 2045 deletions
+29 -6
View File
@@ -1,6 +1,18 @@
on:
workflow_call:
inputs:
GITHUB_REGISTRY_URL:
required: true
type: string
GITHUB_ORG:
required: true
type: string
NPM_REGISTRY_URL:
required: true
type: string
NPM_PUBLISH_ACCESS:
required: true
type: string
IMAGE_VERSION_TAG:
required: true
type: string
@@ -13,8 +25,12 @@ jobs:
name: Publish to npm
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_REGISTRY_URL: ${{ inputs.GITHUB_REGISTRY_URL }}
GITHUB_ORG: ${{ inputs.GITHUB_ORG }}
NPM_REGISTRY_URL: ${{ inputs.NPM_REGISTRY_URL }}
NPM_PUBLISH_ACCESS: ${{ inputs.NPM_PUBLISH_ACCESS }}
IMAGE_VERSION_TAG: ${{ inputs.IMAGE_VERSION_TAG }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- uses: actions/checkout@v4.2.2
with:
@@ -25,15 +41,22 @@ jobs:
cache: yarn
- name: Install hardened (no HARD flag)
run: PUPPETEER_SKIP_DOWNLOAD=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn --immutable
- name: Auth to npm as Speckle
- name: Auth to npm
run: |
echo "npmRegistryServer: https://registry.npmjs.org/" >> .yarnrc.yml
echo "npmAuthToken: $NPM_TOKEN" >> .yarnrc.yml
echo "npmRegistryServer: ${NPM_REGISTRY_URL}" >> .yarnrc.yml
echo "npmAuthToken: ${NPM_TOKEN}" >> .yarnrc.yml
echo "npmPublishAccess: ${NPM_PUBLISH_ACCESS}" >> .yarnrc.yml
- name: Try login to npm
run: yarn npm whoami
- name: Amend package name & git repository to match
# required as per https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry#publishing-a-package-using-a-local-npmrc-file
run: |
if [ "${NPM_REGISTRY_URL}" != "https://registry.npmjs.org/" ]; then
yarn workspaces foreach -tvW --no-private exec "jq '.name |= sub(\"@speckle\"; \"${GITHUB_ORG}\"), .repository.url = \"${GITHUB_REGISTRY_URL}\"' package.json > tmp.json && mv tmp.json package.json"
fi
- name: Build public packages
run: yarn workspaces foreach -ptvW --no-private run build
- name: Bump all versions
run: yarn workspaces foreach -tvW version $IMAGE_VERSION_TAG
run: yarn workspaces foreach -tvW version ${IMAGE_VERSION_TAG}
- name: publish to npm
run: 'yarn workspaces foreach -pvW --no-private npm publish --access public'
run: 'yarn workspaces foreach -pvW --no-private npm publish --access ${NPM_PUBLISH_ACCESS}'
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
REGISTRY_DOMAIN: 'ghcr.io'
REGISTRY_USERNAME: ${{ github.actor }}
# REGISTRY_DOMAIN, REGISTRY_USERNAME, REGISTRY_TOKEN must allow pushing to the below IMAGE_PREFIX
# REGISTRY_DOMAIN, REGISTRY_USERNAME, REGISTRY_TOKEN must be configured to match the below IMAGE_PREFIX
IMAGE_PREFIX: 'ghcr.io/specklesystems'
PUBLISH: false # do not publish the sourcemaps or include the version in frontend-2 builds for pull requests
secrets:
+48 -21
View File
@@ -36,27 +36,27 @@ jobs:
uses: ./.github/workflows/builds.yml
with:
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
REGISTRY_DOMAIN: 'docker.io'
REGISTRY_USERNAME: 'speckledevops'
REGISTRY_DOMAIN: ${{ (github.repository == 'specklesystems/speckle-server') && 'docker.io' || 'ghcr.io' }}
REGISTRY_USERNAME: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckledevops' || github.actor }}
# REGISTRY_DOMAIN, REGISTRY_USERNAME, REGISTRY_TOKEN must allow pushing to the below IMAGE_PREFIX
IMAGE_PREFIX: 'speckle' # without an explicit host, Docker defaults to pushing Docker Hub
IMAGE_PREFIX: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckle' || 'ghcr.io/specklesystems' }}
PUBLISH: true # publish the sourcemaps and include the version in frontend-2 builds
PUBLISH_LATEST: ${{ startsWith(github.ref, 'refs/heads/main') }}
secrets:
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
REGISTRY_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
REGISTRY_TOKEN: ${{ (github.repository == 'specklesystems/speckle-server') && secrets.DOCKERHUB_TOKEN || secrets.GITHUB_TOKEN }}
# Temporary duplicate of builds job to push to ghcr.io
#HACK temporary job to build and push to ghcr.io until we migrate everything
builds-ghcr:
needs: [get-version]
uses: ./.github/workflows/builds.yml
with:
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
REGISTRY_DOMAIN: 'ghcr.io'
REGISTRY_DOMAIN: ${{ 'ghcr.io' }}
REGISTRY_USERNAME: ${{ github.actor }}
# REGISTRY_DOMAIN, REGISTRY_USERNAME, REGISTRY_TOKEN must allow pushing to the below IMAGE_PREFIX
IMAGE_PREFIX: 'ghcr.io/specklesystems'
PUBLISH: true # do not publish the sourcemaps or include the version in frontend-2 builds for pull requests
IMAGE_PREFIX: ${{ 'ghcr.io/specklesystems' }}
PUBLISH: true # publish the sourcemaps and include the version in frontend-2 builds
PUBLISH_LATEST: ${{ startsWith(github.ref, 'refs/heads/main') }}
secrets:
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
@@ -67,38 +67,65 @@ jobs:
uses: ./.github/workflows/deployment-tests.yml
with:
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
REGISTRY_DOMAIN: 'ghcr.io'
REGISTRY_USERNAME: ${{ github.actor }}
IMAGE_PREFIX: 'ghcr.io/specklesystems'
REGISTRY_DOMAIN: ${{ (github.repository == 'specklesystems/speckle-server') && 'docker.io' || 'ghcr.io' }}
REGISTRY_USERNAME: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckledevops' || github.actor }}
IMAGE_PREFIX: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckle' || 'ghcr.io/specklesystems' }}
secrets:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REGISTRY_TOKEN: ${{ (github.repository == 'specklesystems/speckle-server') && secrets.DOCKERHUB_TOKEN || secrets.GITHUB_TOKEN }}
deploy:
needs: [get-version, tests, builds, test-deployments, get-chart-name]
needs: [get-version, tests, builds, builds-ghcr, test-deployments, get-chart-name]
uses: ./.github/workflows/publish.yml
with:
IMAGE_PREFIX: 'ghcr.io/specklesystems'
IMAGE_PREFIX: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckle' || 'ghcr.io/specklesystems' }}
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
OCI_REGISTRY_DOMAIN: ghcr.io
OCI_REGISTRY_PATH: specklesystems
OCI_REGISTRY_USERNAME: ${{ github.actor }}
OCI_REGISTRY_DOMAIN: ${{ (github.repository == 'specklesystems/speckle-server') && 'docker.io' || 'ghcr.io' }}
OCI_REGISTRY_PATH: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckle' || 'specklesystems' }}
OCI_REGISTRY_USERNAME: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckledevops' || github.actor }}
CHART_NAME: ${{ needs.get-chart-name.outputs.CHART_NAME }}
secrets:
# we do not inherit here as we wish to configure secrets depending on the target registry
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
OCI_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} # we are pushing helm chart to ghcr
OCI_REGISTRY_PASSWORD: ${{ (github.repository == 'specklesystems/speckle-server') && secrets.DOCKERHUB_TOKEN || secrets.GITHUB_TOKEN }}
GH_DEVOPS_PAT: ${{ secrets.GH_DEVOPS_PAT }}
#HACK temporary job to publish helm charts to ghcr.io until we migrate everything
ghcr-helm-chart-oci:
needs: [get-version, tests, builds, builds-ghcr, test-deployments, get-chart-name]
runs-on: blacksmith-4vcpu-ubuntu-2404
name: Helm chart oci
container:
image: speckle/pre-commit-runner:latest
env:
IMAGE_PREFIX: 'ghcr.io/specklesystems'
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
HELM_REGISTRY_DOMAIN: 'ghcr.io'
HELM_REPOSITORY_PATH: 'specklesystems'
REGISTRY_USERNAME: ${{ github.actor }}
REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
CHART_NAME: ${{ needs.get-chart-name.outputs.CHART_NAME }}
steps:
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- run: git config --global --add safe.directory $PWD
- name: Publish Helm Chart
run: ./.github/workflows/scripts/publish_helm_chart_oci.sh
npm:
needs: [get-version, tests, builds, builds-ghcr]
uses: ./.github/workflows/npm.yml
# only run if a tag triggered the workflow
# only run if a tag triggered the workflow on specklesystems/speckle-server repository
if: startsWith(github.ref, 'refs/tags/')
with:
GITHUB_REGISTRY_URL: ${{ format('%s%s.git', 'https://github.com/', github.repository) }}
GITHUB_ORG: ${{ github.repository_owner }}
NPM_REGISTRY_URL: ${{ github.repository == 'specklesystems/speckle-server' && 'https://registry.npmjs.org/' || 'https://npm.pkg.github.com/' }}
NPM_PUBLISH_ACCESS: ${{ github.repository == 'specklesystems/speckle-server' && 'public' || 'restricted' }}
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
secrets: inherit
secrets:
NPM_TOKEN: ${{ github.repository == 'specklesystems/speckle-server' && secrets.NPM_TOKEN || github.token}}
snyk:
needs: [tests]
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -74,7 +74,6 @@ import { SpeckleViewer } from '@speckle/shared'
import { keyboardClick } from '@speckle/ui-components'
import { graphql } from '~/lib/common/generated/gql/gql'
import type { HeaderNavShare_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
import { useCopyModelLink } from '~~/lib/projects/composables/modelManagement'
graphql(`
fragment HeaderNavShare_Project on Project {
@@ -90,7 +89,6 @@ const props = defineProps<{
}>()
const { copy } = useClipboard()
const copyModelLink = useCopyModelLink()
const menuButtonId = useId()
const embedDialogOpen = ref(false)
@@ -99,37 +97,16 @@ const parsedResourceIds = computed(() =>
SpeckleViewer.ViewerRoute.parseUrlParameters(props.resourceIdString)
)
const firstResource = computed(() => parsedResourceIds.value[0] || {})
const versionId = computed(() => {
if (SpeckleViewer.ViewerRoute.isModelResource(firstResource.value)) {
return firstResource.value.versionId
}
return ''
})
const modelId = computed(() => {
if (SpeckleViewer.ViewerRoute.isModelResource(firstResource.value)) {
return firstResource.value.modelId // Assuming your firstResource object has a modelId property
}
return ''
})
const isFederated = computed(() => parsedResourceIds.value.length > 1)
const handleCopyId = () => {
copy(props.resourceIdString, { successMessage: 'ID copied to clipboard' })
const handleCopyId = async () => {
await copy(props.resourceIdString, { successMessage: 'ID copied to clipboard' })
}
const handleCopyLink = () => {
const modelIdValue = modelId.value
const versionIdValue = versionId.value ? versionId.value : undefined
void copyModelLink({
model: {
projectId: props.project.id,
id: modelIdValue
},
versionId: versionIdValue
const handleCopyLink = async () => {
if (import.meta.server) return
await copy(window.location.href, {
successMessage: 'Copied link to clipboard'
})
}
@@ -0,0 +1,107 @@
<template>
<div
class="flex items-center justify-between border border-foreground-1 bg-foundation rounded-lg p-2"
>
<div class="flex items-center space-x-3">
<img
:src="integration.logo"
alt=""
class="w-10 h-10 p-1 object-cover border border-foreground-1 rounded-lg"
/>
<div class="flex flex-col">
<span class="font-medium">{{ integration.name }}</span>
<span class="text-sm text-foreground-2">
{{ integration.description }}
</span>
</div>
</div>
<div class="flex items-center space-x-4">
<CommonLoadingIcon v-if="loading" :loading="true" class="opacity-50 mr-2" />
<div v-else-if="integration.enabled">
<div class="flex items-center text-sm text-foreground-2 space-x-2">
<span
v-if="
integration.status === 'connected' || integration.status === 'expired'
"
class="w-2 h-2 rounded-full"
:class="{
'bg-success': integration.status === 'connected',
'bg-warning': integration.status === 'expired'
}"
></span>
<div>{{ statusText() }}</div>
<!-- CTA -->
<FormButton size="sm" color="outline" @click="handleCTA()">
<span v-if="integration.status === 'notConnected'">Log in</span>
<span v-else-if="integration.status === 'expired'">Reconnect</span>
<span v-else>Log out</span>
</FormButton>
</div>
</div>
<div v-else>
<FormButton size="sm" @click="$emit('upgrade')">Upgrade</FormButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAccAuthManager } from '~/lib/acc/composables/useAccAuthManager'
import { useAccIntegration } from '~/lib/integrations/composables/useAccIntegration'
const props = defineProps<{ workspaceId: string; workspaceSlug: string }>()
defineEmits<{
(e: 'handleCTA'): void
(e: 'upgrade'): void
}>()
const loading = ref(true)
const { integration, checkConnection } = useAccIntegration()
const { tokens, authAcc, logOut, tryGetTokensFromCookies, fetchTokens } =
useAccAuthManager() // later can be generalized
// await checkConnection(props.workspaceSlug, props.workspaceId || '')
const statusText = () => {
switch (integration.value.status) {
case 'connected':
return 'Connected'
case 'expired':
return 'Expired'
case 'notConnected':
return ''
default:
break
}
}
const handleCTA = async () => {
if (
integration.value.status === 'notConnected' ||
integration.value.status === 'expired'
) {
authAcc(`/settings/workspaces/${props.workspaceSlug}/integrations`)
} else {
logOut()
await checkConnection(props.workspaceSlug, props.workspaceId || '')
}
}
onMounted(async () => {
loading.value = true
try {
await tryGetTokensFromCookies()
if (!tokens.value) {
await fetchTokens()
}
await checkConnection(props.workspaceSlug, props.workspaceId || '')
} finally {
loading.value = false
}
})
</script>
@@ -0,0 +1,95 @@
<template>
<div class="flex flex-row h-full overflow-hidden border rounded-lg bg-foundation">
<!-- Left Pane for tree -->
<div class="w-1/4 p-2 overflow-y-auto border-r">
<h3 class="font-semibold text-lg text-center">Folders</h3>
<hr class="mb-1" />
<div v-if="!rootFolder"></div>
<ul
v-else-if="rootFolder && rootFolder.children?.items.length"
class="space-y-1 pt-1"
>
<IntegrationsAccFolderNode
v-for="folder in rootFolderChildren"
:key="folder.id"
:project-id="projectId"
:folder-id="folder.id"
:tokens="tokens"
:selected-folder-id="selectedFolderId"
@select="onFolderClick"
/>
</ul>
</div>
<!-- Right Pane for content -->
<div class="w-3/4 p-2 overflow-y-auto">
<h3 class="font-semibold text-lg text-center">Files</h3>
<hr class="mb-1" />
<IntegrationsAccFolderContents
v-if="!!selectedFolderId"
:key="`contents-${selectedFolderId}`"
:project-id="projectId"
:folder-id="selectedFolderId"
:tokens="tokens"
:selected-file-id="selectedFileId"
@select="onFileSelected"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { AccTokens } from '@speckle/shared/acc'
import { ref, watch } from 'vue'
import { useAcc, type AccItemVersion } from '~/lib/acc/composables/useAccFiles'
import { useAccFolder } from '~/lib/acc/composables/useAccFolderData'
const props = defineProps<{
hubId: string
projectId: string
tokens: AccTokens | undefined
}>()
const emit = defineEmits<{
'file-selected': [fileId: string, fileVersion: AccItemVersion]
}>()
const { init, rootProjectFolderId } = useAcc()
const rootFolder = useAccFolder(props.projectId, rootProjectFolderId, props.tokens)
const rootFolderChildren = computed(
() =>
rootFolder.value?.children?.items?.filter(
(child) => child.name === 'Project Files'
) ?? []
)
const selectedFolderId = ref<string | undefined>()
const selectedFileId = ref<string | undefined>()
const onFolderClick = async (folderId: string) => {
selectedFolderId.value = folderId
selectedFileId.value = undefined
}
const onFileSelected = (fileId: string, fileVersion: AccItemVersion) => {
selectedFileId.value = fileId
emit('file-selected', fileId, fileVersion)
}
// Watch for changes in projectId to re-initialize the folder tree
watch(
() => props.projectId,
async (newProjectId) => {
selectedFolderId.value = undefined
selectedFileId.value = undefined
if (newProjectId && props.tokens) {
await init(props.hubId, newProjectId, props.tokens.access_token)
}
},
{ immediate: true }
)
watch(rootFolderChildren, (newValue) => {
selectedFolderId.value = newValue.at(0)?.id
})
</script>
@@ -0,0 +1,56 @@
<template>
<div>
<ul v-if="items?.length" class="space-y-1">
<template v-for="item in items" :key="item.id">
<li
class="flex items-center space-x-1 px-2 rounded-md transition-colors w-full"
:class="{
'bg-foundation-focus font-semibold': selectedFileId === item.id,
'hover:bg-primary-muted cursor-pointer': selectedFileId !== item.id
}"
>
<button
class="flex items-center space-x-1 p-1 rounded-md transition-colors w-full"
@click="
emit('select', item.id, removeNullOrUndefinedKeys(item.latestVersion))
"
>
<span>
{{ item.name }}
</span>
</button>
</li>
<hr />
</template>
</ul>
<div v-else class="text-center text-foreground-2 py-2">
<span>No files found.</span>
</div>
</div>
</template>
<script setup lang="ts">
import { removeNullOrUndefinedKeys } from '@speckle/shared'
import type { AccTokens } from '@speckle/shared/acc'
import type { AccItemVersion } from '~/lib/acc/composables/useAccFiles'
import { useAccFolder } from '~/lib/acc/composables/useAccFolderData'
const props = defineProps<{
projectId: string
folderId: string
tokens: AccTokens | undefined
selectedFileId: string | undefined
}>()
const emit = defineEmits<{
select: [fileId: string, fileVersion: AccItemVersion]
}>()
const folder = useAccFolder(props.projectId, props.folderId, props.tokens)
const items = computed(() => {
return folder.value.contents?.items.filter(
(item) => item.latestVersion.fileType?.toLowerCase() === 'rvt'
)
})
</script>
@@ -0,0 +1,104 @@
<template>
<li>
<button
class="flex items-center space-x-1 p-1 rounded-md transition-colors w-full"
:class="{
'bg-foundation-focus font-semibold': selectedFolderId === folder.id,
'hover:bg-primary-muted cursor-pointer': selectedFolderId !== folder.id
}"
@click="emit('select', folderId)"
>
<ChevronDownIcon
:class="`h-4 w-5 transition ${!isExpanded ? '-rotate-90' : 'rotate-0'}`"
@click.stop="isExpanded = !isExpanded"
/>
<span>{{ folder.name }}</span>
</button>
<ul
v-if="isExpanded && folder.children && folder.children.items.length > 0"
class="ml-4 mt-1 space-y-1"
>
<IntegrationsAccFolderNode
v-for="child in folder.children.items"
:key="child.id"
:folder-id="child.id"
:project-id="projectId"
:tokens="tokens"
:selected-folder-id="selectedFolderId"
@select="(id) => emit('select', id)"
/>
</ul>
</li>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
import { graphql } from '~/lib/common/generated/gql'
import { useAccFolder } from '~/lib/acc/composables/useAccFolderData'
import type { AccTokens } from '@speckle/shared/acc'
graphql(`
fragment AccIntegrationFolderNode_AccFolder on AccFolder {
id
name
contents {
items {
id
name
latestVersion {
id
name
versionNumber
fileType
}
}
}
children {
items {
id
name
children {
items {
id
name
}
}
contents {
items {
id
name
}
}
}
}
}
`)
const props = defineProps<{
// TODO ACC Maybe inject from shared local state within file navigation
projectId: string
folderId: string
tokens?: AccTokens
// TODO ACC Maybe inject from shared local state within file navigation
selectedFolderId: string | undefined
}>()
const emit = defineEmits<{
select: [folderId: string]
}>()
const folder = useAccFolder(props.projectId, props.folderId, props.tokens)
// watch(
// folder,
// (f) => {
// console.log({ resultFolder: f })
// },
// {
// immediate: true
// }
// )
const isExpanded = ref(false)
</script>
@@ -11,12 +11,12 @@
/>
<CommonLoadingBar v-if="loading" loading />
<ProjectPageAccModelItem
<IntegrationsAccModelItem
v-for="model in models"
:key="model.id"
:model="model"
:selected="model.id === selectedModel?.id"
:disabled="!!props.accSyncItems?.find((i) => i.modelId === model.id)"
:disabled="!!props.accSyncItems?.find((i) => i.model?.id === model.id)"
@click="onModelItemClicked(model)"
/>
<button
@@ -0,0 +1,59 @@
<template>
<div v-tippy="statusLabel">
<CommonBadge
:color-classes="
[runStatusClasses(status), 'shrink-0 grow-0 text-foreground'].join(' ')
"
>
ACC
</CommonBadge>
</div>
</template>
<script setup lang="ts">
import { graphql } from '~/lib/common/generated/gql'
import type {
AccSyncItemStatus,
SyncStatusModelItem_AccSyncItemFragment
} from '~/lib/common/generated/gql/graphql'
graphql(`
fragment SyncStatusModelItem_AccSyncItem on AccSyncItem {
id
status
}
`)
const props = defineProps<{
item: SyncStatusModelItem_AccSyncItemFragment
}>()
const status = computed(() => props.item.status)
const statusLabel = computed(
() => status.value.charAt(0).toUpperCase() + status.value.slice(1)
)
const runStatusClasses = (run: AccSyncItemStatus) => {
const classParts = ['w-24 justify-center']
switch (run) {
case 'syncing':
classParts.push('bg-info-lighter')
break
case 'pending':
classParts.push('bg-warning-lighter')
break
case 'paused':
classParts.push('bg-warning-lighter')
break
case 'failed':
classParts.push('bg-danger-lighter')
break
case 'succeeded':
classParts.push('bg-success-lighter')
break
}
return classParts.join(' ')
}
</script>
@@ -0,0 +1,142 @@
<template>
<div class="flex flex-col space-y-2">
<div class="flex text-body-xs text-foreground font-medium">Sync models</div>
<LayoutTable
class="bg-foundation"
:columns="[
{ id: 'status', header: 'Status', classes: 'col-span-2' },
{ id: 'accFileName', header: 'File name', classes: 'col-span-2' },
{ id: 'accFileViewName', header: 'View name', classes: 'col-span-2' },
{ id: 'modelId', header: 'Model id', classes: 'col-span-2' },
{ id: 'createdBy', header: 'Created by', classes: 'col-span-2' },
{ id: 'actions', header: 'Actions', classes: 'col-span-2' }
]"
:items="accSyncItems"
>
<template #status="{ item }">
<IntegrationsAccSyncStatus :status="item.status" />
</template>
<template #accFileName="{ item }">
{{ item.accFileName }}
</template>
<template #accFileViewName="{ item }">
{{ item.accFileViewName || '-' }}
</template>
<template #modelId="{ item }">
<NuxtLink
class="text-foreground-1 hover:text-blue-500 underline"
:to="`/projects/${projectId}/models/${item.model?.id}`"
>
{{ item.model?.id }}
</NuxtLink>
</template>
<template #createdBy="{ item }">
{{ item.author?.name }}
</template>
<template #actions="{ item }">
<div class="space-x-2">
<FormButton
hide-text
color="outline"
:icon-left="item.status === 'paused' ? PlayIcon : PauseIcon"
@click="handleStatusSyncItem(item.id, item.status === 'paused')"
/>
<FormButton
hide-text
color="outline"
:icon-left="TrashIcon"
@click="handleDeleteSyncItem(item.id)"
/>
</div>
</template>
</LayoutTable>
</div>
</template>
<script setup lang="ts">
import type { AccTokens } from '@speckle/shared/acc'
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable'
import {
accSyncItemDeleteMutation,
accSyncItemUpdateMutation
} from '~/lib/acc/graphql/mutations'
import { projectAccSyncItemsQuery } from '~/lib/acc/graphql/queries'
import { onProjectAccSyncItemUpdatedSubscription } from '~/lib/acc/graphql/subscriptions'
import { PauseIcon } from '@heroicons/vue/24/solid'
import { TrashIcon, PlayIcon } from '@heroicons/vue/24/outline'
const props = defineProps<{
projectId: string
tokens: AccTokens | undefined
isLoggedIn: boolean
}>()
const { triggerNotification } = useGlobalToast()
const { result: accSyncItemsResult, refetch: refetchAccSyncItems } = useQuery(
projectAccSyncItemsQuery,
() => ({
id: props.projectId
})
)
const accSyncItems = computed(
() => accSyncItemsResult.value?.project.accSyncItems.items || []
)
const { onResult: onProjectAccSyncItemsUpdated } = useSubscription(
onProjectAccSyncItemUpdatedSubscription,
() => ({
id: props.projectId
})
)
onProjectAccSyncItemsUpdated((res) => {
// TODO ACC: Mutate local cache instead of refetch
refetchAccSyncItems()
triggerNotification({
type: ToastNotificationType.Info,
title: `ACC sync model ${res.data?.projectAccSyncItemsUpdated.type.toLowerCase()}`,
description: res.data?.projectAccSyncItemsUpdated.accSyncItem?.accFileName
})
})
const { mutate: deleteAccSyncItem } = useMutation(accSyncItemDeleteMutation)
const handleDeleteSyncItem = async (id: string) => {
try {
await deleteAccSyncItem({
input: {
projectId: props.projectId,
id
}
})
} catch (error) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Delete sync item failed',
description: error instanceof Error ? error.message : 'Unexpected error'
})
}
}
const { mutate: updateAccSyncItem } = useMutation(accSyncItemUpdateMutation)
const handleStatusSyncItem = async (id: string, isPaused: boolean) => {
try {
await updateAccSyncItem({
input: {
projectId: props.projectId,
id,
status: isPaused ? 'pending' : 'paused'
}
})
} catch (error) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Update sync item failed',
description: error instanceof Error ? error.message : 'Unexpected error'
})
}
}
</script>
@@ -0,0 +1,198 @@
<template>
<LayoutDialog
v-model:open="isOpen"
:title="dialogTitle"
:buttons="dialogButtons"
max-width="lg"
fullscreen="none"
>
<div class="flex flex-col space-y-2">
<IntegrationsAccHubs
:hubs="hubs"
:loading="loadingHubs"
@hub-selected="onHubClick"
/>
<IntegrationsAccProjects
v-if="selectedHubId"
:hub-id="selectedHubId"
:projects="projects"
:loading="loadingProjects"
@project-selected="onProjectClick"
/>
<IntegrationsAccFileSelector
v-if="selectedProjectId && selectedHubId && tokens"
:key="selectedProjectId"
:hub-id="selectedHubId"
:project-id="selectedProjectId"
:tokens="tokens"
@file-selected="onFileSelected"
/>
<FormTextInput
v-model="revitViewName"
name="revitFileViewName"
color="foundation"
label="Revit view name (Optional)"
show-label
:disabled="!selectedFileVersion"
></FormTextInput>
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { AccHub } from '@speckle/shared/acc'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useMutation } from '@vue/apollo-composable'
import { useForm } from 'vee-validate'
import { useAccAuthManager } from '~/lib/acc/composables/useAccAuthManager'
import {
useAcc,
type AccFolder,
type AccItemVersion
} from '~/lib/acc/composables/useAccFiles'
import { accSyncItemCreateMutation } from '~/lib/acc/graphql/mutations'
import { useCreateNewModel } from '~/lib/projects/composables/modelManagement'
type FormValues = { feedback: string }
const props = defineProps<{
title?: string
intro?: string
hideSuppport?: boolean
projectId?: string
metadata?: Record<string, unknown>
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const { handleSubmit } = useForm<FormValues>()
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Create',
props: { color: 'primary' },
onClick: () => {
onSubmit()
},
id: 'createAccSync'
}
])
const { triggerNotification } = useGlobalToast()
const createModel = useCreateNewModel()
const dialogTitle = computed(() => props.title || 'Create sync from ACC')
const onSubmit = handleSubmit(async () => {
await addSync()
isOpen.value = false
})
const selectedHub = ref<AccHub | null>(null)
const selectedHubId = ref<string | null>(null)
const selectedProjectId = ref<string | null>(null)
const revitViewName = ref<string>()
const selectedFolder = ref<AccFolder | undefined>()
const selectedFileId = ref<string | undefined>()
const selectedFileVersion = ref<AccItemVersion | undefined>()
const {
hubs,
fetchHubs,
loadingHubs,
projects,
fetchProjects,
loadingProjects,
rootProjectFolderId,
init
} = useAcc()
const { tokens, tryGetTokensFromCookies } = useAccAuthManager()
const onHubClick = async (hub: AccHub) => {
selectedHub.value = hub
selectedHubId.value = hub.id
await fetchProjects(hub.id, tokens.value!.access_token)
}
const onProjectClick = async (hubId: string, projectId: string) => {
selectedFolder.value = undefined
selectedFileVersion.value = undefined
selectedProjectId.value = projectId
await init(hubId, projectId, tokens.value!.access_token)
}
const onFileSelected = (fileId: string, fileVersion: AccItemVersion) => {
selectedFileId.value = fileId
selectedFileVersion.value = fileVersion
}
const { mutate: createAccSyncItem } = useMutation(accSyncItemCreateMutation)
const addSync = async () => {
try {
if (!selectedFileVersion.value || !selectedFileVersion.value.fileType) {
return
}
const fileVersion = selectedFileVersion.value.versionNumber
const accFileViewName = revitViewName.value === '' ? undefined : revitViewName.value
const res = await createModel({
name: selectedFileVersion.value.name,
description: '',
projectId: props.projectId as string
})
await createAccSyncItem({
input: {
projectId: props.projectId as string,
modelId: res?.id as string,
accRegion: selectedHub.value?.attributes?.region as string,
accFileExtension: selectedFileVersion.value.fileType,
accHubId: selectedHubId.value!,
accProjectId: selectedProjectId.value as string,
accRootProjectFolderUrn: rootProjectFolderId.value!,
accFileLineageUrn: selectedFileId.value as string,
accFileName: selectedFileVersion.value.name,
accFileVersionIndex: fileVersion,
accFileVersionUrn: selectedFileVersion.value.id,
accFileViewName
}
})
} catch (error) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Add sync item failed',
description: error instanceof Error ? error.message : 'Unexpected error'
})
} finally {
revitViewName.value = undefined
}
}
watch(tokens, (newTokens) => {
if (newTokens?.access_token) {
fetchHubs(newTokens?.access_token)
}
})
onMounted(async () => {
await tryGetTokensFromCookies()
if (tokens.value) {
fetchHubs(tokens.value.access_token)
}
})
watch(isOpen, (newVal) => {
if (newVal) {
selectedFileVersion.value = undefined
}
})
</script>
@@ -29,6 +29,7 @@ import { LucideChevronLeft, LucideChevronRight, LucideRotateCcw } from 'lucide-v
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
import { clamp } from 'lodash-es'
import { useEventListener } from '@vueuse/core'
import { useResetViewUtils } from '~/lib/presentations/composables/utils'
defineProps<{
hideUi?: boolean
@@ -36,8 +37,9 @@ defineProps<{
const {
ui: { slideIdx: currentVisibleIndex, slideCount },
viewer: { resetView, hasViewChanged }
viewer: { hasViewChanged }
} = useInjectedPresentationState()
const { resetView } = useResetViewUtils()
const disablePrevious = computed(() => currentVisibleIndex.value === 0)
const disableNext = computed(() =>
@@ -26,6 +26,7 @@ import { graphql } from '~~/lib/common/generated/gql'
import type { PresentationSlideListSlide_SavedViewFragment } from '~~/lib/common/generated/gql/graphql'
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
import { useAuthManager } from '~~/lib/auth/composables/auth'
import { useResetViewUtils } from '~/lib/presentations/composables/utils'
graphql(`
fragment PresentationSlideListSlide_SavedView on SavedView {
@@ -42,10 +43,10 @@ const props = defineProps<{
}>()
const {
ui: { slideIdx: currentSlideIdx, slide: currentSlide },
viewer: { resetView }
ui: { slideIdx: currentSlideIdx, slide: currentSlide }
} = useInjectedPresentationState()
const { presentationToken } = useAuthManager()
const { resetView } = useResetViewUtils()
const isCurrentSlide = computed(() => currentSlide.value?.id === props.slide.id)
@@ -40,7 +40,7 @@ const slideListRef = ref<HTMLUListElement>()
const containerRef = computed(() => slideListRef.value?.parentElement)
const scrollToActiveSlide = () => {
const scrollToActiveSlide = (scrollBehavior: ScrollBehavior) => {
if (!slideListRef.value || !containerRef.value) return
const activeSlideElement = slideListRef.value.children[slideIdx.value] as HTMLElement
@@ -54,7 +54,7 @@ const scrollToActiveSlide = () => {
containerRef.value.scrollTo({
top: Math.max(0, scrollTop),
behavior: 'smooth'
behavior: scrollBehavior
})
}
@@ -63,8 +63,12 @@ const throttledScrollToActiveSlide = useThrottleFn(scrollToActiveSlide, 100)
watch(
slideIdx,
() => {
throttledScrollToActiveSlide()
throttledScrollToActiveSlide('smooth')
},
{ immediate: true }
)
onMounted(() => {
scrollToActiveSlide('instant')
})
</script>
@@ -0,0 +1,12 @@
<template>
<div class="presentation-viewer-post-setup h-full"><slot /></div>
</template>
<script setup lang="ts">
import { usePresentationViewerPostSetup } from '~/lib/presentations/composables/viewerPostSetup'
/**
* The only point of this component is to get around the stupid limitation where a component that injects() also can't provide() the same stuff back...
*/
usePresentationViewerPostSetup()
</script>
@@ -1,9 +1,11 @@
<template>
<ViewerStateSetup :init-params="initParams">
<PresentationViewerSetup
@loading-change="onLoadingChange"
@progress-change="onProgressChange"
/>
<PresentationViewerPostSetup>
<PresentationViewerSetup
@loading-change="onLoadingChange"
@progress-change="onProgressChange"
/>
</PresentationViewerPostSetup>
</ViewerStateSetup>
</template>
<script setup lang="ts">
@@ -1,128 +0,0 @@
<template>
<div class="flex flex-row h-full overflow-hidden border rounded-lg bg-foundation">
<!-- Left Pane for tree -->
<div class="w-1/4 p-2 overflow-y-auto border-r">
<h3 class="font-semibold text-lg text-center">Folders</h3>
<hr class="mb-1" />
<div v-if="loadingTree" class="text-center text-foreground-2 py-2">
Loading folders...
<InfiniteLoading />
</div>
<ul v-else-if="folderTree && folderTree.children" class="space-y-1 pt-1">
<ProjectPageAccFolderNode
v-for="folder in folderTree.children"
:key="folder.id"
:folder="folder"
:on-select-folder="onFolderClick"
:selected-folder-id="selectedFolder?.id"
/>
</ul>
<div v-else class="text-center text-foreground-2 py-4">No folders found.</div>
</div>
<!-- Right Pane for content -->
<div class="w-3/4 p-2 overflow-y-auto">
<h3 class="font-semibold text-lg text-center">Files</h3>
<hr class="mb-1" />
<div
v-if="loadingItems || loadingTree"
class="text-center text-foreground-2 py-2"
>
<div v-if="selectedFolder">
<span>Loading files in</span>
<span class="font-bold">{{ ` ${selectedFolder.attributes.name} ` }}</span>
<span>folder.</span>
<InfiniteLoading />
</div>
<div v-else>Waiting for folder selection...</div>
</div>
<ul v-else-if="folderItems.length > 0" class="space-y-1">
<template v-for="item in folderItems" :key="item.id">
<li
class="flex items-center space-x-1 px-2 rounded-md transition-colors w-full"
:class="{
'bg-foundation-focus font-semibold': selectedFile?.id === item.id,
'hover:bg-primary-muted cursor-pointer': selectedFile?.id !== item.id
}"
>
<button
class="flex items-center space-x-1 p-1 rounded-md transition-colors w-full"
@click="onFileSelected(item)"
>
<span>
{{ item.attributes.name || item.attributes.displayName }}
</span>
</button>
</li>
<hr />
</template>
</ul>
<div v-else class="text-center text-foreground-2 py-2">
<div v-if="selectedFolder">
<span>No files found in</span>
<span class="font-bold">{{ ` ${selectedFolder.attributes.name} ` }}</span>
<span>folder.</span>
</div>
<span v-else>Select a folder to view files..</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AccTokens } from '@speckle/shared/acc'
import { ref, watch } from 'vue'
import { useAcc } from '~/lib/acc/composables/useAcc'
import type { AccFolder, AccItem } from '~/lib/acc/composables/useAcc'
const props = defineProps<{
hubId: string
projectId: string
tokens: AccTokens | undefined
}>()
const emit = defineEmits(['file-selected'])
const {
loadingTree,
loadingItems,
folderTree,
folderItems,
fetchItemsForFolder,
init
} = useAcc()
const selectedFolder = ref<AccFolder | undefined>()
const selectedFile = ref<AccItem | undefined>()
const onFolderClick = async (folder: AccFolder) => {
selectedFolder.value = folder
selectedFile.value = undefined
await fetchItemsForFolder(folder.id, props.projectId, props.tokens!.access_token)
}
const onFileSelected = (item: AccItem) => {
selectedFile.value = item
emit('file-selected', item)
}
// Watch for changes in projectId to re-initialize the folder tree
watch(
() => props.projectId,
async (newProjectId) => {
selectedFolder.value = undefined
selectedFile.value = undefined
if (newProjectId && props.tokens) {
await init(props.hubId, newProjectId, props.tokens.access_token)
// Automatically select the first folder and fetch its files
if (
folderTree.value &&
folderTree.value.children &&
folderTree.value.children.length > 0
) {
await onFolderClick(folderTree.value.children[0])
}
}
},
{ immediate: true }
)
</script>
@@ -1,49 +0,0 @@
<template>
<li>
<button
class="flex items-center space-x-1 p-1 rounded-md transition-colors w-full"
:class="{
'bg-foundation-focus font-semibold': selectedFolderId === folder.id,
'hover:bg-primary-muted cursor-pointer': selectedFolderId !== folder.id
}"
@click="select(folder)"
>
<ChevronDownIcon
:class="`h-4 w-5 transition ${!isExpanded ? '-rotate-90' : 'rotate-0'}`"
@click.stop="isExpanded = !isExpanded"
/>
<span>{{ folder.attributes.name }}</span>
</button>
<ul
v-if="isExpanded && folder.children && folder.children.length > 0"
class="ml-4 mt-1 space-y-1"
>
<ProjectPageAccFolderNode
v-for="child in folder.children"
:key="child.id"
:folder="child"
:on-select-folder="onSelectFolder"
:selected-folder-id="selectedFolderId"
/>
</ul>
</li>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
import type { AccFolder } from '~/lib/acc/composables/useAcc'
const props = defineProps<{
folder: AccFolder
onSelectFolder: (folder: AccFolder) => void
selectedFolderId: string | undefined
}>()
const isExpanded = ref(false)
const select = (folder: AccFolder) => {
props.onSelectFolder(folder)
}
</script>
@@ -1,339 +0,0 @@
<template>
<div class="flex flex-col space-y-2">
<div class="flex text-body-xs text-foreground font-medium">Sync models</div>
<LayoutTable
class="bg-foundation"
:columns="[
{ id: 'status', header: 'Status', classes: 'col-span-2' },
{ id: 'accFileName', header: 'File name', classes: 'col-span-2' },
{ id: 'accFileViewName', header: 'View name', classes: 'col-span-2' },
{ id: 'modelId', header: 'Model id', classes: 'col-span-2' },
{ id: 'createdBy', header: 'Created by', classes: 'col-span-2' },
{ id: 'actions', header: 'Actions', classes: 'col-span-2' }
]"
:items="accSyncItems"
>
<template #status="{ item }">
<ProjectPageAccSyncStatus :status="item.status" />
</template>
<template #accFileName="{ item }">
{{ item.accFileName }}
</template>
<template #accFileViewName="{ item }">
{{ item.accFileViewName || '-' }}
</template>
<template #modelId="{ item }">
<NuxtLink
class="text-foreground-1 hover:text-blue-500 underline"
:to="`/projects/${projectId}/models/${item.modelId}`"
>
{{ item.modelId }}
</NuxtLink>
</template>
<template #createdBy="{ item }">
{{ item.author?.name }}
</template>
<template #actions="{ item }">
<div class="space-x-2">
<FormButton
hide-text
color="outline"
:icon-left="item.status === 'paused' ? PlayIcon : PauseIcon"
@click="handleStatusSyncItem(item.id, item.status === 'paused')"
/>
<FormButton
hide-text
color="outline"
:icon-left="TrashIcon"
@click="handleDeleteSyncItem(item.id)"
/>
</div>
</template>
</LayoutTable>
<FormButton
color="outline"
size="sm"
:disabled="!isLoggedIn"
:disabled-tooltip="'Log in required'"
@click="showNewSyncDialog = true"
>
<template #default>
<div v-tippy="isLoggedIn ? undefined : 'Log in required'">New sync</div>
</template>
</FormButton>
<LayoutDialog
v-model:open="showNewSyncDialog"
title="Create new sync"
@fully-closed="step = 0"
>
<div class="flex flex-col">
<div v-if="step === 0" class="space-y-2">
<ProjectPageAccHubs
:hubs="hubs"
:loading="loadingHubs"
@hub-selected="onHubClick"
/>
<ProjectPageAccProjects
v-if="selectedHubId"
:hub-id="selectedHubId"
:projects="projects"
:loading="loadingProjects"
@project-selected="onProjectClick"
/>
<ProjectPageAccFileSelector
v-if="selectedProjectId && selectedHubId && tokens"
:hub-id="selectedHubId"
:project-id="selectedProjectId"
:tokens="tokens"
@file-selected="onFileSelected"
/>
<FormTextInput
v-model="revitViewName"
name="revitFileViewName"
color="foundation"
label="Revit view name (Optional)"
show-label
:disabled="!selectedFile"
></FormTextInput>
<div class="flex flex-row justify-center mt-4 space-x-2">
<FormButton size="sm" color="outline" @click="showNewSyncDialog = false">
Cancel
</FormButton>
<FormButton size="sm" :disabled="!selectedFile" @click="step++">
Next
</FormButton>
</div>
</div>
<div v-if="step === 1" class="flex flex-col space-y-2">
<CommonAlert color="info" hide-icon>
<template #title>
Selected ACC file:
{{
selectedFile?.attributes.name || selectedFile?.attributes.displayName
}}
</template>
</CommonAlert>
<hr />
<ProjectPageAccModelSelector
:project-id="projectId"
:acc-sync-items="accSyncItems"
@model-selected="(model) => (selectedModel = model)"
/>
<hr />
<div class="flex flex-row justify-center space-x-2">
<FormButton size="sm" color="outline" @click="showNewSyncDialog = false">
Cancel
</FormButton>
<FormButton size="sm" :disabled="!selectedModel" @click="addSync">
Add
</FormButton>
</div>
</div>
</div>
</LayoutDialog>
</div>
</template>
<script setup lang="ts">
import type { AccTokens, AccHub, AccItem } from '@speckle/shared/acc'
import { ref, computed, watch } from 'vue'
import type { ProjectPageLatestItemsModelItemFragment } from '~/lib/common/generated/gql/graphql'
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable'
import {
accSyncItemCreateMutation,
accSyncItemDeleteMutation,
accSyncItemUpdateMutation
} from '~/lib/acc/graphql/mutations'
import { projectAccSyncItemsQuery } from '~/lib/acc/graphql/queries'
import { onProjectAccSyncItemUpdatedSubscription } from '~/lib/acc/graphql/subscriptions'
import { PauseIcon } from '@heroicons/vue/24/solid'
import { TrashIcon, PlayIcon } from '@heroicons/vue/24/outline'
import type { AccFolder } from '~/lib/acc/composables/useAcc'
import { useAcc } from '~/lib/acc/composables/useAcc'
const props = defineProps<{
projectId: string
tokens: AccTokens | undefined
isLoggedIn: boolean
}>()
const step = ref(0)
const showNewSyncDialog = ref(false)
const { triggerNotification } = useGlobalToast()
const tokens = computed(() => props.tokens)
const selectedHub = ref<AccHub | null>(null)
const selectedHubId = ref<string | null>(null)
const selectedProjectId = ref<string | null>(null)
const revitViewName = ref<string>()
const selectedModel = ref<ProjectPageLatestItemsModelItemFragment>()
const selectedFolder = ref<AccFolder | undefined>()
const selectedFile = ref<AccItem | undefined>()
// Use the composable to get the state and functions
const {
hubs,
fetchHubs,
loadingHubs,
projects,
fetchProjects,
loadingProjects,
folderTree,
fetchItemsForFolder,
rootProjectFolderId,
init
} = useAcc()
const onFileSelected = (item: AccItem) => {
selectedFile.value = item
}
const { result: accSyncItemsResult, refetch: refetchAccSyncItems } = useQuery(
projectAccSyncItemsQuery,
() => ({
id: props.projectId
})
)
const accSyncItems = computed(
() => accSyncItemsResult.value?.project.accSyncItems.items || []
)
const { onResult: onProjectAccSyncItemsUpdated } = useSubscription(
onProjectAccSyncItemUpdatedSubscription,
() => ({
id: props.projectId
})
)
onProjectAccSyncItemsUpdated((res) => {
// TODO ACC: Mutate local cache instead of refetch
refetchAccSyncItems()
triggerNotification({
type: ToastNotificationType.Info,
title: `ACC sync model ${res.data?.projectAccSyncItemsUpdated.type.toLowerCase()}`,
description: res.data?.projectAccSyncItemsUpdated.accSyncItem?.accFileName
})
})
const onHubClick = async (hub: AccHub) => {
selectedHub.value = hub
selectedHubId.value = hub.id
await fetchProjects(hub.id, tokens.value!.access_token)
}
// Refactored onProjectClick to use the composable's init function
const onProjectClick = async (hubId: string, projectId: string) => {
selectedFolder.value = undefined
selectedFile.value = undefined
selectedProjectId.value = projectId
await init(hubId, projectId, tokens.value!.access_token)
// defaulting to first
if (folderTree.value && folderTree.value.children) {
selectedFolder.value = folderTree.value?.children[0]
await fetchItemsForFolder(
selectedFolder.value.id,
selectedProjectId.value,
tokens.value!.access_token
)
}
}
const { mutate: createAccSyncItem } = useMutation(accSyncItemCreateMutation)
const addSync = async () => {
try {
// annoying but looks like ACC does not give the exact version number directly
const fileVersion = Number(
new URLSearchParams(selectedFile.value?.latestVersionId?.split('?')[1]).get(
'version'
)
)
const accFileViewName = revitViewName.value === '' ? undefined : revitViewName.value
await createAccSyncItem({
input: {
projectId: props.projectId,
modelId: selectedModel.value?.id as string,
accRegion: selectedHub.value?.attributes?.region as string,
accFileExtension: selectedFile.value?.fileExtension as string,
accHubId: selectedHubId.value!,
accProjectId: selectedProjectId.value as string,
accRootProjectFolderUrn: rootProjectFolderId.value!,
accFileLineageUrn: selectedFile.value?.id as string,
accFileName: (selectedFile.value?.attributes.displayName ||
selectedFile.value?.attributes.name) as string,
accFileVersionIndex: fileVersion,
accFileVersionUrn: selectedFile.value?.latestVersionId as string,
accFileViewName
}
})
} catch (error) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Add sync item failed',
description: error instanceof Error ? error.message : 'Unexpected error'
})
} finally {
revitViewName.value = undefined
showNewSyncDialog.value = false
step.value = 0
}
}
const { mutate: deleteAccSyncItem } = useMutation(accSyncItemDeleteMutation)
const handleDeleteSyncItem = async (id: string) => {
try {
await deleteAccSyncItem({
input: {
projectId: props.projectId,
id
}
})
} catch (error) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Delete sync item failed',
description: error instanceof Error ? error.message : 'Unexpected error'
})
}
}
const { mutate: updateAccSyncItem } = useMutation(accSyncItemUpdateMutation)
const handleStatusSyncItem = async (id: string, isPaused: boolean) => {
try {
await updateAccSyncItem({
input: {
projectId: props.projectId,
id,
status: isPaused ? 'pending' : 'paused'
}
})
} catch (error) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Update sync item failed',
description: error instanceof Error ? error.message : 'Unexpected error'
})
}
}
watch(tokens, (newTokens) => {
if (newTokens?.access_token) {
fetchHubs(newTokens?.access_token)
}
})
</script>
@@ -1,152 +0,0 @@
<template>
<div class="flex flex-col text-xs space-y-2">
<ProjectPageAccSyncs
:project-id="projectId"
:is-logged-in="hasTokens"
:tokens="tokens"
/>
<ClientOnly>
<div v-if="!hasTokens">
<CommonLoadingBar v-if="loadingTokens" :loading="true" class="my-2" />
<div v-else>
<hr class="mb-2" />
<FormButton size="sm" @click="authAcc()">Connect to ACC</FormButton>
</div>
</div>
</ClientOnly>
<!-- USER INFO -->
<div v-if="userInfo" class="flex flex-col space-y-2">
<hr class="my-2" />
<div class="flex flex-col text ml-1 space-y-2 mb-2">
<span>
<strong>Name:</strong>
{{ userInfo.firstName }} {{ userInfo.lastName }}
</span>
<span>
<strong>Email:</strong>
{{ userInfo.emailId }}
</span>
<span>
<strong>User ID:</strong>
{{ userInfo.userId }}
</span>
</div>
<!-- <div v-if="tokens?.access_token" class="flex flex-row items-center space-x-2">
<FormButton
class="mr-2"
hide-text
:icon-left="DocumentDuplicateIcon"
color="outline"
@click="copy(tokens?.access_token)"
>
Copy to clipboard
</FormButton>
{{ tokens?.access_token.slice(0, 32) }}...
</div> -->
</div>
</div>
</template>
<script setup lang="ts">
import type { AccTokens, AccUserInfo } from '@speckle/shared/acc'
// import { DocumentDuplicateIcon } from '@heroicons/vue/24/outline'
const props = defineProps<{ projectId: string }>()
const { triggerNotification } = useGlobalToast()
// const { copy } = useClipboard()
const apiOrigin = useApiOrigin()
const tokens = ref<AccTokens>()
const hasTokens = computed(() => !!tokens.value?.access_token)
const loadingTokens = ref(true)
const userInfo = ref<AccUserInfo>()
const loadingUser = ref(false)
// AUTH + TOKEN FLOW
const fetchTokens = async () => {
try {
const res = await fetch(`${apiOrigin}/api/v1/acc/auth/status`, {
credentials: 'include'
})
if (!res.ok) return
tokens.value = await res.json()
} finally {
loadingTokens.value = false
}
}
fetchTokens()
const authAcc = async () => {
try {
const response = await fetch(`${apiOrigin}/api/v1/acc/auth/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: props.projectId })
})
if (!response.ok) throw new Error('Failed to initiate ACC login.')
const { authorizeUrl } = await response.json()
if (!authorizeUrl) throw new Error('No authorize URL returned by server.')
window.location.href = authorizeUrl
} catch (error) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Error starting ACC login',
description: error instanceof Error ? error.message : 'Unexpected error'
})
}
}
const scheduleRefresh = (expiresInSeconds: number) => {
const refreshTime = (expiresInSeconds - 60) * 1000
setTimeout(async () => {
loadingTokens.value = true
const res = await fetch(`${apiOrigin}/api/v1/acc/auth/refresh`, {
method: 'POST',
credentials: 'include'
})
if (res.ok) {
const refreshed = await res.json()
await fetchTokens()
triggerNotification({
type: ToastNotificationType.Success,
title: 'ACC tokens refreshed',
description: refreshed
})
scheduleRefresh(refreshed.expires_in)
}
loadingTokens.value = false
}, refreshTime)
}
watch(tokens, (newTokens) => {
if (newTokens?.expires_in) scheduleRefresh(newTokens.expires_in)
if (newTokens?.access_token) {
fetchUserInfo()
}
})
// USER INFO
const fetchUserInfo = async () => {
loadingUser.value = true
try {
const res = await fetch(
'https://developer.api.autodesk.com/userprofile/v1/users/@me',
{ headers: { Authorization: `Bearer ${tokens.value!.access_token}` } }
)
if (!res.ok) throw new Error('Failed to get user info directly from ACC')
userInfo.value = await res.json()
} catch (error) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Error fetching user info directly',
description: error instanceof Error ? error.message : 'Unexpected error'
})
} finally {
loadingUser.value = false
}
}
</script>
@@ -26,6 +26,18 @@
New model
</FormButton>
</div>
<!-- I believe for now sync limits corralate with model limit since new sync creates new model, once we have limits for syncs, this should change -->
<div
v-tippy="canCreateModel.cantClickCreateReason.value"
class="grow inline-flex sm:grow-0 lg:hidden"
>
<FormButton
:disabled="!canCreateModel.canClickCreate.value"
@click="showNewAccSync = true"
>
New sync
</FormButton>
</div>
</div>
</div>
<div
@@ -78,7 +90,23 @@
>
View all in 3D
</FormButton>
<div v-tippy="canCreateModel.cantClickCreateReason.value" class="test123">
<LayoutMenu
v-if="showAccIntegration"
v-model:open="showMenu"
:items="menuItems"
:menu-position="HorizontalDirection.Left"
:menu-id="menuId"
@click.stop.prevent
@chosen="onActionChosen"
>
<FormButton color="primary" @click="showMenu = !showMenu">
<div class="flex items-center gap-1">
Add model
<ChevronDownIcon class="h-3 w-3" />
</div>
</FormButton>
</LayoutMenu>
<div v-else v-tippy="canCreateModel.cantClickCreateReason.value">
<FormButton
:disabled="!canCreateModel.canClickCreate.value"
class="hidden lg:inline-flex shrink-0"
@@ -91,9 +119,11 @@
</div>
</div>
<ProjectModelsAdd v-model:open="showNewDialog" :project="project" />
<IntegrationsAccDialogCreateSync :open="showNewAccSync" :project-id="project?.id" />
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
import { SourceApps, SpeckleViewer } from '@speckle/shared'
import type { SourceAppDefinition } from '@speckle/shared'
import { debounce } from 'lodash-es'
@@ -103,9 +133,13 @@ import type {
ProjectModelsPageHeader_ProjectFragment
} from '~~/lib/common/generated/gql/graphql'
import { modelRoute } from '~~/lib/common/helpers/route'
import type { GridListToggleValue } from '~~/lib/layout/helpers/components'
import type {
GridListToggleValue,
LayoutMenuItem
} from '~~/lib/layout/helpers/components'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useCanCreateModel } from '~/lib/projects/composables/permissions'
import { HorizontalDirection } from '@speckle/ui-components'
const emit = defineEmits<{
(e: 'update:selected-members', val: FormUsersSelectItemFragment[]): void
@@ -147,6 +181,9 @@ graphql(`
canCreateModel {
...FullPermissionCheckResult
}
canReadAccIntegrationSettings {
...FullPermissionCheckResult
}
}
...ProjectModelsAdd_Project
}
@@ -168,6 +205,8 @@ const sourceAppsBtnId = useId()
const router = useRouter()
const mp = useMixpanel()
const menuId = useId()
const onViewAllClick = () => {
router.push(allModelsRoute.value)
@@ -180,6 +219,11 @@ const onViewAllClick = () => {
}
const showNewDialog = ref(false)
const showNewAccSync = ref(false)
const showAccIntegration = computed(
() => props.project?.permissions.canReadAccIntegrationSettings.authorized
)
const canCreateModel = useCanCreateModel({
project: computed(() => props.project)
@@ -217,6 +261,46 @@ const allModelsRoute = computed(() => {
return modelRoute(props.projectId, resourceIdString)
})
const showMenu = ref(false)
enum AddNewModelActionTypes {
NewModel = 'new-model',
NewAccSyncItem = 'new-acc-sync-item'
}
const menuItems = computed<LayoutMenuItem[][]>(() => [
[
{
title: 'Create new model...',
id: AddNewModelActionTypes.NewModel,
disabled: !canCreateModel.canClickCreate.value,
disabledTooltip: canCreateModel.cantClickCreateReason.value
},
// TODO ACC: Upload a file
{
// TODO: Do we show this disabled in all non-enterprise cases?
title: 'Sync from ACC...',
id: AddNewModelActionTypes.NewAccSyncItem,
// I believe for now sync limits corralate with model limit since new sync creates new model, once we have limits for syncs, this should change
disabled: !canCreateModel.canClickCreate.value,
disabledTooltip: canCreateModel.cantClickCreateReason.value
}
]
])
const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) => {
const { item } = params
switch (item.id) {
case AddNewModelActionTypes.NewModel:
handleCreateModelClick()
break
case AddNewModelActionTypes.NewAccSyncItem:
showNewAccSync.value = true
break
}
}
const team = computed(() => props.project?.team.map((t) => t.user) || [])
const updateDebouncedSearch = debounce(() => {
@@ -55,8 +55,12 @@
submodel
</FormButton>
</div>
<div v-if="accSyncItem" class="flex items-center ml-2">
<IntegrationsAccSyncStatusModelItem :item="accSyncItem" />
</div>
<!-- Spacer -->
<div class="flex-grow"></div>
<template v-if="!isPendingFileUpload(item)">
<div
v-show="
@@ -308,6 +312,9 @@ graphql(`
...ProjectPageLatestItemsModelItem
...ProjectCardImportFileArea_Model
...ProjectPageModelsCard_Model
accSyncItem {
...SyncStatusModelItem_AccSyncItem
}
}
hasChildren
updatedAt
@@ -332,6 +339,10 @@ const props = defineProps<{
const router = useRouter()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const accSyncItem = computed(() =>
props.item.__typename === 'ModelsTreeItem' ? props.item.model?.accSyncItem : undefined
)
const importArea = ref(
null as Nullable<{
triggerPicker: () => void
@@ -10,6 +10,7 @@
:disabled="loading"
class="z-[1] relative"
/>
<ProjectPageModelsResults
v-model:grid-or-list="gridOrList"
v-model:search="search"
@@ -0,0 +1,64 @@
<template>
<div class="flex flex-col text-xs space-y-2">
<IntegrationsAccSyncs
:project-id="projectId"
:is-logged-in="hasTokens"
:tokens="tokens"
/>
<ClientOnly>
<div v-if="!hasTokens">
<CommonLoadingBar v-if="loadingTokens" :loading="true" class="my-2" />
<div v-else>
<hr class="mb-2" />
<FormButton size="sm" @click="authAcc(`/projects/${projectId}/acc`)">
Connect to ACC
</FormButton>
</div>
</div>
<!-- USER INFO -->
<div v-if="userInfo" class="flex flex-col space-y-2">
<hr class="my-2" />
<div class="flex flex-col text ml-1 space-y-2 mb-2">
<span>
<strong>Name:</strong>
{{ userInfo.firstName }} {{ userInfo.lastName }}
</span>
<span>
<strong>Email:</strong>
{{ userInfo.emailId }}
</span>
<span>
<strong>User ID:</strong>
{{ userInfo.userId }}
</span>
</div>
</div>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import { useAccAuthManager } from '~/lib/acc/composables/useAccAuthManager'
import { useAccUser } from '~/lib/acc/composables/useAccUser'
defineProps<{ projectId: string }>()
const hasTokens = computed(() => !!tokens.value?.access_token)
const { tokens, loadingTokens, authAcc, tryGetTokensFromCookies } = useAccAuthManager()
const { userInfo, fetchUserInfo } = useAccUser()
watch(tokens, async (newTokens) => {
if (newTokens?.access_token) {
await fetchUserInfo(newTokens?.access_token)
}
})
onMounted(async () => {
await tryGetTokensFromCookies()
if (tokens.value) {
await fetchUserInfo(tokens.value?.access_token)
}
})
</script>
@@ -159,7 +159,7 @@ const parentEl = ref(null as Nullable<HTMLElement>)
const { isLoggedIn } = useActiveUser()
const viewerState = useInjectedViewerState()
const { sessionId } = viewerState
const { users } = useViewerUserActivityTracking({ parentEl })
const { users } = useViewerUserActivityTracking({ anchoredPointsParentEl: parentEl })
const { isOpenThread, open, closeAllThreads } = useThreadUtilities()
const {
filters: { hasAnyFiltersApplied },
@@ -40,7 +40,7 @@ const open = defineModel<boolean>('open', {
const {
projectId,
resources: {
request: { resourceIdString }
response: { concreteResourceIdString }
}
} = useInjectedViewerState()
const isLoading = useMutationLoading()
@@ -70,7 +70,7 @@ const onSubmit = handleSubmit(async (values) => {
const group = await createGroup({
projectId: projectId.value,
resourceIdString: resourceIdString.value,
resourceIdString: concreteResourceIdString.value,
groupName: values.name
})
if (group) {
@@ -165,7 +165,7 @@ watch(open, (newVal, oldVal) => {
name: props.view.name,
description: props.view.description,
visibility: props.view.visibility,
group: props.view.group
group: markRaw({ ...props.view.group }) // vue-validate doesnt like this read-only proxified object
})
}
})
@@ -1,272 +0,0 @@
import type { AccHub, AccProject } from '@speckle/shared/acc'
import { ref } from 'vue'
// Placeholder types for demonstration. You should use your actual types.
export interface AccItem {
id: string
type: string
attributes: {
name: string
displayName: string
fileType?: string
objectCount?: number
}
latestVersionId?: string
fileExtension?: string
storageUrn?: string | null
}
export interface AccFolder extends AccItem {
children?: AccFolder[]
}
/**
* A composable function to handle ACC data fetching and state management.
* The project details are passed to the `init` function and exptects to refresh all state when user selected new project.
*/
export function useAcc() {
const loadingTree = ref<boolean>(false)
const loadingItems = ref<boolean>(false)
const loadingHubs = ref<boolean>(false)
const loadingProjects = ref<boolean>(false)
const folderTree = ref<AccFolder | undefined>()
const folderItems = ref<AccItem[]>([])
const hubs = ref<AccHub[]>([])
const projects = ref<AccProject[]>([])
const rootProjectFolderId = ref<string | undefined>()
const supportedFileExtensions = ['rvt']
const logger = useLogger()
// ACC API Functions
/**
* Fetches all hubs for the authenticated user.
*/
const fetchHubs = async (token: string) => {
loadingHubs.value = true
try {
const res = await fetch('https://developer.api.autodesk.com/project/v1/hubs', {
headers: { Authorization: `Bearer ${token}` }
})
if (!res.ok) throw new Error('Failed to fetch hubs')
hubs.value = (await res.json()).data
} catch (error) {
logger.error(error, 'Error fetching ACC hubs')
hubs.value = []
} finally {
loadingHubs.value = false
}
}
/**
* Fetches all projects for a given hub.
*/
const fetchProjects = async (hubId: string, token: string) => {
loadingProjects.value = true
try {
const res = await fetch(
`https://developer.api.autodesk.com/project/v1/hubs/${hubId}/projects`,
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!res.ok) throw new Error('Failed to fetch projects')
projects.value = (await res.json()).data
} catch (error) {
logger.error(error, 'Error fetching ACC projects')
projects.value = []
} finally {
loadingProjects.value = false
}
}
/**
* Fetches the root folder ID for the project.
*/
const getProjectRootFolderId = async (
hubId: string,
projectId: string,
token: string
): Promise<string | undefined> => {
try {
const res = await fetch(
`https://developer.api.autodesk.com/project/v1/hubs/${hubId}/projects/${projectId}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!res.ok) throw new Error('Failed to get project details')
const r = await res.json()
rootProjectFolderId.value = r.data.relationships?.rootFolder?.data?.id || null
return rootProjectFolderId.value
} catch (error) {
logger.error(error, `Error getting root folder ID for project: ${projectId}`)
return undefined
}
}
/**
* Fetches the immediate contents (folders and items) of a single folder.
* This is a non-recursive, single-level fetch.
*/
const fetchFolderContents = async (
projectId: string,
folderId: string,
token: string
): Promise<AccItem[]> => {
try {
const res = await fetch(
`https://developer.api.autodesk.com/data/v1/projects/${projectId}/folders/${folderId}/contents`,
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!res.ok) {
throw new Error(`Failed to fetch contents of folder ${folderId}`)
}
const data = (await res.json()).data
return data
} catch (error) {
logger.error(error, `Error fetching folder contents for ${folderId}:`)
return []
}
}
/**
* Fetches the latest version details for a specific item (file).
* This function is separated for on-demand use.
*/
const fetchItemLatestVersion = async (
projectId: string,
itemId: string,
token: string
) => {
try {
const res = await fetch(
`https://developer.api.autodesk.com/data/v1/projects/${projectId}/items/${itemId}/tip`,
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!res.ok) {
throw new Error(`Failed to fetch latest version for item ${itemId}`)
}
const data = (await res.json()).data
return data
} catch (error) {
logger.error(error, `Error fetching latest version for item ${itemId}`)
return null
}
}
// Application Logic
/**
* Builds the nested folder tree structure on initial project load.
* This is a recursive function that only fetches folders, not files.
* It now uses `attributes.objectCount` to avoid unnecessary API calls for empty folders.
*/
const buildFolderTree = async (
projectId: string,
folderId: string,
token: string
): Promise<AccFolder> => {
const contents = await fetchFolderContents(projectId, folderId, token)
const folders = contents.filter((item) => item.type === 'folders') as AccFolder[]
const populatedFolders: AccFolder[] = []
for (const folder of folders) {
// We only want to add a folder to the tree if it contains something.
// The `objectCount` attribute tells us if it's empty.
if (folder.attributes.objectCount && folder.attributes.objectCount > 0) {
// Recursively build the full subtree for this folder
const subTree = await buildFolderTree(projectId, folder.id, token)
populatedFolders.push({
id: folder.id,
type: folder.type,
attributes: {
name: folder.attributes.name || folder.attributes.displayName,
displayName: folder.attributes.displayName
},
children: subTree.children
})
}
}
const folderTree = {
id: folderId,
type: 'folders',
attributes: { name: 'Root Folder', displayName: 'Root Folder' },
relationships: {},
children: populatedFolders
} as AccFolder
return folderTree
}
/**
* Fetches all items (files) for a specific folder when a user clicks on it.
*/
const fetchItemsForFolder = async (
folderId: string,
projectId: string,
token: string
) => {
loadingItems.value = true
folderItems.value = [] // Clear previous items
const contents = await fetchFolderContents(projectId, folderId, token)
const items = contents.filter((item) => item.type === 'items') as AccItem[] // items === files
const itemPromises = items.map(async (item) => {
const version = await fetchItemLatestVersion(projectId, item.id, token)
if (version) {
const storageUrn = version.relationships?.storage?.data?.id || null
return {
...item,
latestVersionId: version.id,
fileExtension: version.attributes.fileType,
storageUrn
}
}
return item
})
folderItems.value = (await Promise.all(itemPromises)).filter((item) =>
supportedFileExtensions.includes(item.fileExtension)
)
loadingItems.value = false
}
/**
* Main entry point to initialize the folder tree for the selected project.
*/
const init = async (hubId: string, projectId: string, token: string) => {
loadingTree.value = true
folderItems.value = []
folderTree.value = undefined
rootProjectFolderId.value = undefined
try {
const rootFolderId = await getProjectRootFolderId(hubId, projectId, token)
if (rootFolderId) {
folderTree.value = await buildFolderTree(projectId, rootFolderId, token)
}
} catch (error) {
logger.error(error, 'Failed to initialize Autodesk ACC composable')
} finally {
loadingTree.value = false
}
}
return {
loadingTree,
loadingItems,
loadingHubs,
loadingProjects,
folderTree,
folderItems,
hubs,
projects,
rootProjectFolderId,
fetchHubs,
fetchProjects,
fetchItemsForFolder,
init
}
}
@@ -0,0 +1,177 @@
import type { AccTokens } from '@speckle/shared/acc'
import Cookies from 'js-cookie'
/**
* Manages authentication logic of ACC.
* We store tokens and its timestamp in under `acc_tokens` cookie.
* Detection of "Refresh needed" happens with timestamp check.
* ACC auth logic returns only expires in seconds and we need to correlate it with timestamp to substract later to understand refresh needed or not.
* Token lifespans:
* - Bearer token: 60 minutes
* - Refresh token: 15 days
*/
export function useAccAuthManager() {
const ACC_COOKIE_KEY = 'acc_tokens'
const logger = useLogger()
const apiOrigin = useApiOrigin()
const { triggerNotification } = useGlobalToast()
const loadingTokens = ref(false)
const tokens = ref<AccTokens>()
const isExpired = ref(false)
const REFRESH_TOKEN_LIFESPAN = 15 * 24 * 60 * 60 // in seconds
/**
* Main logic to understand existing token in cookies is expired or not.
* If refresh needed, we refresh and schedule
* Otherwise, we calculate the time diff and schedule refresh accordingly
*/
const tryGetTokensFromCookies = async () => {
const accTokens = Cookies.get(ACC_COOKIE_KEY)
if (accTokens) {
logger.info('Acc tokens are found in cookies')
const tokensInCookies = JSON.parse(accTokens) as AccTokens
const timeDiff = (Date.now() - tokensInCookies.timestamp) / 1000 // in seconds
if (timeDiff > REFRESH_TOKEN_LIFESPAN) {
logger.info('Acc refresh token in cookies is expired')
isExpired.value = true
logOut()
} else if (timeDiff + 300 > tokensInCookies.expires_in) {
logger.info('Acc access token in cookies need refreshing')
// 300s (6min) is arbitrary guard
const refreshedTokens = await refreshTokens(tokensInCookies)
tokens.value = refreshedTokens
await saveTokensToCookies()
if (tokens.value) scheduleRefresh(tokens.value)
} else {
logger.info('Acc tokens in cookies still valid')
tokens.value = tokensInCookies
const remainingTime = tokensInCookies.expires_in - timeDiff
scheduleRefresh(tokens.value, remainingTime)
}
}
loadingTokens.value = false
}
const logOut = () => {
tokens.value = undefined
Cookies.remove(ACC_COOKIE_KEY)
}
const refreshTokens = async (tokensToRefresh: AccTokens) => {
try {
loadingTokens.value = true
const res = await fetch(`${apiOrigin}/api/v1/acc/auth/refresh`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(tokensToRefresh)
})
if (res.ok) {
const refreshedTokens = await res.json()
tokens.value = { ...refreshedTokens, timestamp: Date.now() }
return refreshedTokens
}
} catch (error) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Error on refreshing ACC credientials',
description: error instanceof Error ? error.message : 'Unexpected error'
})
} finally {
loadingTokens.value = false
}
}
const saveTokensToCookies = async () => {
const tokensWithTimestamp = { ...tokens.value, timestamp: Date.now() }
Cookies.set('acc_tokens', JSON.stringify(tokensWithTimestamp), {
expires: 30, // since acc refresh token lifespan 15 days, it is a safe expiration
secure: true,
sameSite: 'Strict'
})
isExpired.value = false
}
const fetchTokens = async () => {
try {
loadingTokens.value = true
const res = await fetch(`${apiOrigin}/api/v1/acc/auth/status`, {
credentials: 'include'
})
if (!res.ok) return
tokens.value = await res.json()
if (tokens.value?.expires_in) {
scheduleRefresh(tokens.value)
}
await saveTokensToCookies()
} finally {
loadingTokens.value = false
}
}
const authAcc = async (callbackEndpoint: string) => {
try {
const response = await fetch(`${apiOrigin}/api/v1/acc/auth/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ callbackEndpoint })
})
if (!response.ok) throw new Error('Failed to initiate ACC login.')
const { authorizeUrl } = await response.json()
if (!authorizeUrl) throw new Error('No authorize URL returned by server.')
window.location.href = authorizeUrl
} catch (error) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Error starting ACC login',
description: error instanceof Error ? error.message : 'Unexpected error'
})
}
}
const scheduleRefresh = (
tokensToScheduleRefresh: AccTokens,
resfreshInSeconds?: number
) => {
const refreshTimeInMs =
(resfreshInSeconds ?? tokensToScheduleRefresh.expires_in) * 1000
setTimeout(async () => {
loadingTokens.value = true
const res = await fetch(`${apiOrigin}/api/v1/acc/auth/refresh`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(tokensToScheduleRefresh)
})
if (res.ok) {
const refreshed = await res.json()
tokens.value = refreshed
// triggerNotification({
// type: ToastNotificationType.Success,
// title: 'ACC tokens refreshed'
// })
await saveTokensToCookies()
scheduleRefresh(refreshed, refreshed.expires_in)
}
loadingTokens.value = false
}, refreshTimeInMs)
}
return {
isExpired,
tokens,
loadingTokens,
authAcc,
logOut,
fetchTokens,
refreshTokens,
tryGetTokensFromCookies,
saveTokensToCookies
}
}
@@ -0,0 +1,179 @@
import type { AccHub, AccProject } from '@speckle/shared/acc'
import { ref } from 'vue'
// Placeholder types for demonstration. You should use your actual types.
export interface AccItem {
id: string
type: string
attributes: {
name: string
displayName: string
fileType?: string
objectCount?: number
}
latestVersionId?: string
fileExtension?: string
storageUrn?: string | null
}
export interface AccFolder extends AccItem {
children?: AccFolder[]
}
export type AccItemVersion = {
id: string
name: string
fileType?: string
versionNumber: number
}
/**
* A composable function to handle ACC data fetching and state management.
* The project details are passed to the `init` function and exptects to refresh all state when user selected new project.
*/
export function useAcc() {
const loadingTree = ref<boolean>(false)
const loadingItems = ref<boolean>(false)
const loadingHubs = ref<boolean>(false)
const loadingProjects = ref<boolean>(false)
const folderTree = ref<AccFolder | undefined>()
const folderItems = ref<AccItem[]>([])
const hubs = ref<AccHub[]>([])
const projects = ref<AccProject[]>([])
const rootProjectFolderId = ref<string | undefined>()
const logger = useLogger()
// ACC API Functions
/**
* Fetches all hubs for the authenticated user.
*/
const fetchHubs = async (token: string) => {
loadingHubs.value = true
try {
const res = await fetch('https://developer.api.autodesk.com/project/v1/hubs', {
headers: { Authorization: `Bearer ${token}` }
})
if (!res.ok) throw new Error('Failed to fetch hubs')
hubs.value = (await res.json()).data
} catch (error) {
logger.error(error, 'Error fetching ACC hubs')
hubs.value = []
} finally {
loadingHubs.value = false
}
}
// type HubsResponse = {
// data: {
// id: string
// type: 'hubs'
// attributes: {
// name: string
// region: string
// }
// }[]
// }
/**
* Fetches all projects for a given hub.
*/
const fetchProjects = async (hubId: string, token: string) => {
loadingProjects.value = true
try {
const res = await fetch(
`https://developer.api.autodesk.com/project/v1/hubs/${hubId}/projects`,
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!res.ok) throw new Error('Failed to fetch projects')
projects.value = (await res.json()).data
} catch (error) {
logger.error(error, 'Error fetching ACC projects')
projects.value = []
} finally {
loadingProjects.value = false
}
}
// type ProjectsResponse = {
// data: {
// id: string
// type: 'projects'
// attributes: {
// name: string
// }
// relationships: {
// hub: {
// data: {
// id: string
// type: string
// }
// }
// rootFolder: {
// data: {
// id: string
// }
// }
// }
// }[]
// }
/**
* Fetches the root folder ID for the project.
*/
const getProjectRootFolderId = async (
hubId: string,
projectId: string,
token: string
): Promise<string | undefined> => {
try {
const res = await fetch(
`https://developer.api.autodesk.com/project/v1/hubs/${hubId}/projects/${projectId}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!res.ok) throw new Error('Failed to get project details')
const r = await res.json()
rootProjectFolderId.value = r.data.relationships?.rootFolder?.data?.id || null
return rootProjectFolderId.value
} catch (error) {
logger.error(error, `Error getting root folder ID for project: ${projectId}`)
return undefined
}
}
/**
* Main entry point to initialize the folder tree for the selected project.
*/
const init = async (hubId: string, projectId: string, token: string) => {
loadingTree.value = true
folderItems.value = []
folderTree.value = undefined
rootProjectFolderId.value = undefined
try {
await getProjectRootFolderId(hubId, projectId, token)
} catch (error) {
logger.error(error, 'Failed to initialize Autodesk ACC composable')
} finally {
loadingTree.value = false
}
}
return {
loadingTree,
loadingItems,
loadingHubs,
loadingProjects,
folderTree,
folderItems,
hubs,
projects,
rootProjectFolderId,
fetchHubs,
fetchProjects,
init
}
}
@@ -0,0 +1,81 @@
import { gql } from '@apollo/client/core'
import type { AccTokens } from '@speckle/shared/acc'
import { useApolloClient, useQuery } from '@vue/apollo-composable'
import { accFolderDataQuery } from '~/lib/acc/graphql/queries'
import type { AccIntegrationFolderNode_AccFolderFragment } from '~/lib/common/generated/gql/graphql'
import { useActiveWorkspaceSlug } from '~/lib/user/composables/activeWorkspace'
export const useAccFolder = (
accProjectId: string,
accFolderId: MaybeRef<string | undefined>,
accTokens?: AccTokens
) => {
const workspaceSlug = useActiveWorkspaceSlug()
const apollo = useApolloClient()
const cachedFolder = computed(() => {
return apollo.client.cache.readFragment<AccIntegrationFolderNode_AccFolderFragment>(
{
id: `AccFolder:${unref(accFolderId)}`,
fragment: gql`
fragment AccFolderContents on AccFolder {
id
name
contents {
items {
id
name
latestVersion {
id
name
versionNumber
fileType
}
}
}
children {
items {
id
name
}
}
}
`
}
)
})
// watch(cachedFolder, (v) => {
// console.log({ cachedFolder: v })
// }, {
// immediate: true
// })
const { result: folder } = useQuery(
accFolderDataQuery,
() => ({
workspaceSlug: workspaceSlug.value!,
accToken: accTokens!.access_token,
accProjectId,
accFolderId: unref(accFolderId)!
}),
() => ({
enabled: !!unref(accFolderId) && !!accTokens && !!workspaceSlug.value
})
)
// watch(folder, (v) => {
// console.log({ queryFolder: v })
// }, {
// immediate: true
// })
const folderData = computed(() => ({
id: accFolderId,
...cachedFolder.value,
...folder.value?.workspaceBySlug.integrations?.acc?.folder
}))
return folderData
}
@@ -0,0 +1,34 @@
import type { AccUserInfo } from '@speckle/shared/acc'
export function useAccUser() {
const { triggerNotification } = useGlobalToast()
const loadingUser = ref(false)
const userInfo = ref<AccUserInfo>()
const fetchUserInfo = async (token: string) => {
loadingUser.value = true
try {
const res = await fetch(
'https://developer.api.autodesk.com/userprofile/v1/users/@me',
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!res.ok) throw new Error('Failed to get user info directly from ACC')
userInfo.value = await res.json()
} catch (error) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Error fetching user info directly',
description: error instanceof Error ? error.message : 'Unexpected error'
})
} finally {
loadingUser.value = false
}
}
return {
loadingUser,
userInfo,
fetchUserInfo
}
}
@@ -3,8 +3,12 @@ import { graphql } from '~~/lib/common/generated/gql'
export const projectAccSyncItemFragment = graphql(`
fragment ProjectAccSyncItem on AccSyncItem {
id
projectId
modelId
project {
id
}
model {
id
}
accRegion
accHubId
accProjectId
@@ -11,3 +11,24 @@ export const projectAccSyncItemsQuery = graphql(`
}
}
`)
export const accFolderDataQuery = graphql(`
query AccFolderData(
$workspaceSlug: String!
$accToken: String!
$accProjectId: String!
$accFolderId: String!
) {
workspaceBySlug(slug: $workspaceSlug) {
id
integrations {
acc(token: $accToken) {
folder(projectId: $accProjectId, folderId: $accFolderId) {
id
...AccIntegrationFolderNode_AccFolder
}
}
}
}
}
`)
+12
View File
@@ -1,4 +1,16 @@
import type { AccHub, AccItem } from '@speckle/shared/acc'
import type { Integration } from '~/lib/integrations/types'
import accLogo from '~/assets/images/integrations/acc.png'
export const AccIntegration: Integration = {
cookieKey: 'acc_tokens',
name: 'Autodesk Construction Cloud',
description: 'Sync your files in ACC into Speckle.',
logo: accLogo,
connected: false,
enabled: false,
status: 'notConnected'
}
// TODO ACC: Replace with type information inferred from gql queries, if possible
export type AccSyncItem = {
@@ -20,7 +20,7 @@ export function usePasswordReset() {
triggerNotification({
type: ToastNotificationType.Info,
title: 'Password reset email sent',
description: `We've sent the password reset instructions to ${email}`
description: `If the email address '${email}' is associated with a registered user, we have sent password reset instructions to that address.`
})
} catch (e) {
triggerNotification({
@@ -63,6 +63,8 @@ type Documents = {
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": typeof types.HeaderNavShare_ProjectFragmentDoc,
"\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n workspaceSlug\n user {\n id\n }\n }\n": typeof types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc,
"\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspace {\n id\n name\n }\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": typeof types.HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment AccIntegrationFolderNode_AccFolder on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n children {\n items {\n id\n name\n }\n }\n contents {\n items {\n id\n name\n }\n }\n }\n }\n }\n": typeof types.AccIntegrationFolderNode_AccFolderFragmentDoc,
"\n fragment SyncStatusModelItem_AccSyncItem on AccSyncItem {\n id\n status\n }\n": typeof types.SyncStatusModelItem_AccSyncItemFragmentDoc,
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n slug\n domainBasedMembershipProtectionEnabled\n defaultSeatType\n domains {\n domain\n id\n }\n seats {\n editors {\n available\n }\n }\n ...InviteDialogSharedSelectUsers_Workspace\n ...WorkspacesPlan_Workspace\n }\n": typeof types.InviteDialogWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n workspaceId\n workspace {\n id\n name\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n ...WorkspacesPlan_Workspace\n }\n ...InviteDialogProjectRow_Project\n }\n": typeof types.InviteDialogProject_ProjectFragmentDoc,
"\n fragment InviteDialogProjectRow_Project on Project {\n id\n workspaceId\n workspace {\n id\n role\n }\n }\n": typeof types.InviteDialogProjectRow_ProjectFragmentDoc,
@@ -115,10 +117,10 @@ type Documents = {
"\n fragment ProjectPageModelsActions_Project on Project {\n id\n workspace {\n id\n slug\n }\n ...ProjectsModelPageEmbed_Project\n }\n": typeof types.ProjectPageModelsActions_ProjectFragmentDoc,
"\n fragment ProjectPageModelsCardProject on Project {\n id\n role\n visibility\n ...ProjectPageModelsActions_Project\n ...ProjectCardImportFileArea_Project\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageModelsCardProjectFragmentDoc,
"\n fragment ProjectPageModelsCard_Model on Model {\n id\n homeView {\n id\n resourceIds\n }\n lastUpload: uploads(input: { limit: 1, cursor: null }) {\n items {\n id\n updatedAt\n convertedStatus\n }\n }\n lastVersion: versions(limit: 1, cursor: null) {\n items {\n id\n createdAt\n }\n }\n }\n": typeof types.ProjectPageModelsCard_ModelFragmentDoc,
"\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n": typeof types.ProjectModelsPageHeader_ProjectFragmentDoc,
"\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n": typeof types.ProjectModelsPageHeader_ProjectFragmentDoc,
"\n fragment ProjectModelsPageResults_Project on Project {\n ...ProjectPageLatestItemsModels\n }\n": typeof types.ProjectModelsPageResults_ProjectFragmentDoc,
"\n fragment ProjectPageModelsStructureItem_Project on Project {\n id\n ...ProjectPageModelsActions_Project\n ...ProjectCardImportFileArea_Project\n ...UseCanCreateModel_Project\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageModelsStructureItem_ProjectFragmentDoc,
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n }\n hasChildren\n updatedAt\n }\n": typeof types.SingleLevelModelTreeItemFragmentDoc,
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n accSyncItem {\n ...SyncStatusModelItem_AccSyncItem\n }\n }\n hasChildren\n updatedAt\n }\n": typeof types.SingleLevelModelTreeItemFragmentDoc,
"\n fragment ProjectPageModelsUploadsDialog_FileUpload on FileUpload {\n id\n convertedStatus\n convertedMessage\n fileName\n fileSize\n convertedLastUpdate\n convertedVersionId\n uploadDate\n uploadComplete\n branchName\n ...UseFailedFileImportJobUtils_FileUpload\n }\n": typeof types.ProjectPageModelsUploadsDialog_FileUploadFragmentDoc,
"\n query GetModelUploads(\n $projectId: String!\n $modelId: String!\n $input: GetModelUploadsInput!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n uploads(input: $input) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageModelsUploadsDialog_FileUpload\n }\n }\n }\n }\n }\n": typeof types.GetModelUploadsDocument,
"\n fragment ProjectPageModelsCardDeleteDialog on Model {\n id\n name\n }\n": typeof types.ProjectPageModelsCardDeleteDialogFragmentDoc,
@@ -224,11 +226,13 @@ type Documents = {
"\n fragment WorkspaceSidebar_Workspace on Workspace {\n ...WorkspaceSidebarMembers_Workspace\n ...WorkspaceSidebarAbout_Workspace\n ...WorkspaceSidebarSecurity_Workspace\n id\n role\n slug\n domains {\n id\n }\n plan {\n name\n }\n }\n": typeof types.WorkspaceSidebar_WorkspaceFragmentDoc,
"\n fragment WorkspaceWizard_Workspace on Workspace {\n creationState {\n completed\n state\n }\n name\n slug\n }\n": typeof types.WorkspaceWizard_WorkspaceFragmentDoc,
"\n fragment WorkspaceWizardStepRegion_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": typeof types.WorkspaceWizardStepRegion_ServerInfoFragmentDoc,
"\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n projectId\n modelId\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n": typeof types.ProjectAccSyncItemFragmentDoc,
"\n fragment AccFolderContents on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n }\n }\n }\n ": typeof types.AccFolderContentsFragmentDoc,
"\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n project {\n id\n }\n model {\n id\n }\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n": typeof types.ProjectAccSyncItemFragmentDoc,
"\n mutation CreateAccSyncItem($input: CreateAccSyncItemInput!) {\n accSyncItemMutations {\n create(input: $input) {\n id\n accFileLineageUrn\n status\n }\n }\n }\n": typeof types.CreateAccSyncItemDocument,
"\n mutation DeleteAccSyncItem($input: DeleteAccSyncItemInput!) {\n accSyncItemMutations {\n delete(input: $input)\n }\n }\n": typeof types.DeleteAccSyncItemDocument,
"\n mutation UpdateAccSyncItem($input: UpdateAccSyncItemInput!) {\n accSyncItemMutations {\n update(input: $input) {\n id\n status\n }\n }\n }\n": typeof types.UpdateAccSyncItemDocument,
"\n query ProjectAccSyncItems($id: String!) {\n project(id: $id) {\n accSyncItems {\n items {\n ...ProjectAccSyncItem\n }\n }\n }\n }\n": typeof types.ProjectAccSyncItemsDocument,
"\n query AccFolderData(\n $workspaceSlug: String!\n $accToken: String!\n $accProjectId: String!\n $accFolderId: String!\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n integrations {\n acc(token: $accToken) {\n folder(projectId: $accProjectId, folderId: $accFolderId) {\n id\n ...AccIntegrationFolderNode_AccFolder\n }\n }\n }\n }\n }\n": typeof types.AccFolderDataDocument,
"\n subscription OnProjectAccSyncItemUpdated($id: String!, $itemIds: [String!]) {\n projectAccSyncItemsUpdated(id: $id, itemIds: $itemIds) {\n type\n accSyncItem {\n ...ProjectAccSyncItem\n }\n }\n }\n": typeof types.OnProjectAccSyncItemUpdatedDocument,
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n email\n verified\n primary\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n ...ProjectsAdd_User\n }\n }\n": typeof types.ActiveUserMainMetadataDocument,
"\n query ActiveUserProjectsToMove($filter: UserProjectsFilter) {\n activeUser {\n id\n projects(filter: $filter) {\n totalCount\n }\n }\n }\n": typeof types.ActiveUserProjectsToMoveDocument,
@@ -458,7 +462,7 @@ type Documents = {
"\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n position\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n": typeof types.UseDraggableView_SavedViewFragmentDoc,
"\n fragment UseDraggableViewTargetView_SavedView on SavedView {\n id\n name\n position\n group {\n id\n }\n }\n": typeof types.UseDraggableViewTargetView_SavedViewFragmentDoc,
"\n fragment UseDraggableViewTargetGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": typeof types.UseDraggableViewTargetGroup_SavedViewGroupFragmentDoc,
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n canSetAsHomeView {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n": typeof types.UseViewerSavedViewSetup_SavedViewFragmentDoc,
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n ...ViewerCommentThreadData\n }\n": typeof types.ViewerCommentThreadFragmentDoc,
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": typeof types.ViewerCommentsReplyItemFragmentDoc,
@@ -508,6 +512,7 @@ type Documents = {
"\n mutation WorkspaceUpdateAutoJoinMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n discoverabilityAutoJoinEnabled\n }\n }\n }\n": typeof types.WorkspaceUpdateAutoJoinMutationDocument,
"\n mutation WorkspaceUpdateDefaultSeatTypeMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n defaultSeatType\n }\n }\n }\n": typeof types.WorkspaceUpdateDefaultSeatTypeMutationDocument,
"\n mutation WorkspaceUpdateExclusiveMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n isExclusive\n }\n }\n }\n": typeof types.WorkspaceUpdateExclusiveMutationDocument,
"\n query Workspace($featureName: WorkspaceFeatureName!, $workspaceId: String!) {\n workspace(id: $workspaceId) {\n hasAccessToFeature(featureName: $featureName)\n }\n }\n": typeof types.WorkspaceDocument,
"\n query WorkspaceAccessCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n slug\n }\n activeUser {\n id\n activeWorkspace {\n id\n slug\n }\n }\n }\n": typeof types.WorkspaceAccessCheckDocument,
"\n query WorkspacePageQuery(\n $workspaceSlug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n ...WorkspacePage_Workspace\n }\n }\n": typeof types.WorkspacePageQueryDocument,
"\n query WorkspaceProjectsQuery(\n $workspaceSlug: String!\n $filter: WorkspaceProjectsFilter\n $cursor: String\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n projects(filter: $filter, cursor: $cursor, limit: 10) {\n ...WorkspaceDashboardProjectList_ProjectCollection\n }\n }\n }\n": typeof types.WorkspaceProjectsQueryDocument,
@@ -540,10 +545,10 @@ type Documents = {
"\n fragment AutomateFunctionPage_AutomateFunction on AutomateFunction {\n id\n name\n description\n logo\n supportedSourceApps\n tags\n ...AutomateFunctionPageHeader_Function\n ...AutomateFunctionPageInfo_AutomateFunction\n ...AutomateAutomationCreateDialog_AutomateFunction\n creator {\n id\n }\n }\n": typeof types.AutomateFunctionPage_AutomateFunctionFragmentDoc,
"\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 canReadAccIntegrationSettings {\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 ...WorkspaceMoveProject_Project\n }\n": typeof types.ProjectPageProjectFragmentDoc,
"\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 ...WorkspaceMoveProject_Project\n }\n": typeof types.ProjectPageProjectFragmentDoc,
"\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 canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageSettingsTab_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\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,
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": typeof types.SettingsServerRegionsDocument,
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n plan {\n status\n name\n }\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
@@ -606,6 +611,8 @@ const documents: Documents = {
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": types.HeaderNavShare_ProjectFragmentDoc,
"\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n workspaceSlug\n user {\n id\n }\n }\n": types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc,
"\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspace {\n id\n name\n }\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment AccIntegrationFolderNode_AccFolder on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n children {\n items {\n id\n name\n }\n }\n contents {\n items {\n id\n name\n }\n }\n }\n }\n }\n": types.AccIntegrationFolderNode_AccFolderFragmentDoc,
"\n fragment SyncStatusModelItem_AccSyncItem on AccSyncItem {\n id\n status\n }\n": types.SyncStatusModelItem_AccSyncItemFragmentDoc,
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n slug\n domainBasedMembershipProtectionEnabled\n defaultSeatType\n domains {\n domain\n id\n }\n seats {\n editors {\n available\n }\n }\n ...InviteDialogSharedSelectUsers_Workspace\n ...WorkspacesPlan_Workspace\n }\n": types.InviteDialogWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n workspaceId\n workspace {\n id\n name\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n ...WorkspacesPlan_Workspace\n }\n ...InviteDialogProjectRow_Project\n }\n": types.InviteDialogProject_ProjectFragmentDoc,
"\n fragment InviteDialogProjectRow_Project on Project {\n id\n workspaceId\n workspace {\n id\n role\n }\n }\n": types.InviteDialogProjectRow_ProjectFragmentDoc,
@@ -658,10 +665,10 @@ const documents: Documents = {
"\n fragment ProjectPageModelsActions_Project on Project {\n id\n workspace {\n id\n slug\n }\n ...ProjectsModelPageEmbed_Project\n }\n": types.ProjectPageModelsActions_ProjectFragmentDoc,
"\n fragment ProjectPageModelsCardProject on Project {\n id\n role\n visibility\n ...ProjectPageModelsActions_Project\n ...ProjectCardImportFileArea_Project\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageModelsCardProjectFragmentDoc,
"\n fragment ProjectPageModelsCard_Model on Model {\n id\n homeView {\n id\n resourceIds\n }\n lastUpload: uploads(input: { limit: 1, cursor: null }) {\n items {\n id\n updatedAt\n convertedStatus\n }\n }\n lastVersion: versions(limit: 1, cursor: null) {\n items {\n id\n createdAt\n }\n }\n }\n": types.ProjectPageModelsCard_ModelFragmentDoc,
"\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n": types.ProjectModelsPageHeader_ProjectFragmentDoc,
"\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n": types.ProjectModelsPageHeader_ProjectFragmentDoc,
"\n fragment ProjectModelsPageResults_Project on Project {\n ...ProjectPageLatestItemsModels\n }\n": types.ProjectModelsPageResults_ProjectFragmentDoc,
"\n fragment ProjectPageModelsStructureItem_Project on Project {\n id\n ...ProjectPageModelsActions_Project\n ...ProjectCardImportFileArea_Project\n ...UseCanCreateModel_Project\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageModelsStructureItem_ProjectFragmentDoc,
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n }\n hasChildren\n updatedAt\n }\n": types.SingleLevelModelTreeItemFragmentDoc,
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n accSyncItem {\n ...SyncStatusModelItem_AccSyncItem\n }\n }\n hasChildren\n updatedAt\n }\n": types.SingleLevelModelTreeItemFragmentDoc,
"\n fragment ProjectPageModelsUploadsDialog_FileUpload on FileUpload {\n id\n convertedStatus\n convertedMessage\n fileName\n fileSize\n convertedLastUpdate\n convertedVersionId\n uploadDate\n uploadComplete\n branchName\n ...UseFailedFileImportJobUtils_FileUpload\n }\n": types.ProjectPageModelsUploadsDialog_FileUploadFragmentDoc,
"\n query GetModelUploads(\n $projectId: String!\n $modelId: String!\n $input: GetModelUploadsInput!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n uploads(input: $input) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageModelsUploadsDialog_FileUpload\n }\n }\n }\n }\n }\n": types.GetModelUploadsDocument,
"\n fragment ProjectPageModelsCardDeleteDialog on Model {\n id\n name\n }\n": types.ProjectPageModelsCardDeleteDialogFragmentDoc,
@@ -767,11 +774,13 @@ const documents: Documents = {
"\n fragment WorkspaceSidebar_Workspace on Workspace {\n ...WorkspaceSidebarMembers_Workspace\n ...WorkspaceSidebarAbout_Workspace\n ...WorkspaceSidebarSecurity_Workspace\n id\n role\n slug\n domains {\n id\n }\n plan {\n name\n }\n }\n": types.WorkspaceSidebar_WorkspaceFragmentDoc,
"\n fragment WorkspaceWizard_Workspace on Workspace {\n creationState {\n completed\n state\n }\n name\n slug\n }\n": types.WorkspaceWizard_WorkspaceFragmentDoc,
"\n fragment WorkspaceWizardStepRegion_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": types.WorkspaceWizardStepRegion_ServerInfoFragmentDoc,
"\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n projectId\n modelId\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n": types.ProjectAccSyncItemFragmentDoc,
"\n fragment AccFolderContents on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n }\n }\n }\n ": types.AccFolderContentsFragmentDoc,
"\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n project {\n id\n }\n model {\n id\n }\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n": types.ProjectAccSyncItemFragmentDoc,
"\n mutation CreateAccSyncItem($input: CreateAccSyncItemInput!) {\n accSyncItemMutations {\n create(input: $input) {\n id\n accFileLineageUrn\n status\n }\n }\n }\n": types.CreateAccSyncItemDocument,
"\n mutation DeleteAccSyncItem($input: DeleteAccSyncItemInput!) {\n accSyncItemMutations {\n delete(input: $input)\n }\n }\n": types.DeleteAccSyncItemDocument,
"\n mutation UpdateAccSyncItem($input: UpdateAccSyncItemInput!) {\n accSyncItemMutations {\n update(input: $input) {\n id\n status\n }\n }\n }\n": types.UpdateAccSyncItemDocument,
"\n query ProjectAccSyncItems($id: String!) {\n project(id: $id) {\n accSyncItems {\n items {\n ...ProjectAccSyncItem\n }\n }\n }\n }\n": types.ProjectAccSyncItemsDocument,
"\n query AccFolderData(\n $workspaceSlug: String!\n $accToken: String!\n $accProjectId: String!\n $accFolderId: String!\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n integrations {\n acc(token: $accToken) {\n folder(projectId: $accProjectId, folderId: $accFolderId) {\n id\n ...AccIntegrationFolderNode_AccFolder\n }\n }\n }\n }\n }\n": types.AccFolderDataDocument,
"\n subscription OnProjectAccSyncItemUpdated($id: String!, $itemIds: [String!]) {\n projectAccSyncItemsUpdated(id: $id, itemIds: $itemIds) {\n type\n accSyncItem {\n ...ProjectAccSyncItem\n }\n }\n }\n": types.OnProjectAccSyncItemUpdatedDocument,
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n email\n verified\n primary\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n ...ProjectsAdd_User\n }\n }\n": types.ActiveUserMainMetadataDocument,
"\n query ActiveUserProjectsToMove($filter: UserProjectsFilter) {\n activeUser {\n id\n projects(filter: $filter) {\n totalCount\n }\n }\n }\n": types.ActiveUserProjectsToMoveDocument,
@@ -1001,7 +1010,7 @@ const documents: Documents = {
"\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n position\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n": types.UseDraggableView_SavedViewFragmentDoc,
"\n fragment UseDraggableViewTargetView_SavedView on SavedView {\n id\n name\n position\n group {\n id\n }\n }\n": types.UseDraggableViewTargetView_SavedViewFragmentDoc,
"\n fragment UseDraggableViewTargetGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": types.UseDraggableViewTargetGroup_SavedViewGroupFragmentDoc,
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n canSetAsHomeView {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n": types.UseViewerSavedViewSetup_SavedViewFragmentDoc,
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n ...ViewerCommentThreadData\n }\n": types.ViewerCommentThreadFragmentDoc,
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": types.ViewerCommentsReplyItemFragmentDoc,
@@ -1051,6 +1060,7 @@ const documents: Documents = {
"\n mutation WorkspaceUpdateAutoJoinMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n discoverabilityAutoJoinEnabled\n }\n }\n }\n": types.WorkspaceUpdateAutoJoinMutationDocument,
"\n mutation WorkspaceUpdateDefaultSeatTypeMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n defaultSeatType\n }\n }\n }\n": types.WorkspaceUpdateDefaultSeatTypeMutationDocument,
"\n mutation WorkspaceUpdateExclusiveMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n isExclusive\n }\n }\n }\n": types.WorkspaceUpdateExclusiveMutationDocument,
"\n query Workspace($featureName: WorkspaceFeatureName!, $workspaceId: String!) {\n workspace(id: $workspaceId) {\n hasAccessToFeature(featureName: $featureName)\n }\n }\n": types.WorkspaceDocument,
"\n query WorkspaceAccessCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n slug\n }\n activeUser {\n id\n activeWorkspace {\n id\n slug\n }\n }\n }\n": types.WorkspaceAccessCheckDocument,
"\n query WorkspacePageQuery(\n $workspaceSlug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n ...WorkspacePage_Workspace\n }\n }\n": types.WorkspacePageQueryDocument,
"\n query WorkspaceProjectsQuery(\n $workspaceSlug: String!\n $filter: WorkspaceProjectsFilter\n $cursor: String\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n projects(filter: $filter, cursor: $cursor, limit: 10) {\n ...WorkspaceDashboardProjectList_ProjectCollection\n }\n }\n }\n": types.WorkspaceProjectsQueryDocument,
@@ -1083,10 +1093,10 @@ const documents: Documents = {
"\n fragment AutomateFunctionPage_AutomateFunction on AutomateFunction {\n id\n name\n description\n logo\n supportedSourceApps\n tags\n ...AutomateFunctionPageHeader_Function\n ...AutomateFunctionPageInfo_AutomateFunction\n ...AutomateAutomationCreateDialog_AutomateFunction\n creator {\n id\n }\n }\n": types.AutomateFunctionPage_AutomateFunctionFragmentDoc,
"\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 canReadAccIntegrationSettings {\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 ...WorkspaceMoveProject_Project\n }\n": types.ProjectPageProjectFragmentDoc,
"\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 ...WorkspaceMoveProject_Project\n }\n": types.ProjectPageProjectFragmentDoc,
"\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 canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageSettingsTab_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\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,
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": types.SettingsServerRegionsDocument,
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n plan {\n status\n name\n }\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
@@ -1310,6 +1320,14 @@ export function graphql(source: "\n fragment HeaderNavNotificationsProjectInvit
* 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 HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspace {\n id\n name\n }\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"): (typeof documents)["\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspace {\n id\n name\n }\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\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 AccIntegrationFolderNode_AccFolder on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n children {\n items {\n id\n name\n }\n }\n contents {\n items {\n id\n name\n }\n }\n }\n }\n }\n"): (typeof documents)["\n fragment AccIntegrationFolderNode_AccFolder on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n children {\n items {\n id\n name\n }\n }\n contents {\n items {\n id\n name\n }\n }\n }\n }\n }\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 SyncStatusModelItem_AccSyncItem on AccSyncItem {\n id\n status\n }\n"): (typeof documents)["\n fragment SyncStatusModelItem_AccSyncItem on AccSyncItem {\n id\n status\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1521,7 +1539,7 @@ export function graphql(source: "\n fragment ProjectPageModelsCard_Model on Mod
/**
* 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 ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n"): (typeof documents)["\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n"];
export function graphql(source: "\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n"): (typeof documents)["\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1533,7 +1551,7 @@ export function graphql(source: "\n fragment ProjectPageModelsStructureItem_Pro
/**
* 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 SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n }\n hasChildren\n updatedAt\n }\n"): (typeof documents)["\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n }\n hasChildren\n updatedAt\n }\n"];
export function graphql(source: "\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n accSyncItem {\n ...SyncStatusModelItem_AccSyncItem\n }\n }\n hasChildren\n updatedAt\n }\n"): (typeof documents)["\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n accSyncItem {\n ...SyncStatusModelItem_AccSyncItem\n }\n }\n hasChildren\n updatedAt\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1957,7 +1975,11 @@ export function graphql(source: "\n fragment WorkspaceWizardStepRegion_ServerIn
/**
* 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 ProjectAccSyncItem on AccSyncItem {\n id\n projectId\n modelId\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n"): (typeof documents)["\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n projectId\n modelId\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n"];
export function graphql(source: "\n fragment AccFolderContents on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n }\n }\n }\n "): (typeof documents)["\n fragment AccFolderContents on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n }\n }\n }\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 ProjectAccSyncItem on AccSyncItem {\n id\n project {\n id\n }\n model {\n id\n }\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n"): (typeof documents)["\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n project {\n id\n }\n model {\n id\n }\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1974,6 +1996,10 @@ export function graphql(source: "\n mutation UpdateAccSyncItem($input: UpdateAc
* 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 ProjectAccSyncItems($id: String!) {\n project(id: $id) {\n accSyncItems {\n items {\n ...ProjectAccSyncItem\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectAccSyncItems($id: String!) {\n project(id: $id) {\n accSyncItems {\n items {\n ...ProjectAccSyncItem\n }\n }\n }\n }\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 query AccFolderData(\n $workspaceSlug: String!\n $accToken: String!\n $accProjectId: String!\n $accFolderId: String!\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n integrations {\n acc(token: $accToken) {\n folder(projectId: $accProjectId, folderId: $accFolderId) {\n id\n ...AccIntegrationFolderNode_AccFolder\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query AccFolderData(\n $workspaceSlug: String!\n $accToken: String!\n $accProjectId: String!\n $accFolderId: String!\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n integrations {\n acc(token: $accToken) {\n folder(projectId: $accProjectId, folderId: $accFolderId) {\n id\n ...AccIntegrationFolderNode_AccFolder\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2893,7 +2919,7 @@ export function graphql(source: "\n fragment UseDraggableViewTargetGroup_SavedV
/**
* 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 UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
export function graphql(source: "\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n canSetAsHomeView {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n canSetAsHomeView {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -3090,6 +3116,10 @@ export function graphql(source: "\n mutation WorkspaceUpdateDefaultSeatTypeMuta
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation WorkspaceUpdateExclusiveMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n isExclusive\n }\n }\n }\n"): (typeof documents)["\n mutation WorkspaceUpdateExclusiveMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n isExclusive\n }\n }\n }\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 query Workspace($featureName: WorkspaceFeatureName!, $workspaceId: String!) {\n workspace(id: $workspaceId) {\n hasAccessToFeature(featureName: $featureName)\n }\n }\n"): (typeof documents)["\n query Workspace($featureName: WorkspaceFeatureName!, $workspaceId: String!) {\n workspace(id: $workspaceId) {\n hasAccessToFeature(featureName: $featureName)\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -3221,7 +3251,7 @@ export function graphql(source: "\n query AutomateFunctionPageWorkspace($worksp
/**
* 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 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 canReadAccIntegrationSettings {\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 ...WorkspaceMoveProject_Project\n }\n"): (typeof documents)["\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 canReadAccIntegrationSettings {\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 ...WorkspaceMoveProject_Project\n }\n"];
export function graphql(source: "\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 ...WorkspaceMoveProject_Project\n }\n"): (typeof documents)["\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 ...WorkspaceMoveProject_Project\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -3233,7 +3263,7 @@ export function graphql(source: "\n fragment ProjectPageAutomationPage_Project
/**
* 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 ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
export function graphql(source: "\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\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
@@ -23,7 +23,7 @@ export function wrapRefWithTracking<R extends Ref<unknown>>(
},
set: (newVal) => {
if (!readsOnly) {
logger().debug(`debugging: '${name}' written to`, newVal, getTrace())
logger().debug(`debugging: '${name}' written to`, { newVal }, getTrace())
}
ref.value = newVal
@@ -70,6 +70,11 @@ export const settingsWorkspaceRoutes = {
route: (slug: MaybeNullOrUndefined<string>) =>
slug ? `/settings/workspaces/${slug}/projects` : '/'
},
integrations: {
name: 'settings-workspaces-slug-integrations',
route: (slug: MaybeNullOrUndefined<string>) =>
slug ? `/settings/workspaces/${slug}/integrations` : '/'
},
automation: {
name: 'settings-workspaces-slug-automation',
route: (slug?: string) => `/settings/workspaces/${slug}/automation`
@@ -134,6 +139,9 @@ export const projectWebhooksRoute = (projectId: string) =>
export const projectTokensRoute = (projectId: string) =>
`/projects/${projectId}/settings/tokens`
export const projectIntegrationsRoute = (projectId: string) =>
`/projects/${projectId}/settings/integrations`
export const threadRedirectRoute = (projectId: string, threadId: string) =>
`/projects/${projectId}/threads/${threadId}`
@@ -0,0 +1,69 @@
import { useApolloClient } from '@vue/apollo-composable'
import { useAccAuthManager } from '~/lib/acc/composables/useAccAuthManager'
import { AccIntegration } from '~/lib/acc/types'
import { WorkspaceFeatureName } from '~/lib/common/generated/gql/graphql'
import type { Integration } from '~/lib/integrations/types'
import { workspaceFeatureEnabledCheckQuery } from '~/lib/workspaces/graphql/queries'
export function useAccIntegration() {
const integration = ref<Integration>(AccIntegration)
const apollo = useApolloClient().client
const loading = ref(false)
const checkConnection = async (workspaceSlug: string, workspaceId: string) => {
loading.value = true
try {
const isAccModuleEnabled = useIsAccModuleEnabled()
if (isAccModuleEnabled) {
const accIntegationEnabled = await isAccEnabledInWorkspace(workspaceId)
const callbackEndpoint = `settings/workspaces/${workspaceSlug}/integrations`
if (accIntegationEnabled) {
const { isExpired, tokens, tryGetTokensFromCookies } = useAccAuthManager()
await tryGetTokensFromCookies() // also refreshes the tokens - so we can rely on existance of tokens to say 'connected'
integration.value = {
...AccIntegration,
connected: tokens.value !== undefined,
status: isExpired.value
? 'expired'
: tokens.value !== undefined
? 'connected'
: 'notConnected',
enabled: true,
callbackEndpoint
}
} else {
integration.value = { ...AccIntegration, callbackEndpoint }
}
}
} finally {
loading.value = false
}
}
const isAccEnabledInWorkspace = async (workspaceId: string) => {
const { data } = await apollo.query({
query: workspaceFeatureEnabledCheckQuery,
variables: {
workspaceId,
featureName: WorkspaceFeatureName.AccIntegration
},
fetchPolicy: 'network-only'
})
return data?.workspace?.hasAccessToFeature ?? false
}
const checkCredientials = async () => {
const { tryGetTokensFromCookies } = useAccAuthManager()
await tryGetTokensFromCookies()
}
return {
loading,
integration,
checkConnection,
checkCredientials
}
}
@@ -0,0 +1,10 @@
export type Integration = {
name: string
description: string
cookieKey: string
logo: string
connected: boolean
enabled: boolean
status: 'connected' | 'expired' | 'notConnected'
callbackEndpoint?: string
}
@@ -8,8 +8,6 @@ import {
SavedViewVisibility
} from '~/lib/common/generated/gql/graphql'
import { projectPresentationPageQuery } from '~/lib/presentations/graphql/queries'
import { useEventBus } from '~/lib/core/composables/eventBus'
import { ViewerEventBusKeys } from '~/lib/viewer/helpers/eventBus'
import { useProjectSavedViewsUpdateTracking } from '~/lib/viewer/composables/savedViews/subscriptions'
type ResponseProject = Optional<Get<ProjectPresentationPageQuery, 'project'>>
@@ -44,10 +42,6 @@ export type InjectablePresentationState = Readonly<{
* active slide etc.
*/
resourceIdString: ComputedRef<string>
/**
* Reset the current view to the saved view state of the current slide
*/
resetView: () => void
/**
* Whether the current view has been changed from the saved view state
*/
@@ -101,8 +95,6 @@ const setupStateViewer = (initState: ResponseState & UiState): ViewerState => {
ui: { slideIdx }
} = initState
const { emit, on } = useEventBus()
const hasViewChanged = ref(false)
const resourceIdString = computed(() => {
@@ -113,32 +105,9 @@ const setupStateViewer = (initState: ResponseState & UiState): ViewerState => {
.toString()
})
const resetView = () => {
const slides = presentation.value?.views.items || []
const currentSlide = slides.at(slideIdx.value)
if (!currentSlide?.id) return
emit(ViewerEventBusKeys.ApplySavedView, {
id: currentSlide.id,
loadOriginal: false
})
hasViewChanged.value = false
}
on(ViewerEventBusKeys.UserChangedOpenedView, () => {
hasViewChanged.value = true
})
watch(slideIdx, () => {
hasViewChanged.value = false
})
return {
viewer: {
resourceIdString,
resetView,
hasViewChanged
}
}
@@ -0,0 +1,27 @@
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
export const useResetViewUtils = () => {
const {
response: { presentation },
ui: { slideIdx },
viewer: { hasViewChanged }
} = useInjectedPresentationState()
const { emit } = useEventBus()
const resetView = () => {
const slides = presentation.value?.views.items || []
const currentSlide = slides.at(slideIdx.value)
if (!currentSlide?.id) return
emit(ViewerEventBusKeys.ApplySavedView, {
id: currentSlide.id,
loadOriginal: false
})
hasViewChanged.value = false
}
return { resetView }
}
@@ -0,0 +1,44 @@
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
import { useViewerUserActivityTracking } from '~/lib/viewer/composables/activity'
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
const useActivityTrackingIntegration = () => {
useViewerUserActivityTracking({
trackInternallyOnly: true
})
}
const useResetTrackingIntegration = () => {
const { on } = useEventBus()
const {
ui: { slideIdx },
viewer: { hasViewChanged }
} = useInjectedPresentationState()
const {
ui: {
savedViews: { savedViewStateId }
}
} = useInjectedViewerState()
on(ViewerEventBusKeys.UserChangedOpenedView, () => {
hasViewChanged.value = true
})
watch(
slideIdx,
() => {
savedViewStateId.value = undefined
hasViewChanged.value = false
},
{ flush: 'sync' }
)
}
/**
* Post setup work to run after the viewer (and presentation) states have been set up
*/
export const usePresentationViewerPostSetup = () => {
if (import.meta.server) return
useActivityTrackingIntegration()
useResetTrackingIntegration()
}
@@ -66,6 +66,12 @@ export const useSettingsMenu = () => {
}
]
: []),
{
title: 'Integrations',
name: settingsWorkspaceRoutes.integrations.name,
route: (slug?: string) => settingsWorkspaceRoutes.integrations.route(slug),
permission: [Roles.Workspace.Admin, Roles.Workspace.Member]
},
{
title: 'Security',
name: settingsWorkspaceRoutes.security.name,
@@ -261,10 +261,19 @@ export type UserActivityModel = Merge<
/**
* Track other user activity and emit viewing/disconnected updates
*/
export function useViewerUserActivityTracking(params: {
parentEl: Ref<Nullable<HTMLElement>>
}) {
const { parentEl } = params
export function useViewerUserActivityTracking(
params?: Partial<{
/**
* Set if you need users to be positioned correctly in viewer world space in an overlaid anchored points element
*/
anchoredPointsParentEl: Ref<Nullable<HTMLElement>>
/**
* Whether to only track viewer state changes, without broadcasting it to other users or getting updates from them
*/
trackInternallyOnly: boolean
}>
) {
const { anchoredPointsParentEl: parentEl, trackInternallyOnly } = params || {}
const {
projectId,
@@ -275,10 +284,27 @@ export function useViewerUserActivityTracking(params: {
} = useInjectedViewerState()
const { isLoggedIn } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const { update } = useViewerRealtimeActivityTracker()
const sendUpdate = useViewerUserActivityBroadcasting()
const { isEnabled: isEmbedEnabled } = useEmbed()
const { activeUser } = useActiveUser()
const processViewerViewing = async () => {
if (trackInternallyOnly) {
update({ status: ViewerUserActivityStatus.Viewing })
} else {
await sendUpdate.emitViewing()
}
}
const processViewerDisconnected = async () => {
if (trackInternallyOnly) {
update({ status: ViewerUserActivityStatus.Disconnected })
} else {
await sendUpdate.emitDisconnected()
}
}
// TODO: For some reason subscription is set up twice? Vue Apollo bug?
const { onResult: onUserActivity } = useSubscription(
onViewerUserActivityBroadcastedSubscription,
@@ -291,7 +317,7 @@ export function useViewerUserActivityTracking(params: {
sessionId: sessionId.value
}),
() => ({
enabled: isLoggedIn.value
enabled: isLoggedIn.value && !trackInternallyOnly
})
)
@@ -378,53 +404,61 @@ export function useViewerUserActivityTracking(params: {
}
})
useViewerAnchoredPoints<UserActivityModel, Partial<{ smoothTranslation: boolean }>>({
parentEl,
points: computed(() => Object.values(users.value)),
pointLocationGetter: (user) => {
const selection = user.state.ui.selection
const selectionVector = selection
? new Vector3(selection[0], selection[1], selection[2])
: null
if (parentEl) {
useViewerAnchoredPoints<UserActivityModel, Partial<{ smoothTranslation: boolean }>>(
{
parentEl,
points: computed(() => Object.values(users.value)),
pointLocationGetter: (user) => {
const selection = user.state.ui.selection
const selectionVector = selection
? new Vector3(selection[0], selection[1], selection[2])
: null
function getPointInBetweenByPerc(
pointA: Vector3,
pointB: Vector3,
percentage: number
) {
let dir = pointB.clone().sub(pointA)
const len = dir.length()
dir = dir.normalize().multiplyScalar(len * percentage)
return pointA.clone().add(dir)
}
function getPointInBetweenByPerc(
pointA: Vector3,
pointB: Vector3,
percentage: number
) {
let dir = pointB.clone().sub(pointA)
const len = dir.length()
dir = dir.normalize().multiplyScalar(len * percentage)
return pointA.clone().add(dir)
}
// If there is no selection location, return to a blended location based on the camera's target and location.
// This ensures that rotation and zoom will have an effect on the users' cursors and create a lively environment.
if (!selectionVector) {
const camPos = user.state.ui.camera.position
const camPosVector = new Vector3(camPos[0], camPos[1], camPos[2])
// If there is no selection location, return to a blended location based on the camera's target and location.
// This ensures that rotation and zoom will have an effect on the users' cursors and create a lively environment.
if (!selectionVector) {
const camPos = user.state.ui.camera.position
const camPosVector = new Vector3(camPos[0], camPos[1], camPos[2])
const camTarget = user.state.ui.camera.target
const camTargetVector = new Vector3(camTarget[0], camTarget[1], camTarget[2])
const camTarget = user.state.ui.camera.target
const camTargetVector = new Vector3(
camTarget[0],
camTarget[1],
camTarget[2]
)
return getPointInBetweenByPerc(camTargetVector, camPosVector, 0.2)
}
return getPointInBetweenByPerc(camTargetVector, camPosVector, 0.2)
}
return selectionVector.clone()
},
updatePositionCallback: (user, result, options) => {
user.isOccluded = result.isOccluded
user.style = {
...user.style,
target: {
...user.style.target,
...result.style,
transition: options?.smoothTranslation === false ? '' : 'all 0.1s ease'
// opacity: user.isOccluded ? '0.5' : user.isStale ? '0.2' : '1.0' // note: handled in component via css
return selectionVector.clone()
},
updatePositionCallback: (user, result, options) => {
user.isOccluded = result.isOccluded
user.style = {
...user.style,
target: {
...user.style.target,
...result.style,
transition: options?.smoothTranslation === false ? '' : 'all 0.1s ease'
// opacity: user.isOccluded ? '0.5' : user.isStale ? '0.2' : '1.0' // note: handled in component via css
}
}
}
}
}
})
)
}
const hideStaleUsers = () => {
if (!Object.values(users.value).length) return
@@ -451,7 +485,7 @@ export function useViewerUserActivityTracking(params: {
// Debounced disconnect function - 30 second delay
const debouncedDisconnect = debounce(
async () => {
await sendUpdate.emitDisconnected()
await processViewerDisconnected()
},
30 * 1000 // 30 seconds
)
@@ -463,13 +497,13 @@ export function useViewerUserActivityTracking(params: {
} else {
// Window regained focus - cancel any pending disconnect and emit viewing
debouncedDisconnect.cancel()
await sendUpdate.emitViewing()
await processViewerViewing()
}
})
const sendUpdateAndHideStaleUsers = () => {
if (!focused.value) return
hideStaleUsers()
sendUpdate.emitViewing()
processViewerViewing()
}
useIntervalFn(sendUpdateAndHideStaleUsers, OWN_ACTIVITY_UPDATE_INTERVAL)
@@ -484,22 +518,22 @@ export function useViewerUserActivityTracking(params: {
doubleClickCallback: selectionCallback
})
useViewerCameraControlEndTracker(() => sendUpdate.emitViewing())
useViewerCameraControlEndTracker(() => processViewerViewing())
useOnBeforeWindowUnload(async () => {
// Cancel any pending debounced disconnect since we're actually leaving
debouncedDisconnect.cancel()
await sendUpdate.emitDisconnected()
await processViewerDisconnected()
})
onMounted(() => {
sendUpdate.emitViewing()
processViewerViewing()
})
onBeforeUnmount(() => {
// Cancel any pending debounced disconnect
debouncedDisconnect.cancel()
sendUpdate.emitDisconnected()
void processViewerDisconnected()
})
const state = useInjectedViewerState()
@@ -519,7 +553,7 @@ export function useViewerUserActivityTracking(params: {
watch(resourceIdString, (newVal, oldVal) => {
if (newVal !== oldVal) {
sendUpdate.emitViewing()
void processViewerViewing()
}
})
@@ -38,9 +38,12 @@ const createSavedViewMutation = graphql(`
export const useCollectNewSavedViewViewerData = () => {
const {
projectId,
viewer: { instance: viewerInstance }
viewer: { instance: viewerInstance },
resources: {
response: { concreteResourceIdString }
}
} = useInjectedViewerState()
const { serialize, buildConcreteResourceIdString } = useStateSerialization()
const { serialize } = useStateSerialization()
const collect = async (): Promise<
Pick<
@@ -51,7 +54,7 @@ export const useCollectNewSavedViewViewerData = () => {
const screenshot = await viewerInstance.screenshot()
return {
projectId: projectId.value,
resourceIdString: buildConcreteResourceIdString(),
resourceIdString: concreteResourceIdString.value,
viewerState: serialize({ concreteResourceIdString: true }),
screenshot
}
@@ -82,40 +85,12 @@ export const useCreateSavedView = () => {
) => {
if (!userId.value) return
const result = await mutate(
{
input: {
...input,
...(await collect())
}
},
{
update: (cache, { data }) => {
const res = data?.projectMutations.savedViewMutations.createView
if (!res) return
// const viewId = res.id
// onNewGroupViewCacheUpdates({
// cache,
// viewId,
// projectId: projectId.value,
// ...(res.groupId
// ? {
// group: {
// id: res.groupId,
// resourceIds: res.group.resourceIds
// }
// }
// : {
// view: {
// resourceIds: res.resourceIds
// }
// })
// })
}
const result = await mutate({
input: {
...input,
...(await collect())
}
).catch(convertThrowIntoFetchResult)
}).catch(convertThrowIntoFetchResult)
const res = result?.data?.projectMutations.savedViewMutations.createView
if (!res?.id) {
@@ -174,40 +149,12 @@ export const useDeleteSavedView = () => {
return
}
const result = await mutate(
{
input: {
projectId,
id
}
},
{
update: (cache, res) => {
if (!res.data?.projectMutations.savedViewMutations.deleteView) return
// onGroupViewRemovalCacheUpdates({
// cache,
// viewId: id,
// projectId,
// ...(group.groupId
// ? {
// group: {
// id: group.groupId,
// resourceIds: group.resourceIds
// }
// }
// : {
// view: {
// resourceIds: params.view.resourceIds
// }
// })
// })
// // Remove the view from the cache
// cache.evict({ id: getCacheId('SavedView', id) })
}
const result = await mutate({
input: {
projectId,
id
}
).catch(convertThrowIntoFetchResult)
}).catch(convertThrowIntoFetchResult)
const res = result?.data?.projectMutations.savedViewMutations.deleteView
if (res) {
@@ -294,143 +241,7 @@ export const useUpdateSavedView = () => {
) => {
if (!isLoggedIn.value) return
const { input } = params
const oldGroup = params.view.group
const result = await mutate(
{ input },
{
update: (cache, res) => {
const update = res.data?.projectMutations.savedViewMutations.updateView
if (!update) return
const newGroup = update.group
const groupChanged = oldGroup.id !== newGroup.id
if (groupChanged) {
// Clean up old group
// onGroupViewRemovalCacheUpdates({
// cache,
// viewId: params.view.id,
// projectId: params.view.projectId,
// ...(oldGroup.groupId
// ? {
// group: {
// id: oldGroup.groupId,
// resourceIds: oldGroup.resourceIds
// }
// }
// : {
// view: {
// resourceIds: params.view.resourceIds
// }
// })
// })
// // Update new group
// onNewGroupViewCacheUpdates({
// cache,
// viewId: update.id,
// projectId: params.view.projectId,
// ...(newGroup.groupId
// ? {
// group: {
// id: newGroup.groupId,
// resourceIds: newGroup.resourceIds
// }
// }
// : {
// view: {
// resourceIds: params.view.resourceIds
// }
// })
// })
}
// // If set to home view, clear home view on all other views related to the same resourceIdString
// if (update.isHomeView && update.groupResourceIds.length === 1) {
// const allSavedViewKeys = getCachedObjectKeys(cache, 'SavedView')
// const modelId = update.groupResourceIds[0]
// for (const savedViewKey of allSavedViewKeys) {
// modifyObjectField(
// cache,
// savedViewKey,
// 'isHomeView',
// ({ value: isHomeView, helpers: { readObject } }) => {
// const view = readObject()
// const groupIds = view.groupResourceIds
// const viewId = view.id
// const projectId = view.projectId
// if (viewId === update.id) return
// if (update.projectId !== projectId) return
// if (isHomeView && groupIds?.length === 1 && groupIds[0] === modelId) {
// return false
// }
// }
// )
// }
// }
// // If position changed, recalculate it according to sort dir in vars
// if (input.position) {
// // Go through all SavedViewGroup.views, where this view exists and update array position
// iterateObjectField(
// cache,
// getCacheId('Project', params.view.projectId),
// 'savedViewGroups',
// ({ value }) => {
// const items = value.items
// if (!items) return
// items.forEach((groupRef) => {
// const parsed = parseObjectReference(groupRef)
// modifyObjectField(
// cache,
// getCacheId('SavedViewGroup', parsed.id),
// 'views',
// ({ helpers: { createUpdatedValue, readField }, variables }) => {
// const sortDir =
// variables.input.sortDirection || SortDirection.Desc
// const sortBy = (variables.input.sortBy || 'position') as
// | 'position'
// | 'updatedAt'
// return createUpdatedValue(({ update }) => {
// update('items', (items) => {
// const newItems = items.slice().sort((a, b) => {
// const process = (
// ref: CacheObjectReference<'SavedView'>
// ) => {
// const val = readField(ref, sortBy)
// if (!val) return -1
// if (sortBy === 'updatedAt') {
// return new Date(val).getTime()
// }
// return val as number
// }
// const aVal = process(a)
// const bVal = process(b)
// if (aVal < bVal)
// return sortDir === SortDirection.Asc ? -1 : 1
// if (aVal > bVal)
// return sortDir === SortDirection.Asc ? 1 : -1
// return 0
// })
// return newItems
// })
// })
// }
// )
// })
// }
// )
// }
}
}
).catch(convertThrowIntoFetchResult)
const result = await mutate({ input }).catch(convertThrowIntoFetchResult)
const res = result?.data?.projectMutations.savedViewMutations.updateView
if (!options?.skipToast) {
@@ -492,40 +303,7 @@ export const useCreateSavedViewGroup = () => {
return async (input: CreateSavedViewGroupInput) => {
if (!isLoggedIn.value) return
const ret = await mutate(
{ input },
{
update: (cache, res) => {
const group = res.data?.projectMutations.savedViewMutations.createGroup
if (!group?.id) return
// // Project.savedViewGroups +1
// modifyObjectField(
// cache,
// getCacheId('Project', input.projectId),
// 'savedViewGroups',
// ({ helpers: { createUpdatedValue, fromRef, ref } }) =>
// createUpdatedValue(({ update }) => {
// update('totalCount', (totalCount) => totalCount + 1)
// update('items', (items) => {
// const newItems = items.slice()
// // default comes first, then new group
// const defaultIdx = newItems.findIndex((i) =>
// isUngroupedGroup(fromRef(i).id)
// )
// newItems.splice(defaultIdx + 1, 0, ref('SavedViewGroup', group.id))
// return newItems
// })
// }),
// { autoEvictFiltered: filterKeys }
// )
}
}
).catch(convertThrowIntoFetchResult)
const ret = await mutate({ input }).catch(convertThrowIntoFetchResult)
const res = ret?.data?.projectMutations.savedViewMutations.createGroup
if (res?.id) {
triggerNotification({
@@ -583,28 +361,9 @@ export const useDeleteSavedViewGroup = () => {
const projectId = group.projectId
if (!groupId || group.isUngroupedViewsGroup) return // not real group
const result = await mutate(
{ input: { groupId, projectId } },
{
update: (cache, res) => {
const deleteSuccessful =
res.data?.projectMutations.savedViewMutations.deleteGroup
if (!deleteSuccessful) return
// // Views can be moved around, just easier to evict Project.savedViewGroups
// modifyObjectField(
// cache,
// getCacheId('Project', projectId),
// 'savedViewGroups',
// ({ helpers: { evict } }) => evict()
// )
// // Evict
// cache.evict({
// id: getCacheId('SavedViewGroup', groupId)
// })
}
}
).catch(convertThrowIntoFetchResult)
const result = await mutate({ input: { groupId, projectId } }).catch(
convertThrowIntoFetchResult
)
const res = result?.data?.projectMutations.savedViewMutations.deleteGroup
if (res) {
@@ -24,16 +24,15 @@ export const useViewerSavedViewIntegration = () => {
},
response: { savedView }
},
urlHashState: { savedView: urlHashStateSavedViewSettings }
urlHashState: { savedView: urlHashStateSavedViewSettings },
ui: {
savedViews: { savedViewStateId }
}
} = useInjectedViewerState()
const applyState = useApplySerializedState()
const { serializedStateId } = useViewerRealtimeActivityTracker()
const { on, emit } = useEventBus()
// Saved View ID will be unset, once the user does anything to the viewer that
// changes it from the saved view
const savedViewStateId = ref<string>()
const validState = (state: unknown) => (isSerializedViewerState(state) ? state : null)
const apply = async () => {
@@ -131,6 +130,7 @@ export type SavedViewsUIState = ReturnType<typeof useBuildSavedViewsUIState>
export const useBuildSavedViewsUIState = () => {
const openedGroupState = ref<Map<string, true>>(new Map())
const savedViewStateId = ref<string>()
onUnmounted(() => {
openedGroupState.value = new Map()
@@ -140,7 +140,12 @@ export const useBuildSavedViewsUIState = () => {
/**
* Groups that should currently be expanded/open
*/
openedGroupState
openedGroupState,
/**
* A kind of a "viewer snapshot" ID associated w/ the saved view being loaded. Helps track
* if user has changed the view since loading the saved view
*/
savedViewStateId
}
}
@@ -296,13 +296,25 @@ export const useOnProjectSavedViewGroupsUpdated = (params: {
const group = event.savedViewGroup
if (event.type === ProjectSavedViewsUpdatedMessageType.Deleted) {
// Views can be moved around, just easier to evict Project.savedViewGroups
// Views can be moved around, just easier to evict Project groups
modifyObjectField(
cache,
getCacheId('Project', unref(projectId)),
'savedViewGroups',
({ helpers: { evict } }) => evict()
)
// Evict all 'default' groups - items will fall in there
modifyObjectField(
cache,
getCacheId('Project', unref(projectId)),
'savedViewGroup',
({ helpers: { evict, fromRef }, value }) => {
const { id } = fromRef(value)
if (isUngroupedGroup(id)) return evict()
}
)
// Evict
cache.evict({
id: getCacheId('SavedViewGroup', id)
@@ -29,6 +29,9 @@ graphql(`
canEditDescription {
...FullPermissionCheckResult
}
canSetAsHomeView {
...FullPermissionCheckResult
}
}
}
`)
@@ -47,12 +50,11 @@ export const useSavedViewValidationHelpers = (params: {
}
} = useInjectedViewerState()
const canUpdate = computed(() => params.view.value?.permissions.canUpdate)
const canMove = computed(() => params.view.value?.permissions.canMove)
const canEditTitle = computed(() => params.view.value?.permissions.canEditTitle)
const canEditDescription = computed(
() => params.view.value?.permissions.canEditDescription
)
const permissions = computed(() => params.view.value?.permissions)
const canUpdate = computed(() => permissions.value?.canUpdate)
const canMove = computed(() => permissions.value?.canMove)
const canEditTitle = computed(() => permissions.value?.canEditTitle)
const canEditDescription = computed(() => permissions.value?.canEditDescription)
const canOpenEditDialog = computed(
(): FullPermissionCheckResultFragment | undefined => {
@@ -104,10 +106,10 @@ export const useSavedViewValidationHelpers = (params: {
const canSetHomeView = computed(
(): { authorized: boolean; message: Optional<string> } => {
if (!canUpdate.value?.authorized || isLoading.value) {
if (!permissions.value?.canSetAsHomeView.authorized || isLoading.value) {
return {
authorized: false,
message: canUpdate.value?.errorMessage || undefined
message: permissions.value?.canSetAsHomeView.errorMessage || undefined
}
}
@@ -118,13 +120,6 @@ export const useSavedViewValidationHelpers = (params: {
}
}
if (isOnlyVisibleToMe.value) {
return {
authorized: false,
message: 'A view must be shared to be set as home view'
}
}
return { authorized: true, message: undefined }
}
)
@@ -37,26 +37,6 @@ export function useStateSerialization() {
const dataStore = useFilteringDataStore()
const { box3ToSectionBoxData } = useSectionBoxUtilities()
/**
* We don't want to save a comment w/ implicit identifiers like ones that only have a model ID or a folder prefix, because
* those can resolve to completely different versions/objects as time goes on
*/
const buildConcreteResourceIdString = () => {
const resources = state.resources.response.resourceItems
const builder = SpeckleViewer.ViewerRoute.resourceBuilder()
for (const resource of resources.value) {
if (resource.modelId && resource.versionId) {
builder.addModel(resource.modelId, resource.versionId)
} else {
builder.addObject(resource.objectId)
}
}
const finalString = builder.toString()
return finalString || state.resources.request.resourceIdString.value
}
const serialize = (
options?: Partial<{
/**
@@ -89,7 +69,7 @@ export function useStateSerialization() {
resources: {
request: {
resourceIdString: concreteResourceIdString
? buildConcreteResourceIdString()
? state.resources.response.concreteResourceIdString.value
: state.resources.request.resourceIdString.value,
threadFilters: { ...state.resources.request.threadFilters.value }
}
@@ -159,7 +139,7 @@ export function useStateSerialization() {
return ret
}
return { serialize, buildConcreteResourceIdString }
return { serialize }
}
export enum StateApplyMode {
@@ -295,6 +295,11 @@ export type InjectableViewerState = Readonly<{
* but if none of them actually exist and are loaded then I wouldn't count that as a federated view.
*/
isFederatedView: ComputedRef<boolean>
/**
* We don't want to save a comment or view w/ implicit identifiers like ones that only have a model ID or a folder prefix, because
* those can resolve to completely different versions/objects as time goes on
*/
concreteResourceIdString: ComputedRef<string>
}
}
/**
@@ -643,6 +648,7 @@ function setupResponseResourceItems(
| 'isFederatedView'
| 'resourceItemsExtended'
| 'resourceItemsIds'
| 'concreteResourceIdString'
> {
const globalError = useError()
const {
@@ -819,6 +825,20 @@ function setupResponseResourceItems(
})
const isFederatedView = computed(() => resourceItems.value.length > 1)
const concreteResourceIdString = computed(() => {
const builder = resourceBuilder()
for (const resource of resourceItems.value) {
if (resource.modelId && resource.versionId) {
builder.addModel(resource.modelId, resource.versionId)
} else {
builder.addObject(resource.objectId)
}
}
const finalString = builder.toString()
return finalString || resourceIdString.value
})
return {
resourceItemsExtended,
@@ -827,7 +847,8 @@ function setupResponseResourceItems(
resourceItemsQueryVariables: computed(() => resourceItemsQueryVariables.value),
resourceItemsLoaded,
savedView,
isFederatedView
isFederatedView,
concreteResourceIdString
}
}
@@ -262,7 +262,13 @@ export function useSelectionUtilities() {
const addToSelectionFromObjectIds = (objectIds: string[]) => {
const originalObjects = selectedObjects.value.slice()
setSelectionFromObjectIds(objectIds)
selectedObjects.value = [...originalObjects, ...selectedObjects.value]
// Filter out duplicates by checking if objects with the same ID already exist
const newObjects = selectedObjects.value.filter(
(newObj) => !originalObjects.some((existingObj) => existingObj.id === newObj.id)
)
selectedObjects.value = [...originalObjects, ...newObjects]
}
const removeFromSelectionObjectIds = (objectIds: string[]) => {
@@ -1,5 +1,13 @@
import { graphql } from '~~/lib/common/generated/gql'
export const workspaceFeatureEnabledCheckQuery = graphql(`
query Workspace($featureName: WorkspaceFeatureName!, $workspaceId: String!) {
workspace(id: $workspaceId) {
hasAccessToFeature(featureName: $featureName)
}
}
`)
export const workspaceAccessCheckQuery = graphql(`
query WorkspaceAccessCheck($slug: String!) {
workspaceBySlug(slug: $slug) {
@@ -104,9 +104,6 @@ graphql(`
canReadSettings {
...FullPermissionCheckResult
}
canReadAccIntegrationSettings {
...FullPermissionCheckResult
}
canUpdate {
...FullPermissionCheckResult
}
@@ -182,9 +179,7 @@ const modelCount = computed(() => project.value?.modelCount.totalCount)
const commentCount = computed(() => project.value?.commentThreadCount.totalCount)
const canReadSettings = computed(() => project.value?.permissions.canReadSettings)
const canReadAccIntegrationSettings = computed(
() => project.value?.permissions.canReadAccIntegrationSettings
)
const canUpdate = computed(() => project.value?.permissions.canUpdate)
const hasRole = computed(() => project.value?.role)
const teamUsers = computed(() => project.value?.team.map((t) => t.user) || [])
@@ -233,7 +228,6 @@ const onInviteAccepted = async (params: { accepted: boolean }) => {
const isOwner = computed(() => project.value?.role === Roles.Stream.Owner)
const isAutomateEnabled = useIsAutomateModuleEnabled()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const isAccEnabled = useIsAccModuleEnabled()
const pageTabItems = computed((): LayoutPageTabItem[] => {
const items: LayoutPageTabItem[] = [
@@ -260,13 +254,6 @@ const pageTabItems = computed((): LayoutPageTabItem[] => {
})
}
if (isAccEnabled.value && canReadAccIntegrationSettings.value?.authorized) {
items.push({
title: 'ACC',
id: 'acc'
})
}
if (canReadSettings.value?.authorized) {
items.push({
title: 'Collaborators',
@@ -1,21 +0,0 @@
<template>
<ProjectPageAccTab :project-id="projectId" />
</template>
<script setup lang="ts">
import type { ProjectPageProjectFragment } from '~~/lib/common/generated/gql/graphql'
const route = useRoute()
const attrs = useAttrs() as {
project: ProjectPageProjectFragment
}
const projectName = computed(() =>
attrs.project.name.length ? attrs.project.name : ''
)
const projectId = computed(() => route.params.id as string)
useHead({
title: `Acc | ${projectName.value}`
})
</script>
@@ -14,7 +14,8 @@ import { LayoutTabsVertical, type LayoutPageTabItem } from '@speckle/ui-componen
import {
projectSettingsRoute,
projectWebhooksRoute,
projectTokensRoute
projectTokensRoute,
projectIntegrationsRoute
} from '~~/lib/common/helpers/route'
import { graphql } from '~~/lib/common/generated/gql'
import type { ProjectPageSettingsTab_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
@@ -34,6 +35,9 @@ graphql(`
canReadEmbedTokens {
...FullPermissionCheckResult
}
canReadAccIntegrationSettings {
...FullPermissionCheckResult
}
}
}
`)
@@ -49,6 +53,10 @@ const canReadWebhooks = computed(() => attrs.project.permissions.canReadWebhooks
const projectName = computed(() =>
attrs.project.name.length ? attrs.project.name : ''
)
const isAccEnabled = useIsAccModuleEnabled() // check permission over project
const canReadAccIntegrationSettings = computed(
() => attrs.project.permissions.canReadAccIntegrationSettings
)
useHead({
title: `Settings | ${projectName.value}`
@@ -70,6 +78,12 @@ const settingsTabItems = computed((): LayoutPageTabItem[] => [
id: 'tokens',
disabled: !canReadEmbedTokens.value.authorized,
disabledMessage: canReadEmbedTokens.value.message
},
{
title: 'Integrations',
id: 'integrations',
disabled: isAccEnabled && !canReadAccIntegrationSettings.value.authorized,
disabledMessage: canReadAccIntegrationSettings.value.message
}
])
@@ -80,6 +94,7 @@ const activeSettingsPageTab = computed({
const path = route.path
if (path.includes('/settings/webhooks')) return settingsTabItems.value[1]
if (path.includes('/settings/tokens')) return settingsTabItems.value[2]
if (path.includes('/settings/integrations')) return settingsTabItems.value[3]
return settingsTabItems.value[0]
},
set: (val: LayoutPageTabItem) => {
@@ -90,6 +105,9 @@ const activeSettingsPageTab = computed({
case 'tokens':
router.push(projectTokensRoute(projectId.value))
break
case 'integrations':
router.push(projectIntegrationsRoute(projectId.value))
break
case 'general':
default:
router.push(projectSettingsRoute(projectId.value))
@@ -0,0 +1,7 @@
<template>
<ProjectPageSettingsAccTab :project-id="projectId" />
</template>
<script setup lang="ts">
const route = useRoute()
const projectId = computed(() => route.params.id as string)
</script>
@@ -0,0 +1,33 @@
<template>
<section>
<SettingsSectionHeader
title="Integrations"
text="Connect your workspace to authorized applications."
/>
<IntegrationsAccCard
:workspace-id="workspaceResult?.workspaceBySlug.id || ''"
:workspace-slug="routeSlug"
></IntegrationsAccCard>
<!-- <div v-for="integration in integrations" :key="integration.cookieKey">
<IntegrationsCard
:integration="integration"
@handle-c-t-a="handleCTA(integration)"
></IntegrationsCard>
</div> -->
</section>
</template>
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable'
import { settingsWorkspaceGeneralQuery } from '~/lib/settings/graphql/queries'
definePageMeta({
layout: 'settings'
})
const route = useRoute()
const routeSlug = computed(() => (route.params.slug as string) || '')
const { result: workspaceResult } = useQuery(settingsWorkspaceGeneralQuery, () => ({
slug: routeSlug.value
}))
</script>
@@ -342,7 +342,6 @@ export default defineNuxtPlugin(async (nuxtApp) => {
{
err: error,
info,
isAppError: true,
vm: _vm?.$options.name,
errString: errorToString(error),
vueErrorHandler: true,
+2 -1
View File
@@ -1,3 +1,4 @@
import { isObject } from 'lodash-es'
import mitt from 'mitt'
import type {
EventBusKeyPayloadMap,
@@ -19,7 +20,7 @@ export default defineNuxtPlugin(() => {
handler?: (event: EventBusKeyPayloadMap[T]) => void
) => emitter.off(key, handler),
emit: <T extends EventBusKeys>(key: T, payload: EventBusKeyPayloadMap[T]) =>
emitter.emit(key, payload)
emitter.emit(key, isObject(payload) ? toRaw(payload) : payload)
}
}
}
@@ -0,0 +1,74 @@
extend type WorkspaceIntegrations {
acc(token: String): AccIntegration
}
type AccIntegration {
hub(id: String!): AccHub!
hubs: AccHubCollection!
project(hubId: String!, projectId: String!): AccProject!
folder(projectId: String!, folderId: String!): AccFolder!
item(projectId: String!, itemId: String!): AccItem!
}
type AccHub {
id: ID!
name: String!
project(id: ID!): AccProject!
projects: AccProjectCollection!
}
type AccHubCollection {
items: [AccHub!]!
cursor: String
}
type AccProject {
id: ID!
name: String!
# hub: AccHub!
folder(id: String!): AccFolder!
rootFolder: AccFolder!
}
type AccProjectCollection {
items: [AccProject!]!
cursor: String
}
type AccFolder {
id: ID!
name: String!
# project: AccProject!
contents: AccItemCollection!
children: AccFolderCollection!
}
type AccFolderCollection {
items: [AccFolder!]!
cursor: String
}
type AccItem {
"""
lineage urn
"""
id: ID!
name: String!
# version(version: Int): AccItemVersion!
latestVersion: AccItemVersion!
}
type AccItemCollection {
items: [AccItem!]!
cursor: String
}
type AccItemVersion {
"""
version urn
"""
id: ID!
name: String!
versionNumber: Int!
fileType: String
}
@@ -0,0 +1,5 @@
extend type Workspace {
integrations: WorkspaceIntegrations
}
type WorkspaceIntegrations
@@ -0,0 +1,3 @@
extend type ProjectPermissionChecks {
canReadAccIntegrationSettings: PermissionCheckResult!
}
@@ -3,6 +3,10 @@ extend type Project {
accSyncItem(id: String!): AccSyncItem!
}
extend type Model {
accSyncItem: AccSyncItem
}
type AccSyncItemCollection {
items: [AccSyncItem!]!
totalCount: Int!
@@ -11,8 +15,9 @@ type AccSyncItemCollection {
type AccSyncItem {
id: ID!
projectId: String!
modelId: String!
project: Project!
model: Model
author: LimitedUser
accRegion: String!
accHubId: String!
accProjectId: String!
@@ -25,7 +30,6 @@ type AccSyncItem {
accFileViewName: String
accWebhookId: String
status: AccSyncItemStatus!
author: LimitedUser
createdAt: DateTime!
updatedAt: DateTime!
}
@@ -10,7 +10,6 @@ type ProjectPermissionChecks {
canDelete: PermissionCheckResult!
canUpdateAllowPublicComments: PermissionCheckResult!
canReadSettings: PermissionCheckResult!
canReadAccIntegrationSettings: PermissionCheckResult!
canReadWebhooks: PermissionCheckResult!
canLeave: PermissionCheckResult!
canRequestRender: PermissionCheckResult!
@@ -11,6 +11,7 @@ type SavedViewPermissionChecks {
canMove: PermissionCheckResult!
canEditTitle: PermissionCheckResult!
canEditDescription: PermissionCheckResult!
canSetAsHomeView: PermissionCheckResult!
}
extend type SavedView {
@@ -698,8 +698,12 @@ enum WorkspaceFeatureFlagName {
presentations
}
"""
Either the ID or slug must be set
"""
input AdminAccessToWorkspaceFeatureInput {
workspaceId: ID!
workspaceId: ID
workspaceSlug: String
featureFlagName: WorkspaceFeatureFlagName!
}
+6
View File
@@ -186,6 +186,12 @@ const config: CodegenConfig = {
'@/modules/core/helpers/graphTypes#RootPermissionChecksGraphQLReturn',
WorkspacePermissionChecks:
'@/modules/workspacesCore/helpers/graphTypes#WorkspacePermissionChecksGraphQLReturn',
WorkspaceIntegrations:
'@/modules/acc/helpers/graphTypes#WorkspaceIntegrationsGraphQLReturn',
AccIntegration:
'@/modules/acc/helpers/graphTypes#AccIntegrationGraphQLReturn',
AccFolder: '@/modules/acc/helpers/graphTypes#AccFolderGraphQLReturn',
AccItem: '@/modules/acc/helpers/graphTypes#AccItemGraphQLReturn',
AccSyncItem: '@/modules/acc/helpers/graphTypes#AccSyncItemGraphQLReturn',
AccSyncItemMutations:
'@/modules/acc/helpers/graphTypes#AccSyncItemMutationsGraphQLReturn',
@@ -1,310 +0,0 @@
/* eslint-disable camelcase */
import type { AccTokens } from '@speckle/shared/acc'
import type { AccRegion } from '@/modules/acc/domain/constants'
import { AccRegions } from '@/modules/acc/domain/constants'
import type { ModelDerivativeServiceDesignManifest } from '@/modules/acc/domain/types'
import {
getAutodeskIntegrationClientId,
getAutodeskIntegrationClientSecret
} from '@/modules/shared/helpers/envHelper'
import { logger } from '@/observability/logging'
import { isObjectLike } from 'lodash-es'
import { z } from 'zod'
import crypto from 'crypto'
const invokeJsonRequest = async <T>(params: {
url: string
token: string
method?: RequestInit['method']
body?: URLSearchParams | Record<string, unknown>
headers?: Record<string, string>
}) => {
const { url, method = 'get', body, headers = {}, token } = params
const response = await fetch(url, {
method,
headers: {
'Content-Type':
body instanceof URLSearchParams
? 'application/x-www-form-urlencoded'
: 'application/json',
Authorization: `Bearer ${token}`,
...headers
},
body:
body && body instanceof URLSearchParams
? body
: isObjectLike(body)
? JSON.stringify(body)
: undefined
})
return (await response.json()) as T
}
interface BuildAuthorizeUrlOptions {
clientId: string
redirectUri: string
codeChallenge: string
scopes: string[]
}
interface ExchangeCodeOptions {
code: string
codeVerifier: string
clientId: string
clientSecret: string
redirectUri: string
}
const AccTokens = z.object({
access_token: z.string(),
refresh_token: z.string(),
token_type: z.string(),
id_token: z.string(),
expires_in: z.number()
})
export const generateCodeVerifier = () => {
const codeVerifier = crypto.randomBytes(32).toString('base64url')
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url')
return { codeVerifier, codeChallenge }
}
export const buildAuthorizeUrl = ({
clientId,
redirectUri,
codeChallenge,
scopes
}: BuildAuthorizeUrlOptions) => {
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
code_challenge: codeChallenge,
code_challenge_method: 'S256'
})
return `https://developer.api.autodesk.com/authentication/v2/authorize?${params.toString()}`
}
export const exchangeCodeForTokens = async ({
code,
codeVerifier,
clientId,
clientSecret,
redirectUri
}: ExchangeCodeOptions): Promise<AccTokens> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
code,
code_verifier: codeVerifier
})
const response = await fetch(
'https://developer.api.autodesk.com/authentication/v2/token',
{
method: 'POST',
body: params,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
)
const data = await response.json()
return AccTokens.parse(data)
}
export const exchangeRefreshTokenForTokens = async (args: {
refresh_token: string
}): Promise<AccTokens> => {
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: getAutodeskIntegrationClientId(),
client_secret: getAutodeskIntegrationClientSecret(),
refresh_token: args.refresh_token
})
const response = await fetch(
'https://developer.api.autodesk.com/authentication/v2/token',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
}
)
const data = await response.json()
return AccTokens.parse(data)
}
type AutodeskIntegrationTokenData = {
access_token: string
token_type: string
expires_in: number
}
/**
* Fetch a valid token for server-side operations as our custom integration
*/
export const getToken = async (): Promise<AutodeskIntegrationTokenData> => {
const clientId = getAutodeskIntegrationClientId()
const clientSecret = getAutodeskIntegrationClientSecret()
const token = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64')
const response = await fetch(
'https://developer.api.autodesk.com/authentication/v2/token',
{
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'data:read account:read viewables:read'
}),
headers: {
Authorization: `Basic ${token}`,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
}
}
)
const data = await response.json()
return z
.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number()
})
.parse(data)
}
/**
* Get base Autodesk API endpoint for a given region.
* NOTE: This has been true for all endpoints used so far but should be validated for any new ones!
*/
const getRegionUrl = (region: AccRegion): string => {
switch (region) {
case AccRegions.EMEA:
return 'https://developer.api.autodesk.com/modelderivative/v2/regions/eu'
default:
return 'https://developer.api.autodesk.com/modelderivative/v2'
}
}
const getApiUrl = (path: string, region: AccRegion): string => {
return `${getRegionUrl(region)}${path}`
}
/**
* Encode a urn string in the modified url-safe base64 format expected by the Autodesk API
*/
const encodeUrn = (urn: string): string => {
return btoa(urn).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
}
type AccWebhookConfig = {
// The (unencoded) ACC folder urn to subscribe to. Event will fire for all files in this folder.
rootProjectFolderUrn: string
// The Speckle endpoint to hit when the given event fires.
callbackUrl: string
// The ACC webhook event to subscribe to. You can register an event to a given callback url only once.
event: 'dm.version.added'
// The region where the request is executed
region: AccRegion
}
/**
* Register relevant webhook callbacks for integration with a given ACC project.
* @see https://aps.autodesk.com/en/docs/webhooks/v1/reference/http/webhooks/systems-system-events-event-hooks-POST/
* @returns null if webhook already exists
*/
export const tryRegisterAccWebhook = async (
webhook: AccWebhookConfig
): Promise<string | null> => {
const { rootProjectFolderUrn, callbackUrl, event, region } = webhook
const tokenData = await getToken()
const response = await fetch(
`https://developer.api.autodesk.com/webhooks/v1/systems/data/events/${event}/hooks`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'Content-Type': 'application/json',
'x-ads-region': region
},
body: JSON.stringify({
callbackUrl,
scope: {
folder: rootProjectFolderUrn
}
})
}
)
if (response.ok && response.status === 201) {
const webhookId = response.headers.get('Location')?.split('/').at(-1)
if (!webhookId) {
logger.info({ location: response.headers.get('Location') })
throw new Error('Webhook created but failed to parse id')
}
return webhookId
}
const e = await response.json().catch(() => null)
const isConflict =
response.status === 409 &&
e?.code === 'CONFLICT_ERROR' &&
e?.detail?.includes('Failed to save duplicate webhooks scope')
if (isConflict) {
logger.warn('Webhook already exists. Skipping registration.')
return null
}
throw new Error(`Webhook registration failed: ${JSON.stringify(e, null, 2)}`)
}
/**
* Get a manifest of the available derivative assets for a given design urn
* @see https://aps.autodesk.com/en/docs/model-derivative/v2/reference/http/manifest/urn-manifest-GET/
*/
export const getManifestByUrn = async (
params: {
urn: string
region: AccRegion
},
context: {
token: string
}
): Promise<ModelDerivativeServiceDesignManifest> => {
const { urn, region = AccRegions.EMEA } = params
const { token } = context
const encodedUrn = encodeUrn(urn)
const url = getApiUrl(`/designdata/${encodedUrn}/manifest`, region)
return await invokeJsonRequest({
url,
token,
method: 'GET'
})
}
@@ -0,0 +1,211 @@
import {
encodeUrn,
getApiUrl,
invokeJsonRequest
} from '@/modules/acc/clients/autodesk/helpers'
import { getToken } from '@/modules/acc/clients/autodesk/tokens'
import type { AccRegion } from '@/modules/acc/domain/acc/constants'
import { AccRegions } from '@/modules/acc/domain/acc/constants'
import type {
DataManagementFolderContentsFolder,
DataManagementFolderContentsItem,
DataManagementFolderContentsItemVersion,
ModelDerivativeServiceDesignManifest
} from '@/modules/acc/domain/acc/types'
import { logger } from '@/observability/logging'
type AccWebhookConfig = {
// The (unencoded) ACC folder urn to subscribe to. Event will fire for all files in this folder.
rootProjectFolderUrn: string
// The Speckle endpoint to hit when the given event fires.
callbackUrl: string
// The ACC webhook event to subscribe to. You can register an event to a given callback url only once.
event: 'dm.version.added'
// The region where the request is executed
region: AccRegion
}
/**
* Register relevant webhook callbacks for integration with a given ACC project.
* @see https://aps.autodesk.com/en/docs/webhooks/v1/reference/http/webhooks/systems-system-events-event-hooks-POST/
* @returns null if webhook already exists
*/
export const tryRegisterAccWebhook = async (
webhook: AccWebhookConfig
): Promise<string | null> => {
const { rootProjectFolderUrn, callbackUrl, event, region } = webhook
const tokenData = await getToken()
const response = await fetch(
`https://developer.api.autodesk.com/webhooks/v1/systems/data/events/${event}/hooks`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'Content-Type': 'application/json',
'x-ads-region': region
},
body: JSON.stringify({
callbackUrl,
scope: {
folder: rootProjectFolderUrn
}
})
}
)
if (response.ok && response.status === 201) {
const webhookId = response.headers.get('Location')?.split('/').at(-1)
if (!webhookId) {
logger.info({ location: response.headers.get('Location') })
throw new Error('Webhook created but failed to parse id')
}
return webhookId
}
const e = await response.json().catch(() => null)
const isConflict =
response.status === 409 &&
e?.code === 'CONFLICT_ERROR' &&
e?.detail?.includes('Failed to save duplicate webhooks scope')
if (isConflict) {
logger.warn('Webhook already exists. Skipping registration.')
return null
}
throw new Error(`Webhook registration failed: ${JSON.stringify(e, null, 2)}`)
}
type GetFolderMetadataResponse = { data: DataManagementFolderContentsFolder }
/**
* Get information about a given folder, like its name and object count
* @see https://aps.autodesk.com/en/docs/data/v2/reference/http/projects-project_id-folders-folder_id-GET/
*/
export const getFolderMetadata = async (
params: {
projectId: string
folderId: string
},
context: {
token: string
userId?: string
}
): Promise<DataManagementFolderContentsFolder> => {
const { projectId, folderId } = params
const { token } = context
const { data } = (await invokeJsonRequest({
url: `https://developer.api.autodesk.com/data/v1/projects/${projectId}/folders/${folderId}`,
method: 'GET',
token
})) as GetFolderMetadataResponse
return data
}
type GetFolderContentsResponse = {
data: (DataManagementFolderContentsFolder | DataManagementFolderContentsItem)[]
}
/**
* Get the contents (subfolders and items) of a given ACC folder
* @see https://aps.autodesk.com/en/docs/data/v2/reference/http/projects-project_id-folders-folder_id-contents-GET/
*/
export const getFolderContents = async (
args: {
projectId: string
folderId: string
type?: 'items' | 'folders'
},
context: {
token: string
userId?: string
}
): Promise<
(DataManagementFolderContentsFolder | DataManagementFolderContentsItem)[]
> => {
const { projectId, folderId } = args
const { token } = context
const url = new URL(
`https://developer.api.autodesk.com/data/v1/projects/${projectId}/folders/${folderId}/contents`
)
const params = new URLSearchParams()
if (args.type) {
params.append('filter[type]', args.type)
}
url.search = params.toString()
const { data } = (await invokeJsonRequest({
url: url.toString(),
method: 'GET',
token
})) as GetFolderContentsResponse
return data ?? []
}
type GetItemLatestVersionResponse = {
data: DataManagementFolderContentsItemVersion
}
/**
* Get item version information, like version number and file type, for the latest version of a given file
* @see https://aps.autodesk.com/en/docs/data/v2/reference/http/projects-project_id-items-item_id-tip-GET/
*/
export const getItemLatestVersion = async (
args: {
projectId: string
itemId: string
},
context: {
token: string
userId?: string
}
): Promise<DataManagementFolderContentsItemVersion> => {
const { projectId, itemId } = args
const { token } = context
const { data } = (await invokeJsonRequest({
url: `https://developer.api.autodesk.com/data/v1/projects/${projectId}/items/${itemId}/tip`,
method: 'GET',
token
})) as GetItemLatestVersionResponse
return data
}
/**
* Get a manifest of the available derivative assets for a given design urn
* @see https://aps.autodesk.com/en/docs/model-derivative/v2/reference/http/manifest/urn-manifest-GET/
*/
export const getManifestByUrn = async (
params: {
urn: string
region: AccRegion
},
context: {
token: string
}
): Promise<ModelDerivativeServiceDesignManifest> => {
const { urn, region = AccRegions.EMEA } = params
const { token } = context
const encodedUrn = encodeUrn(urn)
const url = getApiUrl(`/designdata/${encodedUrn}/manifest`, region)
return await invokeJsonRequest({
url,
token,
method: 'GET'
})
}
@@ -0,0 +1,61 @@
import type { AccRegion } from '@/modules/acc/domain/acc/constants'
import { AccRegions } from '@/modules/acc/domain/acc/constants'
import { AutodeskApiRequestError } from '@/modules/acc/errors/acc'
import { logger } from '@/observability/logging'
export const invokeJsonRequest = async <T>(params: {
url: string
token: string
method?: RequestInit['method']
body?: string
headers?: Record<string, string>
}) => {
const { url, method = 'get', body, headers = {}, token } = params
try {
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...headers
},
body
})
return (await response.json()) as T
} catch (e) {
logger.error(
{
...params,
error: e
},
'Autodesk request failed'
)
throw new AutodeskApiRequestError(method, url)
}
}
/**
* Get base Autodesk API endpoint for a given region.
* NOTE: This has been true for all endpoints used so far but should be validated for any new ones!
*/
export const getRegionUrl = (region: AccRegion): string => {
switch (region) {
case AccRegions.EMEA:
return 'https://developer.api.autodesk.com/modelderivative/v2/regions/eu'
default:
return 'https://developer.api.autodesk.com/modelderivative/v2'
}
}
export const getApiUrl = (path: string, region: AccRegion): string => {
return `${getRegionUrl(region)}${path}`
}
/**
* Encode a urn string in the modified url-safe base64 format expected by the Autodesk API
*/
export const encodeUrn = (urn: string): string => {
return btoa(urn).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
}
@@ -0,0 +1,167 @@
/* eslint-disable camelcase */
import crypto from 'crypto'
import { z } from 'zod'
import type { AccTokens } from '@speckle/shared/acc'
import {
getAutodeskIntegrationClientId,
getAutodeskIntegrationClientSecret
} from '@/modules/shared/helpers/envHelper'
interface BuildAuthorizeUrlOptions {
clientId: string
redirectUri: string
codeChallenge: string
scopes: string[]
}
interface ExchangeCodeOptions {
code: string
codeVerifier: string
clientId: string
clientSecret: string
redirectUri: string
}
const AccTokens = z
.object({
access_token: z.string(),
refresh_token: z.string(),
token_type: z.string(),
id_token: z.string().optional(),
expires_in: z.number()
})
.transform((data) => ({
...data,
timestamp: Date.now()
}))
export const generateCodeVerifier = () => {
const codeVerifier = crypto.randomBytes(32).toString('base64url')
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url')
return { codeVerifier, codeChallenge }
}
export const buildAuthorizeUrl = ({
clientId,
redirectUri,
codeChallenge,
scopes
}: BuildAuthorizeUrlOptions) => {
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
code_challenge: codeChallenge,
code_challenge_method: 'S256'
})
return `https://developer.api.autodesk.com/authentication/v2/authorize?${params.toString()}`
}
export const exchangeCodeForTokens = async ({
code,
codeVerifier,
clientId,
clientSecret,
redirectUri
}: ExchangeCodeOptions): Promise<AccTokens> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
code,
code_verifier: codeVerifier
})
const response = await fetch(
'https://developer.api.autodesk.com/authentication/v2/token',
{
method: 'POST',
body: params,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
)
const data = await response.json()
return AccTokens.parse(data)
}
export const exchangeRefreshTokenForTokens = async (args: {
refresh_token: string
}): Promise<AccTokens> => {
const clientId = getAutodeskIntegrationClientId()
const clientSecret = getAutodeskIntegrationClientSecret()
const token = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64')
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: args.refresh_token
})
const response = await fetch(
'https://developer.api.autodesk.com/authentication/v2/token',
{
method: 'POST',
headers: {
Authorization: `Basic ${token}`,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params
}
)
const data = await response.json()
return AccTokens.parse(data)
}
type AutodeskIntegrationTokenData = {
access_token: string
token_type: string
expires_in: number
}
/**
* Fetch a valid token for server-side operations as our custom integration
*/
export const getToken = async (): Promise<AutodeskIntegrationTokenData> => {
const clientId = getAutodeskIntegrationClientId()
const clientSecret = getAutodeskIntegrationClientSecret()
const token = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64')
const response = await fetch(
'https://developer.api.autodesk.com/authentication/v2/token',
{
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'data:read account:read viewables:read'
}),
headers: {
Authorization: `Basic ${token}`,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
}
}
)
const data = await response.json()
return z
.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number()
})
.parse(data)
}
@@ -0,0 +1,25 @@
import type { StringEnumValues } from '@speckle/shared'
import { StringEnum } from '@speckle/shared'
export const AccSyncItemStatuses = StringEnum([
// A new file version had been detected, and we are awaiting a processable file.
'pending',
// We are actively processing the new file version. (The Automate function has been triggered.)
'syncing',
'failed',
'paused',
'succeeded'
])
export type AccSyncItemStatus = StringEnumValues<typeof AccSyncItemStatuses>
export const AccRegions = StringEnum([
'US',
'EMEA',
'AUS',
'CAN',
'DEU',
'IND',
'JPN',
'GBR'
])
export type AccRegion = StringEnumValues<typeof AccRegions>
@@ -1,4 +1,4 @@
import type { AccSyncItem } from '@/modules/acc/domain/types'
import type { AccSyncItem } from '@/modules/acc/domain/acc/types'
export const accSyncItemEventsNamespace = 'accSyncItems' as const
@@ -1,5 +1,5 @@
import type { AccSyncItemStatus } from '@/modules/acc/domain/constants'
import type { AccSyncItem } from '@/modules/acc/domain/types'
import type { AccSyncItemStatus } from '@/modules/acc/domain/acc/constants'
import type { AccSyncItem } from '@/modules/acc/domain/acc/types'
import type { Exact } from 'type-fest'
export type UpsertAccSyncItem = <Item extends Exact<AccSyncItem, Item>>(
@@ -13,7 +13,14 @@ export type UpdateAccSyncItemStatus = (args: {
export type GetAccSyncItemById = (args: { id: string }) => Promise<AccSyncItem | null>
export type GetAccSyncItemByModelId = (args: {
modelId: string
}) => Promise<AccSyncItem | null>
export type GetAccSyncItemsById = (args: { ids: string[] }) => Promise<AccSyncItem[]>
export type GetAccSyncItemsByModelId = (args: {
ids: string[]
}) => Promise<AccSyncItem[]>
export type ListAccSyncItems = (args: {
projectId: string
@@ -1,4 +1,4 @@
import type { AccRegion, AccSyncItemStatus } from '@/modules/acc/domain/constants'
import type { AccRegion, AccSyncItemStatus } from '@/modules/acc/domain/acc/constants'
export type AccSyncItem = {
id: string
@@ -22,6 +22,36 @@ export type AccSyncItem = {
updatedAt: Date
}
export type DataManagementFolderContentsFolder = {
id: string
type: 'folders'
attributes: {
name?: string
displayName: string
objectCount: number
}
}
export type DataManagementFolderContentsItem = {
id: string
type: 'items'
attributes: {
name?: string
displayName: string
}
}
export type DataManagementFolderContentsItemVersion = {
id: string
type: 'versions'
attributes: {
name?: string
displayName: string
versionNumber: number
fileType?: string
}
}
export type ModelDerivativeServiceDesignManifest = {
type: 'manifest'
region: string
@@ -1,32 +1,10 @@
import type { StringEnumValues } from '@speckle/shared'
import { StringEnum } from '@speckle/shared'
export const ImporterAutomateFunctions = {
svf2: {
functionId: '4665e0b3ba',
functionReleaseId: '470ec84b63'
},
rvt: {
functionId: '0725cb0ac6',
functionReleaseId: 'b5c16a1606'
}
}
export const AccSyncItemStatuses = StringEnum([
// A new file version had been detected, and we are awaiting a processable file.
'pending',
// We are actively processing the new file version. (The Automate function has been triggered.)
'syncing',
'failed',
'paused',
'succeeded'
])
export type AccSyncItemStatus = StringEnumValues<typeof AccSyncItemStatuses>
export const AccRegions = StringEnum([
'US',
'EMEA',
'AUS',
'CAN',
'DEU',
'IND',
'JPN',
'GBR'
])
export type AccRegion = StringEnumValues<typeof AccRegions>
+35
View File
@@ -6,6 +6,30 @@ export class AccModuleDisabledError extends BaseError {
static statusCode = 423
}
export class AccNotAuthorizedError extends BaseError {
static defaultMessage = 'ACC token missing or not authorized'
static code = 'ACC_MODULE_NOT_AUTHORIZED'
static statusCode = 401
}
export class AccNotYetImplementedError extends BaseError {
static defaultMessage =
'This functionality for the ACC integration is not yet implemented'
static code = 'ACC_MODULE_NOT_YET_IMPLEMENTED'
static statusCode = 501
}
export class AutodeskApiRequestError extends BaseError {
static defaultMessage = 'Error during external request to Autodesk'
static code = 'ACC_AUTODESK_REQUEST_ERROR'
static statusCode = 500
constructor(method: string, endpoint: string) {
super()
this.message = `Failed to issue ${method} request to ${endpoint}`
}
}
export class DuplicateSyncItemError extends BaseError {
static defaultMessage = 'A sync item with this lineage urn already exists.'
static code = 'ACC_DUPLICATE_SYNC_ITEM_LINEAGE_URN'
@@ -28,3 +52,14 @@ export class SyncItemAutomationTriggerError extends BaseError {
static code = 'ACC_SYNC_ITEM_AUTOMATION_TRIGGER_ERROR'
static statusCode = 422
}
export class SyncItemUnsupportedFileExtensionError extends BaseError {
static defaultMessage = 'Cannot sync this file type from ACC'
static code = 'ACC_SYNC_ITEM_UNSUPPORTED_FILE_EXTENSION'
static statusCode = 422
constructor(fileExtension: string) {
super()
this.message = `Received sync item update with unsupported file extension ${fileExtension}`
}
}
@@ -1,4 +1,4 @@
import { AccSyncItemEvents } from '@/modules/acc/domain/events'
import { AccSyncItemEvents } from '@/modules/acc/domain/acc/events'
import type { EventBusListen, EventPayload } from '@/modules/shared/services/eventBus'
import type { PublishSubscription } from '@/modules/shared/utils/subscriptions'
import { ProjectSubscriptions } from '@/modules/shared/utils/subscriptions'
@@ -0,0 +1,94 @@
import { getFolderContents } from '@/modules/acc/clients/autodesk/acc'
import type {
AccSyncItem,
DataManagementFolderContentsFolder,
DataManagementFolderContentsItem
} from '@/modules/acc/domain/acc/types'
import {
getAccSyncItemsByIdFactory,
getAccSyncItemsByModelIdFactory
} from '@/modules/acc/repositories/accSyncItems'
import { defineRequestDataloaders } from '@/modules/shared/helpers/graphqlHelper'
import type { Nullable } from '@speckle/shared'
import { keyBy } from 'lodash-es'
declare module '@/modules/core/loaders' {
interface ModularizedDataLoaders
extends Partial<ReturnType<typeof dataLoadersDefinition>> {}
}
const dataLoadersDefinition = defineRequestDataloaders(
({ createLoader, deps: { db } }) => {
const getAccSyncItemsById = getAccSyncItemsByIdFactory({ db })
const getAccSyncItemsByModelId = getAccSyncItemsByModelIdFactory({ db })
return {
acc: {
getFolderChildren: createLoader<
{ projectId: string; folderId: string; token: string },
DataManagementFolderContentsFolder[],
string
>(
async (folderIds) => {
return await Promise.all(
folderIds.map(async ({ projectId, folderId, token }) => {
const items = await getFolderContents(
{ projectId, folderId, type: 'folders' },
{ token }
)
return items.filter(
(item): item is DataManagementFolderContentsFolder =>
item.type === 'folders'
)
})
)
},
{
cacheKeyFn: (args) => `${args.projectId}-${args.projectId}`
}
),
getFolderContents: createLoader<
{ projectId: string; folderId: string; token: string },
DataManagementFolderContentsItem[],
string
>(
async (folderIds) => {
return await Promise.all(
folderIds.map(async ({ projectId, folderId, token }) => {
const items = await getFolderContents(
{ projectId, folderId, type: 'items' },
{ token }
)
return items.filter(
(item): item is DataManagementFolderContentsItem =>
item.type === 'items'
)
})
)
},
{
cacheKeyFn: (args) => `${args.projectId}-${args.projectId}`
}
),
getAccSyncItem: createLoader<string, Nullable<AccSyncItem>>(async (ids) => {
const results = keyBy(
await getAccSyncItemsById({ ids: ids.slice() }),
(i) => i.id
)
return ids.map((i) => results[i] || null)
}),
getAccSyncItemByModelId: createLoader<string, Nullable<AccSyncItem>>(
async (ids) => {
const results = keyBy(
await getAccSyncItemsByModelId({ ids: ids.slice() }),
(i) => i.modelId
)
return ids.map((i) => results[i] || null)
}
)
}
}
}
)
export default dataLoadersDefinition
@@ -59,15 +59,117 @@ import {
ProjectSubscriptions
} from '@/modules/shared/utils/subscriptions'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { AccModuleDisabledError, SyncItemNotFoundError } from '@/modules/acc/errors/acc'
import {
AccModuleDisabledError,
AccNotAuthorizedError,
SyncItemNotFoundError
} from '@/modules/acc/errors/acc'
import { getFeatureFlags } from '@speckle/shared/environment'
import type { AccRegion } from '@/modules/acc/domain/constants'
import type { AccRegion } from '@/modules/acc/domain/acc/constants'
import { ProjectNotFoundError } from '@/modules/core/errors/projects'
import {
mapFolderToGql,
mapItemToGql,
mapVersionToGql
} from '@/modules/acc/helpers/acc'
import {
getFolderMetadata,
getItemLatestVersion
} from '@/modules/acc/clients/autodesk/acc'
const { FF_ACC_INTEGRATION_ENABLED, FF_AUTOMATE_MODULE_ENABLED } = getFeatureFlags()
const enableAcc = FF_ACC_INTEGRATION_ENABLED && FF_AUTOMATE_MODULE_ENABLED
const resolvers: Resolvers = {
WorkspaceIntegrations: {
acc: async (_parent, args, ctx) => {
if (!args.token) {
throw new AccNotAuthorizedError()
}
// TODO ACC: Replace with Speckle user - ACC user association
ctx.accToken = args.token
return {}
}
},
AccIntegration: {
folder: async (_parent, args) => {
const { projectId, folderId } = args
return {
id: folderId,
projectId
}
}
},
AccFolder: {
name: async (parent, _args, ctx) => {
if (parent.name) return parent.name
const folderMetadata = await getFolderMetadata(
{
projectId: parent.projectId,
folderId: parent.id
},
{
token: ctx.accToken!
}
)
return (
folderMetadata.attributes.name ?? folderMetadata.attributes.displayName ?? ''
)
},
contents: async (parent, _args, ctx) => {
const { id: folderId, projectId } = parent
const { accToken } = ctx
const files = await ctx.loaders.acc!.getFolderContents.load({
projectId,
folderId,
token: accToken!
})
const items = files.map((file) => ({
...mapItemToGql(file),
projectId
}))
return { items }
},
children: async (parent, _args, ctx) => {
const { id: folderId, projectId } = parent
const { accToken } = ctx
const folders = await ctx.loaders.acc!.getFolderChildren.load({
projectId,
folderId,
token: accToken!
})
const items = folders.map((folder) => ({
...mapFolderToGql(folder),
projectId
}))
return { items }
}
},
AccItem: {
latestVersion: async (parent, _args, ctx) => {
const { id: itemId, projectId } = parent
const { accToken } = ctx
const version = await getItemLatestVersion(
{
projectId,
itemId
},
{
token: accToken!
}
)
return mapVersionToGql(version)
}
},
Mutation: {
accSyncItemMutations: () => ({})
},
@@ -193,6 +295,14 @@ const resolvers: Resolvers = {
}
},
AccSyncItem: {
project: async (parent, _args, context) => {
const project = await context.loaders.streams.getStream.load(parent.projectId)
if (!project) throw new ProjectNotFoundError()
return project
},
model: async (parent, _args, context) => {
return await context.loaders.branches.getById.load(parent.modelId)
},
author: async (parent, _args, context) => {
return await context.loaders.users.getUser.load(parent.authorId)
}
@@ -237,7 +347,32 @@ const resolvers: Resolvers = {
resourceType: TokenResourceIdentifierType.Project
})
const syncItem = await ctx.loaders.acc.getAccSyncItem.load(id)
const syncItem = await ctx.loaders.acc!.getAccSyncItem.load(id)
if (!syncItem) {
throw new SyncItemNotFoundError()
}
return syncItem
}
},
Model: {
accSyncItem: async (parent, _args, context) => {
const authResult =
await context.authPolicies.project.canReadAccIntegrationSettings({
userId: context.userId,
projectId: parent.streamId
})
throwIfAuthNotOk(authResult)
throwIfResourceAccessNotAllowed({
resourceId: parent.streamId,
resourceAccessRules: context.resourceAccessRules,
resourceType: TokenResourceIdentifierType.Project
})
const syncItem = await context.loaders.acc!.getAccSyncItemByModelId.load(
parent.id
)
if (!syncItem) {
throw new SyncItemNotFoundError()
@@ -275,6 +410,11 @@ const resolvers: Resolvers = {
}
const disabledResolvers: Resolvers = {
WorkspaceIntegrations: {
async acc() {
throw new AccModuleDisabledError()
}
},
Mutation: {
accSyncItemMutations: () => ({})
},
@@ -297,6 +437,11 @@ const disabledResolvers: Resolvers = {
throw new AccModuleDisabledError()
}
},
Model: {
async accSyncItem() {
return null
}
},
Subscription: {
projectAccSyncItemsUpdated: {
subscribe: filteredSubscribe(
@@ -0,0 +1,9 @@
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
const resolvers: Resolvers = {
Workspace: {
integrations: () => ({})
}
}
export default resolvers
@@ -0,0 +1,50 @@
import type {
DataManagementFolderContentsFolder,
DataManagementFolderContentsItem,
DataManagementFolderContentsItemVersion
} from '@/modules/acc/domain/acc/types'
import type {
AccFolderGraphQLReturn,
AccItemGraphQLReturn,
AccItemVersionGraphQLReturn
} from '@/modules/acc/helpers/graphTypes'
export const filterContentsToFolders = (
contents: (DataManagementFolderContentsFolder | DataManagementFolderContentsItem)[]
): DataManagementFolderContentsFolder[] => {
return contents.filter(
(entry): entry is DataManagementFolderContentsFolder => entry.type === 'folders'
)
}
export const filterContentsToItems = (
contents: (DataManagementFolderContentsFolder | DataManagementFolderContentsItem)[]
): DataManagementFolderContentsItem[] => {
return contents.filter(
(entry): entry is DataManagementFolderContentsItem => entry.type === 'items'
)
}
export const mapFolderToGql = (
folder: DataManagementFolderContentsFolder
): Omit<AccFolderGraphQLReturn, 'projectId'> => ({
id: folder.id,
name: folder.attributes.name ?? folder.attributes.displayName,
objectCount: folder.attributes.objectCount
})
export const mapItemToGql = (
item: DataManagementFolderContentsItem
): Omit<AccItemGraphQLReturn, 'projectId'> => ({
id: item.id,
name: item.attributes.name ?? item.attributes.displayName
})
export const mapVersionToGql = (
version: DataManagementFolderContentsItemVersion
): AccItemVersionGraphQLReturn => ({
id: version.id,
name: version.attributes.name ?? version.attributes.displayName,
versionNumber: version.attributes.versionNumber,
fileType: version.attributes.fileType
})
@@ -1,4 +1,25 @@
import type { AccSyncItem } from '@/modules/acc/domain/types'
import type { AccSyncItem } from '@/modules/acc/domain/acc/types'
export type WorkspaceIntegrationsGraphQLReturn = {}
export type AccIntegrationGraphQLReturn = {}
export type AccFolderGraphQLReturn = {
id: string
projectId: string
// Resolver will use name provided instead of re-fetching, if possible
name?: string
objectCount?: number
}
export type AccItemGraphQLReturn = {
id: string
name: string
projectId: string
}
export type AccItemVersionGraphQLReturn = {
id: string
name: string
versionNumber: number
fileType?: string
}
export type AccSyncItemGraphQLReturn = AccSyncItem
export type AccSyncItemMutationsGraphQLReturn = {}
@@ -1,4 +1,4 @@
import type { ModelDerivativeServiceDesignManifest } from '@/modules/acc/domain/types'
import type { ModelDerivativeServiceDesignManifest } from '@/modules/acc/domain/acc/types'
export const isReadyForImport = (
manifest: ModelDerivativeServiceDesignManifest
@@ -3,14 +3,15 @@ import type {
CountAccSyncItems,
DeleteAccSyncItemById,
GetAccSyncItemById,
GetAccSyncItemByModelId,
GetAccSyncItemsById,
ListAccSyncItems,
QueryAllAccSyncItems,
UpdateAccSyncItemStatus,
UpsertAccSyncItem
} from '@/modules/acc/domain/operations'
} from '@/modules/acc/domain/acc/operations'
import { executeBatchedSelect } from '@/modules/shared/helpers/dbHelper'
import type { AccSyncItem } from '@/modules/acc/domain/types'
import type { AccSyncItem } from '@/modules/acc/domain/acc/types'
import type { Knex } from 'knex'
import { without } from 'lodash-es'
@@ -30,6 +31,18 @@ export const getAccSyncItemByIdFactory =
)
}
export const getAccSyncItemByModelIdFactory =
(deps: { db: Knex }): GetAccSyncItemByModelId =>
async ({ modelId }) => {
return (
(await tables
.accSyncItems(deps.db)
.select()
.where(AccSyncItems.col.modelId, modelId)
.first()) ?? null
)
}
export const getAccSyncItemsByIdFactory =
(deps: { db: Knex }): GetAccSyncItemsById =>
async ({ ids }) => {
@@ -38,6 +51,17 @@ export const getAccSyncItemsByIdFactory =
return await tables.accSyncItems(deps.db).select().whereIn(AccSyncItems.col.id, ids)
}
export const getAccSyncItemsByModelIdFactory =
(deps: { db: Knex }): GetAccSyncItemsById =>
async ({ ids }) => {
if (!ids.length) return []
return await tables
.accSyncItems(deps.db)
.select()
.whereIn(AccSyncItems.col.modelId, ids)
}
export const upsertAccSyncItemFactory =
(deps: { db: Knex }): UpsertAccSyncItem =>
async (item) => {
+20 -9
View File
@@ -5,7 +5,7 @@ import {
exchangeCodeForTokens,
exchangeRefreshTokenForTokens,
generateCodeVerifier
} from '@/modules/acc/clients/autodesk'
} from '@/modules/acc/clients/autodesk/tokens'
import { sessionMiddlewareFactory } from '@/modules/auth/middleware'
import { corsMiddlewareFactory } from '@/modules/core/configs/cors'
import {
@@ -13,6 +13,7 @@ import {
getAutodeskIntegrationClientSecret,
getServerOrigin
} from '@/modules/shared/helpers/envHelper'
import { logger } from '@/observability/logging'
import type { Express } from 'express'
export const setupAccOidcEndpoints = (app: Express) => {
@@ -24,8 +25,8 @@ export const setupAccOidcEndpoints = (app: Express) => {
corsMiddleware,
sessionMiddleware,
async (req, res) => {
const { projectId } = req.body
req.session.projectId = projectId
const { callbackEndpoint } = req.body
req.session.callbackEndpoint = callbackEndpoint
const { codeVerifier, codeChallenge } = generateCodeVerifier()
req.session.codeVerifier = codeVerifier
@@ -66,7 +67,13 @@ export const setupAccOidcEndpoints = (app: Express) => {
req.session.accTokens = tokens
return res.redirect(`/projects/${req.session.projectId}/acc`)
logger.warn(req.session)
if (!req.session.callbackEndpoint) {
return res.status(500)
}
return res.redirect(req.session.callbackEndpoint)
} catch (error) {
console.error('Token exchange failed:', error)
return res.status(500).send({ error: 'Token exchange failed' })
@@ -75,10 +82,14 @@ export const setupAccOidcEndpoints = (app: Express) => {
)
app.get('/api/v1/acc/auth/status', corsMiddleware, sessionMiddleware, (req, res) => {
if (!req.session.accTokens) {
return res.status(404).send({ error: 'No ACC tokens found' })
try {
if (!req.session.accTokens) {
return res.status(404).send({ error: 'No ACC tokens found' })
}
res.send(req.session.accTokens)
} finally {
req.session.accTokens = undefined // we wanna return it just once
}
res.send(req.session.accTokens)
})
app.post(
@@ -86,7 +97,7 @@ export const setupAccOidcEndpoints = (app: Express) => {
corsMiddleware,
sessionMiddleware,
async (req, res) => {
const { refresh_token } = req.session.accTokens || {}
const { refresh_token } = req.body || {}
if (!refresh_token) {
return res.status(401).json({ error: 'No refresh token found' })
}
@@ -97,7 +108,7 @@ export const setupAccOidcEndpoints = (app: Express) => {
res.json(newTokens)
} catch (error) {
console.error('Error refreshing token:', error)
res.status(500).json({ error: 'Error refreshing token' })
res.status(500).json({ error })
}
}
)
@@ -1,9 +1,7 @@
import {
AccSyncItemStatuses,
ImporterAutomateFunctions
} from '@/modules/acc/domain/constants'
import type { UpdateAccSyncItemStatus } from '@/modules/acc/domain/operations'
import type { AccSyncItem } from '@/modules/acc/domain/types'
import { ImporterAutomateFunctions } from '@/modules/acc/domain/constants'
import { AccSyncItemStatuses } from '@/modules/acc/domain/acc/constants'
import type { UpdateAccSyncItemStatus } from '@/modules/acc/domain/acc/operations'
import type { AccSyncItem } from '@/modules/acc/domain/acc/types'
import {
SyncItemAutomationTriggerError,
SyncItemNotFoundError
@@ -22,7 +20,9 @@ import type { CreateAndStoreAppToken } from '@/modules/core/domain/tokens/operat
import { TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql'
import {
getAutodeskIntegrationClientId,
getAutodeskIntegrationClientSecret
getAutodeskIntegrationClientSecret,
getOdaUserId,
getOdaUserSecret
} from '@/modules/shared/helpers/envHelper'
import { logger } from '@/observability/logging'
import { Scopes } from '@speckle/shared'
@@ -92,8 +92,8 @@ export const triggerSyncItemAutomationFactory =
functionRuns: [
{
id: cryptoRandomString({ length: 15 }),
functionId: ImporterAutomateFunctions.svf2.functionId,
functionReleaseId: ImporterAutomateFunctions.svf2.functionReleaseId,
functionId: ImporterAutomateFunctions.rvt.functionId,
functionReleaseId: ImporterAutomateFunctions.rvt.functionReleaseId,
status: 'pending' as const,
elapsed: 0,
results: null,
@@ -148,9 +148,12 @@ export const triggerSyncItemAutomationFactory =
modelId: syncItem.modelId,
versionUrn: syncItem.accFileVersionUrn,
viewName: syncItem.accFileViewName ?? null,
autodeskProjectId: syncItem.accProjectId.replace('b.', ''),
autodeskRegion: syncItem.accRegion === 'EMEA' ? 1 : 0,
autodeskClientId: getAutodeskIntegrationClientId(),
autodeskClientSecret: getAutodeskIntegrationClientSecret()
autodeskClientSecret: getAutodeskIntegrationClientSecret(),
odaUserId: getOdaUserId(),
odaUserSignature: getOdaUserSecret()
}
})),
manifests: [
+2 -22
View File
@@ -4,9 +4,6 @@ import {
} from '@/modules/acc/repositories/accSyncItems'
import type { ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations'
import { db } from '@/db/knex'
import { getManifestByUrn, getToken } from '@/modules/acc/clients/autodesk'
import { isReadyForImport } from '@/modules/acc/helpers/svfUtils'
import type { Logger } from '@/observability/logging'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import {
getAutomationFactory,
@@ -22,7 +19,7 @@ import {
} from '@/modules/core/repositories/tokens'
import { createAppTokenFactory } from '@/modules/core/services/tokens'
import { TIME_MS } from '@speckle/shared'
import { AccSyncItemStatuses, type AccRegion } from '@/modules/acc/domain/constants'
import { AccSyncItemStatuses } from '@/modules/acc/domain/acc/constants'
import { triggerSyncItemAutomationFactory } from '@/modules/acc/services/automate'
const queryAllAccSyncItems = queryAllAccSyncItemsFactory({ db })
@@ -30,28 +27,11 @@ const queryAllAccSyncItems = queryAllAccSyncItemsFactory({ db })
export const schedulePendingSyncItemsCheck = (deps: {
scheduleExecution: ScheduleExecution
}) => {
const callback = async (_now: Date, { logger }: { logger: Logger }) => {
const tokenData = await getToken()
const callback = async () => {
for await (const items of queryAllAccSyncItems({
filter: { status: AccSyncItemStatuses.pending }
})) {
for (const syncItem of items) {
const manifest = await getManifestByUrn(
{
urn: syncItem.accFileVersionUrn,
region: syncItem.accRegion as AccRegion
},
{ token: tokenData.access_token }
)
const isReady = isReadyForImport(manifest)
logger.info(
{ isReady, syncItem, manifest },
'Checking pending sync item {syncItem.id} for import readiness.'
)
if (!isReady) continue
const projectDb = await getProjectDbClient({ projectId: syncItem.projectId })
await triggerSyncItemAutomationFactory({
@@ -1,23 +1,19 @@
import {
getManifestByUrn,
getToken,
tryRegisterAccWebhook
} from '@/modules/acc/clients/autodesk'
import {
AccSyncItemStatuses,
ImporterAutomateFunctions
} from '@/modules/acc/domain/constants'
import { AccSyncItemEvents } from '@/modules/acc/domain/events'
import { isReadyForImport } from '@/modules/acc/helpers/svfUtils'
import { tryRegisterAccWebhook } from '@/modules/acc/clients/autodesk/acc'
import { ImporterAutomateFunctions } from '@/modules/acc/domain/constants'
import { AccSyncItemStatuses } from '@/modules/acc/domain/acc/constants'
import { AccSyncItemEvents } from '@/modules/acc/domain/acc/events'
import type {
CountAccSyncItems,
DeleteAccSyncItemById,
GetAccSyncItemById,
ListAccSyncItems,
UpsertAccSyncItem
} from '@/modules/acc/domain/operations'
import type { AccSyncItem } from '@/modules/acc/domain/types'
import { SyncItemNotFoundError } from '@/modules/acc/errors/acc'
} from '@/modules/acc/domain/acc/operations'
import type { AccSyncItem } from '@/modules/acc/domain/acc/types'
import {
SyncItemNotFoundError,
SyncItemUnsupportedFileExtensionError
} from '@/modules/acc/errors/acc'
import type { TriggerSyncItemAutomation } from '@/modules/acc/services/automate'
import type {
CreateAutomation,
@@ -82,8 +78,8 @@ export const createAccSyncItemFactory =
automationId: automation.id,
functions: [
{
functionId: ImporterAutomateFunctions.svf2.functionId,
functionReleaseId: ImporterAutomateFunctions.svf2.functionReleaseId
functionId: ImporterAutomateFunctions.rvt.functionId,
functionReleaseId: ImporterAutomateFunctions.rvt.functionReleaseId
}
],
triggerDefinitions: {
@@ -114,7 +110,6 @@ export const createAccSyncItemFactory =
await deps.upsertAccSyncItem(newSyncItem)
// TODO ACC: somehow i could not managed to get subsriptions work, doing stupid timeout refetch in FE after create/delete/update
// Once we have it properly TODO ogu: fix it on FE
await deps.eventEmit({
eventName: AccSyncItemEvents.Created,
@@ -125,19 +120,14 @@ export const createAccSyncItemFactory =
})
// Import new sync item immediately, if possible
const tokenData = await getToken()
const manifest = await getManifestByUrn(
{
urn: newSyncItem.accFileVersionUrn,
region: newSyncItem.accRegion
},
{ token: tokenData.access_token }
)
const isReady = isReadyForImport(manifest)
if (!isReady) return newSyncItem
return await deps.triggerSyncItemAutomation({ id: newSyncItem.id })
switch (newSyncItem.accFileExtension.toLowerCase()) {
case 'rvt': {
return await deps.triggerSyncItemAutomation({ id: newSyncItem.id })
}
default: {
throw new SyncItemUnsupportedFileExtensionError(newSyncItem.accFileExtension)
}
}
}
export type GetPaginatedAccSyncItems = (params: {
@@ -1,8 +1,8 @@
import { AccSyncItemStatuses } from '@/modules/acc/domain/constants'
import { AccSyncItemStatuses } from '@/modules/acc/domain/acc/constants'
import type {
QueryAllAccSyncItems,
UpsertAccSyncItem
} from '@/modules/acc/domain/operations'
} from '@/modules/acc/domain/acc/operations'
import { logger } from '@/observability/logging'
type OnVersionAdded = (params: {
@@ -213,7 +213,7 @@ export const localAuthRestApi = (params: { express: Express }) => {
const user = await authCheck({ token })
expect(user).to.be.ok
expect(user.email).to.equal(params.email)
expect(user.email.toLowerCase()).to.equal(params.email.toLowerCase())
return user
}

Some files were not shown because too many files have changed in this diff Show More