Merge branch 'main' into andrew/web-4112-missing-border-in-models-tab
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user