Merge branch 'main' into andrew/web-4112-missing-border-in-models-tab

This commit is contained in:
andrewwallacespeckle
2025-09-18 15:13:22 +01:00
42 changed files with 1418 additions and 220 deletions
+2
View File
@@ -1,5 +1,7 @@
[tools]
node = "22.19.0"
python = "3.12.11"
[env]
SHARP_IGNORE_GLOBAL_LIBVIPS = 1
FF_DASHBOARDS_MODULE_ENABLED=true
@@ -66,7 +66,7 @@
:active="isActive(dashboardsRoute(activeWorkspaceSlug))"
>
<template #icon>
<IconProjects class="size-4 text-foreground-2" />
<LayoutDashboard class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
@@ -81,20 +81,6 @@
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
<div v-if="isWorkspacesEnabled">
<LayoutSidebarMenuGroupItem
label="Getting started"
@click="openExplainerVideoDialog"
>
<template #icon>
<IconPlay class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
<WorkspaceExplainerVideoDialog
v-model:open="showExplainerVideoDialog"
/>
</div>
</LayoutSidebarMenuGroup>
<LayoutSidebarMenuGroup title="Resources" collapsible>
@@ -154,6 +140,20 @@
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
<div v-if="isWorkspacesEnabled">
<LayoutSidebarMenuGroupItem
label="Getting started"
@click="openExplainerVideoDialog"
>
<template #icon>
<IconPlay class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
<WorkspaceExplainerVideoDialog
v-model:open="showExplainerVideoDialog"
/>
</div>
</LayoutSidebarMenuGroup>
</div>
</LayoutSidebarMenu>
@@ -189,6 +189,7 @@ import { graphql } from '~/lib/common/generated/gql'
import { useQuery } from '@vue/apollo-composable'
import dayjs from 'dayjs'
import { useActiveUserMeta } from '~/lib/user/composables/meta'
import { LayoutDashboard } from 'lucide-vue-next'
const dashboardSidebarQuery = graphql(`
query DashboardSidebar {
@@ -1,148 +0,0 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<div>
<Menu v-if="canShare || urlToken" as="div" class="flex items-center relative">
<MenuButton :id="menuButtonId" v-slot="{ open }" as="div">
<FormButton
color="outline"
class="hidden sm:flex"
size="sm"
:icon-right="open ? ChevronUpIcon : ChevronDownIcon"
>
Share
</FormButton>
</MenuButton>
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute z-50 flex flex-col gap-1 right-0 top-7 min-w-max w-full sm:w-32 py-1 origin-top-right bg-foundation outline outline-1 outline-primary-muted rounded-md shadow-lg overflow-hidden mt-1"
>
<MenuItem v-slot="{ active }">
<div
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
@click="handleCopyLink"
@keypress="keyboardClick(handleCopyLink)"
>
Copy link
</div>
</MenuItem>
<MenuItem v-slot="{ active }">
<div
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
@click="handleCopyEmbedLink"
@keypress="keyboardClick(handleCopyEmbedLink)"
>
Copy embed link
</div>
</MenuItem>
</MenuItems>
</Transition>
</Menu>
</div>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/20/solid'
import { keyboardClick } from '@speckle/ui-components'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { graphql } from '~~/lib/common/generated/gql'
import { useQuery, useMutation } from '@vue/apollo-composable'
import {
convertThrowIntoFetchResult,
getFirstErrorMessage
} from '~~/lib/common/helpers/graphql'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
const dashboardsSharePermissionsQuery = graphql(`
query DashboardsSharePermissions($id: String!) {
dashboard(id: $id) {
id
permissions {
canCreateToken {
...FullPermissionCheckResult
}
}
}
}
`)
const dashboardsShareTokenMutation = graphql(`
mutation DashboardsShareToken($dashboardId: String!) {
dashboardMutations {
createToken(dashboardId: $dashboardId) {
token
}
}
}
`)
const props = defineProps<{
id: MaybeNullOrUndefined<string>
}>()
const { copy } = useClipboard()
const menuButtonId = useId()
const { token: urlToken } = useRoute().query
const route = useRoute()
const { result } = useQuery(dashboardsSharePermissionsQuery, () => ({
id: props.id || ''
}))
const { mutate: createToken } = useMutation(dashboardsShareTokenMutation)
const { triggerNotification } = useGlobalToast()
const canShare = computed(
() => result.value?.dashboard?.permissions?.canCreateToken?.authorized
)
const handleCopyLink = async () => {
if (!urlToken && canShare.value) {
const result = await createToken({ dashboardId: props.id || '' })
if (result?.data?.dashboardMutations?.createToken?.token) {
const token = result.data.dashboardMutations.createToken.token
const url = `${window.location.origin}${route.path}?token=${token}`
copy(url, { successMessage: 'Link copied to clipboard' })
}
} else {
const url = `${window.location.origin}${route.path}`
copy(url, { successMessage: 'Link copied to clipboard' })
}
}
const handleCopyEmbedLink = async () => {
if (!urlToken && canShare.value) {
const result = await createToken({ dashboardId: props.id || '' }).catch(
convertThrowIntoFetchResult
)
if (result?.data?.dashboardMutations?.createToken?.token) {
const token = result.data.dashboardMutations.createToken.token
const url = `${window.location.origin}${route.path}?token=${token}&embed=true`
copy(url, { successMessage: 'Embed link copied to clipboard' })
} else {
const err = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Dashboard creation failed',
description: err
})
}
} else {
const url = `${window.location.origin}${route.path}?embed=true`
copy(url, { successMessage: 'Embed link copied to clipboard' })
}
}
</script>
@@ -0,0 +1,151 @@
<template>
<LayoutDialog v-model:open="open" max-width="sm">
<template #header>Share dashboard</template>
<h4 class="text-body-xs font-medium text-foreground mb-1">Dashboard URL</h4>
<FormClipboardInput :value="dashboardUrl" />
<div v-if="canCreateToken">
<hr class="my-4 border-outline-3" />
<div class="flex items-center justify-between">
<div>
<p class="text-body-xs font-medium text-foreground">Enable public access</p>
<p class="text-body-2xs text-foreground-2">Anyone with the link can view</p>
</div>
<FormSwitch v-model="enablePublicUrl" name="isPublic" :show-label="false" />
</div>
<FormClipboardInput v-if="enablePublicUrl" class="mt-3" :value="shareUrl" />
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import { dashboardRoute } from '~~/lib/common/helpers/route'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { graphql } from '~~/lib/common/generated/gql'
import { useQuery, useMutation } from '@vue/apollo-composable'
const dashboardsDialogSharePermissionsQuery = graphql(`
query DashboardsSharDialogPermissions($id: String!) {
dashboard(id: $id) {
id
shareLink {
id
content
revoked
}
permissions {
canCreateToken {
...FullPermissionCheckResult
}
}
}
}
`)
const dashboardsDialogShareTokenMutation = graphql(`
mutation DashboardsShareToken($dashboardId: String!) {
dashboardMutations {
share(dashboardId: $dashboardId) {
id
revoked
content
}
}
}
`)
const dashboardsDialogShareEnableTokenMutation = graphql(`
mutation DashboardsShareEnableToken($input: DashboardShareInput!) {
dashboardMutations {
enableShare(input: $input) {
id
revoked
content
}
}
}
`)
const dashboardsDialogShareDisableTokenMutation = graphql(`
mutation DashboardsShareDisableToken($input: DashboardShareInput!) {
dashboardMutations {
disableShare(input: $input) {
id
revoked
content
}
}
}
`)
const props = defineProps<{
workspaceSlug: MaybeNullOrUndefined<string>
dashboardId: MaybeNullOrUndefined<string>
}>()
const open = defineModel<boolean>('open', { required: true })
const { result, refetch } = useQuery(dashboardsDialogSharePermissionsQuery, () => ({
id: props.dashboardId || ''
}))
const { mutate: createToken } = useMutation(dashboardsDialogShareTokenMutation)
const { mutate: disableToken } = useMutation(dashboardsDialogShareDisableTokenMutation)
const { mutate: enableToken } = useMutation(dashboardsDialogShareEnableTokenMutation)
const isRevoked = computed(() => result.value?.dashboard?.shareLink?.revoked)
const shareLink = computed(() => result.value?.dashboard?.shareLink)
const shareUrl = computed(() => {
if (!shareLink.value?.id || !props.workspaceSlug || !props.dashboardId) return ''
const url = new URL(
dashboardRoute(props.workspaceSlug, props.dashboardId),
window.location.toString()
)
url.searchParams.set('dashboardToken', shareLink.value.content)
return url.toString()
})
const canCreateToken = computed(
() => result.value?.dashboard?.permissions?.canCreateToken?.authorized
)
const enablePublicUrl = computed({
get: () => !isRevoked.value && !!shareLink.value?.id,
set: (value: boolean) => {
onEnablePublicUrl(value)
}
})
const dashboardUrl = computed(() => {
if (!props.workspaceSlug || !props.dashboardId) return ''
return new URL(
dashboardRoute(props.workspaceSlug, props.dashboardId),
window.location.toString()
).toString()
})
const onEnablePublicUrl = async (value: boolean) => {
if (!props.dashboardId) return
if (value) {
// If enabling and no share link exists, create one first
if (!shareLink.value?.id) {
await createToken({ dashboardId: props.dashboardId })
}
// Enable the share link
if (shareLink.value?.id) {
await enableToken({
input: { dashboardId: props.dashboardId, shareId: shareLink.value.id }
})
}
} else {
if (shareLink.value?.id) {
await disableToken({
input: { dashboardId: props.dashboardId, shareId: shareLink.value.id }
})
}
}
await refetch()
}
</script>
@@ -0,0 +1,53 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<div class="flex items-center relative">
<FormButton
color="outline"
class="hidden sm:flex"
size="sm"
:disabled="!canCreateToken"
@click="shareDialogOpen = true"
>
Share
</FormButton>
<DashboardsShareDialog
v-model:open="shareDialogOpen"
:workspace-slug="workspaceSlug"
:dashboard-id="id"
/>
</div>
</template>
<script setup lang="ts">
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { graphql } from '~~/lib/common/generated/gql'
import { useQuery } from '@vue/apollo-composable'
const dashboardsSharePermissionsQuery = graphql(`
query DashboardsSharePermissions($id: String!) {
dashboard(id: $id) {
id
permissions {
canCreateToken {
...FullPermissionCheckResult
}
}
}
}
`)
const props = defineProps<{
id: MaybeNullOrUndefined<string>
workspaceSlug: MaybeNullOrUndefined<string>
}>()
const { result } = useQuery(dashboardsSharePermissionsQuery, {
id: props.id as string
})
const canCreateToken = computed(
() => result.value?.dashboard?.permissions?.canCreateToken?.authorized
)
const shareDialogOpen = ref(false)
</script>
@@ -2,13 +2,35 @@
<aside
class="bg-foundation h-48 md:h-screen w-full md:w-64 xl:w-80 border-t md:border-t-0 md:border-l border-outline-3 py-5 px-4"
>
<div class="flex items-center justify-between">
<!-- <FormButton :icon-left="LucideCross" /> -->
<div class="hidden md:flex items-center justify-end space-x-0.5">
<FormButton
v-if="canUpdate"
:icon-left="LucidePencilLine"
color="subtle"
hide-text
@click="isSlideEditDialogOpen = true"
/>
<FormButton
:icon-left="LucideX"
color="subtle"
hide-text
@click="$emit('close')"
/>
</div>
<section class="pt-2 flex flex-col gap-4">
<h1 v-if="currentSlide?.name" class="text-xl font-medium text-foreground px-2">
{{ currentSlide?.name }}
</h1>
<div class="flex items-center justify-between gap-x-2">
<h1 v-if="currentSlide?.name" class="text-xl font-medium text-foreground px-2">
{{ currentSlide?.name }}
</h1>
<FormButton
v-if="canUpdate"
:icon-left="LucidePencilLine"
color="subtle"
hide-text
class="md:hidden"
@click="isSlideEditDialogOpen = true"
/>
</div>
<p
v-if="currentSlide?.description"
@@ -17,23 +39,49 @@
{{ currentSlide?.description }}
</p>
</section>
<PresentationSlideEditDialog
v-model:open="isSlideEditDialogOpen"
:slide="currentSlide"
/>
</aside>
</template>
<script setup lang="ts">
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
import { graphql } from '~~/lib/common/generated/gql'
// import { LucideX } from 'lucide-vue-next'
import { LucideX, LucidePencilLine } from 'lucide-vue-next'
defineEmits<{
(e: 'close'): void
}>()
graphql(`
fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {
id
permissions {
canUpdate {
...FullPermissionCheckResult
}
}
}
fragment PresentationInfoSidebar_SavedView on SavedView {
id
...PresentationSlideEditDialog_SavedView
name
description
}
`)
const {
ui: { slide: currentSlide }
ui: { slide: currentSlide },
response: { presentation }
} = useInjectedPresentationState()
const isSlideEditDialogOpen = ref(false)
const canUpdate = computed(() => {
return presentation.value?.permissions?.canUpdate
})
</script>
@@ -43,7 +43,11 @@
/>
</div>
<PresentationInfoSidebar v-if="isInfoSidebarOpen" class="flex-shrink-0 z-20" />
<PresentationInfoSidebar
v-if="isInfoSidebarOpen"
class="flex-shrink-0 z-20"
@close="isInfoSidebarOpen = false"
/>
<PresentationControls
v-if="!hideUi"
@@ -0,0 +1,97 @@
<template>
<LayoutDialog v-model:open="open" max-width="sm" :buttons="dialogButtons">
<template #header>Edit slide</template>
<form @submit="onSubmit">
<div class="flex flex-col gap-2">
<img
:src="slide?.screenshot"
:alt="slide?.name"
class="w-full object-cover rounded-lg border border-outline-3"
/>
<FormTextInput
v-model="name"
name="name"
label="Name"
color="foundation"
:rules="[isRequired]"
/>
<FormTextArea
v-model="description"
name="description"
label="Description"
color="foundation"
placeholder="Add a description..."
/>
</div>
</form>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
import { graphql } from '~~/lib/common/generated/gql'
import type { PresentationSlideEditDialog_SavedViewFragment } from '~/lib/common/generated/gql/graphql'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { isRequired } from '~/lib/common/helpers/validation'
import { useUpdatePresentationSlide } from '~/lib/presentations/composables/mangament'
import { useForm } from 'vee-validate'
graphql(`
fragment PresentationSlideEditDialog_SavedView on SavedView {
id
projectId
name
description
screenshot
}
`)
const props = defineProps<{
slide: MaybeNullOrUndefined<PresentationSlideEditDialog_SavedViewFragment>
}>()
const open = defineModel<boolean>('open', { required: true })
const { mutate: updateSlide, loading } = useUpdatePresentationSlide()
const { handleSubmit } = useForm()
const name = ref<string>('')
const description = ref<string>('')
const onSubmit = handleSubmit(async () => {
if (!props.slide?.id) return
await updateSlide({
id: props.slide.id,
projectId: props.slide.projectId,
name: name.value,
description: description.value
})
open.value = false
})
const dialogButtons = computed((): LayoutDialogButton[] => {
return [
{
text: 'Cancel',
props: { color: 'outline' },
onClick: () => {
open.value = false
}
},
{
text: 'Save',
props: { loading: loading.value },
onClick: onSubmit
}
]
})
watch(
() => open.value,
() => {
name.value = props.slide?.name || ''
description.value = props.slide?.description || ''
}
)
</script>
@@ -46,8 +46,11 @@ type Documents = {
"\n fragment DashboardsCard_Dashboard on Dashboard {\n id\n name\n createdAt\n workspace {\n id\n }\n createdBy {\n id\n name\n avatar\n }\n permissions {\n canDelete {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.DashboardsCard_DashboardFragmentDoc,
"\n fragment DashboardsEditDialog_Dashboard on Dashboard {\n id\n name\n workspace {\n id\n }\n }\n": typeof types.DashboardsEditDialog_DashboardFragmentDoc,
"\n query DashboardsListCanCreateDashboards($slug: String!) {\n workspaceBySlug(slug: $slug) {\n permissions {\n canCreateDashboards {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.DashboardsListCanCreateDashboardsDocument,
"\n query DashboardsSharDialogPermissions($id: String!) {\n dashboard(id: $id) {\n id\n shareLink {\n id\n content\n revoked\n }\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.DashboardsSharDialogPermissionsDocument,
"\n mutation DashboardsShareToken($dashboardId: String!) {\n dashboardMutations {\n share(dashboardId: $dashboardId) {\n id\n revoked\n content\n }\n }\n }\n": typeof types.DashboardsShareTokenDocument,
"\n mutation DashboardsShareEnableToken($input: DashboardShareInput!) {\n dashboardMutations {\n enableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n": typeof types.DashboardsShareEnableTokenDocument,
"\n mutation DashboardsShareDisableToken($input: DashboardShareInput!) {\n dashboardMutations {\n disableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n": typeof types.DashboardsShareDisableTokenDocument,
"\n query DashboardsSharePermissions($id: String!) {\n dashboard(id: $id) {\n id\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.DashboardsSharePermissionsDocument,
"\n mutation DashboardsShareToken($dashboardId: String!) {\n dashboardMutations {\n createToken(dashboardId: $dashboardId) {\n token\n }\n }\n }\n": typeof types.DashboardsShareTokenDocument,
"\n fragment FormSelectModels_Model on Model {\n id\n name\n }\n": typeof types.FormSelectModels_ModelFragmentDoc,
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": typeof types.FormSelectProjects_ProjectFragmentDoc,
"\n fragment FormSelectSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n isUngroupedViewsGroup\n }\n": typeof types.FormSelectSavedViewGroup_SavedViewGroupFragmentDoc,
@@ -67,8 +70,9 @@ type Documents = {
"\n query InviteDialogProjectRowProjectCollaborators(\n $projectId: String!\n $filter: InvitableCollaboratorsFilter\n ) {\n project(id: $projectId) {\n id\n invitableCollaborators(filter: $filter) {\n items {\n ...InviteProjectItem_WorkspaceCollaborator\n }\n }\n }\n }\n": typeof types.InviteDialogProjectRowProjectCollaboratorsDocument,
"\n fragment InviteDialogSharedSelectUsers_Workspace on Workspace {\n id\n slug\n defaultSeatType\n }\n": typeof types.InviteDialogSharedSelectUsers_WorkspaceFragmentDoc,
"\n fragment PresentationHeader_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": typeof types.PresentationHeader_SavedViewGroupFragmentDoc,
"\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n name\n description\n }\n": typeof types.PresentationInfoSidebar_SavedViewFragmentDoc,
"\n fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n ...PresentationSlideEditDialog_SavedView\n name\n description\n }\n": typeof types.PresentationInfoSidebar_SavedViewGroupFragmentDoc,
"\n fragment PresentationLeftSidebar_Workspace on Workspace {\n id\n name\n logo\n }\n": typeof types.PresentationLeftSidebar_WorkspaceFragmentDoc,
"\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n screenshot\n }\n": typeof types.PresentationSlideEditDialog_SavedViewFragmentDoc,
"\n fragment PresentationSlideListSlide_SavedView on SavedView {\n id\n name\n screenshot\n }\n": typeof types.PresentationSlideListSlide_SavedViewFragmentDoc,
"\n fragment PresentationSlideList_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n items {\n id\n ...PresentationSlideListSlide_SavedView\n }\n }\n }\n": typeof types.PresentationSlideList_SavedViewGroupFragmentDoc,
"\n fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n totalCount\n items {\n id\n resourceIdString\n }\n }\n }\n": typeof types.PresentationViewerPageWrapper_SavedViewGroupFragmentDoc,
@@ -267,6 +271,7 @@ type Documents = {
"\n mutation CreateDashboard(\n $workspace: WorkspaceIdentifier!\n $input: DashboardCreateInput!\n ) {\n dashboardMutations {\n create(workspace: $workspace, input: $input) {\n id\n workspace {\n id\n }\n }\n }\n }\n": typeof types.CreateDashboardDocument,
"\n mutation UpdateDashboard($input: DashboardUpdateInput!) {\n dashboardMutations {\n update(input: $input) {\n id\n name\n }\n }\n }\n": typeof types.UpdateDashboardDocument,
"\n mutation DeleteDashboard($id: String!) {\n dashboardMutations {\n delete(id: $id)\n }\n }\n": typeof types.DeleteDashboardDocument,
"\n query DashboardAccessCheck($id: String!) {\n dashboard(id: $id) {\n id\n }\n }\n": typeof types.DashboardAccessCheckDocument,
"\n query Dashboard($id: String!) {\n dashboard(id: $id) {\n id\n ...WorkspaceDashboards_Dashboard\n }\n }\n": typeof types.DashboardDocument,
"\n query WorkspaceDashboards($workspaceSlug: String!, $cursor: String) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n dashboards(cursor: $cursor) {\n cursor\n items {\n id\n ...DashboardsCard_Dashboard\n }\n }\n }\n }\n": typeof types.WorkspaceDashboardsDocument,
"\n mutation DeleteAccessToken($token: String!) {\n apiTokenRevoke(token: $token)\n }\n": typeof types.DeleteAccessTokenDocument,
@@ -290,7 +295,8 @@ type Documents = {
"\n query NavigationWorkspaceList(\n $workspaceFilter: UserWorkspacesFilter\n $projectFilter: UserProjectsFilter\n ) {\n activeUser {\n id\n workspaces(filter: $workspaceFilter) {\n items {\n id\n ...HeaderWorkspaceSwitcherWorkspaceListItem_Workspace\n }\n }\n projects(filter: $projectFilter) {\n totalCount\n }\n }\n }\n": typeof types.NavigationWorkspaceListDocument,
"\n query NavigationProjectInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n }\n }\n": typeof types.NavigationProjectInvitesDocument,
"\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n": typeof types.NavigationWorkspaceInvitesDocument,
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n ...PresentationLeftSidebar_Workspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n": typeof types.ProjectPresentationPageDocument,
"\n mutation UpdatePresentationSlide($input: UpdateSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n updateView(input: $input) {\n id\n name\n description\n }\n }\n }\n }\n": typeof types.UpdatePresentationSlideDocument,
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n ...PresentationLeftSidebar_Workspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n ...PresentationInfoSidebar_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n": typeof types.ProjectPresentationPageDocument,
"\n fragment UseCopyModelLink_Model on Model {\n id\n projectId\n ...GetModelItemRoute_Model\n }\n": typeof types.UseCopyModelLink_ModelFragmentDoc,
"\n fragment UseCanCreatePersonalProject_User on User {\n permissions {\n canCreatePersonalProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseCanCreatePersonalProject_UserFragmentDoc,
"\n fragment UseCanCreateWorkspace_User on User {\n permissions {\n canCreateWorkspace {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseCanCreateWorkspace_UserFragmentDoc,
@@ -574,8 +580,11 @@ const documents: Documents = {
"\n fragment DashboardsCard_Dashboard on Dashboard {\n id\n name\n createdAt\n workspace {\n id\n }\n createdBy {\n id\n name\n avatar\n }\n permissions {\n canDelete {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.DashboardsCard_DashboardFragmentDoc,
"\n fragment DashboardsEditDialog_Dashboard on Dashboard {\n id\n name\n workspace {\n id\n }\n }\n": types.DashboardsEditDialog_DashboardFragmentDoc,
"\n query DashboardsListCanCreateDashboards($slug: String!) {\n workspaceBySlug(slug: $slug) {\n permissions {\n canCreateDashboards {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.DashboardsListCanCreateDashboardsDocument,
"\n query DashboardsSharDialogPermissions($id: String!) {\n dashboard(id: $id) {\n id\n shareLink {\n id\n content\n revoked\n }\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.DashboardsSharDialogPermissionsDocument,
"\n mutation DashboardsShareToken($dashboardId: String!) {\n dashboardMutations {\n share(dashboardId: $dashboardId) {\n id\n revoked\n content\n }\n }\n }\n": types.DashboardsShareTokenDocument,
"\n mutation DashboardsShareEnableToken($input: DashboardShareInput!) {\n dashboardMutations {\n enableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n": types.DashboardsShareEnableTokenDocument,
"\n mutation DashboardsShareDisableToken($input: DashboardShareInput!) {\n dashboardMutations {\n disableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n": types.DashboardsShareDisableTokenDocument,
"\n query DashboardsSharePermissions($id: String!) {\n dashboard(id: $id) {\n id\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.DashboardsSharePermissionsDocument,
"\n mutation DashboardsShareToken($dashboardId: String!) {\n dashboardMutations {\n createToken(dashboardId: $dashboardId) {\n token\n }\n }\n }\n": types.DashboardsShareTokenDocument,
"\n fragment FormSelectModels_Model on Model {\n id\n name\n }\n": types.FormSelectModels_ModelFragmentDoc,
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": types.FormSelectProjects_ProjectFragmentDoc,
"\n fragment FormSelectSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n isUngroupedViewsGroup\n }\n": types.FormSelectSavedViewGroup_SavedViewGroupFragmentDoc,
@@ -595,8 +604,9 @@ const documents: Documents = {
"\n query InviteDialogProjectRowProjectCollaborators(\n $projectId: String!\n $filter: InvitableCollaboratorsFilter\n ) {\n project(id: $projectId) {\n id\n invitableCollaborators(filter: $filter) {\n items {\n ...InviteProjectItem_WorkspaceCollaborator\n }\n }\n }\n }\n": types.InviteDialogProjectRowProjectCollaboratorsDocument,
"\n fragment InviteDialogSharedSelectUsers_Workspace on Workspace {\n id\n slug\n defaultSeatType\n }\n": types.InviteDialogSharedSelectUsers_WorkspaceFragmentDoc,
"\n fragment PresentationHeader_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": types.PresentationHeader_SavedViewGroupFragmentDoc,
"\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n name\n description\n }\n": types.PresentationInfoSidebar_SavedViewFragmentDoc,
"\n fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n ...PresentationSlideEditDialog_SavedView\n name\n description\n }\n": types.PresentationInfoSidebar_SavedViewGroupFragmentDoc,
"\n fragment PresentationLeftSidebar_Workspace on Workspace {\n id\n name\n logo\n }\n": types.PresentationLeftSidebar_WorkspaceFragmentDoc,
"\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n screenshot\n }\n": types.PresentationSlideEditDialog_SavedViewFragmentDoc,
"\n fragment PresentationSlideListSlide_SavedView on SavedView {\n id\n name\n screenshot\n }\n": types.PresentationSlideListSlide_SavedViewFragmentDoc,
"\n fragment PresentationSlideList_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n items {\n id\n ...PresentationSlideListSlide_SavedView\n }\n }\n }\n": types.PresentationSlideList_SavedViewGroupFragmentDoc,
"\n fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n totalCount\n items {\n id\n resourceIdString\n }\n }\n }\n": types.PresentationViewerPageWrapper_SavedViewGroupFragmentDoc,
@@ -795,6 +805,7 @@ const documents: Documents = {
"\n mutation CreateDashboard(\n $workspace: WorkspaceIdentifier!\n $input: DashboardCreateInput!\n ) {\n dashboardMutations {\n create(workspace: $workspace, input: $input) {\n id\n workspace {\n id\n }\n }\n }\n }\n": types.CreateDashboardDocument,
"\n mutation UpdateDashboard($input: DashboardUpdateInput!) {\n dashboardMutations {\n update(input: $input) {\n id\n name\n }\n }\n }\n": types.UpdateDashboardDocument,
"\n mutation DeleteDashboard($id: String!) {\n dashboardMutations {\n delete(id: $id)\n }\n }\n": types.DeleteDashboardDocument,
"\n query DashboardAccessCheck($id: String!) {\n dashboard(id: $id) {\n id\n }\n }\n": types.DashboardAccessCheckDocument,
"\n query Dashboard($id: String!) {\n dashboard(id: $id) {\n id\n ...WorkspaceDashboards_Dashboard\n }\n }\n": types.DashboardDocument,
"\n query WorkspaceDashboards($workspaceSlug: String!, $cursor: String) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n dashboards(cursor: $cursor) {\n cursor\n items {\n id\n ...DashboardsCard_Dashboard\n }\n }\n }\n }\n": types.WorkspaceDashboardsDocument,
"\n mutation DeleteAccessToken($token: String!) {\n apiTokenRevoke(token: $token)\n }\n": types.DeleteAccessTokenDocument,
@@ -818,7 +829,8 @@ const documents: Documents = {
"\n query NavigationWorkspaceList(\n $workspaceFilter: UserWorkspacesFilter\n $projectFilter: UserProjectsFilter\n ) {\n activeUser {\n id\n workspaces(filter: $workspaceFilter) {\n items {\n id\n ...HeaderWorkspaceSwitcherWorkspaceListItem_Workspace\n }\n }\n projects(filter: $projectFilter) {\n totalCount\n }\n }\n }\n": types.NavigationWorkspaceListDocument,
"\n query NavigationProjectInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n }\n }\n": types.NavigationProjectInvitesDocument,
"\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n": types.NavigationWorkspaceInvitesDocument,
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n ...PresentationLeftSidebar_Workspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n": types.ProjectPresentationPageDocument,
"\n mutation UpdatePresentationSlide($input: UpdateSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n updateView(input: $input) {\n id\n name\n description\n }\n }\n }\n }\n": types.UpdatePresentationSlideDocument,
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n ...PresentationLeftSidebar_Workspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n ...PresentationInfoSidebar_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n": types.ProjectPresentationPageDocument,
"\n fragment UseCopyModelLink_Model on Model {\n id\n projectId\n ...GetModelItemRoute_Model\n }\n": types.UseCopyModelLink_ModelFragmentDoc,
"\n fragment UseCanCreatePersonalProject_User on User {\n permissions {\n canCreatePersonalProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseCanCreatePersonalProject_UserFragmentDoc,
"\n fragment UseCanCreateWorkspace_User on User {\n permissions {\n canCreateWorkspace {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseCanCreateWorkspace_UserFragmentDoc,
@@ -1215,11 +1227,23 @@ export function graphql(source: "\n query DashboardsListCanCreateDashboards($sl
/**
* 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 DashboardsSharePermissions($id: String!) {\n dashboard(id: $id) {\n id\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"): (typeof documents)["\n query DashboardsSharePermissions($id: String!) {\n dashboard(id: $id) {\n id\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"];
export function graphql(source: "\n query DashboardsSharDialogPermissions($id: String!) {\n dashboard(id: $id) {\n id\n shareLink {\n id\n content\n revoked\n }\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"): (typeof documents)["\n query DashboardsSharDialogPermissions($id: String!) {\n dashboard(id: $id) {\n id\n shareLink {\n id\n content\n revoked\n }\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\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 mutation DashboardsShareToken($dashboardId: String!) {\n dashboardMutations {\n createToken(dashboardId: $dashboardId) {\n token\n }\n }\n }\n"): (typeof documents)["\n mutation DashboardsShareToken($dashboardId: String!) {\n dashboardMutations {\n createToken(dashboardId: $dashboardId) {\n token\n }\n }\n }\n"];
export function graphql(source: "\n mutation DashboardsShareToken($dashboardId: String!) {\n dashboardMutations {\n share(dashboardId: $dashboardId) {\n id\n revoked\n content\n }\n }\n }\n"): (typeof documents)["\n mutation DashboardsShareToken($dashboardId: String!) {\n dashboardMutations {\n share(dashboardId: $dashboardId) {\n id\n revoked\n content\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 mutation DashboardsShareEnableToken($input: DashboardShareInput!) {\n dashboardMutations {\n enableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n"): (typeof documents)["\n mutation DashboardsShareEnableToken($input: DashboardShareInput!) {\n dashboardMutations {\n enableShare(input: $input) {\n id\n revoked\n content\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 mutation DashboardsShareDisableToken($input: DashboardShareInput!) {\n dashboardMutations {\n disableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n"): (typeof documents)["\n mutation DashboardsShareDisableToken($input: DashboardShareInput!) {\n dashboardMutations {\n disableShare(input: $input) {\n id\n revoked\n content\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 DashboardsSharePermissions($id: String!) {\n dashboard(id: $id) {\n id\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"): (typeof documents)["\n query DashboardsSharePermissions($id: String!) {\n dashboard(id: $id) {\n id\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1299,11 +1323,15 @@ export function graphql(source: "\n fragment PresentationHeader_SavedViewGroup
/**
* 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 PresentationInfoSidebar_SavedView on SavedView {\n id\n name\n description\n }\n"): (typeof documents)["\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n name\n description\n }\n"];
export function graphql(source: "\n fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n ...PresentationSlideEditDialog_SavedView\n name\n description\n }\n"): (typeof documents)["\n fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n ...PresentationSlideEditDialog_SavedView\n name\n description\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 PresentationLeftSidebar_Workspace on Workspace {\n id\n name\n logo\n }\n"): (typeof documents)["\n fragment PresentationLeftSidebar_Workspace on Workspace {\n id\n name\n logo\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 PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n screenshot\n }\n"): (typeof documents)["\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n screenshot\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2096,6 +2124,10 @@ export function graphql(source: "\n mutation UpdateDashboard($input: DashboardU
* 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 DeleteDashboard($id: String!) {\n dashboardMutations {\n delete(id: $id)\n }\n }\n"): (typeof documents)["\n mutation DeleteDashboard($id: String!) {\n dashboardMutations {\n delete(id: $id)\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 DashboardAccessCheck($id: String!) {\n dashboard(id: $id) {\n id\n }\n }\n"): (typeof documents)["\n query DashboardAccessCheck($id: String!) {\n dashboard(id: $id) {\n id\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2191,7 +2223,11 @@ export function graphql(source: "\n query NavigationWorkspaceInvites {\n act
/**
* 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 ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n ...PresentationLeftSidebar_Workspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n ...PresentationLeftSidebar_Workspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n"];
export function graphql(source: "\n mutation UpdatePresentationSlide($input: UpdateSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n updateView(input: $input) {\n id\n name\n description\n }\n }\n }\n }\n"): (typeof documents)["\n mutation UpdatePresentationSlide($input: UpdateSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n updateView(input: $input) {\n id\n name\n description\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 ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n ...PresentationLeftSidebar_Workspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n ...PresentationInfoSidebar_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n ...PresentationLeftSidebar_Workspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n ...PresentationInfoSidebar_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\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.
*/
File diff suppressed because one or more lines are too long
@@ -1,5 +1,13 @@
import { graphql } from '~~/lib/common/generated/gql'
export const dashboardAccessCheckQuery = graphql(`
query DashboardAccessCheck($id: String!) {
dashboard(id: $id) {
id
}
}
`)
export const dashboardQuery = graphql(`
query Dashboard($id: String!) {
dashboard(id: $id) {
@@ -0,0 +1,36 @@
import { useMutation } from '@vue/apollo-composable'
import { ToastNotificationType, useGlobalToast } from '~/lib/common/composables/toast'
import {
convertThrowIntoFetchResult,
getFirstErrorMessage
} from '~/lib/common/helpers/graphql'
import type { UpdateSavedViewInput } from '~/lib/common/generated/gql/graphql'
import { updatePresentationSlideMutation } from '~/lib/presentations/graphql/mutations'
export const useUpdatePresentationSlide = () => {
const { mutate, loading } = useMutation(updatePresentationSlideMutation)
const { triggerNotification } = useGlobalToast()
return {
mutate: async (input: UpdateSavedViewInput) => {
const result = await mutate({ input }).catch(convertThrowIntoFetchResult)
if (result?.data?.projectMutations.savedViewMutations.updateView) {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Slide updated'
})
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Slide update failed',
description: errorMessage
})
}
return result
},
loading
}
}
@@ -0,0 +1,15 @@
import { graphql } from '~/lib/common/generated/gql'
export const updatePresentationSlideMutation = graphql(`
mutation UpdatePresentationSlide($input: UpdateSavedViewInput!) {
projectMutations {
savedViewMutations {
updateView(input: $input) {
id
name
description
}
}
}
}
`)
@@ -18,6 +18,7 @@ export const projectPresentationPageQuery = graphql(`
...PresentationViewerPageWrapper_SavedViewGroup
...PresentationHeader_SavedViewGroup
...PresentationSlideList_SavedViewGroup
...PresentationInfoSidebar_SavedViewGroup
views(input: $input) {
totalCount
items {
@@ -0,0 +1,56 @@
import type { Optional } from '@speckle/shared'
import { useApolloClientFromNuxt } from '~/lib/common/composables/graphql'
import {
convertThrowIntoFetchResult,
errorsToAuthResult
} from '~/lib/common/helpers/graphql'
import { dashboardAccessCheckQuery } from '~/lib/dashboards/graphql/queries'
/**
* Used in dashboard page to validate that dashboard ID refers to a valid dashboard and redirects to 404 if not
*/
export default defineParallelizedNuxtRouteMiddleware(async (to, _from) => {
const dashboardId = to.params.id as string
// Check if dashboard token is present in URL
const dashboardToken = to.query.dashboardToken as Optional<string>
// Skip middleware validation for dashboard tokens - let the auth system handle them
if (dashboardToken) {
return
}
const client = useApolloClientFromNuxt()
const { data, errors } = await client
.query({
query: dashboardAccessCheckQuery,
variables: { id: dashboardId },
context: {
skipLoggingErrors: true
}
})
.catch(convertThrowIntoFetchResult)
if (!data?.dashboard) {
const authResult = errorsToAuthResult({ errors })
switch (authResult.code) {
case 'FORBIDDEN':
return abortNavigation(
createError({
statusCode: 403,
message: authResult.message
})
)
default:
return abortNavigation(
createError({
statusCode: 500,
message: authResult.message
})
)
}
}
})
@@ -24,7 +24,7 @@
</Portal>
<Portal to="primary-actions">
<div class="flex items-center gap-2">
<DashboardsShare :id="dashboard?.id" />
<DashboardsShare :id="dashboard?.id" :workspace-slug="workspace?.slug" />
<FormButton
v-tippy="'Toggle fullscreen'"
size="sm"
@@ -81,7 +81,8 @@ graphql(`
`)
definePageMeta({
layout: 'dashboard'
layout: 'dashboard',
middleware: ['require-valid-dashboard']
})
const { id } = useRoute().params
@@ -1,5 +1,5 @@
import { CustomLogger, getFeatureFlag, ObjectLoader2Flags } from '../types/functions.js'
import { Base } from '../types/types.js'
import { Base, ObjectAttributeMask } from '../types/types.js'
import { ObjectLoader2 } from './objectLoader2.js'
import { IndexedDatabase } from './stages/indexedDatabase.js'
import { MemoryDatabase } from './stages/memory/memoryDatabase.js'
@@ -40,6 +40,7 @@ export class ObjectLoader2Factory {
token?: string
headers?: Headers
options?: ObjectLoader2FactoryOptions
attributeMask?: ObjectAttributeMask
}): ObjectLoader2 {
const log = ObjectLoader2Factory.getLogger(params.options?.logger)
let database
@@ -67,6 +68,7 @@ export class ObjectLoader2Factory {
objectId: params.objectId,
token: params.token,
headers: params.headers,
attributeMask: params.attributeMask,
logger: log || ((): void => {})
}),
database,
@@ -2,7 +2,7 @@ import BatchingQueue from '../../queues/batchingQueue.js'
import Queue from '../../queues/queue.js'
import { ObjectLoaderRuntimeError } from '../../types/errors.js'
import { CustomLogger, Fetcher, indexOf, isBase, take } from '../../types/functions.js'
import { Item } from '../../types/types.js'
import { Item, ObjectAttributeMask } from '../../types/types.js'
import { Downloader } from '../interfaces.js'
export interface ServerDownloaderOptions {
@@ -13,6 +13,7 @@ export interface ServerDownloaderOptions {
headers?: Headers
logger: CustomLogger
fetch?: Fetcher
attributeMask?: ObjectAttributeMask
}
const MAX_SAFARI_DECODE_BYTES = 2 * 1024 * 1024 * 1024 - 1024 * 1024 // 2GB minus a margin
@@ -51,9 +52,10 @@ export default class ServerDownloader implements Downloader {
if (this.#options.token) {
this.#headers['Authorization'] = `Bearer ${this.#options.token}`
}
this.#requestUrlChildren = `${this.#options.serverUrl}/api/getobjects/${
this.#requestUrlChildren = `${this.#options.serverUrl}/api/v2/projects/${
this.#options.streamId
}`
}/object-stream/`
this.#requestUrlRootObj = `${this.#options.serverUrl}/objects/${
this.#options.streamId
}/${this.#options.objectId}/single`
@@ -117,11 +119,12 @@ Chrome's behavior: Chrome generally handles larger data sizes without this speci
const start = performance.now()
this.#logger(`Downloading batch of ${batch.length} items...`)
const attributeMask = this.#options.attributeMask
const keys = new Set<string>(batch)
const response = await this.#fetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ objects: JSON.stringify(batch) })
body: JSON.stringify({ objectIds: batch, attributeMask })
})
this.#validateResponse(response)
@@ -19,3 +19,8 @@ export interface Reference {
export interface DataChunk extends Base {
data?: Base[]
}
export type ObjectAttributeMask =
| { include: string[] }
| { exclude: string[] }
| undefined
@@ -0,0 +1,25 @@
extend type Dashboard {
shareLink: DashboardShareLink
}
type DashboardShareLink {
id: ID!
# going to ignore this in the API for now, its in the DB
# createdBy: LimitedUser
validUntil: DateTime!
createdAt: DateTime!
content: String!
revoked: Boolean!
}
input DashboardShareInput {
dashboardId: ID!
shareId: ID!
}
extend type DashboardMutations {
share(dashboardId: String!): DashboardShareLink!
deleteShare(input: DashboardShareInput!): Boolean!
disableShare(input: DashboardShareInput!): DashboardShareLink!
enableShare(input: DashboardShareInput!): DashboardShareLink!
}
@@ -25,6 +25,10 @@ export type StoreTokenResourceAccessDefinitions = (
defs: TokenResourceAccessDefinition[]
) => Promise<void>
export type RevokeTokenResourceAccess = (
def: TokenResourceAccessDefinition
) => Promise<void>
export type StoreUserServerAppToken = (
token: UserServerAppToken
) => Promise<UserServerAppToken>
@@ -1141,6 +1141,7 @@ export type Dashboard = {
id: Scalars['String']['output'];
name: Scalars['String']['output'];
permissions: DashboardPermissionChecks;
shareLink?: Maybe<DashboardShareLink>;
/** If null, this is a new dashboard and should be initialized by the client */
state?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
@@ -1163,6 +1164,10 @@ export type DashboardMutations = {
create: Dashboard;
createToken: CreateDashboardTokenReturn;
delete: Scalars['Boolean']['output'];
deleteShare: Scalars['Boolean']['output'];
disableShare: DashboardShareLink;
enableShare: DashboardShareLink;
share: DashboardShareLink;
update: Dashboard;
};
@@ -1183,6 +1188,26 @@ export type DashboardMutationsDeleteArgs = {
};
export type DashboardMutationsDeleteShareArgs = {
input: DashboardShareInput;
};
export type DashboardMutationsDisableShareArgs = {
input: DashboardShareInput;
};
export type DashboardMutationsEnableShareArgs = {
input: DashboardShareInput;
};
export type DashboardMutationsShareArgs = {
dashboardId: Scalars['String']['input'];
};
export type DashboardMutationsUpdateArgs = {
input: DashboardUpdateInput;
};
@@ -1195,6 +1220,20 @@ export type DashboardPermissionChecks = {
canRead: PermissionCheckResult;
};
export type DashboardShareInput = {
dashboardId: Scalars['ID']['input'];
shareId: Scalars['ID']['input'];
};
export type DashboardShareLink = {
__typename?: 'DashboardShareLink';
content: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
revoked: Scalars['Boolean']['output'];
validUntil: Scalars['DateTime']['output'];
};
export type DashboardToken = {
__typename?: 'DashboardToken';
createdAt: Scalars['DateTime']['output'];
@@ -6170,6 +6209,8 @@ export type ResolversTypes = {
DashboardCreateInput: DashboardCreateInput;
DashboardMutations: ResolverTypeWrapper<DashboardMutationsGraphQLReturn>;
DashboardPermissionChecks: ResolverTypeWrapper<DashboardPermissionChecksGraphQLReturn>;
DashboardShareInput: DashboardShareInput;
DashboardShareLink: ResolverTypeWrapper<DashboardShareLink>;
DashboardToken: ResolverTypeWrapper<DashboardTokenGraphQLReturn>;
DashboardTokenCollection: ResolverTypeWrapper<Omit<DashboardTokenCollection, 'items'> & { items: Array<ResolversTypes['DashboardToken']> }>;
DashboardTokenCreateInput: DashboardTokenCreateInput;
@@ -6562,6 +6603,8 @@ export type ResolversParentTypes = {
DashboardCreateInput: DashboardCreateInput;
DashboardMutations: DashboardMutationsGraphQLReturn;
DashboardPermissionChecks: DashboardPermissionChecksGraphQLReturn;
DashboardShareInput: DashboardShareInput;
DashboardShareLink: DashboardShareLink;
DashboardToken: DashboardTokenGraphQLReturn;
DashboardTokenCollection: Omit<DashboardTokenCollection, 'items'> & { items: Array<ResolversParentTypes['DashboardToken']> };
DashboardTokenCreateInput: DashboardTokenCreateInput;
@@ -7341,6 +7384,7 @@ export type DashboardResolvers<ContextType = GraphQLContext, ParentType extends
id?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
permissions?: Resolver<ResolversTypes['DashboardPermissionChecks'], ParentType, ContextType>;
shareLink?: Resolver<Maybe<ResolversTypes['DashboardShareLink']>, ParentType, ContextType>;
state?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
workspace?: Resolver<ResolversTypes['LimitedWorkspace'], ParentType, ContextType>;
@@ -7358,6 +7402,10 @@ export type DashboardMutationsResolvers<ContextType = GraphQLContext, ParentType
create?: Resolver<ResolversTypes['Dashboard'], ParentType, ContextType, RequireFields<DashboardMutationsCreateArgs, 'input' | 'workspace'>>;
createToken?: Resolver<ResolversTypes['CreateDashboardTokenReturn'], ParentType, ContextType, RequireFields<DashboardMutationsCreateTokenArgs, 'dashboardId'>>;
delete?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<DashboardMutationsDeleteArgs, 'id'>>;
deleteShare?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<DashboardMutationsDeleteShareArgs, 'input'>>;
disableShare?: Resolver<ResolversTypes['DashboardShareLink'], ParentType, ContextType, RequireFields<DashboardMutationsDisableShareArgs, 'input'>>;
enableShare?: Resolver<ResolversTypes['DashboardShareLink'], ParentType, ContextType, RequireFields<DashboardMutationsEnableShareArgs, 'input'>>;
share?: Resolver<ResolversTypes['DashboardShareLink'], ParentType, ContextType, RequireFields<DashboardMutationsShareArgs, 'dashboardId'>>;
update?: Resolver<ResolversTypes['Dashboard'], ParentType, ContextType, RequireFields<DashboardMutationsUpdateArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@@ -7370,6 +7418,15 @@ export type DashboardPermissionChecksResolvers<ContextType = GraphQLContext, Par
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DashboardShareLinkResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['DashboardShareLink'] = ResolversParentTypes['DashboardShareLink']> = {
content?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
revoked?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
validUntil?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DashboardTokenResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['DashboardToken'] = ResolversParentTypes['DashboardToken']> = {
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
dashboard?: Resolver<ResolversTypes['Dashboard'], ParentType, ContextType>;
@@ -8987,6 +9044,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
DashboardCollection?: DashboardCollectionResolvers<ContextType>;
DashboardMutations?: DashboardMutationsResolvers<ContextType>;
DashboardPermissionChecks?: DashboardPermissionChecksResolvers<ContextType>;
DashboardShareLink?: DashboardShareLinkResolvers<ContextType>;
DashboardToken?: DashboardTokenResolvers<ContextType>;
DashboardTokenCollection?: DashboardTokenCollectionResolvers<ContextType>;
DateTime?: GraphQLScalarType;
@@ -176,6 +176,18 @@ export const getObjectsStreamFactory =
return res.stream({ highWaterMark: 500 })
}
export const getProjectObjectStreamFactory =
(deps: { db: Knex }) =>
({ projectId, objectIds }: { projectId: string; objectIds: string[] }) => {
const res = tables
.objects(deps.db)
.whereIn('id', objectIds)
.andWhere({ streamId: projectId })
.orderBy('id')
.select(knex.raw('"id", data::text as "dataText"'))
return res.stream({})
}
export const hasObjectsFactory =
(deps: { db: Knex }): HasObjects =>
async ({ streamId, objectIds }) => {
@@ -19,6 +19,7 @@ import type {
GetTokenScopesById,
GetUserPersonalAccessTokens,
RevokeTokenById,
RevokeTokenResourceAccess,
RevokeUserTokenById,
StoreApiToken,
StorePersonalApiToken,
@@ -63,6 +64,12 @@ export const storeTokenResourceAccessDefinitionsFactory =
await tables.tokenResourceAccess(deps.db).insert(defs)
}
export const revokeTokenResourceAccessDefinitonsFactory =
(deps: { db: Knex }): RevokeTokenResourceAccess =>
async (definition) => {
await tables.tokenResourceAccess(deps.db).where(definition).delete()
}
export const storeUserServerAppTokenFactory =
(deps: { db: Knex }): StoreUserServerAppToken =>
async (token) => {
@@ -1,9 +1,15 @@
import zlib from 'zlib'
import { corsMiddlewareFactory } from '@/modules/core/configs/cors'
import type { Application } from 'express'
import { SpeckleObjectsStream } from '@/modules/core/rest/speckleObjectsStream'
import {
objectDataTransformFactory,
SpeckleObjectsStream
} from '@/modules/core/rest/speckleObjectsStream'
import { pipeline, PassThrough } from 'stream'
import { getObjectsStreamFactory } from '@/modules/core/repositories/objects'
import {
getObjectsStreamFactory,
getProjectObjectStreamFactory
} from '@/modules/core/repositories/objects'
import { db } from '@/db/knex'
import { validatePermissionsReadStreamFactory } from '@/modules/core/services/streams/auth'
import { getStreamFactory } from '@/modules/core/repositories/streams'
@@ -12,6 +18,15 @@ import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { UserInputError } from '@/modules/core/errors/userinput'
import { ensureError } from '@speckle/shared'
import { DatabaseError } from '@/modules/shared/errors'
import { validateRequest } from 'zod-express'
import { z } from 'zod'
import { authMiddlewareCreator } from '@/modules/shared/middleware'
import {
allowAnonymousUsersOnPublicStreams,
allowForRegisteredUsersOnPublicStreamsEvenWithoutRole,
streamReadPermissionsPipelineFactory
} from '@/modules/shared/authz'
import { chunk } from 'lodash-es'
export default (app: Application) => {
const validatePermissionsReadStream = validatePermissionsReadStreamFactory({
@@ -120,4 +135,100 @@ export default (app: Application) => {
speckleObjStream.end()
}
})
const reqBody = z
.object({
objectIds: z.string().array().min(1),
attributeMask: z
.union([
// using strict objects here, to make the two types exclusive
z.object({ include: z.string().array().min(1) }).strict(),
z.object({ exclude: z.string().array().min(1) }).strict()
])
.optional()
})
.strict()
app.options('/api/v2/projects/:streamId/object-stream', corsMiddlewareFactory())
app.post(
'/api/v2/projects/:streamId/object-stream',
corsMiddlewareFactory(),
authMiddlewareCreator([
...streamReadPermissionsPipelineFactory({
getStream: getStreamFactory({ db })
}),
allowForRegisteredUsersOnPublicStreamsEvenWithoutRole,
allowAnonymousUsersOnPublicStreams
]),
validateRequest({
body: reqBody
}),
async ({ body: { objectIds, attributeMask }, params: { streamId }, log }, res) => {
const projectId = streamId
const projectDb = await getProjectDbClient({ projectId })
const streamObjectsFromDb = getProjectObjectStreamFactory({ db: projectDb })
res.writeHead(200, {
'Content-Encoding': 'gzip',
'Content-Type': 'text/plain; charset=UTF-8'
})
const objectDataTransform = objectDataTransformFactory({ attributeMask })
const gzipStream = zlib.createGzip()
//create the response pipeline here, but we're not sending chunks just yet
pipeline(
objectDataTransform,
gzipStream,
new PassThrough({ highWaterMark: 16384 * 31 }),
res,
(err) => {
if (err) {
switch (err.code) {
case 'ERR_STREAM_PREMATURE_CLOSE':
log.debug({ err }, 'Stream to client has prematurely closed')
break
default:
log.error(err, 'App error streaming objects')
break
}
return
}
log.info(
{
childCount: objectIds.length,
mbWritten: gzipStream.bytesWritten / 1000000
},
'Streamed {childCount} objects (size: {mbWritten} MB)'
)
}
)
// we start chunking objectId-s here and pipe data to the firts write stream in the pipeline
const maxBatchSize = 1000
// TODO, this could potentially be sped up a bit, if we concurrently
// pipe multiple db streams into the transform
try {
for (const objectIdChunk of chunk(objectIds, maxBatchSize)) {
const objectStream = streamObjectsFromDb({
projectId,
objectIds: objectIdChunk
})
await new Promise((resolve, reject) => {
objectStream.once('end', resolve)
objectStream.once('error', reject)
// this is here, to make sure event handlers are registerd before piping the stream
objectStream.pipe(objectDataTransform, { end: false })
})
}
} catch (err) {
log.error(err, `DB Error streaming objects`)
objectDataTransform.emit('error', new DatabaseError('Database streaming error'))
} finally {
// once we're done with streaming data from each chunk, we end the transform stream
objectDataTransform.end()
}
}
)
}
@@ -1,4 +1,5 @@
import { ensureError } from '@speckle/shared'
import { omit, pick } from 'lodash-es'
import type { TransformCallback } from 'stream'
import { Transform } from 'stream'
@@ -47,5 +48,37 @@ class SpeckleObjectsStream extends Transform {
callback()
}
}
export { SpeckleObjectsStream }
export const objectDataTransformFactory = ({
attributeMask
}: {
attributeMask?: { include: string[] } | { exclude: string[] }
}) => {
let objectTransform: ((dataText: string) => string) | null
if (attributeMask) {
let objectFilter: (obj: unknown, props: string[]) => unknown
let filteredAttributes: string[]
if ('include' in attributeMask) {
objectFilter = pick
filteredAttributes = attributeMask.include
}
if ('exclude' in attributeMask) {
objectFilter = omit
filteredAttributes = attributeMask.exclude
}
objectTransform = (dataText: string) =>
JSON.stringify(objectFilter(JSON.parse(dataText), filteredAttributes))
}
return new Transform({
writableObjectMode: true,
transform({ dataText, id }: { dataText: string; id: string }, _, callback) {
try {
const objectDataString = objectTransform ? objectTransform(dataText) : dataText
callback(null, `${id}\t${objectDataString}\n`)
} catch (err) {
callback(ensureError(err))
}
}
})
}
@@ -242,6 +242,8 @@ export const validateTokenFactory =
return { valid: false, tokenId }
}
if (token.revoked) return { valid: false, tokenId }
const timeDiff = Math.abs(Date.now() - new Date(token.createdAt).getTime())
if (timeDiff > token.lifespan) {
await deps.revokeUserTokenById(tokenId, token.owner)
@@ -14,5 +14,6 @@ export const Dashboards = buildTableHelper('dashboards', [
export const DashboardApiTokens = buildTableHelper('dashboard_api_tokens', [
'tokenId',
'dashboardId',
'userId'
'userId',
'content'
])
@@ -1,6 +1,22 @@
import type { DashboardApiTokenRecord } from '@/modules/dashboards/domain/tokens/types'
import type {
DashboardApiToken,
DashboardApiTokenRecord
} from '@/modules/dashboards/domain/tokens/types'
import type { Exact } from 'type-fest'
export type StoreDashboardApiToken = <T extends Exact<DashboardApiTokenRecord, T>>(
token: T
) => Promise<DashboardApiTokenRecord>
export type DeleteDashboardToken = (args: {
tokenId: string
}) => Promise<DashboardApiTokenRecord | null>
export type GetDashboardTokens = (args: {
dashboardId: string
}) => Promise<DashboardApiToken[]>
export type GetDashboardToken = (args: {
dashboardId: string
tokenId: string
}) => Promise<DashboardApiToken | null>
@@ -2,10 +2,12 @@ export type DashboardApiTokenRecord = {
tokenId: string
dashboardId: string
userId: string
content: string
}
export type DashboardApiToken = DashboardApiTokenRecord & {
createdAt: Date
lastUsed: Date
lifespan: number | bigint
revoked: boolean
}
@@ -21,6 +21,13 @@ import { getFeatureFlags } from '@speckle/shared/environment'
import { DashboardsModuleDisabledError } from '@/modules/dashboards/errors/dashboards'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { parseWorkspaceIdentifier } from '@/modules/workspacesCore/helpers/graphHelpers'
import { asOperation } from '@/modules/shared/command'
import { logger } from '@/observability/logging'
import {
revokeTokenResourceAccessDefinitonsFactory,
storeTokenResourceAccessDefinitionsFactory
} from '@/modules/core/repositories/tokens'
import { getDashboardTokensFactory } from '@/modules/dashboards/repositories/tokens'
const { FF_WORKSPACES_MODULE_ENABLED, FF_DASHBOARDS_MODULE_ENABLED } = getFeatureFlags()
@@ -44,6 +51,7 @@ const resolvers: Resolvers = {
dashboardMutations: async () => ({})
},
Dashboard: {
// share links
createdBy: async (parent, _args, context) => {
return await context.loaders.users.getUser.load(parent.ownerId)
},
@@ -80,6 +88,7 @@ const resolvers: Resolvers = {
}
},
DashboardMutations: {
//create share link...
create: async (_parent, args, context) => {
const { name } = args.input
@@ -125,11 +134,26 @@ const resolvers: Resolvers = {
dashboardId
})
throwIfAuthNotOk(authResult)
return await updateDashboardFactory({
getDashboard: getDashboardRecordFactory({ db }),
upsertDashboard: upsertDashboardFactory({ db })
})(removeNullOrUndefinedKeys(args.input))
return await asOperation(
async ({ db }) => {
return await updateDashboardFactory({
getDashboard: getDashboardRecordFactory({ db }),
upsertDashboard: upsertDashboardFactory({ db }),
storeTokenResourceAccessDefinitions:
storeTokenResourceAccessDefinitionsFactory({ db }),
revokeTokenResourceAccess: revokeTokenResourceAccessDefinitonsFactory({
db
}),
getDashboardTokens: getDashboardTokensFactory({ db })
})(removeNullOrUndefinedKeys(args.input))
},
{
logger,
name: 'updateDashboard',
description: 'Update a dashboard',
db
}
)
}
}
}
@@ -0,0 +1,129 @@
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
import { getDashboardRecordFactory } from '@/modules/dashboards/repositories/management'
import { db } from '@/db/knex'
import {
DashboardMalformedTokenError,
DashboardsModuleDisabledError
} from '@/modules/dashboards/errors/dashboards'
import { createDashboardTokenFactory } from '@/modules/dashboards/services/tokens'
import { createTokenFactory } from '@/modules/core/services/tokens'
import {
getApiTokenByIdFactory,
revokeUserTokenByIdFactory,
storeApiTokenFactory,
storeTokenResourceAccessDefinitionsFactory,
storeTokenScopesFactory,
updateApiTokenFactory
} from '@/modules/core/repositories/tokens'
import {
deleteDashboardApiTokenFactory,
getDashboardTokenFactory,
getDashboardTokensFactory,
storeDashboardApiTokenFactory
} from '@/modules/dashboards/repositories/tokens'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import dayjs from 'dayjs'
import { deleteDashboardShareFactory } from '@/modules/dashboards/services/shares'
import type { DashboardApiToken } from '@/modules/dashboards/domain/tokens/types'
const { FF_WORKSPACES_MODULE_ENABLED, FF_DASHBOARDS_MODULE_ENABLED } = getFeatureFlags()
const isEnabled = FF_WORKSPACES_MODULE_ENABLED && FF_DASHBOARDS_MODULE_ENABLED
const formatDashboardTokenToDashboardShare = (token: DashboardApiToken) => {
return {
...token,
id: token.tokenId,
validUntil: dayjs(token.createdAt)
.add(Number(token.lifespan), 'milliseconds')
.toDate()
}
}
const resolvers: Resolvers = {
Dashboard: {
shareLink: async (parent) => {
const dashboardTokens = await getDashboardTokensFactory({ db })({
dashboardId: parent.id
})
if (!dashboardTokens.length) return null
const token = dashboardTokens[0]
return formatDashboardTokenToDashboardShare(token)
}
},
DashboardMutations: {
share: async (_, args, context) => {
const authResult = await context.authPolicies.dashboard.canCreateToken({
userId: context.userId,
dashboardId: args.dashboardId
})
throwIfAuthNotOk(authResult)
const token = await createDashboardTokenFactory({
getDashboard: getDashboardRecordFactory({ db }),
createToken: createTokenFactory({
storeApiToken: storeApiTokenFactory({ db }),
storeTokenScopes: storeTokenScopesFactory({ db }),
storeTokenResourceAccessDefinitions:
storeTokenResourceAccessDefinitionsFactory({ db })
}),
getToken: getApiTokenByIdFactory({ db }),
storeDashboardApiToken: storeDashboardApiTokenFactory({ db })
})({
dashboardId: args.dashboardId,
userId: context.userId!
})
return formatDashboardTokenToDashboardShare(token.tokenMetadata)
},
disableShare: async (_, { input }, context) => {
const authResult = await context.authPolicies.dashboard.canCreateToken({
userId: context.userId,
dashboardId: input.dashboardId
})
throwIfAuthNotOk(authResult)
await updateApiTokenFactory({ db })(input.shareId, { revoked: true })
const token = await getDashboardTokenFactory({ db })({
dashboardId: input.dashboardId,
tokenId: input.shareId
})
if (!token) throw new DashboardMalformedTokenError()
return formatDashboardTokenToDashboardShare(token)
},
enableShare: async (_, { input }, context) => {
const authResult = await context.authPolicies.dashboard.canCreateToken({
userId: context.userId,
dashboardId: input.dashboardId
})
throwIfAuthNotOk(authResult)
await updateApiTokenFactory({ db })(input.shareId, { revoked: false })
const token = await getDashboardTokenFactory({ db })({
dashboardId: input.dashboardId,
tokenId: input.shareId
})
if (!token) throw new DashboardMalformedTokenError()
return formatDashboardTokenToDashboardShare(token)
},
deleteShare: async (_, { input }, context) => {
const authResult = await context.authPolicies.dashboard.canCreateToken({
userId: context.userId,
dashboardId: input.dashboardId
})
throwIfAuthNotOk(authResult)
await deleteDashboardShareFactory({
deleteDashboardToken: deleteDashboardApiTokenFactory({ db }),
revokeUserTokenById: revokeUserTokenByIdFactory({ db })
})(input)
return true
}
}
}
const disabledResolvers: Resolvers = {
DashboardMutations: {
share: async () => {
throw new DashboardsModuleDisabledError()
}
}
}
export default isEnabled ? resolvers : disabledResolvers
@@ -0,0 +1,15 @@
import type { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
// clearing all items in the table, as they do not have the content field set
await knex('dashboard_api_tokens').truncate()
await knex.schema.alterTable('dashboard_api_tokens', (table) => {
table.string('content').notNullable()
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('dashboard_api_tokens', (table) => {
table.dropColumn('content')
})
}
@@ -1,12 +1,19 @@
import type { ApiTokenRecord } from '@/modules/auth/repositories'
import { ApiTokens } from '@/modules/core/dbSchema'
import { DashboardApiTokens } from '@/modules/dashboards/dbSchema'
import type { StoreDashboardApiToken } from '@/modules/dashboards/domain/tokens/operations'
import type { DashboardApiTokenRecord } from '@/modules/dashboards/domain/tokens/types'
import type {
DeleteDashboardToken,
GetDashboardToken,
GetDashboardTokens,
StoreDashboardApiToken
} from '@/modules/dashboards/domain/tokens/operations'
import type {
DashboardApiToken,
DashboardApiTokenRecord
} from '@/modules/dashboards/domain/tokens/types'
import type { Knex } from 'knex'
const tables = {
apiTokens: (db: Knex) => db<ApiTokenRecord>(ApiTokens.name),
// apiTokens: (db: Knex) => db<ApiTokenRecord>(ApiTokens.name),
dashboardApiTokens: (db: Knex) => db<DashboardApiTokenRecord>(DashboardApiTokens.name)
}
@@ -19,3 +26,51 @@ export const storeDashboardApiTokenFactory =
.returning('*')
return newToken
}
export const deleteDashboardApiTokenFactory =
(deps: { db: Knex }): DeleteDashboardToken =>
async ({ tokenId }) => {
const [deletedToken] = await tables
.dashboardApiTokens(deps.db)
.where({ tokenId })
.del()
.returning('*')
return deletedToken
}
export const getDashboardTokensFactory =
(deps: { db: Knex }): GetDashboardTokens =>
async ({ dashboardId }) => {
const tokens = await tables
.dashboardApiTokens(deps.db)
.orderBy(ApiTokens.col.createdAt)
.join(ApiTokens.name, ApiTokens.col.id, DashboardApiTokens.col.tokenId)
.select<DashboardApiToken[]>([
...DashboardApiTokens.cols,
ApiTokens.col.createdAt,
ApiTokens.col.lastUsed,
ApiTokens.col.lifespan,
ApiTokens.col.revoked
])
.where({ dashboardId })
return tokens
}
export const getDashboardTokenFactory =
(deps: { db: Knex }): GetDashboardToken =>
async ({ dashboardId, tokenId }) => {
const token = await tables
.dashboardApiTokens(deps.db)
.orderBy(ApiTokens.col.createdAt)
.join(ApiTokens.name, ApiTokens.col.id, DashboardApiTokens.col.tokenId)
.select<DashboardApiToken[]>([
...DashboardApiTokens.cols,
ApiTokens.col.createdAt,
ApiTokens.col.lastUsed,
ApiTokens.col.lifespan,
ApiTokens.col.revoked
])
.where({ dashboardId, tokenId })
.first()
return token ?? null
}
@@ -13,6 +13,12 @@ import {
encodeIsoDateCursor
} from '@/modules/shared/helpers/dbHelper'
import cryptoRandomString from 'crypto-random-string'
import type { GetDashboardTokens } from '@/modules/dashboards/domain/tokens/operations'
import type {
RevokeTokenResourceAccess,
StoreTokenResourceAccessDefinitions
} from '@/modules/core/domain/tokens/operations'
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
export type CreateDashboard = (params: {
name: string
@@ -48,6 +54,9 @@ export type UpdateDashboard = (params: {
export const updateDashboardFactory =
(deps: {
getDashboard: GetDashboardRecord
getDashboardTokens: GetDashboardTokens
storeTokenResourceAccessDefinitions: StoreTokenResourceAccessDefinitions
revokeTokenResourceAccess: RevokeTokenResourceAccess
upsertDashboard: UpsertDashboardRecord
}): UpdateDashboard =>
async ({ id, ...update }) => {
@@ -57,6 +66,47 @@ export const updateDashboardFactory =
throw new DashboardNotFoundError()
}
const newProjectIds = [
...new Set(update.projectIds).difference(new Set(dashboard.projectIds))
]
const deletedProjectIds = [
...new Set(dashboard.projectIds).difference(new Set(update.projectIds))
]
const projectIdsChanged = newProjectIds.length || deletedProjectIds.length
if (projectIdsChanged) {
const dashboardTokens = await deps.getDashboardTokens({
dashboardId: dashboard.id
})
if (newProjectIds.length && dashboardTokens.length) {
const newResourceAccessRules = dashboardTokens.flatMap((t) =>
newProjectIds.map((p) => ({
resourceId: p,
tokenId: t.tokenId,
resourceType: TokenResourceIdentifierType.Project
}))
)
await deps.storeTokenResourceAccessDefinitions(newResourceAccessRules)
}
if (deletedProjectIds.length && dashboardTokens.length) {
await Promise.all(
// i know this is bad and sending more than one delete requests
// but most of the time there are only a couple of projects deleted at max from dashboards
dashboardTokens.flatMap((t) =>
deletedProjectIds.map((p) =>
deps.revokeTokenResourceAccess({
resourceId: p,
resourceType: TokenResourceIdentifierType.Project,
tokenId: t.tokenId
})
)
)
)
}
}
const nextDashboard: Dashboard = {
...dashboard,
...update,
@@ -0,0 +1,14 @@
import { DashboardMalformedTokenError } from '@/modules/dashboards/errors/dashboards'
import type { RevokeUserTokenById } from '@/modules/core/domain/tokens/operations'
import type { DeleteDashboardToken } from '@/modules/dashboards/domain/tokens/operations'
export const deleteDashboardShareFactory =
(deps: {
deleteDashboardToken: DeleteDashboardToken
revokeUserTokenById: RevokeUserTokenById
}) =>
async ({ shareId }: { shareId: string }) => {
const dashboardToken = await deps.deleteDashboardToken({ tokenId: shareId })
if (!dashboardToken) throw new DashboardMalformedTokenError()
await deps.revokeUserTokenById(dashboardToken.tokenId, dashboardToken.userId)
}
@@ -59,7 +59,8 @@ export const createDashboardTokenFactory =
const tokenMetadata: DashboardApiTokenRecord = {
userId,
dashboardId,
tokenId: id
tokenId: id,
content: token
}
await deps.storeDashboardApiToken(tokenMetadata)
@@ -73,6 +74,7 @@ export const createDashboardTokenFactory =
return {
token,
tokenMetadata: {
revoked: false,
...tokenMetadata,
...pick(apiToken, 'createdAt', 'lastUsed', 'lifespan')
}
@@ -5,20 +5,50 @@ import {
import { DashboardNotFoundError } from '@speckle/shared/authz'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import type { Dashboard } from '@/modules/dashboards/domain/types'
import { assign } from 'lodash-es'
import type { DashboardApiToken } from '@/modules/dashboards/domain/tokens/types'
import type { TokenResourceAccessDefinition } from '@/modules/core/domain/tokens/types'
const buildTestDashboard = (overrides?: Partial<Dashboard>): Dashboard =>
assign(
{
id: cryptoRandomString({ length: 9 }),
ownerId: '',
name: 'original-name',
workspaceId: '',
projectIds: [],
createdAt: new Date(),
updatedAt: new Date()
},
overrides
)
const buildTestDashboardToken = (
overrides?: Partial<DashboardApiToken>
): DashboardApiToken =>
assign(
{
tokenId: cryptoRandomString({ length: 8 }),
dashboardId: cryptoRandomString({ length: 10 }),
userId: cryptoRandomString({ length: 10 }),
content: 'tokencontent',
createdAt: new Date(),
lastUsed: new Date(),
lifespan: 1000,
revoked: false
},
overrides
)
describe('updateDashboardFactory returns a function, that', () => {
it('updates and returns the updated dashboard', async () => {
const dashboardId = cryptoRandomString({ length: 9 })
const result = await updateDashboardFactory({
getDashboard: async () => ({
id: dashboardId,
ownerId: '',
name: 'original-name',
workspaceId: '',
projectIds: [],
createdAt: new Date(),
updatedAt: new Date()
}),
getDashboard: async () => buildTestDashboard({ id: dashboardId }),
getDashboardTokens: async () => [],
storeTokenResourceAccessDefinitions: async () => {},
revokeTokenResourceAccess: async () => {},
upsertDashboard: async () => {}
})({
id: dashboardId,
@@ -29,12 +59,126 @@ describe('updateDashboardFactory returns a function, that', () => {
it('throws if dashboard does not exist', async () => {
const updateDashboard = updateDashboardFactory({
getDashboard: async () => undefined,
getDashboardTokens: async () => {
expect.fail()
},
storeTokenResourceAccessDefinitions: async () => {
expect.fail()
},
revokeTokenResourceAccess: async () => {
expect.fail()
},
upsertDashboard: async () => {
expect.fail()
}
})
expect(updateDashboard({ id: '' })).to.eventually.throw(DashboardNotFoundError)
})
it('does not affect tokens if projectIds are not changing', async () => {
const dashboard = buildTestDashboard()
const result = await updateDashboardFactory({
getDashboard: async () => dashboard,
getDashboardTokens: async () => {
expect.fail()
},
storeTokenResourceAccessDefinitions: async () => {
expect.fail()
},
revokeTokenResourceAccess: async () => {
expect.fail()
},
upsertDashboard: async () => {}
})({ id: dashboard.id })
expect(result.projectIds).to.deep.equalInAnyOrder(dashboard.projectIds)
})
it('does not add token resource access rules if there are not share tokens', async () => {
const dashboard = buildTestDashboard()
const updateDashboard = updateDashboardFactory({
getDashboard: async () => dashboard,
getDashboardTokens: async () => {
return []
},
storeTokenResourceAccessDefinitions: async () => {
expect.fail()
},
revokeTokenResourceAccess: async () => {
expect.fail()
},
upsertDashboard: async () => {}
})
const updatedPorjectIds = [cryptoRandomString({ length: 10 })]
const result = await updateDashboard({
id: dashboard.id,
projectIds: updatedPorjectIds
})
expect(result.projectIds).to.deep.equalInAnyOrder(updatedPorjectIds)
})
it('adds new token access rules for new projects for each existing tokens', async () => {
const dashboard = buildTestDashboard()
const dashboardTokens = [buildTestDashboardToken({ dashboardId: dashboard.id })]
const updatePorjectIds = [cryptoRandomString({ length: 10 })]
const updateDashboard = updateDashboardFactory({
getDashboard: async () => dashboard,
getDashboardTokens: async () => dashboardTokens,
storeTokenResourceAccessDefinitions: async (resourceAccessDefinitions) => {
expect(resourceAccessDefinitions).to.deep.equalInAnyOrder(
updatePorjectIds.flatMap((projectId) =>
dashboardTokens.map((token) => {
const tokenResourceAccessRecord: TokenResourceAccessDefinition = {
resourceId: projectId,
tokenId: token.tokenId,
resourceType: 'project'
}
return tokenResourceAccessRecord
})
)
)
},
revokeTokenResourceAccess: async () => {
expect.fail()
},
upsertDashboard: async () => {}
})
const result = await updateDashboard({
id: dashboard.id,
projectIds: updatePorjectIds
})
expect(result.projectIds).to.deep.equalInAnyOrder(updatePorjectIds)
})
it('removes token access rules for projects removed from the dashboards for each existing tokens', async () => {
const dashboard = buildTestDashboard()
const dashboardTokens = [buildTestDashboardToken({ dashboardId: dashboard.id })]
const updatePorjectIds = [cryptoRandomString({ length: 10 })]
const updateDashboard = updateDashboardFactory({
getDashboard: async () => dashboard,
getDashboardTokens: async () => dashboardTokens,
storeTokenResourceAccessDefinitions: async (resourceAccessDefinitions) => {
expect(resourceAccessDefinitions).to.deep.equalInAnyOrder(
updatePorjectIds.flatMap((projectId) =>
dashboardTokens.map((token) => {
const tokenResourceAccessRecord: TokenResourceAccessDefinition = {
resourceId: projectId,
tokenId: token.tokenId,
resourceType: 'project'
}
return tokenResourceAccessRecord
})
)
)
},
revokeTokenResourceAccess: async () => {
expect.fail()
},
upsertDashboard: async () => {}
})
const result = await updateDashboard({
id: dashboard.id,
projectIds: updatePorjectIds
})
expect(result.projectIds).to.deep.equalInAnyOrder(updatePorjectIds)
})
})
describe('deleteDashboardFactory returns a function, that', () => {
@@ -79,6 +79,8 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
case Authz.PersonalProjectsLimitedError.code:
case Authz.UngroupedSavedViewGroupLockError.code:
return new BadRequestError(e.message)
case Authz.DashboardNoProjectsError.code:
return new BadRequestError(e.message)
default:
throwUncoveredError(e)
}
@@ -231,6 +231,12 @@ export const DashboardNotFoundError = defineAuthError({
message: 'Dashboard not found'
})
export const DashboardNoProjectsError = defineAuthError({
code: 'DashboardNoProjects',
message:
'Dashboard has no projects added to it. You need to add at least one project before sharing.'
})
export const DashboardProjectsNotEnoughPermissionsError = defineAuthError<
'DashboardProjectsNotEnoughPermissions',
{
@@ -2,6 +2,7 @@ import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { DashboardContext, MaybeUserContext } from '../../domain/context.js'
import {
DashboardNoProjectsError,
DashboardNotFoundError,
DashboardProjectsNotEnoughPermissionsError,
DashboardsNotEnabledError,
@@ -40,6 +41,7 @@ type PolicyErrors = InstanceType<
| typeof WorkspacePlanNoFeatureAccessError
| typeof WorkspaceNoEditorSeatError
| typeof DashboardNotFoundError
| typeof DashboardNoProjectsError
| typeof DashboardProjectsNotEnoughPermissionsError
>
@@ -76,6 +78,7 @@ export const canCreateDashboardTokenPolicy: AuthPolicy<
})
if (!isWorkspaceEditorSeat) return err(new WorkspaceNoEditorSeatError())
if (!dashboard.projectIds.length) return err(new DashboardNoProjectsError())
const ensuredProjectAccess = await ensureDashboardProjectsReadAccess(loaders)({
userId: userId!,
dashboardId
@@ -4,6 +4,9 @@ import { DashboardContext, MaybeUserContext } from '../../domain/context.js'
import {
DashboardNotFoundError,
DashboardsNotEnabledError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
@@ -14,9 +17,11 @@ import {
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getServerRole
| typeof AuthCheckContextLoaderKeys.getDashboard
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
@@ -26,6 +31,9 @@ type PolicyArgs = MaybeUserContext & DashboardContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof DashboardNotFoundError
| typeof ServerNoSessionError
| typeof ServerNoAccessError
| typeof ServerNotEnoughPermissionsError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
>
@@ -37,6 +45,11 @@ export const canReadDashboardPolicy: AuthPolicy<
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId,
role: Roles.Server.User
})
if (ensuredServerRole.isErr) return err(ensuredServerRole.error)
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)