From 13dc1a445f19cedf8a29c62b74a1860d246060d2 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Mon, 25 May 2026 19:10:13 +0530 Subject: [PATCH 1/9] fix(orchestrator-form): evaluate conditional ui:hidden with scoped form data Evaluate sibling when paths against the current object/step form data so conditionally hidden fields show and hide correctly in wizards. Fix the review step toggle by iterating schema properties when including hidden fields and applying the same scoped condition evaluation. Co-authored-by: Cursor --- .../components/HiddenObjectFieldTemplate.tsx | 10 ++- .../src/utils/evaluateHiddenCondition.test.ts | 63 ++++++++++++++- .../src/utils/evaluateHiddenCondition.ts | 46 +++++++++-- .../src/utils/generateReviewTableData.test.ts | 46 +++++++++++ .../src/utils/generateReviewTableData.ts | 77 ++++++++++++++----- 5 files changed, 211 insertions(+), 31 deletions(-) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/HiddenObjectFieldTemplate.tsx b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/HiddenObjectFieldTemplate.tsx index 416775dbdc..902107113a 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/HiddenObjectFieldTemplate.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/HiddenObjectFieldTemplate.tsx @@ -76,8 +76,8 @@ const HiddenObjectFieldTemplate = ( ButtonTemplates: { AddButton }, } = registry.templates; - const rootFormData = - (formContext?.formData as JsonObject) || (formData as JsonObject) || {}; + const rootFormData = (formContext?.formData as JsonObject) || {}; + const localFormData = (formData as JsonObject) || rootFormData; return ( <> @@ -105,7 +105,11 @@ const HiddenObjectFieldTemplate = ( const hiddenCondition = getHiddenCondition(uiSchema, element.name); const isHiddenByCondition = hiddenCondition !== undefined - ? evaluateHiddenCondition(hiddenCondition, rootFormData) + ? evaluateHiddenCondition( + hiddenCondition, + localFormData, + rootFormData, + ) : false; const isHidden = element.hidden || isHiddenByCondition; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.test.ts index a0aa42e1f8..2ba8167ded 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.test.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.test.ts @@ -20,7 +20,10 @@ import { HiddenCondition, HiddenConditionObject, } from '../types/HiddenCondition'; -import { evaluateHiddenCondition } from './evaluateHiddenCondition'; +import { + evaluateHiddenCondition, + getValueForWhen, +} from './evaluateHiddenCondition'; describe('evaluateHiddenCondition', () => { describe('boolean conditions', () => { @@ -185,4 +188,62 @@ describe('evaluateHiddenCondition', () => { expect(evaluateHiddenCondition(condition, formData)).toBe(true); }); }); + + describe('scoped form data (wizard step / object siblings)', () => { + const rootFormData: JsonObject = { + inputs: { + field1: 'show', + field2: 'ready', + conditionalDetail: 'value', + }, + step2: { + other: 'x', + }, + }; + + const stepLocalData = rootFormData.inputs as JsonObject; + + it('getValueForWhen resolves sibling fields from local object data', () => { + expect(getValueForWhen('field1', stepLocalData, rootFormData)).toBe( + 'show', + ); + expect(getValueForWhen('field1', rootFormData)).toBeUndefined(); + }); + + it('shows field3 when field1 is show and field2 is ready (local scope)', () => { + const condition: HiddenCondition = { + anyOf: [ + { when: 'field1', isNot: 'show' }, + { when: 'field2', isNot: 'ready' }, + ], + }; + expect( + evaluateHiddenCondition(condition, stepLocalData, rootFormData), + ).toBe(false); + expect(evaluateHiddenCondition(condition, rootFormData)).toBe(true); + }); + + it('hides field3 when field1 is hide or field2 is idle', () => { + const condition: HiddenCondition = { + anyOf: [ + { when: 'field1', isNot: 'show' }, + { when: 'field2', isNot: 'ready' }, + ], + }; + expect( + evaluateHiddenCondition( + condition, + { field1: 'hide', field2: 'ready' }, + rootFormData, + ), + ).toBe(true); + expect( + evaluateHiddenCondition( + condition, + { field1: 'show', field2: 'idle' }, + rootFormData, + ), + ).toBe(true); + }); + }); }); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.ts index 0a0fa01f78..d3e1b23637 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.ts @@ -17,6 +17,7 @@ import { JsonObject, JsonValue } from '@backstage/types'; import get from 'lodash/get'; +import has from 'lodash/has'; import { HiddenCondition, @@ -57,14 +58,38 @@ function matchesAny( }); } +/** + * Resolves the value for a `when` path. Prefers the current object's form data + * (sibling fields in the same step/object), then falls back to root form data + * for cross-step paths such as `step1.field1`. + */ +export function getValueForWhen( + when: string, + localFormData: JsonObject, + rootFormData?: JsonObject, +): JsonValue | undefined { + if (has(localFormData, when)) { + return get(localFormData, when); + } + if (rootFormData !== undefined && rootFormData !== localFormData) { + return get(rootFormData, when); + } + return get(localFormData, when); +} + /** * Evaluate a simple condition object */ function evaluateConditionObject( condition: HiddenConditionObject, - formData: JsonObject, + localFormData: JsonObject, + rootFormData?: JsonObject, ): boolean { - const fieldValue = get(formData, condition.when); + const fieldValue = getValueForWhen( + condition.when, + localFormData, + rootFormData, + ); // Check isEmpty condition if (condition.isEmpty !== undefined) { @@ -91,19 +116,20 @@ function evaluateConditionObject( */ function evaluateCompositeCondition( condition: HiddenConditionComposite, - formData: JsonObject, + localFormData: JsonObject, + rootFormData?: JsonObject, ): boolean { // Evaluate 'allOf' (AND logic - all must be true) if (condition.allOf) { return condition.allOf.every(subCondition => - evaluateHiddenCondition(subCondition, formData), + evaluateHiddenCondition(subCondition, localFormData, rootFormData), ); } // Evaluate 'anyOf' (OR logic - at least one must be true) if (condition.anyOf) { return condition.anyOf.some(subCondition => - evaluateHiddenCondition(subCondition, formData), + evaluateHiddenCondition(subCondition, localFormData, rootFormData), ); } @@ -114,10 +140,14 @@ function evaluateCompositeCondition( /** * Evaluate a hidden condition * Returns true if the field should be hidden + * + * @param localFormData - Form data for the current object (sibling field scope) + * @param rootFormData - Optional root form data for cross-step/cross-object `when` paths */ export function evaluateHiddenCondition( condition: HiddenCondition, - formData: JsonObject, + localFormData: JsonObject, + rootFormData?: JsonObject, ): boolean { // Handle boolean (static) if (typeof condition === 'boolean') { @@ -126,12 +156,12 @@ export function evaluateHiddenCondition( // Handle simple condition object if ('when' in condition) { - return evaluateConditionObject(condition, formData); + return evaluateConditionObject(condition, localFormData, rootFormData); } // Handle composite condition if ('allOf' in condition || 'anyOf' in condition) { - return evaluateCompositeCondition(condition, formData); + return evaluateCompositeCondition(condition, localFormData, rootFormData); } // Unknown condition type, don't hide diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateReviewTableData.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateReviewTableData.test.ts index a32f07a71d..908226b461 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateReviewTableData.test.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateReviewTableData.test.ts @@ -236,6 +236,52 @@ describe('mapSchemaToData', () => { expect(result).toEqual(expectedResult); }); + it('should include conditionally hidden schema fields not present in form data when includeHiddenFields is true', () => { + const schema: JSONSchema7 = { + type: 'object', + properties: { + inputs: { + type: 'object', + title: 'Step 1', + properties: { + field1: { + type: 'string', + title: 'Field 1', + }, + conditionalDetail: { + type: 'string', + title: 'Field 3', + 'ui:hidden': { + anyOf: [{ when: 'field1', isNot: 'show' }], + }, + } as JSONSchema7, + }, + }, + }, + }; + + const data = { + inputs: { + field1: 'hide', + }, + }; + + expect(generateReviewTableData(schema, data)).toEqual({ + 'Step 1': { + 'Field 1': 'hide', + }, + }); + + expect( + generateReviewTableData(schema, data, { includeHiddenFields: true }), + ).toEqual({ + 'Step 1': { + 'Field 1': 'hide', + 'Field 3': undefined, + }, + }); + }); + it('should exclude nested hidden fields from review table', () => { const schema: JSONSchema7 = { type: 'object', diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateReviewTableData.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateReviewTableData.ts index b1d70bb887..b1247fe02a 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateReviewTableData.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateReviewTableData.ts @@ -18,12 +18,38 @@ import { JsonObject, JsonValue } from '@backstage/types'; import type { JSONSchema7 } from 'json-schema'; import { JsonSchema, Draft07 as JSONSchema } from 'json-schema-library'; +import get from 'lodash/get'; import { isJsonObject } from '@red-hat-developer-hub/backstage-plugin-orchestrator-common'; import { HiddenCondition } from '../types/HiddenCondition'; import { evaluateHiddenCondition } from './evaluateHiddenCondition'; +/** Parent object form data for sibling `when` paths (e.g. `inputs.field1`). */ +function getScopedFormData( + schemaKey: string, + rootFormState: JsonObject, +): { localFormData: JsonObject; rootFormData: JsonObject } { + const rootFormData = rootFormState; + if (!schemaKey) { + return { localFormData: rootFormData, rootFormData }; + } + const parts = schemaKey.split('/'); + if (parts.length === 1) { + const local = get(rootFormData, parts[0]); + return { + localFormData: isJsonObject(local) ? local : rootFormData, + rootFormData, + }; + } + const parentPath = parts.slice(0, -1).join('.'); + const local = get(rootFormData, parentPath); + return { + localFormData: isJsonObject(local) ? local : rootFormData, + rootFormData, + }; +} + export function processSchema( key: string, value: JsonValue | undefined, @@ -47,7 +73,12 @@ export function processSchema( if (!includeHiddenFields && uiHidden !== undefined) { // Handle both static boolean and condition objects const hiddenCondition = uiHidden as HiddenCondition; - const isHidden = evaluateHiddenCondition(hiddenCondition, formState); + const { localFormData, rootFormData } = getScopedFormData(key, formState); + const isHidden = evaluateHiddenCondition( + hiddenCondition, + localFormData, + rootFormData, + ); if (isHidden) { return {}; } @@ -57,24 +88,32 @@ export function processSchema( return { [name]: '******' }; } - if (isJsonObject(value)) { - // Recurse nested objects - const nestedValue = Object.entries(value).reduce( - (prev, [nestedKey, _nestedValue]) => { - const curKey = key ? `${key}/${nestedKey}` : nestedKey; - return { - ...prev, - ...processSchema( - curKey, - _nestedValue, - schema, - formState, - includeHiddenFields, - ), - }; - }, - {}, - ); + if (isJsonObject(value) || definitionInSchema.properties) { + const dataObj = isJsonObject(value) ? value : {}; + const schemaProperties = definitionInSchema.properties ?? {}; + const nestedKeys = includeHiddenFields + ? [ + ...new Set([ + ...Object.keys(schemaProperties), + ...Object.keys(dataObj), + ]), + ] + : Object.keys(dataObj); + + // Recurse nested objects; include schema-only keys when showing hidden fields + const nestedValue = nestedKeys.reduce((prev, nestedKey) => { + const curKey = key ? `${key}/${nestedKey}` : nestedKey; + return { + ...prev, + ...processSchema( + curKey, + dataObj[nestedKey], + schema, + formState, + includeHiddenFields, + ), + }; + }, {}); // Skip if all nested fields are hidden (resulting in empty object) if (Object.keys(nestedValue).length === 0) { From 472796d5aaa618eb1a84c03e1b8966729a0a5b80 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Mon, 25 May 2026 19:21:43 +0530 Subject: [PATCH 2/9] chore(orchestrator): add changeset for scoped conditional ui:hidden fix Co-authored-by: Cursor --- .../.changeset/conditional-ui-hidden-scoped-formdata.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 workspaces/orchestrator/.changeset/conditional-ui-hidden-scoped-formdata.md diff --git a/workspaces/orchestrator/.changeset/conditional-ui-hidden-scoped-formdata.md b/workspaces/orchestrator/.changeset/conditional-ui-hidden-scoped-formdata.md new file mode 100644 index 0000000000..c22d0e84c1 --- /dev/null +++ b/workspaces/orchestrator/.changeset/conditional-ui-hidden-scoped-formdata.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': patch +--- + +Evaluate conditional `ui:hidden` using scoped form data so sibling `when` paths work on wizard steps. From d4b3b9e2bf4e94192345a833ca18ceeab182794e Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Fri, 29 May 2026 13:45:04 +0530 Subject: [PATCH 3/9] chore(orchestrator): clarify changeset for conditional hidden fields fix Co-authored-by: Cursor --- .../.changeset/conditional-ui-hidden-scoped-formdata.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/orchestrator/.changeset/conditional-ui-hidden-scoped-formdata.md b/workspaces/orchestrator/.changeset/conditional-ui-hidden-scoped-formdata.md index c22d0e84c1..0406f00f9d 100644 --- a/workspaces/orchestrator/.changeset/conditional-ui-hidden-scoped-formdata.md +++ b/workspaces/orchestrator/.changeset/conditional-ui-hidden-scoped-formdata.md @@ -2,4 +2,4 @@ '@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': patch --- -Evaluate conditional `ui:hidden` using scoped form data so sibling `when` paths work on wizard steps. +Remove unnecessary gaps from conditionally hidden form fields. From 1dd32acdd4f2ab0875aa8d295b707d2fb1acb2d0 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Mon, 1 Jun 2026 19:17:52 +0530 Subject: [PATCH 4/9] fix(orchestrator): restore status and date filters on workflow runs Filtering workflow runs by status or date failed after query variables were introduced. Use the correct filter types so results load instead of showing an error. Co-authored-by: Cursor --- .../fix-graphql-filter-variable-types.md | 5 ++ .../src/helpers/filterBuilder.ts | 54 +++++++++++++++---- .../src/helpers/filterBuilders.test.ts | 12 +++++ 3 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 workspaces/orchestrator/.changeset/fix-graphql-filter-variable-types.md diff --git a/workspaces/orchestrator/.changeset/fix-graphql-filter-variable-types.md b/workspaces/orchestrator/.changeset/fix-graphql-filter-variable-types.md new file mode 100644 index 0000000000..e05015456c --- /dev/null +++ b/workspaces/orchestrator/.changeset/fix-graphql-filter-variable-types.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-backend': patch +--- + +Fix an issue where filtering workflow runs by status or date could show an error instead of results. diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts index 584cafe714..592ca13eaf 100644 --- a/workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts @@ -111,23 +111,53 @@ function handleNestedFilter( return filterClause; } -function handleBetweenOperator(filter: FieldFilter): FilterClause { +function getGraphQLVariableType( + fieldName: string, + fieldDef: IntrospectionField | undefined, + type: ProcessType, + isArray: boolean, +): string { + if (isEnumFilter(fieldName, type)) { + return isArray ? '[ProcessInstanceState!]' : 'ProcessInstanceState'; + } + + if (fieldDef?.type.name === TypeName.Date) { + return 'DateTime!'; + } + + if (isArray) { + return '[String!]'; + } + + return 'String'; +} + +function handleBetweenOperator( + filter: FieldFilter, + fieldDef: IntrospectionField | undefined, +): FilterClause { if (!Array.isArray(filter.value) || filter.value.length !== 2) { throw new Error('Between operator requires an array of two elements'); } + const paramType = getGraphQLVariableType( + filter.field, + fieldDef, + 'ProcessInstance', + false, + ); const filterClauseVariableArray: FilterClauseVariable[] = []; const clauseVariableName1 = `clauseVariable${nonSecureRandomAlphaNumeric()}`; const filterClauseVariable1: FilterClauseVariable = { clauseVariableName: clauseVariableName1, formattedValue: filter.value[0], - clauseVariableType: 'String', + clauseVariableType: paramType, }; const clauseVariableName2 = `clauseVariable${nonSecureRandomAlphaNumeric()}`; const filterClauseVariable2: FilterClauseVariable = { clauseVariableName: clauseVariableName2, formattedValue: filter.value[1], - clauseVariableType: 'String', + clauseVariableType: paramType, }; const clause = `${filter.field}: {${getGraphQLOperator( @@ -193,14 +223,11 @@ function handleBinaryOperator( } } let formattedValue: any; - let paramType: string; - if (Array.isArray(binaryFilter.value)) { - formattedValue = binaryFilter.value.map(v => + const isArray = Array.isArray(binaryFilter.value); + if (isArray) { + formattedValue = (binaryFilter.value as unknown[]).map((v: unknown) => formatValue(binaryFilter.field, v, fieldDef, type), ); - paramType = isEnumFilter(binaryFilter.field, type) - ? '[ProcessInstanceState!]' - : '[String!]'; } else { formattedValue = formatValue( binaryFilter.field, @@ -208,8 +235,13 @@ function handleBinaryOperator( fieldDef, type, ); - paramType = 'String'; } + const paramType = getGraphQLVariableType( + binaryFilter.field, + fieldDef, + type, + isArray, + ); const clauseVariableName = `clauseVariable${nonSecureRandomAlphaNumeric()}`; const clause = `${binaryFilter.field}: {${getGraphQLOperator(binaryFilter.operator)}: $${clauseVariableName}}`; @@ -273,7 +305,7 @@ export function buildFilterCondition( case FieldFilterOperatorEnum.IsNull: return handleIsNullOperator(filters); case FieldFilterOperatorEnum.Between: - return handleBetweenOperator(filters); + return handleBetweenOperator(filters, fieldDef); case FieldFilterOperatorEnum.Eq: case FieldFilterOperatorEnum.Like: case FieldFilterOperatorEnum.In: diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilders.test.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilders.test.ts index 6befa390ed..3ad256566b 100644 --- a/workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilders.test.ts +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilders.test.ts @@ -128,6 +128,7 @@ describe('column filters', () => { filter: Filter | undefined; expectedResult: string | FilterClause; expectedFormattedValue: Array; + expectedVariableTypes?: string[]; }; describe('empty filter testcases', () => { const emptyFilterTestCases: FilterTestCase[] = [ @@ -519,6 +520,7 @@ describe('column filters', () => { ), expectedResult: `start: {equal: $variable1}`, expectedFormattedValue: [testDate1], + expectedVariableTypes: ['DateTime!'], }, { name: 'returns correct filter for single date field with isNull operator (false as boolean)', @@ -595,6 +597,7 @@ describe('column filters', () => { ]), expectedResult: `start: {between: {from: $variable1, to: $variable2}}`, expectedFormattedValue: [testDate1, testDate2], + expectedVariableTypes: ['DateTime!', 'DateTime!'], }, { name: 'returns correct OR filter for multiple id fields with equal, isNull, and GT operators', @@ -683,6 +686,7 @@ describe('column filters', () => { filter, expectedResult, expectedFormattedValue, + expectedVariableTypes, }) => { it(`${name}`, () => { const result = buildFilterCondition( @@ -698,6 +702,9 @@ describe('column filters', () => { `$${item.clauseVariableName}`, ); expect(item.formattedValue).toEqual(expectedFormattedValue[index]); + expect(item.clauseVariableType).toBe( + expectedVariableTypes?.[index] ?? item.clauseVariableType, + ); }); expect(formattedClause).toBe(result.clause); }); @@ -718,6 +725,7 @@ describe('column filters', () => { ), expectedResult: `state: {equal: $variable1}`, expectedFormattedValue: ['COMPLETED'], + expectedVariableTypes: ['ProcessInstanceState'], }, ]; @@ -728,6 +736,7 @@ describe('column filters', () => { filter, expectedResult, expectedFormattedValue, + expectedVariableTypes, }) => { it(`${name}`, () => { const result = buildFilterCondition( @@ -743,6 +752,9 @@ describe('column filters', () => { `$${item.clauseVariableName}`, ); expect(item.formattedValue).toEqual(expectedFormattedValue[index]); + expect(item.clauseVariableType).toBe( + expectedVariableTypes?.[index] ?? item.clauseVariableType, + ); }); expect(formattedClause).toBe(result.clause); }); From 5fbe34fe588d2458fed71b40f703208e06fccff7 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Tue, 2 Jun 2026 13:07:52 +0530 Subject: [PATCH 5/9] feat(orchestrator-form): hide wizard steps for conditional ui:hidden Evaluate conditional ui:hidden when filtering wizard steps using scoped form data and root fallbacks, align validation with visible steps, and add isNotEmptyList/notContains operators with unit tests. Co-authored-by: Cursor --- .../conditional-step-hiding-operators.md | 5 + .../components/OrchestratorFormWrapper.tsx | 2 +- .../src/components/StepperObjectField.tsx | 4 +- .../src/types/HiddenCondition.ts | 10 ++ .../src/utils/evaluateHiddenCondition.test.ts | 63 +++++++++ .../src/utils/evaluateHiddenCondition.ts | 40 +++++- .../src/utils/getSortedStepEntries.test.ts | 131 ++++++++++++++++++ .../src/utils/getSortedStepEntries.ts | 47 ++++++- .../src/utils/useValidator.ts | 2 +- 9 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 workspaces/orchestrator/.changeset/conditional-step-hiding-operators.md create mode 100644 workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/getSortedStepEntries.test.ts diff --git a/workspaces/orchestrator/.changeset/conditional-step-hiding-operators.md b/workspaces/orchestrator/.changeset/conditional-step-hiding-operators.md new file mode 100644 index 0000000000..4f385a34fc --- /dev/null +++ b/workspaces/orchestrator/.changeset/conditional-step-hiding-operators.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': minor +--- + +Hide wizard steps when conditional `ui:hidden` rules evaluate to true, and add `isNotEmptyList`/`notContains` operators for conditional hidden expressions. diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx index 89e387312f..91d2ef9396 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx @@ -78,7 +78,7 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => { return undefined; } - return getActiveStepKey(schema, activeStep); + return getActiveStepKey(schema, activeStep, formData); }; const onSubmit = async (_formData: JsonObject) => { diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/StepperObjectField.tsx b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/StepperObjectField.tsx index 4472ccbbd3..2ac629c9d8 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/StepperObjectField.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/StepperObjectField.tsx @@ -41,8 +41,8 @@ const StepperObjectField = ({ const { t } = useTranslation(); const sortedStepEntries = useMemo( - () => getSortedStepEntries(schema), - [schema], + () => getSortedStepEntries(schema, formData), + [schema, formData], ); if (sortedStepEntries === undefined) { throw new Error(t('stepperObjectField.error')); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/types/HiddenCondition.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/types/HiddenCondition.ts index 9438bc87b8..b9ee58a607 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/types/HiddenCondition.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/types/HiddenCondition.ts @@ -42,6 +42,16 @@ export interface HiddenConditionObject { * Hide if the field is empty (undefined, null, empty string, or empty array) */ isEmpty?: boolean; + + /** + * Hide if the field is a non-empty array (when true) or an empty/non-array value (when false). + */ + isNotEmptyList?: boolean; + + /** + * Hide if the field value (an array) does NOT contain this value. + */ + notContains?: JsonValue; } /** diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.test.ts index 2ba8167ded..cf43010748 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.test.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.test.ts @@ -124,6 +124,69 @@ describe('evaluateHiddenCondition', () => { }; expect(evaluateHiddenCondition(condition, nestedFormData)).toBe(true); }); + + it('should hide when list is non-empty (isNotEmptyList)', () => { + const condition: HiddenConditionObject = { + when: 'items', + isNotEmptyList: true, + }; + expect( + evaluateHiddenCondition(condition, { + items: ['a'], + }), + ).toBe(true); + expect( + evaluateHiddenCondition(condition, { + items: [], + }), + ).toBe(false); + }); + + it('should hide when list does not contain value (notContains)', () => { + const condition: HiddenConditionObject = { + when: 'items', + notContains: 'x', + }; + expect( + evaluateHiddenCondition(condition, { + items: ['a', 'b'], + }), + ).toBe(true); + expect( + evaluateHiddenCondition(condition, { + items: ['a', 'x'], + }), + ).toBe(false); + expect( + evaluateHiddenCondition(condition, { + items: 'not-a-list', + }), + ).toBe(false); + }); + + it('should AND multiple operators in one condition object', () => { + const condition: HiddenConditionObject = { + when: 'items', + isNotEmptyList: true, + notContains: 'x', + }; + + expect( + evaluateHiddenCondition(condition, { + items: ['a', 'b'], + }), + ).toBe(true); + expect( + evaluateHiddenCondition(condition, { + items: [], + }), + ).toBe(false); + expect( + evaluateHiddenCondition(condition, { + items: ['x'], + }), + ).toBe(false); + }); }); describe('composite conditions', () => { diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.ts index d3e1b23637..4429343526 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/evaluateHiddenCondition.ts @@ -91,24 +91,56 @@ function evaluateConditionObject( rootFormData, ); + let hasCondition = false; + let shouldHide = true; + // Check isEmpty condition if (condition.isEmpty !== undefined) { + hasCondition = true; const empty = isEmptyValue(fieldValue); - return condition.isEmpty ? empty : !empty; + if (!(condition.isEmpty ? empty : !empty)) { + shouldHide = false; + } } // Check 'is' condition (hide if field equals any value) if (condition.is !== undefined) { - return matchesAny(fieldValue, condition.is); + hasCondition = true; + if (!matchesAny(fieldValue, condition.is)) { + shouldHide = false; + } } // Check 'isNot' condition (hide if field does NOT equal any value) if (condition.isNot !== undefined) { - return !matchesAny(fieldValue, condition.isNot); + hasCondition = true; + if (matchesAny(fieldValue, condition.isNot)) { + shouldHide = false; + } + } + + // Check 'isNotEmptyList' condition + if (condition.isNotEmptyList !== undefined) { + hasCondition = true; + const isNonEmptyList = Array.isArray(fieldValue) && fieldValue.length > 0; + if (!(condition.isNotEmptyList ? isNonEmptyList : !isNonEmptyList)) { + shouldHide = false; + } + } + + // Check 'notContains' condition (hide when array does not include value) + if (condition.notContains !== undefined) { + hasCondition = true; + const notContainsValue = + Array.isArray(fieldValue) && + !fieldValue.some(item => matchesAny(item, condition.notContains!)); + if (!notContainsValue) { + shouldHide = false; + } } // No valid condition found, don't hide - return false; + return hasCondition ? shouldHide : false; } /** diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/getSortedStepEntries.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/getSortedStepEntries.test.ts new file mode 100644 index 0000000000..5d921a2d0e --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/getSortedStepEntries.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonObject } from '@backstage/types'; + +import { JSONSchema7 } from 'json-schema'; + +import { getActiveStepKey, getSortedStepEntries } from './getSortedStepEntries'; + +describe('getSortedStepEntries', () => { + it('filters a step with conditional ui:hidden at the step level', () => { + const schema: JSONSchema7 = { + type: 'object', + properties: { + step1: { + type: 'object', + properties: { + trigger: { type: 'string' }, + }, + }, + step2: { + type: 'object', + 'ui:hidden': { when: 'step1.trigger', is: 'hide' }, + properties: { + detail: { type: 'string' }, + }, + } as JSONSchema7, + }, + }; + + const formData: JsonObject = { + step1: { trigger: 'hide' }, + step2: { detail: '' }, + }; + + expect(getSortedStepEntries(schema, formData)?.map(([key]) => key)).toEqual( + ['step1'], + ); + }); + + it('filters a step when all properties are conditionally hidden (local scope)', () => { + const schema: JSONSchema7 = { + type: 'object', + properties: { + inputs: { + type: 'object', + properties: { + mode: { type: 'string', 'ui:hidden': true }, + detailsA: { + type: 'string', + 'ui:hidden': { when: 'mode', isNot: 'show' }, + } as JSONSchema7, + detailsB: { + type: 'string', + 'ui:hidden': { when: 'mode', isNot: 'show' }, + } as JSONSchema7, + }, + } as JSONSchema7, + summary: { + type: 'object', + properties: { + info: { type: 'string' }, + }, + }, + }, + }; + + const formData: JsonObject = { + inputs: { + mode: 'hide', + details: '', + }, + summary: { + info: 'visible', + }, + }; + + expect(getSortedStepEntries(schema, formData)?.map(([key]) => key)).toEqual( + ['summary'], + ); + }); + + it('resolves active step key using filtered step list', () => { + const schema: JSONSchema7 = { + type: 'object', + properties: { + step1: { + type: 'object', + properties: { + trigger: { type: 'string' }, + }, + }, + step2: { + type: 'object', + 'ui:hidden': { when: 'step1.trigger', is: 'hide' }, + properties: { + detail: { type: 'string' }, + }, + } as JSONSchema7, + step3: { + type: 'object', + properties: { + final: { type: 'string' }, + }, + }, + }, + }; + + const formData: JsonObject = { + step1: { trigger: 'hide' }, + step2: { detail: '' }, + step3: { final: '' }, + }; + + expect(getActiveStepKey(schema, 0, formData)).toBe('step1'); + expect(getActiveStepKey(schema, 1, formData)).toBe('step3'); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/getSortedStepEntries.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/getSortedStepEntries.ts index 3962eda9b0..09beda7669 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/getSortedStepEntries.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/getSortedStepEntries.ts @@ -13,9 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { JsonObject } from '@backstage/types'; + import { JSONSchema7, JSONSchema7Definition } from 'json-schema'; import get from 'lodash/get'; +import { HiddenCondition } from '../types/HiddenCondition'; +import { evaluateHiddenCondition } from './evaluateHiddenCondition'; + +const asJsonObject = (value: unknown): JsonObject | undefined => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as JsonObject) + : undefined; + /** * Get step entries from the schema sorted by the ui:order property. * If the ui:order property is not present, the step entries are sorted by the order of the properties in the schema. @@ -23,10 +33,12 @@ import get from 'lodash/get'; * Steps where ALL inputs are marked with ui:hidden: true are also automatically filtered out. * * @param schema - The schema to get the sorted step entries from. + * @param formData - Optional form data to evaluate conditional `ui:hidden` expressions. * @returns An array of [key, subSchema] pairs, a subSchema conforms a single wizard step. */ export const getSortedStepEntries = ( schema: JSONSchema7, + formData?: JsonObject, ): [string, JSONSchema7Definition][] | undefined => { if (!schema.properties) { return undefined; @@ -51,13 +63,28 @@ export const getSortedStepEntries = ( // Filter out hidden steps (fields with ui:hidden: true) // Also filter out steps where ALL inputs are hidden - sortedStepEntries = sortedStepEntries.filter(([_, subSchema]) => { + sortedStepEntries = sortedStepEntries.filter(([stepKey, subSchema]) => { if (typeof subSchema === 'boolean') { return true; } + const rootFormData = formData ?? {}; + const localFormData = asJsonObject(formData?.[stepKey]) ?? rootFormData; + // Check if step itself is explicitly hidden - if (get(subSchema, 'ui:hidden') === true) { + const stepHidden = get(subSchema, 'ui:hidden'); + if (stepHidden === true) { + return false; + } + if ( + typeof stepHidden === 'object' && + stepHidden !== null && + evaluateHiddenCondition( + stepHidden as HiddenCondition, + localFormData, + rootFormData, + ) + ) { return false; } @@ -71,7 +98,18 @@ export const getSortedStepEntries = ( if (typeof prop === 'boolean') { return false; } - return get(prop, 'ui:hidden') === true; + const propHidden = get(prop, 'ui:hidden'); + if (propHidden === true) { + return true; + } + if (typeof propHidden === 'object' && propHidden !== null) { + return evaluateHiddenCondition( + propHidden as HiddenCondition, + localFormData, + rootFormData, + ); + } + return false; }); if (allHidden) { @@ -89,8 +127,9 @@ export const getSortedStepEntries = ( export const getActiveStepKey = ( schema: JSONSchema7, activeStep: number, + formData?: JsonObject, ): string => { - const sortedStepEntries = getSortedStepEntries(schema) ?? []; + const sortedStepEntries = getSortedStepEntries(schema, formData) ?? []; const activeKey = sortedStepEntries[activeStep]?.[0]; if (!activeKey) { throw new Error( diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useValidator.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useValidator.ts index 257e2bf1fe..a2bd1df29e 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useValidator.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useValidator.ts @@ -69,7 +69,7 @@ const useValidator = (isMultiStepSchema: boolean) => { return validationData; } - const activeKey = getActiveStepKey(_schema, activeStep); + const activeKey = getActiveStepKey(_schema, activeStep, formData); return { errors: validationData.errors.filter(err => err.property?.startsWith(`.${activeKey}.`), From fee3dab6c4a8c4a3d5753cc294f23cc51c9cbf44 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Tue, 2 Jun 2026 13:16:11 +0530 Subject: [PATCH 6/9] chore(orchestrator-form): update API reports for HiddenCondition operators Co-authored-by: Cursor --- .../orchestrator/plugins/orchestrator-form-react/report.api.md | 2 ++ .../orchestrator/plugins/orchestrator/report-alpha.api.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/report.api.md b/workspaces/orchestrator/plugins/orchestrator-form-react/report.api.md index 39b086e65f..a1adf76888 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/report.api.md +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/report.api.md @@ -37,6 +37,8 @@ export interface HiddenConditionObject { is?: JsonValue | JsonValue[]; isEmpty?: boolean; isNot?: JsonValue | JsonValue[]; + isNotEmptyList?: boolean; + notContains?: JsonValue; when: string; } diff --git a/workspaces/orchestrator/plugins/orchestrator/report-alpha.api.md b/workspaces/orchestrator/plugins/orchestrator/report-alpha.api.md index 3cade1af0d..70cbe51c47 100644 --- a/workspaces/orchestrator/plugins/orchestrator/report-alpha.api.md +++ b/workspaces/orchestrator/plugins/orchestrator/report-alpha.api.md @@ -124,11 +124,11 @@ const _default: OverridableFrontendPlugin< defaultGroup?: [Error: `Use the 'group' param instead`]; group?: | ( - | 'operation' | 'overview' | 'documentation' | 'development' | 'deployment' + | 'operation' | 'observability' ) | (string & {}); From 013b8421bb6aea954969d7182066cd8572e34c0c Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Tue, 2 Jun 2026 13:21:46 +0530 Subject: [PATCH 7/9] chore(orchestrator): revert unrelated report-alpha.api.md ordering change Co-authored-by: Cursor --- .../orchestrator/plugins/orchestrator/report-alpha.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/orchestrator/plugins/orchestrator/report-alpha.api.md b/workspaces/orchestrator/plugins/orchestrator/report-alpha.api.md index 70cbe51c47..3cade1af0d 100644 --- a/workspaces/orchestrator/plugins/orchestrator/report-alpha.api.md +++ b/workspaces/orchestrator/plugins/orchestrator/report-alpha.api.md @@ -124,11 +124,11 @@ const _default: OverridableFrontendPlugin< defaultGroup?: [Error: `Use the 'group' param instead`]; group?: | ( + | 'operation' | 'overview' | 'documentation' | 'development' | 'deployment' - | 'operation' | 'observability' ) | (string & {}); From 4b8c3cbad2c913149ce8fb387eb333193e0e26c1 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu <102503482+lokanandaprabhu@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:27:05 +0530 Subject: [PATCH 8/9] Update workspaces/orchestrator/.changeset/conditional-step-hiding-operators.md Co-authored-by: Karthik Jeeyar --- .../.changeset/conditional-step-hiding-operators.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/orchestrator/.changeset/conditional-step-hiding-operators.md b/workspaces/orchestrator/.changeset/conditional-step-hiding-operators.md index 4f385a34fc..add3fb587e 100644 --- a/workspaces/orchestrator/.changeset/conditional-step-hiding-operators.md +++ b/workspaces/orchestrator/.changeset/conditional-step-hiding-operators.md @@ -1,5 +1,5 @@ --- -'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': minor +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': patch --- Hide wizard steps when conditional `ui:hidden` rules evaluate to true, and add `isNotEmptyList`/`notContains` operators for conditional hidden expressions. From 2066050798dcf0927742fabbee942f14b39a1243 Mon Sep 17 00:00:00 2001 From: Karthik Date: Fri, 29 May 2026 19:01:43 +0530 Subject: [PATCH 9/9] detect GitHub SAML SSO session expiry and prompt users to re-authorize --- .../.changeset/nervous-dolphins-compete.md | 8 ++ .../orchestrator-form-api/report.api.md | 3 +- .../plugins/orchestrator-form-api/src/api.ts | 1 + .../orchestrator-form-react/report.api.md | 1 + .../src/components/OrchestratorForm.tsx | 4 + .../src/components/SingleStepForm.tsx | 1 + .../src/utils/useFetch.ts | 12 ++ .../src/widgets/ActiveDropdown.tsx | 7 +- .../src/widgets/ActiveMultiSelect.tsx | 7 +- .../src/widgets/ActiveTextInput.tsx | 7 +- .../src/widgets/SchemaUpdater.tsx | 7 +- .../plugins/orchestrator/report-alpha.api.md | 5 + .../plugins/orchestrator/report.api.md | 5 + .../ExecuteWorkflowPage.tsx | 15 ++- .../WorkflowInstancePage.tsx | 24 +++- .../WorkflowInstancePage/WorkflowResult.tsx | 16 +++ .../components/ui/SamlSsoExpiredDialog.tsx | 73 ++++++++++++ .../orchestrator/src/translations/de.ts | 8 ++ .../orchestrator/src/translations/es.ts | 8 ++ .../orchestrator/src/translations/fr.ts | 9 +- .../orchestrator/src/translations/it.ts | 8 ++ .../orchestrator/src/translations/ja.ts | 8 ++ .../orchestrator/src/translations/ref.ts | 9 ++ .../orchestrator/src/utils/ErrorUtils.test.ts | 104 ++++++++++++++++++ .../orchestrator/src/utils/ErrorUtils.ts | 26 +++++ 25 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 workspaces/orchestrator/.changeset/nervous-dolphins-compete.md create mode 100644 workspaces/orchestrator/plugins/orchestrator/src/components/ui/SamlSsoExpiredDialog.tsx create mode 100644 workspaces/orchestrator/plugins/orchestrator/src/utils/ErrorUtils.test.ts diff --git a/workspaces/orchestrator/.changeset/nervous-dolphins-compete.md b/workspaces/orchestrator/.changeset/nervous-dolphins-compete.md new file mode 100644 index 0000000000..ed0f170e35 --- /dev/null +++ b/workspaces/orchestrator/.changeset/nervous-dolphins-compete.md @@ -0,0 +1,8 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': patch +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-api': patch +'@red-hat-developer-hub/backstage-plugin-orchestrator': patch +--- + +detect GitHub SAML SSO session expiry and prompt users to re-authorize diff --git a/workspaces/orchestrator/plugins/orchestrator-form-api/report.api.md b/workspaces/orchestrator/plugins/orchestrator-form-api/report.api.md index 7e0e044069..603d0d773c 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-api/report.api.md +++ b/workspaces/orchestrator/plugins/orchestrator-form-api/report.api.md @@ -53,6 +53,7 @@ export type OrchestratorFormContextProps = { setIsChangedByUser: (id: string, isChangedByUser: boolean) => void; handleFetchStarted?: () => void; handleFetchEnded?: () => void; + onSamlSsoError?: (error: Error) => void; }; // @public @@ -90,7 +91,7 @@ export const useOrchestratorFormApiOrDefault: () => OrchestratorFormApi; // Warnings were encountered during analysis: // -// src/api.d.ts:131:22 - (ae-undocumented) Missing documentation for "useOrchestratorFormApiOrDefault". +// src/api.d.ts:132:22 - (ae-undocumented) Missing documentation for "useOrchestratorFormApiOrDefault". // (No @packageDocumentation comment for this package) ``` diff --git a/workspaces/orchestrator/plugins/orchestrator-form-api/src/api.ts b/workspaces/orchestrator/plugins/orchestrator-form-api/src/api.ts index 97143d70b4..5091ce73fe 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-api/src/api.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-api/src/api.ts @@ -46,6 +46,7 @@ export type OrchestratorFormContextProps = { setIsChangedByUser: (id: string, isChangedByUser: boolean) => void; handleFetchStarted?: () => void; handleFetchEnded?: () => void; + onSamlSsoError?: (error: Error) => void; }; /** diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/report.api.md b/workspaces/orchestrator/plugins/orchestrator-form-react/report.api.md index a1adf76888..24e8935ef8 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/report.api.md +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/report.api.md @@ -58,6 +58,7 @@ export type OrchestratorFormProps = { schema: JSONSchema7; updateSchema: OrchestratorFormContextProps['updateSchema']; setAuthTokenDescriptors: OrchestratorFormContextProps['setAuthTokenDescriptors']; + onSamlSsoError?: OrchestratorFormContextProps['onSamlSsoError']; isExecuting: boolean; handleExecute: (parameters: JsonObject) => Promise; handleExecuteAsEvent?: (parameters: JsonObject) => Promise; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorForm.tsx b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorForm.tsx index b31a0200ab..b4494e87c6 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorForm.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorForm.tsx @@ -57,6 +57,7 @@ export type OrchestratorFormProps = { schema: JSONSchema7; updateSchema: OrchestratorFormContextProps['updateSchema']; setAuthTokenDescriptors: OrchestratorFormContextProps['setAuthTokenDescriptors']; + onSamlSsoError?: OrchestratorFormContextProps['onSamlSsoError']; isExecuting: boolean; handleExecute: (parameters: JsonObject) => Promise; handleExecuteAsEvent?: (parameters: JsonObject) => Promise; @@ -157,6 +158,7 @@ const OrchestratorForm = ({ isExecuting, initialFormData, setAuthTokenDescriptors, + onSamlSsoError, t, executeLabel, executeAsEventLabel, @@ -270,6 +272,7 @@ const OrchestratorForm = ({ formData={formData} setFormData={setFormData} setAuthTokenDescriptors={setAuthTokenDescriptors} + onSamlSsoError={onSamlSsoError} getIsChangedByUser={getIsChangedByUser} setIsChangedByUser={setIsChangedByUser} > @@ -284,6 +287,7 @@ const OrchestratorForm = ({ formData={formData} setFormData={setFormData} setAuthTokenDescriptors={setAuthTokenDescriptors} + onSamlSsoError={onSamlSsoError} getIsChangedByUser={getIsChangedByUser} setIsChangedByUser={setIsChangedByUser} /> diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/SingleStepForm.tsx b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/SingleStepForm.tsx index 892b2dffeb..d89adfbf79 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/SingleStepForm.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/SingleStepForm.tsx @@ -32,6 +32,7 @@ type SingleStepFormProps = Pick< | 'formData' | 'setFormData' | 'setAuthTokenDescriptors' + | 'onSamlSsoError' | 'getIsChangedByUser' | 'setIsChangedByUser' >; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts index 537ab3d4d3..270d3de9a8 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts @@ -48,6 +48,7 @@ export const useFetch = ( formData: JsonObject, uiProps: UiProps, retrigger: ReturnType, + onSamlSsoError?: (error: Error) => void, ) => { const fetchApi = useApi(fetchApiRef); @@ -173,7 +174,18 @@ export const useFetch = ( () => fetchApi.fetch(evaluatedFetchUrl, evaluatedRequestInit), retryOptions, ); + if (!response.ok) { + const ssoHeader = response.headers.get('x-github-sso'); + if (response.status === 403 && ssoHeader) { + const urlMatch = ssoHeader.match(/url=(\S+)/); + const reauthorizeUrl = urlMatch?.[1]; + const samlError = new Error( + `GitHub SAML SSO session expired.${reauthorizeUrl ? ` Re-authorize at: ${reauthorizeUrl}` : ''}`, + ); + onSamlSsoError?.(samlError); + return; + } throw new Error( `Request ${evaluatedFetchUrl} returned status ${response.status}. Status text: ${response.statusText}.`, ); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx index df9db62c81..7f053db938 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx @@ -101,7 +101,12 @@ export const ActiveDropdown: Widget< uiProps['fetch:retrigger'] as string[], ); - const { data, error, loading } = useFetch(formData ?? {}, uiProps, retrigger); + const { data, error, loading } = useFetch( + formData ?? {}, + uiProps, + retrigger, + formContext?.onSamlSsoError, + ); // Track the complete loading state (fetch + processing) const { completeLoading, wrapProcessing } = useProcessingState( diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx index 16ccca9198..1e8ee12ec6 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx @@ -142,7 +142,12 @@ export const ActiveMultiSelect: Widget< uiProps['fetch:retrigger'] as string[], ); - const { data, error, loading } = useFetch(formData ?? {}, uiProps, retrigger); + const { data, error, loading } = useFetch( + formData ?? {}, + uiProps, + retrigger, + formContext?.onSamlSsoError, + ); // Track the complete loading state (fetch + processing) const { completeLoading, wrapProcessing } = useProcessingState( diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx index 04bc568d78..e5e2872256 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx @@ -96,7 +96,12 @@ export const ActiveTextInput: Widget< uiProps['fetch:retrigger'] as string[], ); - const { data, error, loading } = useFetch(formData ?? {}, uiProps, retrigger); + const { data, error, loading } = useFetch( + formData ?? {}, + uiProps, + retrigger, + formContext?.onSamlSsoError, + ); // Track the complete loading state (fetch + processing) const { completeLoading, wrapProcessing } = useProcessingState( diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/SchemaUpdater.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/SchemaUpdater.tsx index 64f71d1497..cfeb0c3da2 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/SchemaUpdater.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/SchemaUpdater.tsx @@ -63,7 +63,12 @@ export const SchemaUpdater: Widget< uiProps['fetch:retrigger'] as string[], ); - const { data, error, loading } = useFetch(formData ?? {}, uiProps, retrigger); + const { data, error, loading } = useFetch( + formData ?? {}, + uiProps, + retrigger, + formContext?.onSamlSsoError, + ); // Track the complete loading state (fetch + processing) const { completeLoading, wrapProcessing } = useProcessingState( diff --git a/workspaces/orchestrator/plugins/orchestrator/report-alpha.api.md b/workspaces/orchestrator/plugins/orchestrator/report-alpha.api.md index 3cade1af0d..3763a69f13 100644 --- a/workspaces/orchestrator/plugins/orchestrator/report-alpha.api.md +++ b/workspaces/orchestrator/plugins/orchestrator/report-alpha.api.md @@ -387,6 +387,11 @@ export const orchestratorTranslationRef: TranslationRef< readonly 'alerts.duplicateWorkflowIds.learnMore': string; readonly 'stepperObjectField.error': string; readonly 'formDecorator.error': string; + readonly 'samlSso.body': string; + readonly 'samlSso.title': string; + readonly 'samlSso.reauthorizeButton': string; + readonly 'samlSso.reauthorizeHint': string; + readonly 'samlSso.fallbackHint': string; readonly 'aria.close': string; } >; diff --git a/workspaces/orchestrator/plugins/orchestrator/report.api.md b/workspaces/orchestrator/plugins/orchestrator/report.api.md index b388ebc8e3..17119f6edf 100644 --- a/workspaces/orchestrator/plugins/orchestrator/report.api.md +++ b/workspaces/orchestrator/plugins/orchestrator/report.api.md @@ -181,6 +181,11 @@ export const orchestratorTranslationRef: TranslationRef< readonly 'alerts.duplicateWorkflowIds.learnMore': string; readonly 'stepperObjectField.error': string; readonly 'formDecorator.error': string; + readonly 'samlSso.body': string; + readonly 'samlSso.title': string; + readonly 'samlSso.reauthorizeButton': string; + readonly 'samlSso.reauthorizeHint': string; + readonly 'samlSso.fallbackHint': string; readonly 'aria.close': string; } >; diff --git a/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/ExecuteWorkflowPage.tsx b/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/ExecuteWorkflowPage.tsx index 9f639245ee..2e42a0325b 100644 --- a/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/ExecuteWorkflowPage.tsx +++ b/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/ExecuteWorkflowPage.tsx @@ -55,9 +55,14 @@ import { workflowInstanceRouteRef, workflowRunsRouteRef, } from '../../routes'; -import { getErrorObject } from '../../utils/ErrorUtils'; +import { + extractSsoReauthorizeUrl, + getErrorObject, + isSamlSsoError, +} from '../../utils/ErrorUtils'; import { buildUrl } from '../../utils/UrlUtils'; import { BaseOrchestratorPage } from '../ui/BaseOrchestratorPage'; +import { SamlSsoExpiredDialog } from '../ui/SamlSsoExpiredDialog'; import MissingSchemaNotice from './MissingSchemaNotice'; import { mergeQueryParamsIntoFormData } from './queryParamsToFormData'; import { getSchemaUpdater } from './schemaUpdater'; @@ -204,11 +209,16 @@ export const ExecuteWorkflowPage = () => { } else { pageContent = ( - {updateError && ( + {updateError && !isSamlSsoError(updateError) && ( )} + setUpdateError(undefined)} + /> {!!schema ? ( @@ -222,6 +232,7 @@ export const ExecuteWorkflowPage = () => { isExecuting={isExecuting} initialFormData={initialFormData} setAuthTokenDescriptors={setAuthTokenDescriptors} + onSamlSsoError={err => setUpdateError(err)} t={t as unknown as TranslationFunction} executeLabel={t('common.run')} executeAsEventLabel={ diff --git a/workspaces/orchestrator/plugins/orchestrator/src/components/WorkflowInstancePage/WorkflowInstancePage.tsx b/workspaces/orchestrator/plugins/orchestrator/src/components/WorkflowInstancePage/WorkflowInstancePage.tsx index 700ec979d3..8a05b04cc8 100644 --- a/workspaces/orchestrator/plugins/orchestrator/src/components/WorkflowInstancePage/WorkflowInstancePage.tsx +++ b/workspaces/orchestrator/plugins/orchestrator/src/components/WorkflowInstancePage/WorkflowInstancePage.tsx @@ -73,6 +73,10 @@ import { } from '../../routes'; import { orchestratorTranslationRef } from '../../translations'; import { deepSearchObject } from '../../utils/deepSearchObject'; +import { + extractSsoReauthorizeUrl, + isSamlSsoError, +} from '../../utils/ErrorUtils'; import { isNonNullable } from '../../utils/TypeGuards'; import { buildUrl } from '../../utils/UrlUtils'; import { BaseOrchestratorPage } from '../ui/BaseOrchestratorPage'; @@ -82,6 +86,7 @@ import { isAccessDeniedError, PermissionDeniedPanel, } from '../ui/PermissionDeniedPanel'; +import { SamlSsoExpiredDialog } from '../ui/SamlSsoExpiredDialog'; import { WorkflowInstancePageContent } from './WorkflowInstancePageContent'; const useStyles = makeStyles()(theme => ({ @@ -232,6 +237,7 @@ export const WorkflowInstancePage = () => { const [isRetrigger, setIsRetrigger] = useState(false); const [isRetriggerSnackbarOpen, setIsRetriggerSnackbarOpen] = useState(false); const [retriggerError, setRetriggerError] = useState(''); + const [samlSsoError, setSamlSsoError] = useState(); const handleAbortBarClose = () => { setIsAbortSnackbarOpen(false); @@ -359,8 +365,17 @@ export const WorkflowInstancePage = () => { authTokens, ); restart(); - } catch (retriggerInstanceError) { - if (retriggerInstanceError.toString().includes('Failed Node ID')) { + } catch (retriggerInstanceError: any) { + const retriggerErr = + retriggerInstanceError instanceof globalThis.Error + ? retriggerInstanceError + : new globalThis.Error(String(retriggerInstanceError)); + if (isSamlSsoError(retriggerErr)) { + setSamlSsoError(retriggerErr); + return; + } else if ( + retriggerInstanceError.toString().includes('Failed Node ID') + ) { setRetriggerError(t('workflow.buttons.runFailedAgain')); } else { setRetriggerError( @@ -568,6 +583,11 @@ export const WorkflowInstancePage = () => { + setSamlSsoError(undefined)} + /> ) : null} diff --git a/workspaces/orchestrator/plugins/orchestrator/src/components/WorkflowInstancePage/WorkflowResult.tsx b/workspaces/orchestrator/plugins/orchestrator/src/components/WorkflowInstancePage/WorkflowResult.tsx index ccf09bd65e..5d0277cf99 100644 --- a/workspaces/orchestrator/plugins/orchestrator/src/components/WorkflowInstancePage/WorkflowResult.tsx +++ b/workspaces/orchestrator/plugins/orchestrator/src/components/WorkflowInstancePage/WorkflowResult.tsx @@ -48,8 +48,13 @@ import { orchestratorApiRef } from '../../api'; import { useLogsEnabled } from '../../hooks/useLogsEnabled'; import { useTranslation } from '../../hooks/useTranslation'; import { executeWorkflowRouteRef } from '../../routes'; +import { + extractSsoReauthorizeUrl, + isSamlSsoError, +} from '../../utils/ErrorUtils'; import { buildUrl } from '../../utils/UrlUtils'; import { Trans } from '../Trans'; +import { SamlSsoExpiredDialog } from '../ui/SamlSsoExpiredDialog'; import { WorkflowDescriptionModal, WorkflowDescriptionModalProps, @@ -416,6 +421,12 @@ export const WorkflowResult: React.FC<{ ); const logsEnabled = useLogsEnabled(); + const errorObj = instance.error?.message + ? new Error(instance.error.message) + : undefined; + const hasSamlError = isSamlSsoError(errorObj); + const [isSamlDialogOpen, setIsSamlDialogOpen] = useState(hasSamlError); + return ( <> + setIsSamlDialogOpen(false)} + /> ); }; diff --git a/workspaces/orchestrator/plugins/orchestrator/src/components/ui/SamlSsoExpiredDialog.tsx b/workspaces/orchestrator/plugins/orchestrator/src/components/ui/SamlSsoExpiredDialog.tsx new file mode 100644 index 0000000000..1cfa62902d --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator/src/components/ui/SamlSsoExpiredDialog.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import WarningAmberOutlined from '@mui/icons-material/WarningAmberOutlined'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { InfoDialog } from './InfoDialog'; + +export interface SamlSsoExpiredDialogProps { + open: boolean; + reauthorizeUrl?: string; + onClose: () => void; +} + +export const SamlSsoExpiredDialog: React.FC = ({ + open, + reauthorizeUrl, + onClose, +}) => { + const { t } = useTranslation(); + return ( + } + open={open} + onClose={onClose} + dialogActions={ + <> + {reauthorizeUrl && ( + + )} + + + } + > + + {t('samlSso.body')} + + + {reauthorizeUrl + ? t('samlSso.reauthorizeHint') + : t('samlSso.fallbackHint')} + + + ); +}; + +SamlSsoExpiredDialog.displayName = 'SamlSsoExpiredDialog'; diff --git a/workspaces/orchestrator/plugins/orchestrator/src/translations/de.ts b/workspaces/orchestrator/plugins/orchestrator/src/translations/de.ts index b72d5b9179..ae9a8025b6 100644 --- a/workspaces/orchestrator/plugins/orchestrator/src/translations/de.ts +++ b/workspaces/orchestrator/plugins/orchestrator/src/translations/de.ts @@ -202,6 +202,14 @@ const orchestratorTranslationDe = createTranslationMessages({ 'workflow.progress': 'Workflow-Fortschritt', 'workflow.status.available': 'Verfügbar', 'workflow.status.unavailable': 'Nicht verfügbar', + 'samlSso.title': 'GitHub SAML SSO-Sitzung abgelaufen', + 'samlSso.reauthorizeButton': 'SSO erneut autorisieren', + 'samlSso.body': + 'Ihre GitHub SAML SSO-Sitzung ist abgelaufen. Ihre Organisation erfordert eine aktive SAML-Sitzung, um auf ihre Ressourcen zugreifen zu können.', + 'samlSso.reauthorizeHint': + "Klicken Sie auf 'SSO erneut autorisieren', um sich bei dem Identitätsanbieter Ihrer Organisation erneut zu authentifizieren.", + 'samlSso.fallbackHint': + 'Bitte melden Sie sich ab und über Einstellungen > Auth-Anbieter erneut an, um Ihre SAML-Sitzung wiederherzustellen.', }, }); diff --git a/workspaces/orchestrator/plugins/orchestrator/src/translations/es.ts b/workspaces/orchestrator/plugins/orchestrator/src/translations/es.ts index 62e7641836..bf75516ba8 100644 --- a/workspaces/orchestrator/plugins/orchestrator/src/translations/es.ts +++ b/workspaces/orchestrator/plugins/orchestrator/src/translations/es.ts @@ -204,6 +204,14 @@ const orchestratorTranslationEs = createTranslationMessages({ 'workflow.progress': 'Progreso del flujo de trabajo', 'workflow.status.available': 'Disponible', 'workflow.status.unavailable': 'No disponible', + 'samlSso.title': 'Sesión de GitHub SAML SSO expirada', + 'samlSso.reauthorizeButton': 'Reautorizar SSO', + 'samlSso.body': + 'Su sesión de GitHub SAML SSO ha expirado. Su organización requiere una sesión SAML activa para acceder a sus recursos.', + 'samlSso.reauthorizeHint': + "Haga clic en 'Reautorizar SSO' para volver a autenticarse con el proveedor de identidad de su organización.", + 'samlSso.fallbackHint': + 'Por favor, cierre sesión y vuelva a iniciar sesión desde Configuración > Proveedores de autenticación para restablecer su sesión SAML.', }, }); diff --git a/workspaces/orchestrator/plugins/orchestrator/src/translations/fr.ts b/workspaces/orchestrator/plugins/orchestrator/src/translations/fr.ts index cb77816677..fb22a110fa 100644 --- a/workspaces/orchestrator/plugins/orchestrator/src/translations/fr.ts +++ b/workspaces/orchestrator/plugins/orchestrator/src/translations/fr.ts @@ -202,7 +202,14 @@ const orchestratorTranslationFr = createTranslationMessages({ "Le flux de travail est actuellement interrompu ou en état d'erreur. Son exécution maintenant peut échouer ou produire des résultats inattendus.", 'workflow.progress': 'Avancement du flux de travail', 'workflow.status.available': 'Disponible', - 'workflow.status.unavailable': 'Non disponible', + 'samlSso.title': 'Session GitHub SAML SSO expirée', + 'samlSso.reauthorizeButton': 'Réautoriser SSO', + 'samlSso.body': + 'Votre session GitHub SAML SSO a expiré. Votre organisation nécessite une session SAML active pour accéder à ses ressources.', + 'samlSso.reauthorizeHint': + "Cliquez sur 'Réautoriser SSO' pour vous réauthentifier auprès du fournisseur d'identité de votre organisation.", + 'samlSso.fallbackHint': + "Veuillez vous déconnecter et vous reconnecter depuis Paramètres > Fournisseurs d'authentification pour rétablir votre session SAML.", }, }); diff --git a/workspaces/orchestrator/plugins/orchestrator/src/translations/it.ts b/workspaces/orchestrator/plugins/orchestrator/src/translations/it.ts index 8f164b4f0f..8ce003c474 100644 --- a/workspaces/orchestrator/plugins/orchestrator/src/translations/it.ts +++ b/workspaces/orchestrator/plugins/orchestrator/src/translations/it.ts @@ -203,6 +203,14 @@ const orchestratorTranslationIt = createTranslationMessages({ 'workflow.progress': 'Avanzamento del flusso di lavoro', 'workflow.status.available': 'Disponibile', 'workflow.status.unavailable': 'Non disponibile', + 'samlSso.title': 'Sessione GitHub SAML SSO scaduta', + 'samlSso.reauthorizeButton': 'Riautorizza SSO', + 'samlSso.body': + 'La sessione GitHub SAML SSO è scaduta. La tua organizzazione richiede una sessione SAML attiva per accedere alle sue risorse.', + 'samlSso.reauthorizeHint': + "Fai clic su 'Riautorizza SSO' per riautenticarti con il provider di identità della tua organizzazione.", + 'samlSso.fallbackHint': + 'Disconnettiti e accedi nuovamente da Impostazioni > Provider di autenticazione per ristabilire la sessione SAML.', }, }); diff --git a/workspaces/orchestrator/plugins/orchestrator/src/translations/ja.ts b/workspaces/orchestrator/plugins/orchestrator/src/translations/ja.ts index dfc41423ab..6b2c17e1c9 100644 --- a/workspaces/orchestrator/plugins/orchestrator/src/translations/ja.ts +++ b/workspaces/orchestrator/plugins/orchestrator/src/translations/ja.ts @@ -199,6 +199,14 @@ const orchestratorTranslationJa = createTranslationMessages({ 'workflow.progress': 'ワークフロー進捗', 'workflow.status.available': '利用可能', 'workflow.status.unavailable': '利用不可', + 'samlSso.title': 'GitHub SAML SSO セッションの有効期限切れ', + 'samlSso.reauthorizeButton': 'SSO を再認証', + 'samlSso.body': + 'GitHub SAML SSO セッションの有効期限が切れました。組織のリソースにアクセスするには、有効な SAML セッションが必要です。', + 'samlSso.reauthorizeHint': + "'SSO を再認証' をクリックして、組織のアイデンティティプロバイダーで再認証してください。", + 'samlSso.fallbackHint': + '設定 > 認証プロバイダー からサインアウトし、再度サインインして SAML セッションを再確立してください。', }, }); diff --git a/workspaces/orchestrator/plugins/orchestrator/src/translations/ref.ts b/workspaces/orchestrator/plugins/orchestrator/src/translations/ref.ts index d7ea541dfb..c54cb640f3 100644 --- a/workspaces/orchestrator/plugins/orchestrator/src/translations/ref.ts +++ b/workspaces/orchestrator/plugins/orchestrator/src/translations/ref.ts @@ -241,6 +241,15 @@ export const orchestratorMessages = { formDecorator: { error: 'Form decorator must provide context data.', }, + samlSso: { + title: 'GitHub SAML SSO Session Expired', + reauthorizeButton: 'Re-authorize SSO', + body: 'Your GitHub SAML SSO session has expired. Your organization requires an active SAML session to access its resources.', + reauthorizeHint: + "Click 'Re-authorize SSO' to re-authenticate with your organization's identity provider.", + fallbackHint: + 'Please sign out and sign back in from Settings > Auth Providers to re-establish your SAML session.', + }, aria: { close: 'close', }, diff --git a/workspaces/orchestrator/plugins/orchestrator/src/utils/ErrorUtils.test.ts b/workspaces/orchestrator/plugins/orchestrator/src/utils/ErrorUtils.test.ts new file mode 100644 index 0000000000..76a4ff8693 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator/src/utils/ErrorUtils.test.ts @@ -0,0 +1,104 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { extractSsoReauthorizeUrl, isSamlSsoError } from './ErrorUtils'; + +describe('isSamlSsoError', () => { + it('returns true for GitHub SAML SSO expired message', () => { + expect(isSamlSsoError(new Error('GitHub SAML SSO session expired.'))).toBe( + true, + ); + }); + + it('returns true for GitHub organization SAML enforcement message', () => { + expect( + isSamlSsoError( + new Error('GitHub resource protected by organization SAML enforcement'), + ), + ).toBe(true); + }); + + it('returns false for non-GitHub SAML errors', () => { + expect( + isSamlSsoError( + new Error('Resource protected by organization SAML enforcement'), + ), + ).toBe(false); + }); + + it('returns false for unrelated errors', () => { + expect(isSamlSsoError(new Error('Network timeout'))).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isSamlSsoError(undefined)).toBe(false); + }); + + it('returns false for empty error message', () => { + expect(isSamlSsoError(new Error(''))).toBe(false); + }); + + it('is case-insensitive for github keyword', () => { + expect(isSamlSsoError(new Error('GITHUB SAML SSO session expired.'))).toBe( + true, + ); + }); + + it('returns true for x-github-sso header indicator', () => { + expect( + isSamlSsoError( + new Error('GitHub request failed with x-github-sso header'), + ), + ).toBe(true); + }); + + it('returns true for grant oauth token access on GitHub', () => { + expect( + isSamlSsoError( + new Error( + 'GitHub: You must grant your OAuth token access to this organization', + ), + ), + ).toBe(true); + }); +}); + +describe('extractSsoReauthorizeUrl', () => { + it('extracts URL from error message', () => { + const err = new Error( + 'GitHub SAML SSO session expired. Re-authorize at: https://github.com/orgs/acme/sso?sso=abc', + ); + expect(extractSsoReauthorizeUrl(err)).toBe( + 'https://github.com/orgs/acme/sso?sso=abc', + ); + }); + + it('returns undefined when no URL present', () => { + expect( + extractSsoReauthorizeUrl(new Error('Some other error')), + ).toBeUndefined(); + }); + + it('returns undefined for undefined error', () => { + expect(extractSsoReauthorizeUrl(undefined)).toBeUndefined(); + }); + + it('returns undefined when message has no Re-authorize prefix', () => { + expect( + extractSsoReauthorizeUrl(new Error('GitHub SAML SSO session expired.')), + ).toBeUndefined(); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator/src/utils/ErrorUtils.ts b/workspaces/orchestrator/plugins/orchestrator/src/utils/ErrorUtils.ts index 7dd69458eb..b94e568cdc 100644 --- a/workspaces/orchestrator/plugins/orchestrator/src/utils/ErrorUtils.ts +++ b/workspaces/orchestrator/plugins/orchestrator/src/utils/ErrorUtils.ts @@ -25,3 +25,29 @@ export const getErrorObject = (err: unknown): Error => { } return new Error('Unexpected error'); }; + +const SAML_SSO_INDICATORS = [ + 'saml sso session expired', + 'x-github-sso', + 're-authorize at:', + 'saml session', + 'saml re-authorization', + 'organization saml enforcement', + 'grant your oauth token access', + 'saml enforcement', +]; + +export const isSamlSsoError = (error: Error | undefined): boolean => { + if (!error?.message) return false; + const message = error.message.toLowerCase(); + if (!message.includes('github')) return false; + return SAML_SSO_INDICATORS.some(indicator => message.includes(indicator)); +}; + +export const extractSsoReauthorizeUrl = ( + error: Error | undefined, +): string | undefined => { + if (!error?.message) return undefined; + const match = error.message.match(/Re-authorize at:\s*(\S+)/i); + return match?.[1]; +};