From 2f42732deb7c7a251135e8669b40f20b3786083f Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 27 May 2024 23:44:41 +0200 Subject: [PATCH] #549 Done --- src/account/i18n/i18n.tsx | 81 ++++++++++++++++-------- src/account/pages/Totp.tsx | 7 +- src/login/i18n/i18n.tsx | 67 ++++++++++++++------ src/login/pages/LoginConfigTotp.tsx | 5 +- src/login/pages/WebauthnAuthenticate.tsx | 5 +- 5 files changed, 110 insertions(+), 55 deletions(-) diff --git a/src/account/i18n/i18n.tsx b/src/account/i18n/i18n.tsx index a4d2717c..e2a1067b 100644 --- a/src/account/i18n/i18n.tsx +++ b/src/account/i18n/i18n.tsx @@ -1,11 +1,9 @@ import "minimal-polyfills/Object.fromEntries"; -//NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35 import { useEffect, useState, useRef } from "react"; import fallbackMessages from "./baseMessages/en"; import { getMessages } from "./baseMessages"; import { assert } from "tsafe/assert"; import type { KcContext } from "../kcContext/KcContext"; -import { Markdown } from "keycloakify/tools/Markdown"; export const fallbackLanguageTag = "en"; @@ -53,16 +51,31 @@ export type GenericI18n = { */ msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string; /** + * This is meant to be used when the key argument is variable, something that might have been configured by the user + * in the Keycloak admin for example. + * * Examples assuming currentLanguageTag === "en" - * advancedMsg("${access-denied} foo bar") === ${msgStr("access-denied")} foo bar === Access denied foo bar + * { + * en: { + * "access-denied": "Access denied", + * "foo": "Foo {0} {1}", + * "bar": "Bar {0}" + * } + * } + * + * advancedMsg("${access-denied} foo bar") === {msgStr("access-denied")} foo bar === Access denied foo bar * advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied") === Access denied * advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === not-a-message-key + * advancedMsg("${bar}", "c") + * === {msgStr("bar", "XXX")} + * === Bar <strong>XXX</strong> (The html in the arg is partially escaped for security reasons, it might be untrusted) + * advancedMsg("${foo} xx ${bar}", "a", "b", "c") + * === {msgStr("foo", "a", "b")} xx {msgStr("bar")} + * === Foo a b xx Bar {0} (The substitution are only applied in the first message) */ advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element; /** - * Examples assuming currentLanguageTag === "en" - * advancedMsg("${access-denied} foo bar") === msg("access-denied") + " foo bar" === "Access denied foo bar" - * advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === "not-a-message-key" + * See advancedMsg() but instead of returning a JSX.Element it returns a string. */ advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string; }; @@ -133,8 +146,8 @@ function createI18nTranslationFunctions(params: { }): Pick, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> { const { fallbackMessages, messages } = params; - function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): string | JSX.Element | undefined { - const { key, args, doRenderMarkdown } = props; + function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined { + const { key, args, doRenderAsHtml } = props; const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key]; @@ -163,51 +176,67 @@ function createI18nTranslationFunctions(params: { return; } - messageWithArgsInjected = messageWithArgsInjected.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg); + messageWithArgsInjected = messageWithArgsInjected.replace( + new RegExp(`\\{${i + startIndex}\\}`, "g"), + arg.replace(//g, ">") + ); }); return messageWithArgsInjected; })(); - return doRenderMarkdown ? ( - - {messageWithArgsInjectedIfAny} - + return doRenderAsHtml ? ( + ) : ( messageWithArgsInjectedIfAny ); } - function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): JSX.Element | string { - const { key, args, doRenderMarkdown } = props; + function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string { + const { key, args, doRenderAsHtml } = props; - const match = key.match(/^\$\{([^{]+)\}$/); + if (!/\$\{[^}]+\}/.test(key)) { + const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml }); - const keyUnwrappedFromCurlyBraces = match === null ? key : match[1]; + if (resolvedMessage === undefined) { + return doRenderAsHtml ? : key; + } - const out = resolveMsg({ - key: keyUnwrappedFromCurlyBraces, - args, - doRenderMarkdown + return resolvedMessage; + } + + let isFirstMatch = true; + + const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => { + const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i; + + isFirstMatch = false; + + return replaceBy; }); - return (out !== undefined ? out : doRenderMarkdown ? {keyUnwrappedFromCurlyBraces} : keyUnwrappedFromCurlyBraces) as any; + return doRenderAsHtml ? : resolvedComplexMessage; } return { - msgStr: (key, ...args) => resolveMsg({ key, args, doRenderMarkdown: false }) as string, - msg: (key, ...args) => resolveMsg({ key, args, doRenderMarkdown: true }) as JSX.Element, + msgStr: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: false }) as string, + msg: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: true }) as JSX.Element, advancedMsg: (key, ...args) => resolveMsgAdvanced({ key, args, - doRenderMarkdown: true + doRenderAsHtml: true }) as JSX.Element, advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args, - doRenderMarkdown: false + doRenderAsHtml: false }) as string }; } diff --git a/src/account/pages/Totp.tsx b/src/account/pages/Totp.tsx index 208dcdc8..cd07cd60 100644 --- a/src/account/pages/Totp.tsx +++ b/src/account/pages/Totp.tsx @@ -3,7 +3,6 @@ import type { PageProps } from "keycloakify/account/pages/PageProps"; import { useGetClassName } from "keycloakify/account/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; -import { MessageKey } from "keycloakify/account/i18n/i18n"; export default function Totp(props: PageProps, I18n>) { const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; @@ -15,7 +14,7 @@ export default function Totp(props: PageProps = { HmacSHA1: "SHA1", @@ -78,9 +77,7 @@ export default function Totp(props: PageProps

