- @if (!editableInputs().length) {
+ @if (authorizationRequirements().length) {
+
+
+
Provider Authorizations
+
Provide the missing runtime credentials required by this execution.
+
+
+ @for (requirement of authorizationRequirements(); track requirement.key) {
+
+
+
+
{{ requirement.provider }}
+
{{ requirement.key }}
+
+
+ {{ requirement.fieldName || 'authorization' }}
+
+
+
+
{{ requirement.description || 'Runtime authorization required.' }}
+
+ @if (requirement.requiredBySteps.length) {
+
+ Required by: {{ requirement.requiredBySteps.join(', ') }}
+
+ }
+
+
+
+
+
+
+
+
+
+ @if (isAuthorizationSaving(requirement.key)) {
+
Saving authorization...
+ }
+ @if (authorizationSavingError(requirement.key); as errorMessage) {
+
{{ errorMessage }}
+ }
+
+ }
+
+ }
+
+ @if (!editableInputs().length && !authorizationRequirements().length) {
No manual inputs required.
} @else {
+ @if (editableInputs().length) {
+
Manual Inputs
+ }
@for (executionInput of editableInputs(); track executionInput.key) {
{{ executionInput.label }}
diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts
index c2ec5b7..9ba5a06 100644
--- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts
+++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts
@@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
+import { TaskExecutionAuthorizationRequirement } from '@models/task-execution';
export type EditableExecutionInput = {
key: string;
@@ -18,12 +19,20 @@ export type EditableExecutionInput = {
})
export class TaskExecutionInputsPanelComponent {
readonly editableInputs = input
([]);
+ readonly authorizationRequirements = input([]);
+ readonly authorizationValues = input>({});
+ readonly savingAuthorizations = input>({});
+ readonly authorizationErrors = input>({});
readonly savingInputs = input>({});
readonly savingErrors = input>({});
readonly readOnly = input(false);
readonly textInputChange = output<{ input: EditableExecutionInput; value: string }>();
readonly fileInputChange = output<{ input: EditableExecutionInput; file: File }>();
+ readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>();
+ readonly authorizationSubmit = output();
+
+ private readonly authorizationVisibility = new Map();
isFileInput(input: EditableExecutionInput): boolean {
return input.type.includes('FILE') || input.type.includes('BINARY');
@@ -49,4 +58,44 @@ export class TaskExecutionInputsPanelComponent {
inputSavingError(key: string): string | null {
return this.savingErrors()[key] ?? null;
}
+
+ authorizationValue(key: string): string {
+ return this.authorizationValues()[key] ?? '';
+ }
+
+ onAuthorizationValueChange(requirement: TaskExecutionAuthorizationRequirement, value: string) {
+ if (this.readOnly()) return;
+ this.authorizationValueChange.emit({ requirement, value });
+ }
+
+ submitAuthorization(requirement: TaskExecutionAuthorizationRequirement, event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ if (this.readOnly() || !this.canSubmitAuthorization(requirement.key)) return;
+ this.authorizationSubmit.emit(requirement);
+ }
+
+ canSubmitAuthorization(key: string): boolean {
+ if (this.readOnly()) return false;
+ if (this.isAuthorizationSaving(key)) return false;
+ return this.authorizationValue(key).trim().length > 0;
+ }
+
+ isAuthorizationSaving(key: string): boolean {
+ return this.savingAuthorizations()[key] === true;
+ }
+
+ authorizationSavingError(key: string): string | null {
+ return this.authorizationErrors()[key] ?? null;
+ }
+
+ toggleAuthorizationVisibility(key: string, event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ this.authorizationVisibility.set(key, !this.isAuthorizationVisible(key));
+ }
+
+ isAuthorizationVisible(key: string): boolean {
+ return this.authorizationVisibility.get(key) === true;
+ }
}
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html
index f8e421a..605461c 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.html
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html
@@ -67,9 +67,15 @@
Execution Inputs
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
index cfdbef0..076dcbb 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
@@ -1,7 +1,12 @@
import { CommonModule } from '@angular/common';
-import { Component, computed, inject, input, OnDestroy, signal } from '@angular/core';
+import { Component, computed, effect, inject, input, OnDestroy, signal } from '@angular/core';
import { FlowBlockConnection, FlowData } from '@models/flow';
-import { getExecutionStatusGroup, TaskExecution, TaskExecutionStep } from '@models/task-execution';
+import {
+ getExecutionStatusGroup,
+ TaskExecution,
+ TaskExecutionAuthorizationRequirement,
+ TaskExecutionStep
+} from '@models/task-execution';
import {
EditableExecutionInput,
TaskExecutionInputsPanelComponent
@@ -19,17 +24,48 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private static readonly TEXT_INPUT_DEBOUNCE_MS = 1200;
private taskExecutionsService = inject(TaskExecutionsService);
private readonly textInputDebounceTimers = new Map>();
+ private lastExecutionId: string | null = null;
readonly execution = input(null);
readonly contextAsideOpen = signal(true);
readonly startInProgress = signal(false);
readonly savingInputs = signal>({});
readonly savingErrors = signal>({});
readonly pendingTextInputs = signal>({});
+ readonly pendingAuthorizationValues = signal>({});
+ readonly savingAuthorizations = signal>({});
+ readonly authorizationErrors = signal>({});
+
+ constructor() {
+ effect(() => {
+ const executionId = this.execution()?.id ?? null;
+ if (executionId === this.lastExecutionId) return;
+ this.lastExecutionId = executionId;
+ this.pendingAuthorizationValues.set({});
+ this.savingAuthorizations.set({});
+ this.authorizationErrors.set({});
+ });
+ }
readonly stepsArray = computed(() =>
Object.values(this.execution()?.context.steps ?? {})
);
+ readonly authorizationRequirements = computed(() => {
+ const execution = this.execution();
+ if (!execution?.requiredAuthorizations) return [];
+
+ const required = execution.requiredAuthorizations;
+ const entries = Array.isArray(required) ? required : Object.values(required);
+ return entries
+ .filter((entry): entry is TaskExecutionAuthorizationRequirement => !!entry && typeof entry.key === 'string')
+ .sort((a, b) => a.provider.localeCompare(b.provider) || a.key.localeCompare(b.key));
+ });
+
+ readonly missingAuthorizationRequirements = computed(() => {
+ const missingKeys = new Set(this.execution()?.missingAuthorizationKeys ?? []);
+ return this.authorizationRequirements().filter((requirement) => missingKeys.has(requirement.key));
+ });
+
readonly executionFlowData = computed(() => {
const executionStatusGroup = getExecutionStatusGroup(this.execution()?.context.status);
const contextInputs = this.execution()?.context.inputs ?? {};
@@ -45,6 +81,8 @@ export class TaskExecutionViewerComponent implements OnDestroy {
...step.block,
specificConfiguration: {
...(step.block.specificConfiguration ?? {}),
+ __executionId: this.execution()?.id ?? null,
+ __executionNodeId: step.id,
__stepStatus: step.status,
__executionStatusGroup: executionStatusGroup,
__isWaitingStep: waitingSteps.includes(step.id),
@@ -79,6 +117,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly canStartExecution = computed(() => {
const execution = this.execution();
if (!execution) return false;
+ if ((execution.missingAuthorizationKeys?.length ?? 0) > 0) return false;
const statusGroup = getExecutionStatusGroup(execution.context.status);
if (statusGroup !== 'INIT') return false;
@@ -182,6 +221,36 @@ export class TaskExecutionViewerComponent implements OnDestroy {
});
}
+ onAuthorizationValueChange(requirement: TaskExecutionAuthorizationRequirement, value: string) {
+ this.pendingAuthorizationValues.update((current) => ({ ...current, [requirement.key]: value }));
+ this.authorizationErrors.update((current) => {
+ const next = { ...current };
+ delete next[requirement.key];
+ return next;
+ });
+ }
+
+ submitAuthorization(requirement: TaskExecutionAuthorizationRequirement) {
+ const executionId = this.execution()?.id;
+ if (!executionId) return;
+
+ const value = (this.pendingAuthorizationValues()[requirement.key] ?? '').trim();
+ if (!value) return;
+
+ this.setAuthorizationSaving(requirement.key, true);
+ this.taskExecutionsService.provideAuthorization(executionId, requirement.key, value).subscribe({
+ next: () => {
+ this.pendingAuthorizationValues.update((current) => {
+ const next = { ...current };
+ delete next[requirement.key];
+ return next;
+ });
+ this.clearAuthorizationSaving(requirement.key);
+ },
+ error: () => this.setAuthorizationError(requirement.key, 'Failed to save authorization')
+ });
+ }
+
private setInputSaving(key: string, saving: boolean) {
this.savingInputs.update((current) => ({ ...current, [key]: saving }));
if (saving) {
@@ -214,6 +283,31 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.textInputDebounceTimers.clear();
}
+ private setAuthorizationSaving(key: string, saving: boolean) {
+ this.savingAuthorizations.update((current) => ({ ...current, [key]: saving }));
+ if (saving) {
+ this.authorizationErrors.update((current) => {
+ const next = { ...current };
+ delete next[key];
+ return next;
+ });
+ }
+ }
+
+ private clearAuthorizationSaving(key: string) {
+ this.savingAuthorizations.update((current) => ({ ...current, [key]: false }));
+ this.authorizationErrors.update((current) => {
+ const next = { ...current };
+ delete next[key];
+ return next;
+ });
+ }
+
+ private setAuthorizationError(key: string, message: string) {
+ this.savingAuthorizations.update((current) => ({ ...current, [key]: false }));
+ this.authorizationErrors.update((current) => ({ ...current, [key]: message }));
+ }
+
private sendPreparedTextInput(input: EditableExecutionInput, executionId: string) {
if (this.inputsReadOnly() || this.execution()?.id !== executionId) return;
diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts
index f7e1e17..d828d7f 100644
--- a/src/app/utilities/rete-editor.ts
+++ b/src/app/utilities/rete-editor.ts
@@ -131,6 +131,14 @@ export async function addBlockToEditor(
const node = new ClassicPreset.Node(toNodeLabel(block.typeName)) as HFNode;
const removeNode = async () => {
if (!editor.getNode(node.id)) return;
+ const relatedConnectionIds = editor.getConnections()
+ .filter((connection) => connection.source === node.id || connection.target === node.id)
+ .map((connection) => connection.id);
+
+ for (const connectionId of relatedConnectionIds) {
+ await editor.removeConnection(connectionId);
+ }
+
await editor.removeNode(node.id);
};
const replaceWithCreatedBlock = async (createdBlock: FlowBlock) => {