feat: accept & decline workspace invite as a registered member (#2675)

* abstract base invite banner

* WIP banner actions

* WIP modify obj

* minor fix

* invite accept/decline cache mutations

* banner accept/decline basically works

* new block for accepting workspace invite

* WIP wrong account flow

* login/registration block changes

* add email invite related changes

* add new email FE

* add email w/ invite works

* final adjustments

* minor fixes

* addressing cr comments

* no-FF support

* extra workspace ff checks
This commit is contained in:
Kristaps Fabians Geikins
2024-08-19 13:01:25 +03:00
committed by GitHub
parent 585ba6a102
commit 2bb7802fb9
61 changed files with 1736 additions and 435 deletions
@@ -6,7 +6,7 @@
class="mx-auto w-full"
>
<div class="space-y-4">
<div class="flex flex-col items-center gap-y-2 pb-4">
<div v-if="!workspaceInvite" class="flex flex-col items-center gap-y-2 pb-4">
<h1 class="text-heading-xl text-center inline-block">
{{ title }}
</h1>
@@ -14,11 +14,13 @@
{{ subtitle }}
</h2>
</div>
<AuthWorkspaceInviteHeader v-else :invite="workspaceInvite" />
<AuthThirdPartyLoginBlock
v-if="hasThirdPartyStrategies && serverInfo"
:server-info="serverInfo"
:challenge="challenge"
:app-id="appId"
:newsletter-consent="false"
/>
<div>
<div
@@ -27,8 +29,12 @@
>
{{ hasThirdPartyStrategies ? 'Or login with your email' : '' }}
</div>
<AuthLoginWithEmailBlock v-if="hasLocalStrategy" :challenge="challenge" />
<div class="text-center text-body-sm">
<AuthLoginWithEmailBlock
v-if="hasLocalStrategy"
:challenge="challenge"
:workspace-invite="result?.workspaceInvite || undefined"
/>
<div v-if="!forcedInviteEmail" class="text-center text-body-sm">
<span class="mr-2">Don't have an account?</span>
<CommonTextLink :to="finalRegisterRoute" :icon-right="ArrowRightIcon">
Register
@@ -43,10 +49,10 @@
import { useQuery } from '@vue/apollo-composable'
import { AuthStrategy } from '~~/lib/auth/helpers/strategies'
import { useLoginOrRegisterUtils, useAuthManager } from '~~/lib/auth/composables/auth'
import { loginServerInfoQuery } from '~~/lib/auth/graphql/queries'
import { LayoutDialog } from '@speckle/ui-components'
import { ArrowRightIcon } from '@heroicons/vue/20/solid'
import { registerRoute } from '~~/lib/common/helpers/route'
import { authLoginPanelQuery } from '~/lib/auth/graphql/queries'
const props = withDefaults(
defineProps<{
@@ -61,9 +67,13 @@ const props = withDefaults(
}
)
const { appId, challenge } = useLoginOrRegisterUtils()
const { isLoggedIn } = useActiveUser()
const { inviteToken } = useAuthManager()
const router = useRouter()
const { result } = useQuery(authLoginPanelQuery, () => ({
token: inviteToken.value
}))
const finalRegisterRoute = computed(() => {
const result = router.resolve({
@@ -77,8 +87,8 @@ const concreteComponent = computed(() => {
return props.dialogMode ? LayoutDialog : 'div'
})
const { result } = useQuery(loginServerInfoQuery)
const { appId, challenge } = useLoginOrRegisterUtils()
const workspaceInvite = computed(() => result.value?.workspaceInvite)
const forcedInviteEmail = computed(() => workspaceInvite.value?.email)
const serverInfo = computed(() => result.value?.serverInfo)
const hasLocalStrategy = computed(() =>
@@ -10,7 +10,7 @@
color="foundation"
:rules="emailRules"
show-label
:disabled="loading"
:disabled="!!(loading || shouldForceInviteEmail)"
auto-focus
/>
<FormTextInput
@@ -49,14 +49,27 @@ import { ensureError } from '@speckle/shared'
import { useAuthManager } from '~~/lib/auth/composables/auth'
import { forgottenPasswordRoute } from '~~/lib/common/helpers/route'
import { useMounted } from '@vueuse/core'
import { graphql } from '~/lib/common/generated/gql'
import type { AuthLoginWithEmailBlock_PendingWorkspaceCollaboratorFragment } from '~/lib/common/generated/gql/graphql'
type FormValues = { email: string; password: string }
graphql(`
fragment AuthLoginWithEmailBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {
id
email
user {
id
}
}
`)
const props = defineProps<{
challenge: string
workspaceInvite?: AuthLoginWithEmailBlock_PendingWorkspaceCollaboratorFragment
}>()
const { handleSubmit } = useForm<FormValues>()
const { handleSubmit, setValues } = useForm<FormValues>()
const loading = ref(false)
const emailRules = [isEmail]
@@ -66,6 +79,12 @@ const isMounted = useMounted()
const { loginWithEmail } = useAuthManager()
const { triggerNotification } = useGlobalToast()
const inviteEmail = computed(() => props.workspaceInvite?.email)
const isInviteForExistingUser = computed(() => !!props.workspaceInvite?.user)
const shouldForceInviteEmail = computed(
() => !!(inviteEmail.value && isInviteForExistingUser.value)
)
const onSubmit = handleSubmit(async ({ email, password }) => {
try {
loading.value = true
@@ -80,4 +99,14 @@ const onSubmit = handleSubmit(async ({ email, password }) => {
loading.value = false
}
})
watch(
shouldForceInviteEmail,
(shouldForce) => {
if (shouldForce) {
setValues({ email: inviteEmail.value || '' })
}
},
{ immediate: true }
)
</script>
@@ -1,7 +1,7 @@
<template>
<div class="--mx-auto w-full">
<div class="space-y-6">
<div class="flex flex-col items-center gap-y-2">
<div v-if="!workspaceInvite" class="flex flex-col items-center gap-y-2">
<h1 class="text-heading-xl text-center inline-block">
Create your Speckle account
</h1>
@@ -9,6 +9,7 @@
Connectivity, Collaboration and Automation for 3D
</h2>
</div>
<AuthWorkspaceInviteHeader v-else :invite="workspaceInvite" />
<template v-if="isInviteOnly && !inviteToken">
<div class="flex space-x-2 items-center">
<ExclamationTriangleIcon class="h-8 w-8 text-warning" />
@@ -17,7 +18,7 @@
follow the instructions in it.
</div>
</div>
<div class="flex space-x-2 items-center justify-center">
<div v-if="!inviteEmail" class="flex space-x-2 items-center justify-center">
<span>Already have an account?</span>
<CommonTextLink :to="loginRoute">Log in</CommonTextLink>
</div>
@@ -28,6 +29,7 @@
:server-info="serverInfo"
:challenge="challenge"
:app-id="appId"
:newsletter-consent="newsletterConsent"
/>
<div>
<div
@@ -38,6 +40,7 @@
</div>
<AuthRegisterWithEmailBlock
v-if="serverInfo && hasLocalStrategy"
v-model:newsletter-consent="newsletterConsent"
:challenge="challenge"
:server-info="serverInfo"
:invite-email="inviteEmail"
@@ -51,41 +54,39 @@
import { useQuery } from '@vue/apollo-composable'
import { AuthStrategy } from '~~/lib/auth/helpers/strategies'
import { useLoginOrRegisterUtils } from '~~/lib/auth/composables/auth'
import { loginServerInfoQuery } from '~~/lib/auth/graphql/queries'
import { graphql } from '~~/lib/common/generated/gql'
import { ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
import { loginRoute } from '~~/lib/common/helpers/route'
graphql(`
fragment AuthRegisterPanelServerInfo on ServerInfo {
inviteOnly
}
`)
const serverInviteQuery = graphql(`
query RegisterPanelServerInvite($token: String!) {
const registerPanelQuery = graphql(`
query AuthRegisterPanel($token: String) {
serverInfo {
inviteOnly
authStrategies {
id
}
...AuthStategiesServerInfoFragment
...ServerTermsOfServicePrivacyPolicyFragment
}
serverInviteByToken(token: $token) {
id
email
}
workspaceInvite(token: $token) {
id
...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator
}
}
`)
const { appId, challenge, inviteToken } = useLoginOrRegisterUtils()
const { result } = useQuery(registerPanelQuery, () => ({
token: inviteToken.value
}))
const newsletterConsent = ref(false)
provide('newsletterconsent', newsletterConsent)
const { result } = useQuery(loginServerInfoQuery)
const { appId, challenge, inviteToken } = useLoginOrRegisterUtils()
const { result: inviteMetadata } = useQuery(
serverInviteQuery,
() => ({ token: inviteToken.value || '' }),
{
enabled: computed(() => !!inviteToken.value?.length)
}
)
const inviteEmail = computed(() => inviteMetadata.value?.serverInviteByToken?.email)
const inviteEmail = computed(() => result.value?.serverInviteByToken?.email)
const serverInfo = computed(() => result.value?.serverInfo)
const hasLocalStrategy = computed(() =>
(serverInfo.value?.authStrategies || []).some((s) => s.id === AuthStrategy.Local)
@@ -96,4 +97,5 @@ const hasThirdPartyStrategies = computed(() =>
)
const isInviteOnly = computed(() => !!serverInfo.value?.inviteOnly)
const workspaceInvite = computed(() => result.value?.workspaceInvite)
</script>
@@ -2,18 +2,6 @@
<template>
<form method="post" @submit="onSubmit">
<div class="flex flex-col space-y-2">
<FormTextInput
type="text"
name="name"
label="Full name"
placeholder="My name"
size="lg"
:rules="nameRules"
color="foundation"
show-label
:disabled="loading"
auto-focus
/>
<FormTextInput
v-model="email"
type="email"
@@ -26,6 +14,18 @@
show-label
:disabled="isEmailDisabled"
/>
<FormTextInput
type="text"
name="name"
label="Full name"
placeholder="My name"
size="lg"
:rules="nameRules"
color="foundation"
show-label
:disabled="loading"
auto-focus
/>
<FormTextInput
v-model="password"
type="password"
@@ -66,7 +66,7 @@
class="mt-2 text-body-2xs text-foreground-2 text-center terms-of-service"
v-html="serverInfo.termsOfService"
/>
<div class="mt-2 sm:mt-4 text-center text-body-sm">
<div v-if="!inviteEmail" class="mt-2 sm:mt-4 text-center text-body-sm">
<span class="mr-2">Already have an account?</span>
<CommonTextLink :to="finalLoginRoute" :icon-right="ArrowRightIcon">
Log in
@@ -111,8 +111,9 @@ const { handleSubmit } = useForm<FormValues>()
const router = useRouter()
const { signUpWithEmail, inviteToken } = useAuthManager()
const { triggerNotification } = useGlobalToast()
const isMounted = useMounted()
const newsletterConsent = defineModel<boolean>('newsletterConsent', { required: true })
const loading = ref(false)
const password = ref('')
const email = ref('')
@@ -120,8 +121,6 @@ const email = ref('')
const emailRules = [isEmail]
const nameRules = [isRequired]
const newsletterConsent = inject<Ref<boolean>>('newsletterconsent')
const isEmailDisabled = computed(() => !!props.inviteEmail?.length || loading.value)
const finalLoginRoute = computed(() => {
@@ -140,7 +139,7 @@ const onSubmit = handleSubmit(async (fullUser) => {
user,
challenge: props.challenge,
inviteToken: inviteToken.value,
newsletter: newsletterConsent?.value
newsletter: newsletterConsent.value
})
} catch (e) {
triggerNotification({
@@ -0,0 +1,42 @@
<template>
<div class="space-y-8 mb-8">
<h1 class="text-heading-xl text-center">Join workspace</h1>
<div class="p-4 border border-outline-2 rounded text-body-xs">
You're accepting an invitation to join
<span class="font-semibold">{{ invite.workspaceName }}</span>
<template v-if="invite.user">
as
<div class="inline-flex items-center">
<UserAvatar :user="invite.user" size="sm" class="mr-1" />
<span class="font-semibold">{{ invite.user.name }}</span>
</div>
.
</template>
<template v-else>
using the
<span class="font-semibold">{{ invite.email }}</span>
email address.
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { graphql } from '~/lib/common/generated/gql'
import type { AuthWorkspaceInviteHeader_PendingWorkspaceCollaboratorFragment } from '~/lib/common/generated/gql/graphql'
graphql(`
fragment AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {
id
workspaceName
email
user {
id
...LimitedUserAvatar
}
}
`)
defineProps<{
invite: AuthWorkspaceInviteHeader_PendingWorkspaceCollaboratorFragment
}>()
</script>
@@ -6,6 +6,7 @@
v-for="strat in thirdPartyStrategies"
:key="strat.id"
to="javascript:;"
:server-info="serverInfo"
@click="() => onClick(strat)"
/>
</div>
@@ -35,6 +36,7 @@ graphql(`
name
url
}
...AuthThirdPartyLoginButtonOIDC_ServerInfo
}
`)
@@ -42,14 +44,13 @@ const props = defineProps<{
serverInfo: AuthStategiesServerInfoFragmentFragment
challenge: string
appId: string
newsletterConsent: boolean
}>()
const apiOrigin = useApiOrigin()
const mixpanel = useMixpanel()
const { inviteToken } = useAuthManager()
const newsletterConsent = inject<Ref<boolean>>('newsletterconsent')
const NuxtLink = resolveComponent('NuxtLink')
const GoogleButton = resolveComponent('AuthThirdPartyLoginButtonGoogle')
const MicrosoftButton = resolveComponent('AuthThirdPartyLoginButtonMicrosoft')
@@ -69,7 +70,7 @@ const buildAuthUrl = (strat: StrategyType) => {
url.searchParams.set('token', inviteToken.value)
}
if (newsletterConsent?.value) {
if (props.newsletterConsent) {
url.searchParams.set('newsletter', 'true')
}
@@ -6,15 +6,24 @@
</template>
<script setup lang="ts">
import { IdentificationIcon } from '@heroicons/vue/24/outline'
import { useQuery } from '@vue/apollo-composable'
import { loginServerInfoQuery } from '~~/lib/auth/graphql/queries'
import { graphql } from '~/lib/common/generated/gql'
import type { AuthThirdPartyLoginButtonOidc_ServerInfoFragment } from '~/lib/common/generated/gql/graphql'
defineProps<{
graphql(`
fragment AuthThirdPartyLoginButtonOIDC_ServerInfo on ServerInfo {
authStrategies {
id
name
}
}
`)
const props = defineProps<{
to: string
serverInfo: AuthThirdPartyLoginButtonOidc_ServerInfoFragment
}>()
const { result } = useQuery(loginServerInfoQuery)
const authStrategies = computed(() => result.value?.serverInfo.authStrategies)
const authStrategies = computed(() => props.serverInfo.authStrategies)
const oidcName = computed(() => {
const oidcStrategy = authStrategies.value?.find((strategy) => strategy.id === 'oidc')
@@ -1,9 +1,7 @@
<template>
<div class="flex flex-col items-center space-y-8">
<ErrorPageProjectAccessErrorBlock
v-if="isNoProjectAccessError"
:error="finalError"
/>
<ErrorPageProjectAccessErrorBlock v-if="isNoProjectAccessError" />
<ErrorPageWorkspaceAccessErrorBlock v-else-if="isNoWorkspaceAccessError" />
<ErrorPageGenericErrorBlock v-else :error="finalError" />
</div>
</template>
@@ -24,4 +22,9 @@ const isNoProjectAccessError = computed(
finalError.value.statusCode === 403 &&
finalError.value.message.includes('You do not have access to this project')
)
const isNoWorkspaceAccessError = computed(
() =>
finalError.value.statusCode === 403 &&
finalError.value.message.includes('You do not have access to this workspace')
)
</script>
@@ -0,0 +1,31 @@
<template>
<NuxtErrorBoundary @error="onError">
<WorkspaceInviteBlock v-if="invite" :invite="invite" />
<ErrorPageGenericUnauthorizedBlock v-else resource-type="workspace" />
</NuxtErrorBoundary>
</template>
<script setup lang="ts">
import { type Optional } from '@speckle/shared'
import { useQuery } from '@vue/apollo-composable'
import { workspaceInviteQuery } from '~/lib/workspaces/graphql/queries'
const route = useRoute()
const logger = useLogger()
const token = computed(() => route.query.token as Optional<string>)
const workspaceId = computed(() => route.params.id as Optional<string>)
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { result } = useQuery(
workspaceInviteQuery,
() => ({
workspaceId: workspaceId.value,
token: token.value
}),
() => ({ enabled: !!(workspaceId.value && isWorkspacesEnabled.value) })
)
const invite = computed(() => result.value?.workspaceInvite)
const onError = (err: unknown) => logger.error(err)
</script>
@@ -0,0 +1,130 @@
<template>
<div :class="mainClasses">
<div :class="mainInfoBlockClasses">
<UserAvatar :user="invite.invitedBy" :size="avatarSize" />
<div class="text-foreground">
<slot name="message" />
</div>
</div>
<div class="flex space-x-2 w-full sm:w-auto shrink-0">
<div v-if="isLoggedIn" class="flex items-center justify-end w-full space-x-2">
<FormButton
:size="buttonSize"
color="subtle"
text
:full-width="block"
:disabled="loading"
@click="$emit('processed', false, token)"
>
Decline
</FormButton>
<FormButton
:full-width="block"
:size="buttonSize"
color="outline"
class="px-4"
:icon-left="CheckIcon"
:disabled="loading"
@click="$emit('processed', true, token)"
>
Accept
</FormButton>
</div>
<template v-else>
<FormButton
:size="buttonSize"
color="outline"
full-width
:disabled="loading"
@click.stop.prevent="onLoginSignupClick"
>
{{ isForRegisteredUser ? 'Log in' : 'Sign up' }}
</FormButton>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import type { MaybeNullOrUndefined, Optional } from '@speckle/shared'
import type { AvatarUserType } from '~/lib/user/composables/avatar'
import { CheckIcon } from '@heroicons/vue/24/solid'
import { usePostAuthRedirect } from '~/lib/auth/composables/postAuthRedirect'
import {
useNavigateToLogin,
useNavigateToRegistration
} from '~/lib/common/helpers/route'
defineEmits<{
processed: [accept: boolean, token: Optional<string>]
}>()
type GenericInviteItem = {
invitedBy: AvatarUserType
user?: MaybeNullOrUndefined<{
id: string
}>
token?: MaybeNullOrUndefined<string>
}
const props = defineProps<{
invite: GenericInviteItem
/**
* Render this as a big block, instead of a small row. Used in full-page project access error pages.
*/
block?: boolean
loading?: boolean
}>()
const route = useRoute()
const { isLoggedIn } = useActiveUser()
const postAuthRedirect = usePostAuthRedirect()
const goToLogin = useNavigateToLogin()
const goToSignUp = useNavigateToRegistration()
const token = computed(
() => props.invite?.token || (route.query.token as Optional<string>)
)
const mainClasses = computed(() => {
const classParts = ['flex flex-col space-y-4 px-4 py-5 transition ']
if (props.block) {
classParts.push('')
} else {
classParts.push('hover:bg-primary-muted')
classParts.push('sm:space-y-0 sm:space-x-2 sm:items-center sm:flex-row sm:py-2')
}
return classParts.join(' ')
})
const mainInfoBlockClasses = computed(() => {
const classParts = ['flex grow items-center']
if (props.block) {
classParts.push('flex-col space-y-2')
} else {
classParts.push('flex-row space-x-2 text-body-xs')
}
return classParts.join(' ')
})
const avatarSize = computed(() => (props.block ? 'xxl' : 'base'))
const buttonSize = computed(() => (props.block ? 'lg' : 'sm'))
const isForRegisteredUser = computed(() => !!props.invite.user?.id)
const onLoginSignupClick = async () => {
postAuthRedirect.setCurrentRoute()
const query = {
token: token.value || undefined
}
if (isForRegisteredUser.value) {
await goToLogin({
query
})
} else {
await goToSignUp({ query })
}
}
</script>
@@ -50,6 +50,7 @@ import type { ProjectUpdateInput } from '~~/lib/common/generated/gql/graphql'
import { useUpdateProject } from '~~/lib/projects/composables/projectManagement'
import { graphql } from '~~/lib/common/generated/gql'
import { useTeamInternals } from '~/lib/projects/composables/team'
import { skipLoggingErrorsIfOneFieldError } from '~/lib/common/helpers/graphql'
const projectPageSettingsGeneralQuery = graphql(`
query ProjectPageSettingsGeneral($projectId: String!) {
@@ -81,9 +82,7 @@ const { result: pageResult } = useQuery(
// doesn't kill the entire query
errorPolicy: 'all',
context: {
skipLoggingErrors: (err) =>
err.graphQLErrors?.length === 1 &&
err.graphQLErrors.some((e) => !!e.path?.includes('invitedTeam'))
skipLoggingErrors: skipLoggingErrorsIfOneFieldError('invitedTeam')
}
})
)
@@ -1,23 +1,10 @@
<template>
<div>
<Portal to="primary-actions"></Portal>
<div
class="w-[calc(100vw-8px)] ml-[calc(50%-50vw+4px)] mr-[calc(50%-50vw+4px)] -mt-6 mb-10 rounded-b-xl bg-foundation transition shadow-md hover:shadow-xl divide-y divide-outline-3"
>
<div v-if="showChecklist">
<OnboardingChecklistV1 show-intro />
</div>
<ProjectsInviteBanners
v-if="projectsPanelResult?.activeUser?.projectInvites?.length"
:invites="projectsPanelResult?.activeUser"
/>
<ProjectsNewSpeckleBanner
v-if="showNewSpeckleBanner"
@dismissed="onDismissNewSpeckleBanner"
/>
</div>
<PromoBannersWrapper v-if="promoBanners.length" :banners="promoBanners" />
<ProjectsDashboardHeader
:user="projectsPanelResult?.activeUser || undefined"
class="mb-10"
/>
<div v-if="!showEmptyState" class="flex flex-col gap-4">
<div class="flex items-center gap-2 mb-2">
@@ -87,8 +74,6 @@ import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables
import { projectRoute } from '~~/lib/common/helpers/route'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import type { Nullable, Optional, StreamRoles } from '@speckle/shared'
import { useSynchronizedCookie } from '~~/lib/common/composables/reactiveCookie'
import type { PromoBanner } from '~/lib/promo-banners/types'
import { useDebouncedTextInput, type InfiniteLoaderState } from '@speckle/ui-components'
import { MagnifyingGlassIcon, Squares2X2Icon } from '@heroicons/vue/24/outline'
@@ -104,17 +89,6 @@ const { triggerNotification } = useGlobalToast()
const areQueriesLoading = useQueryLoading()
const apollo = useApolloClient().client
const promoBanners = ref<PromoBanner[]>([
{
id: 'speckleverse',
primaryText: 'Join our online hackathon!',
secondaryText: 'June 7 - 9, 2024',
url: 'https://beyond-the-speckleverse.devpost.com/',
priority: 1,
expiryDate: '2024-06-10'
}
])
const {
on,
bind,
@@ -268,58 +242,4 @@ const clearSearch = () => {
search.value = ''
selectedRoles.value = []
}
const hasCompletedChecklistV1 = useSynchronizedCookie<boolean>(
`hasCompletedChecklistV1`,
{
default: () => false
}
)
const hasDismissedChecklistTime = useSynchronizedCookie<string | undefined>(
`hasDismissedChecklistTime`,
{ default: () => undefined }
)
const hasDismissedChecklistForever = useSynchronizedCookie<boolean | undefined>(
`hasDismissedChecklistForever`,
{
default: () => false
}
)
const hasDismissedChecklistTimeAgo = computed(() => {
return (
new Date().getTime() -
new Date(hasDismissedChecklistTime.value || Date.now()).getTime()
)
})
const hasDismissedNewSpeckleBanner = useSynchronizedCookie<boolean | undefined>(
`hasDismissedNewSpeckleBanner`,
{ default: () => false }
)
const showChecklist = computed(() => {
if (hasDismissedChecklistForever.value) return false
if (hasCompletedChecklistV1.value) return false
if (hasDismissedChecklistTime.value === undefined) return true
if (
hasDismissedChecklistTime.value !== undefined &&
hasDismissedChecklistTimeAgo.value > 86400000
)
return true
return false
})
const showNewSpeckleBanner = computed(() => {
if (hasDismissedNewSpeckleBanner.value) return false
if (projectsPanelResult?.value?.activeUser?.projectInvites.length) return false
return true
})
const onDismissNewSpeckleBanner = () => {
hasDismissedNewSpeckleBanner.value = true
}
</script>
@@ -0,0 +1,101 @@
<template>
<div>
<div
class="w-[calc(100vw-8px)] ml-[calc(50%-50vw+4px)] mr-[calc(50%-50vw+4px)] -mt-6 bg-foundation divide-y divide-outline-3 border-b border-outline-3"
>
<div v-if="showChecklist">
<OnboardingChecklistV1 show-intro />
</div>
<ProjectsInviteBanners v-if="user?.projectInvites?.length" :invites="user" />
<WorkspaceInviteBanners v-if="user?.workspaceInvites?.length" :invites="user" />
<ProjectsNewSpeckleBanner
v-if="showNewSpeckleBanner"
@dismissed="onDismissNewSpeckleBanner"
/>
</div>
<PromoBannersWrapper v-if="promoBanners.length" :banners="promoBanners" />
</div>
</template>
<script setup lang="ts">
import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie'
import { graphql } from '~/lib/common/generated/gql'
import type { ProjectsDashboardHeader_UserFragment } from '~/lib/common/generated/gql/graphql'
import type { PromoBanner } from '~/lib/promo-banners/types'
graphql(`
fragment ProjectsDashboardHeader_User on User {
...ProjectsInviteBanners
...WorkspaceInviteBanners_User
}
`)
const props = defineProps<{
user?: ProjectsDashboardHeader_UserFragment
}>()
const promoBanners = ref<PromoBanner[]>([
{
id: 'speckleverse',
primaryText: 'Join our online hackathon!',
secondaryText: 'June 7 - 9, 2024',
url: 'https://beyond-the-speckleverse.devpost.com/',
priority: 1,
expiryDate: '2024-06-10'
}
])
const hasCompletedChecklistV1 = useSynchronizedCookie<boolean>(
`hasCompletedChecklistV1`,
{
default: () => false
}
)
const hasDismissedChecklistTime = useSynchronizedCookie<string | undefined>(
`hasDismissedChecklistTime`,
{ default: () => undefined }
)
const hasDismissedChecklistForever = useSynchronizedCookie<boolean | undefined>(
`hasDismissedChecklistForever`,
{
default: () => false
}
)
const hasDismissedChecklistTimeAgo = computed(() => {
return (
new Date().getTime() -
new Date(hasDismissedChecklistTime.value || Date.now()).getTime()
)
})
const hasDismissedNewSpeckleBanner = useSynchronizedCookie<boolean | undefined>(
`hasDismissedNewSpeckleBanner`,
{ default: () => false }
)
const showChecklist = computed(() => {
if (hasDismissedChecklistForever.value) return false
if (hasCompletedChecklistV1.value) return false
if (hasDismissedChecklistTime.value === undefined) return true
if (
hasDismissedChecklistTime.value !== undefined &&
hasDismissedChecklistTimeAgo.value > 86400000
)
return true
return false
})
const showNewSpeckleBanner = computed(() => {
if (hasDismissedNewSpeckleBanner.value) return false
if (props.user?.projectInvites.length || props.user?.workspaceInvites?.length)
return false
return true
})
const onDismissNewSpeckleBanner = () => {
hasDismissedNewSpeckleBanner.value = true
}
</script>
@@ -1,61 +1,25 @@
<template>
<div v-if="invite" :class="mainClasses">
<div :class="mainInfoBlockClasses">
<UserAvatar :user="invite.invitedBy" :size="avatarSize" />
<div class="text-foreground">
<span class="font-medium">{{ invite.invitedBy.name }}</span>
has invited you to be part of the team from
<template v-if="showProjectName">
the project {{ invite.projectName }}.
</template>
<template v-else>this project.</template>
</div>
</div>
<div class="flex space-x-2 w-full sm:w-auto shrink-0">
<div v-if="isLoggedIn" class="flex items-center justify-end w-full space-x-2">
<FormButton
:size="buttonSize"
color="danger"
text
:full-width="block"
@click="processInvite(false)"
>
Decline
</FormButton>
<FormButton
:full-width="block"
:size="buttonSize"
class="px-4"
:icon-left="CheckIcon"
@click="processInvite(true)"
>
Accept
</FormButton>
</div>
<template v-else>
<FormButton
:size="buttonSize"
full-width
@click.stop.prevent="onLoginSignupClick"
>
{{ isForRegisteredUser ? 'Log in' : 'Sign up' }}
</FormButton>
<InviteBanner
:invite="invite"
:block="block"
:disabled="loading"
@processed="processInvite"
>
<template #message>
<span class="font-medium">{{ invite.invitedBy.name }}</span>
has invited you to be part of the team in
<template v-if="showProjectName">
the project
<span class="font-medium">{{ invite.projectName }}</span>
</template>
</div>
</div>
<div v-else class="hidden" />
<template v-else>this project</template>
</template>
</InviteBanner>
</template>
<script setup lang="ts">
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { graphql } from '~~/lib/common/generated/gql'
import type { ProjectsInviteBannerFragment } from '~~/lib/common/generated/gql/graphql'
import {
useNavigateToLogin,
useNavigateToRegistration
} from '~~/lib/common/helpers/route'
import { usePostAuthRedirect } from '~~/lib/auth/composables/postAuthRedirect'
import type { Optional } from '@speckle/shared'
import { CheckIcon } from '@heroicons/vue/24/solid'
import { useProjectInviteManager } from '~/lib/projects/composables/invites'
graphql(`
@@ -79,7 +43,7 @@ const emit = defineEmits<{
const props = withDefaults(
defineProps<{
invite?: ProjectsInviteBannerFragment
invite: ProjectsInviteBannerFragment
showProjectName?: boolean
/**
* Render this as a big block, instead of a small row. Used in full-page project access error pages.
@@ -89,72 +53,21 @@ const props = withDefaults(
{ showProjectName: true }
)
const route = useRoute()
const { isLoggedIn } = useActiveUser()
const { useInvite } = useProjectInviteManager()
const postAuthRedirect = usePostAuthRedirect()
const goToLogin = useNavigateToLogin()
const goToSignUp = useNavigateToRegistration()
const loading = ref(false)
const token = computed(
() => props.invite?.token || (route.query.token as Optional<string>)
)
const isForRegisteredUser = computed(() => !!props.invite?.user?.id)
const mainClasses = computed(() => {
const classParts = ['flex flex-col space-y-4 px-4 py-5 transition ']
if (props.block) {
classParts.push('')
} else {
classParts.push('hover:bg-primary-muted')
classParts.push('sm:space-y-0 sm:space-x-2 sm:items-center sm:flex-row sm:py-2')
}
return classParts.join(' ')
})
const mainInfoBlockClasses = computed(() => {
const classParts = ['flex grow items-center']
if (props.block) {
classParts.push('flex-col space-y-2')
} else {
classParts.push('flex-row space-x-2 text-sm')
}
return classParts.join(' ')
})
const buttonSize = computed(() => (props.block ? 'lg' : 'base'))
const avatarSize = computed(() => (props.block ? 'xxl' : 'base'))
const processInvite = async (accept: boolean) => {
if (!token.value || !props.invite) return
const processInvite = async (accept: boolean, token: Optional<string>) => {
if (!token) return
loading.value = true
const success = await useInvite({
projectId: props.invite.projectId,
accept,
token: token.value,
token,
inviteId: props.invite.id
})
loading.value = false
if (!success) return
emit('processed', { accepted: accept })
}
const onLoginSignupClick = async () => {
postAuthRedirect.setCurrentRoute()
const query = {
token: token.value || undefined
}
if (isForRegisteredUser.value) {
await goToLogin({
query
})
} else {
await goToSignUp({ query })
}
}
</script>
@@ -1,13 +1,7 @@
<template>
<!-- Breakout div from main container -->
<div class="flex flex-col">
<ProjectsInviteBanner
v-for="item in items"
:key="item.id"
:invite="item"
@processed="$emit('processed', $event)"
/>
<div class="flex flex-col divide-y divide-outline-3">
<ProjectsInviteBanner v-for="item in items" :key="item.id" :invite="item" />
</div>
</template>
<script setup lang="ts">
@@ -22,10 +16,6 @@ graphql(`
}
`)
defineEmits<{
(e: 'processed', val: { accepted: boolean }): void
}>()
const props = defineProps<{
invites: ProjectsInviteBannersFragment
}>()
@@ -35,7 +35,6 @@
<FormSelectWorkspaceRoles
:model-value="item.role as WorkspaceRoles"
fully-control-value
:disabled="!isCurrentUser(item.id)"
@update:model-value="
(newRoleValue) => openChangeUserRoleDialog(item, newRoleValue)
"
@@ -57,7 +56,6 @@
// Todo: Enable searching once supported
import type { WorkspaceRoles } from '@speckle/shared'
import { workspaceUpdateRoleMutation } from '~~/lib/workspaces/graphql/mutations'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { useMutation } from '@vue/apollo-composable'
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
import {
@@ -101,7 +99,6 @@ const props = defineProps<{
const { triggerNotification } = useGlobalToast()
// const { on, bind, value: search } = useDebouncedTextInput()
const { activeUser } = useActiveUser()
const { mutate: updateChangeRole } = useMutation(workspaceUpdateRoleMutation)
const showChangeUserRoleDialog = ref(false)
@@ -116,7 +113,6 @@ const members = computed(() =>
)
const oldRole = computed(() => userToModify.value?.role as WorkspaceRoles)
const isCurrentUser = (id: string) => id === activeUser.value?.id
const openChangeUserRoleDialog = (
user: UserItem,
@@ -0,0 +1,63 @@
<template>
<InviteBanner :invite="invite" :disabled="loading" @processed="processInvite">
<template #message>
<span class="font-medium">{{ invite.invitedBy.name }}</span>
has invited you to join
<template v-if="showWorkspaceName">
the workspace
<span class="font-medium">{{ invite.workspaceName }}</span>
</template>
<template v-else>this workspace</template>
</template>
</InviteBanner>
</template>
<script setup lang="ts">
import type { Optional } from '@speckle/shared'
import type { WorkspaceInviteBanner_PendingWorkspaceCollaboratorFragment } from '~/lib/common/generated/gql/graphql'
import { useWorkspaceInviteManager } from '~/lib/workspaces/composables/management'
import { graphql } from '~~/lib/common/generated/gql'
graphql(`
fragment WorkspaceInviteBanner_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {
id
invitedBy {
id
...LimitedUserAvatar
}
workspaceId
workspaceName
token
user {
id
}
...UseWorkspaceInviteManager_PendingWorkspaceCollaborator
}
`)
const props = withDefaults(
defineProps<{
invite: WorkspaceInviteBanner_PendingWorkspaceCollaboratorFragment
showWorkspaceName?: boolean
}>(),
{ showWorkspaceName: true }
)
const { loading, accept, decline } = useWorkspaceInviteManager(
{
invite: computed(() => props.invite)
},
{
preventRedirect: true
}
)
const processInvite = async (shouldAccept: boolean, token: Optional<string>) => {
if (!token) return
if (shouldAccept) {
await accept()
} else {
await decline()
}
}
</script>
@@ -0,0 +1,27 @@
<template>
<div class="flex flex-col divide-y divide-outline-3">
<WorkspaceInviteBanner v-for="item in items" :key="item.id" :invite="item" />
</div>
</template>
<script setup lang="ts">
import { graphql } from '~~/lib/common/generated/gql'
import type { WorkspaceInviteBanners_UserFragment } from '~~/lib/common/generated/gql/graphql'
/**
* TODO: Add this to new dashboard page and remove from projects dashboard
*/
graphql(`
fragment WorkspaceInviteBanners_User on User {
workspaceInvites {
...WorkspaceInviteBanner_PendingWorkspaceCollaborator
}
}
`)
const props = defineProps<{
invites: WorkspaceInviteBanners_UserFragment
}>()
const items = computed(() => props.invites.workspaceInvites || [])
</script>
@@ -0,0 +1,154 @@
<template>
<div class="space-y-8 max-w-96">
<h1 class="text-heading-xl text-center">Join workspace</h1>
<div class="p-4 border border-outline-2 rounded text-body-xs">
You're accepting an invitation to join
<span class="font-semibold">{{ invite.workspaceName }}</span>
<template v-if="isCurrentUserTarget">.</template>
<template v-else>
<template v-if="targetUser">
however the invitation was sent to
<span class="inline-flex items-center font-semibold">
<UserAvatar :user="targetUser" size="sm" class="mr-1" />
{{ targetUser.name }}
</span>
. You have to sign out from the current account to proceed.
</template>
<template v-else>
using the
<span class="font-semibold">{{ invite.email }}</span>
email address.
</template>
</template>
</div>
<div class="flex flex-col space-y-4">
<template v-if="isCurrentUserTarget">
<FormButton
color="primary"
size="lg"
full-width
:disabled="loading"
@click="() => accept()"
>
Accept
</FormButton>
<FormButton
color="outline"
size="lg"
full-width
:disabled="loading"
@click="() => decline()"
>
Decline
</FormButton>
</template>
<template v-else>
<template v-if="targetUser">
<FormButton
color="primary"
size="lg"
full-width
:disabled="loading"
@click="signOutGoToLogin"
>
Sign out to continue
</FormButton>
</template>
<template v-else>
<FormButton
color="outline"
size="lg"
full-width
:disabled="loading"
@click="signOutGoToRegister"
>
Create new account
</FormButton>
<FormButton
color="primary"
size="lg"
full-width
:disabled="loading"
@click="acceptAndAddEmail"
>
Add new email to existing account
</FormButton>
</template>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { RelativeURL } from '@speckle/shared'
import { useAuthManager } from '~/lib/auth/composables/auth'
import { usePostAuthRedirect } from '~/lib/auth/composables/postAuthRedirect'
import { graphql } from '~/lib/common/generated/gql'
import type { WorkspaceInviteBlock_PendingWorkspaceCollaboratorFragment } from '~/lib/common/generated/gql/graphql'
import {
useNavigateToLogin,
useNavigateToRegistration
} from '~/lib/common/helpers/route'
import { useWorkspaceInviteManager } from '~/lib/workspaces/composables/management'
graphql(`
fragment WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {
id
workspaceId
workspaceName
token
user {
id
name
...LimitedUserAvatar
}
title
email
...UseWorkspaceInviteManager_PendingWorkspaceCollaborator
}
`)
const props = defineProps<{
invite: WorkspaceInviteBlock_PendingWorkspaceCollaboratorFragment
}>()
const route = useRoute()
const postAuthRedirect = usePostAuthRedirect()
const { logout } = useAuthManager()
const goToLogin = useNavigateToLogin()
const goToRegister = useNavigateToRegistration()
const { loading, accept, decline, token, isCurrentUserTarget, targetUser } =
useWorkspaceInviteManager({
invite: computed(() => props.invite)
})
const signOutGoToLogin = async () => {
await logout({ skipRedirect: true })
postAuthRedirect.setCurrentRoute(true)
await goToLogin({
query: {
token: token.value
}
})
}
/**
* Go to register and set post-auth redirect to accepting the invite
* and adding the target email to the account
*/
const signOutGoToRegister = async () => {
const currentRoute = new RelativeURL(route.fullPath)
currentRoute.searchParams.set('addNewEmail', 'true')
currentRoute.searchParams.set('token', token.value || '')
currentRoute.searchParams.set('accept', 'true')
await logout({ skipRedirect: true })
postAuthRedirect.set(currentRoute.toString(), true)
await goToRegister({
query: {
token: token.value
}
})
}
const acceptAndAddEmail = () => accept({ addNewEmail: true })
</script>
@@ -318,6 +318,7 @@ export const useAuthManager = () => {
const logout = async (
options?: Partial<{
skipToast: boolean
skipRedirect: boolean
}>
) => {
await saveNewToken(undefined, { skipRedirect: true })
@@ -331,7 +332,7 @@ export const useAuthManager = () => {
}
postAuthRedirect.deleteState()
goToLogin()
if (!options?.skipRedirect) goToLogin()
}
return {
@@ -1,11 +1,18 @@
import { graphql } from '~~/lib/common/generated/gql'
export const loginServerInfoQuery = graphql(`
query AuthServerInfo {
export const authLoginPanelQuery = graphql(`
query AuthLoginPanel($token: String) {
workspaceInvite(token: $token) {
id
email
...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator
...AuthLoginWithEmailBlock_PendingWorkspaceCollaborator
}
serverInfo {
authStrategies {
id
}
...AuthStategiesServerInfoFragment
...ServerTermsOfServicePrivacyPolicyFragment
...AuthRegisterPanelServerInfo
}
}
`)
@@ -13,7 +13,8 @@ export function useUserSearch(params: { variables: Ref<UserSearchQueryVariables>
const {
result,
variables: usedVariables,
refetch
refetch,
loading
} = useQuery(userSearchQuery, variables, () => ({
debounce: 300,
enabled: (variables.value.query || '').length >= 3
@@ -22,6 +23,7 @@ export function useUserSearch(params: { variables: Ref<UserSearchQueryVariables>
return {
userSearch: result,
searchVariables: usedVariables,
refetch
refetch,
loading
}
}
@@ -13,12 +13,14 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
* Therefore it is highly recommended to use the babel or swc plugin for production.
*/
const documents = {
"\n fragment AuthRegisterPanelServerInfo on ServerInfo {\n inviteOnly\n }\n": types.AuthRegisterPanelServerInfoFragmentDoc,
"\n query RegisterPanelServerInvite($token: String!) {\n serverInviteByToken(token: $token) {\n id\n email\n }\n }\n": types.RegisterPanelServerInviteDocument,
"\n fragment AuthLoginWithEmailBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n email\n user {\n id\n }\n }\n": types.AuthLoginWithEmailBlock_PendingWorkspaceCollaboratorFragmentDoc,
"\n query AuthRegisterPanel($token: String) {\n serverInfo {\n inviteOnly\n authStrategies {\n id\n }\n ...AuthStategiesServerInfoFragment\n ...ServerTermsOfServicePrivacyPolicyFragment\n }\n serverInviteByToken(token: $token) {\n id\n email\n }\n workspaceInvite(token: $token) {\n id\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n }\n }\n": types.AuthRegisterPanelDocument,
"\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 AuthStategiesServerInfoFragment on ServerInfo {\n authStrategies {\n id\n name\n url\n }\n }\n": types.AuthStategiesServerInfoFragmentFragmentDoc,
"\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 AuthStategiesServerInfoFragment on ServerInfo {\n authStrategies {\n id\n name\n url\n }\n ...AuthThirdPartyLoginButtonOIDC_ServerInfo\n }\n": types.AuthStategiesServerInfoFragmentFragmentDoc,
"\n fragment AuthThirdPartyLoginButtonOIDC_ServerInfo on ServerInfo {\n authStrategies {\n id\n name\n }\n }\n": types.AuthThirdPartyLoginButtonOidc_ServerInfoFragmentDoc,
"\n fragment AutomateAutomationCreateDialog_AutomateFunction on AutomateFunction {\n id\n ...AutomationsFunctionsCard_AutomateFunction\n ...AutomateAutomationCreateDialogFunctionParametersStep_AutomateFunction\n }\n": types.AutomateAutomationCreateDialog_AutomateFunctionFragmentDoc,
"\n fragment AutomateAutomationCreateDialogFunctionParametersStep_AutomateFunction on AutomateFunction {\n id\n releases(limit: 1) {\n items {\n id\n inputSchema\n }\n }\n }\n": types.AutomateAutomationCreateDialogFunctionParametersStep_AutomateFunctionFragmentDoc,
"\n query AutomationCreateDialogFunctionsSearch($search: String, $cursor: String = null) {\n automateFunctions(limit: 20, filter: { search: $search }, cursor: $cursor) {\n cursor\n totalCount\n items {\n id\n ...AutomateAutomationCreateDialog_AutomateFunction\n }\n }\n }\n": types.AutomationCreateDialogFunctionsSearchDocument,
@@ -82,6 +84,7 @@ const documents = {
"\n fragment ProjectsPageTeamDialogManagePermissions_Project on Project {\n id\n visibility\n role\n }\n": types.ProjectsPageTeamDialogManagePermissions_ProjectFragmentDoc,
"\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n ": types.OnUserProjectsUpdateDocument,
"\n fragment ProjectsDashboardFilled on ProjectCollection {\n items {\n ...ProjectDashboardItem\n }\n }\n": types.ProjectsDashboardFilledFragmentDoc,
"\n fragment ProjectsDashboardHeader_User on User {\n ...ProjectsInviteBanners\n ...WorkspaceInviteBanners_User\n }\n": types.ProjectsDashboardHeader_UserFragmentDoc,
"\n fragment ProjectsInviteBanner on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n": types.ProjectsInviteBannerFragmentDoc,
"\n fragment ProjectsInviteBanners on User {\n projectInvites {\n ...ProjectsInviteBanner\n }\n }\n": types.ProjectsInviteBannersFragmentDoc,
"\n fragment SettingsDialog_User on User {\n workspaces {\n items {\n id\n name\n }\n }\n }\n": types.SettingsDialog_UserFragmentDoc,
@@ -108,11 +111,14 @@ const documents = {
"\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n id\n team {\n id\n user {\n id\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n": types.WorkspaceInviteDialog_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 id\n name\n logo\n description\n totalProjects: projects {\n totalCount\n }\n team {\n id\n user {\n id\n name\n ...LimitedUserAvatar\n }\n }\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,
"\n fragment WorkspaceInviteBanners_User on User {\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n": types.WorkspaceInviteBanners_UserFragmentDoc,
"\n fragment WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceName\n token\n user {\n id\n name\n ...LimitedUserAvatar\n }\n title\n email\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.WorkspaceInviteBlock_PendingWorkspaceCollaboratorFragmentDoc,
"\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 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,
"\n query AuthServerInfo {\n serverInfo {\n ...AuthStategiesServerInfoFragment\n ...ServerTermsOfServicePrivacyPolicyFragment\n ...AuthRegisterPanelServerInfo\n }\n }\n": types.AuthServerInfoDocument,
"\n query AuthLoginPanel($token: String) {\n workspaceInvite(token: $token) {\n id\n email\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n ...AuthLoginWithEmailBlock_PendingWorkspaceCollaborator\n }\n serverInfo {\n authStrategies {\n id\n }\n ...AuthStategiesServerInfoFragment\n }\n }\n": types.AuthLoginPanelDocument,
"\n query AuthorizableAppMetadata($id: String!) {\n app(id: $id) {\n id\n name\n description\n trustByDefault\n redirectUrl\n scopes {\n name\n description\n }\n author {\n name\n id\n avatar\n }\n }\n }\n": types.AuthorizableAppMetadataDocument,
"\n fragment FunctionRunStatusForSummary on AutomateFunctionRun {\n id\n status\n }\n": types.FunctionRunStatusForSummaryFragmentDoc,
"\n fragment TriggeredAutomationsStatusSummary on TriggeredAutomationsStatus {\n id\n automationRuns {\n id\n functionRuns {\n id\n ...FunctionRunStatusForSummary\n }\n }\n }\n": types.TriggeredAutomationsStatusSummaryFragmentDoc,
@@ -131,7 +137,7 @@ const documents = {
"\n query ServerInfoAllScopes {\n serverInfo {\n scopes {\n name\n description\n }\n }\n }\n": types.ServerInfoAllScopesDocument,
"\n query ProjectModelsSelectorValues($projectId: String!, $cursor: String) {\n project(id: $projectId) {\n id\n models(limit: 100, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...CommonModelSelectorModel\n }\n }\n }\n }\n": types.ProjectModelsSelectorValuesDocument,
"\n query MainServerInfoData {\n serverInfo {\n adminContact\n blobSizeLimitBytes\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n }\n }\n": types.MainServerInfoDataDocument,
"\n query DashboardProjectsPageQuery {\n activeUser {\n id\n projects(limit: 3) {\n items {\n ...DashboardProjectCard_Project\n }\n }\n }\n }\n": types.DashboardProjectsPageQueryDocument,
"\n query DashboardProjectsPageQuery {\n activeUser {\n id\n projects(limit: 3) {\n items {\n ...DashboardProjectCard_Project\n }\n }\n ...ProjectsDashboardHeader_User\n }\n }\n": types.DashboardProjectsPageQueryDocument,
"\n mutation DeleteAccessToken($token: String!) {\n apiTokenRevoke(token: $token)\n }\n": types.DeleteAccessTokenDocument,
"\n mutation CreateAccessToken($token: ApiTokenCreateInput!) {\n apiTokenCreate(token: $token)\n }\n": types.CreateAccessTokenDocument,
"\n mutation DeleteApplication($appId: String!) {\n appDelete(appId: $appId)\n }\n": types.DeleteApplicationDocument,
@@ -181,7 +187,7 @@ const documents = {
"\n mutation CreateTestAutomation(\n $projectId: ID!\n $input: ProjectTestAutomationCreateInput!\n ) {\n projectMutations {\n automationMutations(projectId: $projectId) {\n createTestAutomation(input: $input) {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n }\n }\n }\n": types.CreateTestAutomationDocument,
"\n query ProjectAccessCheck($id: String!) {\n project(id: $id) {\n id\n }\n }\n": types.ProjectAccessCheckDocument,
"\n query ProjectRoleCheck($id: String!) {\n project(id: $id) {\n id\n role\n }\n }\n": types.ProjectRoleCheckDocument,
"\n query ProjectsDashboardQuery($filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(filter: $filter, limit: 6, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...ProjectDashboardItem\n }\n }\n ...ProjectsInviteBanners\n }\n }\n": types.ProjectsDashboardQueryDocument,
"\n query ProjectsDashboardQuery($filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(filter: $filter, limit: 6, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...ProjectDashboardItem\n }\n }\n ...ProjectsDashboardHeader_User\n }\n }\n": types.ProjectsDashboardQueryDocument,
"\n query ProjectPageQuery($id: String!, $token: String) {\n project(id: $id) {\n ...ProjectPageProject\n }\n projectInvite(projectId: $id, token: $token) {\n ...ProjectsInviteBanner\n }\n }\n": types.ProjectPageQueryDocument,
"\n query ProjectLatestModels($projectId: String!, $filter: ProjectModelsFilter) {\n project(id: $projectId) {\n id\n models(cursor: null, limit: 16, filter: $filter) {\n totalCount\n cursor\n items {\n ...ProjectPageLatestItemsModelItem\n }\n }\n pendingImportedModels {\n ...PendingFileUpload\n }\n }\n }\n": types.ProjectLatestModelsDocument,
"\n query ProjectLatestModelsPagination(\n $projectId: String!\n $filter: ProjectModelsFilter\n $cursor: String = null\n ) {\n project(id: $projectId) {\n id\n models(cursor: $cursor, limit: 16, filter: $filter) {\n totalCount\n cursor\n items {\n ...ProjectPageLatestItemsModelItem\n }\n }\n }\n }\n": types.ProjectLatestModelsPaginationDocument,
@@ -257,14 +263,18 @@ const documents = {
"\n subscription OnViewerUserActivityBroadcasted(\n $target: ViewerUpdateTrackingTarget!\n $sessionId: String!\n ) {\n viewerUserActivityBroadcasted(target: $target, sessionId: $sessionId) {\n userName\n userId\n user {\n ...LimitedUserAvatar\n }\n state\n status\n sessionId\n }\n }\n": types.OnViewerUserActivityBroadcastedDocument,
"\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 user {\n id\n }\n }\n": types.UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragmentDoc,
"\n mutation UpdateRole($input: WorkspaceRoleUpdateInput!) {\n workspaceMutations {\n updateRole(input: $input) {\n id\n team {\n id\n role\n }\n }\n }\n }\n": types.UpdateRoleDocument,
"\n mutation InviteToWorkspace(\n $workspaceId: String!\n $input: [WorkspaceInviteCreateInput!]!\n ) {\n workspaceMutations {\n invites {\n batchCreate(workspaceId: $workspaceId, input: $input) {\n id\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n }\n }\n }\n": types.InviteToWorkspaceDocument,
"\n mutation ProcessWorkspaceInvite($input: WorkspaceInviteUseInput!) {\n workspaceMutations {\n invites {\n use(input: $input)\n }\n }\n }\n": types.ProcessWorkspaceInviteDocument,
"\n query WorkspaceAccessCheck($id: String!) {\n workspace(id: $id) {\n id\n }\n }\n": types.WorkspaceAccessCheckDocument,
"\n query WorkspacePageQuery(\n $workspaceId: String!\n $filter: WorkspaceProjectsFilter\n $cursor: String\n ) {\n workspace(id: $workspaceId) {\n id\n ...WorkspaceHeader_Workspace\n projects(filter: $filter, cursor: $cursor, limit: 10) {\n ...WorkspaceProjectList_ProjectCollection\n }\n }\n }\n": types.WorkspacePageQueryDocument,
"\n query WorkspaceProjectsQuery(\n $workspaceId: String!\n $filter: WorkspaceProjectsFilter\n $cursor: String\n ) {\n workspace(id: $workspaceId) {\n id\n projects(filter: $filter, cursor: $cursor, limit: 10) {\n ...WorkspaceProjectList_ProjectCollection\n }\n }\n }\n": types.WorkspaceProjectsQueryDocument,
"\n query WorkspaceInvite($workspaceId: String, $token: String) {\n workspaceInvite(workspaceId: $workspaceId, token: $token) {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n ...WorkspaceInviteBlock_PendingWorkspaceCollaborator\n }\n }\n": types.WorkspaceInviteDocument,
"\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n project(id: $streamId) {\n modelByName(name: $branchName) {\n id\n }\n }\n }\n": types.LegacyBranchRedirectMetadataDocument,
"\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n project(id: $streamId) {\n version(id: $commitId) {\n id\n model {\n id\n }\n }\n }\n }\n": types.LegacyViewerCommitRedirectMetadataDocument,
"\n query LegacyViewerStreamRedirectMetadata($streamId: String!) {\n project(id: $streamId) {\n id\n versions(limit: 1) {\n totalCount\n items {\n id\n model {\n id\n }\n }\n }\n }\n }\n": types.LegacyViewerStreamRedirectMetadataDocument,
"\n query AutoAcceptableWorkspaceInvite($token: String!, $workspaceId: String!) {\n workspaceInvite(token: $token, workspaceId: $workspaceId) {\n id\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n }\n": types.AutoAcceptableWorkspaceInviteDocument,
"\n query ResolveCommentLink($commentId: String!, $projectId: String!) {\n project(id: $projectId) {\n comment(id: $commentId) {\n id\n ...LinkableComment\n }\n }\n }\n": types.ResolveCommentLinkDocument,
"\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 }\n": types.AutomateFunctionPageDocument,
@@ -292,11 +302,11 @@ export function graphql(source: string): unknown;
/**
* 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 AuthRegisterPanelServerInfo on ServerInfo {\n inviteOnly\n }\n"): (typeof documents)["\n fragment AuthRegisterPanelServerInfo on ServerInfo {\n inviteOnly\n }\n"];
export function graphql(source: "\n fragment AuthLoginWithEmailBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n email\n user {\n id\n }\n }\n"): (typeof documents)["\n fragment AuthLoginWithEmailBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n email\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 query RegisterPanelServerInvite($token: String!) {\n serverInviteByToken(token: $token) {\n id\n email\n }\n }\n"): (typeof documents)["\n query RegisterPanelServerInvite($token: String!) {\n serverInviteByToken(token: $token) {\n id\n email\n }\n }\n"];
export function graphql(source: "\n query AuthRegisterPanel($token: String) {\n serverInfo {\n inviteOnly\n authStrategies {\n id\n }\n ...AuthStategiesServerInfoFragment\n ...ServerTermsOfServicePrivacyPolicyFragment\n }\n serverInviteByToken(token: $token) {\n id\n email\n }\n workspaceInvite(token: $token) {\n id\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n query AuthRegisterPanel($token: String) {\n serverInfo {\n inviteOnly\n authStrategies {\n id\n }\n ...AuthStategiesServerInfoFragment\n ...ServerTermsOfServicePrivacyPolicyFragment\n }\n serverInviteByToken(token: $token) {\n id\n email\n }\n workspaceInvite(token: $token) {\n id\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -312,7 +322,15 @@ export function graphql(source: "\n mutation RequestVerification {\n request
/**
* 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 AuthStategiesServerInfoFragment on ServerInfo {\n authStrategies {\n id\n name\n url\n }\n }\n"): (typeof documents)["\n fragment AuthStategiesServerInfoFragment on ServerInfo {\n authStrategies {\n id\n name\n url\n }\n }\n"];
export function graphql(source: "\n fragment AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceName\n email\n user {\n id\n ...LimitedUserAvatar\n }\n }\n"): (typeof documents)["\n fragment AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceName\n email\n user {\n id\n ...LimitedUserAvatar\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 AuthStategiesServerInfoFragment on ServerInfo {\n authStrategies {\n id\n name\n url\n }\n ...AuthThirdPartyLoginButtonOIDC_ServerInfo\n }\n"): (typeof documents)["\n fragment AuthStategiesServerInfoFragment on ServerInfo {\n authStrategies {\n id\n name\n url\n }\n ...AuthThirdPartyLoginButtonOIDC_ServerInfo\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 AuthThirdPartyLoginButtonOIDC_ServerInfo on ServerInfo {\n authStrategies {\n id\n name\n }\n }\n"): (typeof documents)["\n fragment AuthThirdPartyLoginButtonOIDC_ServerInfo on ServerInfo {\n authStrategies {\n id\n name\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -565,6 +583,10 @@ export function graphql(source: "\n subscription OnUserProjectsUpdate {\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 ProjectsDashboardFilled on ProjectCollection {\n items {\n ...ProjectDashboardItem\n }\n }\n"): (typeof documents)["\n fragment ProjectsDashboardFilled on ProjectCollection {\n items {\n ...ProjectDashboardItem\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 ProjectsDashboardHeader_User on User {\n ...ProjectsInviteBanners\n ...WorkspaceInviteBanners_User\n }\n"): (typeof documents)["\n fragment ProjectsDashboardHeader_User on User {\n ...ProjectsInviteBanners\n ...WorkspaceInviteBanners_User\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -669,6 +691,18 @@ export function graphql(source: "\n fragment WorkspaceProjectList_ProjectCollec
* 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 WorkspaceHeader_Workspace on Workspace {\n id\n name\n logo\n description\n totalProjects: projects {\n totalCount\n }\n team {\n id\n user {\n id\n name\n ...LimitedUserAvatar\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceHeader_Workspace on Workspace {\n id\n name\n logo\n description\n totalProjects: projects {\n totalCount\n }\n team {\n id\n user {\n id\n name\n ...LimitedUserAvatar\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 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"): (typeof documents)["\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"];
/**
* 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 WorkspaceInviteBanners_User on User {\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n fragment WorkspaceInviteBanners_User on User {\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\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 WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceName\n token\n user {\n id\n name\n ...LimitedUserAvatar\n }\n title\n email\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"): (typeof documents)["\n fragment WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceName\n token\n user {\n id\n name\n ...LimitedUserAvatar\n }\n title\n email\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -688,7 +722,7 @@ export function graphql(source: "\n mutation RequestVerificationByEmail($email:
/**
* 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 AuthServerInfo {\n serverInfo {\n ...AuthStategiesServerInfoFragment\n ...ServerTermsOfServicePrivacyPolicyFragment\n ...AuthRegisterPanelServerInfo\n }\n }\n"): (typeof documents)["\n query AuthServerInfo {\n serverInfo {\n ...AuthStategiesServerInfoFragment\n ...ServerTermsOfServicePrivacyPolicyFragment\n ...AuthRegisterPanelServerInfo\n }\n }\n"];
export function graphql(source: "\n query AuthLoginPanel($token: String) {\n workspaceInvite(token: $token) {\n id\n email\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n ...AuthLoginWithEmailBlock_PendingWorkspaceCollaborator\n }\n serverInfo {\n authStrategies {\n id\n }\n ...AuthStategiesServerInfoFragment\n }\n }\n"): (typeof documents)["\n query AuthLoginPanel($token: String) {\n workspaceInvite(token: $token) {\n id\n email\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n ...AuthLoginWithEmailBlock_PendingWorkspaceCollaborator\n }\n serverInfo {\n authStrategies {\n id\n }\n ...AuthStategiesServerInfoFragment\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -764,7 +798,7 @@ export function graphql(source: "\n query MainServerInfoData {\n serverInfo
/**
* 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 DashboardProjectsPageQuery {\n activeUser {\n id\n projects(limit: 3) {\n items {\n ...DashboardProjectCard_Project\n }\n }\n }\n }\n"): (typeof documents)["\n query DashboardProjectsPageQuery {\n activeUser {\n id\n projects(limit: 3) {\n items {\n ...DashboardProjectCard_Project\n }\n }\n }\n }\n"];
export function graphql(source: "\n query DashboardProjectsPageQuery {\n activeUser {\n id\n projects(limit: 3) {\n items {\n ...DashboardProjectCard_Project\n }\n }\n ...ProjectsDashboardHeader_User\n }\n }\n"): (typeof documents)["\n query DashboardProjectsPageQuery {\n activeUser {\n id\n projects(limit: 3) {\n items {\n ...DashboardProjectCard_Project\n }\n }\n ...ProjectsDashboardHeader_User\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -964,7 +998,7 @@ export function graphql(source: "\n query ProjectRoleCheck($id: String!) {\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 ProjectsDashboardQuery($filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(filter: $filter, limit: 6, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...ProjectDashboardItem\n }\n }\n ...ProjectsInviteBanners\n }\n }\n"): (typeof documents)["\n query ProjectsDashboardQuery($filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(filter: $filter, limit: 6, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...ProjectDashboardItem\n }\n }\n ...ProjectsInviteBanners\n }\n }\n"];
export function graphql(source: "\n query ProjectsDashboardQuery($filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(filter: $filter, limit: 6, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...ProjectDashboardItem\n }\n }\n ...ProjectsDashboardHeader_User\n }\n }\n"): (typeof documents)["\n query ProjectsDashboardQuery($filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(filter: $filter, limit: 6, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...ProjectDashboardItem\n }\n }\n ...ProjectsDashboardHeader_User\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1265,6 +1299,10 @@ export function graphql(source: "\n subscription OnViewerCommentsUpdated($targe
* 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 LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n"): (typeof documents)["\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\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 UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n user {\n id\n }\n }\n"): (typeof documents)["\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\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.
*/
@@ -1273,6 +1311,10 @@ export function graphql(source: "\n mutation UpdateRole($input: WorkspaceRoleUp
* 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 InviteToWorkspace(\n $workspaceId: String!\n $input: [WorkspaceInviteCreateInput!]!\n ) {\n workspaceMutations {\n invites {\n batchCreate(workspaceId: $workspaceId, input: $input) {\n id\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation InviteToWorkspace(\n $workspaceId: String!\n $input: [WorkspaceInviteCreateInput!]!\n ) {\n workspaceMutations {\n invites {\n batchCreate(workspaceId: $workspaceId, input: $input) {\n id\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\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 ProcessWorkspaceInvite($input: WorkspaceInviteUseInput!) {\n workspaceMutations {\n invites {\n use(input: $input)\n }\n }\n }\n"): (typeof documents)["\n mutation ProcessWorkspaceInvite($input: WorkspaceInviteUseInput!) {\n workspaceMutations {\n invites {\n use(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.
*/
@@ -1285,6 +1327,10 @@ export function graphql(source: "\n query WorkspacePageQuery(\n $workspaceId
* 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 WorkspaceProjectsQuery(\n $workspaceId: String!\n $filter: WorkspaceProjectsFilter\n $cursor: String\n ) {\n workspace(id: $workspaceId) {\n id\n projects(filter: $filter, cursor: $cursor, limit: 10) {\n ...WorkspaceProjectList_ProjectCollection\n }\n }\n }\n"): (typeof documents)["\n query WorkspaceProjectsQuery(\n $workspaceId: String!\n $filter: WorkspaceProjectsFilter\n $cursor: String\n ) {\n workspace(id: $workspaceId) {\n id\n projects(filter: $filter, cursor: $cursor, limit: 10) {\n ...WorkspaceProjectList_ProjectCollection\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 WorkspaceInvite($workspaceId: String, $token: String) {\n workspaceInvite(workspaceId: $workspaceId, token: $token) {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n ...WorkspaceInviteBlock_PendingWorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n query WorkspaceInvite($workspaceId: String, $token: String) {\n workspaceInvite(workspaceId: $workspaceId, token: $token) {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n ...WorkspaceInviteBlock_PendingWorkspaceCollaborator\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1297,6 +1343,10 @@ export function graphql(source: "\n query LegacyViewerCommitRedirectMetadata($s
* 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 LegacyViewerStreamRedirectMetadata($streamId: String!) {\n project(id: $streamId) {\n id\n versions(limit: 1) {\n totalCount\n items {\n id\n model {\n id\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query LegacyViewerStreamRedirectMetadata($streamId: String!) {\n project(id: $streamId) {\n id\n versions(limit: 1) {\n totalCount\n items {\n id\n model {\n id\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 query AutoAcceptableWorkspaceInvite($token: String!, $workspaceId: String!) {\n workspaceInvite(token: $token, workspaceId: $workspaceId) {\n id\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n query AutoAcceptableWorkspaceInvite($token: String!, $workspaceId: String!) {\n workspaceInvite(token: $token, workspaceId: $workspaceId) {\n id\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\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,7 +13,15 @@ import type {
} from '@apollo/client/core'
import { GraphQLError } from 'graphql'
import type { DocumentNode } from 'graphql'
import { flatten, isUndefined, has, isFunction, isString } from 'lodash-es'
import {
flatten,
isUndefined,
has,
isFunction,
isString,
isArray,
intersection
} from 'lodash-es'
import type { Modifier, Reference } from '@apollo/client/cache'
import type { PartialDeep } from 'type-fest'
import type { GraphQLErrors, NetworkError } from '@apollo/client/errors'
@@ -21,6 +29,7 @@ import { nanoid } from 'nanoid'
import { StackTrace } from '~~/lib/common/helpers/debugging'
import dayjs from 'dayjs'
import { base64Encode } from '~/lib/common/helpers/encodeDecode'
import type { ErrorResponse } from '@apollo/client/link/error'
export const isServerError = (err: Error): err is ServerError =>
has(err, 'response') && has(err, 'result') && has(err, 'statusCode')
@@ -278,7 +287,7 @@ export function isReference(obj: unknown): obj is CacheObjectReference {
* @param storeFieldName
* @param fieldName
*/
const revolveFieldNameAndVariables = <
export const revolveFieldNameAndVariables = <
V extends Optional<Record<string, unknown>> = undefined
>(
storeFieldName: string,
@@ -476,3 +485,54 @@ export const getDateCursorFromReference = (params: {
const iso = date.toISOString()
return base64Encode(iso)
}
/**
* Simplified version of modifyObjectFields, just targetting a single field
* @see modifyObjectFields
*/
export const modifyObjectField = <
FieldData = unknown,
Variables extends Optional<Record<string, unknown>> = undefined
>(
cache: ApolloCache<unknown>,
id: string,
fieldName: string,
updater: (params: {
fieldName: string
variables: Variables
value: ModifyFnCacheData<FieldData>
details: Parameters<Modifier<ModifyFnCacheData<FieldData>>>[1] & {
ref: typeof getObjectReference
revolveFieldNameAndVariables: typeof revolveFieldNameAndVariables
}
}) =>
| Optional<ModifyFnCacheData<FieldData>>
| Parameters<Modifier<ModifyFnCacheData<FieldData>>>[1]['DELETE']
| Parameters<Modifier<ModifyFnCacheData<FieldData>>>[1]['INVALIDATE'],
options?: Partial<{
debug: boolean
}>
) => {
modifyObjectFields<Variables, FieldData>(
cache,
id,
(field, variables, value, details) => {
if (field !== fieldName) return
return updater({ fieldName: field, variables, value, details })
},
options
)
}
/**
* Build skipLoggingErrors function that skips logging errors if there's only one error and it's related to a specific field
*/
export const skipLoggingErrorsIfOneFieldError =
(fieldName: string | string[]) =>
(err: ErrorResponse): boolean => {
const fieldNames = isArray(fieldName) ? fieldName : [fieldName]
return (
err.graphQLErrors?.length === 1 &&
err.graphQLErrors.some((e) => intersection(e.path || [], fieldNames).length > 0)
)
}
@@ -86,6 +86,8 @@ export const automationFunctionsRoute = '/functions'
export const automationFunctionRoute = (functionId: string) =>
`${automationFunctionsRoute}/${functionId}`
export const workspaceRoute = (id: string) => `/workspaces/${id}`
const buildNavigationComposable = (route: string) => () => {
const router = useRouter()
return (params?: { query?: LocationQueryRaw }) => {
@@ -9,6 +9,7 @@ export const dashboardProjectsPageQuery = graphql(`
...DashboardProjectCard_Project
}
}
...ProjectsDashboardHeader_User
}
}
`)
@@ -28,7 +28,7 @@ export const projectsDashboardQuery = graphql(`
...ProjectDashboardItem
}
}
...ProjectsInviteBanners
...ProjectsDashboardHeader_User
}
}
`)
@@ -82,7 +82,7 @@ export const useResolveInviteTargets = (params: {
}) => {
const { search, excludeUserIds, excludeEmails } = params
const { userSearch, searchVariables } = useUserSearch({
const { userSearch, searchVariables, loading } = useUserSearch({
variables: computed(() => ({
query: search.value || '',
limit: 5
@@ -90,6 +90,8 @@ export const useResolveInviteTargets = (params: {
})
const emails = computed(() => {
if (loading.value) return []
const query = searchVariables.value?.query || ''
const multipleEmails = isValidEmail(query)
? [query]
@@ -1,26 +1,45 @@
import type { RouteLocationNormalized } from 'vue-router'
import { waitForever, type MaybeAsync, type Optional } from '@speckle/shared'
import { useMutation } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import type {
Query,
QueryWorkspaceArgs,
QueryWorkspaceInviteArgs,
User,
UserWorkspacesArgs,
UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragment,
Workspace,
WorkspaceInviteCreateInput,
WorkspaceInvitedTeamArgs
WorkspaceInvitedTeamArgs,
WorkspaceInviteUseInput
} from '~/lib/common/generated/gql/graphql'
import {
evictObjectFields,
getCacheId,
getFirstErrorMessage,
getObjectReference,
modifyObjectFields
modifyObjectField,
modifyObjectFields,
ROOT_QUERY
} from '~/lib/common/helpers/graphql'
import { inviteToWorkspaceMutation } from '~/lib/workspaces/graphql/mutations'
import { useNavigateToHome, workspaceRoute } from '~/lib/common/helpers/route'
import { useMixpanel } from '~/lib/core/composables/mp'
import {
inviteToWorkspaceMutation,
processWorkspaceInviteMutation
} from '~/lib/workspaces/graphql/mutations'
export const useInviteUserToWorkspace = () => {
const { activeUser } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const { mutate } = useMutation(inviteToWorkspaceMutation)
const isWorkspacesEnabled = useIsWorkspacesEnabled()
return async (workspaceId: string, inputs: WorkspaceInviteCreateInput[]) => {
const userId = activeUser.value?.id
if (!userId) return
if (!isWorkspacesEnabled.value) return
const { data, errors } =
(await mutate(
@@ -76,3 +95,223 @@ export const useInviteUserToWorkspace = () => {
return data?.workspaceMutations.invites.batchCreate
}
}
export const useProcessWorkspaceInvite = () => {
const { mutate } = useMutation(processWorkspaceInviteMutation)
const { activeUser } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const mp = useMixpanel()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
return async (
params: {
input: WorkspaceInviteUseInput
workspaceId: string
inviteId: string
},
options?: Partial<{
/**
* Do something once mutation has finished, before all cache updates
*/
callback: () => MaybeAsync<void>
preventErrorToasts?: boolean
}>
) => {
if (!isWorkspacesEnabled.value) return
const userId = activeUser.value?.id
if (!userId) return
const { input, workspaceId, inviteId } = params
const { data, errors } =
(await mutate(
{ input },
{
update: async (cache, { data, errors }) => {
if (errors?.length) return
if (options?.callback) await options.callback()
const accepted = data?.workspaceMutations.invites.use
if (accepted) {
// Evict Query.workspace
modifyObjectField<Query['workspace'], QueryWorkspaceArgs>(
cache,
ROOT_QUERY,
'workspace',
({ variables, details: { DELETE } }) => {
if (variables.id === workspaceId) return DELETE
}
)
// Evict all User.workspaces
modifyObjectField<User['workspaces'], UserWorkspacesArgs>(
cache,
getCacheId('User', userId),
'workspaces',
({ details: { DELETE } }) => DELETE
)
}
// Set Query.workspaceInvite(id) = null (no invite)
modifyObjectField<Query['workspaceInvite'], QueryWorkspaceInviteArgs>(
cache,
ROOT_QUERY,
'workspaceInvite',
({ value, variables, details: { readField } }) => {
if (value) {
const workspaceId = readField('workspaceId', value)
if (workspaceId === workspaceId) return null
} else {
if (variables.workspaceId === workspaceId) return null
}
}
)
// Evict invite itself
cache.evict({
id: getCacheId('PendingWorkspaceCollaborator', inviteId)
})
}
}
).catch(convertThrowIntoFetchResult)) || {}
if (data?.workspaceMutations.invites.use) {
triggerNotification({
type: ToastNotificationType.Success,
title: input.accept ? 'Invite accepted' : 'Invite dismissed'
})
mp.track('Invite Action', {
type: 'workspace invite',
accepted: input.accept
})
} else {
if (!options?.preventErrorToasts) {
const err = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to process invite',
description: err
})
}
}
return !!data?.workspaceMutations.invites.use
}
}
graphql(`
fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {
id
token
workspaceId
user {
id
}
}
`)
export const useWorkspaceInviteManager = <
Invite extends UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragment = UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragment
>(
params: {
invite: Ref<Optional<Invite>>
},
options?: Partial<{
/**
* Whether to prevent any reloads/redirects on successful processing of the invite
*/
preventRedirect: boolean
route: RouteLocationNormalized
preventErrorToasts: boolean
}>
) => {
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { invite } = params
const { preventRedirect, preventErrorToasts } = options || {}
const useInvite = useProcessWorkspaceInvite()
const route = options?.route || useRoute()
const goHome = useNavigateToHome()
const { activeUser } = useActiveUser()
const loading = ref(false)
const token = computed(
() => (route.query.token as Optional<string>) || invite.value?.token
)
const isCurrentUserTarget = computed(
() =>
activeUser.value &&
invite.value?.user &&
activeUser.value.id === invite.value.user.id
)
const targetUser = computed((): Invite['user'] => invite.value?.user)
const needsToAddNewEmail = computed(
() => !isCurrentUserTarget.value && !targetUser.value
)
const canAddNewEmail = computed(() => needsToAddNewEmail.value && token.value)
const processInvite = async (
accept: boolean,
options?: Partial<{
/**
* If invite is attached to an unregistered email, the invite can only be used if this is set to true.
* Upon accepting such an invite, the unregistered email will be added to the user's account as well.
*/
addNewEmail: boolean
}>
) => {
const { addNewEmail } = options || {}
if (!isWorkspacesEnabled.value) return false
if (!token.value || !invite.value) return false
if (needsToAddNewEmail.value && !addNewEmail) return false
const workspaceId = invite.value.workspaceId
const shouldAddNewEmail = canAddNewEmail.value && addNewEmail
loading.value = true
const success = await useInvite(
{
workspaceId,
input: {
accept,
token: token.value,
...(shouldAddNewEmail ? { addNewEmail: shouldAddNewEmail } : {})
},
inviteId: invite.value.id
},
{
callback: async () => {
if (preventRedirect) return
// Redirect
if (accept) {
if (workspaceId) {
window.location.href = workspaceRoute(workspaceId)
} else {
window.location.reload()
}
await waitForever() // to prevent UI changes while reload is happening
} else {
await goHome()
}
},
preventErrorToasts
}
)
loading.value = false
return !!success
}
return {
loading: computed(() => loading.value),
token,
isCurrentUserTarget,
targetUser,
accept: (options?: Parameters<typeof processInvite>[1]) =>
processInvite(true, options),
decline: (options?: Parameters<typeof processInvite>[1]) =>
processInvite(false, options)
}
}
@@ -31,3 +31,13 @@ export const inviteToWorkspaceMutation = graphql(`
}
}
`)
export const processWorkspaceInviteMutation = graphql(`
mutation ProcessWorkspaceInvite($input: WorkspaceInviteUseInput!) {
workspaceMutations {
invites {
use(input: $input)
}
}
}
`)
@@ -38,3 +38,12 @@ export const workspaceProjectsQuery = graphql(`
}
}
`)
export const workspaceInviteQuery = graphql(`
query WorkspaceInvite($workspaceId: String, $token: String) {
workspaceInvite(workspaceId: $workspaceId, token: $token) {
...WorkspaceInviteBanner_PendingWorkspaceCollaborator
...WorkspaceInviteBlock_PendingWorkspaceCollaborator
}
}
`)
@@ -13,11 +13,11 @@ export const roleSelectItems: Record<
},
[Roles.Workspace.Member]: {
id: Roles.Workspace.Member,
title: 'Can edit'
title: 'Member'
},
[Roles.Workspace.Guest]: {
id: Roles.Workspace.Guest,
title: 'Can view'
title: 'Guest'
},
['delete']: {
id: 'delete',
@@ -2,37 +2,92 @@ import { type Optional } from '@speckle/shared'
import { omit } from 'lodash-es'
import { activeUserQuery } from '~/lib/auth/composables/activeUser'
import { useApolloClientFromNuxt } from '~/lib/common/composables/graphql'
import { graphql } from '~/lib/common/generated/gql'
import type { UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragment } from '~/lib/common/generated/gql/graphql'
import { useProjectInviteManager } from '~/lib/projects/composables/invites'
import { useWorkspaceInviteManager } from '~/lib/workspaces/composables/management'
const autoAcceptableWorkspaceInviteQuery = graphql(`
query AutoAcceptableWorkspaceInvite($token: String!, $workspaceId: String!) {
workspaceInvite(token: $token, workspaceId: $workspaceId) {
id
...UseWorkspaceInviteManager_PendingWorkspaceCollaborator
}
}
`)
/**
* Handles all of the invite auto-accepting logic (when clicking on email accept links)
*/
export default defineNuxtRouteMiddleware(async (to) => {
const { useInvite } = useProjectInviteManager()
const client = useApolloClientFromNuxt()
const { data } = await client
.query({
query: activeUserQuery
})
.catch(convertThrowIntoFetchResult)
// Ignore if not logged in
if (!data?.activeUser?.id) return
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const shouldTryProjectAccept = to.path.startsWith('/projects/')
const shouldTryWorkspaceAccept =
to.path.startsWith('/workspaces/') && isWorkspacesEnabled.value
const idParam = to.params.id as Optional<string>
const token = to.query.token as Optional<string>
const accept = to.query.accept === 'true'
const addNewEmail = to.query.addNewEmail === 'true'
if (!token || !accept) {
return
if (!idParam?.length) return
if (!shouldTryProjectAccept && !shouldTryWorkspaceAccept) return
if (!token?.length || !accept) return
const { useInvite } = useProjectInviteManager()
const client = useApolloClientFromNuxt()
const workspaceInvite =
ref<UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragment>()
const { accept: acceptWorkspaceInvite } = useWorkspaceInviteManager(
{
invite: workspaceInvite
},
{
route: to,
preventRedirect: true,
preventErrorToasts: true
}
)
const [activeUserData, workspaceInviteData] = await Promise.all([
client
.query({
query: activeUserQuery
})
.catch(convertThrowIntoFetchResult),
...(shouldTryWorkspaceAccept
? [
client
.query({
query: autoAcceptableWorkspaceInviteQuery,
variables: {
token,
workspaceId: idParam
}
})
.catch(convertThrowIntoFetchResult)
]
: [])
])
if (workspaceInviteData.data?.workspaceInvite) {
workspaceInvite.value = workspaceInviteData.data.workspaceInvite
}
if (!to.path.startsWith('/projects/')) return
const projectId = to.params.id as Optional<string>
if (!projectId) return
// Ignore if not logged in
if (!activeUserData.data?.activeUser?.id) return
const success = await useInvite({ token, accept, projectId })
let success = false
if (shouldTryProjectAccept) {
success = await useInvite({ token, accept, projectId: idParam })
} else if (shouldTryWorkspaceAccept) {
success = await acceptWorkspaceInvite({ addNewEmail })
}
if (success) {
return navigateTo(
{
query: omit(to.query, ['token', 'accept'])
query: omit(to.query, ['token', 'accept', 'addNewEmail'])
},
{ replace: true }
)
+2
View File
@@ -1,5 +1,7 @@
<template>
<div class="flex flex-col gap-y-12">
<ProjectsDashboardHeader :user="projectsResult?.activeUser || undefined" />
<section>
<h2 class="text-heading-sm text-foreground-2">Quick start</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 pt-5">
@@ -2,6 +2,7 @@
<div>
<div v-if="project">
<ProjectsInviteBanner
v-if="invite"
:invite="invite"
:show-project-name="false"
@processed="onInviteAccepted"
@@ -8,6 +8,6 @@ const route = useRoute()
const workspaceId = computed(() => route.params.id as string)
definePageMeta({
middleware: ['auth', 'requires-workspaces-enabled', 'require-valid-workspace']
middleware: ['requires-workspaces-enabled', 'require-valid-workspace']
})
</script>
+19 -2
View File
@@ -1,9 +1,10 @@
import type { Optional } from '@speckle/shared'
import { activeUserQuery } from '~/lib/auth/composables/activeUser'
import { loginServerInfoQuery } from '~/lib/auth/graphql/queries'
import { authLoginPanelQuery } from '~/lib/auth/graphql/queries'
import { usePreloadApolloQueries } from '~/lib/common/composables/graphql'
import { mainServerInfoDataQuery } from '~/lib/core/composables/server'
import { projectAccessCheckQuery } from '~/lib/projects/graphql/queries'
import { workspaceAccessCheckQuery } from '~/lib/workspaces/graphql/queries'
/**
* Prefetches data for specific routes to avoid the problem of serial API requests
@@ -13,6 +14,7 @@ export default defineNuxtPlugin(async (ctx) => {
const logger = useLogger()
const route = ctx._route
const preload = usePreloadApolloQueries()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
if (!route) {
logger.info('No route obj found, skipping data preload...')
@@ -45,12 +47,27 @@ export default defineNuxtPlugin(async (ctx) => {
)
}
// Preload workspace data
if (idParam && path.startsWith('/workspaces/') && isWorkspacesEnabled.value) {
promises.push(
preload({
queries: [
{
query: workspaceAccessCheckQuery,
variables: { id: idParam },
context: { skipLoggingErrors: true }
}
]
})
)
}
// Preload viewer data
if (route.meta.key === '/projects/:id/models/resources') {
// Unable to preload this from vue components due to SSR being essentially turned off for the viewer
promises.push(
preload({
queries: [{ query: loginServerInfoQuery }]
queries: [{ query: authLoginPanelQuery }]
})
)
}
@@ -100,7 +100,7 @@ extend type Query {
"""
Receive metadata about an invite by the invite token
"""
serverInviteByToken(token: String!): ServerInvite
serverInviteByToken(token: String): ServerInvite
}
type ServerInvite {
@@ -1,15 +1,16 @@
extend type Query {
workspace(id: String!): Workspace!
@hasServerRole(role: SERVER_USER)
@hasServerRole(role: SERVER_GUEST)
@hasScope(scope: "workspace:read")
"""
Look for an invitation to a workspace, for the current user (authed or not). If token
isn't specified, the server will look for any valid invite.
Look for an invitation to a workspace, for the current user (authed or not).
If token is specified, it will return the corresponding invite even if it belongs to a different user.
Either token or workspaceId must be specified, or both
"""
workspaceInvite(workspaceId: String!, token: String): PendingWorkspaceCollaborator
workspaceInvite(workspaceId: String, token: String): PendingWorkspaceCollaborator
}
input WorkspaceCreateInput {
@@ -76,18 +77,23 @@ extend type ProjectInviteMutations {
}
extend type Mutation {
workspaceMutations: WorkspaceMutations! @hasServerRole(role: SERVER_USER)
workspaceMutations: WorkspaceMutations! @hasServerRole(role: SERVER_GUEST)
}
type WorkspaceMutations {
create(input: WorkspaceCreateInput!): Workspace!
@hasServerRole(role: SERVER_USER)
@hasScope(scope: "workspace:create")
delete(workspaceId: String!): Boolean! @hasScope(scope: "workspace:delete")
update(input: WorkspaceUpdateInput!): Workspace! @hasScope(scope: "workspace:update")
delete(workspaceId: String!): Boolean!
@hasScope(scope: "workspace:delete")
@hasServerRole(role: SERVER_USER)
update(input: WorkspaceUpdateInput!): Workspace!
@hasScope(scope: "workspace:update")
@hasServerRole(role: SERVER_USER)
updateRole(input: WorkspaceRoleUpdateInput!): Workspace!
@hasScope(scope: "workspace:update")
leave(id: ID!): Boolean!
@hasServerRole(role: SERVER_USER)
leave(id: ID!): Boolean! @hasServerRole(role: SERVER_GUEST)
invites: WorkspaceInviteMutations!
}
@@ -113,6 +119,11 @@ input WorkspaceInviteCreateInput {
input WorkspaceInviteUseInput {
token: String!
accept: Boolean!
"""
If invite is attached to an unregistered email, the invite can only be used if this is set to true.
Upon accepting such an invite, the unregistered email will be added to the user's account as well.
"""
addNewEmail: Boolean
}
type WorkspaceInviteMutations {
@@ -191,6 +202,11 @@ type PendingWorkspaceCollaborator {
workspaceId: String!
workspaceName: String!
"""
E-mail address if target is unregistered or primary e-mail of target registered user
if token was specified to retrieve this invite
"""
email: String
"""
E-mail address or name of the invited user
"""
title: String!
@@ -204,7 +220,8 @@ type PendingWorkspaceCollaborator {
"""
user: LimitedUser
"""
Only available if the active user is the pending workspace collaborator
Only available if the active user is the pending workspace collaborator or if it was already
specified when retrieving this invite
"""
token: String
}
@@ -2,6 +2,21 @@ import { UserEmail } from '@/modules/core/domain/userEmails/types'
import { Optional } from '@speckle/shared'
import { Knex } from 'knex'
/**
* Validate and insert new user email
*/
export type ValidateAndCreateUserEmail = (params: {
userEmail: Pick<UserEmail, 'email' | 'userId'> & {
primary?: boolean
verified?: boolean
}
}) => Promise<UserEmail>
export type EnsureNoPrimaryEmailForUser = (params: { userId: string }) => Promise<void>
/**
* Insert new user email (no validation)
*/
export type CreateUserEmail = ({
userEmail
}: {
@@ -1724,6 +1724,11 @@ export type PendingStreamCollaborator = {
export type PendingWorkspaceCollaborator = {
__typename?: 'PendingWorkspaceCollaborator';
/**
* E-mail address if target is unregistered or primary e-mail of target registered user
* if token was specified to retrieve this invite
*/
email?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
inviteId: Scalars['String']['output'];
invitedBy: LimitedUser;
@@ -1731,7 +1736,10 @@ export type PendingWorkspaceCollaborator = {
role: Scalars['String']['output'];
/** E-mail address or name of the invited user */
title: Scalars['String']['output'];
/** Only available if the active user is the pending workspace collaborator */
/**
* Only available if the active user is the pending workspace collaborator or if it was already
* specified when retrieving this invite
*/
token?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
/** Set only if user is registered */
@@ -2455,10 +2463,11 @@ export type Query = {
userSearch: UserSearchResultCollection;
workspace: Workspace;
/**
* Look for an invitation to a workspace, for the current user (authed or not). If token
* isn't specified, the server will look for any valid invite.
* Look for an invitation to a workspace, for the current user (authed or not).
*
* If token is specified, it will return the corresponding invite even if it belongs to a different user.
*
* Either token or workspaceId must be specified, or both
*/
workspaceInvite?: Maybe<PendingWorkspaceCollaborator>;
};
@@ -2541,7 +2550,7 @@ export type QueryProjectInviteArgs = {
export type QueryServerInviteByTokenArgs = {
token: Scalars['String']['input'];
token?: InputMaybe<Scalars['String']['input']>;
};
@@ -2594,7 +2603,7 @@ export type QueryWorkspaceArgs = {
export type QueryWorkspaceInviteArgs = {
token?: InputMaybe<Scalars['String']['input']>;
workspaceId: Scalars['String']['input'];
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
/** Deprecated: Used by old stream-based mutations */
@@ -3904,6 +3913,11 @@ export type WorkspaceInviteMutationsUseArgs = {
export type WorkspaceInviteUseInput = {
accept: Scalars['Boolean']['input'];
/**
* If invite is attached to an unregistered email, the invite can only be used if this is set to true.
* Upon accepting such an invite, the unregistered email will be added to the user's account as well.
*/
addNewEmail?: InputMaybe<Scalars['Boolean']['input']>;
token: Scalars['String']['input'];
};
@@ -5196,6 +5210,7 @@ export type PendingStreamCollaboratorResolvers<ContextType = GraphQLContext, Par
};
export type PendingWorkspaceCollaboratorResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['PendingWorkspaceCollaborator'] = ResolversParentTypes['PendingWorkspaceCollaborator']> = {
email?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
inviteId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
invitedBy?: Resolver<ResolversTypes['LimitedUser'], ParentType, ContextType>;
@@ -5407,7 +5422,7 @@ export type QueryResolvers<ContextType = GraphQLContext, ParentType extends Reso
project?: Resolver<ResolversTypes['Project'], ParentType, ContextType, RequireFields<QueryProjectArgs, 'id'>>;
projectInvite?: Resolver<Maybe<ResolversTypes['PendingStreamCollaborator']>, ParentType, ContextType, RequireFields<QueryProjectInviteArgs, 'projectId'>>;
serverInfo?: Resolver<ResolversTypes['ServerInfo'], ParentType, ContextType>;
serverInviteByToken?: Resolver<Maybe<ResolversTypes['ServerInvite']>, ParentType, ContextType, RequireFields<QueryServerInviteByTokenArgs, 'token'>>;
serverInviteByToken?: Resolver<Maybe<ResolversTypes['ServerInvite']>, ParentType, ContextType, Partial<QueryServerInviteByTokenArgs>>;
serverStats?: Resolver<ResolversTypes['ServerStats'], ParentType, ContextType>;
stream?: Resolver<Maybe<ResolversTypes['Stream']>, ParentType, ContextType, RequireFields<QueryStreamArgs, 'id'>>;
streamAccessRequest?: Resolver<Maybe<ResolversTypes['StreamAccessRequest']>, ParentType, ContextType, RequireFields<QueryStreamAccessRequestArgs, 'streamId'>>;
@@ -5418,7 +5433,7 @@ export type QueryResolvers<ContextType = GraphQLContext, ParentType extends Reso
userPwdStrength?: Resolver<ResolversTypes['PasswordStrengthCheckResults'], ParentType, ContextType, RequireFields<QueryUserPwdStrengthArgs, 'pwd'>>;
userSearch?: Resolver<ResolversTypes['UserSearchResultCollection'], ParentType, ContextType, RequireFields<QueryUserSearchArgs, 'archived' | 'emailOnly' | 'limit' | 'query'>>;
workspace?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<QueryWorkspaceArgs, 'id'>>;
workspaceInvite?: Resolver<Maybe<ResolversTypes['PendingWorkspaceCollaborator']>, ParentType, ContextType, RequireFields<QueryWorkspaceInviteArgs, 'workspaceId'>>;
workspaceInvite?: Resolver<Maybe<ResolversTypes['PendingWorkspaceCollaborator']>, ParentType, ContextType, Partial<QueryWorkspaceInviteArgs>>;
};
export type ResourceIdentifierResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ResourceIdentifier'] = ResolversParentTypes['ResourceIdentifier']> = {
@@ -2,11 +2,19 @@ import { Resolvers } from '@/modules/core/graph/generated/graphql'
import {
createUserEmailFactory,
deleteUserEmailFactory,
ensureNoPrimaryEmailForUserFactory,
findEmailFactory,
findEmailsByUserIdFactory,
setPrimaryUserEmailFactory
} from '@/modules/core/repositories/userEmails'
import { db } from '@/db/knex'
import { requestNewEmailVerification } from '@/modules/emails/services/verification/request'
import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing'
import {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails'
export = {
ActiveUserMutations: {
@@ -19,14 +27,25 @@ export = {
},
UserEmailMutations: {
create: async (_parent, args, ctx) => {
const email = await createUserEmailFactory({ db })({
const validateAndCreateUserEmail = validateAndCreateUserEmailFactory({
createUserEmail: createUserEmailFactory({ db }),
ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }),
findEmail: findEmailFactory({ db }),
updateEmailInvites: finalizeInvitedServerRegistrationFactory({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
}),
requestNewEmailVerification
})
await validateAndCreateUserEmail({
userEmail: {
userId: ctx.userId!,
email: args.input.email,
primary: false
}
})
await requestNewEmailVerification(email.id)
return ctx.loaders.users.getUser.load(ctx.userId!)
},
delete: async (_parent, args, ctx) => {
@@ -11,13 +11,7 @@ export type UserRecord = {
name: string
bio: Nullable<string>
company: Nullable<string>
/**
* @deprecated Use UserEmails table
*/
email: string
/**
* @deprecated Use UserEmails table
*/
verified: boolean
avatar: string
profiles: Nullable<string>
@@ -4,6 +4,7 @@ import {
CountEmailsByUserId,
CreateUserEmail,
DeleteUserEmail,
EnsureNoPrimaryEmailForUser,
FindEmail,
FindEmailsByUserId,
FindPrimaryEmailForUser,
@@ -13,7 +14,6 @@ import {
import { UserEmail } from '@/modules/core/domain/userEmails/types'
import { UserEmails } from '@/modules/core/dbSchema'
import {
UserEmailAlreadyExistsError,
UserEmailDeleteError,
UserEmailPrimaryAlreadyExistsError,
UserEmailPrimaryUnverifiedError
@@ -24,9 +24,9 @@ const whereEmailIs = (query: Knex.QueryBuilder, email: string) => {
query.whereRaw('lower("email") = lower(?)', [email])
}
const checkPrimaryEmail =
({ db }: { db: Knex }) =>
async ({ userId }: { userId: string }) => {
export const ensureNoPrimaryEmailForUserFactory =
({ db }: { db: Knex }): EnsureNoPrimaryEmailForUser =>
async ({ userId }) => {
const primaryEmail = await findPrimaryEmailForUserFactory({ db })({ userId })
if (primaryEmail) {
throw new UserEmailPrimaryAlreadyExistsError()
@@ -39,17 +39,6 @@ export const createUserEmailFactory =
const id = crs({ length: 10 })
const { email, ...rest } = userEmail
if (rest.primary) {
await checkPrimaryEmail({ db })(rest)
}
const existingEmail = await findEmailFactory({ db })({
email
})
if (existingEmail) {
throw new UserEmailAlreadyExistsError()
}
const [row] = await db<UserEmail>(UserEmails.name).insert(
{
id,
@@ -68,7 +57,7 @@ export const updateUserEmailFactory =
async ({ query, update }) => {
const queryWithUserId = query as Pick<UserEmail, 'userId'>
if (queryWithUserId.userId && update.primary) {
await checkPrimaryEmail({ db })(queryWithUserId)
await ensureNoPrimaryEmailForUserFactory({ db })(queryWithUserId)
}
const q = db<UserEmail>(UserEmails.name)
.where(omit(query, ['email']))
@@ -0,0 +1,53 @@
import {
CreateUserEmail,
FindEmail,
ValidateAndCreateUserEmail
} from '@/modules/core/domain/userEmails/operations'
import { ensureNoPrimaryEmailForUserFactory } from '@/modules/core/repositories/userEmails'
import { UserEmailAlreadyExistsError } from '@/modules/core/errors/userEmails'
import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing'
import { requestNewEmailVerification } from '@/modules/emails/services/verification/request'
export const validateAndCreateUserEmailFactory =
(deps: {
createUserEmail: CreateUserEmail
ensureNoPrimaryEmailForUser: ReturnType<typeof ensureNoPrimaryEmailForUserFactory>
findEmail: FindEmail
updateEmailInvites: ReturnType<typeof finalizeInvitedServerRegistrationFactory>
requestNewEmailVerification: typeof requestNewEmailVerification
}): ValidateAndCreateUserEmail =>
async (params) => {
const { userEmail } = params
const { email, userId, primary } = userEmail
const validationPromises: Array<Promise<unknown>> = []
if (primary) {
validationPromises.push(deps.ensureNoPrimaryEmailForUser({ userId }))
}
validationPromises.push(
(async () => {
const existingEmail = await deps.findEmail({
email
})
if (existingEmail) {
throw new UserEmailAlreadyExistsError()
}
})()
)
await Promise.all(validationPromises)
const result = await deps.createUserEmail({ userEmail })
// Update all invites referencing the email, to point to the user
await deps.updateEmailInvites(result.email, result.userId)
// Request email verification (if needed)
if (!userEmail.verified) {
await deps.requestNewEmailVerification(result.id)
}
return result
}
+27 -2
View File
@@ -35,9 +35,23 @@ const { sanitizeImageUrl } = require('@/modules/shared/helpers/sanitization')
const {
createUserEmailFactory,
findPrimaryEmailForUserFactory,
findEmailFactory
findEmailFactory,
ensureNoPrimaryEmailForUserFactory
} = require('@/modules/core/repositories/userEmails')
const {
validateAndCreateUserEmailFactory
} = require('@/modules/core/services/userEmails')
const { db } = require('@/db/knex')
const {
finalizeInvitedServerRegistrationFactory
} = require('@/modules/serverinvites/services/processing')
const {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
const {
requestNewEmailVerification
} = require('@/modules/emails/services/verification/request')
const _changeUserRole = async ({ userId, role }) =>
await Acl().where({ userId }).update({ role })
@@ -116,7 +130,18 @@ module.exports = {
await Acl().insert({ userId: newId, role: userRole })
await createUserEmailFactory({ db })({
const validateAndCreateUserEmail = validateAndCreateUserEmailFactory({
createUserEmail: createUserEmailFactory({ db }),
ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }),
findEmail: findEmailFactory({ db }),
updateEmailInvites: finalizeInvitedServerRegistrationFactory({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
}),
requestNewEmailVerification
})
await validateAndCreateUserEmail({
userEmail: {
email: user.email,
userId: user.id,
@@ -8,6 +8,7 @@ import {
} from '@/modules/core/helpers/testHelpers'
import {
createUserEmailFactory,
ensureNoPrimaryEmailForUserFactory,
findEmailFactory
} from '@/modules/core/repositories/userEmails'
import { db } from '@/db/knex'
@@ -18,6 +19,24 @@ import {
SetPrimaryUserEmailDocument
} from '@/test/graphql/generated/graphql'
import { UserEmails, Users } from '@/modules/core/dbSchema'
import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails'
import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing'
import {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import { requestNewEmailVerification } from '@/modules/emails/services/verification/request'
const createUserEmail = validateAndCreateUserEmailFactory({
createUserEmail: createUserEmailFactory({ db }),
ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }),
findEmail: findEmailFactory({ db }),
updateEmailInvites: finalizeInvitedServerRegistrationFactory({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
}),
requestNewEmailVerification
})
describe('User emails graphql @core', () => {
before(async () => {
@@ -69,7 +88,7 @@ describe('User emails graphql @core', () => {
})
const email = createRandomEmail()
const { id } = await createUserEmailFactory({ db })({
const { id } = await createUserEmail({
userEmail: {
email,
userId,
@@ -99,7 +118,7 @@ describe('User emails graphql @core', () => {
})
const email = createRandomEmail()
const { id } = await createUserEmailFactory({ db })({
const { id } = await createUserEmail({
userEmail: {
email,
userId,
@@ -18,6 +18,7 @@ import {
import {
createUserEmailFactory,
deleteUserEmailFactory,
ensureNoPrimaryEmailForUserFactory,
findEmailFactory,
findPrimaryEmailForUserFactory,
setPrimaryUserEmailFactory,
@@ -28,6 +29,24 @@ import { MaybeNullOrUndefined } from '@speckle/shared'
import { BasicTestUser, createTestUsers } from '@/test/authHelper'
import { UserEmails, Users } from '@/modules/core/dbSchema'
import { UserEmailPrimaryUnverifiedError } from '@/modules/core/errors/userEmails'
import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails'
import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing'
import {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import { requestNewEmailVerification } from '@/modules/emails/services/verification/request'
const createUserEmail = validateAndCreateUserEmailFactory({
createUserEmail: createUserEmailFactory({ db }),
ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }),
findEmail: findEmailFactory({ db }),
updateEmailInvites: finalizeInvitedServerRegistrationFactory({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
}),
requestNewEmailVerification
})
describe('Core @user-emails', () => {
before(async () => {
@@ -81,7 +100,7 @@ describe('Core @user-emails', () => {
password: createRandomPassword()
})
await createUserEmailFactory({ db })({
await createUserEmail({
userEmail: {
email: createRandomEmail(),
userId,
@@ -105,7 +124,7 @@ describe('Core @user-emails', () => {
password: createRandomPassword()
})
await createUserEmailFactory({ db })({
await createUserEmail({
userEmail: {
email,
userId,
@@ -146,7 +165,7 @@ describe('Core @user-emails', () => {
password: createRandomPassword()
})
await createUserEmailFactory({ db })({
await createUserEmail({
userEmail: {
email: email2,
userId,
@@ -187,7 +206,7 @@ describe('Core @user-emails', () => {
password: createRandomPassword()
})
await createUserEmailFactory({ db })({
await createUserEmail({
userEmail: {
email,
userId,
@@ -223,7 +242,7 @@ describe('Core @user-emails', () => {
})
})
describe('createUserEmail', () => {
describe('validateAndCreateUserEmailFactory', () => {
it('should throw an error when trying to create a primary email for a user and there is already one for that user', async () => {
const email = createRandomEmail()
const userId = await createUser({
@@ -233,7 +252,7 @@ describe('Core @user-emails', () => {
})
const err = await expectToThrow(() =>
createUserEmailFactory({ db })({
createUserEmail({
userEmail: {
email,
userId,
@@ -258,7 +277,7 @@ describe('Core @user-emails', () => {
})
// pre existing email
await createUserEmailFactory({ db })({
await createUserEmail({
userEmail: {
email,
userId: userId1,
@@ -267,7 +286,7 @@ describe('Core @user-emails', () => {
})
const err = await expectToThrow(() =>
createUserEmailFactory({ db })({
createUserEmail({
userEmail: {
email,
userId: userId2,
@@ -289,7 +308,7 @@ describe('Core @user-emails', () => {
password: createRandomPassword()
})
await createUserEmailFactory({ db })({
await createUserEmail({
userEmail: {
email,
userId,
@@ -382,11 +401,11 @@ describe('Core @user-emails', () => {
updateEmailDirectly(newEmail)
})
it('with createUserEmailFactory()', async () => {
it('with validateAndCreateUserEmailFactory()', async () => {
const { id: userId } = randomCaseGuy
const newEmail = createRandomEmail()
const createdEmail = (
await createUserEmailFactory({ db })({
await createUserEmail({
userEmail: { email: newEmail, userId }
})
)?.email
@@ -13,11 +13,33 @@ import {
createRandomEmail,
createRandomPassword
} from '@/modules/core/helpers/testHelpers'
import { createUserEmailFactory } from '@/modules/core/repositories/userEmails'
import {
createUserEmailFactory,
ensureNoPrimaryEmailForUserFactory,
findEmailFactory
} from '@/modules/core/repositories/userEmails'
import { db } from '@/db/knex'
import { before } from 'mocha'
import { testApolloServer } from '@/test/graphqlHelper'
import { GetActiveUserEmailsDocument } from '@/test/graphql/generated/graphql'
import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails'
import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing'
import {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import { requestNewEmailVerification } from '@/modules/emails/services/verification/request'
const createUserEmail = validateAndCreateUserEmailFactory({
createUserEmail: createUserEmailFactory({ db }),
ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }),
findEmail: findEmailFactory({ db }),
updateEmailInvites: finalizeInvitedServerRegistrationFactory({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
}),
requestNewEmailVerification
})
describe('Users (GraphQL)', () => {
const me: BasicTestUser = {
@@ -105,7 +127,7 @@ describe('Users (GraphQL)', () => {
email: createRandomEmail(),
password: createRandomPassword()
})
await createUserEmailFactory({ db })({
await createUserEmail({
userEmail: {
email: createRandomEmail(),
userId,
@@ -1713,6 +1713,11 @@ export type PendingStreamCollaborator = {
export type PendingWorkspaceCollaborator = {
__typename?: 'PendingWorkspaceCollaborator';
/**
* E-mail address if target is unregistered or primary e-mail of target registered user
* if token was specified to retrieve this invite
*/
email?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
inviteId: Scalars['String']['output'];
invitedBy: LimitedUser;
@@ -1720,7 +1725,10 @@ export type PendingWorkspaceCollaborator = {
role: Scalars['String']['output'];
/** E-mail address or name of the invited user */
title: Scalars['String']['output'];
/** Only available if the active user is the pending workspace collaborator */
/**
* Only available if the active user is the pending workspace collaborator or if it was already
* specified when retrieving this invite
*/
token?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
/** Set only if user is registered */
@@ -2444,10 +2452,11 @@ export type Query = {
userSearch: UserSearchResultCollection;
workspace: Workspace;
/**
* Look for an invitation to a workspace, for the current user (authed or not). If token
* isn't specified, the server will look for any valid invite.
* Look for an invitation to a workspace, for the current user (authed or not).
*
* If token is specified, it will return the corresponding invite even if it belongs to a different user.
*
* Either token or workspaceId must be specified, or both
*/
workspaceInvite?: Maybe<PendingWorkspaceCollaborator>;
};
@@ -2530,7 +2539,7 @@ export type QueryProjectInviteArgs = {
export type QueryServerInviteByTokenArgs = {
token: Scalars['String']['input'];
token?: InputMaybe<Scalars['String']['input']>;
};
@@ -2583,7 +2592,7 @@ export type QueryWorkspaceArgs = {
export type QueryWorkspaceInviteArgs = {
token?: InputMaybe<Scalars['String']['input']>;
workspaceId: Scalars['String']['input'];
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
/** Deprecated: Used by old stream-based mutations */
@@ -3893,6 +3902,11 @@ export type WorkspaceInviteMutationsUseArgs = {
export type WorkspaceInviteUseInput = {
accept: Scalars['Boolean']['input'];
/**
* If invite is attached to an unregistered email, the invite can only be used if this is set to true.
* Upon accepting such an invite, the unregistered email will be added to the user's account as well.
*/
addNewEmail?: InputMaybe<Scalars['Boolean']['input']>;
token: Scalars['String']['input'];
};
@@ -9,6 +9,7 @@ import {
import {
cancelResourceInviteFactory,
deleteInviteFactory,
finalizeInvitedServerRegistrationFactory,
finalizeResourceInviteFactory
} from '@/modules/serverinvites/services/processing'
import {
@@ -29,7 +30,9 @@ import {
deleteInviteFactory as deleteInviteFromDbFactory,
queryAllUserResourceInvitesFactory,
queryAllResourceInvitesFactory,
markInviteUpdatedfactory
markInviteUpdatedfactory,
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import {
createProjectInviteFactory,
@@ -57,6 +60,13 @@ import {
} from '@/modules/serverinvites/services/coreFinalization'
import { addStreamInviteDeclinedActivity } from '@/modules/activitystream/services/streamActivity'
import { addOrUpdateStreamCollaborator } from '@/modules/core/services/streams/streamAccessService'
import {
createUserEmailFactory,
ensureNoPrimaryEmailForUserFactory,
findEmailFactory
} from '@/modules/core/repositories/userEmails'
import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails'
import { requestNewEmailVerification } from '@/modules/emails/services/verification/request'
const buildCreateAndSendServerOrProjectInvite = () =>
createAndSendInviteFactory({
@@ -100,6 +110,8 @@ export = {
},
async serverInviteByToken(_parent, args) {
const { token } = args
if (!token?.length) return null
return getServerInviteForTokenFactory({
findServerInvite: findServerInviteFactory({ db })
})(token)
@@ -262,7 +274,18 @@ export = {
}),
deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }),
insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }),
emitEvent: (...args) => getEventBus().emit(...args)
emitEvent: (...args) => getEventBus().emit(...args),
findEmail: findEmailFactory({ db }),
validateAndCreateUserEmail: validateAndCreateUserEmailFactory({
createUserEmail: createUserEmailFactory({ db }),
ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }),
findEmail: findEmailFactory({ db }),
updateEmailInvites: finalizeInvitedServerRegistrationFactory({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
}),
requestNewEmailVerification
})
})
})
@@ -388,7 +411,18 @@ export = {
}),
deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }),
insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }),
emitEvent: (...args) => getEventBus().emit(...args)
emitEvent: (...args) => getEventBus().emit(...args),
findEmail: findEmailFactory({ db }),
validateAndCreateUserEmail: validateAndCreateUserEmailFactory({
createUserEmail: createUserEmailFactory({ db }),
ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }),
findEmail: findEmailFactory({ db }),
updateEmailInvites: finalizeInvitedServerRegistrationFactory({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
}),
requestNewEmailVerification
})
})
})
@@ -27,6 +27,12 @@ export type FinalizeInvite = (params: {
accept: boolean
token: string
resourceType?: InviteResourceTargetType
/**
* If true, finalization also allows accepting an invite that technically belongs to a different
* email, one that is not yet attached to any user account.
* If the invite is accepted, the email will be attached to the user account as well in a verified state.
*/
allowAttachingNewEmail?: boolean
}) => Promise<void>
export type ResendInviteEmail = (params: { inviteId: string }) => Promise<void>
@@ -5,7 +5,8 @@ import {
} from '@/modules/serverinvites/errors'
import {
buildUserTarget,
isProjectResourceTarget
isProjectResourceTarget,
resolveTarget
} from '@/modules/serverinvites/helpers/core'
import { getFrontendOrigin, useNewFrontend } from '@/modules/shared/helpers/envHelper'
@@ -33,6 +34,10 @@ import { noop } from 'lodash'
import { ServerInvitesEvents } from '@/modules/serverinvites/domain/events'
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import {
FindEmail,
ValidateAndCreateUserEmail
} from '@/modules/core/domain/userEmails/operations'
/**
* Resolve the relative auth redirect path, after registering with an invite
@@ -112,6 +117,8 @@ type FinalizeResourceInviteFactoryDeps = {
deleteInvitesByTarget: DeleteInvitesByTarget
insertInviteAndDeleteOld: InsertInviteAndDeleteOld
emitEvent: EventBusEmit
findEmail: FindEmail
validateAndCreateUserEmail: ValidateAndCreateUserEmail
}
export const finalizeResourceInviteFactory =
@@ -123,28 +130,48 @@ export const finalizeResourceInviteFactory =
processInvite,
deleteInvitesByTarget,
insertInviteAndDeleteOld,
emitEvent
emitEvent,
findEmail,
validateAndCreateUserEmail
} = deps
const {
finalizerUserId,
accept,
token,
resourceType,
finalizerResourceAccessLimits
finalizerResourceAccessLimits,
allowAttachingNewEmail
} = params
const finalizerUserTarget = buildUserTarget(finalizerUserId)
const invite = await findInvite({
token,
target: buildUserTarget(finalizerUserId),
target: allowAttachingNewEmail ? undefined : finalizerUserTarget,
resourceFilter: resourceType ? { resourceType } : undefined
})
if (!invite) {
throw new NoInviteFoundError(
'Attempted to finalize nonexistant resource invite',
{
info: params
throw new NoInviteFoundError('Attempted to finalize nonexistant invite', {
info: params
})
}
const inviteTarget = resolveTarget(invite.target)
let isNewEmailTarget = !!inviteTarget.userEmail?.length
if (isNewEmailTarget && allowAttachingNewEmail) {
const existingEmail = await findEmail({ email: inviteTarget.userEmail! })
if (existingEmail) {
// This shouldn't really happen, but just in case
isNewEmailTarget = false
}
}
if (!isNewEmailTarget && invite.target !== finalizerUserTarget) {
throw new InviteFinalizingError('Attempted to finalize mismatched invite', {
info: {
finalizerUserId,
invite
}
)
})
}
const action = accept
@@ -161,13 +188,24 @@ export const finalizeResourceInviteFactory =
// Delete invites first, so that any subscriptions fired by processInvite
// don't return the invite back to the frontend
await deleteInvitesByTarget(
buildUserTarget(finalizerUserId),
invite.target,
invite.resource.resourceType,
invite.resource.resourceId
)
// Process invite
try {
// Add email
if (isNewEmailTarget) {
await validateAndCreateUserEmail({
userEmail: {
email: inviteTarget.userEmail!,
userId: finalizerUserId,
verified: true
}
})
}
// Process invite
await processInvite({
invite,
finalizerUserId,
@@ -13,17 +13,20 @@ import {
deleteAllResourceInvitesFactory,
deleteInviteFactory,
deleteInvitesByTargetFactory,
deleteServerOnlyInvitesFactory,
findInviteFactory,
findUserByTargetFactory,
insertInviteAndDeleteOldFactory,
queryAllResourceInvitesFactory,
queryAllUserResourceInvitesFactory
queryAllUserResourceInvitesFactory,
updateAllInviteTargetsFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents'
import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection'
import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation'
import {
cancelResourceInviteFactory,
finalizeInvitedServerRegistrationFactory,
finalizeResourceInviteFactory
} from '@/modules/serverinvites/services/processing'
import { createProjectInviteFactory } from '@/modules/serverinvites/services/projectInviteManagement'
@@ -75,6 +78,13 @@ import { getWorkspacesForUserFactory } from '@/modules/workspaces/services/retri
import { Roles, WorkspaceRoles, removeNullOrUndefinedKeys } from '@speckle/shared'
import { chunk } from 'lodash'
import { deleteStream } from '@/modules/core/repositories/streams'
import {
createUserEmailFactory,
ensureNoPrimaryEmailForUserFactory,
findEmailFactory
} from '@/modules/core/repositories/userEmails'
import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails'
import { requestNewEmailVerification } from '@/modules/emails/services/verification/request'
const buildCreateAndSendServerOrProjectInvite = () =>
createAndSendInviteFactory({
@@ -400,6 +410,17 @@ export = FF_WORKSPACES_MODULE_ENABLED
getStreams,
grantStreamPermissions
})
}),
findEmail: findEmailFactory({ db }),
validateAndCreateUserEmail: validateAndCreateUserEmailFactory({
createUserEmail: createUserEmailFactory({ db }),
ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }),
findEmail: findEmailFactory({ db }),
updateEmailInvites: finalizeInvitedServerRegistrationFactory({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
}),
requestNewEmailVerification
})
})
@@ -408,7 +429,8 @@ export = FF_WORKSPACES_MODULE_ENABLED
finalizerResourceAccessLimits: ctx.resourceAccessRules,
token: args.input.token,
accept: args.input.accept,
resourceType: WorkspaceInviteResourceType
resourceType: WorkspaceInviteResourceType,
allowAttachingNewEmail: args.input.addNewEmail ?? undefined
})
return true
@@ -501,6 +523,9 @@ export = FF_WORKSPACES_MODULE_ENABLED
return user ? removePrivateFields(user) : null
},
token: async (parent, _args, ctx) => {
// If it was specified with the request, just return it
if (parent.token?.length) return parent.token
const authedUserId = ctx.userId
const targetUserId = parent.user?.id
const inviteId = parent.inviteId
@@ -512,6 +537,26 @@ export = FF_WORKSPACES_MODULE_ENABLED
const invite = await ctx.loaders.invites.getInvite.load(inviteId)
return invite?.token || null
},
email: async (parent, _args, ctx) => {
if (!parent.user) return parent.email
// TODO: Tests to check token & email access?
const token = parent.token
const authedUserId = ctx.userId
const targetUserId = parent.user?.id
// Only returning it for the user that is the pending stream collaborator
// OR if the token was specified
if (
(!authedUserId || !targetUserId || authedUserId !== targetUserId) &&
!token
) {
return null
}
return parent.email
}
},
User: {
@@ -7,7 +7,7 @@ import {
import { mapServerRoleToValue } from '@/modules/core/helpers/graphTypes'
import { getWorkspaceRoute } from '@/modules/core/helpers/routeHelper'
import { isResourceAllowed } from '@/modules/core/helpers/token'
import { LimitedUserRecord } from '@/modules/core/helpers/types'
import { UserRecord } from '@/modules/core/helpers/types'
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import { getUser } from '@/modules/core/repositories/users'
import { ServerInviteResourceType } from '@/modules/serverinvites/domain/constants'
@@ -240,8 +240,11 @@ ${inviter.name} has just sent you this invitation to join the "${workspace.name}
function buildPendingWorkspaceCollaboratorModel(
invite: ServerInviteRecord<WorkspaceInviteResourceTarget>,
targetUser: Nullable<LimitedUserRecord>
targetUser: Nullable<UserRecord>,
token?: string
): PendingWorkspaceCollaboratorGraphQLReturn {
const { userEmail } = resolveTarget(invite.target)
return {
id: `invite:${invite.id}`,
inviteId: invite.id,
@@ -249,20 +252,25 @@ function buildPendingWorkspaceCollaboratorModel(
title: resolveInviteTargetTitle(invite, targetUser),
role: invite.resource.role || Roles.Workspace.Member,
invitedById: invite.inviterId,
user: targetUser,
updatedAt: invite.updatedAt
user: targetUser ? removePrivateFields(targetUser) : null,
updatedAt: invite.updatedAt,
email: targetUser?.email || userEmail || '',
token
}
}
export const getUserPendingWorkspaceInviteFactory =
(deps: { findInvite: FindInvite; getUser: typeof getUser }) =>
async (params: {
workspaceId: string
workspaceId: MaybeNullOrUndefined<string>
userId: MaybeNullOrUndefined<string>
token: MaybeNullOrUndefined<string>
}) => {
const { workspaceId, userId, token } = params
if (!userId && !token) return null
if (!userId?.length && !token?.length) return null
if (!token?.length && !workspaceId?.length) return null
// TODO: Test w/o token & workspace, or w/ just token
const userTarget = userId ? buildUserTarget(userId) : undefined
@@ -274,7 +282,7 @@ export const getUserPendingWorkspaceInviteFactory =
token: token || undefined,
resourceFilter: {
resourceType: WorkspaceInviteResourceType,
resourceId: workspaceId
resourceId: workspaceId || undefined
}
})
if (!invite) return null
@@ -282,7 +290,11 @@ export const getUserPendingWorkspaceInviteFactory =
const targetUserId = resolveTarget(invite.target).userId
const targetUser = targetUserId ? await deps.getUser(targetUserId) : null
return buildPendingWorkspaceCollaboratorModel(invite, targetUser)
return buildPendingWorkspaceCollaboratorModel(
invite,
targetUser,
token || undefined
)
}
export const getUserPendingWorkspaceInvitesFactory =
@@ -335,10 +347,10 @@ export const getPendingWorkspaceCollaboratorsFactory =
// Build results
const results = []
for (const invite of invites) {
let user: LimitedUserRecord | null = null
let user: UserRecord | null = null
const { userId } = resolveTarget(invite.target)
if (userId && usersById[userId]) {
user = removePrivateFields(usersById[userId])
user = usersById[userId]
}
results.push(buildPendingWorkspaceCollaboratorModel(invite, user))
@@ -421,11 +433,9 @@ export const processFinalizedWorkspaceInviteFactory =
)
}
const target = resolveTarget(invite.target)
if (action === InviteFinalizationAction.ACCEPT) {
await deps.updateWorkspaceRole({
userId: target.userId!,
userId: finalizerUserId,
workspaceId: workspace.id,
role: invite.resource.role || Roles.Workspace.Member
})
@@ -9,6 +9,10 @@ export = !FF_WORKSPACES_MODULE_ENABLED
Query: {
workspace: async () => {
throw new WorkspacesModuleDisabledError()
},
workspaceInvite: async () => {
// Easier to manage in FE if this doesn't throw, just returns null
return null
}
},
Mutation: {
@@ -63,6 +67,10 @@ export = !FF_WORKSPACES_MODULE_ENABLED
User: {
workspaces: async () => {
throw new WorkspacesModuleDisabledError()
},
workspaceInvites: async () => {
// Easier to manage in FE if this doesn't throw, just returns empty
return []
}
},
Project: {
@@ -17,6 +17,11 @@ export type PendingWorkspaceCollaboratorGraphQLReturn = {
invitedById: string
user: LimitedUserRecord | null
updatedAt: Date
email: string
/**
* The token that was specified when retrieving this collaborator, if any
*/
token?: string
}
export type WorkspaceCollaboratorGraphQLReturn = UserWithRole<LimitedUserRecord> & {
@@ -1714,6 +1714,11 @@ export type PendingStreamCollaborator = {
export type PendingWorkspaceCollaborator = {
__typename?: 'PendingWorkspaceCollaborator';
/**
* E-mail address if target is unregistered or primary e-mail of target registered user
* if token was specified to retrieve this invite
*/
email?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
inviteId: Scalars['String']['output'];
invitedBy: LimitedUser;
@@ -1721,7 +1726,10 @@ export type PendingWorkspaceCollaborator = {
role: Scalars['String']['output'];
/** E-mail address or name of the invited user */
title: Scalars['String']['output'];
/** Only available if the active user is the pending workspace collaborator */
/**
* Only available if the active user is the pending workspace collaborator or if it was already
* specified when retrieving this invite
*/
token?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
/** Set only if user is registered */
@@ -2445,10 +2453,11 @@ export type Query = {
userSearch: UserSearchResultCollection;
workspace: Workspace;
/**
* Look for an invitation to a workspace, for the current user (authed or not). If token
* isn't specified, the server will look for any valid invite.
* Look for an invitation to a workspace, for the current user (authed or not).
*
* If token is specified, it will return the corresponding invite even if it belongs to a different user.
*
* Either token or workspaceId must be specified, or both
*/
workspaceInvite?: Maybe<PendingWorkspaceCollaborator>;
};
@@ -2531,7 +2540,7 @@ export type QueryProjectInviteArgs = {
export type QueryServerInviteByTokenArgs = {
token: Scalars['String']['input'];
token?: InputMaybe<Scalars['String']['input']>;
};
@@ -2584,7 +2593,7 @@ export type QueryWorkspaceArgs = {
export type QueryWorkspaceInviteArgs = {
token?: InputMaybe<Scalars['String']['input']>;
workspaceId: Scalars['String']['input'];
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
/** Deprecated: Used by old stream-based mutations */
@@ -3894,6 +3903,11 @@ export type WorkspaceInviteMutationsUseArgs = {
export type WorkspaceInviteUseInput = {
accept: Scalars['Boolean']['input'];
/**
* If invite is attached to an unregistered email, the invite can only be used if this is set to true.
* Upon accepting such an invite, the unregistered email will be added to the user's account as well.
*/
addNewEmail?: InputMaybe<Scalars['Boolean']['input']>;
token: Scalars['String']['input'];
};