feat(fe2): show custom data residency restriction disclaimer (#3605)

* move to workspace disclaimer

* disclaimer added everywhere

* cleanup

* copy update

* Update copy

---------

Co-authored-by: Benjamin Ottensten <benjamin.ottensten@gmail.com>
This commit is contained in:
Kristaps Fabians Geikins
2024-12-03 10:58:14 +00:00
committed by GitHub
parent c68090a041
commit 16897b86cb
8 changed files with 265 additions and 49 deletions
@@ -6,7 +6,7 @@
:size-limit="maxSizeInBytes"
:accept="accept"
class="flex items-center h-full"
@files-selected="onFilesSelected"
@files-selected="triggerAction"
>
<div
class="w-full h-full border-dashed border rounded-md p-4 flex items-center justify-center text-sm"
@@ -42,6 +42,12 @@
file here.
</span>
</div>
<WorkspaceRegionStaticDataDisclaimer
v-if="showRegionStaticDataDisclaimer"
v-model:open="showRegionStaticDataDisclaimer"
:variant="RegionStaticDataDisclaimerVariant.UploadModel"
@confirm="onConfirmHandler"
/>
</FormFileUploadZone>
</template>
<script setup lang="ts">
@@ -50,6 +56,10 @@ import { useFileUploadProgressCore } from '~~/lib/form/composables/fileUpload'
import { ExclamationTriangleIcon } from '@heroicons/vue/24/solid'
import { downloadManagerUrl } from '~/lib/common/helpers/route'
import type { Nullable } from '@speckle/shared'
import {
useWorkspaceCustomDataResidencyDisclaimerQuery,
RegionStaticDataDisclaimerVariant
} from '~/lib/workspaces/composables/region'
const props = defineProps<{
projectId: string
@@ -58,12 +68,18 @@ const props = defineProps<{
const {
maxSizeInBytes,
onFilesSelected,
onFilesSelected: onFilesSelectedInternal,
accept,
upload: fileUpload,
isUploading
} = useFileImport(toRefs(props))
const { showRegionStaticDataDisclaimer, triggerAction, onConfirmHandler } =
useWorkspaceCustomDataResidencyDisclaimerQuery({
projectId: computed(() => props.projectId),
onConfirmAction: onFilesSelectedInternal
})
const { errorMessage, progressBarClasses, progressBarStyle } =
useFileUploadProgressCore({
item: fileUpload
@@ -48,6 +48,12 @@
</div>
</div>
</div>
<WorkspaceRegionStaticDataDisclaimer
v-if="showRegionStaticDataDisclaimer"
v-model:open="showRegionStaticDataDisclaimer"
:variant="RegionStaticDataDisclaimerVariant.MoveProjectIntoWorkspace"
@confirm="onConfirmHandler"
/>
</LayoutDialog>
</template>
@@ -57,12 +63,15 @@ import type {
ProjectsMoveToWorkspaceDialog_WorkspaceFragment,
ProjectsMoveToWorkspaceDialog_ProjectFragment
} from '~~/lib/common/generated/gql/graphql'
import { projectWorkspaceSelectQuery } from '~/lib/projects/graphql/queries'
import { useQuery } from '@vue/apollo-composable'
import { useMutationLoading, useQuery } from '@vue/apollo-composable'
import { type LayoutDialogButton } from '@speckle/ui-components'
import { useMoveProjectToWorkspace } from '~/lib/projects/composables/projectManagement'
import { Roles } from '@speckle/shared'
import { workspacesRoute } from '~/lib/common/helpers/route'
import {
useWorkspaceCustomDataResidencyDisclaimer,
RegionStaticDataDisclaimerVariant
} from '~/lib/workspaces/composables/region'
graphql(`
fragment ProjectsMoveToWorkspaceDialog_Workspace on Workspace {
@@ -71,6 +80,8 @@ graphql(`
name
defaultLogoIndex
logo
...WorkspaceHasCustomDataResidency_Workspace
...ProjectsWorkspaceSelect_Workspace
}
`)
@@ -97,6 +108,15 @@ graphql(`
}
`)
const query = graphql(`
query ProjectsMoveToWorkspaceDialog {
activeUser {
id
...ProjectsMoveToWorkspaceDialog_User
}
}
`)
const props = defineProps<{
project: ProjectsMoveToWorkspaceDialog_ProjectFragment
workspace?: ProjectsMoveToWorkspaceDialog_WorkspaceFragment
@@ -105,9 +125,10 @@ const props = defineProps<{
const open = defineModel<boolean>('open', { required: true })
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { result } = useQuery(projectWorkspaceSelectQuery, null, () => ({
const { result } = useQuery(query, null, () => ({
enabled: isWorkspacesEnabled.value
}))
const loading = useMutationLoading()
const moveProject = useMoveProjectToWorkspace()
const selectedWorkspace = ref<ProjectsMoveToWorkspaceDialog_WorkspaceFragment>()
@@ -134,9 +155,9 @@ const dialogButtons = computed<LayoutDialogButton[]>(() => {
text: 'Move',
props: {
color: 'primary',
disabled: !selectedWorkspace.value && !props.workspace
disabled: (!selectedWorkspace.value && !props.workspace) || loading.value
},
onClick: () => onMoveProject()
onClick: () => triggerAction()
}
]
: [
@@ -153,27 +174,31 @@ const dialogButtons = computed<LayoutDialogButton[]>(() => {
const onMoveProject = async () => {
const workspaceId = selectedWorkspace.value?.id ?? props.workspace?.id
const workspaceName = selectedWorkspace.value?.name ?? props.workspace?.name
if (!workspaceId || !workspaceName) return
if (workspaceId && workspaceName) {
try {
await moveProject({
projectId: props.project.id,
workspaceId,
workspaceName,
eventSource: props.eventSource
})
open.value = false
} catch {
// Do nothing on error, composable already shows notification
}
const res = await moveProject({
projectId: props.project.id,
workspaceId,
workspaceName,
eventSource: props.eventSource
})
if (res?.id) {
open.value = false
}
}
const { showRegionStaticDataDisclaimer, triggerAction, onConfirmHandler } =
useWorkspaceCustomDataResidencyDisclaimer({
workspace: computed(() => selectedWorkspace.value ?? props.workspace),
onConfirmAction: onMoveProject
})
watch(
() => open.value,
(isOpen, oldIsOpen) => {
if (isOpen && isOpen !== oldIsOpen) {
selectedWorkspace.value = undefined
showRegionStaticDataDisclaimer.value = false
}
}
)
@@ -0,0 +1,54 @@
<template>
<LayoutDialog
v-model:open="open"
max-width="xs"
title="Data residency notice"
:buttons="dialogButtons"
>
<template
v-if="variant === RegionStaticDataDisclaimerVariant.MoveProjectIntoWorkspace"
>
Your workspace has custom data residency set up. However, we currently do not
support moving projects between data regions, so the projects you move in to the
workspace will remain in their previous location.
</template>
<template v-else-if="variant === RegionStaticDataDisclaimerVariant.UploadModel">
The workspace where the project resides has custom data residency set up. However,
we currently do not support custom data residency for file uploads. As a result,
the uploaded file will be stored in the default location.
</template>
</LayoutDialog>
</template>
<script lang="ts" setup>
import type { LayoutDialogButton } from '@speckle/ui-components'
import { RegionStaticDataDisclaimerVariant } from '~/lib/workspaces/composables/region'
const emit = defineEmits<{
cancel: []
confirm: []
}>()
defineProps<{
variant: RegionStaticDataDisclaimerVariant
}>()
const open = defineModel<boolean>('open', { required: true })
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'outline' },
onClick: () => {
open.value = false
emit('cancel')
}
},
{
text: 'I Understand',
onClick: () => {
open.value = false
emit('confirm')
}
}
])
</script>
@@ -98,9 +98,10 @@ const documents = {
"\n fragment ProjectsDashboardHeaderProjects_User on User {\n projectInvites {\n ...ProjectsInviteBanner\n }\n }\n": types.ProjectsDashboardHeaderProjects_UserFragmentDoc,
"\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace\n }\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n": types.ProjectsDashboardHeaderWorkspaces_UserFragmentDoc,
"\n fragment ProjectsHiddenProjectWarning_User on User {\n id\n expiredSsoSessions {\n id\n slug\n name\n logo\n defaultLogoIndex\n }\n }\n": types.ProjectsHiddenProjectWarning_UserFragmentDoc,
"\n fragment ProjectsMoveToWorkspaceDialog_Workspace on Workspace {\n id\n role\n name\n defaultLogoIndex\n logo\n }\n": types.ProjectsMoveToWorkspaceDialog_WorkspaceFragmentDoc,
"\n fragment ProjectsMoveToWorkspaceDialog_Workspace on Workspace {\n id\n role\n name\n defaultLogoIndex\n logo\n ...WorkspaceHasCustomDataResidency_Workspace\n ...ProjectsWorkspaceSelect_Workspace\n }\n": types.ProjectsMoveToWorkspaceDialog_WorkspaceFragmentDoc,
"\n fragment ProjectsMoveToWorkspaceDialog_User on User {\n workspaces {\n items {\n ...ProjectsMoveToWorkspaceDialog_Workspace\n }\n }\n }\n": types.ProjectsMoveToWorkspaceDialog_UserFragmentDoc,
"\n fragment ProjectsMoveToWorkspaceDialog_Project on Project {\n id\n name\n modelCount: models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n }\n": types.ProjectsMoveToWorkspaceDialog_ProjectFragmentDoc,
"\n query ProjectsMoveToWorkspaceDialog {\n activeUser {\n id\n ...ProjectsMoveToWorkspaceDialog_User\n }\n }\n": types.ProjectsMoveToWorkspaceDialogDocument,
"\n fragment ProjectsWorkspaceSelect_Workspace on Workspace {\n id\n role\n name\n defaultLogoIndex\n logo\n }\n": types.ProjectsWorkspaceSelect_WorkspaceFragmentDoc,
"\n fragment ProjectsInviteBanner on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n": types.ProjectsInviteBannerFragmentDoc,
"\n fragment SettingsDialog_Workspace on Workspace {\n ...WorkspaceAvatar_Workspace\n ...SettingsMenu_Workspace\n id\n slug\n role\n name\n plan {\n status\n }\n }\n": types.SettingsDialog_WorkspaceFragmentDoc,
@@ -341,6 +342,8 @@ const documents = {
"\n fragment WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n }\n": types.WorkspaceMixpanelUpdateGroup_WorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspaceMixpanelUpdateGroup_Workspace on Workspace {\n id\n name\n description\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n plan {\n status\n name\n }\n team {\n totalCount\n items {\n ...WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator\n }\n }\n }\n": types.WorkspaceMixpanelUpdateGroup_WorkspaceFragmentDoc,
"\n subscription OnWorkspaceProjectsUpdate($slug: String!) {\n workspaceProjectsUpdated(workspaceId: null, workspaceSlug: $slug) {\n projectId\n workspaceId\n type\n project {\n ...ProjectDashboardItem\n }\n }\n }\n ": types.OnWorkspaceProjectsUpdateDocument,
"\n fragment WorkspaceHasCustomDataResidency_Workspace on Workspace {\n id\n defaultRegion {\n id\n name\n }\n }\n": types.WorkspaceHasCustomDataResidency_WorkspaceFragmentDoc,
"\n query CheckProjectWorkspaceDataResidency($projectId: String!) {\n project(id: $projectId) {\n id\n workspace {\n ...WorkspaceHasCustomDataResidency_Workspace\n }\n }\n }\n": types.CheckProjectWorkspaceDataResidencyDocument,
"\n fragment WorkspaceSsoStatus_Workspace on Workspace {\n id\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n session {\n validUntil\n }\n }\n }\n ": types.WorkspaceSsoStatus_WorkspaceFragmentDoc,
"\n fragment WorkspaceSsoStatus_User on User {\n expiredSsoSessions {\n id\n slug\n }\n }\n ": types.WorkspaceSsoStatus_UserFragmentDoc,
"\n mutation UpdateRole($input: WorkspaceRoleUpdateInput!) {\n workspaceMutations {\n updateRole(input: $input) {\n team {\n items {\n id\n role\n }\n }\n }\n }\n }\n": types.UpdateRoleDocument,
@@ -726,7 +729,7 @@ export function graphql(source: "\n fragment ProjectsHiddenProjectWarning_User
/**
* 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 ProjectsMoveToWorkspaceDialog_Workspace on Workspace {\n id\n role\n name\n defaultLogoIndex\n logo\n }\n"): (typeof documents)["\n fragment ProjectsMoveToWorkspaceDialog_Workspace on Workspace {\n id\n role\n name\n defaultLogoIndex\n logo\n }\n"];
export function graphql(source: "\n fragment ProjectsMoveToWorkspaceDialog_Workspace on Workspace {\n id\n role\n name\n defaultLogoIndex\n logo\n ...WorkspaceHasCustomDataResidency_Workspace\n ...ProjectsWorkspaceSelect_Workspace\n }\n"): (typeof documents)["\n fragment ProjectsMoveToWorkspaceDialog_Workspace on Workspace {\n id\n role\n name\n defaultLogoIndex\n logo\n ...WorkspaceHasCustomDataResidency_Workspace\n ...ProjectsWorkspaceSelect_Workspace\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -735,6 +738,10 @@ export function graphql(source: "\n fragment ProjectsMoveToWorkspaceDialog_User
* 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 ProjectsMoveToWorkspaceDialog_Project on Project {\n id\n name\n modelCount: models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n }\n"): (typeof documents)["\n fragment ProjectsMoveToWorkspaceDialog_Project on Project {\n id\n name\n modelCount: models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\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 query ProjectsMoveToWorkspaceDialog {\n activeUser {\n id\n ...ProjectsMoveToWorkspaceDialog_User\n }\n }\n"): (typeof documents)["\n query ProjectsMoveToWorkspaceDialog {\n activeUser {\n id\n ...ProjectsMoveToWorkspaceDialog_User\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1695,6 +1702,14 @@ export function graphql(source: "\n fragment WorkspaceMixpanelUpdateGroup_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 subscription OnWorkspaceProjectsUpdate($slug: String!) {\n workspaceProjectsUpdated(workspaceId: null, workspaceSlug: $slug) {\n projectId\n workspaceId\n type\n project {\n ...ProjectDashboardItem\n }\n }\n }\n "): (typeof documents)["\n subscription OnWorkspaceProjectsUpdate($slug: String!) {\n workspaceProjectsUpdated(workspaceId: null, workspaceSlug: $slug) {\n projectId\n workspaceId\n type\n project {\n ...ProjectDashboardItem\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 WorkspaceHasCustomDataResidency_Workspace on Workspace {\n id\n defaultRegion {\n id\n name\n }\n }\n"): (typeof documents)["\n fragment WorkspaceHasCustomDataResidency_Workspace on Workspace {\n id\n defaultRegion {\n id\n name\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 query CheckProjectWorkspaceDataResidency($projectId: String!) {\n project(id: $projectId) {\n id\n workspace {\n ...WorkspaceHasCustomDataResidency_Workspace\n }\n }\n }\n"): (typeof documents)["\n query CheckProjectWorkspaceDataResidency($projectId: String!) {\n project(id: $projectId) {\n id\n workspace {\n ...WorkspaceHasCustomDataResidency_Workspace\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
@@ -1,6 +1,6 @@
import type { ApolloCache } from '@apollo/client/core'
import type { Optional } from '@speckle/shared'
import { useApolloClient, useSubscription } from '@vue/apollo-composable'
import { useApolloClient, useMutation, useSubscription } from '@vue/apollo-composable'
import type { MaybeRef } from '@vueuse/core'
import type { Get } from 'type-fest'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
@@ -544,10 +544,9 @@ export function useLeaveProject() {
}
export function useMoveProjectToWorkspace() {
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const mixpanel = useMixpanel()
const { mutate } = useMutation(useMoveProjectToWorkspaceMutation)
return async (params: {
projectId: string
@@ -557,10 +556,9 @@ export function useMoveProjectToWorkspace() {
}) => {
const { projectId, workspaceId, workspaceName, eventSource } = params
const { data, errors } = await apollo
.mutate({
mutation: useMoveProjectToWorkspaceMutation,
variables: { projectId, workspaceId },
const res = await mutate(
{ projectId, workspaceId },
{
update: (cache, { data }) => {
if (!data?.workspaceMutations.projects.moveToWorkspace) return
if (!workspaceId) return
@@ -576,10 +574,10 @@ export function useMoveProjectToWorkspace() {
}
)
}
})
.catch(convertThrowIntoFetchResult)
}
).catch(convertThrowIntoFetchResult)
if (data?.workspaceMutations) {
if (res?.data?.workspaceMutations.projects.moveToWorkspace.id) {
triggerNotification({
type: ToastNotificationType.Success,
title: `Moved project to ${workspaceName}`
@@ -592,13 +590,15 @@ export function useMoveProjectToWorkspace() {
source: eventSource
})
} else {
const errMsg = getFirstErrorMessage(errors)
const errMsg = getFirstErrorMessage(res?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: "Couldn't move project",
description: errMsg
})
}
return res?.data?.workspaceMutations.projects.moveToWorkspace
}
}
@@ -0,0 +1,92 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { MaybeAsync, MaybeNullOrUndefined } from '@speckle/shared'
import { useQuery } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import type { WorkspaceHasCustomDataResidency_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
export enum RegionStaticDataDisclaimerVariant {
MoveProjectIntoWorkspace = 'MoveProjectIntoWorkspace',
UploadModel = 'UploadModel'
}
graphql(`
fragment WorkspaceHasCustomDataResidency_Workspace on Workspace {
id
defaultRegion {
id
name
}
}
`)
const checkProjectWorkspaceDataResidencyQuery = graphql(`
query CheckProjectWorkspaceDataResidency($projectId: String!) {
project(id: $projectId) {
id
workspace {
...WorkspaceHasCustomDataResidency_Workspace
}
}
}
`)
export const useWorkspaceCustomDataResidencyDisclaimer = <
ConfirmArgs extends any[]
>(params: {
workspace: Ref<
MaybeNullOrUndefined<WorkspaceHasCustomDataResidency_WorkspaceFragment>
>
onConfirmAction: (...args: ConfirmArgs) => MaybeAsync<void>
}) => {
const { onConfirmAction, workspace } = params
const showRegionStaticDataDisclaimer = ref(false)
const storedArgs = shallowRef<ConfirmArgs>()
const hasCustomDataResidency = computed(() => {
return !!workspace.value?.defaultRegion
})
/**
* Trigger the actual action that requires the user to confirm the data residency disclaimer
*/
const triggerAction = (...args: ConfirmArgs) => {
if (!hasCustomDataResidency.value) {
onConfirmAction(...args)
} else {
storedArgs.value = args
showRegionStaticDataDisclaimer.value = true
}
}
/**
* Disclaimer on-confirm handler
*/
const onConfirmHandler = () => {
showRegionStaticDataDisclaimer.value = false
onConfirmAction(...storedArgs.value!)
}
return {
hasCustomDataResidency,
showRegionStaticDataDisclaimer,
triggerAction,
onConfirmHandler
}
}
export const useWorkspaceCustomDataResidencyDisclaimerQuery = <
ConfirmArgs extends any[]
>(params: {
projectId: Ref<string>
onConfirmAction: (...args: ConfirmArgs) => MaybeAsync<void>
}) => {
const { projectId, onConfirmAction } = params
const { result } = useQuery(checkProjectWorkspaceDataResidencyQuery, () => ({
projectId: projectId.value
}))
return useWorkspaceCustomDataResidencyDisclaimer({
workspace: computed(() => result.value?.project?.workspace),
onConfirmAction
})
}
@@ -506,9 +506,6 @@ describe('Core GraphQL Subscriptions (New)', () => {
}
)
await meSubClient.waitForReadiness()
await meSubClient.waitForReadiness()
await meSubClient.waitForReadiness()
await meSubClient.waitForReadiness()
await addOrUpdateStreamCollaborator(
otherGuysProj.id,