diff --git a/.changeset/rich-content-links.md b/.changeset/rich-content-links.md new file mode 100644 index 0000000000..4a0f40881c --- /dev/null +++ b/.changeset/rich-content-links.md @@ -0,0 +1,13 @@ +--- +'@forgerock/davinci-client': minor +--- + +**Breaking change**: `ReadOnlyCollector.output.content` now returns a plain `string` (the label text) instead of `ContentPart[]`. + +A new `ReadOnlyCollector.output.richContent` property is always present and contains the structured link data when a LABEL field includes `richContent`. Its shape is `CollectorRichContent` — a template string with `{{key}}` placeholders (`content`) and a validated `replacements` array (`ValidatedReplacement[]`). When no `richContent` is present, `replacements` is an empty array. + +**Removed type exports**: `ContentPart`, `TextContentPart`, `LinkContentPart` + +**New type exports**: `RichContentLink`, `ValidatedReplacement`, `CollectorRichContent` + +Includes href protocol validation that rejects unsafe URI schemes (e.g. `javascript:`, `data:`). diff --git a/.nxignore b/.nxignore new file mode 100644 index 0000000000..0467d38052 --- /dev/null +++ b/.nxignore @@ -0,0 +1 @@ +.opensource/ diff --git a/.opensource/forgerock-javascript-sdk b/.opensource/forgerock-javascript-sdk new file mode 160000 index 0000000000..1e3f0d7de2 --- /dev/null +++ b/.opensource/forgerock-javascript-sdk @@ -0,0 +1 @@ +Subproject commit 1e3f0d7de2572ae5a0433525c5af65c73c031e67 diff --git a/e2e/davinci-app/components/label.ts b/e2e/davinci-app/components/label.ts index 29fc355fe7..4d896f1490 100644 --- a/e2e/davinci-app/components/label.ts +++ b/e2e/davinci-app/components/label.ts @@ -7,9 +7,43 @@ import type { ReadOnlyCollector } from '@forgerock/davinci-client/types'; export default function (formEl: HTMLFormElement, collector: ReadOnlyCollector) { - // create paragraph element with text of "Loading ... " const p = document.createElement('p'); + p.style.whiteSpace = 'pre-line'; + const { richContent } = collector.output; + + if (richContent.replacements.length === 0) { + p.innerText = collector.output.content; + formEl?.appendChild(p); + return; + } + + // Interpolate the template by splitting on {{key}} and inserting links + const segments = richContent.content.split(/\{\{(\w+)\}\}/); + const replacementMap = new Map(richContent.replacements.map((r) => [r.key, r])); + + for (let i = 0; i < segments.length; i++) { + if (i % 2 === 0) { + // Text segment + if (segments[i]) { + p.appendChild(document.createTextNode(segments[i])); + } + } else { + // Replacement key + const replacement = replacementMap.get(segments[i]); + if (replacement?.type === 'link') { + const a = document.createElement('a'); + a.href = replacement.href; + a.textContent = replacement.value; + if (replacement.target) { + a.target = replacement.target; + if (replacement.target === '_blank') { + a.rel = 'noopener noreferrer'; + } + } + p.appendChild(a); + } + } + } - p.innerText = collector.output.label; formEl?.appendChild(p); } diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index b2528bf664..c555aaa42c 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -177,6 +177,14 @@ export interface CollectorErrors { target: string; } +// @public +export interface CollectorRichContent { + // (undocumented) + content: string; + // (undocumented) + replacements: RichContentLink[]; +} + // @public (undocumented) export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; @@ -267,13 +275,11 @@ 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; poll: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +293,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +305,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +314,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +329,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -1029,7 +1037,7 @@ export type InferAutoCollectorType = T extends 'Pr export type InferMultiValueCollectorType = T extends 'MultiSelectCollector' ? MultiValueCollectorWithValue<'MultiSelectCollector'> : MultiValueCollectorWithValue<'MultiValueCollector'> | MultiValueCollectorNoValue<'MultiValueCollector'>; // @public -export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? NoValueCollectorBase<'ReadOnlyCollector'> : T extends 'QrCodeCollector' ? QrCodeCollectorBase : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>; +export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? ReadOnlyCollector : T extends 'QrCodeCollector' ? QrCodeCollector : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>; // @public export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; @@ -1170,15 +1178,15 @@ value: Record; }, string>; // @public -export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { - getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; +export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | AgreementCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { + getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | AgreementCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; }; // @public (undocumented) export type NodeStates = StartNode | ContinueNode | ErrorNode | SuccessNode | FailureNode; // @public (undocumented) -export type NoValueCollector = NoValueCollectorBase; +export type NoValueCollector = InferNoValueCollectorType; // @public (undocumented) export interface NoValueCollectorBase { @@ -1201,7 +1209,7 @@ export interface NoValueCollectorBase { } // @public (undocumented) -export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | NoValueCollectorBase<'ReadOnlyCollector'> | QrCodeCollectorBase | AgreementCollector; +export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | ReadOnlyCollector | QrCodeCollector | AgreementCollector; // @public export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector'; @@ -1415,28 +1423,12 @@ export interface ProtectOutputValue { universalDeviceIdentification: boolean; } -// @public (undocumented) -export type QrCodeCollector = QrCodeCollectorBase; - -// @public (undocumented) -export interface QrCodeCollectorBase { - // (undocumented) - category: 'NoValueCollector'; - // (undocumented) - error: string | null; - // (undocumented) - id: string; - // (undocumented) - name: string; +// @public +export interface QrCodeCollector extends NoValueCollectorBase<'QrCodeCollector'> { // (undocumented) - output: { - key: string; - label: string; - type: string; + output: NoValueCollectorBase<'QrCodeCollector'>['output'] & { src: string; }; - // (undocumented) - type: 'QrCodeCollector'; } // @public (undocumented) @@ -1447,13 +1439,20 @@ export type QrCodeField = { fallbackText?: string; }; -// @public (undocumented) -export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>; +// @public +export interface ReadOnlyCollector extends NoValueCollectorBase<'ReadOnlyCollector'> { + // (undocumented) + output: NoValueCollectorBase<'ReadOnlyCollector'>['output'] & { + content: string; + richContent: CollectorRichContent; + }; +} -// @public (undocumented) +// @public export type ReadOnlyField = { type: 'LABEL'; content: string; + richContent?: RichContent; key?: string; }; @@ -1473,6 +1472,34 @@ export type RedirectFields = RedirectField; export { RequestMiddleware } +// @public +export type RichContent = { + content: string; + replacements?: Record; +}; + +// @public +export interface RichContentLink { + // (undocumented) + href: string; + // (undocumented) + key: string; + // (undocumented) + target?: '_self' | '_blank'; + // (undocumented) + type: 'link'; + // (undocumented) + value: string; +} + +// @public +export type RichContentReplacement = { + type: 'link'; + value: string; + href: string; + target?: '_self' | '_blank'; +}; + // @public (undocumented) export interface SelectorOption { // (undocumented) 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 2321431a0a..1492074108 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -177,6 +177,14 @@ export interface CollectorErrors { target: string; } +// @public +export interface CollectorRichContent { + // (undocumented) + content: string; + // (undocumented) + replacements: RichContentLink[]; +} + // @public (undocumented) export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; @@ -267,13 +275,11 @@ 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; poll: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +293,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +305,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +314,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +329,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -1026,7 +1034,7 @@ export type InferAutoCollectorType = T extends 'Pr export type InferMultiValueCollectorType = T extends 'MultiSelectCollector' ? MultiValueCollectorWithValue<'MultiSelectCollector'> : MultiValueCollectorWithValue<'MultiValueCollector'> | MultiValueCollectorNoValue<'MultiValueCollector'>; // @public -export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? NoValueCollectorBase<'ReadOnlyCollector'> : T extends 'QrCodeCollector' ? QrCodeCollectorBase : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>; +export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? ReadOnlyCollector : T extends 'QrCodeCollector' ? QrCodeCollector : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>; // @public export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; @@ -1167,15 +1175,15 @@ value: Record; }, string>; // @public -export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { - getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; +export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | AgreementCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { + getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | AgreementCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; }; // @public (undocumented) export type NodeStates = StartNode | ContinueNode | ErrorNode | SuccessNode | FailureNode; // @public (undocumented) -export type NoValueCollector = NoValueCollectorBase; +export type NoValueCollector = InferNoValueCollectorType; // @public (undocumented) export interface NoValueCollectorBase { @@ -1198,7 +1206,7 @@ export interface NoValueCollectorBase { } // @public (undocumented) -export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | NoValueCollectorBase<'ReadOnlyCollector'> | QrCodeCollectorBase | AgreementCollector; +export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | ReadOnlyCollector | QrCodeCollector | AgreementCollector; // @public export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector'; @@ -1412,28 +1420,12 @@ export interface ProtectOutputValue { universalDeviceIdentification: boolean; } -// @public (undocumented) -export type QrCodeCollector = QrCodeCollectorBase; - -// @public (undocumented) -export interface QrCodeCollectorBase { - // (undocumented) - category: 'NoValueCollector'; - // (undocumented) - error: string | null; - // (undocumented) - id: string; - // (undocumented) - name: string; +// @public +export interface QrCodeCollector extends NoValueCollectorBase<'QrCodeCollector'> { // (undocumented) - output: { - key: string; - label: string; - type: string; + output: NoValueCollectorBase<'QrCodeCollector'>['output'] & { src: string; }; - // (undocumented) - type: 'QrCodeCollector'; } // @public (undocumented) @@ -1444,13 +1436,20 @@ export type QrCodeField = { fallbackText?: string; }; -// @public (undocumented) -export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>; +// @public +export interface ReadOnlyCollector extends NoValueCollectorBase<'ReadOnlyCollector'> { + // (undocumented) + output: NoValueCollectorBase<'ReadOnlyCollector'>['output'] & { + content: string; + richContent: CollectorRichContent; + }; +} -// @public (undocumented) +// @public export type ReadOnlyField = { type: 'LABEL'; content: string; + richContent?: RichContent; key?: string; }; @@ -1470,6 +1469,34 @@ export type RedirectFields = RedirectField; export { RequestMiddleware } +// @public +export type RichContent = { + content: string; + replacements?: Record; +}; + +// @public +export interface RichContentLink { + // (undocumented) + href: string; + // (undocumented) + key: string; + // (undocumented) + target?: '_self' | '_blank'; + // (undocumented) + type: 'link'; + // (undocumented) + value: string; +} + +// @public +export type RichContentReplacement = { + type: 'link'; + value: string; + href: string; + target?: '_self' | '_blank'; +}; + // @public (undocumented) export interface SelectorOption { // (undocumented) diff --git a/packages/davinci-client/src/lib/collector.types.test-d.ts b/packages/davinci-client/src/lib/collector.types.test-d.ts index 5fe63303ad..5e9bbe8e31 100644 --- a/packages/davinci-client/src/lib/collector.types.test-d.ts +++ b/packages/davinci-client/src/lib/collector.types.test-d.ts @@ -27,6 +27,9 @@ import type { ReadOnlyCollector, QrCodeCollector, AgreementCollector, + RichContentLink, + CollectorRichContent, + NoValueCollector, } from './collector.types.js'; describe('Collector Types', () => { @@ -372,6 +375,8 @@ describe('Collector Types', () => { key: 'read-only', label: 'Read Only Field', type: 'READ_ONLY', + content: '', + richContent: { content: '', replacements: [] }, }, }; @@ -420,4 +425,64 @@ describe('Collector Types', () => { expectTypeOf(tCollector).toEqualTypeOf(); }); }); + + describe('Rich Content Types', () => { + describe('RichContentLink', () => { + it('should require key, type, value, and href', () => { + expectTypeOf().toHaveProperty('key').toBeString(); + expectTypeOf().toHaveProperty('type').toEqualTypeOf<'link'>(); + expectTypeOf().toHaveProperty('value').toBeString(); + expectTypeOf().toHaveProperty('href').toBeString(); + }); + + it('should have optional target constrained to _self or _blank', () => { + expectTypeOf() + .toHaveProperty('target') + .toEqualTypeOf<'_self' | '_blank' | undefined>(); + }); + }); + + describe('CollectorRichContent', () => { + it('should have required content string and replacements array', () => { + expectTypeOf().toHaveProperty('content').toBeString(); + expectTypeOf() + .toHaveProperty('replacements') + .toEqualTypeOf(); + }); + }); + + describe('ReadOnlyCollector', () => { + it('should have content as string, not array', () => { + expectTypeOf().toBeString(); + }); + + it('should have required richContent with CollectorRichContent shape', () => { + expectTypeOf< + ReadOnlyCollector['output']['richContent'] + >().toEqualTypeOf(); + }); + + it('should have standard collector fields', () => { + expectTypeOf() + .toHaveProperty('category') + .toEqualTypeOf<'NoValueCollector'>(); + expectTypeOf() + .toHaveProperty('type') + .toEqualTypeOf<'ReadOnlyCollector'>(); + expectTypeOf().toHaveProperty('error').toEqualTypeOf(); + }); + }); + + describe("NoValueCollector<'ReadOnlyCollector'>", () => { + it('should resolve to ReadOnlyCollector', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('should have content and richContent on output', () => { + type Resolved = NoValueCollector<'ReadOnlyCollector'>; + expectTypeOf().toBeString(); + expectTypeOf().toEqualTypeOf(); + }); + }); + }); }); diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index d99ebacfed..d231afac76 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -501,20 +501,53 @@ export interface NoValueCollectorBase { }; } -export interface QrCodeCollectorBase { - category: 'NoValueCollector'; - error: string | null; - type: 'QrCodeCollector'; - id: string; - name: string; - output: { - key: string; - label: string; - type: string; +/** + * @interface RichContentLink - A hyperlink replacement embedded inside a + * `ReadOnlyCollector` template. The `key` matches the `{{key}}` token in the + * template; `href` is passed through from DaVinci unmodified — consumers are + * responsible for sanitizing it before rendering. + */ +export interface RichContentLink { + key: string; + type: 'link'; + value: string; + href: string; + target?: '_self' | '_blank'; +} + +/** + * @interface CollectorRichContent - The normalized rich-content payload exposed on a + * `ReadOnlyCollector`. `content` holds the raw template (with `{{key}}` tokens), and + * `replacements` is the array of substitution entries (the API's keyed Record flattened + * into an array, with the original key carried on each entry). + */ +export interface CollectorRichContent { + content: string; + replacements: RichContentLink[]; +} + +/** + * @interface QrCodeCollector - Collector for displaying a QR code image. Extends the + * generic `NoValueCollectorBase` with the image `src` on `output`. + */ +export interface QrCodeCollector extends NoValueCollectorBase<'QrCodeCollector'> { + output: NoValueCollectorBase<'QrCodeCollector'>['output'] & { src: string; }; } +/** + * @interface ReadOnlyCollector - Display-only collector for LABEL fields. Extends + * `NoValueCollectorBase` with the original plain-text `content` and a structured + * `richContent` payload (template + validated replacements). + */ +export interface ReadOnlyCollector extends NoValueCollectorBase<'ReadOnlyCollector'> { + output: NoValueCollectorBase<'ReadOnlyCollector'>['output'] & { + content: string; + richContent: CollectorRichContent; + }; +} + export interface AgreementCollector extends NoValueCollectorBase<'AgreementCollector'> { output: { key: string; @@ -539,24 +572,20 @@ export interface AgreementCollector extends NoValueCollectorBase<'AgreementColle */ export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' - ? NoValueCollectorBase<'ReadOnlyCollector'> + ? ReadOnlyCollector : T extends 'QrCodeCollector' - ? QrCodeCollectorBase + ? QrCodeCollector : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>; export type NoValueCollectors = | NoValueCollectorBase<'NoValueCollector'> - | NoValueCollectorBase<'ReadOnlyCollector'> - | QrCodeCollectorBase + | ReadOnlyCollector + | QrCodeCollector | AgreementCollector; -export type NoValueCollector = NoValueCollectorBase; - -export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>; - -export type QrCodeCollector = QrCodeCollectorBase; +export type NoValueCollector = InferNoValueCollectorType; /** ********************************************************************* * UNKNOWN COLLECTOR diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index c9d1381857..4e033ac479 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -24,6 +24,7 @@ import { returnObjectValueAutoCollector, returnQrCodeCollector, returnAgreementCollector, + normalizeReplacements, } from './collector.utils.js'; import type { DaVinciField, @@ -37,6 +38,7 @@ import type { PollingField, ReadOnlyField, RedirectField, + RichContentReplacement, StandardField, AgreementField, } from './davinci.types.js'; @@ -796,7 +798,7 @@ describe('No Value Collectors', () => { }); describe('returnReadOnlyCollector', () => { - it('should return a valid ReadOnlyCollector with value in output', () => { + it('should return a ReadOnlyCollector with plain content and empty richContent when no richContent on field', () => { const result = returnReadOnlyCollector(mockField, 0); expect(result).toEqual({ category: 'NoValueCollector', @@ -808,9 +810,86 @@ describe('No Value Collectors', () => { key: 'LABEL-0', label: mockField.content, type: mockField.type, + content: mockField.content, + richContent: { + content: mockField.content, + replacements: [], + }, + }, + }); + }); + + it('should pass through richContent template and validated replacements', () => { + const field: ReadOnlyField = { + type: 'LABEL', + content: 'I agree to the terms and conditions', + richContent: { + content: 'I agree to the {{link}}', + replacements: { + link: { + type: 'link', + value: 'terms and conditions', + href: 'https://example.com', + target: '_blank', + }, + }, + }, + key: 'terms', + }; + + const result = returnReadOnlyCollector(field, 0); + + expect(result).toEqual({ + category: 'NoValueCollector', + error: null, + type: 'ReadOnlyCollector', + id: 'terms-0', + name: 'terms-0', + output: { + key: 'terms-0', + label: 'I agree to the terms and conditions', + type: 'LABEL', + content: 'I agree to the terms and conditions', + richContent: { + content: 'I agree to the {{link}}', + replacements: [ + { + key: 'link', + type: 'link', + value: 'terms and conditions', + href: 'https://example.com', + target: '_blank', + }, + ], + }, }, }); }); + + it('should pass through unsafe-looking hrefs unchanged (consumer is responsible for sanitization)', () => { + const field: ReadOnlyField = { + type: 'LABEL', + content: 'Click the link', + richContent: { + content: 'Click {{bad}}', + replacements: { + bad: { + type: 'link', + value: 'here', + href: 'javascript:alert(1)', + }, + }, + }, + }; + + const result = returnReadOnlyCollector(field, 0); + + expect(result.error).toBeNull(); + expect(result.output.richContent).toEqual({ + content: 'Click {{bad}}', + replacements: [{ key: 'bad', type: 'link', value: 'here', href: 'javascript:alert(1)' }], + }); + }); }); }); @@ -1181,3 +1260,148 @@ describe('Return collector validator', () => { ); }); }); + +describe('normalizeReplacements', () => { + it('should flatten a single link replacement', () => { + const replacements: Record = { + link1: { + type: 'link', + value: 'terms and conditions', + href: 'https://example.com', + target: '_blank', + }, + }; + + expect(normalizeReplacements(replacements)).toEqual([ + { + key: 'link1', + type: 'link', + value: 'terms and conditions', + href: 'https://example.com', + target: '_blank', + }, + ]); + }); + + it('should flatten multiple link replacements', () => { + const replacements: Record = { + link1: { + type: 'link', + value: 'terms', + href: 'https://example.com', + target: '_blank', + }, + link2: { + type: 'link', + value: 'policy', + href: 'https://xyz.com', + target: '_self', + }, + }; + + expect(normalizeReplacements(replacements)).toEqual([ + { + key: 'link1', + type: 'link', + value: 'terms', + href: 'https://example.com', + target: '_blank', + }, + { key: 'link2', type: 'link', value: 'policy', href: 'https://xyz.com', target: '_self' }, + ]); + }); + + it('should omit target when not provided', () => { + const replacements: Record = { + link: { + type: 'link', + value: 'here', + href: 'https://example.com', + }, + }; + + expect(normalizeReplacements(replacements)).toEqual([ + { key: 'link', type: 'link', value: 'here', href: 'https://example.com' }, + ]); + }); + + it('should return empty array for empty replacements', () => { + expect(normalizeReplacements({})).toEqual([]); + }); + + it('should pass non-http(s) hrefs through unchanged', () => { + const replacements: Record = { + link: { + type: 'link', + value: 'here', + href: 'javascript:alert(1)', + }, + }; + + expect(normalizeReplacements(replacements)).toEqual([ + { key: 'link', type: 'link', value: 'here', href: 'javascript:alert(1)' }, + ]); + }); +}); + +describe('Terms and Conditions Integration', () => { + it('should handle a form with a checkbox and a label with a T&C link', () => { + const labelField: ReadOnlyField = { + type: 'LABEL', + content: 'I agree to the terms and conditions', + richContent: { + content: 'I agree to the {{link}}', + replacements: { + link: { + type: 'link', + value: 'terms and conditions', + href: 'https://example.com/terms', + target: '_blank', + }, + }, + }, + key: 'terms-label', + }; + + const checkboxField = { + type: 'CHECKBOX' as const, + key: 'agree-checkbox', + label: 'Agreement', + required: true, + options: [{ label: 'I agree', value: 'agree' }], + inputType: 'MULTI_SELECT' as const, + }; + + const labelCollector = returnReadOnlyCollector(labelField, 0); + const checkboxCollector = returnMultiSelectCollector(checkboxField, 1, []); + + // Verify label collector has pass-through richContent + expect(labelCollector.type).toBe('ReadOnlyCollector'); + expect(labelCollector.category).toBe('NoValueCollector'); + expect(labelCollector.error).toBeNull(); + expect(labelCollector.output.label).toBe('I agree to the terms and conditions'); + expect(labelCollector.output.content).toBe('I agree to the terms and conditions'); + expect(labelCollector.output.richContent).toEqual({ + content: 'I agree to the {{link}}', + replacements: [ + { + key: 'link', + type: 'link', + value: 'terms and conditions', + href: 'https://example.com/terms', + target: '_blank', + }, + ], + }); + + // Verify checkbox collector works alongside + expect(checkboxCollector.type).toBe('MultiSelectCollector'); + expect(checkboxCollector.category).toBe('MultiValueCollector'); + expect(checkboxCollector.error).toBeNull(); + expect(checkboxCollector.output.options).toEqual([{ label: 'I agree', value: 'agree' }]); + expect(checkboxCollector.input.value).toEqual([]); + expect(checkboxCollector.input.validation).toEqual([ + { type: 'required', message: 'Value cannot be empty', rule: true }, + ]); + }); +}); diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index 6233c3202d..0226731176 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -29,7 +29,9 @@ import type { AutoCollectors, SingleValueAutoCollectorTypes, ObjectValueAutoCollectorTypes, - QrCodeCollectorBase, + QrCodeCollector, + ReadOnlyCollector, + RichContentLink, AgreementCollector, } from './collector.types.js'; import type { @@ -44,6 +46,7 @@ import type { PollingField, ReadOnlyField, RedirectField, + RichContentReplacement, SingleSelectField, StandardField, ValidatedField, @@ -712,6 +715,27 @@ export function returnObjectValueCollector( return returnObjectCollector(field, idx, 'PhoneNumberCollector', prefillData); } +/** + * @function normalizeReplacements - Flattens the API's keyed + * `Record` into an array of `RichContentLink` + * with the original key carried on each entry. Hrefs are passed through + * unmodified — consumers are responsible for sanitizing before rendering. + * + * @param {Record} replacements - The replacements map from the API. + * @returns {RichContentLink[]} The flattened array of replacement entries. + */ +export function normalizeReplacements( + replacements: Record, +): RichContentLink[] { + return Object.entries(replacements).map(([key, replacement]) => ({ + key, + type: replacement.type, + value: replacement.value, + href: replacement.href, + ...(replacement.target && { target: replacement.target }), + })); +} + /** * @function returnNoValueCollector - Creates a NoValueCollector object based on the provided field, index, and optional collector type. * @param {DaVinciField} field - The field object containing key, label, type, and links. @@ -746,22 +770,48 @@ export function returnNoValueCollector< } /** - * @function returnReadOnlyCollector - Creates a ReadOnlyCollector object based on the provided field and index. - * @param {DaVinciField} field - The field object containing key, label, type, and links. + * @function returnReadOnlyCollector - Creates a ReadOnlyCollector with pass-through rich content. + * When richContent is present, the template and normalized replacements are passed through + * unmodified. When absent, richContent echoes the plain content with empty replacements. + * + * @param {ReadOnlyField} field - The LABEL field from the API response. * @param {number} idx - The index to be used in the id of the ReadOnlyCollector. - * @returns {ReadOnlyCollector} The constructed ReadOnlyCollector object. + * @returns {ReadOnlyCollector} The constructed ReadOnlyCollector. */ -export function returnReadOnlyCollector(field: ReadOnlyField, idx: number) { - return returnNoValueCollector(field, idx, 'ReadOnlyCollector'); +export function returnReadOnlyCollector(field: ReadOnlyField, idx: number): ReadOnlyCollector { + const base = returnNoValueCollector(field, idx, 'ReadOnlyCollector'); + + if (!field.richContent) { + return { + ...base, + output: { + ...base.output, + content: field.content, + richContent: { content: field.content, replacements: [] }, + }, + }; + } + + return { + ...base, + output: { + ...base.output, + content: field.content, + richContent: { + content: field.richContent.content, + replacements: normalizeReplacements(field.richContent.replacements ?? {}), + }, + }, + }; } /** * @function returnQrCodeCollector - Creates a QrCodeCollector object for displaying QR code images. * @param {QrCodeField} field - The field object containing key, content, type, and optional fallbackText. * @param {number} idx - The index to be used in the id of the QrCodeCollector. - * @returns {QrCodeCollectorBase} The constructed QrCodeCollector object. + * @returns {QrCodeCollector} The constructed QrCodeCollector object. */ -export function returnQrCodeCollector(field: QrCodeField, idx: number): QrCodeCollectorBase { +export function returnQrCodeCollector(field: QrCodeField, idx: number): QrCodeCollector { const base = returnNoValueCollector(field, idx, 'QrCodeCollector'); return { diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index 2b0f21f24b..8423958add 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -68,9 +68,38 @@ export type StandardField = { required?: boolean; }; +/** + * A single replacement entry in the raw DaVinci `richContent.replacements` map. + * The map's key (set on the parent `RichContent`) corresponds to the `{{key}}` + * token in `content`. Currently only `link` is supported. + */ +export type RichContentReplacement = { + type: 'link'; + value: string; + href: string; + target?: '_self' | '_blank'; +}; + +/** + * Raw rich-content payload as returned by DaVinci on a LABEL field. + * `content` is a template string with `{{key}}` tokens; `replacements` maps + * each key to its substitution data. Validated and normalized into + * `CollectorRichContent` by the SDK. + */ +export type RichContent = { + content: string; + replacements?: Record; +}; + +/** + * The shape of a LABEL field in a DaVinci form. `content` is the plain-text + * fallback; `richContent`, when present, carries a template + replacement data + * for rendering inline links. + */ export type ReadOnlyField = { type: 'LABEL'; content: string; + richContent?: RichContent; key?: string; }; diff --git a/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts b/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts index 4962470b45..2e8f9061ae 100644 --- a/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts +++ b/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts @@ -23,6 +23,22 @@ export const obj = { type: 'LABEL', content: 'Welcome to Ping Identity', }, + { + type: 'LABEL', + content: 'I agree to the terms and conditions', + richContent: { + content: 'I agree to the {{link}}', + replacements: { + link: { + type: 'link', + value: 'terms and conditions', + href: 'https://example.com/terms', + target: '_blank', + }, + }, + }, + key: 'terms-label', + }, { type: 'ERROR_DISPLAY', },