{msg("totpStep1")}

-
    - {totp.supportedApplications?.map(app =>
  • {msg(app as MessageKey)}
  • )} -
+
    {totp.supportedApplications?.map(app =>
  • {advancedMsg(app)}
  • )}
{mode && mode == "manual" ? ( diff --git a/src/login/i18n/i18n.tsx b/src/login/i18n/i18n.tsx index 81760663..7b39e676 100644 --- a/src/login/i18n/i18n.tsx +++ b/src/login/i18n/i18n.tsx @@ -52,16 +52,31 @@ export type GenericI18n = { */ msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string; /** + * This is meant to be used when the key argument is variable, something that might have been configured by the user + * in the Keycloak admin for example. + * * Examples assuming currentLanguageTag === "en" - * advancedMsg("${access-denied} foo bar") === ${msgStr("access-denied")} foo bar === Access denied foo bar + * { + * en: { + * "access-denied": "Access denied", + * "foo": "Foo {0} {1}", + * "bar": "Bar {0}" + * } + * } + * + * advancedMsg("${access-denied} foo bar") === {msgStr("access-denied")} foo bar === Access denied foo bar * advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied") === Access denied * advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === not-a-message-key + * advancedMsg("${bar}", "c") + * === {msgStr("bar", "XXX")} + * === Bar <strong>XXX</strong> (The html in the arg is partially escaped for security reasons, it might be untrusted) + * advancedMsg("${foo} xx ${bar}", "a", "b", "c") + * === {msgStr("foo", "a", "b")} xx {msgStr("bar")} + * === Foo a b xx Bar {0} (The substitution are only applied in the first message) */ advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element; /** - * Examples assuming currentLanguageTag === "en" - * advancedMsg("${access-denied} foo bar") === msg("access-denied") + " foo bar" === "Access denied foo bar" - * advancedMsg("${not-a-message-key}") === advancedMsg("not-a-message-key") === "not-a-message-key" + * See advancedMsg() but instead of returning a JSX.Element it returns a string. */ advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string; }; @@ -132,7 +147,7 @@ function createI18nTranslationFunctions(params: { messages: Record; __localizationRealmOverridesUserProfile: Record; }): Pick, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> { - const { fallbackMessages, messages /*__localizationRealmOverridesUserProfile*/ } = params; + const { fallbackMessages, messages, __localizationRealmOverridesUserProfile } = params; function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined { const { key, args, doRenderAsHtml } = props; @@ -188,26 +203,42 @@ function createI18nTranslationFunctions(params: { function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string { const { key, args, doRenderAsHtml } = props; - // TODO: - /* - if( key in __localizationRealmOverridesUserProfile ){ - - + if (key in __localizationRealmOverridesUserProfile) { + const resolvedMessage = __localizationRealmOverridesUserProfile[key]; + return doRenderAsHtml ? ( + + ) : ( + resolvedMessage + ); } - */ - const match = key.match(/^\$\{([^{]+)\}$/); + if (!/\$\{[^}]+\}/.test(key)) { + const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml }); - const keyUnwrappedFromCurlyBraces = match === null ? key : match[1]; + if (resolvedMessage === undefined) { + return doRenderAsHtml ? : key; + } - const out = resolveMsg({ - key: keyUnwrappedFromCurlyBraces, - args, - doRenderAsHtml + return resolvedMessage; + } + + let isFirstMatch = true; + + const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => { + const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i; + + isFirstMatch = false; + + return replaceBy; }); - return (out !== undefined ? out : doRenderAsHtml ? {keyUnwrappedFromCurlyBraces} : keyUnwrappedFromCurlyBraces) as any; + return doRenderAsHtml ? : resolvedComplexMessage; } return { diff --git a/src/login/pages/LoginConfigTotp.tsx b/src/login/pages/LoginConfigTotp.tsx index 46cda7c0..0a2bbd90 100644 --- a/src/login/pages/LoginConfigTotp.tsx +++ b/src/login/pages/LoginConfigTotp.tsx @@ -3,7 +3,6 @@ import type { PageProps } from "keycloakify/login/pages/PageProps"; import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; -import { MessageKey } from "keycloakify/login/i18n/i18n"; export default function LoginConfigTotp(props: PageProps, I18n>) { const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; @@ -15,7 +14,7 @@ export default function LoginConfigTotp(props: PageProps @@ -26,7 +25,7 @@ export default function LoginConfigTotp(props: PageProps {totp.supportedApplications.map(app => ( -
  • {msg(app as MessageKey)}
  • +
  • {advancedMsg(app)}
  • ))} diff --git a/src/login/pages/WebauthnAuthenticate.tsx b/src/login/pages/WebauthnAuthenticate.tsx index e618b475..76c18c33 100644 --- a/src/login/pages/WebauthnAuthenticate.tsx +++ b/src/login/pages/WebauthnAuthenticate.tsx @@ -1,6 +1,5 @@ import { useEffect, Fragment } from "react"; import { clsx } from "keycloakify/tools/clsx"; -import type { MessageKey } from "keycloakify/login/i18n/i18n"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; import { assert } from "tsafe/assert"; @@ -29,7 +28,7 @@ export default function WebauthnAuthenticate(props: PageProps - {msg(authenticator.label as MessageKey)} + {advancedMsg(authenticator.label)} {authenticator.transports.displayNameProperties?.length && (