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:
committed by
GitHub
parent
4b944bb259
commit
b7db51649d
@@ -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[] => [
|
||||
|
||||
+94
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user