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..add3fb587e --- /dev/null +++ b/workspaces/orchestrator/.changeset/conditional-step-hiding-operators.md @@ -0,0 +1,5 @@ +--- +'@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. 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..0406f00f9d --- /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 +--- + +Remove unnecessary gaps from conditionally hidden form fields. 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/.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-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); }); 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 39b086e65f..24e8935ef8 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; } @@ -56,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/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/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/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/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-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 a0aa42e1f8..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 @@ -20,7 +20,10 @@ import { HiddenCondition, HiddenConditionObject, } from '../types/HiddenCondition'; -import { evaluateHiddenCondition } from './evaluateHiddenCondition'; +import { + evaluateHiddenCondition, + getValueForWhen, +} from './evaluateHiddenCondition'; describe('evaluateHiddenCondition', () => { describe('boolean conditions', () => { @@ -121,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', () => { @@ -185,4 +251,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..4429343526 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,33 +58,89 @@ 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, + ); + + 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; } /** @@ -91,19 +148,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 +172,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 +188,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) { 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}.`), 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]; +};