Feat: Bashboards in app (#5333)

This commit is contained in:
Mike
2025-09-01 14:24:17 +02:00
committed by GitHub
parent 63e36898c0
commit 08eb1f7a1d
75 changed files with 3400 additions and 20 deletions
+3
View File
@@ -40,6 +40,9 @@ NUXT_PUBLIC_INTERCOM_APP_ID=
# Enable Autodesk construction cloud integration
NUXT_PUBLIC_FF_ACC_INTEGRATION_ENABLED=false
# Local or remote URL for dashboards
NUXT_PUBLIC_DASHBOARDS_ORIGIN=http://localhost:8083
##########################################################
# Local dev settings
##########################################################
@@ -1,6 +1,6 @@
<template>
<LayoutDialog v-model:open="open" max-width="xs" :buttons="dialogButtons">
<template #header>Discard changes?</template>
<template #header>{{ title ?? 'Discard changes?' }}</template>
<p v-if="text" class="mb-2">{{ text }}</p>
<p v-else class="mb-2">You have unsaved changes. Are you sure you want to leave?</p>
</LayoutDialog>
@@ -8,8 +8,10 @@
<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
defineProps<{
const props = defineProps<{
title?: string
text?: string
confirmText?: string
}>()
const emit = defineEmits(['confirm'])
@@ -26,7 +28,7 @@ const dialogButtons = computed((): LayoutDialogButton[] => {
}
},
{
text: 'Continue',
text: props.confirmText ?? 'Confirm',
onClick: () => {
open.value = false
emit('confirm')
@@ -40,7 +40,7 @@
<div class="flex flex-col gap-y-2 lg:gap-y-4">
<LayoutSidebarMenuGroup>
<NuxtLink
v-if="showProjectsLink"
v-if="showWorkspaceLinks"
:to="projectsLink"
@click="isOpenMobile = false"
>
@@ -56,6 +56,21 @@
</LayoutSidebarMenuGroupItem>
</NuxtLink>
<NuxtLink
v-if="showWorkspaceLinks && canListDashboards"
:to="dashboardsRoute(activeWorkspaceSlug)"
@click="isOpenMobile = false"
>
<LayoutSidebarMenuGroupItem
label="Intelligence"
:active="isActive(dashboardsRoute(activeWorkspaceSlug))"
>
<template #icon>
<IconProjects class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
<NuxtLink :to="connectorsRoute" @click="isOpenMobile = false">
<LayoutSidebarMenuGroupItem
label="Connectors"
@@ -163,7 +178,8 @@ import {
connectorsRoute,
workspaceRoute,
tutorialsRoute,
docsPageUrl
docsPageUrl,
dashboardsRoute
} from '~/lib/common/helpers/route'
import { useRoute } from 'vue-router'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
@@ -186,12 +202,34 @@ const dashboardSidebarQuery = graphql(`
}
`)
const sidebarPermissionsQuery = graphql(`
query SidebarPermissions($slug: String!) {
workspaceBySlug(slug: $slug) {
permissions {
canListDashboards {
...FullPermissionCheckResult
}
}
}
}
`)
const { isLoggedIn } = useActiveUser()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const isDashboardsEnabled = useIsDashboardsModuleEnabled()
const route = useRoute()
const activeWorkspaceSlug = useActiveWorkspaceSlug()
const { $intercom } = useNuxtApp()
const mixpanel = useMixpanel()
const { result: permissionsResult } = useQuery(
sidebarPermissionsQuery,
() => ({
slug: activeWorkspaceSlug.value || ''
}),
() => ({
enabled: isDashboardsEnabled.value && !!activeWorkspaceSlug.value
})
)
const { result } = useQuery(dashboardSidebarQuery, () => ({}), {
enabled: isWorkspacesEnabled.value
})
@@ -205,7 +243,12 @@ const showIntelligenceCommunityStandUpPromo = computed(() => {
return dayjs().isBefore('2025-09-10', 'day')
})
const activeWorkspace = computed(() => result.value?.activeUser?.activeWorkspace)
const showProjectsLink = computed(() => {
const canListDashboards = computed(() => {
return permissionsResult.value?.workspaceBySlug?.permissions?.canListDashboards
?.authorized
})
const showWorkspaceLinks = computed(() => {
return isWorkspacesEnabled.value
? activeWorkspace.value
? !!activeWorkspace.value?.role
@@ -0,0 +1,103 @@
<template>
<div>
<CommonCard
class="bg-foundation cursor-pointer"
@click="navigateTo(dashboardRoute(activeWorkspaceSlug, dashboard.id))"
>
<div class="flex justify-between items-center gap-x-2">
<div>
<h1 class="break-words text-heading line-clamp-2">
{{ dashboard.name }}
</h1>
<span class="text-body-3xs text-foreground-2 select-none">
{{ updatedAt.full }}
</span>
</div>
<div class="flex items-center gap-x-2">
<UserAvatar
v-if="dashboard.createdBy"
:user="dashboard.createdBy"
size="sm"
/>
<Trash
v-tippy="canDelete ? undefined : 'You can only delete your own dashboards'"
class="size-3.5"
:class="
canDelete
? 'cursor-pointer text-foreground-2 hover:text-foreground'
: 'cursor-not-allowed text-foreground-3'
"
@click.stop="toggleDeleteDialog"
/>
</div>
</div>
</CommonCard>
<CommonConfirmDialog
v-model:open="isDeleteDialogOpen"
title="Delete dashboard"
text="Are you sure you want to delete this dashboard? This action cannot be undone."
confirm-text="Delete"
@confirm="handleDelete"
/>
</div>
</template>
<script setup lang="ts">
import { graphql } from '~~/lib/common/generated/gql'
import type { DashboardsCard_DashboardFragment } from '~~/lib/common/generated/gql/graphql'
import { dashboardRoute } from '~/lib/common/helpers/route'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { Trash } from 'lucide-vue-next'
import { useDeleteDashboard } from '~/lib/dashboards/composables/management'
graphql(`
fragment DashboardsCard_Dashboard on Dashboard {
id
name
createdAt
workspace {
id
}
createdBy {
id
name
avatar
}
permissions {
canDelete {
...FullPermissionCheckResult
}
}
}
`)
const props = defineProps<{
dashboard: DashboardsCard_DashboardFragment
activeWorkspaceSlug: MaybeNullOrUndefined<string>
}>()
const deleteDashboard = useDeleteDashboard()
const { formattedFullDate } = useDateFormatters()
const isDeleteDialogOpen = ref(false)
const updatedAt = computed(() => {
return {
full: formattedFullDate(props.dashboard.createdAt)
}
})
const canDelete = computed(() => {
return props.dashboard.permissions?.canDelete?.authorized
})
const toggleDeleteDialog = () => {
isDeleteDialogOpen.value = !isDeleteDialogOpen.value
}
const handleDelete = async () => {
if (!canDelete.value || !props.dashboard.id) return
await deleteDashboard(props.dashboard.id, props.dashboard.workspace.id)
}
</script>
@@ -0,0 +1,70 @@
<template>
<LayoutDialog
v-model:open="open"
title="Create new dashboard"
:buttons="dialogButtons"
:on-submit="onSubmit"
max-width="xs"
>
<FormTextInput
v-model="dashboardName"
name="name"
label="Dashboard name"
placeholder="Name"
color="foundation"
:rules="[isRequired, isStringOfLength({ maxLength: 512 })]"
auto-focus
autocomplete="off"
show-label
class="mb-2"
/>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { isRequired, isStringOfLength } from '~~/lib/common/helpers/validation'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useCreateDashboard } from '~/lib/dashboards/composables/management'
const props = defineProps<{
workspaceSlug?: MaybeNullOrUndefined<string>
}>()
const open = defineModel<boolean>('open', { required: true })
const createDashboard = useCreateDashboard()
const dashboardName = ref('')
watch(open, (newValue, oldValue) => {
if (newValue && !oldValue) {
dashboardName.value = ''
}
})
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'outline' },
onClick: () => (open.value = false)
},
{
text: 'Create',
props: {
submit: true
},
onClick: () => {
open.value = false
}
}
])
const onSubmit = async () => {
await createDashboard({
identifier: { slug: props.workspaceSlug },
input: { name: dashboardName.value }
})
open.value = false
}
</script>
@@ -0,0 +1,85 @@
<template>
<LayoutDialog
v-model:open="open"
title="Edit dashboard"
:buttons="dialogButtons"
:on-submit="onSubmit"
max-width="xs"
>
<FormTextInput
v-model="dashboardName"
name="name"
label="Dashboard name"
placeholder="Name"
color="foundation"
:rules="[isRequired, isStringOfLength({ maxLength: 512 })]"
auto-focus
autocomplete="off"
show-label
class="mb-2"
/>
</LayoutDialog>
</template>
<script setup lang="ts">
import { isRequired, isStringOfLength } from '~~/lib/common/helpers/validation'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useUpdateDashboard } from '~/lib/dashboards/composables/management'
import { graphql } from '~~/lib/common/generated/gql'
import type { DashboardsEditDialog_DashboardFragment } from '~~/lib/common/generated/gql/graphql'
import type { MaybeNullOrUndefined } from '@speckle/shared'
graphql(`
fragment DashboardsEditDialog_Dashboard on Dashboard {
id
name
workspace {
id
}
}
`)
const props = defineProps<{
dashboard: MaybeNullOrUndefined<DashboardsEditDialog_DashboardFragment>
}>()
const open = defineModel<boolean>('open', { required: true })
const updateDashboard = useUpdateDashboard()
const dashboardName = ref()
watch(open, (newValue, oldValue) => {
if (newValue && !oldValue) {
dashboardName.value = props.dashboard?.name || ''
}
})
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'outline' },
onClick: () => (open.value = false)
},
{
text: 'Create',
props: {
submit: true
}
}
])
const onSubmit = async () => {
if (!props.dashboard || !props.dashboard.id || !props.dashboard.workspace.id) return
await updateDashboard(
{
id: props.dashboard.id,
name: dashboardName.value
},
props.dashboard.workspace.id
)
open.value = false
}
</script>
@@ -0,0 +1,105 @@
<template>
<div class="flex flex-col gap-y-6">
<section class="flex items-center gap-2 justify-between">
<h1 class="text-heading-sm md:text-heading">Dashboards</h1>
<FormButton color="outline" @click="showCreateDashboardDialog = true">
Add dashboard
</FormButton>
</section>
<div
v-if="!isVeryFirstLoading && !result?.workspaceBySlug?.dashboards.items.length"
class="flex flex-col items-center justify-center gap-y-4 mx-auto my-14"
>
<h2 class="text-heading-sm text-foreground-2">
This workspace has no dashboards yet
</h2>
<FormButton
v-if="canCreateDashboards"
color="outline"
@click="showCreateDashboardDialog = true"
>
Add dashboard
</FormButton>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="dashboard in result?.workspaceBySlug?.dashboards.items"
:key="dashboard.id"
>
<DashboardsCard :dashboard="dashboard" :active-workspace-slug="workspaceSlug" />
</div>
</div>
<InfiniteLoading :settings="{ identifier }" @infinite="onInfiniteLoad" />
<DashboardsCreateDialog
v-model:open="showCreateDashboardDialog"
:workspace-slug="workspaceSlug"
/>
</div>
</template>
<script setup lang="ts">
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import { workspaceDashboardsQuery } from '~/lib/dashboards/graphql/queries'
import type { Nullable } from '@speckle/shared'
import { graphql } from '~~/lib/common/generated/gql'
import { useQuery } from '@vue/apollo-composable'
const canCreateDashboardsQuery = graphql(`
query DashboardsListCanCreateDashboards($slug: String!) {
workspaceBySlug(slug: $slug) {
permissions {
canCreateDashboards {
...FullPermissionCheckResult
}
}
}
}
`)
const route = useRoute()
const workspaceSlug = computed(() => route.params.slug as string)
const { result: canCreateDashboardsResult } = useQuery(
canCreateDashboardsQuery,
() => ({
slug: workspaceSlug.value
})
)
const {
identifier,
onInfiniteLoad,
isVeryFirstLoading,
query: { result }
} = usePaginatedQuery({
query: workspaceDashboardsQuery,
options: computed(() => ({
enabled: !!workspaceSlug.value
})),
baseVariables: computed(() => ({
workspaceSlug: workspaceSlug.value || '',
cursor: null as Nullable<string>
})),
resolveKey: () => [''],
resolveCurrentResult: (res) =>
res?.workspaceBySlug?.dashboards
? {
totalCount: res.workspaceBySlug.dashboards.items.length,
items: res.workspaceBySlug.dashboards.items
}
: undefined,
resolveNextPageVariables: (baseVars, cursor) => ({
...baseVars,
cursor
}),
resolveCursorFromVariables: (vars) => vars.cursor
})
const showCreateDashboardDialog = ref(false)
const canCreateDashboards = computed(
() =>
canCreateDashboardsResult.value?.workspaceBySlug?.permissions?.canCreateDashboards
?.authorized
)
</script>
@@ -0,0 +1,133 @@
<!-- 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'
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 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 || '' })
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 url = `${window.location.origin}${route.path}?embed=true`
copy(url, { successMessage: 'Embed link copied to clipboard' })
}
}
</script>
@@ -6,6 +6,9 @@
<div>
<slot name="header-left" />
</div>
<div>
<slot name="header-center" />
</div>
<div>
<slot name="header-right" />
</div>
@@ -27,7 +27,7 @@
<PortalTarget name="primary-actions"></PortalTarget>
</ClientOnly>
<HeaderNavNotifications v-if="isLoggedIn" />
<div class="flex justify-end gap-x-2">
<div v-if="!hideUserNav" class="flex justify-end gap-x-2">
<FormButton
v-if="!activeUser"
:to="loginUrl.fullPath"
@@ -50,6 +50,10 @@ import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { loginRoute } from '~~/lib/common/helpers/route'
import type { Optional } from '@speckle/shared'
defineProps<{
hideUserNav?: boolean
}>()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { activeUser, isLoggedIn } = useActiveUser()
const route = useRoute()
@@ -1,9 +1,13 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<Menu as="div" class="flex items-center relative">
<MenuButton :id="menuButtonId" as="div">
<MenuButton :id="menuButtonId" v-slot="{ open }" as="div">
<!-- Desktop Button -->
<FormButton class="hidden sm:flex" :icon-right="ChevronDownIcon">
<FormButton
color="outline"
class="hidden sm:flex"
:icon-right="open ? ChevronUpIcon : ChevronDownIcon"
>
Share
</FormButton>
<!-- Mobile Button -->
@@ -26,13 +30,13 @@
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute z-50 flex flex-col gap-1 right-0 sm:right-4 top-8 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"
class="absolute z-50 flex flex-col gap-1 right-0 top-11 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-sm flex px-2 py-1.5 text-foreground cursor-pointer transition mx-1.5 rounded'
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
@click="handleCopyLink"
@keypress="keyboardClick(handleCopyLink)"
@@ -44,7 +48,7 @@
<div
:class="[
active ? 'bg-highlight-1' : '',
'text-body-sm flex px-2 py-1.5 text-foreground cursor-pointer transition mx-1.5 rounded'
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
@click="handleCopyId"
@keypress="keyboardClick(handleCopyId)"
@@ -56,7 +60,7 @@
<div
:class="[
active ? 'bg-highlight-1' : '',
'text-body-sm flex px-2 py-1.5 text-foreground cursor-pointer transition mx-1.5 rounded'
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
@click="handleEmbed"
@keypress="keyboardClick(handleEmbed)"
@@ -72,7 +76,7 @@
<script setup lang="ts">
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
import { ShareIcon } from '@heroicons/vue/24/outline'
import { ChevronDownIcon } from '@heroicons/vue/20/solid'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/20/solid'
import { SpeckleViewer } from '@speckle/shared'
import { keyboardClick } from '@speckle/ui-components'
import { graphql } from '~/lib/common/generated/gql/gql'
@@ -92,4 +92,11 @@ export const useIsNoPersonalEmailsEnabled = () => {
return ref(FF_NO_PERSONAL_EMAILS_ENABLED)
}
export const useIsDashboardsModuleEnabled = () => {
const {
public: { FF_DASHBOARDS_MODULE_ENABLED }
} = useRuntimeConfig()
return ref(FF_DASHBOARDS_MODULE_ENABLED)
}
export { useGlobalToast, useActiveUser, usePageQueryStandardFetchPolicy, useEventBus }
+28
View File
@@ -0,0 +1,28 @@
<template>
<div>
<ClientOnly>
<HeaderNavBar v-if="!isEmbedEnabled" hide-user-nav />
</ClientOnly>
<div class="h-dvh w-dvh overflow-hidden flex flex-col">
<!-- Static Spacer to allow for absolutely positioned HeaderNavBar -->
<div v-if="!isEmbedEnabled" class="h-12 w-full shrink-0"></div>
<div
class="relative flex"
:class="isEmbedEnabled ? 'h-[100dvh]' : 'h-[calc(100dvh-3rem)]'"
>
<main class="w-full h-full overflow-y-auto simple-scrollbar">
<div class="container w-full">
<slot />
</div>
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useDashboardEmbed } from '~/lib/dashboards/composables/embed'
const { isEmbedEnabled } = useDashboardEmbed()
</script>
@@ -247,9 +247,16 @@ export const useAuthManager = (
const embedToken = computed(() => route.query.embedToken as Optional<string>)
/**
* Get the effective auth token (embed token takes precedence)
* Token used for dashboard sharing
*/
const effectiveAuthToken = computed(() => embedToken.value || authToken.value)
const dashboardToken = computed(() => route.query.token as Optional<string>)
/**
* Get the effective auth token
*/
const effectiveAuthToken = computed(
() => dashboardToken.value || embedToken.value || authToken.value
)
/**
* Set/clear new token value and redirect to home
@@ -42,6 +42,12 @@ type Documents = {
"\n fragment BillingAlert_Workspace on Workspace {\n id\n role\n slug\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": typeof types.BillingAlert_WorkspaceFragmentDoc,
"\n fragment CommonModelSelectorModel on Model {\n id\n name\n }\n": typeof types.CommonModelSelectorModelFragmentDoc,
"\n query DashboardSidebar {\n activeUser {\n id\n activeWorkspace {\n id\n role\n }\n }\n }\n": typeof types.DashboardSidebarDocument,
"\n query SidebarPermissions($slug: String!) {\n workspaceBySlug(slug: $slug) {\n permissions {\n canListDashboards {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.SidebarPermissionsDocument,
"\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 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,
@@ -251,6 +257,11 @@ type Documents = {
"\n fragment UseFileImport_Project on Project {\n id\n }\n": typeof types.UseFileImport_ProjectFragmentDoc,
"\n fragment UseFileImport_Model on Model {\n id\n name\n }\n": typeof types.UseFileImport_ModelFragmentDoc,
"\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n configuration {\n isEmailEnabled\n }\n }\n }\n": typeof types.MainServerInfoDataDocument,
"\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 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,
"\n mutation CreateAccessToken($token: ApiTokenCreateInput!) {\n apiTokenCreate(token: $token)\n }\n": typeof types.CreateAccessTokenDocument,
"\n mutation DeleteApplication($appId: String!) {\n appDelete(appId: $appId)\n }\n": typeof types.DeleteApplicationDocument,
@@ -517,6 +528,7 @@ type Documents = {
"\n fragment SettingsWorkspacesRegions_Workspace on Workspace {\n id\n role\n defaultRegion {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n hasAccessToMultiRegion: hasAccessToFeature(\n featureName: workspaceDataRegionSpecificity\n )\n hasProjects: projects(limit: 0) {\n totalCount\n }\n }\n": typeof types.SettingsWorkspacesRegions_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesRegions_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": typeof types.SettingsWorkspacesRegions_ServerInfoFragmentDoc,
"\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n ...SettingsWorkspacesSecurityDefaultSeat_Workspace\n ...SettingsWorkspacesSecurityDomainManagement_Workspace\n ...SettingsWorkspacesSecurityDiscoverability_Workspace\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n ...SettingsWorkspacesSecurityDomainProtection_Workspace\n ...SettingsWorkspacesSecurityWorkspaceCreation_Workspace\n id\n slug\n }\n": typeof types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc,
"\n fragment WorkspaceDashboards_Dashboard on Dashboard {\n ...DashboardsEditDialog_Dashboard\n id\n name\n createdBy {\n id\n name\n avatar\n }\n createdAt\n updatedAt\n workspace {\n id\n name\n slug\n logo\n }\n }\n": typeof types.WorkspaceDashboards_DashboardFragmentDoc,
"\n fragment WorkspacePage_Workspace on Workspace {\n ...WorkspaceDashboard_Workspace\n ...WorkspaceSidebar_Workspace\n }\n": typeof types.WorkspacePage_WorkspaceFragmentDoc,
};
const documents: Documents = {
@@ -548,6 +560,12 @@ const documents: Documents = {
"\n fragment BillingAlert_Workspace on Workspace {\n id\n role\n slug\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": types.BillingAlert_WorkspaceFragmentDoc,
"\n fragment CommonModelSelectorModel on Model {\n id\n name\n }\n": types.CommonModelSelectorModelFragmentDoc,
"\n query DashboardSidebar {\n activeUser {\n id\n activeWorkspace {\n id\n role\n }\n }\n }\n": types.DashboardSidebarDocument,
"\n query SidebarPermissions($slug: String!) {\n workspaceBySlug(slug: $slug) {\n permissions {\n canListDashboards {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.SidebarPermissionsDocument,
"\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 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,
@@ -757,6 +775,11 @@ const documents: Documents = {
"\n fragment UseFileImport_Project on Project {\n id\n }\n": types.UseFileImport_ProjectFragmentDoc,
"\n fragment UseFileImport_Model on Model {\n id\n name\n }\n": types.UseFileImport_ModelFragmentDoc,
"\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n configuration {\n isEmailEnabled\n }\n }\n }\n": types.MainServerInfoDataDocument,
"\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 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,
"\n mutation CreateAccessToken($token: ApiTokenCreateInput!) {\n apiTokenCreate(token: $token)\n }\n": types.CreateAccessTokenDocument,
"\n mutation DeleteApplication($appId: String!) {\n appDelete(appId: $appId)\n }\n": types.DeleteApplicationDocument,
@@ -1023,6 +1046,7 @@ const documents: Documents = {
"\n fragment SettingsWorkspacesRegions_Workspace on Workspace {\n id\n role\n defaultRegion {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n hasAccessToMultiRegion: hasAccessToFeature(\n featureName: workspaceDataRegionSpecificity\n )\n hasProjects: projects(limit: 0) {\n totalCount\n }\n }\n": types.SettingsWorkspacesRegions_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesRegions_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": types.SettingsWorkspacesRegions_ServerInfoFragmentDoc,
"\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n ...SettingsWorkspacesSecurityDefaultSeat_Workspace\n ...SettingsWorkspacesSecurityDomainManagement_Workspace\n ...SettingsWorkspacesSecurityDiscoverability_Workspace\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n ...SettingsWorkspacesSecurityDomainProtection_Workspace\n ...SettingsWorkspacesSecurityWorkspaceCreation_Workspace\n id\n slug\n }\n": types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc,
"\n fragment WorkspaceDashboards_Dashboard on Dashboard {\n ...DashboardsEditDialog_Dashboard\n id\n name\n createdBy {\n id\n name\n avatar\n }\n createdAt\n updatedAt\n workspace {\n id\n name\n slug\n logo\n }\n }\n": types.WorkspaceDashboards_DashboardFragmentDoc,
"\n fragment WorkspacePage_Workspace on Workspace {\n ...WorkspaceDashboard_Workspace\n ...WorkspaceSidebar_Workspace\n }\n": types.WorkspacePage_WorkspaceFragmentDoc,
};
@@ -1152,6 +1176,30 @@ export function graphql(source: "\n fragment CommonModelSelectorModel on Model
* 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 DashboardSidebar {\n activeUser {\n id\n activeWorkspace {\n id\n role\n }\n }\n }\n"): (typeof documents)["\n query DashboardSidebar {\n activeUser {\n id\n activeWorkspace {\n id\n role\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 SidebarPermissions($slug: String!) {\n workspaceBySlug(slug: $slug) {\n permissions {\n canListDashboards {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"): (typeof documents)["\n query SidebarPermissions($slug: String!) {\n workspaceBySlug(slug: $slug) {\n permissions {\n canListDashboards {\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 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 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"];
/**
* 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 DashboardsEditDialog_Dashboard on Dashboard {\n id\n name\n workspace {\n id\n }\n }\n"): (typeof documents)["\n fragment DashboardsEditDialog_Dashboard on Dashboard {\n id\n name\n workspace {\n 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 DashboardsListCanCreateDashboards($slug: String!) {\n workspaceBySlug(slug: $slug) {\n permissions {\n canCreateDashboards {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"): (typeof documents)["\n query DashboardsListCanCreateDashboards($slug: String!) {\n workspaceBySlug(slug: $slug) {\n permissions {\n canCreateDashboards {\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 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.
*/
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"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1988,6 +2036,26 @@ export function graphql(source: "\n fragment UseFileImport_Model on Model {\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 MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n configuration {\n isEmailEnabled\n }\n }\n }\n"): (typeof documents)["\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n configuration {\n isEmailEnabled\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 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 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"];
/**
* 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 UpdateDashboard($input: DashboardUpdateInput!) {\n dashboardMutations {\n update(input: $input) {\n id\n name\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateDashboard($input: DashboardUpdateInput!) {\n dashboardMutations {\n update(input: $input) {\n id\n name\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n 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 Dashboard($id: String!) {\n dashboard(id: $id) {\n id\n ...WorkspaceDashboards_Dashboard\n }\n }\n"): (typeof documents)["\n query Dashboard($id: String!) {\n dashboard(id: $id) {\n id\n ...WorkspaceDashboards_Dashboard\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 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 documents)["\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"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -3052,6 +3120,10 @@ export function graphql(source: "\n fragment SettingsWorkspacesRegions_ServerIn
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n ...SettingsWorkspacesSecurityDefaultSeat_Workspace\n ...SettingsWorkspacesSecurityDomainManagement_Workspace\n ...SettingsWorkspacesSecurityDiscoverability_Workspace\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n ...SettingsWorkspacesSecurityDomainProtection_Workspace\n ...SettingsWorkspacesSecurityWorkspaceCreation_Workspace\n id\n slug\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n ...SettingsWorkspacesSecurityDefaultSeat_Workspace\n ...SettingsWorkspacesSecurityDomainManagement_Workspace\n ...SettingsWorkspacesSecurityDiscoverability_Workspace\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n ...SettingsWorkspacesSecurityDomainProtection_Workspace\n ...SettingsWorkspacesSecurityWorkspaceCreation_Workspace\n id\n slug\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 WorkspaceDashboards_Dashboard on Dashboard {\n ...DashboardsEditDialog_Dashboard\n id\n name\n createdBy {\n id\n name\n avatar\n }\n createdAt\n updatedAt\n workspace {\n id\n name\n slug\n logo\n }\n }\n"): (typeof documents)["\n fragment WorkspaceDashboards_Dashboard on Dashboard {\n ...DashboardsEditDialog_Dashboard\n id\n name\n createdBy {\n id\n name\n avatar\n }\n createdAt\n updatedAt\n workspace {\n id\n name\n slug\n logo\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
@@ -152,11 +152,18 @@ export const workspaceRoute = (slug: MaybeNullOrUndefined<string>) =>
slug ? `/workspaces/${slug}` : '/'
export const workspaceSsoRoute = (slug: string) => `/workspaces/${slug}/sso`
export const dashboardsRoute = (slug?: MaybeNullOrUndefined<string>) =>
`/workspaces/${slug}/dashboards`
export const dashboardRoute = (slug?: MaybeNullOrUndefined<string>, id?: string) =>
`/workspaces/${slug}/dashboards/${id}`
export const workspaceCreateRoute = '/workspaces/actions/create'
export const workspaceJoinRoute = '/workspaces/actions/join'
export const workspaceFunctionsRoute = (slug: string) => `/workspaces/${slug}/functions`
export const workspaceFunctionsRoute = (slug?: string) =>
`/workspaces/${slug}/functions`
const buildNavigationComposable = (route: string) => () => {
const router = useRouter()
@@ -0,0 +1,52 @@
import { computed } from 'vue'
import { useRoute } from 'vue-router'
export function useDashboardEmbed() {
const route = useRoute()
const isEmbedEnabled = computed(() => {
// Check for embed parameter in query string
if (route.query.embed === 'true') {
return true
}
// Check for embed parameter in hash fragment
if (route.hash) {
const hashParams = new URLSearchParams(route.hash.substring(1))
const embedParam = hashParams.get('embed')
if (embedParam === 'true') {
return true
}
}
// Check if the page is being loaded in an iframe
if (import.meta.client) {
try {
return window.self !== window.top
} catch {
// If we can't access window.top due to cross-origin restrictions,
// it's likely an embed
return true
}
}
return false
})
const isCrossOriginEmbed = computed(() => {
if (!isEmbedEnabled.value || !import.meta.client) return false
try {
return window.location.origin !== window.top?.location.origin
} catch {
// If we can't access window.top.location.origin due to cross-origin restrictions,
// it's definitely a cross-origin embed
return true
}
})
return {
isEmbedEnabled,
isCrossOriginEmbed
}
}
@@ -0,0 +1,148 @@
import { useApolloClient, useMutation } from '@vue/apollo-composable'
import {
getFirstErrorMessage,
modifyObjectField,
getCacheId
} from '~/lib/common/helpers/graphql'
import type {
DashboardCreateInput,
DashboardUpdateInput,
WorkspaceIdentifier
} from '~/lib/common/generated/gql/graphql'
import {
createDashboardMutation,
updateDashboardMutation,
deleteDashboardMutation
} from '~/lib/dashboards/graphql/mutations'
import { useMixpanel } from '~/lib/core/composables/mp'
export function useCreateDashboard() {
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const { activeUser } = useActiveUser()
const { track } = useMixpanel()
return async (options: {
identifier: WorkspaceIdentifier
input: DashboardCreateInput
}) => {
const userId = activeUser.value?.id
if (!userId) return
const { identifier, input } = options
const res = await apollo
.mutate({
mutation: createDashboardMutation,
variables: { workspace: identifier, input },
update: (cache, { data }) => {
const dashboardId = data?.dashboardMutations.create.id
const workspaceId = data?.dashboardMutations.create.workspace.id
if (!dashboardId || !workspaceId) return
modifyObjectField(
cache,
getCacheId('Workspace', workspaceId),
'dashboards',
({ helpers: { createUpdatedValue, ref } }) => {
return createUpdatedValue(({ update }) => {
update('totalCount', (totalCount) => totalCount + 1)
update('items', (items) => [ref('Dashboard', dashboardId), ...items])
})
},
{
autoEvictFiltered: true
}
)
}
})
.catch(convertThrowIntoFetchResult)
if (res.data?.dashboardMutations.create) {
track('Dashboard Created', {
// eslint-disable-next-line camelcase
workspace_id: res.data.dashboardMutations.create.workspace.id
})
triggerNotification({
type: ToastNotificationType.Success,
title: 'Dashboard successfully created'
})
} else {
const err = getFirstErrorMessage(res.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Dashboard creation failed',
description: err
})
}
return res
}
}
export function useUpdateDashboard() {
const { mutate } = useMutation(updateDashboardMutation)
const { triggerNotification } = useGlobalToast()
const { track } = useMixpanel()
return async (input: DashboardUpdateInput, workspaceId: string) => {
const result = await mutate({ input }).catch(convertThrowIntoFetchResult)
if (result?.data?.dashboardMutations.update) {
track('Dashboard Updated', {
// eslint-disable-next-line camelcase
workspace_id: workspaceId
})
triggerNotification({
type: ToastNotificationType.Success,
title: 'Dashboard successfully updated'
})
} else {
const err = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Dashboard update failed',
description: err
})
}
}
}
export function useDeleteDashboard() {
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const { track } = useMixpanel()
return async (id: string, workspaceId: string) => {
const res = await apollo
.mutate({
mutation: deleteDashboardMutation,
variables: { id },
update: (cache, { data }) => {
if (!data?.dashboardMutations?.delete) return
cache.evict({ id: getCacheId('Dashboard', id) })
}
})
.catch(convertThrowIntoFetchResult)
if (res.data?.dashboardMutations.delete) {
track('Dashboard Deleted', {
// eslint-disable-next-line camelcase
workspace_id: workspaceId
})
triggerNotification({
type: ToastNotificationType.Success,
title: 'Dashboard successfully deleted'
})
} else {
const err = getFirstErrorMessage(res.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Dashboard deletion failed',
description: err
})
}
}
}
@@ -0,0 +1,36 @@
import { graphql } from '~~/lib/common/generated/gql'
export const createDashboardMutation = graphql(`
mutation CreateDashboard(
$workspace: WorkspaceIdentifier!
$input: DashboardCreateInput!
) {
dashboardMutations {
create(workspace: $workspace, input: $input) {
id
workspace {
id
}
}
}
}
`)
export const updateDashboardMutation = graphql(`
mutation UpdateDashboard($input: DashboardUpdateInput!) {
dashboardMutations {
update(input: $input) {
id
name
}
}
}
`)
export const deleteDashboardMutation = graphql(`
mutation DeleteDashboard($id: String!) {
dashboardMutations {
delete(id: $id)
}
}
`)
@@ -0,0 +1,25 @@
import { graphql } from '~~/lib/common/generated/gql'
export const dashboardQuery = graphql(`
query Dashboard($id: String!) {
dashboard(id: $id) {
id
...WorkspaceDashboards_Dashboard
}
}
`)
export const workspaceDashboardsQuery = graphql(`
query WorkspaceDashboards($workspaceSlug: String!, $cursor: String) {
workspaceBySlug(slug: $workspaceSlug) {
id
dashboards(cursor: $cursor) {
cursor
items {
id
...DashboardsCard_Dashboard
}
}
}
}
`)
+1
View File
@@ -84,6 +84,7 @@ export default defineNuxtConfig({
datadogService: '',
datadogEnv: '',
intercomAppId: '',
dashboardsOrigin: '',
parallelMiddlewares: true
}
},
@@ -0,0 +1,126 @@
<template>
<div>
<Portal to="navigation">
<div class="flex items-center">
<HeaderNavLink
:to="dashboardsRoute(workspace?.slug)"
name="Dashboard"
:separator="false"
/>
<HeaderNavLink
:to="dashboardRoute(workspace?.slug, id as string)"
:name="dashboard?.name"
/>
<FormButton
v-tippy="'Edit name'"
size="sm"
color="subtle"
class="ml-2"
hide-text
:icon-right="Pencil"
@click="toggleEditDialog"
/>
</div>
</Portal>
<Portal to="primary-actions">
<div class="flex items-center gap-2">
<DashboardsShare :id="dashboard?.id" />
<FormButton
v-tippy="'Toggle fullscreen'"
size="sm"
color="outline"
:icon-right="Fullscreen"
hide-text
@click="toggleFullScreen()"
>
Fullscreen
</FormButton>
</div>
</Portal>
<div class="w-screen h-screen">
<iframe
:src="dashboardUrl"
class="w-full h-full border-0"
frameborder="0"
:title="dashboard?.name"
/>
</div>
<DashboardsEditDialog v-model:open="editDialogOpen" :dashboard="dashboard" />
</div>
</template>
<script setup lang="ts">
import { dashboardsRoute, dashboardRoute } from '~/lib/common/helpers/route'
import { dashboardQuery } from '~/lib/dashboards/graphql/queries'
import { useQuery } from '@vue/apollo-composable'
import { graphql } from '~~/lib/common/generated/gql'
import { useAuthManager } from '~/lib/auth/composables/auth'
import { Fullscreen, Pencil } from 'lucide-vue-next'
import { useTheme } from '~/lib/core/composables/theme'
graphql(`
fragment WorkspaceDashboards_Dashboard on Dashboard {
...DashboardsEditDialog_Dashboard
id
name
createdBy {
id
name
avatar
}
createdAt
updatedAt
workspace {
id
name
slug
logo
}
}
`)
definePageMeta({
layout: 'dashboard'
})
const { id } = useRoute().params
const { token: urlToken } = useRoute().query
const { result } = useQuery(dashboardQuery, () => ({ id: id as string }))
const { effectiveAuthToken } = useAuthManager()
const logger = useLogger()
const { isDarkTheme } = useTheme()
const {
public: { dashboardsOrigin }
} = useRuntimeConfig()
const editDialogOpen = ref(false)
const workspace = computed(() => result.value?.dashboard?.workspace)
const dashboard = computed(() => result.value?.dashboard)
const dashboardUrl = computed(() => {
return urlToken
? `${dashboardsOrigin}/view/${id}?token=${urlToken}&isEmbed=true&theme=${
isDarkTheme.value ? 'dark' : 'light'
}`
: `${dashboardsOrigin}/dashboards/${id}?token=${
effectiveAuthToken.value
}&isEmbed=true&theme=${isDarkTheme.value ? 'dark' : 'light'}`
})
const toggleFullScreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
logger.warn(`Error attempting to enable fullscreen: ${err.message}`)
})
} else {
document.exitFullscreen().catch((err) => {
logger.warn(`Error attempting to exit fullscreen: ${err.message}`)
})
}
}
const toggleEditDialog = () => {
editDialogOpen.value = !editDialogOpen.value
}
</script>
@@ -0,0 +1,29 @@
<template>
<div>
<Portal to="navigation">
<HeaderNavLink
:to="dashboardsRoute(activeWorkspaceSlug)"
name="Intelligence"
:separator="false"
/>
</Portal>
<div>
<DashboardsList />
</div>
<DashboardsCreateDialog
v-model:open="showCreateDashboardDialog"
:workspace-slug="activeWorkspaceSlug"
/>
</div>
</template>
<script setup lang="ts">
import { dashboardsRoute } from '~/lib/common/helpers/route'
import { useActiveWorkspaceSlug } from '~/lib/user/composables/activeWorkspace'
const activeWorkspaceSlug = useActiveWorkspaceSlug()
const showCreateDashboardDialog = ref(false)
</script>
@@ -0,0 +1,47 @@
extend type Query {
dashboard(id: String!): Dashboard!
}
extend type Mutation {
dashboardMutations: DashboardMutations! @hasServerRole(role: SERVER_GUEST)
}
type Dashboard {
id: String!
name: String!
workspace: LimitedWorkspace!
createdBy: LimitedUser
"""
If null, this is a new dashboard and should be initialized by the client
"""
state: String
createdAt: DateTime!
updatedAt: DateTime!
}
type DashboardCollection {
items: [Dashboard!]!
cursor: String
totalCount: Int!
}
extend type Workspace {
dashboards(limit: Int! = 50, cursor: String): DashboardCollection!
}
type DashboardMutations {
create(workspace: WorkspaceIdentifier!, input: DashboardCreateInput!): Dashboard!
delete(id: String!): Boolean!
update(input: DashboardUpdateInput!): Dashboard!
}
input DashboardCreateInput {
name: String!
}
input DashboardUpdateInput {
id: String!
name: String
projectIds: [String!]
state: String
}
@@ -0,0 +1,15 @@
extend type WorkspacePermissionChecks {
canCreateDashboards: PermissionCheckResult!
canListDashboards: PermissionCheckResult!
}
extend type Dashboard {
permissions: DashboardPermissionChecks!
}
type DashboardPermissionChecks {
canCreateToken: PermissionCheckResult!
canDelete: PermissionCheckResult!
canEdit: PermissionCheckResult!
canRead: PermissionCheckResult!
}
@@ -0,0 +1,33 @@
type DashboardToken {
tokenId: String!
dashboard: Dashboard!
projects: [Project!]!
user: LimitedUser
createdAt: DateTime!
lifespan: BigInt!
lastUsed: DateTime!
}
extend type DashboardMutations {
createToken(dashboardId: String!): CreateDashboardTokenReturn!
}
input DashboardTokenCreateInput {
dashboardId: String!
lifespan: BigInt
}
type CreateDashboardTokenReturn {
token: String!
tokenMetadata: DashboardToken!
}
extend type Project {
dashboardTokens(cursor: String, limit: Int): DashboardTokenCollection!
}
type DashboardTokenCollection {
items: [DashboardToken!]!
totalCount: Int!
cursor: String
}
@@ -260,6 +260,11 @@ input PendingWorkspaceCollaboratorsFilter {
search: String
}
input WorkspaceIdentifier @oneOf {
id: String
slug: String
}
type Workspace {
id: ID!
name: String!
+8 -1
View File
@@ -201,7 +201,14 @@ const config: CodegenConfig = {
SavedViewGroupPermissionChecks:
'@/modules/viewer/helpers/graphTypes#SavedViewGroupPermissionChecksGraphQLReturn',
ExtendedViewerResources:
'@/modules/viewer/helpers/graphTypes#ExtendedViewerResourcesGraphQLReturn'
'@/modules/viewer/helpers/graphTypes#ExtendedViewerResourcesGraphQLReturn',
Dashboard: '@/modules/dashboards/helpers/graphTypes#DashboardGraphQLReturn',
DashboardMutations:
'@/modules/dashboards/helpers/graphTypes#DashboardMutationsGraphQLReturn',
DashboardPermissionChecks:
'@/modules/dashboards/helpers/graphTypes#DashboardPermissionChecksGraphQLReturn',
DashboardToken:
'@/modules/dashboards/helpers/graphTypes#DashboardTokenGraphQLReturn'
}
}
}
@@ -17,6 +17,7 @@ import type { GendoAIRenderGraphQLReturn } from '@/modules/gendo/helpers/types/g
import type { ServerRegionItemGraphQLReturn } from '@/modules/multiregion/helpers/graphTypes';
import type { AccSyncItemGraphQLReturn, AccSyncItemMutationsGraphQLReturn } from '@/modules/acc/helpers/graphTypes';
import type { SavedViewGraphQLReturn, SavedViewGroupGraphQLReturn, SavedViewPermissionChecksGraphQLReturn, SavedViewGroupPermissionChecksGraphQLReturn, ExtendedViewerResourcesGraphQLReturn } from '@/modules/viewer/helpers/graphTypes';
import type { DashboardGraphQLReturn, DashboardMutationsGraphQLReturn, DashboardPermissionChecksGraphQLReturn, DashboardTokenGraphQLReturn } from '@/modules/dashboards/helpers/graphTypes';
import type { GraphQLContext } from '@/modules/shared/helpers/typeHelper';
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type Maybe<T> = T | null;
@@ -1055,6 +1056,12 @@ export type CreateCommentReplyInput = {
threadId: Scalars['String']['input'];
};
export type CreateDashboardTokenReturn = {
__typename?: 'CreateDashboardTokenReturn';
token: Scalars['String']['output'];
tokenMetadata: DashboardToken;
};
export type CreateEmbedTokenReturn = {
__typename?: 'CreateEmbedTokenReturn';
token: Scalars['String']['output'];
@@ -1127,6 +1134,97 @@ export type CurrencyBasedPrices = {
usd: WorkspacePaidPlanPrices;
};
export type Dashboard = {
__typename?: 'Dashboard';
createdAt: Scalars['DateTime']['output'];
createdBy?: Maybe<LimitedUser>;
id: Scalars['String']['output'];
name: Scalars['String']['output'];
permissions: DashboardPermissionChecks;
/** If null, this is a new dashboard and should be initialized by the client */
state?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
workspace: LimitedWorkspace;
};
export type DashboardCollection = {
__typename?: 'DashboardCollection';
cursor?: Maybe<Scalars['String']['output']>;
items: Array<Dashboard>;
totalCount: Scalars['Int']['output'];
};
export type DashboardCreateInput = {
name: Scalars['String']['input'];
};
export type DashboardMutations = {
__typename?: 'DashboardMutations';
create: Dashboard;
createToken: CreateDashboardTokenReturn;
delete: Scalars['Boolean']['output'];
update: Dashboard;
};
export type DashboardMutationsCreateArgs = {
input: DashboardCreateInput;
workspace: WorkspaceIdentifier;
};
export type DashboardMutationsCreateTokenArgs = {
dashboardId: Scalars['String']['input'];
};
export type DashboardMutationsDeleteArgs = {
id: Scalars['String']['input'];
};
export type DashboardMutationsUpdateArgs = {
input: DashboardUpdateInput;
};
export type DashboardPermissionChecks = {
__typename?: 'DashboardPermissionChecks';
canCreateToken: PermissionCheckResult;
canDelete: PermissionCheckResult;
canEdit: PermissionCheckResult;
canRead: PermissionCheckResult;
};
export type DashboardToken = {
__typename?: 'DashboardToken';
createdAt: Scalars['DateTime']['output'];
dashboard: Dashboard;
lastUsed: Scalars['DateTime']['output'];
lifespan: Scalars['BigInt']['output'];
projects: Array<Project>;
tokenId: Scalars['String']['output'];
user?: Maybe<LimitedUser>;
};
export type DashboardTokenCollection = {
__typename?: 'DashboardTokenCollection';
cursor?: Maybe<Scalars['String']['output']>;
items: Array<DashboardToken>;
totalCount: Scalars['Int']['output'];
};
export type DashboardTokenCreateInput = {
dashboardId: Scalars['String']['input'];
lifespan?: InputMaybe<Scalars['BigInt']['input']>;
};
export type DashboardUpdateInput = {
id: Scalars['String']['input'];
name?: InputMaybe<Scalars['String']['input']>;
projectIds?: InputMaybe<Array<Scalars['String']['input']>>;
state?: InputMaybe<Scalars['String']['input']>;
};
export type DeleteAccSyncItemInput = {
id: Scalars['ID']['input'];
projectId: Scalars['String']['input'];
@@ -1799,6 +1897,7 @@ export type Mutation = {
* @deprecated Part of the old API surface and will be removed in the future. Use VersionMutations.moveToModel instead.
*/
commitsMove: Scalars['Boolean']['output'];
dashboardMutations: DashboardMutations;
fileUploadMutations: FileUploadMutations;
/**
* Delete a pending invite
@@ -2375,6 +2474,7 @@ export type Project = {
/** All comment threads in this project */
commentThreads: ProjectCommentCollection;
createdAt: Scalars['DateTime']['output'];
dashboardTokens: DashboardTokenCollection;
description?: Maybe<Scalars['String']['output']>;
/** Public project-level configuration for embedded viewer */
embedOptions: ProjectEmbedOptions;
@@ -2483,6 +2583,12 @@ export type ProjectCommentThreadsArgs = {
};
export type ProjectDashboardTokensArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
};
export type ProjectEmbedTokensArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -3203,6 +3309,7 @@ export type Query = {
* @deprecated Use Project/Version/Model 'commentThreads' fields instead
*/
comments?: Maybe<CommentCollection>;
dashboard: Dashboard;
/**
* All of the discoverable streams of the server
* @deprecated Part of the old API surface and will be removed in the future.
@@ -3344,6 +3451,11 @@ export type QueryCommentsArgs = {
};
export type QueryDashboardArgs = {
id: Scalars['String']['input'];
};
export type QueryDiscoverableStreamsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: Scalars['Int']['input'];
@@ -5146,6 +5258,7 @@ export type Workspace = {
/** Info about the workspace creation state */
creationState?: Maybe<WorkspaceCreationState>;
customerPortalUrl?: Maybe<Scalars['String']['output']>;
dashboards: DashboardCollection;
/**
* The default role workspace members will receive for workspace projects.
* @deprecated Always the reviewer role. Will be removed in the future.
@@ -5214,6 +5327,12 @@ export type WorkspaceAutomateFunctionsArgs = {
};
export type WorkspaceDashboardsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: Scalars['Int']['input'];
};
export type WorkspaceHasAccessToFeatureArgs = {
featureName: WorkspaceFeatureName;
};
@@ -5346,6 +5465,10 @@ export const WorkspaceFeatureName = {
} as const;
export type WorkspaceFeatureName = typeof WorkspaceFeatureName[keyof typeof WorkspaceFeatureName];
export type WorkspaceIdentifier =
{ id: Scalars['String']['input']; slug?: never; }
| { id?: never; slug: Scalars['String']['input']; };
export type WorkspaceInviteCreateInput = {
/** Either this or userId must be filled */
email?: InputMaybe<Scalars['String']['input']>;
@@ -5571,9 +5694,11 @@ export const WorkspacePaymentMethod = {
export type WorkspacePaymentMethod = typeof WorkspacePaymentMethod[keyof typeof WorkspacePaymentMethod];
export type WorkspacePermissionChecks = {
__typename?: 'WorkspacePermissionChecks';
canCreateDashboards: PermissionCheckResult;
canCreateProject: PermissionCheckResult;
canEditEmbedOptions: PermissionCheckResult;
canInvite: PermissionCheckResult;
canListDashboards: PermissionCheckResult;
canMakeWorkspaceExclusive: PermissionCheckResult;
canMoveProjectToWorkspace: PermissionCheckResult;
canReadMemberEmail: PermissionCheckResult;
@@ -6019,6 +6144,7 @@ export type ResolversTypes = {
CreateAutomateFunctionWithoutVersionInput: CreateAutomateFunctionWithoutVersionInput;
CreateCommentInput: CreateCommentInput;
CreateCommentReplyInput: CreateCommentReplyInput;
CreateDashboardTokenReturn: ResolverTypeWrapper<Omit<CreateDashboardTokenReturn, 'tokenMetadata'> & { tokenMetadata: ResolversTypes['DashboardToken'] }>;
CreateEmbedTokenReturn: ResolverTypeWrapper<Omit<CreateEmbedTokenReturn, 'tokenMetadata'> & { tokenMetadata: ResolversTypes['EmbedToken'] }>;
CreateModelInput: CreateModelInput;
CreateSavedViewGroupInput: CreateSavedViewGroupInput;
@@ -6028,6 +6154,15 @@ export type ResolversTypes = {
CreateVersionInput: CreateVersionInput;
Currency: Currency;
CurrencyBasedPrices: ResolverTypeWrapper<Omit<CurrencyBasedPrices, 'gbp' | 'usd'> & { gbp: ResolversTypes['WorkspacePaidPlanPrices'], usd: ResolversTypes['WorkspacePaidPlanPrices'] }>;
Dashboard: ResolverTypeWrapper<DashboardGraphQLReturn>;
DashboardCollection: ResolverTypeWrapper<Omit<DashboardCollection, 'items'> & { items: Array<ResolversTypes['Dashboard']> }>;
DashboardCreateInput: DashboardCreateInput;
DashboardMutations: ResolverTypeWrapper<DashboardMutationsGraphQLReturn>;
DashboardPermissionChecks: ResolverTypeWrapper<DashboardPermissionChecksGraphQLReturn>;
DashboardToken: ResolverTypeWrapper<DashboardTokenGraphQLReturn>;
DashboardTokenCollection: ResolverTypeWrapper<Omit<DashboardTokenCollection, 'items'> & { items: Array<ResolversTypes['DashboardToken']> }>;
DashboardTokenCreateInput: DashboardTokenCreateInput;
DashboardUpdateInput: DashboardUpdateInput;
DateTime: ResolverTypeWrapper<Scalars['DateTime']['output']>;
DeleteAccSyncItemInput: DeleteAccSyncItemInput;
DeleteModelInput: DeleteModelInput;
@@ -6262,6 +6397,7 @@ export type ResolversTypes = {
WorkspaceEmbedOptions: ResolverTypeWrapper<WorkspaceEmbedOptions>;
WorkspaceFeatureFlagName: WorkspaceFeatureFlagName;
WorkspaceFeatureName: WorkspaceFeatureName;
WorkspaceIdentifier: WorkspaceIdentifier;
WorkspaceInviteCreateInput: WorkspaceInviteCreateInput;
WorkspaceInviteLookupOptions: WorkspaceInviteLookupOptions;
WorkspaceInviteMutations: ResolverTypeWrapper<WorkspaceInviteMutationsGraphQLReturn>;
@@ -6401,6 +6537,7 @@ export type ResolversParentTypes = {
CreateAutomateFunctionWithoutVersionInput: CreateAutomateFunctionWithoutVersionInput;
CreateCommentInput: CreateCommentInput;
CreateCommentReplyInput: CreateCommentReplyInput;
CreateDashboardTokenReturn: Omit<CreateDashboardTokenReturn, 'tokenMetadata'> & { tokenMetadata: ResolversParentTypes['DashboardToken'] };
CreateEmbedTokenReturn: Omit<CreateEmbedTokenReturn, 'tokenMetadata'> & { tokenMetadata: ResolversParentTypes['EmbedToken'] };
CreateModelInput: CreateModelInput;
CreateSavedViewGroupInput: CreateSavedViewGroupInput;
@@ -6409,6 +6546,15 @@ export type ResolversParentTypes = {
CreateUserEmailInput: CreateUserEmailInput;
CreateVersionInput: CreateVersionInput;
CurrencyBasedPrices: Omit<CurrencyBasedPrices, 'gbp' | 'usd'> & { gbp: ResolversParentTypes['WorkspacePaidPlanPrices'], usd: ResolversParentTypes['WorkspacePaidPlanPrices'] };
Dashboard: DashboardGraphQLReturn;
DashboardCollection: Omit<DashboardCollection, 'items'> & { items: Array<ResolversParentTypes['Dashboard']> };
DashboardCreateInput: DashboardCreateInput;
DashboardMutations: DashboardMutationsGraphQLReturn;
DashboardPermissionChecks: DashboardPermissionChecksGraphQLReturn;
DashboardToken: DashboardTokenGraphQLReturn;
DashboardTokenCollection: Omit<DashboardTokenCollection, 'items'> & { items: Array<ResolversParentTypes['DashboardToken']> };
DashboardTokenCreateInput: DashboardTokenCreateInput;
DashboardUpdateInput: DashboardUpdateInput;
DateTime: Scalars['DateTime']['output'];
DeleteAccSyncItemInput: DeleteAccSyncItemInput;
DeleteModelInput: DeleteModelInput;
@@ -6618,6 +6764,7 @@ export type ResolversParentTypes = {
WorkspaceDomain: WorkspaceDomain;
WorkspaceDomainDeleteInput: WorkspaceDomainDeleteInput;
WorkspaceEmbedOptions: WorkspaceEmbedOptions;
WorkspaceIdentifier: WorkspaceIdentifier;
WorkspaceInviteCreateInput: WorkspaceInviteCreateInput;
WorkspaceInviteLookupOptions: WorkspaceInviteLookupOptions;
WorkspaceInviteMutations: WorkspaceInviteMutationsGraphQLReturn;
@@ -7159,6 +7306,12 @@ export type CountOnlyCollectionResolvers<ContextType = GraphQLContext, ParentTyp
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CreateDashboardTokenReturnResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['CreateDashboardTokenReturn'] = ResolversParentTypes['CreateDashboardTokenReturn']> = {
token?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
tokenMetadata?: Resolver<ResolversTypes['DashboardToken'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CreateEmbedTokenReturnResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['CreateEmbedTokenReturn'] = ResolversParentTypes['CreateEmbedTokenReturn']> = {
token?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
tokenMetadata?: Resolver<ResolversTypes['EmbedToken'], ParentType, ContextType>;
@@ -7171,6 +7324,59 @@ export type CurrencyBasedPricesResolvers<ContextType = GraphQLContext, ParentTyp
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DashboardResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['Dashboard'] = ResolversParentTypes['Dashboard']> = {
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
createdBy?: Resolver<Maybe<ResolversTypes['LimitedUser']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
permissions?: Resolver<ResolversTypes['DashboardPermissionChecks'], ParentType, ContextType>;
state?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
workspace?: Resolver<ResolversTypes['LimitedWorkspace'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DashboardCollectionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['DashboardCollection'] = ResolversParentTypes['DashboardCollection']> = {
cursor?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
items?: Resolver<Array<ResolversTypes['Dashboard']>, ParentType, ContextType>;
totalCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DashboardMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['DashboardMutations'] = ResolversParentTypes['DashboardMutations']> = {
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'>>;
update?: Resolver<ResolversTypes['Dashboard'], ParentType, ContextType, RequireFields<DashboardMutationsUpdateArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DashboardPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['DashboardPermissionChecks'] = ResolversParentTypes['DashboardPermissionChecks']> = {
canCreateToken?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canDelete?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canEdit?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canRead?: Resolver<ResolversTypes['PermissionCheckResult'], 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>;
lastUsed?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
lifespan?: Resolver<ResolversTypes['BigInt'], ParentType, ContextType>;
projects?: Resolver<Array<ResolversTypes['Project']>, ParentType, ContextType>;
tokenId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
user?: Resolver<Maybe<ResolversTypes['LimitedUser']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DashboardTokenCollectionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['DashboardTokenCollection'] = ResolversParentTypes['DashboardTokenCollection']> = {
cursor?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
items?: Resolver<Array<ResolversTypes['DashboardToken']>, ParentType, ContextType>;
totalCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export interface DateTimeScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes['DateTime'], any> {
name: 'DateTime';
}
@@ -7436,6 +7642,7 @@ export type MutationResolvers<ContextType = GraphQLContext, ParentType extends R
commitUpdate?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationCommitUpdateArgs, 'commit'>>;
commitsDelete?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationCommitsDeleteArgs, 'input'>>;
commitsMove?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationCommitsMoveArgs, 'input'>>;
dashboardMutations?: Resolver<ResolversTypes['DashboardMutations'], ParentType, ContextType>;
fileUploadMutations?: Resolver<ResolversTypes['FileUploadMutations'], ParentType, ContextType>;
inviteDelete?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationInviteDeleteArgs, 'inviteId'>>;
inviteResend?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationInviteResendArgs, 'inviteId'>>;
@@ -7564,6 +7771,7 @@ export type ProjectResolvers<ContextType = GraphQLContext, ParentType extends Re
comment?: Resolver<Maybe<ResolversTypes['Comment']>, ParentType, ContextType, RequireFields<ProjectCommentArgs, 'id'>>;
commentThreads?: Resolver<ResolversTypes['ProjectCommentCollection'], ParentType, ContextType, Partial<ProjectCommentThreadsArgs>>;
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
dashboardTokens?: Resolver<ResolversTypes['DashboardTokenCollection'], ParentType, ContextType, Partial<ProjectDashboardTokensArgs>>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
embedOptions?: Resolver<ResolversTypes['ProjectEmbedOptions'], ParentType, ContextType>;
embedTokens?: Resolver<ResolversTypes['EmbedTokenCollection'], ParentType, ContextType, Partial<ProjectEmbedTokensArgs>>;
@@ -7817,6 +8025,7 @@ export type QueryResolvers<ContextType = GraphQLContext, ParentType extends Reso
automateValidateAuthCode?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<QueryAutomateValidateAuthCodeArgs, 'payload'>>;
comment?: Resolver<Maybe<ResolversTypes['Comment']>, ParentType, ContextType, RequireFields<QueryCommentArgs, 'id' | 'streamId'>>;
comments?: Resolver<Maybe<ResolversTypes['CommentCollection']>, ParentType, ContextType, RequireFields<QueryCommentsArgs, 'archived' | 'limit' | 'streamId'>>;
dashboard?: Resolver<ResolversTypes['Dashboard'], ParentType, ContextType, RequireFields<QueryDashboardArgs, 'id'>>;
discoverableStreams?: Resolver<Maybe<ResolversTypes['StreamCollection']>, ParentType, ContextType, RequireFields<QueryDiscoverableStreamsArgs, 'limit'>>;
otherUser?: Resolver<Maybe<ResolversTypes['LimitedUser']>, ParentType, ContextType, RequireFields<QueryOtherUserArgs, 'id'>>;
project?: Resolver<ResolversTypes['Project'], ParentType, ContextType, RequireFields<QueryProjectArgs, 'id'>>;
@@ -8441,6 +8650,7 @@ export type WorkspaceResolvers<ContextType = GraphQLContext, ParentType extends
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
creationState?: Resolver<Maybe<ResolversTypes['WorkspaceCreationState']>, ParentType, ContextType>;
customerPortalUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
dashboards?: Resolver<ResolversTypes['DashboardCollection'], ParentType, ContextType, RequireFields<WorkspaceDashboardsArgs, 'limit'>>;
defaultProjectRole?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
defaultRegion?: Resolver<Maybe<ResolversTypes['ServerRegionItem']>, ParentType, ContextType>;
defaultSeatType?: Resolver<ResolversTypes['WorkspaceSeatType'], ParentType, ContextType>;
@@ -8584,9 +8794,11 @@ export type WorkspacePaidPlanPricesResolvers<ContextType = GraphQLContext, Paren
};
export type WorkspacePermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspacePermissionChecks'] = ResolversParentTypes['WorkspacePermissionChecks']> = {
canCreateDashboards?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateProject?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canEditEmbedOptions?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canInvite?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canListDashboards?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canMakeWorkspaceExclusive?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canMoveProjectToWorkspace?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType, Partial<WorkspacePermissionChecksCanMoveProjectToWorkspaceArgs>>;
canReadMemberEmail?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
@@ -8754,8 +8966,15 @@ export type Resolvers<ContextType = GraphQLContext> = {
Commit?: CommitResolvers<ContextType>;
CommitCollection?: CommitCollectionResolvers<ContextType>;
CountOnlyCollection?: CountOnlyCollectionResolvers<ContextType>;
CreateDashboardTokenReturn?: CreateDashboardTokenReturnResolvers<ContextType>;
CreateEmbedTokenReturn?: CreateEmbedTokenReturnResolvers<ContextType>;
CurrencyBasedPrices?: CurrencyBasedPricesResolvers<ContextType>;
Dashboard?: DashboardResolvers<ContextType>;
DashboardCollection?: DashboardCollectionResolvers<ContextType>;
DashboardMutations?: DashboardMutationsResolvers<ContextType>;
DashboardPermissionChecks?: DashboardPermissionChecksResolvers<ContextType>;
DashboardToken?: DashboardTokenResolvers<ContextType>;
DashboardTokenCollection?: DashboardTokenCollectionResolvers<ContextType>;
DateTime?: GraphQLScalarType;
EmbedToken?: EmbedTokenResolvers<ContextType>;
EmbedTokenCollection?: EmbedTokenCollectionResolvers<ContextType>;
@@ -0,0 +1,13 @@
import { db } from '@/db/knex'
import { getDashboardRecordFactory } from '@/modules/dashboards/repositories/management'
import { defineModuleLoaders } from '@/modules/loaders'
export default defineModuleLoaders(async () => {
const getDashboard = getDashboardRecordFactory({ db })
return {
getDashboard: async ({ dashboardId }) => {
return (await getDashboard({ id: dashboardId })) ?? null
}
}
})
@@ -0,0 +1,18 @@
import { buildTableHelper } from '@/modules/core/dbSchema'
export const Dashboards = buildTableHelper('dashboards', [
'id',
'name',
'workspaceId',
'projectIds',
'ownerId',
'state',
'createdAt',
'updatedAt'
])
export const DashboardApiTokens = buildTableHelper('dashboard_api_tokens', [
'tokenId',
'dashboardId',
'userId'
])
@@ -0,0 +1,22 @@
import type { Dashboard } from '@/modules/dashboards/domain/types'
import type { Exact } from 'type-fest'
export type GetDashboardRecord = (args: {
id: string
}) => Promise<Dashboard | undefined>
export type DeleteDashboardRecord = (args: { id: string }) => Promise<number>
export type UpsertDashboardRecord = <T extends Exact<Dashboard, T>>(
item: T
) => Promise<void>
export type ListDashboardRecords = (args: {
workspaceId: string
filter?: {
updatedBefore: string | null
limit: number | null
}
}) => Promise<Dashboard[]>
export type CountDashboardRecords = (args: { workspaceId: string }) => Promise<number>
@@ -0,0 +1,6 @@
import type { 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>
@@ -0,0 +1,11 @@
export type DashboardApiTokenRecord = {
tokenId: string
dashboardId: string
userId: string
}
export type DashboardApiToken = DashboardApiTokenRecord & {
createdAt: Date
lastUsed: Date
lifespan: number | bigint
}
@@ -0,0 +1,13 @@
export type Dashboard = {
id: string
name: string
workspaceId: string
// TODO: Shortcut for permissions when sharing
projectIds: string[]
// TODO: Replace with some sort of acl concept
ownerId: string
// TODO: Anything other than this
state?: string
createdAt: Date
updatedAt: Date
}
@@ -0,0 +1,26 @@
import { BaseError } from '@/modules/shared/errors'
export class DashboardsModuleDisabledError extends BaseError {
static defaultMessage = 'Dashboards module is disabled'
static code = 'DASHBOARDS_MODULE_DISABLED'
static statusCode = 423
}
export class DashboardsNotYetImplementedError extends BaseError {
static defaultMessage = 'This dashboards feature is not yet implemented'
static code = 'DASHBOARDS_NOT_YET_IMPLEMENTED'
static statusCode = 501
}
export class DashboardNotFoundError extends BaseError {
static defaultMessage = 'Dashboard not found'
static code = 'DASHBOARDS_NOT_FOUND'
static statusCode = 404
}
export class DashboardMalformedTokenError extends BaseError {
static defaultMessage =
'Dashboard not associated with any projects. Cannot create token.'
static code = 'DASHBOARDS_MALFORMED_TOKEN'
static statusCode = 422
}
@@ -0,0 +1,155 @@
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
import {
countDashboardsFactory,
deleteDashboardRecordFactory,
getDashboardRecordFactory,
listDashboardsFactory,
upsertDashboardFactory
} from '@/modules/dashboards/repositories/management'
import { db } from '@/db/knex'
import {
createDashboardFactory,
getPaginatedDashboardsFactory,
getDashboardFactory,
updateDashboardFactory,
deleteDashboardFactory
} from '@/modules/dashboards/services/management'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { toLimitedWorkspace } from '@/modules/workspaces/domain/logic'
import { removeNullOrUndefinedKeys } from '@speckle/shared'
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'
const { FF_WORKSPACES_MODULE_ENABLED, FF_DASHBOARDS_MODULE_ENABLED } = getFeatureFlags()
const isEnabled = FF_WORKSPACES_MODULE_ENABLED && FF_DASHBOARDS_MODULE_ENABLED
const resolvers: Resolvers = {
Query: {
dashboard: async (_parent, args, context) => {
const authResult = await context.authPolicies.dashboard.canRead({
userId: context.userId,
dashboardId: args.id
})
throwIfAuthNotOk(authResult)
return await getDashboardFactory({
getDashboard: getDashboardRecordFactory({ db })
})({ id: args.id })
}
},
Mutation: {
dashboardMutations: async () => ({})
},
Dashboard: {
createdBy: async (parent, _args, context) => {
return await context.loaders.users.getUser.load(parent.ownerId)
},
workspace: async (parent, _args, context) => {
const workspace = await context.loaders.workspaces?.getWorkspace.load(
parent.workspaceId
)
if (!workspace) {
throw new WorkspaceNotFoundError()
}
return toLimitedWorkspace(workspace)
}
},
Workspace: {
dashboards: async (parent, args, context) => {
const authResult = await context.authPolicies.workspace.canListDashboards({
userId: context.userId,
workspaceId: parent.id
})
throwIfAuthNotOk(authResult)
return await getPaginatedDashboardsFactory({
listDashboards: listDashboardsFactory({ db }),
countDashboards: countDashboardsFactory({ db })
})({
workspaceId: parent.id,
filter: {
limit: args.limit,
cursor: args.cursor ?? null
}
})
}
},
DashboardMutations: {
create: async (_parent, args, context) => {
const { name } = args.input
const { id: workspaceId } = await parseWorkspaceIdentifier(
args.workspace,
context
)
const authResult = await context.authPolicies.workspace.canCreateDashboards({
userId: context.userId,
workspaceId
})
throwIfAuthNotOk(authResult)
return await createDashboardFactory({
upsertDashboard: upsertDashboardFactory({ db })
})({
name,
workspaceId,
ownerId: context.userId!
})
},
delete: async (_parent, args, context) => {
const { id: dashboardId } = args
const authResult = await context.authPolicies.dashboard.canDelete({
userId: context.userId,
dashboardId
})
throwIfAuthNotOk(authResult)
await deleteDashboardFactory({
deleteDashboard: deleteDashboardRecordFactory({ db })
})({ id: dashboardId })
return true
},
update: async (_parent, args, context) => {
const { id: dashboardId } = args.input
const authResult = await context.authPolicies.dashboard.canEdit({
userId: context.userId,
dashboardId
})
throwIfAuthNotOk(authResult)
return await updateDashboardFactory({
getDashboard: getDashboardRecordFactory({ db }),
upsertDashboard: upsertDashboardFactory({ db })
})(removeNullOrUndefinedKeys(args.input))
}
}
}
const disabledResolvers: Resolvers = {
Query: {
dashboard: async () => {
throw new DashboardsModuleDisabledError()
}
},
Mutation: {
dashboardMutations: async () => {
throw new DashboardsModuleDisabledError()
}
},
Workspace: {
dashboards: async () => {
throw new DashboardsModuleDisabledError()
}
}
}
export default isEnabled ? resolvers : disabledResolvers
@@ -0,0 +1,56 @@
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
import { Authz } from '@speckle/shared'
const resolvers: Resolvers = {
WorkspacePermissionChecks: {
canCreateDashboards: async (parent, _args, context) => {
const authResult = await context.authPolicies.workspace.canCreateDashboards({
workspaceId: parent.workspaceId,
userId: context.userId
})
return Authz.toGraphqlResult(authResult)
},
canListDashboards: async (parent, _args, context) => {
const authResult = await context.authPolicies.workspace.canListDashboards({
workspaceId: parent.workspaceId,
userId: context.userId
})
return Authz.toGraphqlResult(authResult)
}
},
Dashboard: {
permissions: (parent) => ({ dashboardId: parent.id })
},
DashboardPermissionChecks: {
canCreateToken: async (parent, _args, context) => {
const authResult = await context.authPolicies.dashboard.canCreateToken({
userId: context.userId,
dashboardId: parent.dashboardId
})
return Authz.toGraphqlResult(authResult)
},
canDelete: async (parent, _args, context) => {
const authResult = await context.authPolicies.dashboard.canDelete({
userId: context.userId,
dashboardId: parent.dashboardId
})
return Authz.toGraphqlResult(authResult)
},
canEdit: async (parent, _args, context) => {
const authResult = await context.authPolicies.dashboard.canEdit({
userId: context.userId,
dashboardId: parent.dashboardId
})
return Authz.toGraphqlResult(authResult)
},
canRead: async (parent, _args, context) => {
const authResult = await context.authPolicies.dashboard.canRead({
userId: context.userId,
dashboardId: parent.dashboardId
})
return Authz.toGraphqlResult(authResult)
}
}
}
export default resolvers
@@ -0,0 +1,90 @@
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
import { getDashboardRecordFactory } from '@/modules/dashboards/repositories/management'
import { getDashboardFactory } from '@/modules/dashboards/services/management'
import { db } from '@/db/knex'
import type { StreamRecord } from '@/modules/core/helpers/types'
import {
DashboardNotFoundError,
DashboardsModuleDisabledError
} from '@/modules/dashboards/errors/dashboards'
import { createDashboardTokenFactory } from '@/modules/dashboards/services/tokens'
import { createTokenFactory } from '@/modules/core/services/tokens'
import {
getApiTokenByIdFactory,
storeApiTokenFactory,
storeTokenResourceAccessDefinitionsFactory,
storeTokenScopesFactory
} from '@/modules/core/repositories/tokens'
import { storeDashboardApiTokenFactory } from '@/modules/dashboards/repositories/tokens'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
const { FF_WORKSPACES_MODULE_ENABLED, FF_DASHBOARDS_MODULE_ENABLED } = getFeatureFlags()
const isEnabled = FF_WORKSPACES_MODULE_ENABLED && FF_DASHBOARDS_MODULE_ENABLED
const resolvers: Resolvers = {
DashboardMutations: {
createToken: async (_parent, args, context) => {
const authResult = await context.authPolicies.dashboard.canCreateToken({
userId: context.userId,
dashboardId: args.dashboardId
})
throwIfAuthNotOk(authResult)
return 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!
})
}
},
DashboardToken: {
dashboard: async (parent) => {
const dashboard = await getDashboardFactory({
getDashboard: getDashboardRecordFactory({ db })
})({ id: parent.dashboardId })
if (!dashboard) {
throw new DashboardNotFoundError()
}
return dashboard
},
projects: async (parent, _args, context) => {
const dashboard = await getDashboardFactory({
getDashboard: getDashboardRecordFactory({ db })
})({ id: parent.dashboardId })
const projects = await context.loaders.streams.getStream.loadMany(
dashboard.projectIds ?? []
)
return projects.filter(
(project): project is StreamRecord => !!project && 'id' in project
)
},
user: async (parent, _args, context) => {
return await context.loaders.users.getUser.load(parent.userId)
}
}
}
const disabledResolvers: Resolvers = {
DashboardMutations: {
createToken: async () => {
throw new DashboardsModuleDisabledError()
}
}
}
export default isEnabled ? resolvers : disabledResolvers
@@ -0,0 +1,8 @@
import type { MutationsObjectGraphQLReturn } from '@/modules/core/helpers/graphTypes'
import type { DashboardApiToken } from '@/modules/dashboards/domain/tokens/types'
import type { Dashboard } from '@/modules/dashboards/domain/types'
export type DashboardGraphQLReturn = Dashboard
export type DashboardMutationsGraphQLReturn = MutationsObjectGraphQLReturn
export type DashboardPermissionChecksGraphQLReturn = { dashboardId: string }
export type DashboardTokenGraphQLReturn = DashboardApiToken
@@ -0,0 +1,14 @@
import type { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import { moduleLogger } from '@/observability/logging'
import { getFeatureFlags } from '@speckle/shared/environment'
const { FF_WORKSPACES_MODULE_ENABLED, FF_DASHBOARDS_MODULE_ENABLED } = getFeatureFlags()
const dashboardsModule: SpeckleModule = {
init: async () => {
if (!FF_WORKSPACES_MODULE_ENABLED || !FF_DASHBOARDS_MODULE_ENABLED) return
moduleLogger.info('🧢 Init dashboards module')
}
}
export default dashboardsModule
@@ -0,0 +1,23 @@
import type { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('dashboards', (table) => {
table.text('id').primary()
table.text('name').notNullable()
table
.text('workspaceId')
.notNullable()
.references('id')
.inTable('workspaces')
.onDelete('cascade')
table.text('ownerId').references('id').inTable('users').onDelete('set null')
table.specificType('projectIds', 'text[]').notNullable().defaultTo('{}')
table.text('state').nullable()
table.timestamp('createdAt', { precision: 3, useTz: true }).notNullable()
table.timestamp('updatedAt', { precision: 3, useTz: true }).notNullable()
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable('dashboards')
}
@@ -0,0 +1,29 @@
import type { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('dashboard_api_tokens', (table) => {
table
.string('tokenId')
.notNullable()
.references('id')
.inTable('api_tokens')
.onDelete('cascade')
table
.string('dashboardId')
.notNullable()
.references('id')
.inTable('dashboards')
.onDelete('cascade')
table
.string('userId')
.notNullable()
.references('id')
.inTable('users')
.onDelete('cascade')
table.primary(['dashboardId', 'tokenId'])
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('dashboard_api_tokens')
}
@@ -0,0 +1,75 @@
import { Dashboards } from '@/modules/dashboards/dbSchema'
import type {
CountDashboardRecords,
DeleteDashboardRecord,
GetDashboardRecord,
ListDashboardRecords,
UpsertDashboardRecord
} from '@/modules/dashboards/domain/operations'
import type { Dashboard } from '@/modules/dashboards/domain/types'
import type { Knex } from 'knex'
const tables = {
dashboards: (db: Knex) => db.table<Dashboard>(Dashboards.name)
}
export const getDashboardRecordFactory =
(deps: { db: Knex }): GetDashboardRecord =>
async ({ id }) => {
return await tables
.dashboards(deps.db)
.select('*')
.where(Dashboards.col.id, id)
.first()
}
export const deleteDashboardRecordFactory =
(deps: { db: Knex }): DeleteDashboardRecord =>
async ({ id }) => {
return await tables.dashboards(deps.db).where(Dashboards.col.id, id).delete()
}
export const upsertDashboardFactory =
(deps: { db: Knex }): UpsertDashboardRecord =>
async (item) => {
await tables
.dashboards(deps.db)
.insert(item)
.onConflict(Dashboards.withoutTablePrefix.col.id)
.merge([
Dashboards.withoutTablePrefix.col.name,
Dashboards.withoutTablePrefix.col.projectIds,
Dashboards.withoutTablePrefix.col.state,
Dashboards.withoutTablePrefix.col.updatedAt
] as (keyof Dashboard)[])
}
export const listDashboardsFactory =
(deps: { db: Knex }): ListDashboardRecords =>
async ({ workspaceId, filter }) => {
const q = tables
.dashboards(deps.db)
.select()
.where(Dashboards.col.workspaceId, workspaceId)
.orderBy(Dashboards.col.updatedAt, 'desc')
if (filter?.limit) {
q.limit(filter.limit)
}
if (filter?.updatedBefore) {
q.andWhere(Dashboards.col.updatedAt, '<', filter.updatedBefore)
}
return await q
}
export const countDashboardsFactory =
(deps: { db: Knex }): CountDashboardRecords =>
async ({ workspaceId }) => {
const [{ count }] = await tables
.dashboards(deps.db)
.where(Dashboards.col.workspaceId, workspaceId)
.count()
return Number.parseInt(count as string)
}
@@ -0,0 +1,21 @@
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 { Knex } from 'knex'
const tables = {
apiTokens: (db: Knex) => db<ApiTokenRecord>(ApiTokens.name),
dashboardApiTokens: (db: Knex) => db<DashboardApiTokenRecord>(DashboardApiTokens.name)
}
export const storeDashboardApiTokenFactory =
(deps: { db: Knex }): StoreDashboardApiToken =>
async (token) => {
const [newToken] = await tables
.dashboardApiTokens(deps.db)
.insert(token)
.returning('*')
return newToken
}
@@ -0,0 +1,132 @@
import type {
CountDashboardRecords,
DeleteDashboardRecord,
GetDashboardRecord,
ListDashboardRecords,
UpsertDashboardRecord
} from '@/modules/dashboards/domain/operations'
import type { Dashboard } from '@/modules/dashboards/domain/types'
import { DashboardNotFoundError } from '@/modules/dashboards/errors/dashboards'
import type { Collection } from '@/modules/shared/helpers/dbHelper'
import {
decodeIsoDateCursor,
encodeIsoDateCursor
} from '@/modules/shared/helpers/dbHelper'
import cryptoRandomString from 'crypto-random-string'
export type CreateDashboard = (params: {
name: string
workspaceId: string
ownerId: string
}) => Promise<Dashboard>
export const createDashboardFactory =
(deps: { upsertDashboard: UpsertDashboardRecord }): CreateDashboard =>
async ({ name, workspaceId, ownerId }) => {
const dashboard: Dashboard = {
id: cryptoRandomString({ length: 9 }),
name,
workspaceId,
ownerId,
projectIds: [],
createdAt: new Date(),
updatedAt: new Date()
}
await deps.upsertDashboard(dashboard)
return dashboard
}
export type UpdateDashboard = (params: {
id: string
name?: string
projectIds?: string[]
state?: string
}) => Promise<Dashboard>
export const updateDashboardFactory =
(deps: {
getDashboard: GetDashboardRecord
upsertDashboard: UpsertDashboardRecord
}): UpdateDashboard =>
async ({ id, ...update }) => {
const dashboard = await deps.getDashboard({ id })
if (!dashboard) {
throw new DashboardNotFoundError()
}
const nextDashboard: Dashboard = {
...dashboard,
...update,
updatedAt: new Date(),
id
}
await deps.upsertDashboard(nextDashboard)
return nextDashboard
}
export type DeleteDashboard = (params: { id: string }) => Promise<void>
export const deleteDashboardFactory =
(deps: { deleteDashboard: DeleteDashboardRecord }): DeleteDashboard =>
async ({ id }) => {
const itemCount = await deps.deleteDashboard({ id })
if (itemCount === 0) {
throw new DashboardNotFoundError()
}
}
export type GetDashboard = (params: { id: string }) => Promise<Dashboard>
export const getDashboardFactory =
(deps: { getDashboard: GetDashboardRecord }): GetDashboard =>
async ({ id }) => {
const dashboard = await deps.getDashboard({ id })
if (!dashboard) {
throw new DashboardNotFoundError()
}
return dashboard
}
export type GetPaginatedDashboards = (params: {
workspaceId: string
filter?: {
limit: number | null
cursor: string | null
}
}) => Promise<Collection<Dashboard>>
export const getPaginatedDashboardsFactory =
(deps: {
listDashboards: ListDashboardRecords
countDashboards: CountDashboardRecords
}): GetPaginatedDashboards =>
async ({ workspaceId, filter }) => {
const cursor = filter?.cursor ? decodeIsoDateCursor(filter.cursor) : null
const [items, totalCount] = await Promise.all([
deps.listDashboards({
workspaceId,
filter: {
updatedBefore: cursor,
limit: filter?.limit ?? null
}
}),
deps.countDashboards({ workspaceId })
])
const lastItem = items.at(-1)
return {
items,
totalCount,
cursor: lastItem ? encodeIsoDateCursor(lastItem.updatedAt) : null
}
}
@@ -0,0 +1,80 @@
import type {
CreateAndStoreUserToken,
GetApiTokenById
} from '@/modules/core/domain/tokens/operations'
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
import type { GetDashboardRecord } from '@/modules/dashboards/domain/operations'
import type { StoreDashboardApiToken } from '@/modules/dashboards/domain/tokens/operations'
import type {
DashboardApiToken,
DashboardApiTokenRecord
} from '@/modules/dashboards/domain/tokens/types'
import {
DashboardMalformedTokenError,
DashboardNotFoundError
} from '@/modules/dashboards/errors/dashboards'
import { LogicError } from '@/modules/shared/errors'
import { Scopes } from '@speckle/shared'
import cryptoRandomString from 'crypto-random-string'
import { pick } from 'lodash-es'
export type CreateAndStoreDashboardToken = (args: {
dashboardId: string
userId: string
lifespan?: number | bigint
}) => Promise<{
token: string
tokenMetadata: DashboardApiToken
}>
export const createDashboardTokenFactory =
(deps: {
getDashboard: GetDashboardRecord
createToken: CreateAndStoreUserToken
getToken: GetApiTokenById
storeDashboardApiToken: StoreDashboardApiToken
}): CreateAndStoreDashboardToken =>
async ({ dashboardId, userId, lifespan }) => {
const dashboard = await deps.getDashboard({ id: dashboardId })
if (!dashboard) {
throw new DashboardNotFoundError()
}
if (dashboard.projectIds.length === 0) {
throw new DashboardMalformedTokenError()
}
const { id, token } = await deps.createToken({
userId,
name: `dat-${cryptoRandomString({ length: 10 })}`,
scopes: [Scopes.Streams.Read, Scopes.Users.Read, Scopes.Workspaces.Read],
limitResources: dashboard.projectIds.map((id) => ({
id,
type: TokenResourceIdentifierType.Project
})),
lifespan
})
const tokenMetadata: DashboardApiTokenRecord = {
userId,
dashboardId,
tokenId: id
}
await deps.storeDashboardApiToken(tokenMetadata)
const apiToken = await deps.getToken(id)
if (!apiToken) {
throw new LogicError('Failed to create api token for dashboard')
}
return {
token,
tokenMetadata: {
...tokenMetadata,
...pick(apiToken, 'createdAt', 'lastUsed', 'lifespan')
}
}
}
@@ -0,0 +1,100 @@
import { createRandomEmail } from '@/modules/core/helpers/testHelpers'
import {
getDashboardRecordFactory,
upsertDashboardFactory
} from '@/modules/dashboards/repositories/management'
import type { BasicTestWorkspace } from '@/modules/workspaces/tests/helpers/creation'
import { createTestWorkspace } from '@/modules/workspaces/tests/helpers/creation'
import type { BasicTestUser } from '@/test/authHelper'
import { createTestUser } from '@/test/authHelper'
import { db } from '@/db/knex'
import cryptoRandomString from 'crypto-random-string'
import { expect } from 'chai'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
const { FF_DASHBOARDS_MODULE_ENABLED } = getFeatureFlags()
const getDashboardRecord = getDashboardRecordFactory({ db })
const upsertDashboardRecord = upsertDashboardFactory({ db })
;(FF_DASHBOARDS_MODULE_ENABLED ? describe : describe.skip)(
'basic dashboard crud',
() => {
const testUser: BasicTestUser = {
id: '',
name: 'Stacy Fakename',
email: createRandomEmail()
}
const testWorkspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: '',
name: 'Dashboard Workspace'
}
before(async () => {
await createTestUser(testUser)
await createTestWorkspace(testWorkspace, testUser)
})
describe('upsertDashboardFactory returns a function, that', async () => {
it('should correctly upsert empty project id array', async () => {
const id = cryptoRandomString({ length: 9 })
await upsertDashboardRecord({
id,
name: cryptoRandomString({ length: 9 }),
workspaceId: testWorkspace.id,
ownerId: testUser.id,
projectIds: [],
createdAt: new Date(),
updatedAt: new Date()
})
const dashboard = await getDashboardRecord({ id })
expect(dashboard).to.exist
expect(dashboard?.projectIds.length).to.equal(0)
})
it('should correctly upsert project id array of one element', async () => {
const id = cryptoRandomString({ length: 9 })
await upsertDashboardRecord({
id,
name: cryptoRandomString({ length: 9 }),
workspaceId: testWorkspace.id,
ownerId: testUser.id,
projectIds: ['foo'],
createdAt: new Date(),
updatedAt: new Date()
})
const dashboard = await getDashboardRecord({ id })
expect(dashboard).to.exist
expect(dashboard?.projectIds.length).to.equal(1)
expect(dashboard?.projectIds.at(0)).to.equal('foo')
})
it('should correctly upsert project id array of several elements', async () => {
const id = cryptoRandomString({ length: 9 })
const projectIds = ['foo', 'bar', 'baz']
await upsertDashboardRecord({
id,
name: cryptoRandomString({ length: 9 }),
workspaceId: testWorkspace.id,
ownerId: testUser.id,
projectIds,
createdAt: new Date(),
updatedAt: new Date()
})
const dashboard = await getDashboardRecord({ id })
expect(dashboard).to.exist
expect(dashboard?.projectIds.length).to.equal(3)
expect(dashboard?.projectIds).to.deep.equalInAnyOrder(projectIds)
})
})
}
)
@@ -0,0 +1,47 @@
import {
deleteDashboardFactory,
updateDashboardFactory
} from '@/modules/dashboards/services/management'
import { DashboardNotFoundError } from '@speckle/shared/authz'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
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()
}),
upsertDashboard: async () => {}
})({
id: dashboardId,
name: 'new-name'
})
expect(result.name).to.equal('new-name')
})
it('throws if dashboard does not exist', async () => {
const updateDashboard = updateDashboardFactory({
getDashboard: async () => undefined,
upsertDashboard: async () => {
expect.fail()
}
})
expect(updateDashboard({ id: '' })).to.eventually.throw(DashboardNotFoundError)
})
})
describe('deleteDashboardFactory returns a function, that', () => {
it('throws if dashboard not found', async () => {
const deleteDashboard = deleteDashboardFactory({
deleteDashboard: async () => 0
})
expect(deleteDashboard({ id: '' })).to.eventually.throw(DashboardNotFoundError)
})
})
@@ -0,0 +1,79 @@
import type { ApiTokenRecord } from '@/modules/auth/repositories'
import type { DashboardApiTokenRecord } from '@/modules/dashboards/domain/tokens/types'
import { DashboardMalformedTokenError } from '@/modules/dashboards/errors/dashboards'
import { createDashboardTokenFactory } from '@/modules/dashboards/services/tokens'
import { DashboardNotFoundError } from '@speckle/shared/authz'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
describe('createDashboardTokenFactory returns a function, that', () => {
it('returns a token associated with the given dashboard', async () => {
const dashboardId = cryptoRandomString({ length: 9 })
const userId = cryptoRandomString({ length: 9 })
const createDashboardToken = createDashboardTokenFactory({
getDashboard: async () => ({
id: dashboardId,
ownerId: userId,
workspaceId: cryptoRandomString({ length: 9 }),
name: cryptoRandomString({ length: 9 }),
projectIds: [cryptoRandomString({ length: 9 })],
createdAt: new Date(),
updatedAt: new Date()
}),
createToken: async () => ({
id: cryptoRandomString({ length: 10 }),
token: cryptoRandomString({ length: 20 })
}),
getToken: async () => ({} as ApiTokenRecord),
storeDashboardApiToken: async () => ({} as DashboardApiTokenRecord)
})
const result = await createDashboardToken({ dashboardId, userId })
expect(result.tokenMetadata.dashboardId).to.equal(dashboardId)
})
it('throws if dashboard not found', async () => {
const dashboardId = cryptoRandomString({ length: 9 })
const userId = cryptoRandomString({ length: 9 })
const createDashboardToken = createDashboardTokenFactory({
getDashboard: async () => undefined,
createToken: async () => {
expect.fail()
},
getToken: async () => {
expect.fail()
},
storeDashboardApiToken: async () => {
expect.fail()
}
})
expect(createDashboardToken({ dashboardId, userId })).to.eventually.throw(
DashboardNotFoundError
)
})
it('throws if dashboard not associated with any projects', async () => {
const dashboardId = cryptoRandomString({ length: 9 })
const userId = cryptoRandomString({ length: 9 })
const createDashboardToken = createDashboardTokenFactory({
getDashboard: async () => ({
id: dashboardId,
ownerId: userId,
workspaceId: cryptoRandomString({ length: 9 }),
name: cryptoRandomString({ length: 9 }),
projectIds: [],
createdAt: new Date(),
updatedAt: new Date()
}),
createToken: async () => {
expect.fail()
},
getToken: async () => {
expect.fail()
},
storeDashboardApiToken: async () => {
expect.fail()
}
})
expect(createDashboardToken({ dashboardId, userId })).to.eventually.throw(
DashboardMalformedTokenError
)
})
})
+1
View File
@@ -101,6 +101,7 @@ const getEnabledModuleNames = () => {
'comments',
'core',
'cross-server-sync',
'dashboards',
'emails',
'fileuploads',
'notifications',
@@ -2,6 +2,7 @@ import { AccModuleDisabledError } from '@/modules/acc/errors/acc'
import { AutomateModuleDisabledError } from '@/modules/core/errors/automate'
import { StreamNotFoundError } from '@/modules/core/errors/stream'
import { WorkspacesModuleDisabledError } from '@/modules/core/errors/workspaces'
import { DashboardsModuleDisabledError } from '@/modules/dashboards/errors/dashboards'
import type { BaseError } from '@/modules/shared/errors'
import { BadRequestError, ForbiddenError, NotFoundError } from '@/modules/shared/errors'
import { SsoSessionMissingOrExpiredError } from '@/modules/workspacesCore/errors'
@@ -43,6 +44,8 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
case Authz.EligibleForExclusiveWorkspaceError.code:
case Authz.AutomateFunctionNotCreatorError.code:
case Authz.SavedViewNoAccessError.code:
case Authz.DashboardNotOwnerError.code:
case Authz.DashboardProjectsNotEnoughPermissionsError.code:
return new ForbiddenError(e.message)
case Authz.WorkspaceSsoSessionNoAccessError.code:
throw new SsoSessionMissingOrExpiredError(e.message, {
@@ -60,6 +63,8 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
return new AutomateModuleDisabledError()
case Authz.AccIntegrationNotEnabledError.code:
return new AccModuleDisabledError()
case Authz.DashboardsNotEnabledError.code:
return new DashboardsModuleDisabledError()
case Authz.ProjectLastOwnerError.code:
case Authz.ReservedModelNotDeletableError.code:
return new BadRequestError(e.message)
@@ -69,6 +74,7 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
case Authz.AutomateFunctionNotFoundError.code:
case Authz.SavedViewNotFoundError.code:
case Authz.SavedViewGroupNotFoundError.code:
case Authz.DashboardNotFoundError.code:
return new NotFoundError(e.message)
case Authz.PersonalProjectsLimitedError.code:
case Authz.UngroupedSavedViewGroupLockError.code:
@@ -0,0 +1,32 @@
import { UserInputError } from '@/modules/core/errors/userinput'
import type { GraphQLContext } from '@/modules/shared/helpers/typeHelper'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import type { Workspace } from '@/modules/workspacesCore/domain/types'
export const parseWorkspaceIdentifier = async (
identifier: Partial<Pick<Workspace, 'id' | 'slug'>>,
context: GraphQLContext
): Promise<Workspace> => {
const { id, slug } = identifier
if (!id && !slug) {
// GraphQL @oneof asserts this at runtime, but typescript type is not narrow enough
throw new UserInputError('Must provide either id or slug')
}
let workspace: Workspace | null = null
if (id) {
workspace = await context.loaders.workspaces!.getWorkspace.load(id)
}
if (slug) {
workspace = await context.loaders.workspaces!.getWorkspaceBySlug.load(slug)
}
if (!workspace) {
throw new WorkspaceNotFoundError()
}
return workspace
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { isDashboardOwner } from './dashboards.js'
import cryptoRandomString from 'crypto-random-string'
describe('dashboard checks', () => {
describe('isDashboardOwner returns a function, that', () => {
it('returns false for dashboard not found', async () => {
const result = await isDashboardOwner({
getDashboard: async () => null
})({
userId: cryptoRandomString({ length: 9 }),
dashboardId: cryptoRandomString({ length: 9 })
})
expect(result).to.equal(false)
})
it('returns false if user not owner', async () => {
const result = await isDashboardOwner({
getDashboard: async () => ({
id: cryptoRandomString({ length: 9 }),
ownerId: cryptoRandomString({ length: 9 }),
workspaceId: '',
projectIds: []
})
})({
userId: cryptoRandomString({ length: 9 }),
dashboardId: cryptoRandomString({ length: 9 })
})
expect(result).to.equal(false)
})
it('returns true if user is owner', async () => {
const userId = cryptoRandomString({ length: 9 })
const result = await isDashboardOwner({
getDashboard: async () => ({
id: cryptoRandomString({ length: 9 }),
ownerId: userId,
workspaceId: '',
projectIds: []
})
})({
userId,
dashboardId: cryptoRandomString({ length: 9 })
})
expect(result).to.equal(true)
})
})
})
@@ -0,0 +1,14 @@
import { DashboardContext, UserContext } from '../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../domain/loaders.js'
import { AuthPolicyCheck } from '../domain/policies.js'
export const isDashboardOwner: AuthPolicyCheck<
typeof AuthCheckContextLoaderKeys.getDashboard,
UserContext & DashboardContext
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const dashboard = await loaders.getDashboard({ dashboardId })
if (!dashboard) return false
return dashboard.ownerId === userId
}
@@ -221,6 +221,31 @@ export const UngroupedSavedViewGroupLockError = defineAuthError({
message: 'The default/ungrouped group cannot be modified.'
})
export const DashboardsNotEnabledError = defineAuthError({
code: 'DashboardsNotEnabled',
message: 'Dashboards are not enabled for this server or workspaces.'
})
export const DashboardNotFoundError = defineAuthError({
code: 'DashboardNotFound',
message: 'Dashboard not found'
})
export const DashboardProjectsNotEnoughPermissionsError = defineAuthError<
'DashboardProjectsNotEnoughPermissions',
{
projectIds: string[]
}
>({
code: 'DashboardProjectsNotEnoughPermissions',
message: 'You do not have sufficient access to some projects in this workspace.'
})
export const DashboardNotOwnerError = defineAuthError({
code: 'DashboardNotOwner',
message: 'You must be a dashboard owner to perform this action'
})
// Resolve all exported error types
export type AllAuthErrors = ValueOf<{
[key in keyof typeof import('./authErrors.js')]: typeof import('./authErrors.js')[key] extends new (
@@ -7,6 +7,8 @@ export type MaybeUserContext = { userId?: string }
export type WorkspaceContext = { workspaceId: string }
export type MaybeWorkspaceContext = { workspaceId?: string }
export type DashboardContext = { dashboardId: string }
export type CommentContext = { commentId: string }
export type ModelContext = { modelId: string }
@@ -0,0 +1,3 @@
import { Dashboard } from './types.js'
export type GetDashboard = (args: { dashboardId: string }) => Promise<Dashboard | null>
@@ -0,0 +1,6 @@
export type Dashboard = {
id: string
ownerId: string
workspaceId: string
projectIds: string[]
}
@@ -26,6 +26,7 @@ import { GetModel } from './models/operations.js'
import { GetVersion } from './versions/operations.js'
import { GetAutomateFunction } from './automate/operations.js'
import { GetSavedView, GetSavedViewGroup } from './savedViews/operations.js'
import { GetDashboard } from './dashboards/operations.js'
// utility type that ensures all properties functions that return promises
type PromiseAll<T> = {
@@ -58,6 +59,7 @@ type AuthContextLoaderMappingDefinition<
export const AuthCheckContextLoaderKeys = StringEnum([
'getEnv',
'getAutomateFunction',
'getDashboard',
'getProject',
'getProjectRoleCounts',
'getProjectRole',
@@ -92,6 +94,7 @@ export type AllAuthCheckContextLoaders = AuthContextLoaderMappingDefinition<{
getEnv: GetEnv
getAdminOverrideEnabled: GetAdminOverrideEnabled
getAutomateFunction: GetAutomateFunction
getDashboard: GetDashboard
getProject: GetProject
getProjectRole: GetProjectRole
getProjectRoleCounts: GetProjectRoleCounts
@@ -0,0 +1,90 @@
import { err, ok } from 'true-myth/result'
import {
DashboardNotFoundError,
DashboardProjectsNotEnoughPermissionsError,
DashboardsNotEnabledError,
WorkspacePlanNoFeatureAccessError
} from '../domain/authErrors.js'
import { Loaders } from '../domain/loaders.js'
import { AuthPolicyEnsureFragment } from '../domain/policies.js'
import { DashboardContext, UserContext, WorkspaceContext } from '../domain/context.js'
import {
isWorkspaceFeatureFlagOn,
WorkspaceFeatureFlags
} from '../../workspaces/index.js'
import { ensureMinimumProjectRoleFragment } from './projects.js'
export const ensureDashboardsEnabledFragment: AuthPolicyEnsureFragment<
typeof Loaders.getEnv,
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
{},
InstanceType<typeof DashboardsNotEnabledError>
> = (loaders) => async () => {
const env = await loaders.getEnv()
if (!env.FF_DASHBOARDS_MODULE_ENABLED) return err(new DashboardsNotEnabledError())
return ok()
}
export const ensureWorkspaceDashboardsFeatureAccessFragment: AuthPolicyEnsureFragment<
typeof Loaders.getWorkspacePlan,
WorkspaceContext,
InstanceType<typeof WorkspacePlanNoFeatureAccessError>
> =
(loaders) =>
async ({ workspaceId }) => {
const plan = await loaders.getWorkspacePlan({ workspaceId })
if (!plan) return err(new WorkspacePlanNoFeatureAccessError())
const isFlagOn = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags: plan.featureFlags,
feature: WorkspaceFeatureFlags.dashboards
})
if (!isFlagOn) return err(new WorkspacePlanNoFeatureAccessError())
return ok()
}
export const ensureDashboardProjectsReadAccess: AuthPolicyEnsureFragment<
| typeof Loaders.getDashboard
| typeof Loaders.getProjectRole
| typeof Loaders.getProject
| typeof Loaders.getServerRole
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession,
DashboardContext & UserContext,
InstanceType<
typeof DashboardNotFoundError | typeof DashboardProjectsNotEnoughPermissionsError
>
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const dashboard = await loaders.getDashboard({ dashboardId })
if (!dashboard) return err(new DashboardNotFoundError())
const allProjectResults: [
string,
Awaited<ReturnType<ReturnType<typeof ensureMinimumProjectRoleFragment>>>
][] = await Promise.all(
dashboard.projectIds.map(async (projectId) => {
return [
projectId,
await ensureMinimumProjectRoleFragment(loaders)({ projectId, userId })
]
})
)
const projectAccessErrors = allProjectResults.filter(([, e]) => e.isErr)
return projectAccessErrors.length
? err(
new DashboardProjectsNotEnoughPermissionsError({
payload: {
projectIds: projectAccessErrors.map(([projectId]) => projectId)
}
})
)
: ok()
}
@@ -0,0 +1,86 @@
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { DashboardContext, MaybeUserContext } from '../../domain/context.js'
import {
DashboardNotFoundError,
DashboardProjectsNotEnoughPermissionsError,
DashboardsNotEnabledError,
WorkspaceNoEditorSeatError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureDashboardProjectsReadAccess,
ensureDashboardsEnabledFragment,
ensureWorkspaceDashboardsFeatureAccessFragment
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
import { hasEditorSeat } from '../../checks/workspaceSeat.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getDashboard
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSeat
| typeof AuthCheckContextLoaderKeys.getProjectRole
| typeof AuthCheckContextLoaderKeys.getProject
| typeof AuthCheckContextLoaderKeys.getServerRole
| typeof AuthCheckContextLoaderKeys.getWorkspace
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession
type PolicyArgs = MaybeUserContext & DashboardContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
| typeof WorkspaceNoEditorSeatError
| typeof DashboardNotFoundError
| typeof DashboardProjectsNotEnoughPermissionsError
>
export const canCreateDashboardTokenPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)
const dashboard = await loaders.getDashboard({ dashboardId })
if (!dashboard) return err(new DashboardNotFoundError())
const { workspaceId } = dashboard
const ensuredFeatureAccess = await ensureWorkspaceDashboardsFeatureAccessFragment(
loaders
)({ workspaceId })
if (ensuredFeatureAccess.isErr) return err(ensuredFeatureAccess.error)
const isWorkspaceMember = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Member
})
if (!isWorkspaceMember) return err(new WorkspaceNotEnoughPermissionsError())
const isWorkspaceEditorSeat = await hasEditorSeat(loaders)({
userId: userId!,
workspaceId
})
if (!isWorkspaceEditorSeat) return err(new WorkspaceNoEditorSeatError())
const ensuredProjectAccess = await ensureDashboardProjectsReadAccess(loaders)({
userId: userId!,
dashboardId
})
if (ensuredProjectAccess.isErr) return err(ensuredProjectAccess.error)
return ok()
}
@@ -0,0 +1,76 @@
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { DashboardContext, MaybeUserContext } from '../../domain/context.js'
import {
DashboardNotFoundError,
DashboardNotOwnerError,
DashboardsNotEnabledError,
WorkspaceNoEditorSeatError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureDashboardsEnabledFragment,
ensureWorkspaceDashboardsFeatureAccessFragment
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
import { hasEditorSeat } from '../../checks/workspaceSeat.js'
import { isDashboardOwner } from '../../checks/dashboards.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getDashboard
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSeat
type PolicyArgs = MaybeUserContext & DashboardContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof DashboardNotOwnerError
| typeof DashboardNotFoundError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
| typeof WorkspaceNoEditorSeatError
>
export const canDeleteDashboardPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)
const dashboard = await loaders.getDashboard({ dashboardId })
if (!dashboard) return err(new DashboardNotFoundError())
const { workspaceId } = dashboard
const ensuredFeatureAccess = await ensureWorkspaceDashboardsFeatureAccessFragment(
loaders
)({ workspaceId })
if (ensuredFeatureAccess.isErr) return err(ensuredFeatureAccess.error)
const isWorkspaceEditorSeat = await hasEditorSeat(loaders)({
userId: userId!,
workspaceId
})
if (!isWorkspaceEditorSeat) return err(new WorkspaceNoEditorSeatError())
const isWorkspaceAdmin = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Admin
})
const isOwner = await isDashboardOwner(loaders)({ userId: userId!, dashboardId })
if (!isWorkspaceAdmin && !isOwner) return err(new DashboardNotOwnerError())
return ok()
}
@@ -0,0 +1,71 @@
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { MaybeUserContext, DashboardContext } from '../../domain/context.js'
import {
DashboardNotFoundError,
DashboardsNotEnabledError,
WorkspaceNoEditorSeatError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureDashboardsEnabledFragment,
ensureWorkspaceDashboardsFeatureAccessFragment
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
import { hasEditorSeat } from '../../checks/workspaceSeat.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getDashboard
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSeat
type PolicyArgs = MaybeUserContext & DashboardContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof DashboardNotFoundError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
| typeof WorkspaceNoEditorSeatError
>
export const canEditDashboardPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)
const dashboard = await loaders.getDashboard({ dashboardId })
if (!dashboard) return err(new DashboardNotFoundError())
const { workspaceId } = dashboard
const ensuredFeatureAccess = await ensureWorkspaceDashboardsFeatureAccessFragment(
loaders
)({ workspaceId })
if (ensuredFeatureAccess.isErr) return err(ensuredFeatureAccess.error)
const isWorkspaceMember = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Member
})
if (!isWorkspaceMember) return err(new WorkspaceNotEnoughPermissionsError())
const isWorkspaceEditorSeat = await hasEditorSeat(loaders)({
userId: userId!,
workspaceId
})
if (!isWorkspaceEditorSeat) return err(new WorkspaceNoEditorSeatError())
return ok()
}
@@ -0,0 +1,61 @@
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { DashboardContext, MaybeUserContext } from '../../domain/context.js'
import {
DashboardNotFoundError,
DashboardsNotEnabledError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureDashboardsEnabledFragment,
ensureWorkspaceDashboardsFeatureAccessFragment
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getDashboard
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
type PolicyArgs = MaybeUserContext & DashboardContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof DashboardNotFoundError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
>
export const canReadDashboardPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)
const dashboard = await loaders.getDashboard({ dashboardId })
if (!dashboard) return err(new DashboardNotFoundError())
const { workspaceId } = dashboard
const ensuredFeatureAccess = await ensureWorkspaceDashboardsFeatureAccessFragment(
loaders
)({ workspaceId })
if (ensuredFeatureAccess.isErr) return err(ensuredFeatureAccess.error)
const isWorkspaceMember = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Member
})
if (!isWorkspaceMember) return err(new WorkspaceNotEnoughPermissionsError())
return ok()
}
+15 -1
View File
@@ -38,6 +38,12 @@ import { canCreateSavedViewPolicy } from './project/savedViews/canCreate.js'
import { canUpdateSavedViewPolicy } from './project/savedViews/canUpdate.js'
import { canUpdateSavedViewGroupPolicy } from './project/savedViews/canUpdateGroup.js'
import { canReadSavedViewPolicy } from './project/savedViews/canRead.js'
import { canListDashboardsPolicy } from './workspace/canListDashboards.js'
import { canDeleteDashboardPolicy } from './dashboard/canDelete.js'
import { canCreateDashboardsPolicy } from './workspace/canCreateDashboards.js'
import { canCreateDashboardTokenPolicy } from './dashboard/canCreateToken.js'
import { canEditDashboardPolicy } from './dashboard/canEdit.js'
import { canReadDashboardPolicy } from './dashboard/canRead.js'
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
automate: {
@@ -45,6 +51,12 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
canRegenerateToken: canEditFunctionPolicy(loaders)
}
},
dashboard: {
canCreateToken: canCreateDashboardTokenPolicy(loaders),
canDelete: canDeleteDashboardPolicy(loaders),
canEdit: canEditDashboardPolicy(loaders),
canRead: canReadDashboardPolicy(loaders)
},
project: {
automation: {
canCreate: canCreateAutomationPolicy(loaders),
@@ -99,7 +111,9 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
canReceiveWorkspaceProjectsUpdatedMessagePolicy(loaders),
canUseWorkspacePlanFeature: canUseWorkspacePlanFeature(loaders),
canReadMemberEmail: canReadMemberEmailPolicy(loaders),
canCreateWorkspace: canCreateWorkspacePolicy(loaders)
canCreateWorkspace: canCreateWorkspacePolicy(loaders),
canCreateDashboards: canCreateDashboardsPolicy(loaders),
canListDashboards: canListDashboardsPolicy(loaders)
}
})
@@ -0,0 +1,63 @@
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { MaybeUserContext, WorkspaceContext } from '../../domain/context.js'
import {
DashboardsNotEnabledError,
WorkspaceNoEditorSeatError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureDashboardsEnabledFragment,
ensureWorkspaceDashboardsFeatureAccessFragment
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
import { hasEditorSeat } from '../../checks/workspaceSeat.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSeat
type PolicyArgs = MaybeUserContext & WorkspaceContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
| typeof WorkspaceNoEditorSeatError
>
export const canCreateDashboardsPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, workspaceId }) => {
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)
const ensuredFeatureAccess = await ensureWorkspaceDashboardsFeatureAccessFragment(
loaders
)({ workspaceId })
if (ensuredFeatureAccess.isErr) return err(ensuredFeatureAccess.error)
const isWorkspaceMember = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Member
})
if (!isWorkspaceMember) return err(new WorkspaceNotEnoughPermissionsError())
const isWorkspaceEditorSeat = await hasEditorSeat(loaders)({
userId: userId!,
workspaceId
})
if (!isWorkspaceEditorSeat) return err(new WorkspaceNoEditorSeatError())
return ok()
}
@@ -0,0 +1,53 @@
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { MaybeUserContext, WorkspaceContext } from '../../domain/context.js'
import {
DashboardsNotEnabledError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureDashboardsEnabledFragment,
ensureWorkspaceDashboardsFeatureAccessFragment
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
type PolicyArgs = MaybeUserContext & WorkspaceContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
>
export const canListDashboardsPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, workspaceId }) => {
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)
const ensuredFeatureAccess = await ensureWorkspaceDashboardsFeatureAccessFragment(
loaders
)({ workspaceId })
if (ensuredFeatureAccess.isErr) return err(ensuredFeatureAccess.error)
const isWorkspaceMember = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Member
})
if (!isWorkspaceMember) return err(new WorkspaceNotEnoughPermissionsError())
return ok()
}
@@ -19,6 +19,7 @@ export type FeatureFlags = {
FF_RHINO_FILE_IMPORTER_ENABLED: boolean
FF_LEGACY_FILE_IMPORTS_ENABLED: boolean
FF_ACC_INTEGRATION_ENABLED: boolean
FF_DASHBOARDS_MODULE_ENABLED: boolean
FF_SAVED_VIEWS_ENABLED: boolean
FF_USERS_INVITE_SCOPE_IS_PUBLIC: boolean
}
+5
View File
@@ -135,6 +135,11 @@ export const parseFeatureFlags = (
'Enables the integration with ACC. This synchronizes models with specified ACC assets.',
defaults: { _: false }
},
FF_DASHBOARDS_MODULE_ENABLED: {
schema: z.boolean(),
description: 'Enables the dashboards module.',
defaults: { _: false }
},
FF_SAVED_VIEWS_ENABLED: {
schema: z.boolean(),
description: 'Enables the saved views feature for project models',
@@ -621,6 +621,9 @@ Generate the environment variables for Speckle server and Speckle objects deploy
key: {{ default "acc_integration_client_secret" .Values.server.accIntegration.clientSecret.secretKey }}
{{- end }}
- name: FF_DASHBOARDS_MODULE_ENABLED
value: {{ .Values.featureFlags.dashboardsModuleEnabled | quote }}
{{- if .Values.featureFlags.billingIntegrationEnabled }}
- name: STRIPE_API_KEY
valueFrom:
@@ -151,6 +151,8 @@ spec:
value: {{ .Values.featureFlags.legacyFileImportsEnabled | quote }}
- name: NUXT_PUBLIC_FF_ACC_INTEGRATION_ENABLED
value: {{ .Values.featureFlags.accIntegrationEnabled | quote }}
- name: NUXT_PUBLIC_FF_DASHBOARDS_MODULE_ENABLED
value: {{ .Values.featureFlags.dashboardsModuleEnabled | quote }}
{{- if .Values.analytics.intercom_app_id }}
- name: NUXT_PUBLIC_INTERCOM_APP_ID
value: {{ .Values.analytics.intercom_app_id | quote }}
@@ -120,6 +120,11 @@
"description": "Enables the ability to import data from ACC",
"default": false
},
"dashboardsModuleEnabled": {
"type": "boolean",
"description": "Enables the ability to create and manage dashboards",
"default": false
},
"rhinoFileImporterEnabled": {
"type": "boolean",
"description": "Enables the dedicated Rhino based file importer. This is not part of the deployment.",
+2
View File
@@ -69,6 +69,8 @@ featureFlags:
legacyFileImportsEnabled: false
## @param featureFlags.accIntegrationEnabled Enables the ability to import data from ACC
accIntegrationEnabled: false
## @param featureFlags.dashboardsModuleEnabled Enables the ability to create and manage dashboards
dashboardsModuleEnabled: false
## @param featureFlags.rhinoFileImporterEnabled Enables the dedicated Rhino based file importer. This is not part of the deployment.
rhinoFileImporterEnabled: false
## @param featureFlags.savedViewsEnabled Enables the ability to create and manage saved views