"
+ },
+ "formFieldsList": {
+ "value": [
+ {
+ "preferredControlType": "textField",
+ "preferredDataType": "string",
+ "propertyName": "buttonValue"
+ }
+ ]
+ },
+ "nodeTitle": {
+ "value": "FIDO2 Authentication Success"
+ },
+ "customCSS": {
+ "value": ":root {\n --animation-dimension: 100px;\n --animation-start-color: #71e171;\n --animation-midway-color: #67d067;\n --animation-finish-color: #59b259;\n}\n\n.checkmark {\n width: var(--animation-dimension);\n height: var(--animation-dimension);\n border-radius: 50%;\n display: block;\n stroke-width: 5;\n stroke: #ffffff;\n stroke-miterlimit: 10;\n box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);\n animation: fill 0.4s ease-in-out 0.4s forwards,\n scale 0.3s ease-in-out 0.9s both;\n}\n\n.checkmarkCircle {\n stroke-dasharray: 166;\n stroke-dashoffset: 166;\n stroke-width: 5;\n stroke-miterlimit: 10;\n stroke: var(--animation-finish-color);\n fill: none;\n animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards,\n colorChange 1.2s ease-in-out forwards;\n}\n\n.checkmarkCheck {\n transform-origin: 50% 50%;\n stroke-dasharray: 48;\n stroke-dashoffset: 48;\n animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards,\n colorChange 0.6s ease-in-out 0.8s forwards;\n}\n\n@keyframes colorChange {\n 0% {\n stroke: var(--animation-start-color);\n }\n 50% {\n stroke: var(--animation-midway-color);\n } \n 100% {\n stroke: var(--animation-finish-color);\n }\n}\n\n@keyframes stroke {\n 100% {\n stroke-dashoffset: 0;\n }\n}\n@keyframes scale {\n 0%,\n 100% {\n transform: none;\n }\n 50% {\n transform: scale3d(1.1, 1.1, 1);\n }\n 70% {\n transform: scale3d(0.98, 0.98, 1);\n }\n 85% {\n transform: scale3d(1.02, 1.02, 1);\n }\n}\n@keyframes fill {\n 100% {\n box-shadow: inset 0px 0px 0px 0px var(--animation-finish-color);\n }\n}"
+ },
+ "validationRules": {
+ "value": []
+ },
+ "backgroundColor": {
+ "value": "#afd5ffff"
+ },
+ "customScript": {},
+ "inputSchema": {
+ "value": "{\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\",\n \"displayName\": \"Title\",\n \"preferredControlType\": \"textField\",\n \"enableParameters\": true,\n \"propertyName\": \"title\"\n },\n\t\"textOne\": {\n \"type\": \"string\",\n \"displayName\": \"Text One\",\n \"preferredControlType\": \"textField\",\n \"enableParameters\": true,\n \"propertyName\": \"textOne\"\n },\n\t\"textTwo\": {\n \"type\": \"string\",\n \"displayName\": \"Text Two\",\n \"preferredControlType\": \"textField\",\n \"enableParameters\": true,\n \"propertyName\": \"textTwo\"\n }\n }\n}"
+ },
+ "title": {
+ "value": "[\n {\n \"children\": [\n {\n \"text\": \"Success\"\n }\n ]\n }\n]"
+ },
+ "nodeDescription": {
+ "value": "FIDO2 Authentication Success"
+ },
+ "oeInteractionCacheExpire": {
+ "value": false
+ },
+ "textOne": {
+ "value": "[\n {\n \"children\": [\n {\n \"text\": \"FIDO2 Authentication Success\"\n }\n ]\n }\n]"
+ }
+ },
+ "idUnique": "1lozlfcww2"
+ },
+ "position": {
+ "x": 2370,
+ "y": 1080
+ },
+ "group": "nodes",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": false,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "8tby8d48wl",
+ "nodeType": "CONNECTION",
+ "connectionId": "53ab83a4a4ab919d9f2cb02d9e111ac8",
+ "connectorId": "errorConnector",
+ "name": "Error Message",
+ "label": "Error Message",
+ "status": "configured",
+ "capabilityName": "customErrorMessage",
+ "type": "action",
+ "properties": {
+ "errorMessage": {
+ "value": "[\n {\n \"children\": [\n {\n \"text\": \"Error FIDO Assertion\"\n }\n ]\n }\n]"
+ },
+ "errorDescription": {
+ "value": "[\n {\n \"children\": [\n {\n \"text\": \"Error FIDO Assertion - \"\n },\n {\n \"text\": \"\"\n },\n {\n \"type\": \"link\",\n \"src\": \"pingIdentity.svg\",\n \"url\": \"message\",\n \"data\": \"{{local.xpbuzakj5t.payload.error.message}}\",\n \"tooltip\": \"{{local.xpbuzakj5t.payload.error.message}}\",\n \"children\": [\n {\n \"text\": \"message\"\n }\n ]\n },\n {\n \"text\": \"\"\n }\n ]\n }\n]"
+ }
+ },
+ "idUnique": "hui3b06h86"
+ },
+ "position": {
+ "x": 2370,
+ "y": 1230
+ },
+ "group": "nodes",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": false,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "kntyt0l56g",
+ "nodeType": "EVAL"
+ },
+ "position": {
+ "x": 2235,
+ "y": 1140
+ },
+ "group": "nodes",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": false,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "l9ealagz9f",
+ "nodeType": "EVAL",
+ "properties": {
+ "lbContext": {
+ "value": "global"
+ },
+ "8tby8d48wl": {
+ "value": "allTriggersFalse"
+ }
+ }
+ },
+ "position": {
+ "x": 2235,
+ "y": 1215
+ },
+ "group": "nodes",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": false,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "dtms9zh93s",
+ "nodeType": "CONNECTION",
+ "connectionId": "c3e6a164bde107954e93f5c09f0c8bce",
+ "connectorId": "pingOneAuthenticationConnector",
+ "name": "PingOne Authentication",
+ "label": "PingOne Authentication",
+ "status": "configured",
+ "capabilityName": "returnSuccessResponseRedirect",
+ "type": "action",
+ "properties": {
+ "userId": {
+ "value": "[\n {\n \"children\": [\n {\n \"text\": \"\"\n },\n {\n \"text\": \"\"\n },\n {\n \"type\": \"link\",\n \"src\": \"pingIdentity.svg\",\n \"url\": \"id\",\n \"data\": \"{{local.6cmfqmm16j.payload.output.rawResponse.user.id}}\",\n \"tooltip\": \"{{local.6cmfqmm16j.payload.output.rawResponse.user.id}}\",\n \"children\": [\n {\n \"text\": \"id\"\n }\n ]\n },\n {\n \"text\": \"\"\n }\n ]\n }\n]"
+ },
+ "authenticationMethods": {
+ "value": "mfa"
+ },
+ "idleTimeout": {
+ "value": 43200
+ },
+ "backgroundColor": {
+ "value": "#9dc967ff"
+ },
+ "nodeTitle": {
+ "value": "Return success"
+ },
+ "nodeDescription": {
+ "value": "Returns Successful Flow Completion Back To The PingOne Application Connection"
+ },
+ "oeInteractionCacheExpire": {
+ "value": true
+ }
+ },
+ "idUnique": "almtj0g04q",
+ "isDisabled": true
+ },
+ "position": {
+ "x": 3060,
+ "y": 990
+ },
+ "group": "nodes",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": false,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "wy6gd6su5z",
+ "nodeType": "CONNECTION",
+ "connectionId": "c3e6a164bde107954e93f5c09f0c8bce",
+ "connectorId": "pingOneAuthenticationConnector",
+ "name": "PingOne Authentication",
+ "label": "PingOne Authentication",
+ "status": "configured",
+ "capabilityName": "returnSuccessResponseRedirect",
+ "type": "action",
+ "properties": {
+ "userId": {
+ "value": "[\n {\n \"children\": [\n {\n \"text\": \"\"\n },\n {\n \"text\": \"\"\n },\n {\n \"type\": \"link\",\n \"src\": \"pingIdentity.svg\",\n \"url\": \"id\",\n \"data\": \"{{local.xpbuzakj5t.payload.output.rawResponse.user.id}}\",\n \"tooltip\": \"{{local.xpbuzakj5t.payload.output.rawResponse.user.id}}\",\n \"children\": [\n {\n \"text\": \"id\"\n }\n ]\n },\n {\n \"text\": \"\"\n }\n ]\n }\n]"
+ },
+ "authenticationMethods": {
+ "value": "mfa"
+ },
+ "idleTimeout": {
+ "value": 43200
+ },
+ "backgroundColor": {
+ "value": "#9dc967ff"
+ },
+ "nodeTitle": {
+ "value": "Return success"
+ },
+ "nodeDescription": {
+ "value": "Returns Successful Flow Completion Back To The PingOne Application Connection"
+ },
+ "oeInteractionCacheExpire": {
+ "value": true
+ }
+ },
+ "idUnique": "nwdqrt189r"
+ },
+ "position": {
+ "x": 2700,
+ "y": 1080
+ },
+ "group": "nodes",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": false,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "br7hr5tmjl",
+ "nodeType": "EVAL"
+ },
+ "position": {
+ "x": 2520,
+ "y": 1080
+ },
+ "group": "nodes",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": false,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "gatq391k8j",
+ "nodeType": "EVAL"
+ },
+ "position": {
+ "x": 1410,
+ "y": 1035
+ },
+ "group": "nodes",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": false,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "baa4rpuft4",
+ "nodeType": "EVAL"
+ },
+ "position": {
+ "x": 3075,
+ "y": 870
+ },
+ "group": "nodes",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": false,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "kjfb5ojy4y",
+ "nodeType": "EVAL"
+ },
+ "position": {
+ "x": 3360,
+ "y": 692
+ },
+ "group": "nodes",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": false,
+ "classes": ""
+ }
+ ],
+ "edges": [
+ {
+ "data": {
+ "id": "rn7xa4z8ii",
+ "source": "enzl8c64ap",
+ "target": "trerm4c65h"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "b835zbi9la",
+ "source": "kntyt0l56g",
+ "target": "7nf6nfrmjh"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "gp9e0cyg75",
+ "source": "ua74ll3hyr",
+ "target": "csuaeoul2e"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "529d7o10sf",
+ "source": "l9ealagz9f",
+ "target": "8tby8d48wl"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "b2lc13gvry",
+ "source": "xpbuzakj5t",
+ "target": "l9ealagz9f"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "epf9gs03ih",
+ "source": "7nf6nfrmjh",
+ "target": "br7hr5tmjl"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "xsr45bpx3x",
+ "source": "6cmfqmm16j",
+ "target": "hw7hqbx36s"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "qxmhd5wht8",
+ "source": "hw7hqbx36s",
+ "target": "ua74ll3hyr"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "8ekcwyl15b",
+ "source": "bzk95jzjwb",
+ "target": "u9du1kjnth"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "yn2b9muiwx",
+ "source": "6ypyq8g13m",
+ "target": "k9j98wntkg"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "0756hhk2gj",
+ "source": "29rynrdato",
+ "target": "ux137s6uzv"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "2v0soa7tz9",
+ "source": "nt9dq360b4",
+ "target": "rbagu2oy94"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "w6lbcdgt5w",
+ "source": "mcebh3n0ut",
+ "target": "dyejxr37px"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "i9j4t4jmi8",
+ "source": "1t29hcqrkh",
+ "target": "mcebh3n0ut",
+ "multiValueSourceId": "zpjpqxqiuo"
+ },
+ "position": {
+ "x": 64,
+ "y": 132
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "v7fq1ai8nd",
+ "source": "1t29hcqrkh",
+ "target": "shs6l2ctft",
+ "multiValueSourceId": "sew2kgu30s"
+ },
+ "position": {
+ "x": 64,
+ "y": 92
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "lk9de53c5q",
+ "source": "shs6l2ctft",
+ "target": "dyejxr37px"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "xxkagott3l",
+ "source": "blfdzfqr95",
+ "target": "38juhz1fqt"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "x95qct7292",
+ "source": "ij8wljzsca",
+ "target": "38juhz1fqt"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "nm1y65t7zr",
+ "source": "q458xnlqrm",
+ "target": "elux9ix4p8"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "0i1ekj6ll4",
+ "source": "n6fkt340v4",
+ "target": "lbju1ewykh"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "470oyroz3u",
+ "source": "majchkbryg",
+ "target": "2hvz9mzvef"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "pwkp1qdizt",
+ "source": "czpww8psjn",
+ "target": "4zn6trv91h"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "uq8wrdj25v",
+ "source": "udiotajo7l",
+ "target": "majchkbryg"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "mdnonncx9c",
+ "source": "3qs0bpg7re",
+ "target": "pasqgevo0m"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "hq47auhppp",
+ "source": "5vlifd8z3q",
+ "target": "czpww8psjn"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "mz6okbq5oo",
+ "source": "4zn6trv91h",
+ "target": "cy1ofx1q0h"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "27eq8jhl6p",
+ "source": "btzuhgbxex",
+ "target": "5omuhveoxz"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "2rnbjv5sdc",
+ "source": "d9760ru3th",
+ "target": "kdezpu6465"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "ykmaik8w62",
+ "source": "o0liifk6mu",
+ "target": "xysncspcvz"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "xqspxma3iu",
+ "source": "7egq6vnyck",
+ "target": "cyzsj86ko3"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "zeadacgsh5",
+ "source": "1t29hcqrkh",
+ "target": "79ekrdr8uv",
+ "multiValueSourceId": "-1"
+ },
+ "position": {
+ "x": 64,
+ "y": 212
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "y63i0icdo6",
+ "source": "hej9qphxwy",
+ "target": "9sq46sbnox"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "a2cbht6jh2",
+ "source": "4rajec1wao",
+ "target": "gotpafgt1e"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "82vrmufrnj",
+ "source": "90edpoicow",
+ "target": "t8p5mr23kz"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "lwz4cq2m0s",
+ "source": "t8p5mr23kz",
+ "target": "udiotajo7l"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "s3l979ied7",
+ "source": "cyzsj86ko3",
+ "target": "1t29hcqrkh"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "yi67hoa5ys",
+ "source": "gotpafgt1e",
+ "target": "5f3yq564qe",
+ "multiValueSourceId": "vgii63uk2p"
+ },
+ "position": {
+ "x": 64,
+ "y": 52
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "p153sdqf1u",
+ "source": "1t29hcqrkh",
+ "target": "sbi37mzy02",
+ "multiValueSourceId": "u4wa5b3ksv"
+ },
+ "position": {
+ "x": 64,
+ "y": 52
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "72b3tsj33o",
+ "source": "adwz0hx2wc",
+ "target": "hej9qphxwy"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "25p7ck06uu",
+ "source": "5f3yq564qe",
+ "target": "7egq6vnyck"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "uenkm5u054",
+ "source": "sbi37mzy02",
+ "target": "o0liifk6mu"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "b7lq9j8e11",
+ "source": "79ekrdr8uv",
+ "target": "u9du1kjnth"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "i6jc0p9yub",
+ "source": "gotpafgt1e",
+ "target": "3qs0bpg7re",
+ "multiValueSourceId": "lz24dadkcd"
+ },
+ "position": {
+ "x": 64,
+ "y": 132
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "chb8titjp6",
+ "source": "9sq46sbnox",
+ "target": "4rajec1wao"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "u7g3fqitnq",
+ "source": "kdezpu6465",
+ "target": "nt9dq360b4"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "pubjzlv2tu",
+ "source": "5omuhveoxz",
+ "target": "3stgvz0icw"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "xc0ig3bkn0",
+ "source": "5vlifd8z3q",
+ "target": "nax6zlleme"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "l4ro8u103d",
+ "source": "sgzxpg2n9b",
+ "target": "cpeitqe8mo"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "vyss7vrmrd",
+ "source": "5zf4r3ucmv",
+ "target": "t6mdtcaroq"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "tstvdv5bvx",
+ "source": "t6mdtcaroq",
+ "target": "9sq46sbnox"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "rk7rj59nqk",
+ "source": "gotpafgt1e",
+ "target": "om0w04vne3",
+ "multiValueSourceId": "js0k5824y5"
+ },
+ "position": {
+ "x": 64,
+ "y": 92
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "j28b9m98yc",
+ "source": "om0w04vne3",
+ "target": "6ypyq8g13m"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "zfltc5ngjt",
+ "source": "rbagu2oy94",
+ "target": "blfdzfqr95"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "wnis7f7jv9",
+ "source": "dyejxr37px",
+ "target": "d9760ru3th"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "rjliu39bnz",
+ "source": "uzheay8uuo",
+ "target": "5q1qpa0sil",
+ "multiValueSourceId": "js0k5824y5"
+ },
+ "position": {
+ "x": 64,
+ "y": 92
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "xuo35j4why",
+ "source": "ibssk8xhd4",
+ "target": "84lznqrx1r"
+ },
+ "position": {
"x": 0,
"y": 0
},
@@ -3183,9 +5714,9 @@
},
{
"data": {
- "id": "nm1y65t7zr",
- "source": "q458xnlqrm",
- "target": "elux9ix4p8"
+ "id": "1a1y9amqql",
+ "source": "cpeitqe8mo",
+ "target": "ndbr7s2m1f"
},
"position": {
"x": 0,
@@ -3202,9 +5733,9 @@
},
{
"data": {
- "id": "0i1ekj6ll4",
- "source": "n6fkt340v4",
- "target": "lbju1ewykh"
+ "id": "ytg5wdronb",
+ "source": "5q1qpa0sil",
+ "target": "43zyexjaqy"
},
"position": {
"x": 0,
@@ -3221,9 +5752,29 @@
},
{
"data": {
- "id": "470oyroz3u",
- "source": "majchkbryg",
- "target": "2hvz9mzvef"
+ "id": "snlir9mdob",
+ "source": "uzheay8uuo",
+ "target": "ibssk8xhd4",
+ "multiValueSourceId": "vgii63uk2p"
+ },
+ "position": {
+ "x": 64,
+ "y": 52
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "kl05olakgz",
+ "source": "84lznqrx1r",
+ "target": "5vlifd8z3q"
},
"position": {
"x": 0,
@@ -3240,9 +5791,9 @@
},
{
"data": {
- "id": "pwkp1qdizt",
- "source": "czpww8psjn",
- "target": "4zn6trv91h"
+ "id": "e8atw456ve",
+ "source": "ndbr7s2m1f",
+ "target": "uzheay8uuo"
},
"position": {
"x": 0,
@@ -3259,9 +5810,9 @@
},
{
"data": {
- "id": "uq8wrdj25v",
- "source": "udiotajo7l",
- "target": "majchkbryg"
+ "id": "9ng17ls6fj",
+ "source": "1025ysyl1b",
+ "target": "sgzxpg2n9b"
},
"position": {
"x": 0,
@@ -3278,9 +5829,9 @@
},
{
"data": {
- "id": "mdnonncx9c",
- "source": "3qs0bpg7re",
- "target": "pasqgevo0m"
+ "id": "9mol742fd5",
+ "source": "3stgvz0icw",
+ "target": "ij8wljzsca"
},
"position": {
"x": 0,
@@ -3297,9 +5848,9 @@
},
{
"data": {
- "id": "hq47auhppp",
- "source": "5vlifd8z3q",
- "target": "czpww8psjn"
+ "id": "oe9ir5h1sv",
+ "source": "xysncspcvz",
+ "target": "btzuhgbxex"
},
"position": {
"x": 0,
@@ -3316,9 +5867,9 @@
},
{
"data": {
- "id": "mz6okbq5oo",
- "source": "4zn6trv91h",
- "target": "cy1ofx1q0h"
+ "id": "knfbq7epce",
+ "source": "pbg0lrkzbn",
+ "target": "imxpe6sk4o"
},
"position": {
"x": 0,
@@ -3335,9 +5886,9 @@
},
{
"data": {
- "id": "27eq8jhl6p",
- "source": "btzuhgbxex",
- "target": "5omuhveoxz"
+ "id": "4q87gy6y4d",
+ "source": "7bz2i8lmr7",
+ "target": "xzt3f89bs9"
},
"position": {
"x": 0,
@@ -3354,9 +5905,9 @@
},
{
"data": {
- "id": "2rnbjv5sdc",
- "source": "d9760ru3th",
- "target": "kdezpu6465"
+ "id": "db0ikotv1m",
+ "source": "pbg0lrkzbn",
+ "target": "u1zlbiftfc"
},
"position": {
"x": 0,
@@ -3373,9 +5924,9 @@
},
{
"data": {
- "id": "ykmaik8w62",
- "source": "o0liifk6mu",
- "target": "xysncspcvz"
+ "id": "vzby98so48",
+ "source": "u1zlbiftfc",
+ "target": "4yradjfshj"
},
"position": {
"x": 0,
@@ -3392,9 +5943,9 @@
},
{
"data": {
- "id": "xqspxma3iu",
- "source": "7egq6vnyck",
- "target": "cyzsj86ko3"
+ "id": "kxicwgwoir",
+ "source": "xzt3f89bs9",
+ "target": "yy8govzsfj"
},
"position": {
"x": 0,
@@ -3411,10 +5962,29 @@
},
{
"data": {
- "id": "zeadacgsh5",
- "source": "1t29hcqrkh",
- "target": "79ekrdr8uv",
- "multiValueSourceId": "-1"
+ "id": "nzjn6u6g5u",
+ "source": "yy8govzsfj",
+ "target": "pbg0lrkzbn"
+ },
+ "position": {
+ "x": 0,
+ "y": 0
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "0nz5upcn3k",
+ "source": "gotpafgt1e",
+ "target": "xb7zluzyvp",
+ "multiValueSourceId": "qr5jh5knhn"
},
"position": {
"x": 64,
@@ -3431,9 +6001,9 @@
},
{
"data": {
- "id": "y63i0icdo6",
- "source": "hej9qphxwy",
- "target": "9sq46sbnox"
+ "id": "c8ns2pvi75",
+ "source": "xb7zluzyvp",
+ "target": "r0d3h0ncoj"
},
"position": {
"x": 0,
@@ -3450,9 +6020,9 @@
},
{
"data": {
- "id": "a2cbht6jh2",
- "source": "4rajec1wao",
- "target": "gotpafgt1e"
+ "id": "8go3g98p21",
+ "source": "891xvrja20",
+ "target": "57qbtxkl6i"
},
"position": {
"x": 0,
@@ -3469,9 +6039,9 @@
},
{
"data": {
- "id": "82vrmufrnj",
- "source": "90edpoicow",
- "target": "t8p5mr23kz"
+ "id": "nbc6156oyx",
+ "source": "57qbtxkl6i",
+ "target": "7bz2i8lmr7"
},
"position": {
"x": 0,
@@ -3488,9 +6058,9 @@
},
{
"data": {
- "id": "lwz4cq2m0s",
- "source": "t8p5mr23kz",
- "target": "udiotajo7l"
+ "id": "ualojoirw2",
+ "source": "do9u0o5e4w",
+ "target": "c5zkr5dqvn"
},
"position": {
"x": 0,
@@ -3507,9 +6077,9 @@
},
{
"data": {
- "id": "s3l979ied7",
- "source": "cyzsj86ko3",
- "target": "1t29hcqrkh"
+ "id": "4dfi3ezo2i",
+ "source": "c5zkr5dqvn",
+ "target": "wws2jfd505"
},
"position": {
"x": 0,
@@ -3526,14 +6096,13 @@
},
{
"data": {
- "id": "yi67hoa5ys",
- "source": "gotpafgt1e",
- "target": "5f3yq564qe",
- "multiValueSourceId": "vgii63uk2p"
+ "id": "hjp9w3wn43",
+ "source": "4yradjfshj",
+ "target": "w4mssa7ppk"
},
"position": {
- "x": 64,
- "y": 52
+ "x": 0,
+ "y": 0
},
"group": "edges",
"removed": false,
@@ -3546,14 +6115,13 @@
},
{
"data": {
- "id": "p153sdqf1u",
- "source": "1t29hcqrkh",
- "target": "sbi37mzy02",
- "multiValueSourceId": "u4wa5b3ksv"
+ "id": "xpf2pwainy",
+ "source": "lbju1ewykh",
+ "target": "ena2d7ummb"
},
"position": {
- "x": 64,
- "y": 52
+ "x": 0,
+ "y": 0
},
"group": "edges",
"removed": false,
@@ -3566,9 +6134,9 @@
},
{
"data": {
- "id": "72b3tsj33o",
- "source": "adwz0hx2wc",
- "target": "hej9qphxwy"
+ "id": "rq2xev0ktg",
+ "source": "ft78l44mq0",
+ "target": "n6fkt340v4"
},
"position": {
"x": 0,
@@ -3585,9 +6153,9 @@
},
{
"data": {
- "id": "25p7ck06uu",
- "source": "5f3yq564qe",
- "target": "7egq6vnyck"
+ "id": "sffzoeqek8",
+ "source": "wws2jfd505",
+ "target": "ft78l44mq0"
},
"position": {
"x": 0,
@@ -3604,9 +6172,9 @@
},
{
"data": {
- "id": "uenkm5u054",
- "source": "sbi37mzy02",
- "target": "o0liifk6mu"
+ "id": "slspmyjq7q",
+ "source": "ft78l44mq0",
+ "target": "l8n89brc94"
},
"position": {
"x": 0,
@@ -3623,9 +6191,29 @@
},
{
"data": {
- "id": "b7lq9j8e11",
- "source": "79ekrdr8uv",
- "target": "u9du1kjnth"
+ "id": "0fpi0el32n",
+ "source": "gotpafgt1e",
+ "target": "q458xnlqrm",
+ "multiValueSourceId": "zn71z71tew"
+ },
+ "position": {
+ "x": 64,
+ "y": 212
+ },
+ "group": "edges",
+ "removed": false,
+ "selected": false,
+ "selectable": true,
+ "locked": false,
+ "grabbable": true,
+ "pannable": true,
+ "classes": ""
+ },
+ {
+ "data": {
+ "id": "k424ykozz5",
+ "source": "k9j98wntkg",
+ "target": "f8ftclpx0j"
},
"position": {
"x": 0,
@@ -3642,14 +6230,13 @@
},
{
"data": {
- "id": "i6jc0p9yub",
- "source": "gotpafgt1e",
- "target": "3qs0bpg7re",
- "multiValueSourceId": "lz24dadkcd"
+ "id": "5dgld0i6ee",
+ "source": "84eyscl19m",
+ "target": "29rynrdato"
},
"position": {
- "x": 64,
- "y": 132
+ "x": 0,
+ "y": 0
},
"group": "edges",
"removed": false,
@@ -3662,13 +6249,14 @@
},
{
"data": {
- "id": "chb8titjp6",
- "source": "9sq46sbnox",
- "target": "4rajec1wao"
+ "id": "yvnruw40xu",
+ "source": "f8ftclpx0j",
+ "target": "hin927mp81",
+ "multiValueSourceId": "u4wa5b3ksv"
},
"position": {
- "x": 0,
- "y": 0
+ "x": 64,
+ "y": 52
},
"group": "edges",
"removed": false,
@@ -3681,9 +6269,9 @@
},
{
"data": {
- "id": "u7g3fqitnq",
- "source": "kdezpu6465",
- "target": "nt9dq360b4"
+ "id": "pf247dy7a1",
+ "source": "hin927mp81",
+ "target": "84eyscl19m"
},
"position": {
"x": 0,
@@ -3700,13 +6288,14 @@
},
{
"data": {
- "id": "pubjzlv2tu",
- "source": "5omuhveoxz",
- "target": "3stgvz0icw"
+ "id": "3fx0npd4r0",
+ "source": "f8ftclpx0j",
+ "target": "ssqpb18kvf",
+ "multiValueSourceId": "sew2kgu30s"
},
"position": {
- "x": 0,
- "y": 0
+ "x": 64,
+ "y": 92
},
"group": "edges",
"removed": false,
@@ -3719,9 +6308,9 @@
},
{
"data": {
- "id": "xc0ig3bkn0",
- "source": "5vlifd8z3q",
- "target": "nax6zlleme"
+ "id": "ae1l9v035o",
+ "source": "ssqpb18kvf",
+ "target": "zofgrldkqp"
},
"position": {
"x": 0,
@@ -3738,13 +6327,14 @@
},
{
"data": {
- "id": "l4ro8u103d",
- "source": "sgzxpg2n9b",
- "target": "cpeitqe8mo"
+ "id": "42wdcz2tuc",
+ "source": "f8ftclpx0j",
+ "target": "ifmuvlu9ja",
+ "multiValueSourceId": "zpjpqxqiuo"
},
"position": {
- "x": 0,
- "y": 0
+ "x": 64,
+ "y": 132
},
"group": "edges",
"removed": false,
@@ -3757,9 +6347,9 @@
},
{
"data": {
- "id": "vyss7vrmrd",
- "source": "5zf4r3ucmv",
- "target": "t6mdtcaroq"
+ "id": "wze851vy0n",
+ "source": "ifmuvlu9ja",
+ "target": "iplym3pytc"
},
"position": {
"x": 0,
@@ -3776,9 +6366,9 @@
},
{
"data": {
- "id": "tstvdv5bvx",
- "source": "t6mdtcaroq",
- "target": "9sq46sbnox"
+ "id": "p44ws9jp21",
+ "source": "zofgrldkqp",
+ "target": "oufu89amkt"
},
"position": {
"x": 0,
@@ -3795,14 +6385,13 @@
},
{
"data": {
- "id": "rk7rj59nqk",
- "source": "gotpafgt1e",
- "target": "om0w04vne3",
- "multiValueSourceId": "js0k5824y5"
+ "id": "ihme9cnmtz",
+ "source": "oufu89amkt",
+ "target": "ux137s6uzv"
},
"position": {
- "x": 64,
- "y": 92
+ "x": 0,
+ "y": 0
},
"group": "edges",
"removed": false,
@@ -3815,9 +6404,9 @@
},
{
"data": {
- "id": "j28b9m98yc",
- "source": "om0w04vne3",
- "target": "6ypyq8g13m"
+ "id": "8a04zhxy57",
+ "source": "iplym3pytc",
+ "target": "7t80vi0ee5"
},
"position": {
"x": 0,
@@ -3834,9 +6423,9 @@
},
{
"data": {
- "id": "pd0g0eq5i5",
- "source": "6ypyq8g13m",
- "target": "66pnxif5yl"
+ "id": "ufwrw1ati3",
+ "source": "7t80vi0ee5",
+ "target": "ux137s6uzv"
},
"position": {
"x": 0,
@@ -3853,13 +6442,14 @@
},
{
"data": {
- "id": "5dg8pul2ud",
- "source": "66pnxif5yl",
- "target": "ux137s6uzv"
+ "id": "bnkjs89qvb",
+ "source": "f8ftclpx0j",
+ "target": "bzk95jzjwb",
+ "multiValueSourceId": "-1"
},
"position": {
- "x": 0,
- "y": 0
+ "x": 64,
+ "y": 212
},
"group": "edges",
"removed": false,
@@ -3872,13 +6462,14 @@
},
{
"data": {
- "id": "wnis7f7jv9",
- "source": "dyejxr37px",
- "target": "d9760ru3th"
+ "id": "qaw3vq314x",
+ "source": "1t29hcqrkh",
+ "target": "8sdxfsme0c",
+ "multiValueSourceId": "zxu2ux50qq"
},
"position": {
- "x": 0,
- "y": 0
+ "x": 64,
+ "y": 172
},
"group": "edges",
"removed": false,
@@ -3891,14 +6482,13 @@
},
{
"data": {
- "id": "rjliu39bnz",
- "source": "uzheay8uuo",
- "target": "5q1qpa0sil",
- "multiValueSourceId": "js0k5824y5"
+ "id": "qtof7f7qrx",
+ "source": "8sdxfsme0c",
+ "target": "cg1j2768j5"
},
"position": {
- "x": 64,
- "y": 92
+ "x": 0,
+ "y": 0
},
"group": "edges",
"removed": false,
@@ -3911,9 +6501,9 @@
},
{
"data": {
- "id": "xuo35j4why",
- "source": "ibssk8xhd4",
- "target": "84lznqrx1r"
+ "id": "wj43jmtj3x",
+ "source": "cg1j2768j5",
+ "target": "ihaib2krqq"
},
"position": {
"x": 0,
@@ -3930,9 +6520,9 @@
},
{
"data": {
- "id": "1a1y9amqql",
- "source": "cpeitqe8mo",
- "target": "ndbr7s2m1f"
+ "id": "pcsr85v6y4",
+ "source": "ihaib2krqq",
+ "target": "0b046cg4qm"
},
"position": {
"x": 0,
@@ -3949,9 +6539,9 @@
},
{
"data": {
- "id": "ytg5wdronb",
- "source": "5q1qpa0sil",
- "target": "43zyexjaqy"
+ "id": "ze6w5dxfd7",
+ "source": "0b046cg4qm",
+ "target": "ioxacj0u1n"
},
"position": {
"x": 0,
@@ -3968,14 +6558,13 @@
},
{
"data": {
- "id": "snlir9mdob",
- "source": "uzheay8uuo",
- "target": "ibssk8xhd4",
- "multiValueSourceId": "vgii63uk2p"
+ "id": "b67zrz1chx",
+ "source": "ioxacj0u1n",
+ "target": "djlska0cjc"
},
"position": {
- "x": 64,
- "y": 52
+ "x": 0,
+ "y": 0
},
"group": "edges",
"removed": false,
@@ -3988,9 +6577,9 @@
},
{
"data": {
- "id": "kl05olakgz",
- "source": "84lznqrx1r",
- "target": "5vlifd8z3q"
+ "id": "42jyx67x8n",
+ "source": "tr84x1ef1s",
+ "target": "gatq391k8j"
},
"position": {
"x": 0,
@@ -4007,9 +6596,9 @@
},
{
"data": {
- "id": "e8atw456ve",
- "source": "ndbr7s2m1f",
- "target": "uzheay8uuo"
+ "id": "4hns7zgvpl",
+ "source": "gatq391k8j",
+ "target": "8xm4kwjit3"
},
"position": {
"x": 0,
@@ -4026,13 +6615,14 @@
},
{
"data": {
- "id": "9ng17ls6fj",
- "source": "1025ysyl1b",
- "target": "sgzxpg2n9b"
+ "id": "rxuhpkkzj0",
+ "source": "djlska0cjc",
+ "target": "bfw82fkxu1",
+ "multiValueSourceId": "jtdtvs8l1n"
},
"position": {
- "x": 0,
- "y": 0
+ "x": 64,
+ "y": 52
},
"group": "edges",
"removed": false,
@@ -4045,9 +6635,9 @@
},
{
"data": {
- "id": "9mol742fd5",
- "source": "3stgvz0icw",
- "target": "ij8wljzsca"
+ "id": "425iqttw70",
+ "source": "bfw82fkxu1",
+ "target": "xqp0tzgwd3"
},
"position": {
"x": 0,
@@ -4064,9 +6654,9 @@
},
{
"data": {
- "id": "oe9ir5h1sv",
- "source": "xysncspcvz",
- "target": "btzuhgbxex"
+ "id": "at4kvgxpcu",
+ "source": "csuaeoul2e",
+ "target": "xpbuzakj5t"
},
"position": {
"x": 0,
@@ -4083,9 +6673,9 @@
},
{
"data": {
- "id": "knfbq7epce",
- "source": "pbg0lrkzbn",
- "target": "imxpe6sk4o"
+ "id": "b6cyn8vgre",
+ "source": "xpbuzakj5t",
+ "target": "kntyt0l56g"
},
"position": {
"x": 0,
@@ -4102,9 +6692,9 @@
},
{
"data": {
- "id": "4q87gy6y4d",
- "source": "7bz2i8lmr7",
- "target": "xzt3f89bs9"
+ "id": "wvkkr69pqw",
+ "source": "rp7vuf786l",
+ "target": "1ch625x7gi"
},
"position": {
"x": 0,
@@ -4121,9 +6711,9 @@
},
{
"data": {
- "id": "db0ikotv1m",
- "source": "pbg0lrkzbn",
- "target": "u1zlbiftfc"
+ "id": "9kcbv384ut",
+ "source": "1ch625x7gi",
+ "target": "38juhz1fqt"
},
"position": {
"x": 0,
@@ -4140,13 +6730,14 @@
},
{
"data": {
- "id": "vzby98so48",
- "source": "u1zlbiftfc",
- "target": "4yradjfshj"
+ "id": "dd1debkoj",
+ "source": "gotpafgt1e",
+ "target": "hhi13srswe",
+ "multiValueSourceId": "jr2r676f48"
},
"position": {
- "x": 0,
- "y": 0
+ "x": 64,
+ "y": 252
},
"group": "edges",
"removed": false,
@@ -4159,9 +6750,9 @@
},
{
"data": {
- "id": "kxicwgwoir",
- "source": "xzt3f89bs9",
- "target": "yy8govzsfj"
+ "id": "si8lnyaezg",
+ "source": "hhi13srswe",
+ "target": "hhxmb2swrv"
},
"position": {
"x": 0,
@@ -4178,9 +6769,9 @@
},
{
"data": {
- "id": "nzjn6u6g5u",
- "source": "yy8govzsfj",
- "target": "pbg0lrkzbn"
+ "id": "af9vzpf16a",
+ "source": "hhxmb2swrv",
+ "target": "enzl8c64ap"
},
"position": {
"x": 0,
@@ -4197,14 +6788,13 @@
},
{
"data": {
- "id": "0nz5upcn3k",
- "source": "gotpafgt1e",
- "target": "xb7zluzyvp",
- "multiValueSourceId": "qr5jh5knhn"
+ "id": "mf8lpmlq19",
+ "source": "enzl8c64ap",
+ "target": "trerm4c65h"
},
"position": {
- "x": 64,
- "y": 172
+ "x": 0,
+ "y": 0
},
"group": "edges",
"removed": false,
@@ -4217,9 +6807,9 @@
},
{
"data": {
- "id": "c8ns2pvi75",
- "source": "xb7zluzyvp",
- "target": "r0d3h0ncoj"
+ "id": "21f7y0s0uk",
+ "source": "trerm4c65h",
+ "target": "l9io6mss2m"
},
"position": {
"x": 0,
@@ -4236,9 +6826,9 @@
},
{
"data": {
- "id": "8go3g98p21",
- "source": "891xvrja20",
- "target": "57qbtxkl6i"
+ "id": "st45nbmokp",
+ "source": "l9io6mss2m",
+ "target": "123jnvu4g6"
},
"position": {
"x": 0,
@@ -4255,9 +6845,9 @@
},
{
"data": {
- "id": "nbc6156oyx",
- "source": "57qbtxkl6i",
- "target": "7bz2i8lmr7"
+ "id": "hteajtzjgh",
+ "source": "123jnvu4g6",
+ "target": "tp4db1oi9s"
},
"position": {
"x": 0,
@@ -4274,9 +6864,9 @@
},
{
"data": {
- "id": "ualojoirw2",
- "source": "do9u0o5e4w",
- "target": "c5zkr5dqvn"
+ "id": "dltaqgvatj",
+ "source": "tp4db1oi9s",
+ "target": "tr84x1ef1s"
},
"position": {
"x": 0,
@@ -4293,9 +6883,9 @@
},
{
"data": {
- "id": "4dfi3ezo2i",
- "source": "c5zkr5dqvn",
- "target": "wws2jfd505"
+ "id": "0bksyv5u19",
+ "source": "br7hr5tmjl",
+ "target": "wy6gd6su5z"
},
"position": {
"x": 0,
@@ -4312,9 +6902,9 @@
},
{
"data": {
- "id": "hjp9w3wn43",
- "source": "4yradjfshj",
- "target": "w4mssa7ppk"
+ "id": "g2dtj7eb67",
+ "source": "123jnvu4g6",
+ "target": "cx7qx9n94m"
},
"position": {
"x": 0,
@@ -4331,9 +6921,9 @@
},
{
"data": {
- "id": "xpf2pwainy",
- "source": "lbju1ewykh",
- "target": "ena2d7ummb"
+ "id": "y00wym5n0x",
+ "source": "cx7qx9n94m",
+ "target": "5novbfa0vx"
},
"position": {
"x": 0,
@@ -4350,13 +6940,14 @@
},
{
"data": {
- "id": "rq2xev0ktg",
- "source": "ft78l44mq0",
- "target": "n6fkt340v4"
+ "id": "hkie2wu9qm",
+ "source": "f8ftclpx0j",
+ "target": "hi31a14lpy",
+ "multiValueSourceId": "bm3lqpxphv"
},
"position": {
- "x": 0,
- "y": 0
+ "x": 64,
+ "y": 172
},
"group": "edges",
"removed": false,
@@ -4369,9 +6960,9 @@
},
{
"data": {
- "id": "sffzoeqek8",
- "source": "wws2jfd505",
- "target": "ft78l44mq0"
+ "id": "7ceoj7kevu",
+ "source": "hi31a14lpy",
+ "target": "6cmfqmm16j"
},
"position": {
"x": 0,
@@ -4388,13 +6979,14 @@
},
{
"data": {
- "id": "slspmyjq7q",
- "source": "ft78l44mq0",
- "target": "l8n89brc94"
+ "id": "zuwlnh8r28",
+ "source": "djlska0cjc",
+ "target": "baa4rpuft4",
+ "multiValueSourceId": "-1"
},
"position": {
- "x": 0,
- "y": 0
+ "x": 64,
+ "y": 92
},
"group": "edges",
"removed": false,
@@ -4407,14 +6999,13 @@
},
{
"data": {
- "id": "0fpi0el32n",
- "source": "gotpafgt1e",
- "target": "q458xnlqrm",
- "multiValueSourceId": "zn71z71tew"
+ "id": "3e8nsx3it9",
+ "source": "baa4rpuft4",
+ "target": "cdg0e7lafl"
},
"position": {
- "x": 64,
- "y": 212
+ "x": 0,
+ "y": 0
},
"group": "edges",
"removed": false,
@@ -4427,9 +7018,9 @@
},
{
"data": {
- "id": "2v0soa7tz9",
- "source": "nt9dq360b4",
- "target": "rbagu2oy94"
+ "id": "w079ean2bw",
+ "source": "cdg0e7lafl",
+ "target": "kjfb5ojy4y"
},
"position": {
"x": 0,
@@ -4446,9 +7037,9 @@
},
{
"data": {
- "id": "zfltc5ngjt",
- "source": "rbagu2oy94",
- "target": "blfdzfqr95"
+ "id": "q8eb4kf3yi",
+ "source": "kjfb5ojy4y",
+ "target": "rp7vuf786l"
},
"position": {
"x": 0,
@@ -4483,7 +7074,7 @@
}
},
"flowColor": "#ff661c",
- "savedDate": 1748290330306,
+ "savedDate": 1774392716358,
"variables": [
{
"context": "flowInstance",
@@ -4493,10 +7084,12 @@
"displayName": "Url for company's logo image",
"mutable": true,
"min": 0,
- "max": 2000
+ "max": 2000,
+ "sizeInBytes": 0
},
"id": "557935c0-d1d3-425a-ae43-4526237fae9b",
"type": "property",
+ "updatedDate": 1771564975908,
"visibility": "private",
"name": "companyLogo##SK##flowInstance",
"companyId": "02fb4743-189a-4bc7-9d6c-a919edfe6447"
@@ -4509,14 +7102,512 @@
"displayName": "",
"mutable": true,
"min": 0,
- "max": 2000
+ "max": 2000,
+ "sizeInBytes": 0
},
"id": "42db1765-1a4f-4738-b1dc-149b6cd7cae3",
"type": "property",
+ "updatedDate": 1771564975909,
"visibility": "private",
"name": "companyName##SK##flowInstance",
"companyId": "02fb4743-189a-4bc7-9d6c-a919edfe6447"
}
],
+ "forms": {
+ "2d1831bc-2d55-49d7-ae20-6225d0a90c0e": {
+ "id": "2d1831bc-2d55-49d7-ae20-6225d0a90c0e",
+ "environment": {
+ "id": "02fb4743-189a-4bc7-9d6c-a919edfe6447"
+ },
+ "name": "SDK Automation - Sign On",
+ "description": "This is an out-of-the-box sign on form that prompts for username and password.",
+ "category": "CUSTOM",
+ "components": {
+ "fields": [
+ {
+ "type": "SLATE_TEXTBLOB",
+ "position": {
+ "row": 0,
+ "col": 0
+ },
+ "content": "[{\"children\":[{\"text\":\"Sign On\",\"fontWeight\":\"bold\"}],\"type\":\"heading-1\"},{\"type\":\"divider\",\"children\":[{\"text\":\"\"}]},{\"type\":\"paragraph\",\"children\":[{\"text\":\"\"}]}]",
+ "key": "rich-text"
+ },
+ {
+ "type": "ERROR_DISPLAY",
+ "position": {
+ "row": 1,
+ "col": 0
+ }
+ },
+ {
+ "type": "TEXT",
+ "position": {
+ "row": 2,
+ "col": 0
+ },
+ "key": "user.username",
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Username\"}]}]",
+ "required": true,
+ "validation": {
+ "type": "NONE",
+ "regex": "",
+ "errorMessage": ""
+ },
+ "otherOptionEnabled": false,
+ "otherOptionAttributeDisabled": false,
+ "attributeDisabled": false
+ },
+ {
+ "type": "PASSWORD",
+ "position": {
+ "row": 3,
+ "col": 0
+ },
+ "key": "user.password",
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Password\"}]}]",
+ "required": true,
+ "validation": {
+ "type": "NONE",
+ "regex": "",
+ "errorMessage": ""
+ },
+ "otherOptionEnabled": false,
+ "otherOptionAttributeDisabled": false,
+ "showPasswordRequirements": false,
+ "attributeDisabled": false
+ },
+ {
+ "type": "SUBMIT_BUTTON",
+ "position": {
+ "row": 4,
+ "col": 0
+ },
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"\"},{\"type\":\"i18n\",\"key\":\"button.text.signOn\",\"defaultTranslation\":\"Sign On\",\"inline\":true,\"children\":[{\"text\":\"\"}]},{\"text\":\"\"}]}]"
+ }
+ ]
+ },
+ "cols": 4,
+ "markOptional": true,
+ "markRequired": false,
+ "textAutoCompleteEnabled": false,
+ "passwordAutoCompleteEnabled": false,
+ "translationMethod": "TRANSLATE",
+ "created": "2025-03-31T17:50:30.048Z",
+ "modified": "2026-03-28T15:01:57.888Z",
+ "readOnly": false
+ },
+ "52688b76-2a56-4e9b-b67d-cb786866e749": {
+ "id": "52688b76-2a56-4e9b-b67d-cb786866e749",
+ "environment": {
+ "id": "02fb4743-189a-4bc7-9d6c-a919edfe6447"
+ },
+ "name": "FIDO2 Registration",
+ "category": "CUSTOM",
+ "components": {
+ "fields": [
+ {
+ "type": "ERROR_DISPLAY",
+ "position": {
+ "row": 0,
+ "col": 0
+ }
+ },
+ {
+ "type": "FIDO2",
+ "position": {
+ "row": 1,
+ "col": 0
+ },
+ "key": "fido2",
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Custom Label\"}]}]",
+ "action": "REGISTER",
+ "trigger": "AUTOMATIC",
+ "required": true
+ }
+ ]
+ },
+ "cols": 4,
+ "markOptional": true,
+ "markRequired": false,
+ "textAutoCompleteEnabled": false,
+ "passwordAutoCompleteEnabled": false,
+ "translationMethod": "TRANSLATE",
+ "created": "2025-04-28T18:19:24.870Z",
+ "modified": "2026-02-09T22:07:01.440Z",
+ "readOnly": false
+ },
+ "c1161c4e-9234-41df-9bbf-afd5c7c9ca71": {
+ "id": "c1161c4e-9234-41df-9bbf-afd5c7c9ca71",
+ "environment": {
+ "id": "02fb4743-189a-4bc7-9d6c-a919edfe6447"
+ },
+ "name": "SDK Automation - Device Authentication",
+ "description": "Test form for DEVICE_AUTHENTICATION collector",
+ "category": "CUSTOM",
+ "components": {
+ "fields": [
+ {
+ "type": "ERROR_DISPLAY",
+ "position": {
+ "row": 0,
+ "col": 0
+ }
+ },
+ {
+ "type": "DEVICE_AUTHENTICATION",
+ "position": {
+ "row": 1,
+ "col": 0
+ },
+ "key": "device-authentication",
+ "label": "MFA Device Selection - Authentication",
+ "options": [
+ {
+ "type": "FIDO2",
+ "title": "Biometrics/Security Key",
+ "description": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Use your device biometrics or security key to authenticate.\"}]}]",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.10.0/images/icon-outline-fido-passkey.svg"
+ },
+ {
+ "type": "TOTP",
+ "title": "Authenticator App",
+ "description": "Use a code from your authenticator app to authenticate.",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.10.0/images/icon-outline-iphone.svg"
+ },
+ {
+ "type": "EMAIL",
+ "title": "Email",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.10.0/images/icon-outline-mail.svg"
+ },
+ {
+ "type": "SMS",
+ "title": "Text Message",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.18.2/images/icon-outline-text-message.svg"
+ },
+ {
+ "type": "VOICE",
+ "title": "Voice",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.10.0/images/icon-outline-call.svg"
+ },
+ {
+ "type": "MOBILE",
+ "title": "Mobile App",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.10.0/images/icon-outline-iphone.svg"
+ },
+ {
+ "type": "MAGIC_LINK",
+ "title": "Magic Link",
+ "description": "{{magicLinkHint}}",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.18.2/images/link-variant.svg"
+ }
+ ],
+ "required": true
+ }
+ ]
+ },
+ "cols": 4,
+ "markOptional": true,
+ "markRequired": false,
+ "textAutoCompleteEnabled": false,
+ "passwordAutoCompleteEnabled": false,
+ "translationMethod": "TRANSLATE",
+ "created": "2025-05-23T03:26:19.397Z",
+ "modified": "2025-05-26T19:04:58.291Z",
+ "readOnly": false
+ },
+ "a48ea92f-2180-4bac-97c2-e605acc77c90": {
+ "id": "a48ea92f-2180-4bac-97c2-e605acc77c90",
+ "environment": {
+ "id": "02fb4743-189a-4bc7-9d6c-a919edfe6447"
+ },
+ "name": "SDK Automation - Enter Email",
+ "description": "Enter email for registration",
+ "category": "CUSTOM",
+ "components": {
+ "fields": [
+ {
+ "type": "SLATE_TEXTBLOB",
+ "position": {
+ "row": 0,
+ "col": 0
+ },
+ "content": "[{\"type\":\"align-left\",\"children\":[{\"children\":[{\"text\":\"Enter Email\"}],\"type\":\"heading-1\"}]}]",
+ "key": "rich-text",
+ "icon": {
+ "type": "NONE",
+ "size": "MEDIUM"
+ }
+ },
+ {
+ "type": "ERROR_DISPLAY",
+ "position": {
+ "row": 1,
+ "col": 0
+ }
+ },
+ {
+ "type": "TEXT",
+ "position": {
+ "row": 2,
+ "col": 0
+ },
+ "key": "email",
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Email Address\"}]}]",
+ "required": true,
+ "validation": {
+ "type": "NONE",
+ "regex": "",
+ "errorMessage": ""
+ },
+ "otherOptionEnabled": false,
+ "otherOptionAttributeDisabled": false,
+ "attributeDisabled": false
+ },
+ {
+ "type": "SUBMIT_BUTTON",
+ "position": {
+ "row": 3,
+ "col": 0
+ },
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"\"},{\"type\":\"i18n\",\"key\":\"button.text\",\"defaultTranslation\":\"Submit\",\"inline\":true,\"children\":[{\"text\":\"\"}]},{\"text\":\"\"}]}]"
+ }
+ ]
+ },
+ "cols": 4,
+ "markOptional": true,
+ "markRequired": false,
+ "textAutoCompleteEnabled": false,
+ "passwordAutoCompleteEnabled": false,
+ "translationMethod": "TRANSLATE",
+ "created": "2025-05-26T18:02:56.839Z",
+ "modified": "2025-05-26T22:24:55.561Z",
+ "readOnly": false
+ },
+ "50409983-d54d-4859-b34a-bacc1723fe51": {
+ "id": "50409983-d54d-4859-b34a-bacc1723fe51",
+ "environment": {
+ "id": "02fb4743-189a-4bc7-9d6c-a919edfe6447"
+ },
+ "name": "FIDO2 Authentication",
+ "category": "CUSTOM",
+ "components": {
+ "fields": [
+ {
+ "type": "ERROR_DISPLAY",
+ "position": {
+ "row": 0,
+ "col": 0
+ }
+ },
+ {
+ "type": "FIDO2",
+ "position": {
+ "row": 1,
+ "col": 0
+ },
+ "key": "fido2",
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Custom Label\"}]}]",
+ "action": "AUTHENTICATE",
+ "trigger": "AUTOMATIC",
+ "required": true
+ }
+ ]
+ },
+ "cols": 4,
+ "markOptional": true,
+ "markRequired": false,
+ "textAutoCompleteEnabled": false,
+ "passwordAutoCompleteEnabled": false,
+ "translationMethod": "TRANSLATE",
+ "created": "2025-04-28T18:58:58.443Z",
+ "modified": "2025-11-13T21:21:44.140Z",
+ "readOnly": false
+ },
+ "518a98b5-229c-40fd-9fe5-e8be384f8de3": {
+ "id": "518a98b5-229c-40fd-9fe5-e8be384f8de3",
+ "environment": {
+ "id": "02fb4743-189a-4bc7-9d6c-a919edfe6447"
+ },
+ "name": "SDK Automation - Enter Phone Number",
+ "description": "Enter phone number",
+ "category": "CUSTOM",
+ "components": {
+ "fields": [
+ {
+ "type": "SLATE_TEXTBLOB",
+ "position": {
+ "row": 0,
+ "col": 0
+ },
+ "content": "[{\"type\":\"align-center\",\"children\":[{\"children\":[{\"text\":\"Phone Number Collector\"}],\"type\":\"heading-1\"}]}]",
+ "key": "rich-text",
+ "icon": {
+ "type": "NONE",
+ "size": "MEDIUM"
+ }
+ },
+ {
+ "type": "ERROR_DISPLAY",
+ "position": {
+ "row": 1,
+ "col": 0
+ }
+ },
+ {
+ "type": "DROPDOWN",
+ "position": {
+ "row": 2,
+ "col": 0
+ },
+ "key": "countryCode",
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Country Code\"}]}]",
+ "required": true,
+ "validation": {
+ "type": "NONE",
+ "regex": "",
+ "errorMessage": ""
+ },
+ "options": [
+ {
+ "value": "US",
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"United States\"}]}]"
+ },
+ {
+ "value": "IN",
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"India\"}]}]"
+ },
+ {
+ "value": "BR",
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Brazil\"}]}]"
+ },
+ {
+ "value": "CA",
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Canada\"}]}]"
+ }
+ ],
+ "otherOptionEnabled": false,
+ "otherOptionAttributeDisabled": false,
+ "attributeDisabled": false
+ },
+ {
+ "type": "PHONE_NUMBER",
+ "position": {
+ "row": 3,
+ "col": 0
+ },
+ "key": "phone-input-field",
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Enter Phone Number\"}]}]",
+ "required": true,
+ "validation": {
+ "type": "CUSTOM",
+ "regex": "^[0-9]+$",
+ "errorMessage": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Must be a number and 10 digits\"}]}]"
+ },
+ "otherOptionEnabled": false,
+ "otherOptionAttributeDisabled": false,
+ "defaultCountryCode": "IN",
+ "validatePhoneNumber": false,
+ "showExtension": true,
+ "extensionLabel": "Extension",
+ "attributeDisabled": false
+ },
+ {
+ "type": "SUBMIT_BUTTON",
+ "position": {
+ "row": 4,
+ "col": 0
+ },
+ "label": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"\"},{\"type\":\"i18n\",\"key\":\"button.text\",\"defaultTranslation\":\"Submit\",\"inline\":true,\"children\":[{\"text\":\"\"}]},{\"text\":\"\"}]}]"
+ }
+ ]
+ },
+ "cols": 4,
+ "markOptional": true,
+ "markRequired": false,
+ "textAutoCompleteEnabled": false,
+ "passwordAutoCompleteEnabled": false,
+ "translationMethod": "TRANSLATE",
+ "created": "2025-05-26T18:04:09.127Z",
+ "modified": "2026-04-01T17:39:03.995Z",
+ "readOnly": false
+ },
+ "cb110bef-0994-4fa8-a806-79fb028f0229": {
+ "id": "cb110bef-0994-4fa8-a806-79fb028f0229",
+ "environment": {
+ "id": "02fb4743-189a-4bc7-9d6c-a919edfe6447"
+ },
+ "name": "SDK Automation - Device Registration",
+ "category": "CUSTOM",
+ "components": {
+ "fields": [
+ {
+ "type": "ERROR_DISPLAY",
+ "position": {
+ "row": 0,
+ "col": 0
+ }
+ },
+ {
+ "type": "DEVICE_REGISTRATION",
+ "position": {
+ "row": 1,
+ "col": 0
+ },
+ "key": "device-registration",
+ "label": "MFA Device Selection - Registration",
+ "options": [
+ {
+ "type": "FIDO2",
+ "title": "Biometrics/Security Key",
+ "description": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Register your device to sign on using your fingerprint, face, voice, or device PIN.\"}]}]",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.10.0/images/icon-outline-fido-passkey.svg"
+ },
+ {
+ "type": "TOTP",
+ "title": "Authenticator App",
+ "description": "Use an authenticator app such as Google Authenticator to authenticate.",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.10.0/images/icon-outline-iphone.svg"
+ },
+ {
+ "type": "EMAIL",
+ "title": "Email",
+ "description": "Receive an authentication passcode in your email.",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.10.0/images/icon-outline-mail.svg"
+ },
+ {
+ "type": "SMS",
+ "title": "Text Message",
+ "description": "Receive an authentication passcode in a text message.",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.18.2/images/icon-outline-text-message.svg"
+ },
+ {
+ "type": "VOICE",
+ "title": "Voice",
+ "description": "Receive a phone call with an authentication passcode.",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.10.0/images/icon-outline-call.svg"
+ },
+ {
+ "type": "MOBILE",
+ "title": "Mobile App",
+ "description": "Use Mobile App to receive push notifications.",
+ "iconSrc": "https://assets.pingone.com/ux/end-user/2.10.0/images/icon-outline-iphone.svg"
+ }
+ ],
+ "required": true
+ }
+ ]
+ },
+ "cols": 4,
+ "markOptional": true,
+ "markRequired": false,
+ "textAutoCompleteEnabled": false,
+ "passwordAutoCompleteEnabled": false,
+ "translationMethod": "TRANSLATE",
+ "created": "2025-05-23T01:44:20.268Z",
+ "modified": "2025-05-26T18:28:34.695Z",
+ "readOnly": false
+ }
+ },
"connections": []
}
\ No newline at end of file
diff --git a/davinci/src/androidTest/kotlin/com/pingidentity/davinci/FormFieldValidationTest.kt b/davinci/src/androidTest/kotlin/com/pingidentity/davinci/FormFieldValidationTest.kt
index b78fca8aa..e21f48c7e 100644
--- a/davinci/src/androidTest/kotlin/com/pingidentity/davinci/FormFieldValidationTest.kt
+++ b/davinci/src/androidTest/kotlin/com/pingidentity/davinci/FormFieldValidationTest.kt
@@ -87,7 +87,7 @@ class FormFieldValidationTest {
// Validate should return list with 2 validation errors since the value is empty
// and does not match the configured regex
var usernameValidationResult = username.validate()
- assertTrue(usernameValidationResult.size == 2)
+ assertEquals(2, usernameValidationResult.size)
assertEquals("Required", usernameValidationResult[0].toString())
assertEquals("Must be alphanumeric", (usernameValidationResult[1] as RegexError).message)
@@ -119,7 +119,7 @@ class FormFieldValidationTest {
email.value = "not an email"
emailValidationResult = email.validate() // Should return 1 validation error this time
- assertTrue(emailValidationResult.size == 1)
+ assertEquals(1, emailValidationResult.size)
assertEquals("Not a valid email", (emailValidationResult[0] as RegexError).message)
email.value = "valid@email.com"
@@ -138,20 +138,19 @@ class FormFieldValidationTest {
// Password filed...
assertTrue(node.collectors[3] is PasswordCollector)
val password = node.collectors[3] as PasswordCollector
- //TODO PasswordPolicy
- /*
val passwordPolicy = password.passwordPolicy()
// Assert the password policy
+ assertNotNull(passwordPolicy)
assertTrue(passwordPolicy?.default ?: false)
- assertEquals("Standard", passwordPolicy?.name)
- assertEquals("A standard policy that incorporates industry best practices", passwordPolicy?.description)
- assertEquals(Length(min=8, max=255), passwordPolicy?.length)
- assertEquals(5, passwordPolicy?.minUniqueCharacters)
- assertTrue(passwordPolicy?.minCharacters?.containsKey("0123456789") ?: false)
- assertTrue(passwordPolicy?.minCharacters?.containsKey("ABCDEFGHIJKLMNOPQRSTUVWXYZ") ?: false)
- assertTrue(passwordPolicy?.minCharacters?.containsKey("abcdefghijklmnopqrstuvwxyz") ?: false)
- assertTrue(passwordPolicy?.minCharacters?.containsKey("~!@#$%^&*()-_=+[]{}|;:,.<>/?") ?: false)
+ assertEquals("Standard", passwordPolicy.name)
+ assertEquals("A standard policy that incorporates industry best practices", passwordPolicy.description)
+ assertEquals(Length(min=8, max=255), passwordPolicy.length)
+ assertEquals(5, passwordPolicy.minUniqueCharacters)
+ assertTrue(passwordPolicy.minCharacters.containsKey("0123456789"))
+ assertTrue(passwordPolicy.minCharacters.containsKey("ABCDEFGHIJKLMNOPQRSTUVWXYZ"))
+ assertTrue(passwordPolicy.minCharacters.containsKey("abcdefghijklmnopqrstuvwxyz"))
+ assertTrue(passwordPolicy.minCharacters.containsKey("~!@#$%^&*()-_=+[]{}|;:,.<>/?"))
// Assert the properties of the Password field
assertEquals("Password", password.label)
@@ -168,7 +167,7 @@ class FormFieldValidationTest {
var passwordValidationResult = password.validate()
// The default password policy is:
- assertTrue(passwordValidationResult.size == 7)
+ assertEquals(7, passwordValidationResult.size)
assertTrue(passwordValidationResult.contains(Required))
assertTrue(passwordValidationResult.contains(InvalidLength(min=8, max=255)))
assertTrue(passwordValidationResult.contains(UniqueCharacter(min=5)))
@@ -181,7 +180,7 @@ class FormFieldValidationTest {
password.value = "password123"
passwordValidationResult = password.validate()
- assertTrue(passwordValidationResult.size == 2)
+ assertEquals(2, passwordValidationResult.size)
assertFalse(passwordValidationResult.contains(Required)) // Should not contain Required error
assertFalse(passwordValidationResult.contains(InvalidLength(min=8, max=255))) // Should not contain InvalidLength error
assertFalse(passwordValidationResult.contains(UniqueCharacter(min=5))) // Should not contain UniqueCharacter error
@@ -196,14 +195,13 @@ class FormFieldValidationTest {
// Should return empty list this time
assertTrue(passwordValidationResult.isEmpty())
- */
}
@TestRailCase(27507)
@Test
fun errorNodeTest() = runTest {
// Go to the "Error Node" form
- var node = daVinci.start() as ContinueNode
+ val node = daVinci.start() as ContinueNode
(node.collectors[2] as? FlowCollector)?.value = "click"
val errorNode = node.next()
assertTrue(errorNode is ErrorNode)
diff --git a/davinci/src/androidTest/kotlin/com/pingidentity/davinci/FormFieldsTest.kt b/davinci/src/androidTest/kotlin/com/pingidentity/davinci/FormFieldsTest.kt
index e6d047f83..950d1713b 100644
--- a/davinci/src/androidTest/kotlin/com/pingidentity/davinci/FormFieldsTest.kt
+++ b/davinci/src/androidTest/kotlin/com/pingidentity/davinci/FormFieldsTest.kt
@@ -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.
@@ -12,6 +12,7 @@ import com.pingidentity.davinci.collector.FlowCollector
import com.pingidentity.davinci.collector.LabelCollector
import com.pingidentity.davinci.collector.MultiSelectCollector
import com.pingidentity.davinci.collector.PhoneNumberCollector
+import com.pingidentity.davinci.collector.BooleanCollector
import com.pingidentity.davinci.collector.SingleSelectCollector
import com.pingidentity.davinci.collector.SubmitCollector
import com.pingidentity.davinci.collector.TextCollector
@@ -29,11 +30,30 @@ import org.junit.Rule
import org.junit.rules.TestWatcher
import kotlin.test.BeforeTest
import kotlin.test.Test
+import kotlin.test.assertNotNull
import kotlin.test.assertEquals
import kotlin.test.assertTrue
+private val tokenPattern = Regex("""\{\{(\w+)\}\}""")
+
@SmallTest
class FormFieldsTest {
+
+ companion object {
+ const val LABEL_TEXTBLOB_INDEX = 0
+ const val LABEL_TRANSLATABLE_INDEX = 1
+ const val LABEL_RICH_TEXT_INDEX = 2
+ const val TEXT_INPUT_INDEX = 3
+ const val CHECKBOX_INDEX = 4
+ const val DROPDOWN_INDEX = 5
+ const val RADIO_INDEX = 6
+ const val COMBOBOX_INDEX = 7
+ const val PHONE_NUMBER_INDEX = 8
+ const val SINGLE_CHECKBOX_INDEX = 9
+ const val FLOW_BUTTON_INDEX = 10
+ const val FLOW_LINK_INDEX = 11
+ }
+
private var daVinci = DaVinci {
logger = Logger.STANDARD
@@ -63,20 +83,84 @@ class FormFieldsTest {
(node.collectors[0] as? SubmitCollector)?.value = "click"
node = node.next() as ContinueNode
- // Make sure that the first 2 collectors in the form are LabelCollectors
- assertTrue(node.collectors[0] is LabelCollector)
- assertTrue(node.collectors[1] is LabelCollector)
- val labelCollector1 = node.collectors[0] as LabelCollector
- val labelCollector2 = node.collectors[1] as LabelCollector
+ // Make sure that the first 3 collectors in the form are LabelCollectors
+ // [LABEL_TEXTBLOB_INDEX] TEXTBLOB → HTML content, no key
+ // [LABEL_TRANSLATABLE_INDEX] SLATE_TEXTBLOB → plain translatable text, key = "translatable-rich-text-key"
+ // [LABEL_RICH_TEXT_INDEX] SLATE_TEXTBLOB → translatable link, key = "rich-text"
+ assertTrue(node.collectors[LABEL_TEXTBLOB_INDEX] is LabelCollector)
+ assertTrue(node.collectors[LABEL_TRANSLATABLE_INDEX] is LabelCollector)
+ assertTrue(node.collectors[LABEL_RICH_TEXT_INDEX] is LabelCollector)
+ val labelCollector1 = node.collectors[LABEL_TEXTBLOB_INDEX] as LabelCollector
+ val labelCollector2 = node.collectors[LABEL_TRANSLATABLE_INDEX] as LabelCollector
+ val labelCollector3 = node.collectors[LABEL_RICH_TEXT_INDEX] as LabelCollector
// TODO: Update the following assertion to be more specific when the bug in DaVinci is fixed - see: https://pingidentity.slack.com/archives/C06CCT3NSP5/p1736897937860359
- assertTrue(labelCollector1.content.contains("Rich Text fields produce LABELs"))
- assertEquals ("Translatable Rich Text produce LABELs too!\n\n", labelCollector2.content)
+ assertEquals("Translatable Rich Text produce LABELs too!\n\n", labelCollector2.content)
// SDKS-3957 Add support for key attribute in Label Collectors
assertEquals("translatable-rich-text-key", labelCollector2.key)
// Note that the Rich Text component has been deprecated, so the key is not set
assertEquals("", labelCollector1.key)
+
+ // labelCollector1: No richContent field in the input → richContent should be null and
+ // content should contain the plain text (with HTML tags, since it's a TEXTBLOB)
+ val richContent1 = labelCollector1.richContent
+ assertNull(richContent1)
+ assertTrue(labelCollector1.content.contains("Rich Text fields produce LABELs"))
+ // TEXTBLOB content is raw HTML — verify HTML tags are present so that buildRichTextLabel
+ // can parse it correctly when richContent is null
+ assertTrue(
+ labelCollector1.content.contains("<"),
+ "Expected TEXTBLOB content to contain HTML tags, was: ${labelCollector1.content}"
+ )
+
+ // labelCollector2: plain translatable text — richText comes from richContent.content
+ // (without the trailing newlines present in the top-level content field)
+ val richContent2 = labelCollector2.richContent
+ assertNotNull(richContent2)
+ assertEquals("Translatable Rich Text produce LABELs too!", richContent2.content)
+ // No replacement tokens on this label
+ assertTrue(richContent2.replacements.isEmpty())
+
+ // labelCollector3: translatable link — richText contains {{token}} placeholders and replacements map contains corresponding entries
+ val richContent3 = labelCollector3.richContent
+ assertNotNull(richContent3)
+ assertEquals("A translatable rich text to take the user to {{link1}}", richContent3.content)
+ assertTrue(richContent3.replacements.containsKey("link1"))
+ val replacement = richContent3.replacements["link1"]
+ assertNotNull(replacement)
+ assertEquals("google.com", replacement.value)
+ assertEquals("https://www.google.com", replacement.href)
+ }
+
+ @Test
+ fun labelCollectorWithTranslatableLinkTest() = runTest {
+ // Go to the "Form Fields" form
+ var node = daVinci.start() as ContinueNode
+ (node.collectors[0] as? SubmitCollector)?.value = "click"
+ node = node.next() as ContinueNode
+
+ // Find a LabelCollector that has replacements (translatable link label)
+ val linkLabel = node.collectors
+ .filterIsInstance()
+ .firstOrNull { it.richContent?.replacements?.isNotEmpty() ?: false }
+
+ if (linkLabel != null) {
+ // richText must contain at least one {{token}} placeholder
+ assertTrue(
+ tokenPattern.containsMatchIn(linkLabel.richContent?.content ?: ""),
+ "Expected richText to contain {{token}} placeholders, was: ${linkLabel.richContent?.content}"
+ )
+ // Every token present in richText must have a corresponding replacement entry
+ for (match in tokenPattern.findAll(linkLabel.richContent?.content ?: "")) {
+ val token = match.groupValues[1]
+ val replacement = linkLabel.richContent?.replacements[token]
+ assertNotNull(replacement, "Missing replacement for token '$token'")
+ assertTrue(replacement.value.isNotEmpty(), "Replacement value for '$token' must not be empty")
+ assertTrue(replacement.href.isNotEmpty(), "Replacement href for '$token' must not be empty")
+ }
+ }
+ // If no such label is present in the current form, the test is a no-op (no assertion failure)
}
@TestRailCase(26032, 26031)
@@ -87,9 +171,9 @@ class FormFieldsTest {
(node.collectors[0] as? SubmitCollector)?.value = "click"
node = node.next() as ContinueNode
- // 3rd collector in the form is a TextCollector
- assertTrue(node.collectors[2] is TextCollector)
- val textCollector = node.collectors[2] as TextCollector
+ // 4th collector in the form is a TextCollector
+ assertTrue(node.collectors[TEXT_INPUT_INDEX] is TextCollector)
+ val textCollector = node.collectors[TEXT_INPUT_INDEX] as TextCollector
// Assert the properties of the TextCollector
assertEquals("Text Input Label", textCollector.label)
@@ -104,7 +188,7 @@ class FormFieldsTest {
// Validate should return list with 2 validation errors since the value is empty
// and does not match the configured regex
val validationResult = textCollector.validate()
- assertTrue(validationResult.size == 1)
+ assertEquals(1, validationResult.size)
assertEquals("Required", validationResult[0].toString())
textCollector.value = "Sometext123"
@@ -123,9 +207,9 @@ class FormFieldsTest {
(node.collectors[0] as? SubmitCollector)?.value = "click"
node = node.next() as ContinueNode
- // 4th collector in the form is a Checkbox group
- assertTrue(node.collectors[3] is MultiSelectCollector)
- val checkbox = node.collectors[3] as MultiSelectCollector
+ // 5th collector in the form is a Checkbox group
+ assertTrue(node.collectors[CHECKBOX_INDEX] is MultiSelectCollector)
+ val checkbox = node.collectors[CHECKBOX_INDEX] as MultiSelectCollector
// Assert the properties of the checkBox
assertEquals("CHECKBOX", checkbox.type)
@@ -164,9 +248,9 @@ class FormFieldsTest {
(node.collectors[0] as? SubmitCollector)?.value = "click"
node = node.next() as ContinueNode
- // 5th collector in the form is a Dropdown field
- assertTrue(node.collectors[4] is SingleSelectCollector)
- val dropdown = node.collectors[4] as SingleSelectCollector
+ // 6th collector in the form is a Dropdown field
+ assertTrue(node.collectors[DROPDOWN_INDEX] is SingleSelectCollector)
+ val dropdown = node.collectors[DROPDOWN_INDEX] as SingleSelectCollector
// Assert the properties of the Dropdown
assertEquals("DROPDOWN", dropdown.type)
@@ -206,9 +290,9 @@ class FormFieldsTest {
(node.collectors[0] as? SubmitCollector)?.value = "click"
node = node.next() as ContinueNode
- // 6th collector in the form is a Radio Group field
- assertTrue(node.collectors[5] is SingleSelectCollector)
- val radio = node.collectors[5] as SingleSelectCollector
+ // 7th collector in the form is a Radio Group field
+ assertTrue(node.collectors[RADIO_INDEX] is SingleSelectCollector)
+ val radio = node.collectors[RADIO_INDEX] as SingleSelectCollector
// Assert the properties of the radio group
assertEquals("RADIO", radio.type)
@@ -248,9 +332,9 @@ class FormFieldsTest {
(node.collectors[0] as? SubmitCollector)?.value = "click"
node = node.next() as ContinueNode
- // 7th collector in the form is a combo-box field
- assertTrue(node.collectors[6] is MultiSelectCollector)
- val combobox = node.collectors[6] as MultiSelectCollector
+ // 8th collector in the form is a combo-box field
+ assertTrue(node.collectors[COMBOBOX_INDEX] is MultiSelectCollector)
+ val combobox = node.collectors[COMBOBOX_INDEX] as MultiSelectCollector
// Assert the properties of the comboBox
assertEquals("COMBOBOX", combobox.type)
@@ -294,8 +378,8 @@ class FormFieldsTest {
(node.collectors[0] as? SubmitCollector)?.value = "click"
node = node.next() as ContinueNode
- assertTrue(node.collectors[7] is PhoneNumberCollector)
- val phone = node.collectors[7] as PhoneNumberCollector
+ assertTrue(node.collectors[PHONE_NUMBER_INDEX] is PhoneNumberCollector)
+ val phone = node.collectors[PHONE_NUMBER_INDEX] as PhoneNumberCollector
// Assert the properties of the comboBox
assertEquals("PHONE_NUMBER", phone.type)
@@ -330,6 +414,40 @@ class FormFieldsTest {
assertTrue(validationResult.isEmpty())
}
+ @TestRailCase(/* Add test rail IDs */)
+ @Test
+ fun booleanCollectorTest() = runTest {
+ // Go to the "Form Fields" form
+ var node = daVinci.start() as ContinueNode
+ (node.collectors[0] as? SubmitCollector)?.value = "click"
+ node = node.next() as ContinueNode
+
+ // 10th collector in the form is a SingleCheckbox (index 9)
+ assertTrue(node.collectors[SINGLE_CHECKBOX_INDEX] is BooleanCollector)
+ val singleCheckbox = (node.collectors[SINGLE_CHECKBOX_INDEX] as BooleanCollector)
+
+ // Assert the properties
+ assertEquals("SINGLE_CHECKBOX", singleCheckbox.type)
+ assertEquals("single-checkbox-field", singleCheckbox.key)
+ assertEquals("I agree to the Terms and Conditions", singleCheckbox.label)
+ val richContent = singleCheckbox.richContent
+ assertNotNull(richContent)
+ assertEquals("I agree to the {{link1}}", richContent.content)
+ assertEquals(1, richContent.replacements.size)
+ assertTrue(richContent.replacements.containsKey("link1"))
+ assertEquals(true, singleCheckbox.required)
+
+ // Default value should be false
+ assertEquals(false, singleCheckbox.value)
+ val requiredErrors = singleCheckbox.validate()
+ assertTrue(requiredErrors.isNotEmpty())
+
+ // Set the value to true and validate
+ singleCheckbox.value = true
+ val validationResult = singleCheckbox.validate() // Should return empty list since it's valid
+ assertTrue(validationResult.isEmpty())
+ }
+
@TestRailCase(26033)
@Test
fun flowButtonCollectorTest() = runTest {
@@ -338,10 +456,9 @@ class FormFieldsTest {
(node.collectors[0] as? SubmitCollector)?.value = "click"
node = node.next() as ContinueNode
- // Make sure that FlowButton is present
- assertTrue(node.collectors[8] is FlowCollector)
- val flowButton = (node.collectors[8] as FlowCollector)
- flowButton.value = "action"
+ // 11th collector in the form is a FlowButton (index 10)
+ assertTrue(node.collectors[FLOW_BUTTON_INDEX] is FlowCollector)
+ val flowButton = (node.collectors[FLOW_BUTTON_INDEX] as FlowCollector)
// Assert the properties
assertEquals("FLOW_BUTTON", flowButton.type)
@@ -363,9 +480,9 @@ class FormFieldsTest {
(node.collectors[0] as? SubmitCollector)?.value = "click"
node = node.next() as ContinueNode
- // Make sure that FlowLink is present
- assertTrue(node.collectors[9] is FlowCollector)
- val flowLink = (node.collectors[9] as FlowCollector)
+ // 12th collector in the form is a FlowLink (index 11)
+ assertTrue(node.collectors[FLOW_LINK_INDEX] is FlowCollector)
+ val flowLink = (node.collectors[FLOW_LINK_INDEX] as FlowCollector)
// Assert the properties
assertEquals("FLOW_LINK", flowLink.type)
diff --git a/davinci/src/androidTest/kotlin/com/pingidentity/davinci/FormMFADevicesTest.kt b/davinci/src/androidTest/kotlin/com/pingidentity/davinci/FormMFADevicesTest.kt
index 97dfe37e2..98d11da31 100644
--- a/davinci/src/androidTest/kotlin/com/pingidentity/davinci/FormMFADevicesTest.kt
+++ b/davinci/src/androidTest/kotlin/com/pingidentity/davinci/FormMFADevicesTest.kt
@@ -69,6 +69,7 @@ class FormMFADevicesTest {
private lateinit var email2: String
private lateinit var phoneNumber1: String
private lateinit var phoneNumber2: String
+ private lateinit var extension: String
@JvmField
@Rule
@@ -85,6 +86,7 @@ class FormMFADevicesTest {
email2 = usernamePrefix + System.currentTimeMillis() + "@example.net"
phoneNumber1 = "888123456"
phoneNumber2 = "888123457"
+ extension = "100"
// Make sure to start with a clean session
daVinci.user()?.logout()
@@ -404,7 +406,7 @@ class FormMFADevicesTest {
assertEquals("Enter phone number", node.description)
// Assert the collectors
- assertTrue(node.collectors.size == 4)
+ assertEquals(4, node.collectors.size)
assertTrue(node.collectors[0] is LabelCollector)
assertTrue(node.collectors[1] is SingleSelectCollector)
assertTrue(node.collectors[2] is PhoneNumberCollector)
@@ -437,10 +439,12 @@ class FormMFADevicesTest {
assertTrue(phoneNumberCollector.required)
assertFalse(phoneNumberCollector.validatePhoneNumber)
assertEquals("IN", phoneNumberCollector.defaultCountryCode)
+ assertTrue(phoneNumberCollector.showExtension)
// Select a country code and enter a valid phone number:...
phoneNumberCollector.countryCode = "CA" // Select Canada...
phoneNumberCollector.phoneNumber = "7783177183" // Enter a valid phone number...
+ phoneNumberCollector.extension = "100" // Enter extension
// Submit the form
(node.collectors[3] as? SubmitCollector)?.value = "click"
diff --git a/davinci/src/main/kotlin/com/pingidentity/davinci/CollectorRegistry.kt b/davinci/src/main/kotlin/com/pingidentity/davinci/CollectorRegistry.kt
index d7066f78c..6e1dfcf69 100644
--- a/davinci/src/main/kotlin/com/pingidentity/davinci/CollectorRegistry.kt
+++ b/davinci/src/main/kotlin/com/pingidentity/davinci/CollectorRegistry.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
+ * Copyright (c) 2024 - 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.
@@ -15,6 +15,10 @@ import com.pingidentity.davinci.collector.LabelCollector
import com.pingidentity.davinci.collector.MultiSelectCollector
import com.pingidentity.davinci.collector.PasswordCollector
import com.pingidentity.davinci.collector.PhoneNumberCollector
+import com.pingidentity.davinci.collector.ReadOnlyTextCollector
+import com.pingidentity.davinci.collector.PollingCollector
+import com.pingidentity.davinci.collector.QRCodeCollector
+import com.pingidentity.davinci.collector.BooleanCollector
import com.pingidentity.davinci.collector.SingleSelectCollector
import com.pingidentity.davinci.collector.SubmitCollector
import com.pingidentity.davinci.collector.TextCollector
@@ -50,9 +54,14 @@ internal class CollectorRegistry : ModuleInitializer() {
CollectorFactory.register("SINGLE_SELECT", ::SingleSelectCollector)
CollectorFactory.register("MULTI_SELECT", ::MultiSelectCollector)
+ CollectorFactory.register("BOOLEAN", ::BooleanCollector)
CollectorFactory.register("DEVICE_REGISTRATION", ::DeviceRegistrationCollector)
CollectorFactory.register("DEVICE_AUTHENTICATION", ::DeviceAuthenticationCollector)
CollectorFactory.register("PHONE_NUMBER", ::PhoneNumberCollector)
+
+ CollectorFactory.register("READ_ONLY_TEXT", ::ReadOnlyTextCollector)
+ CollectorFactory.register("POLLING", ::PollingCollector)
+ CollectorFactory.register("QR_CODE", ::QRCodeCollector)
}
}
\ No newline at end of file
diff --git a/davinci/src/main/kotlin/com/pingidentity/davinci/RichContent.kt b/davinci/src/main/kotlin/com/pingidentity/davinci/RichContent.kt
new file mode 100644
index 000000000..68fab0470
--- /dev/null
+++ b/davinci/src/main/kotlin/com/pingidentity/davinci/RichContent.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+
+package com.pingidentity.davinci
+
+const val RICH_CONTENT = "richContent"
+const val REPLACEMENTS = "replacements"
+const val VALUE = "value"
+const val HREF = "href"
+const val TYPE = "type"
+const val TARGET = "target"
+
+
+/**
+ * Represents a single replacement token inside a rich-text label.
+ *
+ * @property value The display text of the replacement (e.g. "google.com").
+ * @property href The URL the replacement links to (empty string if not a link).
+ * @property type The replacement type (e.g. "link").
+ * @property target The link target (e.g. "_self", "_blank").
+ */
+data class RichContentReplacement(
+ val value: String = "",
+ val href: String = "",
+ val type: String = "",
+ val target: String = "",
+)
+
+/**
+ * Represents rich text content with optional token replacements.
+ *
+ * @property content The rich text content, potentially containing `{{tokenName}}` placeholders.
+ * @property replacements Map of token name → [RichContentReplacement] parsed from
+ * `richContent.replacements`. Empty when there are no replacements.
+ */
+data class RichContent(
+ val content: String = "",
+ val replacements: Map = emptyMap(),
+)
\ No newline at end of file
diff --git a/davinci/src/main/kotlin/com/pingidentity/davinci/collector/BooleanCollector.kt b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/BooleanCollector.kt
new file mode 100644
index 000000000..05599a4de
--- /dev/null
+++ b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/BooleanCollector.kt
@@ -0,0 +1,96 @@
+/*
+ * 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.
+ */
+
+package com.pingidentity.davinci.collector
+
+import com.pingidentity.davinci.HREF
+import com.pingidentity.davinci.REPLACEMENTS
+import com.pingidentity.davinci.RICH_CONTENT
+import com.pingidentity.davinci.RichContent
+import com.pingidentity.davinci.RichContentReplacement
+import com.pingidentity.davinci.TARGET
+import com.pingidentity.davinci.TYPE
+import com.pingidentity.davinci.VALUE
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonPrimitive
+
+/**
+ * A Collector implementation for a single checkbox field.
+ *
+ * ```json
+ * {
+ * "type": "SINGLE_CHECKBOX",
+ * "key": "single-checkbox-field",
+ * "label": "This is a sample checkbox test. ",
+ * "required": true,
+ * "inputType": "BOOLEAN",
+ * "appearance": "CHECKBOX", # Optional, defaults to CHECKBOX. Can be either CHECKBOX or SWITCH.
+ * "errorMessage": "Select the checkbox to continue.",
+ * "richContent": {
+ * "content": "This is a sample checkbox test. "
+ * }
+ * }
+ *
+ */
+class BooleanCollector: FieldCollector() {
+ var appearance: BooleanCollectorAppearance = BooleanCollectorAppearance.CHECKBOX
+ private set
+
+ var errorMessage: String = ""
+ private set
+
+ var value: Boolean = false
+ var richContent: RichContent? = null
+ private set
+
+ override fun init(input: JsonObject): BooleanCollector {
+ super.init(input)
+ appearance = when (input["appearance"]?.jsonPrimitive?.contentOrNull ?: BooleanCollectorAppearance.CHECKBOX.value) {
+ BooleanCollectorAppearance.CHECKBOX.value -> BooleanCollectorAppearance.CHECKBOX
+ BooleanCollectorAppearance.SWITCH.value -> BooleanCollectorAppearance.SWITCH
+ else -> BooleanCollectorAppearance.CHECKBOX
+ }
+ errorMessage = input["errorMessage"]?.jsonPrimitive?.contentOrNull ?: "This field is required."
+ val richContentJson = input[RICH_CONTENT] as? JsonObject
+ richContentJson?.let {
+ richContent = RichContent(
+ content = it["content"]?.jsonPrimitive?.contentOrNull ?: "",
+ replacements = (it[REPLACEMENTS] as? JsonObject)
+ ?.mapValues { (_, element) ->
+ val obj = element as? JsonObject ?: return@mapValues RichContentReplacement()
+ RichContentReplacement(
+ value = (obj[VALUE] as? JsonPrimitive)?.contentOrNull.orEmpty(),
+ href = (obj[HREF] as? JsonPrimitive)?.contentOrNull.orEmpty(),
+ type = (obj[TYPE] as? JsonPrimitive)?.contentOrNull.orEmpty(),
+ target = (obj[TARGET] as? JsonPrimitive)?.contentOrNull.orEmpty(),
+ )
+ } ?: emptyMap()
+ )
+ }
+ return this
+ }
+
+ override fun validate(): List {
+ val errors = mutableListOf()
+ val result = super.validate()
+ errors.addAll(result)
+ if (required && !value) {
+ errors.add(Required)
+ }
+ return errors
+ }
+
+ override fun payload(): Boolean {
+ return value
+ }
+}
+
+enum class BooleanCollectorAppearance(val value: String) {
+ CHECKBOX("CHECKBOX"),
+ SWITCH("SWITCH")
+}
\ No newline at end of file
diff --git a/davinci/src/main/kotlin/com/pingidentity/davinci/collector/HtmlEscaper.kt b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/HtmlEscaper.kt
new file mode 100644
index 000000000..54aaef6f1
--- /dev/null
+++ b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/HtmlEscaper.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+
+package com.pingidentity.davinci.collector
+
+/**
+ * Escapes characters that are unsafe inside HTML text content or attribute values.
+ *
+ * The `&` replacement **must** run first to prevent any subsequent replacement from
+ * producing an entity that then gets double-escaped (e.g. `<` → `<` → `<`).
+ *
+ * Characters escaped:
+ * - `&` → `&`
+ * - `<` → `<`
+ * - `>` → `>`
+ * - `"` → `"`
+ * - `'` → `'`
+ */
+fun String.escapeHtml(): String = this
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'")
+
diff --git a/davinci/src/main/kotlin/com/pingidentity/davinci/collector/LabelCollector.kt b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/LabelCollector.kt
index 21a0468f5..ba174e6b7 100644
--- a/davinci/src/main/kotlin/com/pingidentity/davinci/collector/LabelCollector.kt
+++ b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/LabelCollector.kt
@@ -7,9 +7,19 @@
package com.pingidentity.davinci.collector
+import com.pingidentity.davinci.HREF
+import com.pingidentity.davinci.REPLACEMENTS
+import com.pingidentity.davinci.RICH_CONTENT
+import com.pingidentity.davinci.RichContent
+import com.pingidentity.davinci.RichContentReplacement
+import com.pingidentity.davinci.TARGET
+import com.pingidentity.davinci.TYPE
+import com.pingidentity.davinci.VALUE
import com.pingidentity.davinci.plugin.Collector
import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.contentOrNull
private const val CONTENT = "content"
private const val KEY = "key"
@@ -19,22 +29,49 @@ private const val KEY = "key"
*
* This class inherits from the [Collector] class. It is used to display a label on the form.
* @constructor Creates a new LabelCollector.
+ * @see RichContent
+ * @see RichContentReplacement
*/
class LabelCollector : Collector {
var key = ""
private set
+
+ /** Plain-text fallback content (top-level `content` field). */
var content = ""
private set
+ /**
+ * Rich content, potentially containing `{{tokenName}}` placeholders
+ * that should be substituted with entries from [RichContentReplacement].
+ * Falls back to [content] if no `richContent` is present.
+ */
+ var richContent: RichContent? = null
+ private set
+
+ override fun init(input: JsonObject): LabelCollector {
+ content = input[CONTENT]?.jsonPrimitive?.contentOrNull ?: ""
+ key = input[KEY]?.jsonPrimitive?.contentOrNull ?: ""
+
+ val richContentJson = input[RICH_CONTENT] as? JsonObject
+ richContentJson?.let {
+ richContent = RichContent(
+ content = it[CONTENT]?.jsonPrimitive?.contentOrNull ?: "",
+ replacements = (it[REPLACEMENTS] as? JsonObject)
+ ?.mapValues { (_, element) ->
+ val obj = element as? JsonObject ?: return@mapValues RichContentReplacement()
+ RichContentReplacement(
+ value = (obj[VALUE] as? JsonPrimitive)?.contentOrNull.orEmpty(),
+ href = (obj[HREF] as? JsonPrimitive)?.contentOrNull.orEmpty(),
+ type = (obj[TYPE] as? JsonPrimitive)?.contentOrNull.orEmpty(),
+ target = (obj[TARGET] as? JsonPrimitive)?.contentOrNull.orEmpty(),
+ )
+ } ?: emptyMap()
+ )
+ }
- override fun init(input: JsonObject) : LabelCollector{
- content = input[CONTENT]?.jsonPrimitive?.content ?: ""
- key = input[KEY]?.jsonPrimitive?.content ?: ""
return this
}
- override fun id(): String {
- return key
- }
+ override fun id(): String = key
}
\ No newline at end of file
diff --git a/davinci/src/main/kotlin/com/pingidentity/davinci/collector/PasswordCollector.kt b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/PasswordCollector.kt
index f5f87b1c9..50269fd20 100644
--- a/davinci/src/main/kotlin/com/pingidentity/davinci/collector/PasswordCollector.kt
+++ b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/PasswordCollector.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
+ * Copyright (c) 2024 - 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.
@@ -7,9 +7,13 @@
package com.pingidentity.davinci.collector
+import com.pingidentity.davinci.json
import com.pingidentity.orchestrate.Closeable
import com.pingidentity.orchestrate.ContinueNode
import com.pingidentity.orchestrate.ContinueNodeAware
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.jsonObject
/**
* Class representing a PASSWORD Type.
@@ -21,11 +25,15 @@ import com.pingidentity.orchestrate.ContinueNodeAware
*/
class PasswordCollector : ValidatedCollector(), ContinueNodeAware, Closeable {
+ companion object {
+ private const val PASSWORD_POLICY = "passwordPolicy"
+ }
+
/**
* The continue node for the DaVinci flow.
*/
override lateinit var continueNode: ContinueNode
- private var cachedPasswordPolicy: PasswordPolicy? = null
+ private var passwordPolicy: PasswordPolicy? = null
// A flag to determine whether to clear the password or not after submission.
var clearPassword = true
@@ -39,23 +47,28 @@ class PasswordCollector : ValidatedCollector(), ContinueNodeAware, Closeable {
if (clearPassword) value = ""
}
+ override fun init(input: JsonObject): ValidatedCollector {
+ super.init(input)
+ passwordPolicy = input[PASSWORD_POLICY]?.jsonObject?.let {
+ json.decodeFromJsonElement(it)
+ }
+ return this
+ }
+
/**
* Function to retrieve the password policy, if available.
*/
- //TODO PasswordPolicy
- /*
fun passwordPolicy(): PasswordPolicy? {
- if (cachedPasswordPolicy == null) {
- cachedPasswordPolicy = continueNode.input["passwordPolicy"]?.jsonObject?.let {
+ if (passwordPolicy == null) {
+ // Fall back to continue node input if not found in the collector input
+ passwordPolicy = continueNode.input[PASSWORD_POLICY]?.jsonObject?.let {
json.decodeFromJsonElement(it)
}
}
- return cachedPasswordPolicy
+ return passwordPolicy
}
- */
-
/**
* Function to validate the password field.
* @return A list of validation errors.
@@ -65,8 +78,6 @@ class PasswordCollector : ValidatedCollector(), ContinueNodeAware, Closeable {
val result = super.validate()
errors.addAll(result)
- //TODO PasswordPolicy
- /*
passwordPolicy()?.let { policy ->
if (value.length !in policy.length.min..policy.length.max) {
errors.add(InvalidLength(policy.length.min, policy.length.max))
@@ -86,7 +97,6 @@ class PasswordCollector : ValidatedCollector(), ContinueNodeAware, Closeable {
}
}
}
- */
return errors
}
}
\ No newline at end of file
diff --git a/davinci/src/main/kotlin/com/pingidentity/davinci/collector/PhoneNumberCollector.kt b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/PhoneNumberCollector.kt
index 82ac5343b..62b56763e 100644
--- a/davinci/src/main/kotlin/com/pingidentity/davinci/collector/PhoneNumberCollector.kt
+++ b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/PhoneNumberCollector.kt
@@ -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.
@@ -16,9 +16,12 @@ import kotlinx.serialization.json.put
private const val COUNTRY_CODE = "countryCode"
private const val PHONE_NUMBER = "phoneNumber"
+private const val EXTENSION = "extension"
private const val DEFAULT_COUNTRY_CODE = "defaultCountryCode"
private const val VALIDATE_PHONE_NUMBER = "validatePhoneNumber"
+private const val SHOW_EXTENSION = "showExtension"
+private const val EXTENSION_LABEL = "extensionLabel"
/**
* A collector for phone number.
@@ -31,16 +34,24 @@ class PhoneNumberCollector : FieldCollector(), Validator {
var validatePhoneNumber: Boolean = false
private set
+ var showExtension: Boolean = false
+ private set
+ var extensionLabel: String = ""
+ private set
// country code
var countryCode: String = ""
// phone number
var phoneNumber: String = ""
+ // extension
+ var extension: String = ""
override fun init(input: JsonObject) : PhoneNumberCollector {
super.init(input)
defaultCountryCode = input[DEFAULT_COUNTRY_CODE]?.jsonPrimitive?.content ?: ""
validatePhoneNumber = input[VALIDATE_PHONE_NUMBER]?.jsonPrimitive?.boolean ?: false
+ showExtension = input[SHOW_EXTENSION]?.jsonPrimitive?.boolean ?: false
+ extensionLabel = input[EXTENSION_LABEL]?.jsonPrimitive?.content ?: ""
return this
}
@@ -49,6 +60,8 @@ class PhoneNumberCollector : FieldCollector(), Validator {
// New structure with phoneNumber and countryCode
phoneNumber = input[PHONE_NUMBER]?.jsonPrimitive?.content ?: ""
countryCode = input[COUNTRY_CODE]?.jsonPrimitive?.content ?: ""
+ extension = input[EXTENSION]?.jsonPrimitive?.content ?: ""
+ extensionLabel = input[EXTENSION_LABEL]?.jsonPrimitive?.content ?: ""
} else {
// Legacy structure - simple string
phoneNumber = input.jsonPrimitive.content
@@ -62,6 +75,7 @@ class PhoneNumberCollector : FieldCollector(), Validator {
buildJsonObject {
put(COUNTRY_CODE, countryCode)
put(PHONE_NUMBER, phoneNumber)
+ put(EXTENSION, extension)
}
}
}
diff --git a/davinci/src/main/kotlin/com/pingidentity/davinci/collector/PollingCollector.kt b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/PollingCollector.kt
new file mode 100644
index 000000000..16ccca361
--- /dev/null
+++ b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/PollingCollector.kt
@@ -0,0 +1,429 @@
+/*
+ * 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.
+ */
+
+package com.pingidentity.davinci.collector
+
+import com.pingidentity.davinci.plugin.DaVinci
+import com.pingidentity.davinci.plugin.DaVinciAware
+import com.pingidentity.davinci.plugin.Submittable
+import com.pingidentity.orchestrate.Closeable
+import com.pingidentity.orchestrate.ContinueNode
+import com.pingidentity.orchestrate.ContinueNodeAware
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.yield
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.boolean
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+// JSON field constants for polling configuration and status
+
+/** Event type identifier for polling collectors */
+private const val POLLING = "polling"
+
+/** JSON key for the polling interval in milliseconds between requests */
+private const val POLL_INTERVAL = "pollInterval"
+
+/** JSON key for the maximum number of polling retry attempts */
+private const val POLL_RETRIES = "pollRetries"
+
+/** JSON key indicating whether to actively poll for challenge status */
+private const val POLL_CHALLENGE_STATUS = "pollChallengeStatus"
+
+/** JSON key for the status field in payloads and responses */
+private const val STATUS = "status"
+
+/** JSON key for the challenge identifier used in polling URLs */
+private const val CHALLENGE = "challenge"
+
+/** JSON key in server responses indicating if a challenge is complete */
+private const val IS_CHALLENGE_COMPLETE = "isChallengeComplete"
+
+/** JSON key in error responses identifying the type of service error */
+private const val SERVICE_NAME = "serviceName"
+
+/** The serviceName value returned by the server when the challenge has expired */
+private const val CHALLENGE_EXPIRED = "challengeExpired"
+
+/**
+ * Sealed class representing the status of a polling operation.
+ */
+sealed class PollingStatus {
+ /**
+ * Indicates that polling is in progress.
+ * @param retryCount The current retry count.
+ * @param maxRetries The maximum number of retries.
+ */
+ data class Continue(val retryCount: Int, val maxRetries: Int) : PollingStatus()
+
+ /**
+ * Indicates that polling has expired (max retries reached).
+ */
+ data object TimedOut : PollingStatus()
+
+ /**
+ * Indicates that the challenge has expired because the server returned a non-200 HTTP response.
+ * Polling stops immediately when this status is emitted.
+ */
+ data object Expired : PollingStatus()
+
+ /**
+ * Indicates that an error occurred during polling.
+ * @param exception The exception that occurred.
+ */
+ data class Error(val exception: Exception) : PollingStatus()
+
+ /**
+ * Indicates that polling has completed successfully.
+ */
+ data class Complete(val status: String) : PollingStatus()
+}
+
+/**
+ * A collector that handles asynchronous polling operations in DaVinci authentication flows.
+ *
+ * The PollingCollector is used for authentication scenarios that require waiting for user actions
+ * on another device or channel, such as:
+ * - Push notification authentication
+ * - QR code scanning
+ * - Email verification
+ * - Out-of-band (OOB) authentication
+ *
+ * ## Polling Modes
+ *
+ * ### Simple Polling Mode
+ * When [pollChallengeStatus] is `false` or [challenge] is empty, performs a simple delay-based
+ * polling without actively checking server status. After each delay:
+ * - Decrements [retriesAllowed]
+ * - Emits [PollingStatus.Continue] if retries remain
+ * - Emits [PollingStatus.TimedOut] when retries reach 0
+ *
+ * This mode is useful for basic waiting periods where the client simply needs to wait before
+ * checking again.
+ *
+ * ### Challenge Status Polling Mode
+ * When [pollChallengeStatus] is `true` and [challenge] is not empty, actively polls a server
+ * endpoint to check the status of an authentication challenge. The polling continues until:
+ * - The challenge is completed successfully ([PollingStatus.Complete])
+ * - Maximum retries are reached ([PollingStatus.TimedOut])
+ * - The server returns a non-200 HTTP response ([PollingStatus.Expired])
+ * - An error occurs ([PollingStatus.Error])
+ *
+ * **Polling Endpoint:** `{baseUrl}/davinci/user/credentials/challenge/{challenge}/status`
+ *
+ * ## Flow-based API
+ *
+ * The [pollStatus] method returns a Flow that emits [PollingStatus] updates throughout the polling
+ * lifecycle, providing real-time feedback to the application for UI updates.
+ *
+ * **Flow Interception:** The flow automatically intercepts each emission using `onEach` to set
+ * the [value] property before the caller receives it. This ensures [value] is always synchronized
+ * with the emitted status.
+ *
+ * ```
+ *
+ * ## Value Assignment
+ *
+ * The [value] property is automatically set via flow interception based on the polling outcome:
+ * - [PollingStatus.Complete] → Server status value (e.g., "approved")
+ * - [PollingStatus.TimedOut] → "timedOut"
+ * - [PollingStatus.Expired] → "expired"
+ * - [PollingStatus.Error] → "error"
+ * - [PollingStatus.Continue] → "continue"
+ *
+ * This value is submitted as part of the form data when continuing to the next node in the flow.
+ *
+ * @see PollingStatus
+ * @see poll
+ */
+class PollingCollector : SingleValueCollector(), Submittable, ContinueNodeAware, DaVinciAware,
+ Closeable {
+
+ /**
+ * The continue node for the DaVinci flow.
+ * Used to extract configuration and context for the polling operation.
+ */
+ override lateinit var continueNode: ContinueNode
+
+ /**
+ * The DaVinci workflow instance.
+ * Provides access to HTTP client and logger for polling operations.
+ */
+ override lateinit var davinci: DaVinci
+
+ /**
+ * Polling interval in milliseconds between each polling attempt.
+ * Default value is "2000" (2 seconds).
+ */
+ lateinit var pollInterval: String
+ private set
+
+ /**
+ * Maximum number of polling attempts before timing out.
+ * Default value is "60".
+ */
+ lateinit var pollRetries: String
+ private set
+
+ /**
+ * Whether to actively poll for challenge status.
+ * When `false`, performs simple delay polling.
+ * When `true`, polls the server endpoint for challenge completion.
+ */
+ var pollChallengeStatus = false
+ private set
+
+ /**
+ * The challenge identifier to poll for.
+ * Used to construct the polling endpoint URL.
+ */
+ lateinit var challenge: String
+ private set
+
+
+ override fun eventType(): String = POLLING
+
+ /**
+ * The number of polling attempts remaining before timeout in simple polling mode.
+ *
+ * This property is only used in simple polling mode (when [pollChallengeStatus] is false).
+ * It is initialized to [pollRetries] value and decremented after each polling interval.
+ * When it reaches 0, [PollingStatus.TimedOut] is emitted.
+ *
+ * For challenge status polling mode, this value is maintained but not actively used
+ * in the polling logic.
+ */
+ var retriesAllowed: Int = 0
+
+ /**
+ * Initializes the PollingCollector with configuration from the input JSON.
+ *
+ * Extracts and sets the following parameters:
+ * - [pollInterval]: Polling interval in milliseconds (default: "2000")
+ * - [pollRetries]: Maximum number of retries (default: "60")
+ * - [pollChallengeStatus]: Whether to poll for challenge status (default: false)
+ * - [challenge]: Challenge identifier for polling (default: "")
+ *
+ * @param input JSON object containing the collector configuration
+ * @return This PollingCollector instance for method chaining
+ *
+ * @see SingleValueCollector.init
+ */
+ override fun init(input: JsonObject): PollingCollector {
+ super.init(input)
+ // Extract polling configuration from input JSON with sensible defaults
+ pollInterval = input[POLL_INTERVAL]?.jsonPrimitive?.content ?: "2000" // Default: 2 seconds
+ pollRetries = input[POLL_RETRIES]?.jsonPrimitive?.content ?: "60" // Default: 60 attempts
+ pollChallengeStatus =
+ input[POLL_CHALLENGE_STATUS]?.jsonPrimitive?.boolean ?: false // Default: simple polling
+ challenge = input[CHALLENGE]?.jsonPrimitive?.content ?: "" // Default: no challenge ID
+
+ // Initialize remaining retries counter for simple polling mode
+ retriesAllowed = pollRetries.toInt()
+ return this
+ }
+
+ /**
+ * Polls for the challenge status and emits the current polling status.
+ *
+ * This method returns a Flow that emits [PollingStatus] updates throughout the polling lifecycle.
+ * The polling behavior depends on the configuration set during [init]:
+ *
+ * ## Challenge Status Polling Mode
+ * When [pollChallengeStatus] is `true` and [challenge] is not empty:
+ *
+ * 1. **Extracts configuration** from [continueNode].input:
+ * - `_links.next.href`: Used to construct base URL
+ * - `interactionId`: Required header for polling requests
+ *
+ * 2. **Constructs polling URL**:
+ * `{baseUrl}/davinci/user/credentials/challenge/{challenge}/status`
+ *
+ * 3. **Polling loop** (repeats up to [pollRetries] times):
+ * - Delays for [pollInterval] milliseconds
+ * - Makes HTTP POST request with `interactionId` header
+ * - Parses JSON response for `isChallengeComplete` field
+ * - **If HTTP error (status ≠ 200)**: Emits [PollingStatus.Expired] and stops polling
+ * - **If challenge complete**: Emits [PollingStatus.Complete] with server status and exits
+ * - **If not complete**: Emits [PollingStatus.Continue] and continues polling
+ * - **If exception occurs**: Emits [PollingStatus.Error] and exits
+ *
+ * 4. **If max retries reached**: Emits [PollingStatus.TimedOut]
+ *
+ * 5. **If configuration missing**: Emits [PollingStatus.Error] immediately
+ *
+ * ## Simple Polling Mode
+ * When [pollChallengeStatus] is `false` or [challenge] is empty:
+ *
+ * 1. **Validates [pollInterval]** (must be > 0)
+ * 2. **Delays** for [pollInterval] milliseconds
+ * 3. **Decrements [retriesAllowed]**
+ * 4. **If retries remain**: Emits [PollingStatus.Continue] with remaining retries
+ * 5. **If retries exhausted**: Emits [PollingStatus.TimedOut]
+ * 6. **If invalid interval**: Emits [PollingStatus.Error]
+ *
+ * ## Flow Interception
+ *
+ * After emission but before the caller receives it, the flow uses `onEach` to set
+ * the [value] property based on the emitted status:
+ *
+ * | Status Type | Value Set |
+ * |-------------|-----------|
+ * | [PollingStatus.Complete] | Server status (e.g., "approved") |
+ * | [PollingStatus.TimedOut] | "timedOut" |
+ * | [PollingStatus.Expired] | "expired" |
+ * | [PollingStatus.Error] | "error" |
+ * | [PollingStatus.Continue] | "continue" |
+ *
+ * @return A Flow of [PollingStatus] that emits status updates throughout the polling operation.
+ * The Flow completes naturally when polling finishes (Complete, TimedOut, Expired, or Error).
+ *
+ * @throws IllegalStateException if [continueNode] or [davinci] are not initialized (challenge mode only)
+ *
+ * @see PollingStatus
+ * @see PollingStatus.Continue
+ * @see PollingStatus.Complete
+ * @see PollingStatus.TimedOut
+ * @see PollingStatus.Expired
+ * @see PollingStatus.Error
+ */
+ fun pollStatus(): Flow = flow {
+ // Determine polling mode based on configuration
+ if (pollChallengeStatus && challenge.isNotEmpty()) {
+ // --- Challenge Status Polling Mode ---
+ // Actively polls the server to check if a challenge has been completed
+
+ // Extract polling configuration from the continue node input
+ val links = continueNode.input["_links"]?.jsonObject
+ val selfHref = links?.get("self")?.jsonObject?.get("href")?.jsonPrimitive?.content
+ val interactionId = continueNode.input["interactionId"]?.jsonPrimitive?.content
+
+ if (selfHref != null && interactionId != null) {
+ // Construct the polling endpoint URL
+ // Example: https://auth.pingone.ca/env-id/davinci/user/credentials/challenge/abc123/status
+ val baseUrl = selfHref.substringBefore("/davinci/connections")
+ val pollingUrl = "$baseUrl/davinci/user/credentials/challenge/$challenge/status"
+
+ val httpClient = davinci.config.httpClient
+ val maxRetries = pollRetries.toIntOrNull() ?: 60
+ val interval = pollInterval.toLongOrNull() ?: 2000L
+
+ var retryCount = 0
+ var shouldContinuePolling = true
+
+ // Poll repeatedly until challenge is complete, max retries reached, or error occurs
+ while (retryCount < maxRetries && shouldContinuePolling) {
+ // Wait before making the next polling request
+ delay(interval)
+
+ try {
+ // Make HTTP POST request to check challenge status
+ val response = httpClient.request {
+ url = pollingUrl
+ header("interactionId", interactionId)
+ post()
+ }
+
+ // Handle non-200 HTTP responses
+ if (response.status != 200) {
+ val errorBody: String = response.body()
+ davinci.config.logger.i("Server returned non-200 response: ${response.status}, $errorBody")
+ val errorJson = runCatching {
+ Json.parseToJsonElement(errorBody).jsonObject
+ }.getOrNull()
+ val serviceName = errorJson?.get(SERVICE_NAME)?.jsonPrimitive?.content
+ if (serviceName == CHALLENGE_EXPIRED) {
+ emit(PollingStatus.Expired)
+ } else {
+ emit(PollingStatus.Error(IllegalStateException("Server error: ${response.status}, $errorBody")))
+ }
+ shouldContinuePolling = false
+ } else {
+ // Parse the JSON response to check challenge completion status
+ val responseBody: String = response.body()
+ val jsonResponse = Json.parseToJsonElement(responseBody).jsonObject
+ val challengeComplete =
+ jsonResponse[IS_CHALLENGE_COMPLETE]?.jsonPrimitive?.boolean ?: false
+
+ if (challengeComplete) {
+ // Challenge successfully completed - emit Complete with status from server
+ val statusValue = jsonResponse[STATUS]?.jsonPrimitive?.content ?: ""
+ emit(PollingStatus.Complete(statusValue))
+ shouldContinuePolling = false // Exit the polling loop
+ } else {
+ // Challenge not yet complete - emit Continue status with current retry count
+ emit(PollingStatus.Continue(retryCount + 1, maxRetries))
+ }
+ }
+ } catch (e: Exception) {
+ // Handle network errors, JSON parsing errors, or other exceptions
+ currentCoroutineContext().ensureActive()
+ davinci.config.logger.w("Error polling challenge status", e)
+ emit(PollingStatus.Error(e))
+ shouldContinuePolling = false // Exit the polling loop on exception
+ }
+
+ // Yield to allow coroutine cancellation between polling attempts
+ if (shouldContinuePolling) {
+ yield()
+ retryCount++
+ }
+ }
+
+ // If we exited the loop due to max retries (not due to completion or error)
+ if (shouldContinuePolling) {
+ emit(PollingStatus.TimedOut)
+ }
+ } else {
+ // Required configuration (selfHref or interactionId) is missing
+ emit(PollingStatus.Error(IllegalStateException("Missing selfHref or interactionId")))
+ }
+ } else {
+ // --- Continue Polling Mode ---
+ // Performs a simple delay without actively checking server status
+
+ if (pollInterval.toInt() > 0) {
+ // Wait for the specified interval
+ delay(pollInterval.toLong())
+
+ // Decrement retries and emit Continue status
+ if (--retriesAllowed <= 0) {
+ emit(PollingStatus.TimedOut)
+ } else {
+ // Continue Polling is consider completed, but set status to server with value "continue"
+ emit(PollingStatus.Complete("continue"))
+ }
+ } else {
+ // Invalid polling interval - emit error
+ emit(PollingStatus.Error(IllegalArgumentException("Invalid pollInterval: $pollInterval")))
+ }
+ }
+ }.onEach { status ->
+ // Intercept each emission and set the value property before caller receives it
+ value = when (status) {
+ is PollingStatus.Complete -> status.status
+ is PollingStatus.TimedOut -> "timedOut"
+ is PollingStatus.Error -> "error"
+ is PollingStatus.Continue -> "continue"
+ is PollingStatus.Expired -> "expired"
+ }
+ }
+
+ /**
+ * Clears the collector's value property.
+ * @see Closeable.close
+ */
+ override fun close() {
+ value = ""
+ }
+}
\ No newline at end of file
diff --git a/davinci/src/main/kotlin/com/pingidentity/davinci/collector/QRCodeCollector.kt b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/QRCodeCollector.kt
new file mode 100644
index 000000000..b2fdcbff7
--- /dev/null
+++ b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/QRCodeCollector.kt
@@ -0,0 +1,117 @@
+/*
+ * 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.
+ */
+
+package com.pingidentity.davinci.collector
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import com.pingidentity.davinci.plugin.Collector
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlin.io.encoding.Base64
+
+/**
+ * A collector that handles QR code display in DaVinci authentication flows.
+ *
+ * The QRCodeCollector is used to display QR codes to users for authentication scenarios such as:
+ * - Device pairing
+ * - Multi-factor authentication setup
+ * - Out-of-band authentication
+ * - Cross-device authentication flows
+ *
+ * The QR code content is typically provided as a Base64-encoded image string that can be
+ * decoded and displayed as a bitmap.
+ *
+ * ## Content Format
+ *
+ * The [content] field typically contains a data URI string with Base64-encoded image data:
+ * ```
+ * data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
+ * ```
+ *
+ * @see Collector
+ * @see bitmap
+ */
+class QRCodeCollector : Collector {
+
+ /**
+ * The QR code content as a Base64-encoded data URI string.
+ *
+ * This typically contains the full data URI including the MIME type and Base64 prefix:
+ * `data:image/png;base64,{base64-encoded-data}`
+ *
+ * The [bitmap] method extracts and decodes this content to create a displayable bitmap.
+ */
+ lateinit var content: String
+ private set
+
+ /**
+ * Fallback text to display when the QR code cannot be rendered.
+ *
+ * This text provides an alternative way for users to complete the authentication
+ * if the QR code cannot be displayed or scanned. It may contain:
+ * - A manual entry code
+ * - Instructions for alternative authentication methods
+ * - Error or help information
+ */
+ lateinit var fallbackText: String
+ private set
+
+ /**
+ * Initializes the QRCodeCollector with configuration from the input JSON.
+ *
+ * Extracts and sets the following parameters:
+ * - [content]: QR code content as Base64-encoded data URI (default: "")
+ * - [fallbackText]: Alternative text if QR code cannot be displayed (default: "")
+ *
+ * @param input JSON object containing the collector configuration with fields:
+ * - `content`: The Base64-encoded QR code image data
+ * - `fallbackText`: Alternative text for display
+ * @return This QRCodeCollector instance for method chaining
+ *
+ * @see Collector.init
+ */
+ override fun init(input: JsonObject): QRCodeCollector {
+ super.init(input)
+ content = input["content"]?.jsonPrimitive?.content ?: ""
+ fallbackText = input["fallbackText"]?.jsonPrimitive?.content ?: ""
+ return this
+ }
+
+ /**
+ * Converts the Base64-encoded QR code content to a displayable Bitmap.
+ *
+ * This method:
+ * 1. Extracts the Base64 data from the [content] string (after "base64,")
+ * 2. Decodes the Base64 string to a byte array
+ * 3. Converts the byte array to a Bitmap using BitmapFactory
+ *
+ * ## Content Format
+ *
+ * The method expects [content] to be in data URI format:
+ * ```
+ * data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
+ * ```
+ *
+ * The substring after "base64," is extracted and decoded.
+ *
+ * @return A Bitmap representation of the QR code, or `null` if decoding fails
+ *
+ * @see content
+ * @see fallbackText
+ */
+ fun bitmap(): Bitmap? {
+ return try {
+ // Decode Base64 content and convert to Bitmap
+ val decodedBytes = Base64.decode(content.substringAfter("base64,"))
+ BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size)
+ } catch (e: Exception) {
+ // Return null if decoding fails
+ null
+ }
+ }
+}
\ No newline at end of file
diff --git a/davinci/src/main/kotlin/com/pingidentity/davinci/collector/ReadOnlyTextCollector.kt b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/ReadOnlyTextCollector.kt
new file mode 100644
index 000000000..a7bf076c5
--- /dev/null
+++ b/davinci/src/main/kotlin/com/pingidentity/davinci/collector/ReadOnlyTextCollector.kt
@@ -0,0 +1,118 @@
+/*
+ * 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.
+ */
+
+package com.pingidentity.davinci.collector
+
+import com.pingidentity.davinci.plugin.Collector
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.boolean
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+private const val KEY = "key"
+private const val TYPE = "type"
+private const val CONTENT = "content"
+private const val TITLE = "title"
+private const val TITLE_ENABLED = "titleEnabled"
+private const val ENABLED = "enabled"
+private const val AGREEMENT = "agreement"
+private const val ID = "id"
+private const val USE_DYNAMIC_AGREEMENT = "useDynamicAgreement"
+
+/**
+ * Class representing a READ_ONLY_TEXT type collector.
+ *
+ * This class handles the response from a DaVinci form field of type READ_ONLY_TEXT (inputType).
+ * It displays read-only text content such as agreement/terms-of-service text.
+ *
+ * Example JSON:
+ * ```json
+ * {
+ * "type": "AGREEMENT",
+ * "inputType": "READ_ONLY_TEXT",
+ * "key": "agreement",
+ * "content": "This is example agreement text...",
+ * "titleEnabled": true,
+ * "title": "Terms of Service Agreement",
+ * "agreement": {
+ * "id": "6ff30c9e-cd98-4fe5-85ca-01111ca20702",
+ * "useDynamicAgreement": false
+ * },
+ * "enabled": true
+ * }
+ * ```
+ *
+ * @constructor Creates a new ReadOnlyTextCollector.
+ */
+class ReadOnlyTextCollector : Collector {
+
+ /**
+ * The key identifier for this collector, used as the form field key during submission.
+ */
+ var key = ""
+ private set
+
+ /**
+ * The type of this collector (e.g., "AGREEMENT").
+ */
+ var type = ""
+ private set
+
+ /**
+ * The text content to display to the user.
+ */
+ var content = ""
+ private set
+
+ /**
+ * The title (shown when [titleEnabled] is true).
+ */
+ var title = ""
+ private set
+
+ /**
+ * Whether to display the [title] above the content.
+ */
+ var titleEnabled = false
+ private set
+
+ /**
+ * Whether this collector is enabled.
+ */
+ var enabled = true
+ private set
+
+ /**
+ * The unique ID of the agreement definition.
+ */
+ var agreementId = ""
+ private set
+
+ /**
+ * Whether the agreement content is loaded dynamically.
+ */
+ var useDynamicAgreement = false
+ private set
+
+ override fun init(input: JsonObject): ReadOnlyTextCollector {
+ key = input[KEY]?.jsonPrimitive?.content ?: ""
+ type = input[TYPE]?.jsonPrimitive?.content ?: ""
+ content = input[CONTENT]?.jsonPrimitive?.content ?: ""
+ title = input[TITLE]?.jsonPrimitive?.content ?: ""
+ titleEnabled = input[TITLE_ENABLED]?.jsonPrimitive?.boolean ?: false
+ enabled = input[ENABLED]?.jsonPrimitive?.boolean ?: true
+
+ input[AGREEMENT]?.jsonObject?.let { agreement ->
+ agreementId = agreement[ID]?.jsonPrimitive?.content ?: ""
+ useDynamicAgreement = agreement[USE_DYNAMIC_AGREEMENT]?.jsonPrimitive?.boolean ?: false
+ }
+
+ return this
+ }
+
+ override fun id(): String = key
+}
\ No newline at end of file
diff --git a/davinci/src/main/kotlin/com/pingidentity/davinci/module/Transform.kt b/davinci/src/main/kotlin/com/pingidentity/davinci/module/Transform.kt
index 70d91be9e..8c7c93bf1 100644
--- a/davinci/src/main/kotlin/com/pingidentity/davinci/module/Transform.kt
+++ b/davinci/src/main/kotlin/com/pingidentity/davinci/module/Transform.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
+ * Copyright (c) 2024 - 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.
@@ -11,8 +11,10 @@ import com.pingidentity.davinci.collector.Form
import com.pingidentity.davinci.plugin.Collector
import com.pingidentity.davinci.plugin.CollectorFactory
import com.pingidentity.davinci.plugin.DaVinci
+import com.pingidentity.davinci.plugin.collectors
import com.pingidentity.exception.ApiException
import com.pingidentity.oidc.exception.AuthorizeException
+import com.pingidentity.orchestrate.ContinueNode
import com.pingidentity.orchestrate.ErrorNode
import com.pingidentity.orchestrate.FailureNode
import com.pingidentity.orchestrate.FlowContext
@@ -116,6 +118,22 @@ private fun transform(
)
}
+ val eventName = json["eventName"]?.jsonPrimitive?.content
+ if (eventName == "rewindStateToLastRenderedUI" || eventName == "rewindStateToSpecificRenderedUI") {
+ val existing = context.flowContext.getValue(CONTINUE_NODE)
+ ?: return FailureNode(IllegalStateException("Rewind state to last rendered UI failed."))
+ // Create a new Connector instance with the same so that Jetpack Compose
+ // sees a different object reference and triggers recomposition and its collectors.
+ return Connector(
+ existing.context,
+ (existing as Connector).daVinci,
+ existing.input,
+ existing.collectors,
+ ).apply {
+ CollectorFactory.inject(this)
+ }
+ }
+
val collectors = mutableListOf>()
if ("form" in json) collectors.addAll(Form.parse(daVinci, json))
diff --git a/davinci/src/test/kotlin/com/pingidentity/davinci/BooleanCollectorTest.kt b/davinci/src/test/kotlin/com/pingidentity/davinci/BooleanCollectorTest.kt
new file mode 100644
index 000000000..06f7eb1a3
--- /dev/null
+++ b/davinci/src/test/kotlin/com/pingidentity/davinci/BooleanCollectorTest.kt
@@ -0,0 +1,188 @@
+/*
+ * 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.
+ */
+
+package com.pingidentity.davinci
+
+import com.pingidentity.davinci.collector.BooleanCollectorAppearance
+import com.pingidentity.davinci.collector.BooleanCollector
+import com.pingidentity.davinci.collector.Required
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+
+class BooleanCollectorTest {
+
+ @Test
+ fun initializesDefaultValuesWhenNoInputProvided() {
+ val input = buildJsonObject {}
+ val collector = BooleanCollector()
+ collector.init(input)
+
+ assertEquals(BooleanCollectorAppearance.CHECKBOX, collector.appearance)
+ assertEquals("This field is required.", collector.errorMessage)
+ assertNull(collector.richContent)
+ assertFalse(collector.value)
+ }
+
+ @Test
+ fun initializesAppearanceAsCheckbox() {
+ val input = buildJsonObject {
+ put("appearance", "CHECKBOX")
+ }
+ val collector = BooleanCollector()
+ collector.init(input)
+
+ assertEquals(BooleanCollectorAppearance.CHECKBOX, collector.appearance)
+ }
+
+ @Test
+ fun initializesAppearanceAsSwitch() {
+ val input = buildJsonObject {
+ put("appearance", "SWITCH")
+ }
+ val collector = BooleanCollector()
+ collector.init(input)
+
+ assertEquals(BooleanCollectorAppearance.SWITCH, collector.appearance)
+ }
+
+ @Test
+ fun initializesAppearanceAsCheckboxWhenUnknownValueProvided() {
+ val input = buildJsonObject {
+ put("appearance", "UNKNOWN")
+ }
+ val collector = BooleanCollector()
+ collector.init(input)
+
+ assertEquals(BooleanCollectorAppearance.CHECKBOX, collector.appearance)
+ }
+
+ @Test
+ fun initializesErrorMessage() {
+ val input = buildJsonObject {
+ put("errorMessage", "Select the checkbox to continue.")
+ }
+ val collector = BooleanCollector()
+ collector.init(input)
+
+ assertEquals("Select the checkbox to continue.", collector.errorMessage)
+ }
+
+ @Test
+ fun initializesRichContent() {
+ val input = buildJsonObject {
+ put("richContent", buildJsonObject {
+ put("content", "This is a sample checkbox test.")
+ })
+ }
+ val collector = BooleanCollector()
+ collector.init(input)
+ val richContent = collector.richContent
+ assertNotNull(richContent)
+ assertEquals("This is a sample checkbox test.", richContent.content)
+ }
+
+ @Test
+ fun initializeRichContentWithLinks() {
+ val input = buildJsonObject {
+ put("richContent", buildJsonObject {
+ put("content", "A translatable rich text to take the user to {{link1}}")
+ put("replacements", buildJsonObject {
+ put("link1", buildJsonObject {
+ put("value", "Google")
+ put("href", "https://www.google.com")
+ put("type", "link")
+ put("target", "_self")
+ })
+ })
+ })
+ }
+ val collector = BooleanCollector()
+ collector.init(input)
+ val richContent = collector.richContent
+ assertNotNull(richContent)
+ assertEquals("A translatable rich text to take the user to {{link1}}", richContent.content)
+ assertTrue(richContent.replacements.isNotEmpty())
+ assertEquals(1, richContent.replacements.size)
+
+ val link1 = richContent.replacements["link1"]
+ assertEquals(RichContentReplacement(
+ value = "Google",
+ href = "https://www.google.com",
+ type = "link",
+ target = "_self"
+ ), link1)
+ }
+
+
+ @Test
+ fun validateReturnsNoErrorsWhenRequiredAndChecked() {
+ val input = buildJsonObject {
+ put("required", true)
+ put("errorMessage", "Select the checkbox to continue.")
+ }
+ val collector = BooleanCollector()
+ collector.init(input)
+ collector.value = true
+
+ val errors = collector.validate()
+
+ assertTrue(errors.isEmpty())
+ }
+
+ @Test
+ fun validateReturnsErrorWhenRequiredAndUnchecked() {
+ val input = buildJsonObject {
+ put("required", true)
+ put("errorMessage", "Select the checkbox to continue.")
+ }
+ val collector = BooleanCollector()
+ collector.init(input)
+ collector.value = false
+
+ val errors = collector.validate()
+
+ assertTrue(errors.isNotEmpty())
+ assertIs(errors[0])
+ }
+
+ @Test
+ fun validateReturnsNoErrorsWhenNotRequired() {
+ val input = buildJsonObject {
+ put("required", false)
+ }
+ val collector = BooleanCollector()
+ collector.init(input)
+ collector.value = false
+
+ val errors = collector.validate()
+
+ assertTrue(errors.isEmpty())
+ }
+
+ @Test
+ fun payloadReturnsTrueWhenChecked() {
+ val collector = BooleanCollector()
+ collector.value = true
+
+ assertTrue(collector.payload())
+ }
+
+ @Test
+ fun payloadReturnsFalseWhenUnchecked() {
+ val collector = BooleanCollector()
+ collector.value = false
+
+ assertFalse(collector.payload())
+ }
+}
+
diff --git a/davinci/src/test/kotlin/com/pingidentity/davinci/CollectorRegistryTest.kt b/davinci/src/test/kotlin/com/pingidentity/davinci/CollectorRegistryTest.kt
index cfc43cb81..62f56f385 100644
--- a/davinci/src/test/kotlin/com/pingidentity/davinci/CollectorRegistryTest.kt
+++ b/davinci/src/test/kotlin/com/pingidentity/davinci/CollectorRegistryTest.kt
@@ -1,15 +1,20 @@
/*
- * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
+ * Copyright (c) 2024 - 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 com.pingidentity.davinci.CollectorRegistry
+import com.pingidentity.davinci.collector.DeviceAuthenticationCollector
+import com.pingidentity.davinci.collector.DeviceRegistrationCollector
import com.pingidentity.davinci.collector.FlowCollector
import com.pingidentity.davinci.collector.LabelCollector
import com.pingidentity.davinci.collector.MultiSelectCollector
import com.pingidentity.davinci.collector.PasswordCollector
+import com.pingidentity.davinci.collector.PhoneNumberCollector
+import com.pingidentity.davinci.collector.PollingCollector
+import com.pingidentity.davinci.collector.QRCodeCollector
import com.pingidentity.davinci.collector.SingleSelectCollector
import com.pingidentity.davinci.collector.SubmitCollector
import com.pingidentity.davinci.collector.TextCollector
@@ -61,9 +66,15 @@ class CollectorRegistryTest {
add(buildJsonObject { put("inputType", "SINGLE_SELECT") })
add(buildJsonObject { put("inputType", "MULTI_SELECT") })
add(buildJsonObject { put("inputType", "MULTI_SELECT") })
+ add(buildJsonObject { put("inputType", "DEVICE_REGISTRATION") })
+ add(buildJsonObject { put("inputType", "DEVICE_AUTHENTICATION") })
+ add(buildJsonObject { put("inputType", "PHONE_NUMBER") })
+ add(buildJsonObject { put("type", "POLLING") })
+ add(buildJsonObject { put("type", "QR_CODE") })
}
val collectors = CollectorFactory.collector(mockk(), jsonArray)
+ assertEquals(16, collectors.size)
assertEquals(TextCollector::class.java, collectors[0]::class.java)
assertEquals(PasswordCollector::class.java, collectors[1]::class.java)
assertEquals(PasswordCollector::class.java, collectors[2]::class.java)
@@ -75,6 +86,11 @@ class CollectorRegistryTest {
assertEquals(SingleSelectCollector::class.java, collectors[8]::class.java)
assertEquals(MultiSelectCollector::class.java, collectors[9]::class.java)
assertEquals(MultiSelectCollector::class.java, collectors[10]::class.java)
+ assertEquals(DeviceRegistrationCollector::class.java, collectors[11]::class.java)
+ assertEquals(DeviceAuthenticationCollector::class.java, collectors[12]::class.java)
+ assertEquals(PhoneNumberCollector::class.java, collectors[13]::class.java)
+ assertEquals(PollingCollector::class.java, collectors[14]::class.java)
+ assertEquals(QRCodeCollector::class.java, collectors[15]::class.java)
}
@TestRailCase(21280)
@@ -88,10 +104,11 @@ class CollectorRegistryTest {
add(buildJsonObject { put("type", "SUBMIT_BUTTON") })
add(buildJsonObject { put("inputType", "ACTION") })
add(buildJsonObject { put("type", "UNKNOWN") })
+ add(buildJsonObject { put("type", "QR_CODE") })
}
val collectors = CollectorFactory.collector(mockk(), jsonArray)
- assertEquals(4, collectors.size)
+ assertEquals(5, collectors.size)
}
}
\ No newline at end of file
diff --git a/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.Response.kt b/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.Response.kt
index 73d606163..bfb4650b4 100644
--- a/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.Response.kt
+++ b/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.Response.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
+ * Copyright (c) 2024 - 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.
@@ -16,15 +16,19 @@ val headers = headersOf(HttpHeaders.ContentType, "application/json")
fun openIdConfigurationResponse() =
ByteReadChannel(
- "{\n" +
- " \"authorization_endpoint\" : \"http://auth.test-one-pingone.com/authorize\",\n" +
- " \"token_endpoint\" : \"https://auth.test-one-pingone.com/token\",\n" +
- " \"userinfo_endpoint\" : \"https://auth.test-one-pingone.com/userinfo\",\n" +
- " \"end_session_endpoint\" : \"https://auth.test-one-pingone.com/signoff\",\n" +
- " \"revocation_endpoint\" : \"https://auth.test-one-pingone.com/revoke\"\n" +
- "}",
+ """
+ {
+ "authorization_endpoint" : "http://auth.test-one-pingone.com/authorize",
+ "token_endpoint" : "https://auth.test-one-pingone.com/token",
+ "userinfo_endpoint" : "https://auth.test-one-pingone.com/userinfo",
+ "end_session_endpoint" : "https://auth.test-one-pingone.com/signoff",
+ "revocation_endpoint" : "https://auth.test-one-pingone.com/revoke",
+ "pushed_authorization_request_endpoint" : "https://auth.test-one-pingone.com/par"
+ }
+ """,
)
+
fun tokeResponse() =
ByteReadChannel(
"{\n" +
@@ -199,6 +203,20 @@ fun customHTMLTemplateWithInvalidPassword() = ByteReadChannel(
)
+fun rewindStateToLastRenderedUIResponse() =
+ ByteReadChannel(
+ "{\n" +
+ " \"eventName\": \"rewindStateToLastRenderedUI\"\n" +
+ "}",
+ )
+
+fun rewindStateToSpecificRenderedUIResponse() =
+ ByteReadChannel(
+ "{\n" +
+ " \"eventName\": \"rewindStateToSpecificRenderedUI\"\n" +
+ "}",
+ )
+
fun tokeErrorResponse() =
ByteReadChannel(
"{\n" +
diff --git a/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt b/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt
index f73c9d74f..e2ac7235e 100644
--- a/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt
+++ b/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
+ * Copyright (c) 2024 - 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.
@@ -59,6 +59,7 @@ import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
+import kotlin.test.assertNotSame
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -105,10 +106,14 @@ class DaVinciTest {
respond(authorizeResponse(), HttpStatusCode.OK, authorizeResponseHeaders)
}
+ "/par" -> {
+ respond(parResponse(), HttpStatusCode.Created, headers)
+ }
+
else -> {
return@MockEngine respond(
content =
- ByteReadChannel(""),
+ ByteReadChannel(""),
status = HttpStatusCode.InternalServerError,
)
}
@@ -179,9 +184,9 @@ class DaVinciTest {
assertTrue(node is ContinueNode)
assertTrue { (node as ContinueNode).collectors.size == 5 }
assertEquals("cq77vwelou", node.id)
- assertEquals("Username/Password Form", node.name)
- assertEquals("Test Description", node.description)
- assertEquals("CUSTOM_HTML", node.category)
+ assertEquals("Username/Password Form", node.name)
+ assertEquals("Test Description", node.description)
+ assertEquals("CUSTOM_HTML", node.category)
(node.collectors[0] as? TextCollector)?.value = "My First Name"
(node.collectors[1] as? PasswordCollector)?.value = "My Password"
@@ -234,7 +239,10 @@ class DaVinciTest {
//Make sure the request to signoff is made
val signOff = mockEngine.requestHistory[5]
- assertEquals("https://auth.test-one-pingone.com/signoff?id_token_hint=Dummy+IdToken&client_id=test", signOff.url.toString())
+ assertEquals(
+ "https://auth.test-one-pingone.com/signoff?id_token_hint=Dummy+IdToken&client_id=test",
+ signOff.url.toString()
+ )
assertContains(signOff.headers["Cookie"].toString(), "ST=session_token")
//Ensure storage are removed
assertNull(tokenStorage.get())
@@ -355,13 +363,17 @@ class DaVinciTest {
}
"/authorize" -> {
- respond(ByteReadChannel(readFile("ResponseWithBasicType.json")), HttpStatusCode.OK, authorizeResponseHeaders)
+ respond(
+ ByteReadChannel(readFile("ResponseWithBasicType.json")),
+ HttpStatusCode.OK,
+ authorizeResponseHeaders
+ )
}
else -> {
return@MockEngine respond(
content =
- ByteReadChannel(""),
+ ByteReadChannel(""),
status = HttpStatusCode.InternalServerError,
)
}
@@ -389,7 +401,12 @@ class DaVinciTest {
assertEquals(11, node.collectors.size)
(node.collectors[0] as? LabelCollector)?.content?.let { assertEquals("Sign On", it) }
- (node.collectors[1] as? LabelCollector)?.content?.let { assertEquals("Welcome to Ping Identity", it) }
+ (node.collectors[1] as? LabelCollector)?.content?.let {
+ assertEquals(
+ "Welcome to Ping Identity",
+ it
+ )
+ }
(node.collectors[2] as? TextCollector)?.let {
assertEquals("TEXT", it.type)
@@ -464,4 +481,163 @@ class DaVinciTest {
assertEquals("default-checkbox", it.value)
}
}
+
+ @Test
+ fun `DaVinci with PAR enabled`() = runTest {
+ val tokenStorage = MemoryStorage()
+ val cookieStorage = MemoryStorage()
+ val daVinci =
+ DaVinci {
+ httpClient = KtorHttpClient(HttpClient(mockEngine))
+ // Oidc as module with PAR enabled
+ module(Oidc) {
+ clientId = "test"
+ discoveryEndpoint =
+ "http://localhost/.well-known/openid-configuration"
+ scopes = mutableSetOf("openid", "email", "address")
+ redirectUri = "http://localhost:8080"
+ storage = { tokenStorage }
+ par = true // Enable PAR
+ logger = Logger.STANDARD
+ }
+ module(Cookie) {
+ storage = { cookieStorage }
+ persist = mutableListOf("ST")
+ }
+ }
+
+ var node = daVinci.start() // Return first Node
+ assertTrue(node is ContinueNode)
+ assertTrue { (node as ContinueNode).collectors.size == 5 }
+
+ (node.collectors[0] as? TextCollector)?.value = "My First Name"
+ (node.collectors[1] as? PasswordCollector)?.value = "My Password"
+ (node.collectors[2] as? SubmitCollector)?.value = "click me"
+
+ node = node.next()
+ assertTrue(node is SuccessNode)
+
+ mockEngine.requestHistory[0] // well-known
+ val parRequest = mockEngine.requestHistory[1] // par
+ assertEquals("https://auth.test-one-pingone.com/par", parRequest.url.toString())
+ // Verify client_id and response_mode are in the POST body, not URL
+ assertTrue(parRequest.body is FormDataContent)
+ val parBody = parRequest.body as FormDataContent
+ assertEquals("test", parBody.formData["client_id"])
+ assertEquals("code", parBody.formData["response_type"])
+ assertEquals("pi.flow", parBody.formData["response_mode"])
+ assertEquals("openid email address", parBody.formData["scope"])
+ assertEquals("http://localhost:8080", parBody.formData["redirect_uri"])
+ assertNotNull(parBody.formData["code_challenge"])
+ assertEquals("S256", parBody.formData["code_challenge_method"])
+
+
+ // Verify PAR request was made
+ val authorizeRequest =
+ mockEngine.requestHistory[2] // authorize request (after well-known, authorize, customHTMLTemplate)
+ assertEquals(
+ "http://auth.test-one-pingone.com/authorize?response_mode=pi.flow&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Atest-request-uri&client_id=test",
+ authorizeRequest.url.toString()
+ )
+
+ // The token request should use the PAR flow
+ val tokenRequest = mockEngine.requestHistory[4] // token request
+ assertEquals("https://auth.test-one-pingone.com/token", tokenRequest.url.toString())
+ }
+
+ private fun parResponse(): String =
+ """
+ {
+ "request_uri": "urn:ietf:params:oauth:request_uri:test-request-uri",
+ "expires_in": 60
+ }
+ """.trimIndent()
+
+ @Test
+ fun `DaVinci rewindStateToLastRenderedUI returns previous ContinueNode`() = runTest {
+ // Override the mock engine so /customHTMLTemplate returns a rewind event
+ mockEngine = MockEngine { request ->
+ when (request.url.encodedPath) {
+ "/.well-known/openid-configuration" ->
+ respond(openIdConfigurationResponse(), HttpStatusCode.OK, headers)
+ "/authorize" ->
+ respond(authorizeResponse(), HttpStatusCode.OK, authorizeResponseHeaders)
+ "/customHTMLTemplate" ->
+ respond(rewindStateToLastRenderedUIResponse(), HttpStatusCode.OK, customHTMLTemplateHeaders)
+ else ->
+ respond(ByteReadChannel(""), HttpStatusCode.InternalServerError)
+ }
+ }
+
+ val daVinci = DaVinci {
+ httpClient = KtorHttpClient(HttpClient(mockEngine))
+ module(Oidc) {
+ clientId = "test"
+ discoveryEndpoint = "http://localhost/.well-known/openid-configuration"
+ scopes = mutableSetOf("openid", "email", "address")
+ redirectUri = "http://localhost:8080"
+ storage = { MemoryStorage() }
+ }
+ module(Cookie) {
+ storage = { MemoryStorage() }
+ }
+ }
+
+ // start() stores the returned ContinueNode in FlowContext via the ContinueNode module
+ val firstNode = daVinci.start()
+ assertTrue(firstNode is ContinueNode)
+
+ // next() receives rewindStateToLastRenderedUI → transform retrieves the stored ContinueNode
+ val rewindNode = firstNode.next()
+
+ assertTrue(rewindNode is ContinueNode)
+ // Must be the exact same instance that was stored in FlowContext
+ assertNotSame(firstNode, rewindNode)
+ assertEquals(firstNode.id, rewindNode.id)
+ assertEquals(firstNode.name, rewindNode.name)
+ }
+
+ @Test
+ fun `DaVinci rewindStateToSpecificRenderedUI returns previous ContinueNode`() = runTest {
+ // Override the mock engine so /customHTMLTemplate returns a rewind event
+ mockEngine = MockEngine { request ->
+ when (request.url.encodedPath) {
+ "/.well-known/openid-configuration" ->
+ respond(openIdConfigurationResponse(), HttpStatusCode.OK, headers)
+ "/authorize" ->
+ respond(authorizeResponse(), HttpStatusCode.OK, authorizeResponseHeaders)
+ "/customHTMLTemplate" ->
+ respond(rewindStateToSpecificRenderedUIResponse(), HttpStatusCode.OK, customHTMLTemplateHeaders)
+ else ->
+ respond(ByteReadChannel(""), HttpStatusCode.InternalServerError)
+ }
+ }
+
+ val daVinci = DaVinci {
+ httpClient = KtorHttpClient(HttpClient(mockEngine))
+ module(Oidc) {
+ clientId = "test"
+ discoveryEndpoint = "http://localhost/.well-known/openid-configuration"
+ scopes = mutableSetOf("openid", "email", "address")
+ redirectUri = "http://localhost:8080"
+ storage = { MemoryStorage() }
+ }
+ module(Cookie) {
+ storage = { MemoryStorage() }
+ }
+ }
+
+ // start() stores the returned ContinueNode in FlowContext via the ContinueNode module
+ val firstNode = daVinci.start()
+ assertTrue(firstNode is ContinueNode)
+
+ // next() receives rewindStateToSpecificRenderedUI → transform retrieves the stored ContinueNode
+ val rewindNode = firstNode.next()
+
+ assertTrue(rewindNode is ContinueNode)
+ // Must be the exact same instance that was stored in FlowContext
+ assertNotSame(firstNode, rewindNode)
+ assertEquals(firstNode.id, rewindNode.id)
+ assertEquals(firstNode.name, rewindNode.name)
+ }
}
diff --git a/davinci/src/test/kotlin/com/pingidentity/davinci/HtmlEscaperTest.kt b/davinci/src/test/kotlin/com/pingidentity/davinci/HtmlEscaperTest.kt
new file mode 100644
index 000000000..b47a7a745
--- /dev/null
+++ b/davinci/src/test/kotlin/com/pingidentity/davinci/HtmlEscaperTest.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.
+ */
+
+package com.pingidentity.davinci
+
+import com.pingidentity.davinci.collector.escapeHtml
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class HtmlEscaperTest {
+
+ // ── individual character escapes ──────────────────────────────────────────
+
+ @Test
+ fun `escapeHtml escapes ampersand`() {
+ assertEquals("AT&T", "AT&T".escapeHtml())
+ }
+
+ @Test
+ fun `escapeHtml escapes less-than`() {
+ assertEquals("<script>", ""
+ assertEquals("<script>alert(1)</script>", malicious.escapeHtml())
+ }
+
+ @Test
+ fun `escapeHtml leaves plain text unchanged`() {
+ val plain = "Visit google.com for more info"
+ assertEquals(plain, plain.escapeHtml())
+ }
+
+ @Test
+ fun `escapeHtml handles empty string`() {
+ assertEquals("", "".escapeHtml())
+ }
+
+ @Test
+ fun `escapeHtml escapes all dangerous characters together`() {
+ val input = """text"""
+ assertEquals("""<a href="bad'url&more">text</a>""", input.escapeHtml())
+ }
+}
+
diff --git a/davinci/src/test/kotlin/com/pingidentity/davinci/LabelCollectorTest.kt b/davinci/src/test/kotlin/com/pingidentity/davinci/LabelCollectorTest.kt
index 9cd078319..7deea9efc 100644
--- a/davinci/src/test/kotlin/com/pingidentity/davinci/LabelCollectorTest.kt
+++ b/davinci/src/test/kotlin/com/pingidentity/davinci/LabelCollectorTest.kt
@@ -13,6 +13,9 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
class LabelCollectorTest {
@@ -36,4 +39,201 @@ class LabelCollectorTest {
assertEquals("", collector.content)
}
+ @Test
+ fun `richContent is null when no richContent field is present`() {
+ val input = buildJsonObject {
+ put("key", "label-key")
+ put("content", "Plain text content")
+ }
+ val collector = LabelCollector()
+ collector.init(input)
+ // richContent is only populated when a richContent JSON object is present.
+ // The rendering layer (buildRichTextLabel) handles the null fallback to content.
+ assertNull(collector.richContent)
+ assertEquals("Plain text content", collector.content)
+ }
+
+ @Test
+ fun `richText is read from richContent content when present`() {
+ val input = buildJsonObject {
+ put("key", "rich-text")
+ put("content", "A translatable rich text to take the user to google.com")
+ put("richContent", buildJsonObject {
+ put("content", "A translatable rich text to take the user to {{link1}}")
+ })
+ }
+ val collector = LabelCollector()
+ collector.init(input)
+ assertEquals("A translatable rich text to take the user to google.com", collector.content)
+ val richContent = collector.richContent
+ assertNotNull(richContent)
+ val replacement = richContent.replacements
+ assertEquals("A translatable rich text to take the user to {{link1}}", richContent.content)
+ assertTrue(replacement.isEmpty())
+ }
+
+ @Test
+ fun `replacements are parsed from richContent when present`() {
+ val input = buildJsonObject {
+ put("key", "rich-text")
+ put("content", "A translatable rich text to take the user to google.com")
+ put("richContent", buildJsonObject {
+ put("content", "A translatable rich text to take the user to {{link1}}")
+ put("replacements", buildJsonObject {
+ put("link1", buildJsonObject {
+ put("value", "google.com")
+ put("href", "https://www.google.com")
+ put("type", "link")
+ put("target", "_self")
+ })
+ })
+ })
+ }
+ val collector = LabelCollector()
+ collector.init(input)
+ val richContent = collector.richContent
+
+ assertNotNull(richContent)
+ assertEquals("A translatable rich text to take the user to {{link1}}", richContent.content)
+ assertEquals(1, richContent.replacements.size)
+
+ val link1 = richContent.replacements["link1"]
+ assertEquals(RichContentReplacement(
+ value = "google.com",
+ href = "https://www.google.com",
+ type = "link",
+ target = "_self"
+ ), link1)
+ }
+
+ @Test
+ fun `replacements map is empty when richContent has no replacements`() {
+ val input = buildJsonObject {
+ put("key", "rich-text")
+ put("richContent", buildJsonObject {
+ put("content", "Some rich text")
+ })
+ }
+ val collector = LabelCollector()
+ collector.init(input)
+ val richContent = collector.richContent
+ assertNotNull(richContent)
+ assertTrue(richContent.replacements.isEmpty())
+ }
+
+ @Test
+ fun `richContent is null and content retains HTML when no richContent field is present`() {
+ val input = buildJsonObject {
+ put("content", "
Rich Text fields produce LABELs
")
+ }
+ val collector = LabelCollector()
+ collector.init(input)
+ // TEXTBLOB-style labels have no richContent JSON → richContent is null.
+ // The raw HTML is stored in content; buildRichTextLabel parses it at render time.
+ assertNull(collector.richContent)
+ assertEquals("