Merge remote-tracking branch 'origin' into chuck/web-2435-move-comments-and-webhooks-without-attachments

This commit is contained in:
Chuck Driesler
2025-02-18 16:04:20 +00:00
609 changed files with 5668 additions and 51059 deletions
+9 -38
View File
@@ -2,7 +2,7 @@ version: 2.1
orbs:
snyk: snyk/snyk@2.0.3
# codecov: codecov/codecov@4.1.0
codecov: codecov/codecov@5.0.3
workflows:
test-build:
@@ -20,6 +20,7 @@ workflows:
context:
- speckle-server-licensing
- stripe-integration
- speckle-server-codecov
filters: &filters-allow-all
tags:
# run tests for any commit on any branch, including any tags
@@ -93,7 +94,6 @@ workflows:
- get-version
- deployment-testing-approval
- docker-build-server
- docker-build-frontend
- docker-build-frontend-2
- docker-build-previews
- docker-build-webhooks
@@ -116,12 +116,6 @@ workflows:
requires:
- get-version
- docker-build-frontend:
context: *build-context
filters: *filters-build
requires:
- get-version
- docker-build-frontend-2:
context: *build-context
filters: *filters-build
@@ -195,22 +189,6 @@ workflows:
- test-server-multiregion
- test-preview-service
- docker-publish-frontend:
context: *docker-hub-context
filters: *filters-publish
requires:
- docker-build-frontend
- get-version
- pre-commit
- publish-approval
- test-frontend-2
- test-viewer
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-frontend-2:
context: *docker-hub-context
filters: *filters-publish
@@ -340,7 +318,6 @@ workflows:
- deployment-test-helm-chart
- docker-publish-docker-compose-ingress
- docker-publish-file-imports
- docker-publish-frontend
- docker-publish-frontend-2
- docker-publish-monitor-container
- docker-publish-previews
@@ -494,8 +471,9 @@ jobs:
S3_CREATE_BUCKET: 'true'
REDIS_URL: 'redis://127.0.0.1:6379'
S3_REGION: '' # optional, defaults to 'us-east-1'
FRONTEND_ORIGIN: 'http://127.0.0.1:8081'
ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
FF_BILLING_INTEGRATION_ENABLED: 'true'
ENABLE_ALL_FFS: 'true'
RATELIMITER_ENABLED: 'false'
steps:
- checkout
@@ -535,8 +513,8 @@ jobs:
command: yarn test:report
working_directory: 'packages/server'
# - codecov/upload:
# file: packages/server/coverage/lcov.info
- codecov/upload:
files: packages/server/coverage/lcov.info
- run:
name: Introspect GQL schema for subsequent checks
@@ -566,6 +544,7 @@ jobs:
NODE_ENV: test
DATABASE_URL: 'postgres://speckle:speckle@127.0.0.1:5432/speckle2_test'
PGDATABASE: speckle2_test
POSTGRES_MAX_CONNECTIONS_SERVER: 20
PGUSER: speckle
SESSION_SECRET: 'keyboard cat'
STRATEGY_LOCAL: 'true'
@@ -577,6 +556,7 @@ jobs:
S3_CREATE_BUCKET: 'true'
REDIS_URL: 'redis://127.0.0.1:6379'
S3_REGION: '' # optional, defaults to 'us-east-1'
FRONTEND_ORIGIN: 'http://127.0.0.1:8081'
ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
DISABLE_ALL_FFS: 'true'
RATELIMITER_ENABLED: 'false'
@@ -627,6 +607,7 @@ jobs:
S3_CREATE_BUCKET: 'true'
REDIS_URL: 'redis://127.0.0.1:6379'
S3_REGION: '' # optional, defaults to 'us-east-1'
FRONTEND_ORIGIN: 'http://127.0.0.1:8081'
ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
FF_BILLING_INTEGRATION_ENABLED: 'true'
# These are the only different env keys:
@@ -1018,11 +999,6 @@ jobs:
environment:
SPECKLE_SERVER_PACKAGE: server
docker-build-frontend:
<<: *build-job
environment:
SPECKLE_SERVER_PACKAGE: frontend
docker-build-frontend-2:
<<: *build-job
resource_class: xlarge
@@ -1088,11 +1064,6 @@ jobs:
environment:
SPECKLE_SERVER_PACKAGE: server
docker-publish-frontend:
<<: *publish-job
environment:
SPECKLE_SERVER_PACKAGE: frontend
docker-publish-frontend-2:
<<: *publish-job
environment:
-3
View File
@@ -1,8 +1,6 @@
*node_modules
packages/server/.env
packages/server/dist
packages/frontend/dist
packages/frontend/profiler
packages/viewer/dist
packages/objectloader/dist
packages/*/dist
@@ -22,7 +20,6 @@ coverage/
packages/viewer/example/*.js
packages/viewer/example/*.js.map
packages/frontend/schema.graphql
.tool-versions
packages/server/reports*
+1 -1
View File
@@ -25,7 +25,7 @@
This monorepo is the home of the Speckle v2 web packages:
- [`packages/server`](https://github.com/specklesystems/speckle-server/blob/main/packages/server): the Server, a nodejs app. Core external dependencies are a Redis and Postgresql db.
- [`packages/frontend`](https://github.com/specklesystems/speckle-server/blob/main/packages/frontend): the Frontend, a static Vue app.
- [`packages/frontend-2`](https://github.com/specklesystems/speckle-server/blob/main/packages/frontend-2): the Frontend, a Nuxt/Vue app.
- [`packages/viewer`](https://github.com/specklesystems/speckle-server/blob/main/packages/viewer): a threejs extension that allows you to display 3D data [![npm version](https://camo.githubusercontent.com/dc69232cc57b77de6554e752dd6dfc60ca0ecdfbe91bdfcbf7c7531a511ec200/68747470733a2f2f62616467652e667572792e696f2f6a732f253430737065636b6c652532467669657765722e737667)](https://www.npmjs.com/package/@speckle/viewer)
- [`packages/objectloader`](https://github.com/specklesystems/speckle-server/blob/main/packages/objectloader): a small js utility class that helps you stream an object and all its sub-components from the Speckle Server API. [![npm version](https://camo.githubusercontent.com/4d4f1e38ce50aaf11b4a3ad8e01ce3eaaa561dc5fd08febbae556f52f1d41097/68747470733a2f2f62616467652e667572792e696f2f6a732f253430737065636b6c652532466f626a6563746c6f616465722e737667)](https://www.npmjs.com/package/@speckle/objectloader)
- [`packages/preview-service`](https://github.com/specklesystems/speckle-server/blob/main/packages/preview-service): generates object previews for Speckle Objects headlessly. This package is meant to be called on by the server.
+4
View File
@@ -0,0 +1,4 @@
codecov:
notify:
notify_error: true
require_ci_to_pass: false
-1
View File
@@ -72,7 +72,6 @@ services:
FILE_SIZE_LIMIT_MB: 100
EMAIL_FROM: 'no-reply@example.org'
USE_FRONTEND_2: true
FRONTEND_ORIGIN: 'http://127.0.0.1'
ONBOARDING_STREAM_URL: 'https://latest.speckle.systems/projects/843d07eb10'
-1
View File
@@ -9,7 +9,6 @@ services:
environment:
SPECKLE_SERVER: http://127.0.0.1 # this is the canonical url
SERVER_VERSION: 2
FRONTEND_VERSION: '2'
VERIFY_CERTIFICATE: '0'
restart: 'no'
-1
View File
@@ -32,7 +32,6 @@
"dev:minimal": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend-2}' run dev",
"gqlgen": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/frontend-2,@speckle/dui3}' run gqlgen",
"dev:server": "yarn workspace @speckle/server dev",
"dev:frontend": "yarn workspace @speckle/frontend dev",
"dev:frontend-2": "yarn workspace @speckle/frontend-2 dev",
"dev:shared": "yarn workspace @speckle/shared dev",
"prepare": "husky install",
+1 -39
View File
@@ -9,7 +9,6 @@ const Observability = require('@speckle/shared/dist/commonjs/observability/index
const tables = (db) => ({
objects: db('objects'),
closures: db('object_children_closure'),
branches: db('branches'),
streams: db('streams'),
apiTokens: db('api_tokens'),
@@ -47,17 +46,9 @@ module.exports = class ServerAPI {
async createObject({ streamId, object }) {
const insertionObject = this.prepInsertionObject(streamId, object)
const closures = []
const totalChildrenCountByDepth = {}
if (object.__closure !== null) {
for (const prop in object.__closure) {
closures.push({
streamId,
parent: insertionObject.id,
child: prop,
minDepth: object.__closure[prop]
})
if (totalChildrenCountByDepth[object.__closure[prop].toString()])
totalChildrenCountByDepth[object.__closure[prop].toString()]++
else totalChildrenCountByDepth[object.__closure[prop].toString()] = 1
@@ -67,22 +58,17 @@ module.exports = class ServerAPI {
delete insertionObject.__tree
delete insertionObject.__closure
insertionObject.totalChildrenCount = closures.length
insertionObject.totalChildrenCount = object.__closures.length
insertionObject.totalChildrenCountByDepth = JSON.stringify(
totalChildrenCountByDepth
)
await this.tables.objects.insert(insertionObject).onConflict().ignore()
if (closures.length > 0) {
await this.tables.closures.insert(closures).onConflict().ignore()
}
return insertionObject.id
}
async createObjectsBatched(streamId, objects) {
const closures = []
const objsToInsert = []
const ids = []
@@ -94,12 +80,6 @@ module.exports = class ServerAPI {
if (obj.__closure !== null) {
for (const prop in obj.__closure) {
closures.push({
streamId,
parent: insertionObject.id,
child: prop,
minDepth: obj.__closure[prop]
})
totalChildrenCountGlobal++
if (totalChildrenCountByDepth[obj.__closure[prop].toString()])
totalChildrenCountByDepth[obj.__closure[prop].toString()]++
@@ -119,7 +99,6 @@ module.exports = class ServerAPI {
ids.push(insertionObject.id)
})
const closureBatchSize = 1000
const objectsBatchSize = 500
// step 1: insert objects
@@ -139,23 +118,6 @@ module.exports = class ServerAPI {
}
}
// step 2: insert closures
if (closures.length > 0) {
const batches = chunk(closures, closureBatchSize)
for (const [index, batch] of batches.entries()) {
this.prepInsertionClosureBatch(batch)
await this.tables.closures.insert(batch).onConflict().ignore()
this.logger.info(
{
currentBatchCount: batch.length,
currentBatchId: index + 1,
totalNumberOfBatches: batches.length
},
'Inserted {currentBatchCount} closures from batch {currentBatchId} of {totalNumberOfBatches}'
)
}
}
return ids
}
@@ -12,6 +12,7 @@
show-label
:disabled="!!(loading || shouldForceInviteEmail)"
auto-focus
autocomplete="email"
/>
<FormTextInput
type="password"
@@ -23,6 +24,7 @@
:rules="passwordRules"
show-label
:disabled="loading"
autocomplete="current-password"
/>
</div>
<FormButton
@@ -1,5 +1,6 @@
<template>
<FormCheckbox
id="newsletter-consent-checkbox"
v-model="newsletterConsent"
name="newsletter"
label="Opt in for exclusive Speckle news and tips"
@@ -8,5 +9,7 @@
</template>
<script setup lang="ts">
const newsletterConsent = ref<true | undefined>(undefined)
const newsletterConsent = defineModel<boolean>('newsletterConsent', {
required: true
})
</script>
@@ -13,6 +13,7 @@
:rules="emailRules"
show-label
:disabled="isEmailDisabled"
autocomplete="email"
/>
<FormTextInput
type="text"
@@ -25,6 +26,7 @@
show-label
:disabled="loading"
auto-focus
autocomplete="name"
/>
<FormTextInput
v-model="password"
@@ -37,6 +39,7 @@
:rules="passwordRules"
show-label
:disabled="loading"
autocomplete="new-password"
/>
</div>
<AuthPasswordChecks :password="password" class="mt-2 h-12 sm:h-8" />
@@ -1,113 +0,0 @@
<template>
<div v-if="shouldShowBanner" class="flex flex-col px-3 pb-3">
<div class="text-body-2xs text-foreground mb-3">{{ verifyBannerText }}</div>
<FormButton
size="sm"
color="outline"
:disabled="loading"
@click="requestVerification"
>
{{ verifyBannerCtaText }}
</FormButton>
</div>
<div v-else-if="noticeLoading">
<CommonLoadingIcon size="sm" class="my-2 mx-auto" />
</div>
<div v-else />
</template>
<script setup lang="ts">
import { graphql } from '~~/lib/common/generated/gql'
import { useApolloClient, useQuery } from '@vue/apollo-composable'
import {
convertThrowIntoFetchResult,
getFirstErrorMessage
} from '~~/lib/common/helpers/graphql'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
const reminderStateQuery = graphql(`
query EmailVerificationBannerState {
activeUser {
id
email
verified
hasPendingVerification
}
}
`)
const requestVerificationMutation = graphql(`
mutation RequestVerification {
requestVerification
}
`)
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const { result, loading: noticeLoading } = useQuery(reminderStateQuery)
const user = computed(() => result.value?.activeUser || null)
const dismissed = ref(false)
const loading = ref(false)
const shouldShowBanner = computed(() => {
if (!user.value) return false
if (user.value.verified) return false
if (dismissed.value) return false
return true
})
const hasPendingVerification = computed(() => !!user.value?.hasPendingVerification)
const verifyBannerText = computed(() => {
if (!user.value?.email) return ''
return hasPendingVerification.value
? `Please check your inbox (${user.value.email}) for the verification e-mail`
: `Please verify your e-mail address.`
})
const verifyBannerCtaText = computed(() =>
hasPendingVerification.value ? `Re-send verification` : `Send verification`
)
const dismiss = () => (dismissed.value = true)
const requestVerification = async () => {
const userData = user.value
if (!userData) return
loading.value = true
const { data, errors } = await apollo
.mutate({
mutation: requestVerificationMutation,
update: (cache, { data }) => {
const isSuccess = !!data?.requestVerification
if (!isSuccess) return
// Switch hasPendingVerification to true
cache.modify({
id: cache.identify(userData),
fields: {
hasPendingVerification: () => true
}
})
}
})
.catch(convertThrowIntoFetchResult)
.finally(() => (loading.value = false))
if (!data?.requestVerification) {
const errMsg = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Resend failed',
description: errMsg
})
} else {
triggerNotification({
type: ToastNotificationType.Info,
title: 'Verification e-mail sent, please check your inbox'
})
dismiss()
}
}
</script>
@@ -39,7 +39,7 @@ import { useQuery } from '@vue/apollo-composable'
const route = useRoute()
const loading = ref(false)
const newsletterConsent = ref<true | undefined>(undefined)
const newsletterConsent = ref<boolean>(false)
const { challenge } = useLoginOrRegisterUtils()
const { signInOrSignUpWithSso } = useAuthManager()
@@ -0,0 +1,186 @@
<template>
<div>
<Portal to="navigation">
<HeaderNavLink :to="homeRoute" name="Dashboard" hide-chevron :separator="false" />
</Portal>
<PromoBannersWrapper
v-if="promoBanners && promoBanners.length"
:banners="promoBanners"
/>
<ProjectsDashboardHeader
:projects-invites="projectsResult?.activeUser || undefined"
:workspaces-invites="workspacesResult?.activeUser || undefined"
/>
<div class="flex flex-col gap-y-12">
<div class="flex flex-col-reverse lg:flex-col gap-y-12">
<section>
<h2 class="text-heading-sm text-foreground-2">Quickstart</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 pt-5">
<CommonCard
v-for="quickStartItem in quickStartItems"
:key="quickStartItem.title"
:title="quickStartItem.title"
:description="quickStartItem.description"
:buttons="quickStartItem.buttons"
/>
</div>
</section>
<section>
<div class="flex items-center justify-between">
<h2 class="text-heading-sm text-foreground-2">Recently updated projects</h2>
<FormButton
color="outline"
size="sm"
@click.stop="router.push(projectsRoute)"
>
View all
</FormButton>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 pt-5">
<template v-if="hasProjects">
<DashboardProjectCard
v-for="project in projects"
:key="project.id"
:project="project"
/>
</template>
<CommonCard
v-else
title="Create your first project"
description="Projects are the place where your models and their versions live."
:buttons="createProjectButton"
/>
</div>
</section>
</div>
<section>
<div class="flex items-center justify-between">
<h2 class="text-heading-sm text-foreground-2">Tutorials</h2>
<FormButton
color="outline"
size="sm"
to="https://www.speckle.systems/tutorials"
external
target="_blank"
>
View all
</FormButton>
</div>
<div
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 pt-5"
>
<DashboardTutorialCard
v-for="tutorialItem in tutorialItems"
:key="tutorialItem.title"
:tutorial-item="tutorialItem"
/>
</div>
</section>
</div>
<ProjectsAddDialog v-model:open="openNewProject" />
</div>
</template>
<script setup lang="ts">
import {
dashboardProjectsPageQuery,
dashboardProjectsPageWorkspacesQuery
} from '~~/lib/dashboard/graphql/queries'
import type { QuickStartItem } from '~~/lib/dashboard/helpers/types'
import { useQuery } from '@vue/apollo-composable'
import { useMixpanel } from '~~/lib/core/composables/mp'
import {
docsPageUrl,
forumPageUrl,
homeRoute,
projectsRoute
} from '~~/lib/common/helpers/route'
import type { ManagerExtension } from '~~/lib/common/utils/downloadManager'
import { downloadManager } from '~~/lib/common/utils/downloadManager'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import type { LayoutDialogButton } from '@speckle/ui-components'
import type { PromoBanner } from '~/lib/promo-banners/types'
import { tutorials } from '~/lib/dashboard/helpers/tutorials'
import { useUserProjectsUpdatedTracking } from '~~/lib/user/composables/projectUpdates'
const mixpanel = useMixpanel()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { result: projectsResult } = useQuery(dashboardProjectsPageQuery)
const { result: workspacesResult } = useQuery(
dashboardProjectsPageWorkspacesQuery,
undefined,
() => ({
enabled: isWorkspacesEnabled.value
})
)
const { triggerNotification } = useGlobalToast()
const { isGuest } = useActiveUser()
const router = useRouter()
useUserProjectsUpdatedTracking()
const promoBanners = ref<PromoBanner[]>()
const openNewProject = ref(false)
const tutorialItems = shallowRef(tutorials)
const quickStartItems = shallowRef<QuickStartItem[]>([
{
title: 'Install Speckle manager',
description: 'Use our Manager to install and manage Connectors with ease.',
buttons: [
{
text: 'Download for Windows',
onClick: () => onDownloadManager('exe')
},
{
text: 'Download for Mac',
onClick: () => onDownloadManager('dmg')
}
]
},
{
title: "Don't know where to start?",
description: "We'll walk you through some of most common usage scenarios.",
buttons: [
{
text: 'Open documentation',
props: { to: docsPageUrl }
}
]
},
{
title: 'Have a question you need answered?',
description: 'Submit your question on the forum and get help from the community.',
buttons: [
{
text: 'Ask a question',
props: { to: forumPageUrl }
}
]
}
])
const createProjectButton = shallowRef<LayoutDialogButton[]>([
{
text: 'Create a project',
props: { disabled: isGuest.value },
onClick: () => (openNewProject.value = true)
}
])
const projects = computed(() => projectsResult.value?.activeUser?.projects.items)
const hasProjects = computed(() => (projects.value ? projects.value.length > 0 : false))
const onDownloadManager = (extension: ManagerExtension) => {
try {
downloadManager(extension)
mixpanel.track('Manager Download', {
os: extension === 'exe' ? 'win' : 'mac'
})
} catch {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Download failed'
})
}
}
</script>
@@ -98,8 +98,12 @@ const onSubmit = handleSubmit(async () => {
})
await sendWebhook(defaultZapierWebhookUrl, {
userId: user.value?.id ?? '',
feedback: feedback.value
feedback: [
`**Action:** User Feedback`,
`**Type:** ${props.type}`,
`**User ID:** ${user.value?.id}`,
`**Feedback:** ${feedback.value}`
].join('\n')
})
})
@@ -62,6 +62,7 @@ const emit = defineEmits<{
const props = defineProps<{
modelValue?: ValueType
clearable?: boolean
hiddenItems?: StreamRoles[]
disabledItems?: StreamRoles[]
disabledItemsTooltip?: string
allowUnset?: boolean
@@ -80,7 +81,9 @@ const { selectedValue, firstItem, isMultiItemArrayValue, hiddenSelectedItemCount
dynamicVisibility: { elementToWatchForChanges, itemContainer }
})
const roles = computed(() => Object.values(Roles.Stream))
const roles = computed(() =>
Object.values(Roles.Stream).filter((role) => !props.hiddenItems?.includes(role))
)
const disabledItemPredicate = (item: StreamRoles) =>
props.disabledItems && props.disabledItems.length > 0
@@ -11,6 +11,7 @@
:name="name || 'projects'"
:label-id="labelId"
:button-id="buttonId"
:tooltip-text="tooltipText"
by="id"
>
<template #nothing-selected>
@@ -139,6 +140,9 @@ const props = defineProps({
*/
workspaceId: {
type: String as PropType<Optional<string>>
},
tooltipText: {
type: String as PropType<Optional<string>>
}
})
@@ -0,0 +1,14 @@
<template>
<nav class="fixed z-40 top-0 h-12 bg-foundation border-b border-outline-2">
<div
class="flex gap-8 items-center justify-between h-full w-screen py-4 px-3 sm:px-4"
>
<div>
<slot name="header-left" />
</div>
<div>
<slot name="header-right" />
</div>
</div>
</nav>
</template>
@@ -1,7 +1,7 @@
<template>
<Component
:is="mainComponent"
class="flex items-center shrink-0"
class="flex items-center shrink-0 select-none"
:to="to"
:target="target"
>
@@ -18,8 +18,6 @@
<PortalTarget name="secondary-actions"></PortalTarget>
<PortalTarget name="primary-actions"></PortalTarget>
</ClientOnly>
<!-- Notifications dropdown -->
<HeaderNavNotifications v-if="hasNotifications" />
<FormButton
v-if="!activeUser"
:to="loginUrl.fullPath"
@@ -55,10 +53,4 @@ const loginUrl = computed(() =>
}
})
)
const hasNotifications = computed(() => {
if (!activeUser.value) return false
if (!activeUser.value?.verified) return true
return false
})
</script>
@@ -1,51 +0,0 @@
<template>
<div>
<Menu as="div" class="flex items-center">
<MenuButton :id="menuButtonId" v-slot="{ open: menuOpen }" as="div">
<div
class="relative cursor-pointer p-1 w-8 h-8 flex items-center justify-center rounded-md"
:class="menuOpen ? 'border border-outline-2' : ''"
>
<span class="sr-only">Open notifications menu</span>
<div class="relative">
<div v-if="!menuOpen">
<div
class="absolute -top-1 right-0 w-1.5 h-1.5 rounded-full bg-primary animate-ping"
></div>
<div
class="absolute -top-1 right-0 w-1.5 h-1.5 rounded-full bg-primary"
></div>
</div>
<BellIcon v-if="!menuOpen" class="w-5 h-5" />
<XMarkIcon v-else class="w-5 h-5" />
</div>
</div>
</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 right-0 md:right-20 top-10 mt-1.5 w-full sm:w-64 origin-top-right bg-foundation-page outline outline-2 outline-primary-muted rounded-md shadow-lg overflow-hidden"
>
<div class="px-3 py-2 text-body-xs font-medium">Notifications</div>
<!-- <div class="p-2 text-sm">TODO: project invites</div> -->
<MenuItem>
<AuthVerificationReminderMenuNotice />
</MenuItem>
</MenuItems>
</Transition>
</Menu>
</div>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { XMarkIcon, BellIcon } from '@heroicons/vue/24/outline'
const menuButtonId = useId()
</script>
@@ -0,0 +1,31 @@
<template>
<div>
<HeaderEmpty v-if="emptyHeader">
<template #header-left>
<slot name="header-left" />
</template>
<template #header-right>
<slot name="header-right" />
</template>
</HeaderEmpty>
<HeaderNavBar v-else />
<div class="h-dvh w-dvh overflow-hidden flex flex-col">
<!-- Static Spacer to allow for absolutely positioned HeaderNavBar -->
<div class="h-12 w-full shrink-0"></div>
<div class="relative flex h-[calc(100dvh-3rem)]">
<main class="w-full h-full overflow-y-auto simple-scrollbar pt-4 lg:pt-6 pb-16">
<div class="container mx-auto px-6 md:px-8">
<slot />
</div>
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
emptyHeader?: boolean
}>()
</script>
@@ -0,0 +1,284 @@
<template>
<LayoutDialog v-model:open="isOpen" :buttons="dialogButtons" max-width="md">
<template #header>Invite to Project</template>
<template v-if="isInWorkspace && invitableWorkspaceMembers.length">
<InviteDialogProjectWorkspaceMembers :project="props.project" />
<hr v-if="isAdmin" class="border-outline-3 mb-3 mt-5" />
</template>
<template v-else-if="isInWorkspace && !isAdmin">
<p class="text-body-xs text-foreground">
All workspace members are already in this project.
</p>
</template>
<template v-if="isAdmin || !isInWorkspace">
<form @submit="onSubmit">
<div class="flex flex-col gap-y-3 text-foreground">
<div v-for="(item, index) in fields" :key="item.key" class="flex flex-col">
<div class="flex flex-1 gap-x-3">
<div class="flex flex-col gap-y-3 flex-1">
<div class="flex items-start gap-x-3">
<div class="flex-1">
<FormTextInput
v-model="item.value.email"
:name="`email-${item.key}`"
color="foundation"
placeholder="Email address"
show-clear
full-width
use-label-in-errors
show-label
label="Email"
:rules="[isEmailOrEmpty]"
/>
</div>
<FormSelectProjectRoles
v-model="item.value.projectRole"
label="Select role"
:name="`fields.${index}.projectRole`"
class="w-40"
mount-menu-on-body
show-label
:allow-unset="false"
:hidden-items="[Roles.Stream.Owner]"
/>
</div>
<div v-if="isInWorkspace">
<FormSelectProjects
v-model="item.value.project"
label="Select project"
class="w-full"
owned-only
show-optional
mount-menu-on-body
show-label
:name="`project-${index}`"
:disabled="!canBeMember(item.value.email)"
/>
<p
v-if="!canBeMember(item.value.email)"
class="text-body-3xs text-foreground-2 mt-2"
>
This email does not match the set domain policy, and can only be
invited to individual projects
</p>
</div>
</div>
<CommonTextLink class="mt-7">
<TrashIcon
v-if="fields.length > 1"
class="h-4 w-4 text-foreground-2"
@click="removeInvite(index)"
/>
<div v-else class="h-4 w-4"></div>
</CommonTextLink>
</div>
<hr
v-if="index !== fields.length - 1"
class="flex-1 mt-3 border-outline-3"
/>
</div>
<FormButton color="subtle" :icon-left="PlusIcon" @click="addInviteItem">
Add another user
</FormButton>
</div>
</form>
<div
v-if="showBillingInfo"
class="text-body-2xs text-foreground-2 leading-5 mt-4"
>
<p>
Inviting users may add seats to your current billing cycle. Your workspace is
currently billed for
{{ memberSeatText }}{{ hasGuestSeats ? ` and ${guestSeatText}` : '' }}.
</p>
</div>
</template>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
import { graphql } from '~/lib/common/generated/gql'
import { useForm, useFieldArray } from 'vee-validate'
import { PlusIcon, TrashIcon } from '@heroicons/vue/24/outline'
import type { InviteProjectForm, InviteProjectItem } from '~~/lib/invites/helpers/types'
import { emptyInviteProjectItem } from '~~/lib/invites/helpers/constants'
import { isEmailOrEmpty } from '~~/lib/common/helpers/validation'
import { Roles } from '@speckle/shared'
import { matchesDomainPolicy } from '~/lib/invites/helpers/validation'
import {
type InviteDialogProject_ProjectFragment,
type WorkspacePlans,
type ProjectInviteCreateInput,
type WorkspaceProjectInviteCreateInput,
WorkspacePlanStatuses
} from '~/lib/common/generated/gql/graphql'
import { useTeamInternals } from '~~/lib/projects/composables/team'
import { isPaidPlan } from '~/lib/billing/helpers/types'
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
import { useMixpanel } from '~~/lib/core/composables/mp'
graphql(`
fragment InviteDialogProject_Project on Project {
id
name
...InviteDialogProjectWorkspaceMembers_Project
workspace {
id
name
defaultProjectRole
role
domainBasedMembershipProtectionEnabled
domains {
domain
id
}
plan {
status
name
}
subscription {
seats {
guest
plan
}
}
}
}
`)
const props = defineProps<{
project: InviteDialogProject_ProjectFragment
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const mixpanel = useMixpanel()
const createInvite = useInviteUserToProject()
const { collaboratorListItems } = useTeamInternals(computed(() => props.project))
const { handleSubmit } = useForm<InviteProjectForm>({
initialValues: {
fields: [
{
...emptyInviteProjectItem,
projectRole: Roles.Stream.Contributor
}
]
}
})
const {
fields,
replace: replaceFields,
push: pushInvite,
remove: removeInvite
} = useFieldArray<InviteProjectItem>('fields')
const invitableWorkspaceMembers = computed(() => {
const currentProjectMemberIds = new Set(
collaboratorListItems.value.map((item) => item.user?.id)
)
return (
props.project?.workspace?.team?.items.filter(
(member) => member.user.id && !currentProjectMemberIds.has(member.user.id)
) || []
)
})
const isInWorkspace = computed(() => !!props.project.workspace?.id)
const allowedDomains = computed(() =>
props.project.workspace?.domains?.map((d) => d.domain)
)
const memberSeatText = computed(() =>
props.project.workspace?.subscription?.seats.plan
? getSeatText(props.project.workspace.subscription.seats.plan, 'member')
: ''
)
const guestSeatText = computed(() =>
props.project.workspace?.subscription?.seats.guest
? getSeatText(props.project.workspace.subscription.seats.guest, 'guest')
: ''
)
const hasGuestSeats = computed(
() => (props.project.workspace?.subscription?.seats.guest ?? 0) > 0
)
const showBillingInfo = computed(() => {
if (!props.project.workspace?.plan) return false
return (
isPaidPlan(props.project.workspace.plan.name as unknown as WorkspacePlans) &&
props.project.workspace.plan.status === WorkspacePlanStatuses.Valid
)
})
const isAdmin = computed(() => props.project.workspace?.role === Roles.Workspace.Admin)
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'outline' },
onClick: () => (isOpen.value = false)
},
...(!isInWorkspace.value || isAdmin.value
? [
{
text: 'Invite',
props: {
submit: true
},
onClick: onSubmit
}
]
: [])
])
const getSeatText = (count: number, type: 'member' | 'guest') =>
`${count} ${type} ${count === 1 ? 'seat' : 'seats'}`
const canBeMember = (email: string) => matchesDomainPolicy(email, allowedDomains.value)
const addInviteItem = () => {
pushInvite({
...emptyInviteProjectItem,
project: { id: props.project.id, name: props.project.name }
})
}
const onSubmit = handleSubmit(async () => {
const invites = fields.value
.filter((invite) => invite.value.email)
.map((invite) => invite.value)
const inputs: ProjectInviteCreateInput[] | WorkspaceProjectInviteCreateInput[] =
invites.map((u) => ({
role: u.projectRole,
email: u.email,
serverRole: u.serverRole,
...(props.project?.workspace?.id
? {
workspaceRole: u.project?.id
? Roles.Workspace.Member
: Roles.Workspace.Guest
}
: {})
}))
if (!inputs.length) return
await createInvite(props.project.id, inputs)
mixpanel.track('Invite Action', {
type: 'project invite',
name: 'send',
multiple: inputs.length !== 1,
count: inputs.length,
hasProject: true
})
isOpen.value = false
})
watch(isOpen, (newVal, oldVal) => {
if (newVal && !oldVal) {
replaceFields([
{
...emptyInviteProjectItem,
project: { id: props.project.id, name: props.project.name }
}
])
}
})
</script>
@@ -0,0 +1,62 @@
<template>
<li
class="border-outline-2 border-x border-b first:border-t first:rounded-t-lg last:rounded-b-lg p-3 pl-4 border-b-outline-3 last:border-b-outline-2 gap-x-2 flex items-center"
>
<p class="text-body-xs text-foreground flex-1">
{{ user.user.name }}
</p>
<FormSelectProjectRoles
v-model="selectedRole"
label="Select role"
:name="`projectRole-${user.user.id}`"
class="w-40"
mount-menu-on-body
:allow-unset="false"
:hidden-items="[Roles.Stream.Owner]"
size="sm"
/>
<FormButton color="outline" size="sm" @click="onInvite">Add</FormButton>
</li>
</template>
<script setup lang="ts">
import { graphql } from '~/lib/common/generated/gql/gql'
import type { InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragment } from '~/lib/common/generated/gql/graphql'
import { type StreamRoles, Roles } from '@speckle/shared'
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
graphql(`
fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {
role
id
user {
id
name
bio
company
avatar
verified
role
}
}
`)
const props = defineProps<{
user: InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragment
projectId: string
}>()
const createInvite = useInviteUserToProject()
const selectedRole = ref<StreamRoles>(Roles.Stream.Contributor)
const onInvite = async () => {
await createInvite(props.projectId, [
{
userId: props.user.id,
role: selectedRole.value,
workspaceRole: props.user.role
}
])
}
</script>
@@ -0,0 +1,53 @@
<template>
<div>
<h3 class="text-body-xs text-foreground font-medium mb-3">
Add existing workspace members
</h3>
<ul class="flex flex-col">
<InviteDialogProjectWorkspaceMembersRow
v-for="member in invitableWorkspaceMembers"
:key="member.user.id"
:user="member"
:project-id="props.project.id"
/>
</ul>
</div>
</template>
<script setup lang="ts">
import { graphql } from '~/lib/common/generated/gql/gql'
import type { InviteDialogProjectWorkspaceMembers_ProjectFragment } from '~/lib/common/generated/gql/graphql'
import { useTeamInternals } from '~~/lib/projects/composables/team'
graphql(`
fragment InviteDialogProjectWorkspaceMembers_Project on Project {
id
...ProjectPageTeamInternals_Project
workspace {
team {
items {
...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator
}
}
}
}
`)
const props = defineProps<{
project: InviteDialogProjectWorkspaceMembers_ProjectFragment
}>()
const { collaboratorListItems } = useTeamInternals(computed(() => props.project))
const invitableWorkspaceMembers = computed(() => {
const currentProjectMemberIds = new Set(
collaboratorListItems.value.map((item) => item.user?.id)
)
return (
props.project?.workspace?.team?.items.filter(
(member) => member.user.id && !currentProjectMemberIds.has(member.user.id)
) || []
)
})
</script>
@@ -0,0 +1,100 @@
<template>
<div class="flex flex-col items-center gap-2 w-full">
<CommonCard
v-for="workspace in workspaces"
:key="workspace.id"
class="w-full bg-foundation"
>
<div class="flex gap-4">
<div>
<WorkspaceAvatar :name="workspace.name" :logo="workspace.logo" size="xl" />
</div>
<div class="flex flex-col sm:flex-row gap-4 justify-between flex-1">
<div class="flex flex-col flex-1">
<h6 class="text-heading-sm">{{ workspace.name }}</h6>
<p class="text-body-2xs text-foreground-2">{{ workspace.description }}</p>
</div>
<FormButton
color="outline"
size="sm"
:loading="loadingStates[workspace.id]"
:disabled="requestedWorkspaces.includes(workspace.id)"
@click="() => processRequest(true, workspace.id)"
>
{{
requestedWorkspaces.includes(workspace.id)
? 'Requested'
: 'Request to join'
}}
</FormButton>
</div>
</div>
</CommonCard>
<div class="mt-2 w-full">
<FormButton size="lg" full-width @click="$emit('next')">Continue</FormButton>
</div>
</div>
</template>
<script setup lang="ts">
import type { LimitedWorkspace } from '~/lib/common/generated/gql/graphql'
import { dashboardRequestToJoinWorkspaceMutation } from '~~/lib/dashboard/graphql/mutations'
import {
convertThrowIntoFetchResult,
getFirstErrorMessage
} from '~~/lib/common/helpers/graphql'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useMutation } from '@vue/apollo-composable'
defineProps<{
workspaces: LimitedWorkspace[]
}>()
defineEmits(['next'])
const mixpanel = useMixpanel()
const { triggerNotification } = useGlobalToast()
const loadingStates = ref<Record<string, boolean>>({})
const { mutate: requestToJoin } = useMutation(dashboardRequestToJoinWorkspaceMutation)
const requestedWorkspaces = ref<string[]>([])
const processRequest = async (accept: boolean, workspaceId: string) => {
if (accept) {
loadingStates.value[workspaceId] = true
try {
const result = await requestToJoin({
input: { workspaceId }
}).catch(convertThrowIntoFetchResult)
if (result?.data) {
requestedWorkspaces.value.push(workspaceId)
mixpanel.track('Workspace Join Request Sent', {
workspaceId,
location: 'onboarding',
// eslint-disable-next-line camelcase
workspace_id: workspaceId
})
triggerNotification({
title: 'Request sent',
description: 'Your request to join the workspace has been sent.',
type: ToastNotificationType.Success
})
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
title: 'Failed to send request',
description: errorMessage,
type: ToastNotificationType.Danger
})
}
} finally {
loadingStates.value[workspaceId] = false
}
}
}
</script>
@@ -1,445 +0,0 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
<template>
<div class="relative">
<div
:class="`${
background ? 'mx-2 sm:mx-auto px-2 bg-foundation rounded-md shadow-xl' : ''
} ${allCompleted ? 'max-w-lg mx-auto' : ''}`"
>
<div>
<div
v-if="!allCompleted"
:class="`hidden sm:grid gap-2 ${
showIntro ? 'px-4 grid-cols-5' : 'grid-cols-4'
}`"
>
<div
v-if="showIntro"
class="flex-col justify-around px-2 h-full py-2 md:col-span-1 hidden lg:flex"
>
<div class="text-heading-sm">Quickstart checklist</div>
<div class="text-body-sm text-foreground-2">
Become a Speckle pro in four steps!
</div>
<div class="space-x-1">
<FormButton v-if="!allCompleted" size="sm" @click="dismissChecklist()">
I'll do it later
</FormButton>
<FormButton
v-if="!allCompleted"
color="subtle"
size="sm"
@click="dismissChecklistForever()"
>
Don't show again
</FormButton>
</div>
</div>
<div class="grid grid-cols-4 grow col-span-5 lg:col-span-4">
<div
v-for="(step, idx) in steps"
:key="idx"
class="py-2 col-span-4 sm:col-span-2 lg:col-span-1"
>
<div
:class="`
${
step.active
? 'bg-primary text-foreground-on-primary shadow hover:shadow-md scale-100'
: 'text-foreground-2 hover:bg-primary-muted scale-95'
}
transition rounded-md flex flex-col justify-between px-2 cursor-pointer h-full`"
@click.stop="
!step.active
? activateStep(idx)
: idx === 0 || steps[idx - 1].completed
? step.action()
: goToFirstUncompletedStep()
"
>
<div
:class="`text-lg sm:text-xl font-medium flex items-center justify-between ${
step.active ? 'text-foreground-on-primary' : 'text-foreground-2'
}`"
>
<span>{{ idx + 1 }}</span>
<Component
:is="step.icon"
v-if="!step.completed"
:class="`w-4 h-4 mt-1`"
/>
<CheckCircleIcon v-else class="w-4 h-4 mt-1 text-primary" />
</div>
<div
:class="`${
step.active
? 'font-medium text-sm sm:text-base text-foreground-on-primary'
: ''
}`"
>
{{ step.title }}
</div>
<div class="text-xs mt-[2px]">{{ step.blurb }}</div>
<div
class="flex items-center justify-between"
:class="step.active ? 'h-10' : 'h-4'"
>
<div
v-if="idx === 0 || steps[idx - 1].completed"
class="flex justify-between items-center py-2 w-full"
>
<FormButton
v-if="!step.completed && step.active"
:disabled="!step.active"
color="outline"
size="sm"
@click.stop="step.action"
>
{{ step.cta }}
</FormButton>
<FormButton
v-if="step.active && !step.completed"
v-tippy="'Mark completed'"
text
link
size="sm"
color="outline"
@click.stop="markComplete(idx)"
>
<!-- Mark as complete -->
<OutlineCheckCircleIcon class="w-4 h-4 text-foundation" />
</FormButton>
<span v-if="step.completed" class="text-xs font-medium">
Completed!
</span>
<FormButton
v-if="step.completed && step.active"
size="sm"
color="outline"
@click.stop="step.action"
>
{{ step.postCompletionCta }}
</FormButton>
</div>
<div v-else-if="step.active" class="text-sm">
<FormButton
size="sm"
color="outline"
@click.stop="goToFirstUncompletedStep()"
>
Complete the previous step!
</FormButton>
</div>
</div>
</div>
</div>
</div>
<div
v-if="showIntro"
class="lg:hidden col-span-5 pb-3 pt-2 text-center space-x-2"
>
<FormButton v-if="!allCompleted" size="sm" @click="dismissChecklist()">
I'll do it later
</FormButton>
<FormButton
v-if="!allCompleted"
color="subtle"
size="sm"
@click="dismissChecklistForever()"
>
Don't show again
</FormButton>
</div>
</div>
<div
v-else
class="relative hidden sm:flex flex-col sm:flex-row items-center justify-center flex-1 gap-x-2 py-4"
>
<div class="w-6 h-6">
<!-- <CheckCircleIcon class="absolute w-6 h-6 text-primary" /> -->
<CheckCircleIcon class="w-6 h-6 text-primary animate-ping animate-pulse" />
</div>
<div class="text-sm max-w-lg text-center sm:text-left">
<b>All done!</b>
PS: the
<FormButton to="https://speckle.community" target="_blank" link>
Community Forum
</FormButton>
is there to help!
</div>
<div class="absolute right-2 top-3">
<FormButton
color="outline"
:icon-left="XMarkIcon"
hide-text
@click="closeChecklist()"
>
Close
</FormButton>
</div>
</div>
</div>
</div>
<!--
This is used as a dismissal prompt from when showing the checklist on top of the
viewer. It does not directly dismiss the checklist as we still want to show it
on the main dasboard page.
-->
<div v-if="showBottomEscape && !allCompleted" class="text-center mt-2">
<FormButton @click="$emit('dismiss')">
I'll do it later - let me explore first!
</FormButton>
</div>
<OnboardingDialogManager
v-model:open="showManagerDownloadDialog"
@done="markComplete(0)"
@cancel="showManagerDownloadDialog = false"
></OnboardingDialogManager>
<OnboardingDialogAccountLink
v-model:open="showAccountLinkDialog"
@done="markComplete(1)"
@cancel="showAccountLinkDialog = false"
>
<template #header>Desktop login</template>
</OnboardingDialogAccountLink>
<OnboardingDialogFirstSend
v-model:open="showFirstSendDialog"
@done="markComplete(2)"
@cancel="showFirstSendDialog = false"
>
<template #header>Your first upload</template>
</OnboardingDialogFirstSend>
<InviteDialogServer
v-model:open="showServerInviteDialog"
@update:open="(v) => (!v ? markComplete(3) : '')"
/>
</div>
</template>
<script setup lang="ts">
import {
CheckCircleIcon,
ShareIcon,
ComputerDesktopIcon,
UserPlusIcon,
CloudArrowUpIcon,
XMarkIcon
} from '@heroicons/vue/24/solid'
import { CheckCircleIcon as OutlineCheckCircleIcon } from '@heroicons/vue/24/outline'
import { useSynchronizedCookie } from '~~/lib/common/composables/reactiveCookie'
import { useMixpanel } from '~~/lib/core/composables/mp'
withDefaults(
defineProps<{
showIntro?: boolean
showBottomEscape?: boolean
background?: boolean
}>(),
{
showIntro: false,
showBottomEscape: false,
background: false
}
)
const mp = useMixpanel()
const emit = defineEmits(['dismiss'])
const showManagerDownloadDialog = ref(false)
const showAccountLinkDialog = ref(false)
const showFirstSendDialog = ref(false)
const showServerInviteDialog = ref(false)
const hasDownloadedManager = useSynchronizedCookie<boolean>(`hasDownloadedManager`, {
default: () => false
})
const hasLinkedAccount = useSynchronizedCookie<boolean>(`hasLinkedAccount`, {
default: () => false
})
const hasViewedFirstSend = useSynchronizedCookie<boolean>(`hasViewedFirstSend`, {
default: () => false
})
const hasSharedProject = useSynchronizedCookie<boolean>(`hasSharedProject`, {
default: () => false
})
const hasCompletedChecklistV1 = useSynchronizedCookie<boolean>(
`hasCompletedChecklistV1`,
{ default: () => false }
)
const hasDismissedChecklistTime = useSynchronizedCookie<string | undefined>(
`hasDismissedChecklistTime`,
{ default: () => undefined }
)
const hasDismissedChecklistForever = useSynchronizedCookie<boolean | undefined>(
`hasDismissedChecklistForever`,
{ default: () => false }
)
const getStatus = () => {
return {
hasDownloadedManager: hasDownloadedManager.value,
hasLinkedAccount: hasLinkedAccount.value,
hasViewedFirstSend: hasViewedFirstSend.value,
hasSharedProject: hasSharedProject.value
}
}
const steps = ref([
{
title: 'Install Manager ⚙️',
blurb: 'Use Manager to install the Speckle Connectors for your apps!',
active: false,
cta: "Let's go!",
postCompletionCta: 'Download again',
action: () => {
showManagerDownloadDialog.value = true
},
completionAction: () => {
showManagerDownloadDialog.value = false
hasDownloadedManager.value = true
mp.track('Onboarding Action', {
type: 'action',
name: 'checklist',
action: 'step-completed',
stepName: 'download manager'
})
},
completed: hasDownloadedManager.value,
icon: ComputerDesktopIcon
},
{
title: 'Log in 🔑',
blurb: 'Authorise our application connectors to send data to Speckle.',
active: false,
cta: "Let's go!",
postCompletionCta: 'Login again',
action: () => {
showAccountLinkDialog.value = true
},
completionAction: () => {
showAccountLinkDialog.value = false
hasLinkedAccount.value = true
mp.track('Onboarding Action', {
type: 'action',
name: 'checklist',
action: 'step-completed',
stepName: 'manager login'
})
},
completed: hasLinkedAccount.value,
icon: UserPlusIcon
},
{
title: 'Your first model upload ⬆️',
blurb: 'Use your favourite design app to send your first model to Speckle.',
active: false,
cta: "Let's go!",
postCompletionCta: 'Show again',
action: () => {
showFirstSendDialog.value = true
},
completionAction: () => {
showFirstSendDialog.value = false
hasViewedFirstSend.value = true
mp.track('Onboarding Action', {
type: 'action',
name: 'checklist',
action: 'step-completed',
stepName: 'first send'
})
},
completed: hasViewedFirstSend.value,
icon: CloudArrowUpIcon
},
{
title: 'Enable multiplayer 📢',
blurb: 'Share your project with your colleagues!',
active: false,
cta: "Let's go!",
postCompletionCta: 'Invite again',
action: () => {
showServerInviteDialog.value = true
//TODO: modify server invite dialog to include searchable project dropdown
},
completionAction: () => {
showServerInviteDialog.value = false
hasSharedProject.value = true
mp.track('Onboarding Action', {
type: 'action',
name: 'checklist',
action: 'step-completed',
stepName: 'first share'
})
},
completed: hasSharedProject.value,
icon: ShareIcon
}
])
const activateStep = (idx: number) => {
steps.value.forEach((s, index) => (s.active = idx === index))
}
const markComplete = (idx: number) => {
steps.value[idx].completed = true
steps.value[idx].active = false
steps.value[idx].completionAction()
mp.track('Onboarding Action', {
type: 'action',
name: 'checklist',
action: 'mark-complete',
step: idx,
status: getStatus()
})
activateStep(idx + 1)
}
const goToFirstUncompletedStep = () => {
const firstNonCompleteStepIndex = steps.value.findIndex((s) => s.completed === false)
activateStep(firstNonCompleteStepIndex)
if (import.meta.client) {
mp.track('Onboarding Action', {
type: 'action',
name: 'checklist',
action: 'goto-uncompleted-step',
status: getStatus()
})
}
}
const allCompleted = computed(() => steps.value.every((step) => step.completed))
const closeChecklist = () => {
hasCompletedChecklistV1.value = true
}
const dismissChecklist = () => {
hasDismissedChecklistTime.value = Date.now().toString()
emit('dismiss')
mp.track('Onboarding Action', {
type: 'action',
name: 'checklist',
action: 'dismiss',
status: getStatus()
})
}
const dismissChecklistForever = () => {
hasDismissedChecklistForever.value = true
emit('dismiss')
mp.track('Onboarding Action', {
type: 'action',
name: 'checklist',
action: 'dismiss-forever',
status: getStatus()
})
}
goToFirstUncompletedStep()
</script>
@@ -0,0 +1,56 @@
<template>
<form class="w-full flex flex-col gap-4" @submit="onSubmit">
<OnboardingQuestionsRoleSelect v-model="values.role" name="role" required />
<OnboardingQuestionsPlanSelect v-model="values.plan" name="plan" required />
<OnboardingQuestionsSourceSelect v-model="values.source" name="source" required />
<div class="mt-2 flex flex-col gap-4">
<FormButton size="lg" :disabled="!meta.valid || isSubmitting" submit full-width>
Continue
</FormButton>
<div class="opacity-70 hover:opacity-100 max-w-max mx-auto px-1">
<FormButton
v-if="!isOnboardingForced"
size="sm"
text
link
color="subtle"
full-width
@click="setUserOnboardingComplete()"
>
Skip
</FormButton>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import { useForm } from 'vee-validate'
import type {
OnboardingRole,
OnboardingPlan,
OnboardingSource
} from '~/lib/auth/helpers/onboarding'
import { useProcessOnboarding } from '~~/lib/auth/composables/onboarding'
import { homeRoute } from '~/lib/common/helpers/route'
const isOnboardingForced = useIsOnboardingForced()
const { setUserOnboardingComplete, setMixpanelSegments } = useProcessOnboarding()
const { handleSubmit, meta, isSubmitting, values } = useForm({
initialValues: {
role: undefined as OnboardingRole | undefined,
plan: [] as OnboardingPlan[],
source: undefined as OnboardingSource | undefined
}
})
const onSubmit = handleSubmit(async () => {
if (values.role) {
setMixpanelSegments({ role: values.role })
}
await setUserOnboardingComplete()
navigateTo(homeRoute)
})
</script>
@@ -0,0 +1,50 @@
<template>
<FormSelectMulti
v-bind="props"
id="plan-select"
v-model="selectedValue"
label="What are you planning to do with Speckle?"
placeholder="Select all that apply"
required
:rules="isRequired"
name="plan"
show-label
allow-unset
clearable
:items="plans"
>
<template #option="{ item }">
<div class="label label--light">
{{ PlanTitleMap[item] }}
</div>
</template>
<template #something-selected="{ value }">
<template v-if="value.length === 1">
{{ PlanTitleMap[isArrayValue(value) ? value[0] : value] }}
</template>
<template v-else>{{ value.length }} items selected</template>
</template>
</FormSelectMulti>
</template>
<script setup lang="ts">
import { useFormSelectChildInternals } from '@speckle/ui-components'
import { OnboardingPlan, PlanTitleMap } from '~/lib/auth/helpers/onboarding'
import { isRequired } from '~~/lib/common/helpers/validation'
const props = defineProps<{
modelValue?: OnboardingPlan[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: OnboardingPlan | OnboardingPlan[] | undefined): void
}>()
const plans = Object.values(OnboardingPlan)
const { selectedValue, isArrayValue } = useFormSelectChildInternals<OnboardingPlan>({
props: toRefs(props),
emit
})
</script>
@@ -0,0 +1,46 @@
<template>
<FormSelectBase
v-bind="props"
id="role-select"
v-model="selectedValue"
label="What's your role?"
placeholder="Select one"
required
:rules="isRequired"
name="role"
show-label
allow-unset
clearable
:items="roles"
>
<template #option="{ item }">
<div class="label label--light">
{{ RoleTitleMap[item] }}
</div>
</template>
<template #something-selected="{ value }">
<span>{{ RoleTitleMap[isArrayValue(value) ? value[0] : value] }}</span>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { useFormSelectChildInternals } from '@speckle/ui-components'
import { OnboardingRole, RoleTitleMap } from '~/lib/auth/helpers/onboarding'
import { isRequired } from '~~/lib/common/helpers/validation'
const props = defineProps<{
modelValue?: OnboardingRole
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: OnboardingRole | OnboardingRole[] | undefined): void
}>()
const roles = Object.values(OnboardingRole)
const { selectedValue, isArrayValue } = useFormSelectChildInternals<OnboardingRole>({
props: toRefs(props),
emit
})
</script>
@@ -0,0 +1,49 @@
<template>
<FormSelectBase
v-bind="props"
id="source-select"
v-model="selectedValue"
label="How did you hear about Speckle?"
placeholder="Select one"
required
:rules="isRequired"
name="source"
show-label
allow-unset
clearable
:items="sources"
>
<template #option="{ item }">
<div class="label label--light">
{{ SourceTitleMap[item] }}
</div>
</template>
<template #something-selected="{ value }">
<span>{{ SourceTitleMap[isArrayValue(value) ? value[0] : value] }}</span>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { useFormSelectChildInternals } from '@speckle/ui-components'
import { OnboardingSource, SourceTitleMap } from '~/lib/auth/helpers/onboarding'
import { isRequired } from '~~/lib/common/helpers/validation'
const props = defineProps<{
modelValue?: OnboardingSource
}>()
const emit = defineEmits<{
(
e: 'update:modelValue',
value: OnboardingSource | OnboardingSource[] | undefined
): void
}>()
const sources = Object.values(OnboardingSource)
const { selectedValue, isArrayValue } = useFormSelectChildInternals<OnboardingSource>({
props: toRefs(props),
emit
})
</script>
@@ -42,6 +42,8 @@
<p class="text-body-xs text-foreground-2 mt-2 mb-5 ml-0.5">
Copy this code to embed your model in a webpage or document.
</p>
<h4 class="text-heading-sm text-foreground-2 mb-1 ml-0.5">Embed URL</h4>
<FormClipboardInput class="mb-4" :value="updatedUrl" />
<LayoutDialogSection border-b border-t title="Options">
<div class="flex flex-col gap-1.5 sm:gap-2 text-body-xs cursor-default">
<div v-for="option in embedDialogOptions" :key="option.id">
@@ -1,327 +0,0 @@
<template>
<LayoutDialog v-model:open="isOpen" max-width="md" :buttons="dialogButtons">
<template #header>Invite to project</template>
<div class="flex flex-col gap-4 mb-2">
<div v-if="!isWorkspaceMemberAndProjectOwner" class="flex flex-col gap-4">
<FormSelectWorkspaceRoles
v-if="project?.workspaceId"
v-model="workspaceRole"
show-label
label="Workspace role"
size="lg"
help="If target user does not have a role in the parent workspace, they will be assigned this role."
:allow-unset="false"
/>
<FormTextInput
v-model="search"
name="search"
size="lg"
placeholder="Search by email or username..."
:disabled="disabled"
:help="disabled ? 'You must be the project owner to invite users' : ''"
input-classes="pr-[85px] text-sm"
color="foundation"
label="Add people"
show-label
>
<template #input-right>
<div
class="absolute inset-y-0 right-0 flex items-center pr-2"
:class="disabled ? 'pointer-events-none' : ''"
>
<ProjectPageTeamPermissionSelect
v-model="role"
mount-menu-on-body
:show-label="false"
:disabled-roles="isTargettingWorkspaceGuest ? [Roles.Stream.Owner] : []"
:disabled-item-tooltip="
isTargettingWorkspaceGuest
? 'Workspace guests cannot be project owners'
: ''
"
/>
</div>
</template>
</FormTextInput>
<div
v-if="hasTargets"
class="flex flex-col border bg-foundation border-primary-muted rounded-md"
>
<template v-if="searchUsers.length">
<ProjectPageTeamDialogInviteUserServerUserRow
v-for="user in searchUsers"
:key="user.id"
:user="user"
:stream-role="role"
:disabled="loading"
:target-workspace-role="workspaceRole"
@invite-user="($event) => onInviteUser($event.user)"
/>
</template>
<ProjectPageTeamDialogInviteUserEmailsRow
v-else-if="selectedEmails?.length"
:selected-emails="selectedEmails"
:stream-role="role"
:disabled="loading"
:is-guest-mode="isGuestMode"
:unmatching-domain-policy="unmatchingDomainPolicy"
class="p-2"
@invite-emails="($event) => onInviteUser($event.emails, $event.serverRole)"
/>
</div>
</div>
<div v-else class="flex flex-col gap-4">
<FormSelectProjectRoles
v-model="role"
show-label
label="Project role"
size="lg"
/>
<div>
<div class="text-body-xs font-medium mb-1">Add users from workspace</div>
<div
v-if="invitableWorkspaceMembers.length"
class="flex flex-col border bg-foundation border-primary-muted rounded-md"
>
<ProjectPageTeamDialogInviteUserServerUserRow
v-for="user in invitableWorkspaceMembers"
:key="user.user.id"
:user="user.user"
:stream-role="role"
:disabled="!!(loading || disabledWorkspaceMemberRowMessage(user))"
:disabled-message="disabledWorkspaceMemberRowMessage(user)"
:target-workspace-role="workspaceRole"
@invite-user="($event) => onInviteUser($event.user)"
/>
</div>
<p v-else class="text-sm text-gray-500 mt-4">No available users found.</p>
</div>
</div>
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import { Roles } from '@speckle/shared'
import type { ServerRoles, StreamRoles, WorkspaceRoles } from '@speckle/shared'
import type { UserSearchItem } from '~~/lib/common/composables/users'
import type {
ProjectInviteCreateInput,
ProjectPageInviteDialog_ProjectFragment,
WorkspaceProjectInviteCreateInput
} from '~~/lib/common/generated/gql/graphql'
import type { SetFullyRequired } from '~~/lib/common/helpers/type'
import { isString } from 'lodash-es'
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
import { useTeamInternals } from '~~/lib/projects/composables/team'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useServerInfo } from '~~/lib/core/composables/server'
import { graphql } from '~/lib/common/generated/gql/gql'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useResolveInviteTargets } from '~/lib/server/composables/invites'
import { filterInvalidInviteTargets } from '~/lib/workspaces/helpers/invites'
graphql(`
fragment ProjectPageInviteDialog_Project on Project {
id
workspaceId
workspace {
id
defaultProjectRole
team {
items {
role
user {
id
name
bio
company
avatar
verified
role
}
}
}
}
...ProjectPageTeamInternals_Project
workspace {
id
domainBasedMembershipProtectionEnabled
domains {
domain
id
}
}
}
`)
type InvitableUser = UserSearchItem | string
const props = defineProps<{
projectId: string
project?: ProjectPageInviteDialog_ProjectFragment
disabled?: boolean
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const mp = useMixpanel()
const projectId = computed(() => props.projectId as string)
const projectData = computed(() => props.project)
const { collaboratorListItems } = useTeamInternals(projectData)
const workspaceMembers = computed(() => {
return props.project?.workspace?.team?.items || []
})
const invitableWorkspaceMembers = computed(() => {
const currentProjectMemberIds = new Set(
collaboratorListItems.value.map((item) => item.user?.id)
)
return workspaceMembers.value.filter((member) => {
if (!member.user.id || currentProjectMemberIds.has(member.user.id)) return false
return true
})
})
const loading = ref(false)
const search = ref('')
const role = ref<StreamRoles>(Roles.Stream.Contributor)
const workspaceRole = ref<WorkspaceRoles>(Roles.Workspace.Guest)
const { isGuestMode } = useServerInfo()
const createInvite = useInviteUserToProject()
const { activeUser } = useActiveUser()
const {
users: searchUsers,
emails: selectedEmails,
hasTargets
} = useResolveInviteTargets({
search,
excludeUserIds: computed(() =>
collaboratorListItems.value
.filter((i): i is SetFullyRequired<typeof i, 'user'> => !!i.user?.id)
.map((t) => t.user.id)
),
workspaceId: props.project?.workspaceId
})
const isWorkspaceMemberAndProjectOwner = computed(() => {
const userIsWorkspaceMember =
workspaceMembers.value.some(
(item) =>
item.user?.id === activeUser.value?.id && item.role === Roles.Workspace.Member
) ?? false
const userIsProjectOwner = projectData.value?.role === Roles.Stream.Owner
return userIsWorkspaceMember && userIsProjectOwner
})
const dialogButtons = computed<LayoutDialogButton[]>(() => [
{
text: 'Cancel',
props: { color: 'outline' },
onClick: () => {
isOpen.value = false
}
}
])
const isOwnerSelected = computed(() => role.value === Roles.Stream.Owner)
const allowedDomains = computed(() =>
props.project?.workspace?.domains?.map((c) => c.domain)
)
const unmatchingDomainPolicy = computed(() => {
if (props.project?.workspace?.domainBasedMembershipProtectionEnabled) {
return workspaceRole.value === Roles.Workspace.Guest
? false
: !selectedEmails.value?.every((email) =>
allowedDomains.value?.includes(email.split('@')[1])
)
}
return false
})
const isTargettingWorkspaceGuest = computed(
() => workspaceRole.value === Roles.Workspace.Guest
)
const onInviteUser = async (
user: InvitableUser | InvitableUser[],
serverRole?: ServerRoles
) => {
serverRole = serverRole || Roles.Server.User
const users = filterInvalidInviteTargets(user, {
isTargetResourceOwner: isOwnerSelected.value,
emailTargetServerRole: serverRole
})
const inputs: ProjectInviteCreateInput[] | WorkspaceProjectInviteCreateInput[] =
users.map((u) => ({
role: role.value,
...(isString(u)
? {
email: u,
serverRole
}
: {
userId: u.id
}),
...(props.project?.workspaceId
? {
workspaceRole: workspaceRole.value
}
: {})
}))
if (!inputs.length) return
const isEmail = !!inputs.find((u) => !!u.email)
// Invite email
loading.value = true
await createInvite(projectId.value, inputs)
mp.track('Invite Action', {
type: 'project invite',
name: 'send',
multiple: inputs.length !== 1,
count: inputs.length,
hasProject: true,
to: isEmail ? 'email' : 'existing user'
})
loading.value = false
}
const disabledWorkspaceMemberRowMessage = (
item: (typeof invitableWorkspaceMembers.value)[0]
) => {
return item.role === Roles.Workspace.Guest && role.value === Roles.Stream.Owner
? 'You cannot invite a workspace guest as a project owner.'
: undefined
}
watch(
() => props.project?.workspace?.defaultProjectRole,
(newRole, oldRole) => {
if (newRole && newRole !== oldRole) {
role.value = newRole as StreamRoles
}
},
{ immediate: true }
)
watch(workspaceRole, (newRole, oldRole) => {
if (newRole === oldRole) return
if (newRole === Roles.Workspace.Guest && role.value === Roles.Stream.Owner) {
role.value = Roles.Stream.Reviewer
}
})
</script>
@@ -22,7 +22,6 @@
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import { graphql } from '~/lib/common/generated/gql'
import type { ProjectPageAutomationRuns_AutomationFragment } from '~/lib/common/generated/gql/graphql'
import { useMixpanel } from '~/lib/core/composables/mp'
import { useTriggerAutomation } from '~/lib/projects/composables/automationManagement'
import { projectAutomationPagePaginatedRunsQuery } from '~/lib/projects/graphql/queries'
@@ -66,18 +65,8 @@ const { identifier, onInfiniteLoad } = usePaginatedQuery({
resolveCursorFromVariables: (vars) => vars.cursor
})
const triggerAutomation = useTriggerAutomation()
const mixpanel = useMixpanel()
const onTrigger = async () => {
const res = await triggerAutomation(props.projectId, props.automation.id)
if (res) {
mixpanel.track('Automation Run Triggered', {
automationId: props.automation.id,
automationName: props.automation.name,
automationRunId: res,
projectId: props.projectId,
source: 'manual'
})
}
await triggerAutomation(props.projectId, props.automation.id)
}
</script>
@@ -6,7 +6,7 @@
</p>
</template>
<template #top-buttons>
<FormButton @click="toggleInviteDialog">Invite</FormButton>
<FormButton :disabled="!canInvite" @click="toggleInviteDialog">Invite</FormButton>
</template>
<div class="flex flex-col mt-6">
@@ -21,19 +21,17 @@
/>
</div>
<ProjectPageInviteDialog
<InviteDialogProject
v-if="project"
v-model:open="showInviteDialog"
:project="project"
:project-id="projectId"
:disabled="!isOwner"
/>
</ProjectPageSettingsBlock>
</template>
<script setup lang="ts">
import type { Project } from '~~/lib/common/generated/gql/graphql'
import type { ProjectCollaboratorListItem } from '~~/lib/projects/helpers/components'
import type { Nullable, StreamRoles } from '@speckle/shared'
import { type Nullable, type StreamRoles, Roles } from '@speckle/shared'
import { useQuery, useApolloClient } from '@vue/apollo-composable'
import { useTeamInternals } from '~~/lib/projects/composables/team'
import { graphql } from '~~/lib/common/generated/gql'
@@ -53,7 +51,8 @@ const projectPageSettingsCollaboratorsQuery = graphql(`
project(id: $projectId) {
id
...ProjectPageTeamInternals_Project
...ProjectPageInviteDialog_Project
...InviteDialogProject_Project
workspaceId
}
}
`)
@@ -83,15 +82,16 @@ const { result: pageResult } = useQuery(projectPageSettingsCollaboratorsQuery, (
const { result: workspaceResult } = useQuery(
projectPageSettingsCollaboratorWorkspaceQuery,
() => ({
workspaceId: pageResult.value!.project.workspaceId!
workspaceId: pageResult.value!.project.workspace!.id
}),
() => ({
enabled: isWorkspacesEnabled.value && !!pageResult.value?.project.workspaceId
enabled: isWorkspacesEnabled.value && !!pageResult.value?.project.workspace?.id
})
)
const project = computed(() => pageResult.value?.project)
const workspace = computed(() => workspaceResult.value?.workspace)
const projectRole = computed(() => project.value?.role)
const updateRole = useUpdateUserRole(project)
const toggleInviteDialog = () => {
@@ -104,6 +104,9 @@ const { collaboratorListItems, isOwner, isServerGuest } = useTeamInternals(
)
const canEdit = computed(() => isOwner.value && !isServerGuest.value)
const canInvite = computed(() =>
workspace?.value?.id ? projectRole.value !== Roles.Stream.Reviewer : isOwner.value
)
const onCollaboratorRoleChange = async (
collaborator: ProjectCollaboratorListItem,
@@ -2,8 +2,8 @@
<div>
<Portal to="primary-actions"></Portal>
<ProjectsDashboardHeader
:projects-invites="projectsPanelResult?.activeUser || undefined"
:workspaces-invites="workspacesResult?.activeUser || undefined"
:projects-invites="projectsPanelResult?.activeUser"
:workspaces-invites="workspacesResult?.activeUser"
/>
<div v-if="!showEmptyState" class="flex flex-col gap-4">
@@ -24,7 +24,7 @@
:show-clear="!!search"
v-bind="bind"
v-on="on"
></FormTextInput>
/>
<FormSelectProjectRoles
v-if="!showEmptyState"
v-model="selectedRoles"
@@ -65,25 +65,16 @@
</template>
<script setup lang="ts">
import {
useApolloClient,
useQuery,
useQueryLoading,
useSubscription
} from '@vue/apollo-composable'
import { useQuery, useQueryLoading } from '@vue/apollo-composable'
import {
projectsDashboardQuery,
projectsDashboardWorkspaceQuery
} from '~~/lib/projects/graphql/queries'
import { graphql } from '~~/lib/common/generated/gql'
import { getCacheId, modifyObjectField } from '~~/lib/common/helpers/graphql'
import { UserProjectsUpdatedMessageType } from '~~/lib/common/generated/gql/graphql'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import { projectRoute } from '~~/lib/common/helpers/route'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import type { Nullable, Optional, StreamRoles } from '@speckle/shared'
import { useDebouncedTextInput, type InfiniteLoaderState } from '@speckle/ui-components'
import { MagnifyingGlassIcon, Squares2X2Icon } from '@heroicons/vue/24/outline'
import { useUserProjectsUpdatedTracking } from '~~/lib/user/composables/projectUpdates'
graphql(`
fragment ProjectsDashboard_UserProjectCollection on UserProjectCollection {
@@ -98,11 +89,10 @@ const cursor = ref(null as Nullable<string>)
const selectedRoles = ref(undefined as Optional<StreamRoles[]>)
const openNewProject = ref(false)
const showLoadingBar = ref(false)
const { activeUser, isGuest } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const areQueriesLoading = useQueryLoading()
const apollo = useApolloClient().client
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { isGuest } = useActiveUser()
useUserProjectsUpdatedTracking()
const {
on,
@@ -138,20 +128,6 @@ onProjectsResult((res) => {
infiniteLoaderId.value = JSON.stringify(projectsVariables.value?.filter || {})
})
const { onResult: onUserProjectsUpdate } = useSubscription(
graphql(`
subscription OnUserProjectsUpdate {
userProjectsUpdated {
type
id
project {
...ProjectDashboardItem
}
}
}
`)
)
const projects = computed(() => projectsPanelResult.value?.activeUser?.projects)
const showEmptyState = computed(() => {
const isFiltering =
@@ -168,56 +144,6 @@ const moreToLoad = computed(
cursor.value
)
onUserProjectsUpdate((res) => {
const activeUserId = activeUser.value?.id
const event = res.data?.userProjectsUpdated
if (!event) return
if (!activeUserId) return
const isNewProject = event.type === UserProjectsUpdatedMessageType.Added
const incomingProject = event.project
const cache = apollo.cache
if (isNewProject && incomingProject) {
// Add to User.projects where possible
modifyObjectField(
cache,
getCacheId('User', activeUserId),
'projects',
({ helpers: { ref, createUpdatedValue } }) =>
createUpdatedValue(({ update }) => {
update('items', (items) => [
ref('Project', incomingProject.id),
...(items || [])
])
update('totalCount', (count) => count + 1)
}),
{ autoEvictFiltered: true }
)
}
if (!isNewProject) {
// Evict old project from cache entirely to remove it from all searches
cache.evict({
id: getCacheId('Project', event.id)
})
}
// Emit toast notification
triggerNotification({
type: ToastNotificationType.Info,
title: isNewProject ? 'New project added' : 'A project has been removed',
cta:
isNewProject && incomingProject
? {
url: projectRoute(incomingProject.id),
title: 'View project'
}
: undefined
})
})
const infiniteLoad = async (state: InfiniteLoaderState) => {
if (!moreToLoad.value) return state.complete()
@@ -28,6 +28,7 @@ import type {
ProjectsDashboardHeaderWorkspaces_UserFragment
} from '~/lib/common/generated/gql/graphql'
import { CookieKeys } from '~/lib/common/helpers/constants'
import type { MaybeNullOrUndefined } from '@speckle/shared'
graphql(`
fragment ProjectsDashboardHeaderProjects_User on User {
@@ -49,8 +50,8 @@ graphql(`
`)
const props = defineProps<{
projectsInvites?: ProjectsDashboardHeaderProjects_UserFragment
workspacesInvites?: ProjectsDashboardHeaderWorkspaces_UserFragment
projectsInvites: MaybeNullOrUndefined<ProjectsDashboardHeaderProjects_UserFragment>
workspacesInvites: MaybeNullOrUndefined<ProjectsDashboardHeaderWorkspaces_UserFragment>
}>()
const dismissedDiscoverableWorkspaces = useSynchronizedCookie<string[]>(
@@ -81,6 +81,7 @@
? 'TRIAL'
: undefined
"
nested
>
<template #title-icon>
<WorkspaceAvatar
@@ -96,8 +96,8 @@ const onSubmit = handleSubmit(async (tokenFormValues) => {
emit('token-created', result.data.apiTokenCreate)
triggerNotification({
type: ToastNotificationType.Success,
title: 'Webhook created',
description: 'The webhook has been successfully created'
title: 'Token created',
description: 'The token has been successfully created'
})
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
@@ -1,38 +1,33 @@
<template>
<LayoutDialog
v-model:open="isOpen"
title="Delete email address"
:title="cancel ? 'Cancel adding email' : 'Delete email address'"
max-width="xs"
:buttons="dialogButtons"
>
<p class="text-body-xs text-foreground mb-2">
Are you sure you want to delete
<span class="font-medium">{{ email }}</span>
from your account?
{{
cancel
? `Are you sure you want to cancel adding ${email?.email} to your account?`
: `Are you sure you want to delete ${email?.email} from your account?`
}}
</p>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
import { settingsDeleteUserEmailMutation } from '~/lib/settings/graphql/mutations'
import { useMutation } from '@vue/apollo-composable'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import {
getFirstErrorMessage,
convertThrowIntoFetchResult
} from '~~/lib/common/helpers/graphql'
import { useMixpanel } from '~/lib/core/composables/mp'
import type { UserEmail } from '~/lib/common/generated/gql/graphql'
import { useUserEmails } from '~/lib/user/composables/emails'
const props = defineProps<{
emailId: string
email: string
email?: UserEmail
cancel?: boolean
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const { mutate: deleteMutation } = useMutation(settingsDeleteUserEmailMutation)
const { triggerNotification } = useGlobalToast()
const mixpanel = useMixpanel()
const { deleteUserEmail } = useUserEmails()
const dialogButtons = computed((): LayoutDialogButton[] => [
{
@@ -43,7 +38,7 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
}
},
{
text: 'Delete',
text: props.cancel ? 'Confirm' : 'Delete',
props: { color: 'primary' },
onClick: () => {
onDeleteEmail()
@@ -52,24 +47,10 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
])
const onDeleteEmail = async () => {
const result = await deleteMutation({ input: { id: props.emailId } }).catch(
convertThrowIntoFetchResult
)
if (result?.data) {
triggerNotification({
type: ToastNotificationType.Success,
title: `${props.email} deleted`
})
mixpanel.track('Email Deleted')
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: errorMessage
})
if (!props.email) return
const success = await deleteUserEmail(props.email)
if (success) {
isOpen.value = false
}
isOpen.value = false
}
</script>
@@ -1,17 +1,20 @@
<template>
<ul class="flex flex-col">
<SettingsUserEmailListItem
v-for="email in emailData"
:key="email.id"
:email-data="email"
/>
<ul
class="flex flex-col border border-outline-2 rounded-lg divide-y divide-outline-2 mt-4"
>
<li v-for="email in sortedEmails" :key="email.id">
<SettingsUserEmailListItem :email-data="email" />
</li>
</ul>
</template>
<script setup lang="ts">
import type { SettingsUserEmailCards_UserEmailFragment } from '~~/lib/common/generated/gql/graphql'
import { useUserEmails } from '~/lib/user/composables/emails'
import { sortBy } from 'lodash-es'
defineProps<{
emailData: SettingsUserEmailCards_UserEmailFragment[]
}>()
const { emails } = useUserEmails()
const sortedEmails = computed(() =>
sortBy(emails.value, [(email) => !email.primary, 'email'])
)
</script>
@@ -1,7 +1,5 @@
<template>
<li
class="border-outline-2 border-x border-b first:border-t first:rounded-t-lg last:rounded-b-lg p-6 border-b-outline-3 last:border-b-outline-2"
>
<div class="p-6">
<div
v-if="emailData.primary || !emailData.verified"
class="flex w-full gap-x-2 pb-4 md:pb-3"
@@ -18,9 +16,9 @@
v-if="!emailData.verified"
color="outline"
size="sm"
@click="resendVerificationEmail"
@click="handleVerifyEmail"
>
Resend verification email
Verify email
</FormButton>
</div>
<div class="flex flex-col md:flex-row">
@@ -66,38 +64,21 @@
<SettingsUserEmailDeleteDialog
v-model:open="showDeleteDialog"
:email-id="emailData.id"
:email="emailData.email"
:email="emailData"
:is-verifying="!emailData.verified"
/>
</li>
</div>
</template>
<script setup lang="ts">
import type { SettingsUserEmailCards_UserEmailFragment } from '~~/lib/common/generated/gql/graphql'
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
import { graphql } from '~~/lib/common/generated/gql'
import { useMutation } from '@vue/apollo-composable'
import { settingsNewEmailVerificationMutation } from '~~/lib/settings/graphql/mutations'
import {
getFirstErrorMessage,
convertThrowIntoFetchResult
} from '~~/lib/common/helpers/graphql'
graphql(`
fragment SettingsUserEmailCards_UserEmail on UserEmail {
email
id
primary
verified
}
`)
import type { UserEmail } from '~~/lib/common/generated/gql/graphql'
import { useUserEmails } from '~/lib/user/composables/emails'
const props = defineProps<{
emailData: SettingsUserEmailCards_UserEmailFragment
emailData: UserEmail
}>()
const { triggerNotification } = useGlobalToast()
const { mutate: resendMutation } = useMutation(settingsNewEmailVerificationMutation)
const { resendVerificationEmail } = useUserEmails()
const showDeleteDialog = ref(false)
const showSetPrimaryDialog = ref(false)
@@ -108,7 +89,6 @@ const primaryTooltip = computed(() => {
} else if (!props.emailData.verified) {
return 'Unverified emails cannot be set as primary'
}
return undefined
})
@@ -118,10 +98,14 @@ const description = computed(() => {
} else if (!props.emailData.verified) {
return 'Unverified emails cannot be set as primary'
}
return null
})
const handleVerifyEmail = async () => {
await resendVerificationEmail(props.emailData)
navigateTo(`/verify-email?emailId=${props.emailData.id}`)
}
const toggleSetPrimaryDialog = () => {
showSetPrimaryDialog.value = true
}
@@ -129,22 +113,4 @@ const toggleSetPrimaryDialog = () => {
const toggleDeleteDialog = () => {
showDeleteDialog.value = true
}
const resendVerificationEmail = async () => {
const result = await resendMutation({ input: { id: props.emailData.id } }).catch(
convertThrowIntoFetchResult
)
if (result?.data) {
triggerNotification({
type: ToastNotificationType.Success,
title: `Verification mail sent to ${props.emailData.email}`
})
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: errorMessage
})
}
}
</script>
@@ -89,6 +89,8 @@ const onDelete = async () => {
if (!props.workspace) return
if (workspaceNameInput.value !== props.workspace.name) return
// Create a copy of the workspace name and ID before deletion to avoid errors after deletion/cache update
const { name: workspaceName, id: workspaceId } = props.workspace
const cache = apollo.cache
const result = await deleteWorkspace({
workspaceId: props.workspace.id
@@ -121,24 +123,26 @@ const onDelete = async () => {
mixpanel.track('Workspace Deleted', {
// eslint-disable-next-line camelcase
workspace_id: props.workspace.id,
workspace_id: workspaceId,
feedback: feedback.value
})
mixpanel.get_group('workspace_id', props.workspace.id).set_once({
isDeleted: true
})
await sendWebhook(defaultZapierWebhookUrl, {
userId: activeUser.value?.id ?? '',
feedback: feedback.value
? `Action: Workspace Deleted(${props.workspace.name}) Feedback: ${feedback.value}`
: `Action: Workspace Deleted(${props.workspace.name}) - No feedback provided`
feedback: [
`**Action:** Workspace Deleted`,
`**Workspace:** ${workspaceName}`,
`**User ID:** ${activeUser.value?.id}`,
`**Workspace ID:** ${workspaceId}`,
feedback.value
? `**Feedback:** ${feedback.value}`
: '**Feedback:** No feedback provided'
].join('\n')
})
triggerNotification({
type: ToastNotificationType.Success,
title: 'Workspace deleted',
description: `The ${props.workspace.name} workspace has been deleted`
description: `The ${workspaceName} workspace has been deleted`
})
router.push(homeRoute)
@@ -17,11 +17,12 @@
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useApolloClient } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import { type SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragment } from '~/lib/common/generated/gql/graphql'
import {
type Workspace,
type SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragment
} from '~/lib/common/generated/gql/graphql'
import { getCacheId, getFirstErrorMessage } from '~/lib/common/helpers/graphql'
getCacheId,
getFirstErrorMessage,
modifyObjectField
} from '~/lib/common/helpers/graphql'
import { settingsDeleteWorkspaceDomainMutation } from '~/lib/settings/graphql/mutations'
import { useMixpanel } from '~/lib/core/composables/mp'
import type { MaybeNullOrUndefined } from '@speckle/shared'
@@ -69,16 +70,16 @@ const handleRemove = async () => {
const { data } = res
if (!data?.workspaceMutations || !props.workspaceId) return
cache.modify<Workspace>({
id: getCacheId('Workspace', props.workspaceId),
fields: {
domains(currentDomains, { isReference }) {
return [...(currentDomains ?? [])].filter((domain) =>
isReference(domain) ? false : domain.id !== props.domain.id
)
}
modifyObjectField(
cache,
getCacheId('Workspace', props.workspaceId),
'domains',
({ value, helpers }) => {
return value?.filter(
(domain) => helpers.readField(domain, 'id') !== props.domain.id
)
}
})
)
}
})
.catch(convertThrowIntoFetchResult)
@@ -104,7 +104,9 @@ const onSubmit = handleSubmit(() => {
url.searchParams.set('challenge', challenge.value)
}
postAuthRedirect.set(`/workspaces/${props.workspaceSlug}?ssoValidationSuccess=true`)
postAuthRedirect.set(
`/settings/workspaces/${props.workspaceSlug}/security?ssoValidationSuccess=true`
)
mixpanel.track('Workspace SSO Configuration Started', {
// eslint-disable-next-line camelcase
@@ -1,118 +0,0 @@
<template>
<div>
<div class="relative">
<button class="hidden sm:block pointer-events-auto group" @click="toggle(index)">
<div
v-show="!item.viewed"
class="animate-ping absolute bg-primary rounded-full h-8 w-8"
></div>
<div
class="sm:absolute bg-foundation group-hover:scale-125 scale transition rounded-full h-8 w-8 flex items-center justify-center text-primary cursor-pointer select-none text-sm font-medium"
>
<span>{{ index + 1 }}</span>
<!-- <span v-if="!expanded">{{ index + 1 }}</span>
<span v-else><XMarkIcon class="h-6 w-6" /></span> -->
</div>
</button>
<Transition
enter-from-class="opacity-0"
leave-to-class="opacity-0"
enter-active-class="transition duration-300"
leave-active-class="transition duration-300"
>
<div
v-show="item.expanded"
class="transition bg-foundation-page border border-outline-3 rounded-lg shadow-md mb-8 mx-2 gap-2 sm:gap-4 sm:ml-12 sm:max-w-xs pointer-events-auto"
>
<div
class="sm:hidden flex items-center justify-center w-full gap-3 mt-1 mb-3"
>
<div
class="bg-primary rounded-full"
:class="index === 0 ? 'h-3 w-3' : 'h-2 w-2 opacity-50'"
></div>
<div
class="bg-primary rounded-full"
:class="index === 1 ? 'h-3 w-3' : 'h-2 w-2 opacity-50'"
></div>
<div
class="bg-primary rounded-full"
:class="index === 2 ? 'h-3 w-3' : 'h-2 w-2 opacity-50'"
></div>
</div>
<div class="px-6 py-4">
<slot></slot>
</div>
<div
class="flex items-center justify-between pointer-events-auto px-6 py-2 border-t border-outline-3"
>
<slot name="actions">
<FormButton text size="sm" color="outline" @click="$emit('skip')">
Skip
</FormButton>
<div class="flex justify-center items-center space-x-2">
<FormButton
v-show="index !== 0"
size="sm"
color="outline"
text
@click="prev(index)"
>
<ArrowLeftIcon class="h-3 w-3 mr-1" />
Previous
</FormButton>
<div v-if="index === 2">
<div v-if="!disableNext" v-tippy="'First add another model'">
<FormButton disabled>Finish</FormButton>
</div>
<FormButton v-else @click="$emit('skip')">Finish</FormButton>
</div>
<FormButton v-else :icon-right="ArrowRightIcon" @click="next(index)">
Next
</FormButton>
</div>
</slot>
</div>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { Vector3 } from 'three'
import { ArrowRightIcon, ArrowLeftIcon } from '@heroicons/vue/24/solid'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import type { SlideshowItem } from '~~/lib/tour/slideshowItems'
const { next, prev, toggle } = inject('slideshowActions') as {
next: (currentIndex: number) => void
prev: (currentIndex: number) => void
toggle: (i: number) => void
}
defineEmits(['skip', 'previous', 'next'])
const props = defineProps<{
index: number
item: SlideshowItem
disableNext: boolean
}>()
const {
ui: {
camera: { position, target }
}
} = useInjectedViewerState()
watchEffect(() => {
if (props.item.expanded) setView()
})
function setView() {
const camPos = props.item.camPos
position.value = new Vector3(camPos[0], camPos[1], camPos[2])
target.value = new Vector3(camPos[3], camPos[4], camPos[5])
}
</script>
@@ -1,25 +0,0 @@
<template>
<div
class="relative max-w-4xl w-screen h-[100dvh] flex items-center justify-center z-50"
>
<TourSegmentation v-if="showSegmentation" />
<TourSlideshow v-else @next="$emit('complete')" />
</div>
</template>
<script setup lang="ts">
import { useViewerTour } from '~/lib/viewer/composables/tour'
import { useMixpanel } from '~~/lib/core/composables/mp'
defineEmits(['complete'])
const { showSegmentation } = useViewerTour()
const mp = useMixpanel()
watch(showSegmentation, (val) => {
mp.track('Onboarding Action', {
type: 'action',
name: 'step-activation',
stepName: val ? 'slideshow' : 'segmentation'
})
})
</script>
@@ -1,144 +0,0 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<div class="max-w-xl w-screen h-[100dvh] flex items-center justify-center">
<Transition
enter-from-class="opacity-0"
enter-active-class="transition duration-300"
>
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
<div
v-show="step === 0"
class="border border-outline bg-foundation text-foreground backdrop-blur shadow-lg rounded-xl p-4 space-y-2 absolute pointer-events-auto mx-2"
@mouseenter="rotateGently(Math.random() * 2)"
@mouseleave="rotateGently(Math.random() * 2)"
>
<h2 class="text-center text-heading-lg font-medium">
Welcome, {{ activeUser?.name?.split(' ')[0] }}!
</h2>
<p class="text-center text-body-2xs text-foreground2">
Let's get to know each other. What industry do you work in?
</p>
<div class="grid grid-cols-2 gap-3 pt-2">
<FormButton
v-for="val in OnboardingIndustry"
:key="val"
class="text-xs hover:scale-[1.05] capitalize"
size="sm"
color="outline"
full-width
@click="setIndustry(val)"
@mouseenter="rotateGently(Math.random() * 2)"
@focus="rotateGently(Math.random() * 2)"
>
{{ val }}
</FormButton>
</div>
</div>
</Transition>
<Transition
enter-from-class="opacity-0"
enter-active-class="transition duration-300"
>
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
<div
v-show="step === 1"
class="bg-foundation border dark:border-neutral-800 text-foreground backdrop-blur shadow-lg rounded-xl p-4 space-y-2 absolute pointer-events-auto mx-2"
@mouseenter="rotateGently(Math.random() * 2)"
@mouseleave="rotateGently(Math.random() * 2)"
>
<h2 class="text-center text-heading-lg font-medium">Thanks!</h2>
<p class="text-center text-body-2xs text-foreground2">
Last thing! Please select the role that best describes you:
</p>
<div class="grid grid-cols-2 gap-3 pt-2">
<FormButton
v-for="val in OnboardingRole"
:key="val"
class="text-xs hover:scale-[1.05]"
size="sm"
color="outline"
full-width
@click="setRole(val)"
@mouseenter="rotateGently(Math.random() * 2)"
@focus="rotateGently(Math.random() * 2)"
>
{{ RoleTitleMap[val] }}
</FormButton>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { Vector3 } from 'three'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import {
OnboardingIndustry,
OnboardingRole,
RoleTitleMap
} from '~~/lib/auth/helpers/onboarding'
import type { OnboardingState } from '~~/lib/auth/helpers/onboarding'
import { useProcessOnboarding } from '~~/lib/auth/composables/onboarding'
import { useCameraUtilities } from '~~/lib/viewer/composables/ui'
import { useViewerTour } from '~/lib/viewer/composables/tour'
const { setMixpanelSegments } = useProcessOnboarding()
const {
setView,
camera: { position, target }
} = useCameraUtilities()
const onboardingState = ref<OnboardingState>({ industry: undefined, role: undefined })
const { activeUser } = useActiveUser()
const tourState = useViewerTour()
const emit = defineEmits(['next'])
const step = ref(0)
function setIndustry(val: OnboardingIndustry) {
onboardingState.value.industry = val
step.value++
nextView()
}
function setRole(val: OnboardingRole) {
onboardingState.value.role = val
step.value++
nextView()
// NOTE: workaround for being able to view this in storybook
if (activeUser.value?.id) setMixpanelSegments(onboardingState.value)
tourState.showSegmentation.value = false
emit('next')
}
/** Hardcoded vec3s in Z up space */
const camPos = [
[23.86779, 82.9541, 29.05586, -27.41942, 37.72358, 29.05586, 0, 1],
[23.86779, 82.9541, 29.05586, -27.41942, 37.72358, 29.05586, 0, 1],
[27.22726, 2.10995, 27.98292, -27.31762, 36.15982, 27.98292, 0, 1],
[-42.39747, 72.34078, 29.54059, -25.71981, 35.86063, 29.54059, 0, 1],
[27.22726, 2.10995, 27.98292, -27.31762, 36.15982, 27.98292, 0, 1],
[-25.89795, 12.51216, 59.41238, -21.55546, 37.76445, 32.52495, 0, 1]
]
let flip = 1
const rotateGently = (factor = 1) => {
setView({ azimuth: (Math.PI / 12) * flip * factor, polar: 0 }, true)
flip *= -1
}
function nextView() {
position.value = new Vector3(
camPos[step.value][0],
camPos[step.value][1],
camPos[step.value][2]
)
target.value = new Vector3(
camPos[step.value][3],
camPos[step.value][4],
camPos[step.value][5]
)
}
</script>
@@ -1,162 +0,0 @@
<template>
<div
ref="parentEl"
class="fixed z-30 left-0 top-0 w-screen h-[100dvh] pointer-events-none overflow-hidden"
>
<!--
Tour Slideshow
-->
<TourComment
v-for="(item, index) in slideshowItems.slice(0, tourItems.length)"
:key="index"
:item="item"
:index="index"
class="absolute"
:class="isSmallerOrEqualSm ? 'bottom-0 left-0 w-screen' : ''"
:style="isSmallerOrEqualSm ? undefined : item.style"
:show-controls="item.showControls"
:disable-next="hasAddedOverlay"
@skip="finishSlideshow()"
>
<Component :is="tourItems[index]" @has-added-overlay="hasAddedOverlay = true" />
</TourComment>
<!-- In case the bubble is closed by the user, we need to display something -->
<Transition
enter-from-class="opacity-0"
leave-to-class="opacity-0"
enter-active-class="transition duration-300"
leave-active-class="transition duration-300"
>
<div
v-show="!hasOpenComments"
class="fixed bottom-0 left-0 w-full h-28 flex align-center p-10 items-center justify-center space-x-2 pointer-events-auto"
>
<FormButton size="sm" color="outline" rounded @click="finishSlideshow()">
Skip
</FormButton>
<FormButton
size="lg"
:icon-right="ArrowRightIcon"
rounded
class="shadow-md"
@click="resumeSlideshow()"
>
Resume tour
</FormButton>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
// Disclaimer, not the cleanest code.
import type { Nullable } from '@speckle/shared'
import type { Vector3 } from 'three'
import { items as slideshowItemsRaw } from '~~/lib/tour/slideshowItems'
import { ArrowRightIcon } from '@heroicons/vue/24/solid'
import { useViewerAnchoredPoints } from '~~/lib/viewer/composables/anchorPoints'
// Slideshow component imports
import FirstTip from '~~/components/tour/content/FirstTip.vue'
import BasicViewerNavigation from '~~/components/tour/content/BasicViewerNavigation.vue'
import OverlayModel from '~~/components/tour/content/OverlayModel.vue'
import { useCameraUtilities } from '~~/lib/viewer/composables/ui'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useViewerTour } from '~/lib/viewer/composables/tour'
const emit = defineEmits(['next'])
const tourStage = useViewerTour()
const { zoom, setView } = useCameraUtilities()
// Drives the amount of slideshow items
const tourItems = [FirstTip, BasicViewerNavigation, OverlayModel /* , LastTip */]
// Ensuring we don't have more 3d points than actual tips by slicing the array
// TODO: should check the other way around, but since this part is so handcrafted
// doesn't make much sense.
const slideshowItems = ref(slideshowItemsRaw.slice(0, tourItems.length))
provide('slideshowItems', slideshowItems)
const hasAddedOverlay = ref(false)
const lastOpenIndex = ref(0)
const mp = useMixpanel()
// const isSmallerOrEqualSm = computed(() => breakpoints.smallerOrEqual('sm').value)
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
const next = (currentIndex: number) => {
if (currentIndex + 1 >= slideshowItems.value.length) {
finishSlideshow()
return
}
slideshowItems.value[currentIndex].expanded = false
slideshowItems.value[currentIndex].viewed = true
slideshowItems.value[currentIndex + 1].expanded = true
lastOpenIndex.value = currentIndex + 1
mp.track('Onboarding Action', {
type: 'action',
name: 'slideshow',
action: 'next',
step: currentIndex
})
}
const prev = (currentIndex: number) => {
if (currentIndex - 1 < 0) return
slideshowItems.value[currentIndex].expanded = false
slideshowItems.value[currentIndex - 1].expanded = true
lastOpenIndex.value = currentIndex - 1
mp.track('Onboarding Action', {
type: 'action',
name: 'slideshow',
action: 'previous',
step: currentIndex
})
}
const toggle = (index: number) => {
if (!slideshowItems.value[index]) return
slideshowItems.value[index].expanded = !slideshowItems.value[index].expanded
if (slideshowItems.value[index].expanded) lastOpenIndex.value = index
mp.track('Onboarding Action', {
type: 'action',
name: 'slideshow',
action: 'toggle',
step: index
})
}
provide('slideshowActions', { next, prev, toggle })
const hasOpenComments = computed(() => {
return slideshowItems.value.some((item) => item.expanded === true)
})
const parentEl = ref(null as Nullable<HTMLElement>)
useViewerAnchoredPoints({
parentEl,
points: slideshowItems,
pointLocationGetter: (c) => c.location as Vector3,
updatePositionCallback: (c, res) => {
c.style = {
...c.style,
...res.style,
display: 'inline-block',
transition: 'all 0.1s ease'
}
}
})
const finishSlideshow = () => {
zoom()
setView('left')
tourStage.showNavbar.value = true
tourStage.showControls.value = true
emit('next')
}
const resumeSlideshow = () => {
slideshowItems.value[lastOpenIndex.value].expanded = true
}
</script>
@@ -1,78 +0,0 @@
<template>
<div>
<div v-if="isSmallerOrEqualSm">
<p class="text-sm">
<strong>Navigate</strong>
easily with hand gestures:
</p>
<div class="flex items-center justify-between gap-4 py-3 text-xs">
<div class="flex gap-1 items-center">
<IconHandRotate class="h-5 w-5" />
rotate
</div>
<div class="flex gap-1 items-center">
<IconHandSelect class="h-5 w-5" />
select
</div>
<div class="flex gap-1 items-center">
<IconHandZoom class="h-5 w-5" />
zoom
</div>
</div>
</div>
<div v-else>
<p class="text-sm">
<strong>Navigate</strong>
easily with your mouse:
</p>
<div class="flex items-center justify-between gap-4 py-3 text-xs">
<div class="flex gap-1 items-center">
<IconMouseRotate class="h-5 w-5" />
rotate
</div>
<div class="flex gap-1 items-center">
<IconMouseZoom class="h-5 w-5" />
zoom
</div>
<div class="flex gap-1 items-center">
<IconMousePan class="h-5 w-5" />
pan
</div>
</div>
</div>
<div class="text-sm">
<div v-if="hasMovedCamera" class="font-medium flex items-center">
<CheckIcon class="w-4 h-4 text-success mr-2" />
<p>{{ encouragements[controlEndCounts] }}</p>
</div>
<p v-else>Give it a try now!</p>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon } from '@heroicons/vue/24/solid'
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
import { useViewerCameraControlEndTracker } from '~~/lib/viewer/composables/viewer'
const hasMovedCamera = ref(false)
const controlEndCounts = ref(-1)
const encouragements = [
'Nicely done!',
'Wow, you are a pro!',
'3D is fun!',
"Don't get dizzy!"
]
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
useViewerCameraControlEndTracker(() => {
hasMovedCamera.value = true
if (controlEndCounts.value === encouragements.length - 1) {
controlEndCounts.value = 0
} else {
controlEndCounts.value++
}
})
</script>
@@ -1,12 +0,0 @@
<template>
<div>
<p class="text-sm">
Let's run through a few fast tips! This is Speckle's 3D viewer, and what you're
looking at is a
<b>model.</b>
<br />
<br />
Next, we're going to learn how to navigate it!
</p>
</div>
</template>
@@ -1,13 +0,0 @@
<template>
<div>
<p class="text-sm">There's much more to Speckle.</p>
</div>
</template>
<script setup lang="ts">
import { useViewerTour } from '~/lib/viewer/composables/tour'
const state = useViewerTour()
state.showNavbar.value = true
state.showControls.value = true
</script>
@@ -1,72 +0,0 @@
<template>
<div>
<div v-show="!hasAddedOverlay">
<p class="text-sm">
Speckle allows you to load multiple models in the same viewer.
</p>
<p class="text-sm mt-3">
<span v-show="!hasAddedOverlay">
<FormButton
color="outline"
:icon-right="hasAddedOverlay ? CheckIcon : PlusIcon"
:disabled="hasAddedOverlay"
@click="addOverlay()"
>
Add another model
</FormButton>
</span>
</p>
</div>
<div v-show="hasAddedOverlay">
<p class="text-sm">
Nice - you've just created a "federated" model. Ready for what's next?
<!-- <br />
<br />
Let's go to the next step. -->
</p>
<!-- <p class="text-sm">You can overlay as many models as you want.</p> -->
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, PlusIcon } from '@heroicons/vue/24/solid'
import { SpeckleViewer } from '@speckle/shared'
import { useQuery } from '@vue/apollo-composable'
import { latestModelsQuery } from '~~/lib/projects/graphql/queries'
import {
useInjectedViewerRequestedResources,
useInjectedViewerLoadedResources
} from '~~/lib/viewer/composables/setup'
import { SECOND_MODEL_NAME } from '~~/lib/auth/composables/onboarding'
const emit = defineEmits(['hasAddedOverlay'])
const { items } = useInjectedViewerRequestedResources()
const { project } = useInjectedViewerLoadedResources()
const id = project.value?.id as string
const { result } = useQuery(latestModelsQuery, () => ({ projectId: id }))
const hasAddedOverlay = ref(false)
async function addOverlay() {
const models = result.value?.project?.models.items
const otherModel = models?.find((m) => m.name === SECOND_MODEL_NAME)
if (otherModel)
await items.update([
...items.value,
...SpeckleViewer.ViewerRoute.resourceBuilder()
.addModel(otherModel?.id)
.toResources()
])
hasAddedOverlay.value = true
emit('hasAddedOverlay')
}
onBeforeUnmount(() => {
if (hasAddedOverlay.value) return
addOverlay()
})
</script>
@@ -1,15 +1,9 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<div v-if="showControls">
<div>
<div
class="absolute z-20 flex max-h-screen simple-scrollbar flex-col space-y-1 md:space-y-2 px-2"
:class="
showNavbar && !isEmbedEnabled
? 'pt-[3.8rem]'
: isTransparent
? 'pt-2'
: 'pt-2 pb-16'
"
:class="!isEmbedEnabled ? 'pt-[3.8rem]' : isTransparent ? 'pt-2' : 'pt-2 pb-16'"
>
<!-- Models -->
<ViewerControlsButtonToggle
@@ -164,7 +158,7 @@
<div
v-if="activePanel !== 'none'"
ref="resizeHandle"
class="absolute z-10 max-h-[calc(100dvh-4rem)] w-7 mt-[3.9rem] hidden sm:flex group overflow-hidden items-center rounded-r cursor-ew-resize z-30"
class="absolute max-h-[calc(100dvh-4rem)] w-7 mt-[3.9rem] hidden sm:flex group overflow-hidden items-center rounded-r cursor-ew-resize z-30"
:style="`left:${width - 2}px; height:${height ? height - 10 : 0}px`"
@mousedown="startResizing"
>
@@ -252,7 +246,6 @@
<FormButton @click="resetSectionBox()">Reset section box</FormButton>
</Portal>
</div>
<div v-else />
</template>
<script setup lang="ts">
import {
@@ -278,7 +271,6 @@ import {
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
import { useViewerTour } from '~/lib/viewer/composables/tour'
import {
onKeyStroke,
useEventListener,
@@ -361,7 +353,6 @@ const {
} = useSectionBoxUtilities()
const { getActiveMeasurement, removeMeasurement, enableMeasurements } =
useMeasurementUtilities()
const { showNavbar, showControls } = useViewerTour()
const { isTransparent, isEnabled: isEmbedEnabled } = useEmbed()
const {
zoomExtentsOrSelection,
@@ -1,43 +1,29 @@
<template>
<div>
<div :class="`${loadProgress < 1 && viewerBusy ? 'mt-0' : '-mt-5'} transition-all`">
<div
v-show="viewerBusy"
:class="`absolute w-full max-w-screen h-1 bg-blue-500/20 overflow-hidden ${
showNavbar && !isEmbedEnabled ? 'mt-14' : 'mt-0'
} text-xs text-foreground-on-primary z-50`"
:class="`absolute w-full max-w-screen flex justify-center ${
!isEmbedEnabled ? 'mt-14' : 'mt-0'
} z-50`"
>
<div class="swoosher absolute top-0 bg-blue-500/50 rounded-md"></div>
<div
class="relative bg-blue-500/50 mt-0 h-4 rounded-b-lg select-none px-2 py-1 w-2/3 lg:w-1/3 overflow-hidden"
>
<div
class="absolute h-full inset-0 bg-primary transition-[width]"
:style="`width: ${Math.floor(loadProgress * 100)}%`"
></div>
<div
class="absolute h-full inset-0 text-center text-xs text-foreground-on-primary"
>
{{ Math.floor(loadProgress * 100) }}%
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
import { useViewerTour } from '~/lib/viewer/composables/tour'
import { useInjectedViewerInterfaceState } from '~~/lib/viewer/composables/setup'
const { isEnabled: isEmbedEnabled } = useEmbed()
const { viewerBusy } = useInjectedViewerInterfaceState()
const { showNavbar } = useViewerTour()
const { viewerBusy, loadProgress } = useInjectedViewerInterfaceState()
</script>
<style scoped>
.swoosher {
width: 100%;
height: 100%;
animation: swoosh 1s infinite linear;
transform-origin: 0% 30%;
}
@keyframes swoosh {
0% {
transform: translateX(0) scaleX(0);
}
40% {
transform: translateX(0) scaleX(0.4);
}
100% {
transform: translateX(100%) scaleX(0.5);
}
}
</style>
@@ -27,13 +27,6 @@
</Portal>
<ClientOnly>
<!-- Tour host -->
<div
v-if="showTour"
class="fixed w-full h-[100dvh] flex justify-center items-center pointer-events-none z-[100]"
>
<TourOnboarding @complete="showTour = false" />
</div>
<!-- Viewer host -->
<div
class="viewer special-gradient absolute z-10 overflow-hidden w-screen"
@@ -50,20 +43,18 @@
enter-from-class="opacity-0"
enter-active-class="transition duration-1000"
>
<ViewerAnchoredPoints v-show="showControls" />
<ViewerAnchoredPoints />
</Transition>
</div>
<!-- Global loading bar -->
<ViewerLoadingBar class="absolute -top-2 left-0 w-full z-40" />
<ViewerLoadingBar
class="absolute left-0 w-full z-40 h-30"
:class="isEmbedEnabled ? 'top-0' : ' -top-2'"
/>
<!-- Sidebar controls -->
<Transition
enter-from-class="opacity-0"
enter-active-class="transition duration-1000"
>
<ViewerControls v-show="showControls" class="relative z-20" />
</Transition>
<ViewerControls v-if="showControls" class="relative z-20" />
<!-- Viewer Object Selection Info Display -->
<Transition
@@ -71,9 +62,7 @@
enter-from-class="opacity-0"
enter-active-class="transition duration-1000"
>
<div v-show="showControls">
<ViewerSelectionSidebar class="z-20" />
</div>
<ViewerSelectionSidebar class="z-20" />
</Transition>
<div
class="absolute z-10 w-screen px-8 grid grid-cols-1 sm:grid-cols-3 gap-2"
@@ -119,7 +108,6 @@ import {
import dayjs from 'dayjs'
import { graphql } from '~~/lib/common/generated/gql'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
import { useViewerTour } from '~/lib/viewer/composables/tour'
import { useFilterUtilities } from '~/lib/viewer/composables/ui'
import { projectsRoute } from '~~/lib/common/helpers/route'
import { workspaceRoute } from '~/lib/common/helpers/route'
@@ -130,7 +118,6 @@ const emit = defineEmits<{
}>()
const route = useRoute()
const { showTour, showControls } = useViewerTour()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const modelId = computed(() => route.params.modelId as string)
@@ -143,7 +130,12 @@ const state = useSetupViewer({
const {
filters: { hasAnyFiltersApplied }
} = useFilterUtilities({ state })
const { isEnabled: isEmbedEnabled, hideSelectionInfo, isTransparent } = useEmbed()
const {
isEnabled: isEmbedEnabled,
hideSelectionInfo,
isTransparent,
showControls
} = useEmbed()
emit('setup', state)
@@ -1,30 +1,23 @@
<template>
<div class="bg-foundation-page">
<nav class="fixed z-40 top-0 h-12 bg-foundation border-b border-outline-2">
<div class="flex items-center justify-between h-full w-screen py-4 px-3 sm:px-4">
<HeaderLogoBlock
:active="false"
class="min-w-40 cursor-pointer"
no-link
@click="onCancelClick"
/>
<FormButton size="sm" color="outline" @click="onCancelClick">Cancel</FormButton>
</div>
</nav>
<div class="h-dvh w-dvh overflow-hidden flex flex-col">
<div class="h-12 w-full shrink-0" />
<main class="w-full h-full overflow-y-auto simple-scrollbar pt-8 pb-16">
<div class="container mx-auto px-6 md:px-12">
<WorkspaceWizard :workspace-id="workspaceId" />
<HeaderWithEmptyPage empty-header>
<template #header-left>
<HeaderLogoBlock
:active="false"
class="min-w-40 cursor-pointer"
no-link
@click="onCancelClick"
/>
</template>
<template #header-right>
<FormButton size="sm" color="outline" @click="onCancelClick">Cancel</FormButton>
</template>
<WorkspaceWizardCancelDialog
v-model:open="isCancelDialogOpen"
:workspace-id="workspaceId"
/>
</div>
</main>
</div>
</div>
<WorkspaceWizard :workspace-id="workspaceId" />
<WorkspaceWizardCancelDialog
v-model:open="isCancelDialogOpen"
:workspace-id="workspaceId"
/>
</HeaderWithEmptyPage>
</template>
<script setup lang="ts">
@@ -94,11 +94,9 @@ import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import { graphql } from '~~/lib/common/generated/gql'
import type { WorkspaceProjectsQueryQueryVariables } from '~~/lib/common/generated/gql/graphql'
import { workspaceRoute } from '~/lib/common/helpers/route'
import { useWorkspacesMixpanel } from '~/lib/workspaces/composables/mixpanel'
import { useBillingActions } from '~/lib/billing/composables/actions'
import { useWorkspacesWizard } from '~/lib/workspaces/composables/wizard'
import type { WorkspaceWizardState } from '~/lib/workspaces/helpers/types'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
graphql(`
fragment WorkspaceProjectList_Workspace on Workspace {
@@ -107,7 +105,6 @@ graphql(`
...WorkspaceTeam_Workspace
...WorkspaceSecurity_Workspace
...BillingAlert_Workspace
...WorkspaceMixpanelUpdateGroup_Workspace
...MoveProjectsDialog_Workspace
...InviteDialogWorkspace_Workspace
projects {
@@ -131,9 +128,7 @@ graphql(`
}
`)
const { activeUser } = useActiveUser()
const { validateCheckoutSession } = useBillingActions()
const { workspaceMixpanelUpdateGroup } = useWorkspacesMixpanel()
const areQueriesLoading = useQueryLoading()
const route = useRoute()
const {
@@ -236,10 +231,6 @@ onResult((queryResult) => {
}
if (queryResult.data?.workspaceBySlug) {
workspaceMixpanelUpdateGroup(
queryResult.data.workspaceBySlug,
activeUser.value?.email
)
useHeadSafe({
title: queryResult.data.workspaceBySlug.name
})
@@ -1,76 +0,0 @@
<template>
<div class="flex px-4 py-3 items-center space-x-2 justify-between">
<div class="flex items-center space-x-2 flex-1 truncate">
<div
v-if="unmatchingDomainPolicy"
v-tippy="
'Users that do not comply with the domain policy can only be invited as guests'
"
>
<ExclamationCircleIcon class="text-danger w-5 w-4" />
</div>
<span class="truncate text-body-sm flex-1">
{{ selectedEmails.join(', ') }}
</span>
</div>
<div class="flex items-center space-x-2">
<FormSelectServerRoles
v-if="showServerRoleSelect"
v-model="serverRole"
:allow-guest="isGuestMode"
:allow-admin="isAdmin"
fixed-height
/>
<span
v-tippy="
isTryingToSetGuestOwner ? `Server guests can't be project owners` : undefined
"
>
<FormButton
:disabled="isButtonDisabled"
color="outline"
@click="() => $emit('invite-emails', { serverRole })"
>
Invite
</FormButton>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { ExclamationCircleIcon } from '@heroicons/vue/24/outline'
import { Roles, type ServerRoles } from '@speckle/shared'
defineEmits<{
(e: 'invite-emails', payload: { serverRole: ServerRoles }): void
}>()
const props = defineProps<{
selectedEmails: string[]
disabled?: boolean
isGuestMode: boolean
isOwnerRole: boolean
unmatchingDomainPolicy?: boolean
}>()
const { isAdmin } = useActiveUser()
const serverRole = ref<ServerRoles>(Roles.Server.User)
const showServerRoleSelect = computed(() => props.isGuestMode || isAdmin.value)
const isTryingToSetGuestOwner = computed(() => {
if (!showServerRoleSelect.value) return false
if (serverRole.value === Roles.Server.Guest && props.isOwnerRole) return true
return false
})
const isButtonDisabled = computed(() => {
if (props.disabled) return true
if (isTryingToSetGuestOwner.value) return true
if (!props.selectedEmails.length) return true
if (props.unmatchingDomainPolicy) return true
return false
})
</script>
@@ -1,65 +0,0 @@
<template>
<div
class="flex px-4 py-3 items-center space-x-2 justify-between border-b last:border-0 border-outline-3"
>
<div class="flex items-center space-x-2 flex-1 truncate">
<UserAvatar hide-tooltip :user="user" />
<div
v-if="
user.workspaceDomainPolicyCompliant === false &&
targetRole !== Roles.Workspace.Guest
"
v-tippy="
'Users that do not comply with the domain policy can only be invited as guests'
"
>
<ExclamationCircleIcon class="text-danger w-5 w-4" />
</div>
<span class="grow truncate text-body-sm">{{ user.name }}</span>
</div>
<span v-tippy="isTryingToSetGuestOwner ? settingGuestOwnerErrorMessage : undefined">
<FormButton
size="sm"
color="outline"
:disabled="isButtonDisabled"
@click="() => $emit('invite-user')"
>
Invite
</FormButton>
</span>
</div>
</template>
<script setup lang="ts">
import { Roles, type WorkspaceRoles } from '@speckle/shared'
import type { UserSearchItem } from '~~/lib/common/composables/users'
import { ExclamationCircleIcon } from '@heroicons/vue/24/outline'
defineEmits<{
(e: 'invite-user'): void
}>()
const props = withDefaults(
defineProps<{
isOwnerRole: boolean
user: UserSearchItem
disabled?: boolean
settingGuestOwnerErrorMessage?: string
targetRole: WorkspaceRoles
}>(),
{
settingGuestOwnerErrorMessage: "Server guests can't be workspace owners"
}
)
const isTryingToSetGuestOwner = computed(
() => props.user.role === Roles.Server.Guest && props.isOwnerRole
)
const isButtonDisabled = computed(() => {
if (props.disabled) return true
if (isTryingToSetGuestOwner.value) return true
if (props.user.workspaceDomainPolicyCompliant === false)
return props.targetRole !== Roles.Workspace.Guest
return false
})
</script>
@@ -1,17 +0,0 @@
<template>
<div class="flex flex-col border border-outline-3 rounded-lg pt-6 px-8 pb-10 h-full">
<slot name="icon" />
<div class="flex flex-col mt-4">
<h5 class="text-foreground text-heading-sm">{{ title }}</h5>
<p class="text-body-xs text-foreground-2">
{{ description }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string
description: string
}>()
</script>
@@ -42,6 +42,14 @@ export const useIsMultipleEmailsEnabled = () => {
return ref(FF_MULTIPLE_EMAILS_MODULE_ENABLED)
}
export const useIsOnboardingForced = () => {
const {
public: { FF_FORCE_ONBOARDING }
} = useRuntimeConfig()
return ref(FF_FORCE_ONBOARDING)
}
export const useIsGendoModuleEnabled = () => {
const {
public: { FF_GENDOAI_MODULE_ENABLED }
@@ -1,7 +0,0 @@
<template>
<div class="w-screen h-[100dvh] overflow-hidden">
<ClientOnly>
<slot />
</ClientOnly>
</div>
</template>
+3 -36
View File
@@ -1,28 +1,7 @@
<template>
<div class="relative min-h-full">
<div
v-if="debug"
class="pointer-events-none fixed bottom-0 z-40 flex w-full space-x-2 p-3 text-xs"
>
<FormButton class="pointer-events-auto" size="sm" @click="toggleNavbar">
nav
</FormButton>
<FormButton class="pointer-events-auto" size="sm" @click="toggleViewerControls">
viewer ctrls
</FormButton>
<FormButton class="pointer-events-auto" size="sm" @click="toggleTour">
tour ctrls
</FormButton>
<!-- <span>{{ tourState }}</span> -->
</div>
<ClientOnly>
<Transition
enter-from-class="opacity-0"
enter-active-class="transition duration-1000"
>
<HeaderNavBar v-show="showNavbar" class="relative z-20" />
</Transition>
<HeaderNavBar v-if="!isEmbedEnabled" class="relative z-20" />
</ClientOnly>
<main class="absolute top-0 left-0 z-10 h-[100dvh] w-screen">
<slot />
@@ -30,19 +9,7 @@
</div>
</template>
<script setup lang="ts">
import { useViewerTour } from '~/lib/viewer/composables/tour'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
const { showNavbar, showTour, showControls } = useViewerTour()
const debug = ref(false)
const toggleNavbar = () => {
showNavbar.value = !showNavbar.value
}
const toggleTour = () => {
showTour.value = !showTour.value
}
const toggleViewerControls = () => {
showControls.value = !showControls.value
}
const { isEnabled: isEmbedEnabled } = useEmbed()
</script>
@@ -11,6 +11,10 @@ export const activeUserQuery = graphql(`
activeUser {
id
email
emails {
id
verified
}
company
bio
name
@@ -303,12 +303,6 @@ export const useAuthManager = (
skipRedirect: postAuthRedirect.hadPendingRedirect.value
})
triggerNotification({
type: ToastNotificationType.Success,
title: 'Welcome!',
description: "You've been successfully authenticated"
})
postAuthRedirect.popAndFollowRedirect()
} catch (e) {
triggerNotification({
@@ -378,6 +372,9 @@ export const useAuthManager = (
newsletter
})
const registeredThisSession = useRegisteredThisSession()
registeredThisSession.value = true
// eslint-disable-next-line camelcase
goHome({ query: { access_code: accessCode } })
}
@@ -480,6 +477,12 @@ const useAuthAppIdAndChallenge = () => {
return { appId, challenge }
}
/**
* Indicates whether the user just completed registration
*/
export const useRegisteredThisSession = () =>
useState<boolean>('registered-this-session', () => false)
export const useLoginOrRegisterUtils = () => {
const appIdAndChallenge = useAuthAppIdAndChallenge()
const route = useRoute()
@@ -15,26 +15,20 @@ import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables
import { useNavigateToHome } from '~~/lib/common/helpers/route'
import { projectsDashboardQuery } from '~~/lib/projects/graphql/queries'
const ONBOARDING_PROP_INDUSTRY = 'onboarding_v1_industry'
const ONBOARDING_PROP_ROLE = 'onboarding_v1_role'
export const FIRST_MODEL_NAME = 'base design'
export const SECOND_MODEL_NAME = 'building wrapper'
export function useProcessOnboarding() {
export const useProcessOnboarding = () => {
const mixpanel = useMixpanel()
const { distinctId, activeUser } = useActiveUser()
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const goHome = useNavigateToHome()
/**
* Sends to mp the segmentation info (industry, role)
* @param state
*/
const setMixpanelSegments = (state: OnboardingState) => {
mixpanel.people.set_once(ONBOARDING_PROP_INDUSTRY, state.industry || null)
mixpanel.people.set_once(ONBOARDING_PROP_ROLE, state.role || null)
const setMixpanelSegments = (segments: Partial<OnboardingState>) => {
mixpanel.people.set(segments)
}
/**
@@ -113,6 +107,7 @@ export function useProcessOnboarding() {
}
})
.catch(convertThrowIntoFetchResult)
goHome()
}
/**
@@ -130,7 +125,6 @@ export function useProcessOnboarding() {
throw new OnboardingError('Attempting to onboard unidentified user')
// Send data to mixpanel
mixpanel.people.set_once(ONBOARDING_PROP_INDUSTRY, state.industry || null)
mixpanel.people.set_once(ONBOARDING_PROP_ROLE, state.role || null)
// Mark onboarding as finished
@@ -1,31 +1,66 @@
export enum OnboardingIndustry {
Architecture = 'architecture & planning',
Engineering = 'engineering',
Construction = 'construction',
Software = 'software development',
Edu = 'higher education',
export enum OnboardingRole {
ComputationalDesign = 'computational-design',
BIM = 'bim',
ArchitecturePlanning = 'architecture-planning',
EngineeringAEC = 'engineering-aec',
EngineeringSoftware = 'engineering-software',
Education = 'education',
Management = 'management',
Other = 'other'
}
export enum OnboardingRole {
ComputationalDesigner = 'computational-designer',
SoftwareDeveloper = 'software-developer',
DesignerOrEngineer = 'designer-or-engineer',
Manager = 'manager',
Student = 'student',
export enum OnboardingPlan {
Exploring = 'exploring',
DataExchange = 'data-exchange',
Analytics = 'analytics',
Collaboration = 'collaboration',
DataWarehouse = 'data-warehouse',
Development = 'development',
Other = 'other'
}
export enum OnboardingSource {
SocialMedia = 'social-media',
Search = 'internet-search',
Referral = 'friend-or-colleague',
Event = 'event-conference',
Education = 'university-course',
Other = 'other'
}
export const RoleTitleMap: Record<OnboardingRole, string> = {
[OnboardingRole.ComputationalDesigner]: 'Computational Designer',
[OnboardingRole.SoftwareDeveloper]: 'Software Developer',
[OnboardingRole.DesignerOrEngineer]: 'Designer Or Engineer',
[OnboardingRole.Manager]: 'Manager',
[OnboardingRole.Student]: 'Student',
[OnboardingRole.ComputationalDesign]: 'Computational Design',
[OnboardingRole.BIM]: 'Building Information Modelling (BIM)',
[OnboardingRole.ArchitecturePlanning]: 'Architecture & Planning',
[OnboardingRole.EngineeringAEC]: 'Engineering (Structural, MEP, Civil, etc)',
[OnboardingRole.EngineeringSoftware]: 'Engineering (Software)',
[OnboardingRole.Education]: 'Education',
[OnboardingRole.Management]: 'Management & Leadership',
[OnboardingRole.Other]: 'Other'
}
export type OnboardingState = {
industry?: OnboardingIndustry
role?: OnboardingRole
export const PlanTitleMap: Record<OnboardingPlan, string> = {
[OnboardingPlan.Exploring]: 'Just checking things out',
[OnboardingPlan.DataExchange]: 'Exchange data between applications',
[OnboardingPlan.Analytics]:
'Data analytics, visualisation and reporting (eg PowerBI)',
[OnboardingPlan.Collaboration]: 'Collaborate with my team and share 3D models online',
[OnboardingPlan.DataWarehouse]: 'Data warehouse and common data environment (CDE)',
[OnboardingPlan.Development]: 'Develop custom functionalities and apps',
[OnboardingPlan.Other]: 'Other'
}
export const SourceTitleMap: Record<OnboardingSource, string> = {
[OnboardingSource.SocialMedia]: 'Social Media',
[OnboardingSource.Search]: 'Internet search',
[OnboardingSource.Referral]: 'Friend or colleague',
[OnboardingSource.Event]: 'Event or conference',
[OnboardingSource.Education]: 'University or course',
[OnboardingSource.Other]: 'Other'
}
export type OnboardingState = {
role?: OnboardingRole
plans?: OnboardingPlan[]
source?: OnboardingSource
}
@@ -1,6 +1,3 @@
/**
* TODO: Does this need to change for new frontend?
*/
export const speckleWebAppId = 'spklwebapp'
export enum AuthStrategy {
@@ -6,7 +6,6 @@ import {
} from '~~/lib/auth/errors/errors'
import { speckleWebAppId } from '~~/lib/auth/helpers/strategies'
// TODO: Should these differ from the old frontend values?
const appId = speckleWebAppId
const appSecret = speckleWebAppId
@@ -14,8 +14,6 @@ import { settingsBillingCancelCheckoutSessionMutation } from '~/lib/settings/gra
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import { useMixpanel } from '~/lib/core/composables/mp'
import { graphql } from '~~/lib/common/generated/gql'
import { useZapier } from '~/lib/core/composables/zapier'
import { defaultZapierWebhookUrl } from '~/lib/common/helpers/route'
graphql(`
fragment BillingActions_Workspace on Workspace {
@@ -49,7 +47,6 @@ export const useBillingActions = () => {
const { mutate: cancelCheckoutSessionMutation } = useMutation(
settingsBillingCancelCheckoutSessionMutation
)
const { sendWebhook } = useZapier()
const billingPortalRedirect = async (workspaceId?: string) => {
if (!workspaceId) return
@@ -188,9 +185,7 @@ export const useBillingActions = () => {
})
}
const validateCheckoutSession = async (
workspace: BillingActions_WorkspaceFragment
) => {
const validateCheckoutSession = (workspace: BillingActions_WorkspaceFragment) => {
const sessionIdQuery = route.query?.session_id
const paymentStatusQuery = route.query?.payment_status
@@ -218,19 +213,6 @@ export const useBillingActions = () => {
// eslint-disable-next-line camelcase
workspace_id: workspace.id
})
if (import.meta.server) {
await sendWebhook(defaultZapierWebhookUrl, {
workspaceId: workspace.id,
workspaceName: workspace.name,
plan: workspace.plan?.name ?? '',
cycle: workspace.subscription?.billingInterval ?? '',
status: WorkspacePlanStatuses.Valid,
invitedTeamCount: workspace.invitedTeam?.length ?? 0,
teamCount: workspace.team?.totalCount ?? 0,
defaultRegion: workspace.defaultRegion?.name ?? ''
})
}
}
const currentQueryParams = { ...route.query }
@@ -17,8 +17,6 @@ const documents = {
"\n fragment AuthLoginWithEmailBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n email\n user {\n id\n }\n }\n": types.AuthLoginWithEmailBlock_PendingWorkspaceCollaboratorFragmentDoc,
"\n query AuthRegisterPanelWorkspaceInvite($token: String) {\n workspaceInvite(token: $token) {\n id\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n }\n }\n": types.AuthRegisterPanelWorkspaceInviteDocument,
"\n fragment ServerTermsOfServicePrivacyPolicyFragment on ServerInfo {\n termsOfService\n }\n": types.ServerTermsOfServicePrivacyPolicyFragmentFragmentDoc,
"\n query EmailVerificationBannerState {\n activeUser {\n id\n email\n verified\n hasPendingVerification\n }\n }\n": types.EmailVerificationBannerStateDocument,
"\n mutation RequestVerification {\n requestVerification\n }\n": types.RequestVerificationDocument,
"\n fragment AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceName\n email\n user {\n id\n ...LimitedUserAvatar\n }\n }\n": types.AuthWorkspaceInviteHeader_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment AuthSsoLogin_Workspace on LimitedWorkspace {\n id\n slug\n name\n logo\n }\n": types.AuthSsoLogin_WorkspaceFragmentDoc,
"\n fragment AuthStategiesServerInfoFragment on ServerInfo {\n authStrategies {\n id\n name\n url\n }\n ...AuthThirdPartyLoginButtonOIDC_ServerInfo\n }\n": types.AuthStategiesServerInfoFragmentFragmentDoc,
@@ -51,6 +49,9 @@ const documents = {
"\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 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,
"\n fragment InviteDialogProjectWorkspaceMembers_Project on Project {\n id\n ...ProjectPageTeamInternals_Project\n workspace {\n team {\n items {\n ...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator\n }\n }\n }\n }\n": types.InviteDialogProjectWorkspaceMembers_ProjectFragmentDoc,
"\n fragment ProjectModelPageHeaderProject on Project {\n id\n name\n model(id: $modelId) {\n id\n name\n description\n }\n workspace {\n id\n slug\n name\n }\n }\n": types.ProjectModelPageHeaderProjectFragmentDoc,
"\n fragment ProjectModelPageVersionsPagination on Project {\n id\n visibility\n model(id: $modelId) {\n id\n versions(limit: 16, cursor: $versionsCursor) {\n cursor\n totalCount\n items {\n ...ProjectModelPageVersionsCardVersion\n }\n }\n }\n ...ProjectsModelPageEmbed_Project\n }\n": types.ProjectModelPageVersionsPaginationFragmentDoc,
"\n fragment ProjectModelPageVersionsProject on Project {\n ...ProjectPageProjectHeader\n model(id: $modelId) {\n id\n name\n pendingImportedVersions {\n ...PendingFileUpload\n }\n }\n ...ProjectModelPageVersionsPagination\n ...ProjectsModelPageEmbed_Project\n workspace {\n id\n readOnly\n }\n }\n": types.ProjectModelPageVersionsProjectFragmentDoc,
@@ -60,7 +61,6 @@ const documents = {
"\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n": types.ProjectsModelPageEmbed_ProjectFragmentDoc,
"\n fragment ProjectModelPageVersionsCardVersion on Version {\n id\n message\n authorUser {\n ...LimitedUserAvatar\n }\n createdAt\n previewUrl\n sourceApplication\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n ...ProjectModelPageDialogDeleteVersion\n ...ProjectModelPageDialogMoveToVersion\n automationsStatus {\n ...AutomateRunsTriggerStatus_TriggeredAutomationsStatus\n }\n }\n": types.ProjectModelPageVersionsCardVersionFragmentDoc,
"\n fragment ProjectPageProjectHeader on Project {\n id\n role\n name\n description\n visibility\n allowPublicComments\n workspace {\n id\n slug\n name\n logo\n }\n }\n": types.ProjectPageProjectHeaderFragmentDoc,
"\n fragment ProjectPageInviteDialog_Project on Project {\n id\n workspaceId\n workspace {\n id\n defaultProjectRole\n team {\n items {\n role\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n }\n }\n ...ProjectPageTeamInternals_Project\n workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n }\n": types.ProjectPageInviteDialog_ProjectFragmentDoc,
"\n fragment ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunction on AutomationRevisionFunction {\n parameters\n release {\n id\n versionTag\n createdAt\n inputSchema\n function {\n id\n }\n }\n }\n": types.ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunctionFragmentDoc,
"\n fragment ProjectPageAutomationFunctionSettingsDialog_AutomationRevision on AutomationRevision {\n id\n triggerDefinitions {\n ... on VersionCreatedTriggerDefinition {\n type\n model {\n id\n ...CommonModelSelectorModel\n }\n }\n }\n }\n": types.ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFragmentDoc,
"\n fragment ProjectPageAutomationFunctions_Automation on Automation {\n id\n currentRevision {\n id\n ...ProjectPageAutomationFunctionSettingsDialog_AutomationRevision\n functions {\n release {\n id\n inputSchema\n function {\n id\n ...AutomationsFunctionsCard_AutomateFunction\n releases(limit: 1) {\n items {\n id\n }\n }\n }\n }\n ...ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunction\n }\n }\n }\n": types.ProjectPageAutomationFunctions_AutomationFragmentDoc,
@@ -80,7 +80,7 @@ const documents = {
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n }\n hasChildren\n updatedAt\n }\n": types.SingleLevelModelTreeItemFragmentDoc,
"\n fragment ProjectPageModelsCardDeleteDialog on Model {\n id\n name\n }\n": types.ProjectPageModelsCardDeleteDialogFragmentDoc,
"\n fragment ProjectPageModelsCardRenameDialog on Model {\n id\n name\n description\n }\n": types.ProjectPageModelsCardRenameDialogFragmentDoc,
"\n query ProjectPageSettingsCollaborators($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageTeamInternals_Project\n ...ProjectPageInviteDialog_Project\n }\n }\n": types.ProjectPageSettingsCollaboratorsDocument,
"\n query ProjectPageSettingsCollaborators($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageTeamInternals_Project\n ...InviteDialogProject_Project\n workspaceId\n }\n }\n": types.ProjectPageSettingsCollaboratorsDocument,
"\n query ProjectPageSettingsCollaboratorsWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n ...ProjectPageTeamInternals_Workspace\n }\n }\n": types.ProjectPageSettingsCollaboratorsWorkspaceDocument,
"\n query ProjectPageSettingsGeneral($projectId: String!) {\n project(id: $projectId) {\n id\n role\n ...ProjectPageSettingsGeneralBlockProjectInfo_Project\n ...ProjectPageSettingsGeneralBlockAccess_Project\n ...ProjectPageSettingsGeneralBlockDiscussions_Project\n ...ProjectPageSettingsGeneralBlockLeave_Project\n ...ProjectPageSettingsGeneralBlockDelete_Project\n ...ProjectPageTeamInternals_Project\n }\n }\n": types.ProjectPageSettingsGeneralDocument,
"\n fragment ProjectPageSettingsGeneralBlockAccess_Project on Project {\n id\n visibility\n }\n": types.ProjectPageSettingsGeneralBlockAccess_ProjectFragmentDoc,
@@ -93,7 +93,6 @@ const documents = {
"\n fragment ProjectsAddDialog_Workspace on Workspace {\n id\n ...ProjectsWorkspaceSelect_Workspace\n }\n": types.ProjectsAddDialog_WorkspaceFragmentDoc,
"\n fragment ProjectsAddDialog_User on User {\n workspaces {\n items {\n ...ProjectsAddDialog_Workspace\n }\n }\n }\n": types.ProjectsAddDialog_UserFragmentDoc,
"\n fragment ProjectsDashboard_UserProjectCollection on UserProjectCollection {\n numberOfHidden\n }\n": types.ProjectsDashboard_UserProjectCollectionFragmentDoc,
"\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n ": types.OnUserProjectsUpdateDocument,
"\n fragment ProjectsDashboardFilledProject on ProjectCollection {\n items {\n ...ProjectDashboardItem\n }\n }\n": types.ProjectsDashboardFilledProjectFragmentDoc,
"\n fragment ProjectsDashboardFilledUser on UserProjectCollection {\n items {\n ...ProjectDashboardItem\n }\n }\n": types.ProjectsDashboardFilledUserFragmentDoc,
"\n fragment ProjectsDashboardHeaderProjects_User on User {\n projectInvites {\n ...ProjectsInviteBanner\n }\n }\n": types.ProjectsDashboardHeaderProjects_UserFragmentDoc,
@@ -112,7 +111,6 @@ const documents = {
"\n fragment SettingsServerRegionsTable_ServerRegionItem on ServerRegionItem {\n id\n name\n key\n description\n }\n": types.SettingsServerRegionsTable_ServerRegionItemFragmentDoc,
"\n fragment SettingsSharedDeleteUserDialog_Workspace on Workspace {\n id\n plan {\n status\n name\n }\n subscription {\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n }\n": types.SettingsSharedDeleteUserDialog_WorkspaceFragmentDoc,
"\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n": types.SettingsSharedProjects_ProjectFragmentDoc,
"\n fragment SettingsUserEmailCards_UserEmail on UserEmail {\n email\n id\n primary\n verified\n }\n": types.SettingsUserEmailCards_UserEmailFragmentDoc,
"\n fragment SettingsUserProfileChangePassword_User on User {\n id\n email\n }\n": types.SettingsUserProfileChangePassword_UserFragmentDoc,
"\n fragment SettingsUserProfileDeleteAccount_User on User {\n id\n email\n }\n": types.SettingsUserProfileDeleteAccount_UserFragmentDoc,
"\n fragment SettingsUserProfileDetails_User on User {\n id\n name\n company\n ...UserProfileEditDialogAvatar_User\n }\n": types.SettingsUserProfileDetails_UserFragmentDoc,
@@ -139,7 +137,7 @@ const documents = {
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": types.ViewerModelVersionCardItemFragmentDoc,
"\n fragment MoveProjectsDialog_Workspace on Workspace {\n id\n ...ProjectsMoveToWorkspaceDialog_Workspace\n projects {\n items {\n id\n modelCount: models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n }\n }\n }\n": types.MoveProjectsDialog_WorkspaceFragmentDoc,
"\n fragment MoveProjectsDialog_User on User {\n projects {\n items {\n ...ProjectsMoveToWorkspaceDialog_Project\n role\n workspace {\n id\n }\n }\n }\n }\n": types.MoveProjectsDialog_UserFragmentDoc,
"\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...BillingAlert_Workspace\n ...WorkspaceMixpanelUpdateGroup_Workspace\n ...MoveProjectsDialog_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n readOnly\n }\n": types.WorkspaceProjectList_WorkspaceFragmentDoc,
"\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...BillingAlert_Workspace\n ...MoveProjectsDialog_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n readOnly\n }\n": types.WorkspaceProjectList_WorkspaceFragmentDoc,
"\n fragment WorkspaceProjectList_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...ProjectDashboardItem\n }\n cursor\n }\n": types.WorkspaceProjectList_ProjectCollectionFragmentDoc,
"\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...BillingAlert_Workspace\n slug\n readOnly\n }\n": types.WorkspaceHeader_WorkspaceFragmentDoc,
"\n fragment WorkspaceInviteBanner_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.WorkspaceInviteBanner_PendingWorkspaceCollaboratorFragmentDoc,
@@ -152,7 +150,7 @@ const documents = {
"\n fragment WorkspaceSidebar_Workspace on Workspace {\n ...WorkspaceDashboardAbout_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n slug\n plan {\n status\n }\n }\n": types.WorkspaceSidebar_WorkspaceFragmentDoc,
"\n fragment WorkspaceWizard_Workspace on Workspace {\n creationState {\n completed\n state\n }\n name\n slug\n }\n": types.WorkspaceWizard_WorkspaceFragmentDoc,
"\n fragment WorkspaceWizardStepRegion_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": types.WorkspaceWizardStepRegion_ServerInfoFragmentDoc,
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserMainMetadataDocument,
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n verified\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserMainMetadataDocument,
"\n mutation CreateOnboardingProject {\n projectMutations {\n createForOnboarding {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n ": types.CreateOnboardingProjectDocument,
"\n mutation FinishOnboarding {\n activeUserMutations {\n finishOnboarding\n }\n }\n": types.FinishOnboardingDocument,
"\n mutation RequestVerificationByEmail($email: String!) {\n requestVerificationByEmail(email: $email)\n }\n": types.RequestVerificationByEmailDocument,
@@ -204,6 +202,7 @@ const 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 query PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n": types.PagesOnboardingDiscoverableWorkspaces_ActiveUserDocument,
"\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,
@@ -290,9 +289,9 @@ const documents = {
"\n fragment AddDomainWorkspace on Workspace {\n slug\n }\n ": types.AddDomainWorkspaceFragmentDoc,
"\n fragment SettingsMenu_Workspace on Workspace {\n id\n sso {\n provider {\n id\n }\n session {\n validUntil\n }\n }\n }\n": types.SettingsMenu_WorkspaceFragmentDoc,
"\n mutation SettingsUpdateWorkspace($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n ...SettingsWorkspacesGeneral_Workspace\n }\n }\n }\n": types.SettingsUpdateWorkspaceDocument,
"\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsCreateUserEmailDocument,
"\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsDeleteUserEmailDocument,
"\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsSetPrimaryUserEmailDocument,
"\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n": types.SettingsCreateUserEmailDocument,
"\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n": types.SettingsDeleteUserEmailDocument,
"\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n": types.SettingsSetPrimaryUserEmailDocument,
"\n mutation SettingsNewEmailVerification($input: EmailVerificationRequestInput!) {\n activeUserMutations {\n emailMutations {\n requestNewEmailVerification(input: $input)\n }\n }\n }\n": types.SettingsNewEmailVerificationDocument,
"\n mutation SettingsUpdateWorkspaceSecurity($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n }\n }\n": types.SettingsUpdateWorkspaceSecurityDocument,
"\n mutation SettingsDeleteWorkspace($workspaceId: String!) {\n workspaceMutations {\n delete(workspaceId: $workspaceId)\n }\n }\n": types.SettingsDeleteWorkspaceDocument,
@@ -312,15 +311,18 @@ const documents = {
"\n query SettingsWorkspacesMembersSearch($slug: String!, $filter: WorkspaceTeamFilter) {\n workspaceBySlug(slug: $slug) {\n id\n team(filter: $filter) {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n }\n": types.SettingsWorkspacesMembersSearchDocument,
"\n query SettingsWorkspacesJoinRequestsSearch(\n $slug: String!\n $joinRequestsFilter: AdminWorkspaceJoinRequestFilter\n ) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesMembersRequestsTable_Workspace\n }\n }\n": types.SettingsWorkspacesJoinRequestsSearchDocument,
"\n query SettingsWorkspacesInvitesSearch(\n $slug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n": types.SettingsWorkspacesInvitesSearchDocument,
"\n query SettingsUserEmailsQuery {\n activeUser {\n ...SettingsUserEmails_User\n }\n }\n": types.SettingsUserEmailsQueryDocument,
"\n query SettingsWorkspacesProjects(\n $slug: String!\n $limit: Int!\n $cursor: String\n $filter: WorkspaceProjectsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n id\n slug\n readOnly\n projects(limit: $limit, cursor: $cursor, filter: $filter) {\n cursor\n ...SettingsWorkspacesProjects_ProjectCollection\n }\n }\n }\n": types.SettingsWorkspacesProjectsDocument,
"\n query SettingsWorkspaceSecurity($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesSecurity_Workspace\n }\n activeUser {\n ...SettingsWorkspacesSecurity_User\n }\n }\n": types.SettingsWorkspaceSecurityDocument,
"\n fragment AppAuthorAvatar on AppAuthor {\n id\n name\n avatar\n }\n": types.AppAuthorAvatarFragmentDoc,
"\n fragment LimitedUserAvatar on LimitedUser {\n id\n name\n avatar\n }\n": types.LimitedUserAvatarFragmentDoc,
"\n fragment ActiveUserAvatar on User {\n id\n name\n avatar\n }\n": types.ActiveUserAvatarFragmentDoc,
"\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n ": types.OnUserProjectsUpdateDocument,
"\n mutation UpdateUser($input: UserUpdateInput!) {\n activeUserMutations {\n update(user: $input) {\n id\n name\n bio\n company\n avatar\n }\n }\n }\n": types.UpdateUserDocument,
"\n mutation UpdateNotificationPreferences($input: JSONObject!) {\n userNotificationPreferencesUpdate(preferences: $input)\n }\n": types.UpdateNotificationPreferencesDocument,
"\n mutation DeleteAccount($input: UserDeleteInput!) {\n userDelete(userConfirmation: $input)\n }\n": types.DeleteAccountDocument,
"\n mutation verifyEmail($input: VerifyUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n verify(input: $input)\n }\n }\n }\n": types.VerifyEmailDocument,
"\n fragment EmailFields on UserEmail {\n id\n email\n verified\n primary\n userId\n }\n": types.EmailFieldsFragmentDoc,
"\n query UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n": types.UserEmailsDocument,
"\n fragment ViewerCommentBubblesData on Comment {\n id\n viewedAt\n viewerState\n }\n": types.ViewerCommentBubblesDataFragmentDoc,
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n }\n": types.ViewerCommentThreadFragmentDoc,
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": types.ViewerCommentsReplyItemFragmentDoc,
@@ -339,8 +341,6 @@ const documents = {
"\n subscription OnViewerCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n id\n type\n comment {\n id\n parent {\n id\n }\n ...ViewerCommentThread\n }\n }\n }\n": types.OnViewerCommentsUpdatedDocument,
"\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n": types.LinkableCommentFragmentDoc,
"\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n": types.UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n }\n": types.WorkspaceMixpanelUpdateGroup_WorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspaceMixpanelUpdateGroup_Workspace on Workspace {\n id\n name\n description\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n plan {\n status\n name\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n totalCount\n items {\n ...WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator\n }\n }\n defaultRegion {\n key\n }\n }\n": types.WorkspaceMixpanelUpdateGroup_WorkspaceFragmentDoc,
"\n subscription OnWorkspaceProjectsUpdate($slug: String!) {\n workspaceProjectsUpdated(workspaceId: null, workspaceSlug: $slug) {\n projectId\n workspaceId\n type\n project {\n ...ProjectDashboardItem\n }\n }\n }\n ": types.OnWorkspaceProjectsUpdateDocument,
"\n fragment WorkspaceHasCustomDataResidency_Workspace on Workspace {\n id\n defaultRegion {\n id\n name\n }\n }\n": types.WorkspaceHasCustomDataResidency_WorkspaceFragmentDoc,
"\n query CheckProjectWorkspaceDataResidency($projectId: String!) {\n project(id: $projectId) {\n id\n workspace {\n ...WorkspaceHasCustomDataResidency_Workspace\n }\n }\n }\n": types.CheckProjectWorkspaceDataResidencyDocument,
@@ -382,14 +382,13 @@ const documents = {
"\n fragment AutomateFunctionPage_AutomateFunction on AutomateFunction {\n id\n name\n description\n logo\n supportedSourceApps\n tags\n ...AutomateFunctionPageHeader_Function\n ...AutomateFunctionPageInfo_AutomateFunction\n ...AutomateAutomationCreateDialog_AutomateFunction\n creator {\n id\n }\n }\n": types.AutomateFunctionPage_AutomateFunctionFragmentDoc,
"\n query AutomateFunctionPage($functionId: ID!) {\n automateFunction(id: $functionId) {\n ...AutomateFunctionPage_AutomateFunction\n }\n activeUser {\n workspaces {\n items {\n ...AutomateFunctionCreateDialog_Workspace\n ...AutomateFunctionEditDialog_Workspace\n }\n }\n }\n }\n": types.AutomateFunctionPageDocument,
"\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n": types.AutomateFunctionPageWorkspaceDocument,
"\n query AutomateFunctionsPage($search: String, $cursor: String = null) {\n ...AutomateFunctionsPageItems_Query\n ...AutomateFunctionsPageHeader_Query\n }\n": types.AutomateFunctionsPageDocument,
"\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n": types.PagesOnboarding_DiscoverableWorkspacesFragmentDoc,
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n }\n": types.ProjectPageProjectFragmentDoc,
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": types.ProjectPageAutomationPage_AutomationFragmentDoc,
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": types.ProjectPageAutomationPage_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n role\n }\n": types.ProjectPageSettingsTab_ProjectFragmentDoc,
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsServerProjects_ProjectCollectionFragmentDoc,
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": types.SettingsServerRegionsDocument,
"\n fragment SettingsUserEmails_User on User {\n id\n emails {\n ...SettingsUserEmailCards_UserEmail\n }\n }\n": types.SettingsUserEmails_UserFragmentDoc,
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n name\n status\n createdAt\n paymentMethod\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n items {\n id\n role\n }\n }\n }\n": types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n defaultProjectRole\n plan {\n status\n name\n }\n }\n": types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n team {\n items {\n id\n }\n }\n invitedTeam(filter: $invitesFilter) {\n user {\n id\n }\n }\n adminWorkspacesJoinRequests(filter: $joinRequestsFilter) {\n totalCount\n }\n }\n": types.SettingsWorkspacesMembers_WorkspaceFragmentDoc,
@@ -425,14 +424,6 @@ export function graphql(source: "\n query AuthRegisterPanelWorkspaceInvite($tok
* 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 ServerTermsOfServicePrivacyPolicyFragment on ServerInfo {\n termsOfService\n }\n"): (typeof documents)["\n fragment ServerTermsOfServicePrivacyPolicyFragment on ServerInfo {\n termsOfService\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 EmailVerificationBannerState {\n activeUser {\n id\n email\n verified\n hasPendingVerification\n }\n }\n"): (typeof documents)["\n query EmailVerificationBannerState {\n activeUser {\n id\n email\n verified\n hasPendingVerification\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 RequestVerification {\n requestVerification\n }\n"): (typeof documents)["\n mutation RequestVerification {\n requestVerification\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -561,6 +552,18 @@ 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 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 documents)["\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"];
/**
* 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 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 documents)["\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"];
/**
* 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 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 documents)["\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"];
/**
* 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 InviteDialogProjectWorkspaceMembers_Project on Project {\n id\n ...ProjectPageTeamInternals_Project\n workspace {\n team {\n items {\n ...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator\n }\n }\n }\n }\n"): (typeof documents)["\n fragment InviteDialogProjectWorkspaceMembers_Project on Project {\n id\n ...ProjectPageTeamInternals_Project\n workspace {\n team {\n items {\n ...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -597,10 +600,6 @@ export function graphql(source: "\n fragment ProjectModelPageVersionsCardVersio
* 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 ProjectPageProjectHeader on Project {\n id\n role\n name\n description\n visibility\n allowPublicComments\n workspace {\n id\n slug\n name\n logo\n }\n }\n"): (typeof documents)["\n fragment ProjectPageProjectHeader on Project {\n id\n role\n name\n description\n visibility\n allowPublicComments\n workspace {\n id\n slug\n name\n logo\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 ProjectPageInviteDialog_Project on Project {\n id\n workspaceId\n workspace {\n id\n defaultProjectRole\n team {\n items {\n role\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n }\n }\n ...ProjectPageTeamInternals_Project\n workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageInviteDialog_Project on Project {\n id\n workspaceId\n workspace {\n id\n defaultProjectRole\n team {\n items {\n role\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n }\n }\n ...ProjectPageTeamInternals_Project\n workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -680,7 +679,7 @@ export function graphql(source: "\n fragment ProjectPageModelsCardRenameDialog
/**
* 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 ProjectPageSettingsCollaborators($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageTeamInternals_Project\n ...ProjectPageInviteDialog_Project\n }\n }\n"): (typeof documents)["\n query ProjectPageSettingsCollaborators($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageTeamInternals_Project\n ...ProjectPageInviteDialog_Project\n }\n }\n"];
export function graphql(source: "\n query ProjectPageSettingsCollaborators($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageTeamInternals_Project\n ...InviteDialogProject_Project\n workspaceId\n }\n }\n"): (typeof documents)["\n query ProjectPageSettingsCollaborators($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageTeamInternals_Project\n ...InviteDialogProject_Project\n workspaceId\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -729,10 +728,6 @@ export function graphql(source: "\n fragment ProjectsAddDialog_User on User {\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 ProjectsDashboard_UserProjectCollection on UserProjectCollection {\n numberOfHidden\n }\n"): (typeof documents)["\n fragment ProjectsDashboard_UserProjectCollection on UserProjectCollection {\n numberOfHidden\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 subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n "): (typeof documents)["\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n "];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -805,10 +800,6 @@ export function graphql(source: "\n fragment SettingsSharedDeleteUserDialog_Wor
* 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 SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\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 SettingsUserEmailCards_UserEmail on UserEmail {\n email\n id\n primary\n verified\n }\n"): (typeof documents)["\n fragment SettingsUserEmailCards_UserEmail on UserEmail {\n email\n id\n primary\n verified\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -916,7 +907,7 @@ export function graphql(source: "\n fragment MoveProjectsDialog_User on User {\
/**
* 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 WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...BillingAlert_Workspace\n ...WorkspaceMixpanelUpdateGroup_Workspace\n ...MoveProjectsDialog_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n readOnly\n }\n"): (typeof documents)["\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...BillingAlert_Workspace\n ...WorkspaceMixpanelUpdateGroup_Workspace\n ...MoveProjectsDialog_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n readOnly\n }\n"];
export function graphql(source: "\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...BillingAlert_Workspace\n ...MoveProjectsDialog_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n readOnly\n }\n"): (typeof documents)["\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...BillingAlert_Workspace\n ...MoveProjectsDialog_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n readOnly\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -968,7 +959,7 @@ export function graphql(source: "\n fragment WorkspaceWizardStepRegion_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 query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"];
export function graphql(source: "\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n verified\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n verified\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1173,6 +1164,10 @@ export function graphql(source: "\n mutation CreateNewRegion($input: CreateServ
* 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 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 documents)["\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"];
/**
* 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 PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n"): (typeof documents)["\n query PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1520,15 +1515,15 @@ export function graphql(source: "\n mutation SettingsUpdateWorkspace($input: Wo
/**
* 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 SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"];
export function graphql(source: "\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"];
export function graphql(source: "\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"];
export function graphql(source: "\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1605,10 +1600,6 @@ export function graphql(source: "\n query SettingsWorkspacesJoinRequestsSearch(
* 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 SettingsWorkspacesInvitesSearch(\n $slug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspacesInvitesSearch(\n $slug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesMembersInvitesTable_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 SettingsUserEmailsQuery {\n activeUser {\n ...SettingsUserEmails_User\n }\n }\n"): (typeof documents)["\n query SettingsUserEmailsQuery {\n activeUser {\n ...SettingsUserEmails_User\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1629,6 +1620,10 @@ export function graphql(source: "\n fragment LimitedUserAvatar on LimitedUser {
* 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 ActiveUserAvatar on User {\n id\n name\n avatar\n }\n"): (typeof documents)["\n fragment ActiveUserAvatar on User {\n id\n name\n avatar\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 subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n "): (typeof documents)["\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n "];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1641,6 +1636,18 @@ export function graphql(source: "\n mutation UpdateNotificationPreferences($inp
* 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 DeleteAccount($input: UserDeleteInput!) {\n userDelete(userConfirmation: $input)\n }\n"): (typeof documents)["\n mutation DeleteAccount($input: UserDeleteInput!) {\n userDelete(userConfirmation: $input)\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 verifyEmail($input: VerifyUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n verify(input: $input)\n }\n }\n }\n"): (typeof documents)["\n mutation verifyEmail($input: VerifyUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n verify(input: $input)\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 EmailFields on UserEmail {\n id\n email\n verified\n primary\n userId\n }\n"): (typeof documents)["\n fragment EmailFields on UserEmail {\n id\n email\n verified\n primary\n userId\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 UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n"): (typeof documents)["\n query UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1713,14 +1720,6 @@ export function graphql(source: "\n fragment LinkableComment on Comment {\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 UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n"): (typeof documents)["\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\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 fragment WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n }\n"): (typeof documents)["\n fragment WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\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 WorkspaceMixpanelUpdateGroup_Workspace on Workspace {\n id\n name\n description\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n plan {\n status\n name\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n totalCount\n items {\n ...WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator\n }\n }\n defaultRegion {\n key\n }\n }\n"): (typeof documents)["\n fragment WorkspaceMixpanelUpdateGroup_Workspace on Workspace {\n id\n name\n description\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n plan {\n status\n name\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n totalCount\n items {\n ...WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator\n }\n }\n defaultRegion {\n key\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1888,7 +1887,7 @@ export function graphql(source: "\n query AutomateFunctionPageWorkspace($worksp
/**
* 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 AutomateFunctionsPage($search: String, $cursor: String = null) {\n ...AutomateFunctionsPageItems_Query\n ...AutomateFunctionsPageHeader_Query\n }\n"): (typeof documents)["\n query AutomateFunctionsPage($search: String, $cursor: String = null) {\n ...AutomateFunctionsPageItems_Query\n ...AutomateFunctionsPageHeader_Query\n }\n"];
export function graphql(source: "\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n"): (typeof documents)["\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1913,10 +1912,6 @@ export function graphql(source: "\n fragment SettingsServerProjects_ProjectColl
* 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 SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n"): (typeof documents)["\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\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 SettingsUserEmails_User on User {\n id\n emails {\n ...SettingsUserEmailCards_UserEmail\n }\n }\n"): (typeof documents)["\n fragment SettingsUserEmails_User on User {\n id\n emails {\n ...SettingsUserEmailCards_UserEmail\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
@@ -13,6 +13,8 @@ export const registerRoute = '/authn/register'
export const ssoLoginRoute = '/authn/sso'
export const forgottenPasswordRoute = '/authn/forgotten-password'
export const onboardingRoute = '/onboarding'
export const verifyEmailRoute = '/verify-email'
export const verifyEmailCountdownRoute = '/verify-email?source=registration'
export const serverManagementRoute = '/server-management'
export const downloadManagerUrl = 'https://speckle.systems/download'
export const docsPageUrl = 'https://speckle.guide/'
@@ -1,4 +1,8 @@
import type { InviteServerItem, InviteGenericItem } from '~~/lib/invites/helpers/types'
import type {
InviteServerItem,
InviteGenericItem,
InviteProjectItem
} from '~~/lib/invites/helpers/types'
import { Roles } from '@speckle/shared'
export const emptyInviteServerItem: InviteServerItem = {
@@ -7,6 +11,13 @@ export const emptyInviteServerItem: InviteServerItem = {
project: undefined
}
export const emptyInviteProjectItem: InviteProjectItem = {
email: '',
serverRole: Roles.Server.User,
projectRole: Roles.Stream.Contributor,
project: undefined
}
export const emptyInviteGenericItem: InviteGenericItem = {
email: '',
workspaceRole: undefined,
@@ -1,6 +1,7 @@
import type { ServerRoles, WorkspaceRoles, StreamRoles } from '@speckle/shared'
import type { FormSelectProjects_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
// Server
export type InviteServerItem = {
email: string
serverRole: ServerRoles
@@ -11,6 +12,19 @@ export interface InviteServerForm {
fields: InviteServerItem[]
}
// Project
export type InviteProjectItem = {
email: string
serverRole: ServerRoles
projectRole?: StreamRoles
project?: FormSelectProjects_ProjectFragment
}
export interface InviteProjectForm {
fields: InviteProjectItem[]
}
// Workspace
export type InviteGenericItem = {
email: string
workspaceRole?: WorkspaceRoles
@@ -1,6 +1,12 @@
import { isEmail } from '~/lib/common/helpers/validation'
import type { GenericValidateFunction } from 'vee-validate'
import { Roles, type WorkspaceRoles, type MaybeNullOrUndefined } from '@speckle/shared'
import {
Roles,
type StreamRoles,
type WorkspaceRoles,
type ServerRoles,
type MaybeNullOrUndefined
} from '@speckle/shared'
export const isValidEmail = (val: string) =>
isEmail(val || '', {
@@ -23,16 +29,47 @@ export const canHaveRole =
(params: {
allowedDomains: MaybeNullOrUndefined<string[]>
workspaceRole?: WorkspaceRoles
projectRole?: StreamRoles
}): GenericValidateFunction<string> =>
(val) => {
const { allowedDomains, workspaceRole } = params
const { allowedDomains, workspaceRole, projectRole } = params
if (!allowedDomains || !val) return true
if (
!matchesDomainPolicy(val, allowedDomains) &&
workspaceRole !== Roles.Workspace.Guest
) {
return 'This email does not match the set domain policy, and can only be invited as a guest'
if (!matchesDomainPolicy(val, allowedDomains)) {
if (workspaceRole && workspaceRole !== Roles.Workspace.Guest) {
return 'This email does not match the set domain policy, and can only be invited as a guest'
}
if (projectRole && projectRole !== Roles.Stream.Reviewer) {
return 'This email does not match the set domain policy, and can only be invited as a reviewer'
}
}
return true
}
export const isRequiredIfDependencyExists =
(dependency: () => string) => (val?: string) =>
!dependency() || !!val || 'This field is required'
export const canBeServerGuest =
({
workspaceRole,
projectRole
}: {
workspaceRole?: WorkspaceRoles
projectRole?: StreamRoles
}) =>
(val?: ServerRoles) => {
if (val === Roles.Server.Guest) {
if (projectRole === Roles.Stream.Owner) {
return 'A guest user cannot be a stream owner'
}
if (workspaceRole === Roles.Workspace.Admin) {
return 'A guest user cannot be a workspace admin'
}
if (workspaceRole === Roles.Workspace.Member) {
return 'A guest user cannot be a workspace member'
}
}
return true
@@ -0,0 +1,10 @@
import { graphql } from '~/lib/common/generated/gql'
export const PagesOnboardingDiscoverableWorkspaces = graphql(`
query PagesOnboardingDiscoverableWorkspaces_ActiveUser {
activeUser {
id
...PagesOnboarding_DiscoverableWorkspaces
}
}
`)
@@ -0,0 +1,4 @@
export type OnboardingSelectOption = {
id: string
name: string
}
@@ -304,10 +304,7 @@ export function useInviteUserToProject() {
if (err && !hideToasts) {
triggerNotification({
type: ToastNotificationType.Danger,
title:
input.length > 1
? "Couldn't send invites"
: `Coudldn't send invite to ${input[0].email}`,
title: input.length > 1 ? "Couldn't send invites" : "Couldn't send invite",
description: err
})
} else {
@@ -315,9 +312,7 @@ export function useInviteUserToProject() {
triggerNotification({
type: ToastNotificationType.Success,
title:
input.length > 1
? 'Invites successfully send'
: `Invite successfully sent to ${input[0].email}`
input.length > 1 ? 'Invites successfully send' : 'Invite successfully sent'
})
}
}
@@ -22,5 +22,6 @@ export function sanitizeModelName(name: string): string {
return name
.split('/')
.map((part) => part.trim())
.filter((part) => part.length > 0)
.join('/')
}
@@ -15,7 +15,10 @@ export const settingsCreateUserEmailMutation = graphql(`
activeUserMutations {
emailMutations {
create(input: $input) {
...SettingsUserEmails_User
id
emails {
...EmailFields
}
}
}
}
@@ -27,7 +30,10 @@ export const settingsDeleteUserEmailMutation = graphql(`
activeUserMutations {
emailMutations {
delete(input: $input) {
...SettingsUserEmails_User
id
emails {
...EmailFields
}
}
}
}
@@ -39,7 +45,10 @@ export const settingsSetPrimaryUserEmailMutation = graphql(`
activeUserMutations {
emailMutations {
setPrimary(input: $input) {
...SettingsUserEmails_User
id
emails {
...EmailFields
}
}
}
}
@@ -106,14 +106,6 @@ export const settingsWorkspacesInvitesSearchQuery = graphql(`
}
`)
export const settingsUserEmailsQuery = graphql(`
query SettingsUserEmailsQuery {
activeUser {
...SettingsUserEmails_User
}
}
`)
export const settingsWorkspacesProjectsQuery = graphql(`
query SettingsWorkspacesProjects(
$slug: String!
@@ -0,0 +1,191 @@
import { useApolloClient, useMutation, useQuery } from '@vue/apollo-composable'
import {
settingsNewEmailVerificationMutation,
settingsDeleteUserEmailMutation,
settingsCreateUserEmailMutation
} from '~/lib/settings/graphql/mutations'
import { userEmailsQuery } from '~/lib/user/graphql/queries'
import {
convertThrowIntoFetchResult,
getFirstErrorMessage,
getCacheId,
modifyObjectField
} from '~/lib/common/helpers/graphql'
import type { UserEmail } from '~/lib/common/generated/gql/graphql'
import { useGlobalToast } from '~/lib/common/composables/toast'
import { useMixpanel } from '~/lib/core/composables/mp'
import {
verifyEmailRoute,
homeRoute,
settingsUserRoutes
} from '~/lib/common/helpers/route'
import { verifyEmailMutation } from '~/lib/user/graphql/mutations'
export function useUserEmails() {
const { triggerNotification } = useGlobalToast()
const mixpanel = useMixpanel()
const { result } = useQuery(userEmailsQuery)
const route = useRoute()
const apollo = useApolloClient().client
const { activeUser } = useActiveUser()
const { mutate: resendMutation } = useMutation(settingsNewEmailVerificationMutation)
const { mutate: deleteMutation } = useMutation(settingsDeleteUserEmailMutation)
const { mutate: createMutation } = useMutation(settingsCreateUserEmailMutation)
const { mutate: verifyMutation } = useMutation(verifyEmailMutation)
// Simple array of all emails
const emails = computed(() => result.value?.activeUser?.emails ?? ([] as UserEmail[]))
// Helper computed properties for common queries
const unverifiedPrimaryEmail = computed(
() => emails.value.find((e) => e.primary && !e.verified) || null
)
const unverifiedEmails = computed(() => emails.value.filter((e) => !e.verified))
const addUserEmail = async (email: string) => {
const result = await createMutation({
input: { email }
}).catch(convertThrowIntoFetchResult)
if (result?.data) {
mixpanel.track('Email Added')
navigateTo(verifyEmailRoute)
return true
}
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Error adding email',
description: errorMessage
})
return false
}
const resendVerificationEmail = async (email: UserEmail) => {
const result = await resendMutation({
input: { id: email.id }
}).catch(convertThrowIntoFetchResult)
if (result?.data) {
triggerNotification({
type: ToastNotificationType.Success,
title: `Verification email sent to ${email.email}`
})
navigateTo(verifyEmailRoute)
return true
}
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Error sending verification email',
description: errorMessage
})
return false
}
const deleteUserEmail = async (email: UserEmail, cancel = false) => {
const result = await deleteMutation({
input: { id: email.id }
}).catch(convertThrowIntoFetchResult)
if (result?.data) {
triggerNotification({
type: ToastNotificationType.Success,
title: `${cancel ? 'Cancelled adding email' : 'Deleted email'}`,
description: email.email
})
mixpanel.track('Email Deleted')
// If we're on the verify email page and there are no more unverified emails, redirect home
if (route.path === verifyEmailRoute && unverifiedEmails.value.length === 0) {
navigateTo(homeRoute)
}
return true
}
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Error deleting email',
description: errorMessage
})
return false
}
const verifyUserEmail = async (email: UserEmail, code: string) => {
mixpanel.track('Email Verification Started', {
email: email.email,
isPrimary: email.primary
})
const result = await verifyMutation({
input: { email: email.email, code }
}).catch(convertThrowIntoFetchResult)
const activeUserId = computed(() => activeUser.value?.id)
if (result?.data?.activeUserMutations?.emailMutations?.verify) {
if (!activeUserId.value) return
mixpanel.track('Email Verified', {
email: email.email,
isPrimary: email.primary
})
// Update UserEmail verified status in cache
modifyObjectField(
apollo.cache,
getCacheId('UserEmail', email.id),
'verified',
() => true
)
// Only update User verified status if this is the primary email
if (email.primary) {
modifyObjectField(
apollo.cache,
getCacheId('User', activeUserId.value),
'verified',
() => true
)
navigateTo(homeRoute)
} else {
navigateTo(settingsUserRoutes.emails)
}
triggerNotification({
type: ToastNotificationType.Success,
title: 'Email verified',
description: 'Your email has been successfully verified'
})
return true
}
mixpanel.track('Email Verification Failed', {
email: email.email,
isPrimary: email.primary
})
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Verification failed',
description: 'The verification code you entered is incorrect or expired'
})
return false
}
return {
emails,
unverifiedPrimaryEmail,
unverifiedEmails,
addUserEmail,
resendVerificationEmail,
deleteUserEmail,
verifyUserEmail
}
}
@@ -0,0 +1,76 @@
import { useApolloClient, useSubscription } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import { UserProjectsUpdatedMessageType } from '~/lib/common/generated/gql/graphql'
import { getCacheId, modifyObjectField } from '~/lib/common/helpers/graphql'
import { ToastNotificationType, useGlobalToast } from '~/lib/common/composables/toast'
import { projectRoute } from '~/lib/common/helpers/route'
export function useUserProjectsUpdatedTracking() {
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const { activeUser } = useActiveUser()
const { onResult: onUserProjectsUpdate } = useSubscription(
graphql(`
subscription OnUserProjectsUpdate {
userProjectsUpdated {
type
id
project {
...ProjectDashboardItem
}
}
}
`)
)
onUserProjectsUpdate((res) => {
const activeUserId = activeUser.value?.id
const event = res.data?.userProjectsUpdated
if (!event) return
if (!activeUserId) return
const isNewProject = event.type === UserProjectsUpdatedMessageType.Added
const incomingProject = event.project
const cache = apollo.cache
if (isNewProject && incomingProject) {
// Add to User.projects where possible
modifyObjectField(
cache,
getCacheId('User', activeUserId),
'projects',
({ helpers: { ref, createUpdatedValue } }) =>
createUpdatedValue(({ update }) => {
update('items', (items) => [
ref('Project', incomingProject.id),
...(items || [])
])
update('totalCount', (count) => count + 1)
}),
{ autoEvictFiltered: true }
)
}
if (!isNewProject) {
// Evict old project from cache entirely to remove it from all searches
cache.evict({
id: getCacheId('Project', event.id)
})
}
// Emit toast notification
triggerNotification({
type: ToastNotificationType.Info,
title: isNewProject ? 'New project added' : 'A project has been removed',
cta:
isNewProject && incomingProject
? {
url: projectRoute(incomingProject.id),
title: 'View project'
}
: undefined
})
})
}
@@ -25,3 +25,13 @@ export const deleteAccountMutation = graphql(`
userDelete(userConfirmation: $input)
}
`)
export const verifyEmailMutation = graphql(`
mutation verifyEmail($input: VerifyUserEmailInput!) {
activeUserMutations {
emailMutations {
verify(input: $input)
}
}
}
`)
@@ -0,0 +1,23 @@
import { graphql } from '~/lib/common/generated/gql'
export const emailFieldsFragment = graphql(`
fragment EmailFields on UserEmail {
id
email
verified
primary
userId
}
`)
export const userEmailsQuery = graphql(`
query UserEmails {
activeUser {
id
emails {
...EmailFields
}
hasPendingVerification
}
}
`)
@@ -277,6 +277,7 @@ export type InjectableViewerState = Readonly<{
lightConfig: Ref<SunLightConfiguration>
explodeFactor: Ref<number>
viewerBusy: WritableComputedRef<boolean>
loadProgress: Ref<number>
selection: Ref<Nullable<Vector3>>
measurement: {
enabled: Ref<boolean>
@@ -919,6 +920,8 @@ function setupInterfaceState(
set: (newVal) => (isViewerBusy.value = !!newVal)
})
const loadProgress = ref(0)
const isolatedObjectIds = ref([] as string[])
const hiddenObjectIds = ref([] as string[])
const selectedObjects = shallowRef<Raw<SpeckleObject>[]>([])
@@ -978,6 +981,7 @@ function setupInterfaceState(
explodeFactor,
spotlightUserSessionId,
viewerBusy,
loadProgress,
threads: {
items: commentThreads,
openThread: {
@@ -1,5 +1,7 @@
import { difference, flatten, isEqual, uniq } from 'lodash-es'
import { useThrottleFn, onKeyStroke, watchTriggerable } from '@vueuse/core'
import {
LoaderEvent,
ViewMode,
type PropertyInfo,
type StringPropertyInfo,
@@ -14,7 +16,8 @@ import {
SectionToolEvent,
SectionTool,
ViewModes,
ViewModeEvent
ViewModeEvent,
SpeckleLoader
} from '@speckle/viewer'
import { useAuthCookie } from '~~/lib/auth/composables/auth'
import type {
@@ -55,7 +58,6 @@ import {
useCameraUtilities,
useMeasurementUtilities
} from '~~/lib/viewer/composables/ui'
import { onKeyStroke, watchTriggerable } from '@vueuse/core'
import { setupDebugMode } from '~~/lib/viewer/composables/setup/dev'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
import { useMixpanel } from '~~/lib/core/composables/mp'
@@ -95,24 +97,55 @@ function useViewerObjectAutoLoading() {
resources: {
response: { resourceItems }
},
ui: { loadProgress },
urlHashState: { focusedThreadId }
} = useInjectedViewerState()
const loadingProgressMap: { [id: string]: number } = {}
viewer.on(ViewerEvent.LoadComplete, (id) => {
delete loadingProgressMap[id]
consolidateProgressInternal({ id, progress: 1 })
})
const consolidateProgressInternal = (args: { progress: number; id: string }) => {
loadingProgressMap[args.id] = args.progress
let min = 42
const values = Object.values(loadingProgressMap) as number[]
for (const num of values) {
min = Math.min(min, num)
}
loadProgress.value = min
}
const consolidateProgressThorttled = useThrottleFn(consolidateProgressInternal, 250)
const loadObject = (
objectId: string,
unload?: boolean,
options?: Partial<{ zoomToObject: boolean }>
) => {
const objectUrl = getObjectUrl(projectId.value, objectId)
if (unload) {
viewer.unloadObject(objectUrl)
} else {
viewer.loadObjectAsync(
const loader = new SpeckleLoader(
viewer.getWorldTree(),
objectUrl,
authToken.value || undefined,
disableViewerCache ? false : undefined,
options?.zoomToObject
undefined
)
loader.on(LoaderEvent.LoadProgress, (args) => consolidateProgressThorttled(args))
loader.on(LoaderEvent.LoadCancelled, (id) => {
delete loadingProgressMap[id]
consolidateProgressInternal({ id, progress: 1 })
})
viewer.loadObject(loader, options?.zoomToObject)
}
}
@@ -1,41 +0,0 @@
import { useConditionalViewerRendering } from '~/lib/viewer/composables/ui'
export const useTourStageState = () =>
useState('viewer-tour-state', () => ({
showNavbar: true,
showViewerControls: true,
showTour: false,
showSegmentation: true
}))
export function useViewerTour() {
const state = useTourStageState()
const conditionalRendering = useConditionalViewerRendering()
const showNavbar = computed({
get: () => conditionalRendering.showNavbar.value,
set: (newVal) => (state.value.showNavbar = newVal)
})
const showControls = computed({
get: () => conditionalRendering.showControls.value,
set: (newVal) => (state.value.showViewerControls = newVal)
})
const showTour = computed({
get: () => state.value.showTour,
set: (newVal) => (state.value.showTour = newVal)
})
const showSegmentation = computed({
get: () => state.value.showSegmentation,
set: (newVal) => (state.value.showSegmentation = newVal)
})
return {
showNavbar,
showControls,
showTour,
showSegmentation
}
}
@@ -18,7 +18,6 @@ import {
type InjectableViewerState
} from '~~/lib/viewer/composables/setup'
import { useDiffBuilderUtilities } from '~~/lib/viewer/composables/setup/diff'
import { useTourStageState } from '~~/lib/viewer/composables/tour'
import { Vector3, Box3 } from 'three'
import { getKeyboardShortcutTitle, onKeyboardShortcut } from '@speckle/ui-components'
import { ViewerShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
@@ -435,11 +434,9 @@ export function useMeasurementUtilities() {
* Some conditional rendering values depend on multiple & overlapping states. This utility reconciles that.
*/
export function useConditionalViewerRendering() {
const tourState = useTourStageState()
const embedMode = useEmbedState()
const showControls = computed(() => {
if (tourState.value.showTour && !tourState.value.showViewerControls) return false
if (
embedMode.embedOptions.value?.isEnabled &&
embedMode.embedOptions.value.hideControls
@@ -452,7 +449,6 @@ export function useConditionalViewerRendering() {
const showNavbar = computed(() => {
if (!showControls.value) return false
if (tourState.value.showTour && !tourState.value.showNavbar) return false
if (embedMode.embedOptions.value?.isEnabled) return false
return true
})
@@ -1,135 +0,0 @@
import { useMixpanel } from '~/lib/core/composables/mp'
import { graphql } from '~~/lib/common/generated/gql'
import {
type WorkspaceMixpanelUpdateGroup_WorkspaceFragment,
type WorkspaceMixpanelUpdateGroup_WorkspaceCollaboratorFragment,
type PaidWorkspacePlans,
BillingInterval
} from '~/lib/common/generated/gql/graphql'
import { type MaybeNullOrUndefined, Roles, type WorkspaceRoles } from '@speckle/shared'
import { resolveMixpanelServerId } from '@speckle/shared'
import { WorkspacePlanStatuses } from '~/lib/common/generated/gql/graphql'
import { isPaidPlan } from '@/lib/billing/helpers/types'
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
graphql(`
fragment WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator on WorkspaceCollaborator {
id
role
}
`)
graphql(`
fragment WorkspaceMixpanelUpdateGroup_Workspace on Workspace {
id
name
description
domainBasedMembershipProtectionEnabled
discoverabilityEnabled
plan {
status
name
createdAt
}
subscription {
billingInterval
currentBillingCycleEnd
seats {
guest
plan
}
}
team {
totalCount
items {
...WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator
}
}
defaultRegion {
key
}
}
`)
export const useWorkspacesMixpanel = () => {
const mixpanel = useMixpanel()
const isBillingIntegrationEnabled = useIsBillingIntegrationEnabled()
const workspaceMixpanelUpdateGroup = (
workspace: WorkspaceMixpanelUpdateGroup_WorkspaceFragment,
userEmail: MaybeNullOrUndefined<string>
) => {
if (!workspace.id || !import.meta.client) return
const getEstimatedBill = () => {
if (
!isBillingIntegrationEnabled.value ||
!isPaidPlan(workspace.plan?.name) ||
workspace.plan?.status !== WorkspacePlanStatuses.Valid ||
!workspace.subscription?.billingInterval
)
return null
const planConfig =
pricingPlansConfig.plans[workspace.plan.name as unknown as PaidWorkspacePlans]
const cost = planConfig.cost[workspace.subscription.billingInterval]
const memberPrice = cost[Roles.Workspace.Member]
const guestPrice = cost[Roles.Workspace.Guest]
const memberCount = workspace.subscription?.seats?.plan || 0
const guestCount = workspace.subscription?.seats?.guest || 0
const totalPrice = memberPrice * memberCount + guestPrice * guestCount
return workspace.subscription.billingInterval === BillingInterval.Yearly
? totalPrice * 12
: totalPrice
}
const roleCount = {
[Roles.Workspace.Admin]: 0,
[Roles.Workspace.Member]: 0,
[Roles.Workspace.Guest]: 0
}
workspace.team.items.forEach(
(item: WorkspaceMixpanelUpdateGroup_WorkspaceCollaboratorFragment) => {
roleCount[item.role as WorkspaceRoles] =
(roleCount[item.role as WorkspaceRoles] ?? 0) + 1
}
)
const input = {
name: workspace.name,
description: workspace.description,
domainBasedMembershipProtectionEnabled:
workspace.domainBasedMembershipProtectionEnabled,
discoverabilityEnabled: workspace.discoverabilityEnabled,
teamTotalCount: workspace.team.totalCount,
teamAdminCount: roleCount[Roles.Workspace.Admin],
teamMemberCount: roleCount[Roles.Workspace.Member],
teamGuestCount: roleCount[Roles.Workspace.Guest],
defaultRegionKey: workspace.defaultRegion?.key,
planName: workspace.plan?.name || '',
planStatus: workspace.plan?.status || '',
planCreatedAt: workspace.plan?.createdAt,
subscriptionBillingInterval: workspace.subscription?.billingInterval,
subscriptionCurrentBillingCycleEnd:
workspace.subscription?.currentBillingCycleEnd,
seats: workspace.subscription?.seats?.plan || 0,
seatsGuest: workspace.subscription?.seats?.guest || 0,
estimatedBill: getEstimatedBill(),
// eslint-disable-next-line camelcase
server_id: resolveMixpanelServerId(window.location.hostname)
}
mixpanel.get_group('workspace_id', workspace.id).set(input)
if (userEmail?.includes('speckle.systems')) {
mixpanel.get_group('workspace_id', workspace.id).set_once({
hasSpeckleMembers: true
})
}
}
return {
workspaceMixpanelUpdateGroup
}
}
@@ -0,0 +1,36 @@
import { homeRoute, verifyEmailRoute } from '~/lib/common/helpers/route'
import { activeUserQuery } from '~~/lib/auth/composables/activeUser'
import { useApolloClientFromNuxt } from '~~/lib/common/composables/graphql'
import { convertThrowIntoFetchResult } from '~~/lib/common/helpers/graphql'
/**
* Redirect user to /verify-email, if they haven't done it yet
*/
export default defineNuxtRouteMiddleware(async (to) => {
const client = useApolloClientFromNuxt()
const { data } = await client
.query({
query: activeUserQuery
})
.catch(convertThrowIntoFetchResult)
if (!data?.activeUser?.id) return
const isAuthPage = to.path.startsWith('/authn/')
const isVerifyEmailPage = to.path === verifyEmailRoute
if (isAuthPage) return
const hasUnverifiedEmails = data.activeUser.emails.some((email) => !email.verified)
if (hasUnverifiedEmails) {
// Redirect to verify email if not already there
if (!isVerifyEmailPage) {
return navigateTo(verifyEmailRoute)
}
} else {
if (isVerifyEmailPage) {
return navigateTo(homeRoute)
}
}
})
@@ -17,6 +17,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
// Ignore if not logged in
if (!data?.activeUser?.id) return
// Ignore if user has not verified their email yet
if (!data?.activeUser?.verified) return
const isOnboardingFinished = data?.activeUser?.isOnboardingFinished
const isGoingToOnboarding = to.path === onboardingRoute
const shouldRedirectToOnboarding =
+7 -1
View File
@@ -158,6 +158,12 @@ export default defineNuxtConfig({
'Access-Control-Expose-Headers': '*'
}
},
'/functions': {
redirect: {
to: '/',
statusCode: 307
}
},
// Redirect old settings pages
'/server-management/projects': {
redirect: {
@@ -207,7 +213,7 @@ export default defineNuxtConfig({
'/settings/server/*': {
appMiddleware: ['auth', 'settings', 'admin']
},
'/settings/workspaces/*': {
'/settings/workspaces/:slug/*': {
appMiddleware: [
'auth',
'settings',
+3 -1
View File
@@ -1,5 +1,7 @@
<template>
<NuxtPage />
<div>
<NuxtPage />
</div>
</template>
<script setup lang="ts">
import { loginRoute } from '~~/lib/common/helpers/route'
@@ -50,7 +50,7 @@
</DisclosureButton>
<DisclosurePanel
class="flex flex-col px-2 py-5 space-y-5 label-light border-b border-outline-3 label-light"
class="flex flex-col px-2 py-5 space-y-5 label-light border-b border-outline-3"
>
<table v-if="app.author || app.description?.length" class="table-fixed">
<tbody>
@@ -31,7 +31,7 @@
:disabled="resendVerificationEmailLoading"
@click="onResend"
>
Resend verification
Verify email
</FormButton>
</div>
</div>
@@ -1,112 +0,0 @@
<template>
<div>
<Portal to="navigation">
<HeaderNavLink
:to="publicAutomateFunctionsRoute"
name="Functions"
:separator="false"
/>
</Portal>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2 mb-2">
<IconBolt class="h-5 w-5" />
<h1 class="text-heading-lg">Functions</h1>
</div>
<AutomateFunctionsPageHeader
v-model:search="search"
:active-user="result?.activeUser"
:server-info="result?.serverInfo"
class="mb-6"
/>
</div>
<AutomateFunctionsPageItems
:functions="finalResult"
:search="!!search"
:loading="paginationLoading"
@create-automation-from="openCreateNewAutomation"
@clear-search="search = ''"
/>
<CommonLoadingBar :loading="pageQueryLoading" client-only class="mb-2" />
<InfiniteLoading :settings="{ identifier }" @infinite="onInfiniteLoad" />
<AutomateAutomationCreateDialog
v-model:open="showNewAutomationDialog"
:preselected-function="newAutomationTargetFn"
/>
</div>
</template>
<script setup lang="ts">
import { CommonLoadingBar } from '@speckle/ui-components'
import { useQuery } from '@vue/apollo-composable'
import { automateFunctionsPagePaginationQuery } from '~/lib/automate/graphql/queries'
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
import {
usePageQueryStandardFetchPolicy,
usePaginatedQuery
} from '~/lib/common/composables/graphql'
import { graphql } from '~/lib/common/generated/gql'
import { publicAutomateFunctionsRoute } from '~/lib/common/helpers/route'
definePageMeta({
middleware: ['auth', 'requires-automate-enabled']
})
const pageQuery = graphql(`
query AutomateFunctionsPage($search: String, $cursor: String = null) {
...AutomateFunctionsPageItems_Query
...AutomateFunctionsPageHeader_Query
}
`)
const search = ref('')
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
const { result, loading: pageQueryLoading } = useQuery(
pageQuery,
() => ({
search: search.value?.length ? search.value : null
}),
() => ({
fetchPolicy: pageFetchPolicy.value
})
)
const {
identifier,
onInfiniteLoad,
query: { result: paginatedResult, loading: paginationLoading }
} = usePaginatedQuery({
query: automateFunctionsPagePaginationQuery,
baseVariables: computed(() => ({
search: search.value?.length ? search.value : null
})),
resolveKey: (vars) => [vars.search || ''],
resolveCurrentResult: (res) => res?.automateFunctions,
resolveInitialResult: () => result.value?.automateFunctions,
resolveNextPageVariables: (baseVars, cursor) => ({
...baseVars,
cursor
}),
resolveCursorFromVariables: (vars) => vars.cursor,
options: () => ({
fetchPolicy: pageFetchPolicy.value
})
})
const showNewAutomationDialog = ref(false)
const newAutomationTargetFn = ref<CreateAutomationSelectableFunction>()
const finalResult = computed(
() =>
paginatedResult.value?.automateFunctions.items ||
result.value?.automateFunctions.items
)
const openCreateNewAutomation = (fn: CreateAutomationSelectableFunction) => {
newAutomationTargetFn.value = fn
showNewAutomationDialog.value = true
}
useSeoMeta({
title: 'All functions',
description: 'Select a function get started with Speckle Automate'
})
</script>
+2 -182
View File
@@ -1,192 +1,12 @@
<template>
<div>
<Portal to="navigation">
<HeaderNavLink :to="homeRoute" name="Dashboard" hide-chevron :separator="false" />
</Portal>
<PromoBannersWrapper
v-if="promoBanners && promoBanners.length"
:banners="promoBanners"
/>
<ProjectsDashboardHeader
:projects-invites="projectsResult?.activeUser || undefined"
:workspaces-invites="workspacesResult?.activeUser || undefined"
/>
<div class="flex flex-col gap-y-12">
<div class="flex flex-col-reverse lg:flex-col gap-y-12">
<section>
<h2 class="text-heading-sm text-foreground-2">Quickstart</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 pt-5">
<CommonCard
v-for="quickStartItem in quickStartItems"
:key="quickStartItem.title"
:title="quickStartItem.title"
:description="quickStartItem.description"
:buttons="quickStartItem.buttons"
/>
</div>
</section>
<section>
<div class="flex items-center justify-between">
<h2 class="text-heading-sm text-foreground-2">Recently updated projects</h2>
<FormButton
color="outline"
size="sm"
@click.stop="router.push(projectsRoute)"
>
View all
</FormButton>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 pt-5">
<template v-if="hasProjects">
<DashboardProjectCard
v-for="project in projects"
:key="project.id"
:project="project"
/>
</template>
<CommonCard
v-else
title="Create your first project"
description="Projects are the place where your models and their versions live."
:buttons="createProjectButton"
/>
</div>
</section>
</div>
<section>
<div class="flex items-center justify-between">
<h2 class="text-heading-sm text-foreground-2">Tutorials</h2>
<FormButton
color="outline"
size="sm"
to="https://www.speckle.systems/tutorials"
external
target="_blank"
>
View all
</FormButton>
</div>
<div
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 pt-5"
>
<DashboardTutorialCard
v-for="tutorialItem in tutorialItems"
:key="tutorialItem.title"
:tutorial-item="tutorialItem"
/>
</div>
</section>
</div>
<ProjectsAddDialog v-model:open="openNewProject" />
</div>
<DashboardPage />
</template>
<script setup lang="ts">
import {
dashboardProjectsPageQuery,
dashboardProjectsPageWorkspacesQuery
} from '~~/lib/dashboard/graphql/queries'
import type { QuickStartItem } from '~~/lib/dashboard/helpers/types'
import { useQuery } from '@vue/apollo-composable'
import { useMixpanel } from '~~/lib/core/composables/mp'
import {
docsPageUrl,
forumPageUrl,
homeRoute,
projectsRoute
} from '~~/lib/common/helpers/route'
import type { ManagerExtension } from '~~/lib/common/utils/downloadManager'
import { downloadManager } from '~~/lib/common/utils/downloadManager'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import type { LayoutDialogButton } from '@speckle/ui-components'
import type { PromoBanner } from '~/lib/promo-banners/types'
import { tutorials } from '~/lib/dashboard/helpers/tutorials'
<script setup lang="ts">
useHead({ title: 'Dashboard' })
definePageMeta({
middleware: ['auth'],
alias: ['/profile', '/dashboard']
})
const mixpanel = useMixpanel()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { result: projectsResult } = useQuery(dashboardProjectsPageQuery)
const { result: workspacesResult } = useQuery(
dashboardProjectsPageWorkspacesQuery,
undefined,
() => ({
enabled: isWorkspacesEnabled.value
})
)
const { triggerNotification } = useGlobalToast()
const { isGuest } = useActiveUser()
const router = useRouter()
const openNewProject = ref(false)
const tutorialItems = shallowRef(tutorials)
const quickStartItems = shallowRef<QuickStartItem[]>([
{
title: 'Install Speckle manager',
description: 'Use our Manager to install and manage Connectors with ease.',
buttons: [
{
text: 'Download for Windows',
onClick: () => onDownloadManager('exe')
},
{
text: 'Download for Mac',
onClick: () => onDownloadManager('dmg')
}
]
},
{
title: "Don't know where to start?",
description: "We'll walk you through some of most common usage scenarios.",
buttons: [
{
text: 'Open documentation',
props: { to: docsPageUrl }
}
]
},
{
title: 'Have a question you need answered?',
description: 'Submit your question on the forum and get help from the community.',
buttons: [
{
text: 'Ask a question',
props: { to: forumPageUrl }
}
]
}
])
const createProjectButton = shallowRef<LayoutDialogButton[]>([
{
text: 'Create a project',
props: { disabled: isGuest.value },
onClick: () => (openNewProject.value = true)
}
])
const projects = computed(() => projectsResult.value?.activeUser?.projects.items)
const hasProjects = computed(() => (projects.value ? projects.value.length > 0 : false))
const onDownloadManager = (extension: ManagerExtension) => {
try {
downloadManager(extension)
mixpanel.track('Manager Download', {
os: extension === 'exe' ? 'win' : 'mac'
})
} catch {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Download failed'
})
}
}
const promoBanners = ref<PromoBanner[]>()
</script>

Some files were not shown because too many files have changed in this diff Show More