feat(automate): allow function authors to regenerate function tokens (#5057)
* feat(automate): expose function regeneration endpoint * chore(automate): remember to call the function * fix(automate): use correct auth code action * fix(automate): token regenerate policy * fix(automate): expose function regen token policy * feat(automate): workspace automation settings tab * feat(automate): function token regeneration dialog * fix(automate): improve gql usage in vue components * chore(authz): tests for automate function policies * fix(automate): use paginated query * fix(automate): resolve initial result
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import { cleanFunctionLogo } from '~/lib/automate/helpers/functions'
|
||||
|
||||
type Size = 'base' | 'xs'
|
||||
type Size = 'base' | 'md' | 'xs'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -30,6 +30,9 @@ const classes = computed(() => {
|
||||
case 'xs':
|
||||
classParts.push('h-4 w-4')
|
||||
break
|
||||
case 'md':
|
||||
classParts.push('h-8 w-8')
|
||||
break
|
||||
case 'base':
|
||||
default:
|
||||
classParts.push('h-10 w-10')
|
||||
|
||||
@@ -33,21 +33,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormButton } from '@speckle/ui-components'
|
||||
|
||||
type FormButtonProps = InstanceType<typeof FormButton>['$props']
|
||||
|
||||
interface Button {
|
||||
label: string
|
||||
props: Record<string, unknown> & FormButtonProps
|
||||
onClick?: (e: MouseEvent) => void
|
||||
}
|
||||
import type { LayoutHeaderButton } from '@speckle/ui-components'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
text?: string
|
||||
buttons?: Button[]
|
||||
buttons?: LayoutHeaderButton[]
|
||||
subheading?: boolean
|
||||
hideDivider?: boolean
|
||||
}>(),
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<SettingsSectionHeader
|
||||
subheading
|
||||
title="Functions"
|
||||
:buttons="functionsSectionButtons"
|
||||
>
|
||||
<p class="text-body-xs text-foreground-2 mt-2">
|
||||
View and manage functions accessible only to projects in your workspace
|
||||
</p>
|
||||
</SettingsSectionHeader>
|
||||
<LayoutTable
|
||||
:columns="[
|
||||
{ id: 'name', header: 'Name', classes: 'col-span-3' },
|
||||
{ id: 'owner', header: 'Owner', classes: 'col-span-3' },
|
||||
{ id: 'id', header: 'ID', classes: 'col-span-5' },
|
||||
{ id: 'actions', header: '', classes: 'col-span-1 flex justify-end' }
|
||||
]"
|
||||
:items="workspaceFunctions"
|
||||
>
|
||||
<template #name="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<AutomateFunctionLogo size="md" :logo="item.logo" />
|
||||
<NuxtLink
|
||||
class="text-foreground-3 hover:text-foreground-2 underline"
|
||||
:to="automateFunctionRoute(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<template #owner="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
hide-tooltip
|
||||
:user="item.creator"
|
||||
light-style
|
||||
class="bg-foundation"
|
||||
no-bg
|
||||
/>
|
||||
<span class="truncate text-body-xs text-foreground">
|
||||
{{ item.creator?.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #id="{ item }">
|
||||
<div class="flex items-center text-foreground-2">
|
||||
{{ item.id }}
|
||||
<ClipboardIcon
|
||||
class="w-4 h-4 ml-2 cursor-pointer hover:text-foreground transition"
|
||||
@click="() => handleCopyText(item.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<SettingsWorkspacesAutomationFunctionsTableRowActions
|
||||
:workspace-function="item"
|
||||
:permissions="item.permissions"
|
||||
/>
|
||||
</template>
|
||||
</LayoutTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { SettingsWorkspacesAutomationFunctions_AutomateFunctionFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { automateFunctionRoute } from '~/lib/common/helpers/route'
|
||||
import { ClipboardIcon } from '@heroicons/vue/24/outline'
|
||||
import type { LayoutHeaderButton } from '@speckle/ui-components'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesAutomationFunctions_AutomateFunction on AutomateFunction {
|
||||
id
|
||||
name
|
||||
logo
|
||||
creator {
|
||||
id
|
||||
name
|
||||
avatar
|
||||
}
|
||||
...SettingsWorkspacesAutomationTableRowActions_AutomateFunction
|
||||
}
|
||||
`)
|
||||
|
||||
defineProps<{
|
||||
workspaceFunctions: SettingsWorkspacesAutomationFunctions_AutomateFunctionFragment[]
|
||||
}>()
|
||||
|
||||
const { copy } = useClipboard()
|
||||
|
||||
const functionsSectionButtons = computed<LayoutHeaderButton[]>(() => [
|
||||
{
|
||||
props: {
|
||||
color: 'outline',
|
||||
to: 'https://speckle.guide/automate/create-function.html',
|
||||
target: '_blank',
|
||||
external: true
|
||||
},
|
||||
label: 'Open docs'
|
||||
}
|
||||
])
|
||||
|
||||
const handleCopyText = (text: string) => {
|
||||
copy(text)
|
||||
}
|
||||
</script>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<LayoutDialog v-model:open="isOpen" max-width="sm" :buttons="dialogButtons">
|
||||
<template #header>Regenerate token</template>
|
||||
<div class="flex flex-col gap-2 text-body-xs text-foreground mb-2">
|
||||
<div v-if="!newToken">
|
||||
<p>Are you sure you want to regenerate this function's token?</p>
|
||||
<p>
|
||||
Existing token(s) for
|
||||
<strong>{{ workspaceFunction.name }}</strong>
|
||||
will be
|
||||
<strong>permanently</strong>
|
||||
invalidated.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4 text-foreground">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h6 class="font-medium">Your new token:</h6>
|
||||
<div class="w-full">
|
||||
<CommonClipboardInputWithToast :value="newToken" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-4 items-center bg-foundation-2 border border-outline-3 rounded-lg p-3 text-foreground-2 mb-2"
|
||||
>
|
||||
<div class="max-w-md text-body-2xs">
|
||||
<p>
|
||||
<span class="font-medium">Note:</span>
|
||||
This is the first and last time you will be able to see the full token.
|
||||
</p>
|
||||
<p class="font-medium">Please copy paste it somewhere safe now.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { regenerateFunctionTokenMutation } from '~/lib/automate/graphql/mutations'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunctionFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { getFirstErrorMessage } from '~/lib/common/helpers/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunction on AutomateFunction {
|
||||
id
|
||||
name
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
workspaceFunction: SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunctionFragment
|
||||
}>()
|
||||
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { mutate: regenerateToken, loading } = useMutation(
|
||||
regenerateFunctionTokenMutation
|
||||
)
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const newToken = ref<string>()
|
||||
|
||||
const handleRegenerateToken = async () => {
|
||||
const result = await regenerateToken({
|
||||
functionId: props.workspaceFunction.id
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
const token = result?.data?.automateMutations.regenerateFunctionToken
|
||||
|
||||
if (token) {
|
||||
newToken.value = token
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Token regenerated',
|
||||
description: 'A new token has been generated for your function.'
|
||||
})
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Failed to regenerate token',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline' },
|
||||
onClick: (): boolean => (isOpen.value = false)
|
||||
},
|
||||
{
|
||||
text: 'Regenerate',
|
||||
props: { color: 'danger' },
|
||||
disabled: loading.value || !!newToken.value,
|
||||
onClick: handleRegenerateToken
|
||||
}
|
||||
])
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) {
|
||||
newToken.value = undefined
|
||||
}
|
||||
})
|
||||
</script>
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<LayoutMenu
|
||||
v-model:open="isOpen"
|
||||
:items="actionItems"
|
||||
mount-menu-on-body
|
||||
:menu-position="HorizontalDirection.Left"
|
||||
@chosen="({ item }) => handleAction(item)"
|
||||
>
|
||||
<FormButton
|
||||
:color="isOpen ? 'outline' : 'subtle'"
|
||||
hide-text
|
||||
:icon-right="isOpen ? XMarkIcon : EllipsisHorizontalIcon"
|
||||
@click.stop="isOpen = true"
|
||||
/>
|
||||
<SettingsWorkspacesAutomationFunctionsRegenerateTokenDialog
|
||||
v-model:open="showRegenerateTokenDialog"
|
||||
:workspace-function="workspaceFunction"
|
||||
/>
|
||||
</LayoutMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LayoutMenuItem } from '@speckle/ui-components'
|
||||
import { HorizontalDirection } from '@speckle/ui-components'
|
||||
import { EllipsisHorizontalIcon, XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
import type { SettingsWorkspacesAutomationTableRowActions_AutomateFunctionFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesAutomationTableRowActions_AutomateFunction on AutomateFunction {
|
||||
id
|
||||
permissions {
|
||||
...SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecks
|
||||
}
|
||||
...SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunction
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecks on AutomateFunctionPermissionChecks {
|
||||
canRegenerateToken {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
workspaceFunction: SettingsWorkspacesAutomationTableRowActions_AutomateFunctionFragment
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const showRegenerateTokenDialog = ref(false)
|
||||
|
||||
enum ActionTypes {
|
||||
RegenerateToken = 'regenerate-token'
|
||||
}
|
||||
|
||||
const actionItems = computed<LayoutMenuItem[][]>(() => {
|
||||
return [
|
||||
[
|
||||
{
|
||||
title: 'Regenerate token...',
|
||||
id: ActionTypes.RegenerateToken,
|
||||
disabled: !props.workspaceFunction.permissions.canRegenerateToken.authorized,
|
||||
disabledTooltip: props.workspaceFunction.permissions.canRegenerateToken.message
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
|
||||
const handleAction = (actionItem: LayoutMenuItem) => {
|
||||
switch (actionItem.id) {
|
||||
case ActionTypes.RegenerateToken: {
|
||||
showRegenerateTokenDialog.value = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -22,3 +22,11 @@ export const updateAutomateFunctionMutation = graphql(`
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const regenerateFunctionTokenMutation = graphql(`
|
||||
mutation RegenerateFunctionToken($functionId: String!) {
|
||||
automateMutations {
|
||||
regenerateFunctionToken(functionId: $functionId)
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -129,6 +129,10 @@ type Documents = {
|
||||
"\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n": typeof types.SettingsWorkspaceGeneralDeleteDialog_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n }\n": typeof types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesGeneralEditSlugDialog_Workspace on Workspace {\n id\n name\n slug\n }\n": typeof types.SettingsWorkspacesGeneralEditSlugDialog_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesAutomationFunctions_AutomateFunction on AutomateFunction {\n id\n name\n logo\n creator {\n id\n name\n avatar\n }\n ...SettingsWorkspacesAutomationTableRowActions_AutomateFunction\n }\n": typeof types.SettingsWorkspacesAutomationFunctions_AutomateFunctionFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunction on AutomateFunction {\n id\n name\n }\n": typeof types.SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunctionFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesAutomationTableRowActions_AutomateFunction on AutomateFunction {\n id\n permissions {\n ...SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecks\n }\n ...SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunction\n }\n": typeof types.SettingsWorkspacesAutomationTableRowActions_AutomateFunctionFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecks on AutomateFunctionPermissionChecks {\n canRegenerateToken {\n ...FullPermissionCheckResult\n }\n }\n": typeof types.SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecksFragmentDoc,
|
||||
"\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n subscription {\n currency\n billingInterval\n }\n plan {\n name\n usage {\n projectCount\n modelCount\n }\n }\n ...BillingAlert_Workspace\n }\n": typeof types.WorkspaceBillingPage_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n inviteId\n role\n title\n updatedAt\n email\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n": typeof types.SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n": typeof types.SettingsWorkspacesMembersInvitesTable_WorkspaceFragmentDoc,
|
||||
@@ -200,6 +204,7 @@ type Documents = {
|
||||
"\n fragment SearchAutomateFunctionReleaseItem on AutomateFunctionRelease {\n id\n versionTag\n createdAt\n inputSchema\n }\n": typeof types.SearchAutomateFunctionReleaseItemFragmentDoc,
|
||||
"\n mutation CreateAutomateFunction($input: CreateAutomateFunctionInput!) {\n automateMutations {\n createFunction(input: $input) {\n id\n ...AutomationsFunctionsCard_AutomateFunction\n ...AutomateFunctionCreateDialogDoneStep_AutomateFunction\n }\n }\n }\n": typeof types.CreateAutomateFunctionDocument,
|
||||
"\n mutation UpdateAutomateFunction($input: UpdateAutomateFunctionInput!) {\n automateMutations {\n updateFunction(input: $input) {\n id\n ...AutomateFunctionPage_AutomateFunction\n }\n }\n }\n": typeof types.UpdateAutomateFunctionDocument,
|
||||
"\n mutation RegenerateFunctionToken($functionId: String!) {\n automateMutations {\n regenerateFunctionToken(functionId: $functionId)\n }\n }\n": typeof types.RegenerateFunctionTokenDocument,
|
||||
"\n query SearchAutomateFunctionReleases(\n $functionId: ID!\n $cursor: String\n $limit: Int\n $filter: AutomateFunctionReleasesFilter\n ) {\n automateFunction(id: $functionId) {\n id\n releases(cursor: $cursor, limit: $limit, filter: $filter) {\n cursor\n totalCount\n items {\n ...SearchAutomateFunctionReleaseItem\n }\n }\n }\n }\n": typeof types.SearchAutomateFunctionReleasesDocument,
|
||||
"\n query FunctionAccessCheck($id: ID!) {\n automateFunction(id: $id) {\n id\n }\n }\n": typeof types.FunctionAccessCheckDocument,
|
||||
"\n query ProjectAutomationCreationPublicKeys(\n $projectId: String!\n $automationId: String!\n ) {\n project(id: $projectId) {\n id\n automation(id: $automationId) {\n id\n creationPublicKeys\n }\n }\n }\n": typeof types.ProjectAutomationCreationPublicKeysDocument,
|
||||
@@ -367,6 +372,7 @@ type Documents = {
|
||||
"\n query SettingsWorkspacesInvitesSearch(\n $slug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam(filter: $invitesFilter) {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n }\n": typeof types.SettingsWorkspacesInvitesSearchDocument,
|
||||
"\n query SettingsWorkspacesProjects(\n $slug: String!\n $limit: Int!\n $cursor: String\n $filter: WorkspaceProjectsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesProjects_Workspace\n projects(limit: $limit, cursor: $cursor, filter: $filter) {\n cursor\n ...SettingsWorkspacesProjects_ProjectCollection\n }\n }\n }\n": typeof types.SettingsWorkspacesProjectsDocument,
|
||||
"\n query SettingsWorkspaceSecurity($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesSecurity_Workspace\n }\n }\n": typeof types.SettingsWorkspaceSecurityDocument,
|
||||
"\n query SettingsWorkspaceAutomation($slug: String!, $cursor: String = null) {\n workspaceBySlug(slug: $slug) {\n id\n automateFunctions(\n limit: 10\n cursor: $cursor\n filter: { includeFeatured: false }\n ) {\n items {\n ...SettingsWorkspacesAutomationFunctions_AutomateFunction\n }\n totalCount\n }\n }\n }\n": typeof types.SettingsWorkspaceAutomationDocument,
|
||||
"\n fragment AppAuthorAvatar on AppAuthor {\n id\n name\n avatar\n }\n": typeof types.AppAuthorAvatarFragmentDoc,
|
||||
"\n fragment LimitedUserAvatar on LimitedUser {\n id\n name\n avatar\n }\n": typeof types.LimitedUserAvatarFragmentDoc,
|
||||
"\n fragment ActiveUserAvatar on User {\n id\n name\n avatar\n }\n": typeof types.ActiveUserAvatarFragmentDoc,
|
||||
@@ -594,6 +600,10 @@ const documents: Documents = {
|
||||
"\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n": types.SettingsWorkspaceGeneralDeleteDialog_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n }\n": types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesGeneralEditSlugDialog_Workspace on Workspace {\n id\n name\n slug\n }\n": types.SettingsWorkspacesGeneralEditSlugDialog_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesAutomationFunctions_AutomateFunction on AutomateFunction {\n id\n name\n logo\n creator {\n id\n name\n avatar\n }\n ...SettingsWorkspacesAutomationTableRowActions_AutomateFunction\n }\n": types.SettingsWorkspacesAutomationFunctions_AutomateFunctionFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunction on AutomateFunction {\n id\n name\n }\n": types.SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunctionFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesAutomationTableRowActions_AutomateFunction on AutomateFunction {\n id\n permissions {\n ...SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecks\n }\n ...SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunction\n }\n": types.SettingsWorkspacesAutomationTableRowActions_AutomateFunctionFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecks on AutomateFunctionPermissionChecks {\n canRegenerateToken {\n ...FullPermissionCheckResult\n }\n }\n": types.SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecksFragmentDoc,
|
||||
"\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n subscription {\n currency\n billingInterval\n }\n plan {\n name\n usage {\n projectCount\n modelCount\n }\n }\n ...BillingAlert_Workspace\n }\n": types.WorkspaceBillingPage_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n inviteId\n role\n title\n updatedAt\n email\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_WorkspaceFragmentDoc,
|
||||
@@ -665,6 +675,7 @@ const documents: Documents = {
|
||||
"\n fragment SearchAutomateFunctionReleaseItem on AutomateFunctionRelease {\n id\n versionTag\n createdAt\n inputSchema\n }\n": types.SearchAutomateFunctionReleaseItemFragmentDoc,
|
||||
"\n mutation CreateAutomateFunction($input: CreateAutomateFunctionInput!) {\n automateMutations {\n createFunction(input: $input) {\n id\n ...AutomationsFunctionsCard_AutomateFunction\n ...AutomateFunctionCreateDialogDoneStep_AutomateFunction\n }\n }\n }\n": types.CreateAutomateFunctionDocument,
|
||||
"\n mutation UpdateAutomateFunction($input: UpdateAutomateFunctionInput!) {\n automateMutations {\n updateFunction(input: $input) {\n id\n ...AutomateFunctionPage_AutomateFunction\n }\n }\n }\n": types.UpdateAutomateFunctionDocument,
|
||||
"\n mutation RegenerateFunctionToken($functionId: String!) {\n automateMutations {\n regenerateFunctionToken(functionId: $functionId)\n }\n }\n": types.RegenerateFunctionTokenDocument,
|
||||
"\n query SearchAutomateFunctionReleases(\n $functionId: ID!\n $cursor: String\n $limit: Int\n $filter: AutomateFunctionReleasesFilter\n ) {\n automateFunction(id: $functionId) {\n id\n releases(cursor: $cursor, limit: $limit, filter: $filter) {\n cursor\n totalCount\n items {\n ...SearchAutomateFunctionReleaseItem\n }\n }\n }\n }\n": types.SearchAutomateFunctionReleasesDocument,
|
||||
"\n query FunctionAccessCheck($id: ID!) {\n automateFunction(id: $id) {\n id\n }\n }\n": types.FunctionAccessCheckDocument,
|
||||
"\n query ProjectAutomationCreationPublicKeys(\n $projectId: String!\n $automationId: String!\n ) {\n project(id: $projectId) {\n id\n automation(id: $automationId) {\n id\n creationPublicKeys\n }\n }\n }\n": types.ProjectAutomationCreationPublicKeysDocument,
|
||||
@@ -832,6 +843,7 @@ const documents: Documents = {
|
||||
"\n query SettingsWorkspacesInvitesSearch(\n $slug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam(filter: $invitesFilter) {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n }\n": types.SettingsWorkspacesInvitesSearchDocument,
|
||||
"\n query SettingsWorkspacesProjects(\n $slug: String!\n $limit: Int!\n $cursor: String\n $filter: WorkspaceProjectsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesProjects_Workspace\n projects(limit: $limit, cursor: $cursor, filter: $filter) {\n cursor\n ...SettingsWorkspacesProjects_ProjectCollection\n }\n }\n }\n": types.SettingsWorkspacesProjectsDocument,
|
||||
"\n query SettingsWorkspaceSecurity($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesSecurity_Workspace\n }\n }\n": types.SettingsWorkspaceSecurityDocument,
|
||||
"\n query SettingsWorkspaceAutomation($slug: String!, $cursor: String = null) {\n workspaceBySlug(slug: $slug) {\n id\n automateFunctions(\n limit: 10\n cursor: $cursor\n filter: { includeFeatured: false }\n ) {\n items {\n ...SettingsWorkspacesAutomationFunctions_AutomateFunction\n }\n totalCount\n }\n }\n }\n": types.SettingsWorkspaceAutomationDocument,
|
||||
"\n fragment AppAuthorAvatar on AppAuthor {\n id\n name\n avatar\n }\n": types.AppAuthorAvatarFragmentDoc,
|
||||
"\n fragment LimitedUserAvatar on LimitedUser {\n id\n name\n avatar\n }\n": types.LimitedUserAvatarFragmentDoc,
|
||||
"\n fragment ActiveUserAvatar on User {\n id\n name\n avatar\n }\n": types.ActiveUserAvatarFragmentDoc,
|
||||
@@ -1418,6 +1430,22 @@ export function graphql(source: "\n fragment SettingsWorkspacesGeneralEditAvata
|
||||
* 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 SettingsWorkspacesGeneralEditSlugDialog_Workspace on Workspace {\n id\n name\n slug\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesGeneralEditSlugDialog_Workspace on Workspace {\n id\n name\n slug\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 SettingsWorkspacesAutomationFunctions_AutomateFunction on AutomateFunction {\n id\n name\n logo\n creator {\n id\n name\n avatar\n }\n ...SettingsWorkspacesAutomationTableRowActions_AutomateFunction\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesAutomationFunctions_AutomateFunction on AutomateFunction {\n id\n name\n logo\n creator {\n id\n name\n avatar\n }\n ...SettingsWorkspacesAutomationTableRowActions_AutomateFunction\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 SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunction on AutomateFunction {\n id\n name\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunction on AutomateFunction {\n id\n name\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 SettingsWorkspacesAutomationTableRowActions_AutomateFunction on AutomateFunction {\n id\n permissions {\n ...SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecks\n }\n ...SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunction\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesAutomationTableRowActions_AutomateFunction on AutomateFunction {\n id\n permissions {\n ...SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecks\n }\n ...SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunction\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 SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecks on AutomateFunctionPermissionChecks {\n canRegenerateToken {\n ...FullPermissionCheckResult\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecks on AutomateFunctionPermissionChecks {\n canRegenerateToken {\n ...FullPermissionCheckResult\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1702,6 +1730,10 @@ export function graphql(source: "\n mutation CreateAutomateFunction($input: Cre
|
||||
* 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 UpdateAutomateFunction($input: UpdateAutomateFunctionInput!) {\n automateMutations {\n updateFunction(input: $input) {\n id\n ...AutomateFunctionPage_AutomateFunction\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateAutomateFunction($input: UpdateAutomateFunctionInput!) {\n automateMutations {\n updateFunction(input: $input) {\n id\n ...AutomateFunctionPage_AutomateFunction\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 mutation RegenerateFunctionToken($functionId: String!) {\n automateMutations {\n regenerateFunctionToken(functionId: $functionId)\n }\n }\n"): (typeof documents)["\n mutation RegenerateFunctionToken($functionId: String!) {\n automateMutations {\n regenerateFunctionToken(functionId: $functionId)\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -2370,6 +2402,10 @@ export function graphql(source: "\n query SettingsWorkspacesProjects(\n $slu
|
||||
* 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 SettingsWorkspaceSecurity($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesSecurity_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspaceSecurity($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesSecurity_Workspace\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 SettingsWorkspaceAutomation($slug: String!, $cursor: String = null) {\n workspaceBySlug(slug: $slug) {\n id\n automateFunctions(\n limit: 10\n cursor: $cursor\n filter: { includeFeatured: false }\n ) {\n items {\n ...SettingsWorkspacesAutomationFunctions_AutomateFunction\n }\n totalCount\n }\n }\n }\n"): (typeof documents)["\n query SettingsWorkspaceAutomation($slug: String!, $cursor: String = null) {\n workspaceBySlug(slug: $slug) {\n id\n automateFunctions(\n limit: 10\n cursor: $cursor\n filter: { includeFeatured: false }\n ) {\n items {\n ...SettingsWorkspacesAutomationFunctions_AutomateFunction\n }\n totalCount\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
@@ -63,6 +63,10 @@ export const settingsWorkspaceRoutes = {
|
||||
name: 'settings-workspaces-slug-projects',
|
||||
route: (slug?: string) => `/settings/workspaces/${slug}/projects`
|
||||
},
|
||||
automation: {
|
||||
name: 'settings-workspaces-slug-automation',
|
||||
route: (slug?: string) => `/settings/workspaces/${slug}/automation`
|
||||
},
|
||||
security: {
|
||||
name: 'settings-workspaces-slug-security',
|
||||
route: (slug?: string) => `/settings/workspaces/${slug}/security`
|
||||
|
||||
@@ -33,6 +33,7 @@ graphql(`
|
||||
`)
|
||||
|
||||
export const useSettingsMenu = () => {
|
||||
const isAutomateEnabled = useIsAutomateModuleEnabled()
|
||||
const isMultipleEmailsEnabled = useIsMultipleEmailsEnabled().value
|
||||
const isMultiRegionEnabled = useIsMultiregionEnabled()
|
||||
|
||||
@@ -55,6 +56,16 @@ export const useSettingsMenu = () => {
|
||||
route: (slug?: string) => settingsWorkspaceRoutes.projects.route(slug),
|
||||
permission: [Roles.Workspace.Admin, Roles.Workspace.Member]
|
||||
},
|
||||
...(isAutomateEnabled.value
|
||||
? [
|
||||
{
|
||||
title: 'Automation',
|
||||
name: settingsWorkspaceRoutes.automation.name,
|
||||
route: (slug?: string) => settingsWorkspaceRoutes.automation.route(slug),
|
||||
permission: [Roles.Workspace.Admin, Roles.Workspace.Member]
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: 'Security',
|
||||
name: settingsWorkspaceRoutes.security.name,
|
||||
|
||||
@@ -147,3 +147,21 @@ export const settingsWorkspacesSecurityQuery = graphql(`
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const settingsWorkspacesAutomationQuery = graphql(`
|
||||
query SettingsWorkspaceAutomation($slug: String!, $cursor: String = null) {
|
||||
workspaceBySlug(slug: $slug) {
|
||||
id
|
||||
automateFunctions(
|
||||
limit: 10
|
||||
cursor: $cursor
|
||||
filter: { includeFeatured: false }
|
||||
) {
|
||||
items {
|
||||
...SettingsWorkspacesAutomationFunctions_AutomateFunction
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader
|
||||
title="Automation"
|
||||
text="Manage workspace functions and project automations"
|
||||
/>
|
||||
<SettingsWorkspacesAutomationFunctions
|
||||
:workspace-functions="workspaceFunctions"
|
||||
/>
|
||||
<InfiniteLoading :settings="{ identifier }" @infinite="onInfiniteLoad" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
|
||||
import { settingsWorkspacesAutomationQuery } from '~/lib/settings/graphql/queries'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings | Workspace - Automation'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => (route.params.slug as string) || '')
|
||||
const isAutomateEnabled = useIsAutomateModuleEnabled()
|
||||
|
||||
const {
|
||||
identifier,
|
||||
onInfiniteLoad,
|
||||
query: { result }
|
||||
} = usePaginatedQuery({
|
||||
query: settingsWorkspacesAutomationQuery,
|
||||
baseVariables: computed(() => ({
|
||||
slug: slug.value,
|
||||
cursor: null as Nullable<string>
|
||||
})),
|
||||
options: () => ({
|
||||
enabled: isAutomateEnabled.value
|
||||
}),
|
||||
resolveCurrentResult: (res) => res?.workspaceBySlug?.automateFunctions,
|
||||
resolveInitialResult: () => ({
|
||||
items: [],
|
||||
cursor: undefined
|
||||
}),
|
||||
resolveNextPageVariables: (baseVars, cursor) => ({ ...baseVars, cursor }),
|
||||
resolveKey: (vars) => [vars.slug],
|
||||
resolveCursorFromVariables: (vars) => vars.cursor
|
||||
})
|
||||
|
||||
const workspaceFunctions = computed(
|
||||
() => result?.value?.workspaceBySlug.automateFunctions.items ?? []
|
||||
)
|
||||
</script>
|
||||
@@ -147,7 +147,7 @@ type AutomateFunction {
|
||||
Only returned if user is a part of this speckle server
|
||||
"""
|
||||
creator: LimitedUser
|
||||
workspaceIds: [String!]
|
||||
workspaceIds: [String!]!
|
||||
}
|
||||
|
||||
type AutomateFunctionToken {
|
||||
@@ -338,6 +338,8 @@ type AutomateMutations {
|
||||
@hasServerRole(role: SERVER_ADMIN)
|
||||
updateFunction(input: UpdateAutomateFunctionInput!): AutomateFunction!
|
||||
@hasScope(scope: "automate-functions:write")
|
||||
regenerateFunctionToken(functionId: String!): String!
|
||||
@hasScope(scope: "automate-functions:write")
|
||||
}
|
||||
|
||||
extend type Project {
|
||||
|
||||
@@ -11,3 +11,11 @@ type AutomationPermissionChecks {
|
||||
extend type ProjectPermissionChecks {
|
||||
canCreateAutomation: PermissionCheckResult!
|
||||
}
|
||||
|
||||
type AutomateFunctionPermissionChecks {
|
||||
canRegenerateToken: PermissionCheckResult!
|
||||
}
|
||||
|
||||
extend type AutomateFunction {
|
||||
permissions: AutomateFunctionPermissionChecks!
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ const config: CodegenConfig = {
|
||||
'@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn',
|
||||
CommentMutations:
|
||||
'@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn',
|
||||
AutomateFunctionPermissionChecks:
|
||||
'@/modules/automate/helpers/graphTypes#AutomateFunctionPermissionChecksGraphQLReturn',
|
||||
AutomateMutations:
|
||||
'@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn',
|
||||
AdminMutations:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { convertFunctionToGraphQLReturn } from '@/modules/automate/services/functionManagement'
|
||||
import { defineModuleLoaders } from '@/modules/loaders'
|
||||
|
||||
export default defineModuleLoaders(async () => {
|
||||
return {
|
||||
getAutomateFunction: async ({ functionId }, { dataLoaders }) => {
|
||||
const automateFunction = await dataLoaders.automationsApi.getFunction.load(
|
||||
functionId
|
||||
)
|
||||
return automateFunction ? convertFunctionToGraphQLReturn(automateFunction) : null
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -397,6 +397,21 @@ export const updateFunction = async (params: {
|
||||
})
|
||||
}
|
||||
|
||||
export const regenerateFunctionToken = async (params: {
|
||||
functionId: string
|
||||
authCode: AuthCodePayload
|
||||
}): Promise<{ token: string }> => {
|
||||
const { functionId, authCode } = params
|
||||
const url = getApiUrl(`/api/v2/functions/${functionId}/tokens/regenerate`)
|
||||
return await invokeJsonRequest<{ token: string }>({
|
||||
url,
|
||||
method: 'POST',
|
||||
body: {
|
||||
speckleServerAuthenticationPayload: addOrigin(authCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type GetFunctionResponse = FunctionWithVersionsSchemaType & {
|
||||
versionCount: number
|
||||
versionCursor: Nullable<string>
|
||||
|
||||
@@ -255,6 +255,13 @@ const mocks: SpeckleModuleMocksConfig = FF_AUTOMATE_MODULE_ENABLED
|
||||
}),
|
||||
releases: () => store.get('AutomateFunctionReleaseCollection') as any
|
||||
},
|
||||
AutomateFunctionPermissionChecks: {
|
||||
canRegenerateToken: () => ({
|
||||
authorized: faker.datatype.boolean(),
|
||||
code: faker.string.alphanumeric(10),
|
||||
message: faker.lorem.words(10)
|
||||
})
|
||||
},
|
||||
AutomateFunctionRelease: {
|
||||
function: () => store.get('AutomateFunction') as any
|
||||
},
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
getFunctionReleasesFactory,
|
||||
getUserGithubAuthState,
|
||||
getUserGithubOrganizations,
|
||||
getUserFunctionsFactory
|
||||
getUserFunctionsFactory,
|
||||
regenerateFunctionToken
|
||||
} from '@/modules/automate/clients/executionEngine'
|
||||
import {
|
||||
GetProjectAutomationsParams,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
convertFunctionReleaseToGraphQLReturn,
|
||||
convertFunctionToGraphQLReturn,
|
||||
createFunctionFromTemplateFactory,
|
||||
regenerateFunctionTokenFactory,
|
||||
updateFunctionFactory
|
||||
} from '@/modules/automate/services/functionManagement'
|
||||
import {
|
||||
@@ -630,6 +632,31 @@ export default (FF_AUTOMATE_MODULE_ENABLED
|
||||
operationDescription: 'Update an Automate function'
|
||||
}
|
||||
)
|
||||
},
|
||||
regenerateFunctionToken: async (_parent, args, context) => {
|
||||
const { functionId } = args
|
||||
|
||||
const authResult =
|
||||
await context.authPolicies.automate.function.canRegenerateToken({
|
||||
functionId,
|
||||
userId: context.userId
|
||||
})
|
||||
throwIfAuthNotOk(authResult)
|
||||
|
||||
const logger = context.log.child({
|
||||
functionId
|
||||
})
|
||||
|
||||
return await regenerateFunctionTokenFactory({
|
||||
regenerateFunctionToken,
|
||||
getFunction: getFunctionFactory({ logger }),
|
||||
createStoredAuthCode: createStoredAuthCodeFactory({
|
||||
redis: getGenericRedis()
|
||||
})
|
||||
})({
|
||||
functionId,
|
||||
userId: context.userId!
|
||||
})
|
||||
}
|
||||
},
|
||||
ProjectAutomationMutations: {
|
||||
|
||||
@@ -30,6 +30,19 @@ export default {
|
||||
return Authz.toGraphqlResult(canDeleteAutomation)
|
||||
}
|
||||
},
|
||||
AutomateFunction: {
|
||||
permissions: (parent) => ({ functionId: parent.id })
|
||||
},
|
||||
AutomateFunctionPermissionChecks: {
|
||||
canRegenerateToken: async (parent, _args, context) => {
|
||||
const authResult =
|
||||
await context.authPolicies.automate.function.canRegenerateToken({
|
||||
functionId: parent.functionId,
|
||||
userId: context.userId
|
||||
})
|
||||
return Authz.toGraphqlResult(authResult)
|
||||
}
|
||||
},
|
||||
ProjectPermissionChecks: {
|
||||
canCreateAutomation: async (parent, _args, context) => {
|
||||
const canCreateAutomation =
|
||||
|
||||
@@ -97,3 +97,4 @@ export type ProjectAutomationsUpdatedMessageGraphQLReturn = Merge<
|
||||
export type UserAutomateInfoGraphQLReturn = { userId: string }
|
||||
|
||||
export type AutomationPermissionChecksGraphQLReturn = { projectId: string }
|
||||
export type AutomateFunctionPermissionChecksGraphQLReturn = { functionId: string }
|
||||
|
||||
@@ -15,6 +15,7 @@ export enum AuthCodePayloadAction {
|
||||
ListUserFunctions = 'listUserFunctions',
|
||||
BecomeFunctionAuthor = 'becomeFunctionAuthor',
|
||||
GetAvailableGithubOrganizations = 'getAvailableGithubOrganizations',
|
||||
GenerateFunctionToken = 'generateFunctionToken',
|
||||
UpdateFunction = 'updateFunction'
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ExecutionEngineFunctionTemplateId,
|
||||
createFunction,
|
||||
getFunctionFactory,
|
||||
regenerateFunctionToken,
|
||||
updateFunction as updateExecEngineFunction
|
||||
} from '@/modules/automate/clients/executionEngine'
|
||||
import {
|
||||
@@ -107,7 +108,7 @@ export const convertFunctionToGraphQLReturn = (
|
||||
tags: fn.tags,
|
||||
supportedSourceApps: fn.supportedSourceApps,
|
||||
functionCreator,
|
||||
workspaceIds: fn.workspaceIds
|
||||
workspaceIds: fn.workspaceIds ?? []
|
||||
}
|
||||
|
||||
return ret
|
||||
@@ -185,7 +186,8 @@ export const createFunctionFromTemplateFactory =
|
||||
functionCreator: {
|
||||
speckleServerOrigin: getServerOrigin(),
|
||||
speckleUserId: user.id
|
||||
}
|
||||
},
|
||||
workspaceIds: []
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -290,3 +292,30 @@ export const handleAutomateFunctionCreatorAuthCallbackFactory =
|
||||
|
||||
return res.redirect(redirectUrl.toString())
|
||||
}
|
||||
|
||||
export const regenerateFunctionTokenFactory =
|
||||
(deps: {
|
||||
regenerateFunctionToken: typeof regenerateFunctionToken
|
||||
getFunction: ReturnType<typeof getFunctionFactory>
|
||||
createStoredAuthCode: CreateStoredAuthCode
|
||||
}) =>
|
||||
async (params: { functionId: string; userId: string }) => {
|
||||
const { functionId, userId } = params
|
||||
|
||||
const existingFunction = await deps.getFunction({ functionId })
|
||||
if (!existingFunction) {
|
||||
throw new AutomateFunctionUpdateError('Function not found')
|
||||
}
|
||||
|
||||
const authCode = await deps.createStoredAuthCode({
|
||||
userId,
|
||||
action: AuthCodePayloadAction.GenerateFunctionToken
|
||||
})
|
||||
|
||||
const res = await deps.regenerateFunctionToken({
|
||||
functionId,
|
||||
authCode
|
||||
})
|
||||
|
||||
return res.token
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { BaseError } from '@/modules/shared/errors/base'
|
||||
|
||||
export class AutomateModuleDisabledError extends BaseError {
|
||||
static defaultMessage = 'Automate is not enabled on this server'
|
||||
static code = 'AUTOMATE_MODULE_DISABLED_ERROR'
|
||||
static statusCode = 403
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql';
|
||||
import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, ObjectGraphQLReturn, VersionGraphQLReturn, ServerInviteGraphQLReturnType, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, MutationsObjectGraphQLReturn, LimitedUserGraphQLReturn, UserGraphQLReturn, EmbedTokenGraphQLReturn, GraphQLEmptyReturn, StreamCollaboratorGraphQLReturn, ProjectCollaboratorGraphQLReturn, ServerInfoGraphQLReturn, BranchGraphQLReturn, UserMetaGraphQLReturn, ProjectPermissionChecksGraphQLReturn, ModelPermissionChecksGraphQLReturn, VersionPermissionChecksGraphQLReturn, RootPermissionChecksGraphQLReturn } from '@/modules/core/helpers/graphTypes';
|
||||
import { StreamAccessRequestGraphQLReturn, ProjectAccessRequestGraphQLReturn } from '@/modules/accessrequests/helpers/graphTypes';
|
||||
import { AutomateFunctionPermissionChecksGraphQLReturn, AutomateFunctionGraphQLReturn, AutomateFunctionReleaseGraphQLReturn, AutomationGraphQLReturn, AutomationPermissionChecksGraphQLReturn, AutomationRevisionGraphQLReturn, AutomationRevisionFunctionGraphQLReturn, AutomateRunGraphQLReturn, AutomationRunTriggerGraphQLReturn, AutomationRevisionTriggerDefinitionGraphQLReturn, AutomateFunctionRunGraphQLReturn, TriggeredAutomationsStatusGraphQLReturn, ProjectAutomationMutationsGraphQLReturn, ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn, UserAutomateInfoGraphQLReturn } from '@/modules/automate/helpers/graphTypes';
|
||||
import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn, CommentPermissionChecksGraphQLReturn } from '@/modules/comments/helpers/graphTypes';
|
||||
import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/helpers/graphTypes';
|
||||
import { FileUploadGraphQLReturn } from '@/modules/fileuploads/helpers/types';
|
||||
import { AutomateFunctionGraphQLReturn, AutomateFunctionReleaseGraphQLReturn, AutomationGraphQLReturn, AutomationPermissionChecksGraphQLReturn, AutomationRevisionGraphQLReturn, AutomationRevisionFunctionGraphQLReturn, AutomateRunGraphQLReturn, AutomationRunTriggerGraphQLReturn, AutomationRevisionTriggerDefinitionGraphQLReturn, AutomateFunctionRunGraphQLReturn, TriggeredAutomationsStatusGraphQLReturn, ProjectAutomationMutationsGraphQLReturn, ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn, UserAutomateInfoGraphQLReturn } from '@/modules/automate/helpers/graphTypes';
|
||||
import { WorkspaceGraphQLReturn, WorkspaceSsoGraphQLReturn, WorkspaceMutationsGraphQLReturn, WorkspaceJoinRequestMutationsGraphQLReturn, WorkspaceInviteMutationsGraphQLReturn, WorkspaceProjectMutationsGraphQLReturn, PendingWorkspaceCollaboratorGraphQLReturn, WorkspaceCollaboratorGraphQLReturn, LimitedWorkspaceGraphQLReturn, LimitedWorkspaceCollaboratorGraphQLReturn, WorkspaceJoinRequestGraphQLReturn, LimitedWorkspaceJoinRequestGraphQLReturn, ProjectMoveToWorkspaceDryRunGraphQLReturn, ProjectRoleGraphQLReturn, WorkspacePermissionChecksGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes';
|
||||
import { WorkspacePlanGraphQLReturn, WorkspacePlanUsageGraphQLReturn, PriceGraphQLReturn } from '@/modules/gatekeeperCore/helpers/graphTypes';
|
||||
import { WorkspaceBillingMutationsGraphQLReturn, WorkspaceSubscriptionSeatsGraphQLReturn, WorkspaceSubscriptionGraphQLReturn } from '@/modules/gatekeeper/helpers/graphTypes';
|
||||
@@ -289,12 +289,13 @@ export type AutomateFunction = {
|
||||
isFeatured: Scalars['Boolean']['output'];
|
||||
logo?: Maybe<Scalars['String']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
permissions: AutomateFunctionPermissionChecks;
|
||||
releases: AutomateFunctionReleaseCollection;
|
||||
repo: BasicGitRepositoryMetadata;
|
||||
/** SourceAppNames values from @speckle/shared. Empty array means - all of them */
|
||||
supportedSourceApps: Array<Scalars['String']['output']>;
|
||||
tags: Array<Scalars['String']['output']>;
|
||||
workspaceIds?: Maybe<Array<Scalars['String']['output']>>;
|
||||
workspaceIds: Array<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
|
||||
@@ -311,6 +312,11 @@ export type AutomateFunctionCollection = {
|
||||
totalCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AutomateFunctionPermissionChecks = {
|
||||
__typename?: 'AutomateFunctionPermissionChecks';
|
||||
canRegenerateToken: PermissionCheckResult;
|
||||
};
|
||||
|
||||
export type AutomateFunctionRelease = {
|
||||
__typename?: 'AutomateFunctionRelease';
|
||||
commitId: Scalars['String']['output'];
|
||||
@@ -393,6 +399,7 @@ export type AutomateMutations = {
|
||||
__typename?: 'AutomateMutations';
|
||||
createFunction: AutomateFunction;
|
||||
createFunctionWithoutVersion: AutomateFunctionToken;
|
||||
regenerateFunctionToken: Scalars['String']['output'];
|
||||
updateFunction: AutomateFunction;
|
||||
};
|
||||
|
||||
@@ -407,6 +414,11 @@ export type AutomateMutationsCreateFunctionWithoutVersionArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type AutomateMutationsRegenerateFunctionTokenArgs = {
|
||||
functionId: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type AutomateMutationsUpdateFunctionArgs = {
|
||||
input: UpdateAutomateFunctionInput;
|
||||
};
|
||||
@@ -5429,6 +5441,7 @@ export type ResolversTypes = {
|
||||
AutomateAuthCodeResources: AutomateAuthCodeResources;
|
||||
AutomateFunction: ResolverTypeWrapper<AutomateFunctionGraphQLReturn>;
|
||||
AutomateFunctionCollection: ResolverTypeWrapper<Omit<AutomateFunctionCollection, 'items'> & { items: Array<ResolversTypes['AutomateFunction']> }>;
|
||||
AutomateFunctionPermissionChecks: ResolverTypeWrapper<AutomateFunctionPermissionChecksGraphQLReturn>;
|
||||
AutomateFunctionRelease: ResolverTypeWrapper<AutomateFunctionReleaseGraphQLReturn>;
|
||||
AutomateFunctionReleaseCollection: ResolverTypeWrapper<Omit<AutomateFunctionReleaseCollection, 'items'> & { items: Array<ResolversTypes['AutomateFunctionRelease']> }>;
|
||||
AutomateFunctionReleasesFilter: AutomateFunctionReleasesFilter;
|
||||
@@ -5781,6 +5794,7 @@ export type ResolversParentTypes = {
|
||||
AutomateAuthCodeResources: AutomateAuthCodeResources;
|
||||
AutomateFunction: AutomateFunctionGraphQLReturn;
|
||||
AutomateFunctionCollection: Omit<AutomateFunctionCollection, 'items'> & { items: Array<ResolversParentTypes['AutomateFunction']> };
|
||||
AutomateFunctionPermissionChecks: AutomateFunctionPermissionChecksGraphQLReturn;
|
||||
AutomateFunctionRelease: AutomateFunctionReleaseGraphQLReturn;
|
||||
AutomateFunctionReleaseCollection: Omit<AutomateFunctionReleaseCollection, 'items'> & { items: Array<ResolversParentTypes['AutomateFunctionRelease']> };
|
||||
AutomateFunctionReleasesFilter: AutomateFunctionReleasesFilter;
|
||||
@@ -6221,11 +6235,12 @@ export type AutomateFunctionResolvers<ContextType = GraphQLContext, ParentType e
|
||||
isFeatured?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
logo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
permissions?: Resolver<ResolversTypes['AutomateFunctionPermissionChecks'], ParentType, ContextType>;
|
||||
releases?: Resolver<ResolversTypes['AutomateFunctionReleaseCollection'], ParentType, ContextType, Partial<AutomateFunctionReleasesArgs>>;
|
||||
repo?: Resolver<ResolversTypes['BasicGitRepositoryMetadata'], ParentType, ContextType>;
|
||||
supportedSourceApps?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
tags?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
workspaceIds?: Resolver<Maybe<Array<ResolversTypes['String']>>, ParentType, ContextType>;
|
||||
workspaceIds?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
@@ -6236,6 +6251,11 @@ export type AutomateFunctionCollectionResolvers<ContextType = GraphQLContext, Pa
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type AutomateFunctionPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomateFunctionPermissionChecks'] = ResolversParentTypes['AutomateFunctionPermissionChecks']> = {
|
||||
canRegenerateToken?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type AutomateFunctionReleaseResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomateFunctionRelease'] = ResolversParentTypes['AutomateFunctionRelease']> = {
|
||||
commitId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
@@ -6286,6 +6306,7 @@ export type AutomateFunctionTokenResolvers<ContextType = GraphQLContext, ParentT
|
||||
export type AutomateMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomateMutations'] = ResolversParentTypes['AutomateMutations']> = {
|
||||
createFunction?: Resolver<ResolversTypes['AutomateFunction'], ParentType, ContextType, RequireFields<AutomateMutationsCreateFunctionArgs, 'input'>>;
|
||||
createFunctionWithoutVersion?: Resolver<ResolversTypes['AutomateFunctionToken'], ParentType, ContextType, RequireFields<AutomateMutationsCreateFunctionWithoutVersionArgs, 'input'>>;
|
||||
regenerateFunctionToken?: Resolver<ResolversTypes['String'], ParentType, ContextType, RequireFields<AutomateMutationsRegenerateFunctionTokenArgs, 'functionId'>>;
|
||||
updateFunction?: Resolver<ResolversTypes['AutomateFunction'], ParentType, ContextType, RequireFields<AutomateMutationsUpdateFunctionArgs, 'input'>>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
@@ -7979,6 +8000,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
|
||||
AuthStrategy?: AuthStrategyResolvers<ContextType>;
|
||||
AutomateFunction?: AutomateFunctionResolvers<ContextType>;
|
||||
AutomateFunctionCollection?: AutomateFunctionCollectionResolvers<ContextType>;
|
||||
AutomateFunctionPermissionChecks?: AutomateFunctionPermissionChecksResolvers<ContextType>;
|
||||
AutomateFunctionRelease?: AutomateFunctionReleaseResolvers<ContextType>;
|
||||
AutomateFunctionReleaseCollection?: AutomateFunctionReleaseCollectionResolvers<ContextType>;
|
||||
AutomateFunctionRun?: AutomateFunctionRunResolvers<ContextType>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AutomateModuleDisabledError } from '@/modules/core/errors/automate'
|
||||
import { StreamNotFoundError } from '@/modules/core/errors/stream'
|
||||
import { WorkspacesModuleDisabledError } from '@/modules/core/errors/workspaces'
|
||||
import {
|
||||
@@ -43,6 +44,7 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
|
||||
case Authz.ProjectNotEnoughPermissionsError.code:
|
||||
case Authz.WorkspacePlanNoFeatureAccessError.code:
|
||||
case Authz.EligibleForExclusiveWorkspaceError.code:
|
||||
case Authz.AutomateFunctionNotCreatorError.code:
|
||||
return new ForbiddenError(e.message)
|
||||
case Authz.WorkspaceSsoSessionNoAccessError.code:
|
||||
throw new SsoSessionMissingOrExpiredError(e.message, {
|
||||
@@ -56,12 +58,15 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
|
||||
return new ForbiddenError(e.message)
|
||||
case Authz.WorkspacesNotEnabledError.code:
|
||||
return new WorkspacesModuleDisabledError()
|
||||
case Authz.AutomateNotEnabledError.code:
|
||||
return new AutomateModuleDisabledError()
|
||||
case Authz.ProjectLastOwnerError.code:
|
||||
case Authz.ReservedModelNotDeletableError.code:
|
||||
return new BadRequestError(e.message)
|
||||
case Authz.CommentNotFoundError.code:
|
||||
case Authz.ModelNotFoundError.code:
|
||||
case Authz.VersionNotFoundError.code:
|
||||
case Authz.AutomateFunctionNotFoundError.code:
|
||||
return new NotFoundError(e.message)
|
||||
case Authz.PersonalProjectsLimitedError.code:
|
||||
return new BadRequestError(e.message)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineModuleLoaders } from '@/modules/loaders'
|
||||
import { LoaderUnsupportedError } from '@/modules/shared/errors'
|
||||
|
||||
export default defineModuleLoaders(() => ({
|
||||
getAutomateFunction: async () => null,
|
||||
getWorkspace: async () => {
|
||||
throw new LoaderUnsupportedError()
|
||||
},
|
||||
|
||||
@@ -181,6 +181,21 @@ export const VersionNotFoundError = defineAuthError({
|
||||
message: 'Version not found'
|
||||
})
|
||||
|
||||
export const AutomateNotEnabledError = defineAuthError({
|
||||
code: 'AutomateNotEnabled',
|
||||
message: 'Automate is not enabled on this server'
|
||||
})
|
||||
|
||||
export const AutomateFunctionNotFoundError = defineAuthError({
|
||||
code: 'AutomateFunctionNotFound',
|
||||
message: 'Function not found'
|
||||
})
|
||||
|
||||
export const AutomateFunctionNotCreatorError = defineAuthError({
|
||||
code: 'AutomateFunctionNotCreator',
|
||||
message: 'You are not the function creator and cannot make changes to it.'
|
||||
})
|
||||
|
||||
// Resolve all exported error types
|
||||
export type AllAuthErrors = ValueOf<{
|
||||
[key in keyof typeof import('./authErrors.js')]: typeof import('./authErrors.js')[key] extends new (
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AutomateFunction } from './types.js'
|
||||
|
||||
export type GetAutomateFunction = (args: {
|
||||
functionId: string
|
||||
}) => Promise<AutomateFunction | null>
|
||||
@@ -0,0 +1,9 @@
|
||||
export type AutomateFunction = {
|
||||
id: string
|
||||
name: string
|
||||
functionCreator: {
|
||||
speckleUserId: string
|
||||
speckleServerOrigin: string
|
||||
} | null
|
||||
workspaceIds: string[]
|
||||
}
|
||||
@@ -12,3 +12,5 @@ export type CommentContext = { commentId: string }
|
||||
export type ModelContext = { modelId: string }
|
||||
|
||||
export type VersionContext = { versionId: string }
|
||||
|
||||
export type AutomateFunctionContext = { functionId: string }
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
import { GetComment } from './comments/operations.js'
|
||||
import { GetModel } from './models/operations.js'
|
||||
import { GetVersion } from './versions/operations.js'
|
||||
import { GetAutomateFunction } from './automate/operations.js'
|
||||
|
||||
// utility type that ensures all properties functions that return promises
|
||||
type PromiseAll<T> = {
|
||||
@@ -55,6 +56,7 @@ type AuthContextLoaderMappingDefinition<
|
||||
/* v8 ignore start */
|
||||
export const AuthCheckContextLoaderKeys = <const>{
|
||||
getEnv: 'getEnv',
|
||||
getAutomateFunction: 'getAutomateFunction',
|
||||
getProject: 'getProject',
|
||||
getProjectRoleCounts: 'getProjectRoleCounts',
|
||||
getProjectRole: 'getProjectRole',
|
||||
@@ -85,6 +87,7 @@ export type AuthCheckContextLoaderKeys =
|
||||
export type AllAuthCheckContextLoaders = AuthContextLoaderMappingDefinition<{
|
||||
getEnv: GetEnv
|
||||
getAdminOverrideEnabled: GetAdminOverrideEnabled
|
||||
getAutomateFunction: GetAutomateFunction
|
||||
getProject: GetProject
|
||||
getProjectRole: GetProjectRole
|
||||
getProjectRoleCounts: GetProjectRoleCounts
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ensureAutomateEnabledFragment,
|
||||
ensureAutomateFunctionCreatorFragment
|
||||
} from './automate.js'
|
||||
import { OverridesOf } from '../../tests/helpers/types.js'
|
||||
import { parseFeatureFlags } from '../../environment/index.js'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import {
|
||||
AutomateFunctionNotCreatorError,
|
||||
AutomateFunctionNotFoundError,
|
||||
AutomateNotEnabledError
|
||||
} from '../domain/authErrors.js'
|
||||
|
||||
describe('ensureAutomateEnabledFragment', () => {
|
||||
const buildFragment = (
|
||||
overrides?: OverridesOf<typeof ensureAutomateEnabledFragment>
|
||||
) =>
|
||||
ensureAutomateEnabledFragment({
|
||||
getEnv: async () =>
|
||||
parseFeatureFlags({
|
||||
FF_AUTOMATE_MODULE_ENABLED: 'true'
|
||||
}),
|
||||
...overrides
|
||||
})
|
||||
|
||||
it('returns ok when automate is enabled', async () => {
|
||||
const result = await buildFragment()({})
|
||||
expect(result).toBeAuthOKResult()
|
||||
})
|
||||
|
||||
it('returns err when automate is disabled', async () => {
|
||||
const result = await buildFragment({
|
||||
getEnv: async () =>
|
||||
parseFeatureFlags({
|
||||
FF_AUTOMATE_MODULE_ENABLED: 'false'
|
||||
})
|
||||
})({})
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: AutomateNotEnabledError.code
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureAutomateFunctionCreatorFragment', () => {
|
||||
const buildFragment = (
|
||||
overrides?: OverridesOf<typeof ensureAutomateFunctionCreatorFragment>
|
||||
) =>
|
||||
ensureAutomateFunctionCreatorFragment({
|
||||
getAutomateFunction: async ({ functionId }) => {
|
||||
return {
|
||||
id: functionId,
|
||||
name: cryptoRandomString({ length: 9 }),
|
||||
functionCreator: {
|
||||
speckleUserId: 'foo',
|
||||
speckleServerOrigin: 'example.org'
|
||||
},
|
||||
workspaceIds: []
|
||||
}
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
|
||||
it('returns ok when user is function creator', async () => {
|
||||
const userId = cryptoRandomString({ length: 9 })
|
||||
|
||||
const result = await buildFragment({
|
||||
getAutomateFunction: async ({ functionId }) => {
|
||||
return {
|
||||
id: functionId,
|
||||
name: cryptoRandomString({ length: 9 }),
|
||||
functionCreator: {
|
||||
speckleUserId: userId,
|
||||
speckleServerOrigin: 'example.org'
|
||||
},
|
||||
workspaceIds: []
|
||||
}
|
||||
}
|
||||
})({
|
||||
userId,
|
||||
functionId: cryptoRandomString({ length: 9 })
|
||||
})
|
||||
|
||||
expect(result).toBeAuthOKResult()
|
||||
})
|
||||
|
||||
it('return err if function is not found', async () => {
|
||||
const result = await buildFragment({
|
||||
getAutomateFunction: async () => null
|
||||
})({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
functionId: cryptoRandomString({ length: 9 })
|
||||
})
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: AutomateFunctionNotFoundError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('returns err if user is not function creator', async () => {
|
||||
const result = await buildFragment({
|
||||
getAutomateFunction: async ({ functionId }) => {
|
||||
return {
|
||||
id: functionId,
|
||||
name: cryptoRandomString({ length: 9 }),
|
||||
functionCreator: {
|
||||
speckleUserId: cryptoRandomString({ length: 9 }),
|
||||
speckleServerOrigin: 'example.org'
|
||||
},
|
||||
workspaceIds: []
|
||||
}
|
||||
}
|
||||
})({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
functionId: cryptoRandomString({ length: 9 })
|
||||
})
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: AutomateFunctionNotCreatorError.code
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { err, ok } from 'true-myth/result'
|
||||
import {
|
||||
AutomateFunctionNotCreatorError,
|
||||
AutomateFunctionNotFoundError,
|
||||
AutomateNotEnabledError
|
||||
} from '../domain/authErrors.js'
|
||||
import { Loaders } from '../domain/loaders.js'
|
||||
import { AuthPolicyEnsureFragment } from '../domain/policies.js'
|
||||
import { AutomateFunctionContext, MaybeUserContext } from '../domain/context.js'
|
||||
|
||||
export const ensureAutomateEnabledFragment: AuthPolicyEnsureFragment<
|
||||
typeof Loaders.getEnv,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
{},
|
||||
InstanceType<typeof AutomateNotEnabledError>
|
||||
> = (loaders) => async () => {
|
||||
const env = await loaders.getEnv()
|
||||
if (!env.FF_AUTOMATE_MODULE_ENABLED) return err(new AutomateNotEnabledError())
|
||||
return ok()
|
||||
}
|
||||
|
||||
export const ensureAutomateFunctionCreatorFragment: AuthPolicyEnsureFragment<
|
||||
typeof Loaders.getAutomateFunction,
|
||||
MaybeUserContext & AutomateFunctionContext,
|
||||
InstanceType<
|
||||
typeof AutomateFunctionNotFoundError | typeof AutomateFunctionNotCreatorError
|
||||
>
|
||||
> =
|
||||
(loaders) =>
|
||||
async ({ userId, functionId }) => {
|
||||
const automateFunction = await loaders.getAutomateFunction({ functionId })
|
||||
if (!automateFunction) return err(new AutomateFunctionNotFoundError())
|
||||
if (!userId || automateFunction.functionCreator?.speckleUserId !== userId)
|
||||
return err(new AutomateFunctionNotCreatorError())
|
||||
return ok()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canEditFunctionPolicy } from './canEditFunction.js'
|
||||
import { OverridesOf } from '../../../../tests/helpers/types.js'
|
||||
import { parseFeatureFlags } from '../../../../environment/index.js'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import {
|
||||
AutomateFunctionNotCreatorError,
|
||||
AutomateFunctionNotFoundError,
|
||||
AutomateNotEnabledError
|
||||
} from '../../../domain/authErrors.js'
|
||||
|
||||
describe('canEditFunctionPolicy creates a function, that', () => {
|
||||
const buildCanEditFunctionPolicy = (
|
||||
overrides?: OverridesOf<typeof canEditFunctionPolicy>
|
||||
) =>
|
||||
canEditFunctionPolicy({
|
||||
getEnv: async () =>
|
||||
parseFeatureFlags({
|
||||
FF_AUTOMATE_MODULE_ENABLED: 'true'
|
||||
}),
|
||||
getAutomateFunction: async () => null,
|
||||
...overrides
|
||||
})
|
||||
|
||||
it('forbids edit if automate is not enabled', async () => {
|
||||
const result = await buildCanEditFunctionPolicy({
|
||||
getEnv: async () =>
|
||||
parseFeatureFlags({
|
||||
FF_AUTOMATE_MODULE_ENABLED: 'false'
|
||||
})
|
||||
})({
|
||||
functionId: cryptoRandomString({ length: 9 })
|
||||
})
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: AutomateNotEnabledError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('forbids edit if function cannot be found', async () => {
|
||||
const result = await buildCanEditFunctionPolicy()({
|
||||
functionId: cryptoRandomString({ length: 9 })
|
||||
})
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: AutomateFunctionNotFoundError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('forbids edit if user is not function creator', async () => {
|
||||
const result = await buildCanEditFunctionPolicy({
|
||||
getAutomateFunction: async ({ functionId }) => ({
|
||||
id: functionId,
|
||||
name: cryptoRandomString({ length: 9 }),
|
||||
functionCreator: {
|
||||
speckleUserId: cryptoRandomString({ length: 9 }),
|
||||
speckleServerOrigin: 'example.org'
|
||||
},
|
||||
workspaceIds: []
|
||||
})
|
||||
})({
|
||||
functionId: cryptoRandomString({ length: 9 })
|
||||
})
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: AutomateFunctionNotCreatorError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('allows edit for function creators', async () => {
|
||||
const userId = cryptoRandomString({ length: 9 })
|
||||
|
||||
const result = await buildCanEditFunctionPolicy({
|
||||
getAutomateFunction: async ({ functionId }) => ({
|
||||
id: functionId,
|
||||
name: cryptoRandomString({ length: 9 }),
|
||||
functionCreator: {
|
||||
speckleUserId: userId,
|
||||
speckleServerOrigin: 'example.org'
|
||||
},
|
||||
workspaceIds: []
|
||||
})
|
||||
})({
|
||||
userId,
|
||||
functionId: cryptoRandomString({ length: 9 })
|
||||
})
|
||||
|
||||
expect(result).toBeAuthOKResult()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { err, ok } from 'true-myth/result'
|
||||
import {
|
||||
AutomateFunctionNotCreatorError,
|
||||
AutomateFunctionNotFoundError,
|
||||
AutomateNotEnabledError
|
||||
} from '../../../domain/authErrors.js'
|
||||
import { AutomateFunctionContext, MaybeUserContext } from '../../../domain/context.js'
|
||||
import { AuthCheckContextLoaderKeys } from '../../../domain/loaders.js'
|
||||
import { AuthPolicy } from '../../../domain/policies.js'
|
||||
import {
|
||||
ensureAutomateEnabledFragment,
|
||||
ensureAutomateFunctionCreatorFragment
|
||||
} from '../../../fragments/automate.js'
|
||||
|
||||
type PolicyLoaderKeys =
|
||||
| typeof AuthCheckContextLoaderKeys.getEnv
|
||||
| typeof AuthCheckContextLoaderKeys.getAutomateFunction
|
||||
|
||||
type PolicyArgs = MaybeUserContext & AutomateFunctionContext
|
||||
|
||||
type PolicyErrors = InstanceType<
|
||||
| typeof AutomateNotEnabledError
|
||||
| typeof AutomateFunctionNotFoundError
|
||||
| typeof AutomateFunctionNotCreatorError
|
||||
>
|
||||
|
||||
export const canEditFunctionPolicy: AuthPolicy<
|
||||
PolicyLoaderKeys,
|
||||
PolicyArgs,
|
||||
PolicyErrors
|
||||
> =
|
||||
(loaders) =>
|
||||
async ({ userId, functionId }) => {
|
||||
const isAutomateEnabled = await ensureAutomateEnabledFragment(loaders)({})
|
||||
if (isAutomateEnabled.isErr) return err(isAutomateEnabled.error)
|
||||
|
||||
const isAutomateFunctionCreator = await ensureAutomateFunctionCreatorFragment(
|
||||
loaders
|
||||
)({
|
||||
userId,
|
||||
functionId
|
||||
})
|
||||
if (isAutomateFunctionCreator.isErr) return err(isAutomateFunctionCreator.error)
|
||||
|
||||
return ok()
|
||||
}
|
||||
@@ -31,9 +31,15 @@ import { canLoadPolicy } from './project/canLoad.js'
|
||||
import { canReadMemberEmailPolicy } from './workspace/canReadMemberEmail.js'
|
||||
import { canCreateWorkspacePolicy } from './workspace/canCreateWorkspace.js'
|
||||
import { canUseWorkspacePlanFeature } from './workspace/canUseWorkspacePlanFeature.js'
|
||||
import { canEditFunctionPolicy } from './automate/function/canEditFunction.js'
|
||||
import { canUpdateEmbedTokensPolicy } from './project/canUpdateEmbedTokens.js'
|
||||
|
||||
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
|
||||
automate: {
|
||||
function: {
|
||||
canRegenerateToken: canEditFunctionPolicy(loaders)
|
||||
}
|
||||
},
|
||||
project: {
|
||||
automation: {
|
||||
canCreate: canCreateAutomationPolicy(loaders),
|
||||
|
||||
@@ -48,4 +48,10 @@ export type LayoutDialogButton = {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export type LayoutHeaderButton = {
|
||||
label: string
|
||||
props: Record<string, unknown> & FormButtonProps
|
||||
onClick?: (e: MouseEvent) => void
|
||||
}
|
||||
|
||||
export type LayoutTableColours = 'primary' | 'outline' | 'subtle' | 'danger'
|
||||
|
||||
@@ -50,7 +50,8 @@ import LayoutDisclosure from '~~/src/components/layout/Disclosure.vue'
|
||||
import LayoutGridListToggle from '~~/src/components/layout/GridListToggle.vue'
|
||||
import type {
|
||||
LayoutPageTabItem,
|
||||
LayoutDialogButton
|
||||
LayoutDialogButton,
|
||||
LayoutHeaderButton
|
||||
} from '~~/src/helpers/layout/components'
|
||||
import { GridListToggleValue } from '~~/src/helpers/layout/components'
|
||||
import {
|
||||
@@ -188,6 +189,7 @@ export {
|
||||
}
|
||||
export type {
|
||||
LayoutDialogButton,
|
||||
LayoutHeaderButton,
|
||||
ToastNotification,
|
||||
BulletStepType,
|
||||
NumberStepType,
|
||||
|
||||
Reference in New Issue
Block a user