Compare commits

..

90 Commits

Author SHA1 Message Date
da1dc0309b Release candidate 2024-06-12 08:57:59 +02:00
30f4e7d833 Add PasswordPolicies on every page where there's user profile 2024-06-12 08:57:40 +02:00
cf3a86fb9b Release candidate 2024-06-11 21:22:34 +02:00
e1633f43f4 Apply same strategy for UserProfileFormField than for TempateProps for extendability 2024-06-11 21:21:58 +02:00
5b64cfc23c Release candidate 2024-06-11 20:50:31 +02:00
19709cf085 Only types are capitalized 2024-06-11 20:50:11 +02:00
b8bb6c4f02 Fix build 2024-06-11 20:40:00 +02:00
b7a543f8cb Do not export PageProps in the index 2024-06-11 20:30:39 +02:00
04b4e19563 Release candidate 2024-06-11 20:27:53 +02:00
ffb27fc66d Extract Props from UserProfileFormFields so it's ejectable 2024-06-11 20:27:03 +02:00
8b5f7eefda Release candidate 2024-06-11 19:14:19 +02:00
c750bf4ee8 Export PageProps 2024-06-11 19:14:04 +02:00
aa74019ef6 Fix build 2024-06-11 19:08:36 +02:00
9be6d9f75f Release candidate 2024-06-11 17:27:40 +02:00
81ebb9b552 Prevent the jar to be corrupted when rebuild 2024-06-11 17:19:36 +02:00
5e13b8c41f Exclude Keycloak 22 from test panel 2024-06-11 17:12:12 +02:00
dd1ed948ec Update Keycloak 25 default realm config 2024-06-11 16:26:03 +02:00
8b93f701cf Add realms configurations for Keycloak majors 2024-06-11 16:19:54 +02:00
2f0084de5b Pass the input options translation to the kcContext 2024-06-11 16:10:54 +02:00
2ef9828625 Start with keycloak 18 for local container 2024-06-11 11:39:03 +02:00
89db8983a7 Fix exception in terms.ftl 2024-06-11 11:37:45 +02:00
287dd9bd31 Refactor + attributes with options rendered by default as select inputs 2024-06-11 09:22:50 +02:00
9a92054c1a Remove unused dependency 2024-06-10 21:06:02 +02:00
4189036213 Fix storybook 2024-06-10 21:05:17 +02:00
2c0a427ba5 Fix the script to export realm 2024-06-10 20:51:00 +02:00
77b488d624 Fix the formatNumber function 2024-06-10 20:14:14 +02:00
5249e05746 Release candidate 2024-06-10 19:36:11 +02:00
1e7a0dd7a6 Enable to add files to the jar with the post build options 2024-06-10 19:35:56 +02:00
fd67f2402a Release candidate 2024-06-10 17:30:20 +02:00
60a65ede2f Preserve ordering on user attributes 2024-06-10 17:30:00 +02:00
1fa659ce61 Release candidate 2024-06-10 16:01:56 +02:00
0ab903dbc7 Add new build target for Kc 25 https://github.com/p2-inc/keycloak-account-v1/pull/13 2024-06-10 15:29:08 +02:00
70b0a04793 Release candidate 2024-06-10 15:08:46 +02:00
c0df9aa939 Remove logs 2024-06-10 09:32:07 +02:00
60a1886942 Fix path error 2024-06-10 09:28:31 +02:00
1ebf97871b Fix logical error 2024-06-10 09:26:47 +02:00
72e321aa32 Fix update of the build process checkpoint 2024-06-10 09:24:16 +02:00
b0f602b565 Fix post build script 2024-06-10 09:12:24 +02:00
84c774503d Build rework checkpoint 2024-06-10 07:57:12 +02:00
9bbc7cc651 Release candidate 2024-06-09 15:04:47 +02:00
458083fb6d Prettier stable generated code 2024-06-09 15:04:31 +02:00
8dcfc840b4 Remove useless 'as const' 2024-06-09 14:34:41 +02:00
9d06a3a6ad Release candidate 2024-06-09 14:33:42 +02:00
86cd08b954 Add missing file to the NPM bundle 2024-06-09 14:33:29 +02:00
144c3cc082 Release candidate 2024-06-09 11:53:41 +02:00
802cef41a6 Rename KcApp to KcPage 2024-06-09 11:53:25 +02:00
e128e8f0a9 Release candidate 2024-06-09 11:25:05 +02:00
8a25b93ab2 Rename Fallback to DefaultPage 2024-06-09 11:24:50 +02:00
7a040935e9 i18n need to be passed as props if we want to be able to ovewrite 2024-06-09 11:20:45 +02:00
2015882688 Avoid loop rebuild in watch mode 2024-06-09 10:28:06 +02:00
379301eb9d Release candidate 2024-06-09 09:50:27 +02:00
5d86b05cdb Fix eject-page script 2024-06-09 09:50:02 +02:00
73c99d3157 Fix scripts 2024-06-09 09:39:16 +02:00
acba197c94 Release candidate 2024-06-09 09:36:31 +02:00
2441d8ed8a Fix tests 2024-06-09 09:36:16 +02:00
9c123f37c8 Make of doMakeUserConfirmPassword a prop of UserProfileFormFields 2024-06-09 09:34:39 +02:00
b48dbd99cf Enable to pass a path to a file for exclusions #525 2024-06-09 09:20:55 +02:00
25c8599d8f Rename BuildOptions -> BuildContext 2024-06-09 09:15:45 +02:00
3453a17c15 Rename reactAppRootDirPath -> projectDirPath and reactAppBuildDirPath -> projectBuildDirPath 2024-06-09 09:03:43 +02:00
6e95dacd3a Remove todo 2024-06-09 08:52:52 +02:00
a286e252e9 Enable to pass environement variables to test docker container 2024-06-09 08:50:59 +02:00
a8997e92c3 Improve cache strategy for getKcClsx 2024-06-09 08:37:23 +02:00
89137153a0 Remove isStorybook util 2024-06-09 08:37:02 +02:00
e3382de8e0 Fix boolean logic error 2024-06-09 08:30:57 +02:00
1a48681591 getClassName -> kcClsx 2024-06-09 08:27:07 +02:00
8f006f0009 Apply the new way i18n is implemented to every pages 2024-06-09 04:43:18 +02:00
77e32aad2a Make useGetClassName not a hook 2024-06-08 18:54:27 +02:00
8d365dae53 Refactor i18n, make component use the hook directly 2024-06-08 17:55:05 +02:00
01fb89674c Refactor i18n so that we don't have to wait for translations to be downloaded to render the page 2024-06-08 15:50:04 +02:00
e3144adc61 Release candidate 2024-06-08 14:21:20 +02:00
c9fb0ca6ae Rename extention types 2024-06-08 14:20:56 +02:00
82d7e1371e Add code gen for environement variables an theme name 2024-06-08 14:02:07 +02:00
e1341dfdba Release candidate 2024-06-08 07:17:29 +02:00
7f917311d8 Export ClassKey in the index instead of PageProps 2024-06-08 07:17:11 +02:00
2bfb856f07 Inconsistency fix 2024-06-08 04:10:04 +02:00
702f52f1c9 Only add the lang annotation if the lang is different from the current 2024-06-07 08:05:35 +02:00
7ba8649940 Release candidate 2024-06-07 08:03:28 +02:00
485ca28a29 Enable the lang of the term to be undefined 2024-06-07 08:03:13 +02:00
33460afaf2 Release candidate 2024-06-07 02:20:27 +02:00
2421ac2c11 Make the user return the actual language of the terms for accesibility 2024-06-07 02:20:12 +02:00
f0cdb0b80b Fix build 2024-06-06 09:31:27 +02:00
2af953927e Release candidate 2024-06-06 09:14:27 +02:00
dcb9fbd0f7 fixes for the add-story command 2024-06-06 09:13:58 +02:00
5bc1f6479d fmt 2024-06-06 09:13:13 +02:00
f3e4bca468 Add script to copy over the stories 2024-06-06 07:41:01 +02:00
54645f5cff Update storybook setup for portability 2024-06-06 07:28:34 +02:00
a7f3e00821 Remove createKcContextMock from the index 2024-06-06 06:27:28 +02:00
108c281b0c Enable to eject Template.tsx and UserProfileFormFields.tsx 2024-06-06 06:12:05 +02:00
58892cbb56 Change first level build target of bin 2024-06-06 06:11:34 +02:00
dae1053ca8 Consistency with the starter 2024-06-06 06:10:41 +02:00
185 changed files with 14434 additions and 3012 deletions

View File

@ -7,13 +7,11 @@
background-color: #393939;
}
body.sb-show-preparing-docs > .sb-wrapper {
visibility: hidden;
}
body .sb-preparing-story {
visibility: hidden;
}
</style>

View File

@ -1,6 +1,6 @@
{
"name": "keycloakify",
"version": "10.0.0-rc.33",
"version": "10.0.0-rc.54",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
@ -44,6 +44,7 @@
"dist/bin/shared/constants.js",
"dist/bin/shared/constants.d.ts",
"dist/bin/shared/constants.js.map",
"dist/bin/shared/buildContext.d.ts",
"!dist/vite-plugin/",
"dist/vite-plugin/index.d.ts",
"dist/vite-plugin/vite-plugin.d.ts",
@ -109,7 +110,6 @@
"react-dom": "^18.2.0",
"recast": "^0.23.3",
"run-exclusive": "^2.2.19",
"scripting-tools": "^0.19.13",
"storybook-dark-mode": "^1.1.2",
"termost": "^0.12.0",
"ts-node": "^10.9.2",

View File

@ -52,7 +52,25 @@ transformCodebase({
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
patchDeprecatedBufferApiUsage(join("dist", "bin", "main.js"));
{
let hasBeenPatched = false;
fs.readdirSync(join("dist", "bin")).forEach(fileBasename => {
if (fileBasename !== "main.js" && !fileBasename.endsWith(".index.js")) {
return;
}
const { hasBeenPatched: hasBeenPatched_i } = patchDeprecatedBufferApiUsage(
join("dist", "bin", fileBasename)
);
if (hasBeenPatched_i) {
hasBeenPatched = true;
}
});
assert(hasBeenPatched);
}
fs.chmodSync(
join("dist", "bin", "main.js"),
@ -93,6 +111,10 @@ run(
)}`
);
fs.readdirSync(join("dist", "ncc_out")).forEach(fileBasename =>
assert(!fileBasename.endsWith(".index.js"))
);
transformCodebase({
srcDirPath: join("dist", "ncc_out"),
destDirPath: join("dist", "vite-plugin"),
@ -105,12 +127,30 @@ transformCodebase({
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
patchDeprecatedBufferApiUsage(join("dist", "vite-plugin", "index.js"));
{
const { hasBeenPatched } = patchDeprecatedBufferApiUsage(
join("dist", "vite-plugin", "index.js")
);
assert(hasBeenPatched);
}
fs.rmSync(join("dist", "src"), { recursive: true, force: true });
fs.cpSync("src", join("dist", "src"), { recursive: true });
transformCodebase({
srcDirPath: join("stories"),
destDirPath: join("dist", "stories"),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (!fileRelativePath.endsWith(".stories.tsx")) {
return undefined;
}
return { modifiedSourceCode: sourceCode };
}
});
console.log(chalk.green(`✓ built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`));
function run(command: string) {
@ -127,7 +167,9 @@ function patchDeprecatedBufferApiUsage(filePath: string) {
`var buffer = Buffer.allocUnsafe ? Buffer.allocUnsafe(toRead) : new Buffer(toRead);`
);
assert(after !== before, `Patch failed for ${relative(process.cwd(), filePath)}`);
fs.writeFileSync(filePath, Buffer.from(after, "utf8"));
const hasBeenPatched = after !== before;
return { hasBeenPatched };
}

View File

@ -3,40 +3,83 @@ import child_process from "child_process";
import { SemVer } from "../src/bin/tools/SemVer";
import { join as pathJoin, relative as pathRelative } from "path";
import chalk from "chalk";
import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
run(
[
`docker exec -it ${containerName}`,
`/opt/keycloak/bin/kc.sh export`,
`--dir /tmp`,
`--realm myrealm`,
`--users realm_file`
].join(" ")
);
(async () => {
{
const dCompleted = new Deferred<void>();
const keycloakMajorVersionNumber = SemVer.parse(
child_process
.execSync(`docker inspect --format '{{.Config.Image}}' ${containerName}`)
.toString("utf8")
.trim()
.split(":")[1]
).major;
const child = child_process.spawn("docker", [
...["exec", containerName],
...["/opt/keycloak/bin/kc.sh", "export"],
...["--dir", "/tmp"],
...["--realm", "myrealm"],
...["--users", "realm_file"]
]);
const targetFilePath = pathRelative(
process.cwd(),
pathJoin(
__dirname,
"..",
"src",
"bin",
"start-keycloak",
`myrealm-realm-${keycloakMajorVersionNumber}.json`
)
);
let output = "";
run(`docker cp ${containerName}:/tmp/myrealm-realm.json ${targetFilePath}`);
const onExit = (code: number | null) => {
dCompleted.reject(new Error(`Exited with code ${code}`));
};
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
child.on("exit", onExit);
child.stdout.on("data", data => {
const outputStr = data.toString("utf8");
if (outputStr.includes("Export finished successfully")) {
child.removeListener("exit", onExit);
child.kill();
dCompleted.resolve();
}
output += outputStr;
});
child.stderr.on("data", data => (output += chalk.red(data.toString("utf8"))));
try {
await dCompleted.pr;
} catch (error) {
assert(is<Error>(error));
console.log(chalk.red(error.message));
console.log(output);
process.exit(1);
}
}
const keycloakMajorVersionNumber = SemVer.parse(
child_process
.execSync(`docker inspect --format '{{.Config.Image}}' ${containerName}`)
.toString("utf8")
.trim()
.split(":")[1]
).major;
const targetFilePath = pathRelative(
process.cwd(),
pathJoin(
__dirname,
"..",
"src",
"bin",
"start-keycloak",
`myrealm-realm-${keycloakMajorVersionNumber}.json`
)
);
run(`docker cp ${containerName}:/tmp/myrealm-realm.json ${targetFilePath}`);
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
})();
function run(command: string) {
console.log(chalk.grey(`$ ${command}`));

View File

@ -26,7 +26,7 @@ async function main() {
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildOptions: {
buildContext: {
cacheDirPath: pathJoin(
thisCodebaseRootDirPath,
"node_modules",

View File

@ -28,7 +28,7 @@ export function startRebuildOnSrcChange() {
console.log(chalk.green("Watching for changes in src/"));
chokidar.watch("src", { ignoreInitial: true }).on("all", async () => {
chokidar.watch(["src", "stories"], { ignoreInitial: true }).on("all", async () => {
await waitForDebounce();
runYarnBuild();

View File

@ -1,8 +1,8 @@
import { lazy, Suspense } from "react";
import { assert, type Equals } from "tsafe/assert";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "./KcContext";
import type { I18n } from "./i18n";
import type { KcContext } from "keycloakify/account/KcContext";
import { I18n } from "keycloakify/account/i18n";
const Password = lazy(() => import("keycloakify/account/pages/Password"));
const Account = lazy(() => import("keycloakify/account/pages/Account"));
@ -12,7 +12,7 @@ const Applications = lazy(() => import("keycloakify/account/pages/Applications")
const Log = lazy(() => import("keycloakify/account/pages/Log"));
const FederatedIdentity = lazy(() => import("keycloakify/account/pages/FederatedIdentity"));
export default function Fallback(props: PageProps<KcContext, I18n>) {
export default function DefaultPage(props: PageProps<KcContext, I18n>) {
const { kcContext, ...rest } = props;
return (

View File

@ -4,20 +4,20 @@ import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
export type ExtendKcContext<
KcContextExtraProperties extends { properties?: Record<string, string | undefined> },
KcContextExtraPropertiesPerPage extends Record<string, Record<string, unknown>>
KcContextExtension extends { properties?: Record<string, string | undefined> },
KcContextExtensionPerPage extends Record<string, Record<string, unknown>>
> = ValueOf<{
[PageId in keyof KcContextExtraPropertiesPerPage | KcContext["pageId"]]: Extract<
[PageId in keyof KcContextExtensionPerPage | KcContext["pageId"]]: Extract<
KcContext,
{ pageId: PageId }
> extends never
? KcContext.Common &
KcContextExtraProperties & {
KcContextExtension & {
pageId: PageId;
} & KcContextExtraPropertiesPerPage[PageId]
} & KcContextExtensionPerPage[PageId]
: Extract<KcContext, { pageId: PageId }> &
KcContextExtraProperties &
KcContextExtraPropertiesPerPage[PageId];
KcContextExtension &
KcContextExtensionPerPage[PageId];
}>;
export type KcContext =

View File

@ -7,43 +7,32 @@ import { kcContextMocks, kcContextCommonMock } from "./kcContextMocks";
import { exclude } from "tsafe/exclude";
export function createGetKcContextMock<
KcContextExtraProperties extends { properties?: Record<string, string | undefined> },
KcContextExtraPropertiesPerPage extends Record<
`${string}.ftl`,
Record<string, unknown>
>
KcContextExtension extends { properties?: Record<string, string | undefined> },
KcContextExtensionPerPage extends Record<`${string}.ftl`, Record<string, unknown>>
>(params: {
kcContextExtraProperties: KcContextExtraProperties;
kcContextExtraPropertiesPerPage: KcContextExtraPropertiesPerPage;
overrides?: DeepPartial<KcContextExtraProperties & KcContextBase.Common>;
kcContextExtension: KcContextExtension;
kcContextExtensionPerPage: KcContextExtensionPerPage;
overrides?: DeepPartial<KcContextExtension & KcContextBase.Common>;
overridesPerPage?: {
[PageId in
| AccountThemePageId
| keyof KcContextExtraPropertiesPerPage]?: DeepPartial<
[PageId in AccountThemePageId | keyof KcContextExtensionPerPage]?: DeepPartial<
Extract<
ExtendKcContext<
KcContextExtraProperties,
KcContextExtraPropertiesPerPage
>,
ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>,
{ pageId: PageId }
>
>;
};
}) {
const {
kcContextExtraProperties,
kcContextExtraPropertiesPerPage,
kcContextExtension,
kcContextExtensionPerPage,
overrides: overrides_global,
overridesPerPage: overridesPerPage_global
} = params;
type KcContext = ExtendKcContext<
KcContextExtraProperties,
KcContextExtraPropertiesPerPage
>;
type KcContext = ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>;
function getKcContextMock<
PageId extends AccountThemePageId | keyof KcContextExtraPropertiesPerPage
PageId extends AccountThemePageId | keyof KcContextExtensionPerPage
>(params: {
pageId: PageId;
overrides?: DeepPartial<Extract<KcContext, { pageId: PageId }>>;
@ -58,8 +47,8 @@ export function createGetKcContextMock<
);
[
kcContextExtraProperties,
kcContextExtraPropertiesPerPage[pageId],
kcContextExtension,
kcContextExtensionPerPage[pageId],
overrides_global,
overridesPerPage_global?.[pageId],
overrides

View File

@ -1,17 +1,17 @@
import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import type { TemplateProps } from "keycloakify/account/TemplateProps";
import type { KcContext } from "./KcContext";
import type { I18n } from "./i18n";
import type { KcContext } from "./KcContext";
export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props;
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
@ -23,12 +23,12 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
useSetClassName({
qualifiedName: "html",
className: getClassName("kcHtmlClass")
className: kcClsx("kcHtmlClass")
});
useSetClassName({
qualifiedName: "body",
className: clsx("admin-console", "user", getClassName("kcBodyClass"))
className: clsx("admin-console", "user", kcClsx("kcBodyClass"))
});
useEffect(() => {

View File

@ -1,17 +1,13 @@
import type { ReactNode } from "react";
import type { KcContext } from "./KcContext";
import type { I18n } from "./i18n";
export type TemplateProps<
KcContext extends KcContext.Common,
I18nExtended extends I18n
> = {
export type TemplateProps<KcContext, I18n> = {
kcContext: KcContext;
i18n: I18nExtended;
i18n: I18n;
doUseDefaultCss: boolean;
active: string;
classes?: Partial<Record<ClassKey, string>>;
children: ReactNode;
active: string;
};
export type ClassKey =

View File

@ -1,8 +1,8 @@
import "keycloakify/tools/Object.fromEntries";
import { useEffect, useState, useRef } from "react";
import fallbackMessages from "./baseMessages/en";
import { getMessages } from "./baseMessages";
import { useEffect, useState } from "react";
import { assert } from "tsafe/assert";
import messages_fallbackLanguage from "./baseMessages/en";
import { getMessages } from "./baseMessages";
import type { KcContext } from "../KcContext";
import { Reflect } from "tsafe/Reflect";
@ -17,7 +17,7 @@ export type KcContextLike = {
assert<KcContext extends KcContextLike ? true : false>();
export type MessageKey = keyof typeof fallbackMessages;
export type MessageKey = keyof typeof messages_fallbackLanguage;
export type GenericI18n<MessageKey extends string> = {
/**
@ -79,166 +79,245 @@ export type GenericI18n<MessageKey extends string> = {
* See advancedMsg() but instead of returning a JSX.Element it returns a string.
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
};
export type I18n = GenericI18n<MessageKey>;
function createGetI18n<ExtraMessageKey extends string = never>(extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string } }) {
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
function getI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
use_cache: {
const cachedResult = cachedResultByKcContext.get(kcContext);
if (cachedResult === undefined) {
break use_cache;
}
return cachedResult;
}
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocalUrl" | "labelBySupportedLanguageTag"> = {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag,
getChangeLocalUrl: 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`);
return targetSupportedLocale.url;
},
labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]))
};
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey, ExtraMessageKey>({
messages_fallbackLanguage,
extraMessages_fallbackLanguage: extraMessages[fallbackLanguageTag],
extraMessages: extraMessages[partialI18n.currentLanguageTag]
});
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === fallbackLanguageTag;
const result: Result = {
i18n: {
...partialI18n,
...createI18nTranslationFunctions({ messages: undefined }),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages = await getMessages(partialI18n.currentLanguageTag);
const i18n_currentLanguage: I18n = {
...partialI18n,
...createI18nTranslationFunctions({ messages }),
isFetchingTranslations: false
};
// NOTE: This promise.resolve is just because without it we TypeScript
// gives a Variable 'result' is used before being assigned. error
await Promise.resolve().then(() => {
result.i18n = i18n_currentLanguage;
result.prI18n_currentLanguage = undefined;
});
return i18n_currentLanguage;
})()
};
cachedResultByKcContext.set(kcContext, result);
return result;
}
return { getI18n };
}
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
[languageTag: string]: { [key in ExtraMessageKey]: string };
}) {
function useI18n(params: { kcContext: KcContextLike }): GenericI18n<MessageKey | ExtraMessageKey> | null {
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
const { getI18n } = createGetI18n(extraMessages);
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params;
const [i18n, setI18n] = useState<GenericI18n<ExtraMessageKey | MessageKey> | undefined>(undefined);
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
const refHasStartedFetching = useRef(false);
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(i18n);
useEffect(() => {
if (refHasStartedFetching.current) {
return;
}
let isActive = true;
refHasStartedFetching.current = true;
(async () => {
const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {};
setI18n({
...createI18nTranslationFunctions({
fallbackMessages: {
...fallbackMessages,
...(extraMessages[fallbackLanguageTag] ?? {})
} as any,
messages: {
...(await getMessages(currentLanguageTag)),
...(extraMessages[currentLanguageTag] ?? {})
} as any
}),
currentLanguageTag,
getChangeLocalUrl: 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`);
return targetSupportedLocale.url;
},
labelBySupportedLanguageTag: Object.fromEntries(
(kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])
)
});
})();
}, []);
return i18n ?? null;
}
return {
useI18n,
ofTypeI18n: Reflect<GenericI18n<MessageKey | ExtraMessageKey>>()
};
}
function createI18nTranslationFunctions<MessageKey extends string>(params: {
fallbackMessages: Record<MessageKey, string>;
messages: Record<MessageKey, string>;
}): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const { fallbackMessages, messages } = params;
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];
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) {
prI18n_currentLanguage?.then(i18n => {
if (!isActive) {
return;
}
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
setI18n_toReturn(i18n);
});
return messageWithArgsInjected;
})();
return () => {
isActive = false;
};
}, []);
return doRenderAsHtml ? (
<span
// NOTE: The message is trusted. The arguments are not but are escaped.
dangerouslySetInnerHTML={{
__html: messageWithArgsInjectedIfAny
}}
/>
) : (
messageWithArgsInjectedIfAny
);
return { i18n: i18n_toReturn };
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
const { key, args, doRenderAsHtml } = props;
return { useI18n, ofTypeI18n: Reflect<I18n>() };
}
if (!/\$\{[^}]+\}/.test(key)) {
const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml });
function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraMessageKey extends string>(params: {
messages_fallbackLanguage: Record<MessageKey, string>;
extraMessages_fallbackLanguage: Record<ExtraMessageKey, string> | undefined;
extraMessages: Partial<Record<ExtraMessageKey, string>> | undefined;
}) {
const { extraMessages } = params;
if (resolvedMessage === undefined) {
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: key }} /> : key;
const messages_fallbackLanguage = {
...params.messages_fallbackLanguage,
...params.extraMessages_fallbackLanguage
};
function createI18nTranslationFunctions(params: {
messages: Partial<Record<MessageKey, string>> | undefined;
}): Pick<GenericI18n<MessageKey | ExtraMessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const messages = {
...params.messages,
...extraMessages
};
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] ?? (messages_fallbackLanguage as any)[key];
if (messageOrUndefined === undefined) {
return undefined;
}
return resolvedMessage;
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.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
});
return messageWithArgsInjected;
})();
return doRenderAsHtml ? (
<span
// NOTE: The message is trusted. The arguments are not but are escaped.
dangerouslySetInnerHTML={{
__html: messageWithArgsInjectedIfAny
}}
/>
) : (
messageWithArgsInjectedIfAny
);
}
let isFirstMatch = true;
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
const { key, args, doRenderAsHtml } = props;
const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => {
const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i;
if (!/\$\{[^}]+\}/.test(key)) {
const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml });
isFirstMatch = false;
if (resolvedMessage === undefined) {
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: key }} /> : key;
}
return replaceBy;
});
return resolvedMessage;
}
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: resolvedComplexMessage }} /> : resolvedComplexMessage;
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 doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: resolvedComplexMessage }} /> : resolvedComplexMessage;
}
return {
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,
doRenderAsHtml: true
}) as JSX.Element,
advancedMsgStr: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderAsHtml: false
}) as string
};
}
return {
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,
doRenderAsHtml: true
}) as JSX.Element,
advancedMsgStr: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderAsHtml: false
}) as string
};
return { createI18nTranslationFunctions };
}

View File

@ -1,2 +1,5 @@
export type { I18n } from "./i18n";
import type { GenericI18n, MessageKey, KcContextLike } from "./i18n";
export type { MessageKey, KcContextLike };
export type I18n = GenericI18n<MessageKey>;
export { createUseI18n } from "./i18n";
export { fallbackLanguageTag } from "./i18n";

View File

@ -1,4 +1,3 @@
export type { ExtendKcContext } from "keycloakify/account/KcContext";
export type { PageProps } from "keycloakify/account/pages/PageProps";
export { createGetKcContextMock } from "keycloakify/account/KcContext";
export type { ClassKey } from "keycloakify/account/TemplateProps";
export { createUseI18n } from "keycloakify/account/i18n";

View File

@ -1,7 +1,7 @@
import { createUseClassName } from "keycloakify/lib/useGetClassName";
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
import type { ClassKey } from "keycloakify/account/TemplateProps";
export const { useGetClassName } = createUseClassName<ClassKey>({
export const { getKcClsx } = createGetKcClsx<ClassKey>({
defaultClasses: {
kcHtmlClass: undefined,
kcBodyClass: undefined,
@ -19,3 +19,7 @@ export const { useGetClassName } = createUseClassName<ClassKey>({
"pf-c-form__helper-text pf-m-error required kc-feedback-text"
}
});
export type { ClassKey };
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];

View File

@ -1,18 +1,20 @@
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function Account(props: PageProps<Extract<KcContext, { pageId: "account.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { kcContext, i18n, doUseDefaultCss, Template } = props;
const { getClassName } = useGetClassName({
const classes = {
...props.classes,
kcBodyClass: clsx(props.classes?.kcBodyClass, "user")
};
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes: {
...classes,
kcBodyClass: clsx(classes?.kcBodyClass, "user")
}
classes
});
const { url, realm, messagesPerField, stateChecker, account, referrer } = kcContext;
@ -102,11 +104,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
{referrer !== undefined && <a href={referrer?.url}>{msg("backToApplication")}</a>}
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
name="submitAction"
value="Save"
>
@ -114,11 +112,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
</button>
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonLargeClass")}
name="submitAction"
value="Cancel"
>

View File

@ -1,5 +1,4 @@
import { clsx } from "keycloakify/tools/clsx";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -7,7 +6,7 @@ import type { I18n } from "../i18n";
export default function Applications(props: PageProps<Extract<KcContext, { pageId: "applications.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -114,7 +113,7 @@ export default function Applications(props: PageProps<Extract<KcContext, { pageI
application.additionalGrants.length > 0 ? (
<button
type="submit"
className={clsx(getClassName("kcButtonPrimaryClass"), getClassName("kcButtonClass"))}
className={kcClsx("kcButtonPrimaryClass", "kcButtonClass")}
id={`revoke-${application.client.clientId}`}
name="clientId"
value={application.client.id}

View File

@ -1,13 +1,13 @@
import type { Key } from "react";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { I18n } from "../i18n";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function Log(props: PageProps<Extract<KcContext, { pageId: "log.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -18,7 +18,7 @@ export default function Log(props: PageProps<Extract<KcContext, { pageId: "log.f
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="log">
<div className={getClassName("kcContentWrapperClass")}>
<div className={kcClsx("kcContentWrapperClass")}>
<div className="col-md-10">
<h2>{msg("accountLogHtmlTitle")}</h2>
</div>

View File

@ -1,12 +1,10 @@
import type { TemplateProps, ClassKey } from "keycloakify/account/TemplateProps";
import { type TemplateProps, type ClassKey } from "keycloakify/account/TemplateProps";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { I18n } from "../i18n";
import type { KcContext } from "../KcContext";
export type PageProps<NarrowedKcContext = KcContext, I18nExtended extends I18n = I18n> = {
export type PageProps<NarrowedKcContext, I18n> = {
Template: LazyOrNot<(props: TemplateProps<any, any>) => JSX.Element | null>;
kcContext: NarrowedKcContext;
i18n: I18nExtended;
i18n: I18n;
doUseDefaultCss: boolean;
classes?: Partial<Record<ClassKey, string>>;
};

View File

@ -1,19 +1,21 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function Password(props: PageProps<Extract<KcContext, { pageId: "password.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { kcContext, i18n, doUseDefaultCss, Template } = props;
const { getClassName } = useGetClassName({
const classes = {
...props.classes,
kcBodyClass: clsx(props.classes?.kcBodyClass, "password")
};
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes: {
...classes,
kcBodyClass: clsx(classes?.kcBodyClass, "password")
}
classes
});
const { url, password, account, stateChecker } = kcContext;
@ -192,11 +194,7 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
<button
disabled={newPasswordError !== "" || newPasswordConfirmError !== ""}
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
name="submitAction"
value="Save"
>

View File

@ -1,5 +1,4 @@
import { clsx } from "keycloakify/tools/clsx";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -7,7 +6,7 @@ import type { I18n } from "../i18n";
export default function Sessions(props: PageProps<Extract<KcContext, { pageId: "sessions.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -17,7 +16,7 @@ export default function Sessions(props: PageProps<Extract<KcContext, { pageId: "
const { msg } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="sessions">
<div className={getClassName("kcContentWrapperClass")}>
<div className={kcClsx("kcContentWrapperClass")}>
<div className="col-md-10">
<h2>{msg("sessionsHtmlTitle")}</h2>
</div>
@ -56,7 +55,7 @@ export default function Sessions(props: PageProps<Extract<KcContext, { pageId: "
<form action={url.sessionsUrl} method="post">
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
<button id="logout-all-sessions" type="submit" className={clsx(getClassName("kcButtonDefaultClass"), getClassName("kcButtonClass"))}>
<button id="logout-all-sessions" type="submit" className={kcClsx("kcButtonDefaultClass", "kcButtonClass")}>
{msg("doLogOutAllSessions")}
</button>
</form>

View File

@ -1,5 +1,5 @@
import { clsx } from "keycloakify/tools/clsx";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -7,7 +7,7 @@ import type { I18n } from "../i18n";
export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -140,9 +140,9 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
</li>
</ol>
<hr />
<form action={url.totpUrl} className={getClassName("kcFormClass")} id="kc-totp-settings-form" method="post">
<form action={url.totpUrl} className={kcClsx("kcFormClass")} id="kc-totp-settings-form" method="post">
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
<div className={getClassName("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<div className="col-sm-2 col-md-2">
<label htmlFor="totp" className="control-label">
{msg("authenticatorCode")}
@ -155,12 +155,12 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
id="totp"
name="totp"
autoComplete="off"
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={messagesPerField.existsError("totp")}
/>
{messagesPerField.existsError("totp") && (
<span id="input-error-otp-code" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
<span id="input-error-otp-code" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("totp")}
</span>
)}
@ -169,9 +169,9 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
{mode && <input type="hidden" id="mode" value={mode} />}
</div>
<div className={getClassName("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<div className="col-sm-2 col-md-2">
<label htmlFor="userLabel" className={getClassName("kcLabelClass")}>
<label htmlFor="userLabel" className={kcClsx("kcLabelClass")}>
{msg("totpDeviceName")}
</label>
{totp.otpCredentials.length >= 1 && <span className="required">*</span>}
@ -182,37 +182,28 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
id="userLabel"
name="userLabel"
autoComplete="off"
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={messagesPerField.existsError("userLabel")}
/>
{messagesPerField.existsError("userLabel") && (
<span id="input-error-otp-label" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
<span id="input-error-otp-label" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("userLabel")}
</span>
)}
</div>
</div>
<div id="kc-form-buttons" className={clsx(getClassName("kcFormGroupClass"), "text-right")}>
<div className={getClassName("kcInputWrapperClass")}>
<div id="kc-form-buttons" className={clsx(kcClsx("kcFormGroupClass"), "text-right")}>
<div className={kcClsx("kcInputWrapperClass")}>
<input
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
id="saveTOTPBtn"
value={msgStr("doSave")}
/>
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonLargeClass", "kcButtonLargeClass")}
id="cancelTOTPBtn"
name="submitAction"
value="Cancel"

109
src/bin/add-story.ts Normal file
View File

@ -0,0 +1,109 @@
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
import cliSelect from "cli-select";
import {
loginThemePageIds,
accountThemePageIds,
type LoginThemePageId,
type AccountThemePageId,
themeTypes,
type ThemeType
} from "./shared/constants";
import { capitalize } from "tsafe/capitalize";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import { getThemeSrcDirPath } from "./shared/getThemeSrcDirPath";
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import chalk from "chalk";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildContext = getBuildContext({
cliCommandOptions
});
console.log(chalk.cyan("Theme type:"));
const { value: themeType } = await cliSelect<ThemeType>({
values: [...themeTypes]
}).catch(() => {
process.exit(-1);
});
console.log(`${themeType}`);
console.log(chalk.cyan("Select the page you want to create a Storybook for:"));
const { value: pageId } = await cliSelect<LoginThemePageId | AccountThemePageId>({
values: (() => {
switch (themeType) {
case "login":
return [...loginThemePageIds];
case "account":
return [...accountThemePageIds];
}
assert<Equals<typeof themeType, never>>(false);
})()
}).catch(() => {
process.exit(-1);
});
console.log(`${pageId}`);
const { themeSrcDirPath } = getThemeSrcDirPath({
projectDirPath: buildContext.projectDirPath
});
const componentBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(
/ftl$/,
"stories.tsx"
);
const targetFilePath = pathJoin(
themeSrcDirPath,
themeType,
"pages",
componentBasename
);
if (fs.existsSync(targetFilePath)) {
console.log(`${pathRelative(process.cwd(), targetFilePath)} already exists`);
process.exit(-1);
}
const componentCode = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
"stories",
themeType,
"pages",
componentBasename
)
)
.toString("utf8")
.replace('import React from "react";\n', "");
{
const targetDirPath = pathDirname(targetFilePath);
if (!fs.existsSync(targetDirPath)) {
fs.mkdirSync(targetDirPath, { recursive: true });
}
}
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
console.log(
[
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
)} copy pasted from the Keycloakify source code into your project`,
`You can start storybook with ${chalk.bold("yarn storybook")}`
].join("\n")
);
}

View File

@ -1,13 +1,13 @@
import { copyKeycloakResourcesToPublic } from "./shared/copyKeycloakResourcesToPublic";
import { readBuildOptions } from "./shared/buildOptions";
import { getBuildContext } from "./shared/buildContext";
import type { CliCommandOptions } from "./main";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({ cliCommandOptions });
const buildContext = getBuildContext({ cliCommandOptions });
await copyKeycloakResourcesToPublic({
buildOptions
buildContext
});
}

View File

@ -1,63 +0,0 @@
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
import { readBuildOptions } from "./shared/buildOptions";
import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTheme";
import { transformCodebase } from "./tools/transformCodebase";
import type { CliCommandOptions } from "./main";
import chalk from "chalk";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({
cliCommandOptions
});
console.log(
chalk.cyan(
"Select the Keycloak version from which you want to download the builtins theme:"
)
);
const { keycloakVersion } = await promptKeycloakVersion({
startingFromMajor: undefined,
cacheDirPath: buildOptions.cacheDirPath
});
console.log(`${keycloakVersion}`);
const destDirPath = pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme"
);
console.log(
[
`Downloading builtins theme of Keycloak ${keycloakVersion} here:`,
`- ${chalk.bold(
`.${pathSep}${pathJoin(pathRelative(process.cwd(), destDirPath), "base")}`
)}`,
`- ${chalk.bold(
`.${pathSep}${pathJoin(
pathRelative(process.cwd(), destDirPath),
"keycloak"
)}`
)}`
].join("\n")
);
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildOptions
});
transformCodebase({
srcDirPath: defaultThemeDirPath,
destDirPath
});
console.log(chalk.green(`✓ done`));
}

