From 1a617382f6a7221a406a3e94b8f4169f3832a898 Mon Sep 17 00:00:00 2001
From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
Date: Wed, 27 Sep 2023 13:39:54 +0200
Subject: [PATCH] test: error message formatting
---
.../ory/helpers/error-flow-nodes.spec.tsx | 52 ++++++
.../ory/helpers/node.spec.tsx | 134 ++++++++++++++
src/react-components/ory/helpers/node.tsx | 166 +++++++++++++-----
3 files changed, 310 insertions(+), 42 deletions(-)
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..71d62bcbd 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,55 @@ 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(
+ ,
+ )
+
+ // we check relative time here, because the test runner might be a bit slow
+ await expect(component).toContainText(
+ /The login flow expired (9|10)\.[0-9]{2} minutes ago, please try again\./,
+ )
+})
+
+test("message unix until minutes with formatted date", async ({ mount }) => {
+ const component = await mount(
+ ,
+ )
+
+ // we check relative time here, because the test runner might be a bit slow
+ 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 (9|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 (
@@ -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 (