Files
speckle-server/packages/frontend-2/components/settings/workspaces/security/Discoverability.vue
T
2025-06-13 13:23:29 +02:00

301 lines
9.1 KiB
Vue

<template>
<section class="py-8">
<SettingsSectionHeader title="Discoverable workspace" subheading />
<p class="text-body-xs text-foreground-2 mt-2 mb-6">
Make the workspace discoverable to coworkers for easy joining.
</p>
<div class="flex flex-col space-y-6">
<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">
Enable workspace discoverability
</p>
<p class="text-body-2xs text-foreground-2 leading-5 max-w-md mt-1">
Users can discover the workspace if they sign up with a verified email
domain.
</p>
</div>
<div
v-tippy="
!isWorkspaceAdmin
? 'You must be a workspace admin'
: !hasWorkspaceDomains
? 'Your workspace must have at least one verified domain'
: undefined
"
>
<FormSwitch
v-model="isDomainDiscoverabilityEnabled"
name="domain-discoverability"
:disabled="!hasWorkspaceDomains || !isWorkspaceAdmin"
:show-label="false"
/>
</div>
</div>
<div v-if="isDomainDiscoverabilityEnabled" class="flex flex-col gap-2">
<p class="text-body-xs font-medium text-foreground">
When someone wants to join
</p>
<div
v-tippy="!isWorkspaceAdmin ? 'You must be a workspace admin' : undefined"
class="max-w-max"
>
<FormRadio
v-for="option in radioOptions"
:key="option.value"
:disabled="!isWorkspaceAdmin"
:label="option.title"
:value="option.value"
name="joinPolicy"
:checked="joinPolicy === option.value"
size="sm"
label-classes="!font-normal"
@change="handleRadioChange(option.value)"
/>
</div>
</div>
</div>
<SettingsConfirmDialog
v-model:open="showConfirmJoinPolicyDialog"
title="Confirm change"
@confirm="handleJoinPolicyConfirm"
@cancel="handleJoinPolicyCancel"
>
<p class="text-body-xs text-foreground mb-2">
This will allow users with verified domain emails to join automatically without
admin approval.
<span
v-if="
workspace.defaultSeatType === SeatTypes.Editor &&
isSelfServePlan &&
isPaidPlan
"
>
They will join on a paid Editor seat.
</span>
</p>
<p class="text-body-xs text-foreground">Are you sure you want to enable this?</p>
</SettingsConfirmDialog>
</section>
</template>
<script setup lang="ts">
import { Roles, SeatTypes } from '@speckle/shared'
import { useMutation } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import type { SettingsWorkspacesSecurityDiscoverability_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import { useMixpanel } from '~/lib/core/composables/mp'
import {
workspaceUpdateDiscoverabilityMutation,
workspaceUpdateAutoJoinMutation
} from '~/lib/workspaces/graphql/mutations'
import { useWorkspacePlan } from '~/lib/workspaces/composables/plan'
enum JoinPolicy {
AdminApproval = 'admin-approval',
AutoJoin = 'auto-join'
}
graphql(`
fragment SettingsWorkspacesSecurityDiscoverability_Workspace on Workspace {
id
slug
role
domains {
id
domain
}
discoverabilityEnabled
discoverabilityAutoJoinEnabled
defaultSeatType
}
`)
const props = defineProps<{
workspace: SettingsWorkspacesSecurityDiscoverability_WorkspaceFragment
}>()
const mixpanel = useMixpanel()
const { mutate: updateDiscoverability } = useMutation(
workspaceUpdateDiscoverabilityMutation
)
const { mutate: updateAutoJoin } = useMutation(workspaceUpdateAutoJoinMutation)
const { triggerNotification } = useGlobalToast()
const { isSelfServePlan, isPaidPlan } = useWorkspacePlan(props.workspace.slug)
const showConfirmJoinPolicyDialog = ref(false)
const pendingIsAutoJoinEnabled = ref(false)
const currentJoinPolicy = ref<JoinPolicy>()
const workspaceDomains = computed(() => {
return props.workspace?.domains || []
})
const isWorkspaceAdmin = computed(() => props.workspace.role === Roles.Workspace.Admin)
const hasWorkspaceDomains = computed(() => workspaceDomains.value.length > 0)
const isDomainDiscoverabilityEnabled = computed({
get: () => props.workspace?.discoverabilityEnabled || false,
set: async (newVal) => {
if (!props.workspace?.id) return
const result = await updateDiscoverability({
input: {
id: props.workspace.id,
discoverabilityEnabled: newVal
}
}).catch(convertThrowIntoFetchResult)
if (result?.data) {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Workspace discoverability updated',
description: `Workspace discoverability has been ${
newVal ? 'enabled' : 'disabled'
}`
})
mixpanel.track('Workspace Discoverability Toggled', {
value: newVal,
// eslint-disable-next-line camelcase
workspace_id: props.workspace?.id
})
// If turning off discoverability, also turn off auto-join
if (!newVal && props.workspace.discoverabilityAutoJoinEnabled) {
const autoJoinResult = await updateAutoJoin({
input: {
id: props.workspace.id,
discoverabilityAutoJoinEnabled: false
}
}).catch(convertThrowIntoFetchResult)
if (autoJoinResult?.data) {
mixpanel.track('Workspace Join Policy Updated', {
value: 'admin-approval',
// eslint-disable-next-line camelcase
workspace_id: props.workspace.id
})
}
}
}
}
})
const joinPolicy = computed({
get: () => {
// Use currentJoinPolicy if it's been set, otherwise use workspace state
if (currentJoinPolicy.value !== undefined) {
return currentJoinPolicy.value
}
return props.workspace?.discoverabilityAutoJoinEnabled
? JoinPolicy.AutoJoin
: JoinPolicy.AdminApproval
},
set: (newVal) => {
handleJoinPolicyUpdate(newVal)
}
})
const radioOptions = shallowRef([
{
title: 'A workspace admin has to accept a join request',
value: JoinPolicy.AdminApproval
},
{
title: 'Users can join immediately without admin approval',
value: JoinPolicy.AutoJoin
}
] as const)
const handleJoinPolicyUpdate = async (newValue: JoinPolicy, confirmed = false) => {
if (!props.workspace?.id) return
// If enabling auto-join and not yet confirmed, show confirmation dialog
if (newValue === JoinPolicy.AutoJoin && !confirmed) {
showConfirmJoinPolicyDialog.value = true
pendingIsAutoJoinEnabled.value = true
return
}
const result = await updateAutoJoin({
input: {
id: props.workspace.id,
discoverabilityAutoJoinEnabled: newValue === JoinPolicy.AutoJoin
}
}).catch(convertThrowIntoFetchResult)
if (result?.data) {
// Update our local state to match the successful change
currentJoinPolicy.value = newValue
// Reset dialog state if it was open
if (showConfirmJoinPolicyDialog.value) {
showConfirmJoinPolicyDialog.value = false
pendingIsAutoJoinEnabled.value = false
}
const notificationConfig =
newValue === JoinPolicy.AutoJoin
? {
title: 'Join without admin approval enabled',
description:
'Users with a verified domain can now join without admin approval'
}
: {
title: 'Admin approval enabled',
description: 'Admin approval is now required for new users to join'
}
triggerNotification({
type: ToastNotificationType.Success,
...notificationConfig
})
mixpanel.track('Workspace Join Policy Updated', {
value: newValue === JoinPolicy.AutoJoin ? 'auto-join' : 'admin-approval',
// eslint-disable-next-line camelcase
workspace_id: props.workspace.id
})
}
}
const handleJoinPolicyConfirm = async () => {
if (!pendingIsAutoJoinEnabled.value) return
await handleJoinPolicyUpdate(JoinPolicy.AutoJoin, true)
}
const handleJoinPolicyCancel = () => {
// Revert the radio selection back to the current actual state
currentJoinPolicy.value = props.workspace?.discoverabilityAutoJoinEnabled
? JoinPolicy.AutoJoin
: JoinPolicy.AdminApproval
// Close dialog and reset pending state
showConfirmJoinPolicyDialog.value = false
pendingIsAutoJoinEnabled.value = false
}
const handleRadioChange = (newValue: JoinPolicy) => {
// Immediately update our local state to show the selection
currentJoinPolicy.value = newValue
// Then handle the policy update (which may show confirmation dialog)
handleJoinPolicyUpdate(newValue)
}
watch(
() => workspaceDomains.value.length,
async (newLength) => {
// If last domain was removed, disable discoverability features
if (newLength === 0 && props.workspace?.id) {
if (props.workspace.discoverabilityEnabled) {
isDomainDiscoverabilityEnabled.value = false
}
}
}
)
</script>