View File

@ -17,13 +17,13 @@ import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import { getThemeSrcDirPath } from "./shared/getThemeSrcDirPath";
import type { CliCommandOptions } from "./main";
import { readBuildOptions } from "./shared/buildOptions";
import { getBuildContext } from "./shared/buildContext";
import chalk from "chalk";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({
const buildContext = getBuildContext({
cliCommandOptions
});
@ -39,13 +39,26 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(chalk.cyan("Select the page you want to customize:"));
const { value: pageId } = await cliSelect<LoginThemePageId | AccountThemePageId>({
const templateValue = "Template.tsx (Layout common to every page)";
const userProfileFormFieldsValue =
"UserProfileFormFields.tsx (Renders the form of the register.ftl, login-update-profile.ftl, update-email.ftl and idp-review-user-profile.ftl)";
const { value: pageIdOrComponent } = await cliSelect<
| LoginThemePageId
| AccountThemePageId
| typeof templateValue
| typeof userProfileFormFieldsValue
>({
values: (() => {
switch (themeType) {
case "login":
return [...loginThemePageIds];
return [
templateValue,
userProfileFormFieldsValue,
...loginThemePageIds
];
case "account":
return [...accountThemePageIds];
return [templateValue, ...accountThemePageIds];
}
assert<Equals<typeof themeType, never>>(false);
})()
@ -53,27 +66,45 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
process.exit(-1);
});
console.log(`${pageId}`);
const componentPageBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(
/ftl$/,
"tsx"
);
console.log(`${pageIdOrComponent}`);
const { themeSrcDirPath } = getThemeSrcDirPath({
reactAppRootDirPath: buildOptions.reactAppRootDirPath
projectDirPath: buildContext.projectDirPath
});
const componentBasename = (() => {
if (pageIdOrComponent === templateValue) {
return "Template.tsx";
}
if (pageIdOrComponent === userProfileFormFieldsValue) {
return "UserProfileFormFields.tsx";
}
return capitalize(kebabCaseToCamelCase(pageIdOrComponent)).replace(/ftl$/, "tsx");
})();
const pagesOrDot = (() => {
if (
pageIdOrComponent === templateValue ||
pageIdOrComponent === userProfileFormFieldsValue
) {
return ".";
}
return "pages";
})();
const targetFilePath = pathJoin(
themeSrcDirPath,
themeType,
"pages",
componentPageBasename
pagesOrDot,
componentBasename
);
if (fs.existsSync(targetFilePath)) {
console.log(
`${pageId} is already ejected, ${pathRelative(
`${pageIdOrComponent} is already ejected, ${pathRelative(
process.cwd(),
targetFilePath
)} already exists`
@ -82,6 +113,18 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
process.exit(-1);
}
const componentCode = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
themeType,
pagesOrDot,
componentBasename
)
)
.toString("utf8");
{
const targetDirPath = pathDirname(targetFilePath);
@ -90,28 +133,66 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
}
}
const componentPageContent = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
themeType,
"pages",
componentPageBasename
)
)
.toString("utf8");
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
fs.writeFileSync(targetFilePath, Buffer.from(componentPageContent, "utf8"));
console.log(
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
)} copy pasted from the Keycloakify source code into your project`
);
edit_KcApp: {
if (
pageIdOrComponent !== templateValue &&
pageIdOrComponent !== userProfileFormFieldsValue
) {
break edit_KcApp;
}
const kcAppTsxPath = pathJoin(themeSrcDirPath, themeType, "KcPage.tsx");
const kcAppTsxCode = fs.readFileSync(kcAppTsxPath).toString("utf8");
const modifiedKcAppTsxCode = (() => {
switch (pageIdOrComponent) {
case templateValue:
return kcAppTsxCode.replace(
`keycloakify/${themeType}/Template`,
"./Template"
);
case userProfileFormFieldsValue:
return kcAppTsxCode.replace(
`keycloakify/login/UserProfileFormFields`,
"./UserProfileFormFields"
);
}
assert<Equals<typeof pageIdOrComponent, never>>(false);
})();
if (kcAppTsxCode === modifiedKcAppTsxCode) {
console.log(
chalk.red(
"Unable to automatically update KcPage.tsx, please update it manually"
)
);
return;
}
fs.writeFileSync(kcAppTsxPath, Buffer.from(modifiedKcAppTsxCode, "utf8"));
console.log(
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), kcAppTsxPath))
)} Updated`
);
return;
}
const userProfileFormFieldComponentName = "UserProfileFormFields";
console.log(
[
``,
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
)} copy pasted from the Keycloakify source code into your project`,
``,
`You now need to update your page router:`,
``,
@ -120,21 +201,21 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
".",
pathRelative(process.cwd(), themeSrcDirPath),
themeType,
"KcApp.tsx"
"KcPage.tsx"
)
)}:`,
chalk.grey("```"),
`// ...`,
``,
chalk.green(
`+const ${componentPageBasename.replace(
`+const ${componentBasename.replace(
/.tsx$/,
""
)} = lazy(() => import("./pages/${componentPageBasename}"));`
)} = lazy(() => import("./pages/${componentBasename}"));`
),
...[
``,
` export default function KcApp(props: { kcContext: KcContext; }) {`,
` export default function KcPage(props: { kcContext: KcContext; }) {`,
``,
` // ...`,
``,
@ -143,16 +224,17 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
` {(() => {`,
` switch (kcContext.pageId) {`,
` // ...`,
`+ case "${pageId}": return (`,
`+ <Login`,
`+ case "${pageIdOrComponent}": return (`,
`+ <${componentBasename}`,
`+ {...{ kcContext, i18n, classes }}`,
`+ Template={Template}`,
...(!componentPageContent.includes(userProfileFormFieldComponentName)
`+ doUseDefaultCss={true}`,
...(!componentCode.includes(userProfileFormFieldComponentName)
? []
: [
`+ ${userProfileFormFieldComponentName}={${userProfileFormFieldComponentName}}`
`+ ${userProfileFormFieldComponentName}={${userProfileFormFieldComponentName}}`,
`+ doMakeUserConfirmPassword={doMakeUserConfirmPassword}`
]),
`+ doUseDefaultCss={true}`,
`+ />`,
`+ );`,
` default: return <Fallback /* .. */ />;`,

View File

@ -2,7 +2,7 @@ import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTh
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
import { readBuildOptions } from "./shared/buildOptions";
import { getBuildContext } from "./shared/buildContext";
import * as fs from "fs";
import { getThemeSrcDirPath } from "./shared/getThemeSrcDirPath";
import type { CliCommandOptions } from "./main";
@ -10,10 +10,10 @@ import type { CliCommandOptions } from "./main";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({ cliCommandOptions });
const buildContext = getBuildContext({ cliCommandOptions });
const { themeSrcDirPath } = getThemeSrcDirPath({
reactAppRootDirPath: buildOptions.reactAppRootDirPath
projectDirPath: buildContext.projectDirPath
});
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
@ -34,12 +34,13 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const { keycloakVersion } = await promptKeycloakVersion({
// NOTE: This is arbitrary
startingFromMajor: 17,
cacheDirPath: buildOptions.cacheDirPath
excludeMajorVersions: [],
cacheDirPath: buildContext.cacheDirPath
});
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildOptions
buildContext
});
transformCodebase({

View File

@ -5,12 +5,12 @@ import type {
} from "./extensionVersions";
import { join as pathJoin, dirname as pathDirname } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import * as fs from "fs/promises";
import { accountV1ThemeName } from "../../shared/constants";
import {
generatePom,
BuildOptionsLike as BuildOptionsLike_generatePom
BuildContextLike as BuildContextLike_generatePom
} from "./generatePom";
import { readFileSync } from "fs";
import { isInside } from "../../tools/isInside";
@ -18,7 +18,7 @@ import child_process from "child_process";
import { rmSync } from "../../tools/fs.rmSync";
import { getMetaInfKeycloakThemesJsonFilePath } from "../../shared/metaInfKeycloakThemes";
export type BuildOptionsLike = BuildOptionsLike_generatePom & {
export type BuildContextLike = BuildContextLike_generatePom & {
keycloakifyBuildDirPath: string;
themeNames: string[];
artifactId: string;
@ -26,57 +26,34 @@ export type BuildOptionsLike = BuildOptionsLike_generatePom & {
cacheDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function buildJar(params: {
jarFileBasename: string;
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
buildOptions: BuildOptionsLike;
resourcesDirPath: string;
buildContext: BuildContextLike;
}): Promise<void> {
const {
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
buildOptions
resourcesDirPath,
buildContext
} = params;
const keycloakifyBuildTmpDirPath = pathJoin(
buildOptions.cacheDirPath,
buildContext.cacheDirPath,
jarFileBasename.replace(".jar", "")
);
rmSync(keycloakifyBuildTmpDirPath, { recursive: true, force: true });
{
const transformCodebase_common = (params: {
fileRelativePath: string;
sourceCode: Buffer;
}): { modifiedSourceCode: Buffer } | undefined => {
const { fileRelativePath, sourceCode } = params;
if (
fileRelativePath ===
getMetaInfKeycloakThemesJsonFilePath({ keycloakifyBuildDirPath: "." })
) {
return { modifiedSourceCode: sourceCode };
}
for (const themeName of [...buildOptions.themeNames, accountV1ThemeName]) {
if (
isInside({
dirPath: pathJoin("src", "main", "resources", "theme", themeName),
filePath: fileRelativePath
})
) {
return { modifiedSourceCode: sourceCode };
}
}
return undefined;
};
const transformCodebase_patchForUsingBuiltinAccountV1 =
transformCodebase({
srcDirPath: resourcesDirPath,
destDirPath: pathJoin(keycloakifyBuildTmpDirPath, "src", "main", "resources"),
transformSourceCode:
keycloakAccountV1Version !== null
? undefined
: (params: {
@ -87,13 +64,7 @@ export async function buildJar(params: {
if (
isInside({
dirPath: pathJoin(
"src",
"main",
"resources",
"theme",
accountV1ThemeName
),
dirPath: pathJoin("theme", accountV1ThemeName),
filePath: fileRelativePath
})
) {
@ -103,7 +74,7 @@ export async function buildJar(params: {
if (
fileRelativePath ===
getMetaInfKeycloakThemesJsonFilePath({
keycloakifyBuildDirPath: "."
resourcesDirPath: "."
})
) {
const keycloakThemesJsonParsed = JSON.parse(
@ -125,18 +96,10 @@ export async function buildJar(params: {
};
}
for (const themeName of buildOptions.themeNames) {
for (const themeName of buildContext.themeNames) {
if (
fileRelativePath ===
pathJoin(
"src",
"main",
"resources",
"theme",
themeName,
"account",
"theme.properties"
)
pathJoin("theme", themeName, "account", "theme.properties")
) {
const modifiedSourceCode = Buffer.from(
sourceCode
@ -157,31 +120,8 @@ export async function buildJar(params: {
}
return { modifiedSourceCode: sourceCode };
};
transformCodebase({
srcDirPath: buildOptions.keycloakifyBuildDirPath,
destDirPath: keycloakifyBuildTmpDirPath,
transformSourceCode: params => {
const resultCommon = transformCodebase_common(params);
if (transformCodebase_patchForUsingBuiltinAccountV1 === undefined) {
return resultCommon;
}
if (resultCommon === undefined) {
return undefined;
}
const { modifiedSourceCode } = resultCommon;
return transformCodebase_patchForUsingBuiltinAccountV1({
...params,
sourceCode: modifiedSourceCode
});
}
});
}
}
});
route_legacy_pages: {
// NOTE: If there's no account theme there is no special target for keycloak 24 and up so we create
@ -203,7 +143,7 @@ export async function buildJar(params: {
}
(["register.ftl", "login-update-profile.ftl"] as const).forEach(pageId =>
buildOptions.themeNames.map(themeName => {
buildContext.themeNames.map(themeName => {
const ftlFilePath = pathJoin(
keycloakifyBuildTmpDirPath,
"src",
@ -244,7 +184,7 @@ export async function buildJar(params: {
{
const { pomFileCode } = generatePom({
buildOptions,
buildContext,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
});
@ -285,9 +225,9 @@ export async function buildJar(params: {
pathJoin(
keycloakifyBuildTmpDirPath,
"target",
`${buildOptions.artifactId}-${buildOptions.themeVersion}.jar`
`${buildContext.artifactId}-${buildContext.themeVersion}.jar`
),
pathJoin(buildOptions.keycloakifyBuildDirPath, jarFileBasename)
pathJoin(buildContext.keycloakifyBuildDirPath, jarFileBasename)
);
rmSync(keycloakifyBuildTmpDirPath, { recursive: true });

View File

@ -5,25 +5,27 @@ import {
keycloakThemeAdditionalInfoExtensionVersions
} from "./extensionVersions";
import { getKeycloakVersionRangeForJar } from "./getKeycloakVersionRangeForJar";
import { buildJar, BuildOptionsLike as BuildOptionsLike_buildJar } from "./buildJar";
import type { BuildOptions } from "../../shared/buildOptions";
import { buildJar, BuildContextLike as BuildContextLike_buildJar } from "./buildJar";
import type { BuildContext } from "../../shared/buildContext";
import { getJarFileBasename } from "../../shared/getJarFileBasename";
import { readMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
import { readMetaInfKeycloakThemes_fromResourcesDirPath } from "../../shared/metaInfKeycloakThemes";
import { accountV1ThemeName } from "../../shared/constants";
export type BuildOptionsLike = BuildOptionsLike_buildJar & {
export type BuildContextLike = BuildContextLike_buildJar & {
keycloakifyBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function buildJars(params: {
buildOptions: BuildOptionsLike;
resourcesDirPath: string;
onlyBuildJarFileBasename: string | undefined;
buildContext: BuildContextLike;
}): Promise<void> {
const { buildOptions } = params;
const { onlyBuildJarFileBasename, resourcesDirPath, buildContext } = params;
const doesImplementAccountTheme = readMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildOptions.keycloakifyBuildDirPath
const doesImplementAccountTheme = readMetaInfKeycloakThemes_fromResourcesDirPath({
resourcesDirPath
}).themes.some(({ name }) => name === accountV1ThemeName);
await Promise.all(
@ -56,12 +58,20 @@ export async function buildJars(params: {
keycloakVersionRange
});
if (
onlyBuildJarFileBasename !== undefined &&
onlyBuildJarFileBasename !== jarFileBasename
) {
return undefined;
}
return {
keycloakThemeAdditionalInfoExtensionVersion,
jarFileBasename
};
}
)
.filter(exclude(undefined))
.map(
({
keycloakThemeAdditionalInfoExtensionVersion,
@ -71,7 +81,8 @@ export async function buildJars(params: {
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
buildOptions
resourcesDirPath,
buildContext
})
)
)

View File

@ -1,5 +1,5 @@
// NOTE: v0.5 is a dummy version.
export const keycloakAccountV1Versions = [null, "0.3", "0.4"] as const;
export const keycloakAccountV1Versions = [null, "0.3", "0.4", "0.6"] as const;
/**
* https://central.sonatype.com/artifact/io.phasetwo.keycloak/keycloak-account-v1

View File

@ -1,27 +1,27 @@
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import type {
KeycloakAccountV1Version,
KeycloakThemeAdditionalInfoExtensionVersion
} from "./extensionVersions";
export type BuildOptionsLike = {
export type BuildContextLike = {
groupId: string;
artifactId: string;
themeVersion: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function generatePom(params: {
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}) {
const {
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
buildOptions
buildContext
} = params;
const { pomFileCode } = (function generatePomFileCode(): {
@ -33,10 +33,10 @@ export function generatePom(params: {
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
` <modelVersion>4.0.0</modelVersion>`,
` <groupId>${buildOptions.groupId}</groupId>`,
` <artifactId>${buildOptions.artifactId}</artifactId>`,
` <version>${buildOptions.themeVersion}</version>`,
` <name>${buildOptions.artifactId}</name>`,
` <groupId>${buildContext.groupId}</groupId>`,
` <artifactId>${buildContext.artifactId}</artifactId>`,
` <version>${buildContext.themeVersion}</version>`,
` <name>${buildContext.artifactId}</name>`,
` <description />`,
` <packaging>jar</packaging>`,
` <properties>`,

View File

@ -44,12 +44,20 @@ export function getKeycloakVersionRangeForJar(params: {
case null:
return undefined;
case "1.1.5":
return "24-and-above" as const;
return "24" as const;
}
assert<
Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>
>(false);
case "0.6":
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return undefined;
case "1.1.5":
return "25-and-above" as const;
}
}
assert<Equals<typeof keycloakAccountV1Version, never>>(false);
})();
assert<
@ -65,7 +73,6 @@ export function getKeycloakVersionRangeForJar(params: {
if (keycloakAccountV1Version !== null) {
return undefined;
}
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return "21-and-below";

View File

@ -180,10 +180,42 @@ try {
<#if attribute.annotations.inputTypePlaceholder??>
"${attribute.annotations.inputTypePlaceholder}": decodeHtmlEntities("${advancedMsg(attribute.annotations.inputTypePlaceholder)?js_string}"),
</#if>
<!-- Loop through the options that are in attribute.validators.options.options -->
<#if (
attribute.annotations.inputOptionLabelsI18nPrefix?? &&
attribute.validators?? &&
attribute.validators.options??
)>
<#list attribute.validators.options.options as option>
"${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}": decodeHtmlEntities("${msg(attribute.annotations.inputOptionLabelsI18nPrefix + "." + option)?js_string}"),
</#list>
</#if>
</#list>
};
</#if>
attributes_to_attributesByName: {
if( !out["profile"] ){
break attributes_to_attributesByName;
}
if( !out["profile"]["attributes"] ){
break attributes_to_attributesByName;
}
var attributes = out["profile"]["attributes"];
delete out["profile"]["attributes"];
out["profile"]["attributesByName"] = {};
attributes.forEach(function(attribute){
out["profile"]["attributesByName"][attribute.name] = attribute;
});
}
return out;
function decodeHtmlEntities(htmlStr){
@ -287,8 +319,8 @@ function decodeHtmlEntities(htmlStr){
key == "realmAttributes" &&
are_same_path(path, [])
) || (
<#-- attributesByName adds a lot of noise to the output and is not needed -->
key == "attributes" &&
<#-- attributesByName adds a lot of noise to the output and is not needed, we already have profile.attributes -->
key == "attributesByName" &&
are_same_path(path, ["profile"])
) || (
<#-- We already have the attributes in profile speedup the rendering by filtering it out from the register object -->
@ -304,7 +336,7 @@ function decodeHtmlEntities(htmlStr){
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
<#if (
["register.ftl", "register-user-profile.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
["register.ftl", "register-user-profile.ftl", "terms.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
key == "attemptedUsername" && are_same_path(path, ["auth"])
)>
<#attempt>

View File

@ -4,7 +4,7 @@ import { generateCssCodeToDefineGlobals } from "../replacers/replaceImportsInCss
import { replaceImportsInInlineCssCode } from "../replacers/replaceImportsInInlineCssCode";
import * as fs from "fs";
import { join as pathJoin } from "path";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import {
type ThemeType,
@ -15,22 +15,22 @@ import {
} from "../../shared/constants";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
export type BuildOptionsLike = {
export type BuildContextLike = {
bundler: "vite" | "webpack";
themeVersion: string;
urlPathname: string | undefined;
reactAppBuildDirPath: string;
projectBuildDirPath: string;
assetsDirPath: string;
kcContextExclusionsFtlCode: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function generateFtlFilesCodeFactory(params: {
themeName: string;
indexHtmlCode: string;
cssGlobalsToDefine: Record<string, string>;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
keycloakifyVersion: string;
themeType: ThemeType;
fieldNames: string[];
@ -39,7 +39,7 @@ export function generateFtlFilesCodeFactory(params: {
themeName,
cssGlobalsToDefine,
indexHtmlCode,
buildOptions,
buildContext,
keycloakifyVersion,
themeType,
fieldNames
@ -55,7 +55,7 @@ export function generateFtlFilesCodeFactory(params: {
const { fixedJsCode } = replaceImportsInJsCode({
jsCode,
buildOptions
buildContext
});
$(element).text(fixedJsCode);
@ -68,7 +68,7 @@ export function generateFtlFilesCodeFactory(params: {
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
buildOptions
buildContext
});
$(element).text(fixedCssCode);
@ -91,7 +91,7 @@ export function generateFtlFilesCodeFactory(params: {
attrName,
href.replace(
new RegExp(
`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`
`^${(buildContext.urlPathname ?? "/").replace(/\//g, "\\/")}`
),
`\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
)
@ -106,7 +106,7 @@ export function generateFtlFilesCodeFactory(params: {
"<style>",
generateCssCodeToDefineGlobals({
cssGlobalsToDefine,
buildOptions
buildContext
}).cssCodeToPrependInHead,
"</style>",
""
@ -134,7 +134,7 @@ export function generateFtlFilesCodeFactory(params: {
fieldNames.map(name => `"${name}"`).join(", ")
)
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildContext.themeVersion)
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", themeName)
.replace("RESOURCES_COMMON_cLsLsMrtDkpVv", resources_common)
@ -144,7 +144,7 @@ export function generateFtlFilesCodeFactory(params: {
)
.replace(
"USER_DEFINED_EXCLUSIONS_eKsaY4ZsZ4eMr2",
buildOptions.kcContextExclusionsFtlCode ?? ""
buildContext.kcContextExclusionsFtlCode ?? ""
);
const ftlObjectToJsCodeDeclaringAnObjectPlaceholder =
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }';

View File

@ -1,7 +1,7 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import {
resources_common,
lastKeycloakVersionWithAccountV1,
@ -10,27 +10,26 @@ import {
import { downloadKeycloakDefaultTheme } from "../../shared/downloadKeycloakDefaultTheme";
import { transformCodebase } from "../../tools/transformCodebase";
export type BuildOptionsLike = {
export type BuildContextLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
keycloakifyBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function bringInAccountV1(params: { buildOptions: BuildOptionsLike }) {
const { buildOptions } = params;
export async function bringInAccountV1(params: {
resourcesDirPath: string;
buildContext: BuildContextLike;
}) {
const { resourcesDirPath, buildContext } = params;
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion: lastKeycloakVersionWithAccountV1,
buildOptions
buildContext
});
const accountV1DirPath = pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
resourcesDirPath,
"theme",
accountV1ThemeName,
"account"

View File

@ -8,6 +8,7 @@ import * as recast from "recast";
import * as babelParser from "@babel/parser";
import babelGenerate from "@babel/generator";
import * as babelTypes from "@babel/types";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
export function generateMessageProperties(params: {
themeSrcDirPath: string;
@ -146,7 +147,7 @@ export function generateMessageProperties(params: {
for (const [languageTag, keyValueMap] of Object.entries(keyValueMapByLanguageTag)) {
const propertiesFileSource = Object.entries(keyValueMap)
.map(([key, value]) => `${key}=${escapeString(value)}`)
.map(([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`)
.join("\n");
out.push({
@ -164,68 +165,3 @@ export function generateMessageProperties(params: {
return out;
}
// Convert a JavaScript string to UTF-16 encoding
function toUTF16(codePoint: number): string {
if (codePoint <= 0xffff) {
// BMP character
return "\\u" + codePoint.toString(16).padStart(4, "0");
} else {
// Non-BMP character
codePoint -= 0x10000;
let highSurrogate = (codePoint >> 10) + 0xd800;
let lowSurrogate = (codePoint % 0x400) + 0xdc00;
return (
"\\u" +
highSurrogate.toString(16).padStart(4, "0") +
"\\u" +
lowSurrogate.toString(16).padStart(4, "0")
);
}
}
// Escapes special characters for use in a .properties file
function escapeString(str: string): string {
let escapedStr = "";
for (const char of [...str]) {
const codePoint = char.codePointAt(0);
if (!codePoint) continue;
switch (char) {
case "\n":
escapedStr += "\\n";
break;
case "\r":
escapedStr += "\\r";
break;
case "\t":
escapedStr += "\\t";
break;
case "\\":
escapedStr += "\\\\";
break;
case ":":
escapedStr += "\\:";
break;
case "=":
escapedStr += "\\=";
break;
case "#":
escapedStr += "\\#";
break;
case "!":
escapedStr += "\\!";
break;
case "'":
escapedStr += "''";
break;
default:
if (codePoint > 0x7f) {
escapedStr += toUTF16(codePoint); // Non-ASCII characters
} else {
escapedStr += char; // ASCII character needs no escape
}
}
}
return escapedStr;
}

View File

@ -1,34 +1,42 @@
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import {
generateSrcMainResourcesForMainTheme,
type BuildOptionsLike as BuildOptionsLike_generateSrcMainResourcesForMainTheme
type BuildContextLike as BuildContextLike_generateSrcMainResourcesForMainTheme
} from "./generateSrcMainResourcesForMainTheme";
import { generateSrcMainResourcesForThemeVariant } from "./generateSrcMainResourcesForThemeVariant";
import fs from "fs";
import { rmSync } from "../../tools/fs.rmSync";
export type BuildOptionsLike = BuildOptionsLike_generateSrcMainResourcesForMainTheme & {
export type BuildContextLike = BuildContextLike_generateSrcMainResourcesForMainTheme & {
themeNames: string[];
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateSrcMainResources(params: {
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
resourcesDirPath: string;
}): Promise<void> {
const { buildOptions } = params;
const { resourcesDirPath, buildContext } = params;
const [themeName, ...themeVariantNames] = buildOptions.themeNames;
const [themeName, ...themeVariantNames] = buildContext.themeNames;
if (fs.existsSync(resourcesDirPath)) {
rmSync(resourcesDirPath, { recursive: true });
}
await generateSrcMainResourcesForMainTheme({
resourcesDirPath,
themeName,
buildOptions
buildContext
});
for (const themeVariantName of themeVariantNames) {
generateSrcMainResourcesForThemeVariant({
resourcesDirPath,
themeName,
themeVariantName,
buildOptions
themeVariantName
});
}
}

View File

@ -5,7 +5,7 @@ import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import {
generateFtlFilesCodeFactory,
type BuildOptionsLike as BuildOptionsLike_kcContextExclusionsFtlCode
type BuildContextLike as BuildContextLike_kcContextExclusionsFtlCode
} from "../generateFtl";
import {
type ThemeType,
@ -17,18 +17,18 @@ import {
accountThemePageIds
} from "../../shared/constants";
import { isInside } from "../../tools/isInside";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import { assert, type Equals } from "tsafe/assert";
import {
downloadKeycloakStaticResources,
type BuildOptionsLike as BuildOptionsLike_downloadKeycloakStaticResources
type BuildContextLike as BuildContextLike_downloadKeycloakStaticResources
} from "../../shared/downloadKeycloakStaticResources";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import { generateMessageProperties } from "./generateMessageProperties";
import {
bringInAccountV1,
type BuildOptionsLike as BuildOptionsLike_bringInAccountV1
type BuildContextLike as BuildContextLike_bringInAccountV1
} from "./bringInAccountV1";
import { getThemeSrcDirPath } from "../../shared/getThemeSrcDirPath";
import { rmSync } from "../../tools/fs.rmSync";
@ -38,43 +38,34 @@ import {
type MetaInfKeycloakTheme
} from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
export type BuildOptionsLike = BuildOptionsLike_kcContextExclusionsFtlCode &
BuildOptionsLike_downloadKeycloakStaticResources &
BuildOptionsLike_bringInAccountV1 & {
bundler: "vite" | "webpack";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_downloadKeycloakStaticResources &
BuildContextLike_bringInAccountV1 & {
extraThemeProperties: string[] | undefined;
loginThemeResourcesFromKeycloakVersion: string;
reactAppBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
reactAppRootDirPath: string;
keycloakifyBuildDirPath: string;
projectDirPath: string;
projectBuildDirPath: string;
environmentVariables: { name: string; default: string }[];
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateSrcMainResourcesForMainTheme(params: {
themeName: string;
buildOptions: BuildOptionsLike;
resourcesDirPath: string;
buildContext: BuildContextLike;
}): Promise<void> {
const { themeName, buildOptions } = params;
const { themeName, resourcesDirPath, buildContext } = params;
const { themeSrcDirPath } = getThemeSrcDirPath({
reactAppRootDirPath: buildOptions.reactAppRootDirPath
projectDirPath: buildContext.projectDirPath
});
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => {
const { themeType } = params;
return pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme",
themeName,
themeType
);
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
};
const cssGlobalsToDefine: Record<string, string> = {};
@ -122,7 +113,7 @@ export async function generateSrcMainResourcesForMainTheme(params: {
}
transformCodebase({
srcDirPath: buildOptions.reactAppBuildDirPath,
srcDirPath: buildContext.projectBuildDirPath,
destDirPath,
transformSourceCode: ({ filePath, sourceCode }) => {
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
@ -130,7 +121,7 @@ export async function generateSrcMainResourcesForMainTheme(params: {
if (
isInside({
dirPath: pathJoin(
buildOptions.reactAppBuildDirPath,
buildContext.projectBuildDirPath,
keycloak_resources
),
filePath
@ -161,7 +152,7 @@ export async function generateSrcMainResourcesForMainTheme(params: {
if (/\.js?$/i.test(filePath)) {
const { fixedJsCode } = replaceImportsInJsCode({
jsCode: sourceCode.toString("utf8"),
buildOptions
buildContext
});
return {
@ -177,10 +168,10 @@ export async function generateSrcMainResourcesForMainTheme(params: {
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
indexHtmlCode: fs
.readFileSync(pathJoin(buildOptions.reactAppBuildDirPath, "index.html"))
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
.toString("utf8"),
cssGlobalsToDefine,
buildOptions,
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: readFieldNameUsage({
@ -205,8 +196,6 @@ export async function generateSrcMainResourcesForMainTheme(params: {
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.mkdirSync(themeTypeDirPath, { recursive: true });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
@ -240,12 +229,12 @@ export async function generateSrcMainResourcesForMainTheme(params: {
case "account":
return lastKeycloakVersionWithAccountV1;
case "login":
return buildOptions.loginThemeResourcesFromKeycloakVersion;
return buildContext.loginThemeResourcesFromKeycloakVersion;
}
})(),
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildOptions
buildContext
});
fs.writeFileSync(
@ -261,7 +250,11 @@ export async function generateSrcMainResourcesForMainTheme(params: {
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(buildOptions.extraThemeProperties ?? [])
...(buildContext.extraThemeProperties ?? []),
buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
)
].join("\n\n"),
"utf8"
)
@ -285,7 +278,8 @@ export async function generateSrcMainResourcesForMainTheme(params: {
if (implementedThemeTypes.account) {
await bringInAccountV1({
buildOptions
resourcesDirPath,
buildContext
});
}
@ -307,7 +301,7 @@ export async function generateSrcMainResourcesForMainTheme(params: {
}
writeMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildOptions.keycloakifyBuildDirPath,
resourcesDirPath,
metaInfKeycloakThemes
});
}

View File

@ -1,33 +1,26 @@
import { join as pathJoin, extname as pathExtname, sep as pathSep } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import {
readMetaInfKeycloakThemes,
readMetaInfKeycloakThemes_fromResourcesDirPath,
writeMetaInfKeycloakThemes
} from "../../shared/metaInfKeycloakThemes";
import { assert } from "tsafe/assert";
export type BuildOptionsLike = {
export type BuildContextLike = {
keycloakifyBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function generateSrcMainResourcesForThemeVariant(params: {
resourcesDirPath: string;
themeName: string;
themeVariantName: string;
buildOptions: BuildOptionsLike;
}) {
const { themeName, themeVariantName, buildOptions } = params;
const { resourcesDirPath, themeName, themeVariantName } = params;
const mainThemeDirPath = pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme",
themeName
);
const mainThemeDirPath = pathJoin(resourcesDirPath, "theme", themeName);
transformCodebase({
srcDirPath: mainThemeDirPath,
@ -57,9 +50,10 @@ export function generateSrcMainResourcesForThemeVariant(params: {
});
{
const updatedMetaInfKeycloakThemes = readMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildOptions.keycloakifyBuildDirPath
});
const updatedMetaInfKeycloakThemes =
readMetaInfKeycloakThemes_fromResourcesDirPath({
resourcesDirPath
});
updatedMetaInfKeycloakThemes.themes.push({
name: themeVariantName,
@ -73,7 +67,7 @@ export function generateSrcMainResourcesForThemeVariant(params: {
});
writeMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildOptions.keycloakifyBuildDirPath,
resourcesDirPath,
metaInfKeycloakThemes: updatedMetaInfKeycloakThemes
});
}

View File

@ -1,74 +0,0 @@
import * as fs from "fs";
import {
join as pathJoin,
relative as pathRelative,
basename as pathBasename
} from "path";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../shared/buildOptions";
import { accountV1ThemeName } from "../shared/constants";
export type BuildOptionsLike = {
keycloakifyBuildDirPath: string;
themeNames: string[];
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh";
const containerName = "keycloak-testing-container";
const keycloakVersion = "24.0.4";
/** Files for being able to run a hot reload keycloak container */
export function generateStartKeycloakTestingContainer(params: {
jarFilePath: string;
doesImplementAccountTheme: boolean;
buildOptions: BuildOptionsLike;
}) {
const { jarFilePath, doesImplementAccountTheme, buildOptions } = params;
const themeRelativeDirPath = pathJoin("src", "main", "resources", "theme");
fs.writeFileSync(
pathJoin(
buildOptions.keycloakifyBuildDirPath,
generateStartKeycloakTestingContainer.basename
),
Buffer.from(
[
"#!/usr/bin/env bash",
"",
`docker rm ${containerName} || true`,
"",
`cd "${buildOptions.keycloakifyBuildDirPath}"`,
"",
"docker run \\",
" -p 8080:8080 \\",
` --name ${containerName} \\`,
" -e KEYCLOAK_ADMIN=admin \\",
" -e KEYCLOAK_ADMIN_PASSWORD=admin \\",
` -v "${pathJoin(
"$(pwd)",
pathRelative(buildOptions.keycloakifyBuildDirPath, jarFilePath)
)}":"/opt/keycloak/providers/${pathBasename(jarFilePath)}" \\`,
[
...(doesImplementAccountTheme ? [accountV1ThemeName] : []),
...buildOptions.themeNames
].map(
themeName =>
` -v "${pathJoin(
"$(pwd)",
themeRelativeDirPath,
themeName
).replace(/\\/g, "/")}":"/opt/keycloak/themes/${themeName}":rw \\`
),
` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`,
` start-dev`,
""
].join("\n"),
"utf8"
),
{ mode: 0o755 }
);
}

View File

@ -2,13 +2,17 @@ import { generateSrcMainResources } from "./generateSrcMainResources";
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
import * as child_process from "child_process";
import * as fs from "fs";
import { readBuildOptions } from "../shared/buildOptions";
import { vitePluginSubScriptEnvNames, skipBuildJarsEnvName } from "../shared/constants";
import { getBuildContext } from "../shared/buildContext";
import {
vitePluginSubScriptEnvNames,
onlyBuildJarFileBasenameEnvName
} from "../shared/constants";
import { buildJars } from "./buildJars";
import type { CliCommandOptions } from "../main";
import chalk from "chalk";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import * as os from "os";
import { rmSync } from "../tools/fs.rmSync";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
exit_if_maven_not_installed: {
@ -47,7 +51,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({ cliCommandOptions });
const buildContext = getBuildContext({ cliCommandOptions });
console.log(
[
@ -55,7 +59,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
chalk.green(
`Building the keycloak theme in .${pathSep}${pathRelative(
process.cwd(),
buildOptions.keycloakifyBuildDirPath
buildContext.keycloakifyBuildDirPath
)} ...`
)
].join(" ")
@ -64,44 +68,51 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const startTime = Date.now();
{
if (!fs.existsSync(buildOptions.keycloakifyBuildDirPath)) {
fs.mkdirSync(buildOptions.keycloakifyBuildDirPath, {
if (!fs.existsSync(buildContext.keycloakifyBuildDirPath)) {
fs.mkdirSync(buildContext.keycloakifyBuildDirPath, {
recursive: true
});
}
fs.writeFileSync(
pathJoin(buildOptions.keycloakifyBuildDirPath, ".gitignore"),
pathJoin(buildContext.keycloakifyBuildDirPath, ".gitignore"),
Buffer.from("*", "utf8")
);
}
await generateSrcMainResources({ buildOptions });
const resourcesDirPath = pathJoin(buildContext.keycloakifyBuildDirPath, "resources");
await generateSrcMainResources({
resourcesDirPath,
buildContext
});
run_post_build_script: {
if (buildOptions.bundler !== "vite") {
if (buildContext.bundler !== "vite") {
break run_post_build_script;
}
child_process.execSync("npx vite", {
cwd: buildOptions.reactAppRootDirPath,
cwd: buildContext.projectDirPath,
env: {
...process.env,
[vitePluginSubScriptEnvNames.runPostBuildScript]:
JSON.stringify(buildOptions)
JSON.stringify(buildContext)
}
});
}
build_jars: {
if (process.env[skipBuildJarsEnvName]) {
break build_jars;
}
await buildJars({
resourcesDirPath,
buildContext,
onlyBuildJarFileBasename: process.env[onlyBuildJarFileBasenameEnvName]
});
await buildJars({ buildOptions });
}
rmSync(resourcesDirPath, { recursive: true });
console.log(
chalk.green(`✓ built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`)
chalk.green(
`✓ keycloak theme built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`
)
);
}

View File

@ -1,13 +1,13 @@
import * as crypto from "crypto";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import { basenameOfTheKeycloakifyResourcesDir } from "../../shared/constants";
export type BuildOptionsLike = {
export type BuildContextLike = {
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInCssCode(params: { cssCode: string }): {
fixedCssCode: string;
@ -44,11 +44,11 @@ export function replaceImportsInCssCode(params: { cssCode: string }): {
export function generateCssCodeToDefineGlobals(params: {
cssGlobalsToDefine: Record<string, string>;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}): {
cssCodeToPrependInHead: string;
} {
const { cssGlobalsToDefine, buildOptions } = params;
const { cssGlobalsToDefine, buildContext } = params;
return {
cssCodeToPrependInHead: [
@ -59,7 +59,7 @@ export function generateCssCodeToDefineGlobals(params: {
`--${cssVariableName}:`,
cssGlobalsToDefine[cssVariableName].replace(
new RegExp(
`url\\(${(buildOptions.urlPathname ?? "/").replace(
`url\\(${(buildContext.urlPathname ?? "/").replace(
/\//g,
"\\/"
)}`,

View File

@ -1,25 +1,25 @@
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import { basenameOfTheKeycloakifyResourcesDir } from "../../shared/constants";
export type BuildOptionsLike = {
export type BuildContextLike = {
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInInlineCssCode(params: {
cssCode: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}): {
fixedCssCode: string;
} {
const { cssCode, buildOptions } = params;
const { cssCode, buildContext } = params;
const fixedCssCode = cssCode.replace(
buildOptions.urlPathname === undefined
buildContext.urlPathname === undefined
? /url\(["']?\/([^/][^)"']+)["']?\)/g
: new RegExp(`url\\(["']?${buildOptions.urlPathname}([^)"']+)["']?\\)`, "g"),
: new RegExp(`url\\(["']?${buildContext.urlPathname}([^)"']+)["']?\\)`, "g"),
(...[, group]) =>
`url(\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/${group})`
);

View File

@ -1,38 +1,38 @@
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../../shared/buildOptions";
import type { BuildContext } from "../../../shared/buildContext";
import { replaceImportsInJsCode_vite } from "./vite";
import { replaceImportsInJsCode_webpack } from "./webpack";
import * as fs from "fs";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
export type BuildContextLike = {
projectBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
bundler: "vite" | "webpack";
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInJsCode(params: {
jsCode: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}) {
const { jsCode, buildOptions } = params;
const { jsCode, buildContext } = params;
const { fixedJsCode } = (() => {
switch (buildOptions.bundler) {
switch (buildContext.bundler) {
case "vite":
return replaceImportsInJsCode_vite({
jsCode,
buildOptions,
buildContext,
basenameOfAssetsFiles: readAssetsDirSync({
assetsDirPath: params.buildOptions.assetsDirPath
assetsDirPath: params.buildContext.assetsDirPath
})
});
case "webpack":
return replaceImportsInJsCode_webpack({
jsCode,
buildOptions
buildContext
});
}
})();

View File

@ -3,21 +3,21 @@ import {
basenameOfTheKeycloakifyResourcesDir
} from "../../../shared/constants";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../../shared/buildOptions";
import type { BuildContext } from "../../../shared/buildContext";
import * as nodePath from "path";
import { replaceAll } from "../../../tools/String.prototype.replaceAll";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
export type BuildContextLike = {
projectBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInJsCode_vite(params: {
jsCode: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
basenameOfAssetsFiles: string[];
systemType?: "posix" | "win32";
}): {
@ -25,7 +25,7 @@ export function replaceImportsInJsCode_vite(params: {
} {
const {
jsCode,
buildOptions,
buildContext,
basenameOfAssetsFiles,
systemType = nodePath.sep === "/" ? "posix" : "win32"
} = params;
@ -35,11 +35,11 @@ export function replaceImportsInJsCode_vite(params: {
let fixedJsCode = jsCode;
replace_base_javacript_import: {
if (buildOptions.urlPathname === undefined) {
if (buildContext.urlPathname === undefined) {
break replace_base_javacript_import;
}
// Optimization
if (!jsCode.includes(buildOptions.urlPathname)) {
if (!jsCode.includes(buildContext.urlPathname)) {
break replace_base_javacript_import;
}
@ -47,7 +47,7 @@ export function replaceImportsInJsCode_vite(params: {
fixedJsCode = fixedJsCode.replace(
new RegExp(
`([\\w\\$][\\w\\d\\$]*)=function\\(([\\w\\$][\\w\\d\\$]*)\\)\\{return"${replaceAll(
buildOptions.urlPathname,
buildContext.urlPathname,
"/",
"\\/"
)}"\\+\\2\\}`,
@ -62,15 +62,15 @@ export function replaceImportsInJsCode_vite(params: {
// Example: "assets/ or "foo/bar/"
const staticDir = (() => {
let out = pathRelative(
buildOptions.reactAppBuildDirPath,
buildOptions.assetsDirPath
buildContext.projectBuildDirPath,
buildContext.assetsDirPath
);
out = replaceAll(out, pathSep, "/") + "/";
if (out === "/") {
throw new Error(
`The assetsDirPath must be a subdirectory of reactAppBuildDirPath`
`The assetsDirPath must be a subdirectory of projectBuildDirPath`
);
}
@ -93,7 +93,7 @@ export function replaceImportsInJsCode_vite(params: {
fixedJsCode = replaceAll(
fixedJsCode,
`"${buildOptions.urlPathname ?? "/"}${relativePathOfAssetFile}"`,
`"${buildContext.urlPathname ?? "/"}${relativePathOfAssetFile}"`,
`(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
);
});

View File

@ -3,28 +3,28 @@ import {
basenameOfTheKeycloakifyResourcesDir
} from "../../../shared/constants";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../../shared/buildOptions";
import type { BuildContext } from "../../../shared/buildContext";
import * as nodePath from "path";
import { replaceAll } from "../../../tools/String.prototype.replaceAll";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
export type BuildContextLike = {
projectBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInJsCode_webpack(params: {
jsCode: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
systemType?: "posix" | "win32";
}): {
fixedJsCode: string;
} {
const {
jsCode,
buildOptions,
buildContext,
systemType = nodePath.sep === "/" ? "posix" : "win32"
} = params;
@ -32,12 +32,12 @@ export function replaceImportsInJsCode_webpack(params: {
let fixedJsCode = jsCode;
if (buildOptions.urlPathname !== undefined) {
if (buildContext.urlPathname !== undefined) {
// "__esModule",{value:!0})},n.p="/foo-bar/",function(){if("undefined" -> ... n.p="/" ...
fixedJsCode = fixedJsCode.replace(
new RegExp(
`,([a-zA-Z]\\.[a-zA-Z])="${replaceAll(
buildOptions.urlPathname,
buildContext.urlPathname,
"/",
"\\/"
)}",`,
@ -50,15 +50,15 @@ export function replaceImportsInJsCode_webpack(params: {
// Example: "static/ or "foo/bar/"
const staticDir = (() => {
let out = pathRelative(
buildOptions.reactAppBuildDirPath,
buildOptions.assetsDirPath
buildContext.projectBuildDirPath,
buildContext.assetsDirPath
);
out = replaceAll(out, pathSep, "/") + "/";
if (out === "/") {
throw new Error(
`The assetsDirPath must be a subdirectory of reactAppBuildDirPath`
`The assetsDirPath must be a subdirectory of projectBuildDirPath`
);
}

View File

@ -5,7 +5,7 @@ import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
import * as child_process from "child_process";
export type CliCommandOptions = {
reactAppRootDirPath: string | undefined;
projectDirPath: string | undefined;
};
const program = termost<CliCommandOptions>(
@ -25,7 +25,7 @@ const program = termost<CliCommandOptions>(
const optionsKeys: string[] = [];
program.option({
key: "reactAppRootDirPath",
key: "projectDirPath",
name: (() => {
const long = "project";
const short = "p";
@ -134,20 +134,6 @@ program
}
});
program
.command({
name: "download-keycloak-default-theme",
description: "Download the built-in Keycloak theme."
})
.task({
skip,
handler: async cliCommandOptions => {
const { command } = await import("./download-keycloak-default-theme");
await command({ cliCommandOptions });
}
});
program
.command({
name: "eject-page",
@ -162,6 +148,20 @@ program
}
});
program
.command({
name: "add-story",
description: "Add *.stories.tsx file for a specific page to in your Storybook."
})
.task({
skip,
handler: async cliCommandOptions => {
const { command } = await import("./add-story");
await command({ cliCommandOptions });
}
});
program
.command({
name: "initialize-email-theme",
@ -191,6 +191,21 @@ program
}
});
program
.command({
name: "update-kc-gen",
description:
"(Webpack/Create-React-App only) Create/update the kc.gen.ts file in your project."
})
.task({
skip,
handler: async cliCommandOptions => {
const { command } = await import("./update-kc-gen");
await command({ cliCommandOptions });
}
});
// Fallback to build command if no command is provided
{
const [, , ...rest] = process.argv;

View File

@ -5,5 +5,5 @@ export type KeycloakVersionRange =
export namespace KeycloakVersionRange {
export type WithoutAccountTheme = "21-and-below" | "22-and-above";
export type WithAccountTheme = "21-and-below" | "23" | "24-and-above";
export type WithAccountTheme = "21-and-below" | "23" | "24" | "25-and-above";
}

View File

@ -9,18 +9,16 @@ import { assert } from "tsafe";
import * as child_process from "child_process";
import { vitePluginSubScriptEnvNames } from "./constants";
/** Consolidated build option gathered form CLI arguments and config in package.json */
export type BuildOptions = {
export type BuildContext = {
bundler: "vite" | "webpack";
themeVersion: string;
themeNames: string[];
themeNames: [string, ...string[]];
extraThemeProperties: string[] | undefined;
groupId: string;
artifactId: string;
loginThemeResourcesFromKeycloakVersion: string;
reactAppRootDirPath: string;
// TODO: Remove from vite type
reactAppBuildDirPath: string;
projectDirPath: string;
projectBuildDirPath: string;
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
keycloakifyBuildDirPath: string;
publicDirPath: string;
@ -31,16 +29,18 @@ export type BuildOptions = {
assetsDirPath: string;
npmWorkspaceRootDirPath: string;
kcContextExclusionsFtlCode: string | undefined;
environmentVariables: { name: string; default: string }[];
};
export type UserProvidedBuildOptions = {
export type BuildOptions = {
themeName?: string | string[];
environmentVariables?: { name: string; default: string }[];
extraThemeProperties?: string[];
artifactId?: string;
groupId?: string;
loginThemeResourcesFromKeycloakVersion?: string;
keycloakifyBuildDirPath?: string;
themeName?: string | string[];
kcContextExclusionsFtlCode?: string;
kcContextExclusionsFtl?: string;
};
export type ResolvedViteConfig = {
@ -48,21 +48,21 @@ export type ResolvedViteConfig = {
publicDir: string;
assetsDir: string;
urlPathname: string | undefined;
userProvidedBuildOptions: UserProvidedBuildOptions;
buildOptions: BuildOptions;
};
export function readBuildOptions(params: {
export function getBuildContext(params: {
cliCommandOptions: CliCommandOptions;
}): BuildOptions {
}): BuildContext {
const { cliCommandOptions } = params;
const reactAppRootDirPath = (() => {
if (cliCommandOptions.reactAppRootDirPath === undefined) {
const projectDirPath = (() => {
if (cliCommandOptions.projectDirPath === undefined) {
return process.cwd();
}
return getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.reactAppRootDirPath,
pathIsh: cliCommandOptions.projectDirPath,
cwd: process.cwd()
});
})();
@ -70,7 +70,7 @@ export function readBuildOptions(params: {
const { resolvedViteConfig } = (() => {
if (
fs
.readdirSync(reactAppRootDirPath)
.readdirSync(projectDirPath)
.find(fileBasename => fileBasename.startsWith("vite.config")) ===
undefined
) {
@ -79,7 +79,7 @@ export function readBuildOptions(params: {
const output = child_process
.execSync("npx vite", {
cwd: reactAppRootDirPath,
cwd: projectDirPath,
env: {
...process.env,
[vitePluginSubScriptEnvNames.resolveViteConfig]: "true"
@ -106,8 +106,8 @@ export function readBuildOptions(params: {
name: string;
version?: string;
homepage?: string;
keycloakify?: UserProvidedBuildOptions & {
reactAppBuildDirPath?: string;
keycloakify?: BuildOptions & {
projectBuildDirPath?: string;
};
};
@ -121,7 +121,7 @@ export function readBuildOptions(params: {
artifactId: z.string().optional(),
groupId: z.string().optional(),
loginThemeResourcesFromKeycloakVersion: z.string().optional(),
reactAppBuildDirPath: z.string().optional(),
projectBuildDirPath: z.string().optional(),
keycloakifyBuildDirPath: z.string().optional(),
themeName: z.union([z.string(), z.array(z.string())]).optional()
})
@ -137,20 +137,18 @@ export function readBuildOptions(params: {
return zParsedPackageJson.parse(
JSON.parse(
fs
.readFileSync(pathJoin(reactAppRootDirPath, "package.json"))
.toString("utf8")
fs.readFileSync(pathJoin(projectDirPath, "package.json")).toString("utf8")
)
);
})();
const userProvidedBuildOptions: UserProvidedBuildOptions = {
const buildOptions: BuildOptions = {
...parsedPackageJson.keycloakify,
...resolvedViteConfig?.userProvidedBuildOptions
...resolvedViteConfig?.buildOptions
};
const themeNames = (() => {
if (userProvidedBuildOptions.themeName === undefined) {
const themeNames = ((): [string, ...string[]] => {
if (buildOptions.themeName === undefined) {
return [
parsedPackageJson.name
.replace(/^@(.*)/, "$1")
@ -159,34 +157,38 @@ export function readBuildOptions(params: {
];
}
if (typeof userProvidedBuildOptions.themeName === "string") {
return [userProvidedBuildOptions.themeName];
if (typeof buildOptions.themeName === "string") {
return [buildOptions.themeName];
}
return userProvidedBuildOptions.themeName;
const [mainThemeName, ...themeVariantNames] = buildOptions.themeName;
assert(mainThemeName !== undefined);
return [mainThemeName, ...themeVariantNames];
})();
const reactAppBuildDirPath = (() => {
const projectBuildDirPath = (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
if (parsedPackageJson.keycloakify?.reactAppBuildDirPath !== undefined) {
if (parsedPackageJson.keycloakify?.projectBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: parsedPackageJson.keycloakify.reactAppBuildDirPath,
cwd: reactAppRootDirPath
pathIsh: parsedPackageJson.keycloakify.projectBuildDirPath,
cwd: projectDirPath
});
}
return pathJoin(reactAppRootDirPath, "build");
return pathJoin(projectDirPath, "build");
}
return pathJoin(reactAppRootDirPath, resolvedViteConfig.buildDir);
return pathJoin(projectDirPath, resolvedViteConfig.buildDir);
})();
const { npmWorkspaceRootDirPath } = getNpmWorkspaceRootDirPath({
reactAppRootDirPath,
projectDirPath,
dependencyExpected: "keycloakify"
});
@ -195,13 +197,13 @@ export function readBuildOptions(params: {
themeVersion:
process.env.KEYCLOAKIFY_THEME_VERSION ?? parsedPackageJson.version ?? "0.0.0",
themeNames,
extraThemeProperties: userProvidedBuildOptions.extraThemeProperties,
extraThemeProperties: buildOptions.extraThemeProperties,
groupId: (() => {
const fallbackGroupId = `${themeNames[0]}.keycloak`;
return (
process.env.KEYCLOAKIFY_GROUP_ID ??
userProvidedBuildOptions.groupId ??
buildOptions.groupId ??
(parsedPackageJson.homepage === undefined
? fallbackGroupId
: urlParse(parsedPackageJson.homepage)
@ -213,22 +215,22 @@ export function readBuildOptions(params: {
})(),
artifactId:
process.env.KEYCLOAKIFY_ARTIFACT_ID ??
userProvidedBuildOptions.artifactId ??
buildOptions.artifactId ??
`${themeNames[0]}-keycloak-theme`,
loginThemeResourcesFromKeycloakVersion:
userProvidedBuildOptions.loginThemeResourcesFromKeycloakVersion ?? "24.0.4",
reactAppRootDirPath,
reactAppBuildDirPath,
buildOptions.loginThemeResourcesFromKeycloakVersion ?? "24.0.4",
projectDirPath,
projectBuildDirPath,
keycloakifyBuildDirPath: (() => {
if (userProvidedBuildOptions.keycloakifyBuildDirPath !== undefined) {
if (buildOptions.keycloakifyBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: userProvidedBuildOptions.keycloakifyBuildDirPath,
cwd: reactAppRootDirPath
pathIsh: buildOptions.keycloakifyBuildDirPath,
cwd: projectDirPath
});
}
return pathJoin(
reactAppRootDirPath,
projectDirPath,
resolvedViteConfig?.buildDir === undefined
? "build_keycloak"
: `${resolvedViteConfig.buildDir}_keycloak`
@ -243,14 +245,14 @@ export function readBuildOptions(params: {
if (process.env.PUBLIC_DIR_PATH !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: process.env.PUBLIC_DIR_PATH,
cwd: reactAppRootDirPath
cwd: projectDirPath
});
}
return pathJoin(reactAppRootDirPath, "public");
return pathJoin(projectDirPath, "public");
}
return pathJoin(reactAppRootDirPath, resolvedViteConfig.publicDir);
return pathJoin(projectDirPath, resolvedViteConfig.publicDir);
})(),
cacheDirPath: (() => {
const cacheDirPath = pathJoin(
@ -299,12 +301,28 @@ export function readBuildOptions(params: {
break webpack;
}
return pathJoin(reactAppBuildDirPath, "static");
return pathJoin(projectBuildDirPath, "static");
}
return pathJoin(reactAppBuildDirPath, resolvedViteConfig.assetsDir);
return pathJoin(projectBuildDirPath, resolvedViteConfig.assetsDir);
})(),
npmWorkspaceRootDirPath,
kcContextExclusionsFtlCode: userProvidedBuildOptions.kcContextExclusionsFtlCode
kcContextExclusionsFtlCode: (() => {
if (buildOptions.kcContextExclusionsFtl === undefined) {
return undefined;
}
if (buildOptions.kcContextExclusionsFtl.endsWith(".ftl")) {
const kcContextExclusionsFtlPath = getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.kcContextExclusionsFtl,
cwd: projectDirPath
});
return fs.readFileSync(kcContextExclusionsFtlPath).toString("utf8");
}
return buildOptions.kcContextExclusionsFtl;
})(),
environmentVariables: buildOptions.environmentVariables ?? []
};
}

View File

@ -16,7 +16,7 @@ export const vitePluginSubScriptEnvNames = {
resolveViteConfig: "KEYCLOAKIFY_RESOLVE_VITE_CONFIG"
} as const;
export const skipBuildJarsEnvName = "KEYCLOAKIFY_SKIP_BUILD_JAR";
export const onlyBuildJarFileBasenameEnvName = "KEYCLOAKIFY_ONLY_BUILD_JAR_FILE_BASENAME";
export const loginThemePageIds = [
"login.ftl",

View File

@ -1,6 +1,6 @@
import {
downloadKeycloakStaticResources,
type BuildOptionsLike as BuildOptionsLike_downloadKeycloakStaticResources
type BuildContextLike as BuildContextLike_downloadKeycloakStaticResources
} from "./downloadKeycloakStaticResources";
import { join as pathJoin, relative as pathRelative } from "path";
import {
@ -12,21 +12,21 @@ import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import { assert } from "tsafe/assert";
import * as fs from "fs";
import { rmSync } from "../tools/fs.rmSync";
import type { BuildOptions } from "./buildOptions";
import type { BuildContext } from "./buildContext";
export type BuildOptionsLike = BuildOptionsLike_downloadKeycloakStaticResources & {
export type BuildContextLike = BuildContextLike_downloadKeycloakStaticResources & {
loginThemeResourcesFromKeycloakVersion: string;
publicDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function copyKeycloakResourcesToPublic(params: {
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}) {
const { buildOptions } = params;
const { buildContext } = params;
const destDirPath = pathJoin(buildOptions.publicDirPath, keycloak_resources);
const destDirPath = pathJoin(buildContext.publicDirPath, keycloak_resources);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
@ -34,12 +34,12 @@ export async function copyKeycloakResourcesToPublic(params: {
{
destDirPath,
keycloakifyVersion: readThisNpmPackageVersion(),
buildOptions: {
buildContext: {
loginThemeResourcesFromKeycloakVersion: readThisNpmPackageVersion(),
cacheDirPath: pathRelative(destDirPath, buildOptions.cacheDirPath),
cacheDirPath: pathRelative(destDirPath, buildContext.cacheDirPath),
npmWorkspaceRootDirPath: pathRelative(
destDirPath,
buildOptions.npmWorkspaceRootDirPath
buildContext.npmWorkspaceRootDirPath
)
}
},
@ -74,14 +74,14 @@ export async function copyKeycloakResourcesToPublic(params: {
keycloakVersion: (() => {
switch (themeType) {
case "login":
return buildOptions.loginThemeResourcesFromKeycloakVersion;
return buildContext.loginThemeResourcesFromKeycloakVersion;
case "account":
return lastKeycloakVersionWithAccountV1;
}
})(),
themeType,
themeDirPath: destDirPath,
buildOptions
buildContext
});
}

View File

@ -1,27 +1,27 @@
import { join as pathJoin, relative as pathRelative } from "path";
import { type BuildOptions } from "./buildOptions";
import { type BuildContext } from "./buildContext";
import { assert } from "tsafe/assert";
import { lastKeycloakVersionWithAccountV1 } from "./constants";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
import { isInside } from "../tools/isInside";
export type BuildOptionsLike = {
export type BuildContextLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function downloadKeycloakDefaultTheme(params: {
keycloakVersion: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}): Promise<{ defaultThemeDirPath: string }> {
const { keycloakVersion, buildOptions } = params;
const { keycloakVersion, buildContext } = params;
const { extractedDirPath } = await downloadAndExtractArchive({
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
cacheDirPath: buildOptions.cacheDirPath,
npmWorkspaceRootDirPath: buildOptions.npmWorkspaceRootDirPath,
cacheDirPath: buildContext.cacheDirPath,
npmWorkspaceRootDirPath: buildContext.npmWorkspaceRootDirPath,
uniqueIdOfOnOnArchiveFile: "downloadKeycloakDefaultTheme",
onArchiveFile: async params => {
if (!isInside({ dirPath: "theme", filePath: params.fileRelativePath })) {

View File

@ -2,28 +2,28 @@ import { transformCodebase } from "../tools/transformCodebase";
import { join as pathJoin } from "path";
import {
downloadKeycloakDefaultTheme,
type BuildOptionsLike as BuildOptionsLike_downloadKeycloakDefaultTheme
type BuildContextLike as BuildContextLike_downloadKeycloakDefaultTheme
} from "./downloadKeycloakDefaultTheme";
import { resources_common, type ThemeType } from "./constants";
import type { BuildOptions } from "./buildOptions";
import type { BuildContext } from "./buildContext";
import { assert } from "tsafe/assert";
import { existsAsync } from "../tools/fs.existsAsync";
export type BuildOptionsLike = BuildOptionsLike_downloadKeycloakDefaultTheme & {};
export type BuildContextLike = BuildContextLike_downloadKeycloakDefaultTheme & {};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function downloadKeycloakStaticResources(params: {
themeType: ThemeType;
themeDirPath: string;
keycloakVersion: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}) {
const { themeType, themeDirPath, keycloakVersion, buildOptions } = params;
const { themeType, themeDirPath, keycloakVersion, buildContext } = params;
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildOptions
buildContext
});
const resourcesDirPath = pathJoin(themeDirPath, themeType, "resources");

View File

@ -0,0 +1,72 @@
import { assert } from "tsafe/assert";
import type { BuildContext } from "./buildContext";
import { getThemeSrcDirPath } from "./getThemeSrcDirPath";
import * as fs from "fs/promises";
import { join as pathJoin } from "path";
import { existsAsync } from "../tools/fs.existsAsync";
export type BuildContextLike = {
projectDirPath: string;
themeNames: string[];
environmentVariables: { name: string; default: string }[];
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateKcGenTs(params: {
buildContext: BuildContextLike;
}): Promise<void> {
const { buildContext } = params;
const { themeSrcDirPath } = getThemeSrcDirPath({
projectDirPath: buildContext.projectDirPath
});
const filePath = pathJoin(themeSrcDirPath, "kc.gen.ts");
const currentContent = (await existsAsync(filePath))
? await fs.readFile(filePath)
: undefined;
const newContent = Buffer.from(
[
`/* prettier-ignore-start */`,
``,
`/* eslint-disable */`,
``,
`// @ts-nocheck`,
``,
`// noinspection JSUnusedGlobalSymbols`,
``,
`// This file is auto-generated by Keycloakify`,
``,
`export type ThemeName = ${buildContext.themeNames.map(themeName => `"${themeName}"`).join(" | ")};`,
``,
`export const themeNames: ThemeName[] = [${buildContext.themeNames.map(themeName => `"${themeName}"`).join(", ")}];`,
``,
`export type KcEnvName = ${buildContext.environmentVariables.length === 0 ? "never" : buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(" | ")};`,
``,
`export const kcEnvNames: KcEnvName[] = [${buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(", ")}];`,
``,
`export const kcEnvDefaults: Record<KcEnvName, string> = ${JSON.stringify(
Object.fromEntries(
buildContext.environmentVariables.map(
({ name, default: defaultValue }) => [name, defaultValue]
)
),
null,
2
)};`,
``,
`/* prettier-ignore-end */`,
``
].join("\n"),
"utf8"
);
if (currentContent !== undefined && currentContent.equals(newContent)) {
return;
}
await fs.writeFile(filePath, newContent);
}

View File

@ -7,10 +7,10 @@ import { themeTypes } from "./constants";
const themeSrcDirBasenames = ["keycloak-theme", "keycloak_theme"];
/** Can't catch error, if the directory isn't found, this function will just exit the process with an error message. */
export function getThemeSrcDirPath(params: { reactAppRootDirPath: string }) {
const { reactAppRootDirPath } = params;
export function getThemeSrcDirPath(params: { projectDirPath: string }) {
const { projectDirPath } = params;
const srcDirPath = pathJoin(reactAppRootDirPath, "src");
const srcDirPath = pathJoin(projectDirPath, "src");
const themeSrcDirPath: string | undefined = crawl({
dirPath: srcDirPath,

View File

@ -1,50 +1,73 @@
import { join as pathJoin, dirname as pathDirname } from "path";
import type { ThemeType } from "./constants";
import * as fs from "fs";
import { assert } from "tsafe/assert";
import { extractArchive } from "../tools/extractArchive";
export type MetaInfKeycloakTheme = {
themes: { name: string; types: (ThemeType | "email")[] }[];
};
export function getMetaInfKeycloakThemesJsonFilePath(params: {
keycloakifyBuildDirPath: string;
resourcesDirPath: string;
}) {
const { keycloakifyBuildDirPath } = params;
const { resourcesDirPath } = params;
return pathJoin(
keycloakifyBuildDirPath === "." ? "" : keycloakifyBuildDirPath,
"src",
"main",
"resources",
resourcesDirPath === "." ? "" : resourcesDirPath,
"META-INF",
"keycloak-themes.json"
);
}
export function readMetaInfKeycloakThemes(params: {
keycloakifyBuildDirPath: string;
}): MetaInfKeycloakTheme {
const { keycloakifyBuildDirPath } = params;
export function readMetaInfKeycloakThemes_fromResourcesDirPath(params: {
resourcesDirPath: string;
}) {
const { resourcesDirPath } = params;
return JSON.parse(
fs
.readFileSync(
getMetaInfKeycloakThemesJsonFilePath({
keycloakifyBuildDirPath
resourcesDirPath
})
)
.toString("utf8")
) as MetaInfKeycloakTheme;
}
export async function readMetaInfKeycloakThemes_fromJar(params: {
jarFilePath: string;
}): Promise<MetaInfKeycloakTheme> {
const { jarFilePath } = params;
let metaInfKeycloakThemes: MetaInfKeycloakTheme | undefined = undefined;
await extractArchive({
archiveFilePath: jarFilePath,
onArchiveFile: async ({ relativeFilePathInArchive, readFile, earlyExit }) => {
if (
relativeFilePathInArchive ===
getMetaInfKeycloakThemesJsonFilePath({ resourcesDirPath: "." })
) {
metaInfKeycloakThemes = JSON.parse((await readFile()).toString("utf8"));
earlyExit();
}
}
});
assert(metaInfKeycloakThemes !== undefined);
return metaInfKeycloakThemes;
}
export function writeMetaInfKeycloakThemes(params: {
keycloakifyBuildDirPath: string;
resourcesDirPath: string;
metaInfKeycloakThemes: MetaInfKeycloakTheme;
}) {
const { keycloakifyBuildDirPath, metaInfKeycloakThemes } = params;
const { resourcesDirPath, metaInfKeycloakThemes } = params;
const metaInfKeycloakThemesJsonPath = getMetaInfKeycloakThemesJsonFilePath({
keycloakifyBuildDirPath
resourcesDirPath
});
{

View File

@ -9,9 +9,10 @@ import { id } from "tsafe/id";
export async function promptKeycloakVersion(params: {
startingFromMajor: number | undefined;
excludeMajorVersions: number[];
cacheDirPath: string;
}) {
const { startingFromMajor, cacheDirPath } = params;
const { startingFromMajor, excludeMajorVersions, cacheDirPath } = params;
const { getLatestsSemVersionedTag } = (() => {
const { octokit } = (() => {
@ -95,6 +96,10 @@ export async function promptKeycloakVersion(params: {
return;
}
if (excludeMajorVersions.includes(semVersionedTag.version.major)) {
return;
}
const currentSemVersionedTag = semVersionedTagByMajor.get(
semVersionedTag.version.major
);

View File

@ -2,26 +2,26 @@ import * as child_process from "child_process";
import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import type { BuildOptions } from "../shared/buildOptions";
import type { BuildContext } from "../shared/buildContext";
import * as fs from "fs";
import { join as pathJoin } from "path";
export type BuildOptionsLike = {
reactAppRootDirPath: string;
export type BuildContextLike = {
projectDirPath: string;
keycloakifyBuildDirPath: string;
bundler: "vite" | "webpack";
npmWorkspaceRootDirPath: string;
reactAppBuildDirPath: string;
projectBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function appBuild(params: {
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}): Promise<{ isAppBuildSuccess: boolean }> {
const { buildOptions } = params;
const { buildContext } = params;
const { bundler } = buildOptions;
const { bundler } = buildContext;
const { command, args, cwd } = (() => {
switch (bundler) {
@ -29,12 +29,12 @@ export async function appBuild(params: {
return {
command: "npx",
args: ["vite", "build"],
cwd: buildOptions.reactAppRootDirPath
cwd: buildContext.projectDirPath
};
case "webpack": {
for (const dirPath of [
buildOptions.reactAppRootDirPath,
buildOptions.npmWorkspaceRootDirPath
buildContext.projectDirPath,
buildContext.npmWorkspaceRootDirPath
]) {
try {
const parsedPackageJson = JSON.parse(

View File

@ -1,31 +1,31 @@
import { skipBuildJarsEnvName } from "../shared/constants";
import { onlyBuildJarFileBasenameEnvName } from "../shared/constants";
import * as child_process from "child_process";
import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../shared/buildOptions";
import type { BuildContext } from "../shared/buildContext";
export type BuildOptionsLike = {
reactAppRootDirPath: string;
export type BuildContextLike = {
projectDirPath: string;
keycloakifyBuildDirPath: string;
bundler: "vite" | "webpack";
npmWorkspaceRootDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function keycloakifyBuild(params: {
doSkipBuildJars: boolean;
buildOptions: BuildOptionsLike;
onlyBuildJarFileBasename: string | undefined;
buildContext: BuildContextLike;
}): Promise<{ isKeycloakifyBuildSuccess: boolean }> {
const { buildOptions, doSkipBuildJars } = params;
const { buildContext, onlyBuildJarFileBasename } = params;
const dResult = new Deferred<{ isSuccess: boolean }>();
const child = child_process.spawn("npx", ["keycloakify", "build"], {
cwd: buildOptions.reactAppRootDirPath,
cwd: buildContext.projectDirPath,
env: {
...process.env,
...(doSkipBuildJars ? { [skipBuildJarsEnvName]: "true" } : {})
[onlyBuildJarFileBasenameEnvName]: onlyBuildJarFileBasename
}
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -587,7 +587,9 @@
"publicClient": true,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@ -619,7 +621,9 @@
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@ -695,7 +699,9 @@
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@ -783,6 +789,7 @@
"config": {
"introspection.token.claim": "true",
"multivalued": "true",
"userinfo.token.claim": "true",
"user.attribute": "foo",
"id.token.claim": "true",
"access.token.claim": "true",
@ -827,7 +834,8 @@
"config": {
"id.token.claim": "true",
"introspection.token.claim": "true",
"access.token.claim": "true"
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
@ -1348,10 +1356,10 @@
"config": {
"allowed-protocol-mapper-types": [
"oidc-sha256-pairwise-sub-mapper",
"oidc-address-mapper",
"saml-user-property-mapper",
"oidc-full-name-mapper",
"oidc-usermodel-attribute-mapper",
"saml-user-property-mapper",
"oidc-address-mapper",
"saml-role-list-mapper",
"saml-user-attribute-mapper",
"oidc-usermodel-property-mapper"
@ -1423,13 +1431,13 @@
"subComponents": {},
"config": {
"allowed-protocol-mapper-types": [
"saml-user-property-mapper",
"oidc-full-name-mapper",
"saml-user-attribute-mapper",
"oidc-sha256-pairwise-sub-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-address-mapper",
"saml-role-list-mapper",
"oidc-full-name-mapper",
"saml-user-property-mapper",
"oidc-address-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-usermodel-property-mapper"
]
}
@ -2043,7 +2051,7 @@
"name": "Terms and Conditions",
"providerId": "TERMS_AND_CONDITIONS",
"enabled": true,
"defaultAction": false,
"defaultAction": true,
"priority": 20,
"config": {}
},
@ -2122,8 +2130,8 @@
"cibaExpiresIn": "120",
"cibaAuthRequestedUserHint": "login_hint",
"oauth2DeviceCodeLifespan": "600",
"oauth2DevicePollingInterval": "5",
"clientOfflineSessionMaxLifespan": "0",
"oauth2DevicePollingInterval": "5",
"clientSessionIdleTimeout": "0",
"parRequestUriLifespan": "60",
"clientSessionMaxLifespan": "0",

View File

@ -1501,14 +1501,14 @@
"subComponents": {},
"config": {
"allowed-protocol-mapper-types": [
"oidc-sha256-pairwise-sub-mapper",
"saml-role-list-mapper",
"oidc-address-mapper",
"oidc-usermodel-property-mapper",
"oidc-sha256-pairwise-sub-mapper",
"saml-user-attribute-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-full-name-mapper",
"saml-user-property-mapper",
"oidc-usermodel-attribute-mapper"
"saml-user-property-mapper"
]
}
},
@ -1541,13 +1541,13 @@
"config": {
"allowed-protocol-mapper-types": [
"oidc-sha256-pairwise-sub-mapper",
"saml-user-property-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-address-mapper",
"oidc-usermodel-property-mapper",
"oidc-full-name-mapper",
"oidc-address-mapper",
"oidc-usermodel-attribute-mapper",
"saml-user-property-mapper",
"saml-role-list-mapper",
"saml-user-attribute-mapper",
"oidc-full-name-mapper"
"saml-user-attribute-mapper"
]
}
},
@ -2200,7 +2200,7 @@
"name": "Terms and Conditions",
"providerId": "TERMS_AND_CONDITIONS",
"enabled": true,
"defaultAction": false,
"defaultAction": true,
"priority": 20,
"config": {}
},
@ -2307,7 +2307,7 @@
"cibaInterval": "5",
"realmReusableOtpCode": "false"
},
"keycloakVersion": "24.0.4",
"keycloakVersion": "24.0.5",
"userManagedAccessAllowed": false,
"clientProfiles": {
"profiles": []

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
import { readBuildOptions } from "../shared/buildOptions";
import { getBuildContext } from "../shared/buildContext";
import { exclude } from "tsafe/exclude";
import type { CliCommandOptions as CliCommandOptions_common } from "../main";
import { promptKeycloakVersion } from "../shared/promptKeycloakVersion";
import { readMetaInfKeycloakThemes } from "../shared/metaInfKeycloakThemes";
import { readMetaInfKeycloakThemes_fromJar } from "../shared/metaInfKeycloakThemes";
import { accountV1ThemeName, containerName } from "../shared/constants";
import { SemVer } from "../tools/SemVer";
import type { KeycloakVersionRange } from "../shared/KeycloakVersionRange";
@ -20,6 +21,9 @@ import * as runExclusive from "run-exclusive";
import { extractArchive } from "../tools/extractArchive";
import { appBuild } from "./appBuild";
import { keycloakifyBuild } from "./keycloakifyBuild";
import { isInside } from "../tools/isInside";
import { existsAsync } from "../tools/fs.existsAsync";
import { rm } from "../tools/fs.rm";
export type CliCommandOptions = CliCommandOptions_common & {
port: number;
@ -80,11 +84,11 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({ cliCommandOptions });
const buildContext = getBuildContext({ cliCommandOptions });
{
const { isAppBuildSuccess } = await appBuild({
buildOptions
buildContext
});
if (!isAppBuildSuccess) {
@ -97,8 +101,8 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
}
const { isKeycloakifyBuildSuccess } = await keycloakifyBuild({
doSkipBuildJars: false,
buildOptions
onlyBuildJarFileBasename: undefined,
buildContext
});
if (!isKeycloakifyBuildSuccess) {
@ -111,13 +115,31 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
}
}
const metaInfKeycloakThemes = readMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildOptions.keycloakifyBuildDirPath
});
const { doesImplementAccountTheme } = await (async () => {
const latestJarFilePath = fs
.readdirSync(buildContext.keycloakifyBuildDirPath)
.filter(fileBasename => fileBasename.endsWith(".jar"))
.map(fileBasename =>
pathJoin(buildContext.keycloakifyBuildDirPath, fileBasename)
)
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];
const doesImplementAccountTheme = metaInfKeycloakThemes.themes.some(
({ name }) => name === accountV1ThemeName
);
assert(latestJarFilePath !== undefined);
const metaInfKeycloakThemes = await readMetaInfKeycloakThemes_fromJar({
jarFilePath: latestJarFilePath
});
const mainThemeEntry = metaInfKeycloakThemes.themes.find(
({ name }) => name === buildContext.themeNames[0]
);
assert(mainThemeEntry !== undefined);
const doesImplementAccountTheme = mainThemeEntry.types.includes("account");
return { doesImplementAccountTheme };
})();
const { keycloakVersion, keycloakMajorNumber: keycloakMajorVersionNumber } =
await (async function getKeycloakMajor(): Promise<{
@ -137,8 +159,9 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
);
const { keycloakVersion } = await promptKeycloakVersion({
startingFromMajor: 17,
cacheDirPath: buildOptions.cacheDirPath
startingFromMajor: 18,
excludeMajorVersions: [22],
cacheDirPath: buildContext.cacheDirPath
});
console.log(`${keycloakVersion}`);
@ -171,7 +194,11 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
return "23" as const;
}
return "24-and-above" as const;
if (keycloakMajorVersionNumber === 24) {
return "24" as const;
}
return "25-and-above" as const;
})();
assert<
@ -205,6 +232,10 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const realmJsonFilePath = await (async () => {
if (cliCommandOptions.realmJsonFilePath !== undefined) {
if (cliCommandOptions.realmJsonFilePath === "none") {
return undefined;
}
console.log(
chalk.green(
`Using realm json file: ${cliCommandOptions.realmJsonFilePath}`
@ -259,67 +290,36 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
return pathJoin(dirPath, value);
})();
const jarFilePath = pathJoin(buildOptions.keycloakifyBuildDirPath, jarFileBasename);
const { doUseBuiltInAccountV1Theme } = await (async () => {
let doUseBuiltInAccountV1Theme = false;
const jarFilePath = pathJoin(buildContext.keycloakifyBuildDirPath, jarFileBasename);
async function extractThemeResourcesFromJar() {
await extractArchive({
archiveFilePath: jarFilePath,
onArchiveFile: async ({ relativeFilePathInArchive, readFile, earlyExit }) => {
for (const themeName of buildOptions.themeNames) {
if (
relativeFilePathInArchive ===
pathJoin("theme", themeName, "account", "theme.properties")
) {
if (
(await readFile())
.toString("utf8")
.includes("parent=keycloak")
) {
doUseBuiltInAccountV1Theme = true;
}
earlyExit();
}
onArchiveFile: async ({ relativeFilePathInArchive, writeFile }) => {
if (isInside({ dirPath: "theme", filePath: relativeFilePathInArchive })) {
await writeFile({
filePath: pathJoin(
buildContext.keycloakifyBuildDirPath,
relativeFilePathInArchive
)
});
}
}
});
}
return { doUseBuiltInAccountV1Theme };
})();
{
const destDirPath = pathJoin(buildContext.keycloakifyBuildDirPath, "theme");
if (await existsAsync(destDirPath)) {
await rm(destDirPath, { recursive: true });
}
}
const accountThemePropertyPatch = !doUseBuiltInAccountV1Theme
? undefined
: () => {
for (const themeName of buildOptions.themeNames) {
const filePath = pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme",
themeName,
"account",
"theme.properties"
);
await extractThemeResourcesFromJar();
const sourceCode = fs.readFileSync(filePath);
const jarFilePath_cacheDir = pathJoin(buildContext.cacheDirPath, jarFileBasename);
const modifiedSourceCode = Buffer.from(
sourceCode
.toString("utf8")
.replace(`parent=${accountV1ThemeName}`, "parent=keycloak"),
"utf8"
);
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
fs.writeFileSync(filePath, modifiedSourceCode);
}
};
accountThemePropertyPatch?.();
fs.copyFileSync(jarFilePath, jarFilePath_cacheDir);
try {
child_process.execSync(`docker rm --force ${containerName}`, {
@ -341,20 +341,28 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
"-v",
`${realmJsonFilePath}:/opt/keycloak/data/import/myrealm-realm.json`
]),
...["-v", `${jarFilePath}:/opt/keycloak/providers/keycloak-theme.jar`],
...[
"-v",
`${jarFilePath_cacheDir}:/opt/keycloak/providers/keycloak-theme.jar`
],
...(keycloakMajorVersionNumber <= 20
? ["-e", "JAVA_OPTS=-Dkeycloak.profile=preview"]
: []),
...[
...buildOptions.themeNames,
...(doUseBuiltInAccountV1Theme ? [] : [accountV1ThemeName])
...buildContext.themeNames,
...(fs.existsSync(
pathJoin(
buildContext.keycloakifyBuildDirPath,
"theme",
accountV1ThemeName
)
)
? [accountV1ThemeName]
: [])
]
.map(themeName => ({
localDirPath: pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
buildContext.keycloakifyBuildDirPath,
"theme",
themeName
),
@ -365,6 +373,17 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
`${localDirPath}:${containerDirPath}:rw`
])
.flat(),
...buildContext.environmentVariables
.map(({ name }) => ({ name, envValue: process.env[name] }))
.map(({ name, envValue }) =>
envValue === undefined ? undefined : { name, envValue }
)
.filter(exclude(undefined))
.map(({ name, envValue }) => [
"--env",
`${name}='${envValue.replace(/'/g, "'\\''")}'`
])
.flat(),
`quay.io/keycloak/keycloak:${keycloakVersion}`,
"start-dev",
...(21 <= keycloakMajorVersionNumber && keycloakMajorVersionNumber < 24
@ -373,7 +392,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
...(realmJsonFilePath === undefined ? [] : ["--import-realm"])
],
{
cwd: buildOptions.keycloakifyBuildDirPath
cwd: buildContext.keycloakifyBuildDirPath
}
] as const;
@ -385,7 +404,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
child.on("exit", process.exit);
const srcDirPath = pathJoin(buildOptions.reactAppRootDirPath, "src");
const srcDirPath = pathJoin(buildContext.projectDirPath, "src");
{
const handler = async (data: Buffer) => {
@ -417,7 +436,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
`- password: ${chalk.cyan.bold("password123")}`,
"",
`Watching for changes in ${chalk.bold(
`.${pathSep}${pathRelative(process.cwd(), buildOptions.reactAppRootDirPath)}`
`.${pathSep}${pathRelative(process.cwd(), buildContext.projectDirPath)}`
)}`
].join("\n")
);
@ -431,7 +450,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(chalk.cyan("Detected changes in the theme. Rebuilding ..."));
const { isAppBuildSuccess } = await appBuild({
buildOptions
buildContext
});
if (!isAppBuildSuccess) {
@ -439,15 +458,15 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
}
const { isKeycloakifyBuildSuccess } = await keycloakifyBuild({
doSkipBuildJars: true,
buildOptions
onlyBuildJarFileBasename: jarFileBasename,
buildContext
});
if (!isKeycloakifyBuildSuccess) {
return;
}
accountThemePropertyPatch?.();
await extractThemeResourcesFromJar();
console.log(chalk.green("Theme rebuilt and updated in Keycloak."));
});
@ -458,11 +477,11 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
.watch(
[
srcDirPath,
buildOptions.publicDirPath,
pathJoin(buildOptions.reactAppRootDirPath, "package.json"),
pathJoin(buildOptions.reactAppRootDirPath, "vite.config.ts"),
pathJoin(buildOptions.reactAppRootDirPath, "vite.config.js"),
pathJoin(buildOptions.reactAppRootDirPath, "index.html"),
buildContext.publicDirPath,
pathJoin(buildContext.projectDirPath, "package.json"),
pathJoin(buildContext.projectDirPath, "vite.config.ts"),
pathJoin(buildContext.projectDirPath, "vite.config.js"),
pathJoin(buildContext.projectDirPath, "index.html"),
pathJoin(getThisCodebaseRootDirPath(), "src")
],
{

View File

@ -0,0 +1,64 @@
// Convert a JavaScript string to UTF-16 encoding
function toUTF16(codePoint: number): string {
if (codePoint <= 0xffff) {
// BMP character
return "\\u" + codePoint.toString(16).padStart(4, "0");
} else {
// Non-BMP character
codePoint -= 0x10000;
let highSurrogate = (codePoint >> 10) + 0xd800;
let lowSurrogate = (codePoint % 0x400) + 0xdc00;
return (
"\\u" +
highSurrogate.toString(16).padStart(4, "0") +
"\\u" +
lowSurrogate.toString(16).padStart(4, "0")
);
}
}
// Escapes special characters for use in a .properties file
export function escapeStringForPropertiesFile(str: string): string {
let escapedStr = "";
for (const char of [...str]) {
const codePoint = char.codePointAt(0);
if (!codePoint) continue;
switch (char) {
case "\n":
escapedStr += "\\n";
break;
case "\r":
escapedStr += "\\r";
break;
case "\t":
escapedStr += "\\t";
break;
case "\\":
escapedStr += "\\\\";
break;
case ":":
escapedStr += "\\:";
break;
case "=":
escapedStr += "\\=";
break;
case "#":
escapedStr += "\\#";
break;
case "!":
escapedStr += "\\!";
break;
case "'":
escapedStr += "''";
break;
default:
if (codePoint > 0x7f) {
escapedStr += toUTF16(codePoint); // Non-ASCII characters
} else {
escapedStr += char; // ASCII character needs no escape
}
}
}
return escapedStr;
}

View File

@ -4,14 +4,14 @@ import { assert } from "tsafe/assert";
import * as fs from "fs";
export function getNpmWorkspaceRootDirPath(params: {
reactAppRootDirPath: string;
projectDirPath: string;
dependencyExpected: string;
}) {
const { reactAppRootDirPath, dependencyExpected } = params;
const { projectDirPath, dependencyExpected } = params;
const npmWorkspaceRootDirPath = (function callee(depth: number): string {
const cwd = pathResolve(
pathJoin(...[reactAppRootDirPath, ...Array(depth).fill("..")])
pathJoin(...[projectDirPath, ...Array(depth).fill("..")])
);
assert(cwd !== pathSep, "NPM workspace not found");

View File

@ -1,10 +1,11 @@
{
"extends": "../../tsproject.json",
"compilerOptions": {
"module": "CommonJS",
"target": "ES5",
"module": "ES2020",
"target": "ES2017",
"esModuleInterop": true,
"lib": ["es2015", "DOM", "ES2019.Object"],
"lib": ["es2015", "ES2019.Object"],
"moduleResolution": "node",
"outDir": "../../dist/bin",
"rootDir": "."
}

13
src/bin/update-kc-gen.ts Normal file
View File

@ -0,0 +1,13 @@
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import { generateKcGenTs } from "./shared/generateKcGenTs";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildContext = getBuildContext({
cliCommandOptions
});
await generateKcGenTs({ buildContext });
}

89
src/lib/getKcClsx.ts Normal file
View File

@ -0,0 +1,89 @@
import type { Param0 } from "tsafe";
import { type CxArg, clsx_withTransform } from "../tools/clsx_withTransform";
import { clsx } from "../tools/clsx";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
export function createGetKcClsx<ClassKey extends string>(params: {
defaultClasses: Record<ClassKey, string | undefined>;
}) {
const { defaultClasses } = params;
function areSameParams(
params1: Param0<typeof getKcClsx>,
params2: Param0<typeof getKcClsx>
): boolean {
if (params1.doUseDefaultCss !== params2.doUseDefaultCss) {
return false;
}
if (params1.classes === params2.classes) {
return true;
}
if (params1.classes === undefined || params2.classes === undefined) {
return false;
}
if (Object.keys(params1.classes).length !== Object.keys(params2.classes).length) {
return false;
}
for (const key in params1.classes) {
if (params1.classes[key] !== params2.classes[key]) {
return false;
}
}
return true;
}
let cache:
| {
params: Param0<typeof getKcClsx>;
result: ReturnType<typeof getKcClsx>;
}
| undefined = undefined;
function getKcClsx(params: {
doUseDefaultCss: boolean;
classes: Partial<Record<ClassKey, string>> | undefined;
}): { kcClsx: (...args: CxArg<ClassKey>[]) => string } {
// NOTE: We implement a cache here only so that getClassName can be stable across renders.
// We don't want to use useConstCallback because we want this to be useable outside of React.
use_cache: {
if (cache === undefined) {
break use_cache;
}
if (!areSameParams(cache.params, params)) {
break use_cache;
}
return cache.result;
}
const { classes, doUseDefaultCss } = params;
function kcClsx(...args: CxArg<ClassKey>[]): string {
return clsx_withTransform({
args,
transform: classKey => {
assert(is<ClassKey>(classKey));
return clsx(
classKey,
doUseDefaultCss ? defaultClasses[classKey] : undefined,
classes?.[classKey]
);
}
});
}
cache = { params, result: { kcClsx } };
return { kcClsx };
}
return { getKcClsx };
}

View File

@ -1,3 +0,0 @@
export const isStorybook =
typeof window === "object" &&
Object.keys(window).find(key => key.startsWith("__STORYBOOK")) !== undefined;

View File

@ -1,27 +0,0 @@
import { clsx } from "keycloakify/tools/clsx";
import { useConstCallback } from "keycloakify/tools/useConstCallback";
export function createUseClassName<ClassKey extends string>(params: {
defaultClasses: Record<ClassKey, string | undefined>;
}) {
const { defaultClasses } = params;
function useGetClassName(params: {
doUseDefaultCss: boolean;
classes: Partial<Record<ClassKey, string>> | undefined;
}) {
const { classes, doUseDefaultCss } = params;
const getClassName = useConstCallback((classKey: ClassKey): string => {
return clsx(
classKey,
doUseDefaultCss ? defaultClasses[classKey] : undefined,
classes?.[classKey]
);
});
return { getClassName };
}
return { useGetClassName };
}

View File

@ -2,9 +2,9 @@ import { lazy, Suspense } from "react";
import { assert, type Equals } from "tsafe/assert";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "./KcContext";
import type { I18n } from "./i18n";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFields";
import type { I18n } from "keycloakify/login/i18n";
import type { KcContext } from "keycloakify/login/KcContext";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps";
const Login = lazy(() => import("keycloakify/login/pages/Login"));
const Register = lazy(() => import("keycloakify/login/pages/Register"));
@ -41,11 +41,12 @@ const LoginResetOtp = lazy(() => import("keycloakify/login/pages/LoginResetOtp")
const LoginX509Info = lazy(() => import("keycloakify/login/pages/LoginX509Info"));
const WebauthnError = lazy(() => import("keycloakify/login/pages/WebauthnError"));
type FallbackProps = PageProps<KcContext, I18n> & {
type DefaultPageProps = PageProps<KcContext, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
doMakeUserConfirmPassword: boolean;
};
export default function Fallback(props: FallbackProps) {
export default function DefaultPage(props: DefaultPageProps) {
const { kcContext, ...rest } = props;
return (

View File

@ -10,20 +10,20 @@ import type { Equals } from "tsafe";
import type { MessageKey } from "../i18n/i18n";
export type ExtendKcContext<
KcContextExtraProperties extends { properties?: Record<string, string | undefined> },
KcContextExtraPropertiesPerPage extends Record<string, Record<string, unknown>>
KcContextExtension extends { properties?: Record<string, string | undefined> },
KcContextExtensionPerPage extends Record<string, Record<string, unknown>>
> = ValueOf<{
[PageId in keyof KcContextExtraPropertiesPerPage | KcContext["pageId"]]: Extract<
[PageId in keyof KcContextExtensionPerPage | KcContext["pageId"]]: Extract<
KcContext,
{ pageId: PageId }
> extends never
? KcContext.Common &
KcContextExtraProperties & {
KcContextExtension & {
pageId: PageId;
} & KcContextExtraPropertiesPerPage[PageId]
} & KcContextExtensionPerPage[PageId]
: Extract<KcContext, { pageId: PageId }> &
KcContextExtraProperties &
KcContextExtraPropertiesPerPage[PageId];
KcContextExtension &
KcContextExtensionPerPage[PageId];
}>;
/** Take theses type definition with a grain of salt.
@ -209,17 +209,13 @@ export declare namespace KcContext {
export type Register = Common & {
pageId: "register.ftl";
profile: UserProfile;
passwordPolicies?: PasswordPolicies;
url: {
registrationAction: string;
};
passwordRequired: boolean;
recaptchaRequired: boolean;
recaptchaSiteKey?: string;
/**
* Theses values are added by: https://github.com/jcputney/keycloak-theme-additional-info-extension
* A Keycloak Java extension used as dependency in Keycloakify.
*/
passwordPolicies?: PasswordPolicies;
termsAcceptanceRequired?: boolean;
};
@ -479,16 +475,19 @@ export declare namespace KcContext {
export type LoginUpdateProfile = Common & {
pageId: "login-update-profile.ftl";
profile: UserProfile;
passwordPolicies?: PasswordPolicies;
};
export type IdpReviewUserProfile = Common & {
pageId: "idp-review-user-profile.ftl";
profile: UserProfile;
passwordPolicies?: PasswordPolicies;
};
export type UpdateEmail = Common & {
pageId: "update-email.ftl";
profile: UserProfile;
passwordPolicies?: PasswordPolicies;
};
export type SelectAuthenticator = Common & {
@ -752,6 +751,10 @@ export declare namespace Validators {
assert<Equals<OnlyInExpected, never>>();
}
/**
* Theses values are added by: https://github.com/jcputney/keycloak-theme-additional-info-extension
* A Keycloak Java extension used as dependency in Keycloakify.
*/
export type PasswordPolicies = {
/** The minimum length of the password */
length?: number;

View File

@ -7,43 +7,32 @@ import { kcContextMocks, kcContextCommonMock } from "./kcContextMocks";
import { exclude } from "tsafe/exclude";
export function createGetKcContextMock<
KcContextExtraProperties extends { properties?: Record<string, string | undefined> },
KcContextExtraPropertiesPerPage extends Record<
`${string}.ftl`,
Record<string, unknown>
>
KcContextExtension extends { properties?: Record<string, string | undefined> },
KcContextExtensionPerPage extends Record<`${string}.ftl`, Record<string, unknown>>
>(params: {
kcContextExtraProperties: KcContextExtraProperties;
kcContextExtraPropertiesPerPage: KcContextExtraPropertiesPerPage;
overrides?: DeepPartial<KcContextExtraProperties & KcContextBase.Common>;
kcContextExtension: KcContextExtension;
kcContextExtensionPerPage: KcContextExtensionPerPage;
overrides?: DeepPartial<KcContextExtension & KcContextBase.Common>;
overridesPerPage?: {
[PageId in
| LoginThemePageId
| keyof KcContextExtraPropertiesPerPage]?: DeepPartial<
[PageId in LoginThemePageId | keyof KcContextExtensionPerPage]?: DeepPartial<
Extract<
ExtendKcContext<
KcContextExtraProperties,
KcContextExtraPropertiesPerPage
>,
ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>,
{ pageId: PageId }
>
>;
};
}) {
const {
kcContextExtraProperties,
kcContextExtraPropertiesPerPage,
kcContextExtension,
kcContextExtensionPerPage,
overrides: overrides_global,
overridesPerPage: overridesPerPage_global
} = params;
type KcContext = ExtendKcContext<
KcContextExtraProperties,
KcContextExtraPropertiesPerPage
>;
type KcContext = ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>;
function getKcContextMock<
PageId extends LoginThemePageId | keyof KcContextExtraPropertiesPerPage
PageId extends LoginThemePageId | keyof KcContextExtensionPerPage
>(params: {
pageId: PageId;
overrides?: DeepPartial<Extract<KcContext, { pageId: PageId }>>;
@ -58,8 +47,8 @@ export function createGetKcContextMock<
);
[
kcContextExtraProperties,
kcContextExtraPropertiesPerPage[pageId],
kcContextExtension,
kcContextExtensionPerPage[pageId],
overrides_global,
overridesPerPage_global?.[pageId],
overrides

View File

@ -2,12 +2,12 @@ import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import type { TemplateProps } from "keycloakify/login/TemplateProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import type { KcContext } from "./KcContext";
import type { I18n } from "./i18n";
import type { KcContext } from "./KcContext";
export default function Template(props: TemplateProps<KcContext, I18n>) {
const {
@ -27,7 +27,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
children
} = props;
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
@ -39,12 +39,12 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
useSetClassName({
qualifiedName: "html",
className: getClassName("kcHtmlClass")
className: kcClsx("kcHtmlClass")
});
useSetClassName({
qualifiedName: "body",
className: bodyClassName ?? getClassName("kcBodyClass")
className: bodyClassName ?? kcClsx("kcBodyClass")
});
useEffect(() => {
@ -116,19 +116,19 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
}
return (
<div className={getClassName("kcLoginClass")}>
<div id="kc-header" className={getClassName("kcHeaderClass")}>
<div id="kc-header-wrapper" className={getClassName("kcHeaderWrapperClass")}>
<div className={kcClsx("kcLoginClass")}>
<div id="kc-header" className={kcClsx("kcHeaderClass")}>
<div id="kc-header-wrapper" className={kcClsx("kcHeaderWrapperClass")}>
{msg("loginTitleHtml", realm.displayNameHtml)}
</div>
</div>
<div className={getClassName("kcFormCardClass")}>
<header className={getClassName("kcFormHeaderClass")}>
<div className={kcClsx("kcFormCardClass")}>
<header className={kcClsx("kcFormHeaderClass")}>
{realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && (
<div className={getClassName("kcLocaleMainClass")} id="kc-locale">
<div id="kc-locale-wrapper" className={getClassName("kcLocaleWrapperClass")}>
<div id="kc-locale-dropdown" className={clsx("menu-button-links", getClassName("kcLocaleDropDownClass"))}>
<div className={kcClsx("kcLocaleMainClass")} id="kc-locale">
<div id="kc-locale-wrapper" className={kcClsx("kcLocaleWrapperClass")}>
<div id="kc-locale-dropdown" className={clsx("menu-button-links", kcClsx("kcLocaleDropDownClass"))}>
<button
tabIndex={1}
id="kc-current-locale-link"
@ -145,14 +145,14 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
aria-labelledby="kc-current-locale-link"
aria-activedescendant=""
id="language-switch1"
className={getClassName("kcLocaleListClass")}
className={kcClsx("kcLocaleListClass")}
>
{locale.supported.map(({ languageTag }, i) => (
<li key={languageTag} className={getClassName("kcLocaleListItemClass")} role="none">
<li key={languageTag} className={kcClsx("kcLocaleListItemClass")} role="none">
<a
role="menuitem"
id={`language-${i + 1}`}
className={getClassName("kcLocaleItemClass")}
className={kcClsx("kcLocaleItemClass")}
href={getChangeLocalUrl(languageTag)}
>
{labelBySupportedLanguageTag[languageTag]}
@ -166,8 +166,8 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
)}
{!(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
displayRequiredFields ? (
<div className={getClassName("kcContentWrapperClass")}>
<div className={clsx(getClassName("kcLabelWrapperClass"), "subtitle")}>
<div className={kcClsx("kcContentWrapperClass")}>
<div className={clsx(kcClsx("kcLabelWrapperClass"), "subtitle")}>
<span className="subtitle">
<span className="required">*</span>
{msg("requiredFields")}
@ -181,19 +181,19 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<h1 id="kc-page-title">{headerNode}</h1>
)
) : displayRequiredFields ? (
<div className={getClassName("kcContentWrapperClass")}>
<div className={clsx(getClassName("kcLabelWrapperClass"), "subtitle")}>
<div className={kcClsx("kcContentWrapperClass")}>
<div className={clsx(kcClsx("kcLabelWrapperClass"), "subtitle")}>
<span className="subtitle">
<span className="required">*</span> {msg("requiredFields")}
</span>
</div>
<div className="col-md-10">
{showUsernameNode}
<div id="kc-username" className={getClassName("kcFormGroupClass")}>
<div id="kc-username" className={kcClsx("kcFormGroupClass")}>
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
<div className="kc-login-tooltip">
<i className={getClassName("kcResetFlowIcon")}></i>
<i className={kcClsx("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
@ -203,11 +203,11 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
) : (
<>
{showUsernameNode}
<div id="kc-username" className={getClassName("kcFormGroupClass")}>
<div id="kc-username" className={kcClsx("kcFormGroupClass")}>
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
<div className="kc-login-tooltip">
<i className={getClassName("kcResetFlowIcon")}></i>
<i className={kcClsx("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
@ -222,18 +222,18 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<div
className={clsx(
`alert-${message.type}`,
getClassName("kcAlertClass"),
kcClsx("kcAlertClass"),
`pf-m-${message?.type === "error" ? "danger" : message.type}`
)}
>
<div className="pf-c-alert__icon">
{message.type === "success" && <span className={getClassName("kcFeedbackSuccessIcon")}></span>}
{message.type === "warning" && <span className={getClassName("kcFeedbackWarningIcon")}></span>}
{message.type === "error" && <span className={getClassName("kcFeedbackErrorIcon")}></span>}
{message.type === "info" && <span className={getClassName("kcFeedbackInfoIcon")}></span>}
{message.type === "success" && <span className={kcClsx("kcFeedbackSuccessIcon")}></span>}
{message.type === "warning" && <span className={kcClsx("kcFeedbackWarningIcon")}></span>}
{message.type === "error" && <span className={kcClsx("kcFeedbackErrorIcon")}></span>}
{message.type === "info" && <span className={kcClsx("kcFeedbackInfoIcon")}></span>}
</div>
<span
className={getClassName("kcAlertTitleClass")}
className={kcClsx("kcAlertTitleClass")}
dangerouslySetInnerHTML={{
__html: message.summary
}}
@ -243,8 +243,8 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
{children}
{auth !== undefined && auth.showTryAnotherWayLink && (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<input type="hidden" name="tryAnotherWay" value="on" />
<a
href="#"
@ -262,8 +262,8 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
)}
{socialProvidersNode}
{displayInfo && (
<div id="kc-info" className={getClassName("kcSignUpClass")}>
<div id="kc-info-wrapper" className={getClassName("kcInfoAreaWrapperClass")}>
<div id="kc-info" className={kcClsx("kcSignUpClass")}>
<div id="kc-info-wrapper" className={kcClsx("kcInfoAreaWrapperClass")}>
{infoNode}
</div>
</div>

View File

@ -1,15 +1,11 @@
import type { ReactNode } from "react";
import type { KcContext } from "./KcContext";
import type { I18n } from "./i18n";
export type TemplateProps<
KcContext extends KcContext.Common,
I18nExtended extends I18n
> = {
export type TemplateProps<KcContext, I18n> = {
kcContext: KcContext;
i18n: I18nExtended;
i18n: I18n;
doUseDefaultCss: boolean;
classes?: Partial<Record<ClassKey, string>>;
children: ReactNode;
displayInfo?: boolean;
displayMessage?: boolean;
@ -21,8 +17,6 @@ export type TemplateProps<
infoNode?: ReactNode;
documentTitle?: string;
bodyClassName?: string;
children: ReactNode;
};
export type ClassKey =

View File

@ -1,38 +1,19 @@
import { useEffect, useReducer, Fragment } from "react";
import { assert } from "tsafe/assert";
import type { ClassKey } from "keycloakify/login/TemplateProps";
import type { KcClsx } from "keycloakify/login/lib/kcClsx";
import {
useUserProfileForm,
getButtonToDisplayForMultivaluedAttributeField,
type KcContextLike,
type FormAction,
type FormFieldError
} from "keycloakify/login/lib/useUserProfileForm";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps";
import type { Attribute } from "keycloakify/login/KcContext";
import type { KcContext } from "./KcContext";
import type { I18n } from "./i18n";
export type UserProfileFormFieldsProps = {
kcContext: KcContextLike;
i18n: I18n;
getClassName: (classKey: ClassKey) => string;
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
BeforeField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
AfterField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
};
type BeforeAfterFieldProps = {
attribute: Attribute;
dispatchFormAction: React.Dispatch<FormAction>;
displayableErrors: FormFieldError[];
i18n: I18n;
valueOrValues: string | string[];
};
// NOTE: Enabled by default but it's a UX best practice to set it to false.
const doMakeUserConfirmPassword = true;
export default function UserProfileFormFields(props: UserProfileFormFieldsProps) {
const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props;
export default function UserProfileFormFields(props: UserProfileFormFieldsProps<KcContext, I18n>) {
const { kcContext, i18n, kcClsx, onIsFormSubmittableValueChange, doMakeUserConfirmPassword, BeforeField, AfterField } = props;
const { advancedMsg } = i18n;
@ -56,32 +37,33 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps)
{formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => {
return (
<Fragment key={attribute.name}>
<GroupLabel attribute={attribute} getClassName={getClassName} i18n={i18n} groupNameRef={groupNameRef} />
<GroupLabel attribute={attribute} groupNameRef={groupNameRef} i18n={i18n} kcClsx={kcClsx} />
{BeforeField !== undefined && (
<BeforeField
attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
i18n={i18n}
valueOrValues={valueOrValues}
kcClsx={kcClsx}
i18n={i18n}
/>
)}
<div
className={getClassName("kcFormGroupClass")}
className={kcClsx("kcFormGroupClass")}
style={{
display: attribute.name === "password-confirm" && !doMakeUserConfirmPassword ? "none" : undefined
}}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor={attribute.name} className={getClassName("kcLabelClass")}>
<div className={kcClsx("kcLabelWrapperClass")}>
<label htmlFor={attribute.name} className={kcClsx("kcLabelClass")}>
{advancedMsg(attribute.displayName ?? "")}
</label>
{attribute.required && <>*</>}
</div>
<div className={getClassName("kcInputWrapperClass")}>
<div className={kcClsx("kcInputWrapperClass")}>
{attribute.annotations.inputHelperTextBefore !== undefined && (
<div
className={getClassName("kcInputHelperTextBeforeClass")}
className={kcClsx("kcInputHelperTextBeforeClass")}
id={`form-help-text-before-${attribute.name}`}
aria-live="polite"
>
@ -92,19 +74,14 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps)
attribute={attribute}
valueOrValues={valueOrValues}
displayableErrors={displayableErrors}
formValidationDispatch={dispatchFormAction}
getClassName={getClassName}
dispatchFormAction={dispatchFormAction}
kcClsx={kcClsx}
i18n={i18n}
/>
<FieldErrors
attribute={attribute}
getClassName={getClassName}
displayableErrors={displayableErrors}
fieldIndex={undefined}
/>
<FieldErrors attribute={attribute} displayableErrors={displayableErrors} kcClsx={kcClsx} fieldIndex={undefined} />
{attribute.annotations.inputHelperTextAfter !== undefined && (
<div
className={getClassName("kcInputHelperTextAfterClass")}
className={kcClsx("kcInputHelperTextAfterClass")}
id={`form-help-text-after-${attribute.name}`}
aria-live="polite"
>
@ -117,8 +94,9 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps)
attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
i18n={i18n}
valueOrValues={valueOrValues}
kcClsx={kcClsx}
i18n={i18n}
/>
)}
{/* NOTE: Downloading of html5DataAnnotations scripts is done in the useUserProfileForm hook */}
@ -133,13 +111,13 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps)
function GroupLabel(props: {
attribute: Attribute;
getClassName: UserProfileFormFieldsProps["getClassName"];
i18n: I18n;
groupNameRef: {
current: string;
};
i18n: I18n;
kcClsx: KcClsx;
}) {
const { attribute, getClassName, i18n, groupNameRef } = props;
const { attribute, groupNameRef, i18n, kcClsx } = props;
const { advancedMsg } = i18n;
@ -151,7 +129,7 @@ function GroupLabel(props: {
return (
<div
className={getClassName("kcFormGroupClass")}
className={kcClsx("kcFormGroupClass")}
{...Object.fromEntries(Object.entries(attribute.group.html5DataAnnotations).map(([key, value]) => [`data-${key}`, value]))}
>
{(() => {
@ -159,8 +137,8 @@ function GroupLabel(props: {
const groupHeaderText = groupDisplayHeader !== "" ? advancedMsg(groupDisplayHeader) : attribute.group.name;
return (
<div className={getClassName("kcContentWrapperClass")}>
<label id={`header-${attribute.group.name}`} className={getClassName("kcFormGroupHeader")}>
<div className={kcClsx("kcContentWrapperClass")}>
<label id={`header-${attribute.group.name}`} className={kcClsx("kcFormGroupHeader")}>
{groupHeaderText}
</label>
</div>
@ -173,8 +151,8 @@ function GroupLabel(props: {
const groupDescriptionText = advancedMsg(groupDisplayDescription);
return (
<div className={getClassName("kcLabelWrapperClass")}>
<label id={`description-${attribute.group.name}`} className={getClassName("kcLabelClass")}>
<div className={kcClsx("kcLabelWrapperClass")}>
<label id={`description-${attribute.group.name}`} className={kcClsx("kcLabelClass")}>
{groupDescriptionText}
</label>
</div>
@ -191,13 +169,8 @@ function GroupLabel(props: {
return null;
}
function FieldErrors(props: {
attribute: Attribute;
getClassName: UserProfileFormFieldsProps["getClassName"];
displayableErrors: FormFieldError[];
fieldIndex: number | undefined;
}) {
const { attribute, getClassName, fieldIndex } = props;
function FieldErrors(props: { attribute: Attribute; displayableErrors: FormFieldError[]; fieldIndex: number | undefined; kcClsx: KcClsx }) {
const { attribute, fieldIndex, kcClsx } = props;
const displayableErrors = props.displayableErrors.filter(error => error.fieldIndex === fieldIndex);
@ -208,7 +181,7 @@ function FieldErrors(props: {
return (
<span
id={`input-error-${attribute.name}${fieldIndex === undefined ? "" : `-${fieldIndex}`}`}
className={getClassName("kcInputErrorMessageClass")}
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
>
{displayableErrors
@ -227,9 +200,9 @@ type InputFiledByTypeProps = {
attribute: Attribute;
valueOrValues: string | string[];
displayableErrors: FormFieldError[];
formValidationDispatch: React.Dispatch<FormAction>;
getClassName: UserProfileFormFieldsProps["getClassName"];
dispatchFormAction: React.Dispatch<FormAction>;
i18n: I18n;
kcClsx: KcClsx;
};
function InputFiledByType(props: InputFiledByTypeProps) {
@ -259,7 +232,7 @@ function InputFiledByType(props: InputFiledByTypeProps) {
if (attribute.name === "password" || attribute.name === "password-confirm") {
return (
<PasswordWrapper getClassName={props.getClassName} i18n={props.i18n} passwordInputId={attribute.name}>
<PasswordWrapper kcClsx={props.kcClsx} i18n={props.i18n} passwordInputId={attribute.name}>
{inputNode}
</PasswordWrapper>
);
@ -270,8 +243,8 @@ function InputFiledByType(props: InputFiledByTypeProps) {
}
}
function PasswordWrapper(props: { getClassName: (classKey: ClassKey) => string; i18n: I18n; passwordInputId: string; children: JSX.Element }) {
const { getClassName, i18n, passwordInputId, children } = props;
function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: string; children: JSX.Element }) {
const { kcClsx, i18n, passwordInputId, children } = props;
const { msgStr } = i18n;
@ -286,26 +259,23 @@ function PasswordWrapper(props: { getClassName: (classKey: ClassKey) => string;
}, [isPasswordRevealed]);
return (
<div className={getClassName("kcInputGroup")}>
<div className={kcClsx("kcInputGroup")}>
{children}
<button
type="button"
className={getClassName("kcFormPasswordVisibilityButtonClass")}
className={kcClsx("kcFormPasswordVisibilityButtonClass")}
aria-label={msgStr(isPasswordRevealed ? "hidePassword" : "showPassword")}
aria-controls={passwordInputId}
onClick={toggleIsPasswordRevealed}
>
<i
className={getClassName(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")}
aria-hidden
/>
<i className={kcClsx(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")} aria-hidden />
</button>
</div>
);
}
function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefined }) {
const { attribute, fieldIndex, getClassName, formValidationDispatch, valueOrValues, i18n, displayableErrors } = props;
const { attribute, fieldIndex, kcClsx, dispatchFormAction, valueOrValues, i18n, displayableErrors } = props;
return (
<>
@ -331,7 +301,7 @@ function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefine
return valueOrValues;
})()}
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={displayableErrors.find(error => error.fieldIndex === fieldIndex) !== undefined}
disabled={attribute.readOnly}
autoComplete={attribute.autocomplete}
@ -349,7 +319,7 @@ function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefine
step={attribute.annotations.inputTypeStep}
{...Object.fromEntries(Object.entries(attribute.html5DataAnnotations ?? {}).map(([key, value]) => [`data-${key}`, value]))}
onChange={event =>
formValidationDispatch({
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: (() => {
@ -370,7 +340,7 @@ function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefine
})
}
onBlur={() =>
props.formValidationDispatch({
dispatchFormAction({
action: "focus lost",
name: attribute.name,
fieldIndex: fieldIndex
@ -388,17 +358,12 @@ function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefine
return (
<>
<FieldErrors
attribute={attribute}
getClassName={getClassName}
displayableErrors={displayableErrors}
fieldIndex={fieldIndex}
/>
<FieldErrors attribute={attribute} kcClsx={kcClsx} displayableErrors={displayableErrors} fieldIndex={fieldIndex} />
<AddRemoveButtonsMultiValuedAttribute
attribute={attribute}
values={values}
fieldIndex={fieldIndex}
dispatchFormAction={formValidationDispatch}
dispatchFormAction={dispatchFormAction}
i18n={i18n}
/>
</>
@ -465,7 +430,7 @@ function AddRemoveButtonsMultiValuedAttribute(props: {
}
function InputTagSelects(props: InputFiledByTypeProps) {
const { attribute, formValidationDispatch, getClassName, valueOrValues } = props;
const { attribute, dispatchFormAction, kcClsx, valueOrValues } = props;
const { advancedMsg } = props.i18n;
@ -478,16 +443,16 @@ function InputTagSelects(props: InputFiledByTypeProps) {
case "select-radiobuttons":
return {
inputType: "radio",
classDiv: getClassName("kcInputClassRadio"),
classInput: getClassName("kcInputClassRadioInput"),
classLabel: getClassName("kcInputClassRadioLabel")
classDiv: kcClsx("kcInputClassRadio"),
classInput: kcClsx("kcInputClassRadioInput"),
classLabel: kcClsx("kcInputClassRadioLabel")
};
case "multiselect-checkboxes":
return {
inputType: "checkbox",
classDiv: getClassName("kcInputClassCheckbox"),
classInput: getClassName("kcInputClassCheckboxInput"),
classLabel: getClassName("kcInputClassCheckboxLabel")
classDiv: kcClsx("kcInputClassCheckbox"),
classInput: kcClsx("kcInputClassCheckboxInput"),
classLabel: kcClsx("kcInputClassCheckboxLabel")
};
}
})();
@ -530,7 +495,7 @@ function InputTagSelects(props: InputFiledByTypeProps) {
disabled={attribute.readOnly}
checked={valueOrValues instanceof Array ? valueOrValues.includes(option) : valueOrValues === option}
onChange={event =>
formValidationDispatch({
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: (() => {
@ -553,7 +518,7 @@ function InputTagSelects(props: InputFiledByTypeProps) {
})
}
onBlur={() =>
formValidationDispatch({
dispatchFormAction({
action: "focus lost",
name: attribute.name,
fieldIndex: undefined
@ -562,7 +527,7 @@ function InputTagSelects(props: InputFiledByTypeProps) {
/>
<label
htmlFor={`${attribute.name}-${option}`}
className={`${classLabel}${attribute.readOnly ? ` ${getClassName("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
className={`${classLabel}${attribute.readOnly ? ` ${kcClsx("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
>
{advancedMsg(option)}
</label>
@ -573,7 +538,7 @@ function InputTagSelects(props: InputFiledByTypeProps) {
}
function TextareaTag(props: InputFiledByTypeProps) {
const { attribute, formValidationDispatch, getClassName, displayableErrors, valueOrValues } = props;
const { attribute, dispatchFormAction, kcClsx, displayableErrors, valueOrValues } = props;
assert(typeof valueOrValues === "string");
@ -583,7 +548,7 @@ function TextareaTag(props: InputFiledByTypeProps) {
<textarea
id={attribute.name}
name={attribute.name}
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly}
cols={attribute.annotations.inputTypeCols === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeCols}`)}
@ -591,14 +556,14 @@ function TextareaTag(props: InputFiledByTypeProps) {
maxLength={attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`)}
value={value}
onChange={event =>
formValidationDispatch({
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: event.target.value
})
}
onBlur={() =>
formValidationDispatch({
dispatchFormAction({
action: "focus lost",
name: attribute.name,
fieldIndex: undefined
@ -609,7 +574,7 @@ function TextareaTag(props: InputFiledByTypeProps) {
}
function SelectTag(props: InputFiledByTypeProps) {
const { attribute, formValidationDispatch, getClassName, displayableErrors, i18n, valueOrValues } = props;
const { attribute, dispatchFormAction, kcClsx, displayableErrors, i18n, valueOrValues } = props;
const { advancedMsg } = i18n;
@ -619,14 +584,14 @@ function SelectTag(props: InputFiledByTypeProps) {
<select
id={attribute.name}
name={attribute.name}
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly}
multiple={isMultiple}
size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)}
value={valueOrValues}
onChange={event =>
formValidationDispatch({
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: (() => {
@ -639,7 +604,7 @@ function SelectTag(props: InputFiledByTypeProps) {
})
}
onBlur={() =>
formValidationDispatch({
dispatchFormAction({
action: "focus lost",
name: attribute.name,
fieldIndex: undefined

View File

@ -0,0 +1,22 @@
import { type FormAction, type FormFieldError } from "keycloakify/login/lib/useUserProfileForm";
import type { KcClsx } from "keycloakify/login/lib/kcClsx";
import type { Attribute } from "keycloakify/login/KcContext";
export type UserProfileFormFieldsProps<KcContext = any, I18n = any> = {
kcContext: Extract<KcContext, { profile: unknown }>;
i18n: I18n;
kcClsx: KcClsx;
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
doMakeUserConfirmPassword: boolean;
BeforeField?: (props: BeforeAfterFieldProps<I18n>) => JSX.Element | null;
AfterField?: (props: BeforeAfterFieldProps<I18n>) => JSX.Element | null;
};
type BeforeAfterFieldProps<I18n> = {
attribute: Attribute;
dispatchFormAction: React.Dispatch<FormAction>;
displayableErrors: FormFieldError[];
valueOrValues: string | string[];
kcClsx: KcClsx;
i18n: I18n;
};

View File

@ -1,7 +1,7 @@
import "keycloakify/tools/Object.fromEntries";
import { useEffect, useState, useRef } from "react";
import { useEffect, useState } from "react";
import { assert } from "tsafe/assert";
import fallbackMessages from "./baseMessages/en";
import messages_fallbackLanguage from "./baseMessages/en";
import { getMessages } from "./baseMessages";
import type { KcContext } from "../KcContext";
import { Reflect } from "tsafe/Reflect";
@ -18,7 +18,7 @@ export type KcContextLike = {
assert<KcContext extends KcContextLike ? true : false>();
export type MessageKey = keyof typeof fallbackMessages;
export type MessageKey = keyof typeof messages_fallbackLanguage;
export type GenericI18n<MessageKey extends string> = {
/**
@ -80,183 +80,262 @@ export type GenericI18n<MessageKey extends string> = {
* See advancedMsg() but instead of returning a JSX.Element it returns a string.
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
};
export type I18n = GenericI18n<MessageKey>;
function createGetI18n<ExtraMessageKey extends string = never>(extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string } }) {
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
function getI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
use_cache: {
const cachedResult = cachedResultByKcContext.get(kcContext);
if (cachedResult === undefined) {
break use_cache;
}
return cachedResult;
}
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocalUrl" | "labelBySupportedLanguageTag"> = {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag,
getChangeLocalUrl: 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`);
return targetSupportedLocale.url;
},
labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]))
};
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey, ExtraMessageKey>({
messages_fallbackLanguage,
extraMessages_fallbackLanguage: extraMessages[fallbackLanguageTag],
extraMessages: extraMessages[partialI18n.currentLanguageTag],
__localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile
});
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === fallbackLanguageTag;
const result: Result = {
i18n: {
...partialI18n,
...createI18nTranslationFunctions({ messages: undefined }),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages = await getMessages(partialI18n.currentLanguageTag);
const i18n_currentLanguage: I18n = {
...partialI18n,
...createI18nTranslationFunctions({ messages }),
isFetchingTranslations: false
};
// NOTE: This promise.resolve is just because without it we TypeScript
// gives a Variable 'result' is used before being assigned. error
await Promise.resolve().then(() => {
result.i18n = i18n_currentLanguage;
result.prI18n_currentLanguage = undefined;
});
return i18n_currentLanguage;
})()
};
cachedResultByKcContext.set(kcContext, result);
return result;
}
return { getI18n };
}
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
[languageTag: string]: { [key in ExtraMessageKey]: string };
}) {
function useI18n(params: { kcContext: KcContextLike }): GenericI18n<MessageKey | ExtraMessageKey> | null {
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
const { getI18n } = createGetI18n(extraMessages);
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params;
const [i18n, setI18n] = useState<GenericI18n<ExtraMessageKey | MessageKey> | undefined>(undefined);
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
const refHasStartedFetching = useRef(false);
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(i18n);
useEffect(() => {
if (refHasStartedFetching.current) {
return;
}
let isActive = true;
refHasStartedFetching.current = true;
(async () => {
const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {};
setI18n({
...createI18nTranslationFunctions({
fallbackMessages: {
...fallbackMessages,
...(extraMessages[fallbackLanguageTag] ?? {})
} as any,
messages: {
...(await getMessages(currentLanguageTag)),
...(extraMessages[currentLanguageTag] ?? {})
} as any,
__localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile
}),
currentLanguageTag,
getChangeLocalUrl: 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`);
return targetSupportedLocale.url;
},
labelBySupportedLanguageTag: Object.fromEntries(
(kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])
)
});
})();
}, []);
return i18n ?? null;
}
return {
useI18n,
ofTypeI18n: Reflect<GenericI18n<MessageKey | ExtraMessageKey>>()
};
}
function createI18nTranslationFunctions<MessageKey extends string>(params: {
fallbackMessages: Record<MessageKey, string>;
messages: Record<MessageKey, string>;
__localizationRealmOverridesUserProfile: Record<string, string> | undefined;
}): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const { fallbackMessages, messages, __localizationRealmOverridesUserProfile } = params;
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];
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) {
prI18n_currentLanguage?.then(i18n => {
if (!isActive) {
return;
}
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
setI18n_toReturn(i18n);
});
return messageWithArgsInjected;
})();
return () => {
isActive = false;
};
}, []);
return doRenderAsHtml ? (
<span
// NOTE: The message is trusted. The arguments are not but are escaped.
dangerouslySetInnerHTML={{
__html: messageWithArgsInjectedIfAny
}}
/>
) : (
messageWithArgsInjectedIfAny
);
return { i18n: i18n_toReturn };
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
const { key, args, doRenderAsHtml } = props;
return { useI18n, ofTypeI18n: Reflect<I18n>() };
}
if (__localizationRealmOverridesUserProfile !== undefined && key in __localizationRealmOverridesUserProfile) {
const resolvedMessage = __localizationRealmOverridesUserProfile[key];
function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraMessageKey extends string>(params: {
messages_fallbackLanguage: Record<MessageKey, string>;
extraMessages_fallbackLanguage: Record<ExtraMessageKey, string> | undefined;
extraMessages: Partial<Record<ExtraMessageKey, string>> | undefined;
__localizationRealmOverridesUserProfile: Record<string, string> | undefined;
}) {
const { __localizationRealmOverridesUserProfile, extraMessages } = params;
const messages_fallbackLanguage = {
...params.messages_fallbackLanguage,
...params.extraMessages_fallbackLanguage
};
function createI18nTranslationFunctions(params: {
messages: Partial<Record<MessageKey, string>> | undefined;
}): Pick<GenericI18n<MessageKey | ExtraMessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const messages = {
...params.messages,
...extraMessages
};
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] ?? (messages_fallbackLanguage 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.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
});
return messageWithArgsInjected;
})();
return doRenderAsHtml ? (
<span
// NOTE: The message is trusted. The arguments are not but are escaped.
dangerouslySetInnerHTML={{
__html: resolvedMessage
__html: messageWithArgsInjectedIfAny
}}
/>
) : (
resolvedMessage
messageWithArgsInjectedIfAny
);
}
if (!/\$\{[^}]+\}/.test(key)) {
const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml });
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
const { key, args, doRenderAsHtml } = props;
if (resolvedMessage === undefined) {
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: key }} /> : key;
if (__localizationRealmOverridesUserProfile !== undefined && key in __localizationRealmOverridesUserProfile) {
const resolvedMessage = __localizationRealmOverridesUserProfile[key];
return doRenderAsHtml ? (
<span
// NOTE: The message is trusted. The arguments are not but are escaped.
dangerouslySetInnerHTML={{
__html: resolvedMessage
}}
/>
) : (
resolvedMessage
);
}
return resolvedMessage;
if (!/\$\{[^}]+\}/.test(key)) {
const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml });
if (resolvedMessage === undefined) {
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: key }} /> : key;
}
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 doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: resolvedComplexMessage }} /> : resolvedComplexMessage;
}
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 doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: resolvedComplexMessage }} /> : resolvedComplexMessage;
return {
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,
doRenderAsHtml: true
}) as JSX.Element,
advancedMsgStr: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderAsHtml: false
}) as string
};
}
return {
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,
doRenderAsHtml: true
}) as JSX.Element,
advancedMsgStr: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderAsHtml: false
}) as string
};
return { createI18nTranslationFunctions };
}

View File

@ -1,2 +1,5 @@
export type { I18n, MessageKey } from "./i18n";
export { createUseI18n, fallbackLanguageTag } from "./i18n";
import type { GenericI18n, MessageKey, KcContextLike } from "./i18n";
export type { MessageKey, KcContextLike };
export type I18n = GenericI18n<MessageKey>;
export { createUseI18n } from "./i18n";
export { fallbackLanguageTag } from "./i18n";

View File

@ -1,5 +1,4 @@
export type { ExtendKcContext, Attribute } from "keycloakify/login/KcContext";
export type { PageProps } from "keycloakify/login/pages/PageProps";
export { createGetKcContextMock } from "keycloakify/login/KcContext";
export type { ClassKey } from "keycloakify/login/TemplateProps";
export { useDownloadTerms } from "keycloakify/login/lib/useDownloadTerms";
export { createUseI18n } from "keycloakify/login/i18n";

View File

@ -1,7 +1,7 @@
import { createUseClassName } from "keycloakify/lib/useGetClassName";
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
import type { ClassKey } from "keycloakify/login/TemplateProps";
export const { useGetClassName } = createUseClassName<ClassKey>({
export const { getKcClsx } = createGetKcClsx<ClassKey>({
defaultClasses: {
kcHtmlClass: "login-pf",
kcBodyClass: undefined,
@ -137,3 +137,7 @@ export const { useGetClassName } = createUseClassName<ClassKey>({
kcLabelClass: "pf-c-form__label pf-c-form__label-text"
}
});
export type { ClassKey };
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];

View File

@ -7,7 +7,13 @@ import {
import { useOnFistMount } from "keycloakify/tools/useOnFirstMount";
import { KcContext } from "../KcContext";
const obsTermsMarkdown = createStatefulObservable<string | undefined>(() => undefined);
const obs = createStatefulObservable<
| {
termsMarkdown: string;
termsLanguageTag: string | undefined;
}
| undefined
>(() => undefined);
export type KcContextLike = {
pageId: string;
@ -22,26 +28,30 @@ assert<KcContext extends KcContextLike ? true : false>();
/** Allow to avoid bundling the terms and download it on demand*/
export function useDownloadTerms(params: {
kcContext: KcContextLike;
downloadTermMarkdown: (params: { currentLanguageTag: string }) => Promise<string>;
downloadTermsMarkdown: (params: {
currentLanguageTag: string;
}) => Promise<{ termsMarkdown: string; termsLanguageTag: string | undefined }>;
}) {
const { kcContext, downloadTermMarkdown } = params;
const { kcContext, downloadTermsMarkdown } = params;
useOnFistMount(async () => {
if (kcContext.pageId === "terms.ftl" || kcContext.termsAcceptanceRequired) {
const termsMarkdown = await downloadTermMarkdown({
obs.current = await downloadTermsMarkdown({
currentLanguageTag:
kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag
});
obsTermsMarkdown.current = termsMarkdown;
}
});
}
export function useTermsMarkdown() {
useRerenderOnChange(obsTermsMarkdown);
useRerenderOnChange(obs);
const termsMarkdown = obsTermsMarkdown.current;
if (obs.current === undefined) {
return { isDownloadComplete: false as const };
}
return { termsMarkdown };
const { termsMarkdown, termsLanguageTag } = obs.current;
return { isDownloadComplete: true, termsMarkdown, termsLanguageTag };
}

View File

@ -7,8 +7,10 @@ import { useConstCallback } from "keycloakify/tools/useConstCallback";
import { emailRegexp } from "keycloakify/tools/emailRegExp";
import { formatNumber } from "keycloakify/tools/formatNumber";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import type { KcContext, PasswordPolicies, Attribute, Validators } from "keycloakify/login/KcContext";
import type { PasswordPolicies, Attribute, Validators } from "keycloakify/login/KcContext";
import type { KcContext } from "../KcContext";
import type { MessageKey } from "keycloakify/login/i18n";
import { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n";
import type { I18n } from "../i18n";
export type FormFieldError = {
@ -64,21 +66,22 @@ export type FormAction =
fieldIndex: number | undefined;
};
export type KcContextLike = {
messagesPerField: Pick<KcContext.Common["messagesPerField"], "existsError" | "get">;
profile: {
attributesByName: Record<string, Attribute>;
html5DataAnnotations?: Record<string, string>;
export type KcContextLike = KcContextLike_i18n &
KcContextLike_useGetErrors & {
profile: {
attributesByName: Record<string, Attribute>;
html5DataAnnotations?: Record<string, string>;
};
passwordRequired?: boolean;
realm: { registrationEmailAsUsername: boolean };
url: {
resourcesPath: string;
};
};
passwordRequired?: boolean;
realm: { registrationEmailAsUsername: boolean };
passwordPolicies?: PasswordPolicies;
url: {
resourcesPath: string;
};
};
export type ParamsOfUseUserProfileForm = {
assert<Extract<Extract<KcContext, { profile: unknown }>, { pageId: "register.ftl" }> extends KcContextLike ? true : false>();
export type UseUserProfileFormParams = {
kcContext: KcContextLike;
i18n: I18n;
doMakeUserConfirmPassword: boolean;
@ -102,7 +105,7 @@ namespace internal {
};
}
export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm {
export function useUserProfileForm(params: UseUserProfileFormParams): ReturnTypeOfUseUserProfileForm {
const { kcContext, i18n, doMakeUserConfirmPassword } = params;
const { insertScriptTags } = useInsertScriptTags({
@ -127,168 +130,137 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy
const initialState = useMemo((): internal.State => {
// NOTE: We don't use te kcContext.profile.attributes directly because
// they don't includes the password and password confirm fields and we want to add them.
// Also, we want to polyfill the attributes for older Keycloak version before User Profile was introduced.
// Finally we want to patch the changes made by Keycloak on the attributes format so we have an homogeneous
// attributes format to work with.
const syntheticAttributes = (() => {
const syntheticAttributes: Attribute[] = [];
// We also want to apply some retro-compatibility and consistency patches.
const attributes: Attribute[] = (() => {
mock_user_profile_attributes_for_older_keycloak_versions: {
if (
"profile" in kcContext &&
"attributesByName" in kcContext.profile &&
Object.keys(kcContext.profile.attributesByName).length !== 0
) {
break mock_user_profile_attributes_for_older_keycloak_versions;
}
const attributes = (() => {
retrocompat_patch: {
if (
"profile" in kcContext &&
"attributesByName" in kcContext.profile &&
Object.keys(kcContext.profile.attributesByName).length !== 0
) {
break retrocompat_patch;
}
if ("register" in kcContext && kcContext.register instanceof Object && "formData" in kcContext.register) {
//NOTE: Handle legacy register.ftl page
return (["firstName", "lastName", "email", "username"] as const)
.filter(name => (name !== "username" ? true : !kcContext.realm.registrationEmailAsUsername))
.map(name =>
id<Attribute>({
name: name,
displayName: id<`\${${MessageKey}}`>(`\${${name}}`),
required: true,
value: (kcContext.register as any).formData[name] ?? "",
html5DataAnnotations: {},
readOnly: false,
validators: {},
annotations: {},
autocomplete: (() => {
switch (name) {
case "email":
return "email";
case "username":
return "username";
default:
return undefined;
}
})()
})
);
}
if ("user" in kcContext && kcContext.user instanceof Object) {
//NOTE: Handle legacy login-update-profile.ftl
return (["username", "email", "firstName", "lastName"] as const)
.filter(name => (name !== "username" ? true : (kcContext.user as any).editUsernameAllowed))
.map(name =>
id<Attribute>({
name: name,
displayName: id<`\${${MessageKey}}`>(`\${${name}}`),
required: true,
value: (kcContext as any).user[name] ?? "",
html5DataAnnotations: {},
readOnly: false,
validators: {},
annotations: {},
autocomplete: (() => {
switch (name) {
case "email":
return "email";
case "username":
return "username";
default:
return undefined;
}
})()
})
);
}
if ("email" in kcContext && kcContext.email instanceof Object) {
//NOTE: Handle legacy update-email.ftl
return [
if ("register" in kcContext && kcContext.register instanceof Object && "formData" in kcContext.register) {
//NOTE: Handle legacy register.ftl page
return (["firstName", "lastName", "email", "username"] as const)
.filter(name => (name !== "username" ? true : !kcContext.realm.registrationEmailAsUsername))
.map(name =>
id<Attribute>({
name: "email",
displayName: id<`\${${MessageKey}}`>(`\${email}`),
name: name,
displayName: id<`\${${MessageKey}}`>(`\${${name}}`),
required: true,
value: (kcContext.email as any).value ?? "",
value: (kcContext.register as any).formData[name] ?? "",
html5DataAnnotations: {},
readOnly: false,
validators: {},
annotations: {},
autocomplete: "email"
autocomplete: (() => {
switch (name) {
case "email":
return "email";
case "username":
return "username";
default:
return undefined;
}
})()
})
];
}
assert(false, "Unable to mock user profile from the current kcContext");
);
}
return Object.values(kcContext.profile.attributesByName).map(attribute_pre_group_patch => {
if (typeof attribute_pre_group_patch.group === "string" && attribute_pre_group_patch.group !== "") {
const { group, groupDisplayHeader, groupDisplayDescription, groupAnnotations, ...rest } =
attribute_pre_group_patch as Attribute & {
group: string;
groupDisplayHeader?: string;
groupDisplayDescription?: string;
groupAnnotations: Record<string, string>;
};
if ("user" in kcContext && kcContext.user instanceof Object) {
//NOTE: Handle legacy login-update-profile.ftl
return (["username", "email", "firstName", "lastName"] as const)
.filter(name => (name !== "username" ? true : (kcContext.user as any).editUsernameAllowed))
.map(name =>
id<Attribute>({
name: name,
displayName: id<`\${${MessageKey}}`>(`\${${name}}`),
required: true,
value: (kcContext as any).user[name] ?? "",
html5DataAnnotations: {},
readOnly: false,
validators: {},
annotations: {},
autocomplete: (() => {
switch (name) {
case "email":
return "email";
case "username":
return "username";
default:
return undefined;
}
})()
})
);
}
return id<Attribute>({
...rest,
group: {
name: group,
displayHeader: groupDisplayHeader,
displayDescription: groupDisplayDescription,
html5DataAnnotations: {}
}
});
}
return attribute_pre_group_patch;
});
})();
for (const attribute of attributes) {
syntheticAttributes.push(structuredCloneButFunctions(attribute));
add_password_and_password_confirm: {
if (!kcContext.passwordRequired) {
break add_password_and_password_confirm;
}
if (attribute.name !== (kcContext.realm.registrationEmailAsUsername ? "email" : "username")) {
// NOTE: We want to add password and password-confirm after the field that identifies the user.
// It's either email or username.
break add_password_and_password_confirm;
}
syntheticAttributes.push(
{
name: "password",
displayName: id<`\${${MessageKey}}`>("${password}"),
if ("email" in kcContext && kcContext.email instanceof Object) {
//NOTE: Handle legacy update-email.ftl
return [
id<Attribute>({
name: "email",
displayName: id<`\${${MessageKey}}`>(`\${email}`),
required: true,
value: (kcContext.email as any).value ?? "",
html5DataAnnotations: {},
readOnly: false,
validators: {},
annotations: {},
autocomplete: "new-password",
html5DataAnnotations: {},
// NOTE: Compat with Keycloak version prior to 24
...({ groupAnnotations: {} } as {})
},
{
name: "password-confirm",
displayName: id<`\${${MessageKey}}`>("${passwordConfirm}"),
required: true,
readOnly: false,
validators: {},
annotations: {},
html5DataAnnotations: {},
autocomplete: "new-password",
// NOTE: Compat with Keycloak version prior to 24
...({ groupAnnotations: {} } as {})
}
);
autocomplete: "email"
})
];
}
assert(false, "Unable to mock user profile from the current kcContext");
}
// NOTE: Consistency patch
syntheticAttributes.forEach(attribute => {
return Object.values(kcContext.profile.attributesByName).map(structuredCloneButFunctions);
})();
// Retro-compatibility and consistency patches
attributes.forEach(attribute => {
patch_legacy_group: {
if (typeof attribute.group !== "string") {
break patch_legacy_group;
}
const { group, groupDisplayHeader, groupDisplayDescription /*, groupAnnotations*/ } = attribute as Attribute & {
group: string;
groupDisplayHeader?: string;
groupDisplayDescription?: string;
groupAnnotations: Record<string, string>;
};
delete attribute.group;
// @ts-expect-error
delete attribute.groupDisplayHeader;
// @ts-expect-error
delete attribute.groupDisplayDescription;
// @ts-expect-error
delete attribute.groupAnnotations;
if (group === "") {
break patch_legacy_group;
}
attribute.group = {
name: group,
displayHeader: groupDisplayHeader,
displayDescription: groupDisplayDescription,
html5DataAnnotations: {}
};
}
// Attributes with options rendered by default as select inputs
if (attribute.validators.options !== undefined && attribute.annotations.inputType === undefined) {
attribute.annotations.inputType = "select";
}
// Consistency patch on values/value property
{
if (getIsMultivaluedSingleField({ attribute })) {
attribute.multivalued = true;
}
@ -300,65 +272,98 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy
attribute.value ??= attribute.values?.[0];
delete attribute.values;
}
});
}
});
return syntheticAttributes;
})();
const initialFormFieldState = (() => {
const out: {
attribute: Attribute;
valueOrValues: string | string[];
}[] = [];
for (const attribute of syntheticAttributes) {
handle_multi_valued_attribute: {
if (!attribute.multivalued) {
break handle_multi_valued_attribute;
}
const values = attribute.values?.length ? attribute.values : [""];
apply_validator_min_range: {
if (getIsMultivaluedSingleField({ attribute })) {
break apply_validator_min_range;
}
const validator = attribute.validators.multivalued;
if (validator === undefined) {
break apply_validator_min_range;
}
const { min: minStr } = validator;
if (!minStr) {
break apply_validator_min_range;
}
const min = parseInt(`${minStr}`);
for (let index = values.length; index < min; index++) {
values.push("");
}
}
out.push({
attribute,
valueOrValues: values
});
continue;
}
out.push({
attribute,
valueOrValues: attribute.value ?? ""
});
add_password_and_password_confirm: {
if (!kcContext.passwordRequired) {
break add_password_and_password_confirm;
}
return out;
})();
attributes.forEach((attribute, i) => {
if (attribute.name !== (kcContext.realm.registrationEmailAsUsername ? "email" : "username")) {
// NOTE: We want to add password and password-confirm after the field that identifies the user.
// It's either email or username.
return;
}
attributes.splice(
i + 1,
0,
{
name: "password",
displayName: id<`\${${MessageKey}}`>("${password}"),
required: true,
readOnly: false,
validators: {},
annotations: {},
autocomplete: "new-password",
html5DataAnnotations: {}
},
{
name: "password-confirm",
displayName: id<`\${${MessageKey}}`>("${passwordConfirm}"),
required: true,
readOnly: false,
validators: {},
annotations: {},
html5DataAnnotations: {},
autocomplete: "new-password"
}
);
});
}
const initialFormFieldState: {
attribute: Attribute;
valueOrValues: string | string[];
}[] = [];
for (const attribute of attributes) {
handle_multi_valued_attribute: {
if (!attribute.multivalued) {
break handle_multi_valued_attribute;
}
const values = attribute.values?.length ? attribute.values : [""];
apply_validator_min_range: {
if (getIsMultivaluedSingleField({ attribute })) {
break apply_validator_min_range;
}
const validator = attribute.validators.multivalued;
if (validator === undefined) {
break apply_validator_min_range;
}
const { min: minStr } = validator;
if (!minStr) {
break apply_validator_min_range;
}
const min = parseInt(`${minStr}`);
for (let index = values.length; index < min; index++) {
values.push("");
}
}
initialFormFieldState.push({
attribute,
valueOrValues: values
});
continue;
}
initialFormFieldState.push({
attribute,
valueOrValues: attribute.value ?? ""
});
}
const initialState: internal.State = {
formFieldStates: initialFormFieldState.map(({ attribute, valueOrValues }) => ({
@ -515,7 +520,14 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy
};
}
function useGetErrors(params: { kcContext: Pick<KcContextLike, "messagesPerField" | "passwordPolicies">; i18n: I18n }) {
type KcContextLike_useGetErrors = KcContextLike_i18n & {
messagesPerField: Pick<KcContext["messagesPerField"], "existsError" | "get">;
passwordPolicies?: PasswordPolicies;
};
assert<KcContextLike extends KcContextLike_useGetErrors ? true : false>();
function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18n }) {
const { kcContext, i18n } = params;
const { messagesPerField, passwordPolicies } = kcContext;

View File

@ -1,4 +1,4 @@
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -6,7 +6,7 @@ import type { I18n } from "../i18n";
export default function Code(props: PageProps<Extract<KcContext, { pageId: "code.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -17,14 +17,17 @@ export default function Code(props: PageProps<Extract<KcContext, { pageId: "code
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
headerNode={code.success ? msg("codeSuccessTitle") : msg("codeErrorTitle", code.error)}
>
<div id="kc-code">
{code.success ? (
<>
<p>{msg("copyCodeInstruction")}</p>
<input id="code" className={getClassName("kcTextareaClass")} defaultValue={code.code} />
<input id="code" className={kcClsx("kcTextareaClass")} defaultValue={code.code} />
</>
) : (
<p id="error">{code.error}</p>

View File

@ -1,5 +1,4 @@
import { clsx } from "keycloakify/tools/clsx";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -7,7 +6,7 @@ import type { I18n } from "../i18n";
export default function DeleteAccountConfirm(props: PageProps<Extract<KcContext, { pageId: "delete-account-confirm.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -17,7 +16,7 @@ export default function DeleteAccountConfirm(props: PageProps<Extract<KcContext,
const { msg, msgStr } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("deleteAccountConfirm")}>
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={doUseDefaultCss} classes={classes} headerNode={msg("deleteAccountConfirm")}>
<form action={url.loginAction} className="form-vertical" method="post">
<div className="alert alert-warning" style={{ marginTop: "0", marginBottom: "30px" }}>
<span className="pficon pficon-warning-triangle-o"></span>
@ -37,13 +36,13 @@ export default function DeleteAccountConfirm(props: PageProps<Extract<KcContext,
<p className="delete-account-text">{msg("finalDeletionConfirmation")}</p>
<div id="kc-form-buttons">
<input
className={clsx(getClassName("kcButtonClass"), getClassName("kcButtonPrimaryClass"), getClassName("kcButtonLargeClass"))}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
type="submit"
value={msgStr("doConfirmDelete")}
/>
{triggered_from_aia && (
<button
className={clsx(getClassName("kcButtonClass"), getClassName("kcButtonDefaultClass"), getClassName("kcButtonLargeClass"))}
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonLargeClass")}
style={{ marginLeft: "calc(100% - 220px)" }}
type="submit"
name="cancel-aia"

View File

@ -1,5 +1,4 @@
import { clsx } from "keycloakify/tools/clsx";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -9,7 +8,7 @@ export default function DeleteCredential(props: PageProps<Extract<KcContext, { p
const { msgStr, msg } = i18n;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -18,21 +17,24 @@ export default function DeleteCredential(props: PageProps<Extract<KcContext, { p
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayMessage={false}
headerNode={msg("deleteCredentialTitle", credentialLabel)}
>
<div id="kc-delete-text">{msg("deleteCredentialMessage", credentialLabel)}</div>
<form className="form-actions" action={url.loginAction} method="POST">
<input
className={clsx(getClassName("kcButtonClass"), getClassName("kcButtonPrimaryClass"), getClassName("kcButtonLargeClass"))}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
name="accept"
id="kc-accept"
type="submit"
value={msgStr("doConfirmDelete")}
/>
<input
className={clsx(getClassName("kcButtonClass"), getClassName("kcButtonDefaultClass"), getClassName("kcButtonLargeClass"))}
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonLargeClass")}
name="cancel-aia"
value={msgStr("doCancel")}
id="kc-decline"

View File

@ -10,7 +10,14 @@ export default function Error(props: PageProps<Extract<KcContext, { pageId: "err
const { msg } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("errorTitle")}>
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayMessage={false}
headerNode={msg("errorTitle")}
>
<div id="kc-error-message">
<p className="instruction">{message.summary}</p>
{!skipLink && client !== undefined && client.baseUrl !== undefined && (

View File

@ -18,7 +18,10 @@ export default function FrontchannelLogout(props: PageProps<Extract<KcContext, {
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
documentTitle={msgStr("frontchannel-logout.title")}
headerNode={msg("frontchannel-logout.title")}
>

View File

@ -1,20 +1,20 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFields";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
type IdpReviewUserProfileProps = PageProps<Extract<KcContext, { pageId: "idp-review-user-profile.ftl" }>, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
doMakeUserConfirmPassword: boolean;
};
export default function IdpReviewUserProfile(props: IdpReviewUserProfileProps) {
const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields } = props;
const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields, doMakeUserConfirmPassword } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -27,30 +27,29 @@ export default function IdpReviewUserProfile(props: IdpReviewUserProfileProps) {
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayMessage={messagesPerField.exists("global")}
displayRequiredFields
headerNode={msg("loginIdpReviewProfileTitle")}
>
<form id="kc-idp-review-profile-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
<form id="kc-idp-review-profile-form" className={kcClsx("kcFormClass")} action={url.loginAction} method="post">
<UserProfileFormFields
kcContext={kcContext}
onIsFormSubmittableValueChange={setIsFomSubmittable}
i18n={i18n}
getClassName={getClassName}
onIsFormSubmittableValueChange={setIsFomSubmittable}
kcClsx={kcClsx}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/>
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")} />
<div className={kcClsx("kcFormGroupClass")}>
<div id="kc-form-options" className={kcClsx("kcFormOptionsClass")}>
<div className={kcClsx("kcFormOptionsWrapperClass")} />
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<div id="kc-form-buttons" className={kcClsx("kcFormButtonsClass")}>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonBlockClass", "kcButtonLargeClass")}
type="submit"
value={msgStr("doSubmit")}
disabled={!isFomSubmittable}

View File

@ -17,7 +17,10 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayMessage={false}
headerNode={messageHeader !== undefined ? <>{messageHeader}</> : <>{message.summary}</>}
>

View File

@ -2,14 +2,14 @@ import { useState, useEffect, useReducer } from "react";
import { assert } from "tsafe/assert";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function Login(props: PageProps<Extract<KcContext, { pageId: "login.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -22,7 +22,10 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayMessage={!messagesPerField.existsError("username", "password")}
headerNode={msg("loginAccountTitle")}
displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled}
@ -41,32 +44,23 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
socialProvidersNode={
<>
{realm.password && social.providers?.length && (
<div id="kc-social-providers" className={getClassName("kcFormSocialAccountSectionClass")}>
<div id="kc-social-providers" className={kcClsx("kcFormSocialAccountSectionClass")}>
<hr />
<h2>{msg("identity-provider-login-label")}</h2>
<ul
className={clsx(
getClassName("kcFormSocialAccountListClass"),
social.providers.length > 3 && getClassName("kcFormSocialAccountListGridClass")
)}
>
<ul className={kcClsx("kcFormSocialAccountListClass", social.providers.length > 3 && "kcFormSocialAccountListGridClass")}>
{social.providers.map((...[p, , providers]) => (
<li key={p.alias}>
<a
id={`social-${p.alias}`}
className={clsx(
getClassName("kcFormSocialAccountListButtonClass"),
providers.length > 3 && getClassName("kcFormSocialAccountGridItem")
className={kcClsx(
"kcFormSocialAccountListButtonClass",
providers.length > 3 && "kcFormSocialAccountGridItem"
)}
type="button"
href={p.loginUrl}
>
{p.iconClasses && (
<i className={clsx(getClassName("kcCommonLogoIdP"), p.iconClasses)} aria-hidden="true"></i>
)}
<span
className={clsx(getClassName("kcFormSocialAccountNameClass"), p.iconClasses && "kc-social-icon-text")}
>
{p.iconClasses && <i className={clsx(kcClsx("kcCommonLogoIdP"), p.iconClasses)} aria-hidden="true"></i>}
<span className={clsx(kcClsx("kcFormSocialAccountNameClass"), p.iconClasses && "kc-social-icon-text")}>
{p.displayName}
</span>
</a>
@ -91,8 +85,8 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
method="post"
>
{!usernameHidden && (
<div className={getClassName("kcFormGroupClass")}>
<label htmlFor="username" className={getClassName("kcLabelClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<label htmlFor="username" className={kcClsx("kcLabelClass")}>
{!realm.loginWithEmailAllowed
? msg("username")
: !realm.registrationEmailAsUsername
@ -102,7 +96,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
<input
tabIndex={2}
id="username"
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
name="username"
defaultValue={login.username ?? ""}
type="text"
@ -111,22 +105,22 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
aria-invalid={messagesPerField.existsError("username", "password")}
/>
{messagesPerField.existsError("username", "password") && (
<span id="input-error" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
<span id="input-error" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.getFirstError("username", "password")}
</span>
)}
</div>
)}
<div className={getClassName("kcFormGroupClass")}>
<label htmlFor="password" className={getClassName("kcLabelClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<label htmlFor="password" className={kcClsx("kcLabelClass")}>
{msg("password")}
</label>
<PasswordWrapper getClassName={getClassName} i18n={i18n} passwordInputId="password">
<PasswordWrapper kcClsx={kcClsx} i18n={i18n} passwordInputId="password">
<input
tabIndex={3}
id="password"
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
name="password"
type="password"
autoComplete="current-password"
@ -134,13 +128,13 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
/>
</PasswordWrapper>
{usernameHidden && messagesPerField.existsError("username", "password") && (
<span id="input-error" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
<span id="input-error" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.getFirstError("username", "password")}
</span>
)}
</div>
<div className={clsx(getClassName("kcFormGroupClass"), getClassName("kcFormSettingClass"))}>
<div className={kcClsx("kcFormGroupClass", "kcFormSettingClass")}>
<div id="kc-form-options">
{realm.rememberMe && !usernameHidden && (
<div className="checkbox">
@ -157,7 +151,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
</div>
)}
</div>
<div className={getClassName("kcFormOptionsWrapperClass")}>
<div className={kcClsx("kcFormOptionsWrapperClass")}>
{realm.resetPasswordAllowed && (
<span>
<a tabIndex={6} href={url.loginResetCredentialsUrl}>
@ -168,17 +162,12 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
</div>
</div>
<div id="kc-form-buttons" className={getClassName("kcFormGroupClass")}>
<div id="kc-form-buttons" className={kcClsx("kcFormGroupClass")}>
<input type="hidden" id="id-hidden-input" name="credentialId" value={auth.selectedCredential} />
<input
tabIndex={7}
disabled={isLoginButtonDisabled}
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonBlockClass", "kcButtonLargeClass")}
name="login"
id="kc-login"
type="submit"
@ -193,13 +182,8 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
);
}
function PasswordWrapper(props: {
getClassName: ReturnType<typeof useGetClassName>["getClassName"];
i18n: I18n;
passwordInputId: string;
children: JSX.Element;
}) {
const { getClassName, i18n, passwordInputId, children } = props;
function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: string; children: JSX.Element }) {
const { kcClsx, i18n, passwordInputId, children } = props;
const { msgStr } = i18n;
@ -214,19 +198,16 @@ function PasswordWrapper(props: {
}, [isPasswordRevealed]);
return (
<div className={getClassName("kcInputGroup")}>
<div className={kcClsx("kcInputGroup")}>
{children}
<button
type="button"
className={getClassName("kcFormPasswordVisibilityButtonClass")}
className={kcClsx("kcFormPasswordVisibilityButtonClass")}
aria-label={msgStr(isPasswordRevealed ? "hidePassword" : "showPassword")}
aria-controls={passwordInputId}
onClick={toggleIsPasswordRevealed}
>
<i
className={getClassName(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")}
aria-hidden
/>
<i className={kcClsx(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")} aria-hidden />
</button>
</div>
);

View File

@ -1,5 +1,4 @@
import { clsx } from "keycloakify/tools/clsx";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { getKcClsx, KcClsx } from "keycloakify/login/lib/kcClsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -7,7 +6,7 @@ import type { I18n } from "../i18n";
export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pageId: "login-config-totp.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -17,7 +16,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
const { msg, msgStr, advancedMsg } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("loginTotpTitle")}>
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={doUseDefaultCss} classes={classes} headerNode={msg("loginTotpTitle")}>
<>
<ol id="kc-totp-settings">
<li>
@ -87,26 +86,26 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
</li>
</ol>
<form action={url.loginAction} className={getClassName("kcFormClass")} id="kc-totp-settings-form" method="post">
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcInputWrapperClass")}>
<label htmlFor="totp" className={getClassName("kcLabelClass")}>
<form action={url.loginAction} className={kcClsx("kcFormClass")} id="kc-totp-settings-form" method="post">
<div className={kcClsx("kcFormGroupClass")}>
<div className={kcClsx("kcInputWrapperClass")}>
<label htmlFor="totp" className={kcClsx("kcLabelClass")}>
{msg("authenticatorCode")}
</label>{" "}
<span className="required">*</span>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<div className={kcClsx("kcInputWrapperClass")}>
<input
type="text"
id="totp"
name="totp"
autoComplete="off"
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={messagesPerField.existsError("totp")}
/>
{messagesPerField.existsError("totp") && (
<span id="input-error-otp-code" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
<span id="input-error-otp-code" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("totp")}
</span>
)}
@ -115,54 +114,45 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
{mode && <input type="hidden" id="mode" value={mode} />}
</div>
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcInputWrapperClass")}>
<label htmlFor="userLabel" className={getClassName("kcLabelClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<div className={kcClsx("kcInputWrapperClass")}>
<label htmlFor="userLabel" className={kcClsx("kcLabelClass")}>
{msg("loginTotpDeviceName")}
</label>{" "}
{totp.otpCredentials.length >= 1 && <span className="required">*</span>}
</div>
<div className={getClassName("kcInputWrapperClass")}>
<div className={kcClsx("kcInputWrapperClass")}>
<input
type="text"
id="userLabel"
name="userLabel"
autoComplete="off"
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={messagesPerField.existsError("userLabel")}
/>
{messagesPerField.existsError("userLabel") && (
<span id="input-error-otp-label" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
<span id="input-error-otp-label" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("userLabel")}
</span>
)}
</div>
</div>
<div className={getClassName("kcFormGroupClass")}>
<LogoutOtherSessions {...{ getClassName, i18n }} />
<div className={kcClsx("kcFormGroupClass")}>
<LogoutOtherSessions kcClsx={kcClsx} i18n={i18n} />
</div>
{isAppInitiatedAction ? (
<>
<input
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
id="saveTOTPBtn"
value={msgStr("doSubmit")}
/>
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonLargeClass", "kcButtonLargeClass")}
id="cancelTOTPBtn"
name="cancel-aia"
value="true"
@ -173,7 +163,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
) : (
<input
type="submit"
className={clsx(getClassName("kcButtonClass"), getClassName("kcButtonPrimaryClass"), getClassName("kcButtonLargeClass"))}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
id="saveTOTPBtn"
value={msgStr("doSubmit")}
/>
@ -184,14 +174,14 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
);
}
function LogoutOtherSessions(props: { getClassName: ReturnType<typeof useGetClassName>["getClassName"]; i18n: I18n }) {
const { getClassName, i18n } = props;
function LogoutOtherSessions(props: { kcClsx: KcClsx; i18n: I18n }) {
const { kcClsx, i18n } = props;
const { msg } = i18n;
return (
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}>
<div id="kc-form-options" className={kcClsx("kcFormOptionsClass")}>
<div className={kcClsx("kcFormOptionsWrapperClass")}>
<div className="checkbox">
<label>
<input type="checkbox" id="logout-sessions" name="logout-sessions" value="on" defaultChecked={true} />

View File

@ -1,5 +1,4 @@
import { clsx } from "keycloakify/tools/clsx";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -7,7 +6,7 @@ import type { I18n } from "../i18n";
export default function LoginIdpLinkConfirm(props: PageProps<Extract<KcContext, { pageId: "login-idp-link-confirm.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -17,17 +16,12 @@ export default function LoginIdpLinkConfirm(props: PageProps<Extract<KcContext,
const { msg } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("confirmLinkIdpTitle")}>
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={doUseDefaultCss} classes={classes} headerNode={msg("confirmLinkIdpTitle")}>
<form id="kc-register-form" action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonBlockClass", "kcButtonLargeClass")}
name="submitAction"
id="updateProfile"
value="updateProfile"
@ -36,12 +30,7 @@ export default function LoginIdpLinkConfirm(props: PageProps<Extract<KcContext,
</button>
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonBlockClass", "kcButtonLargeClass")}
name="submitAction"
id="linkAccount"
value="linkAccount"

Some files were not shown because too many files have changed in this diff Show More