feat(fe2): Guest table should show what they have access to (#3047)

* Initial work

* Update role dialog

* useDebouncedTextInput

* Only show dialog if user has projects

* Update Cache on updating role

* Remove unused cache eviction

* Fix reactivity bug

* Handle pluralisation. Empty state when no projects left

* Hide owner from Permission Select
This commit is contained in:
andrewwallacespeckle
2024-09-23 15:20:38 +01:00
committed by GitHub
parent 4b944bb259
commit b7db51649d
12 changed files with 207 additions and 56 deletions
@@ -32,6 +32,7 @@
<ProjectPageTeamPermissionSelect
v-model="role"
hide-remove
:show-label="false"
:disabled-roles="isTargettingWorkspaceGuest ? [Roles.Stream.Owner] : []"
/>
</div>
@@ -13,25 +13,24 @@
:disabled-item-predicate="disabledItemPredicate"
hide-checkmarks
by="id"
class="min-w-[85px]"
class="w-28"
mount-menu-on-body
size="sm"
>
<template #something-selected="{ value }">
<div class="text-normal text-right">
<div class="text-right text-foreground text-body-xs font-medium">
{{ 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
:class="[
'opacity-90 hover:opacity-100',
selected ? 'text-foreground font-medium !opacity-100' : 'text-foreground-2',
item.id === 'delete' ? '!text-danger' : ''
]"
>
{{ item.title }}
</div>
</template>
</FormSelectBase>
@@ -6,6 +6,7 @@
v-model="selectedRole"
:allow-guest="isGuestMode"
allow-admin
show-label
allow-archived
:disabled="isCurrentUser"
/>
@@ -1,6 +1,6 @@
<template>
<LayoutDialog v-model:open="open" max-width="xs" :buttons="dialogButtons">
<template #header>Remove user</template>
<template #header>{{ title }}</template>
<div class="flex flex-col gap-4 text-body-xs text-foreground">
<p>
Are you sure you want to remove
@@ -21,8 +21,10 @@ const emit = defineEmits<{
}>()
defineProps<{
title: string
name: string
}>()
const open = defineModel<boolean>('open', { required: true })
const dialogButtons = computed((): LayoutDialogButton[] => [
@@ -0,0 +1,94 @@
<template>
<LayoutDialog v-model:open="open" max-width="sm">
<template #header>Change project permissions</template>
<div class="text-foreground mb-8">
<div v-if="projectCount > 0" class="flex flex-col gap-4">
<p class="font-medium text-body-xs">
Projects {{ user?.user.name }} has access to:
</p>
<FormTextInput
v-bind="searchBind"
name="searchGuests"
color="foundation"
type="text"
size="lg"
:placeholder="`Search ${projectCount} project${
projectCount !== 1 ? 's' : ''
}...`"
class="px-3 py-2 border border-outline-3 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
v-on="searchOn"
/>
<div
class="flex flex-col divide-y divide-outline-3 rounded-md border border-outline-3"
>
<div
v-for="projectRole in filteredProjectRoles"
:key="projectRole.project.id"
class="flex items-center justify-between p-4"
>
<span class="text-body-sm">{{ projectRole.project.name }}</span>
<ProjectPageTeamPermissionSelect
:model-value="projectRole.role"
:disabled="false"
hide-owner
@update:model-value="
(newRole) => updateProjectRole(projectRole.project.id, newRole)
"
@delete="() => updateProjectRole(projectRole.project.id, null)"
/>
</div>
</div>
</div>
<div v-else>
This guest doesn't have access to any projects in this workspace.
</div>
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { StreamRoles } from '@speckle/shared'
import { useUpdateUserRole } from '~~/lib/projects/composables/projectManagement'
import type { WorkspaceCollaborator } from '~/lib/common/generated/gql/graphql'
import { useDebouncedTextInput } from '@speckle/ui-components'
const props = defineProps<{
user: WorkspaceCollaborator
workspaceId: string
}>()
const open = defineModel<boolean>('open', { required: true })
const loading = ref(false)
const searchTerm = ref('')
const { on: searchOn, bind: searchBind } = useDebouncedTextInput({
model: searchTerm,
debouncedBy: 300
})
const project = computed(() => ({ workspaceId: props.workspaceId }))
const filteredProjectRoles = computed(() => {
const roles = props.user?.projectRoles
if (!searchTerm.value) return roles || []
return (roles || []).filter((projectRole) =>
projectRole.project.name.toLowerCase().includes(searchTerm.value.toLowerCase())
)
})
const updateRole = useUpdateUserRole(project)
const updateProjectRole = async (projectId: string, newRole: StreamRoles | null) => {
if (!props.user) return
loading.value = true
await updateRole({
projectId,
userId: props.user.id,
role: newRole
})
loading.value = false
}
const projectCount = computed(() => props.user?.projectRoles?.length)
</script>
@@ -9,9 +9,10 @@
<LayoutTable
class="mt-6 md:mt-8"
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-4' },
{ id: 'company', header: 'Company', classes: 'col-span-4' },
{ 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: 'projects', header: 'Projects', classes: 'col-span-2' },
{ id: 'actions', header: '', classes: 'col-span-1 flex justify-end' }
]"
:items="guests"
@@ -24,18 +25,29 @@
>
<template #name="{ item }">
<div class="flex items-center gap-2">
<UserAvatar :user="item" />
<span class="truncate text-body-xs text-foreground">{{ item.name }}</span>
<UserAvatar :user="item.user" />
<span class="truncate text-body-xs text-foreground">
{{ item.user.name }}
</span>
</div>
</template>
<template #company="{ item }">
<span class="text-body-xs text-foreground">
{{ item.company ? item.company : '-' }}
{{ item.user.company ? item.user.company : '-' }}
</span>
</template>
<template #verified="{ item }">
<span class="text-body-xs text-foreground-2">
{{ item.verified ? 'Verified' : 'Unverified' }}
{{ item.user.verified ? 'Verified' : 'Unverified' }}
</span>
</template>
<template #projects="{ item }">
<span class="text-body-xs text-foreground-2">
<CommonBadge color-classes="bg-foundation-2 text-foreground-2" rounded>
{{ item.projectRoles.length }} project{{
item.projectRoles.length !== 1 ? 's' : ''
}}
</CommonBadge>
</span>
</template>
<template #actions="{ item }">
@@ -43,6 +55,7 @@
v-if="isWorkspaceAdmin"
v-model:open="showActionsMenu[item.id]"
:items="actionItems"
size="lg"
mount-menu-on-body
:menu-position="HorizontalDirection.Left"
@chosen="({ item: actionItem }) => onActionChosen(actionItem, item)"
@@ -57,17 +70,27 @@
</template>
</LayoutTable>
<!-- Delete User Dialog -->
<SettingsSharedDeleteUserDialog
v-model:open="showDeleteUserRoleDialog"
:name="userToModify?.name ?? ''"
title="Remove guest"
:name="userToModify?.user.name ?? ''"
@remove-user="onRemoveUser"
/>
<SettingsWorkspacesMembersGuestsPermissionsDialog
v-if="userToModify"
v-model:open="showGuestsPermissionsDialog"
:user="userToModify"
:workspace-id="workspaceId"
/>
</div>
</template>
<script setup lang="ts">
import type { SettingsWorkspacesMembersMembersTable_WorkspaceFragment } from '~~/lib/common/generated/gql/graphql'
import type {
SettingsWorkspacesMembersGuestsTable_WorkspaceFragment,
WorkspaceCollaborator
} from '~/lib/common/generated/gql/graphql'
import { graphql } from '~/lib/common/generated/gql'
import { Roles } from '@speckle/shared'
import { settingsWorkspacesMembersSearchQuery } from '~~/lib/settings/graphql/queries'
@@ -89,6 +112,13 @@ graphql(`
company
verified
}
projectRoles {
role
project {
id
name
}
}
}
`)
@@ -106,20 +136,24 @@ graphql(`
`)
enum ActionTypes {
ChangeProjectPermissions = 'change-project-permissions',
RemoveMember = 'remove-member'
}
type UserItem = (typeof guests)['value'][0]
const props = defineProps<{
workspace?: SettingsWorkspacesMembersMembersTable_WorkspaceFragment
workspace?: SettingsWorkspacesMembersGuestsTable_WorkspaceFragment
workspaceId: string
}>()
const search = ref('')
const showActionsMenu = ref<Record<string, boolean>>({})
const showDeleteUserRoleDialog = ref(false)
const userToModify = ref<UserItem>()
const showGuestsPermissionsDialog = ref(false)
const userIdToModify = ref<string | null>(null)
const userToModify = computed(
() => guests.value.find((guest) => guest.id === userIdToModify.value) || null
)
const mixpanel = useMixpanel()
const updateUserRole = useWorkspaceUpdateRole()
@@ -142,38 +176,46 @@ const guests = computed(() => {
? searchResult.value?.workspace?.team.items
: props.workspace?.team.items
return (guestArray || [])
.filter(({ role }) => role === Roles.Workspace.Guest)
.map(({ user, ...rest }) => ({
...user,
...rest
}))
return (guestArray || []).filter(
(item): item is WorkspaceCollaborator => item.role === Roles.Workspace.Guest
)
})
const isWorkspaceAdmin = computed(() => props.workspace?.role === Roles.Workspace.Admin)
const actionItems: LayoutMenuItem[][] = [
[{ title: 'Remove member', id: ActionTypes.RemoveMember }]
]
const actionItems = computed(() => {
const items: LayoutMenuItem[][] = [
[{ title: 'Remove guest...', id: ActionTypes.RemoveMember }]
]
const onActionChosen = (actionItem: LayoutMenuItem, user: UserItem) => {
userToModify.value = user
if (guests.value.find((guest) => guest.projectRoles.length)) {
items.unshift([
{
title: 'Change project permissions...',
id: ActionTypes.ChangeProjectPermissions
}
])
}
return items
})
const onActionChosen = (actionItem: LayoutMenuItem, user: WorkspaceCollaborator) => {
userIdToModify.value = user.id
if (actionItem.id === ActionTypes.ChangeProjectPermissions) {
showGuestsPermissionsDialog.value = true
}
if (actionItem.id === ActionTypes.RemoveMember) {
openDeleteUserRoleDialog(user)
showDeleteUserRoleDialog.value = true
}
}
const openDeleteUserRoleDialog = (user: UserItem) => {
userToModify.value = user
showDeleteUserRoleDialog.value = true
}
const onRemoveUser = async () => {
if (!userToModify.value?.id) return
if (!userIdToModify.value) return
await updateUserRole({
userId: userToModify.value.id,
userId: userIdToModify.value,
role: null,
workspaceId: props.workspaceId
})
@@ -89,6 +89,9 @@
/>
<SettingsSharedDeleteUserDialog
v-model:open="showDeleteUserRoleDialog"
:title="
userToModify?.role === Roles.Workspace.Guest ? 'Remove guest' : 'Remove user'
"
:name="userToModify?.name ?? ''"
@remove-user="onRemoveUser"
/>
@@ -117,7 +117,7 @@ const documents = {
"\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n": types.SettingsWorkspacesMembers_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsWorkspacesProjects_ProjectCollectionFragmentDoc,
"\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n\n fragment SettingsWorkspacesSecurity_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n": types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n projectRoles {\n role\n project {\n id\n name\n }\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n inviteId\n role\n title\n updatedAt\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam(filter: $invitesFilter) {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_WorkspaceFragmentDoc,
@@ -765,7 +765,7 @@ export function graphql(source: "\n fragment SettingsWorkspacesSecurity_Workspa
/**
* 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 SettingsWorkspacesMembersGuestsTable_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 SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n"];
export function graphql(source: "\n fragment SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n projectRoles {\n role\n project {\n id\n name\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n projectRoles {\n role\n project {\n id\n name\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
@@ -240,7 +240,13 @@ export function useUpdateUserRole(
const { data, errors } = await apollo
.mutate({
mutation: updateWorkspaceProjectRoleMutation,
variables: { input }
variables: { input },
update: (cache) => {
cache.evict({ id: getCacheId('Project', input.projectId) })
cache.evict({
id: getCacheId('WorkspaceCollaborator', input.userId)
})
}
})
.catch(convertThrowIntoFetchResult)
@@ -14,7 +14,8 @@
:class="[
'mt-1 w-44 origin-top-right divide-y divide-outline-3 rounded-md bg-foundation shadow-lg border border-outline-2 z-50',
menuDirection === HorizontalDirection.Left ? 'right-0' : '',
mountMenuOnBody ? 'fixed' : 'absolute'
mountMenuOnBody ? 'fixed' : 'absolute',
size === 'lg' ? 'w-52' : 'w-44'
]"
:style="menuItemsStyles"
>
@@ -67,6 +68,7 @@ const props = defineProps<{
* 2D array so that items can be grouped with dividers between them
*/
items: LayoutMenuItem[][]
size?: 'base' | 'lg'
menuId?: string
menuPosition?: HorizontalDirection
mountMenuOnBody?: boolean
@@ -96,7 +98,8 @@ const menuItemsStyles = computed(() => {
let offsetPosition = menuButtonBounding.left.value
if (props.menuPosition === HorizontalDirection.Left) {
offsetPosition = menuButtonBounding.left.value - 150
const menuWidth = props.size === 'lg' ? 175 : 143
offsetPosition = menuButtonBounding.left.value - menuWidth
}
return {