Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(fe2): Workspace members table. Update Role #2823

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
205373e
WIP Leave Workspace
andrewwallacespeckle Aug 29, 2024
032d103
Merge branch 'main' into andrew/web-1719-move-role-select-from-member…
andrewwallacespeckle Aug 29, 2024
3a9454b
Merge branch 'main' into andrew/web-1719-move-role-select-from-member…
andrewwallacespeckle Aug 30, 2024
ae1ed0f
Merge branch 'main' into andrew/web-1719-move-role-select-from-member…
andrewwallacespeckle Aug 30, 2024
fb65e5b
Merge in Mikes changes
andrewwallacespeckle Aug 30, 2024
e008d14
Add leave workspace
andrewwallacespeckle Aug 30, 2024
f007bd3
update name
andrewwallacespeckle Aug 30, 2024
03bddcf
Merge branch 'main' into andrew/web-1719-move-role-select-from-member…
andrewwallacespeckle Aug 30, 2024
fe8ab45
Remove check for workspaceAdmin
andrewwallacespeckle Aug 30, 2024
5213c7a
Remove un-needed fragment as prop type
andrewwallacespeckle Aug 30, 2024
1c3750d
Merge branch 'main' into andrew/web-1719-move-role-select-from-member…
andrewwallacespeckle Sep 2, 2024
184ff9d
Use defineModel
andrewwallacespeckle Sep 2, 2024
620d016
Added description for roles
Mikehrn Sep 2, 2024
000ff97
Added description for roles
Mikehrn Sep 2, 2024
c5d9dfd
Set role as undefined until user selects
andrewwallacespeckle Sep 2, 2024
ec0d0d9
Merge branch 'main' into andrew/web-1719-move-role-select-from-member…
andrewwallacespeckle Sep 2, 2024
9074ce1
Tidy ups
andrewwallacespeckle Sep 2, 2024
1eb2e01
Merge branch 'main' into andrew/web-1719-move-role-select-from-member…
andrewwallacespeckle Sep 2, 2024
c05e0af
Remove unused resets
andrewwallacespeckle Sep 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 61 additions & 18 deletions packages/frontend-2/components/settings/shared/ChangeRoleDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@
<LayoutDialog v-model:open="open" max-width="sm" :buttons="dialogButtons">
<template #header>Change role</template>
<div class="flex flex-col gap-4 text-body-xs text-foreground">
<p>Are you sure you want to change the role of the selected user?</p>
<div v-if="newRole && oldRole" class="flex flex-col gap-3">
<div class="flex items-center gap-2 font-medium">
{{ name }}
</div>
<div class="flex gap-2 items-center">
<span>{{ getRoleLabel(oldRole).title }}</span>
<ArrowRightIcon class="h-4 w-4" />
<span>{{ getRoleLabel(newRole).title }}</span>
<p>
Select a new role for
<strong>{{ name }}</strong>
:
</p>
<FormSelectWorkspaceRoles
v-model="newRole"
fully-control-value
:disabled-items="disabledItems"
/>
<div v-if="newRole" class="flex flex-col items-start gap-1 text-xs">
<div
v-for="(message, i) in getWorkspaceProjectRoleMessages(newRole)"
:key="`message-${i}`"
>
{{ message }}
</div>
</div>
</div>
Expand All @@ -19,20 +26,25 @@

<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
import type { WorkspaceRoles } from '@speckle/shared'
import { ArrowRightIcon } from '@heroicons/vue/24/outline'
import { getRoleLabel } from '~~/lib/settings/helpers/utils'
import { Roles, type WorkspaceRoles } from '@speckle/shared'

const emit = defineEmits<{
(e: 'updateRole'): void
(e: 'updateRole', newRole: WorkspaceRoles): void
}>()

