Fix: Dont allow non domain policy matching members to be invited to workspace (#2838)

This commit is contained in:
Mike
2024-09-02 14:05:16 +02:00
committed by GitHub
parent e52962c60b
commit f820084f03
7 changed files with 102 additions and 38 deletions
@@ -39,6 +39,7 @@
:user="user"
:disabled="disabled"
:is-owner-role="isOwnerRole"
:target-role="role"
@invite-user="() => onInviteUser(user)"
/>
</template>
@@ -48,6 +49,7 @@
:disabled="disabled"
:is-owner-role="isOwnerRole"
:is-guest-mode="isGuestMode"
:unmatching-domain-policy="unmatchingDomainPolicy"
class="p-2"
@invite-emails="({ serverRole }) => onInviteUser(emails, serverRole)"
/>
@@ -82,6 +84,11 @@ import { mapMainRoleToGqlWorkspaceRole } from '~/lib/workspaces/helpers/roles'
graphql(`
fragment WorkspaceInviteDialog_Workspace on Workspace {
domainBasedMembershipProtectionEnabled
domains {
domain
id
}
id
team {
items {
@@ -117,7 +124,8 @@ const { users, emails, hasTargets } = useResolveInviteTargets({
...(props.workspace?.invitedTeam?.map((c) => c.user?.id).filter(isNonNullable) ||
[])
]),
excludeEmails: computed(() => props.workspace?.invitedTeam?.map((c) => c.title))
excludeEmails: computed(() => props.workspace?.invitedTeam?.map((c) => c.title)),
workspaceId: props.workspaceId
})
const { isGuestMode } = useServerInfo()
@@ -135,7 +143,18 @@ const buttons = computed((): LayoutDialogButton[] => [
])
const isOwnerRole = computed(() => role.value === Roles.Workspace.Admin)
const allowedDomains = computed(() => props.workspace?.domains?.map((c) => c.domain))
const unmatchingDomainPolicy = computed(() => {
if (props.workspace?.domainBasedMembershipProtectionEnabled) {
return role.value === Roles.Workspace.Guest
? false
: !emails.value?.every((email) =>
allowedDomains.value?.includes(email.split('@')[1])
)
}
return false
})
const onInviteUser = async (
user: UserSearchItemOrEmail | UserSearchItemOrEmail[],
serverRole: ServerRoles = Roles.Server.User
@@ -1,7 +1,18 @@
<template>
<div class="flex px-4 py-3 items-center space-x-2">
<UserAvatar />
<span class="grow truncate text-body-sm">{{ selectedEmails.join(', ') }}</span>
<div class="flex px-4 py-3 items-center space-x-2 justify-between">
<div class="flex items-center space-x-2 flex-1 truncate">
<div
v-if="unmatchingDomainPolicy"
v-tippy="
'Users that do not comply with the domain policy can only be invited as guests'
"
>
<ExclamationCircleIcon class="text-danger w-5 w-4" />
</div>
<span class="truncate text-body-sm flex-1">
{{ selectedEmails.join(', ') }}
</span>
</div>
<div class="flex items-center space-x-2">
<FormSelectServerRoles
v-if="showServerRoleSelect"
@@ -27,9 +38,9 @@
</div>
</template>
<script setup lang="ts">
import { Roles } from '@speckle/shared'
import type { ServerRoles } from '@speckle/shared'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { ExclamationCircleIcon } from '@heroicons/vue/24/outline'
import { Roles, type ServerRoles } from '@speckle/shared'
defineEmits<{
(e: 'invite-emails', payload: { serverRole: ServerRoles }): void
@@ -40,6 +51,7 @@ const props = defineProps<{
disabled?: boolean
isGuestMode: boolean
isOwnerRole: boolean
unmatchingDomainPolicy?: boolean
}>()
const { isAdmin } = useActiveUser()
@@ -58,6 +70,7 @@ const isButtonDisabled = computed(() => {
if (props.disabled) return true
if (isTryingToSetGuestOwner.value) return true
if (!props.selectedEmails.length) return true
if (props.unmatchingDomainPolicy) return true
return false
})
</script>
@@ -1,14 +1,31 @@
<template>
<div
class="flex px-4 py-3 items-center space-x-2 border-b last:border-0 border-outline-3"
class="flex px-4 py-3 items-center space-x-2 justify-between border-b last:border-0 border-outline-3"
>
<UserAvatar :user="user" />
<span class="grow truncate text-body-sm">{{ user.name }}</span>
<div class="flex items-center space-x-2 flex-1 truncate">
<UserAvatar :user="user" />
<div
v-if="
user.workspaceDomainPolicyCompliant === false &&
targetRole !== Roles.Workspace.Guest
"
v-tippy="
'Users that do not comply with the domain policy can only be invited as guests'
"
>
<ExclamationCircleIcon class="text-danger w-5 w-4" />
</div>
<span class="grow truncate text-body-sm">{{ user.name }}</span>
</div>
<span v-tippy="isTryingToSetGuestOwner ? settingGuestOwnerErrorMessage : undefined">
<FormButton
:disabled="isButtonDisabled"
size="sm"
color="outline"
:disabled="
(user.workspaceDomainPolicyCompliant === false &&
targetRole !== Roles.Workspace.Guest) ||
isButtonDisabled
"
@click="() => $emit('invite-user')"
>
Invite
@@ -17,8 +34,9 @@
</div>
</template>
<script setup lang="ts">
import { Roles } from '@speckle/shared'
import { Roles, type WorkspaceRoles } from '@speckle/shared'
import type { UserSearchItem } from '~~/lib/common/composables/users'
import { ExclamationCircleIcon } from '@heroicons/vue/24/outline'
defineEmits<{
(e: 'invite-user'): void
@@ -30,6 +48,7 @@ const props = withDefaults(
user: UserSearchItem
disabled?: boolean
settingGuestOwnerErrorMessage?: string
targetRole: WorkspaceRoles
}>(),
{
settingGuestOwnerErrorMessage: "Server guests can't be workspace owners"
@@ -123,7 +123,7 @@ const documents = {
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": types.ViewerCommentsListItemFragmentDoc,
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": types.ViewerModelVersionCardItemFragmentDoc,
"\n fragment WorkspaceAvatar_Workspace on Workspace {\n id\n logo\n defaultLogoIndex\n }\n": types.WorkspaceAvatar_WorkspaceFragmentDoc,
"\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n id\n team {\n items {\n id\n user {\n id\n }\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n": types.WorkspaceInviteDialog_WorkspaceFragmentDoc,
"\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n id\n team {\n items {\n id\n user {\n id\n }\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n": types.WorkspaceInviteDialog_WorkspaceFragmentDoc,
"\n fragment WorkspaceProjectList_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...ProjectDashboardItem\n }\n cursor\n }\n": types.WorkspaceProjectList_ProjectCollectionFragmentDoc,
"\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceAvatar_Workspace\n id\n role\n name\n logo\n description\n totalProjects: projects {\n totalCount\n }\n billing {\n versionsCount {\n ...WorkspacePageVersionCount_WorkspaceVersionsCount\n }\n }\n team {\n items {\n id\n user {\n id\n name\n ...LimitedUserAvatar\n }\n }\n }\n ...WorkspaceInviteDialog_Workspace\n }\n": types.WorkspaceHeader_WorkspaceFragmentDoc,
"\n fragment WorkspaceInviteBanner_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.WorkspaceInviteBanner_PendingWorkspaceCollaboratorFragmentDoc,
@@ -150,7 +150,7 @@ const documents = {
"\n query ProjectAutomationCreationPublicKeys(\n $projectId: String!\n $automationId: String!\n ) {\n project(id: $projectId) {\n id\n automation(id: $automationId) {\n id\n creationPublicKeys\n }\n }\n }\n": types.ProjectAutomationCreationPublicKeysDocument,
"\n query AutomateFunctionsPagePagination($search: String, $cursor: String) {\n ...AutomateFunctionsPageItems_Query\n }\n": types.AutomateFunctionsPagePaginationDocument,
"\n query MentionsUserSearch($query: String!, $emailOnly: Boolean = false) {\n userSearch(\n query: $query\n limit: 5\n cursor: null\n archived: false\n emailOnly: $emailOnly\n ) {\n items {\n id\n name\n company\n }\n }\n }\n": types.MentionsUserSearchDocument,
"\n query UserSearch($query: String!, $limit: Int, $cursor: String, $archived: Boolean) {\n userSearch(query: $query, limit: $limit, cursor: $cursor, archived: $archived) {\n cursor\n items {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n }\n": types.UserSearchDocument,
"\n query UserSearch(\n $query: String!\n $limit: Int\n $cursor: String\n $archived: Boolean\n $workspaceId: String\n ) {\n userSearch(query: $query, limit: $limit, cursor: $cursor, archived: $archived) {\n cursor\n items {\n id\n name\n bio\n company\n avatar\n verified\n role\n workspaceDomainPolicyCompliant(workspaceId: $workspaceId)\n }\n }\n }\n": types.UserSearchDocument,
"\n query ServerInfoBlobSizeLimit {\n serverInfo {\n blobSizeLimitBytes\n }\n }\n": types.ServerInfoBlobSizeLimitDocument,
"\n query ServerInfoAllScopes {\n serverInfo {\n scopes {\n name\n description\n }\n }\n }\n": types.ServerInfoAllScopesDocument,
"\n query ProjectModelsSelectorValues($projectId: String!, $cursor: String) {\n project(id: $projectId) {\n id\n models(limit: 100, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...CommonModelSelectorModel\n }\n }\n }\n }\n": types.ProjectModelsSelectorValuesDocument,
@@ -775,7 +775,7 @@ export function graphql(source: "\n fragment WorkspaceAvatar_Workspace on Works
/**
* 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 WorkspaceInviteDialog_Workspace on Workspace {\n id\n team {\n items {\n id\n user {\n id\n }\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n id\n team {\n items {\n id\n user {\n id\n }\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n"];
export function graphql(source: "\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n id\n team {\n items {\n id\n user {\n id\n }\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n id\n team {\n items {\n id\n user {\n id\n }\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -883,7 +883,7 @@ export function graphql(source: "\n query MentionsUserSearch($query: String!, $
/**
* 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 UserSearch($query: String!, $limit: Int, $cursor: String, $archived: Boolean) {\n userSearch(query: $query, limit: $limit, cursor: $cursor, archived: $archived) {\n cursor\n items {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n }\n"): (typeof documents)["\n query UserSearch($query: String!, $limit: Int, $cursor: String, $archived: Boolean) {\n userSearch(query: $query, limit: $limit, cursor: $cursor, archived: $archived) {\n cursor\n items {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n }\n"];
export function graphql(source: "\n query UserSearch(\n $query: String!\n $limit: Int\n $cursor: String\n $archived: Boolean\n $workspaceId: String\n ) {\n userSearch(query: $query, limit: $limit, cursor: $cursor, archived: $archived) {\n cursor\n items {\n id\n name\n bio\n company\n avatar\n verified\n role\n workspaceDomainPolicyCompliant(workspaceId: $workspaceId)\n }\n }\n }\n"): (typeof documents)["\n query UserSearch(\n $query: String!\n $limit: Int\n $cursor: String\n $archived: Boolean\n $workspaceId: String\n ) {\n userSearch(query: $query, limit: $limit, cursor: $cursor, archived: $archived) {\n cursor\n items {\n id\n name\n bio\n company\n avatar\n verified\n role\n workspaceDomainPolicyCompliant(workspaceId: $workspaceId)\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
@@ -19,7 +19,13 @@ export const mentionsUserSearchQuery = graphql(`
`)
export const userSearchQuery = graphql(`
query UserSearch($query: String!, $limit: Int, $cursor: String, $archived: Boolean) {
query UserSearch(
$query: String!
$limit: Int
$cursor: String
$archived: Boolean
$workspaceId: String
) {
userSearch(query: $query, limit: $limit, cursor: $cursor, archived: $archived) {
cursor
items {
@@ -30,6 +36,7 @@ export const userSearchQuery = graphql(`
avatar
verified
role
workspaceDomainPolicyCompliant(workspaceId: $workspaceId)
}
}
}
@@ -79,13 +79,18 @@ export const useResolveInviteTargets = (params: {
*/
excludeUserIds?: Ref<MaybeNullOrUndefined<string[]>>
excludeEmails?: Ref<MaybeNullOrUndefined<string[]>>
/**
* Used for searching of users within a workspace context
*/
workspaceId?: string
}) => {
const { search, excludeUserIds, excludeEmails } = params
const { search, excludeUserIds, excludeEmails, workspaceId } = params
const { userSearch, searchVariables, loading } = useUserSearch({
variables: computed(() => ({
query: search.value || '',
limit: 5
limit: 5,
workspaceId
}))
})