Merge branch 'main' of github.com:specklesystems/speckle-server into alessandro/web-2803-downscale-workspace-subscription

This commit is contained in:
Alessandro Magionami
2025-03-19 09:30:37 +01:00
34 changed files with 605 additions and 284 deletions
+19 -5
View File
@@ -1,4 +1,3 @@
version: '2.4'
services:
speckle-ingress:
build:
@@ -22,12 +21,16 @@ services:
restart: always
environment:
NUXT_PUBLIC_SERVER_NAME: 'local'
#TODO: Change this to the URL of your server. This is the URL of the server as accessed by users.
NUXT_PUBLIC_API_ORIGIN: 'http://127.0.0.1'
#TODO: Change this to the URL of your server. This is the URL of the server as accessed by users.
NUXT_PUBLIC_BASE_URL: 'http://127.0.0.1'
# This is the URL of the server as accessed via this docker compose network.
NUXT_PUBLIC_BACKEND_API_ORIGIN: 'http://speckle-server:3000'
NUXT_PUBLIC_LOG_LEVEL: 'warn'
NUXT_REDIS_URL: 'redis://redis'
LOG_LEVEL: 'info'
LOG_PRETTY: 'true'
speckle-server:
build:
@@ -47,14 +50,20 @@ services:
retries: 3
start_period: 90s
environment:
# TODO: Change this to the URL of the speckle server, as accessed from the network
# TODO. Change this to the url of your server. This is the URL of the server as accessed by users.
CANONICAL_URL: 'http://127.0.0.1'
# This is the URL of the server as accessed by other Speckle services within this docker compose network, such as preview-service.
# This will be the same value as NUXT_PUBLIC_BACKEND_API_ORIGIN as defined in the frontend-2 service.
PRIVATE_OBJECTS_SERVER_URL: 'http://speckle-server:3000'
# TODO: Change this to a unique secret for this server
SESSION_SECRET: 'TODO:Replace'
# This is the authentication strategy to use. Local (i.e. username & password) is the default strategy.
STRATEGY_LOCAL: 'true'
LOG_LEVEL: 'info'
LOG_PRETTY: 'true'
POSTGRES_URL: 'postgres'
POSTGRES_USER: 'speckle'
@@ -62,6 +71,8 @@ services:
POSTGRES_DB: 'speckle'
REDIS_URL: 'redis://redis'
PREVIEW_SERVICE_USE_PRIVATE_OBJECTS_SERVER_URL: 'true'
PREVIEW_SERVICE_REDIS_URL: 'redis://redis'
S3_ENDPOINT: 'http://minio:9000'
S3_ACCESS_KEY: 'minioadmin'
@@ -85,10 +96,11 @@ services:
mem_limit: '3000m'
memswap_limit: '3000m'
environment:
HOST: '127.0.0.1' # Only accept connections from localhost, as preview service does not need to be exposed outside the container.
METRICS_HOST: '127.0.0.1' # Amend if you want to expose Prometheus metrics outside of the container
HOST: '127.0.0.1' # The preview service does not need to be exposed outside the container.
PORT: '3001'
LOG_LEVEL: 'info'
PG_CONNECTION_STRING: 'postgres://speckle:speckle@postgres/speckle'
LOG_PRETTY: 'true'
REDIS_URL: 'redis://redis'
webhook-service:
build:
@@ -99,6 +111,7 @@ services:
restart: always
environment:
LOG_LEVEL: 'info'
LOG_PRETTY: 'true'
PG_CONNECTION_STRING: 'postgres://speckle:speckle@postgres/speckle'
fileimport-service:
@@ -110,6 +123,7 @@ services:
restart: always
environment:
LOG_LEVEL: 'info'
LOG_PRETTY: 'true'
PG_CONNECTION_STRING: 'postgres://speckle:speckle@postgres/speckle'
SPECKLE_SERVER_URL: 'http://speckle-server:3000'
FILE_IMPORT_TIME_LIMIT_MIN: 10
+2 -1
View File
@@ -74,4 +74,5 @@ ENV IFC_DOTNET_DLL_PATH='/speckle-server/packages/fileimport-service/src/ifc-dot
WORKDIR /speckle-server/packages/fileimport-service
ENTRYPOINT [ "tini", "--", "node", "--loader=./dist/src/aliasLoader.js", "bin/www.js" ]
ENTRYPOINT [ "tini", "--", "node", "--loader=./dist/src/aliasLoader.js" ]
CMD ["bin/www.js"]
@@ -23,13 +23,17 @@
@click="isOpenMobile = false"
/>
<div
class="absolute z-40 lg:static h-full flex w-[17rem] shrink-0 transition-all"
:class="isOpenMobile ? '' : '-translate-x-[17rem] lg:translate-x-0'"
class="absolute z-40 lg:static h-full flex w-[13rem] shrink-0 transition-all"
:class="isOpenMobile ? '' : '-translate-x-[13rem] lg:translate-x-0'"
>
<LayoutSidebar
class="border-r border-outline-3 px-2 pt-3 pb-2 bg-foundation-page"
>
<LayoutSidebarMenu>
<LayoutSidebarMenuGroup v-if="isWorkspacesEnabled && isMobile">
<HeaderWorkspaceSwitcher />
</LayoutSidebarMenuGroup>
<LayoutSidebarMenuGroup>
<template v-if="!isWorkspacesEnabled">
<NuxtLink :to="projectsRoute" @click="isOpenMobile = false">
@@ -168,11 +172,15 @@ import { useRoute } from 'vue-router'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { HomeIcon } from '@heroicons/vue/24/outline'
import { useNavigation } from '~~/lib/navigation/composables/navigation'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import { useBreakpoints } from '@vueuse/core'
const { isLoggedIn } = useActiveUser()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const route = useRoute()
const { activeWorkspaceSlug, isProjectsActive } = useNavigation()
const breakpoints = useBreakpoints(TailwindBreakpoints)
const isMobile = breakpoints.smaller('lg')
const isOpenMobile = ref(false)
const showFeedbackDialog = ref(false)
@@ -1,9 +1,7 @@
<template>
<div>
<nav class="fixed z-40 top-0 h-12 bg-foundation border-b border-outline-2">
<div
class="flex gap-4 items-center justify-between h-full w-screen py-4 px-3 sm:px-4"
>
<div class="flex gap-4 items-center justify-between h-full w-screen py-4 px-3">
<div class="hidden lg:block">
<HeaderWorkspaceSwitcher v-if="showWorkspaceSwitcher" />
<HeaderLogoBlock
@@ -1,13 +1,11 @@
<template>
<div class="flex items-center">
<div class="w-6 flex-shrink-0">
<IconCheck v-if="isActive" class="w-4 h-4 mx-1 text-foreground" />
</div>
<MenuItem class="min-w-0 w-full">
<NuxtLink class="flex-1 min-w-0" @click="$emit('onClick')">
<LayoutSidebarMenuGroupItem
:label="name"
:tag="tag"
:active="isActive"
color-classes="bg-foundation-2 text-foreground-2"
>
<template #icon>
@@ -1,12 +1,16 @@
<template>
<div>
<Menu as="div" class="flex items-center">
<MenuButton :id="menuButtonId" v-slot="{ open: userOpen }">
<MenuButton :id="menuButtonId" v-slot="{ open: userOpen }" class="w-full">
<span class="sr-only">Open workspace menu</span>
<div class="flex items-center gap-2 p-0.5 pr-1.5 hover:bg-highlight-2 rounded">
<template v-if="activeWorkspaceSlug || isProjectsActive">
<div class="relative">
<WorkspaceAvatar :name="displayName" :logo="displayLogo" />
<WorkspaceAvatar
:size="isMobile ? 'sm' : 'base'"
:name="displayName || ''"
:logo="displayLogo"
/>
<div
v-if="hasDiscoverableWorkspaces"
class="absolute -top-[4px] -right-[4px] size-3 border-[2px] border-foundation-page bg-primary rounded-full"
@@ -32,93 +36,40 @@
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute left-4 top-14 w-64 origin-top-right bg-foundation outline outline-1 outline-primary-muted rounded-md shadow-lg overflow-hidden divide-y divide-outline-2"
class="absolute left-2 lg:left-3 top-12 lg:top-14 w-full lg:w-[17rem] origin-top-right bg-foundation outline outline-1 outline-primary-muted rounded-md shadow-lg overflow-hidden divide-y divide-outline-2"
>
<HeaderWorkspaceSwitcherHeaderSsoExpired
v-if="activeWorkspaceHasExpiredSsoSession"
:workspace="expiredSsoWorkspaceData"
/>
<HeaderWorkspaceSwitcherHeaderProjects v-else-if="isProjectsActive" />
<HeaderWorkspaceSwitcherHeaderWorkspace
v-else-if="!!activeWorkspace"
:workspace="activeWorkspace"
/>
<div
v-if="activeWorkspaceSlug || isProjectsActive"
class="p-2 pb-3 flex flex-col gap-y-4"
class="p-2 pt-1 max-h-[60vh] lg:max-h-96 overflow-y-auto simple-scrollbar"
>
<div class="flex gap-x-2 items-center">
<MenuItem>
<NuxtLink
:to="
activeWorkspaceSlug
? workspaceRoute(activeWorkspaceSlug)
: projectsRoute
"
>
<WorkspaceAvatar
:name="displayName"
:logo="displayLogo"
size="lg"
class="flex-shrink-0"
/>
</NuxtLink>
</MenuItem>
<div class="flex flex-col space-between min-w-0">
<p class="text-body-xs text-foreground truncate">
{{ displayName }}
</p>
<p
v-if="activeWorkspace"
class="text-body-2xs text-foreground-2 capitalize truncate"
>
{{ activeWorkspace?.plan?.name }} ·
{{ activeWorkspace?.team?.totalCount }} member{{
activeWorkspace?.team?.totalCount > 1 ? 's' : ''
}}
</p>
<p v-else class="text-body-2xs text-foreground-2 truncate">
2 projects to move
</p>
</div>
</div>
<div v-if="activeWorkspaceSlug" class="flex gap-x-2">
<MenuItem>
<FormButton
color="outline"
full-width
size="sm"
@click="goToSettingsRoute"
>
Settings
</FormButton>
</MenuItem>
<MenuItem>
<FormButton
full-width
color="outline"
size="sm"
:disabled="activeWorkspace?.role !== Roles.Workspace.Admin"
@click="showInviteDialog = true"
>
Invite members
</FormButton>
</MenuItem>
</div>
</div>
<div class="p-2 pt-1 max-h-96 overflow-y-auto simple-scrollbar">
<LayoutSidebarMenuGroup
title="Workspaces"
:icon-click="isGuest ? undefined : handlePlusClick"
icon-text="Create workspace"
>
<div v-if="hasWorkspaces" class="w-full">
<template v-for="item in workspaces" :key="`menu-item-${item.id}`">
<DashboardSidebarWorkspaceItem
:is-active="item.slug === activeWorkspaceSlug"
:name="item.name"
:logo="item.logo"
@on-click="onWorkspaceSelect(item.slug)"
/>
</template>
<DashboardSidebarWorkspaceItem
:is-active="route.path === projectsRoute"
name="Personal projects"
tag="LEGACY"
@on-click="onProjectsSelect"
/>
</div>
<HeaderWorkspaceSwitcherItem
v-for="item in workspaces"
:key="`menu-item-${item.id}`"
:is-active="item.slug === activeWorkspaceSlug"
:name="item.name"
:logo="item.logo"
:tag="getWorkspaceTag(item)"
@on-click="onWorkspaceSelect(item.slug)"
/>
<HeaderWorkspaceSwitcherItem
:is-active="route.path === projectsRoute"
name="Personal projects"
tag="LEGACY"
@on-click="onProjectsSelect"
/>
</LayoutSidebarMenuGroup>
</div>
<MenuItem v-if="hasDiscoverableWorkspacesOrJoinRequests">
@@ -138,11 +89,6 @@
</Transition>
</Menu>
<InviteDialogWorkspace
v-model:open="showInviteDialog"
:workspace="activeWorkspace"
/>
<WorkspaceDiscoverableWorkspacesModal
v-model:open="showDiscoverableWorkspacesModal"
/>
@@ -155,28 +101,54 @@ import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import {
workspaceCreateRoute,
workspaceRoute,
settingsWorkspaceRoutes,
projectsRoute
} from '~/lib/common/helpers/route'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useUserWorkspaces } from '~/lib/user/composables/workspaces'
import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces'
import { graphql } from '~/lib/common/generated/gql'
import { useNavigation } from '~~/lib/navigation/composables/navigation'
import { Roles } from '@speckle/shared'
import { Roles, WorkspacePlans } from '@speckle/shared'
import type { HeaderWorkspaceSwitcherWorkspaceList_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import { useBreakpoints } from '@vueuse/core'
graphql(`
fragment HeaderWorkspaceSwitcher_Workspace on Workspace {
...InviteDialogWorkspace_Workspace
fragment HeaderWorkspaceSwitcherActiveWorkspace_Workspace on Workspace {
id
name
logo
...HeaderWorkspaceSwitcherHeaderWorkspace_Workspace
}
`)
graphql(`
fragment HeaderWorkspaceSwitcherWorkspaceList_Workspace on Workspace {
id
name
logo
role
slug
creationState {
completed
}
plan {
name
}
team {
totalCount
}
`)
graphql(`
fragment HeaderWorkspaceSwitcherWorkspaceList_User on User {
id
expiredSsoSessions {
id
...HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace
}
workspaces {
items {
id
...HeaderWorkspaceSwitcherWorkspaceList_Workspace
}
}
}
`)
@@ -189,30 +161,40 @@ const {
isProjectsActive,
mutateActiveWorkspaceSlug,
mutateIsProjectsActive,
workspaceData
activeWorkspaceData,
workspaceList: workspaces,
activeWorkspaceHasExpiredSsoSession,
expiredSsoWorkspaceData
} = useNavigation()
const showInviteDialog = ref(false)
const showDiscoverableWorkspacesModal = ref(false)
const activeWorkspace = computed(() => {
return workspaceData.value
})
const displayName = computed(() => activeWorkspace.value?.name || 'Personal projects')
const displayLogo = computed(() => {
if (isProjectsActive.value) return null
return activeWorkspace.value?.logo
})
const route = useRoute()
const { workspaces, hasWorkspaces } = useUserWorkspaces()
const {
hasDiscoverableWorkspaces,
discoverableWorkspacesCount,
hasDiscoverableWorkspacesOrJoinRequests
} = useDiscoverableWorkspaces()
const breakpoints = useBreakpoints(TailwindBreakpoints)
const isMobile = breakpoints.smaller('lg')
const showDiscoverableWorkspacesModal = ref(false)
const activeWorkspace = computed(() => {
return activeWorkspaceData.value
})
const displayName = computed(() =>
isProjectsActive.value
? 'Personal projects'
: activeWorkspaceHasExpiredSsoSession.value
? expiredSsoWorkspaceData.value?.name
: activeWorkspace.value?.name
)
const displayLogo = computed(() => {
if (isProjectsActive.value) return null
return activeWorkspaceHasExpiredSsoSession.value
? expiredSsoWorkspaceData.value?.logo
: activeWorkspace.value?.logo
})
const onWorkspaceSelect = (slug: string) => {
navigateTo(workspaceRoute(slug))
@@ -224,15 +206,17 @@ const onProjectsSelect = () => {
navigateTo(projectsRoute)
}
const goToSettingsRoute = () => {
if (!activeWorkspaceSlug.value) return
navigateTo(settingsWorkspaceRoutes.general.route(activeWorkspaceSlug.value))
}
const handlePlusClick = () => {
navigateTo(workspaceCreateRoute())
mixpanel.track('Create Workspace Button Clicked', {
source: 'navigation'
})
}
const getWorkspaceTag = (
workspace: HeaderWorkspaceSwitcherWorkspaceList_WorkspaceFragment
) => {
if (workspace.role === Roles.Workspace.Guest) return 'GUEST'
if (workspace.plan?.name === WorkspacePlans.Free) return 'FREE'
}
</script>
@@ -0,0 +1,33 @@
<template>
<div class="p-2 pb-3 flex flex-col gap-y-4">
<div class="flex gap-x-2 items-center">
<MenuItem>
<NuxtLink :to="to">
<WorkspaceAvatar
:name="name || ''"
:logo="logo"
size="lg"
class="flex-shrink-0"
/>
</NuxtLink>
</MenuItem>
<div class="flex flex-col space-between min-w-0">
<p class="text-body-xs text-foreground truncate">
{{ name }}
</p>
<slot />
</div>
</div>
<slot name="actions" />
</div>
</template>
<script setup lang="ts">
import type { MaybeNullOrUndefined } from '@speckle/shared'
defineProps<{
name: MaybeNullOrUndefined<string>
logo?: MaybeNullOrUndefined<string>
to: string
}>()
</script>
@@ -0,0 +1,9 @@
<template>
<HeaderWorkspaceSwitcherHeader name="Projects" :to="projectsRoute">
<p class="text-body-2xs text-foreground-2 truncate">2 projects to move</p>
</HeaderWorkspaceSwitcherHeader>
</template>
<script setup lang="ts">
import { projectsRoute } from '~/lib/common/helpers/route'
</script>
@@ -0,0 +1,52 @@
<template>
<HeaderWorkspaceSwitcherHeader
:name="workspace?.name"
:logo="workspace?.logo"
:to="workspaceRoute(activeWorkspaceSlug || '')"
>
<p class="text-body-2xs text-foreground-2 truncate">Requires SSO authentication</p>
<template #actions>
<MenuItem>
<FormButton color="outline" full-width size="sm" @click="handleSsoLogin">
Sign in with SSO
</FormButton>
</MenuItem>
</template>
</HeaderWorkspaceSwitcherHeader>
</template>
<script setup lang="ts">
import { MenuItem } from '@headlessui/vue'
import { graphql } from '~/lib/common/generated/gql'
import type { HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { workspaceRoute } from '~/lib/common/helpers/route'
import { useNavigation } from '~~/lib/navigation/composables/navigation'
import { useAuthManager, useLoginOrRegisterUtils } from '~/lib/auth/composables/auth'
graphql(`
fragment HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace on LimitedWorkspace {
id
slug
name
logo
}
`)
defineProps<{
workspace: MaybeNullOrUndefined<HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspaceFragment>
}>()
const { activeWorkspaceSlug } = useNavigation()
const { signInOrSignUpWithSso } = useAuthManager()
const { challenge } = useLoginOrRegisterUtils()
const handleSsoLogin = () => {
if (!activeWorkspaceSlug.value) return
signInOrSignUpWithSso({
workspaceSlug: activeWorkspaceSlug.value,
challenge: challenge.value
})
}
</script>
@@ -0,0 +1,75 @@
<template>
<div>
<HeaderWorkspaceSwitcherHeader
:name="workspace?.name"
:logo="workspace?.logo"
:to="workspaceRoute(activeWorkspaceSlug || '')"
>
<p class="text-body-2xs text-foreground-2 capitalize truncate">
{{ workspace?.plan?.name }} · {{ workspace?.team?.totalCount ?? 0 }} member{{
(workspace?.team?.totalCount ?? 0) > 1 ? 's' : ''
}}
</p>
<template #actions>
<div class="flex gap-x-2 w-full">
<MenuItem>
<FormButton
color="outline"
full-width
size="sm"
:to="settingsWorkspaceRoutes.general.route(activeWorkspaceSlug || '')"
>
Settings
</FormButton>
</MenuItem>
<MenuItem>
<FormButton
full-width
color="outline"
size="sm"
:disabled="workspace?.role !== Roles.Workspace.Admin"
@click="showInviteDialog = true"
>
Invite members
</FormButton>
</MenuItem>
</div>
</template>
</HeaderWorkspaceSwitcherHeader>
<InviteDialogWorkspace v-model:open="showInviteDialog" :workspace="workspace" />
</div>
</template>
<script setup lang="ts">
import { MenuItem } from '@headlessui/vue'
import { graphql } from '~/lib/common/generated/gql'
import type { HeaderWorkspaceSwitcherHeaderWorkspace_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import { Roles, type MaybeNullOrUndefined } from '@speckle/shared'
import { workspaceRoute, settingsWorkspaceRoutes } from '~/lib/common/helpers/route'
import { useNavigation } from '~~/lib/navigation/composables/navigation'
graphql(`
fragment HeaderWorkspaceSwitcherHeaderWorkspace_Workspace on Workspace {
...InviteDialogWorkspace_Workspace
id
name
logo
role
plan {
name
}
team {
totalCount
}
}
`)
defineProps<{
workspace: MaybeNullOrUndefined<HeaderWorkspaceSwitcherHeaderWorkspace_WorkspaceFragment>
}>()
const { activeWorkspaceSlug } = useNavigation()
const showInviteDialog = ref(false)
</script>
@@ -3,7 +3,9 @@
<Portal to="navigation">
<HeaderNavLink
v-if="project.workspace && isWorkspacesEnabled"
:to="workspaceRoute(project.workspace.slug)"
:to="
isWorkspaceNewPlansEnabled ? 'Home' : workspaceRoute(project.workspace.slug)
"
:name="project.workspace.name"
:separator="false"
/>
@@ -55,4 +57,5 @@ const props = defineProps<{
}>()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
</script>
@@ -4,21 +4,13 @@
<template v-if="project.workspace && isWorkspacesEnabled">
<HeaderNavLink
:to="workspaceRoute(project.workspace.slug)"
:name="project.workspace.name"
:name="isWorkspaceNewPlansEnabled ? 'Home' : project.workspace.name"
:separator="false"
></HeaderNavLink>
/>
</template>
<HeaderNavLink
v-else
:to="projectsRoute"
name="Projects"
:separator="false"
></HeaderNavLink>
<HeaderNavLink v-else :to="projectsRoute" name="Projects" :separator="false" />
<HeaderNavLink
:to="projectRoute(project.id)"
:name="project.name"
></HeaderNavLink>
<HeaderNavLink :to="projectRoute(project.id)" :name="project.name" />
</Portal>
<div class="flex gap-x-3">
@@ -69,4 +61,5 @@ defineProps<{
}>()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
</script>
@@ -19,10 +19,7 @@
</NuxtLink>
</div>
</Portal>
<div
class="absolute z-40 lg:static h-full flex w-[17rem] shrink-0 transition-all"
:class="isOpenMobile ? '' : '-translate-x-[17rem] lg:translate-x-0'"
>
<div :class="wrapperClasses">
<LayoutSidebar
class="border-r border-outline-3 px-2 pt-3 pb-2 bg-foundation-page"
>
@@ -254,6 +251,15 @@ const workspaceItems = computed(
const activeWorkspaceItem = computed(() =>
workspaceItems.value.find((item) => item.slug === activeWorkspaceSlug.value)
)
const wrapperClasses = computed(() => {
// TODO: Simplify post WS migration
const width = isWorkspaceNewPlansEnabled.value ? '13' : '17'
return [
'absolute z-40 lg:static h-full flex shrink-0 transition-all',
`w-[${width}rem]`,
isOpenMobile.value ? '' : `-translate-x-[${width}rem] lg:translate-x-0`
]
})
const needsSsoSession = (
workspace: SettingsMenu_WorkspaceFragment,
@@ -13,7 +13,16 @@
</div>
<p class="text-body mt-1">
<span class="font-medium">
{{ formatPrice(finalPlanPrice) }}
{{
formatPrice(
props.yearlyIntervalSelected && planPrice?.['workspace:member']
? {
...planPrice['workspace:member'],
amount: planPrice['workspace:member'].amount * 0.8
}
: planPrice?.['workspace:member']
)
}}
</span>
per seat/month
</p>
@@ -136,19 +145,7 @@ const isUpgradeDialogOpen = ref(false)
const isYearlyIntervalSelected = ref(props.yearlyIntervalSelected)
const planFeatures = computed(() => WorkspacePlanConfigs[props.plan].features)
const planPrice = computed(
() =>
prices.value?.[props.plan]?.[props.yearlyIntervalSelected ? 'yearly' : 'monthly']
)
const finalPlanPrice = computed(() => {
const basePrice = prices.value?.[props.plan].monthly?.['workspace:member']
if (!basePrice) return undefined
return {
...basePrice,
amount: props.yearlyIntervalSelected ? basePrice.amount * 0.8 : basePrice.amount
}
})
const planPrice = computed(() => prices.value?.[props.plan]?.monthly)
const hasCta = computed(() => !!slots.cta)
const canUpgradeToPlan = computed(() => {
@@ -8,20 +8,17 @@
<template v-if="project?.workspace && isWorkspacesEnabled">
<HeaderNavLink
:to="workspaceRoute(project?.workspace.slug)"
:name="project?.workspace.name"
:name="isWorkspaceNewPlansEnabled ? 'Home' : project?.workspace.name"
:separator="false"
></HeaderNavLink>
/>
</template>
<HeaderNavLink
v-else
:to="projectsRoute"
name="Projects"
:separator="false"
></HeaderNavLink>
<HeaderNavLink
:to="`/projects/${project?.id}`"
:name="project?.name"
></HeaderNavLink>
/>
<HeaderNavLink :to="`/projects/${project?.id}`" :name="project?.name" />
<ViewerExplorerNavbarLink />
</ViewerScope>
</Portal>
@@ -140,6 +137,7 @@ const projectId = writableAsyncComputed({
asyncRead: false
})
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
const state = useSetupViewer({
projectId
})
@@ -48,7 +48,11 @@ type Documents = {
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": typeof types.FormSelectProjects_ProjectFragmentDoc,
"\n fragment FormUsersSelectItem on LimitedUser {\n id\n name\n avatar\n }\n": typeof types.FormUsersSelectItemFragmentDoc,
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": typeof types.HeaderNavShare_ProjectFragmentDoc,
"\n fragment HeaderWorkspaceSwitcher_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n": typeof types.HeaderWorkspaceSwitcher_WorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherActiveWorkspace_Workspace on Workspace {\n id\n name\n logo\n ...HeaderWorkspaceSwitcherHeaderWorkspace_Workspace\n }\n": typeof types.HeaderWorkspaceSwitcherActiveWorkspace_WorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherWorkspaceList_Workspace on Workspace {\n id\n name\n logo\n role\n slug\n creationState {\n completed\n }\n plan {\n name\n }\n }\n": typeof types.HeaderWorkspaceSwitcherWorkspaceList_WorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherWorkspaceList_User on User {\n id\n expiredSsoSessions {\n id\n ...HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace\n }\n workspaces {\n items {\n id\n ...HeaderWorkspaceSwitcherWorkspaceList_Workspace\n }\n }\n }\n": typeof types.HeaderWorkspaceSwitcherWorkspaceList_UserFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace on LimitedWorkspace {\n id\n slug\n name\n logo\n }\n": typeof types.HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherHeaderWorkspace_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n": typeof types.HeaderWorkspaceSwitcherHeaderWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n": typeof types.InviteDialogWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n ...InviteDialogProjectWorkspaceMembers_Project\n workspace {\n id\n name\n defaultProjectRole\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n }\n": typeof types.InviteDialogProject_ProjectFragmentDoc,
"\n fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {\n role\n id\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n": typeof types.InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragmentDoc,
@@ -208,9 +212,11 @@ type Documents = {
"\n query InviteUserSearch($input: UsersRetrievalInput!) {\n users(input: $input) {\n items {\n id\n name\n avatar\n }\n }\n }\n": typeof types.InviteUserSearchDocument,
"\n mutation CreateNewRegion($input: CreateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n create(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": typeof types.CreateNewRegionDocument,
"\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": typeof types.UpdateRegionDocument,
"\n fragment UseNavigation_Workspace on Workspace {\n ...HeaderWorkspaceSwitcher_Workspace\n id\n }\n": typeof types.UseNavigation_WorkspaceFragmentDoc,
"\n fragment UseNavigationActiveWorkspace_Workspace on Workspace {\n ...HeaderWorkspaceSwitcherActiveWorkspace_Workspace\n id\n }\n": typeof types.UseNavigationActiveWorkspace_WorkspaceFragmentDoc,
"\n fragment UseNavigationWorkspaceList_User on User {\n id\n ...HeaderWorkspaceSwitcherWorkspaceList_User\n }\n": typeof types.UseNavigationWorkspaceList_UserFragmentDoc,
"\n mutation SetActiveWorkspace($slug: String, $isProjectsActive: Boolean) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug, isProjectsActive: $isProjectsActive)\n }\n }\n": typeof types.SetActiveWorkspaceDocument,
"\n query HeaderWorkspaceSwitcher($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...HeaderWorkspaceSwitcher_Workspace\n }\n }\n": typeof types.HeaderWorkspaceSwitcherDocument,
"\n query NavigationActiveWorkspace($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...UseNavigationActiveWorkspace_Workspace\n }\n }\n": typeof types.NavigationActiveWorkspaceDocument,
"\n query NavigationWorkspaceList {\n activeUser {\n id\n ...UseNavigationWorkspaceList_User\n }\n }\n": typeof types.NavigationWorkspaceListDocument,
"\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n": typeof types.ProjectPageTeamInternals_ProjectFragmentDoc,
"\n fragment ProjectPageTeamInternals_Workspace on Workspace {\n id\n team {\n items {\n id\n role\n user {\n id\n }\n }\n }\n }\n": typeof types.ProjectPageTeamInternals_WorkspaceFragmentDoc,
"\n fragment ProjectDashboardItemNoModels on Project {\n id\n name\n createdAt\n updatedAt\n role\n team {\n id\n user {\n id\n name\n avatar\n }\n }\n ...ProjectPageModelsCardProject\n }\n": typeof types.ProjectDashboardItemNoModelsFragmentDoc,
@@ -448,7 +454,11 @@ const documents: Documents = {
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": types.FormSelectProjects_ProjectFragmentDoc,
"\n fragment FormUsersSelectItem on LimitedUser {\n id\n name\n avatar\n }\n": types.FormUsersSelectItemFragmentDoc,
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": types.HeaderNavShare_ProjectFragmentDoc,
"\n fragment HeaderWorkspaceSwitcher_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n": types.HeaderWorkspaceSwitcher_WorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherActiveWorkspace_Workspace on Workspace {\n id\n name\n logo\n ...HeaderWorkspaceSwitcherHeaderWorkspace_Workspace\n }\n": types.HeaderWorkspaceSwitcherActiveWorkspace_WorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherWorkspaceList_Workspace on Workspace {\n id\n name\n logo\n role\n slug\n creationState {\n completed\n }\n plan {\n name\n }\n }\n": types.HeaderWorkspaceSwitcherWorkspaceList_WorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherWorkspaceList_User on User {\n id\n expiredSsoSessions {\n id\n ...HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace\n }\n workspaces {\n items {\n id\n ...HeaderWorkspaceSwitcherWorkspaceList_Workspace\n }\n }\n }\n": types.HeaderWorkspaceSwitcherWorkspaceList_UserFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace on LimitedWorkspace {\n id\n slug\n name\n logo\n }\n": types.HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherHeaderWorkspace_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n": types.HeaderWorkspaceSwitcherHeaderWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n": types.InviteDialogWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n ...InviteDialogProjectWorkspaceMembers_Project\n workspace {\n id\n name\n defaultProjectRole\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n }\n": types.InviteDialogProject_ProjectFragmentDoc,
"\n fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {\n role\n id\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n": types.InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragmentDoc,
@@ -608,9 +618,11 @@ const documents: Documents = {
"\n query InviteUserSearch($input: UsersRetrievalInput!) {\n users(input: $input) {\n items {\n id\n name\n avatar\n }\n }\n }\n": types.InviteUserSearchDocument,
"\n mutation CreateNewRegion($input: CreateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n create(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": types.CreateNewRegionDocument,
"\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": types.UpdateRegionDocument,
"\n fragment UseNavigation_Workspace on Workspace {\n ...HeaderWorkspaceSwitcher_Workspace\n id\n }\n": types.UseNavigation_WorkspaceFragmentDoc,
"\n fragment UseNavigationActiveWorkspace_Workspace on Workspace {\n ...HeaderWorkspaceSwitcherActiveWorkspace_Workspace\n id\n }\n": types.UseNavigationActiveWorkspace_WorkspaceFragmentDoc,
"\n fragment UseNavigationWorkspaceList_User on User {\n id\n ...HeaderWorkspaceSwitcherWorkspaceList_User\n }\n": types.UseNavigationWorkspaceList_UserFragmentDoc,
"\n mutation SetActiveWorkspace($slug: String, $isProjectsActive: Boolean) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug, isProjectsActive: $isProjectsActive)\n }\n }\n": types.SetActiveWorkspaceDocument,
"\n query HeaderWorkspaceSwitcher($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...HeaderWorkspaceSwitcher_Workspace\n }\n }\n": types.HeaderWorkspaceSwitcherDocument,
"\n query NavigationActiveWorkspace($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...UseNavigationActiveWorkspace_Workspace\n }\n }\n": types.NavigationActiveWorkspaceDocument,
"\n query NavigationWorkspaceList {\n activeUser {\n id\n ...UseNavigationWorkspaceList_User\n }\n }\n": types.NavigationWorkspaceListDocument,
"\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n": types.ProjectPageTeamInternals_ProjectFragmentDoc,
"\n fragment ProjectPageTeamInternals_Workspace on Workspace {\n id\n team {\n items {\n id\n role\n user {\n id\n }\n }\n }\n }\n": types.ProjectPageTeamInternals_WorkspaceFragmentDoc,
"\n fragment ProjectDashboardItemNoModels on Project {\n id\n name\n createdAt\n updatedAt\n role\n team {\n id\n user {\n id\n name\n avatar\n }\n }\n ...ProjectPageModelsCardProject\n }\n": types.ProjectDashboardItemNoModelsFragmentDoc,
@@ -967,7 +979,23 @@ export function graphql(source: "\n fragment HeaderNavShare_Project on Project
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment HeaderWorkspaceSwitcher_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n"): (typeof documents)["\n fragment HeaderWorkspaceSwitcher_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n"];
export function graphql(source: "\n fragment HeaderWorkspaceSwitcherActiveWorkspace_Workspace on Workspace {\n id\n name\n logo\n ...HeaderWorkspaceSwitcherHeaderWorkspace_Workspace\n }\n"): (typeof documents)["\n fragment HeaderWorkspaceSwitcherActiveWorkspace_Workspace on Workspace {\n id\n name\n logo\n ...HeaderWorkspaceSwitcherHeaderWorkspace_Workspace\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 HeaderWorkspaceSwitcherWorkspaceList_Workspace on Workspace {\n id\n name\n logo\n role\n slug\n creationState {\n completed\n }\n plan {\n name\n }\n }\n"): (typeof documents)["\n fragment HeaderWorkspaceSwitcherWorkspaceList_Workspace on Workspace {\n id\n name\n logo\n role\n slug\n creationState {\n completed\n }\n plan {\n name\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 HeaderWorkspaceSwitcherWorkspaceList_User on User {\n id\n expiredSsoSessions {\n id\n ...HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace\n }\n workspaces {\n items {\n id\n ...HeaderWorkspaceSwitcherWorkspaceList_Workspace\n }\n }\n }\n"): (typeof documents)["\n fragment HeaderWorkspaceSwitcherWorkspaceList_User on User {\n id\n expiredSsoSessions {\n id\n ...HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace\n }\n workspaces {\n items {\n id\n ...HeaderWorkspaceSwitcherWorkspaceList_Workspace\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 HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace on LimitedWorkspace {\n id\n slug\n name\n logo\n }\n"): (typeof documents)["\n fragment HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace on LimitedWorkspace {\n id\n slug\n name\n logo\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment HeaderWorkspaceSwitcherHeaderWorkspace_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n"): (typeof documents)["\n fragment HeaderWorkspaceSwitcherHeaderWorkspace_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1607,7 +1635,11 @@ export function graphql(source: "\n mutation UpdateRegion($input: UpdateServerR
/**
* 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 UseNavigation_Workspace on Workspace {\n ...HeaderWorkspaceSwitcher_Workspace\n id\n }\n"): (typeof documents)["\n fragment UseNavigation_Workspace on Workspace {\n ...HeaderWorkspaceSwitcher_Workspace\n id\n }\n"];
export function graphql(source: "\n fragment UseNavigationActiveWorkspace_Workspace on Workspace {\n ...HeaderWorkspaceSwitcherActiveWorkspace_Workspace\n id\n }\n"): (typeof documents)["\n fragment UseNavigationActiveWorkspace_Workspace on Workspace {\n ...HeaderWorkspaceSwitcherActiveWorkspace_Workspace\n id\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 UseNavigationWorkspaceList_User on User {\n id\n ...HeaderWorkspaceSwitcherWorkspaceList_User\n }\n"): (typeof documents)["\n fragment UseNavigationWorkspaceList_User on User {\n id\n ...HeaderWorkspaceSwitcherWorkspaceList_User\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1615,7 +1647,11 @@ export function graphql(source: "\n mutation SetActiveWorkspace($slug: String,
/**
* 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 HeaderWorkspaceSwitcher($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...HeaderWorkspaceSwitcher_Workspace\n }\n }\n"): (typeof documents)["\n query HeaderWorkspaceSwitcher($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...HeaderWorkspaceSwitcher_Workspace\n }\n }\n"];
export function graphql(source: "\n query NavigationActiveWorkspace($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...UseNavigationActiveWorkspace_Workspace\n }\n }\n"): (typeof documents)["\n query NavigationActiveWorkspace($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...UseNavigationActiveWorkspace_Workspace\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 NavigationWorkspaceList {\n activeUser {\n id\n ...UseNavigationWorkspaceList_User\n }\n }\n"): (typeof documents)["\n query NavigationWorkspaceList {\n activeUser {\n id\n ...UseNavigationWorkspaceList_User\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
@@ -18,7 +18,7 @@ export const connectorItems: ConnectorItem[] = [
title: 'Revit',
slug: 'revit',
description:
'Publish and load models to boost design coordination and business intelligence workflows.',
'Publish and load Revit models to boost design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/revit',
image: '/images/connectors/revit.png',
categories: [ConnectorCategory.NextGen, ConnectorCategory.BIM]
@@ -36,7 +36,7 @@ export const connectorItems: ConnectorItem[] = [
title: 'Power BI',
slug: 'powerbi',
description:
'Load Power BI models to boost design coordination and business intelligence workflows.',
'Load your models into Power BI to boost design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/power-bi',
image: '/images/connectors/powerbi.png',
categories: [ConnectorCategory.BusinessIntelligence]
@@ -139,7 +139,7 @@ export const connectorItems: ConnectorItem[] = [
{
title: 'Blender',
slug: 'blender',
description: 'Load Blender models to boost design coordination workflows.',
description: 'Load models into Blender to boost design coordination workflows.',
image: '/images/connectors/blender.png',
categories: [ConnectorCategory.Visualisation, ConnectorCategory.CADAndModeling],
isComingSoon: true
@@ -1,21 +1,31 @@
import { setActiveWorkspaceMutation } from '~/lib/navigation/graphql/mutations'
import { useMutation, useQuery } from '@vue/apollo-composable'
import { headerWorkspaceSwitcherQuery } from '~/lib/navigation/graphql/queries'
import {
navigationActiveWorkspaceQuery,
navigationWorkspaceListQuery
} from '~/lib/navigation/graphql/queries'
import { graphql } from '~/lib/common/generated/gql'
import type { UseNavigation_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import type { UseNavigationActiveWorkspace_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
graphql(`
fragment UseNavigation_Workspace on Workspace {
...HeaderWorkspaceSwitcher_Workspace
fragment UseNavigationActiveWorkspace_Workspace on Workspace {
...HeaderWorkspaceSwitcherActiveWorkspace_Workspace
id
}
`)
graphql(`
fragment UseNavigationWorkspaceList_User on User {
id
...HeaderWorkspaceSwitcherWorkspaceList_User
}
`)
export const useNavigationState = () =>
useState<{
activeWorkspaceSlug: string | null
isProjectsActive: boolean
cachedWorkspaceData: UseNavigation_WorkspaceFragment | null
cachedWorkspaceData: UseNavigationActiveWorkspace_WorkspaceFragment | null
}>('navigation-state', () => ({
activeWorkspaceSlug: null,
isProjectsActive: false,
@@ -25,31 +35,48 @@ export const useNavigationState = () =>
export const useNavigation = () => {
const state = useNavigationState()
const { mutate } = useMutation(setActiveWorkspaceMutation)
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const activeWorkspaceSlug = computed({
get: () => state.value.activeWorkspaceSlug,
set: (newVal) => (state.value.activeWorkspaceSlug = newVal)
})
const {
result,
loading: workspaceLoading,
onResult
} = useQuery(
headerWorkspaceSwitcherQuery,
() => ({
slug: activeWorkspaceSlug.value || ''
}),
() => ({
enabled: !!activeWorkspaceSlug.value
})
)
const isProjectsActive = computed({
get: () => state.value.isProjectsActive,
set: (newVal) => (state.value.isProjectsActive = newVal)
})
const { result: workspacesResult } = useQuery(navigationWorkspaceListQuery, null, {
enabled: isWorkspacesEnabled.value
})
// Check for expired SSO sessions
const expiredSsoSessions = computed(
() => workspacesResult.value?.activeUser?.expiredSsoSessions || []
)
// Check if the current active workspace has an expired SSO session
const activeWorkspaceHasExpiredSsoSession = computed(
() =>
!!expiredSsoSessions.value.find(
(session) => session.slug === activeWorkspaceSlug.value
)
)
const { result: activeWorkspaceResult, onResult } = useQuery(
navigationActiveWorkspaceQuery,
() => ({
slug: activeWorkspaceSlug.value || ''
}),
() => ({
enabled:
!!activeWorkspaceSlug.value &&
isWorkspacesEnabled.value &&
!activeWorkspaceHasExpiredSsoSession.value
})
)
// Set state and mutate
const mutateActiveWorkspaceSlug = async (newVal: string) => {
state.value.activeWorkspaceSlug = newVal
@@ -64,11 +91,28 @@ export const useNavigation = () => {
await mutate({ isProjectsActive: state.value.isProjectsActive, slug: null })
}
// Active workspace where SSO session is expired
const expiredSsoWorkspaceData = computed(() =>
expiredSsoSessions.value.find(
(session) => session.slug === activeWorkspaceSlug.value
)
)
// Use the cached data or the current result
const workspaceData = computed(() => {
return result.value?.workspaceBySlug || state.value.cachedWorkspaceData
const activeWorkspaceData = computed(() => {
return (
activeWorkspaceResult.value?.workspaceBySlug || state.value.cachedWorkspaceData
)
})
const workspaceList = computed(() =>
workspacesResult.value?.activeUser
? workspacesResult.value.activeUser.workspaces.items.filter(
(workspace) => workspace.creationState?.completed !== false
)
: []
)
// Save data in the state, the prevent flickering when the component remount in between navigation
onResult((result) => {
const workspace = result.data?.workspaceBySlug
@@ -82,7 +126,9 @@ export const useNavigation = () => {
isProjectsActive,
mutateActiveWorkspaceSlug,
mutateIsProjectsActive,
workspaceData,
workspaceLoading
activeWorkspaceData,
workspaceList,
activeWorkspaceHasExpiredSsoSession,
expiredSsoWorkspaceData
}
}
@@ -1,9 +1,18 @@
import { graphql } from '~/lib/common/generated/gql'
export const headerWorkspaceSwitcherQuery = graphql(`
query HeaderWorkspaceSwitcher($slug: String!) {
export const navigationActiveWorkspaceQuery = graphql(`
query NavigationActiveWorkspace($slug: String!) {
workspaceBySlug(slug: $slug) {
...HeaderWorkspaceSwitcher_Workspace
...UseNavigationActiveWorkspace_Workspace
}
}
`)
export const navigationWorkspaceListQuery = graphql(`
query NavigationWorkspaceList {
activeUser {
id
...UseNavigationWorkspaceList_User
}
}
`)
@@ -1,24 +0,0 @@
import { useQuery } from '@vue/apollo-composable'
import { settingsSidebarQuery } from '~/lib/settings/graphql/queries'
export const useUserWorkspaces = () => {
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { result } = useQuery(settingsSidebarQuery, null, {
enabled: isWorkspacesEnabled.value
})
const workspaces = computed(() =>
result.value?.activeUser
? result.value.activeUser.workspaces.items.filter(
(workspace) => workspace.creationState?.completed !== false
)
: []
)
const hasWorkspaces = computed(() => workspaces.value.length > 0)
return {
workspaces,
hasWorkspaces
}
}
@@ -192,7 +192,6 @@ export const useWorkspacesWizard = () => {
const finalizeWizard = async (state: WorkspaceWizardState, workspaceId: string) => {
isLoading.value = true
mutateActiveWorkspaceSlug(workspaceId)
if (state.region?.key && state.plan === PaidWorkspacePlans.Business) {
await updateWorkspaceDefaultRegion({
@@ -1,8 +1,12 @@
import { emailLogger as logger } from '@/observability/logging'
import { emailLogger } from '@/observability/logging'
import { SendEmail, SendEmailParams } from '@/modules/emails/domain/operations'
import { getTransporter } from '@/modules/emails/utils/transporter'
import { getEmailFromAddress } from '@/modules/shared/helpers/envHelper'
import { resolveMixpanelUserId } from '@speckle/shared'
import {
getRequestLogger,
loggerWithMaybeContext
} from '@/observability/components/express/requestContext'
export type { SendEmailParams } from '@/modules/emails/domain/operations'
/**
@@ -15,6 +19,7 @@ export const sendEmail: SendEmail = async ({
text,
html
}: SendEmailParams): Promise<boolean> => {
const logger = getRequestLogger() || loggerWithMaybeContext({ logger: emailLogger })
const transporter = getTransporter()
if (!transporter) {
logger.warn('No email transport present. Cannot send emails. Skipping send...')
@@ -37,7 +37,11 @@ import {
storeTokenScopesFactory,
storeUserServerAppTokenFactory
} from '@/modules/core/repositories/tokens'
import { getServerOrigin } from '@/modules/shared/helpers/envHelper'
import {
getPrivateObjectsServerOrigin,
getServerOrigin,
previewServiceShouldUsePrivateObjectsServerUrl
} from '@/modules/shared/helpers/envHelper'
import { requestObjectPreviewFactory } from '@/modules/previews/queues/previews'
import type { Queue } from 'bull'
import type { Knex } from 'knex'
@@ -61,7 +65,10 @@ const buildCreateObjectPreviewFunction = ({
queue: previewRequestQueue,
responseQueue: responseQueueName
}),
serverOrigin: getServerOrigin(),
// use the private server origin if defined, otherwise use the public server origin
serverOrigin: previewServiceShouldUsePrivateObjectsServerUrl()
? getPrivateObjectsServerOrigin()
: getServerOrigin(),
storeObjectPreview: storeObjectPreviewFactory({ db: projectDb }),
getStreamCollaborators: getStreamCollaboratorsFactory({ db }),
createAppToken: createAppTokenFactory({
@@ -1,7 +1,7 @@
import { MisconfiguredEnvironmentError } from '@/modules/shared/errors'
import { trimEnd } from 'lodash'
import * as Environment from '@speckle/shared/dist/commonjs/environment/index.js'
import { ensureError } from '@speckle/shared'
import { ensureError, Nullable } from '@speckle/shared'
export function getStringFromEnv(
envVarKey: string,
@@ -28,6 +28,32 @@ export function getBooleanFromEnv(envVarKey: string, aDefault = false): boolean
return ['1', 'true', true].includes(process.env[envVarKey] || aDefault.toString())
}
function mustGetUrlFromEnv(name: string, trimTrailingSlash: boolean = false): URL {
const url = getUrlFromEnv(name, trimTrailingSlash)
if (!url) throw new MisconfiguredEnvironmentError(`${name} env var not configured`)
return url
}
function getUrlFromEnv(
name: string,
trimTrailingSlash: boolean = false
): Nullable<URL> {
const value = process.env[name]
if (!value) {
return null
}
try {
return new URL(trimTrailingSlash ? trimEnd(value, '/') : value)
} catch (e: unknown) {
const err = ensureError(e, 'Unknown error parsing URL')
if (err instanceof TypeError && err.message === 'Invalid URL')
throw new MisconfiguredEnvironmentError(`${name} has to be a valid URL`, {
cause: err
})
throw new MisconfiguredEnvironmentError(`Error parsing ${name} URL`, { cause: err })
}
}
export function getSessionSecret() {
if (!process.env.SESSION_SECRET) {
throw new MisconfiguredEnvironmentError('SESSION_SECRET env var not configured')
@@ -86,6 +112,10 @@ export function getRedisUrl() {
return getStringFromEnv('REDIS_URL')
}
export const previewServiceShouldUsePrivateObjectsServerUrl = (): boolean => {
return getBooleanFromEnv('PREVIEW_SERVICE_USE_PRIVATE_OBJECTS_SERVER_URL')
}
export const getPreviewServiceRedisUrl = (): string | undefined => {
return process.env['PREVIEW_SERVICE_REDIS_URL']
}
@@ -195,33 +225,19 @@ export function getFrontendOrigin() {
}
/**
* Get server app origin/base URL
* Get server app origin/base URL.
* This is the public server URL, i.e. 'canonical url', used for external communication.
*/
export function getServerOrigin() {
if (!process.env.CANONICAL_URL) {
throw new MisconfiguredEnvironmentError(
'Server origin environment variable (CANONICAL_URL) not configured'
)
}
return mustGetUrlFromEnv('CANONICAL_URL', true).origin
}
try {
return new URL(trimEnd(process.env.CANONICAL_URL, '/')).origin
} catch (e) {
const err = ensureError(e)
if (e instanceof TypeError && e.message === 'Invalid URL') {
throw new MisconfiguredEnvironmentError(
`Server origin environment variable (CANONICAL_URL) is not a valid URL: ${process.env.CANONICAL_URL} ${err.message}`,
{
cause: e,
info: {
value: process.env.CANONICAL_URL
}
}
)
}
throw err
}
/**
*
* @returns the private server origin, which is used for internal communication between services
*/
export function getPrivateObjectsServerOrigin() {
return mustGetUrlFromEnv('PRIVATE_OBJECTS_SERVER_URL', true).origin
}
export function getBindAddress(aDefault: string = '127.0.0.1') {
@@ -239,26 +255,12 @@ export function isSSLServer() {
return /^https:\/\//.test(getServerOrigin())
}
function parseUrlVar(value: string, name: string) {
try {
return new URL(value)
} catch (err: unknown) {
if (err instanceof TypeError && err.message === 'Invalid URL')
throw new MisconfiguredEnvironmentError(`${name} has to be a valid URL`)
throw err
}
}
export function getServerMovedFrom() {
const value = process.env.MIGRATION_SERVER_MOVED_FROM
if (!value) return value
return parseUrlVar(value, 'MIGRATION_SERVER_MOVED_FROM')
return getUrlFromEnv('MIGRATION_SERVER_MOVED_FROM')
}
export function getServerMovedTo() {
const value = process.env.MIGRATION_SERVER_MOVED_TO
if (!value) return value
return parseUrlVar(value, 'MIGRATION_SERVER_MOVED_TO')
return getUrlFromEnv('MIGRATION_SERVER_MOVED_TO')
}
export function adminOverrideEnabled() {
@@ -528,7 +528,6 @@ Retrieve the s3 parameters from ConfigMap if enabled, or default to retrieving t
{{- end }}
{{- end }}
{{/*
Generate the environment variables for Speckle server and Speckle objects deployments
*/}}
@@ -542,6 +541,10 @@ Generate the environment variables for Speckle server and Speckle objects deploy
- name: PORT
value: {{ include "server.port" $ | quote }}
- name: PRIVATE_OBJECTS_SERVER_URL
value: {{ printf "http://%s:%s" ( include "objects.service.fqdn" $ ) ( include "objects.port" $ ) }}
- name: LOG_LEVEL
value: {{ .Values.server.logLevel }}
- name: LOG_PRETTY
@@ -799,6 +802,12 @@ Generate the environment variables for Speckle server and Speckle objects deploy
value: {{ .Values.server.gendoAI.ratelimiting.burstRenderRequestPeriodSeconds | quote }}
{{- end }}
# *** Preview service ***
{{- if .Values.preview_service.deployInCluster }}
- name: PREVIEW_SERVICE_USE_PRIVATE_OBJECTS_SERVER_URL
value: "true"
{{- end }}
# *** Redis ***
- name: REDIS_URL
valueFrom:
@@ -23,6 +23,11 @@ spec:
- name: main
image: {{ default (printf "speckle/speckle-fileimport-service:%s" .Values.docker_image_tag) .Values.fileimport_service.image }}
imagePullPolicy: {{ .Values.imagePullPolicy }}
args: #overwrites the Dockerfile CMD statement
{{- if .Values.fileimport_service.inspect.enabled }}
- {{ printf "--inspect=%s" .Values.fileimport_service.inspect.port }}
{{- end }}
- "bin/www.js"
ports:
- name: metrics
@@ -1,3 +1,4 @@
{{- if .Values.preview_service.deployInCluster }}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -121,4 +122,4 @@ spec:
# Should be > preview generation time ( 1 hour for good measure )
terminationGracePeriodSeconds: 3600
{{- end }}
@@ -1,3 +1,4 @@
{{- if .Values.preview_service.deployInCluster }}
{{- if (and (.Values.preview_service.networkPolicy.enabled) (eq .Values.networkPlugin.type "cilium")) -}}
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -38,3 +39,4 @@ spec:
# postgres
{{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }}
{{- end }}
{{- end }}
@@ -1,3 +1,4 @@
{{- if .Values.preview_service.deployInCluster }}
{{- if (and (.Values.preview_service.networkPolicy.enabled) (eq .Values.networkPlugin.type "kubernetes")) -}}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
@@ -38,4 +39,5 @@ spec:
protocol: UDP
# postgres
{{ include "speckle.networkpolicy.egress.postgres" $ | indent 4 }}
{{- end -}}
{{- end }}
{{- end }}
@@ -1,3 +1,4 @@
{{- if .Values.preview_service.deployInCluster }}
apiVersion: v1
kind: Service
metadata:
@@ -14,3 +15,4 @@ spec:
name: web
port: {{ .Values.preview_service.port }}
targetPort: metrics
{{- end }}
@@ -1,3 +1,4 @@
{{- if .Values.preview_service.deployInCluster }}
{{- if .Values.preview_service.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
@@ -21,3 +22,4 @@ secrets:
- name: {{ default .Values.secretName .Values.redis.previewServiceConnectionString.secretName }}
{{- end }}
{{- end -}}
{{- end }}
@@ -1884,6 +1884,11 @@
"preview_service": {
"type": "object",
"properties": {
"deployInCluster": {
"type": "boolean",
"description": "If enabled, the Preview Service will be deployed within the cluster and speckle-server will be configured to send the kubernetes service url of the objects server to the Preview Service.",
"default": true
},
"dedicatedPreviewsQueue": {
"type": "boolean",
"description": "Allows using a dedicated redis url for the preview service job queue",
@@ -2138,6 +2143,21 @@
"description": "The maximum number of connections that the File Import Service postgres client will make to the Postgres database.",
"default": 1
},
"inspect": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "If enabled, indicates that the File Import service should be deployed with the nodejs inspect feature enabled",
"default": false
},
"port": {
"type": "string",
"description": "The port on which the nodejs inspect feature should be exposed",
"default": "7000"
}
}
},
"requests": {
"type": "object",
"properties": {
+8
View File
@@ -1101,6 +1101,8 @@ frontend_2:
## @descriptionEnd
##
preview_service:
## @param preview_service.deployInCluster If enabled, the Preview Service will be deployed within the cluster and speckle-server will be configured to send the kubernetes service url of the objects server to the Preview Service.
deployInCluster: true
## @param preview_service.dedicatedPreviewsQueue Allows using a dedicated redis url for the preview service job queue
##
dedicatedPreviewsQueue: false
@@ -1279,6 +1281,12 @@ fileimport_service:
##
postgresMaxConnections: 1
inspect:
## @param fileimport_service.inspect.enabled If enabled, indicates that the File Import service should be deployed with the nodejs inspect feature enabled
enabled: false
## @param fileimport_service.inspect.port The port on which the nodejs inspect feature should be exposed
port: '7000'
requests:
## @param fileimport_service.requests.cpu The CPU that should be available on a node when scheduling this pod.
## ref: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/