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:
committed by
GitHub
parent
585ba6a102
commit
2bb7802fb9
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user