Compare commits

..

17 Commits

26 changed files with 407 additions and 202 deletions

View File

@ -1,6 +1,6 @@
{
"name": "keycloakify",
"version": "10.0.0-rc.102",
"version": "10.0.0-rc.108",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",

View File

@ -1,6 +1,6 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join, relative } from "path";
import { join } from "path";
import { assert } from "tsafe/assert";
import { transformCodebase } from "../src/bin/tools/transformCodebase";
import chalk from "chalk";

View File

@ -12,6 +12,7 @@ import { crawl } from "../src/bin/tools/crawl";
import { downloadKeycloakDefaultTheme } from "../src/bin/shared/downloadKeycloakDefaultTheme";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { deepAssign } from "../src/tools/deepAssign";
import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions";
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
// update the version array for generating for newer version.
@ -33,7 +34,9 @@ async function main() {
".cache",
"keycloakify"
),
npmWorkspaceRootDirPath: thisCodebaseRootDirPath
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: thisCodebaseRootDirPath
})
}
});

View File

@ -33,6 +33,7 @@ export async function buildJar(params: {
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
resourcesDirPath: string;
doesImplementAccountV1Theme: boolean;
buildContext: BuildContextLike;
}): Promise<void> {
const {
@ -40,6 +41,7 @@ export async function buildJar(params: {
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
resourcesDirPath,
doesImplementAccountV1Theme,
buildContext
} = params;
@ -61,7 +63,7 @@ export async function buildJar(params: {
srcDirPath: resourcesDirPath,
destDirPath: tmpResourcesDirPath,
transformSourceCode:
keycloakAccountV1Version !== null
!doesImplementAccountV1Theme || keycloakAccountV1Version !== null
? undefined
: (params: {
fileRelativePath: string;
@ -105,7 +107,17 @@ export async function buildJar(params: {
}
});
if (keycloakAccountV1Version === null) {
remove_account_v1_in_meta_inf: {
if (!doesImplementAccountV1Theme) {
// NOTE: We do not have account v1 anyway
break remove_account_v1_in_meta_inf;
}
if (keycloakAccountV1Version !== null) {
// NOTE: No, we need to keep account-v1 in meta-inf
break remove_account_v1_in_meta_inf;
}
writeMetaInfKeycloakThemes({
resourcesDirPath: tmpResourcesDirPath,
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => {
@ -135,6 +147,7 @@ export async function buildJar(params: {
}
})();
// TODO: Remove this optimization, it's a bit hacky.
if (doBreak) {
break route_legacy_pages;
}
@ -194,7 +207,7 @@ export async function buildJar(params: {
await new Promise<void>((resolve, reject) =>
child_process.exec(
`mvn clean install -Dmaven.repo.local=${pathJoin(keycloakifyBuildTmpDirPath, ".m2")}`,
`mvn clean install -Dmaven.repo.local="${pathJoin(keycloakifyBuildTmpDirPath, ".m2")}"`,
{ cwd: keycloakifyBuildTmpDirPath },
error => {
if (error !== null) {

View File

@ -12,6 +12,7 @@ export type BuildContextLike = BuildContextLike_buildJar & {
keycloakifyBuildDirPath: string;
recordIsImplementedByThemeType: BuildContext["recordIsImplementedByThemeType"];
jarTargets: BuildContext["jarTargets"];
doUseAccountV3: boolean;
};
assert<BuildContext extends BuildContextLike ? true : false>();
@ -22,7 +23,9 @@ export async function buildJars(params: {
}): Promise<void> {
const { resourcesDirPath, buildContext } = params;
const doesImplementAccountTheme = buildContext.recordIsImplementedByThemeType.account;
const doesImplementAccountV1Theme =
buildContext.recordIsImplementedByThemeType.account &&
!buildContext.doUseAccountV3;
await Promise.all(
keycloakAccountV1Versions
@ -30,7 +33,7 @@ export async function buildJars(params: {
keycloakThemeAdditionalInfoExtensionVersions.map(
keycloakThemeAdditionalInfoExtensionVersion => {
const keycloakVersionRange = getKeycloakVersionRangeForJar({
doesImplementAccountTheme,
doesImplementAccountV1Theme,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
});
@ -55,6 +58,7 @@ export async function buildJars(params: {
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
resourcesDirPath,
doesImplementAccountV1Theme,
buildContext
});
}

View File

@ -6,17 +6,17 @@ import type {
import type { KeycloakVersionRange } from "../../shared/KeycloakVersionRange";
export function getKeycloakVersionRangeForJar(params: {
doesImplementAccountTheme: boolean;
doesImplementAccountV1Theme: boolean;
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
}): KeycloakVersionRange | undefined {
const {
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
doesImplementAccountTheme
doesImplementAccountV1Theme
} = params;
if (doesImplementAccountTheme) {
if (doesImplementAccountV1Theme) {
const keycloakVersionRange = (() => {
switch (keycloakAccountV1Version) {
case null:
@ -63,7 +63,7 @@ export function getKeycloakVersionRangeForJar(params: {
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithAccountTheme | undefined
KeycloakVersionRange.WithAccountV1Theme | undefined
>
>();
@ -87,7 +87,7 @@ export function getKeycloakVersionRangeForJar(params: {
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithoutAccountTheme | undefined
KeycloakVersionRange.WithoutAccountV1Theme | undefined
>
>();

View File

@ -34,6 +34,7 @@ export function generateFtlFilesCodeFactory(params: {
keycloakifyVersion: string;
themeType: ThemeType;
fieldNames: string[];
isAccountV3: boolean;
}) {
const {
themeName,
@ -41,7 +42,8 @@ export function generateFtlFilesCodeFactory(params: {
buildContext,
keycloakifyVersion,
themeType,
fieldNames
fieldNames,
isAccountV3
} = params;
const $ = cheerio.load(indexHtmlCode);
@ -68,7 +70,8 @@ export function generateFtlFilesCodeFactory(params: {
const { fixedCssCode } = replaceImportsInCssCode({
cssCode,
cssFileRelativeDirPath: undefined,
buildContext
buildContext,
isAccountV3
});
$(element).text(fixedCssCode);
@ -93,7 +96,7 @@ export function generateFtlFilesCodeFactory(params: {
new RegExp(
`^${(buildContext.urlPathname ?? "/").replace(/\//g, "\\/")}`
),
`\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
`\${${!isAccountV3 ? "url.resourcesPath" : "resourceUrl"}}/${basenameOfTheKeycloakifyResourcesDir}/`
)
);
})

View File

@ -1,4 +1,5 @@
<#assign pageId="PAGE_ID_xIgLsPgGId9D8e">
<#assign themeType="KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr">
const kcContext = ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc};
if( kcContext.messagesPerField ){
var existsError_singleFieldName = kcContext.messagesPerField.existsError;
@ -27,43 +28,106 @@ if( kcContext.messagesPerField ){
}
kcContext.keycloakifyVersion = "KEYCLOAKIFY_VERSION_xEdKd3xEdr";
kcContext.themeVersion = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx";
kcContext.themeType = "KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr";
kcContext.themeType = "${themeType}";
kcContext.themeName = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer";
kcContext.pageId = "${pageId}";
if( kcContext.url && kcContext.url.resourcesPath ){
kcContext.url.resourcesCommonPath = kcContext.url.resourcesPath + "/" + "RESOURCES_COMMON_cLsLsMrtDkpVv";
}
kcContext["x-keycloakify"] = {};
if( kcContext.resourceUrl && !kcContext.url ){
Object.defineProperty(kcContext, "url", {
value: {
resourcesPath: kcContext.resourceUrl
},
enumerable: false
});
}
kcContext["x-keycloakify"] = {
messages: {}
};
<#if profile?? && profile.attributes??>
kcContext["x-keycloakify"].realmMessageBundleUserProfile = {
<#list profile.attributes as attribute>
<#if attribute.annotations?? && attribute.displayName??>
"${attribute.displayName}": decodeHtmlEntities("${advancedMsg(attribute.displayName)?js_string}"),
</#if>
<#if attribute.annotations.inputHelperTextBefore??>
"${attribute.annotations.inputHelperTextBefore}": decodeHtmlEntities("${advancedMsg(attribute.annotations.inputHelperTextBefore)?js_string}"),
</#if>
<#if attribute.annotations.inputHelperTextAfter??>
"${attribute.annotations.inputHelperTextAfter}": decodeHtmlEntities("${advancedMsg(attribute.annotations.inputHelperTextAfter)?js_string}"),
</#if>
<#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>
};
{
var messages = {
<#list profile.attributes as attribute>
<#if attribute.displayName??>
"${attribute.displayName}": decodeHtmlEntities("${advancedMsg(attribute.displayName)?js_string}"),
</#if>
<#if attribute.group??>
<#if attribute.group.displayDescription??>
"${attribute.group.displayDescription}": decodeHtmlEntities("${advancedMsg(attribute.group.displayDescription)?js_string}"),
</#if>
<#if attribute.group.displayHeader??>
"${attribute.group.displayHeader}": decodeHtmlEntities("${advancedMsg(attribute.group.displayHeader)?js_string}"),
</#if>
</#if>
<#if attribute.annotations??>
<#if attribute.annotations.inputHelperTextBefore??>
"${attribute.annotations.inputHelperTextBefore}": decodeHtmlEntities("${advancedMsg(attribute.annotations.inputHelperTextBefore)?js_string}"),
</#if>
<#if attribute.annotations.inputHelperTextAfter??>
"${attribute.annotations.inputHelperTextAfter}": decodeHtmlEntities("${advancedMsg(attribute.annotations.inputHelperTextAfter)?js_string}"),
</#if>
<#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>
</#if>
</#list>
};
Object.assign(kcContext["x-keycloakify"].messages, messages);
}
</#if>
<#if pageId == "terms.ftl" || termsAcceptanceRequired?? && termsAcceptanceRequired>
kcContext["x-keycloakify"].realmMessageBundleTermsText= decodeHtmlEntities("${msg("termsText")?js_string}");
kcContext["x-keycloakify"].messages["termsText"]= decodeHtmlEntities("${msg("termsText")?js_string}");
</#if>
<#if auth?? && auth.authenticationSelections??>
{
var messages = {
<#list auth.authenticationSelections as authenticationSelection>
<#if authenticationSelection.displayName??>
"${authenticationSelection.displayName}": decodeHtmlEntities("${advancedMsg(authenticationSelection.displayName)?js_string}"),
</#if>
<#if authenticationSelection.helpText??>
"${authenticationSelection.helpText}": decodeHtmlEntities("${advancedMsg(authenticationSelection.helpText)?js_string}"),
</#if>
</#list>
};
Object.assign(kcContext["x-keycloakify"].messages, messages);
}
</#if>
<#if themeType == "login" && pageId == "info.ftl" && requiredActions??>
{
var messages = {
<#list requiredActions as requiredAction>
"requiredAction.${requiredAction}": decodeHtmlEntities("${advancedMsg("requiredAction." + requiredAction)?js_string}"),
</#list>
};
Object.assign(kcContext["x-keycloakify"].messages, messages);
}
</#if>
<#if authenticators?? && authenticators.authenticators??>
{
var messages = {
<#list authenticators.authenticators as authenticator>
"${authenticator.label}": decodeHtmlEntities("${advancedMsg(authenticator.label)?js_string}"),
<#if authenticator.transports?? && authenticator.transports.displayNameProperties??>
<#list authenticator.transports.displayNameProperties as displayNameProperty>
"${displayNameProperty}": decodeHtmlEntities("${advancedMsg(displayNameProperty)?js_string}"),
</#list>
</#if>
</#list>
};
Object.assign(kcContext["x-keycloakify"].messages, messages);
}
</#if>
attributes_to_attributesByName: {
if( !kcContext.profile ){
@ -72,7 +136,7 @@ attributes_to_attributesByName: {
if( !kcContext.profile.attributes ){
break attributes_to_attributesByName;
}
var attributes = kcContext.profile.attributes;
var attributes = kcContext.profile.attributes;
delete kcContext.profile.attributes;
kcContext.profile.attributesByName = {};
attributes.forEach(function(attribute){
@ -427,6 +491,14 @@ function decodeHtmlEntities(htmlStr){
</#if>
<#if themeType == "account" && are_same_path(path, ["realm", "isInternationalizationEnabled"])>
<#attempt>
<#return realm.isInternationalizationEnabled()?c>
<#recover>
<#return "ABORT: Couldn't evaluate realm.isInternationalizationEnabled()">
</#attempt>
</#if>
<#return "ABORT: It's a method">
</#if>

View File

@ -4,7 +4,8 @@ import {
join as pathJoin,
resolve as pathResolve,
relative as pathRelative,
dirname as pathDirname
dirname as pathDirname,
basename as pathBasename
} from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
@ -42,6 +43,7 @@ import {
} from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import { downloadAndExtractArchive } from "../../tools/downloadAndExtractArchive";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_downloadKeycloakStaticResources &
@ -54,6 +56,7 @@ export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
recordIsImplementedByThemeType: BuildContext["recordIsImplementedByThemeType"];
themeSrcDirPath: string;
bundler: { type: "vite" } | { type: "webpack" };
doUseAccountV3: boolean;
};
assert<BuildContext extends BuildContextLike ? true : false>();
@ -71,6 +74,7 @@ export async function generateResourcesForMainTheme(params: {
};
for (const themeType of ["login", "account"] as const) {
const isAccountV3 = themeType === "account" && buildContext.doUseAccountV3;
if (!buildContext.recordIsImplementedByThemeType[themeType]) {
continue;
}
@ -136,7 +140,8 @@ export async function generateResourcesForMainTheme(params: {
const { fixedCssCode } = replaceImportsInCssCode({
cssCode: sourceCode.toString("utf8"),
cssFileRelativeDirPath: pathDirname(fileRelativePath),
buildContext
buildContext,
isAccountV3
});
return {
@ -171,7 +176,8 @@ export async function generateResourcesForMainTheme(params: {
fieldNames: readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
})
}),
isAccountV3
});
[
@ -180,13 +186,15 @@ export async function generateResourcesForMainTheme(params: {
case "login":
return loginThemePageIds;
case "account":
return accountThemePageIds;
return isAccountV3 ? ["index.ftl"] : accountThemePageIds;
}
})(),
...readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
})
...(isAccountV3
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
@ -196,40 +204,52 @@ export async function generateResourcesForMainTheme(params: {
);
});
generateMessageProperties({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
i18n_messages_generation: {
if (isAccountV3) {
break i18n_messages_generation;
}
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), {
recursive: true
generateMessageProperties({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), {
recursive: true
});
const propertiesFilePath = pathJoin(
messagesDirPath,
`messages_${languageTag}.properties`
);
fs.writeFileSync(
propertiesFilePath,
Buffer.from(propertiesFileSource, "utf8")
);
});
}
const propertiesFilePath = pathJoin(
messagesDirPath,
`messages_${languageTag}.properties`
);
keycloak_static_resources: {
if (isAccountV3) {
break keycloak_static_resources;
}
fs.writeFileSync(
propertiesFilePath,
Buffer.from(propertiesFileSource, "utf8")
);
});
await downloadKeycloakStaticResources({
keycloakVersion: (() => {
switch (themeType) {
case "account":
return lastKeycloakVersionWithAccountV1;
case "login":
return buildContext.loginThemeResourcesFromKeycloakVersion;
}
})(),
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildContext
});
await downloadKeycloakStaticResources({
keycloakVersion: (() => {
switch (themeType) {
case "account":
return lastKeycloakVersionWithAccountV1;
case "login":
return buildContext.loginThemeResourcesFromKeycloakVersion;
}
})(),
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildContext
});
}
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
@ -238,12 +258,13 @@ export async function generateResourcesForMainTheme(params: {
`parent=${(() => {
switch (themeType) {
case "account":
return accountV1ThemeName;
return isAccountV3 ? "base" : accountV1ThemeName;
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(isAccountV3 ? ["deprecatedMode=false"] : []),
...(buildContext.extraThemeProperties ?? []),
...buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>
@ -268,13 +289,54 @@ export async function generateResourcesForMainTheme(params: {
});
}
if (buildContext.recordIsImplementedByThemeType.account) {
bring_in_account_v1: {
if (buildContext.doUseAccountV3) {
break bring_in_account_v1;
}
if (!buildContext.recordIsImplementedByThemeType.account) {
break bring_in_account_v1;
}
await bringInAccountV1({
resourcesDirPath,
buildContext
});
}
bring_in_account_v3_i18n_messages: {
if (!buildContext.doUseAccountV3) {
break bring_in_account_v3_i18n_messages;
}
const { extractedDirPath } = await downloadAndExtractArchive({
url: "https://repo1.maven.org/maven2/org/keycloak/keycloak-account-ui/25.0.1/keycloak-account-ui-25.0.1.jar",
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "bring_in_account_v3_i18n_messages",
onArchiveFile: async ({ fileRelativePath, writeFile }) => {
if (
!fileRelativePath.startsWith(
pathJoin("theme", "keycloak.v3", "account", "messages")
)
) {
return;
}
await writeFile({
fileRelativePath: pathBasename(fileRelativePath)
});
}
});
transformCodebase({
srcDirPath: extractedDirPath,
destDirPath: pathJoin(
getThemeTypeDirPath({ themeType: "account" }),
"messages"
)
});
}
{
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };

View File

@ -12,11 +12,12 @@ assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInCssCode(params: {
cssCode: string;
cssFileRelativeDirPath: string | undefined;
isAccountV3: boolean;
buildContext: BuildContextLike;
}): {
fixedCssCode: string;
} {
const { cssCode, cssFileRelativeDirPath, buildContext } = params;
const { cssCode, cssFileRelativeDirPath, buildContext, isAccountV3 } = params;
const fixedCssCode = cssCode.replace(
/url\(["']?(\/[^/][^)"']+)["']?\)/g,
@ -37,7 +38,7 @@ export function replaceImportsInCssCode(params: {
break inline_style_in_html;
}
return `url(\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}${assetFileAbsoluteUrlPathname})`;
return `url(\${${!isAccountV3 ? "url.resourcesPath" : "resourceUrl"}}/${basenameOfTheKeycloakifyResourcesDir}${assetFileAbsoluteUrlPathname})`;
}
const assetFileRelativeUrlPathname = posix.relative(

View File

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

View File

@ -63,6 +63,7 @@ export type BuildContext = {
packageJsonDirPath: string;
packageJsonScripts: Record<string, string>;
};
doUseAccountV3: boolean;
};
export type BuildOptions = {
@ -77,16 +78,17 @@ export type BuildOptions = {
kcContextExclusionsFtl?: string;
/** https://docs.keycloakify.dev/v/v10/targetting-specific-keycloak-versions */
keycloakVersionTargets?: BuildOptions.KeycloakVersionTargets;
doUseAccountV3?: boolean;
};
export namespace BuildOptions {
export type KeycloakVersionTargets =
| ({ hasAccountTheme: true } & Record<
KeycloakVersionRange.WithAccountTheme,
KeycloakVersionRange.WithAccountV1Theme,
string | boolean
>)
| ({ hasAccountTheme: false } & Record<
KeycloakVersionRange.WithoutAccountTheme,
KeycloakVersionRange.WithoutAccountV1Theme,
string | boolean
>);
}
@ -229,6 +231,7 @@ export function getBuildContext(params: {
projectBuildDirPath?: string;
staticDirPathInProjectBuildDirPath?: string;
publicDirPath?: string;
doUseAccountV3?: boolean;
};
type ParsedPackageJson = {
@ -297,7 +300,8 @@ export function getBuildContext(params: {
return zKeycloakVersionTargets;
})()
).optional()
).optional(),
doUseAccountV3: z.boolean().optional()
});
{
@ -386,6 +390,8 @@ export function getBuildContext(params: {
const bundler = resolvedViteConfig !== undefined ? "vite" : "webpack";
const doUseAccountV3 = buildOptions.doUseAccountV3 ?? false;
return {
bundler:
resolvedViteConfig !== undefined
@ -569,7 +575,7 @@ export function getBuildContext(params: {
try {
child_process.execSync("npm config get", {
cwd: dirPath,
stdio: "ignore"
stdio: "pipe"
});
} catch (error) {
if (String(error).includes("ENOWORKSPACES")) {
@ -606,10 +612,10 @@ export function getBuildContext(params: {
}
const keycloakVersionRange: KeycloakVersionRange = (() => {
const doesImplementAccountTheme =
recordIsImplementedByThemeType.account;
const doesImplementAccountV1Theme =
!doUseAccountV3 && recordIsImplementedByThemeType.account;
if (doesImplementAccountTheme) {
if (doesImplementAccountV1Theme) {
const keycloakVersionRange = (() => {
if (buildForKeycloakMajorVersionNumber <= 21) {
return "21-and-below" as const;
@ -631,7 +637,7 @@ export function getBuildContext(params: {
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithAccountTheme
KeycloakVersionRange.WithAccountV1Theme
>
>();
@ -648,7 +654,7 @@ export function getBuildContext(params: {
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithoutAccountTheme
KeycloakVersionRange.WithoutAccountV1Theme
>
>();
@ -696,7 +702,7 @@ export function getBuildContext(params: {
const jarTargets_default = (() => {
const jarTargets: BuildContext["jarTargets"] = [];
if (recordIsImplementedByThemeType.account) {
if (!doUseAccountV3 && recordIsImplementedByThemeType.account) {
for (const keycloakVersionRange of [
"21-and-below",
"23",
@ -706,7 +712,7 @@ export function getBuildContext(params: {
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithAccountTheme
KeycloakVersionRange.WithAccountV1Theme
>
>(true);
jarTargets.push({
@ -723,7 +729,7 @@ export function getBuildContext(params: {
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithoutAccountTheme
KeycloakVersionRange.WithoutAccountV1Theme
>
>(true);
jarTargets.push({
@ -742,8 +748,9 @@ export function getBuildContext(params: {
}
if (
buildOptions.keycloakVersionTargets.hasAccountTheme !==
recordIsImplementedByThemeType.account
buildOptions.keycloakVersionTargets.hasAccountTheme !== doUseAccountV3
? false
: recordIsImplementedByThemeType.account
) {
console.log(
chalk.red(
@ -863,6 +870,7 @@ export function getBuildContext(params: {
}
return jarTargets;
})()
})(),
doUseAccountV3
};
}

View File

@ -17,14 +17,14 @@ export async function downloadKeycloakDefaultTheme(params: {
}): Promise<{ defaultThemeDirPath: string }> {
const { keycloakVersion, buildContext } = params;
let kcNodeModulesKeepFilePaths: string[] | undefined = undefined;
let kcNodeModulesKeepFilePaths_lastAccountV1: string[] | undefined = undefined;
let kcNodeModulesKeepFilePaths: Set<string> | undefined = undefined;
let kcNodeModulesKeepFilePaths_lastAccountV1: Set<string> | undefined = undefined;
const { extractedDirPath } = await downloadAndExtractArchive({
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnOnArchiveFile: "downloadKeycloakDefaultTheme",
uniqueIdOfOnArchiveFile: "downloadKeycloakDefaultTheme",
onArchiveFile: async params => {
const fileRelativePath = pathRelative("theme", params.fileRelativePath);
@ -72,16 +72,19 @@ export async function downloadKeycloakDefaultTheme(params: {
}
skip_node_modules: {
if (
!fileRelativePath.startsWith(
pathJoin("keycloak", "common", "resources", "node_modules")
)
) {
const nodeModulesRelativeDirPath = pathJoin(
"keycloak",
"common",
"resources",
"node_modules"
);
if (!fileRelativePath.startsWith(nodeModulesRelativeDirPath)) {
break skip_node_modules;
}
if (kcNodeModulesKeepFilePaths_lastAccountV1 === undefined) {
kcNodeModulesKeepFilePaths_lastAccountV1 = [
kcNodeModulesKeepFilePaths_lastAccountV1 = new Set([
pathJoin("patternfly", "dist", "css", "patternfly.min.css"),
pathJoin(
"patternfly",
@ -125,13 +128,19 @@ export async function downloadKeycloakDefaultTheme(params: {
"fonts",
"PatternFlyIcons-webfont.woff"
)
];
]);
}
for (const keepPath of kcNodeModulesKeepFilePaths_lastAccountV1) {
if (fileRelativePath.endsWith(keepPath)) {
break skip_node_modules;
}
const fileRelativeToNodeModulesPath = fileRelativePath.substring(
nodeModulesRelativeDirPath.length + 1
);
if (
kcNodeModulesKeepFilePaths_lastAccountV1.has(
fileRelativeToNodeModulesPath
)
) {
break skip_node_modules;
}
return;
@ -165,16 +174,19 @@ export async function downloadKeycloakDefaultTheme(params: {
}
skip_node_modules: {
if (
!fileRelativePath.startsWith(
pathJoin("keycloak", "common", "resources", "node_modules")
)
) {
const nodeModulesRelativeDirPath = pathJoin(
"keycloak",
"common",
"resources",
"node_modules"
);
if (!fileRelativePath.startsWith(nodeModulesRelativeDirPath)) {
break skip_node_modules;
}
if (kcNodeModulesKeepFilePaths === undefined) {
kcNodeModulesKeepFilePaths = [
kcNodeModulesKeepFilePaths = new Set([
pathJoin("@patternfly", "patternfly", "patternfly.min.css"),
pathJoin("patternfly", "dist", "css", "patternfly.min.css"),
pathJoin(
@ -231,15 +243,23 @@ export async function downloadKeycloakDefaultTheme(params: {
"fonts",
"PatternFlyIcons-webfont.woff"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Semibold-webfont.woff2"
),
pathJoin("patternfly", "dist", "img", "bg-login.jpg"),
pathJoin("jquery", "dist", "jquery.min.js")
];
]);
}
for (const keepPath of kcNodeModulesKeepFilePaths) {
if (fileRelativePath.endsWith(keepPath)) {
break skip_node_modules;
}
const fileRelativeToNodeModulesPath = fileRelativePath.substring(
nodeModulesRelativeDirPath.length + 1
);
if (kcNodeModulesKeepFilePaths.has(fileRelativeToNodeModulesPath)) {
break skip_node_modules;
}
return;

View File

@ -10,7 +10,7 @@ import { rm } from "./fs.rm";
export async function downloadAndExtractArchive(params: {
url: string;
uniqueIdOfOnOnArchiveFile: string;
uniqueIdOfOnArchiveFile: string;
onArchiveFile: (params: {
fileRelativePath: string;
readFile: () => Promise<Buffer>;
@ -22,7 +22,7 @@ export async function downloadAndExtractArchive(params: {
cacheDirPath: string;
fetchOptions: FetchOptions | undefined;
}): Promise<{ extractedDirPath: string }> {
const { url, uniqueIdOfOnOnArchiveFile, onArchiveFile, cacheDirPath, fetchOptions } =
const { url, uniqueIdOfOnArchiveFile, onArchiveFile, cacheDirPath, fetchOptions } =
params;
const archiveFileBasename = url.split("?")[0].split("/").reverse()[0];
@ -63,7 +63,7 @@ export async function downloadAndExtractArchive(params: {
});
}
const extractDirBasename = `${archiveFileBasename.split(".")[0]}_${uniqueIdOfOnOnArchiveFile}_${crypto
const extractDirBasename = `${archiveFileBasename.split(".")[0]}_${uniqueIdOfOnArchiveFile}_${crypto
.createHash("sha256")
.update(onArchiveFile.toString())
.digest("hex")

View File

@ -1,9 +1,8 @@
import type { ThemeType, LoginThemePageId } from "keycloakify/bin/shared/constants";
import type { ExtractAfterStartingWith } from "keycloakify/tools/ExtractAfterStartingWith";
import type { ValueOf } from "keycloakify/tools/ValueOf";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import type { MessageKey } from "../i18n/i18n";
import type { ClassKey } from "keycloakify/login/TemplateProps";
export type ExtendKcContext<
KcContextExtension extends { properties?: Record<string, string | undefined> },
@ -155,8 +154,7 @@ export declare namespace KcContext {
};
properties: {};
"x-keycloakify": {
realmMessageBundleUserProfile: Record<string, string> | undefined;
realmMessageBundleTermsText: string | undefined;
messages: Record<string, string>;
};
};
@ -221,7 +219,7 @@ export declare namespace KcContext {
export type Info = Common & {
pageId: "info.ftl";
messageHeader?: string;
requiredActions?: ExtractAfterStartingWith<"requiredAction.", MessageKey>[];
requiredActions?: string[];
skipLink: boolean;
pageRedirectUri?: string;
actionUri?: string;
@ -384,7 +382,7 @@ export declare namespace KcContext {
credentialId: string;
transports: {
iconClass: string;
displayNameProperties?: MessageKey[];
displayNameProperties?: string[];
};
label: string;
createdAt: string;
@ -501,26 +499,9 @@ export declare namespace KcContext {
export namespace SelectAuthenticator {
export type AuthenticationSelection = {
authExecId: string;
displayName:
| "otp-display-name"
| "password-display-name"
| "auth-username-form-display-name"
| "auth-username-password-form-display-name"
| "webauthn-display-name"
| "webauthn-passwordless-display-name";
helpText:
| "otp-help-text"
| "password-help-text"
| "auth-username-form-help-text"
| "auth-username-password-form-help-text"
| "webauthn-help-text"
| "webauthn-passwordless-help-text";
iconCssClass?:
| "kcAuthenticatorDefaultClass"
| "kcAuthenticatorPasswordClass"
| "kcAuthenticatorOTPClass"
| "kcAuthenticatorWebAuthnClass"
| "kcAuthenticatorWebAuthnPasswordlessClass";
displayName: string;
helpText: string;
iconCssClass?: ClassKey;
};
}

View File

@ -162,8 +162,7 @@ export const kcContextCommonMock: KcContext.Common = {
isAppInitiatedAction: false,
properties: {},
"x-keycloakify": {
realmMessageBundleUserProfile: undefined,
realmMessageBundleTermsText: undefined
messages: {}
}
};

View File

@ -11,8 +11,7 @@ export type KcContextLike = {
supported: { languageTag: string; url: string; label: string }[];
};
"x-keycloakify": {
realmMessageBundleUserProfile: Record<string, string> | undefined;
realmMessageBundleTermsText: string | undefined;
messages: Record<string, string>;
};
};
@ -131,8 +130,7 @@ export function createGetI18n<ExtraMessageKey extends string = never>(messageBun
messages_fallbackLanguage,
messageBundle_fallbackLanguage: messageBundle[fallbackLanguageTag],
messageBundle_currentLanguage: messageBundle[partialI18n.currentLanguageTag],
realmMessageBundleUserProfile: kcContext["x-keycloakify"].realmMessageBundleUserProfile,
realmMessageBundleTermsText: kcContext["x-keycloakify"].realmMessageBundleTermsText
messageBundle_realm: kcContext["x-keycloakify"].messages
});
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === fallbackLanguageTag;
@ -179,10 +177,9 @@ function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraM
messages_fallbackLanguage: Record<MessageKey, string>;
messageBundle_fallbackLanguage: Record<ExtraMessageKey, string> | undefined;
messageBundle_currentLanguage: Partial<Record<ExtraMessageKey, string>> | undefined;
realmMessageBundleUserProfile: Record<string, string> | undefined;
realmMessageBundleTermsText: string | undefined;
messageBundle_realm: Record<string, string>;
}) {
const { messageBundle_currentLanguage, realmMessageBundleUserProfile, realmMessageBundleTermsText } = params;
const { messageBundle_currentLanguage, messageBundle_realm } = params;
const messages_fallbackLanguage = {
...params.messages_fallbackLanguage,
@ -201,12 +198,21 @@ function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraM
const { key, args, doRenderAsHtml } = props;
const messageOrUndefined: string | undefined = (() => {
const messageOrUndefined = (messages_currentLanguage as any)[key] ?? (messages_fallbackLanguage as any)[key];
terms_text: {
if (key !== "termsText") {
break terms_text;
}
const termsTextMessage = messageBundle_realm[key];
if (key === "termsText" && realmMessageBundleTermsText !== undefined) {
return realmMessageBundleTermsText;
if (termsTextMessage === undefined) {
break terms_text;
}
return termsTextMessage;
}
const messageOrUndefined = (messages_currentLanguage as any)[key] ?? (messages_fallbackLanguage as any)[key];
return messageOrUndefined;
})();
@ -259,15 +265,11 @@ function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraM
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
const { key, args, doRenderAsHtml } = props;
user_profile: {
if (realmMessageBundleUserProfile === undefined) {
break user_profile;
}
const resolvedMessage = realmMessageBundleUserProfile[key] ?? realmMessageBundleUserProfile["${" + key + "}"];
realm_messages: {
const resolvedMessage = messageBundle_realm[key] ?? messageBundle_realm["${" + key + "}"];
if (resolvedMessage === undefined) {
break user_profile;
break realm_messages;
}
return doRenderAsHtml ? (

View File

@ -5,7 +5,7 @@ import type { I18n } from "../i18n";
export default function Info(props: PageProps<Extract<KcContext, { pageId: "info.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { msgStr, msg } = i18n;
const { advancedMsgStr, msg } = i18n;
const { messageHeader, message, requiredActions, skipLink, pageRedirectUri, actionUri, client } = kcContext;
@ -34,7 +34,7 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
if (requiredActions) {
html += "<b>";
html += requiredActions.map(requiredAction => msgStr(`requiredAction.${requiredAction}` as const)).join(",");
html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(",");
html += "</b>";
}

View File

@ -8,7 +8,7 @@ export default function SelectAuthenticator(props: PageProps<Extract<KcContext,
const { url, auth } = kcContext;
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { msg } = i18n;
const { msg, advancedMsg } = i18n;
return (
<Template
@ -30,11 +30,11 @@ export default function SelectAuthenticator(props: PageProps<Extract<KcContext,
value={authenticationSelection.authExecId}
>
<div className={kcClsx("kcSelectAuthListItemIconClass")}>
<i className={kcClsx(authenticationSelection.iconCssClass, "kcSelectAuthListItemIconPropertyClass")} />
<i className={kcClsx("kcSelectAuthListItemIconPropertyClass", authenticationSelection.iconCssClass)} />
</div>
<div className={kcClsx("kcSelectAuthListItemBodyClass")}>
<div className={kcClsx("kcSelectAuthListItemHeadingClass")}>{msg(authenticationSelection.displayName)}</div>
<div className={kcClsx("kcSelectAuthListItemDescriptionClass")}>{msg(authenticationSelection.helpText)}</div>
<div className={kcClsx("kcSelectAuthListItemHeadingClass")}>{advancedMsg(authenticationSelection.displayName)}</div>
<div className={kcClsx("kcSelectAuthListItemDescriptionClass")}>{advancedMsg(authenticationSelection.helpText)}</div>
</div>
<div className={kcClsx("kcSelectAuthListItemFillClass")} />
<div className={kcClsx("kcSelectAuthListItemArrowClass")}>

View File

@ -204,13 +204,13 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
className={kcClsx("kcSelectAuthListItemDescriptionClass")}
>
{authenticator.transports.displayNameProperties
.map((nameProperty, i, arr) => ({
nameProperty,
.map((displayNameProperty, i, arr) => ({
displayNameProperty,
hasNext: i !== arr.length - 1
}))
.map(({ nameProperty, hasNext }) => (
<Fragment key={nameProperty}>
<span>{msg(nameProperty)}</span>
.map(({ displayNameProperty, hasNext }) => (
<Fragment key={displayNameProperty}>
{advancedMsg(displayNameProperty)}
{hasNext && <span>, </span>}
</Fragment>
))}

View File

@ -1,4 +0,0 @@
export type ExtractAfterStartingWith<
Prefix extends string,
StrEnum
> = StrEnum extends `${Prefix}${infer U}` ? U : never;

View File

@ -43,9 +43,14 @@ export const WithRequiredActions: Story = {
<KcPageStory
kcContext={{
message: {
summary: "Server message"
summary: "Required actions: "
},
requiredActions: ["CONFIGURE_TOTP", "UPDATE_PROFILE", "VERIFY_EMAIL"]
requiredActions: ["CONFIGURE_TOTP", "UPDATE_PROFILE", "VERIFY_EMAIL", "CUSTOM_ACTION"],
"x-keycloakify": {
messages: {
"requiredAction.CUSTOM_ACTION": "Custom action"
}
}
}}
/>
)

View File

@ -69,7 +69,7 @@ export const WithRestrictedToMITStudents: Story = {
}
},
"x-keycloakify": {
realmMessageBundleUserProfile: {
messages: {
"${profile.attributes.email.inputHelperTextBefore}": "Please use your MIT or Berkeley email.",
"${profile.attributes.email.pattern.error}":
"This is not an MIT (<strong>@mit.edu</strong>) nor a Berkeley (<strong>@berkeley.edu</strong>) email."
@ -103,7 +103,7 @@ export const WithFavoritePet: Story = {
}
},
"x-keycloakify": {
realmMessageBundleUserProfile: {
messages: {
"${profile.attributes.favoritePet}": "Favorite Pet",
"${profile.attributes.favoritePet.options.cat}": "Fluffy Cat",
"${profile.attributes.favoritePet.options.dog}": "Loyal Dog",
@ -177,7 +177,9 @@ export const WithTermsAcceptance: Story = {
kcContext={{
termsAcceptanceRequired: true,
"x-keycloakify": {
realmMessageBundleTermsText: "<a href='https://example.com/terms'>Service Terms of Use</a>"
messages: {
termsText: "<a href='https://example.com/terms'>Service Terms of Use</a>"
}
}
}}
/>

View File

@ -41,3 +41,29 @@ export const WithDifferentAuthenticationMethods: Story = {
/>
)
};
export const WithRealmTranslations: Story = {
render: () => (
<KcPageStory
kcContext={{
auth: {
authenticationSelections: [
{
authExecId: "f0c22855-eda7-4092-8565-0c22f77d2ffb",
displayName: "home-idp-discovery-display-name",
helpText: "home-idp-discovery-help-text",
iconCssClass: "kcAuthenticatorDefaultClass"
}
]
},
["x-keycloakify"]: {
messages: {
"${home-idp-discovery-display-name}": "Home identity provider",
"${home-idp-discovery-help-text}":
"Sign in via your home identity provider which will be automatically determined based on your provided email address."
}
}
}}
/>
)
};

View File

@ -18,7 +18,9 @@ export const Default: Story = {
<KcPageStory
kcContext={{
"x-keycloakify": {
realmMessageBundleTermsText: "<p>My terms in <strong>English</strong></p>"
messages: {
termsText: "<p>My terms in <strong>English</strong></p>"
}
}
}}
/>
@ -34,7 +36,9 @@ export const French: Story = {
},
"x-keycloakify": {
// cSpell: disable
realmMessageBundleTermsText: "<p>Mes terme en <strong>Français</strong></p>"
messages: {
termsText: "<p>Mes terme en <strong>Français</strong></p>"
}
// cSpell: enable
}
}}

View File

@ -396,6 +396,7 @@ describe("css replacer", () => {
}
`,
cssFileRelativeDirPath: "assets/",
isAccountV3: false,
buildContext: {
urlPathname: undefined
}
@ -434,6 +435,7 @@ describe("css replacer", () => {
}
`,
cssFileRelativeDirPath: "assets/",
isAccountV3: false,
buildContext: {
urlPathname: "/a/b/"
}
@ -472,6 +474,7 @@ describe("css replacer", () => {
}
`,
cssFileRelativeDirPath: undefined,
isAccountV3: false,
buildContext: {
urlPathname: "/a/b/"
}
@ -510,6 +513,7 @@ describe("css replacer", () => {
}
`,
cssFileRelativeDirPath: undefined,
isAccountV3: false,
buildContext: {
urlPathname: undefined
}