diff --git a/.changeset/real-swans-leave.md b/.changeset/real-swans-leave.md new file mode 100644 index 0000000000..3e415a19ec --- /dev/null +++ b/.changeset/real-swans-leave.md @@ -0,0 +1,5 @@ +--- +'@forgerock/davinci-client': minor +--- + +Add MetadataCollector and error helper on client diff --git a/e2e/davinci-app/components/metadata.ts b/e2e/davinci-app/components/metadata.ts new file mode 100644 index 0000000000..b69a8bd091 --- /dev/null +++ b/e2e/davinci-app/components/metadata.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import type { MetadataCollector, MetadataError, Updater } from '@forgerock/davinci-client/types'; + +export default function metadataComponent( + formEl: HTMLFormElement, + updater: Updater, + getMetadataError: (errorDetails: MetadataError) => MetadataError, + submitForm: () => Promise, +) { + const successBtn = document.createElement('button'); + successBtn.type = 'button'; + successBtn.innerHTML = 'Metadata Success'; + + const errorBtn = document.createElement('button'); + errorBtn.type = 'button'; + errorBtn.innerHTML = 'Metadata Error'; + + formEl.appendChild(successBtn); + formEl.appendChild(errorBtn); + + successBtn.onclick = async () => { + updater({ status: 'succeeded' }); + await submitForm(); + }; + + errorBtn.onclick = async () => { + const metadataError = getMetadataError({ code: 'ERROR_CODE', message: 'Operation cancelled' }); + updater(metadataError); + await submitForm(); + }; +} diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index 36a3416ad0..15e017bdea 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -38,6 +38,7 @@ import qrCodeComponent from './components/qr-code.js'; import formImageComponent from './components/form-image.js'; import pollingComponent from './components/polling.js'; import booleanComponent from './components/boolean.js'; +import metadataComponent from './components/metadata.js'; const loggerFn = { error: () => { @@ -289,6 +290,13 @@ const urlParams = new URLSearchParams(window.location.search); davinciClient.update(collector), // Returns an update function for this collector submitForm, ); + } else if (collector.type === 'MetadataCollector') { + metadataComponent( + formEl, // You can ignore this; it's just for rendering + davinciClient.update(collector), // Returns an update function for this collector + davinciClient.getMetadataError, + submitForm, + ); } else if (collector.type === 'BooleanCollector') { booleanComponent(formEl, collector, davinciClient.update(collector)); } else if (collector.type === 'ValidatedBooleanCollector') { diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index 1b952496d5..10ea6f0c31 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -140,7 +140,7 @@ export interface AutoCollector | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | ImageCollector | UnknownCollector; +export type Collectors = FlowCollector | MetadataCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector | BooleanCollector | ValidatedBooleanCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | ImageCollector | UnknownCollector; // @public export type CollectorValueType = T extends { @@ -211,6 +211,8 @@ export type CollectorValueType = T extends { } ? FidoRegistrationInputValue : T extends { type: 'FidoAuthenticationCollector'; } ? FidoAuthenticationInputValue : T extends { + type: 'MetadataCollector'; +} ? Record | MetadataError : T extends { category: 'SingleValueCollector'; } ? string : T extends { category: 'ValidatedSingleValueCollector'; @@ -225,10 +227,10 @@ export type CollectorValueType = T extends { } ? never : CollectorValueTypes; // @public -export type CollectorValueTypes = string | string[] | boolean | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; +export type CollectorValueTypes = string | string[] | boolean | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue | MetadataError; // @public (undocumented) -export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField; +export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField | MetadataField; // @public (undocumented) export interface ContinueNode { @@ -283,16 +285,19 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; pollStatus: (collector: PollingCollector) => Poller; + getMetadataError: (errorDetails: MetadataError) => MetadataError; getClient: () => { action: string; collectors: Collectors[]; description?: string; name?: string; status: "continue"; + } | { + status: "start"; } | { action: string; collectors: Collectors[]; @@ -301,8 +306,6 @@ export function davinci(input: { status: "error"; } | { status: "failure"; - } | { - status: "start"; } | { authorization?: { code?: string; @@ -313,7 +316,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; + getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -322,6 +325,8 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -337,8 +342,6 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -355,14 +358,14 @@ export function davinci(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required = T extends 'IdpCollector' ? IdpCollector : T extends 'SubmitCollector' ? SubmitCollector : T extends 'FlowCollector' ? FlowCollector : ActionCollectorWithUrl<'ActionCollector'> | ActionCollectorNoUrl<'ActionCollector'>; // @public -export type InferAutoCollectorType = T extends 'ProtectCollector' ? ProtectCollector : T extends 'PollingCollector' ? PollingCollector : T extends 'FidoRegistrationCollector' ? FidoRegistrationCollector : T extends 'FidoAuthenticationCollector' ? FidoAuthenticationCollector : T extends 'ObjectValueAutoCollector' ? ObjectValueAutoCollector : SingleValueAutoCollector; +export type InferAutoCollectorType = T extends 'ProtectCollector' ? ProtectCollector : T extends 'PollingCollector' ? PollingCollector : T extends 'FidoRegistrationCollector' ? FidoRegistrationCollector : T extends 'FidoAuthenticationCollector' ? FidoAuthenticationCollector : T extends 'MetadataCollector' ? MetadataCollector : T extends 'ObjectValueAutoCollector' ? ObjectValueAutoCollector : SingleValueAutoCollector; // @public export type InferMultiValueCollectorType = T extends 'MultiSelectCollector' ? MultiValueCollectorWithValue<'MultiSelectCollector'> : MultiValueCollectorWithValue<'MultiValueCollector'> | MultiValueCollectorNoValue<'MultiValueCollector'>; @@ -1005,6 +1011,24 @@ export interface Links { export { LogLevel } +// @public (undocumented) +export type MetadataCollector = AutoCollector<'ObjectValueAutoCollector', 'MetadataCollector', Record, Record>; + +// @public (undocumented) +export interface MetadataError { + // (undocumented) + code: string; + // (undocumented) + message: string; +} + +// @public (undocumented) +export type MetadataField = { + type: 'METADATA'; + key: string; + payload: Record; +}; + // @public (undocumented) export type MultiSelectCollector = MultiValueCollectorWithValue<'MultiSelectCollector'>; @@ -1222,7 +1246,7 @@ export interface ObjectOptionsCollectorWithStringValue>; // @public (undocumented) -export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRegistrationCollector' | 'FidoAuthenticationCollector'; +export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRegistrationCollector' | 'FidoAuthenticationCollector' | 'MetadataCollector'; // @public (undocumented) export type ObjectValueCollector = ObjectOptionsCollectorWithObjectValue | ObjectOptionsCollectorWithStringValue | ObjectValueCollectorWithObjectValue; diff --git a/packages/davinci-client/api-report/davinci-client.types.api.md b/packages/davinci-client/api-report/davinci-client.types.api.md index 91a9488c76..4544f78c26 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -140,7 +140,7 @@ export interface AutoCollector | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | ImageCollector | UnknownCollector; +export type Collectors = FlowCollector | MetadataCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector | BooleanCollector | ValidatedBooleanCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | ImageCollector | UnknownCollector; // @public export type CollectorValueType = T extends { @@ -211,6 +211,8 @@ export type CollectorValueType = T extends { } ? FidoRegistrationInputValue : T extends { type: 'FidoAuthenticationCollector'; } ? FidoAuthenticationInputValue : T extends { + type: 'MetadataCollector'; +} ? Record | MetadataError : T extends { category: 'SingleValueCollector'; } ? string : T extends { category: 'ValidatedSingleValueCollector'; @@ -225,10 +227,10 @@ export type CollectorValueType = T extends { } ? never : CollectorValueTypes; // @public -export type CollectorValueTypes = string | string[] | boolean | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; +export type CollectorValueTypes = string | string[] | boolean | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue | MetadataError; // @public (undocumented) -export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField; +export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField | MetadataField; // @public (undocumented) export interface ContinueNode { @@ -283,16 +285,19 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; pollStatus: (collector: PollingCollector) => Poller; + getMetadataError: (errorDetails: MetadataError) => MetadataError; getClient: () => { action: string; collectors: Collectors[]; description?: string; name?: string; status: "continue"; + } | { + status: "start"; } | { action: string; collectors: Collectors[]; @@ -301,8 +306,6 @@ export function davinci(input: { status: "error"; } | { status: "failure"; - } | { - status: "start"; } | { authorization?: { code?: string; @@ -313,7 +316,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; + getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -322,6 +325,8 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -337,8 +342,6 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -355,14 +358,14 @@ export function davinci(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required = T extends 'IdpCollector' ? IdpCollector : T extends 'SubmitCollector' ? SubmitCollector : T extends 'FlowCollector' ? FlowCollector : ActionCollectorWithUrl<'ActionCollector'> | ActionCollectorNoUrl<'ActionCollector'>; // @public -export type InferAutoCollectorType = T extends 'ProtectCollector' ? ProtectCollector : T extends 'PollingCollector' ? PollingCollector : T extends 'FidoRegistrationCollector' ? FidoRegistrationCollector : T extends 'FidoAuthenticationCollector' ? FidoAuthenticationCollector : T extends 'ObjectValueAutoCollector' ? ObjectValueAutoCollector : SingleValueAutoCollector; +export type InferAutoCollectorType = T extends 'ProtectCollector' ? ProtectCollector : T extends 'PollingCollector' ? PollingCollector : T extends 'FidoRegistrationCollector' ? FidoRegistrationCollector : T extends 'FidoAuthenticationCollector' ? FidoAuthenticationCollector : T extends 'MetadataCollector' ? MetadataCollector : T extends 'ObjectValueAutoCollector' ? ObjectValueAutoCollector : SingleValueAutoCollector; // @public export type InferMultiValueCollectorType = T extends 'MultiSelectCollector' ? MultiValueCollectorWithValue<'MultiSelectCollector'> : MultiValueCollectorWithValue<'MultiValueCollector'> | MultiValueCollectorNoValue<'MultiValueCollector'>; @@ -1002,6 +1008,24 @@ export interface Links { export { LogLevel } +// @public (undocumented) +export type MetadataCollector = AutoCollector<'ObjectValueAutoCollector', 'MetadataCollector', Record, Record>; + +// @public (undocumented) +export interface MetadataError { + // (undocumented) + code: string; + // (undocumented) + message: string; +} + +// @public (undocumented) +export type MetadataField = { + type: 'METADATA'; + key: string; + payload: Record; +}; + // @public (undocumented) export type MultiSelectCollector = MultiValueCollectorWithValue<'MultiSelectCollector'>; @@ -1219,7 +1243,7 @@ export interface ObjectOptionsCollectorWithStringValue>; // @public (undocumented) -export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRegistrationCollector' | 'FidoAuthenticationCollector'; +export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRegistrationCollector' | 'FidoAuthenticationCollector' | 'MetadataCollector'; // @public (undocumented) export type ObjectValueCollector = ObjectOptionsCollectorWithObjectValue | ObjectOptionsCollectorWithStringValue | ObjectValueCollectorWithObjectValue; diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index a95401dd91..5e16d42c39 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -52,6 +52,7 @@ import type { Validator, Poller, CollectorValueTypes, + MetadataError, } from './client.types.js'; import { returnValidator } from './collector.utils.js'; import { returnPasswordPolicyValidator } from './password-policy.rules.js'; @@ -455,6 +456,18 @@ export async function davinci({ }; }, + /** + * @method getMetadataError - Constructs a structured error object from a code and message. + * @param {MetadataError} errorDetails - An error code and description. + * @returns {MetadataError} The structured error object. + */ + getMetadataError: (errorDetails: MetadataError): MetadataError => { + return { + code: errorDetails.code, + message: errorDetails.message, + }; + }, + /** * @method client - Selector to get the node.client from state * @returns {Node.client} - the client property from the current node diff --git a/packages/davinci-client/src/lib/client.types.ts b/packages/davinci-client/src/lib/client.types.ts index 50125904a9..8fdc02b53c 100644 --- a/packages/davinci-client/src/lib/client.types.ts +++ b/packages/davinci-client/src/lib/client.types.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -17,6 +17,7 @@ import type { ValidatedTextCollector, ValidatedBooleanCollector, ValidatedPasswordCollector, + DeviceValue, } from './collector.types.js'; import type { ErrorNode, FailureNode, ContinueNode, StartNode, SuccessNode } from './node.types.js'; @@ -27,6 +28,11 @@ export interface InternalErrorResponse { type: 'internal_error'; } +export interface MetadataError { + code: string; + message: string; +} + export type InitFlow = () => Promise; /** @@ -39,7 +45,22 @@ export type CollectorValueTypes = | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue - | FidoAuthenticationInputValue; + | FidoAuthenticationInputValue + | MetadataError; + +/** + * Allowed value types for DaVinci formData request bodies. This differs from `CollectorValueTypes` because input values may be transformed for DaVinci. + */ +export type DaVinciRequestValueTypes = + | string + | number + | boolean + | (string | number | boolean)[] + | DeviceValue + | PhoneNumberInputValue + | FidoRegistrationInputValue + | FidoAuthenticationInputValue + | MetadataError; /** * Maps collector types to the specific value type they accept. @@ -84,21 +105,23 @@ export type CollectorValueType = ? FidoRegistrationInputValue : T extends { type: 'FidoAuthenticationCollector' } ? FidoAuthenticationInputValue - : // category catch-alls - // fallbacks for collectors that don't match on `type` - T extends { category: 'SingleValueCollector' } - ? string - : T extends { category: 'ValidatedSingleValueCollector' } + : T extends { type: 'MetadataCollector' } + ? Record | MetadataError + : // category catch-alls + // fallbacks for collectors that don't match on `type` + T extends { category: 'SingleValueCollector' } ? string - : T extends { category: 'SingleValueAutoCollector' } + : T extends { category: 'ValidatedSingleValueCollector' } ? string - : T extends { category: 'MultiValueCollector' } - ? string[] - : T extends { category: 'ActionCollector' } - ? never - : T extends { category: 'NoValueCollector' } + : T extends { category: 'SingleValueAutoCollector' } + ? string + : T extends { category: 'MultiValueCollector' } + ? string[] + : T extends { category: 'ActionCollector' } ? never - : CollectorValueTypes; + : T extends { category: 'NoValueCollector' } + ? never + : CollectorValueTypes; /** * A function type that updates a collector's value. Accepts values appropriate for the collector type. diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index aa61c239e4..038a5e5d77 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -806,7 +806,8 @@ export type SingleValueAutoCollectorTypes = export type ObjectValueAutoCollectorTypes = | 'ObjectValueAutoCollector' | 'FidoRegistrationCollector' - | 'FidoAuthenticationCollector'; + | 'FidoAuthenticationCollector' + | 'MetadataCollector'; export type AutoCollectorTypes = SingleValueAutoCollectorTypes | ObjectValueAutoCollectorTypes; export interface AutoCollector< @@ -851,6 +852,12 @@ export type FidoAuthenticationCollector = AutoCollector< FidoAuthenticationInputValue, FidoAuthenticationOutputValue >; +export type MetadataCollector = AutoCollector< + 'ObjectValueAutoCollector', + 'MetadataCollector', + Record, + Record +>; export type PollingCollector = AutoCollector< 'SingleValueAutoCollector', 'PollingCollector', @@ -872,6 +879,7 @@ export type AutoCollectors = | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector + | MetadataCollector | PollingCollector | SingleValueAutoCollector | ObjectValueAutoCollector; @@ -891,10 +899,12 @@ export type InferAutoCollectorType = T extends 'Pr ? FidoRegistrationCollector : T extends 'FidoAuthenticationCollector' ? FidoAuthenticationCollector - : T extends 'ObjectValueAutoCollector' - ? ObjectValueAutoCollector - : /** - * At this point, we have not passed in a collector type - * so we can return a SingleValueAutoCollector - **/ - SingleValueAutoCollector; + : T extends 'MetadataCollector' + ? MetadataCollector + : T extends 'ObjectValueAutoCollector' + ? ObjectValueAutoCollector + : /** + * At this point, we have not passed in a collector type + * so we can return a SingleValueAutoCollector + **/ + SingleValueAutoCollector; diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index 0934081701..3ac6393d74 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -21,6 +21,7 @@ import { returnNoValueCollector, returnObjectSelectCollector, returnObjectValueCollector, + returnMetadataCollector, returnSingleValueAutoCollector, returnObjectValueAutoCollector, returnImageCollector, @@ -35,6 +36,7 @@ import type { DeviceAuthenticationField, DeviceRegistrationField, ImageField, + MetadataField, PasswordField, FidoAuthenticationField, FidoRegistrationField, @@ -2110,3 +2112,39 @@ describe('returnBooleanCollector', () => { expect(result.output).not.toHaveProperty('richContent'); }); }); + +describe('returnMetadataCollector', () => { + const mockField: MetadataField = { + type: 'METADATA', + key: 'metadata-key', + payload: { + sessionToken: 'abc123', + userId: 'user-456', + }, + }; + + it('should create a valid MetadataCollector with payload in output', () => { + const result = returnMetadataCollector(mockField, 0); + expect(result).toEqual({ + category: 'ObjectValueAutoCollector', + error: null, + type: 'MetadataCollector', + id: 'metadata-key-0', + name: 'metadata-key', + input: { + key: 'metadata-key', + value: {}, + type: 'METADATA', + validation: null, + }, + output: { + key: 'metadata-key', + type: 'METADATA', + config: { + sessionToken: 'abc123', + userId: 'user-456', + }, + }, + }); + }); +}); diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index 9c591999e2..c8c690a93d 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -47,6 +47,7 @@ import type { FidoAuthenticationField, FidoRegistrationField, ImageField, + MetadataField, MultiSelectField, PasswordField, PhoneNumberField, @@ -497,7 +498,7 @@ export function returnSingleValueAutoCollector< * @returns {AutoCollector} The constructed AutoCollector object. */ export function returnObjectValueAutoCollector< - Field extends FidoRegistrationField | FidoAuthenticationField, + Field extends FidoRegistrationField | FidoAuthenticationField | MetadataField, CollectorType extends ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector', >(field: Field, idx: number, collectorType: CollectorType) { let error = ''; @@ -517,7 +518,26 @@ export function returnObjectValueAutoCollector< }); } - if (field.action === 'REGISTER') { + if (field.type === 'METADATA') { + return { + category: 'ObjectValueAutoCollector', + error: error || null, + type: collectorType, + id: `${field.key}-${idx}`, + name: field.key, + input: { + key: field.key, + value: {}, + type: field.type, + validation: validationArray.length ? validationArray : null, + }, + output: { + key: field.key, + type: field.type, + config: field.payload, + }, + } as InferAutoCollectorType<'MetadataCollector'>; + } else if (field.action === 'REGISTER') { return { category: 'ObjectValueAutoCollector', error: error || null, @@ -789,6 +809,8 @@ export function returnObjectCollector< }); } + const label = 'label' in field ? field.label : ''; + let options; let defaultValue; let extensionLabel: string | null = null; @@ -884,7 +906,7 @@ export function returnObjectCollector< }, output: { key: field.key, - label: field.label, + label: label, type: field.type, ...(options && { options: options || [] }), ...(extensionLabel !== null && { extensionLabel }), @@ -912,6 +934,16 @@ export function returnObjectSelectCollector( ); } +/** + * @function returnMetadataCollector - Creates a MetadataCollector from a METADATA field. + * @param {MetadataField} field - The METADATA field from the API response. + * @param {number} idx - The index used in the collector id/name. + * @returns {MetadataCollector} The constructed MetadataCollector. + */ +export function returnMetadataCollector(field: MetadataField, idx: number) { + return returnObjectValueAutoCollector(field, idx, 'MetadataCollector'); +} + export function returnObjectValueCollector( field: PhoneNumberField | PhoneNumberExtensionField, idx: number, diff --git a/packages/davinci-client/src/lib/davinci.api.ts b/packages/davinci-client/src/lib/davinci.api.ts index c8a1915dc4..5d6f270e13 100644 --- a/packages/davinci-client/src/lib/davinci.api.ts +++ b/packages/davinci-client/src/lib/davinci.api.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -168,7 +168,12 @@ export const davinciApi = createApi({ } if (!body) { - requestBody = transformSubmitRequest(state.node, logger); + const hasMetadataCollector = state.node.client?.collectors?.some( + (collector) => collector.type === 'MetadataCollector', + ); + requestBody = hasMetadataCollector + ? transformActionRequest(state.node, state.node.client?.action, logger) + : transformSubmitRequest(state.node, logger); } else { requestBody = body; } diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index b94244171a..63a092b703 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -327,6 +327,12 @@ export type PollingField = { challenge?: string; }; +export type MetadataField = { + type: 'METADATA'; + key: string; + payload: Record; +}; + export type UnknownField = Record; export type ComplexValueFields = @@ -336,7 +342,8 @@ export type ComplexValueFields = | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField - | PollingField; + | PollingField + | MetadataField; export type MultiValueFields = MultiSelectField; export type ReadOnlyFields = ReadOnlyField | QrCodeField | AgreementField | ImageField; export type RedirectFields = RedirectField; diff --git a/packages/davinci-client/src/lib/davinci.utils.ts b/packages/davinci-client/src/lib/davinci.utils.ts index f3271d1017..db14770593 100644 --- a/packages/davinci-client/src/lib/davinci.utils.ts +++ b/packages/davinci-client/src/lib/davinci.utils.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -22,12 +22,8 @@ import type { DaVinciSuccessResponse, } from './davinci.types.js'; import type { ContinueNode } from './node.types.js'; -import { - DeviceValue, - FidoAuthenticationInputValue, - FidoRegistrationInputValue, - PhoneNumberInputValue, -} from './collector.types.js'; +import { DaVinciRequestValueTypes } from './client.types.js'; + /** * @function transformSubmitRequest - Transforms a NextNode into a DaVinciRequest for form submissions * @param {ContinueNode} node - The node to transform into a DaVinciRequest @@ -50,15 +46,7 @@ export function transformSubmitRequest( ); const formData = collectors?.reduce<{ - [key: string]: - | string - | number - | boolean - | (string | number | boolean)[] - | DeviceValue - | PhoneNumberInputValue - | FidoRegistrationInputValue - | FidoAuthenticationInputValue; + [key: string]: DaVinciRequestValueTypes; }>((acc, collector) => { acc[collector.input.key] = collector.input.value; return acc; @@ -100,7 +88,26 @@ export function transformActionRequest( action: string, logger: ReturnType, ): DaVinciRequest { + // Filter out ActionCollectors as they do not collect values + const collectors = node.client?.collectors?.filter( + (collector) => + collector.category === 'MultiValueCollector' || + collector.category === 'SingleValueCollector' || + collector.category === 'ValidatedSingleValueCollector' || + collector.category === 'ObjectValueCollector' || + collector.category === 'SingleValueAutoCollector' || + collector.category === 'ObjectValueAutoCollector', + ); + + const formData = collectors?.reduce<{ + [key: string]: DaVinciRequestValueTypes | Record; + }>((acc, collector) => { + acc[collector.input.key] = collector.input.value; + return acc; + }, {}); + logger.debug('Transforming action request', { node, action }); + return { id: node.server.id || '', eventName: node.server.eventName || '', @@ -108,7 +115,8 @@ export function transformActionRequest( parameters: { eventType: 'action', data: { - actionKey: action, + actionKey: action || node.client?.action || '', + ...(Object.keys(formData ?? {}).length && { formData: formData }), }, }, }; diff --git a/packages/davinci-client/src/lib/node.reducer.test.ts b/packages/davinci-client/src/lib/node.reducer.test.ts index 00a8804efc..ea9a09bf98 100644 --- a/packages/davinci-client/src/lib/node.reducer.test.ts +++ b/packages/davinci-client/src/lib/node.reducer.test.ts @@ -12,6 +12,7 @@ import type { DeviceRegistrationCollector, FidoAuthenticationCollector, FidoRegistrationCollector, + MetadataCollector, MultiSelectCollector, PasswordCollector, ValidatedPasswordCollector, @@ -2181,3 +2182,86 @@ describe('The node collector reducer with FidoAuthenticationFieldValue', () => { ]); }); }); + +describe('The node collector reducer with MetadataField', () => { + it('should create a MetadataCollector from a METADATA field', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'METADATA', + key: 'metadata-key', + payload: { sessionToken: 'abc123' }, + }, + ], + formData: {}, + }, + }; + + const result = nodeCollectorReducer(undefined, action); + expect(result[0]).toEqual({ + category: 'ObjectValueAutoCollector', + error: null, + type: 'MetadataCollector', + id: 'metadata-key-0', + name: 'metadata-key', + input: { + key: 'metadata-key', + value: {}, + type: 'METADATA', + validation: null, + }, + output: { + key: 'metadata-key', + type: 'METADATA', + config: { sessionToken: 'abc123' }, + }, + } satisfies MetadataCollector); + }); + + it('should update a MetadataCollector input value', () => { + const state: MetadataCollector[] = [ + { + category: 'ObjectValueAutoCollector', + error: null, + type: 'MetadataCollector', + id: 'metadata-key-0', + name: 'metadata-key', + input: { key: 'metadata-key', value: {}, type: 'METADATA', validation: null }, + output: { + key: 'metadata-key', + type: 'METADATA', + config: { sessionToken: 'abc123' }, + }, + }, + ]; + + const action = { + type: 'node/update', + payload: { id: 'metadata-key-0', value: { result: 'ok' } }, + }; + const result = nodeCollectorReducer(state, action); + expect((result[0] as MetadataCollector).input.value).toEqual({ result: 'ok' }); + }); + + it('should throw when updating a MetadataCollector with a non-object value', () => { + const state: MetadataCollector[] = [ + { + category: 'ObjectValueAutoCollector', + error: null, + type: 'MetadataCollector', + id: 'metadata-key-0', + name: 'metadata-key', + input: { key: 'metadata-key', value: {}, type: 'METADATA', validation: null }, + output: { key: 'metadata-key', type: 'METADATA', config: {} }, + }, + ]; + + const action = { + type: 'node/update', + payload: { id: 'metadata-key-0', value: 'not-an-object' }, + }; + expect(() => nodeCollectorReducer(state, action)).toThrow('Value argument must be an object'); + }); +}); diff --git a/packages/davinci-client/src/lib/node.reducer.ts b/packages/davinci-client/src/lib/node.reducer.ts index c35e9106ca..47855a63a8 100644 --- a/packages/davinci-client/src/lib/node.reducer.ts +++ b/packages/davinci-client/src/lib/node.reducer.ts @@ -15,6 +15,7 @@ import { createAction, createReducer } from '@reduxjs/toolkit'; import { returnActionCollector, returnFlowCollector, + returnMetadataCollector, returnPasswordCollector, returnValidatedPasswordCollector, returnIdpCollector, @@ -169,6 +170,9 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build } break; } + case 'METADATA': { + return returnMetadataCollector(field, idx); + } default: // Default is handled below } @@ -200,6 +204,7 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build if (collector.category === 'NoValueCollector') { throw new Error('NoValueCollectors, like ReadOnlyCollectors, are read-only'); } + if (action.payload.value === undefined) { throw new Error('Value argument cannot be undefined'); } @@ -333,6 +338,17 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build } collector.input.value = action.payload.value; } + + if (collector.type === 'MetadataCollector') { + if ( + typeof action.payload.value !== 'object' || + action.payload.value === null || + Array.isArray(action.payload.value) + ) { + throw new Error('Value argument must be an object'); + } + collector.input.value = action.payload.value as Record; + } }) /** * Using the `pollCollectorValues` const (e.g. `'node/poll'`) to add the case diff --git a/packages/davinci-client/src/lib/node.slice.ts b/packages/davinci-client/src/lib/node.slice.ts index 29d91342bd..5cc3c2907d 100644 --- a/packages/davinci-client/src/lib/node.slice.ts +++ b/packages/davinci-client/src/lib/node.slice.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -20,7 +20,7 @@ import { getCollectorErrors } from './node.utils.js'; */ import type { Draft, PayloadAction } from '@reduxjs/toolkit'; -import type { SubmitCollector } from './collector.types.js'; +import type { MetadataCollector, SubmitCollector } from './collector.types.js'; import type { DavinciErrorResponse, DaVinciFailureResponse, @@ -192,12 +192,16 @@ export const nodeSlice = createSlice({ (collector): collector is SubmitCollector => collector.type === 'SubmitCollector', )[0]; + const metadataCollector = collectors.filter( + (collector): collector is MetadataCollector => collector.type === 'MetadataCollector', + )[0]; + newState.cache = { key: action.payload.requestId, }; newState.client = { - action: submitCollector?.output.key, + action: submitCollector?.output.key || metadataCollector?.output.key, description: action.payload.data?.form?.description, collectors, name: action.payload.data.form?.name, diff --git a/packages/davinci-client/src/lib/node.types.test-d.ts b/packages/davinci-client/src/lib/node.types.test-d.ts index 1c185a8da5..8e3e02a7aa 100644 --- a/packages/davinci-client/src/lib/node.types.test-d.ts +++ b/packages/davinci-client/src/lib/node.types.test-d.ts @@ -19,6 +19,7 @@ import type { ErrorDetail, Links } from './davinci.types.js'; import type { ActionCollector, FlowCollector, + MetadataCollector, MultiSelectCollector, PasswordCollector, ValidatedPasswordCollector, @@ -228,6 +229,7 @@ describe('Node Types', () => { it('should validate Collectors union type', () => { expectTypeOf().toMatchTypeOf< | TextCollector + | MetadataCollector | PasswordCollector | ValidatedPasswordCollector | FlowCollector diff --git a/packages/davinci-client/src/lib/node.types.ts b/packages/davinci-client/src/lib/node.types.ts index 58e3257c97..ff916c4919 100644 --- a/packages/davinci-client/src/lib/node.types.ts +++ b/packages/davinci-client/src/lib/node.types.ts @@ -8,6 +8,7 @@ import { GenericError } from '@forgerock/sdk-types'; import type { FlowCollector, + MetadataCollector, PasswordCollector, ValidatedPasswordCollector, TextCollector, @@ -38,6 +39,7 @@ import type { Links } from './davinci.types.js'; export type Collectors = | FlowCollector + | MetadataCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector