Skip to content

Commit e8b5cc5

Browse files
committed
feat(frontend): add webhook trigger node
Add a "Webhook Trigger" entry (trigger-webhook) under the Triggers category in nodeTypes.ts, matching the new backend trigger type. The NDV's trigger type select gains a Webhook option. Once a workflow has been saved (same "need a saved workflow id first" constraint the manual run/executions actions already have), selecting it fetches and displays the generated webhook URL, masked by default with a Reveal toggle, a one-click Copy-to-clipboard action, and a Rotate action that replaces the token — mirroring the connect/disconnect interaction style of the existing automation affordance in WorkflowList.vue. Before saving, it shows a hint to save first instead. useWorkflowsApi gains getWebhookToken/rotateWebhookToken, which call the new GET/POST /me/workflows/{id}/webhook-token(/rotate) endpoints and combine the returned path with the backend's own non-API base URL (stripping the /api/v1beta1 suffix) to build the full webhook URL, since the webhook route itself lives outside /api/v1beta1. Signed-off-by: Lukas Hirt <info@hirt.cz>
1 parent 8fc9c96 commit e8b5cc5

9 files changed

Lines changed: 361 additions & 11 deletions

File tree

backend/pkg/service/workflows_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
4949

5050
func TestSyncTriggerIndexGeneratesWebhookTokenOnFirstSave(t *testing.T) {
5151
idx := newFakeTriggerIndexer()
52-
h := NewWorkflowsHandler(nil, nil, &fakeUserResolver{userID: "user-1"}, idx, discardLogger())
52+
h := NewWorkflowsHandler(nil, nil, &fakeUserResolver{userID: "user-1"}, idx, nil, discardLogger())
5353

5454
wf := model.WorkflowDefinition{ID: "wf-1", Enabled: true, Trigger: model.WorkflowTrigger{Type: "webhook"}}
5555
h.syncTriggerIndex(t.Context(), "Bearer x", wf)
@@ -71,7 +71,7 @@ func TestSyncTriggerIndexGeneratesWebhookTokenOnFirstSave(t *testing.T) {
7171

7272
func TestSyncTriggerIndexPreservesWebhookTokenAcrossUpdates(t *testing.T) {
7373
idx := newFakeTriggerIndexer()
74-
h := NewWorkflowsHandler(nil, nil, &fakeUserResolver{userID: "user-1"}, idx, discardLogger())
74+
h := NewWorkflowsHandler(nil, nil, &fakeUserResolver{userID: "user-1"}, idx, nil, discardLogger())
7575

7676
wf := model.WorkflowDefinition{ID: "wf-1", Enabled: true, Trigger: model.WorkflowTrigger{Type: "webhook"}}
7777
h.syncTriggerIndex(t.Context(), "Bearer x", wf)
@@ -90,7 +90,7 @@ func TestSyncTriggerIndexPreservesWebhookTokenAcrossUpdates(t *testing.T) {
9090

9191
func TestSyncTriggerIndexKeepsWebhookTokenWhileDisabled(t *testing.T) {
9292
idx := newFakeTriggerIndexer()
93-
h := NewWorkflowsHandler(nil, nil, &fakeUserResolver{userID: "user-1"}, idx, discardLogger())
93+
h := NewWorkflowsHandler(nil, nil, &fakeUserResolver{userID: "user-1"}, idx, nil, discardLogger())
9494

9595
enabled := model.WorkflowDefinition{ID: "wf-1", Enabled: true, Trigger: model.WorkflowTrigger{Type: "webhook"}}
9696
h.syncTriggerIndex(t.Context(), "Bearer x", enabled)
@@ -117,7 +117,7 @@ func TestSyncTriggerIndexKeepsWebhookTokenWhileDisabled(t *testing.T) {
117117

118118
func TestSyncTriggerIndexDeletesEntryWhenTriggerTypeChangesAway(t *testing.T) {
119119
idx := newFakeTriggerIndexer()
120-
h := NewWorkflowsHandler(nil, nil, &fakeUserResolver{userID: "user-1"}, idx, discardLogger())
120+
h := NewWorkflowsHandler(nil, nil, &fakeUserResolver{userID: "user-1"}, idx, nil, discardLogger())
121121

122122
webhook := model.WorkflowDefinition{ID: "wf-1", Enabled: true, Trigger: model.WorkflowTrigger{Type: "webhook"}}
123123
h.syncTriggerIndex(t.Context(), "Bearer x", webhook)
@@ -133,7 +133,7 @@ func TestSyncTriggerIndexDeletesEntryWhenTriggerTypeChangesAway(t *testing.T) {
133133

134134
func TestSyncTriggerIndexScheduleStillDeletedWhenDisabled(t *testing.T) {
135135
idx := newFakeTriggerIndexer()
136-
h := NewWorkflowsHandler(nil, nil, &fakeUserResolver{userID: "user-1"}, idx, discardLogger())
136+
h := NewWorkflowsHandler(nil, nil, &fakeUserResolver{userID: "user-1"}, idx, nil, discardLogger())
137137

138138
wf := model.WorkflowDefinition{ID: "wf-1", Enabled: true, Trigger: model.WorkflowTrigger{Type: "schedule", Schedule: "0 * * * *"}}
139139
h.syncTriggerIndex(t.Context(), "Bearer x", wf)

frontend/src/components/NodeDetailsPanel.vue

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<option value="manual">{{ $gettext('Manual') }}</option>
1919
<option value="schedule">{{ $gettext('Schedule') }}</option>
2020
<option value="event">{{ $gettext('File event') }}</option>
21+
<option value="webhook">{{ $gettext('Webhook') }}</option>
2122
</select>
2223

2324
<template v-if="triggerType === 'schedule'">
@@ -44,6 +45,50 @@
4445
placeholder="/Invoices"
4546
/>
4647
</template>
48+
49+
<template v-if="triggerType === 'webhook'">
50+
<!-- Same "you need a saved workflow id first" problem the manual run/executions
51+
actions already have (see WorkflowBuilder's isNew()/currentId()) — the
52+
webhook URL is per-workflow-id, so it can't exist before the first save. -->
53+
<p v-if="!isWorkflowSaved" class="workflows-ndv-webhook-hint">
54+
{{ $gettext('Save the workflow to generate its webhook URL.') }}
55+
</p>
56+
<template v-else>
57+
<label class="workflows-ndv-label" for="ndv-webhook-url">{{ $gettext('Webhook URL') }}</label>
58+
<div class="workflows-ndv-webhook-row">
59+
<code id="ndv-webhook-url" class="workflows-ndv-webhook-value" data-test="webhook-url">
60+
{{ webhookRevealed && webhookInfo ? webhookInfo.url : webhookMaskedValue }}
61+
</code>
62+
<oc-button
63+
appearance="raw"
64+
:disabled="webhookLoading"
65+
data-test="webhook-reveal"
66+
@click="toggleWebhookRevealed"
67+
>
68+
{{ webhookRevealed ? $gettext('Hide') : $gettext('Reveal') }}
69+
</oc-button>
70+
<oc-button appearance="raw" :disabled="webhookLoading" data-test="webhook-copy" @click="copyWebhookUrl">
71+
{{ webhookCopied ? $gettext('Copied!') : $gettext('Copy') }}
72+
</oc-button>
73+
</div>
74+
<p class="workflows-ndv-description">
75+
{{
76+
$gettext(
77+
'POST a request here (a JSON object body is optional) to run this workflow. The body is exposed to the graph as vars["webhook.body"], plus vars["webhook.body.<key>"] for each top-level JSON key.'
78+
)
79+
}}
80+
</p>
81+
<oc-button
82+
appearance="outline"
83+
:disabled="webhookLoading"
84+
data-test="webhook-rotate"
85+
@click="rotateWebhookTokenNow"
86+
>
87+
{{ $gettext('Rotate token') }}
88+
</oc-button>
89+
<p v-if="webhookError" class="oc-text-input-danger">{{ webhookError }}</p>
90+
</template>
91+
</template>
4792
</template>
4893

4994
<template v-else-if="node.type === 'llm'">
@@ -139,14 +184,16 @@
139184
</template>
140185

141186
<script lang="ts" setup>
142-
import { computed } from 'vue'
187+
import { computed, ref, watch } from 'vue'
143188
import { useGettext } from 'vue3-gettext'
144189
import { findNodeTypeForNode } from '../nodeTypes'
145-
import type { EventTriggerType, WorkflowNode, WorkflowNodeData } from '../types/workflow'
190+
import { useWorkflowsApi } from '../composables/useWorkflowsApi'
191+
import type { EventTriggerType, WebhookTokenInfo, WorkflowNode, WorkflowNodeData } from '../types/workflow'
146192
147-
const props = defineProps<{ node: WorkflowNode }>()
193+
const props = defineProps<{ node: WorkflowNode; workflowId?: string; backendUrl?: string }>()
148194
const emit = defineEmits<{ (e: 'update', data: WorkflowNodeData): void; (e: 'close'): void }>()
149195
const { $gettext } = useGettext()
196+
const api = useWorkflowsApi(props.backendUrl ?? '')
150197
151198
const nodeType = computed(() => findNodeTypeForNode(props.node.type, props.node.data.actionType))
152199
@@ -192,6 +239,83 @@ const paramDestination = actionParam('destination')
192239
const paramNewName = actionParam('newName')
193240
const paramTarget = actionParam('target')
194241
const paramMessage = actionParam('message')
242+
243+
// Webhook trigger: URL/token reveal + rotate. Same "need a saved workflow id first"
244+
// constraint as the manual run/executions actions already have — a webhook URL is
245+
// per-workflow-id, so there's nothing to show until the workflow has been saved at least
246+
// once (see WorkflowBuilder's isNew()/currentId()).
247+
const isWorkflowSaved = computed(() => !!props.workflowId && props.workflowId !== 'new')
248+
const webhookMaskedValue = '••••••••••••••••••••••••••••••••'
249+
250+
const webhookInfo = ref<WebhookTokenInfo | null>(null)
251+
const webhookRevealed = ref(false)
252+
const webhookLoading = ref(false)
253+
const webhookError = ref('')
254+
const webhookCopied = ref(false)
255+
256+
const loadWebhookToken = async () => {
257+
if (!isWorkflowSaved.value) {
258+
return
259+
}
260+
webhookLoading.value = true
261+
webhookError.value = ''
262+
try {
263+
webhookInfo.value = await api.getWebhookToken(props.workflowId!)
264+
} catch (e) {
265+
webhookError.value = e instanceof Error ? e.message : String(e)
266+
} finally {
267+
webhookLoading.value = false
268+
}
269+
}
270+
271+
const toggleWebhookRevealed = async () => {
272+
if (!webhookInfo.value) {
273+
await loadWebhookToken()
274+
}
275+
webhookRevealed.value = !webhookRevealed.value
276+
}
277+
278+
const copyWebhookUrl = async () => {
279+
if (!webhookInfo.value) {
280+
await loadWebhookToken()
281+
}
282+
if (!webhookInfo.value) {
283+
return
284+
}
285+
await navigator.clipboard.writeText(webhookInfo.value.url)
286+
webhookCopied.value = true
287+
setTimeout(() => {
288+
webhookCopied.value = false
289+
}, 2000)
290+
}
291+
292+
const rotateWebhookTokenNow = async () => {
293+
webhookLoading.value = true
294+
webhookError.value = ''
295+
try {
296+
webhookInfo.value = await api.rotateWebhookToken(props.workflowId!)
297+
webhookRevealed.value = true
298+
} catch (e) {
299+
webhookError.value = e instanceof Error ? e.message : String(e)
300+
} finally {
301+
webhookLoading.value = false
302+
}
303+
}
304+
305+
// Fetch eagerly (but keep it masked until "Reveal" is clicked) whenever the panel opens on
306+
// a saved workflow's webhook trigger — mirrors ExecutionsPanel's onMounted(load).
307+
watch(
308+
() => [props.node.id, triggerType.value, isWorkflowSaved.value] as const,
309+
([, type, saved]) => {
310+
webhookInfo.value = null
311+
webhookRevealed.value = false
312+
webhookError.value = ''
313+
if (type === 'webhook' && saved) {
314+
loadWebhookToken()
315+
}
316+
},
317+
{ immediate: true }
318+
)
195319
</script>
196320

197321
<style scoped>
@@ -251,4 +375,21 @@ const paramMessage = actionParam('message')
251375
padding-top: 1rem;
252376
border-top: 1px solid var(--oc-color-border, rgba(0, 0, 0, 0.1));
253377
}
378+
.workflows-ndv-webhook-hint {
379+
opacity: 0.7;
380+
}
381+
.workflows-ndv-webhook-row {
382+
display: flex;
383+
align-items: center;
384+
gap: 0.5rem;
385+
}
386+
.workflows-ndv-webhook-value {
387+
flex: 1;
388+
overflow-x: auto;
389+
white-space: nowrap;
390+
padding: 0.4rem 0.6rem;
391+
background: var(--oc-color-background-muted, #f5f5f5);
392+
border-radius: 4px;
393+
font-size: 0.85rem;
394+
}
254395
</style>

frontend/src/composables/useWorkflowsApi.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
GraphCollection,
66
GraphError,
77
NewWorkflowDefinition,
8+
WebhookTokenInfo,
89
WorkflowDefinition
910
} from '../types/workflow'
1011

@@ -23,6 +24,11 @@ export class WorkflowsApiError extends Error {
2324
export function useWorkflowsApi(backendUrl: string) {
2425
const authStore = useAuthStore()
2526
const base = backendUrl.replace(/\/$/, '')
27+
// The webhook trigger's own POST /hooks/{workflowId}/{token} route lives outside
28+
// /api/v1beta1 (see backend/pkg/server/http/server.go) — it's reached through the same
29+
// reverse-proxy prefix as everything else in this app (.../workflows/...), just without
30+
// the /api/v1beta1 suffix every other request in this file uses.
31+
const hooksBase = base.replace(/\/api\/v1beta1$/, '')
2632

2733
const buildHeaders = (): Record<string, string> => {
2834
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
@@ -113,6 +119,28 @@ export function useWorkflowsApi(backendUrl: string) {
113119
`/me/workflows/${encodeURIComponent(workflowId)}/executions/${encodeURIComponent(execId)}`
114120
)
115121

122+
const toWebhookTokenInfo = (raw: { token: string; path: string }): WebhookTokenInfo => ({
123+
token: raw.token,
124+
url: `${hooksBase}${raw.path}`
125+
})
126+
127+
/** "Reveal" — fetches the webhook trigger's current token/URL. 404s (via
128+
* WorkflowsApiError) if the workflow isn't a webhook trigger, or no token has been
129+
* generated for it yet (shouldn't happen once saved — the backend generates one on
130+
* first save of a webhook trigger). */
131+
const getWebhookToken = (workflowId: string): Promise<WebhookTokenInfo> =>
132+
request<{ token: string; path: string }>(
133+
`/me/workflows/${encodeURIComponent(workflowId)}/webhook-token`
134+
).then(toWebhookTokenInfo)
135+
136+
/** "Rotate" — replaces the webhook trigger's token, immediately invalidating the
137+
* previous URL for any external caller still using it. */
138+
const rotateWebhookToken = (workflowId: string): Promise<WebhookTokenInfo> =>
139+
request<{ token: string; path: string }>(
140+
`/me/workflows/${encodeURIComponent(workflowId)}/webhook-token/rotate`,
141+
{ method: 'POST' }
142+
).then(toWebhookTokenInfo)
143+
116144
const getAutomationStatus = (): Promise<AutomationStatus> => request<AutomationStatus>('/me/automation')
117145

118146
const connectAutomation = (): Promise<AutomationStatus> =>
@@ -129,6 +157,8 @@ export function useWorkflowsApi(backendUrl: string) {
129157
runWorkflow,
130158
listExecutions,
131159
getExecution,
160+
getWebhookToken,
161+
rotateWebhookToken,
132162
getAutomationStatus,
133163
connectAutomation,
134164
disconnectAutomation

frontend/src/nodeTypes.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ export const NODE_TYPES: NodeTypeDefinition[] = [
5353
category: TRIGGER_CATEGORY,
5454
defaultData: { label: 'File event', triggerType: 'event', event: { type: 'upload' } }
5555
},
56+
{
57+
id: 'trigger-webhook',
58+
nodeKind: 'trigger',
59+
label: 'Webhook Trigger',
60+
description: 'Runs when an external request hits a per-workflow URL',
61+
icon: 'link',
62+
category: TRIGGER_CATEGORY,
63+
defaultData: { label: 'Webhook', triggerType: 'webhook' }
64+
},
5665
{
5766
id: 'llm',
5867
nodeKind: 'llm',

frontend/src/types/workflow.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
export type TriggerType = 'manual' | 'schedule' | 'event'
1+
export type TriggerType = 'manual' | 'schedule' | 'event' | 'webhook'
22
export type EventTriggerType = 'upload' | 'move' | 'share' | 'lock'
33
export type ActionType = 'tag' | 'comment' | 'move' | 'copy' | 'rename' | 'notify'
44
export type ExecutionStatus = 'running' | 'succeeded' | 'failed'
5-
export type ExecutionTrigger = 'manual' | 'schedule' | 'event'
5+
export type ExecutionTrigger = 'manual' | 'schedule' | 'event' | 'webhook'
66

77
export interface WorkflowTrigger {
88
type: TriggerType
@@ -103,6 +103,14 @@ export interface AutomationStatus {
103103
expirationDateTime?: string
104104
}
105105

106+
/** The webhook trigger's token/URL — only ever returned by the deliberate reveal/rotate
107+
* actions (see useWorkflowsApi's getWebhookToken/rotateWebhookToken), never by the normal
108+
* workflow GET/List/Patch responses. */
109+
export interface WebhookTokenInfo {
110+
token: string
111+
url: string
112+
}
113+
106114
export interface GraphCollection<T> {
107115
value: T[]
108116
}

frontend/src/views/WorkflowBuilder.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@
9090
<NodeDetailsPanel
9191
v-if="selectedNode"
9292
:node="selectedNode"
93+
:workflow-id="isNew() ? '' : currentId()"
94+
:backend-url="appConfig.backendUrl"
9395
@update="(data) => updateNodeData(selectedNode!.id, data)"
9496
@close="selectedNodeId = null"
9597
/>

0 commit comments

Comments
 (0)