Feat: Bashboards in app (#5333)
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user