Poor man's SSO (#2641)

* Implemented workspace general page

* Added notifications to user input

* Allowed non-admins to view but not edit

* Added redirect to homeroute

* Fixed validation

* Squashed commit of the following:

commit 7bf14ab8af0f76b4c9d0aa87fc08085af7c34959
Author: Chuck Driesler <chuck@speckle.systems>
Date:   Tue Aug 6 19:40:50 2024 +0200

    mob next [ci-skip] [ci skip] [skip ci]

    lastFile:packages/server/modules/workspacesCore/migrations/20240806160740_workspace_domains.ts

commit 8aa3fb0cb052c10eeeb83bf9874ae0d1c065e480
Author: Alessandro Magionami <alessandro.magionami@gmail.com>
Date:   Tue Aug 6 18:54:15 2024 +0200

    mob next [ci-skip] [ci skip] [skip ci]

    lastFile:packages/server/modules/core/domain/userEmails/operations.ts

commit 66dfd0cf6c15a789c8f96a65a3168323e83a7b9e
Author: Chuck Driesler <chuck@speckle.systems>
Date:   Tue Aug 6 18:30:22 2024 +0200

    mob next [ci-skip] [ci skip] [skip ci]

    lastFile:packages/server/modules/workspacesCore/domain/types.ts

Co-authored-by: Alessandro Magionami <alessandro.magionami@gmail.com>

* Move General to workspaces folder

* feat(workspaces): inputs on security section

* feat(workspaces): add domain to workspace mutation

* chore(workspaces): add blocked domains list

* fix(workspaces): modals with buttons

* feat(workspaceDomains): delete domain

* fix(workspaces): use  mutation

* fix(workspaces): present user verified domains as options

* Moved sidebar menu to a composable

* Added coming soon tag back

* feat(workspaces): create domains resolver for workspace

* chore(workspaces): fix tests

* chore(workspaces): fix types

* chore(workspaces): fix linter

* fix(workspaces): do some delete I think

* chore(workspaces): add domainBasedMembershipProtectionEnabled field to workspace

* chore(workspaces): improve validation for email domain

* fix(workspace): query and do the thing

* chore(workspaces): add graphql schema for domainBasedMembershipProtection

* chore(workspaces): lint and test failures

* fix(workspaces): test issues w new field

* feat(workspaces): add discoverability flag

* chore(workspaces): they made me do it

* feat(workspaces): enable toggling domain protection

* feat(workspaces): add discoverability toggle to workspace settings

* feat(workspace): auto enable discoverability on first domain registration

* feat(workspace): discoverability toggle fixes

* fix(eventBus): fix tests

