diff --git a/packages/frontend-2/components/settings/workspaces/security/ConfirmJoinPolicyDialog.vue b/packages/frontend-2/components/settings/workspaces/security/ConfirmJoinPolicyDialog.vue new file mode 100644 index 000000000..b09d000a6 --- /dev/null +++ b/packages/frontend-2/components/settings/workspaces/security/ConfirmJoinPolicyDialog.vue @@ -0,0 +1,50 @@ + + + diff --git a/packages/frontend-2/components/workspace/Card.vue b/packages/frontend-2/components/workspace/Card.vue index 220b3f5f3..04a49bbc2 100644 --- a/packages/frontend-2/components/workspace/Card.vue +++ b/packages/frontend-2/components/workspace/Card.vue @@ -11,7 +11,7 @@
-
+
{{ name }}
@@ -19,10 +19,16 @@
-
+
+
+ {{ bannerText }} +
@@ -31,6 +37,7 @@ const props = defineProps<{ logo: string name: string clickable?: boolean + bannerText?: string | null }>() const emit = defineEmits<{ diff --git a/packages/frontend-2/components/workspace/JoinPage.vue b/packages/frontend-2/components/workspace/JoinPage.vue index c191d954c..6c180c491 100644 --- a/packages/frontend-2/components/workspace/JoinPage.vue +++ b/packages/frontend-2/components/workspace/JoinPage.vue @@ -19,7 +19,7 @@

- Join teammates + Join your coworkers

{{ 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" /> Create a new workspace + + Continue to workspace + + 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 } +) diff --git a/packages/frontend-2/components/workspace/discoverableWorkspaces/Card.vue b/packages/frontend-2/components/workspace/discoverableWorkspaces/Card.vue index 862242043..d3884faab 100644 --- a/packages/frontend-2/components/workspace/discoverableWorkspaces/Card.vue +++ b/packages/frontend-2/components/workspace/discoverableWorkspaces/Card.vue @@ -2,22 +2,30 @@ @@ -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() const showRemoveDomainDialog = ref(false) const removeDialogDomain = ref() const blockedDomainItems: ShallowRef = shallowRef(blockedDomains) +const showConfirmJoinPolicyDialog = ref(false) +const pendingJoinPolicy = ref() 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 + } + }) + } } } )