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
@@ -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>
|
||||
Reference in New Issue
Block a user