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:
Chuck Driesler
2025-07-17 10:24:58 +01:00
committed by GitHub
parent 1465df5923
commit d2f2d7bcfd
39 changed files with 989 additions and 24 deletions
@@ -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
}>(),
@@ -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>
@@ -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>
@@ -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!
}
+2
View File
@@ -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'
+3 -1
View File
@@ -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,