diff --git a/src/react-components/ory/helpers/error-flow-nodes.spec.tsx b/src/react-components/ory/helpers/error-flow-nodes.spec.tsx index 68aa237e0..a65fb9022 100644 --- a/src/react-components/ory/helpers/error-flow-nodes.spec.tsx +++ b/src/react-components/ory/helpers/error-flow-nodes.spec.tsx @@ -98,3 +98,53 @@ test("can render multiple messages", async ({ mount }) => { component.locator("[data-testid='ui/message/1080002']"), ).toBeVisible() }) + +test("message unix expired_at timestamp with formatted date", async ({ + mount, +}) => { + const component = await mount( + , + ) + + await expect(component).toContainText( + /The login flow expired 10\.[0-9]{2} minutes ago, please try again\./, + ) +}) + +test("message unix until minutes with formatted date", async ({ mount }) => { + const component = await mount( + , + ) + + await expect(component).toContainText( + /You successfully recovered your account\. Please change your password or set up an alternative login method \(e\.g\. social sign in\) within the next 10\.[0-9]{2} minutes\./, + ) +}) diff --git a/src/react-components/ory/helpers/node.spec.tsx b/src/react-components/ory/helpers/node.spec.tsx index 4eeca7232..aadb9bd4f 100644 --- a/src/react-components/ory/helpers/node.spec.tsx +++ b/src/react-components/ory/helpers/node.spec.tsx @@ -26,3 +26,137 @@ test("hidden input field shouldn't show label", async ({ mount }) => { await expect(component).toBeHidden() }) + +test("uiTextToFormattedMessage on a list", async ({ mount }) => { + const component = await mount( + , + ) + + await expect(component).toContainText( + "These are your back up recovery codes. Please keep them in a safe place!", + ) + await expect(component).toContainText("te45pbc0") + await expect(component).toContainText("q3vvtd4i") +}) diff --git a/src/react-components/ory/helpers/node.tsx b/src/react-components/ory/helpers/node.tsx index c17a28e4f..8fdb62737 100644 --- a/src/react-components/ory/helpers/node.tsx +++ b/src/react-components/ory/helpers/node.tsx @@ -56,49 +56,131 @@ export const getNodeLabel = (node: UiNode): UiText | undefined => { return node.meta.label } +/** + * Converts a UiText to a FormattedMessage. + * The UiText contains the id of the message and the context. + * The context is used to inject values into the message from Kratos, e.g. a timestamp. + * For example a UI Node from Kratos might look like this: + * + * \{ + * "type":"input", + * "group":"default", + * "attributes": \{ + * "name":"traits.email", + * "type":"email", + * "required":true, + * "autocomplete":"email", + * "disabled":false, + * "node_type":"input" + * \}, + * "messages":[], + * "meta": \{ + * "label": \{ + * "id":1070002, + * "text":"E-Mail", + * "type":"info", + * "context":\{ + * "title":"E-Mail" + * \}, + * \} + * \} + * \} + * + * The context has the key "title" which matches the formatter template name "\{title\}" + * An example translation file would look like this: + * \{ + * "identities.messages.1070002": "\{title\}" + * \} + * + * The formwatter would then take the meta.label.id and look for the translation with the key matching the id. + * It would then replace the template "\{title\}" with the value from the context with the key "title". + * + * @param uiText - The UiText is part of the UiNode object sent by Kratos when performing a flow. + */ export const uiTextToFormattedMessage = ( { id, context = {}, text }: Omit, intl: IntlShape, -) => - intl.formatMessage( +) => { + const contextInjectedMessage = Object.entries(context).reduce( + (accumulator, [key, value]) => { + // context might provide an array of objects instead of a single object + // for example when looking up a recovery code + /* + * + { + "text": { + "id": 1050015, + "text": "3r9noma8, tv14n5tu, ...", + "type": "info", + "context": { + "secrets": [ + { + "context": { + "secret": "3r9noma8" + }, + "id": 1050009, + "text": "3r9noma8", + "type": "info" + }, + { + "context": { + "secret": "tv14n5tu" + }, + "id": 1050009, + "text": "tv14n5tu", + "type": "info" + }, + ] + } + }, + "id": "lookup_secret_codes", + "node_type": "text" + } + */ + if (Array.isArray(value)) { + return { + ...accumulator, + [key]: value, + [key + "_list"]: intl.formatList(value), + } + } else if (key.endsWith("_unix")) { + if (typeof value === "number") { + return { + ...accumulator, + [key]: intl.formatDate(new Date(value * 1000)), + [key + "_since"]: intl.formatDateTimeRange( + new Date(value), + new Date(), + ), + [key + "_since_minutes"]: Math.abs( + (value - new Date().getTime() / 1000) / 60, + ).toFixed(2), + [key + "_until"]: intl.formatDateTimeRange( + new Date(), + new Date(value), + ), + [key + "_until_minutes"]: Math.abs( + (new Date().getTime() / 1000 - value) / 60, + ).toFixed(2), + } + } + } + return { + ...accumulator, + [key]: value as string | number, + } + }, + {}, + ) + + return intl.formatMessage( { id: `identities.messages.${id}`, defaultMessage: text, }, - Object.entries(context).reduce< - Record - >( - (values, [key, value]) => - Array.isArray(value) - ? { - ...values, - [key]: value, - [key + "_list"]: intl.formatList(value), - } - : key.endsWith("_unix") - ? { - ...values, - [key]: intl.formatDate(new Date(value * 1000)), - [key + "_since"]: intl.formatDateTimeRange( - new Date(value as string | number), - new Date(), - ), - [key + "_since_minutes"]: - (value - new Date().getTime() / 1000) / 60, - [key + "_until"]: intl.formatDateTimeRange( - new Date(), - new Date(value as string | number), - ), - [key + "_until_minutes"]: - (new Date().getTime() / 1000 - value) / 60, - } - : { - ...values, - [key]: value as string | number, - }, - {}, - ), + contextInjectedMessage, ) +} export const Node = ({ node, @@ -118,9 +200,9 @@ export const Node = ({ return ( {formatMessage(node.meta.label) @@ -217,7 +299,7 @@ export const Node = ({ return isSocial ? ( } - label={formatMessage(getNodeLabel(node)) as string} + label={formatMessage(getNodeLabel(node))} name={attrs.name} required={attrs.required} defaultValue={attrs.value as string | number | string[]} @@ -264,7 +346,7 @@ export const Node = ({ dataTestid={`node/input/${attrs.name}`} className={className} name={attrs.name} - header={formatMessage(getNodeLabel(node)) as string} + header={formatMessage(getNodeLabel(node))} type={attrs.type} autoComplete={ attrs.autocomplete ?? @@ -280,7 +362,7 @@ export const Node = ({ return (