defineProps<{
const props = defineProps<{
name: string
oldRole?: WorkspaceRoles
newRole?: WorkspaceRoles
workspaceDomainPolicyCompliant: boolean
}>()

const open = defineModel<boolean>('open', { required: true })
const newRole = ref<WorkspaceRoles | undefined>()

const disabledItems = computed<WorkspaceRoles[]>(() =>
!props.workspaceDomainPolicyCompliant
? [Roles.Workspace.Member, Roles.Workspace.Admin]
: []
)

const dialogButtons = computed((): LayoutDialogButton[] => [
{
Expand All @@ -42,11 +54,42 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
},
{
text: 'Update',
props: { color: 'primary', fullWidth: true },
props: { color: 'primary', fullWidth: true, disabled: !newRole.value },
onClick: () => {
open.value = false
emit('updateRole')
if (newRole.value) {
emit('updateRole', newRole.value)
}
}
}
])

const getWorkspaceProjectRoleMessages = (workspaceRole: WorkspaceRoles): string[] => {
switch (workspaceRole) {
case Roles.Workspace.Admin:
return [
'Becomes project owner for all existing and new workspace projects.',
'Cannot be removed or have role changed by project owners.'
]

case Roles.Workspace.Member:
return [
'Becomes project viewer for all existing and new workspace projects.',
'Project owners can change their role or remove them.'
]

case Roles.Workspace.Guest:
return [
'Loses access to all existing workspace projects.',
'Project owners can assign a role or remove them.'
]
}
}

watch(
() => open.value,
() => {
newRole.value = undefined
}
)
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@
? `No members found for '${search}'`
: 'This workspace has no members'
"
:buttons="[
{ icon: TrashIcon, label: 'Delete', action: openDeleteUserRoleDialog }
]"
>
<template #name="{ item }">
<div class="flex items-center gap-2">
Expand All @@ -55,24 +52,17 @@
</span>
</template>
<template #role="{ item }">
<FormSelectWorkspaceRoles
:disabled="!isWorkspaceAdmin"
:model-value="item.role as WorkspaceRoles"
fully-control-value
:disabled-items="
item.workspaceDomainPolicyCompliant === false
? [Roles.Workspace.Member, Roles.Workspace.Admin]
: []
"
@update:model-value="
(newRoleValue) => openChangeUserRoleDialog(item, newRoleValue)
"
/>
<span class="text-foreground-2">
<span>
{{ isWorkspaceRole(item.role) ? getRoleLabel(item.role).title : '' }}
</span>
</span>
</template>
<template #actions="{ item }">
<LayoutMenu
v-if="filteredActionsItems(item).length"
v-model:open="showActionsMenu[item.id]"
:items="actionsItems"
:items="filteredActionsItems(item)"
mount-menu-on-body
:menu-position="HorizontalDirection.Left"
@chosen="({ item: actionItem }) => onActionChosen(actionItem, item)"
Expand All @@ -84,20 +74,28 @@
@click="toggleMenu(item.id)"
/>
</LayoutMenu>
<div v-else />
</template>
</LayoutTable>
<SettingsSharedChangeRoleDialog
v-model:open="showChangeUserRoleDialog"
:name="userToModify?.name ?? ''"
:old-role="oldRole"
:new-role="newRole"
:is-workspace-admin="isWorkspaceAdmin"
:workspace-domain-policy-compliant="
userToModify?.workspaceDomainPolicyCompliant ?? true
"
@update-role="onUpdateRole"
/>
<SettingsSharedDeleteUserDialog
v-model:open="showDeleteUserRoleDialog"
:name="userToModify?.name ?? ''"
@remove-user="onRemoveUser"
/>
<SettingsWorkspacesGeneralLeaveDialog
v-if="workspace"
v-model:open="showLeaveDialog"
:workspace="workspace"
/>
</div>
</template>

Expand All @@ -110,14 +108,14 @@ import { graphql } from '~/lib/common/generated/gql'
import {
EllipsisHorizontalIcon,
ExclamationCircleIcon,
XMarkIcon,
TrashIcon
XMarkIcon
} from '@heroicons/vue/24/outline'
import { useWorkspaceUpdateRole } from '~/lib/workspaces/composables/management'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
import { HorizontalDirection } from '~~/lib/common/composables/window'
import { Roles } from '@speckle/shared'
import { useMixpanel } from '~/lib/core/composables/mp'
import { getRoleLabel } from '~~/lib/settings/helpers/utils'

type UserItem = (typeof members)['value'][0]

