diff --git a/scripts/generate-i18n-messages.ts b/scripts/generate-i18n-messages.ts index 115caa35..879fd3d3 100644 --- a/scripts/generate-i18n-messages.ts +++ b/scripts/generate-i18n-messages.ts @@ -58,7 +58,7 @@ const logger = getLogger({ isSilent }); Object.keys(record).forEach(themeType => { const recordForPageType = record[themeType]; - if (themeType !== "login") { + if (themeType !== "login" && themeType !== "account") { return; } diff --git a/src/account/Fallback.tsx b/src/account/Fallback.tsx new file mode 100644 index 00000000..50e9225a --- /dev/null +++ b/src/account/Fallback.tsx @@ -0,0 +1,26 @@ +import { lazy, Suspense } from "react"; +import type { PageProps } from "keycloakify/account/pages/PageProps"; +import type { I18n } from "keycloakify/account/i18n"; +import type { KcContext } from "./kcContext"; +import { assert, type Equals } from "tsafe/assert"; + +const Password = lazy(() => import("keycloakify/account/pages/Password")); +const Account = lazy(() => import("keycloakify/account/pages/Account")); + +export default function Fallback(props: PageProps) { + const { kcContext, ...rest } = props; + + return ( + + {(() => { + switch (kcContext.pageId) { + case "password.ftl": + return ; + case "account.ftl": + return ; + } + assert>(false); + })()} + + ); +} diff --git a/src/account/Template.tsx b/src/account/Template.tsx new file mode 100644 index 00000000..4ec3f002 --- /dev/null +++ b/src/account/Template.tsx @@ -0,0 +1,127 @@ +import { clsx } from "keycloakify/tools/clsx"; +import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate"; +import { type TemplateProps } from "keycloakify/account/TemplateProps"; +import type { KcContext } from "./kcContext"; +import type { I18n } from "./i18n"; +import { assert } from "keycloakify/tools/assert"; + +export default function Template(props: TemplateProps) { + const { kcContext, i18n, doUseDefaultCss, bodyClass, active, children } = props; + + const { msg, changeLocale, labelBySupportedLanguageTag, currentLanguageTag } = i18n; + + const { locale, url, features, realm, message, referrer } = kcContext; + + const { isReady } = usePrepareTemplate({ + "doFetchDefaultThemeResources": doUseDefaultCss, + url, + "stylesCommon": ["node_modules/patternfly/dist/css/patternfly.min.css", "node_modules/patternfly/dist/css/patternfly-additions.min.css"], + "styles": ["css/account.css"], + "htmlClassName": undefined, + "bodyClassName": clsx("admin-console", "user", bodyClass) + }); + + if (!isReady) { + return null; + } + + return ( + <> +
+ +
+ +
+
+ +
+ +
+ {message !== undefined && ( +
+ {message.type === "success" && } + {message.type === "error" && } + {message.summary} +
+ )} + + {children} +
+
+ + ); +} diff --git a/src/account/TemplateProps.ts b/src/account/TemplateProps.ts new file mode 100644 index 00000000..32d37f1e --- /dev/null +++ b/src/account/TemplateProps.ts @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; +import type { KcContext } from "./kcContext"; +import type { I18n } from "./i18n"; + +export type TemplateProps = { + kcContext: KcContext; + i18n: I18nExtended; + doUseDefaultCss: boolean; + active: string; + bodyClass: string | undefined; + children: ReactNode; +}; diff --git a/src/account/i18n/i18n.tsx b/src/account/i18n/i18n.tsx new file mode 100644 index 00000000..8601fa73 --- /dev/null +++ b/src/account/i18n/i18n.tsx @@ -0,0 +1,229 @@ +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"; + +export type KcContextLike = { + locale?: { + currentLanguageTag: string; + supported: { languageTag: string; url: string; label: string }[]; + }; +}; + +assert(); + +export type MessageKey = keyof typeof fallbackMessages | keyof (typeof keycloakifyExtraMessages)[typeof fallbackLanguageTag]; + +export type GenericI18n = { + /** + * e.g: "en", "fr", "zh-CN" + * + * The current language + */ + currentLanguageTag: string; + /** + * To call when the user switch language. + * This will cause the page to be reloaded, + * on next load currentLanguageTag === newLanguageTag + */ + changeLocale: (newLanguageTag: string) => never; + /** + * e.g. "en" => "English", "fr" => "Français", ... + * + * Used to render a select that enable user to switch language. + * ex: https://user-images.githubusercontent.com/6702424/186044799-38801eec-4e89-483b-81dd-8e9233e8c0eb.png + * */ + labelBySupportedLanguageTag: Record; + /** + * Examples assuming currentLanguageTag === "en" + * + * msg("access-denied") === Access denied + * msg("impersonateTitleHtml", "Foo") === Foo Impersonate User + */ + msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element; + /** + * It's the same thing as msg() but instead of returning a JSX.Element it returns a string. + * It can be more convenient to manipulate strings but if there are HTML tags it wont render. + * msgStr("impersonateTitleHtml", "Foo") === "Foo Impersonate User" + */ + msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string; + /** + * Examples assuming currentLanguageTag === "en" + * 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: (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" + */ + advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string; +}; + +export type I18n = GenericI18n; + +export function createUseI18n(extraMessages: { + [languageTag: string]: { [key in ExtraMessageKey]: string }; +}) { + function useI18n(params: { kcContext: KcContextLike }): GenericI18n | null { + const { kcContext } = params; + + const [i18n, setI18n] = useState | undefined>(undefined); + + const refHasStartedFetching = useRef(false); + + useEffect(() => { + if (refHasStartedFetching.current) { + return; + } + + refHasStartedFetching.current = true; + + (async () => { + const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {}; + + setI18n({ + ...createI18nTranslationFunctions({ + "fallbackMessages": { + ...fallbackMessages, + ...(keycloakifyExtraMessages[fallbackLanguageTag] ?? {}), + ...(extraMessages[fallbackLanguageTag] ?? {}) + } as any, + "messages": { + ...(await getMessages(currentLanguageTag)), + ...((keycloakifyExtraMessages as any)[currentLanguageTag] ?? {}), + ...(extraMessages[currentLanguageTag] ?? {}) + } as any + }), + currentLanguageTag, + "changeLocale": newLanguageTag => { + const { locale } = kcContext; + + assert(locale !== undefined, "Internationalization not enabled"); + + const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag); + + assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`); + + window.location.href = targetSupportedLocale.url; + + assert(false, "never"); + }, + "labelBySupportedLanguageTag": Object.fromEntries( + (kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]) + ) + }); + })(); + }, []); + + return i18n ?? null; + } + + return { useI18n }; +} + +function createI18nTranslationFunctions(params: { + fallbackMessages: Record; + messages: Record; +}): 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; + + const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key]; + + if (messageOrUndefined === undefined) { + return undefined; + } + + const message = messageOrUndefined; + + const messageWithArgsInjectedIfAny = (() => { + const startIndex = message + .match(/{[0-9]+}/g) + ?.map(g => g.match(/{([0-9]+)}/)![1]) + .map(indexStr => parseInt(indexStr)) + .sort((a, b) => a - b)[0]; + + if (startIndex === undefined) { + // No {0} in message (no arguments expected) + return message; + } + + let messageWithArgsInjected = message; + + args.forEach((arg, i) => { + if (arg === undefined) { + return; + } + + messageWithArgsInjected = messageWithArgsInjected.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg); + }); + + return messageWithArgsInjected; + })(); + + return doRenderMarkdown ? ( + + {messageWithArgsInjectedIfAny} + + ) : ( + messageWithArgsInjectedIfAny + ); + } + + function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): JSX.Element | string { + const { key, args, doRenderMarkdown } = props; + + const match = key.match(/^\$\{([^{]+)\}$/); + + const keyUnwrappedFromCurlyBraces = match === null ? key : match[1]; + + const out = resolveMsg({ + "key": keyUnwrappedFromCurlyBraces, + args, + doRenderMarkdown + }); + + return (out !== undefined ? out : doRenderMarkdown ? {keyUnwrappedFromCurlyBraces} : keyUnwrappedFromCurlyBraces) as any; + } + + return { + "msgStr": (key, ...args) => resolveMsg({ key, args, "doRenderMarkdown": false }) as string, + "msg": (key, ...args) => resolveMsg({ key, args, "doRenderMarkdown": true }) as JSX.Element, + "advancedMsg": (key, ...args) => resolveMsgAdvanced({ key, args, "doRenderMarkdown": true }) as JSX.Element, + "advancedMsgStr": (key, ...args) => resolveMsgAdvanced({ key, args, "doRenderMarkdown": false }) as string + }; +} + +const keycloakifyExtraMessages = { + "en": { + "shouldBeEqual": "{0} should be equal to {1}", + "shouldBeDifferent": "{0} should be different to {1}", + "shouldMatchPattern": "Pattern should match: `/{0}/`", + "mustBeAnInteger": "Must be an integer", + "notAValidOption": "Not a valid option" + }, + "fr": { + /* spell-checker: disable */ + "shouldBeEqual": "{0} doit être égal à {1}", + "shouldBeDifferent": "{0} doit être différent de {1}", + "shouldMatchPattern": "Dois respecter le schéma: `/{0}/`", + "mustBeAnInteger": "Doit être un nombre entier", + "notAValidOption": "N'est pas une option valide", + + "logoutConfirmTitle": "Déconnexion", + "logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?", + "doLogout": "Se déconnecter" + /* spell-checker: enable */ + } +}; diff --git a/src/account/i18n/index.ts b/src/account/i18n/index.ts new file mode 100644 index 00000000..36a7e50a --- /dev/null +++ b/src/account/i18n/index.ts @@ -0,0 +1 @@ +export type { I18n } from "./i18n"; diff --git a/src/account/index.ts b/src/account/index.ts new file mode 100644 index 00000000..180ee07e --- /dev/null +++ b/src/account/index.ts @@ -0,0 +1,8 @@ +import Fallback from "keycloakify/account/Fallback"; + +export default Fallback; + +export { getKcContext } from "keycloakify/account/kcContext/getKcContext"; +export { createUseI18n } from "keycloakify/account/i18n/i18n"; + +export type { PageProps } from "keycloakify/account/pages/PageProps"; diff --git a/src/account/kcContext/KcContext.ts b/src/account/kcContext/KcContext.ts new file mode 100644 index 00000000..507f5468 --- /dev/null +++ b/src/account/kcContext/KcContext.ts @@ -0,0 +1,81 @@ +import type { AccountThemePageId } from "keycloakify/bin/keycloakify/generateFtl"; +import { assert } from "tsafe/assert"; +import type { Equals } from "tsafe"; + +export type KcContext = KcContext.Password | KcContext.Account; + +export declare namespace KcContext { + export type Common = { + locale?: { + supported: { + url: string; + label: string; + languageTag: string; + }[]; + currentLanguageTag: string; + }; + url: { + accountUrl: string; + passwordUrl: string; + totpUrl: string; + socialUrl: string; + sessionsUrl: string; + applicationsUrl: string; + logUrl: string; + resourceUrl: string; + resourcesCommonPath: string; + resourcesPath: string; + getLogoutUrl: () => string; + }; + features: { + passwordUpdateSupported: boolean; + identityFederation: boolean; + log: boolean; + authorization: boolean; + }; + realm: { + internationalizationEnabled: boolean; + userManagedAccessAllowed: boolean; + }; + message?: { + type: "success" | "warning" | "error" | "info"; + summary: string; + }; + referrer?: { + url?: string; + name: string; + }; + messagesPerField: { + printIfExists: (fieldName: string, x: T) => T | undefined; + existsError: (fieldName: string) => boolean; + get: (fieldName: string) => string; + exists: (fieldName: string) => boolean; + }; + }; + + export type Password = Common & { + pageId: "password.ftl"; + password: { + passwordSet: boolean; + }; + }; + + export type Account = Common & { + pageId: "account.ftl"; + url: { + referrerURI: string; + accountUrl: string; + }; + realm: { + registrationEmailAsUsername: boolean; + editUsernameAllowed: boolean; + }; + stateChecker: string; + account: { + firstName: string; + lastName?: string; + }; + }; +} + +assert>(); diff --git a/src/account/kcContext/getKcContext.ts b/src/account/kcContext/getKcContext.ts new file mode 100644 index 00000000..ed793333 --- /dev/null +++ b/src/account/kcContext/getKcContext.ts @@ -0,0 +1,72 @@ +import { kcContextMocks, kcContextCommonMock } from "./kcContextMocks"; +import type { DeepPartial } from "keycloakify/tools/DeepPartial"; +import { deepAssign } from "keycloakify/tools/deepAssign"; +import type { ExtendKcContext } from "./getKcContextFromWindow"; +import { getKcContextFromWindow } from "./getKcContextFromWindow"; +import { pathJoin } from "keycloakify/bin/tools/pathJoin"; +import { pathBasename } from "keycloakify/tools/pathBasename"; +import { mockTestingResourcesCommonPath } from "keycloakify/bin/mockTestingResourcesPath"; +import { symToStr } from "tsafe/symToStr"; + +export function getKcContext(params?: { + mockPageId?: ExtendKcContext["pageId"]; + mockData?: readonly DeepPartial>[]; +}): { kcContext: ExtendKcContext | undefined } { + const { mockPageId, mockData } = params ?? {}; + + const realKcContext = getKcContextFromWindow(); + + if (mockPageId !== undefined && realKcContext === undefined) { + //TODO maybe trow if no mock fo custom page + + console.log( + [ + `%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, + `If assets are missing make sure you have built your Keycloak theme at least once.` + ].join(" "), + "background: red; color: yellow; font-size: medium" + ); + + const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId); + + const partialKcContextCustomMock = mockData?.find(({ pageId }) => pageId === mockPageId); + + if (kcContextDefaultMock === undefined && partialKcContextCustomMock === undefined) { + console.warn( + [ + `WARNING: You declared the non build in page ${mockPageId} but you didn't `, + `provide mock data needed to debug the page outside of Keycloak as you are trying to do now.`, + `Please check the documentation of the getKcContext function` + ].join("\n") + ); + } + + const kcContext: any = {}; + + deepAssign({ + "target": kcContext, + "source": kcContextDefaultMock !== undefined ? kcContextDefaultMock : { "pageId": mockPageId, ...kcContextCommonMock } + }); + + if (partialKcContextCustomMock !== undefined) { + deepAssign({ + "target": kcContext, + "source": partialKcContextCustomMock + }); + } + + return { kcContext }; + } + + if (realKcContext === undefined) { + return { "kcContext": undefined }; + } + + { + const { url } = realKcContext; + + url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(mockTestingResourcesCommonPath)); + } + + return { "kcContext": realKcContext }; +} diff --git a/src/account/kcContext/getKcContextFromWindow.ts b/src/account/kcContext/getKcContextFromWindow.ts new file mode 100644 index 00000000..799dad34 --- /dev/null +++ b/src/account/kcContext/getKcContextFromWindow.ts @@ -0,0 +1,11 @@ +import type { KcContext } from "./KcContext"; +import type { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey"; +import { ftlValuesGlobalName } from "keycloakify/bin/keycloakify/ftlValuesGlobalName"; + +export type ExtendKcContext = [KcContextExtension] extends [never] + ? KcContext + : AndByDiscriminatingKey<"pageId", KcContextExtension & KcContext.Common, KcContext>; + +export function getKcContextFromWindow(): ExtendKcContext | undefined { + return typeof window === "undefined" ? undefined : (window as any)[ftlValuesGlobalName]; +} diff --git a/src/account/kcContext/index.ts b/src/account/kcContext/index.ts new file mode 100644 index 00000000..7ecbb2f3 --- /dev/null +++ b/src/account/kcContext/index.ts @@ -0,0 +1 @@ +export type { KcContext } from "./KcContext"; diff --git a/src/account/kcContext/kcContextMocks.ts b/src/account/kcContext/kcContextMocks.ts new file mode 100644 index 00000000..d128e23b --- /dev/null +++ b/src/account/kcContext/kcContextMocks.ts @@ -0,0 +1,153 @@ +import "minimal-polyfills/Object.fromEntries"; +import type { KcContext } from "./KcContext"; +import { mockTestingResourcesCommonPath, mockTestingResourcesPath } from "keycloakify/bin/mockTestingResourcesPath"; +import { pathJoin } from "keycloakify/bin/tools/pathJoin"; +import { id } from "tsafe/id"; + +const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/"; + +export const kcContextCommonMock: KcContext.Common = { + "url": { + "loginAction": "#", + "resourcesPath": pathJoin(PUBLIC_URL, mockTestingResourcesPath), + "resourcesCommonPath": pathJoin(PUBLIC_URL, mockTestingResourcesCommonPath), + "loginRestartFlowUrl": "/auth/realms/myrealm/login-actions/restart?client_id=account&tab_id=HoAx28ja4xg", + "loginUrl": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg" + }, + "realm": { + "name": "myrealm", + "displayName": "myrealm", + "displayNameHtml": "myrealm", + "internationalizationEnabled": true, + "registrationEmailAsUsername": false + }, + "messagesPerField": { + "printIfExists": () => { + return undefined; + }, + "existsError": () => false, + "get": key => `Fake error for ${key}`, + "exists": () => false + }, + "locale": { + "supported": [ + /* spell-checker: disable */ + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=de", + "label": "Deutsch", + "languageTag": "de" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=no", + "label": "Norsk", + "languageTag": "no" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ru", + "label": "Русский", + "languageTag": "ru" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sv", + "label": "Svenska", + "languageTag": "sv" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pt-BR", + "label": "Português (Brasil)", + "languageTag": "pt-BR" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=lt", + "label": "Lietuvių", + "languageTag": "lt" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=en", + "label": "English", + "languageTag": "en" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=it", + "label": "Italiano", + "languageTag": "it" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=fr", + "label": "Français", + "languageTag": "fr" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=zh-CN", + "label": "中文简体", + "languageTag": "zh-CN" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=es", + "label": "Español", + "languageTag": "es" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=cs", + "label": "Čeština", + "languageTag": "cs" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ja", + "label": "日本語", + "languageTag": "ja" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sk", + "label": "Slovenčina", + "languageTag": "sk" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pl", + "label": "Polski", + "languageTag": "pl" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ca", + "label": "Català", + "languageTag": "ca" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=nl", + "label": "Nederlands", + "languageTag": "nl" + }, + { + "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=tr", + "label": "Türkçe", + "languageTag": "tr" + } + /* spell-checker: enable */ + ], + "currentLanguageTag": "en" + }, + "auth": { + "showUsername": false, + "showResetCredentials": false, + "showTryAnotherWayLink": false + }, + "client": { + "clientId": "myApp" + }, + "scripts": [], + "message": { + "type": "success", + "summary": "This is a test message" + }, + "isAppInitiatedAction": false +}; + +export const kcContextMocks: KcContext[] = [ + id({ + ...kcContextCommonMock, + "pageId": "password.ftl", + "password": { + "passwordSet": true + } + }) +]; diff --git a/src/account/pages/Account.tsx b/src/account/pages/Account.tsx new file mode 100644 index 00000000..43c018c5 --- /dev/null +++ b/src/account/pages/Account.tsx @@ -0,0 +1,131 @@ +import { clsx } from "keycloakify/tools/clsx"; +import { type PageProps, defaultClasses } from "keycloakify/account/pages/PageProps"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; +import type { KcContext } from "../kcContext"; +import type { I18n } from "../i18n"; + +export default function LogoutConfirm(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { getClassName } = useGetClassName({ + "defaultClasses": !doUseDefaultCss ? undefined : defaultClasses, + classes + }); + + const { url, realm, messagesPerField, stateChecker, account } = kcContext; + + const { msg } = i18n; + + return ( + + ); +} diff --git a/src/account/pages/PageProps.ts b/src/account/pages/PageProps.ts new file mode 100644 index 00000000..ba048ac0 --- /dev/null +++ b/src/account/pages/PageProps.ts @@ -0,0 +1,21 @@ +import type { LazyExoticComponent } from "react"; +import type { I18n } from "keycloakify/account/i18n"; +import { type TemplateProps } from "keycloakify/account/TemplateProps"; + +export type PageProps = { + Template: LazyExoticComponent<(props: TemplateProps) => JSX.Element | null>; + kcContext: KcContext; + i18n: I18nExtended; + doUseDefaultCss: boolean; + classes?: Partial>; +}; + +export type ClassKey = "kcButtonClass" | "kcButtonPrimaryClass" | "kcButtonLargeClass" | "kcButtonDefaultClass"; + +export const defaultClasses: Record = { + /** password.ftl */ + "kcButtonClass": "btn", + "kcButtonPrimaryClass": "btn-primary", + "kcButtonLargeClass": "btn-lg", + "kcButtonDefaultClass": "btn-default" +}; diff --git a/src/account/pages/Password.tsx b/src/account/pages/Password.tsx new file mode 100644 index 00000000..d9cdcc52 --- /dev/null +++ b/src/account/pages/Password.tsx @@ -0,0 +1,102 @@ +import { clsx } from "keycloakify/tools/clsx"; +import { type PageProps, defaultClasses } from "keycloakify/account/pages/PageProps"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; +import type { KcContext } from "../kcContext"; +import type { I18n } from "../i18n"; + +export default function LogoutConfirm(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { getClassName } = useGetClassName({ + "defaultClasses": !doUseDefaultCss ? undefined : defaultClasses, + classes + }); + + const { password } = kcContext; + + const { msg } = i18n; + + return ( + + ); +} diff --git a/src/bin/keycloakify/generateFtl/ftl_object_to_js_code_declaring_an_object.ftl b/src/bin/keycloakify/generateFtl/ftl_object_to_js_code_declaring_an_object.ftl index 4952fdc3..192bb648 100644 --- a/src/bin/keycloakify/generateFtl/ftl_object_to_js_code_declaring_an_object.ftl +++ b/src/bin/keycloakify/generateFtl/ftl_object_to_js_code_declaring_an_object.ftl @@ -2,8 +2,7 @@ <#assign pageId="PAGE_ID_xIgLsPgGId9D8e"> (()=>{ - const out = -${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc}; + const out = ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc}; out["msg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); }; out["advancedMsg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); }; @@ -113,6 +112,13 @@ ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc}; out["pageId"] = "${pageId}"; + out["url"]["getLogoutUrl"] = function () { + <#attempt> + return "${url.getLogoutUrl()}"; + <#recover> + + }; + return out; })() diff --git a/src/bin/keycloakify/generateFtl/generateFtl.ts b/src/bin/keycloakify/generateFtl/generateFtl.ts index 5c28b731..0f5dd1ff 100644 --- a/src/bin/keycloakify/generateFtl/generateFtl.ts +++ b/src/bin/keycloakify/generateFtl/generateFtl.ts @@ -38,7 +38,7 @@ export const loginThemePageIds = [ "idp-review-user-profile.ftl" ] as const; -export const accountThemePageIds = ["password.ftl"] as const; +export const accountThemePageIds = ["password.ftl", "account.ftl"] as const; export type LoginThemePageId = (typeof loginThemePageIds)[number]; export type AccountThemePageId = (typeof accountThemePageIds)[number]; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..054789a7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export { createKeycloakAdapter } from "keycloakify/lib/keycloakJsAdapter"; diff --git a/src/index.tsx b/src/index.tsx deleted file mode 100644 index a256edf9..00000000 --- a/src/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import * as login from "./login"; - -export { login }; diff --git a/src/login/lib/keycloakJsAdapter.ts b/src/lib/keycloakJsAdapter.ts similarity index 100% rename from src/login/lib/keycloakJsAdapter.ts rename to src/lib/keycloakJsAdapter.ts diff --git a/src/login/lib/useGetClassName.ts b/src/lib/useGetClassName.ts similarity index 100% rename from src/login/lib/useGetClassName.ts rename to src/lib/useGetClassName.ts diff --git a/src/login/lib/usePrepareTemplate.ts b/src/lib/usePrepareTemplate.ts similarity index 76% rename from src/login/lib/usePrepareTemplate.ts rename to src/lib/usePrepareTemplate.ts index 8890b14c..05264505 100644 --- a/src/login/lib/usePrepareTemplate.ts +++ b/src/lib/usePrepareTemplate.ts @@ -12,9 +12,10 @@ export function usePrepareTemplate(params: { resourcesCommonPath: string; resourcesPath: string; }; - htmlClassName: string; + htmlClassName: string | undefined; + bodyClassName: string | undefined; }) { - const { doFetchDefaultThemeResources, stylesCommon, styles, url, scripts, htmlClassName } = params; + const { doFetchDefaultThemeResources, stylesCommon, styles, url, scripts, htmlClassName, bodyClassName } = params; const [isReady, setReady] = useReducer(() => true, !doFetchDefaultThemeResources); @@ -58,17 +59,35 @@ export function usePrepareTemplate(params: { }; }, []); + useSetClassName({ + "target": "html", + "className": htmlClassName + }); + + useSetClassName({ + "target": "body", + "className": bodyClassName + }); + + return { isReady }; +} + +function useSetClassName(params: { target: "html" | "body"; className: string | undefined }) { + const { target, className } = params; + useEffect(() => { + if (className === undefined) { + return; + } + const htmlClassList = document.getElementsByTagName("html")[0].classList; - const tokens = clsx(htmlClassName).split(" "); + const tokens = clsx(target).split(" "); htmlClassList.add(...tokens); return () => { htmlClassList.remove(...tokens); }; - }, [htmlClassName]); - - return { isReady }; + }, [className]); } diff --git a/src/login/Fallback.tsx b/src/login/Fallback.tsx index 7e6cfba8..80124987 100644 --- a/src/login/Fallback.tsx +++ b/src/login/Fallback.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from "react"; import type { PageProps } from "keycloakify/login/pages/PageProps"; -import type { I18n } from "keycloakify/login/i18n"; +import { assert, type Equals } from "tsafe/assert"; +import type { I18n } from "./i18n"; import type { KcContext } from "./kcContext"; const Login = lazy(() => import("keycloakify/login/pages/Login")); @@ -75,6 +76,7 @@ export default function Fallback(props: PageProps) { case "idp-review-user-profile.ftl": return ; } + assert>(false); })()} ); diff --git a/src/login/Template.tsx b/src/login/Template.tsx index 8c036f97..d188f7c1 100644 --- a/src/login/Template.tsx +++ b/src/login/Template.tsx @@ -1,8 +1,8 @@ import { assert } from "keycloakify/tools/assert"; import { clsx } from "keycloakify/tools/clsx"; -import { usePrepareTemplate } from "keycloakify/login/lib/usePrepareTemplate"; +import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate"; import { type TemplateProps, defaultTemplateClasses } from "keycloakify/login/TemplateProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "./kcContext"; import type { I18n } from "./i18n"; @@ -41,7 +41,8 @@ export default function Template(props: TemplateProps) { "lib/zocial/zocial.css" ], "styles": ["css/login.css"], - "htmlClassName": getClassName("kcHtmlClass") + "htmlClassName": getClassName("kcHtmlClass"), + "bodyClassName": undefined }); if (!isReady) { diff --git a/src/login/index.ts b/src/login/index.ts index 5b321467..153bec90 100644 --- a/src/login/index.ts +++ b/src/login/index.ts @@ -2,7 +2,6 @@ import Fallback from "keycloakify/login/Fallback"; export default Fallback; -export { createKeycloakAdapter } from "keycloakify/login/lib/keycloakJsAdapter"; export { useDownloadTerms } from "keycloakify/login/lib/useDownloadTerms"; export { getKcContext } from "keycloakify/login/kcContext/getKcContext"; export { createUseI18n } from "keycloakify/login/i18n/i18n"; diff --git a/src/login/kcContext/kcContextMocks.ts b/src/login/kcContext/kcContextMocks.ts index 3cd37e0a..f72b2ce4 100644 --- a/src/login/kcContext/kcContextMocks.ts +++ b/src/login/kcContext/kcContextMocks.ts @@ -117,7 +117,6 @@ export const kcContextCommonMock: KcContext.Common = { }, "messagesPerField": { "printIfExists": () => { - console.log("coucou"); return undefined; }, "existsError": () => false, diff --git a/src/login/pages/IdpReviewUserProfile.tsx b/src/login/pages/IdpReviewUserProfile.tsx index 9aebd82c..ec7b9725 100644 --- a/src/login/pages/IdpReviewUserProfile.tsx +++ b/src/login/pages/IdpReviewUserProfile.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { clsx } from "keycloakify/tools/clsx"; import { UserProfileFormFields } from "keycloakify/login/pages/shared/UserProfileCommons"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/Login.tsx b/src/login/pages/Login.tsx index 4c3f2aca..4f6497ce 100644 --- a/src/login/pages/Login.tsx +++ b/src/login/pages/Login.tsx @@ -2,7 +2,7 @@ import { useState, type FormEventHandler } from "react"; import { clsx } from "keycloakify/tools/clsx"; import { useConstCallback } from "keycloakify/tools/useConstCallback"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/LoginConfigTotp.tsx b/src/login/pages/LoginConfigTotp.tsx index 7c1bd67a..c51fc452 100644 --- a/src/login/pages/LoginConfigTotp.tsx +++ b/src/login/pages/LoginConfigTotp.tsx @@ -1,6 +1,6 @@ import { clsx } from "keycloakify/tools/clsx"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/LoginIdpLinkConfirm.tsx b/src/login/pages/LoginIdpLinkConfirm.tsx index d5ae1ca6..a5f3af4a 100644 --- a/src/login/pages/LoginIdpLinkConfirm.tsx +++ b/src/login/pages/LoginIdpLinkConfirm.tsx @@ -1,6 +1,6 @@ import { clsx } from "keycloakify/tools/clsx"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/LoginOtp.tsx b/src/login/pages/LoginOtp.tsx index 668acfc1..315a3d30 100644 --- a/src/login/pages/LoginOtp.tsx +++ b/src/login/pages/LoginOtp.tsx @@ -3,7 +3,7 @@ import { headInsert } from "keycloakify/tools/headInsert"; import { pathJoin } from "keycloakify/bin/tools/pathJoin"; import { clsx } from "keycloakify/tools/clsx"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/LoginPassword.tsx b/src/login/pages/LoginPassword.tsx index a34e0458..e6784946 100644 --- a/src/login/pages/LoginPassword.tsx +++ b/src/login/pages/LoginPassword.tsx @@ -3,7 +3,7 @@ import { clsx } from "keycloakify/tools/clsx"; import { useConstCallback } from "keycloakify/tools/useConstCallback"; import type { FormEventHandler } from "react"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/LoginResetPassword.tsx b/src/login/pages/LoginResetPassword.tsx index 3487d780..15ab17d6 100644 --- a/src/login/pages/LoginResetPassword.tsx +++ b/src/login/pages/LoginResetPassword.tsx @@ -1,6 +1,6 @@ import { clsx } from "keycloakify/tools/clsx"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/LoginUpdatePassword.tsx b/src/login/pages/LoginUpdatePassword.tsx index 5ecc9a97..fcd35f5e 100644 --- a/src/login/pages/LoginUpdatePassword.tsx +++ b/src/login/pages/LoginUpdatePassword.tsx @@ -1,6 +1,6 @@ import { clsx } from "keycloakify/tools/clsx"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/LoginUpdateProfile.tsx b/src/login/pages/LoginUpdateProfile.tsx index 1316a903..4f8c51bc 100644 --- a/src/login/pages/LoginUpdateProfile.tsx +++ b/src/login/pages/LoginUpdateProfile.tsx @@ -1,6 +1,6 @@ import { clsx } from "keycloakify/tools/clsx"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/LoginUsername.tsx b/src/login/pages/LoginUsername.tsx index 5e4d8cf0..ec192d13 100644 --- a/src/login/pages/LoginUsername.tsx +++ b/src/login/pages/LoginUsername.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { clsx } from "keycloakify/tools/clsx"; import { useConstCallback } from "keycloakify/tools/useConstCallback"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/LogoutConfirm.tsx b/src/login/pages/LogoutConfirm.tsx index 9e613d01..b03c7b09 100644 --- a/src/login/pages/LogoutConfirm.tsx +++ b/src/login/pages/LogoutConfirm.tsx @@ -1,6 +1,6 @@ import { clsx } from "keycloakify/tools/clsx"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/Register.tsx b/src/login/pages/Register.tsx index 7796eead..8ae5edc3 100644 --- a/src/login/pages/Register.tsx +++ b/src/login/pages/Register.tsx @@ -1,6 +1,6 @@ import { clsx } from "keycloakify/tools/clsx"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/RegisterUserProfile.tsx b/src/login/pages/RegisterUserProfile.tsx index 41069ea5..f873208c 100644 --- a/src/login/pages/RegisterUserProfile.tsx +++ b/src/login/pages/RegisterUserProfile.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { clsx } from "keycloakify/tools/clsx"; import { UserProfileFormFields } from "./shared/UserProfileCommons"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/Terms.tsx b/src/login/pages/Terms.tsx index 1888c7cc..dff76ab9 100644 --- a/src/login/pages/Terms.tsx +++ b/src/login/pages/Terms.tsx @@ -2,7 +2,7 @@ import { clsx } from "keycloakify/tools/clsx"; import { useRerenderOnStateChange } from "evt/hooks"; import { Markdown } from "keycloakify/tools/Markdown"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import { evtTermMarkdown } from "keycloakify/login/lib/useDownloadTerms"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/UpdateUserProfile.tsx b/src/login/pages/UpdateUserProfile.tsx index 2c92145b..1f28a3b7 100644 --- a/src/login/pages/UpdateUserProfile.tsx +++ b/src/login/pages/UpdateUserProfile.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { clsx } from "keycloakify/tools/clsx"; import { UserProfileFormFields } from "keycloakify/login/pages/shared/UserProfileCommons"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/login/pages/WebauthnAuthenticate.tsx b/src/login/pages/WebauthnAuthenticate.tsx index 4b9bc42e..92ff3e33 100644 --- a/src/login/pages/WebauthnAuthenticate.tsx +++ b/src/login/pages/WebauthnAuthenticate.tsx @@ -4,7 +4,7 @@ import type { MessageKey } from "keycloakify/login/i18n/i18n"; import { base64url } from "rfc4648"; import { useConstCallback } from "keycloakify/tools/useConstCallback"; import { type PageProps, defaultClasses } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/test/bin/main.ts b/test/bin/main.ts index 375955af..08be679b 100644 --- a/test/bin/main.ts +++ b/test/bin/main.ts @@ -16,6 +16,8 @@ import { getProjectRoot } from "keycloakify/bin/tools/getProjectRoot.js"; st.execSyncTrace(`node ${pathJoin(binDirPath, "initialize-email-theme")}`, { "cwd": sampleReactProjectDirPath }); + st.execSyncTrace(`node ${pathJoin(binDirPath, "download-builtin-keycloak-theme")}`, { "cwd": sampleReactProjectDirPath }); + st.execSyncTrace( //`node ${pathJoin(binDirPath, "keycloakify")} --external-assets`, `node ${pathJoin(binDirPath, "keycloakify")}`,