Skip to content

feat(fe): workspace security settings refactor #4800

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 37 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
15bfeea
refactor(fe): security page
andrewwallacespeckle May 22, 2025
8362729
Move Discoverability to component
andrewwallacespeckle May 22, 2025
709879b
Move domain protection to component
andrewwallacespeckle May 22, 2025
54b89a3
DomainMangement component
andrewwallacespeckle May 22, 2025
4db4fdf
Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-worksp…
andrewwallacespeckle May 23, 2025
7c24dcf
Wording
andrewwallacespeckle May 23, 2025
9b421d3
Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-worksp…
andrewwallacespeckle May 27, 2025
9b6d3c7
Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-worksp…
andrewwallacespeckle May 27, 2025
28609f0
Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-worksp…
andrewwallacespeckle May 28, 2025
d144d71
Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-worksp…
andrewwallacespeckle May 28, 2025
8374c49
Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-worksp…
andrewwallacespeckle Jun 3, 2025
adf0ccd
Updated domain management
andrewwallacespeckle Jun 4, 2025
6105a6f
Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-worksp…
andrewwallacespeckle Jun 4, 2025
0e83b25
Fix merge
andrewwallacespeckle Jun 4, 2025
8c2bf61
Add defaultseat
andrewwallacespeckle Jun 4, 2025
37eabb2
Swap to radio
andrewwallacespeckle Jun 4, 2025
9ebdda2
Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-worksp…
andrewwallacespeckle Jun 4, 2025
af02506
Design changes
andrewwallacespeckle Jun 4, 2025
b85bfc8
Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-worksp…
andrewwallacespeckle Jun 5, 2025
650c17a
GQL
andrewwallacespeckle Jun 5, 2025
7428653
Permissions. SSO to new layout
andrewwallacespeckle Jun 5, 2025
eb325cc
More permissions
andrewwallacespeckle Jun 5, 2025
c14b8cf
Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-worksp…
andrewwallacespeckle Jun 5, 2025
6e5c752
Fix confirmation dialog for discoverability
andrewwallacespeckle Jun 5, 2025
2b9b504
Remove unused GQL
andrewwallacespeckle Jun 5, 2025
70c2d6c
Error handling
andrewwallacespeckle Jun 5, 2025
f5c4ab3
Remove unused card
andrewwallacespeckle Jun 5, 2025
9339e19
Use fragment
andrewwallacespeckle Jun 5, 2025
2eaeb89
Use boolean pendingIsAutoJoinEnabled
andrewwallacespeckle Jun 5, 2025
3ebefce
Comments from PR
andrewwallacespeckle Jun 5, 2025
a1c0c08
Move disabled radio styles to ui-components
andrewwallacespeckle Jun 5, 2025
02ef0bc
Switch alignment
andrewwallacespeckle Jun 5, 2025
fd3edc1
Non-admin can go to billing. Align switches
andrewwallacespeckle Jun 5, 2025
81137bc
Update DefaultSeat copy
andrewwallacespeckle Jun 5, 2025
3468db5
Update discoverability confirmation dialog
andrewwallacespeckle Jun 5, 2025
ea9381a
Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-worksp…
andrewwallacespeckle Jun 6, 2025
3a68bf6
fix(fe): SSO section
andrewwallacespeckle Jun 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<section class="flex flex-col space-y-3">
<section class="flex flex-col space-y-3 pb-8">
<div class="flex flex-col sm:flex-row gap-y-3 sm:items-center">
<div class="flex-1 flex-col pr-6 gap-y-1">
<p class="text-body-xs font-medium text-foreground">
Expand Down Expand Up @@ -44,15 +44,18 @@
@confirm="handleSeatTypeConfirm"
@cancel="handleSeatTypeCancel"
>
<p class="text-body-xs text-foreground mb-2">
<p
v-if="workspace.discoverabilityAutoJoinEnabled"
class="text-body-xs text-foreground mb-2"
>
You have
<span class="font-medium">Join without admin approval</span>
enabled.
</p>
<p class="text-body-xs text-foreground mb-2">
Setting the default seat type to
<span class="font-medium">Editor</span>
means each user who joins will consume a paid seat and possibly incur charges.
means each user who joins will consume a paid seat and incur charges.
</p>
<p class="text-body-xs text-foreground">Are you sure you want to enable this?</p>
</SettingsConfirmDialog>
Expand All @@ -61,9 +64,10 @@

<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import type {
WorkspaceSeatType,
SettingsWorkspacesSecurity_WorkspaceFragment
SettingsWorkspacesSecurityDefaultSeat_WorkspaceFragment
} from '~/lib/common/generated/gql/graphql'
import { Roles, SeatTypes } from '@speckle/shared'
import { workspaceUpdateDefaultSeatTypeMutation } from '~/lib/workspaces/graphql/mutations'
Expand All @@ -75,8 +79,18 @@ import {
convertThrowIntoFetchResult
} from '~/lib/common/helpers/graphql'

graphql(`
fragment SettingsWorkspacesSecurityDefaultSeat_Workspace on Workspace {
id
slug
defaultSeatType
discoverabilityAutoJoinEnabled
role
}
`)

const props = defineProps<{
workspace: SettingsWorkspacesSecurity_WorkspaceFragment
workspace: SettingsWorkspacesSecurityDefaultSeat_WorkspaceFragment
}>()

const mixpanel = useMixpanel()
Expand Down Expand Up @@ -105,12 +119,8 @@ const seatTypeModel = computed({
const handleSeatTypeChange = (newValue: WorkspaceSeatType) => {
if (newValue === currentSeatType.value) return

// If setting to Editor with auto-join enabled on paid plan, show confirmation
if (
newValue === SeatTypes.Editor &&
props.workspace.discoverabilityAutoJoinEnabled &&
isSelfServePlan
) {
// If setting to Editor on paid plan, show confirmation
if (newValue === SeatTypes.Editor && isSelfServePlan.value) {
pendingNewSeatType.value = newValue
showConfirmSeatTypeDialog.value = true
return
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
<template>
<section class="py-8">
<SettingsSectionHeader title="Workspace discoverability" subheading />
<p class="text-body-xs text-foreground-2 mt-2 mb-6">
Make it easy for coworkers to join the workspace
</p>

<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">
Enable workspace discoverability
</p>
<p class="text-body-2xs text-foreground-2 leading-5 max-w-md">
Lets users discover the workspace if they sign up with a matching email.
</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">
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 } = 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: 'Workspace admins have 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: '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: 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>
Loading