Expand All @@ -139,6 +137,7 @@ graphql(`
graphql(`
fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {
id
name
...SettingsWorkspacesMembersTableHeader_Workspace
team {
items {
Expand All @@ -150,7 +149,9 @@ graphql(`
`)

enum ActionTypes {
RemoveMember = 'remove-member'
RemoveMember = 'remove-member',
ChangeRole = 'change-role',
LeaveWorkspace = 'leave-workspace'
}

const props = defineProps<{
Expand All @@ -175,9 +176,11 @@ const { result: searchResult, loading: searchResultLoading } = useQuery(

const updateUserRole = useWorkspaceUpdateRole()
const mixpanel = useMixpanel()
const { activeUser } = useActiveUser()

const showChangeUserRoleDialog = ref(false)
const showDeleteUserRoleDialog = ref(false)
const showLeaveDialog = ref(false)
const newRole = ref<WorkspaceRoles>()
const userToModify = ref<UserItem>()

Expand All @@ -193,20 +196,42 @@ const members = computed(() => {
}))
})

const oldRole = computed(() => userToModify.value?.role as WorkspaceRoles)
const isWorkspaceAdmin = computed(() => props.workspace?.role === Roles.Workspace.Admin)
const isActiveUserCurrentUser = computed(
() => (user: UserItem) => activeUser.value?.id === user.id
)
const canRemoveMember = computed(
() => (user: UserItem) => activeUser.value?.id !== user.id && isWorkspaceAdmin.value
)

const actionsItems: LayoutMenuItem[][] = [
[{ title: 'Remove member...', id: ActionTypes.RemoveMember }]
]
const filteredActionsItems = (user: UserItem) => {
const baseItems: LayoutMenuItem[][] = []

const openChangeUserRoleDialog = (
user: UserItem,
newRoleValue?: WorkspaceRoles | WorkspaceRoles[]
) => {
if (!newRoleValue) return
// Allow role change if the active user is an admin
if (isWorkspaceAdmin.value) {
baseItems.push([{ title: 'Change role...', id: ActionTypes.ChangeRole }])
}

// Allow the current user to leave the workspace
if (isActiveUserCurrentUser.value(user)) {
baseItems.push([{ title: 'Leave workspace...', id: ActionTypes.LeaveWorkspace }])
}

// Allow removing a member if the active user is an admin and not the current user
if (canRemoveMember.value(user)) {
baseItems.push([{ title: 'Remove user...', id: ActionTypes.RemoveMember }])
}

return baseItems
}

const isWorkspaceRole = (role: string): role is WorkspaceRoles => {
return ['workspace:admin', 'workspace:member', 'workspace:guest'].includes(role)
}

const openChangeUserRoleDialog = (user: UserItem) => {
userToModify.value = user
newRole.value = Array.isArray(newRoleValue) ? newRoleValue[0] : newRoleValue
newRole.value = user.role as WorkspaceRoles
showChangeUserRoleDialog.value = true
}

Expand All @@ -215,17 +240,17 @@ const openDeleteUserRoleDialog = (user: UserItem) => {
showDeleteUserRoleDialog.value = true
}

const onUpdateRole = async () => {
if (!userToModify.value || !newRole.value) return
const onUpdateRole = async (newRoleValue: WorkspaceRoles) => {
if (!userToModify.value || !newRoleValue) return

await updateUserRole({
userId: userToModify.value.id,
role: newRole.value,
role: newRoleValue,
workspaceId: props.workspaceId
})

mixpanel.track('Workspace User Role Updated', {
newRole: newRole.value,
newRole: newRoleValue,
// eslint-disable-next-line camelcase
workspace_id: props.workspaceId
})
Expand Down Expand Up @@ -253,6 +278,12 @@ const onActionChosen = (actionItem: LayoutMenuItem, user: UserItem) => {
case ActionTypes.RemoveMember:
openDeleteUserRoleDialog(user)
break
case ActionTypes.ChangeRole:
openChangeUserRoleDialog(user)
break
case ActionTypes.LeaveWorkspace:
showLeaveDialog.value = true
break
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/frontend-2/lib/common/generated/gql/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const documents = {
"\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,
"\n fragment SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n workspaceDomainPolicyCompliant(workspaceId: $workspaceId)\n }\n }\n": types.SettingsWorkspacesMembersMembersTable_WorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n": types.SettingsWorkspacesMembersMembersTable_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n name\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n": types.SettingsWorkspacesMembersMembersTable_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n role\n ...WorkspaceInviteDialog_Workspace\n }\n": types.SettingsWorkspacesMembersTableHeader_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain on WorkspaceDomain {\n id\n domain\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragmentDoc,
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc,
Expand Down Expand Up @@ -739,7 +739,7 @@ export function graphql(source: "\n fragment SettingsWorkspacesMembersMembersTa
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n"];
export function graphql(source: "\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n name\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n name\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
Expand Down
Loading