feat(fe2): invite + list workspace invites (#2629)
* list invites table * invites list works * update last reminded date on resend * fix FE * WIP invitedialog + updated debounced utility * invite create works * exclude users correctly * more adjustments * minor cleanup * using workspace invite server role * test fix * fixed multiple root eslint issues * minor adjustments
This commit is contained in:
committed by
GitHub
parent
03db1cca94
commit
4dae1569cd
+1
-1
@@ -8,8 +8,8 @@
|
||||
color="foundation"
|
||||
placeholder="Search Functions..."
|
||||
show-clear
|
||||
:model-value="bind.modelValue.value"
|
||||
full-width
|
||||
v-bind="bind"
|
||||
v-on="on"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
name="search"
|
||||
placeholder="Search functions..."
|
||||
show-clear
|
||||
:model-value="bind.modelValue.value"
|
||||
color="foundation"
|
||||
v-bind="bind"
|
||||
v-on="on"
|
||||
/>
|
||||
<FormButton
|
||||
|
||||
@@ -54,7 +54,7 @@ const { on, bind } = useDebouncedTextInput({
|
||||
debouncedBy: 2000,
|
||||
isBasicHtmlInput: true
|
||||
})
|
||||
const visibleDescription = computed(() => bind.modelValue.value)
|
||||
const visibleDescription = computed(() => bind.value.modelValue)
|
||||
|
||||
const descriptionInputClasses = computed(() => [
|
||||
'normal placeholder:text-foreground-2 text-foreground-2 focus:text-foreground',
|
||||
|
||||
@@ -46,7 +46,7 @@ const { on, bind } = useDebouncedTextInput({
|
||||
isBasicHtmlInput: true,
|
||||
submitOnEnter: true
|
||||
})
|
||||
const visibleTitle = computed(() => bind.modelValue.value)
|
||||
const visibleTitle = computed(() => bind.value.modelValue)
|
||||
|
||||
const titleInputClasses = computed(() => {
|
||||
const classParts = [
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<PortalTarget name="primary-actions"></PortalTarget>
|
||||
</ClientOnly>
|
||||
<!-- Notifications dropdown -->
|
||||
<HeaderNavNotifications />
|
||||
<HeaderNavNotifications v-if="hasNotifications" />
|
||||
<FormButton
|
||||
v-if="!activeUser"
|
||||
:to="loginUrl.fullPath"
|
||||
@@ -59,4 +59,10 @@ const loginUrl = computed(() =>
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const hasNotifications = computed(() => {
|
||||
if (!activeUser.value) return false
|
||||
if (!activeUser.value?.verified) return true
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div v-if="hasNotifications">
|
||||
<div>
|
||||
<Menu as="div" class="flex items-center">
|
||||
<MenuButton :id="menuButtonId" v-slot="{ open: menuOpen }" as="div">
|
||||
<div class="cursor-pointer">
|
||||
<span class="sr-only">Open notifications menu</span>
|
||||
<div class="relative">
|
||||
<div v-if="hasNotifications && !menuOpen" class="scale-75">
|
||||
<div v-if="!menuOpen" class="scale-75">
|
||||
<div
|
||||
class="absolute top-1 right-1 w-3 h-3 rounded-full bg-primary animate-ping"
|
||||
></div>
|
||||
@@ -45,14 +45,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
|
||||
import { XMarkIcon, BellIcon } from '@heroicons/vue/24/outline'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
|
||||
const { activeUser } = useActiveUser()
|
||||
const menuButtonId = useId()
|
||||
|
||||
const hasNotifications = computed(() => {
|
||||
if (!activeUser.value) return false
|
||||
if (!activeUser.value?.verified) return true
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
</FormTextInput>
|
||||
<div
|
||||
v-if="searchUsers.length || selectedEmails?.length"
|
||||
v-if="hasTargets"
|
||||
class="flex flex-col border bg-foundation border-primary-muted mt-2 rounded-md"
|
||||
>
|
||||
<template v-if="searchUsers.length">
|
||||
@@ -53,21 +53,21 @@
|
||||
<script setup lang="ts">
|
||||
import { Roles } from '@speckle/shared'
|
||||
import type { ServerRoles, StreamRoles } from '@speckle/shared'
|
||||
import { useUserSearch } from '~~/lib/common/composables/users'
|
||||
import type { UserSearchItem } from '~~/lib/common/composables/users'
|
||||
import type {
|
||||
ProjectInviteCreateInput,
|
||||
ProjectPageInviteDialog_ProjectFragment
|
||||
} from '~~/lib/common/generated/gql/graphql'
|
||||
import type { SetFullyRequired } from '~~/lib/common/helpers/type'
|
||||
import { isEmail } from '~~/lib/common/helpers/validation'
|
||||
import { isArray, isString } from 'lodash-es'
|
||||
import { isString } from 'lodash-es'
|
||||
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
|
||||
import { useTeamInternals } from '~~/lib/projects/composables/team'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useServerInfo } from '~~/lib/core/composables/server'
|
||||
import { graphql } from '~/lib/common/generated/gql/gql'
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { useResolveInviteTargets } from '~/lib/server/composables/invites'
|
||||
import { filterInvalidInviteTargets } from '~/lib/workspaces/helpers/invites'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageInviteDialog_Project on Project {
|
||||
@@ -85,6 +85,11 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
const mp = useMixpanel()
|
||||
|
||||
const projectId = computed(() => props.projectId as string)
|
||||
const projectData = computed(() => props.project)
|
||||
const { collaboratorListItems } = useTeamInternals(projectData)
|
||||
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
@@ -92,19 +97,19 @@ const role = ref<StreamRoles>(Roles.Stream.Contributor)
|
||||
|
||||
const { isGuestMode } = useServerInfo()
|
||||
const createInvite = useInviteUserToProject()
|
||||
const { userSearch, searchVariables } = useUserSearch({
|
||||
variables: computed(() => ({
|
||||
query: search.value,
|
||||
limit: 5
|
||||
}))
|
||||
const {
|
||||
users: searchUsers,
|
||||
emails: selectedEmails,
|
||||
hasTargets
|
||||
} = useResolveInviteTargets({
|
||||
search,
|
||||
excludeUserIds: computed(() =>
|
||||
collaboratorListItems.value
|
||||
.filter((i): i is SetFullyRequired<typeof i, 'user'> => !!i.user?.id)
|
||||
.map((t) => t.user.id)
|
||||
)
|
||||
})
|
||||
|
||||
const projectId = computed(() => props.projectId as string)
|
||||
|
||||
const projectData = computed(() => props.project)
|
||||
|
||||
const { collaboratorListItems } = useTeamInternals(projectData)
|
||||
|
||||
const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
@@ -115,58 +120,29 @@ const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
}
|
||||
])
|
||||
|
||||
const selectedEmails = computed(() => {
|
||||
const query = searchVariables.value?.query || ''
|
||||
if (isValidEmail(query)) return [query]
|
||||
|
||||
const multipleEmails = query.split(',').map((i) => i.trim())
|
||||
const validEmails = multipleEmails.filter((e) => isValidEmail(e))
|
||||
return validEmails.length ? validEmails : null
|
||||
})
|
||||
|
||||
const isOwnerSelected = computed(() => role.value === Roles.Stream.Owner)
|
||||
|
||||
const searchUsers = computed(() => {
|
||||
const searchResults = userSearch.value?.userSearch.items || []
|
||||
const collaboratorIds = new Set(
|
||||
collaboratorListItems.value
|
||||
.filter((i): i is SetFullyRequired<typeof i, 'user'> => !!i.user?.id)
|
||||
.map((t) => t.user.id)
|
||||
)
|
||||
return searchResults.filter((r) => !collaboratorIds.has(r.id))
|
||||
})
|
||||
|
||||
const isValidEmail = (val: string) =>
|
||||
isEmail(val || '', {
|
||||
field: '',
|
||||
value: '',
|
||||
form: {}
|
||||
}) === true
|
||||
? true
|
||||
: false
|
||||
|
||||
const mp = useMixpanel()
|
||||
const onInviteUser = async (
|
||||
user: InvitableUser | InvitableUser[],
|
||||
serverRole?: ServerRoles
|
||||
) => {
|
||||
const users = (isArray(user) ? user : [user]).filter(
|
||||
(u) => !isOwnerSelected.value || isString(u) || u.role !== Roles.Server.Guest
|
||||
)
|
||||
serverRole = serverRole || Roles.Server.User
|
||||
const users = filterInvalidInviteTargets(user, {
|
||||
isTargetResourceOwner: isOwnerSelected.value,
|
||||
emailTargetServerRole: serverRole
|
||||
})
|
||||
|
||||
const inputs: ProjectInviteCreateInput[] = users
|
||||
.filter((u) => (isString(u) ? isValidEmail(u) : u))
|
||||
.map((u) => ({
|
||||
role: role.value,
|
||||
...(isString(u)
|
||||
? {
|
||||
email: u,
|
||||
serverRole
|
||||
}
|
||||
: {
|
||||
userId: u.id
|
||||
})
|
||||
}))
|
||||
const inputs: ProjectInviteCreateInput[] = users.map((u) => ({
|
||||
role: role.value,
|
||||
...(isString(u)
|
||||
? {
|
||||
email: u,
|
||||
serverRole
|
||||
}
|
||||
: {
|
||||
userId: u.id
|
||||
})
|
||||
}))
|
||||
if (!inputs.length) return
|
||||
|
||||
const isEmail = !!inputs.find((u) => !!u.email)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
placeholder="Search automations..."
|
||||
wrapper-classes="shrink-0"
|
||||
show-clear
|
||||
:model-value="bind.modelValue.value"
|
||||
v-bind="bind"
|
||||
v-on="on"
|
||||
/>
|
||||
<FormButton
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
<template>
|
||||
<template v-if="itemsCount">
|
||||
<ProjectModelsBasicCardView
|
||||
:items="items"
|
||||
:project="project"
|
||||
:project-id="projectId"
|
||||
:small-view="smallView"
|
||||
:show-actions="showActions"
|
||||
:show-versions="showVersions"
|
||||
:disable-default-links="disableDefaultLinks"
|
||||
@model-clicked="$emit('model-clicked', $event)"
|
||||
<div>
|
||||
<template v-if="itemsCount">
|
||||
<ProjectModelsBasicCardView
|
||||
:items="items"
|
||||
:project="project"
|
||||
:project-id="projectId"
|
||||
:small-view="smallView"
|
||||
:show-actions="showActions"
|
||||
:show-versions="showVersions"
|
||||
:disable-default-links="disableDefaultLinks"
|
||||
@model-clicked="$emit('model-clicked', $event)"
|
||||
/>
|
||||
<FormButtonSecondaryViewAll
|
||||
v-if="showViewAll"
|
||||
class="mt-4"
|
||||
:to="allProjectModelsRoute(projectId)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="!areQueriesLoading">
|
||||
<CommonEmptySearchState
|
||||
v-if="isFiltering"
|
||||
@clear-search="() => $emit('clear-search')"
|
||||
/>
|
||||
<div v-else>
|
||||
<ProjectCardImportFileArea :project-id="projectId" class="h-36 col-span-4" />
|
||||
</div>
|
||||
</template>
|
||||
<InfiniteLoading
|
||||
v-if="items?.length && !disablePagination"
|
||||
:settings="{ identifier: infiniteLoaderId }"
|
||||
@infinite="infiniteLoad"
|
||||
/>
|
||||
<FormButtonSecondaryViewAll
|
||||
v-if="showViewAll"
|
||||
class="mt-4"
|
||||
:to="allProjectModelsRoute(projectId)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="!areQueriesLoading">
|
||||
<CommonEmptySearchState
|
||||
v-if="isFiltering"
|
||||
@clear-search="() => $emit('clear-search')"
|
||||
/>
|
||||
<div v-else>
|
||||
<ProjectCardImportFileArea :project-id="projectId" class="h-36 col-span-4" />
|
||||
</div>
|
||||
</template>
|
||||
<InfiniteLoading
|
||||
v-if="items?.length && !disablePagination"
|
||||
:settings="{ identifier: infiniteLoaderId }"
|
||||
@infinite="infiniteLoad"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
|
||||
@@ -1,43 +1,45 @@
|
||||
<template>
|
||||
<div v-if="topLevelItems.length && project" class="space-y-2 max-w-full">
|
||||
<div v-for="item in topLevelItems" :key="item.id">
|
||||
<ProjectPageModelsStructureItem
|
||||
:item="item"
|
||||
:project="project"
|
||||
:can-contribute="canContribute"
|
||||
:is-search-result="isUsingSearch"
|
||||
@model-updated="onModelUpdated"
|
||||
@create-submodel="onCreateSubmodel"
|
||||
<div>
|
||||
<div v-if="topLevelItems.length && project" class="space-y-2 max-w-full">
|
||||
<div v-for="item in topLevelItems" :key="item.id">
|
||||
<ProjectPageModelsStructureItem
|
||||
:item="item"
|
||||
:project="project"
|
||||
:can-contribute="canContribute"
|
||||
:is-search-result="isUsingSearch"
|
||||
@model-updated="onModelUpdated"
|
||||
@create-submodel="onCreateSubmodel"
|
||||
/>
|
||||
</div>
|
||||
<FormButtonSecondaryViewAll
|
||||
v-if="showViewAll"
|
||||
:to="allProjectModelsRoute(projectId)"
|
||||
/>
|
||||
</div>
|
||||
<FormButtonSecondaryViewAll
|
||||
v-if="showViewAll"
|
||||
:to="allProjectModelsRoute(projectId)"
|
||||
<template v-else-if="!areQueriesLoading">
|
||||
<CommonEmptySearchState
|
||||
v-if="
|
||||
!topLevelItems.length &&
|
||||
isFiltering &&
|
||||
(baseResult?.project?.modelsTree.items || []).length === 0
|
||||
"
|
||||
@clear-search="$emit('clear-search')"
|
||||
/>
|
||||
<div v-else>
|
||||
<ProjectCardImportFileArea :project-id="projectId" class="h-36 col-span-4" />
|
||||
</div>
|
||||
</template>
|
||||
<InfiniteLoading
|
||||
v-if="topLevelItems?.length && !disablePagination"
|
||||
:settings="{ identifier: infiniteLoaderId }"
|
||||
@infinite="infiniteLoad"
|
||||
/>
|
||||
<ProjectPageModelsNewDialog
|
||||
v-model:open="showNewDialog"
|
||||
:project-id="projectId"
|
||||
:parent-model-name="newSubmodelParent || undefined"
|
||||
/>
|
||||
</div>
|
||||
<template v-else-if="!areQueriesLoading">
|
||||
<CommonEmptySearchState
|
||||
v-if="
|
||||
!topLevelItems.length &&
|
||||
isFiltering &&
|
||||
(baseResult?.project?.modelsTree.items || []).length === 0
|
||||
"
|
||||
@clear-search="$emit('clear-search')"
|
||||
/>
|
||||
<div v-else>
|
||||
<ProjectCardImportFileArea :project-id="projectId" class="h-36 col-span-4" />
|
||||
</div>
|
||||
</template>
|
||||
<InfiniteLoading
|
||||
v-if="topLevelItems?.length && !disablePagination"
|
||||
:settings="{ identifier: infiniteLoaderId }"
|
||||
@infinite="infiniteLoad"
|
||||
/>
|
||||
<ProjectPageModelsNewDialog
|
||||
v-model:open="showNewDialog"
|
||||
:project-id="projectId"
|
||||
:parent-model-name="newSubmodelParent || undefined"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
:show-clear="!!search"
|
||||
placeholder="Search users"
|
||||
class="rounded-md border border-outline-3"
|
||||
:model-value="bind.modelValue.value"
|
||||
v-bind="bind"
|
||||
v-on="on"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
:show-clear="!!search"
|
||||
placeholder="Search invitations"
|
||||
class="rounded-md border border-outline-3"
|
||||
:model-value="bind.modelValue.value"
|
||||
v-bind="bind"
|
||||
v-on="on"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
:show-clear="!!search"
|
||||
placeholder="Search projects"
|
||||
class="rounded-md border border-outline-3"
|
||||
:model-value="bind.modelValue.value"
|
||||
v-bind="bind"
|
||||
v-on="on"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,15 @@
|
||||
<template #default="{ activeItem }">
|
||||
<SettingsWorkspacesMembersTable
|
||||
v-if="activeItem.id === 'members'"
|
||||
:workspace="workspace"
|
||||
:workspace-id="workspaceId"
|
||||
/>
|
||||
<div v-if="activeItem.id === 'guests'">Guests</div>
|
||||
<div v-if="activeItem.id === 'invites'">Pending invites</div>
|
||||
<SettingsWorkspacesMembersInvitesTable
|
||||
v-if="activeItem.id === 'invites'"
|
||||
:workspace-id="workspaceId"
|
||||
:workspace="workspace"
|
||||
/>
|
||||
</template>
|
||||
</LayoutTabsHorizontal>
|
||||
</div>
|
||||
@@ -21,17 +26,53 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LayoutTabItem } from '~~/lib/layout/helpers/components'
|
||||
import { Roles } from '@speckle/shared'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { settingsWorkspacesMembersQuery } from '~/lib/settings/graphql/queries'
|
||||
import type { LayoutPageTabItem } from '~~/lib/layout/helpers/components'
|
||||
|
||||
defineProps<{
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesMembers_Workspace on Workspace {
|
||||
id
|
||||
role
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
workspaceId: string
|
||||
}>()
|
||||
|
||||
const tabItems = ref<LayoutTabItem[]>([
|
||||
const { result } = useQuery(
|
||||
settingsWorkspacesMembersQuery,
|
||||
() => ({
|
||||
workspaceId: props.workspaceId
|
||||
}),
|
||||
() => ({
|
||||
// Custom error policy so that a failing invitedTeam resolver (due to access rights)
|
||||
// doesn't kill the entire query
|
||||
errorPolicy: 'all',
|
||||
context: {
|
||||
skipLoggingErrors: (err) =>
|
||||
err.graphQLErrors?.length === 1 &&
|
||||
err.graphQLErrors.some((e) => e.path?.includes('invitedTeam'))
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const isAdmin = computed(() => result.value?.workspace?.role === Roles.Workspace.Admin)
|
||||
const tabItems = computed<LayoutPageTabItem[]>(() => [
|
||||
{ title: 'Members', id: 'members' },
|
||||
{ title: 'Guests', id: 'guests' },
|
||||
{ title: 'Pending invites', id: 'invites' }
|
||||
{
|
||||
title: 'Pending invites',
|
||||
id: 'invites',
|
||||
disabled: !isAdmin.value,
|
||||
disabledMessage: 'Only workspace admins can manage invites'
|
||||
}
|
||||
])
|
||||
|
||||
const activeTab = ref(tabItems.value[0])
|
||||
|
||||
const workspace = computed(() => result.value?.workspace)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<SettingsWorkspacesMembersTableHeader
|
||||
v-model:search="search"
|
||||
search-placeholder="Search pending invites..."
|
||||
:workspace-id="workspaceId"
|
||||
:workspace="workspace"
|
||||
/>
|
||||
<LayoutTable
|
||||
class="mt-6 md:mt-8"
|
||||
:columns="[
|
||||
{ id: 'name', header: 'Name', classes: 'col-span-3' },
|
||||
{ id: 'invitedBy', header: 'Invited by', classes: 'col-span-4' },
|
||||
{ id: 'role', header: 'Role', classes: 'col-span-2' },
|
||||
{ id: 'lastRemindedOn', header: 'Last reminded on', classes: 'col-span-3' }
|
||||
]"
|
||||
:buttons="buttons"
|
||||
:items="invites"
|
||||
:loading="searchResultLoading"
|
||||
:empty-message="
|
||||
search.length
|
||||
? 'No invites with the specified filter found'
|
||||
: 'No pending invites'
|
||||
"
|
||||
>
|
||||
<template #name="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar v-if="item.user" :user="item.user" />
|
||||
<span class="truncate text-body-xs text-foreground">{{ item.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #invitedBy="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar :user="item.invitedBy" />
|
||||
<span class="truncate text-body-xs text-foreground">
|
||||
{{ item.invitedBy.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #role="{ item }">
|
||||
<span class="text-body-xs text-foreground-2">
|
||||
{{ roleDisplayName(item.role) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #lastRemindedOn="{ item }">
|
||||
<span class="text-body-xs text-foreground-2">
|
||||
{{ formattedFullDate(item.updatedAt) }}
|
||||
</span>
|
||||
</template>
|
||||
</LayoutTable>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { EnvelopeIcon, XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { capitalize } from 'lodash-es'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { SettingsWorkspacesMembersInvitesTable_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { settingsWorkspacesInvitesSearchQuery } from '~/lib/settings/graphql/queries'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {
|
||||
id
|
||||
role
|
||||
title
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
...LimitedUserAvatar
|
||||
}
|
||||
invitedBy {
|
||||
id
|
||||
...LimitedUserAvatar
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {
|
||||
id
|
||||
...SettingsWorkspacesMembersTableHeader_Workspace
|
||||
invitedTeam(filter: $invitesFilter) {
|
||||
...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
workspaceId: string
|
||||
workspace?: SettingsWorkspacesMembersInvitesTable_WorkspaceFragment
|
||||
}>()
|
||||
|
||||
const search = ref('')
|
||||
|
||||
const { result: searchResult, loading: searchResultLoading } = useQuery(
|
||||
settingsWorkspacesInvitesSearchQuery,
|
||||
() => ({
|
||||
invitesSearch: search.value,
|
||||
workspaceId: props.workspaceId
|
||||
}),
|
||||
() => ({
|
||||
enabled: !!search.value.length
|
||||
})
|
||||
)
|
||||
|
||||
const invites = computed(() =>
|
||||
search.value.length
|
||||
? searchResult.value?.workspace.invitedTeam || props.workspace?.invitedTeam
|
||||
: props.workspace?.invitedTeam
|
||||
)
|
||||
const buttons = computed(() => [
|
||||
{
|
||||
label: 'Resend invite',
|
||||
icon: EnvelopeIcon,
|
||||
action: () => ({})
|
||||
},
|
||||
{
|
||||
label: 'Delete invite',
|
||||
icon: XMarkIcon,
|
||||
action: () => ({})
|
||||
}
|
||||
])
|
||||
const roleDisplayName = (role: string) => capitalize(role.split(':')[1])
|
||||
</script>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div>
|
||||
<SettingsWorkspacesMembersTableHeader
|
||||
search-placeholder="Search members..."
|
||||
:workspace-id="workspaceId"
|
||||
:workspace="workspace"
|
||||
/>
|
||||
<LayoutTable
|
||||
class="mt-6 md:mt-8"
|
||||
:columns="[
|
||||
{ id: 'name', header: 'Name', classes: 'col-span-3' },
|
||||
{ id: 'company', header: 'Company', classes: 'col-span-3' },
|
||||
{ id: 'verified', header: 'Status', classes: 'col-span-3' },
|
||||
{ id: 'role', header: 'Role', classes: 'col-span-2' }
|
||||
]"
|
||||
:items="members"
|
||||
>
|
||||
<template #name="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar :user="item" />
|
||||
<span class="truncate text-body-xs text-foreground">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #company="{ item }">
|
||||
<span class="text-body-xs text-foreground">
|
||||
{{ item.company ? item.company : '-' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #verified="{ item }">
|
||||
<span class="text-body-xs text-foreground-2">
|
||||
{{ item.verified ? 'Verified' : 'Unverified' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #role="{ item }">
|
||||
<FormSelectWorkspaceRoles
|
||||
:model-value="item.role as WorkspaceRoles"
|
||||
fully-control-value
|
||||
:disabled="!isCurrentUser(item.id)"
|
||||
@update:model-value="
|
||||
(newRoleValue) => openChangeUserRoleDialog(item, newRoleValue)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</LayoutTable>
|
||||
|
||||
<SettingsSharedChangeRoleDialog
|
||||
v-model:open="showChangeUserRoleDialog"
|
||||
:name="userToModify?.name ?? ''"
|
||||
:old-role="oldRole"
|
||||
:new-role="newRole"
|
||||
@update-role="onUpdateRole"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Todo: Enable searching once supported
|
||||
import type { WorkspaceRoles } from '@speckle/shared'
|
||||
import { workspaceUpdateRoleMutation } from '~~/lib/workspaces/graphql/mutations'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
|
||||
import {
|
||||
convertThrowIntoFetchResult,
|
||||
getFirstErrorMessage
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import type { SettingsWorkspacesMembersMembersTable_WorkspaceFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
|
||||
type UserItem = (typeof members)['value'][0]
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator on WorkspaceCollaborator {
|
||||
id
|
||||
role
|
||||
user {
|
||||
id
|
||||
avatar
|
||||
name
|
||||
company
|
||||
verified
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {
|
||||
id
|
||||
...SettingsWorkspacesMembersTableHeader_Workspace
|
||||
team {
|
||||
id
|
||||
...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
workspace?: SettingsWorkspacesMembersMembersTable_WorkspaceFragment
|
||||
workspaceId: string
|
||||
}>()
|
||||
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
// const { on, bind, value: search } = useDebouncedTextInput()
|
||||
const { activeUser } = useActiveUser()
|
||||
const { mutate: updateChangeRole } = useMutation(workspaceUpdateRoleMutation)
|
||||
|
||||
const showChangeUserRoleDialog = ref(false)
|
||||
const newRole = ref<WorkspaceRoles>()
|
||||
const userToModify = ref<UserItem>()
|
||||
|
||||
const members = computed(() =>
|
||||
(props.workspace?.team || []).map(({ user, ...rest }) => ({
|
||||
...user,
|
||||
...rest
|
||||
}))
|
||||
)
|
||||
|
||||
const oldRole = computed(() => userToModify.value?.role as WorkspaceRoles)
|
||||
const isCurrentUser = (id: string) => id === activeUser.value?.id
|
||||
|
||||
const openChangeUserRoleDialog = (
|
||||
user: UserItem,
|
||||
newRoleValue?: WorkspaceRoles | WorkspaceRoles[]
|
||||
) => {
|
||||
if (!newRoleValue) return
|
||||
userToModify.value = user
|
||||
newRole.value = Array.isArray(newRoleValue) ? newRoleValue[0] : newRoleValue
|
||||
showChangeUserRoleDialog.value = true
|
||||
}
|
||||
|
||||
const onUpdateRole = async () => {
|
||||
if (!userToModify.value || !newRole.value) return
|
||||
|
||||
const mutationResult = await updateChangeRole({
|
||||
input: {
|
||||
userId: userToModify.value.id,
|
||||
role: newRole.value,
|
||||
workspaceId: props.workspaceId
|
||||
}
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (mutationResult?.data?.workspaceMutations?.updateRole) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'User role updated',
|
||||
description: 'The user role has been updated'
|
||||
})
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(mutationResult?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Failed to update role',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,160 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col-reverse md:justify-between md:flex-row md:gap-x-4">
|
||||
<!--
|
||||
Todo: Enable search once supported
|
||||
<div class="relative w-full md:max-w-sm mt-6 md:mt-0">
|
||||
<FormTextInput
|
||||
name="search"
|
||||
:custom-icon="MagnifyingGlassIcon"
|
||||
color="foundation"
|
||||
full-width
|
||||
search
|
||||
:show-clear="!!search"
|
||||
placeholder="Search members"
|
||||
class="rounded-md border border-outline-3"
|
||||
:model-value="bind.modelValue.value"
|
||||
v-on="on"
|
||||
/>
|
||||
</div> -->
|
||||
<!-- Todo: Make this button functional -->
|
||||
<FormButton>Invite</FormButton>
|
||||
</div>
|
||||
<LayoutTable
|
||||
class="mt-6 md:mt-8"
|
||||
:columns="[
|
||||
{ id: 'name', header: 'Name', classes: 'col-span-3' },
|
||||
{ id: 'company', header: 'Company', classes: 'col-span-3' },
|
||||
{ id: 'verified', header: 'Status', classes: 'col-span-3' },
|
||||
{ id: 'role', header: 'Role', classes: 'col-span-2' }
|
||||
]"
|
||||
:items="members"
|
||||
>
|
||||
<template #name="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar :user="item" />
|
||||
<span class="truncate text-body-xs text-foreground">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #company="{ item }">
|
||||
<span class="text-body-xs text-foreground">
|
||||
{{ item.company ? item.company : '-' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #verified="{ item }">
|
||||
<span class="text-body-xs text-foreground-2">
|
||||
{{ item.verified ? 'Verified' : 'Unverified' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #role="{ item }">
|
||||
<FormSelectWorkspaceRoles
|
||||
:model-value="item.role as WorkspaceRoles"
|
||||
fully-control-value
|
||||
:disabled="!isCurrentUser(item.id)"
|
||||
@update:model-value="
|
||||
(newRoleValue) => openChangeUserRoleDialog(item, newRoleValue)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</LayoutTable>
|
||||
|
||||
<SettingsSharedChangeRoleDialog
|
||||
v-model:open="showChangeUserRoleDialog"
|
||||
:name="userToModify?.name ?? ''"
|
||||
:old-role="oldRole"
|
||||
:new-role="newRole"
|
||||
@update-role="onUpdateRole"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Todo: Enable searching once supported
|
||||
import type { WorkspaceRoles } from '@speckle/shared'
|
||||
// import { MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
|
||||
// import { useDebouncedTextInput } from '@speckle/ui-components'
|
||||
import { settingsWorkspacesMembersQuery } from '~~/lib/settings/graphql/queries'
|
||||
import { workspaceUpdateRoleMutation } from '~~/lib/workspaces/graphql/mutations'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable'
|
||||
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
|
||||
import {
|
||||
convertThrowIntoFetchResult,
|
||||
getFirstErrorMessage
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import type { SettingsWorkspacesMembersQuery } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
type UserItem = {
|
||||
id: string
|
||||
role: string
|
||||
name: string
|
||||
verified?: boolean | null
|
||||
avatar?: string | null
|
||||
company?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
workspaceId: string
|
||||
}>()
|
||||
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
// const { on, bind, value: search } = useDebouncedTextInput()
|
||||
const { activeUser } = useActiveUser()
|
||||
const { mutate: updateChangeRole } = useMutation(workspaceUpdateRoleMutation)
|
||||
|
||||
const { result } = useQuery<SettingsWorkspacesMembersQuery>(
|
||||
settingsWorkspacesMembersQuery,
|
||||
() => ({
|
||||
workspaceId: props.workspaceId
|
||||
})
|
||||
)
|
||||
|
||||
const showChangeUserRoleDialog = ref(false)
|
||||
const newRole = ref<WorkspaceRoles>()
|
||||
const userToModify = ref<UserItem>()
|
||||
|
||||
const members = computed(() =>
|
||||
(result.value?.workspace.team || []).map(({ user, ...rest }) => ({
|
||||
...user,
|
||||
...rest
|
||||
}))
|
||||
)
|
||||
|
||||
const oldRole = computed(() => userToModify.value?.role as WorkspaceRoles)
|
||||
const isCurrentUser = (id: string) => id === activeUser.value?.id
|
||||
|
||||
const openChangeUserRoleDialog = (
|
||||
user: UserItem,
|
||||
newRoleValue?: WorkspaceRoles | WorkspaceRoles[]
|
||||
) => {
|
||||
if (!newRoleValue) return
|
||||
userToModify.value = user
|
||||
newRole.value = Array.isArray(newRoleValue) ? newRoleValue[0] : newRoleValue
|
||||
showChangeUserRoleDialog.value = true
|
||||
}
|
||||
|
||||
const onUpdateRole = async () => {
|
||||
if (!userToModify.value || !newRole.value) return
|
||||
|
||||
const mutationResult = await updateChangeRole({
|
||||
input: {
|
||||
userId: userToModify.value.id,
|
||||
role: newRole.value,
|
||||
workspaceId: props.workspaceId
|
||||
}
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (mutationResult?.data?.workspaceMutations?.updateRole) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'User role updated',
|
||||
description: 'The user role has been updated'
|
||||
})
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(mutationResult?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Failed to update role',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="flex flex-col-reverse md:justify-between md:flex-row md:gap-x-4">
|
||||
<div class="relative w-full md:max-w-sm mt-6 md:mt-0">
|
||||
<FormTextInput
|
||||
name="search"
|
||||
:custom-icon="MagnifyingGlassIcon"
|
||||
color="foundation"
|
||||
full-width
|
||||
search
|
||||
show-clear
|
||||
:placeholder="searchPlaceholder"
|
||||
class="rounded-md border border-outline-3"
|
||||
v-bind="bind"
|
||||
v-on="on"
|
||||
/>
|
||||
</div>
|
||||
<FormButton @click="() => (isInviteDialogOpen = !isInviteDialogOpen)">
|
||||
Invite
|
||||
</FormButton>
|
||||
<WorkspaceInviteDialog
|
||||
v-model:open="isInviteDialogOpen"
|
||||
:workspace-id="workspaceId"
|
||||
:workspace="workspace"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
|
||||
import { useDebouncedTextInput } from '@speckle/ui-components'
|
||||
import type { SettingsWorkspacesMembersTableHeader_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {
|
||||
id
|
||||
...WorkspaceInviteDialog_Workspace
|
||||
}
|
||||
`)
|
||||
|
||||
defineProps<{
|
||||
searchPlaceholder: string
|
||||
workspaceId: string
|
||||
workspace?: SettingsWorkspacesMembersTableHeader_WorkspaceFragment
|
||||
}>()
|
||||
|
||||
const search = defineModel<string>('search')
|
||||
const { on, bind } = useDebouncedTextInput({ model: search })
|
||||
const isInviteDialogOpen = ref(false)
|
||||
</script>
|
||||
@@ -239,6 +239,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasAnyFiltersApplied"
|
||||
class="bg-pink-300/0 flex justify-center items-center pointer-events-none transition-all duration-300 ease-in overflow-hidden h-8"
|
||||
>
|
||||
<FormButton class="pointer-events-auto" @click="trackAndResetFilters">
|
||||
@@ -12,10 +11,7 @@
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useFilterUtilities } from '~~/lib/viewer/composables/ui'
|
||||
|
||||
const {
|
||||
resetFilters,
|
||||
filters: { hasAnyFiltersApplied }
|
||||
} = useFilterUtilities()
|
||||
const { resetFilters } = useFilterUtilities()
|
||||
|
||||
const mp = useMixpanel()
|
||||
const trackAndResetFilters = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<slot />
|
||||
<div><slot /></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useViewerPostSetup } from '~~/lib/viewer/composables/setup/postSetup'
|
||||
|
||||
@@ -74,7 +74,11 @@
|
||||
<div class="flex gap-3">
|
||||
<PortalTarget name="pocket-actions"></PortalTarget>
|
||||
<!-- Shows up when filters are applied for an easy return to normality -->
|
||||
<ViewerGlobalFilterReset class="z-20" :embed="!!isEmbedEnabled" />
|
||||
<ViewerGlobalFilterReset
|
||||
v-if="hasAnyFiltersApplied"
|
||||
class="z-20"
|
||||
:embed="!!isEmbedEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-end justify-center sm:justify-end">
|
||||
@@ -103,6 +107,7 @@ import dayjs from 'dayjs'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
import { useFilterUtilities } from '~/lib/viewer/composables/ui'
|
||||
|
||||
const emit = defineEmits<{
|
||||
setup: [InjectableViewerState]
|
||||
@@ -110,6 +115,9 @@ const emit = defineEmits<{
|
||||
|
||||
const route = useRoute()
|
||||
const { showTour, showControls } = useViewerTour()
|
||||
const {
|
||||
filters: { hasAnyFiltersApplied }
|
||||
} = useFilterUtilities()
|
||||
|
||||
const modelId = computed(() => route.params.modelId as string)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<slot v-if="!wrapper" />
|
||||
<div v-else>
|
||||
<slot />
|
||||
<div>
|
||||
<slot v-if="!wrapper" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="filter" class="px-3 flex flex-col space-y-2 pb-2">
|
||||
<div class="px-3 flex flex-col space-y-2 pb-2">
|
||||
<div class="flex w-full space-x-1">
|
||||
<div class="text-xs">Range:</div>
|
||||
<div class="text-xs truncate">[{{ props.filter.min.toFixed(2) }},</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="filter" class="pr-3 pl-2 flex flex-col space-y-2 pb-2">
|
||||
<div class="pr-3 pl-2 flex flex-col space-y-2 pb-2">
|
||||
<ViewerExplorerStringFilterItem
|
||||
v-for="(vg, index) in groupsLimited"
|
||||
:key="index"
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useQuery, useSubscription } from '@vue/apollo-composable'
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
</template>
|
||||
</ViewerSidebar>
|
||||
</ViewerCommentsPortalOrDiv>
|
||||
<div v-else />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="open"
|
||||
max-width="sm"
|
||||
title="Invite people to workspace"
|
||||
:buttons="buttons"
|
||||
buttons-wrapper-classes="flex-row-reverse"
|
||||
>
|
||||
<div>
|
||||
<FormTextInput
|
||||
name="search"
|
||||
size="lg"
|
||||
placeholder="Search by email or username..."
|
||||
:disabled="disabled"
|
||||
input-classes="pr-[85px] text-sm"
|
||||
color="foundation"
|
||||
label="Add people"
|
||||
show-label
|
||||
v-bind="bind"
|
||||
v-on="on"
|
||||
>
|
||||
<template #input-right>
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
:class="disabled ? 'pointer-events-none' : ''"
|
||||
>
|
||||
<WorkspacePermissionSelect v-model="role" hide-remove />
|
||||
</div>
|
||||
</template>
|
||||
</FormTextInput>
|
||||
<div
|
||||
v-if="hasTargets"
|
||||
class="flex flex-col mt-2 border rounded-md border-outline-3"
|
||||
>
|
||||
<template v-if="users.length">
|
||||
<WorkspaceInviteDialogUserRow
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:user="user"
|
||||
:disabled="disabled"
|
||||
:is-owner-role="isOwnerRole"
|
||||
@invite-user="() => onInviteUser(user)"
|
||||
/>
|
||||
</template>
|
||||
<WorkspaceInviteDialogEmailsRow
|
||||
v-else-if="emails.length"
|
||||
:selected-emails="emails"
|
||||
:disabled="disabled"
|
||||
:is-owner-role="isOwnerRole"
|
||||
:is-guest-mode="isGuestMode"
|
||||
class="p-2"
|
||||
@invite-emails="({ serverRole }) => onInviteUser(emails, serverRole)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
isNonNullable,
|
||||
Roles,
|
||||
type ServerRoles,
|
||||
type WorkspaceRoles
|
||||
} from '@speckle/shared'
|
||||
import { useDebouncedTextInput, type LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { isString } from 'lodash-es'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type {
|
||||
WorkspaceInviteCreateInput,
|
||||
WorkspaceInviteDialog_WorkspaceFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { mapServerRoleToGqlServerRole } from '~/lib/common/helpers/roles'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import { useServerInfo } from '~/lib/core/composables/server'
|
||||
import { useResolveInviteTargets } from '~/lib/server/composables/invites'
|
||||
import { useInviteUserToWorkspace } from '~/lib/workspaces/composables/management'
|
||||
import {
|
||||
filterInvalidInviteTargets,
|
||||
type UserSearchItemOrEmail
|
||||
} from '~/lib/workspaces/helpers/invites'
|
||||
import { mapMainRoleToGqlWorkspaceRole } from '~/lib/workspaces/helpers/roles'
|
||||
|
||||
graphql(`
|
||||
fragment WorkspaceInviteDialog_Workspace on Workspace {
|
||||
id
|
||||
team {
|
||||
id
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
invitedTeam(filter: $invitesFilter) {
|
||||
title
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
workspaceId: string
|
||||
workspace?: WorkspaceInviteDialog_WorkspaceFragment
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const mp = useMixpanel()
|
||||
const inviteToWorkspace = useInviteUserToWorkspace()
|
||||
const { on, bind, value: search } = useDebouncedTextInput({ debouncedBy: 500 })
|
||||
const { users, emails, hasTargets } = useResolveInviteTargets({
|
||||
search,
|
||||
excludeUserIds: computed(() => [
|
||||
...(props.workspace?.team.map((c) => c.user.id) || []),
|
||||
...(props.workspace?.invitedTeam?.map((c) => c.user?.id).filter(isNonNullable) ||
|
||||
[])
|
||||
]),
|
||||
excludeEmails: computed(() => props.workspace?.invitedTeam?.map((c) => c.title))
|
||||
})
|
||||
const { isGuestMode } = useServerInfo()
|
||||
|
||||
const disabled = ref(false)
|
||||
const role = ref<WorkspaceRoles>(Roles.Workspace.Member)
|
||||
|
||||
const buttons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Done',
|
||||
props: { color: 'primary' },
|
||||
onClick: () => {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const isOwnerRole = computed(() => role.value === Roles.Workspace.Admin)
|
||||
|
||||
const onInviteUser = async (
|
||||
user: UserSearchItemOrEmail | UserSearchItemOrEmail[],
|
||||
serverRole: ServerRoles = Roles.Server.User
|
||||
) => {
|
||||
const users = filterInvalidInviteTargets(user, {
|
||||
isTargetResourceOwner: isOwnerRole.value,
|
||||
emailTargetServerRole: serverRole
|
||||
})
|
||||
|
||||
const inputs: WorkspaceInviteCreateInput[] = users.map((u) => ({
|
||||
role: mapMainRoleToGqlWorkspaceRole(role.value),
|
||||
...(isString(u)
|
||||
? {
|
||||
email: u,
|
||||
serverRole: mapServerRoleToGqlServerRole(serverRole)
|
||||
}
|
||||
: {
|
||||
userId: u.id
|
||||
})
|
||||
}))
|
||||
if (!inputs.length) return
|
||||
|
||||
disabled.value = true
|
||||
|
||||
await inviteToWorkspace(props.workspaceId, inputs)
|
||||
|
||||
const isEmail = !!inputs.find((u) => !!u.email)
|
||||
mp.track('Invite Action', {
|
||||
type: 'workspace invite',
|
||||
name: 'send',
|
||||
multiple: inputs.length !== 1,
|
||||
count: inputs.length,
|
||||
hasProject: true,
|
||||
to: isEmail ? 'email' : 'existing user'
|
||||
})
|
||||
|
||||
disabled.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<FormSelectBase
|
||||
v-model="modelWrapper"
|
||||
:items="Object.values(items)"
|
||||
label="Workspace access level"
|
||||
button-style="simple"
|
||||
:show-label="showLabel"
|
||||
:name="name || 'role'"
|
||||
:allow-unset="false"
|
||||
:disabled="disabled"
|
||||
:label-id="labelId"
|
||||
:button-id="buttonId"
|
||||
hide-checkmarks
|
||||
by="id"
|
||||
class="min-w-[85px]"
|
||||
mount-menu-on-body
|
||||
>
|
||||
<template #something-selected="{ value }">
|
||||
<div class="text-normal text-right">
|
||||
{{ isArray(value) ? value[0].title : value.title }}
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{ item, selected }">
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
:class="[
|
||||
'text-normal',
|
||||
selected ? 'text-primary' : '',
|
||||
item.id === 'delete' ? 'text-danger' : ''
|
||||
]"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Roles } from '@speckle/shared'
|
||||
import type { WorkspaceRoles } from '@speckle/shared'
|
||||
import { reduce, isArray } from 'lodash-es'
|
||||
import { roleSelectItems } from '~/lib/workspaces/helpers/roles'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string
|
||||
showLabel?: boolean
|
||||
name?: string
|
||||
disabled?: boolean
|
||||
hideRemove?: boolean
|
||||
hideOwner?: boolean
|
||||
}>()
|
||||
|
||||
const labelId = useId()
|
||||
const buttonId = useId()
|
||||
const items = ref(
|
||||
reduce(
|
||||
roleSelectItems,
|
||||
(results, item) => {
|
||||
if (item.id === 'delete') {
|
||||
if (!props.hideRemove) {
|
||||
results[item.id] = item
|
||||
}
|
||||
} else if (item.id === Roles.Workspace.Admin) {
|
||||
if (!props.hideOwner) {
|
||||
results[item.id] = item
|
||||
}
|
||||
} else {
|
||||
results[item.id] = item
|
||||
}
|
||||
|
||||
return results
|
||||
},
|
||||
{} as typeof roleSelectItems
|
||||
)
|
||||
)
|
||||
|
||||
const model = defineModel<WorkspaceRoles>({ required: true })
|
||||
|
||||
const modelWrapper = computed({
|
||||
get: () => items.value[model.value],
|
||||
set: (newVal) => {
|
||||
if (newVal.id === 'delete') return emit('delete')
|
||||
model.value = newVal.id
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="flex px-4 py-3 items-center space-x-2">
|
||||
<UserAvatar />
|
||||
<span class="grow truncate text-body-sm">{{ selectedEmails.join(', ') }}</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormSelectServerRoles
|
||||
v-if="showServerRoleSelect"
|
||||
v-model="serverRole"
|
||||
:allow-guest="isGuestMode"
|
||||
:allow-admin="isAdmin"
|
||||
fixed-height
|
||||
/>
|
||||
<span
|
||||
v-tippy="
|
||||
isTryingToSetGuestOwner ? `Server guests can't be project owners` : undefined
|
||||
"
|
||||
>
|
||||
<FormButton
|
||||
:disabled="isButtonDisabled"
|
||||
color="outline"
|
||||
@click="() => $emit('invite-emails', { serverRole })"
|
||||
>
|
||||
Invite
|
||||
</FormButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Roles } from '@speckle/shared'
|
||||
import type { ServerRoles } from '@speckle/shared'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
|
||||
defineEmits<{
|
||||
(e: 'invite-emails', payload: { serverRole: ServerRoles }): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
selectedEmails: string[]
|
||||
disabled?: boolean
|
||||
isGuestMode: boolean
|
||||
isOwnerRole: boolean
|
||||
}>()
|
||||
|
||||
const { isAdmin } = useActiveUser()
|
||||
|
||||
const serverRole = ref<ServerRoles>(Roles.Server.User)
|
||||
|
||||
const showServerRoleSelect = computed(() => props.isGuestMode || isAdmin.value)
|
||||
|
||||
const isTryingToSetGuestOwner = computed(() => {
|
||||
if (!showServerRoleSelect.value) return false
|
||||
if (serverRole.value === Roles.Server.Guest && props.isOwnerRole) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const isButtonDisabled = computed(() => {
|
||||
if (props.disabled) return true
|
||||
if (isTryingToSetGuestOwner.value) return true
|
||||
if (!props.selectedEmails.length) return true
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex px-4 py-3 items-center space-x-2 border-b last:border-0 border-outline-3"
|
||||
>
|
||||
<UserAvatar :user="user" />
|
||||
<span class="grow truncate text-body-sm">{{ user.name }}</span>
|
||||
<span v-tippy="isTryingToSetGuestOwner ? settingGuestOwnerErrorMessage : undefined">
|
||||
<FormButton
|
||||
:disabled="isButtonDisabled"
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click="() => $emit('invite-user')"
|
||||
>
|
||||
Invite
|
||||
</FormButton>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Roles } from '@speckle/shared'
|
||||
import type { UserSearchItem } from '~~/lib/common/composables/users'
|
||||
|
||||
defineEmits<{
|
||||
(e: 'invite-user'): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
isOwnerRole: boolean
|
||||
user: UserSearchItem
|
||||
disabled?: boolean
|
||||
settingGuestOwnerErrorMessage?: string
|
||||
}>(),
|
||||
{
|
||||
settingGuestOwnerErrorMessage: "Server guests can't be workspace owners"
|
||||
}
|
||||
)
|
||||
|
||||
const isTryingToSetGuestOwner = computed(
|
||||
() => props.user.role === Roles.Server.Guest && props.isOwnerRole
|
||||
)
|
||||
const isButtonDisabled = computed(() => {
|
||||
if (props.disabled) return true
|
||||
if (isTryingToSetGuestOwner.value) return true
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
@@ -55,3 +55,9 @@ export const useDevLogger = () => {
|
||||
const info = logger.info.bind(logger)
|
||||
return info as (...args: unknown[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* console.log replacement for development mode. Calls to this are skipped outside of dev mode
|
||||
* and it ensures that the real structured logger is used.
|
||||
*/
|
||||
export const devLog = (...args: unknown[]) => useDevLogger()(...args)
|
||||
|
||||
@@ -97,7 +97,9 @@ const configs = withNuxt([
|
||||
}
|
||||
}
|
||||
],
|
||||
'vue/html-self-closing': 'off' // messes with prettier
|
||||
'vue/html-self-closing': 'off', // messes with prettier
|
||||
'vue/no-multiple-template-root': 'error',
|
||||
'vue/no-root-v-if': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -92,10 +92,17 @@ const documents = {
|
||||
"\n fragment UserProfileEditDialogDeleteAccount_User on User {\n id\n email\n }\n": types.UserProfileEditDialogDeleteAccount_UserFragmentDoc,
|
||||
"\n fragment UserProfileEditDialogBio_User on User {\n id\n name\n company\n bio\n ...UserProfileEditDialogAvatar_User\n }\n": types.UserProfileEditDialogBio_UserFragmentDoc,
|
||||
"\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n": types.UserProfileEditDialogAvatar_UserFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n": types.SettingsWorkspacesMembers_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\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,
|
||||
"\n fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam(filter: $invitesFilter) {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_WorkspaceFragmentDoc,
|
||||
"\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 ...WorkspaceInviteDialog_Workspace\n }\n": types.SettingsWorkspacesMembersTableHeader_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,
|
||||
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": types.ViewerModelVersionCardItemFragmentDoc,
|
||||
"\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n id\n team {\n id\n user {\n id\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n": types.WorkspaceInviteDialog_WorkspaceFragmentDoc,
|
||||
"\n 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,
|
||||
@@ -136,7 +143,7 @@ const documents = {
|
||||
"\n query GendoAIRenders($versionId: String!, $projectId: String!) {\n project(id: $projectId) {\n id\n version(id: $versionId) {\n id\n gendoAIRenders {\n totalCount\n items {\n id\n createdAt\n updatedAt\n status\n gendoGenerationId\n prompt\n camera\n }\n }\n }\n }\n }\n": types.GendoAiRendersDocument,
|
||||
"\n subscription ProjectVersionGendoAIRenderCreated($id: String!, $versionId: String!) {\n projectVersionGendoAIRenderCreated(id: $id, versionId: $versionId) {\n id\n createdAt\n updatedAt\n status\n gendoGenerationId\n prompt\n camera\n }\n }\n": types.ProjectVersionGendoAiRenderCreatedDocument,
|
||||
"\n subscription ProjectVersionGendoAIRenderUpdated($id: String!, $versionId: String!) {\n projectVersionGendoAIRenderUpdated(id: $id, versionId: $versionId) {\n id\n projectId\n modelId\n versionId\n createdAt\n updatedAt\n gendoGenerationId\n status\n prompt\n camera\n responseImage\n }\n }\n": types.ProjectVersionGendoAiRenderUpdatedDocument,
|
||||
"\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n role\n ...LimitedUserAvatar\n }\n }\n }\n": types.ProjectPageTeamInternals_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n": types.ProjectPageTeamInternals_ProjectFragmentDoc,
|
||||
"\n fragment ProjectDashboardItemNoModels on Project {\n id\n name\n createdAt\n updatedAt\n role\n team {\n id\n user {\n id\n name\n avatar\n }\n }\n ...ProjectPageModelsCardProject\n }\n": types.ProjectDashboardItemNoModelsFragmentDoc,
|
||||
"\n fragment ProjectDashboardItem on Project {\n id\n ...ProjectDashboardItemNoModels\n models(limit: 4, filter: { onlyWithVersions: true }) {\n totalCount\n items {\n ...ProjectPageLatestItemsModelItem\n }\n }\n pendingImportedModels(limit: 4) {\n ...PendingFileUpload\n }\n }\n": types.ProjectDashboardItemFragmentDoc,
|
||||
"\n fragment PendingFileUpload on FileUpload {\n id\n projectId\n modelName\n convertedStatus\n convertedMessage\n uploadDate\n convertedLastUpdate\n fileType\n fileName\n }\n": types.PendingFileUploadFragmentDoc,
|
||||
@@ -215,7 +222,8 @@ const documents = {
|
||||
"\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 query SettingsSidebarWorkspaces {\n activeUser {\n workspaces {\n items {\n id\n name\n }\n }\n }\n }\n": types.SettingsSidebarWorkspacesDocument,
|
||||
"\n query SettingsWorkspacesMembers($workspaceId: String!) {\n workspace(id: $workspaceId) {\n team {\n role\n id\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n }\n }\n": types.SettingsWorkspacesMembersDocument,
|
||||
"\n query SettingsWorkspacesMembers(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembers_Workspace\n ...SettingsWorkspacesMembersMembersTable_Workspace\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n": types.SettingsWorkspacesMembersDocument,
|
||||
"\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 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,
|
||||
@@ -242,6 +250,7 @@ const documents = {
|
||||
"\n subscription OnViewerCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n id\n type\n comment {\n id\n parent {\n id\n }\n ...ViewerCommentThread\n }\n }\n }\n": types.OnViewerCommentsUpdatedDocument,
|
||||
"\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n": types.LinkableCommentFragmentDoc,
|
||||
"\n mutation UpdateRole($input: WorkspaceRoleUpdateInput!) {\n workspaceMutations {\n updateRole(input: $input) {\n id\n team {\n id\n role\n }\n }\n }\n }\n": types.UpdateRoleDocument,
|
||||
"\n mutation InviteToWorkspace(\n $workspaceId: String!\n $input: [WorkspaceInviteCreateInput!]!\n ) {\n workspaceMutations {\n invites {\n batchCreate(workspaceId: $workspaceId, input: $input) {\n id\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n }\n }\n }\n": types.InviteToWorkspaceDocument,
|
||||
"\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n project(id: $streamId) {\n modelByName(name: $branchName) {\n id\n }\n }\n }\n": types.LegacyBranchRedirectMetadataDocument,
|
||||
"\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n project(id: $streamId) {\n version(id: $commitId) {\n id\n model {\n id\n }\n }\n }\n }\n": types.LegacyViewerCommitRedirectMetadataDocument,
|
||||
"\n query LegacyViewerStreamRedirectMetadata($streamId: String!) {\n project(id: $streamId) {\n id\n versions(limit: 1) {\n totalCount\n items {\n id\n model {\n id\n }\n }\n }\n }\n }\n": types.LegacyViewerStreamRedirectMetadataDocument,
|
||||
@@ -585,6 +594,30 @@ export function graphql(source: "\n fragment UserProfileEditDialogBio_User on U
|
||||
* 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 UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n"): (typeof documents)["\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\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 SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n role\n title\n updatedAt\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n role\n title\n updatedAt\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam(filter: $invitesFilter) {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam(filter: $invitesFilter) {\n ...SettingsWorkspacesMembersInvitesTable_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 SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n"): (typeof 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"];
|
||||
/**
|
||||
* 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 SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\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 SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n ...WorkspaceInviteDialog_Workspace\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n ...WorkspaceInviteDialog_Workspace\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -601,6 +634,10 @@ export function graphql(source: "\n fragment ViewerCommentsListItem on Comment
|
||||
* 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 ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n"): (typeof documents)["\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n"];
|
||||
/**
|
||||
* 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 WorkspaceInviteDialog_Workspace on Workspace {\n id\n team {\n id\n user {\n id\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n id\n team {\n id\n user {\n id\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -764,7 +801,7 @@ export function graphql(source: "\n subscription ProjectVersionGendoAIRenderUpd
|
||||
/**
|
||||
* 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 ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n role\n ...LimitedUserAvatar\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n role\n ...LimitedUserAvatar\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1080,7 +1117,11 @@ export function graphql(source: "\n query SettingsSidebarWorkspaces {\n acti
|
||||
/**
|
||||
* 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 SettingsWorkspacesMembers($workspaceId: String!) {\n workspace(id: $workspaceId) {\n team {\n role\n id\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n }\n }\n"): (typeof documents)["\n query SettingsWorkspacesMembers($workspaceId: String!) {\n workspace(id: $workspaceId) {\n team {\n role\n id\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n query SettingsWorkspacesMembers(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembers_Workspace\n ...SettingsWorkspacesMembersMembersTable_Workspace\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspacesMembers(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembers_Workspace\n ...SettingsWorkspacesMembersMembersTable_Workspace\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query SettingsWorkspacesInvitesSearch(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspacesInvitesSearch(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1185,6 +1226,10 @@ export function graphql(source: "\n fragment LinkableComment on Comment {\n
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation UpdateRole($input: WorkspaceRoleUpdateInput!) {\n workspaceMutations {\n updateRole(input: $input) {\n id\n team {\n id\n role\n }\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateRole($input: WorkspaceRoleUpdateInput!) {\n workspaceMutations {\n updateRole(input: $input) {\n id\n team {\n id\n role\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 InviteToWorkspace(\n $workspaceId: String!\n $input: [WorkspaceInviteCreateInput!]!\n ) {\n workspaceMutations {\n invites {\n batchCreate(workspaceId: $workspaceId, input: $input) {\n id\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation InviteToWorkspace(\n $workspaceId: String!\n $input: [WorkspaceInviteCreateInput!]!\n ) {\n workspaceMutations {\n invites {\n batchCreate(workspaceId: $workspaceId, input: $input) {\n id\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -313,23 +313,23 @@ const revolveFieldNameAndVariables = <
|
||||
* full objects. Read more: https://www.apollographql.com/docs/react/caching/cache-interaction/#values-vs-references
|
||||
*/
|
||||
export function modifyObjectFields<
|
||||
V extends Optional<Record<string, unknown>> = undefined,
|
||||
D = unknown
|
||||
Variables extends Optional<Record<string, unknown>> = undefined,
|
||||
FieldData = unknown
|
||||
>(
|
||||
cache: ApolloCache<unknown>,
|
||||
id: string,
|
||||
updater: (
|
||||
fieldName: string,
|
||||
variables: V,
|
||||
value: ModifyFnCacheData<D>,
|
||||
details: Parameters<Modifier<ModifyFnCacheData<D>>>[1] & {
|
||||
variables: Variables,
|
||||
value: ModifyFnCacheData<FieldData>,
|
||||
details: Parameters<Modifier<ModifyFnCacheData<FieldData>>>[1] & {
|
||||
ref: typeof getObjectReference
|
||||
revolveFieldNameAndVariables: typeof revolveFieldNameAndVariables
|
||||
}
|
||||
) =>
|
||||
| Optional<ModifyFnCacheData<D>>
|
||||
| Parameters<Modifier<ModifyFnCacheData<D>>>[1]['DELETE']
|
||||
| Parameters<Modifier<ModifyFnCacheData<D>>>[1]['INVALIDATE'],
|
||||
| Optional<ModifyFnCacheData<FieldData>>
|
||||
| Parameters<Modifier<ModifyFnCacheData<FieldData>>>[1]['DELETE']
|
||||
| Parameters<Modifier<ModifyFnCacheData<FieldData>>>[1]['INVALIDATE'],
|
||||
options?: Partial<{
|
||||
fieldNameWhitelist: string[]
|
||||
debug: boolean
|
||||
@@ -364,13 +364,16 @@ export function modifyObjectFields<
|
||||
return fieldValue as unknown
|
||||
}
|
||||
|
||||
const { variables } = revolveFieldNameAndVariables<V>(storeFieldName, fieldName)
|
||||
const { variables } = revolveFieldNameAndVariables<Variables>(
|
||||
storeFieldName,
|
||||
fieldName
|
||||
)
|
||||
|
||||
log('invoking updater', { fieldName, variables, fieldValue })
|
||||
const res = updater(
|
||||
fieldName,
|
||||
(variables || {}) as V,
|
||||
fieldValue as ModifyFnCacheData<D>,
|
||||
(variables || {}) as Variables,
|
||||
fieldValue as ModifyFnCacheData<FieldData>,
|
||||
{
|
||||
...details,
|
||||
ref: getObjectReference,
|
||||
|
||||
@@ -31,3 +31,16 @@ export function mapServerRoleToValue(graphqlServerRole: ServerRole): ServerRoles
|
||||
return Roles.Server.Guest
|
||||
}
|
||||
}
|
||||
|
||||
export const mapServerRoleToGqlServerRole = (role: ServerRoles): ServerRole => {
|
||||
switch (role) {
|
||||
case Roles.Server.User:
|
||||
return ServerRole.ServerUser
|
||||
case Roles.Server.Admin:
|
||||
return ServerRole.ServerAdmin
|
||||
case Roles.Server.ArchivedUser:
|
||||
return ServerRole.ServerArchivedUser
|
||||
case Roles.Server.Guest:
|
||||
return ServerRole.ServerGuest
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +288,13 @@ function createCache(): InMemoryCache {
|
||||
merge: buildAbstractCollectionMergeFunction('AutomateRunCollection')
|
||||
}
|
||||
}
|
||||
},
|
||||
Workspace: {
|
||||
fields: {
|
||||
invitedTeam: {
|
||||
merge: (_existing, incoming) => incoming
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export { GridListToggleValue } from '@speckle/ui-components'
|
||||
export type { LayoutTabItem, LayoutMenuItem } from '@speckle/ui-components'
|
||||
export type {
|
||||
LayoutTabItem,
|
||||
LayoutMenuItem,
|
||||
LayoutPageTabItem
|
||||
} from '@speckle/ui-components'
|
||||
|
||||
@@ -25,6 +25,7 @@ graphql(`
|
||||
team {
|
||||
role
|
||||
user {
|
||||
id
|
||||
role
|
||||
...LimitedUserAvatar
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
modifyObjectFields
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import { inviteServerUserMutation } from '../graphql/mutations'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import { useUserSearch } from '~/lib/common/composables/users'
|
||||
import { isValidEmail } from '~/lib/workspaces/helpers/invites'
|
||||
import { uniq } from 'lodash-es'
|
||||
|
||||
export function useInviteUserToServer() {
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
@@ -67,3 +71,48 @@ export function useInviteUserToServer() {
|
||||
loading
|
||||
}
|
||||
}
|
||||
|
||||
export const useResolveInviteTargets = (params: {
|
||||
search: Ref<MaybeNullOrUndefined<string>>
|
||||
/**
|
||||
* For excluding already invited/added users from search results.
|
||||
*/
|
||||
excludeUserIds?: Ref<MaybeNullOrUndefined<string[]>>
|
||||
excludeEmails?: Ref<MaybeNullOrUndefined<string[]>>
|
||||
}) => {
|
||||
const { search, excludeUserIds, excludeEmails } = params
|
||||
|
||||
const { userSearch, searchVariables } = useUserSearch({
|
||||
variables: computed(() => ({
|
||||
query: search.value || '',
|
||||
limit: 5
|
||||
}))
|
||||
})
|
||||
|
||||
const emails = computed(() => {
|
||||
const query = searchVariables.value?.query || ''
|
||||
const multipleEmails = isValidEmail(query)
|
||||
? [query]
|
||||
: query.split(',').map((i) => i.trim())
|
||||
const validEmails = multipleEmails.filter((e) => isValidEmail(e))
|
||||
const uniqueEmails = uniq(validEmails)
|
||||
|
||||
const finalEmails = uniqueEmails.length ? uniqueEmails : []
|
||||
const invitedEmails = new Set(excludeEmails?.value || [])
|
||||
if (!invitedEmails.size) return finalEmails
|
||||
|
||||
return finalEmails.filter((e) => !invitedEmails.has(e))
|
||||
})
|
||||
|
||||
const users = computed(() => {
|
||||
const searchResults = userSearch.value?.userSearch.items || []
|
||||
const collaboratorIds = new Set(excludeUserIds?.value || [])
|
||||
if (!collaboratorIds.size) return searchResults
|
||||
|
||||
return searchResults.filter((r) => !collaboratorIds.has(r.id))
|
||||
})
|
||||
|
||||
const hasTargets = computed(() => users.value?.length || emails.value?.length)
|
||||
|
||||
return { users, emails, hasTargets }
|
||||
}
|
||||
|
||||
@@ -14,19 +14,25 @@ export const settingsSidebarWorkspacesQuery = graphql(`
|
||||
`)
|
||||
|
||||
export const settingsWorkspacesMembersQuery = graphql(`
|
||||
query SettingsWorkspacesMembers($workspaceId: String!) {
|
||||
query SettingsWorkspacesMembers(
|
||||
$workspaceId: String!
|
||||
$invitesFilter: PendingWorkspaceCollaboratorsFilter
|
||||
) {
|
||||
workspace(id: $workspaceId) {
|
||||
team {
|
||||
role
|
||||
id
|
||||
user {
|
||||
id
|
||||
avatar
|
||||
name
|
||||
company
|
||||
verified
|
||||
}
|
||||
}
|
||||
...SettingsWorkspacesMembers_Workspace
|
||||
...SettingsWorkspacesMembersMembersTable_Workspace
|
||||
...SettingsWorkspacesMembersInvitesTable_Workspace
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const settingsWorkspacesInvitesSearchQuery = graphql(`
|
||||
query SettingsWorkspacesInvitesSearch(
|
||||
$workspaceId: String!
|
||||
$invitesFilter: PendingWorkspaceCollaboratorsFilter
|
||||
) {
|
||||
workspace(id: $workspaceId) {
|
||||
...SettingsWorkspacesMembersInvitesTable_Workspace
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceInviteCreateInput,
|
||||
WorkspaceInvitedTeamArgs
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
evictObjectFields,
|
||||
getCacheId,
|
||||
getFirstErrorMessage,
|
||||
getObjectReference,
|
||||
modifyObjectFields
|
||||
} from '~/lib/common/helpers/graphql'
|
||||
import { inviteToWorkspaceMutation } from '~/lib/workspaces/graphql/mutations'
|
||||
|
||||
export const useInviteUserToWorkspace = () => {
|
||||
const { activeUser } = useActiveUser()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { mutate } = useMutation(inviteToWorkspaceMutation)
|
||||
|
||||
return async (workspaceId: string, inputs: WorkspaceInviteCreateInput[]) => {
|
||||
const userId = activeUser.value?.id
|
||||
if (!userId) return
|
||||
|
||||
const { data, errors } =
|
||||
(await mutate(
|
||||
{ workspaceId, input: inputs },
|
||||
{
|
||||
update: (cache, { data }) => {
|
||||
if (!data?.workspaceMutations.invites.batchCreate.id) return
|
||||
|
||||
const invitedTeam = data.workspaceMutations.invites.batchCreate.invitedTeam
|
||||
if (!invitedTeam) return
|
||||
|
||||
modifyObjectFields<WorkspaceInvitedTeamArgs, Workspace['invitedTeam']>(
|
||||
cache,
|
||||
getCacheId('Workspace', workspaceId),
|
||||
(_fieldName, vars) => {
|
||||
if (vars.filter?.search?.length) return
|
||||
return invitedTeam.map((i) =>
|
||||
getObjectReference('PendingWorkspaceCollaborator', i.id)
|
||||
)
|
||||
},
|
||||
{
|
||||
fieldNameWhitelist: ['invitedTeam']
|
||||
}
|
||||
)
|
||||
|
||||
// Evict the cache for the invited team if the search filter is active
|
||||
evictObjectFields<WorkspaceInvitedTeamArgs, Workspace['invitedTeam']>(
|
||||
cache,
|
||||
getCacheId('Workspace', workspaceId),
|
||||
(fieldName, vars) => {
|
||||
if (fieldName !== 'invitedTeam') return false
|
||||
return vars.filter?.search?.length !== 0
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
).catch(convertThrowIntoFetchResult)) || {}
|
||||
|
||||
if (!data?.workspaceMutations.invites.batchCreate.id) {
|
||||
const err = getFirstErrorMessage(errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Invitation failed',
|
||||
description: err
|
||||
})
|
||||
} else {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Invite successfully sent'
|
||||
})
|
||||
}
|
||||
|
||||
return data?.workspaceMutations.invites.batchCreate
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,21 @@ export const workspaceUpdateRoleMutation = graphql(`
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const inviteToWorkspaceMutation = graphql(`
|
||||
mutation InviteToWorkspace(
|
||||
$workspaceId: String!
|
||||
$input: [WorkspaceInviteCreateInput!]!
|
||||
) {
|
||||
workspaceMutations {
|
||||
invites {
|
||||
batchCreate(workspaceId: $workspaceId, input: $input) {
|
||||
id
|
||||
invitedTeam {
|
||||
...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Roles, type ServerRoles } from '@speckle/shared'
|
||||
import { isArray, isString } from 'lodash-es'
|
||||
import type { UserSearchItem } from '~/lib/common/composables/users'
|
||||
import { isEmail } from '~/lib/common/helpers/validation'
|
||||
|
||||
export type UserSearchItemOrEmail = UserSearchItem | string
|
||||
|
||||
export const isValidEmail = (val: string) =>
|
||||
isEmail(val || '', {
|
||||
field: '',
|
||||
value: '',
|
||||
form: {}
|
||||
}) === true
|
||||
? true
|
||||
: false
|
||||
|
||||
export const filterInvalidInviteTargets = (
|
||||
targets: UserSearchItemOrEmail | UserSearchItemOrEmail[],
|
||||
params: {
|
||||
isTargetResourceOwner: boolean
|
||||
emailTargetServerRole: ServerRoles
|
||||
}
|
||||
) => {
|
||||
const { isTargetResourceOwner } = params
|
||||
const isTargetServerGuest = (i: UserSearchItemOrEmail) => {
|
||||
if (isString(i)) {
|
||||
return params.emailTargetServerRole === Roles.Server.Guest
|
||||
} else {
|
||||
return i.role === Roles.Server.Guest
|
||||
}
|
||||
}
|
||||
|
||||
return (isArray(targets) ? targets : [targets]).filter((u) => {
|
||||
if (isTargetServerGuest(u) && isTargetResourceOwner) return false
|
||||
|
||||
if (isString(u)) {
|
||||
return isValidEmail(u)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Roles, type WorkspaceRoles } from '@speckle/shared'
|
||||
import { WorkspaceRole } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
export type SelectableWorkspaceRole = WorkspaceRoles | 'delete'
|
||||
|
||||
export const roleSelectItems: Record<
|
||||
SelectableWorkspaceRole | string,
|
||||
{ id: SelectableWorkspaceRole; title: string }
|
||||
> = {
|
||||
[Roles.Workspace.Admin]: {
|
||||
id: Roles.Workspace.Admin,
|
||||
title: 'Admin'
|
||||
},
|
||||
[Roles.Workspace.Member]: {
|
||||
id: Roles.Workspace.Member,
|
||||
title: 'Can edit'
|
||||
},
|
||||
[Roles.Workspace.Guest]: {
|
||||
id: Roles.Workspace.Guest,
|
||||
title: 'Can view'
|
||||
},
|
||||
['delete']: {
|
||||
id: 'delete',
|
||||
title: 'Remove'
|
||||
}
|
||||
}
|
||||
|
||||
export const mapMainRoleToGqlWorkspaceRole = (role: WorkspaceRoles): WorkspaceRole => {
|
||||
switch (role) {
|
||||
case Roles.Workspace.Admin:
|
||||
return WorkspaceRole.Admin
|
||||
case Roles.Workspace.Member:
|
||||
return WorkspaceRole.Member
|
||||
case Roles.Workspace.Guest:
|
||||
return WorkspaceRole.Guest
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,6 @@ export default defineNuxtConfig({
|
||||
shim: false,
|
||||
strict: true
|
||||
},
|
||||
features: {
|
||||
// while nuxt's implementation is broken, we disable this: https://github.com/nuxt/nuxt/issues/26369
|
||||
devLogs: false
|
||||
},
|
||||
modules: [
|
||||
'@nuxt/eslint',
|
||||
'@nuxt/devtools',
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
"@eslint/config-inspector": "^0.4.10",
|
||||
"@graphql-codegen/cli": "^5.0.2",
|
||||
"@graphql-codegen/client-preset": "^4.3.0",
|
||||
"@nuxt/devtools": "^0.2.5",
|
||||
"@nuxt/devtools": "^1.3.9",
|
||||
"@nuxt/eslint": "^0.3.13",
|
||||
"@nuxtjs/tailwindcss": "^6.3.0",
|
||||
"@parcel/watcher": "^2.4.1",
|
||||
|
||||
@@ -103,6 +103,10 @@ input WorkspaceInviteCreateInput {
|
||||
Defaults to the member role, if not specified
|
||||
"""
|
||||
role: WorkspaceRole
|
||||
"""
|
||||
Defaults to User, if not specified
|
||||
"""
|
||||
serverRole: ServerRole
|
||||
}
|
||||
|
||||
input WorkspaceInviteUseInput {
|
||||
@@ -123,6 +127,10 @@ type WorkspaceInviteMutations {
|
||||
@hasServerRole(role: SERVER_USER)
|
||||
}
|
||||
|
||||
input PendingWorkspaceCollaboratorsFilter {
|
||||
search: String
|
||||
}
|
||||
|
||||
type Workspace {
|
||||
id: ID!
|
||||
name: String!
|
||||
@@ -141,7 +149,9 @@ type Workspace {
|
||||
"""
|
||||
Only available to workspace owners
|
||||
"""
|
||||
invitedTeam: [PendingWorkspaceCollaborator!] @hasWorkspaceRole(role: ADMIN)
|
||||
invitedTeam(
|
||||
filter: PendingWorkspaceCollaboratorsFilter
|
||||
): [PendingWorkspaceCollaborator!] @hasWorkspaceRole(role: ADMIN)
|
||||
projects(
|
||||
limit: Int! = 25
|
||||
cursor: String
|
||||
@@ -175,6 +185,7 @@ type WorkspaceCollaborator {
|
||||
|
||||
type PendingWorkspaceCollaborator {
|
||||
id: ID!
|
||||
updatedAt: DateTime!
|
||||
inviteId: String!
|
||||
workspaceId: String!
|
||||
workspaceName: String!
|
||||
|
||||
@@ -338,6 +338,7 @@ export const ServerInvites = buildTableHelper('server_invites', [
|
||||
'target',
|
||||
'inviterId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'message',
|
||||
'resource',
|
||||
'token'
|
||||
|
||||
@@ -1734,12 +1734,17 @@ export type PendingWorkspaceCollaborator = {
|
||||
title: Scalars['String']['output'];
|
||||
/** Only available if the active user is the pending workspace collaborator */
|
||||
token?: Maybe<Scalars['String']['output']>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
/** Set only if user is registered */
|
||||
user?: Maybe<LimitedUser>;
|
||||
workspaceId: Scalars['String']['output'];
|
||||
workspaceName: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PendingWorkspaceCollaboratorsFilter = {
|
||||
search?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
__typename?: 'Project';
|
||||
allowPublicComments: Scalars['Boolean']['output'];
|
||||
@@ -3831,6 +3836,11 @@ export type Workspace = {
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceInvitedTeamArgs = {
|
||||
filter?: InputMaybe<PendingWorkspaceCollaboratorsFilter>;
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceProjectsArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<WorkspaceProjectsFilter>;
|
||||
@@ -3866,6 +3876,8 @@ export type WorkspaceInviteCreateInput = {
|
||||
email?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Defaults to the member role, if not specified */
|
||||
role?: InputMaybe<WorkspaceRole>;
|
||||
/** Defaults to User, if not specified */
|
||||
serverRole?: InputMaybe<ServerRole>;
|
||||
/** Either this or email must be filled */
|
||||
userId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
@@ -4167,6 +4179,7 @@ export type ResolversTypes = {
|
||||
PasswordStrengthCheckResults: ResolverTypeWrapper<PasswordStrengthCheckResults>;
|
||||
PendingStreamCollaborator: ResolverTypeWrapper<PendingStreamCollaboratorGraphQLReturn>;
|
||||
PendingWorkspaceCollaborator: ResolverTypeWrapper<PendingWorkspaceCollaboratorGraphQLReturn>;
|
||||
PendingWorkspaceCollaboratorsFilter: PendingWorkspaceCollaboratorsFilter;
|
||||
Project: ResolverTypeWrapper<ProjectGraphQLReturn>;
|
||||
ProjectAccessRequest: ResolverTypeWrapper<ProjectAccessRequestGraphQLReturn>;
|
||||
ProjectAccessRequestMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
|
||||
@@ -4409,6 +4422,7 @@ export type ResolversParentTypes = {
|
||||
PasswordStrengthCheckResults: PasswordStrengthCheckResults;
|
||||
PendingStreamCollaborator: PendingStreamCollaboratorGraphQLReturn;
|
||||
PendingWorkspaceCollaborator: PendingWorkspaceCollaboratorGraphQLReturn;
|
||||
PendingWorkspaceCollaboratorsFilter: PendingWorkspaceCollaboratorsFilter;
|
||||
Project: ProjectGraphQLReturn;
|
||||
ProjectAccessRequest: ProjectAccessRequestGraphQLReturn;
|
||||
ProjectAccessRequestMutations: MutationsObjectGraphQLReturn;
|
||||
@@ -5196,6 +5210,7 @@ export type PendingWorkspaceCollaboratorResolvers<ContextType = GraphQLContext,
|
||||
role?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
token?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
user?: Resolver<Maybe<ResolversTypes['LimitedUser']>, ParentType, ContextType>;
|
||||
workspaceId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
workspaceName?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
@@ -5848,7 +5863,7 @@ export type WorkspaceResolvers<ContextType = GraphQLContext, ParentType extends
|
||||
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
invitedTeam?: Resolver<Maybe<Array<ResolversTypes['PendingWorkspaceCollaborator']>>, ParentType, ContextType>;
|
||||
invitedTeam?: Resolver<Maybe<Array<ResolversTypes['PendingWorkspaceCollaborator']>>, ParentType, ContextType, Partial<WorkspaceInvitedTeamArgs>>;
|
||||
logo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
projects?: Resolver<ResolversTypes['ProjectCollection'], ParentType, ContextType, RequireFields<WorkspaceProjectsArgs, 'limit'>>;
|
||||
|
||||
@@ -1723,12 +1723,17 @@ export type PendingWorkspaceCollaborator = {
|
||||
title: Scalars['String']['output'];
|
||||
/** Only available if the active user is the pending workspace collaborator */
|
||||
token?: Maybe<Scalars['String']['output']>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
/** Set only if user is registered */
|
||||
user?: Maybe<LimitedUser>;
|
||||
workspaceId: Scalars['String']['output'];
|
||||
workspaceName: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PendingWorkspaceCollaboratorsFilter = {
|
||||
search?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
__typename?: 'Project';
|
||||
allowPublicComments: Scalars['Boolean']['output'];
|
||||
@@ -3820,6 +3825,11 @@ export type Workspace = {
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceInvitedTeamArgs = {
|
||||
filter?: InputMaybe<PendingWorkspaceCollaboratorsFilter>;
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceProjectsArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<WorkspaceProjectsFilter>;
|
||||
@@ -3855,6 +3865,8 @@ export type WorkspaceInviteCreateInput = {
|
||||
email?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Defaults to the member role, if not specified */
|
||||
role?: InputMaybe<WorkspaceRole>;
|
||||
/** Defaults to User, if not specified */
|
||||
serverRole?: InputMaybe<ServerRole>;
|
||||
/** Either this or email must be filled */
|
||||
userId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
@@ -9,7 +9,10 @@ import { ServerInviteResourceFilter } from '@/modules/serverinvites/repositories
|
||||
|
||||
export type FindUserByTarget = (target: string) => Promise<UserWithOptionalRole | null>
|
||||
|
||||
export type ServerInviteRecordInsertModel = Omit<ServerInviteRecord, 'createdAt'>
|
||||
export type ServerInviteRecordInsertModel = Omit<
|
||||
ServerInviteRecord,
|
||||
'createdAt' | 'updatedAt'
|
||||
>
|
||||
|
||||
export type InsertInviteAndDeleteOld = (
|
||||
invite: ServerInviteRecordInsertModel,
|
||||
@@ -57,7 +60,7 @@ export type QueryAllResourceInvites = <
|
||||
filter: Pick<
|
||||
InviteResourceTarget<TargetType, RoleType>,
|
||||
'resourceId' | 'resourceType'
|
||||
>
|
||||
> & { search?: string }
|
||||
) => Promise<ServerInviteRecord<InviteResourceTarget<TargetType, RoleType>>[]>
|
||||
|
||||
export type DeleteAllResourceInvites = <
|
||||
@@ -104,3 +107,5 @@ export type CreateInviteParams = {
|
||||
message?: string | null
|
||||
primaryResourceTarget: PrimaryInviteResourceTarget
|
||||
}
|
||||
|
||||
export type MarkInviteUpdated = (params: { inviteId: string }) => Promise<boolean>
|
||||
|
||||
@@ -54,6 +54,7 @@ export type ServerInviteRecord<
|
||||
target: string
|
||||
inviterId: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
message: Nullable<string>
|
||||
resource: PrimaryInviteResourceTarget<Resource>
|
||||
token: string
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
insertInviteAndDeleteOldFactory,
|
||||
deleteInviteFactory as deleteInviteFromDbFactory,
|
||||
queryAllUserResourceInvitesFactory,
|
||||
queryAllResourceInvitesFactory
|
||||
queryAllResourceInvitesFactory,
|
||||
markInviteUpdatedfactory
|
||||
} from '@/modules/serverinvites/repositories/serverInvites'
|
||||
import {
|
||||
createProjectInviteFactory,
|
||||
@@ -301,7 +302,8 @@ export = {
|
||||
getStream
|
||||
}),
|
||||
findUserByTarget: findUserByTargetFactory(),
|
||||
findInvite: findInviteFactory({ db })
|
||||
findInvite: findInviteFactory({ db }),
|
||||
markInviteUpdated: markInviteUpdatedfactory({ db })
|
||||
})
|
||||
|
||||
await resendInviteEmail({ inviteId })
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Knex } from 'knex'
|
||||
|
||||
const TABLE_NAME = 'server_invites'
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable(TABLE_NAME, (table) => {
|
||||
table
|
||||
.timestamp('updatedAt', { precision: 3, useTz: true })
|
||||
.defaultTo(knex.fn.now())
|
||||
.notNullable()
|
||||
})
|
||||
|
||||
// set updatedAt to be same value as createdAt
|
||||
await knex(TABLE_NAME).update({ updatedAt: knex.raw('??', ['createdAt']) })
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable(TABLE_NAME, (table) => {
|
||||
table.dropColumn('updatedAt')
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { knex, ServerInvites, Streams } from '@/modules/core/dbSchema'
|
||||
import { knex, ServerInvites, Streams, Users } from '@/modules/core/dbSchema'
|
||||
import {
|
||||
getUserByEmail,
|
||||
getUser,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
FindServerInvite,
|
||||
FindServerInvites,
|
||||
InsertInviteAndDeleteOld,
|
||||
MarkInviteUpdated,
|
||||
QueryAllResourceInvites,
|
||||
QueryAllUserResourceInvites,
|
||||
QueryInvites,
|
||||
@@ -91,7 +92,7 @@ const buildInvitesBaseQuery =
|
||||
|
||||
const q = db(ServerInvites.name)
|
||||
.select<Result>(ServerInvites.cols)
|
||||
.orderBy(ServerInvites.col.createdAt, sort)
|
||||
.orderBy(ServerInvites.col.updatedAt, sort)
|
||||
|
||||
// single built in filter
|
||||
projectInviteValidityFilter(q)
|
||||
@@ -241,13 +242,31 @@ export const queryAllResourceInvitesFactory =
|
||||
filter: Pick<
|
||||
InviteResourceTarget<TargetType, RoleType>,
|
||||
'resourceId' | 'resourceType'
|
||||
>
|
||||
> & { search?: string }
|
||||
) => {
|
||||
if (!filter.resourceId) return []
|
||||
|
||||
return await buildInvitesBaseQuery({ db })<
|
||||
const q = buildInvitesBaseQuery({ db })<
|
||||
ServerInviteRecord<InviteResourceTarget<TargetType, RoleType>>[]
|
||||
>({ filterQuery }).where((q) => filterByResource(q, filter))
|
||||
>({ filterQuery })
|
||||
|
||||
q.where((q) => filterByResource(q, filter))
|
||||
|
||||
if (filter.search) {
|
||||
q.leftJoin(
|
||||
Users.name,
|
||||
Users.col.id,
|
||||
knex.raw('SUBSTRING(?? FROM 2)', [ServerInvites.col.target])
|
||||
).where((w1) => {
|
||||
w1.where(ServerInvites.col.target, 'ILIKE', `%${filter.search}%`).orWhere(
|
||||
Users.col.name,
|
||||
'ILIKE',
|
||||
`%${filter.search}%`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return await q
|
||||
}
|
||||
|
||||
export const deleteAllResourceInvitesFactory =
|
||||
@@ -481,3 +500,13 @@ export const findInviteByTokenFactory =
|
||||
|
||||
return (await q) || null
|
||||
}
|
||||
|
||||
export const markInviteUpdatedfactory =
|
||||
({ db }: { db: Knex }): MarkInviteUpdated =>
|
||||
async ({ inviteId }) => {
|
||||
const cols = ServerInvites.with({ withoutTablePrefix: true }).col
|
||||
const ret = await db(ServerInvites.name)
|
||||
.where(ServerInvites.col.id, inviteId)
|
||||
.update(cols.updatedAt, new Date())
|
||||
return !!ret
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
FindInvite,
|
||||
FindUserByTarget,
|
||||
InsertInviteAndDeleteOld,
|
||||
MarkInviteUpdated,
|
||||
ServerInviteRecordInsertModel
|
||||
} from '@/modules/serverinvites/domain/operations'
|
||||
import {
|
||||
@@ -188,11 +189,13 @@ export const resendInviteEmailFactory =
|
||||
({
|
||||
buildInviteEmailContents,
|
||||
findUserByTarget,
|
||||
findInvite
|
||||
findInvite,
|
||||
markInviteUpdated
|
||||
}: {
|
||||
buildInviteEmailContents: BuildInviteEmailContents
|
||||
findUserByTarget: FindUserByTarget
|
||||
findInvite: FindInvite
|
||||
markInviteUpdated: MarkInviteUpdated
|
||||
}): ResendInviteEmail =>
|
||||
async (params: { inviteId: string }) => {
|
||||
const sendInviteEmail = sendInviteEmailFactory({ buildInviteEmailContents })
|
||||
@@ -221,4 +224,6 @@ export const resendInviteEmailFactory =
|
||||
targetUser,
|
||||
targetData
|
||||
})
|
||||
|
||||
await markInviteUpdated({ inviteId })
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
UseStreamInviteDocument
|
||||
} from '@/test/graphql/generated/graphql'
|
||||
import { grantStreamPermissions } from '@/modules/core/repositories/streams'
|
||||
import { ServerInviteRecord } from '@/modules/serverinvites/domain/types'
|
||||
import { reduce } from 'lodash'
|
||||
|
||||
async function cleanup() {
|
||||
await truncateTables([ServerInvites.name, Streams.name, Users.name])
|
||||
@@ -503,6 +505,17 @@ describe('[Stream & Server Invites]', () => {
|
||||
)
|
||||
|
||||
const inviteIds = invites.map((i) => i.inviteId)
|
||||
const inviteLastRemindedDates = reduce(
|
||||
await ServerInvites.knex<ServerInviteRecord[]>().whereIn(
|
||||
ServerInvites.col.id,
|
||||
inviteIds
|
||||
),
|
||||
(res, item) => {
|
||||
res[item.id] = item.updatedAt
|
||||
return res
|
||||
},
|
||||
{} as Record<string, Date>
|
||||
)
|
||||
|
||||
const results = await Promise.all(
|
||||
inviteIds.map((inviteId) =>
|
||||
@@ -516,6 +529,22 @@ describe('[Stream & Server Invites]', () => {
|
||||
}
|
||||
|
||||
expect(sendEmailInvocations.length()).to.eq(inviteIds.length)
|
||||
|
||||
const newInviteLastRemindedDates = reduce(
|
||||
await ServerInvites.knex<ServerInviteRecord[]>().whereIn(
|
||||
ServerInvites.col.id,
|
||||
inviteIds
|
||||
),
|
||||
(res, item) => {
|
||||
res[item.id] = item.updatedAt
|
||||
return res
|
||||
},
|
||||
{} as Record<string, Date>
|
||||
)
|
||||
|
||||
for (const [id, newDate] of Object.entries(newInviteLastRemindedDates)) {
|
||||
expect(newDate).to.be.greaterThan(inviteLastRemindedDates[id])
|
||||
}
|
||||
})
|
||||
|
||||
it('they can delete pre-existing invites irregardless of type', async () => {
|
||||
|
||||
@@ -41,13 +41,15 @@ export const onProjectCreatedFactory =
|
||||
const workspaceMembers = await getWorkspaceRoles({ workspaceId })
|
||||
|
||||
await Promise.all(
|
||||
workspaceMembers.map(({ userId, role: workspaceRole }) =>
|
||||
grantStreamPermissions({
|
||||
workspaceMembers.map(({ userId, role: workspaceRole }) => {
|
||||
if (workspaceRole === Roles.Workspace.Guest) return
|
||||
|
||||
return grantStreamPermissions({
|
||||
streamId: projectId,
|
||||
userId,
|
||||
role: mapWorkspaceRoleToProjectRole(workspaceRole)
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -435,7 +435,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
|
||||
return collaborators
|
||||
},
|
||||
invitedTeam: async (parent) => {
|
||||
invitedTeam: async (parent, args) => {
|
||||
const getPendingTeam = getPendingWorkspaceCollaboratorsFactory({
|
||||
queryAllResourceInvites: queryAllResourceInvitesFactory({
|
||||
db,
|
||||
@@ -444,7 +444,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
getInvitationTargetUsers: getInvitationTargetUsersFactory({ getUsers })
|
||||
})
|
||||
|
||||
return await getPendingTeam({ workspaceId: parent.id })
|
||||
return await getPendingTeam({ workspaceId: parent.id, filter: args.filter })
|
||||
},
|
||||
projects: async (parent, args) => {
|
||||
const getWorkspaceProjects = getWorkspaceProjectsFactory({ getStreams })
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
|
||||
import {
|
||||
PendingWorkspaceCollaboratorsFilter,
|
||||
TokenResourceIdentifierType,
|
||||
WorkspaceInviteCreateInput
|
||||
} from '@/modules/core/graph/generated/graphql'
|
||||
import { mapServerRoleToValue } from '@/modules/core/helpers/graphTypes'
|
||||
import { getWorkspaceRoute } from '@/modules/core/helpers/routeHelper'
|
||||
import { isResourceAllowed } from '@/modules/core/helpers/token'
|
||||
import { LimitedUserRecord } from '@/modules/core/helpers/types'
|
||||
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
|
||||
import { getUser } from '@/modules/core/repositories/users'
|
||||
import { ServerInviteResourceType } from '@/modules/serverinvites/domain/constants'
|
||||
import {
|
||||
FindInvite,
|
||||
QueryAllResourceInvites,
|
||||
@@ -82,7 +85,12 @@ export const createWorkspaceInviteFactory =
|
||||
role:
|
||||
(input.role ? mapGqlWorkspaceRoleToMainRole(input.role) : null) ||
|
||||
Roles.Workspace.Member,
|
||||
primary: true
|
||||
primary: true,
|
||||
secondaryResourceRoles: {
|
||||
...(input.serverRole
|
||||
? { [ServerInviteResourceType]: mapServerRoleToValue(input.serverRole) }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
return await deps.createAndSendInvite(
|
||||
@@ -241,7 +249,8 @@ function buildPendingWorkspaceCollaboratorModel(
|
||||
title: resolveInviteTargetTitle(invite, targetUser),
|
||||
role: invite.resource.role || Roles.Workspace.Member,
|
||||
invitedById: invite.inviterId,
|
||||
user: targetUser
|
||||
user: targetUser,
|
||||
updatedAt: invite.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,8 +315,9 @@ export const getPendingWorkspaceCollaboratorsFactory =
|
||||
}) =>
|
||||
async (params: {
|
||||
workspaceId: string
|
||||
filter?: MaybeNullOrUndefined<PendingWorkspaceCollaboratorsFilter>
|
||||
}): Promise<PendingWorkspaceCollaboratorGraphQLReturn[]> => {
|
||||
const { workspaceId } = params
|
||||
const { workspaceId, filter } = params
|
||||
|
||||
// Get all pending invites
|
||||
const invites = await deps.queryAllResourceInvites<
|
||||
@@ -315,7 +325,8 @@ export const getPendingWorkspaceCollaboratorsFactory =
|
||||
WorkspaceRoles
|
||||
>({
|
||||
resourceId: workspaceId,
|
||||
resourceType: WorkspaceInviteResourceType
|
||||
resourceType: WorkspaceInviteResourceType,
|
||||
search: filter?.search || undefined
|
||||
})
|
||||
|
||||
// Get all target users, if any
|
||||
|
||||
@@ -7,7 +7,7 @@ import { expect } from 'chai'
|
||||
|
||||
describe('Event handlers', () => {
|
||||
describe('onProjectCreatedFactory creates a function, that', () => {
|
||||
it('grants project roles for all workspace members', async () => {
|
||||
it('grants project roles for all workspace members, except guests', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const projectId = cryptoRandomString({ length: 10 })
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Event handlers', () => {
|
||||
project: { workspaceId, id: projectId } as StreamRecord
|
||||
})
|
||||
|
||||
expect(projectRoles.length).to.equal(3)
|
||||
expect(projectRoles.length).to.equal(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ export type PendingWorkspaceCollaboratorGraphQLReturn = {
|
||||
role: WorkspaceRoles
|
||||
invitedById: string
|
||||
user: LimitedUserRecord | null
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export type WorkspaceCollaboratorGraphQLReturn = UserWithRole<LimitedUserRecord> & {
|
||||
|
||||
@@ -1724,12 +1724,17 @@ export type PendingWorkspaceCollaborator = {
|
||||
title: Scalars['String']['output'];
|
||||
/** Only available if the active user is the pending workspace collaborator */
|
||||
token?: Maybe<Scalars['String']['output']>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
/** Set only if user is registered */
|
||||
user?: Maybe<LimitedUser>;
|
||||
workspaceId: Scalars['String']['output'];
|
||||
workspaceName: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PendingWorkspaceCollaboratorsFilter = {
|
||||
search?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
__typename?: 'Project';
|
||||
allowPublicComments: Scalars['Boolean']['output'];
|
||||
@@ -3821,6 +3826,11 @@ export type Workspace = {
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceInvitedTeamArgs = {
|
||||
filter?: InputMaybe<PendingWorkspaceCollaboratorsFilter>;
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceProjectsArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<WorkspaceProjectsFilter>;
|
||||
@@ -3856,6 +3866,8 @@ export type WorkspaceInviteCreateInput = {
|
||||
email?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Defaults to the member role, if not specified */
|
||||
role?: InputMaybe<WorkspaceRole>;
|
||||
/** Defaults to User, if not specified */
|
||||
serverRole?: InputMaybe<ServerRole>;
|
||||
/** Either this or email must be filled */
|
||||
userId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="hasButtons"
|
||||
class="relative z-50 flex px-6 py-3 space-x-3 shrink-0 bg-foundation-page border-t border-outline-2"
|
||||
class="relative z-50 flex px-6 py-3 space-x-3 shrink-0 bg-foundation-page"
|
||||
:class="{
|
||||
'shadow-t': !scrolledToBottom,
|
||||
[buttonsWrapperClasses || '']: true
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
class="w-full text-sm overflow-x-auto overflow-y-visible simple-scrollbar border border-outline-3 rounded-lg"
|
||||
>
|
||||
<div
|
||||
v-if="items.length > 0"
|
||||
class="grid z-10 grid-cols-12 items-center space-x-6 font-medium bg-foundation-page rounded-t-lg w-full border-b border-outline-3 pb-2 pt-4 px-4 min-w-[750px]"
|
||||
:style="{ paddingRight: paddingRightStyle }"
|
||||
>
|
||||
@@ -21,7 +20,17 @@
|
||||
class="divide-y divide-outline-3 h-full overflow-visible"
|
||||
:class="{ 'pb-32': overflowCells }"
|
||||
>
|
||||
<template v-if="items.length">
|
||||
<div
|
||||
v-if="loading || !items"
|
||||
tabindex="0"
|
||||
:style="{ paddingRight: paddingRightStyle }"
|
||||
:class="rowsWrapperClasses"
|
||||
>
|
||||
<div :class="getClasses(undefined, 0, { noPadding: true })" tabindex="0">
|
||||
<CommonLoadingBar loading />
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="items?.length">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
@@ -39,17 +48,19 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute right-1.5 space-x-1 flex items-center p-0 h-full">
|
||||
<div v-for="button in buttons" :key="button.label">
|
||||
<FormButton
|
||||
:icon-left="button.icon"
|
||||
size="sm"
|
||||
color="outline"
|
||||
hide-text
|
||||
:class="button.class"
|
||||
:to="isString(button.action) ? button.action : undefined"
|
||||
@click.stop="!isString(button.action) ? button.action(item) : noop"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="buttons">
|
||||
<div v-for="button in buttons" :key="button.label">
|
||||
<FormButton
|
||||
:icon-left="button.icon"
|
||||
size="sm"
|
||||
color="outline"
|
||||
hide-text
|
||||
:class="button.class"
|
||||
:to="isString(button.action) ? button.action : undefined"
|
||||
@click.stop="!isString(button.action) ? button.action(item) : noop"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -71,12 +82,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends {id: string}, C extends string">
|
||||
import { noop, isString } from 'lodash'
|
||||
import { computed } from 'vue'
|
||||
import type { PropAnyComponent } from '~~/src/helpers/common/components'
|
||||
import { FormButton } from '~~/src/lib'
|
||||
import { CommonLoadingBar, FormButton } from '~~/src/lib'
|
||||
|
||||
export type TableColumn<I> = {
|
||||
id: I
|
||||
@@ -84,31 +94,34 @@ export type TableColumn<I> = {
|
||||
classes: string
|
||||
}
|
||||
|
||||
export interface RowButton<T = unknown> {
|
||||
export type RowButton<T = unknown> = {
|
||||
icon: PropAnyComponent
|
||||
label: string
|
||||
action: (item: T) => void | string
|
||||
action: (item: T) => unknown
|
||||
class?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[]
|
||||
items: T[] | undefined | null
|
||||
buttons?: RowButton<T>[]
|
||||
columns: TableColumn<C>[]
|
||||
overflowCells?: boolean
|
||||
onRowClick?: (item: T) => void
|
||||
rowItemsAlign?: 'center' | 'stretch'
|
||||
emptyMessage?: string
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{ rowItemsAlign: 'center', emptyMessage: 'No data found' }
|
||||
)
|
||||
|
||||
const buttonCount = computed(() => {
|
||||
return (props.buttons || []).length
|
||||
})
|
||||
const paddingRightStyle = computed(() => {
|
||||
const buttonCount = (props.buttons || []).length
|
||||
let padding = 16
|
||||
if (buttonCount > 0) {
|
||||
padding = 48 + (buttonCount - 1) * 42
|
||||
if (buttonCount.value > 0) {
|
||||
padding = 48 + (buttonCount.value - 1) * 42
|
||||
}
|
||||
return `${padding}px`
|
||||
})
|
||||
@@ -118,7 +131,7 @@ const rowsWrapperClasses = computed(() => {
|
||||
'relative grid grid-cols-12 items-center space-x-6 px-4 py-0.5 min-w-[750px] bg-foundation text-body-xs'
|
||||
]
|
||||
|
||||
if (props.onRowClick && props.items.length) {
|
||||
if (props.onRowClick && props.items?.length) {
|
||||
classParts.push('cursor-pointer hover:bg-primary-muted')
|
||||
}
|
||||
|
||||
@@ -134,22 +147,36 @@ const rowsWrapperClasses = computed(() => {
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const getHeaderClasses = (column: C | undefined, colIndex: number): string => {
|
||||
const getHeaderClasses = (
|
||||
column: C | undefined,
|
||||
colIndex: number,
|
||||
options?: Partial<{
|
||||
noPadding: boolean
|
||||
}>
|
||||
): string => {
|
||||
const classParts = [
|
||||
column ? props.columns.find((c) => c.id === column)?.classes : '' || ''
|
||||
]
|
||||
|
||||
if (colIndex === 0) {
|
||||
classParts.push('px-1')
|
||||
} else {
|
||||
classParts.push('lg:p-0 px-1')
|
||||
if (!options?.noPadding) {
|
||||
if (colIndex === 0) {
|
||||
classParts.push('px-1')
|
||||
} else {
|
||||
classParts.push('lg:p-0 px-1')
|
||||
}
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
}
|
||||
|
||||
const getClasses = (column: C | undefined, colIndex: number): string => {
|
||||
const classParts = [getHeaderClasses(column, colIndex)]
|
||||
const getClasses = (
|
||||
column: C | undefined,
|
||||
colIndex: number,
|
||||
options?: Partial<{
|
||||
noPadding: boolean
|
||||
}>
|
||||
): string => {
|
||||
const classParts = [getHeaderClasses(column, colIndex, options)]
|
||||
|
||||
if (colIndex === 0) {
|
||||
classParts.push(`bg-transparent py-2 ${column ? 'pr-5' : 'col-span-full'}`)
|
||||
|
||||
@@ -268,9 +268,9 @@ export function useDebouncedTextInput(params?: {
|
||||
}
|
||||
}
|
||||
}
|
||||
const bind = {
|
||||
modelValue: computed(() => model.value || '')
|
||||
}
|
||||
const bind = computed(() => ({
|
||||
modelValue: model.value || ''
|
||||
}))
|
||||
|
||||
watch(value, (newVal, oldVal) => {
|
||||
if (oldVal === newVal && !oldVal && !newVal) return
|
||||
|
||||
Reference in New Issue
Block a user