feat(fe): workspace security settings - auto join

feat(fe): workspace security settings - auto join
This commit is contained in:
andrewwallacespeckle
2025-05-30 12:20:24 +02:00
committed by GitHub
12 changed files with 428 additions and 83 deletions
@@ -0,0 +1,50 @@
<template>
<LayoutDialog
v-model:open="isOpen"
title="Confirm change"
max-width="xs"
:buttons="dialogButtons"
>
<p class="text-body-xs text-foreground mb-2">
This will allow users with verified domain emails to join automatically without
admin approval.
</p>
<p class="text-body-xs text-foreground">Are you sure you want to enable this?</p>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
const emit = defineEmits<{
(e: 'confirm'): void
(e: 'cancel'): void
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const handleConfirm = () => {
emit('confirm')
isOpen.value = false
}
const handleCancel = () => {
emit('cancel')
isOpen.value = false
}
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'outline' },
onClick: handleCancel
},
{
text: 'Confirm',
props: {
color: 'primary'
},
onClick: handleConfirm
}
])
</script>
@@ -11,7 +11,7 @@
<div class="flex gap-3">
<WorkspaceAvatar :name="name" :logo="logo" size="lg" />
<div class="flex flex-col sm:flex-row gap-4 justify-between flex-1">
<div class="flex flex-col items-start text-body-2xs text-foreground-2">
<div class="flex flex-col items-start text-body-2xs text-foreground-2 gap-1">
<h6 class="text-foreground text-body-sm font-medium">
{{ name }}
</h6>
@@ -19,10 +19,16 @@
</div>
</div>
</div>
<div class="flex flex-col gap-y-2" @click.stop>
<div class="flex flex-col gap-y-2 items-end" @click.stop>
<slot name="actions"></slot>
</div>
</div>
<div
v-if="bannerText"
class="mt-3 text-body-3xs text-center text-success-700 bg-success-50 px-2 py-1 w-full bg-foundation-page rounded-md border-outline-3 border"
>
{{ bannerText }}
</div>
</CommonCard>
</template>
@@ -31,6 +37,7 @@ const props = defineProps<{
logo: string
name: string
clickable?: boolean
bannerText?: string | null
}>()
const emit = defineEmits<{
@@ -19,7 +19,7 @@
<div class="flex flex-col items-center gap-2 w-full max-w-lg mx-auto">
<h1 class="text-heading-xl text-foreground mb-2 font-normal mt-4">
Join teammates
Join your coworkers
</h1>
<p class="text-center text-body-sm text-foreground-2 mb-8">
{{ description }}
@@ -30,6 +30,8 @@
:workspace="workspace"
:request-status="workspace.requestStatus"
location="workspace_join_page"
@auto-joined="workspace.requestStatus = WorkspaceJoinRequestStatus.Approved"
@request="workspace.requestStatus = WorkspaceJoinRequestStatus.Pending"
/>
<FormButton
v-if="!showAllWorkspaces && discoverableWorkspacesAndJoinRequestsCount > 3"
@@ -51,6 +53,7 @@
Continue
</FormButton>
<FormButton
v-if="!hasApprovedWorkspace"
size="lg"
full-width
color="outline"
@@ -58,6 +61,9 @@
>
Create a new workspace
</FormButton>
<FormButton v-else size="lg" full-width @click="navigateTo(homeRoute)">
Continue to workspace
</FormButton>
<FormButton
v-if="!hasDiscoverableJoinRequests && !isWorkspaceNewPlansEnabled"
size="lg"
@@ -76,6 +82,8 @@
import { useAuthManager } from '~/lib/auth/composables/auth'
import { workspaceCreateRoute, homeRoute } from '~~/lib/common/helpers/route'
import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces'
import type { DiscoverableWorkspace_LimitedWorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import { WorkspaceJoinRequestStatus } from '~/lib/common/generated/gql/graphql'
const { logout } = useAuthManager()
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
@@ -86,12 +94,22 @@ const {
hasDiscoverableJoinRequests
} = useDiscoverableWorkspaces()
const hasApprovedWorkspace = computed(() =>
localWorkspaces.value.some(
(workspace) => workspace.requestStatus === WorkspaceJoinRequestStatus.Approved
)
)
const showAllWorkspaces = ref(false)
const localWorkspaces = ref<
(DiscoverableWorkspace_LimitedWorkspaceFragment & { requestStatus: string | null })[]
>([])
const workspacesToShow = computed(() => {
return showAllWorkspaces.value
? discoverableWorkspacesAndJoinRequests.value
: discoverableWorkspacesAndJoinRequests.value.slice(0, 3)
? localWorkspaces.value
: localWorkspaces.value.slice(0, 3)
})
const description = computed(() => {
@@ -100,4 +118,15 @@ const description = computed(() => {
}
return 'We found workspaces that match your email domain'
})
watch(
discoverableWorkspacesAndJoinRequests,
(newWorkspaces) => {
// Only update if localWorkspaces is empty (initial load) or if we don't have any local modifications
if (localWorkspaces.value.length === 0) {
localWorkspaces.value = [...newWorkspaces]
}
},
{ immediate: true }
)
</script>
@@ -2,22 +2,30 @@
<WorkspaceCard
:logo="workspace.logo ?? ''"
:name="workspace.name"
:class="requestStatus === 'pending' ? '' : 'bg-foundation'"
:class="requestStatus === WorkspaceJoinRequestStatus.Pending ? '' : 'bg-foundation'"
:banner-text="
workspace.discoverabilityAutoJoinEnabled &&
requestStatus !== WorkspaceJoinRequestStatus.Approved
? 'You can join this workspace automatically. No admin approval needed.'
: null
"
>
<template #text>
<div class="flex flex-col gap-y-1">
<div v-if="workspace.description" class="text-body-2xs line-clamp-3">
{{ workspace.description }}
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<UserAvatarGroup
v-if="members.length > 0 && requestStatus !== 'pending'"
v-if="members.length > 0"
:users="members"
:max-count="5"
size="base"
/>
<div class="text-body-3xs text-foreground-2">
<span class="font-medium">Admins:&nbsp;</span>
<span class="font-medium">
{{ adminTeam.length === 1 ? 'Admin' : 'Admins' }}:&nbsp;
</span>
<span v-for="(admin, index) in adminTeam.slice(0, 3)" :key="admin.id">
{{ admin.name
}}{{ index < 2 && index < adminTeam.length - 1 ? ', ' : '' }}
@@ -28,12 +36,30 @@
</div>
</template>
<template #actions>
<FormButton v-if="requestStatus" color="outline" size="sm" disabled>
<FormButton
v-if="requestStatus === WorkspaceJoinRequestStatus.Pending"
color="outline"
size="sm"
disabled
>
Join request sent
</FormButton>
<FormButton
v-else-if="requestStatus === WorkspaceJoinRequestStatus.Approved"
color="outline"
size="sm"
:icon-left="CheckIcon"
disabled
>
Workspace joined
</FormButton>
<div v-else class="flex flex-col gap-2 sm:items-end">
<FormButton color="outline" size="sm" @click="onRequest">
Request to join
{{
workspace.discoverabilityAutoJoinEnabled
? 'Join workspace'
: 'Request to join'
}}
</FormButton>
<FormButton
v-if="showDismissButton"
@@ -52,6 +78,8 @@
import type { DiscoverableWorkspace_LimitedWorkspaceFragment } from '~~/lib/common/generated/gql/graphql'
import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { CheckIcon } from '@heroicons/vue/20/solid'
import { WorkspaceJoinRequestStatus } from '~/lib/common/generated/gql/graphql'
const props = defineProps<{
workspace: DiscoverableWorkspace_LimitedWorkspaceFragment
@@ -60,20 +88,41 @@ const props = defineProps<{
requestStatus: string | null
}>()
const emit = defineEmits<{
(e: 'auto-joined'): void
(e: 'request'): void
}>()
const { requestToJoinWorkspace, dismissDiscoverableWorkspace } =
useDiscoverableWorkspaces()
const mixpanel = useMixpanel()
const adminTeam = computed(() => props.workspace.adminTeam?.map((t) => t.user) ?? [])
const adminIds = computed(() => new Set(adminTeam.value.map((admin) => admin.id)))
const members = computed(() =>
(props.workspace.team?.items?.map((u) => u.user) ?? []).filter(
(user) => !adminIds.value.has(user.id)
)
)
const allMembers = computed(() => props.workspace.team?.items?.map((u) => u.user) ?? [])
const members = computed(() => {
// Only deduplicate if there's exactly one person total (admin who is also the only member)
const totalUniqueUsers = new Set([
...adminTeam.value.map((admin) => admin.id),
...allMembers.value.map((member) => member.id)
]).size
if (totalUniqueUsers === 1) {
// Single user case: filter out admins from members to avoid duplication
return allMembers.value.filter((user) => !adminIds.value.has(user.id))
} else {
// Multiple users: show all members including those who are also admins
return allMembers.value
}
})
const onRequest = () => {
requestToJoinWorkspace(props.workspace.id, props.location || 'discovery_card')
requestToJoinWorkspace(props.workspace, props.location || 'discovery_card')
if (props.workspace.discoverabilityAutoJoinEnabled) {
emit('auto-joined')
} else {
emit('request')
}
}
const onDismiss = async () => {
@@ -16,6 +16,9 @@
:request-status="workspace.requestStatus"
show-dismiss-button
location="workspace_switcher"
@auto-joined="workspace.requestStatus = WorkspaceJoinRequestStatus.Approved"
@request="workspace.requestStatus = WorkspaceJoinRequestStatus.Pending"
@go-to-workspace="open = false"
/>
<FormButton
v-if="!showAllWorkspaces && discoverableWorkspacesAndJoinRequestsCount > 3"
@@ -32,6 +35,7 @@
<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces'
import { WorkspaceJoinRequestStatus } from '~/lib/common/generated/gql/graphql'
const {
discoverableWorkspacesAndJoinRequests,
@@ -40,14 +44,15 @@ const {
} = useDiscoverableWorkspaces()
const open = defineModel<boolean>('open', { required: true })
const showAllWorkspaces = ref(false)
const localWorkspaces = ref(discoverableWorkspacesAndJoinRequests.value)
const workspacesToShow = computed(() => {
return showAllWorkspaces.value
? discoverableWorkspacesAndJoinRequests.value
: discoverableWorkspacesAndJoinRequests.value.slice(0, 3)
? localWorkspaces.value
: localWorkspaces.value.slice(0, 3)
})
const dialogButtons = computed((): LayoutDialogButton[] => {
return [
{
@@ -61,5 +66,8 @@ const dialogButtons = computed((): LayoutDialogButton[] => {
watch(open, () => {
showAllWorkspaces.value = false
if (!open.value) {
localWorkspaces.value = discoverableWorkspacesAndJoinRequests.value
}
})
</script>
@@ -31,7 +31,7 @@ const invite = computed(() => ({
const handleRequest = async (accept: boolean) => {
if (accept) {
await requestToJoinWorkspace(props.workspace.id, 'discovery banner')
await requestToJoinWorkspace(props.workspace, 'discovery banner')
} else {
await dismissDiscoverableWorkspace(props.workspace.id)
mixpanel.track('Workspace Discovery Banner Dismissed', {
@@ -379,8 +379,8 @@ type Documents = {
"\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": typeof types.OnViewerCommentsUpdatedDocument,
"\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n": typeof types.LinkableCommentFragmentDoc,
"\n fragment ActiveWorkspace_Workspace on Workspace {\n id\n name\n logo\n role\n slug\n }\n": typeof types.ActiveWorkspace_WorkspaceFragmentDoc,
"\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n }\n": typeof types.DiscoverableWorkspace_LimitedWorkspaceFragmentDoc,
"\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n": typeof types.WorkspaceJoinRequests_LimitedWorkspaceJoinRequestFragmentDoc,
"\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n discoverabilityAutoJoinEnabled\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n }\n": typeof types.DiscoverableWorkspace_LimitedWorkspaceFragmentDoc,
"\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n discoverabilityAutoJoinEnabled\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n": typeof types.WorkspaceJoinRequests_LimitedWorkspaceJoinRequestFragmentDoc,
"\n fragment WorkspacePlanLimits_Workspace on Workspace {\n id\n slug\n plan {\n name\n }\n }\n": typeof types.WorkspacePlanLimits_WorkspaceFragmentDoc,
"\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n": typeof types.UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspacesPlan_Workspace on Workspace {\n id\n slug\n plan {\n status\n createdAt\n name\n paymentMethod\n usage {\n projectCount\n modelCount\n }\n }\n seats {\n editors {\n assigned\n available\n }\n viewers {\n assigned\n available\n }\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n currency\n }\n }\n": typeof types.WorkspacesPlan_WorkspaceFragmentDoc,
@@ -407,6 +407,7 @@ type Documents = {
"\n mutation DenyWorkspaceJoinRequest($input: DenyWorkspaceJoinRequestInput!) {\n workspaceJoinRequestMutations {\n deny(input: $input)\n }\n }\n": typeof types.DenyWorkspaceJoinRequestDocument,
"\n mutation RequestToJoinWorkspace($input: WorkspaceRequestToJoinInput!) {\n workspaceMutations {\n requestToJoin(input: $input)\n }\n }\n": typeof types.RequestToJoinWorkspaceDocument,
"\n mutation DismissDiscoverableWorkspace($input: WorkspaceDismissInput!) {\n workspaceMutations {\n dismiss(input: $input)\n }\n }\n": typeof types.DismissDiscoverableWorkspaceDocument,
"\n mutation WorkspaceUpdateAutoJoinMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n discoverabilityAutoJoinEnabled\n }\n }\n }\n": typeof types.WorkspaceUpdateAutoJoinMutationDocument,
"\n query WorkspaceAccessCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n }\n }\n": typeof types.WorkspaceAccessCheckDocument,
"\n query WorkspaceSidebar(\n $workspaceSlug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n ...WorkspaceSidebar_Workspace\n }\n }\n": typeof types.WorkspaceSidebarDocument,
"\n query WorkspaceDashboard(\n $workspaceSlug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n ...WorkspaceDashboard_Workspace\n }\n }\n": typeof types.WorkspaceDashboardDocument,
@@ -450,7 +451,7 @@ type Documents = {
"\n fragment SettingsWorkspacesProjects_Workspace on Workspace {\n id\n name\n slug\n plan {\n name\n }\n role\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.SettingsWorkspacesProjects_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesRegions_Workspace on Workspace {\n id\n role\n defaultRegion {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n hasAccessToMultiRegion: hasAccessToFeature(\n featureName: workspaceDataRegionSpecificity\n )\n hasProjects: projects(limit: 0) {\n totalCount\n }\n }\n": typeof types.SettingsWorkspacesRegions_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesRegions_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": typeof types.SettingsWorkspacesRegions_ServerInfoFragmentDoc,
"\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n slug\n plan {\n name\n status\n }\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n hasAccessToDomainBasedSecurityPolicies: hasAccessToFeature(\n featureName: domainBasedSecurityPolicies\n )\n }\n": typeof types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n slug\n plan {\n name\n status\n }\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n discoverabilityAutoJoinEnabled\n hasAccessToDomainBasedSecurityPolicies: hasAccessToFeature(\n featureName: domainBasedSecurityPolicies\n )\n }\n": typeof types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc,
};
const documents: Documents = {
"\n fragment AuthLoginWithEmailBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n email\n user {\n id\n }\n }\n": types.AuthLoginWithEmailBlock_PendingWorkspaceCollaboratorFragmentDoc,
@@ -818,8 +819,8 @@ const documents: Documents = {
"\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 fragment ActiveWorkspace_Workspace on Workspace {\n id\n name\n logo\n role\n slug\n }\n": types.ActiveWorkspace_WorkspaceFragmentDoc,
"\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n }\n": types.DiscoverableWorkspace_LimitedWorkspaceFragmentDoc,
"\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n": types.WorkspaceJoinRequests_LimitedWorkspaceJoinRequestFragmentDoc,
"\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n discoverabilityAutoJoinEnabled\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n }\n": types.DiscoverableWorkspace_LimitedWorkspaceFragmentDoc,
"\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n discoverabilityAutoJoinEnabled\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n": types.WorkspaceJoinRequests_LimitedWorkspaceJoinRequestFragmentDoc,
"\n fragment WorkspacePlanLimits_Workspace on Workspace {\n id\n slug\n plan {\n name\n }\n }\n": types.WorkspacePlanLimits_WorkspaceFragmentDoc,
"\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n": types.UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspacesPlan_Workspace on Workspace {\n id\n slug\n plan {\n status\n createdAt\n name\n paymentMethod\n usage {\n projectCount\n modelCount\n }\n }\n seats {\n editors {\n assigned\n available\n }\n viewers {\n assigned\n available\n }\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n currency\n }\n }\n": types.WorkspacesPlan_WorkspaceFragmentDoc,
@@ -846,6 +847,7 @@ const documents: Documents = {
"\n mutation DenyWorkspaceJoinRequest($input: DenyWorkspaceJoinRequestInput!) {\n workspaceJoinRequestMutations {\n deny(input: $input)\n }\n }\n": types.DenyWorkspaceJoinRequestDocument,
"\n mutation RequestToJoinWorkspace($input: WorkspaceRequestToJoinInput!) {\n workspaceMutations {\n requestToJoin(input: $input)\n }\n }\n": types.RequestToJoinWorkspaceDocument,
"\n mutation DismissDiscoverableWorkspace($input: WorkspaceDismissInput!) {\n workspaceMutations {\n dismiss(input: $input)\n }\n }\n": types.DismissDiscoverableWorkspaceDocument,
"\n mutation WorkspaceUpdateAutoJoinMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n discoverabilityAutoJoinEnabled\n }\n }\n }\n": types.WorkspaceUpdateAutoJoinMutationDocument,
"\n query WorkspaceAccessCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n }\n }\n": types.WorkspaceAccessCheckDocument,
"\n query WorkspaceSidebar(\n $workspaceSlug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n ...WorkspaceSidebar_Workspace\n }\n }\n": types.WorkspaceSidebarDocument,
"\n query WorkspaceDashboard(\n $workspaceSlug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n ...WorkspaceDashboard_Workspace\n }\n }\n": types.WorkspaceDashboardDocument,
@@ -889,7 +891,7 @@ const documents: Documents = {
"\n fragment SettingsWorkspacesProjects_Workspace on Workspace {\n id\n name\n slug\n plan {\n name\n }\n role\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.SettingsWorkspacesProjects_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesRegions_Workspace on Workspace {\n id\n role\n defaultRegion {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n hasAccessToMultiRegion: hasAccessToFeature(\n featureName: workspaceDataRegionSpecificity\n )\n hasProjects: projects(limit: 0) {\n totalCount\n }\n }\n": types.SettingsWorkspacesRegions_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesRegions_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": types.SettingsWorkspacesRegions_ServerInfoFragmentDoc,
"\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n slug\n plan {\n name\n status\n }\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n hasAccessToDomainBasedSecurityPolicies: hasAccessToFeature(\n featureName: domainBasedSecurityPolicies\n )\n }\n": types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n slug\n plan {\n name\n status\n }\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n discoverabilityAutoJoinEnabled\n hasAccessToDomainBasedSecurityPolicies: hasAccessToFeature(\n featureName: domainBasedSecurityPolicies\n )\n }\n": types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc,
};
/**
@@ -2369,11 +2371,11 @@ export function graphql(source: "\n fragment ActiveWorkspace_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 DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n }\n"): (typeof documents)["\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n }\n"];
export function graphql(source: "\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n discoverabilityAutoJoinEnabled\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n }\n"): (typeof documents)["\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n discoverabilityAutoJoinEnabled\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n adminTeam {\n user {\n id\n name\n avatar\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 fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n"];
export function graphql(source: "\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n discoverabilityAutoJoinEnabled\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n discoverabilityAutoJoinEnabled\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2478,6 +2480,10 @@ export function graphql(source: "\n mutation RequestToJoinWorkspace($input: Wor
* 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 DismissDiscoverableWorkspace($input: WorkspaceDismissInput!) {\n workspaceMutations {\n dismiss(input: $input)\n }\n }\n"): (typeof documents)["\n mutation DismissDiscoverableWorkspace($input: WorkspaceDismissInput!) {\n workspaceMutations {\n dismiss(input: $input)\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 WorkspaceUpdateAutoJoinMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n discoverabilityAutoJoinEnabled\n }\n }\n }\n"): (typeof documents)["\n mutation WorkspaceUpdateAutoJoinMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n discoverabilityAutoJoinEnabled\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2653,7 +2659,7 @@ export function graphql(source: "\n fragment SettingsWorkspacesRegions_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 fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n slug\n plan {\n name\n status\n }\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n hasAccessToDomainBasedSecurityPolicies: hasAccessToFeature(\n featureName: domainBasedSecurityPolicies\n )\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n slug\n plan {\n name\n status\n }\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n hasAccessToDomainBasedSecurityPolicies: hasAccessToFeature(\n featureName: domainBasedSecurityPolicies\n )\n }\n"];
export function graphql(source: "\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n slug\n plan {\n name\n status\n }\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n discoverabilityAutoJoinEnabled\n hasAccessToDomainBasedSecurityPolicies: hasAccessToFeature(\n featureName: domainBasedSecurityPolicies\n )\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n slug\n plan {\n name\n status\n }\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n discoverabilityAutoJoinEnabled\n hasAccessToDomainBasedSecurityPolicies: hasAccessToFeature(\n featureName: domainBasedSecurityPolicies\n )\n }\n"];
export function graphql(source: string) {
return (documents as any)[source] ?? {};
File diff suppressed because one or more lines are too long
@@ -92,7 +92,8 @@ export function useAddWorkspaceDomain() {
discoverabilityEnabled,
domainBasedMembershipProtectionEnabled,
hasAccessToSSO,
hasAccessToDomainBasedSecurityPolicies
hasAccessToDomainBasedSecurityPolicies,
discoverabilityAutoJoinEnabled: false
}
}
},
@@ -12,6 +12,8 @@ import {
getFirstErrorMessage,
getCacheId
} from '~~/lib/common/helpers/graphql'
import type { DiscoverableWorkspace_LimitedWorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import { activeUserWorkspaceExistenceCheckQuery } from '~/lib/auth/graphql/queries'
graphql(`
fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {
@@ -20,6 +22,7 @@ graphql(`
logo
description
slug
discoverabilityAutoJoinEnabled
team {
totalCount
items {
@@ -49,6 +52,7 @@ graphql(`
name
logo
slug
discoverabilityAutoJoinEnabled
adminTeam {
user {
id
@@ -140,30 +144,56 @@ export const useDiscoverableWorkspaces = () => {
() => discoverableWorkspacesAndJoinRequests.value?.length || 0
)
const requestToJoinWorkspace = async (workspaceId: string, location: string) => {
const requestToJoinWorkspace = async (
workspace: DiscoverableWorkspace_LimitedWorkspaceFragment,
location: string
) => {
const activeUserId = activeUser.value?.id
if (!activeUserId) return
const result = await requestToJoin({
input: { workspaceId }
input: { workspaceId: workspace.id }
}).catch(convertThrowIntoFetchResult)
if (result?.data) {
await refetch()
mixpanel.track('Workspace Join Request Sent', {
workspaceId,
location,
// eslint-disable-next-line camelcase
workspace_id: workspaceId
apollo.query({
query: activeUserWorkspaceExistenceCheckQuery,
variables: {
filter: { personalOnly: true }
},
fetchPolicy: 'network-only'
})
triggerNotification({
title: 'Request sent',
description: 'Your request to join the workspace has been sent.',
type: ToastNotificationType.Success
})
if (workspace.discoverabilityAutoJoinEnabled) {
mixpanel.track('Workspace Auto Joined', {
workspaceId: workspace.id,
location,
// eslint-disable-next-line camelcase
workspace_id: workspace.id
})
triggerNotification({
title: 'Workspace joined',
description: `You have joined ${workspace.name}.`,
type: ToastNotificationType.Success
})
} else {
mixpanel.track('Workspace Join Request Sent', {
workspaceId: workspace.id,
location,
// eslint-disable-next-line camelcase
workspace_id: workspace.id
})
triggerNotification({
title: 'Request sent',
description: 'Your request to join the workspace has been sent.',
type: ToastNotificationType.Success
})
}
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
@@ -152,3 +152,14 @@ export const dismissDiscoverableWorkspaceMutation = graphql(`
}
}
`)
export const workspaceUpdateAutoJoinMutation = graphql(`
mutation WorkspaceUpdateAutoJoinMutation($input: WorkspaceUpdateInput!) {
workspaceMutations {
update(input: $input) {
id
discoverabilityAutoJoinEnabled
}
}
}
`)
@@ -26,11 +26,7 @@
class="border-x border-b first:border-t first:rounded-t-lg border-outline-2 last:rounded-b-lg p-6 py-4 flex items-center"
>
<p class="text-body-xs font-medium flex-1">@{{ domain.domain }}</p>
<FormButton
:disabled="workspaceDomains.length === 1 && isDomainProtectionEnabled"
color="outline"
@click="openRemoveDialog(domain)"
>
<FormButton color="outline" @click="openRemoveDialog(domain)">
Delete
</FormButton>
</li>
@@ -46,7 +42,7 @@
<section class="mt-8">
<div class="grid grid-cols-2 gap-x-6 items-center">
<div class="flex flex-col gap-y-1">
<p class="text-body-xs font-medium text-foreground">New domain</p>
<p class="text-body-xs font-medium text-foreground">Add domain</p>
<p class="text-body-2xs text-foreground-2 leading-5">
Add a domain from a list of email domains for your active account.
</p>
@@ -73,6 +69,52 @@
</section>
<section class="flex flex-col space-y-3 mt-8">
<div class="flex flex-col space-y-8">
<div class="flex items-center">
<div class="flex-1 flex-col pr-6 gap-y-1">
<p class="text-body-xs font-medium text-foreground">
Domain-based discoverability
</p>
<p class="text-body-2xs text-foreground-2 leading-5 max-w-md">
Allow users with verified domain emails to find and request access to
this workspace.
</p>
</div>
<FormSwitch
v-model="isDomainDiscoverabilityEnabled"
v-tippy="
!hasWorkspaceDomains
? 'Your workspace must have at least one verified domain'
: undefined
"
name="domain-discoverability"
:disabled="!hasWorkspaceDomains"
:show-label="false"
/>
</div>
<div class="flex flex-col">
<div class="flex items-center">
<div class="flex-1 flex-col pr-6 gap-y-1">
<p class="text-body-xs font-medium text-foreground">
Join without admin approval
</p>
<p class="text-body-2xs text-foreground-2 leading-5 max-w-md">
Allow users with verified domain emails to join immediately without
admin approval.
</p>
</div>
<FormSwitch
v-model="isAutoJoinEnabled"
v-tippy="
!isDomainDiscoverabilityEnabled
? 'Domain-based discoverability must be enabled'
: undefined
"
name="auto-join"
:disabled="!hasWorkspaceDomains || !isDomainDiscoverabilityEnabled"
:show-label="false"
/>
</div>
</div>
<div class="flex items-center">
<div class="flex-1 flex-col pr-6 gap-y-1">
<div class="flex items-center">
@@ -96,23 +138,6 @@
/>
</div>
</div>
<div class="flex items-center">
<div class="flex-1 flex-col pr-6 gap-y-1">
<p class="text-body-xs font-medium text-foreground">
Domain-based discoverability
</p>
<p class="text-body-2xs text-foreground-2 leading-5 max-w-md">
When enabled, users with a verified email address from your verified
domain list will be able to request to join this workspace.
</p>
</div>
<FormSwitch
v-model="isDomainDiscoverabilityEnabled"
name="domain-discoverability"
:disabled="!hasWorkspaceDomains"
:show-label="false"
/>
</div>
</div>
</section>
</div>
@@ -123,6 +148,12 @@
:workspace-id="workspace?.id"
:domain="removeDialogDomain"
/>
<SettingsWorkspacesSecurityConfirmJoinPolicyDialog
v-if="showConfirmJoinPolicyDialog"
v-model:open="showConfirmJoinPolicyDialog"
@confirm="handleJoinPolicyConfirm"
@cancel="pendingJoinPolicy = undefined"
/>
</section>
</template>
@@ -138,10 +169,16 @@ import { blockedDomains } from '@speckle/shared'
import { useIsWorkspacesSsoEnabled } from '~/composables/globals'
import {
workspaceUpdateDomainProtectionMutation,
workspaceUpdateDiscoverabilityMutation
workspaceUpdateDiscoverabilityMutation,
workspaceUpdateAutoJoinMutation
} from '~/lib/workspaces/graphql/mutations'
import { useVerifiedUserEmailDomains } from '~/lib/workspaces/composables/security'
enum JoinPolicy {
AdminApproval = 'admin-approval',
AutoJoin = 'auto-join'
}
graphql(`
fragment SettingsWorkspacesSecurity_Workspace on Workspace {
id
@@ -158,6 +195,7 @@ graphql(`
...SettingsWorkspacesSecuritySsoWrapper_Workspace
domainBasedMembershipProtectionEnabled
discoverabilityEnabled
discoverabilityAutoJoinEnabled
hasAccessToDomainBasedSecurityPolicies: hasAccessToFeature(
featureName: domainBasedSecurityPolicies
)
@@ -187,12 +225,16 @@ const { mutate: updateDomainProtection } = useMutation(
const { mutate: updateDiscoverability } = useMutation(
workspaceUpdateDiscoverabilityMutation
)
const { mutate: updateAutoJoin } = useMutation(workspaceUpdateAutoJoinMutation)
const { triggerNotification } = useGlobalToast()
const selectedDomain = ref<string>()
const showRemoveDomainDialog = ref(false)
const removeDialogDomain =
ref<SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragment>()
const blockedDomainItems: ShallowRef<string[]> = shallowRef(blockedDomains)
const showConfirmJoinPolicyDialog = ref(false)
const pendingJoinPolicy = ref<JoinPolicy>()
const { result } = useQuery(settingsWorkspacesSecurityQuery, {
slug: slug.value
@@ -257,10 +299,35 @@ const isDomainDiscoverabilityEnabled = computed({
// eslint-disable-next-line camelcase
workspace_id: workspace.value?.id
})
// If turning off discoverability, also turn off auto-join
if (!newVal && workspace.value.discoverabilityAutoJoinEnabled) {
const autoJoinResult = await updateAutoJoin({
input: {
id: workspace.value.id,
discoverabilityAutoJoinEnabled: false
}
}).catch(convertThrowIntoFetchResult)
if (autoJoinResult?.data) {
mixpanel.track('Workspace Join Policy Updated', {
value: 'admin-approval',
// eslint-disable-next-line camelcase
workspace_id: workspace.value.id
})
}
}
}
}
})
const isAutoJoinEnabled = computed({
get: () => workspace.value?.discoverabilityAutoJoinEnabled || false,
set: (newVal) => {
handleJoinPolicyUpdate(newVal ? JoinPolicy.AutoJoin : JoinPolicy.AdminApproval)
}
})
const switchDisabled = computed(() => {
if (isDomainProtectionEnabled.value) return false
if (!hasAccessToDomainBasedSecurityPolicies.value) return true
@@ -308,11 +375,90 @@ const disabledItemPredicate = (item: string) => {
return blockedDomainItems.value.includes(item)
}
const handleJoinPolicyUpdate = async (newValue: JoinPolicy, confirmed = false) => {
if (!workspace.value?.id) return
// If enabling auto-join and not yet confirmed, show confirmation dialog
if (newValue === JoinPolicy.AutoJoin && !confirmed) {
showConfirmJoinPolicyDialog.value = true
pendingJoinPolicy.value = newValue
return
}
const isAutoJoinEnabled = newValue === JoinPolicy.AutoJoin
const result = await updateAutoJoin({
input: {
id: workspace.value.id,
discoverabilityAutoJoinEnabled: isAutoJoinEnabled
}
}).catch(convertThrowIntoFetchResult)
if (result?.data) {
// Reset dialog state if it was open
if (showConfirmJoinPolicyDialog.value) {
showConfirmJoinPolicyDialog.value = false
pendingJoinPolicy.value = undefined
}
const notificationConfig = isAutoJoinEnabled
? {
title: 'Join without admin approval enabled',
description:
'Users with a verified domain can now join without admin approval'
}
: {
title: 'New user policy updated',
description: 'Admin approval is now required for new users to join'
}
triggerNotification({
type: ToastNotificationType.Success,
...notificationConfig
})
mixpanel.track('Workspace Join Policy Updated', {
value: isAutoJoinEnabled ? 'auto-join' : 'admin-approval',
// eslint-disable-next-line camelcase
workspace_id: workspace.value.id
})
}
}
const handleJoinPolicyConfirm = async () => {
if (!pendingJoinPolicy.value) return
await handleJoinPolicyUpdate(pendingJoinPolicy.value, true)
}
watch(
() => workspaceDomains.value,
() => {
if (!hasWorkspaceDomains.value) {
isDomainDiscoverabilityEnabled.value = false
() => workspaceDomains.value.length,
async (newLength) => {
// If last domain was removed, disable all domain features
if (newLength === 0 && workspace.value?.id) {
if (workspace.value.discoverabilityEnabled) {
await updateDiscoverability({
input: {
id: workspace.value.id,
discoverabilityEnabled: false
}
})
}
if (workspace.value.discoverabilityAutoJoinEnabled) {
await updateAutoJoin({
input: {
id: workspace.value.id,
discoverabilityAutoJoinEnabled: false
}
})
}
if (workspace.value.domainBasedMembershipProtectionEnabled) {
await updateDomainProtection({
input: {
id: workspace.value.id,
domainBasedMembershipProtectionEnabled: false
}
})
}
}
}
)