* feat(workspaces): user discoverable workspaces (#2620)

* feat(workspaces): it works just trust me

* fix(workspaces): don't worry about it

* fix(workspaces); happy path success

* fix(workspaces): almost there

* fix(workspaces): successful tests!

* fix(workspaces): we have DISCOVERED (#2621)

* Fixed linting issue

* Updated query

* Updated validation rules

* Updated validation rules

* Fix unsaved file with type export

* Addressed PR comments

* Updated cache

* Updated item classes, add fragment back

* Gergo/web 1574 join workspaces via discovery (#2623)

* chore(useremails): add find verified emails by user function

* chore(workspace): table helper for workspace domains

* chore(workspace): get workspace with domains function

* chore(workspace): test get workspace with domains function

* feat(workspace): restrict workspace membership when updating workspace role

* chore(workspaces): fix types

* feat(workspaces): WIP join

* feat(workspaces): join button makes u join

* chore(useremails): fix type for find verified emails function

* feat(workspaces): join

* feat(workspace): prevent inviting user without email matching domain

* chore(workspaces): fix linter

* fix(workspaces): invoke join (gergo wrote this)

* fuck

* fix(workspaces): properly get discoverable workspaces

* fix(workspaces): test

---------

Co-authored-by: Gergő Jedlicska <gergo@jedlicska.com>
Co-authored-by: Chuck Driesler <chuck@speckle.systems>

* fix(workspaces): some query stuff

* fix(workspaces): mutate cache instead of refetch

* fix(workspaces): more adjustments to gql query and fragment structure

* fix(workspaces): queries, style, structure

* fix(workspaces): match discoverability with current styles

* chore(workspaces): lint lint lint

* fix(workspaces): got it twisted

* chore(workspaces): fix test

* fix(workspaces): route to joined workspace on join

---------

Co-authored-by: Mike Tasset <mike.tasset@gmail.com>
Co-authored-by: Chuck Driesler <chuck@speckle.systems>
Co-authored-by: Alessandro Magionami <alessandro.magionami@gmail.com>
This commit is contained in:
Gergő Jedlicska
2024-08-26 13:33:16 +02:00
committed by GitHub
parent 038a280266
commit 08e941f8af
54 changed files with 3142 additions and 135 deletions
@@ -29,6 +29,7 @@ export type ActiveUserMutations = {
finishOnboarding: Scalars['Boolean']['output'];
/** Edit a user's profile */
update: User;
workspaceMutations?: Maybe<UserWorkspaceMutations>;
};
@@ -56,6 +57,11 @@ export type ActivityCollection = {
totalCount: Scalars['Int']['output'];
};
export type AddDomainToWorkspaceInput = {
domain: Scalars['String']['input'];
workspaceId: Scalars['ID']['input'];
};
export type AdminInviteList = {
__typename?: 'AdminInviteList';
cursor?: Maybe<Scalars['String']['output']>;
@@ -843,6 +849,13 @@ export type DiscoverableStreamsSortingInput = {
type: DiscoverableStreamsSortType;
};
export type DiscoverableWorkspace = {
__typename?: 'DiscoverableWorkspace';
description?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
name: Scalars['String']['output'];
};
export type EditCommentInput = {
commentId: Scalars['String']['input'];
content: CommentContentInput;
@@ -3365,6 +3378,8 @@ export type User = {
/** Returns the apps you have created. */
createdApps?: Maybe<Array<ServerApp>>;
createdAt?: Maybe<Scalars['DateTime']['output']>;
/** Get discoverable workspaces with verified domains that match the active user's */
discoverableWorkspaces: Array<DiscoverableWorkspace>;
/** Only returned if API user is the user being requested or an admin */
email?: Maybe<Scalars['String']['output']>;
emails: Array<UserEmail>;
@@ -4030,4 +4045,4 @@ export type AcccountTestQueryQueryVariables = Exact<{ [key: string]: never; }>;
export type AcccountTestQueryQuery = { __typename?: 'Query', serverInfo: { __typename?: 'ServerInfo', version?: string | null, name: string, company?: string | null } };
export const AcccountTestQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AcccountTestQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}}]}}]}}]} as unknown as DocumentNode<AcccountTestQueryQuery, AcccountTestQueryQueryVariables>;
export const AcccountTestQueryDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "AcccountTestQuery" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "serverInfo" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "version" } }, { "kind": "Field", "name": { "kind": "Name", "value": "name" } }, { "kind": "Field", "name": { "kind": "Name", "value": "company" } }] } }] } }] } as unknown as DocumentNode<AcccountTestQueryQuery, AcccountTestQueryQueryVariables>;
@@ -0,0 +1,42 @@
<template>
<FormSelectBase
v-model="selectedValue"
:items="domains"
name="workspaceDomains"
label="Verified domains"
class="min-w-[110px]"
size="sm"
>
<template #nothing-selected>Select domain</template>
<template #something-selected="{ value }">
<template v-if="isMultiItemArrayValue(value)">
<div v-for="v in value" :key="v">@{{ v }}</div>
</template>
<template v-else>@{{ value }}</template>
</template>
<template #option="{ item }">
<div class="flex items-center">@{{ item }}</div>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { useFormSelectChildInternals } from '@speckle/ui-components'
type ItemType = string
type ValueType = ItemType | ItemType[] | undefined
const emit = defineEmits<{
(e: 'update:modelValue', v: ValueType): void
}>()
const props = defineProps<{
domains: ItemType[]
modelValue: ValueType
}>()
const { selectedValue, isMultiItemArrayValue } = useFormSelectChildInternals<ItemType>({
props: toRefs(props),
emit
})
</script>
@@ -1,7 +1,12 @@
<template>
<div :class="mainClasses">
<div :class="mainInfoBlockClasses">
<UserAvatar :user="invite.invitedBy" :size="avatarSize" />
<UserAvatar v-if="invite.invitedBy" :user="invite.invitedBy" :size="avatarSize" />
<WorkspaceAvatar
v-if="invite.workspace"
:logo="invite.workspace.logo"
:default-logo-index="invite.workspace.defaultLogoIndex"
/>
<div class="text-foreground">
<slot name="message" />
</div>
@@ -9,6 +14,7 @@
<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
v-if="!invite.workspace"
:size="buttonSize"
color="subtle"
text
@@ -59,7 +65,12 @@ defineEmits<{
}>()
type GenericInviteItem = {
invitedBy: AvatarUserType
invitedBy?: AvatarUserType
workspace?: {
id: string
logo?: string
defaultLogoIndex: number
}
user?: MaybeNullOrUndefined<{
id: string
}>
@@ -3,7 +3,8 @@
<Portal to="primary-actions"></Portal>
<ProjectsDashboardHeader
:projects-invites="projectsPanelResult?.activeUser || undefined"
:workspaces-invites="workspacesInvitesResult?.activeUser || undefined"
:workspaces-invites="workspacesResult?.activeUser || undefined"
class="mb-10"
/>
<div v-if="!showEmptyState" class="flex flex-col gap-4">
@@ -64,7 +65,7 @@ import {
} from '@vue/apollo-composable'
import {
projectsDashboardQuery,
projectsDashboardWorkspaceInvitesQuery
projectsDashboardWorkspaceQuery
} from '~~/lib/projects/graphql/queries'
import { graphql } from '~~/lib/common/generated/gql'
import {
@@ -114,8 +115,8 @@ const {
}
}))
const { result: workspacesInvitesResult } = useQuery(
projectsDashboardWorkspaceInvitesQuery,
const { result: workspacesResult } = useQuery(
projectsDashboardWorkspaceQuery,
undefined,
() => ({
enabled: isWorkspacesEnabled.value
@@ -6,7 +6,10 @@
:invites="projectsInvites"
/>
<WorkspaceInviteBanners
v-if="workspacesInvites?.workspaceInvites?.length"
v-if="
workspacesInvites?.workspaceInvites?.length ||
workspacesInvites?.discoverableWorkspaces?.length
"
:invites="workspacesInvites"
/>
</div>
@@ -0,0 +1,224 @@
<template>
<section>
<div class="md:max-w-xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader
title="Security"
text="Manage verified workspace domains and associated features."
/>
<section>
<SettingsSectionHeader title="Workspace domains" subheading />
<div class="pt-4">
<FormButton @click="showAddDomainDialog = true">Add domain</FormButton>
<LayoutTable
class="mt-2 md:mt-4 w-full"
:columns="[
{ id: 'domain', header: 'Domain', classes: 'col-span-6' },
{ id: 'delete', header: 'Delete', classes: 'col-span-6' }
]"
:items="workspaceDomains"
>
<template #domain="{ item }">
<span class="text-body-xs text-foreground">
{{ `@${item.domain}` }}
</span>
</template>
<template #delete="{ item }">
<FormButton color="danger" @click="() => openRemoveDialog(item)">
Delete
</FormButton>
</template>
</LayoutTable>
</div>
</section>
<hr class="my-6 md:my-10" />
<div class="flex flex-col space-y-3">
<SettingsSectionHeader title="Domain features" subheading class="mb-3" />
<FormSwitch
v-model="isDomainProtectionEnabled"
name="domain-protection"
label="Enable domain protection"
/>
<FormSwitch
v-model="isDomainDiscoverabilityEnabled"
name="domain-discoverability"
label="Enable domain discoverability"
/>
</div>
</div>
<SettingsWorkspacesSecurityDomainAddDialog
v-if="verifiedUser && workspace"
v-model:open="showAddDomainDialog"
:workspace="workspace"
:verified-user="verifiedUser"
/>
<SettingsWorkspacesSecurityDomainRemoveDialog
v-if="removeDialogDomain"
v-model:open="showRemoveDomainDialog"
:workspace-id="workspaceId"
:domain="removeDialogDomain"
/>
</section>
</template>
<script setup lang="ts">
import { useApolloClient, useQuery } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import type {
Workspace,
SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragment
} from '~/lib/common/generated/gql/graphql'
import { SettingsUpdateWorkspaceSecurityDocument } from '~/lib/common/generated/gql/graphql'
import { getCacheId, getFirstErrorMessage } from '~/lib/common/helpers/graphql'
import { settingsWorkspacesSecurityQuery } from '~/lib/settings/graphql/queries'
graphql(`
fragment SettingsWorkspacesSecurity_Workspace on Workspace {
id
domains {
...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain
}
domainBasedMembershipProtectionEnabled
discoverabilityEnabled
}
fragment SettingsWorkspacesSecurity_User on User {
...SettingsWorkspacesSecurityDomainAddDialog_User
}
`)
const props = defineProps<{
workspaceId: string
}>()
const { triggerNotification } = useGlobalToast()
const apollo = useApolloClient().client
const showAddDomainDialog = ref(false)
const showRemoveDomainDialog = ref(false)
const removeDialogDomain =
ref<SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragment>()
const { result: workspaceSecuritySettings } = useQuery(
settingsWorkspacesSecurityQuery,
{
workspaceId: props.workspaceId
}
)
const workspace = computed(() => {
return workspaceSecuritySettings.value?.workspace
})
const verifiedUser = computed(() => {
return workspaceSecuritySettings.value?.activeUser
})
const workspaceDomains = computed(() => {
return workspaceSecuritySettings.value?.workspace.domains || []
})
const isDomainProtectionEnabled = computed({
get: () =>
workspaceSecuritySettings.value?.workspace.domainBasedMembershipProtectionEnabled ||
false,
set: async (newVal) => {
const result = await apollo
.mutate({
mutation: SettingsUpdateWorkspaceSecurityDocument,
variables: {
input: {
id: props.workspaceId,
domainBasedMembershipProtectionEnabled: newVal
}
},
optimisticResponse: {
workspaceMutations: {
update: {
__typename: 'Workspace',
id: props.workspaceId,
domainBasedMembershipProtectionEnabled: newVal,
discoverabilityEnabled:
workspaceSecuritySettings.value?.workspace.discoverabilityEnabled ||
false
}
}
},
update: (cache, res) => {
const { data } = res
if (!data?.workspaceMutations) return
cache.modify<Workspace>({
id: getCacheId('Workspace', props.workspaceId),
fields: {
domainBasedMembershipProtectionEnabled: () =>
res.data?.workspaceMutations.update
.domainBasedMembershipProtectionEnabled || false
}
})
}
})
.catch(convertThrowIntoFetchResult)
if (!result?.data) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to update',
description: getFirstErrorMessage(result?.errors)
})
}
}
})
const isDomainDiscoverabilityEnabled = computed({
get: () => workspaceSecuritySettings.value?.workspace.discoverabilityEnabled || false,
set: async (newVal) => {
const result = await apollo.mutate({
mutation: SettingsUpdateWorkspaceSecurityDocument,
variables: {
input: {
id: props.workspaceId,
discoverabilityEnabled: newVal
}
},
optimisticResponse: {
workspaceMutations: {
update: {
__typename: 'Workspace',
id: props.workspaceId,
domainBasedMembershipProtectionEnabled:
workspaceSecuritySettings.value?.workspace
.domainBasedMembershipProtectionEnabled || false,
discoverabilityEnabled: newVal
}
}
},
update: (cache, res) => {
const { data } = res
if (!data?.workspaceMutations) return
cache.modify<Workspace>({
id: getCacheId('Workspace', props.workspaceId),
fields: {
discoverabilityEnabled: () =>
res.data?.workspaceMutations.update.discoverabilityEnabled || false
}
})
}
})
if (!result?.data) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to update',
description: getFirstErrorMessage(result?.errors)
})
}
}
})
const openRemoveDialog = (
domain: SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragment
) => {
removeDialogDomain.value = domain
showRemoveDomainDialog.value = true
}
</script>
@@ -0,0 +1,161 @@
<template>
<LayoutDialog
v-model:open="isOpen"
title="Add domain"
max-width="sm"
:buttons="dialogButtons"
>
<div class="h-24">
<FormSelectWorkspaceDomains
:domains="verifiedUserDomains"
:model-value="selectedDomain"
@update:model-value="onSelectedDomainUpdate"
/>
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import { useApolloClient } from '@vue/apollo-composable'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { settingsAddWorkspaceDomainMutation } from '~/lib/settings/graphql/mutations'
import { getCacheId, getFirstErrorMessage } from '~/lib/common/helpers/graphql'
import type {
SettingsWorkspacesSecurityDomainAddDialog_UserFragment,
SettingsWorkspacesSecurityDomainAddDialog_WorkspaceFragment,
Workspace
} from '~/lib/common/generated/gql/graphql'
import { graphql } from '~/lib/common/generated/gql'
import { isString } from 'lodash-es'
graphql(`
fragment SettingsWorkspacesSecurityDomainAddDialog_Workspace on Workspace {
id
domains {
id
domain
}
discoverabilityEnabled
}
fragment SettingsWorkspacesSecurityDomainAddDialog_User on User {
id
emails {
id
email
verified
}
}
`)
const props = defineProps<{
workspace: SettingsWorkspacesSecurityDomainAddDialog_WorkspaceFragment
verifiedUser: SettingsWorkspacesSecurityDomainAddDialog_UserFragment
}>()
const { workspace } = toRefs(props)
const isOpen = defineModel<boolean>('open', { required: true })
const { triggerNotification } = useGlobalToast()
const apollo = useApolloClient().client
const selectedDomain = ref<string>('')
const onSelectedDomainUpdate = (e?: string | string[]) => {
if (!isString(e)) {
return
}
selectedDomain.value = e
}
const verifiedUserDomains = computed(() => [
...new Set(
(props.verifiedUser.emails ?? [])
.filter((email) => email.verified)
.map((email) => email.email.split('@')[1])
)
])
const onAdd = async () => {
const result = await apollo
.mutate({
mutation: settingsAddWorkspaceDomainMutation,
variables: {
input: {
domain: selectedDomain.value,
workspaceId: workspace.value.id
}
},
optimisticResponse: {
workspaceMutations: {
addDomain: {
__typename: 'Workspace',
id: workspace.value.id,
domains: [
...workspace.value.domains,
{
__typename: 'WorkspaceDomain',
id: '',
domain: selectedDomain.value
}
],
discoverabilityEnabled:
workspace.value.domains.length === 0
? true
: workspace.value.discoverabilityEnabled
}
}
},
update: (cache, res) => {
const { data } = res
if (!data?.workspaceMutations) return
cache.modify<Workspace>({
id: getCacheId('Workspace', props.workspace.id),
fields: {
discoverabilityEnabled() {
return data?.workspaceMutations.addDomain.discoverabilityEnabled || false
},
domains() {
return [...(data?.workspaceMutations.addDomain.domains || [])]
}
}
})
}
})
.catch(convertThrowIntoFetchResult)
if (result?.data) {
isOpen.value = false
triggerNotification({
type: ToastNotificationType.Success,
title: 'Domain added',
description: `The verified domain ${selectedDomain.value} has been added to your workspace`
})
} else {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to add verified domain',
description: getFirstErrorMessage(result?.errors)
})
}
}
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'outline', fullWidth: true },
onClick: () => {
isOpen.value = false
}
},
{
text: 'Add',
props: {
fullWidth: true,
color: 'primary'
},
onClick: onAdd
}
])
</script>
@@ -0,0 +1,112 @@
<template>
<LayoutDialog
v-model:open="isOpen"
title="Remove domain"
max-width="sm"
:buttons="dialogButtons"
>
<p class="text-body-xs text-foreground">
Are you sure you want to remove
<span class="font-semibold">@{{ domain.domain }}</span>
from your workspace's verified domains?
</p>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useApolloClient } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import {
type Workspace,
type SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragment
} from '~/lib/common/generated/gql/graphql'
import { getCacheId, getFirstErrorMessage } from '~/lib/common/helpers/graphql'
import { settingsDeleteWorkspaceDomainMutation } from '~/lib/settings/graphql/mutations'
graphql(`
fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {
id
domains {
...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain
}
}
fragment SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain on WorkspaceDomain {
id
domain
}
`)
const props = defineProps<{
workspaceId: string
domain: SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragment
}>()
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const isOpen = defineModel<boolean>('open', { required: true })
const handleRemove = async () => {
const result = await apollo
.mutate({
mutation: settingsDeleteWorkspaceDomainMutation,
variables: {
input: {
workspaceId: props.workspaceId,
id: props.domain.id
}
},
update: (cache, res) => {
const { data } = res
if (!data?.workspaceMutations) return
cache.modify<Workspace>({
id: getCacheId('Workspace', props.workspaceId),
fields: {
domains(currentDomains, { isReference }) {
return [...currentDomains].filter((domain) =>
isReference(domain) ? false : domain.id !== props.domain.id
)
}
}
})
}
})
.catch(convertThrowIntoFetchResult)
if (result?.data) {
isOpen.value = false
triggerNotification({
type: ToastNotificationType.Success,
title: 'Domain removed',
description: `Removed domain successfully`
})
} else {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to remove domain',
description: getFirstErrorMessage(result?.errors)
})
}
}
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'outline', fullWidth: true },
onClick: () => {
isOpen.value = false
}
},
{
text: 'Remove',
props: {
fullWidth: true,
color: 'danger'
},
onClick: handleRemove
}
])
</script>
@@ -1,6 +1,15 @@
<template>
<div class="flex flex-col divide-y divide-outline-3">
<WorkspaceInviteBanner v-for="item in items" :key="item.id" :invite="item" />
<WorkspaceInviteBanner
v-for="invite in invites"
:key="invite.id"
:invite="invite"
/>
<WorkspaceInviteDiscoverableWorkspaceBanner
v-for="workspace in discoverableWorkspaces"
:key="workspace.id"
:workspace="workspace"
/>
</div>
</template>
<script setup lang="ts">
@@ -13,6 +22,9 @@ import type { WorkspaceInviteBanners_UserFragment } from '~~/lib/common/generate
graphql(`
fragment WorkspaceInviteBanners_User on User {
discoverableWorkspaces {
...WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace
}
workspaceInvites {
...WorkspaceInviteBanner_PendingWorkspaceCollaborator
}
@@ -23,5 +35,8 @@ const props = defineProps<{
invites: WorkspaceInviteBanners_UserFragment
}>()
const items = computed(() => props.invites.workspaceInvites || [])
const invites = computed(() => props.invites.workspaceInvites || [])
const discoverableWorkspaces = computed(
() => props.invites.discoverableWorkspaces || []
)
</script>
@@ -0,0 +1,92 @@
<template>
<InviteBanner :invite="invite" @processed="processJoin">
<template #message>
You may join the workspace
<span class="font-medium">{{ workspace.name }}</span>
because it is part of your organization
</template>
</InviteBanner>
</template>
<script setup lang="ts">
import { useApolloClient } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import {
DashboardJoinWorkspaceDocument,
type WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspaceFragment
} from '~/lib/common/generated/gql/graphql'
import { getCacheId, getFirstErrorMessage } from '~/lib/common/helpers/graphql'
graphql(`
fragment WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace on DiscoverableWorkspace {
id
name
description
logo
defaultLogoIndex
}
fragment WorkspaceInviteDiscoverableWorkspaceBanner_Workspace on Workspace {
id
name
description
createdAt
updatedAt
logo
defaultLogoIndex
domainBasedMembershipProtectionEnabled
discoverabilityEnabled
}
`)
const props = defineProps<{
workspace: WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspaceFragment
}>()
const { client: apollo } = useApolloClient()
const { triggerNotification } = useGlobalToast()
const router = useRouter()
const invite = computed(() => ({
workspace: {
id: props.workspace.id,
logo: props.workspace.logo || undefined,
defaultLogoIndex: props.workspace.defaultLogoIndex
}
}))
const processJoin = async (accept: boolean) => {
if (!accept) {
// TODO: Use cookies to enable dismissing the discoverable workspace invite
return
}
const result = await apollo
.mutate({
mutation: DashboardJoinWorkspaceDocument,
variables: {
input: {
workspaceId: props.workspace.id
}
}
})
.catch(convertThrowIntoFetchResult)
if (result?.data) {
apollo.cache.evict({
id: getCacheId('DiscoverableWorkspace', props.workspace.id)
})
triggerNotification({
type: ToastNotificationType.Success,
title: 'Joined workspace',
description: 'Successfully joined workspace'
})
router.push(`/workspaces/${props.workspace.id}`)
} else {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to join workspace',
description: getFirstErrorMessage(result?.errors)
})
}
}
</script>
@@ -105,6 +105,7 @@ const documents = {
"\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n }\n": types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n": types.SettingsWorkspacesMembers_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsWorkspacesProjects_ProjectCollectionFragmentDoc,
"\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n fragment SettingsWorkspacesSecurity_User on User {\n ...SettingsWorkspacesSecurityDomainAddDialog_User\n }\n": types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n inviteId\n role\n title\n updatedAt\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaboratorFragmentDoc,
@@ -112,6 +113,8 @@ const documents = {
"\n fragment SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n": types.SettingsWorkspacesMembersMembersTable_WorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n": types.SettingsWorkspacesMembersMembersTable_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n role\n ...WorkspaceInviteDialog_Workspace\n }\n": types.SettingsWorkspacesMembersTableHeader_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecurityDomainAddDialog_Workspace on Workspace {\n id\n domains {\n id\n domain\n }\n discoverabilityEnabled\n }\n fragment SettingsWorkspacesSecurityDomainAddDialog_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n": types.SettingsWorkspacesSecurityDomainAddDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain on WorkspaceDomain {\n id\n domain\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n }\n": types.ModelPageProjectFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": types.ThreadCommentAttachmentFragmentDoc,
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": types.ViewerCommentsListItemFragmentDoc,
@@ -121,8 +124,9 @@ const documents = {
"\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 ...WorkspaceAvatar_Workspace\n id\n role\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 ...WorkspaceInviteDialog_Workspace\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 WorkspaceInviteBanners_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace\n }\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 fragment WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace on DiscoverableWorkspace {\n id\n name\n description\n logo\n defaultLogoIndex\n }\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_Workspace on Workspace {\n id\n name\n description\n createdAt\n updatedAt\n logo\n defaultLogoIndex\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n": types.WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspaceFragmentDoc,
"\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,
@@ -147,8 +151,9 @@ 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 mutation DashboardJoinWorkspace($input: JoinWorkspaceInput!) {\n workspaceMutations {\n join(input: $input) {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_Workspace\n }\n }\n }\n": types.DashboardJoinWorkspaceDocument,
"\n query DashboardProjectsPageQuery {\n activeUser {\n id\n projects(limit: 3) {\n items {\n ...DashboardProjectCard_Project\n }\n }\n ...ProjectsDashboardHeaderProjects_User\n }\n }\n": types.DashboardProjectsPageQueryDocument,
"\n query DashboardProjectsPageWorkspaceInvitesQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n": types.DashboardProjectsPageWorkspaceInvitesQueryDocument,
"\n query DashboardProjectsPageWorkspaceQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n": types.DashboardProjectsPageWorkspaceQueryDocument,
"\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,
@@ -199,8 +204,8 @@ 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 ...ProjectsDashboardHeaderProjects_User\n }\n }\n": types.ProjectsDashboardQueryDocument,
"\n query ProjectsDashboardWorkspaceInvitesQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n": types.ProjectsDashboardWorkspaceInvitesQueryDocument,
"\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 ...ProjectsDashboardHeaderProjects_User\n }\n }\n": types.ProjectsDashboardQueryDocument,
"\n query ProjectsDashboardWorkspaceQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n": types.ProjectsDashboardWorkspaceQueryDocument,
"\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,
@@ -246,9 +251,12 @@ const documents = {
"\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsDeleteUserEmailDocument,
"\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsSetPrimaryUserEmailDocument,
"\n mutation SettingsNewEmailVerification($input: EmailVerificationRequestInput!) {\n activeUserMutations {\n emailMutations {\n requestNewEmailVerification(input: $input)\n }\n }\n }\n": types.SettingsNewEmailVerificationDocument,
"\n mutation SettingsUpdateWorkspaceSecurity($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n }\n }\n": types.SettingsUpdateWorkspaceSecurityDocument,
"\n mutation SettingsDeleteWorkspace($workspaceId: String!) {\n workspaceMutations {\n delete(workspaceId: $workspaceId)\n }\n }\n": types.SettingsDeleteWorkspaceDocument,
"\n mutation SettingsResendWorkspaceInvite($input: WorkspaceInviteResendInput!) {\n workspaceMutations {\n invites {\n resend(input: $input)\n }\n }\n }\n": types.SettingsResendWorkspaceInviteDocument,
"\n mutation SettingsCancelWorkspaceInvite($workspaceId: String!, $inviteId: String!) {\n workspaceMutations {\n invites {\n cancel(workspaceId: $workspaceId, inviteId: $inviteId) {\n id\n }\n }\n }\n }\n": types.SettingsCancelWorkspaceInviteDocument,
"\n mutation AddWorkspaceDomain($input: AddDomainToWorkspaceInput!) {\n workspaceMutations {\n addDomain(input: $input) {\n ...SettingsWorkspacesSecurityDomainAddDialog_Workspace\n }\n }\n }\n": types.AddWorkspaceDomainDocument,
"\n mutation DeleteWorkspaceDomain($input: WorkspaceDomainDeleteInput!) {\n workspaceMutations {\n deleteDomain(input: $input) {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_Workspace\n }\n }\n }\n": types.DeleteWorkspaceDomainDocument,
"\n mutation SettingsLeaveWorkspace($leaveId: ID!) {\n workspaceMutations {\n leave(id: $leaveId)\n }\n }\n": types.SettingsLeaveWorkspaceDocument,
"\n query SettingsSidebar {\n activeUser {\n ...SettingsDialog_User\n }\n }\n": types.SettingsSidebarDocument,
"\n query SettingsWorkspaceGeneral($id: String!) {\n workspace(id: $id) {\n ...SettingsWorkspacesGeneral_Workspace\n }\n }\n": types.SettingsWorkspaceGeneralDocument,
@@ -256,6 +264,7 @@ const documents = {
"\n query SettingsWorkspacesInvitesSearch(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n": types.SettingsWorkspacesInvitesSearchDocument,
"\n query SettingsUserEmailsQuery {\n activeUser {\n ...SettingsUserEmails_User\n }\n }\n": types.SettingsUserEmailsQueryDocument,
"\n query SettingsWorkspacesProjects(\n $workspaceId: String!\n $limit: Int!\n $cursor: String\n $filter: WorkspaceProjectsFilter\n ) {\n workspace(id: $workspaceId) {\n id\n projects(limit: $limit, cursor: $cursor, filter: $filter) {\n cursor\n ...SettingsWorkspacesProjects_ProjectCollection\n }\n }\n }\n": types.SettingsWorkspacesProjectsDocument,
"\n query SettingsWorkspaceSecurity($workspaceId: String!) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesSecurity_Workspace\n }\n activeUser {\n ...SettingsWorkspacesSecurity_User\n }\n }\n": types.SettingsWorkspaceSecurityDocument,
"\n fragment AppAuthorAvatar on AppAuthor {\n id\n name\n avatar\n }\n": types.AppAuthorAvatarFragmentDoc,
"\n fragment LimitedUserAvatar on LimitedUser {\n id\n name\n avatar\n }\n": types.LimitedUserAvatarFragmentDoc,
"\n fragment ActiveUserAvatar on User {\n id\n name\n avatar\n }\n": types.ActiveUserAvatarFragmentDoc,
@@ -685,6 +694,10 @@ export function graphql(source: "\n fragment SettingsWorkspacesMembers_Workspac
* 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 SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\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 SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n fragment SettingsWorkspacesSecurity_User on User {\n ...SettingsWorkspacesSecurityDomainAddDialog_User\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n fragment SettingsWorkspacesSecurity_User on User {\n ...SettingsWorkspacesSecurityDomainAddDialog_User\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -713,6 +726,14 @@ export function graphql(source: "\n fragment SettingsWorkspacesMembersMembersTa
* 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 SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n role\n ...WorkspaceInviteDialog_Workspace\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n role\n ...WorkspaceInviteDialog_Workspace\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesSecurityDomainAddDialog_Workspace on Workspace {\n id\n domains {\n id\n domain\n }\n discoverabilityEnabled\n }\n fragment SettingsWorkspacesSecurityDomainAddDialog_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesSecurityDomainAddDialog_Workspace on Workspace {\n id\n domains {\n id\n domain\n }\n discoverabilityEnabled\n }\n fragment SettingsWorkspacesSecurityDomainAddDialog_User on User {\n id\n emails {\n id\n email\n verified\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 SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain on WorkspaceDomain {\n id\n domain\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain on WorkspaceDomain {\n id\n domain\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -752,11 +773,15 @@ export function graphql(source: "\n fragment WorkspaceInviteBanner_PendingWorks
/**
* 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"];
export function graphql(source: "\n fragment WorkspaceInviteBanners_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace\n }\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n fragment WorkspaceInviteBanners_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace\n }\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.
*/
export function graphql(source: "\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace on DiscoverableWorkspace {\n id\n name\n description\n logo\n defaultLogoIndex\n }\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_Workspace on Workspace {\n id\n name\n description\n createdAt\n updatedAt\n logo\n defaultLogoIndex\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n"): (typeof documents)["\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace on DiscoverableWorkspace {\n id\n name\n description\n logo\n defaultLogoIndex\n }\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_Workspace on Workspace {\n id\n name\n description\n createdAt\n updatedAt\n logo\n defaultLogoIndex\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -853,6 +878,10 @@ export function graphql(source: "\n query ProjectModelsSelectorValues($projectI
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query MainServerInfoData {\n serverInfo {\n adminContact\n blobSizeLimitBytes\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n }\n }\n"): (typeof documents)["\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"];
/**
* 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 DashboardJoinWorkspace($input: JoinWorkspaceInput!) {\n workspaceMutations {\n join(input: $input) {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_Workspace\n }\n }\n }\n"): (typeof documents)["\n mutation DashboardJoinWorkspace($input: JoinWorkspaceInput!) {\n workspaceMutations {\n join(input: $input) {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_Workspace\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -860,7 +889,7 @@ export function graphql(source: "\n query DashboardProjectsPageQuery {\n act
/**
* 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 DashboardProjectsPageWorkspaceInvitesQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n"): (typeof documents)["\n query DashboardProjectsPageWorkspaceInvitesQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n"];
export function graphql(source: "\n query DashboardProjectsPageWorkspaceQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n"): (typeof documents)["\n query DashboardProjectsPageWorkspaceQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1064,11 +1093,11 @@ 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 ...ProjectsDashboardHeaderProjects_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 ...ProjectsDashboardHeaderProjects_User\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 ...ProjectsInviteBanners\n ...ProjectsDashboardHeaderProjects_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 ...ProjectsInviteBanners\n ...ProjectsDashboardHeaderProjects_User\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 ProjectsDashboardWorkspaceInvitesQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n"): (typeof documents)["\n query ProjectsDashboardWorkspaceInvitesQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n"];
export function graphql(source: "\n query ProjectsDashboardWorkspaceQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n"): (typeof documents)["\n query ProjectsDashboardWorkspaceQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1249,6 +1278,10 @@ export function graphql(source: "\n mutation SettingsSetPrimaryUserEmail($input
* 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 SettingsNewEmailVerification($input: EmailVerificationRequestInput!) {\n activeUserMutations {\n emailMutations {\n requestNewEmailVerification(input: $input)\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsNewEmailVerification($input: EmailVerificationRequestInput!) {\n activeUserMutations {\n emailMutations {\n requestNewEmailVerification(input: $input)\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation SettingsUpdateWorkspaceSecurity($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsUpdateWorkspaceSecurity($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1261,6 +1294,14 @@ export function graphql(source: "\n mutation SettingsResendWorkspaceInvite($inp
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation SettingsCancelWorkspaceInvite($workspaceId: String!, $inviteId: String!) {\n workspaceMutations {\n invites {\n cancel(workspaceId: $workspaceId, inviteId: $inviteId) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsCancelWorkspaceInvite($workspaceId: String!, $inviteId: String!) {\n workspaceMutations {\n invites {\n cancel(workspaceId: $workspaceId, inviteId: $inviteId) {\n id\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation AddWorkspaceDomain($input: AddDomainToWorkspaceInput!) {\n workspaceMutations {\n addDomain(input: $input) {\n ...SettingsWorkspacesSecurityDomainAddDialog_Workspace\n }\n }\n }\n"): (typeof documents)["\n mutation AddWorkspaceDomain($input: AddDomainToWorkspaceInput!) {\n workspaceMutations {\n addDomain(input: $input) {\n ...SettingsWorkspacesSecurityDomainAddDialog_Workspace\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation DeleteWorkspaceDomain($input: WorkspaceDomainDeleteInput!) {\n workspaceMutations {\n deleteDomain(input: $input) {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_Workspace\n }\n }\n }\n"): (typeof documents)["\n mutation DeleteWorkspaceDomain($input: WorkspaceDomainDeleteInput!) {\n workspaceMutations {\n deleteDomain(input: $input) {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_Workspace\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1289,6 +1330,10 @@ export function graphql(source: "\n query SettingsUserEmailsQuery {\n active
* 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 SettingsWorkspacesProjects(\n $workspaceId: String!\n $limit: Int!\n $cursor: String\n $filter: WorkspaceProjectsFilter\n ) {\n workspace(id: $workspaceId) {\n id\n projects(limit: $limit, cursor: $cursor, filter: $filter) {\n cursor\n ...SettingsWorkspacesProjects_ProjectCollection\n }\n }\n }\n"): (typeof documents)["\n query SettingsWorkspacesProjects(\n $workspaceId: String!\n $limit: Int!\n $cursor: String\n $filter: WorkspaceProjectsFilter\n ) {\n workspace(id: $workspaceId) {\n id\n projects(limit: $limit, cursor: $cursor, filter: $filter) {\n cursor\n ...SettingsWorkspacesProjects_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 SettingsWorkspaceSecurity($workspaceId: String!) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesSecurity_Workspace\n }\n activeUser {\n ...SettingsWorkspacesSecurity_User\n }\n }\n"): (typeof documents)["\n query SettingsWorkspaceSecurity($workspaceId: String!) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesSecurity_Workspace\n }\n activeUser {\n ...SettingsWorkspacesSecurity_User\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
import { graphql } from '~~/lib/common/generated/gql'
export const dashboardJoinWorkspaceMutation = graphql(`
mutation DashboardJoinWorkspace($input: JoinWorkspaceInput!) {
workspaceMutations {
join(input: $input) {
...WorkspaceInviteDiscoverableWorkspaceBanner_Workspace
}
}
}
`)
@@ -14,8 +14,8 @@ export const dashboardProjectsPageQuery = graphql(`
}
`)
export const dashboardProjectsPageWorkspaceInvitesQuery = graphql(`
query DashboardProjectsPageWorkspaceInvitesQuery {
export const dashboardProjectsPageWorkspacesQuery = graphql(`
query DashboardProjectsPageWorkspaceQuery {
activeUser {
id
...ProjectsDashboardHeaderWorkspaces_User
@@ -28,13 +28,14 @@ export const projectsDashboardQuery = graphql(`
...ProjectDashboardItem
}
}
...ProjectsInviteBanners
...ProjectsDashboardHeaderProjects_User
}
}
`)
export const projectsDashboardWorkspaceInvitesQuery = graphql(`
query ProjectsDashboardWorkspaceInvitesQuery {
export const projectsDashboardWorkspaceQuery = graphql(`
query ProjectsDashboardWorkspaceQuery {
activeUser {
id
...ProjectsDashboardHeaderWorkspaces_User
@@ -10,6 +10,7 @@ import SettingsServerActiveUsers from '~/components/settings/server/ActiveUsers.
import SettingsServerPendingInvitations from '~/components/settings/server/PendingInvitations.vue'
import SettingsWorkspaceGeneral from '~/components/settings/workspaces/General.vue'
import SettingsWorkspacesMembers from '~/components/settings/workspaces/Members.vue'
import SettingsWorkspacesSecurity from '~/components/settings/workspaces/Security.vue'
import SettingsWorkspacesProjects from '~/components/settings/workspaces/Projects.vue'
import { useIsMultipleEmailsEnabled } from '~/composables/globals'
@@ -27,16 +28,15 @@ export const useSettingsMenu = () => {
title: 'Projects',
component: SettingsWorkspacesProjects
},
security: {
title: 'Security',
component: SettingsWorkspacesSecurity
},
billing: {
title: 'Billing',
disabled: true,
tooltipText: 'Manage billing for your workspace'
},
security: {
title: 'Security',
disabled: true,
tooltipText: 'SSO, manage permissions, restrict domain access'
},
regions: {
title: 'Regions',
disabled: true,
@@ -56,6 +56,18 @@ export const settingsNewEmailVerificationMutation = graphql(`
}
`)
export const settingsUpdateWorkspaceSecurity = graphql(`
mutation SettingsUpdateWorkspaceSecurity($input: WorkspaceUpdateInput!) {
workspaceMutations {
update(input: $input) {
id
domainBasedMembershipProtectionEnabled
discoverabilityEnabled
}
}
}
`)
export const deleteWorkspaceMutation = graphql(`
mutation SettingsDeleteWorkspace($workspaceId: String!) {
workspaceMutations {
@@ -86,6 +98,25 @@ export const settingsCancelWorkspaceInviteMutation = graphql(`
}
`)
export const settingsAddWorkspaceDomainMutation = graphql(`
mutation AddWorkspaceDomain($input: AddDomainToWorkspaceInput!) {
workspaceMutations {
addDomain(input: $input) {
...SettingsWorkspacesSecurityDomainAddDialog_Workspace
}
}
}
`)
export const settingsDeleteWorkspaceDomainMutation = graphql(`
mutation DeleteWorkspaceDomain($input: WorkspaceDomainDeleteInput!) {
workspaceMutations {
deleteDomain(input: $input) {
...SettingsWorkspacesSecurityDomainRemoveDialog_Workspace
}
}
}
`)
export const settingsLeaveWorkspaceMutation = graphql(`
mutation SettingsLeaveWorkspace($leaveId: ID!) {
workspaceMutations {
@@ -65,3 +65,14 @@ export const settingsWorkspacesProjectsQuery = graphql(`
}
}
`)
export const settingsWorkspacesSecurityQuery = graphql(`
query SettingsWorkspaceSecurity($workspaceId: String!) {
workspace(id: $workspaceId) {
...SettingsWorkspacesSecurity_Workspace
}
activeUser {
...SettingsWorkspacesSecurity_User
}
}
`)
+4 -4
View File
@@ -5,7 +5,7 @@
</Portal>
<ProjectsDashboardHeader
:projects-invites="projectsResult?.activeUser || undefined"
:workspaces-invites="workspaceInvitesResult?.activeUser || undefined"
:workspaces-invites="workspacesResult?.activeUser || undefined"
/>
<div class="flex flex-col gap-y-12">
<div class="flex flex-col-reverse lg:flex-col gap-y-12">
@@ -56,7 +56,7 @@
<script setup lang="ts">
import {
dashboardProjectsPageQuery,
dashboardProjectsPageWorkspaceInvitesQuery
dashboardProjectsPageWorkspacesQuery
} from '~~/lib/dashboard/graphql/queries'
import type { QuickStartItem } from '~~/lib/dashboard/helpers/types'
import { getResizedGhostImage } from '~~/lib/dashboard/helpers/utils'
@@ -80,8 +80,8 @@ const config = useRuntimeConfig()
const mixpanel = useMixpanel()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { result: projectsResult } = useQuery(dashboardProjectsPageQuery)
const { result: workspaceInvitesResult } = useQuery(
dashboardProjectsPageWorkspaceInvitesQuery,
const { result: workspacesResult } = useQuery(
dashboardProjectsPageWorkspacesQuery,
undefined,
() => ({
enabled: isWorkspacesEnabled.value
@@ -28,6 +28,8 @@ input WorkspaceUpdateInput {
"""
logo: String
defaultLogoIndex: Int
domainBasedMembershipProtectionEnabled: Boolean
discoverabilityEnabled: Boolean
}
input WorkspaceRoleUpdateInput {
@@ -67,6 +69,15 @@ input WorkspaceProjectInviteCreateInput {
workspaceRole: String
}
input AddDomainToWorkspaceInput {
domain: String!
workspaceId: ID!
}
input JoinWorkspaceInput {
workspaceId: ID!
}
extend type ProjectInviteMutations {
"""
Create invite(-s) for a project in a workspace. Unlike the base create() mutation, this allows
@@ -97,6 +108,20 @@ type WorkspaceMutations {
@hasServerRole(role: SERVER_USER)
leave(id: ID!): Boolean! @hasServerRole(role: SERVER_GUEST)
invites: WorkspaceInviteMutations!
# TODO: this mutation should have an hasWorkspaceRole directive to authorize only workspace admin
# We are, for the moment, doing the check in the resolver
addDomain(input: AddDomainToWorkspaceInput!): Workspace!
@hasScope(scope: "workspace:update")
# TODO: this mutation should have an hasWorkspaceRole directive to authorize only workspace admin
# We are, for the moment, doing the check in the resolver
deleteDomain(input: WorkspaceDomainDeleteInput!): Workspace!
@hasScope(scope: "workspace:update")
join(input: JoinWorkspaceInput!): Workspace! @hasScope(scope: "workspace:update")
}
input WorkspaceDomainDeleteInput {
workspaceId: ID!
id: ID!
}
input WorkspaceInviteCreateInput {
@@ -168,6 +193,10 @@ type Workspace {
"""
defaultLogoIndex: Int!
"""
Verified workspace domains
"""
domains: [WorkspaceDomain!]! @hasWorkspaceRole(role: ADMIN)
"""
Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you.
"""
role: String
@@ -183,6 +212,27 @@ type Workspace {
cursor: String
filter: WorkspaceProjectsFilter
): ProjectCollection!
"""
Enable/Disable restriction to invite users to workspace as Guests only
"""
domainBasedMembershipProtectionEnabled: Boolean!
"""
Enable/Disable discovery of the workspace
"""
discoverabilityEnabled: Boolean!
}
type DiscoverableWorkspace {
id: ID!
name: String!
description: String
logo: String
defaultLogoIndex: Int!
}
type WorkspaceDomain {
id: ID!
domain: String!
}
input WorkspaceProjectsFilter {
@@ -247,6 +297,11 @@ type WorkspaceCollection {
}
extend type User {
"""
Get discoverable workspaces with verified domains that match the active user's
"""
discoverableWorkspaces: [DiscoverableWorkspace!]!
"""
Get the workspaces for the user
"""
@@ -64,6 +64,15 @@ export type FindEmailsByUserId = ({
userId
}: Pick<Partial<UserEmail>, 'userId'>) => Promise<UserEmail[]>
export type FindVerifiedEmailsByUserId = ({
userId
}: Pick<UserEmail, 'userId'>) => Promise<UserEmail[]>
export type FindVerifiedEmailByUserIdAndDomain = ({
userId,
domain
}: Pick<UserEmail, 'userId'> & { domain: string }) => Promise<UserEmail | null>
export type CountEmailsByUserId = ({
userId
}: Pick<UserEmail, 'userId'>) => Promise<number>
@@ -65,6 +65,11 @@ export type ActivityCollection = {
totalCount: Scalars['Int']['output'];
};
export type AddDomainToWorkspaceInput = {
domain: Scalars['String']['input'];
workspaceId: Scalars['ID']['input'];
};
export type AdminInviteList = {
__typename?: 'AdminInviteList';
cursor?: Maybe<Scalars['String']['output']>;
@@ -857,6 +862,15 @@ export type DiscoverableStreamsSortingInput = {
type: DiscoverableStreamsSortType;
};
export type DiscoverableWorkspace = {
__typename?: 'DiscoverableWorkspace';
defaultLogoIndex: Scalars['Int']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
logo?: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
};
export type EditCommentInput = {
commentId: Scalars['String']['input'];
content: CommentContentInput;
@@ -930,6 +944,10 @@ export type GendoAiRenderInput = {
versionId: Scalars['ID']['input'];
};
export type JoinWorkspaceInput = {
workspaceId: Scalars['ID']['input'];
};
export type LegacyCommentViewerData = {
__typename?: 'LegacyCommentViewerData';
/**
@@ -3379,6 +3397,8 @@ export type User = {
/** Returns the apps you have created. */
createdApps?: Maybe<Array<ServerApp>>;
createdAt?: Maybe<Scalars['DateTime']['output']>;
/** Get discoverable workspaces with verified domains that match the active user's */
discoverableWorkspaces: Array<DiscoverableWorkspace>;
/** Only returned if API user is the user being requested or an admin */
email?: Maybe<Scalars['String']['output']>;
emails: Array<UserEmail>;
@@ -3838,6 +3858,12 @@ export type Workspace = {
/** Selected fallback when `logo` not set */
defaultLogoIndex: Scalars['Int']['output'];
description?: Maybe<Scalars['String']['output']>;
/** Enable/Disable discovery of the workspace */
discoverabilityEnabled: Scalars['Boolean']['output'];
/** Enable/Disable restriction to invite users to workspace as Guests only */
domainBasedMembershipProtectionEnabled: Scalars['Boolean']['output'];
/** Verified workspace domains */
domains: Array<WorkspaceDomain>;
id: Scalars['ID']['output'];
/** Only available to workspace owners */
invitedTeam?: Maybe<Array<PendingWorkspaceCollaborator>>;
@@ -3888,6 +3914,17 @@ export type WorkspaceCreateInput = {
name: Scalars['String']['input'];
};
export type WorkspaceDomain = {
__typename?: 'WorkspaceDomain';
domain: Scalars['String']['output'];
id: Scalars['ID']['output'];
};
export type WorkspaceDomainDeleteInput = {
id: Scalars['ID']['input'];
workspaceId: Scalars['ID']['input'];
};
export type WorkspaceInviteCreateInput = {
/** Either this or userId must be filled */
email?: InputMaybe<Scalars['String']['input']>;
@@ -3953,15 +3990,23 @@ export type WorkspaceInviteUseInput = {
export type WorkspaceMutations = {
__typename?: 'WorkspaceMutations';
addDomain: Workspace;
create: Workspace;
delete: Scalars['Boolean']['output'];
deleteDomain: Workspace;
invites: WorkspaceInviteMutations;
join: Workspace;
leave: Scalars['Boolean']['output'];
update: Workspace;
updateRole: Workspace;
};
export type WorkspaceMutationsAddDomainArgs = {
input: AddDomainToWorkspaceInput;
};
export type WorkspaceMutationsCreateArgs = {
input: WorkspaceCreateInput;
};
@@ -3972,6 +4017,16 @@ export type WorkspaceMutationsDeleteArgs = {
};
export type WorkspaceMutationsDeleteDomainArgs = {
input: WorkspaceDomainDeleteInput;
};
export type WorkspaceMutationsJoinArgs = {
input: JoinWorkspaceInput;
};
export type WorkspaceMutationsLeaveArgs = {
id: Scalars['ID']['input'];
};
@@ -4032,6 +4087,8 @@ export type WorkspaceTeamFilter = {
export type WorkspaceUpdateInput = {
defaultLogoIndex?: InputMaybe<Scalars['Int']['input']>;
description?: InputMaybe<Scalars['String']['input']>;
discoverabilityEnabled?: InputMaybe<Scalars['Boolean']['input']>;
domainBasedMembershipProtectionEnabled?: InputMaybe<Scalars['Boolean']['input']>;
id: Scalars['String']['input'];
/** Logo image as base64-encoded string */
logo?: InputMaybe<Scalars['String']['input']>;
@@ -4112,6 +4169,7 @@ export type ResolversTypes = {
ActiveUserMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
Activity: ResolverTypeWrapper<Activity>;
ActivityCollection: ResolverTypeWrapper<ActivityCollection>;
AddDomainToWorkspaceInput: AddDomainToWorkspaceInput;
AdminInviteList: ResolverTypeWrapper<Omit<AdminInviteList, 'items'> & { items: Array<ResolversTypes['ServerInvite']> }>;
AdminQueries: ResolverTypeWrapper<GraphQLEmptyReturn>;
AdminUserList: ResolverTypeWrapper<AdminUserList>;
@@ -4191,6 +4249,7 @@ export type ResolversTypes = {
DeleteVersionsInput: DeleteVersionsInput;
DiscoverableStreamsSortType: DiscoverableStreamsSortType;
DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput;
DiscoverableWorkspace: ResolverTypeWrapper<DiscoverableWorkspace>;
EditCommentInput: EditCommentInput;
EmailVerificationRequestInput: EmailVerificationRequestInput;
FileUpload: ResolverTypeWrapper<FileUploadGraphQLReturn>;
@@ -4201,6 +4260,7 @@ export type ResolversTypes = {
ID: ResolverTypeWrapper<Scalars['ID']['output']>;
Int: ResolverTypeWrapper<Scalars['Int']['output']>;
JSONObject: ResolverTypeWrapper<Scalars['JSONObject']['output']>;
JoinWorkspaceInput: JoinWorkspaceInput;
LegacyCommentViewerData: ResolverTypeWrapper<LegacyCommentViewerData>;
LimitedUser: ResolverTypeWrapper<LimitedUserGraphQLReturn>;
MarkReceivedVersionInput: MarkReceivedVersionInput;
@@ -4341,6 +4401,8 @@ export type ResolversTypes = {
WorkspaceCollaborator: ResolverTypeWrapper<WorkspaceCollaboratorGraphQLReturn>;
WorkspaceCollection: ResolverTypeWrapper<Omit<WorkspaceCollection, 'items'> & { items: Array<ResolversTypes['Workspace']> }>;
WorkspaceCreateInput: WorkspaceCreateInput;
WorkspaceDomain: ResolverTypeWrapper<WorkspaceDomain>;
WorkspaceDomainDeleteInput: WorkspaceDomainDeleteInput;
WorkspaceInviteCreateInput: WorkspaceInviteCreateInput;
WorkspaceInviteMutations: ResolverTypeWrapper<WorkspaceInviteMutationsGraphQLReturn>;
WorkspaceInviteResendInput: WorkspaceInviteResendInput;
@@ -4360,6 +4422,7 @@ export type ResolversParentTypes = {
ActiveUserMutations: MutationsObjectGraphQLReturn;
Activity: Activity;
ActivityCollection: ActivityCollection;
AddDomainToWorkspaceInput: AddDomainToWorkspaceInput;
AdminInviteList: Omit<AdminInviteList, 'items'> & { items: Array<ResolversParentTypes['ServerInvite']> };
AdminQueries: GraphQLEmptyReturn;
AdminUserList: AdminUserList;
@@ -4435,6 +4498,7 @@ export type ResolversParentTypes = {
DeleteUserEmailInput: DeleteUserEmailInput;
DeleteVersionsInput: DeleteVersionsInput;
DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput;
DiscoverableWorkspace: DiscoverableWorkspace;
EditCommentInput: EditCommentInput;
EmailVerificationRequestInput: EmailVerificationRequestInput;
FileUpload: FileUploadGraphQLReturn;
@@ -4445,6 +4509,7 @@ export type ResolversParentTypes = {
ID: Scalars['ID']['output'];
Int: Scalars['Int']['output'];
JSONObject: Scalars['JSONObject']['output'];
JoinWorkspaceInput: JoinWorkspaceInput;
LegacyCommentViewerData: LegacyCommentViewerData;
LimitedUser: LimitedUserGraphQLReturn;
MarkReceivedVersionInput: MarkReceivedVersionInput;
@@ -4568,6 +4633,8 @@ export type ResolversParentTypes = {
WorkspaceCollaborator: WorkspaceCollaboratorGraphQLReturn;
WorkspaceCollection: Omit<WorkspaceCollection, 'items'> & { items: Array<ResolversParentTypes['Workspace']> };
WorkspaceCreateInput: WorkspaceCreateInput;
WorkspaceDomain: WorkspaceDomain;
WorkspaceDomainDeleteInput: WorkspaceDomainDeleteInput;
WorkspaceInviteCreateInput: WorkspaceInviteCreateInput;
WorkspaceInviteMutations: WorkspaceInviteMutationsGraphQLReturn;
WorkspaceInviteResendInput: WorkspaceInviteResendInput;
@@ -5009,6 +5076,15 @@ export interface DateTimeScalarConfig extends GraphQLScalarTypeConfig<ResolversT
name: 'DateTime';
}
export type DiscoverableWorkspaceResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['DiscoverableWorkspace'] = ResolversParentTypes['DiscoverableWorkspace']> = {
defaultLogoIndex?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
logo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FileUploadResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['FileUpload'] = ResolversParentTypes['FileUpload']> = {
branchName?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
convertedCommitId?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@@ -5739,6 +5815,7 @@ export type UserResolvers<ContextType = GraphQLContext, ParentType extends Resol
company?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
createdApps?: Resolver<Maybe<Array<ResolversTypes['ServerApp']>>, ParentType, ContextType>;
createdAt?: Resolver<Maybe<ResolversTypes['DateTime']>, ParentType, ContextType>;
discoverableWorkspaces?: Resolver<Array<ResolversTypes['DiscoverableWorkspace']>, ParentType, ContextType>;
email?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
emails?: Resolver<Array<ResolversTypes['UserEmail']>, ParentType, ContextType>;
favoriteStreams?: Resolver<ResolversTypes['StreamCollection'], ParentType, ContextType, RequireFields<UserFavoriteStreamsArgs, 'limit'>>;
@@ -5909,6 +5986,9 @@ export type WorkspaceResolvers<ContextType = GraphQLContext, ParentType extends
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
defaultLogoIndex?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
discoverabilityEnabled?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
domainBasedMembershipProtectionEnabled?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
domains?: Resolver<Array<ResolversTypes['WorkspaceDomain']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
invitedTeam?: Resolver<Maybe<Array<ResolversTypes['PendingWorkspaceCollaborator']>>, ParentType, ContextType, Partial<WorkspaceInvitedTeamArgs>>;
logo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@@ -5934,6 +6014,12 @@ export type WorkspaceCollectionResolvers<ContextType = GraphQLContext, ParentTyp
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspaceDomainResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceDomain'] = ResolversParentTypes['WorkspaceDomain']> = {
domain?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspaceInviteMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceInviteMutations'] = ResolversParentTypes['WorkspaceInviteMutations']> = {
batchCreate?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceInviteMutationsBatchCreateArgs, 'input' | 'workspaceId'>>;
cancel?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceInviteMutationsCancelArgs, 'inviteId' | 'workspaceId'>>;
@@ -5944,9 +6030,12 @@ export type WorkspaceInviteMutationsResolvers<ContextType = GraphQLContext, Pare
};
export type WorkspaceMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceMutations'] = ResolversParentTypes['WorkspaceMutations']> = {
addDomain?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsAddDomainArgs, 'input'>>;
create?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsCreateArgs, 'input'>>;
delete?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceMutationsDeleteArgs, 'workspaceId'>>;
deleteDomain?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsDeleteDomainArgs, 'input'>>;
invites?: Resolver<ResolversTypes['WorkspaceInviteMutations'], ParentType, ContextType>;
join?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsJoinArgs, 'input'>>;
leave?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceMutationsLeaveArgs, 'id'>>;
update?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsUpdateArgs, 'input'>>;
updateRole?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsUpdateRoleArgs, 'input'>>;
@@ -5999,6 +6088,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
CommitCollection?: CommitCollectionResolvers<ContextType>;
CountOnlyCollection?: CountOnlyCollectionResolvers<ContextType>;
DateTime?: GraphQLScalarType;
DiscoverableWorkspace?: DiscoverableWorkspaceResolvers<ContextType>;
FileUpload?: FileUploadResolvers<ContextType>;
GendoAIRender?: GendoAiRenderResolvers<ContextType>;
GendoAIRenderCollection?: GendoAiRenderCollectionResolvers<ContextType>;
@@ -6083,6 +6173,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
Workspace?: WorkspaceResolvers<ContextType>;
WorkspaceCollaborator?: WorkspaceCollaboratorResolvers<ContextType>;
WorkspaceCollection?: WorkspaceCollectionResolvers<ContextType>;
WorkspaceDomain?: WorkspaceDomainResolvers<ContextType>;
WorkspaceInviteMutations?: WorkspaceInviteMutationsResolvers<ContextType>;
WorkspaceMutations?: WorkspaceMutationsResolvers<ContextType>;
};
@@ -166,3 +166,12 @@ export const setPrimaryUserEmailFactory =
})
return true
}
export const findVerifiedEmailsByUserIdFactory =
({ db }: { db: Knex }): FindEmailsByUserId =>
async ({ userId }) => {
return db(UserEmails.name).where({
[UserEmails.col.userId]: userId,
[UserEmails.col.verified]: true
})
}
@@ -54,6 +54,11 @@ export type ActivityCollection = {
totalCount: Scalars['Int']['output'];
};
export type AddDomainToWorkspaceInput = {
domain: Scalars['String']['input'];
workspaceId: Scalars['ID']['input'];
};
export type AdminInviteList = {
__typename?: 'AdminInviteList';
cursor?: Maybe<Scalars['String']['output']>;
@@ -846,6 +851,15 @@ export type DiscoverableStreamsSortingInput = {
type: DiscoverableStreamsSortType;
};
export type DiscoverableWorkspace = {
__typename?: 'DiscoverableWorkspace';
defaultLogoIndex: Scalars['Int']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
logo?: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
};
export type EditCommentInput = {
commentId: Scalars['String']['input'];
content: CommentContentInput;
@@ -919,6 +933,10 @@ export type GendoAiRenderInput = {
versionId: Scalars['ID']['input'];
};
export type JoinWorkspaceInput = {
workspaceId: Scalars['ID']['input'];
};
export type LegacyCommentViewerData = {
__typename?: 'LegacyCommentViewerData';
/**
@@ -3368,6 +3386,8 @@ export type User = {
/** Returns the apps you have created. */
createdApps?: Maybe<Array<ServerApp>>;
createdAt?: Maybe<Scalars['DateTime']['output']>;
/** Get discoverable workspaces with verified domains that match the active user's */
discoverableWorkspaces: Array<DiscoverableWorkspace>;
/** Only returned if API user is the user being requested or an admin */
email?: Maybe<Scalars['String']['output']>;
emails: Array<UserEmail>;
@@ -3827,6 +3847,12 @@ export type Workspace = {
/** Selected fallback when `logo` not set */
defaultLogoIndex: Scalars['Int']['output'];
description?: Maybe<Scalars['String']['output']>;
/** Enable/Disable discovery of the workspace */
discoverabilityEnabled: Scalars['Boolean']['output'];
/** Enable/Disable restriction to invite users to workspace as Guests only */
domainBasedMembershipProtectionEnabled: Scalars['Boolean']['output'];
/** Verified workspace domains */
domains: Array<WorkspaceDomain>;
id: Scalars['ID']['output'];
/** Only available to workspace owners */
invitedTeam?: Maybe<Array<PendingWorkspaceCollaborator>>;
@@ -3877,6 +3903,17 @@ export type WorkspaceCreateInput = {
name: Scalars['String']['input'];
};
export type WorkspaceDomain = {
__typename?: 'WorkspaceDomain';
domain: Scalars['String']['output'];
id: Scalars['ID']['output'];
};
export type WorkspaceDomainDeleteInput = {
id: Scalars['ID']['input'];
workspaceId: Scalars['ID']['input'];
};
export type WorkspaceInviteCreateInput = {
/** Either this or userId must be filled */
email?: InputMaybe<Scalars['String']['input']>;
@@ -3942,15 +3979,23 @@ export type WorkspaceInviteUseInput = {
export type WorkspaceMutations = {
__typename?: 'WorkspaceMutations';
addDomain: Workspace;
create: Workspace;
delete: Scalars['Boolean']['output'];
deleteDomain: Workspace;
invites: WorkspaceInviteMutations;
join: Workspace;
leave: Scalars['Boolean']['output'];
update: Workspace;
updateRole: Workspace;
};
export type WorkspaceMutationsAddDomainArgs = {
input: AddDomainToWorkspaceInput;
};
export type WorkspaceMutationsCreateArgs = {
input: WorkspaceCreateInput;
};
@@ -3961,6 +4006,16 @@ export type WorkspaceMutationsDeleteArgs = {
};
export type WorkspaceMutationsDeleteDomainArgs = {
input: WorkspaceDomainDeleteInput;
};
export type WorkspaceMutationsJoinArgs = {
input: JoinWorkspaceInput;
};
export type WorkspaceMutationsLeaveArgs = {
id: Scalars['ID']['input'];
};
@@ -4021,6 +4076,8 @@ export type WorkspaceTeamFilter = {
export type WorkspaceUpdateInput = {
defaultLogoIndex?: InputMaybe<Scalars['Int']['input']>;
description?: InputMaybe<Scalars['String']['input']>;
discoverabilityEnabled?: InputMaybe<Scalars['Boolean']['input']>;
domainBasedMembershipProtectionEnabled?: InputMaybe<Scalars['Boolean']['input']>;
id: Scalars['String']['input'];
/** Logo image as base64-encoded string */
logo?: InputMaybe<Scalars['String']['input']>;
@@ -45,7 +45,7 @@ type EventPayloadsMap = UnionToIntersection<
EventPayloadsByNamespaceMap[keyof EventPayloadsByNamespaceMap]
>
type EventNames = keyof EventPayloadsMap
export type EventNames = keyof EventPayloadsMap
type EventPayloadsByNamespaceMap = {
// for each event namespace
@@ -5,7 +5,7 @@ import { Roles } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
const createFakeWorkspace = (): Workspace => {
const createFakeWorkspace = (): Omit<Workspace, 'domains'> => {
return {
id: cryptoRandomString({ length: 10 }),
description: cryptoRandomString({ length: 10 }),
@@ -13,7 +13,9 @@ const createFakeWorkspace = (): Workspace => {
defaultLogoIndex: 0,
name: cryptoRandomString({ length: 10 }),
updatedAt: new Date(),
createdAt: new Date()
createdAt: new Date(),
domainBasedMembershipProtectionEnabled: false,
discoverabilityEnabled: false
}
}
@@ -131,10 +133,14 @@ describe('Event Bus', () => {
const workspacePayload = {
...createFakeWorkspace(),
createdByUserId: cryptoRandomString({ length: 10 }),
eventName: WorkspaceEvents.Created
eventName: WorkspaceEvents.Created,
domains: []
}
await bus1.emit({ eventName: WorkspaceEvents.Created, payload: workspacePayload })
await bus1.emit({
eventName: WorkspaceEvents.Created,
payload: { ...workspacePayload }
})
expect(workspaces.length).to.equal(2)
expect(workspaces).to.deep.equal([workspacePayload, workspacePayload])
@@ -3,6 +3,8 @@ import { LimitedUserRecord, StreamRecord } from '@/modules/core/helpers/types'
import {
Workspace,
WorkspaceAcl,
WorkspaceDomain,
WorkspaceWithDomains,
WorkspaceWithOptionalRole
} from '@/modules/workspacesCore/domain/types'
import { EventBusPayloads } from '@/modules/shared/services/eventBus'
@@ -12,11 +14,18 @@ import { UserWithRole } from '@/modules/core/repositories/users'
/** Workspace */
type UpsertWorkspaceArgs = {
workspace: Workspace
workspace: Omit<Workspace, 'domains'>
}
export type UpsertWorkspace = (args: UpsertWorkspaceArgs) => Promise<void>
export type GetUserDiscoverableWorkspaces = (args: {
domains: string[]
userId: string
}) => Promise<
Pick<Workspace, 'id' | 'name' | 'description' | 'logo' | 'defaultLogoIndex'>[]
>
export type GetWorkspace = (args: {
workspaceId: string
userId?: string
@@ -27,10 +36,24 @@ export type GetWorkspaces = (args: {
userId?: string
}) => Promise<WorkspaceWithOptionalRole[]>
export type StoreWorkspaceDomain = (args: {
workspaceDomain: WorkspaceDomain
}) => Promise<void>
export type GetWorkspaceDomains = (args: {
workspaceIds: string[]
}) => Promise<WorkspaceDomain[]>
type DeleteWorkspaceArgs = {
workspaceId: string
}
export type DeleteWorkspaceDomain = (args: { id: string }) => Promise<void>
export type GetWorkspaceWithDomains = (args: {
id: string
}) => Promise<WorkspaceWithDomains | null>
export type DeleteWorkspace = (args: DeleteWorkspaceArgs) => Promise<void>
/** Workspace Roles */
@@ -42,3 +42,39 @@ export class WorkspaceNotFoundError extends BaseError {
static code = 'WORKSPACE_NOT_FOUND_ERROR'
static statusCode = 404
}
export class WorkspaceNotDiscoverableError extends BaseError {
static defaultMessage = 'Workspace is not discoverable'
static code = 'WORKSPACE_NOT_DISCOVERABLE'
static statusCode = 400
}
export class WorkspaceNotJoinableError extends BaseError {
static defaultMessage = 'Workspace is not joinable'
static code = 'WORKSPACE_NOT_JOINABLE'
static statusCode = 400
}
export class WorkspaceJoinNotAllowedError extends BaseError {
static defaultMessage = 'You do not have permissions to join this workspace'
static code = 'WORKSPACE_JOIN_NOT_ALLOWED'
static statusCode = 403
}
export class WorkspaceUnverifiedDomainError extends BaseError {
static defaultMessage = 'Cannot add unverified domain to workspace'
static code = 'WORKSPACE_UNVERIFIED_DOMAIN_ERROR'
static statusCode = 403
}
export class WorkspaceDomainBlockedError extends BaseError {
static defaultMessage = 'Cannot add blocked domain to workspace'
static code = 'WORKSPACE_DOMAIN_BLOCKED_ERROR'
static statusCode = 400
}
export class WorkspaceProtectedError extends BaseError {
static defaultMessage = 'Workspace protected'
static code = 'WORKSPACE_PROTECTED'
static statusCode = 400
}
@@ -1,8 +1,14 @@
import { db } from '@/db/knex'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { defineRequestDataloaders } from '@/modules/shared/helpers/graphqlHelper'
import { getWorkspacesFactory } from '@/modules/workspaces/repositories/workspaces'
import { WorkspaceWithOptionalRole } from '@/modules/workspacesCore/domain/types'
import {
getWorkspaceDomainsFactory,
getWorkspacesFactory
} from '@/modules/workspaces/repositories/workspaces'
import {
WorkspaceDomain,
WorkspaceWithOptionalRole
} from '@/modules/workspacesCore/domain/types'
import { keyBy } from 'lodash'
const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
@@ -14,6 +20,7 @@ declare module '@/modules/core/loaders' {
const dataLoadersDefinition = defineRequestDataloaders(({ ctx, createLoader }) => {
const getWorkspaces = getWorkspacesFactory({ db })
const getWorkspaceDomains = getWorkspaceDomainsFactory({ db })
return {
workspaces: {
@@ -29,6 +36,18 @@ const dataLoadersDefinition = defineRequestDataloaders(({ ctx, createLoader }) =
return ids.map((id) => results[id] || null)
}
)
},
workspaceDomains: {
/**
* Get workspace, with the active user's role attached
*/
getWorkspaceDomains: createLoader<string, WorkspaceDomain | null>(async (ids) => {
const results = keyBy(
await getWorkspaceDomains({ workspaceIds: ids.slice() }),
(w) => w.id
)
return ids.map((id) => results[id] || null)
})
}
}
})
@@ -130,6 +130,15 @@ const config: SpeckleModuleMocksConfig = FF_WORKSPACES_MODULE_ENABLED
}
},
User: {
discoverableWorkspaces: resolveAndCache(() => [
{
id: faker.string.uuid(),
name: workspaceName(),
description: faker.lorem.sentence(),
defaultLogoIndex: 0,
logo: null
}
]),
workspaces: resolveAndCache((_parent, args) =>
getMockRef('WorkspaceCollection', {
values: {
@@ -159,7 +168,17 @@ const config: SpeckleModuleMocksConfig = FF_WORKSPACES_MODULE_ENABLED
cursor: args.cursor ? null : undefined
}
})
)
),
domains: resolveAndCache(() => [
{
id: faker.string.uuid(),
domain: 'speckle.systems'
},
{
id: faker.string.uuid(),
domain: 'example.org'
}
])
},
WorkspaceCollaborator: {
role: resolveFromMockParent(),
@@ -41,6 +41,7 @@ import { getEventBus } from '@/modules/shared/services/eventBus'
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
import {
WorkspaceInvalidRoleError,
WorkspaceJoinNotAllowedError,
WorkspaceNotFoundError,
WorkspacesNotAuthorizedError,
WorkspacesNotYetImplementedError
@@ -55,7 +56,12 @@ import {
getWorkspaceRolesForUserFactory,
upsertWorkspaceFactory,
upsertWorkspaceRoleFactory,
workspaceInviteValidityFilter
workspaceInviteValidityFilter,
storeWorkspaceDomainFactory,
deleteWorkspaceDomainFactory,
getWorkspaceDomainsFactory,
getUserDiscoverableWorkspacesFactory,
getWorkspaceWithDomainsFactory
} from '@/modules/workspaces/repositories/workspaces'
import {
buildWorkspaceInviteEmailContentsFactory,
@@ -68,6 +74,7 @@ import {
validateWorkspaceInviteBeforeFinalizationFactory
} from '@/modules/workspaces/services/invites'
import {
addDomainToWorkspaceFactory,
createWorkspaceFactory,
deleteWorkspaceFactory,
deleteWorkspaceRoleFactory,
@@ -78,15 +85,21 @@ import {
getWorkspaceProjectsFactory,
queryAllWorkspaceProjectsFactory
} from '@/modules/workspaces/services/projects'
import { getWorkspacesForUserFactory } from '@/modules/workspaces/services/retrieval'
import {
getDiscoverableWorkspacesForUserFactory,
getWorkspacesForUserFactory
} from '@/modules/workspaces/services/retrieval'
import { Roles, WorkspaceRoles, removeNullOrUndefinedKeys } from '@speckle/shared'
import { chunk } from 'lodash'
import { deleteStream } from '@/modules/core/repositories/streams'
import {
findEmailsByUserIdFactory,
findVerifiedEmailsByUserIdFactory,
createUserEmailFactory,
ensureNoPrimaryEmailForUserFactory,
findEmailFactory
} from '@/modules/core/repositories/userEmails'
import { joinWorkspaceFactory } from '@/modules/workspaces/services/join'
import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails'
import { requestNewEmailVerification } from '@/modules/emails/services/verification/request'
@@ -113,7 +126,9 @@ const buildCreateAndSendWorkspaceInvite = () =>
insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }),
collectAndValidateResourceTargets: collectAndValidateWorkspaceTargetsFactory({
getStream,
getWorkspace: getWorkspaceFactory({ db })
getWorkspace: getWorkspaceFactory({ db }),
getWorkspaceDomains: getWorkspaceDomainsFactory({ db }),
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db })
}),
buildInviteEmailContents: buildWorkspaceInviteEmailContentsFactory({
getStream,
@@ -316,6 +331,8 @@ export = FF_WORKSPACES_MODULE_ENABLED
const updateWorkspaceRole = updateWorkspaceRoleFactory({
upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }),
getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }),
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }),
getWorkspaceRoles,
emitWorkspaceEvent,
getStreams,
@@ -327,6 +344,60 @@ export = FF_WORKSPACES_MODULE_ENABLED
return await getWorkspaceFactory({ db })({ workspaceId })
},
addDomain: async (_parent, args, context) => {
await authorizeResolver(
context.userId!,
args.input.workspaceId,
Roles.Workspace.Admin,
context.resourceAccessRules
)
await addDomainToWorkspaceFactory({
getWorkspace: getWorkspaceFactory({ db }),
findEmailsByUserId: findEmailsByUserIdFactory({ db }),
storeWorkspaceDomain: storeWorkspaceDomainFactory({ db }),
upsertWorkspace: upsertWorkspaceFactory({ db }),
getDomains: getWorkspaceDomainsFactory({ db }),
emitWorkspaceEvent: getEventBus().emit
})({
workspaceId: args.input.workspaceId,
userId: context.userId!,
domain: args.input.domain
})
return await getWorkspaceFactory({ db })({
workspaceId: args.input.workspaceId,
userId: context.userId
})
},
async deleteDomain(_parent, args, context) {
await authorizeResolver(
context.userId!,
args.input.workspaceId,
Roles.Workspace.Admin,
context.resourceAccessRules
)
await deleteWorkspaceDomainFactory({ db })({ id: args.input.id })
return await getWorkspaceFactory({ db })({
workspaceId: args.input.workspaceId,
userId: context.userId
})
},
async join(_parent, args, context) {
if (!context.userId) throw new WorkspaceJoinNotAllowedError()
await joinWorkspaceFactory({
getUserEmails: findEmailsByUserIdFactory({ db }),
getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }),
insertWorkspaceRole: upsertWorkspaceRoleFactory({ db }),
emitWorkspaceEvent: getEventBus().emit
})({ userId: context.userId, workspaceId: args.input.workspaceId })
return await getWorkspaceFactory({ db })({
workspaceId: args.input.workspaceId,
userId: context.userId
})
},
leave: async (_parent, args, ctx) => {
const userId = ctx.userId!
@@ -437,6 +508,8 @@ export = FF_WORKSPACES_MODULE_ENABLED
processInvite: processFinalizedWorkspaceInviteFactory({
getWorkspace: getWorkspaceFactory({ db }),
updateWorkspaceRole: updateWorkspaceRoleFactory({
getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }),
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }),
emitWorkspaceEvent: ({ eventName, payload }) =>
@@ -535,6 +608,9 @@ export = FF_WORKSPACES_MODULE_ENABLED
filter: { ...(args.filter || {}) }
}
)
},
domains: async (parent) => {
return await getWorkspaceDomainsFactory({ db })({ workspaceIds: [parent.id] })
}
},
WorkspaceCollaborator: {
@@ -597,6 +673,19 @@ export = FF_WORKSPACES_MODULE_ENABLED
}
},
User: {
discoverableWorkspaces: async (_parent, _args, context) => {
if (!context.userId) {
throw new WorkspacesNotAuthorizedError()
}
const getDiscoverableWorkspacesForUser =
getDiscoverableWorkspacesForUserFactory({
findEmailsByUserId: findEmailsByUserIdFactory({ db }),
getDiscoverableWorkspaces: getUserDiscoverableWorkspacesFactory({ db })
})
return await getDiscoverableWorkspacesForUser({ userId: context.userId })
},
workspaces: async (_parent, _args, context) => {
if (!context.userId) {
throw new WorkspacesNotAuthorizedError()
@@ -0,0 +1,130 @@
export const blockedDomains = [
// Common Free Email Providers
'gmail.com',
'yahoo.com',
'hotmail.com',
'outlook.com',
'live.com',
'aol.com',
'ymail.com',
'mail.com',
'protonmail.com',
'icloud.com',
'zoho.com',
'gmx.com',
'me.com',
'inbox.com',
// Temporary/Disposable Email Providers
'mailinator.com',
'10minutemail.com',
'guerrillamail.com',
'tempmail.com',
'yopmail.com',
'throwawaymail.com',
'temp-mail.org',
'maildrop.cc',
'getairmail.com',
'mintemail.com',
'fakemail.net',
'temp-mail.ru',
'moakt.com',
'emailondeck.com',
'spamgourmet.com',
'mailcatch.com',
'sharklasers.com',
'trashmail.com',
'mytrashmail.com',
'emailfake.com',
'fakeinbox.com',
'spamex.com',
'spambox.us',
'mailsac.com',
'fakemailgenerator.com',
'33mail.com',
'anonmails.de',
'anonbox.net',
'anonymousspeech.com',
'boun.cr',
'guerrillamailblock.com',
'mailfreeonline.com',
'temp-email.com',
'mailnesia.com',
'hmamail.com',
'fastmail.com',
'tmailinator.com',
'spam4.me',
'fakebox.com',
'emkei.cz',
'dispostable.com',
'mytemp.email',
'deadaddress.com',
'spamdecoy.net',
'0wnd.net',
'0wnd.org',
'10mail.org',
'20mail.it',
'20mail.in',
'24hourmail.com',
'2prong.com',
'3d-painting.com',
'4warding.com',
'4warding.net',
'4warding.org',
'5mail.cf',
'60minutemail.com',
'675hosting.com',
'675hosting.net',
'675hosting.org',
'6ip.us',
'6url.com',
'75hosting.com',
'75hosting.net',
'75hosting.org',
'7tags.com',
'9ox.net',
'a-bc.net',
'afrobacon.com',
'ajaxapp.net',
'amilegit.com',
'anonbox.net',
'antichef.com',
'antichef.net',
'antireg.ru',
'antispam.de',
'baxomale.ht.cx',
'beefmilk.com',
'binkmail.com',
'bio-muesli.net',
'bobmail.info',
'bofthew.com',
'brefmail.com',
'bsnow.net',
'bugmenot.com',
'bumpymail.com',
'casualdx.com',
'chogmail.com',
'cool.fr.nf',
'correo.blogos.net',
'cosmorph.com',
'courriel.fr.nf',
'cubiclink.com',
'curryworld.de',
'dacoolest.com',
'dandikmail.com',
'deadspam.com',
'despam.it',
'devnullmail.com',
'dfgh.net',
'digitalsanctuary.com',
'discardmail.com',
'dispose.it',
'disposableaddress.com',
'disposeamail.com',
'dispostable.com',
'dodgeit.com',
'dodgit.com',
'dodgit.org',
'dontreg.com',
'dontsendmespam.de'
]
@@ -7,7 +7,9 @@ export const Workspaces = buildTableHelper('workspaces', [
'createdAt',
'updatedAt',
'logo',
'defaultLogoIndex'
'defaultLogoIndex',
'domainBasedMembershipProtectionEnabled',
'discoverabilityEnabled'
])
export const WorkspaceAcl = buildTableHelper('workspace_acl', [
@@ -15,3 +17,13 @@ export const WorkspaceAcl = buildTableHelper('workspace_acl', [
'role',
'workspaceId'
])
export const WorkspaceDomains = buildTableHelper('workspace_domains', [
'id',
'workspaceId',
'domain',
'createdAt',
'updatedAt',
'createdByUserId',
'verified'
])
@@ -9,12 +9,14 @@ import { registerOrUpdateRole } from '@/modules/shared/repositories/roles'
import { initializeEventListenersFactory } from '@/modules/workspaces/events/eventListener'
import {
getWorkspaceRolesFactory,
getWorkspaceWithDomainsFactory,
upsertWorkspaceRoleFactory
} from '@/modules/workspaces/repositories/workspaces'
import { getStream, grantStreamPermissions } from '@/modules/core/repositories/streams'
import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { getStreams } from '@/modules/core/services/streams'
import { findVerifiedEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails'
import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense'
const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
@@ -49,6 +51,8 @@ const workspacesModule: SpeckleModule = {
getStream,
logger: moduleLogger,
updateWorkspaceRole: updateWorkspaceRoleFactory({
getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }),
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }),
emitWorkspaceEvent: (...args) => getEventBus().emit(...args),
@@ -1,17 +1,23 @@
import {
Workspace,
WorkspaceAcl,
WorkspaceDomain,
WorkspaceWithOptionalRole
} from '@/modules/workspacesCore/domain/types'
import {
DeleteWorkspace,
DeleteWorkspaceDomain,
DeleteWorkspaceRole,
GetUserDiscoverableWorkspaces,
GetWorkspace,
GetWorkspaceCollaborators,
GetWorkspaceDomains,
GetWorkspaceRoleForUser,
GetWorkspaceRoles,
GetWorkspaceRolesForUser,
GetWorkspaceWithDomains,
GetWorkspaces,
StoreWorkspaceDomain,
UpsertWorkspace,
UpsertWorkspaceRole
} from '@/modules/workspaces/domain/operations'
@@ -21,6 +27,7 @@ import { StreamRecord } from '@/modules/core/helpers/types'
import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace'
import {
WorkspaceAcl as DbWorkspaceAcl,
WorkspaceDomains,
Workspaces
} from '@/modules/workspaces/helpers/db'
import { knex, ServerAcl, ServerInvites, Users } from '@/modules/core/dbSchema'
@@ -35,9 +42,35 @@ import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constan
const tables = {
streams: (db: Knex) => db<StreamRecord>('streams'),
workspaces: (db: Knex) => db<Workspace>('workspaces'),
workspaceDomains: (db: Knex) => db<WorkspaceDomain>('workspace_domains'),
workspacesAcl: (db: Knex) => db<WorkspaceAcl>('workspace_acl')
}
export const getUserDiscoverableWorkspacesFactory =
({ db }: { db: Knex }): GetUserDiscoverableWorkspaces =>
async ({ domains, userId }) => {
if (domains.length === 0) {
return []
}
return (await tables
.workspaces(db)
.select('workspaces.id as id', 'name', 'description', 'logo', 'defaultLogoIndex')
.distinctOn('workspaces.id')
.join('workspace_domains', 'workspace_domains.workspaceId', 'workspaces.id')
.leftJoin(
tables.workspacesAcl(db).select('*').where({ userId }).as('acl'),
'acl.workspaceId',
'workspaces.id'
)
.whereIn('domain', domains)
.where('discoverabilityEnabled', true)
.where('verified', true)
.where('role', null)) as Pick<
Workspace,
'id' | 'name' | 'description' | 'logo' | 'defaultLogoIndex'
>[]
}
export const getWorkspacesFactory =
({ db }: { db: Knex }): GetWorkspaces =>
async (params: {
@@ -92,7 +125,15 @@ export const upsertWorkspaceFactory =
.workspaces(db)
.insert(workspace)
.onConflict('id')
.merge(['description', 'logo', 'defaultLogoIndex', 'name', 'updatedAt'])
.merge([
'description',
'logo',
'defaultLogoIndex',
'name',
'updatedAt',
'domainBasedMembershipProtectionEnabled',
'discoverabilityEnabled'
])
}
export const deleteWorkspaceFactory =
@@ -220,3 +261,44 @@ export const workspaceInviteValidityFilter: InvitesRetrievalValidityFilter = (q)
).orWhereNotNull(Workspaces.col.id)
})
}
export const storeWorkspaceDomainFactory =
({ db }: { db: Knex }): StoreWorkspaceDomain =>
async ({ workspaceDomain }): Promise<void> => {
await tables.workspaceDomains(db).insert(workspaceDomain)
}
export const getWorkspaceDomainsFactory =
({ db }: { db: Knex }): GetWorkspaceDomains =>
({ workspaceIds }) => {
return tables.workspaceDomains(db).whereIn('workspaceId', workspaceIds)
}
export const deleteWorkspaceDomainFactory =
({ db }: { db: Knex }): DeleteWorkspaceDomain =>
async ({ id }) => {
await tables.workspaceDomains(db).where({ id }).delete()
}
export const getWorkspaceWithDomainsFactory =
({ db }: { db: Knex }): GetWorkspaceWithDomains =>
async ({ id }) => {
const workspace = await tables
.workspaces(db)
.select([...Workspaces.cols, WorkspaceDomains.groupArray('domains')])
.where({ [Workspaces.col.id]: id })
.leftJoin(
WorkspaceDomains.name,
WorkspaceDomains.col.workspaceId,
Workspaces.col.id
)
.groupBy(Workspaces.col.id)
.first()
if (!workspace) return null
return {
...workspace,
domains: workspace.domains.filter(
(domain: WorkspaceDomain | null) => domain !== null
)
} as Workspace & { domains: WorkspaceDomain[] }
}
@@ -51,12 +51,17 @@ import {
import { authorizeResolver } from '@/modules/shared'
import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
import { GetWorkspace } from '@/modules/workspaces/domain/operations'
import {
GetWorkspace,
GetWorkspaceDomains
} from '@/modules/workspaces/domain/operations'
import { WorkspaceInviteResourceTarget } from '@/modules/workspaces/domain/types'
import { mapGqlWorkspaceRoleToMainRole } from '@/modules/workspaces/helpers/roles'
import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management'
import { PendingWorkspaceCollaboratorGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes'
import { MaybeNullOrUndefined, Nullable, Roles, WorkspaceRoles } from '@speckle/shared'
import { WorkspaceProtectedError } from '@/modules/workspaces/errors/workspace'
import { FindVerifiedEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
const isWorkspaceResourceTarget = (
target: InviteResourceTarget
@@ -107,6 +112,8 @@ export const createWorkspaceInviteFactory =
type CollectAndValidateWorkspaceTargetsFactoryDeps =
CollectAndValidateCoreTargetsFactoryDeps & {
getWorkspace: GetWorkspace
getWorkspaceDomains: GetWorkspaceDomains
findVerifiedEmailsByUserId: FindVerifiedEmailsByUserId
}
export const collectAndValidateWorkspaceTargetsFactory =
@@ -170,6 +177,27 @@ export const collectAndValidateWorkspaceTargetsFactory =
'Guest users cannot be admins of workspaces'
)
}
if (role !== Roles.Workspace.Guest && targetUser) {
const domains = await deps.getWorkspaceDomains({ workspaceIds: [resourceId] })
const verifiedDomains = domains.filter((domain) => domain?.verified)
if (
workspace &&
verifiedDomains &&
workspace?.domainBasedMembershipProtectionEnabled &&
verifiedDomains.length > 0
) {
const domains = new Set<string>(verifiedDomains.map((vd) => vd.domain))
const verifiedUserEmails = await deps.findVerifiedEmailsByUserId({
userId: targetUser.id
})
const domainMatching = verifiedUserEmails.find((userEmail) =>
domains.has(userEmail.email.split('@')[1])
)
if (!domainMatching) {
throw new WorkspaceProtectedError()
}
}
}
return [...baseTargets, { ...primaryWorkspaceResourceTarget, primary: true }]
}
@@ -0,0 +1,55 @@
import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
import { EventBus } from '@/modules/shared/services/eventBus'
import {
GetWorkspaceWithDomains,
UpsertWorkspaceRole
} from '@/modules/workspaces/domain/operations'
import {
WorkspaceJoinNotAllowedError,
WorkspaceNotDiscoverableError,
WorkspaceNotJoinableError
} from '@/modules/workspaces/errors/workspace'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import { Roles } from '@speckle/shared'
export const joinWorkspaceFactory =
({
getUserEmails,
getWorkspaceWithDomains,
insertWorkspaceRole,
emitWorkspaceEvent
}: {
getUserEmails: FindEmailsByUserId
getWorkspaceWithDomains: GetWorkspaceWithDomains
insertWorkspaceRole: UpsertWorkspaceRole
emitWorkspaceEvent: EventBus['emit']
}) =>
async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => {
const userEmails = await getUserEmails({ userId })
const workspace = await getWorkspaceWithDomains({ id: workspaceId })
if (!workspace?.discoverabilityEnabled) throw new WorkspaceNotDiscoverableError()
const workspaceDomains = workspace.domains.filter((domain) => domain.verified)
if (!workspaceDomains.length) throw new WorkspaceNotJoinableError()
const matchingEmail = userEmails.find((userEmail) => {
if (!userEmail.verified) return false
return workspaceDomains
.map((domain) => domain.domain)
.includes(userEmail.email.split('@')[1])
})
if (!matchingEmail) throw new WorkspaceJoinNotAllowedError()
const role = Roles.Workspace.Member
await insertWorkspaceRole({ userId, workspaceId, role })
await emitWorkspaceEvent({
eventName: WorkspaceEvents.JoinedFromDiscovery,
payload: { userId, workspaceId }
})
await emitWorkspaceEvent({
eventName: WorkspaceEvents.RoleUpdated,
payload: { userId, workspaceId, role }
})
}
@@ -3,11 +3,18 @@ import {
DeleteWorkspace,
EmitWorkspaceEvent,
GetWorkspace,
StoreWorkspaceDomain,
QueryAllWorkspaceProjects,
UpsertWorkspace,
UpsertWorkspaceRole
UpsertWorkspaceRole,
GetWorkspaceWithDomains,
GetWorkspaceDomains
} from '@/modules/workspaces/domain/operations'
import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import {
Workspace,
WorkspaceAcl,
WorkspaceDomain
} from '@/modules/workspacesCore/domain/types'
import { MaybeNullOrUndefined, Roles } from '@speckle/shared'
import cryptoRandomString from 'crypto-random-string'
import {
@@ -23,8 +30,11 @@ import {
} from '@/modules/workspaces/domain/operations'
import {
WorkspaceAdminRequiredError,
WorkspaceInvalidDescriptionError,
WorkspaceNotFoundError
WorkspaceDomainBlockedError,
WorkspaceNotFoundError,
WorkspaceProtectedError,
WorkspaceUnverifiedDomainError,
WorkspaceInvalidDescriptionError
} from '@/modules/workspaces/errors/workspace'
import {
isUserLastWorkspaceAdmin,
@@ -40,6 +50,11 @@ import {
} from '@/modules/core/domain/tokens/types'
import { ForbiddenError } from '@/modules/shared/errors'
import { validateImageString } from '@/modules/workspaces/helpers/images'
import {
FindEmailsByUserId,
FindVerifiedEmailsByUserId
} from '@/modules/core/domain/userEmails/operations'
import { blockedDomains } from '@/modules/workspaces/helpers/blockedDomains'
import { DeleteAllResourceInvites } from '@/modules/serverinvites/domain/operations'
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
import { ProjectInviteResourceType } from '@/modules/serverinvites/domain/constants'
@@ -84,7 +99,9 @@ export const createWorkspaceFactory =
...workspaceInput,
id: cryptoRandomString({ length: 10 }),
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
domainBasedMembershipProtectionEnabled: false,
discoverabilityEnabled: false
}
await upsertWorkspace({ workspace })
// assign the creator as workspace administrator
@@ -100,7 +117,7 @@ export const createWorkspaceFactory =
payload: { ...workspace, createdByUserId: userId }
})
return workspace
return { ...workspace }
}
type WorkspaceUpdateArgs = {
@@ -110,6 +127,8 @@ type WorkspaceUpdateArgs = {
description?: string | null
logo?: string | null
defaultLogoIndex?: number | null
discoverabilityEnabled?: boolean | null
domainBasedMembershipProtectionEnabled?: boolean | null
}
}
@@ -273,12 +292,16 @@ export const getWorkspaceRoleFactory =
export const updateWorkspaceRoleFactory =
({
getWorkspaceRoles,
getWorkspaceWithDomains,
findVerifiedEmailsByUserId,
upsertWorkspaceRole,
emitWorkspaceEvent,
getStreams,
grantStreamPermissions
}: {
getWorkspaceRoles: GetWorkspaceRoles
getWorkspaceWithDomains: GetWorkspaceWithDomains
findVerifiedEmailsByUserId: FindVerifiedEmailsByUserId
upsertWorkspaceRole: UpsertWorkspaceRole
emitWorkspaceEvent: EmitWorkspaceEvent
// TODO: Create `core` domain and import type from there
@@ -305,6 +328,26 @@ export const updateWorkspaceRoleFactory =
throw new WorkspaceAdminRequiredError()
}
if (role !== Roles.Workspace.Guest) {
const workspace = await getWorkspaceWithDomains({ id: workspaceId })
const verifiedDomains = workspace?.domains.filter((domain) => domain?.verified)
if (
workspace &&
verifiedDomains &&
workspace?.domainBasedMembershipProtectionEnabled &&
verifiedDomains.length > 0
) {
const domains = new Set<string>(verifiedDomains.map((vd) => vd.domain))
const verifiedUserEmails = await findVerifiedEmailsByUserId({ userId })
const domainMatching = verifiedUserEmails.find((userEmail) =>
domains.has(userEmail.email.split('@')[1])
)
if (!domainMatching) {
throw new WorkspaceProtectedError()
}
}
}
// Perform upsert
await upsertWorkspaceRole({ userId, workspaceId, role })
@@ -340,3 +383,86 @@ export const updateWorkspaceRoleFactory =
)
}
}
export const addDomainToWorkspaceFactory =
({
findEmailsByUserId,
storeWorkspaceDomain,
getWorkspace,
upsertWorkspace,
emitWorkspaceEvent,
getDomains
}: {
findEmailsByUserId: FindEmailsByUserId
storeWorkspaceDomain: StoreWorkspaceDomain
getWorkspace: GetWorkspace
upsertWorkspace: UpsertWorkspace
getDomains: GetWorkspaceDomains
emitWorkspaceEvent: EventBus['emit']
}) =>
async ({
userId,
domain,
workspaceId
}: {
userId: string
domain: string
workspaceId: string
}) => {
// this function makes the assumption, that the user has a workspace admin role
const sanitizedDomain = domain.toLowerCase().trim()
if (blockedDomains.includes(sanitizedDomain))
throw new WorkspaceDomainBlockedError()
const userEmails = await findEmailsByUserId({
userId
})
const email = userEmails.find(
(userEmail) =>
userEmail.verified && userEmail.email.split('@')[1] === sanitizedDomain
)
if (!email) {
throw new WorkspaceUnverifiedDomainError()
}
// we're treating all user owned domains as verified, cause they have it in their verified emails list
const verified = true
const workspaceWithRole = await getWorkspace({ workspaceId, userId })
if (!workspaceWithRole) throw new WorkspaceAdminRequiredError()
const { role, ...workspace } = workspaceWithRole
if (role !== Roles.Workspace.Admin) {
throw new WorkspaceAdminRequiredError()
}
const domains = await getDomains({ workspaceIds: [workspaceId] })
// idempotent operation
if (domains.find((domain) => domain.domain === sanitizedDomain)) return
const workspaceDomain: WorkspaceDomain = {
workspaceId,
id: cryptoRandomString({ length: 10 }),
domain: sanitizedDomain,
createdByUserId: userId,
createdAt: new Date(),
updatedAt: new Date(),
verified
}
await storeWorkspaceDomain({ workspaceDomain })
if (domains.length === 0) {
await upsertWorkspace({
workspace: { ...workspace, discoverabilityEnabled: true }
})
}
await emitWorkspaceEvent({
eventName: WorkspaceEvents.Updated,
payload: workspace
})
}
@@ -1,10 +1,41 @@
import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
import {
GetUserDiscoverableWorkspaces,
GetWorkspace,
GetWorkspaceRolesForUser
} from '@/modules/workspaces/domain/operations'
import { Workspace } from '@/modules/workspacesCore/domain/types'
import { chunk, isNull } from 'lodash'
type GetDiscoverableWorkspaceForUserArgs = {
userId: string
}
export const getDiscoverableWorkspacesForUserFactory =
({
findEmailsByUserId,
getDiscoverableWorkspaces
}: {
findEmailsByUserId: FindEmailsByUserId
getDiscoverableWorkspaces: GetUserDiscoverableWorkspaces
}) =>
async ({
userId
}: GetDiscoverableWorkspaceForUserArgs): Promise<
Pick<Workspace, 'id' | 'name' | 'description' | 'logo' | 'defaultLogoIndex'>[]
> => {
const userEmails = await findEmailsByUserId({ userId })
const userVerifiedDomains = userEmails
.filter((email) => email.verified)
.map((email) => email.email.split('@')[1])
const workspaces = await getDiscoverableWorkspaces({
domains: userVerifiedDomains,
userId
})
return workspaces
}
type GetWorkspacesForUserArgs = {
userId: string
}
@@ -4,6 +4,7 @@ import {
grantStreamPermissions,
revokeStreamPermissions
} from '@/modules/core/repositories/streams'
import { findVerifiedEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails'
import { getStreams } from '@/modules/core/services/streams'
import {
findUserByTargetFactory,
@@ -16,7 +17,9 @@ import {
upsertWorkspaceFactory,
upsertWorkspaceRoleFactory,
deleteWorkspaceRoleFactory as dbDeleteWorkspaceRoleFactory,
getWorkspaceFactory
getWorkspaceFactory,
getWorkspaceWithDomainsFactory,
getWorkspaceDomainsFactory
} from '@/modules/workspaces/repositories/workspaces'
import {
buildWorkspaceInviteEmailContentsFactory,
@@ -26,7 +29,8 @@ import {
import {
createWorkspaceFactory,
updateWorkspaceRoleFactory,
deleteWorkspaceRoleFactory
deleteWorkspaceRoleFactory,
updateWorkspaceFactory
} from '@/modules/workspaces/services/management'
import { BasicTestUser } from '@/test/authHelper'
import { CreateWorkspaceInviteMutationVariables } from '@/test/graphql/generated/graphql'
@@ -44,6 +48,8 @@ export type BasicTestWorkspace = {
name: string
description?: string
logo?: string
discoverabilityEnabled?: boolean
domainBasedMembershipProtectionEnabled?: boolean
}
export const createTestWorkspace = async (
@@ -56,7 +62,7 @@ export const createTestWorkspace = async (
emitWorkspaceEvent: (...args) => getEventBus().emit(...args)
})
const finalWorkspace = await createWorkspace({
const newWorkspace = await createWorkspace({
userId: owner.id,
workspaceInput: {
name: workspace.name,
@@ -67,7 +73,31 @@ export const createTestWorkspace = async (
userResourceAccessLimits: null
})
workspace.id = finalWorkspace.id
workspace.id = newWorkspace.id
if (workspace.discoverabilityEnabled) {
const updateWorkspace = updateWorkspaceFactory({
getWorkspace: getWorkspaceFactory({ db }),
upsertWorkspace: upsertWorkspaceFactory({ db }),
emitWorkspaceEvent: (...args) => getEventBus().emit(...args)
})
await updateWorkspace({
workspaceId: newWorkspace.id,
workspaceInput: {
discoverabilityEnabled: true
}
})
}
await updateWorkspaceFactory({
getWorkspace: getWorkspaceFactory({ db }),
upsertWorkspace: upsertWorkspaceFactory({ db }),
emitWorkspaceEvent: getEventBus().emit
})({
workspaceId: newWorkspace.id,
workspaceInput: { domainBasedMembershipProtectionEnabled: true }
})
workspace.ownerId = owner.id
}
@@ -77,6 +107,8 @@ export const assignToWorkspace = async (
role?: WorkspaceRoles
) => {
const updateWorkspaceRole = updateWorkspaceRoleFactory({
getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }),
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }),
emitWorkspaceEvent: (...args) => getEventBus().emit(...args),
@@ -136,7 +168,9 @@ export const createWorkspaceInviteDirectly = async (
insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }),
collectAndValidateResourceTargets: collectAndValidateWorkspaceTargetsFactory({
getStream,
getWorkspace: getWorkspaceFactory({ db })
getWorkspace: getWorkspaceFactory({ db }),
getWorkspaceDomains: getWorkspaceDomainsFactory({ db }),
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db })
}),
buildInviteEmailContents: buildWorkspaceInviteEmailContentsFactory({
getStream,
@@ -65,8 +65,23 @@ import {
} from '@/modules/auth/tests/helpers/registration'
import type { Express } from 'express'
import { AllScopes } from '@/modules/core/helpers/mainConstants'
import { getWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces'
import {
getWorkspaceDomainsFactory,
getWorkspaceFactory,
storeWorkspaceDomainFactory,
upsertWorkspaceFactory
} from '@/modules/workspaces/repositories/workspaces'
import { getStream } from '@/modules/core/repositories/streams'
import { addDomainToWorkspaceFactory } from '@/modules/workspaces/services/management'
import {
createUserEmailFactory,
findEmailsByUserIdFactory,
updateUserEmailFactory
} from '@/modules/core/repositories/userEmails'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { markUserEmailAsVerifiedFactory } from '@/modules/core/services/users/emailVerification'
import { WorkspaceProtectedError } from '@/modules/workspaces/errors/workspace'
import { createRandomPassword } from '@/modules/core/helpers/testHelpers'
enum InviteByTarget {
Email = 'email',
@@ -222,7 +237,8 @@ describe('Workspaces Invites GQL', () => {
const myFirstWorkspace: BasicTestWorkspace = {
name: 'My First Workspace',
id: '',
ownerId: ''
ownerId: '',
domainBasedMembershipProtectionEnabled: true
}
const otherGuysWorkspace: BasicTestWorkspace = {
@@ -236,6 +252,18 @@ describe('Workspaces Invites GQL', () => {
app = ctx.app
await createTestUsers([me, otherGuy, myWorkspaceFriend])
const email = 'something@example.org'
await createUserEmailFactory({ db })({
userEmail: {
email,
primary: false,
userId: me.id
}
})
await markUserEmailAsVerifiedFactory({
updateUserEmail: updateUserEmailFactory({ db })
})({ email })
await createTestWorkspaces([
[myFirstWorkspace, me],
[otherGuysWorkspace, otherGuy]
@@ -319,6 +347,28 @@ describe('Workspaces Invites GQL', () => {
expect(res.data?.workspaceMutations?.invites?.create).to.not.be.ok
})
it('should throw an error when trying to invite a user as a memeber without email matching domain and domain protection is enabled', async () => {
await addDomainToWorkspaceFactory({
findEmailsByUserId: findEmailsByUserIdFactory({ db }),
storeWorkspaceDomain: storeWorkspaceDomainFactory({ db }),
getWorkspace: getWorkspaceFactory({ db }),
upsertWorkspace: upsertWorkspaceFactory({ db }),
emitWorkspaceEvent: getEventBus().emit,
getDomains: getWorkspaceDomainsFactory({ db })
})({ userId: me.id, workspaceId: myFirstWorkspace.id, domain: 'example.org' })
const res = await gqlHelpers.createInvite({
workspaceId: myFirstWorkspace.id,
input: {
userId: otherGuy.id,
role: WorkspaceRole.Member
}
})
expect(res).to.haveGraphQLErrors(new WorkspaceProtectedError().message)
expect(res.data?.workspaceMutations?.invites?.create).to.not.be.ok
})
it('batch inviting fails if more than 10 invites', async () => {
const res = await gqlHelpers.batchCreateInvites({
workspaceId: myFirstWorkspace.id,
@@ -400,55 +450,88 @@ describe('Workspaces Invites GQL', () => {
expect(sendEmailInvocations.args).to.have.lengthOf(count)
})
itEach(
[InviteByTarget.Email, InviteByTarget.Id],
(type) => `works when inviting user by ${type}`,
async (type) => {
const sendEmailInvocations = EmailSendingServiceMock.hijackFunction(
'sendEmail',
async () => true
)
it('works when inviting user by id', async () => {
const sendEmailInvocations = EmailSendingServiceMock.hijackFunction(
'sendEmail',
async () => true
)
const randomUnregisteredEmail = 'randomunregisteredguy@email.com'
const res = await gqlHelpers.createInvite({
workspaceId: myFirstWorkspace.id,
input: {
...(type === InviteByTarget.Email
? { email: randomUnregisteredEmail }
: {}),
...(type === InviteByTarget.Id ? { userId: otherGuy.id } : {}),
role: WorkspaceRole.Member
}
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspaceMutations?.invites?.create).to.be.ok
const workspace = res.data!.workspaceMutations!.invites!.create
expect(workspace.invitedTeam).to.have.length(1)
expect(workspace.invitedTeam![0].invitedBy.id).to.equal(me.id)
expect(workspace.invitedTeam![0].token).to.be.not.ok
if (type === InviteByTarget.Email) {
expect(workspace.invitedTeam![0].user).to.be.not.ok
expect(workspace.invitedTeam![0].title).to.equal(randomUnregisteredEmail)
} else {
expect(workspace.invitedTeam![0].user?.id).to.equal(otherGuy.id)
const randomUnregisteredEmail = `${createRandomPassword()}@example.org`
await createUserEmailFactory({ db })({
userEmail: {
userId: otherGuy.id,
email: randomUnregisteredEmail
}
})
await markUserEmailAsVerifiedFactory({
updateUserEmail: updateUserEmailFactory({ db })
})({
email: randomUnregisteredEmail
})
expect(sendEmailInvocations.args).to.have.lengthOf(1)
const emailParams = sendEmailInvocations.args[0][0]
expect(emailParams).to.be.ok
expect(emailParams.to).to.eq(
type === InviteByTarget.Id ? otherGuy.email : randomUnregisteredEmail
)
expect(emailParams.subject).to.be.ok
const res = await gqlHelpers.createInvite({
workspaceId: myFirstWorkspace.id,
input: {
userId: otherGuy.id,
role: WorkspaceRole.Member
}
})
// Validate that invite exists
await validateInviteExistanceFromEmail(emailParams)
}
)
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspaceMutations?.invites?.create).to.be.ok
const workspace = res.data!.workspaceMutations!.invites!.create
expect(workspace.invitedTeam).to.have.length(1)
expect(workspace.invitedTeam![0].invitedBy.id).to.equal(me.id)
expect(workspace.invitedTeam![0].token).to.be.not.ok
expect(workspace.invitedTeam![0].user?.id).to.equal(otherGuy.id)
expect(sendEmailInvocations.args).to.have.lengthOf(1)
const emailParams = sendEmailInvocations.args[0][0]
expect(emailParams).to.be.ok
expect(emailParams.to).to.eq(otherGuy.email)
expect(emailParams.subject).to.be.ok
// Validate that invite exists
await validateInviteExistanceFromEmail(emailParams)
})
it('works when inviting user by email', async () => {
const sendEmailInvocations = EmailSendingServiceMock.hijackFunction(
'sendEmail',
async () => true
)
const randomUnregisteredEmail = `${createRandomPassword()}@example.org`
const res = await gqlHelpers.createInvite({
workspaceId: myFirstWorkspace.id,
input: {
email: randomUnregisteredEmail,
role: WorkspaceRole.Member
}
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspaceMutations?.invites?.create).to.be.ok
const workspace = res.data!.workspaceMutations!.invites!.create
expect(workspace.invitedTeam).to.have.length(1)
expect(workspace.invitedTeam![0].invitedBy.id).to.equal(me.id)
expect(workspace.invitedTeam![0].token).to.be.not.ok
expect(workspace.invitedTeam![0].user).to.be.not.ok
expect(workspace.invitedTeam![0].title).to.equal(randomUnregisteredEmail)
expect(sendEmailInvocations.args).to.have.lengthOf(1)
const emailParams = sendEmailInvocations.args[0][0]
expect(emailParams).to.be.ok
expect(emailParams.to).to.eq(randomUnregisteredEmail)
expect(emailParams.subject).to.be.ok
// Validate that invite exists
await validateInviteExistanceFromEmail(emailParams)
})
it("doesn't work if inviting to a workspace that the token doesn't have access to", async () => {
const res = await gqlHelpers.createInvite(
@@ -6,7 +6,10 @@ import {
upsertWorkspaceRoleFactory,
getWorkspaceRolesFactory,
getWorkspaceRolesForUserFactory,
deleteWorkspaceFactory
deleteWorkspaceFactory,
storeWorkspaceDomainFactory,
getUserDiscoverableWorkspacesFactory,
getWorkspaceWithDomainsFactory
} from '@/modules/workspaces/repositories/workspaces'
import db from '@/db/knex'
import cryptoRandomString from 'crypto-random-string'
@@ -18,6 +21,13 @@ import {
BasicTestWorkspace,
createTestWorkspace
} from '@/modules/workspaces/tests/helpers/creation'
import {
createUserEmailFactory,
updateUserEmailFactory
} from '@/modules/core/repositories/userEmails'
import { Roles } from '@speckle/shared'
import { createRandomPassword } from '@/modules/core/helpers/testHelpers'
import { truncateTables } from '@/test/hooks'
const getWorkspace = getWorkspaceFactory({ db })
const upsertWorkspace = upsertWorkspaceFactory({ db })
@@ -27,6 +37,10 @@ const getWorkspaceRoles = getWorkspaceRolesFactory({ db })
const getWorkspaceRoleForUser = getWorkspaceRoleForUserFactory({ db })
const getWorkspaceRolesForUser = getWorkspaceRolesForUserFactory({ db })
const upsertWorkspaceRole = upsertWorkspaceRoleFactory({ db })
const storeWorkspaceDomain = storeWorkspaceDomainFactory({ db })
const createUserEmail = createUserEmailFactory({ db })
const updateUserEmail = updateUserEmailFactory({ db })
const getUserDiscoverableWorkspaces = getUserDiscoverableWorkspacesFactory({ db })
const createAndStoreTestUser = async (): Promise<BasicTestUser> => {
const testId = cryptoRandomString({ length: 6 })
@@ -44,15 +58,20 @@ const createAndStoreTestUser = async (): Promise<BasicTestUser> => {
return userRecord
}
const createAndStoreTestWorkspace = async (): Promise<Workspace> => {
const workspace: Workspace = {
const createAndStoreTestWorkspace = async (
workspaceOverrides: Partial<Workspace> = {}
) => {
const workspace: Omit<Workspace, 'domains'> = {
id: cryptoRandomString({ length: 10 }),
name: cryptoRandomString({ length: 10 }),
createdAt: new Date(),
updatedAt: new Date(),
description: null,
logo: null,
defaultLogoIndex: 0
domainBasedMembershipProtectionEnabled: false,
discoverabilityEnabled: false,
defaultLogoIndex: 0,
...workspaceOverrides
}
await upsertWorkspace({ workspace })
@@ -77,7 +96,7 @@ describe('Workspace repositories', () => {
const storedWorkspace = await getWorkspace({ workspaceId: testWorkspace.id })
expect(storedWorkspace).to.deep.equal(testWorkspace)
const modifiedTestWorkspace: Workspace = {
const modifiedTestWorkspace: Omit<Workspace, 'domains'> = {
...testWorkspace,
description: 'now im adding a description to the workspace'
}
@@ -282,4 +301,343 @@ describe('Workspace repositories', () => {
await expectToThrow(() => upsertWorkspaceRole(role))
})
})
describe('getDiscoverableWorkspacesForUserFactory creates a function, that', () => {
afterEach(async () => {
await truncateTables(['workspaces'])
})
it('should return only one workspace where multiple emails match', async () => {
const user = await createAndStoreTestUser()
await updateUserEmail({
query: {
email: user.email
},
update: {
verified: true
}
})
await createUserEmail({
userEmail: {
email: 'john-speckle@speckle.systems',
userId: user.id
}
})
await updateUserEmail({
query: {
email: 'john-speckle@speckle.systems'
},
update: {
verified: true
}
})
const workspace = await createAndStoreTestWorkspace({
discoverabilityEnabled: true
})
await storeWorkspaceDomain({
workspaceDomain: {
id: cryptoRandomString({ length: 6 }),
domain: 'example.org',
workspaceId: workspace.id,
verified: true,
createdAt: new Date(),
updatedAt: new Date(),
createdByUserId: user.id
}
})
await storeWorkspaceDomain({
workspaceDomain: {
id: cryptoRandomString({ length: 6 }),
domain: 'speckle.systems',
workspaceId: workspace.id,
verified: true,
createdAt: new Date(),
updatedAt: new Date(),
createdByUserId: user.id
}
})
const workspaces = await getUserDiscoverableWorkspaces({
domains: ['example.org', 'speckle.systems'],
userId: user.id
})
expect(workspaces.length).to.equal(1)
})
it('should not return matches if the user email is not verified', async () => {
const user = await createAndStoreTestUser()
const workspace = await createAndStoreTestWorkspace({
discoverabilityEnabled: true
})
await storeWorkspaceDomain({
workspaceDomain: {
id: cryptoRandomString({ length: 6 }),
domain: 'example.org',
workspaceId: workspace.id,
verified: true,
createdAt: new Date(),
updatedAt: new Date(),
createdByUserId: user.id
}
})
const workspaces = await getUserDiscoverableWorkspaces({
domains: [],
userId: user.id
})
expect(workspaces.length).to.equal(0)
})
it('should not return workspaces if the workspace email is not verified', async () => {
const user = await createAndStoreTestUser()
await updateUserEmail({
query: {
email: user.email
},
update: {
verified: true
}
})
const workspace = await createAndStoreTestWorkspace({
discoverabilityEnabled: true
})
await storeWorkspaceDomain({
workspaceDomain: {
id: cryptoRandomString({ length: 6 }),
domain: 'example.org',
workspaceId: workspace.id,
verified: false,
createdAt: new Date(),
updatedAt: new Date(),
createdByUserId: user.id
}
})
const workspaces = await getUserDiscoverableWorkspaces({
domains: ['example.org'],
userId: user.id
})
expect(workspaces.length).to.equal(0)
})
it('should return multiple workspaces matching the user email', async () => {
const user = await createAndStoreTestUser()
await updateUserEmail({
query: {
email: user.email
},
update: {
verified: true
}
})
await createUserEmail({
userEmail: {
email: 'john-speckle@speckle.systems',
userId: user.id
}
})
await updateUserEmail({
query: {
email: 'john-speckle@speckle.systems'
},
update: {
verified: true
}
})
const workspaceA = await createAndStoreTestWorkspace({
discoverabilityEnabled: true
})
await storeWorkspaceDomain({
workspaceDomain: {
id: cryptoRandomString({ length: 6 }),
domain: 'example.org',
workspaceId: workspaceA.id,
verified: true,
createdAt: new Date(),
updatedAt: new Date(),
createdByUserId: user.id
}
})
const workspaceB = await createAndStoreTestWorkspace({
discoverabilityEnabled: true
})
await storeWorkspaceDomain({
workspaceDomain: {
id: cryptoRandomString({ length: 6 }),
domain: 'example.org',
workspaceId: workspaceB.id,
verified: true,
createdAt: new Date(),
updatedAt: new Date(),
createdByUserId: user.id
}
})
const workspaces = await getUserDiscoverableWorkspaces({
domains: ['example.org'],
userId: user.id
})
expect(workspaces.length).to.equal(2)
})
it('should not return workspaces the user is already a member of', async () => {
const user = await createAndStoreTestUser()
await updateUserEmail({
query: {
email: user.email
},
update: {
verified: true
}
})
const workspace = await createAndStoreTestWorkspace({
discoverabilityEnabled: true
})
await storeWorkspaceDomain({
workspaceDomain: {
id: cryptoRandomString({ length: 6 }),
domain: 'example.org',
workspaceId: workspace.id,
verified: true,
createdAt: new Date(),
updatedAt: new Date(),
createdByUserId: user.id
}
})
await upsertWorkspaceRole({
userId: user.id,
workspaceId: workspace.id,
role: Roles.Workspace.Member
})
const workspaces = await getUserDiscoverableWorkspaces({
domains: ['example.org'],
userId: user.id
})
expect(workspaces.length).to.equal(0)
})
it('should not return workspaces that are not discoverable', async () => {
const user = await createAndStoreTestUser()
await updateUserEmail({
query: {
email: user.email
},
update: {
verified: true
}
})
const workspace = await createAndStoreTestWorkspace()
await storeWorkspaceDomain({
workspaceDomain: {
id: cryptoRandomString({ length: 6 }),
domain: 'example.org',
workspaceId: workspace.id,
verified: true,
createdAt: new Date(),
updatedAt: new Date(),
createdByUserId: user.id
}
})
const workspaces = await getUserDiscoverableWorkspaces({
domains: ['example.org'],
userId: user.id
})
expect(workspaces.length).to.equal(0)
})
it('should return discoverable workspaces that already have members', async () => {
const user = await createAndStoreTestUser()
await updateUserEmail({
query: {
email: user.email
},
update: {
verified: true
}
})
const problemChild = await createAndStoreTestUser()
await updateUserEmail({
query: {
email: problemChild.email
},
update: {
verified: true
}
})
const workspace = await createAndStoreTestWorkspace({
discoverabilityEnabled: true
})
await storeWorkspaceDomain({
workspaceDomain: {
id: cryptoRandomString({ length: 6 }),
domain: 'example.org',
workspaceId: workspace.id,
verified: true,
createdAt: new Date(),
updatedAt: new Date(),
createdByUserId: user.id
}
})
await upsertWorkspaceRole({
userId: user.id,
workspaceId: workspace.id,
role: Roles.Workspace.Member
})
const workspaces = await getUserDiscoverableWorkspaces({
domains: ['example.org'],
userId: problemChild.id
})
expect(workspaces.length).to.equal(1)
})
})
describe('getWorkspaceDomainsFactory creates a function, that', () => {
it('returns a workspace with domains', async () => {
const user = {
id: createRandomPassword(),
name: createRandomPassword(),
email: createRandomPassword()
}
await createTestUser(user)
const workspace = {
id: createRandomPassword(),
name: 'my workspace',
ownerId: user.id
}
await createTestWorkspace(workspace, user)
await storeWorkspaceDomainFactory({ db })({
workspaceDomain: {
id: createRandomPassword(),
domain: 'example.org',
verified: true,
workspaceId: workspace.id,
createdAt: new Date(),
updatedAt: new Date(),
createdByUserId: user.id
}
})
const workspaceWithDomains = await getWorkspaceWithDomainsFactory({ db })({
id: workspace.id
})
expect(workspaceWithDomains?.domains.length).to.eq(1)
})
})
})
@@ -357,17 +357,22 @@ describe('Workspaces GQL CRUD', () => {
const workspaceCreateResult = await apollo.execute(CreateWorkspaceDocument, {
input: { name }
})
expect(workspaceCreateResult).to.not.haveGraphQLErrors()
const id = workspaceCreateResult.data?.workspaceMutations.create.id
if (!id) throw new Error('This should have succeeded')
await apollo.execute(UpdateWorkspaceRoleDocument, {
input: {
userId: testMemberUser.id,
workspaceId: id,
role: Roles.Workspace.Admin
const updateWorkspaceRole = await apollo.execute(
UpdateWorkspaceRoleDocument,
{
input: {
userId: testMemberUser.id,
workspaceId: id,
role: Roles.Workspace.Admin
}
}
})
)
expect(updateWorkspaceRole).to.not.haveGraphQLErrors()
let userWorkspaces = await apollo.execute(GetActiveUserWorkspacesDocument, {})
@@ -0,0 +1,138 @@
import { UserEmail } from '@/modules/core/domain/userEmails/types'
import { createRandomPassword } from '@/modules/core/helpers/testHelpers'
import {
WorkspaceJoinNotAllowedError,
WorkspaceNotDiscoverableError,
WorkspaceNotJoinableError
} from '@/modules/workspaces/errors/workspace'
import { joinWorkspaceFactory } from '@/modules/workspaces/services/join'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import {
WorkspaceAcl,
WorkspaceDomain,
WorkspaceWithDomains
} from '@/modules/workspacesCore/domain/types'
import { expectToThrow } from '@/test/assertionHelper'
import { Roles } from '@speckle/shared'
import { expect } from 'chai'
import { assign } from 'lodash'
const createTestWorkspaceWithDomains = (
arg?: Partial<WorkspaceWithDomains> | undefined
): WorkspaceWithDomains => {
const workspace: WorkspaceWithDomains = {
createdAt: new Date(),
updatedAt: new Date(),
name: createRandomPassword(),
description: createRandomPassword(),
id: createRandomPassword(),
logo: null,
domains: [],
discoverabilityEnabled: false,
domainBasedMembershipProtectionEnabled: false,
defaultLogoIndex: 0
}
if (arg) assign(workspace, arg)
return workspace
}
describe('Workspace join services', () => {
describe('joinWorkspaceFactory returns a function, that', () => {
it('throws an error if the workspace is not discoverable', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
const error = await expectToThrow(async () => {
await joinWorkspaceFactory({
getUserEmails: async () => [],
getWorkspaceWithDomains: async () => {
return createTestWorkspaceWithDomains()
},
insertWorkspaceRole: async () => {
expect.fail()
},
emitWorkspaceEvent: async () => {
expect.fail()
}
})({ userId, workspaceId })
})
expect(error.message).to.be.equal(new WorkspaceNotDiscoverableError().message)
})
it('throws an error if the workspace has no verified domains', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
const error = await expectToThrow(async () => {
await joinWorkspaceFactory({
getUserEmails: async () => [],
getWorkspaceWithDomains: async () => {
return createTestWorkspaceWithDomains({
discoverabilityEnabled: true,
domains: [{ domain: 'example.com', verified: false }] as WorkspaceDomain[]
})
},
insertWorkspaceRole: async () => {
expect.fail()
},
emitWorkspaceEvent: async () => {
expect.fail()
}
})({ userId, workspaceId })
})
expect(error.message).to.be.equal(new WorkspaceNotJoinableError().message)
})
it('throws an error if the user has no verified email matching the domains', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
const error = await expectToThrow(async () => {
await joinWorkspaceFactory({
getUserEmails: async () =>
[{ email: 'test@example.com', verified: false }] as UserEmail[],
getWorkspaceWithDomains: async () => {
return createTestWorkspaceWithDomains({
discoverabilityEnabled: true,
domains: [{ domain: 'example.com', verified: true }] as WorkspaceDomain[]
})
},
insertWorkspaceRole: async () => {
expect.fail()
},
emitWorkspaceEvent: async () => {
expect.fail()
}
})({ userId, workspaceId })
})
expect(error.message).to.be.equal(new WorkspaceJoinNotAllowedError().message)
})
it('creates a workspace member role and emits workspace events', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
let storedWorkspaceRole: WorkspaceAcl | undefined = undefined
const firedEvents: string[] = []
await joinWorkspaceFactory({
getUserEmails: async () =>
[{ email: 'test@example.com', verified: true }] as UserEmail[],
getWorkspaceWithDomains: async () => {
return createTestWorkspaceWithDomains({
discoverabilityEnabled: true,
domains: [{ domain: 'example.com', verified: true }] as WorkspaceDomain[]
})
},
insertWorkspaceRole: async (workspaceRole) => {
storedWorkspaceRole = workspaceRole
},
emitWorkspaceEvent: async ({ eventName }) => {
firedEvents.push(eventName)
return []
}
})({ userId, workspaceId })
expect(storedWorkspaceRole).deep.equal({
userId,
workspaceId,
role: Roles.Workspace.Member
})
expect(firedEvents).deep.equal([
WorkspaceEvents.JoinedFromDiscovery,
WorkspaceEvents.RoleUpdated
])
})
})
})
@@ -1,5 +1,10 @@
import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import {
Workspace,
WorkspaceAcl,
WorkspaceDomain
} from '@/modules/workspacesCore/domain/types'
import {
addDomainToWorkspaceFactory,
createWorkspaceFactory,
deleteWorkspaceRoleFactory,
updateWorkspaceRoleFactory
@@ -10,9 +15,21 @@ import cryptoRandomString from 'crypto-random-string'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
import { expectToThrow } from '@/test/assertionHelper'
import { createRandomPassword } from '@/modules/core/helpers/testHelpers'
import {
WorkspaceAdminRequiredError,
WorkspaceDomainBlockedError,
WorkspaceProtectedError,
WorkspaceUnverifiedDomainError
} from '@/modules/workspaces/errors/workspace'
import { UserEmail } from '@/modules/core/domain/userEmails/types'
import { omit } from 'lodash'
import { GetWorkspaceWithDomains } from '@/modules/workspaces/domain/operations'
import { FindVerifiedEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
import { EventNames } from '@/modules/shared/services/eventBus'
type WorkspaceTestContext = {
storedWorkspaces: Workspace[]
storedWorkspaces: Omit<Workspace, 'domains'>[]
storedRoles: WorkspaceAcl[]
eventData: {
isCalled: boolean
@@ -22,7 +39,7 @@ type WorkspaceTestContext = {
}
const buildCreateWorkspaceWithTestContext = (
dependecyOverrides: Partial<Parameters<typeof createWorkspaceFactory>[0]> = {}
dependencyOverrides: Partial<Parameters<typeof createWorkspaceFactory>[0]> = {}
) => {
const context: WorkspaceTestContext = {
storedWorkspaces: [],
@@ -35,7 +52,11 @@ const buildCreateWorkspaceWithTestContext = (
}
const deps: Parameters<typeof createWorkspaceFactory>[0] = {
upsertWorkspace: async ({ workspace }: { workspace: Workspace }) => {
upsertWorkspace: async ({
workspace
}: {
workspace: Omit<Workspace, 'domains'>
}) => {
context.storedWorkspaces.push(workspace)
},
upsertWorkspaceRole: async (workspaceAcl: WorkspaceAcl) => {
@@ -47,7 +68,7 @@ const buildCreateWorkspaceWithTestContext = (
context.eventData.payload = payload
return []
},
...dependecyOverrides
...dependencyOverrides
}
const createWorkspace = createWorkspaceFactory(deps)
@@ -80,7 +101,7 @@ describe('Workspace services', () => {
})
expect(context.storedWorkspaces.length).to.equal(1)
expect(context.storedWorkspaces[0]).to.deep.equal(workspace)
expect(context.storedWorkspaces[0]).to.deep.equal(omit(workspace, 'domains'))
})
it('makes the workspace creator becomes a workspace:admin', async () => {
const { context, createWorkspace } = buildCreateWorkspaceWithTestContext()
@@ -129,11 +150,13 @@ type WorkspaceRoleTestContext = {
eventName: string
payload: unknown
}
workspace: Partial<Workspace & { domains: Partial<WorkspaceDomain[]> }>
}
const getDefaultWorkspaceRoleTestContext = (): WorkspaceRoleTestContext => {
const workspaceId = cryptoRandomString({ length: 10 })
return {
workspaceId: cryptoRandomString({ length: 10 }),
workspaceId,
workspaceRoles: [],
workspaceProjects: [],
workspaceProjectRoles: [],
@@ -141,6 +164,10 @@ const getDefaultWorkspaceRoleTestContext = (): WorkspaceRoleTestContext => {
isCalled: false,
eventName: '',
payload: {}
},
workspace: {
id: workspaceId,
domains: []
}
}
}
@@ -210,6 +237,9 @@ const buildUpdateWorkspaceRoleAndTestContext = (
const deps: Parameters<typeof updateWorkspaceRoleFactory>[0] = {
getWorkspaceRoles: async () => context.workspaceRoles,
getWorkspaceWithDomains: async () =>
context.workspace as unknown as Workspace & { domains: WorkspaceDomain[] },
findVerifiedEmailsByUserId: async () => [],
upsertWorkspaceRole: async (role) => {
context.workspaceRoles = context.workspaceRoles.filter(
(acl) => acl.userId !== role.userId
@@ -359,6 +389,57 @@ describe('Workspace role services', () => {
updateWorkspaceRole({ ...role, role: Roles.Workspace.Member })
)
})
it('throws if attempting to set user role to more than GUEST and workspace domain protection is enabled and user has not an email matching a workspace domain', async () => {
const adminId = cryptoRandomString({ length: 10 })
const guestId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const roleAdmin: WorkspaceAcl = {
userId: adminId,
workspaceId,
role: Roles.Workspace.Admin
}
const roleGuest: WorkspaceAcl = {
userId: guestId,
workspaceId,
role: Roles.Workspace.Guest
}
const workspace = {
id: workspaceId,
domainBasedMembershipProtectionEnabled: true,
domains: [
{
verified: true,
domain: 'example.org'
}
]
}
const { updateWorkspaceRole } = buildUpdateWorkspaceRoleAndTestContext(
{
workspaceId,
workspaceRoles: [roleAdmin, roleGuest]
},
{
getWorkspaceWithDomains: (() =>
workspace) as unknown as GetWorkspaceWithDomains,
findVerifiedEmailsByUserId: (() => [
{
email: 'notcorrect@nonexample.org'
}
]) as unknown as FindVerifiedEmailsByUserId
}
)
const err = await expectToThrow(() =>
updateWorkspaceRole({
workspaceId,
userId: guestId,
role: Roles.Workspace.Member
})
)
expect(err.message).to.eq(new WorkspaceProtectedError().message)
})
it('sets roles on workspace projects when user added to workspace', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
@@ -410,4 +491,383 @@ describe('Workspace role services', () => {
expect(context.workspaceProjectRoles[0].role).to.equal(Roles.Stream.Contributor)
})
})
describe('Workspace domains', () => {
describe('addDomainToWorkspaceFactory returns a function that,', () => {
it('throws a ForbiddenDomainError if the domain is not allowed to be registered', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
const domain = 'gmail.com'
const err = await expectToThrow(
async () =>
await addDomainToWorkspaceFactory({
findEmailsByUserId: async () => [],
getWorkspace: async () => {
expect.fail()
},
getDomains: async () => {
expect.fail()
},
storeWorkspaceDomain: async () => {
return
},
upsertWorkspace: async () => {
expect.fail()
},
emitWorkspaceEvent: async () => {
expect.fail()
}
})({ userId, workspaceId, domain })
)
expect(err.message).to.eq(new WorkspaceDomainBlockedError().message)
})
it('should throw and error if user has no email with specified domain', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
const domain = 'example.org'
const err = await expectToThrow(
async () =>
await addDomainToWorkspaceFactory({
findEmailsByUserId: async () => [],
getWorkspace: async () => {
expect.fail()
},
getDomains: async () => {
expect.fail()
},
storeWorkspaceDomain: async () => {
return
},
upsertWorkspace: async () => {
expect.fail()
},
emitWorkspaceEvent: async () => {
expect.fail()
}
})({ userId, workspaceId, domain })
)
expect(err.message).to.eq(new WorkspaceUnverifiedDomainError().message)
})
it('should throw and error if the workspace is not found', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
const domain = 'example.org'
const err = await expectToThrow(
async () =>
await addDomainToWorkspaceFactory({
findEmailsByUserId: async () =>
[{ email: `foo@${domain}`, verified: true }] as UserEmail[],
getWorkspace: async () => {
return null
},
getDomains: async () => {
expect.fail()
},
storeWorkspaceDomain: async () => {
return
},
upsertWorkspace: async () => {
expect.fail()
},
emitWorkspaceEvent: async () => {
expect.fail()
}
})({ userId, workspaceId, domain })
)
expect(err.message).to.eq(new WorkspaceAdminRequiredError().message)
})
it('throws a WorkspaceUnverifiedDomainError if the users domain matching email is not verified', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
const domain = 'example.org'
const err = await expectToThrow(
async () =>
await addDomainToWorkspaceFactory({
findEmailsByUserId: async () =>
[{ email: `foo@${domain}`, verified: false }] as UserEmail[],
getWorkspace: async () => {
expect.fail()
},
getDomains: async () => {
expect.fail()
},
storeWorkspaceDomain: async () => {
return
},
upsertWorkspace: async () => {
expect.fail()
},
emitWorkspaceEvent: async () => {
expect.fail()
}
})({ userId, workspaceId, domain })
)
expect(err.message).to.eq(new WorkspaceUnverifiedDomainError().message)
})
it('throws a WorkspaceAdminRequiredError if the user does not have a workspace role', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
const domain = 'example.org'
const err = await expectToThrow(
async () =>
await addDomainToWorkspaceFactory({
findEmailsByUserId: async () =>
[{ email: `foo@${domain}`, verified: true }] as UserEmail[],
getWorkspace: async () => {
return null
},
getDomains: async () => {
expect.fail()
},
storeWorkspaceDomain: async () => {
return
},
upsertWorkspace: async () => {
expect.fail()
},
emitWorkspaceEvent: async () => {
expect.fail()
}
})({ userId, workspaceId, domain })
)
expect(err.message).to.eq(new WorkspaceAdminRequiredError().message)
})
it('throws a WorkspaceAdminRequiredError if the user is not an admin of the workspace', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
const domain = 'example.org'
const err = await expectToThrow(
async () =>
await addDomainToWorkspaceFactory({
findEmailsByUserId: async () =>
[{ email: `foo@${domain}`, verified: true }] as UserEmail[],
getWorkspace: async () => {
return {
role: Roles.Workspace.Guest,
userId,
id: workspaceId,
name: cryptoRandomString({ length: 10 }),
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
description: null,
discoverabilityEnabled: false,
domainBasedMembershipProtectionEnabled: false,
defaultLogoIndex: 0
}
},
getDomains: async () => {
expect.fail()
},
storeWorkspaceDomain: async () => {
return
},
upsertWorkspace: async () => {
expect.fail()
},
emitWorkspaceEvent: async () => {
expect.fail()
}
})({ userId, workspaceId, domain })
)
expect(err.message).to.eq(new WorkspaceAdminRequiredError().message)
})
it('does NOT store the verified workspace domain if its already stored', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
const domain = 'example.org'
const domainRequest = {
userId,
workspaceId,
domain
}
const storedDomains: WorkspaceDomain | undefined = undefined
const workspace: Workspace = {
id: workspaceId,
name: cryptoRandomString({ length: 10 }),
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
description: null,
discoverabilityEnabled: false,
domainBasedMembershipProtectionEnabled: false,
defaultLogoIndex: 0
}
await addDomainToWorkspaceFactory({
findEmailsByUserId: async () =>
[{ email: `foo@${domain}`, verified: true }] as UserEmail[],
getWorkspace: async () => {
return {
role: Roles.Workspace.Admin,
userId,
...workspace
}
},
getDomains: async () => {
return [{ domain }] as WorkspaceDomain[]
},
upsertWorkspace: async () => {
expect.fail()
},
emitWorkspaceEvent: async () => {
expect.fail()
},
storeWorkspaceDomain: async () => {
expect.fail()
}
})(domainRequest)
expect(storedDomains).to.be.undefined
})
it('stores the verified workspace domain, toggles workspace discoverability for first domain, emits update event', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
const domain = 'example.org'
const domainRequest = {
userId,
workspaceId,
domain
}
let storedDomains: WorkspaceDomain | undefined = undefined
let storedWorkspace: Omit<Workspace, 'domains'> | undefined = undefined
let omittedEventName: EventNames | undefined = undefined
const workspace: Workspace = {
id: workspaceId,
name: cryptoRandomString({ length: 10 }),
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
description: null,
discoverabilityEnabled: false,
domainBasedMembershipProtectionEnabled: false,
defaultLogoIndex: 0
}
await addDomainToWorkspaceFactory({
findEmailsByUserId: async () =>
[{ email: `foo@${domain}`, verified: true }] as UserEmail[],
getWorkspace: async () => {
return {
role: Roles.Workspace.Admin,
userId,
...workspace
}
},
getDomains: async () => {
return []
},
upsertWorkspace: async ({ workspace }) => {
storedWorkspace = workspace
},
emitWorkspaceEvent: async ({ eventName }) => {
omittedEventName = eventName
return []
},
storeWorkspaceDomain: async ({ workspaceDomain }) => {
storedDomains = workspaceDomain
}
})(domainRequest)
expect(storedDomains).to.not.be.undefined
expect(storedDomains!.createdByUserId).to.be.equal(userId)
expect(storedDomains!.domain).to.be.equal(domain)
expect(storedDomains!.workspaceId).to.be.equal(workspaceId)
expect(storedDomains!.verified).to.be.true
expect(storedWorkspace!.discoverabilityEnabled).to.be.true
expect(omittedEventName).to.be.equal(WorkspaceEvents.Updated)
})
it('stores the second verified domain, does NOT toggle workspace discoverability for subsequent domains', async () => {
const userId = createRandomPassword()
const workspaceId = createRandomPassword()
const domain = 'example.org'
const domain2 = 'example2.org'
const domainRequest = {
userId,
workspaceId,
domain
}
const workspaceWithoutDomains = {
id: workspaceId,
name: cryptoRandomString({ length: 10 }),
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
description: null,
discoverabilityEnabled: false,
domainBasedMembershipProtectionEnabled: false,
domains: [],
defaultLogoIndex: 0
}
let workspaceData: Workspace = {
...workspaceWithoutDomains
}
const insertedDomains: WorkspaceDomain[] = []
let storedDomains: WorkspaceDomain[] = []
const addDomainToWorkspace = addDomainToWorkspaceFactory({
findEmailsByUserId: async () =>
[
{ email: `foo@${domain}`, verified: true },
{ email: `foo@${domain2}`, verified: true }
] as UserEmail[],
getWorkspace: async () => {
return {
role: Roles.Workspace.Admin,
userId,
...workspaceData
}
},
getDomains: async () => storedDomains,
upsertWorkspace: async ({ workspace }) => {
workspaceData = { ...workspaceData, ...workspace }
},
emitWorkspaceEvent: async () => {
return []
},
storeWorkspaceDomain: async ({ workspaceDomain }) => {
insertedDomains.push(workspaceDomain)
}
})
await addDomainToWorkspace(domainRequest)
expect(insertedDomains).to.have.lengthOf(1)
expect(workspaceData.discoverabilityEnabled).to.be.true
// dirty hack, im post fact storing the domain on the test object
storedDomains = insertedDomains
//faking user interaction disabling discoverability
workspaceData.discoverabilityEnabled = false
await addDomainToWorkspace({ ...domainRequest, domain: domain2 })
expect(workspaceData.discoverabilityEnabled).to.be.false
})
})
})
})
@@ -8,7 +8,8 @@ export const WorkspaceEvents = {
Created: `${workspaceEventPrefix}created`,
Updated: `${workspaceEventPrefix}updated`,
RoleDeleted: `${workspaceEventPrefix}role-deleted`,
RoleUpdated: `${workspaceEventPrefix}role-updated`
RoleUpdated: `${workspaceEventPrefix}role-updated`,
JoinedFromDiscovery: `${workspaceEventPrefix}joined-from-discovery`
} as const
export type WorkspaceEvents = (typeof WorkspaceEvents)[keyof typeof WorkspaceEvents]
@@ -19,10 +20,12 @@ type WorkspaceCreatedPayload = Workspace & {
type WorkspaceUpdatedPayload = Workspace
type WorkspaceRoleDeletedPayload = WorkspaceAcl
type WorkspaceRoleUpdatedPayload = WorkspaceAcl
type WorkspaceJoinedFromDiscoveryPayload = { userId: string; workspaceId: string }
export type WorkspaceEventsPayloads = {
[WorkspaceEvents.Created]: WorkspaceCreatedPayload
[WorkspaceEvents.Updated]: WorkspaceUpdatedPayload
[WorkspaceEvents.RoleDeleted]: WorkspaceRoleDeletedPayload
[WorkspaceEvents.RoleUpdated]: WorkspaceRoleUpdatedPayload
[WorkspaceEvents.JoinedFromDiscovery]: WorkspaceJoinedFromDiscoveryPayload
}
@@ -8,6 +8,19 @@ export type Workspace = {
updatedAt: Date
logo: string | null
defaultLogoIndex: number
domainBasedMembershipProtectionEnabled: boolean
discoverabilityEnabled: boolean
}
export type WorkspaceWithDomains = Workspace & { domains: WorkspaceDomain[] }
export type WorkspaceDomain = {
id: string
workspaceId: string
domain: string
createdAt: Date
updatedAt: Date
createdByUserId: string | null
verified: boolean
}
export type WorkspaceWithOptionalRole = Workspace & { role?: WorkspaceRoles }
@@ -30,6 +30,15 @@ export = !FF_WORKSPACES_MODULE_ENABLED
updateRole: async () => {
throw new WorkspacesModuleDisabledError()
},
addDomain: async () => {
throw new WorkspacesModuleDisabledError()
},
deleteDomain: async () => {
throw new WorkspacesModuleDisabledError()
},
join: async () => {
throw new WorkspacesModuleDisabledError()
},
leave: async () => {
throw new WorkspacesModuleDisabledError()
},
@@ -64,9 +73,15 @@ export = !FF_WORKSPACES_MODULE_ENABLED
},
projects: async () => {
throw new WorkspacesModuleDisabledError()
},
domains: async () => {
throw new WorkspacesModuleDisabledError()
}
},
User: {
discoverableWorkspaces: async () => {
throw new WorkspacesModuleDisabledError()
},
workspaces: async () => {
throw new WorkspacesModuleDisabledError()
},
@@ -0,0 +1,20 @@
import { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('workspace_domains', (table) => {
table.text('id').primary()
table.text('domain').notNullable()
table.boolean('verified').notNullable()
table.timestamp('createdAt', { precision: 3, useTz: true }).notNullable()
table.timestamp('updatedAt', { precision: 3, useTz: true }).notNullable()
table.text('createdByUserId').references('id').inTable('users').onDelete('set null')
table.text('workspaceId').references('id').inTable('workspaces').onDelete('cascade')
table.unique(['workspaceId', 'domain'])
table.index('workspaceId')
table.index('domain')
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable('workspace_domains')
}
@@ -0,0 +1,13 @@
import { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('workspaces', (table) => {
table.boolean('domainBasedMembershipProtectionEnabled').defaultTo(false)
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('workspaces', (table) => {
table.dropColumn('domainBasedMembershipProtectionEnabled')
})
}
@@ -0,0 +1,13 @@
import { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('workspaces', (table) => {
table.boolean('discoverabilityEnabled').defaultTo(false)
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('workspaces', (table) => {
table.dropColumn('discoverabilityEnabled')
})
}
@@ -55,6 +55,11 @@ export type ActivityCollection = {
totalCount: Scalars['Int']['output'];
};
export type AddDomainToWorkspaceInput = {
domain: Scalars['String']['input'];
workspaceId: Scalars['ID']['input'];
};
export type AdminInviteList = {
__typename?: 'AdminInviteList';
cursor?: Maybe<Scalars['String']['output']>;
@@ -847,6 +852,15 @@ export type DiscoverableStreamsSortingInput = {
type: DiscoverableStreamsSortType;
};
export type DiscoverableWorkspace = {
__typename?: 'DiscoverableWorkspace';
defaultLogoIndex: Scalars['Int']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
logo?: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
};
export type EditCommentInput = {
commentId: Scalars['String']['input'];
content: CommentContentInput;
@@ -920,6 +934,10 @@ export type GendoAiRenderInput = {
versionId: Scalars['ID']['input'];
};
export type JoinWorkspaceInput = {
workspaceId: Scalars['ID']['input'];
};
export type LegacyCommentViewerData = {
__typename?: 'LegacyCommentViewerData';
/**
@@ -3369,6 +3387,8 @@ export type User = {
/** Returns the apps you have created. */
createdApps?: Maybe<Array<ServerApp>>;
createdAt?: Maybe<Scalars['DateTime']['output']>;
/** Get discoverable workspaces with verified domains that match the active user's */
discoverableWorkspaces: Array<DiscoverableWorkspace>;
/** Only returned if API user is the user being requested or an admin */
email?: Maybe<Scalars['String']['output']>;
emails: Array<UserEmail>;
@@ -3828,6 +3848,12 @@ export type Workspace = {
/** Selected fallback when `logo` not set */
defaultLogoIndex: Scalars['Int']['output'];
description?: Maybe<Scalars['String']['output']>;
/** Enable/Disable discovery of the workspace */
discoverabilityEnabled: Scalars['Boolean']['output'];
/** Enable/Disable restriction to invite users to workspace as Guests only */
domainBasedMembershipProtectionEnabled: Scalars['Boolean']['output'];
/** Verified workspace domains */
domains: Array<WorkspaceDomain>;
id: Scalars['ID']['output'];
/** Only available to workspace owners */
invitedTeam?: Maybe<Array<PendingWorkspaceCollaborator>>;
@@ -3878,6 +3904,17 @@ export type WorkspaceCreateInput = {
name: Scalars['String']['input'];
};
export type WorkspaceDomain = {
__typename?: 'WorkspaceDomain';
domain: Scalars['String']['output'];
id: Scalars['ID']['output'];
};
export type WorkspaceDomainDeleteInput = {
id: Scalars['ID']['input'];
workspaceId: Scalars['ID']['input'];
};
export type WorkspaceInviteCreateInput = {
/** Either this or userId must be filled */
email?: InputMaybe<Scalars['String']['input']>;
@@ -3943,15 +3980,23 @@ export type WorkspaceInviteUseInput = {
export type WorkspaceMutations = {
__typename?: 'WorkspaceMutations';
addDomain: Workspace;
create: Workspace;
delete: Scalars['Boolean']['output'];
deleteDomain: Workspace;
invites: WorkspaceInviteMutations;
join: Workspace;
leave: Scalars['Boolean']['output'];
update: Workspace;
updateRole: Workspace;
};
export type WorkspaceMutationsAddDomainArgs = {
input: AddDomainToWorkspaceInput;
};
export type WorkspaceMutationsCreateArgs = {
input: WorkspaceCreateInput;
};
@@ -3962,6 +4007,16 @@ export type WorkspaceMutationsDeleteArgs = {
};
export type WorkspaceMutationsDeleteDomainArgs = {
input: WorkspaceDomainDeleteInput;
};
export type WorkspaceMutationsJoinArgs = {
input: JoinWorkspaceInput;
};
export type WorkspaceMutationsLeaveArgs = {
id: Scalars['ID']['input'];
};
@@ -4022,6 +4077,8 @@ export type WorkspaceTeamFilter = {
export type WorkspaceUpdateInput = {
defaultLogoIndex?: InputMaybe<Scalars['Int']['input']>;
description?: InputMaybe<Scalars['String']['input']>;
discoverabilityEnabled?: InputMaybe<Scalars['Boolean']['input']>;
domainBasedMembershipProtectionEnabled?: InputMaybe<Scalars['Boolean']['input']>;
id: Scalars['String']['input'];
/** Logo image as base64-encoded string */
logo?: InputMaybe<Scalars['String']['input']>;
@@ -4633,6 +4690,11 @@ export type GetWorkspaceQueryVariables = Exact<{
export type GetWorkspaceQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', id: string, name: string, description?: string | null, createdAt: string, updatedAt: string, logo?: string | null, team: Array<{ __typename?: 'WorkspaceCollaborator', id: string, role: string }> } };
export type GetActiveUserDiscoverableWorkspacesQueryVariables = Exact<{ [key: string]: never; }>;
export type GetActiveUserDiscoverableWorkspacesQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', discoverableWorkspaces: Array<{ __typename?: 'DiscoverableWorkspace', id: string, name: string, description?: string | null }> } | null };
export type UpdateWorkspaceMutationVariables = Exact<{
input: WorkspaceUpdateInput;
}>;
@@ -4780,6 +4842,7 @@ export const MarkProjectVersionReceivedDocument = {"kind":"Document","definition
export const CreateWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspace"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}}]}}]} as unknown as DocumentNode<CreateWorkspaceMutation, CreateWorkspaceMutationVariables>;
export const DeleteWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"delete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}]}]}}]}}]} as unknown as DocumentNode<DeleteWorkspaceMutation, DeleteWorkspaceMutationVariables>;
export const GetWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspaceTeam"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspaceTeam"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode<GetWorkspaceQuery, GetWorkspaceQueryVariables>;
export const GetActiveUserDiscoverableWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getActiveUserDiscoverableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"discoverableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]}}]} as unknown as DocumentNode<GetActiveUserDiscoverableWorkspacesQuery, GetActiveUserDiscoverableWorkspacesQueryVariables>;
export const UpdateWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"update"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspace"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}}]}}]} as unknown as DocumentNode<UpdateWorkspaceMutation, UpdateWorkspaceMutationVariables>;
export const GetActiveUserWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetActiveUserWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspace"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}}]}}]} as unknown as DocumentNode<GetActiveUserWorkspacesQuery, GetActiveUserWorkspacesQueryVariables>;
export const UpdateWorkspaceRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceRoleUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}}]}}]} as unknown as DocumentNode<UpdateWorkspaceRoleMutation, UpdateWorkspaceRoleMutationVariables>;
@@ -66,6 +66,18 @@ export const getWorkspaceQuery = gql`
${workspaceTeamFragment}
`
export const getActiveUserDiscoverableWorkspacesQuery = gql`
query getActiveUserDiscoverableWorkspaces {
activeUser {
discoverableWorkspaces {
id
name
description
}
}
}
`
export const updateWorkspaceQuery = gql`
mutation UpdateWorkspace($input: WorkspaceUpdateInput!) {
workspaceMutations {
+1 -1
View File
@@ -90,7 +90,7 @@
},
"files.eol": "\n",
"volar.vueserver.maxOldSpaceSize": 4000,
"cSpell.words": ["Automations", "Bursty", "Insertable", "mjml"],
"cSpell.words": ["Automations", "Bursty", "discoverability", "Insertable", "mjml"],
"tailwindCSS.experimental.configFile": {
"packages/frontend-2/tailwind.config.mjs": "packages/frontend-2/**"
},