Feat: Add member settings page (#2574)

This commit is contained in:
Mike
2024-08-06 10:03:22 +02:00
committed by GitHub
parent 12685af29a
commit 3c15fa8809
16 changed files with 420 additions and 17 deletions
@@ -0,0 +1,83 @@
<template>
<FormSelectBase
v-model="selectedValue"
:items="roles"
:multiple="multiple"
name="workspaceRoles"
label="Workspace roles"
class="min-w-[110px]"
:label-id="labelId"
:button-id="buttonId"
mount-menu-on-body
:fully-control-value="fullyControlValue"
size="sm"
>
<template #nothing-selected>
{{ multiple ? 'Select roles' : 'Select role' }}
</template>
<template #something-selected="{ value }">
<template v-if="isMultiItemArrayValue(value)">
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
<div
ref="itemContainer"
class="flex flex-wrap overflow-hidden space-x-0.5 h-6"
>
<div v-for="(item, i) in value" :key="item" class="text-foreground">
{{ RoleInfo.Workspace[item] + (i < value.length - 1 ? ', ' : '') }}
</div>
</div>
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
+{{ hiddenSelectedItemCount }}
</div>
</div>
</template>
<template v-else>
<div class="truncate text-foreground">
{{ RoleInfo.Workspace[firstItem(value)].title }}
</div>
</template>
</template>
<template #option="{ item }">
<div class="flex items-center">
<span class="truncate">{{ RoleInfo.Workspace[firstItem(item)].title }}</span>
</div>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
// Todo: Refactor this to have one component for project/server/workspace roles
import { Roles, RoleInfo } from '@speckle/shared'
import type { Nullable, WorkspaceRoles } from '@speckle/shared'
import { useFormSelectChildInternals } from '@speckle/ui-components'
import type { PropType } from 'vue'
type ValueType = WorkspaceRoles | WorkspaceRoles[] | undefined
const emit = defineEmits<{
(e: 'update:modelValue', v: ValueType): void
}>()
const props = defineProps({
multiple: Boolean,
modelValue: {
type: [String, Array] as PropType<ValueType>,
default: undefined
},
fullyControlValue: Boolean
})
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
const itemContainer = ref(null as Nullable<HTMLElement>)
const labelId = useId()
const buttonId = useId()
const { selectedValue, isMultiItemArrayValue, hiddenSelectedItemCount, firstItem } =
useFormSelectChildInternals<WorkspaceRoles>({
props: toRefs(props),
emit,
dynamicVisibility: { elementToWatchForChanges, itemContainer }
})
const roles = computed(() => Object.values(Roles.Workspace))
</script>
@@ -8,7 +8,7 @@
Move {{ versions.length }} version{{ versions.length > 1 ? 's' : '' }}
</template>
<div class="flex flex-col space-y-4">
<LayoutTabsHoriztonal v-model:active-item="activeTab" :items="tabItems">
<LayoutTabsHorizontal v-model:active-item="activeTab" :items="tabItems">
<template #default="{ activeItem }">
<div class="min-h-40">
<ProjectModelPageDialogMoveToExistingTab
@@ -28,7 +28,7 @@
/>
</div>
</template>
</LayoutTabsHoriztonal>
</LayoutTabsHorizontal>
</div>
</LayoutDialog>
</template>
@@ -38,7 +38,6 @@ import { graphql } from '~~/lib/common/generated/gql'
import type { ProjectModelPageDialogMoveToVersionFragment } from '~~/lib/common/generated/gql/graphql'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useMoveVersions } from '~~/lib/projects/composables/versionManagement'
import { LayoutTabsHoriztonal } from '@speckle/ui-components'
import type { LayoutPageTabItem } from '@speckle/ui-components'
graphql(`
@@ -77,6 +77,7 @@
!isMobile && 'simple-scrollbar overflow-y-auto flex-1'
]"
:user="user"
:workspace-id="targetWorkspaceId"
/>
</div>
</LayoutDialog>
@@ -91,6 +92,7 @@ import SettingsServerGeneral from '~/components/settings/server/General.vue'
import SettingsServerProjects from '~/components/settings/server/Projects.vue'
import SettingsServerActiveUsers from '~/components/settings/server/ActiveUsers.vue'
import SettingsServerPendingInvitations from '~/components/settings/server/PendingInvitations.vue'
import SettingsWorkspacesMembers from '~/components/settings/workspaces/Members.vue'
import { useBreakpoints } from '@vueuse/core'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import { UserIcon, ServerStackIcon } from '@heroicons/vue/24/outline'
@@ -154,7 +156,10 @@ const menuItemConfig = shallowRef<{ [key: string]: { [key: string]: MenuItem } }
}
},
workspace: {
// Workspace menu items will be added here, general, members and projects
members: {
title: 'Members',
component: SettingsWorkspacesMembers
}
}
})
@@ -24,7 +24,7 @@
>
{{ text }}
</p>
<hr v-if="!subheading" class="my-6 md:my-10" />
<hr v-if="!subheading && !hideDivider" class="my-6 md:my-10" />
<slot />
</div>
</template>
@@ -46,6 +46,7 @@ withDefaults(
text?: string
buttons?: Button[]
subheading?: boolean
hideDivider?: boolean
}>(),
{
buttons: () => []
@@ -0,0 +1,52 @@
<template>
<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-semibold">
{{ 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>
</div>
</div>
</div>
</LayoutDialog>
</template>
<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'
const emit = defineEmits<{
(e: 'updateRole'): void
}>()
defineProps<{
name: string
oldRole?: WorkspaceRoles
newRole?: WorkspaceRoles
}>()
const open = defineModel<boolean>('open', { required: true })
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'outline', fullWidth: true },
onClick: () => (open.value = false)
},
{
text: 'Update',
props: { color: 'primary', fullWidth: true },
onClick: () => {
open.value = false
emit('updateRole')
}
}
])
</script>
@@ -0,0 +1,37 @@
<template>
<section>
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader
hide-divider
title="Members"
text="Manage users in your workspace"
/>
<LayoutTabsHorizontal v-model:active-item="activeTab" :items="tabItems">
<template #default="{ activeItem }">
<SettingsWorkspacesMembersTable
v-if="activeItem.id === 'members'"
:workspace-id="workspaceId"
/>
<div v-if="activeItem.id === 'guests'">Guests</div>
<div v-if="activeItem.id === 'invites'">Pending invites</div>
</template>
</LayoutTabsHorizontal>
</div>
</section>
</template>
<script setup lang="ts">
import type { LayoutTabItem } from '~~/lib/layout/helpers/components'
defineProps<{
workspaceId: string
}>()
const tabItems = ref<LayoutTabItem[]>([
{ title: 'Members', id: 'members' },
{ title: 'Guests', id: 'guests' },
{ title: 'Pending invites', id: 'invites' }
])
const activeTab = ref(tabItems.value[0])
</script>
@@ -0,0 +1,160 @@
<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>
@@ -2,7 +2,7 @@
<LayoutDialog v-model:open="open" max-width="lg">
<template #header>Add model</template>
<div class="flex flex-col gap-y-4">
<LayoutTabsHoriztonal v-model:active-item="activeTab" :items="tabItems">
<LayoutTabsHorizontal v-model:active-item="activeTab" :items="tabItems">
<template #default="{ activeItem }">
<ViewerResourcesAddModelDialogModelTab
v-if="activeItem.id === 'model'"
@@ -13,13 +13,12 @@
@chosen="onObjectsChosen"
/>
</template>
</LayoutTabsHoriztonal>
</LayoutTabsHorizontal>
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import { SpeckleViewer } from '@speckle/shared'
import { LayoutTabsHoriztonal } from '@speckle/ui-components'
import { useCameraUtilities } from '~/lib/viewer/composables/ui'
import { useMixpanel } from '~~/lib/core/composables/mp'
import type { LayoutTabItem } from '~~/lib/layout/helpers/components'
@@ -207,6 +207,7 @@ const documents = {
"\n query AdminPanelInvitesList($limit: Int!, $cursor: String, $query: String) {\n admin {\n inviteList(limit: $limit, cursor: $cursor, query: $query) {\n cursor\n items {\n email\n id\n invitedBy {\n id\n name\n }\n }\n totalCount\n }\n }\n }\n": types.AdminPanelInvitesListDocument,
"\n mutation InviteServerUser($input: [ServerInviteCreateInput!]!) {\n serverInviteBatchCreate(input: $input)\n }\n": types.InviteServerUserDocument,
"\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 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,
"\n fragment ActiveUserAvatar on User {\n id\n name\n avatar\n }\n": types.ActiveUserAvatarFragmentDoc,
@@ -231,6 +232,7 @@ const documents = {
"\n subscription OnViewerUserActivityBroadcasted(\n $target: ViewerUpdateTrackingTarget!\n $sessionId: String!\n ) {\n viewerUserActivityBroadcasted(target: $target, sessionId: $sessionId) {\n userName\n userId\n user {\n ...LimitedUserAvatar\n }\n state\n status\n sessionId\n }\n }\n": types.OnViewerUserActivityBroadcastedDocument,
"\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 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,
@@ -1034,6 +1036,10 @@ export function graphql(source: "\n mutation InviteServerUser($input: [ServerIn
* 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 SettingsSidebarWorkspaces {\n activeUser {\n workspaces {\n items {\n id\n name\n }\n }\n }\n }\n"): (typeof documents)["\n query SettingsSidebarWorkspaces {\n activeUser {\n workspaces {\n items {\n id\n name\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 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"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1130,6 +1136,10 @@ export function graphql(source: "\n subscription OnViewerCommentsUpdated($targe
* 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 LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n"): (typeof documents)["\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\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 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.
*/
File diff suppressed because one or more lines are too long
@@ -12,3 +12,21 @@ export const settingsSidebarWorkspacesQuery = graphql(`
}
}
`)
export const settingsWorkspacesMembersQuery = graphql(`
query SettingsWorkspacesMembers($workspaceId: String!) {
workspace(id: $workspaceId) {
team {
role
id
user {
id
avatar
name
company
verified
}
}
}
}
`)
@@ -0,0 +1,7 @@
import { RoleInfo } from '@speckle/shared'
export const roleLookupTable = RoleInfo.Workspace
export const getRoleLabel = (role: keyof typeof roleLookupTable) => {
return roleLookupTable[role] || role.split(':')[1]
}
@@ -0,0 +1,15 @@
import { graphql } from '~~/lib/common/generated/gql'
export const workspaceUpdateRoleMutation = graphql(`
mutation UpdateRole($input: WorkspaceRoleUpdateInput!) {
workspaceMutations {
updateRole(input: $input) {
id
team {
id
role
}
}
}
}
`)
@@ -12,9 +12,9 @@
<ProjectPageHeader :project="project" />
<ProjectPageTeamBlock :project="project" class="w-full md:w-72 shrink-0" />
</div>
<LayoutTabsHoriztonal v-model:active-item="activePageTab" :items="pageTabItems">
<LayoutTabsHorizontal v-model:active-item="activePageTab" :items="pageTabItems">
<NuxtPage :project="project" />
</LayoutTabsHoriztonal>
</LayoutTabsHorizontal>
</div>
</div>
</template>
@@ -24,7 +24,7 @@ import { Roles, type Optional } from '@speckle/shared'
import { graphql } from '~~/lib/common/generated/gql'
import { projectPageQuery } from '~~/lib/projects/graphql/queries'
import { useGeneralProjectPageUpdateTracking } from '~~/lib/projects/composables/projectPages'
import { LayoutTabsHoriztonal, type LayoutPageTabItem } from '@speckle/ui-components'
import { LayoutTabsHorizontal, type LayoutPageTabItem } from '@speckle/ui-components'
import { projectRoute, projectWebhooksRoute } from '~/lib/common/helpers/route'
graphql(`
+1 -1
View File
@@ -59,7 +59,7 @@ export const RoleInfo = Object.freeze(<const>{
'A role assigned workspace members. They have access to resources in the workspace.'
},
[Roles.Workspace.Guest]: {
title: 'Member',
title: 'Guest',
description:
'A role assigned workspace guests. Their access to resources in the workspace is limited to resources they have explicit roles on.'
}
+2 -2
View File
@@ -58,7 +58,7 @@ import {
} from '~~/src/composables/common/window'
import LayoutMenu from '~~/src/components/layout/Menu.vue'
import type { LayoutMenuItem, LayoutTabItem } from '~~/src/helpers/layout/components'
import LayoutTabsHoriztonal from '~~/src/components/layout/tabs/Horizontal.vue'
import LayoutTabsHorizontal from '~~/src/components/layout/tabs/Horizontal.vue'
import LayoutTabsVertical from '~~/src/components/layout/tabs/Vertical.vue'
import LayoutTable from '~~/src/components/layout/Table.vue'
import InfiniteLoading from '~~/src/components/InfiniteLoading.vue'
@@ -147,7 +147,7 @@ export {
useOnBeforeWindowUnload,
useResponsiveHorizontalDirectionCalculation,
LayoutMenu,
LayoutTabsHoriztonal,
LayoutTabsHorizontal,
LayoutTabsVertical,
LayoutTable,
LayoutSidebar,