Compare commits

..

35 Commits

Author SHA1 Message Date
2ab2c9e05e Release candidate 2024-07-08 15:21:23 +02:00
b1e9ba3ac6 Fix buil when paths with spaces 2024-07-08 15:21:03 +02:00
5822ed0185 Relase candidate 2024-07-08 14:54:37 +02:00
17b295788d Generate i18n messages for account v3 2024-07-08 14:54:09 +02:00
6cd5b958c7 Fix typo 2024-07-08 00:21:40 +02:00
df92cc5f73 Fix 2024-07-08 00:21:31 +02:00
03106cdee3 Release candidate 2024-07-07 18:45:38 +02:00
c4638daf1b Support building account v3 2024-07-07 18:45:14 +02:00
e2f5eb79ad Release candidate 2024-07-05 19:55:14 +02:00
b6c8e9bca0 Remove debug console log 2024-07-05 19:55:02 +02:00
573839019e Release candidate 2024-07-04 20:00:03 +02:00
815bf10ae0 Add line break 2024-07-04 19:59:28 +02:00
7c257d97a7 #577 2024-07-04 19:53:57 +02:00
59f8814660 Add missing patternfly image 2024-07-04 19:26:48 +02:00
1a6993099f Release candidate 2024-07-01 19:15:01 +02:00
f62ded3c8e Fix saml-post-form.ftl storybook 2024-07-01 19:15:01 +02:00
4eca6366cc Merge branch 'main' into keycloak_24 2024-06-30 07:13:20 +00:00
51a45b355d Remove script only used in CI 2024-06-29 19:41:36 +02:00
e5765cb902 Release candidate 2024-06-29 19:38:57 +02:00
6e922d2033 Merge pull request #575 from keycloakify/fix/keycloak_24-added-applications-story
Added & Fixed Applications Page Under Account
2024-06-29 17:36:55 +00:00
5d1695ada8 fix: added in applications.ftl story and fixed issue with double comma when realmRolesAvailable and resourceRolesAvailable both present 2024-06-28 16:36:56 -05:00
6e3ce29067 Relase candidate 2024-06-28 19:03:37 +02:00
2b9bbc4cef Ensure pnpm dlx isn't used 2024-06-28 19:03:19 +02:00
9557145f72 Bump version 2024-06-28 19:01:13 +02:00
249877b9c5 Better wording for assertNoPnpmDlx 2024-06-28 19:01:00 +02:00
ff2321fde5 Bump version 2024-06-28 18:51:06 +02:00
1edd6e4193 No pnpm dlx 2024-06-28 18:50:45 +02:00
bdde9162d9 Merge pull request #572 from keycloakify/fix/readme-update-discord
Fix/readme update discord
2024-06-25 16:38:31 -05:00
99b4933536 Merge branch 'main' into fix/readme-update-discord 2024-06-25 16:32:59 -05:00
c5caf7e0da fix: fix for previous discord readme addition, forgot the <a> tag 2024-06-25 16:32:21 -05:00
bcc5308cfb Merge pull request #571 from keycloakify/fix/readme-update-discord
Modified Readme.md to have a more visible discord invitation link
2024-06-25 21:29:50 +00:00
9fb902db5c fix: modified the readme using a slightly more visible discord invitation link 2024-06-25 16:27:22 -05:00
239f98aa9c fmt 2024-06-19 01:49:13 +00:00
f5d0511662 Update README.md 2024-06-19 03:36:00 +02:00
75582d2a26 Update README.md 2024-06-19 03:33:44 +02:00
39 changed files with 460 additions and 188 deletions

View File

@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v4
- uses: bahmutov/npm-install@v1
- name: If this step fails run 'npm run format' then commit again.
run: npm run format:check
run: npm run _format --list-different
test:
runs-on: ${{ matrix.os }}
needs: test_lint

View File

@ -17,9 +17,12 @@
<a href="https://github.com/thomasdarimont/awesome-keycloak">
<img src="https://awesome.re/mentioned-badge.svg"/>
</a>
<a href="https://discord.gg/kYFZG7fQmn">
<img src="https://img.shields.io/discord/1097708346976505977"/>
</a>
<p align="center">
Check out our discord server!<br/>
<a href="https://discord.gg/mJdYJSdcm4">
<img src="https://dcbadge.limes.pink/api/server/kYFZG7fQmn"/>
</a>
</p>
<p align="center">
<a href="https://www.keycloakify.dev">Home</a>
-
@ -38,10 +41,9 @@
<img width="400" src="https://github.com/keycloakify/keycloakify/assets/6702424/e66d105c-c06f-47d1-8a31-a6ab09da4e80">
</p>
Keycloakify is fully compatible with Keycloak 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, [~~22~~](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1822509763), **23** [and up](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)!
Keycloakify is fully compatible with Keycloak 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, [~~22~~](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1822509763), 23, 24, 25...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
> NOTE: Keycloak 24 introduces [important changes](https://www.keycloak.org/docs/latest/upgrading/index.html#changes-to-freemarker-templates-to-render-pages-based-on-the-user-profile-and-realm).
> We're actively working on incorporating them into Keycloakify. [Follow progress](https://github.com/keycloakify/keycloakify/pull/538).
> NOTE: Keycloakify 10 is still in realase-candidate state. [Follow progress](https://github.com/keycloakify/keycloakify/pull/538).
## Sponsor

View File

@ -1,6 +1,6 @@
{
"name": "keycloakify",
"version": "10.0.0-rc.97",
"version": "10.0.0-rc.105",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
@ -15,7 +15,6 @@
"test:types": "tsc -p test/tsconfig.json --noEmit",
"_format": "prettier '**/*.{ts,tsx,json,md}'",
"format": "yarn _format --write",
"format:check": "yarn _format --list-different",
"link-in-app": "tsx scripts/link-in-app.ts",
"build-storybook": "tsx scripts/build-storybook.ts",
"dump-keycloak-realm": "tsx scripts/dump-keycloak-realm.ts"

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

@ -145,7 +145,12 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<div className={clsx("alert", `alert-${message.type}`)}>
{message.type === "success" && <span className="pficon pficon-ok"></span>}
{message.type === "error" && <span className="pficon pficon-error-circle-o"></span>}
<span className="kc-feedback-text">{message.summary}</span>
<span
className="kc-feedback-text"
dangerouslySetInnerHTML={{
__html: message.summary
}}
/>
</div>
)}

View File

@ -62,7 +62,6 @@ export default function Applications(props: PageProps<Extract<KcContext, { pageI
{index < application.realmRolesAvailable.length - 1 && ", "}
</span>
))}
{!isArrayWithEmptyObject(application.realmRolesAvailable) && application.resourceRolesAvailable && ", "}
{application.resourceRolesAvailable &&
Object.keys(application.resourceRolesAvailable).map(resource => (
<span key={resource}>

View File

@ -154,9 +154,14 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
/>
{messagesPerField.existsError("totp") && (
<span id="input-error-otp-code" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("totp")}
</span>
<span
id="input-error-otp-code"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("totp")
}}
/>
)}
</div>
<input type="hidden" id="totpSecret" name="totpSecret" value={totp.totpSecret} />
@ -180,9 +185,14 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
aria-invalid={messagesPerField.existsError("userLabel")}
/>
{messagesPerField.existsError("userLabel") && (
<span id="input-error-otp-label" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("userLabel")}
</span>
<span
id="input-error-otp-label"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("userLabel")
}}
/>
)}
</div>
</div>

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,7 +28,7 @@ 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 ){

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

@ -34,10 +34,7 @@ export function readExtraPagesNames(params: {
const rawSourceFile = fs.readFileSync(candidateFilPath).toString("utf8");
extraPages.push(
...Array.from(
rawSourceFile.matchAll(/["']([^.\s]+.ftl)["']:/g),
m => (console.log(m), m[1])
)
...Array.from(rawSourceFile.matchAll(/["']([^.\s]+.ftl)["']:/g), m => m[1])
);
}

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

@ -3,11 +3,14 @@
import { termost } from "termost";
import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
import * as child_process from "child_process";
import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx";
export type CliCommandOptions = {
projectDirPath: string | undefined;
};
assertNoPnpmDlx();
const program = termost<CliCommandOptions>(
{
name: "keycloakify",

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
@ -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

@ -24,7 +24,7 @@ export async function downloadKeycloakDefaultTheme(params: {
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);
@ -231,6 +231,7 @@ export async function downloadKeycloakDefaultTheme(params: {
"fonts",
"PatternFlyIcons-webfont.woff"
),
pathJoin("patternfly", "dist", "img", "bg-login.jpg"),
pathJoin("jquery", "dist", "jquery.min.js")
];
}

View File

@ -0,0 +1,15 @@
import { sep as pathSep } from "path";
import chalk from "chalk";
export function assertNoPnpmDlx() {
if (__dirname.includes(`${pathSep}pnpm${pathSep}dlx${pathSep}`)) {
console.log(
[
chalk.red("Please don't use `pnpm dlx keycloakify`"),
"\nUse `npx keycloakify` or `pnpm exec keycloakify` instead since you want to use the keycloakify",
"version that is installed in your project and not the latest version on NPM."
].join(" ")
);
process.exit(1);
}
}

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

@ -15,7 +15,6 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
displayMessage = true,
displayRequiredFields = false,
headerNode,
showUsernameNode = null,
socialProvidersNode = null,
infoNode = null,
documentTitle,
@ -164,45 +163,10 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div>
</div>
)}
{!(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
displayRequiredFields ? (
<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">
<h1 id="kc-page-title">{headerNode}</h1>
</div>
</div>
) : (
{(() => {
const node = !(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
<h1 id="kc-page-title">{headerNode}</h1>
)
) : displayRequiredFields ? (
<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={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={kcClsx("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</div>
</div>
) : (
<>
{showUsernameNode}
) : (
<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")}>
@ -212,8 +176,24 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div>
</a>
</div>
</>
)}
);
if (displayRequiredFields) {
return (
<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">{node}</div>
</div>
);
}
return node;
})()}
</header>
<div id="kc-content">
<div id="kc-content-wrapper">

View File

@ -12,7 +12,6 @@ export type TemplateProps<KcContext, I18n> = {
displayRequiredFields?: boolean;
showAnotherWayIfPresent?: boolean;
headerNode: ReactNode;
showUsernameNode?: ReactNode;
socialProvidersNode?: ReactNode;
infoNode?: ReactNode;
documentTitle?: string;

View File

@ -188,7 +188,7 @@ function FieldErrors(props: { attribute: Attribute; displayableErrors: FormField
.filter(error => error.fieldIndex === fieldIndex)
.map(({ errorMessage }, i, arr) => (
<Fragment key={i}>
<span key={i}>{errorMessage}</span>
{errorMessage}
{arr.length - 1 !== i && <br />}
</Fragment>
))}

View File

@ -612,7 +612,14 @@ function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18
return [
{
errorMessageStr,
errorMessage: <span key={0}>{errorMessageStr}</span>,
errorMessage: (
<span
key={0}
dangerouslySetInnerHTML={{
__html: errorMessageStr
}}
/>
),
fieldIndex: undefined,
source: {
type: "server"

View File

@ -19,7 +19,7 @@ export default function Error(props: PageProps<Extract<KcContext, { pageId: "err
headerNode={msg("errorTitle")}
>
<div id="kc-error-message">
<p className="instruction">{message.summary}</p>
<p className="instruction" dangerouslySetInnerHTML={{ __html: message.summary }} />
{!skipLink && client !== undefined && client.baseUrl !== undefined && (
<p>
<a id="backToApplication" href={client.baseUrl}>

View File

@ -16,13 +16,33 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayMessage={false}
headerNode={messageHeader !== undefined ? <>{messageHeader}</> : <>{message.summary}</>}
headerNode={
<span
dangerouslySetInnerHTML={{
__html: messageHeader ?? message.summary
}}
/>
}
>
<div id="kc-info-message">
<p className="instruction">
{message.summary}
{requiredActions && <b>{requiredActions.map(requiredAction => msgStr(`requiredAction.${requiredAction}` as const)).join(",")}</b>}
</p>
<p
className="instruction"
dangerouslySetInnerHTML={{
__html: (() => {
let html = message.summary;
if (requiredActions) {
html += "<b>";
html += requiredActions.map(requiredAction => msgStr(`requiredAction.${requiredAction}` as const)).join(",");
html += "</b>";
}
return html;
})()
}}
/>
{(() => {
if (skipLink) {
return null;

View File

@ -60,9 +60,10 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
href={p.loginUrl}
>
{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>
<span
className={clsx(kcClsx("kcFormSocialAccountNameClass"), p.iconClasses && "kc-social-icon-text")}
dangerouslySetInnerHTML={{ __html: p.displayName }}
></span>
</a>
</li>
))}
@ -105,9 +106,14 @@ 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={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.getFirstError("username", "password")}
</span>
<span
id="input-error"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.getFirstError("username", "password")
}}
/>
)}
</div>
)}
@ -128,9 +134,14 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
/>
</PasswordWrapper>
{usernameHidden && messagesPerField.existsError("username", "password") && (
<span id="input-error" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.getFirstError("username", "password")}
</span>
<span
id="input-error"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.getFirstError("username", "password")
}}
/>
)}
</div>

View File

@ -112,9 +112,14 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
/>
{messagesPerField.existsError("totp") && (
<span id="input-error-otp-code" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("totp")}
</span>
<span
id="input-error-otp-code"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("totp")
}}
/>
)}
</div>
<input type="hidden" id="totpSecret" name="totpSecret" value={totp.totpSecret} />
@ -138,9 +143,14 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
aria-invalid={messagesPerField.existsError("userLabel")}
/>
{messagesPerField.existsError("userLabel") && (
<span id="input-error-otp-label" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("userLabel")}
</span>
<span
id="input-error-otp-label"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("userLabel")
}}
/>
)}
</div>
</div>

View File

@ -70,9 +70,14 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
aria-invalid={messagesPerField.existsError("totp")}
/>
{messagesPerField.existsError("totp") && (
<span id="input-error-otp-code" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("totp")}
</span>
<span
id="input-error-otp-code"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("totp")
}}
/>
)}
</div>
</div>

View File

@ -60,9 +60,14 @@ export default function LoginPassword(props: PageProps<Extract<KcContext, { page
</PasswordWrapper>
{messagesPerField.existsError("password") && (
<span id="input-error-password" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("password")}
</span>
<span
id="input-error-password"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("password")
}}
/>
)}
</div>
<div className={kcClsx("kcFormGroupClass", "kcFormSettingClass")}>

View File

@ -43,9 +43,14 @@ export default function LoginRecoveryAuthnCodeInput(props: PageProps<Extract<KcC
autoFocus
/>
{messagesPerField.existsError("recoveryCodeInput") && (
<span id="input-error" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("recoveryCodeInput")}
</span>
<span
id="input-error"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("recoveryCodeInput")
}}
/>
)}
</div>
</div>

View File

@ -48,9 +48,14 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
aria-invalid={messagesPerField.existsError("username")}
/>
{messagesPerField.existsError("username") && (
<span id="input-error-username" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("username")}
</span>
<span
id="input-error-username"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("username")
}}
/>
)}
</div>
</div>

View File

@ -46,9 +46,14 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
</PasswordWrapper>
{messagesPerField.existsError("password") && (
<span id="input-error-password" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("password")}
</span>
<span
id="input-error-password"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("password")
}}
/>
)}
</div>
</div>
@ -74,9 +79,14 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
</PasswordWrapper>
{messagesPerField.existsError("password-confirm") && (
<span id="input-error-password-confirm" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("password-confirm")}
</span>
<span
id="input-error-password-confirm"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("password-confirm")
}}
/>
)}
</div>

View File

@ -118,9 +118,14 @@ function TermsAcceptance(props: {
</div>
{messagesPerField.existsError("termsAccepted") && (
<div className={kcClsx("kcLabelWrapperClass")}>
<span id="input-error-terms-accepted" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("termsAccepted")}
</span>
<span
id="input-error-terms-accepted"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("termsAccepted")
}}
/>
</div>
)}
</div>

View File

@ -18,7 +18,7 @@ export default function SamlPostForm(props: PageProps<Extract<KcContext, { pageI
}
// Storybook
if (samlPost.url === "") {
if (samlPost.url === "#") {
alert("In a real Keycloak the user would be redirected immediately");
return;
}

View File

@ -0,0 +1,80 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "applications.ftl" });
const meta = {
title: "account/applications.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "applications.ftl",
applications: {
applications: [
{
realmRolesAvailable: [
{
name: "realmRoleName1",
description: "realm role description 1"
},
{
name: "realmRoleName2",
description: "realm role description 2"
}
],
resourceRolesAvailable: {
resource1: [
{
roleName: "Resource Role Name 1",
roleDescription: "Resource role 1 description",
clientName: "Client Name 1",
clientId: "client1"
}
],
resource2: [
{
roleName: "Resource Role Name 2",
clientName: "Client Name 1",
clientId: "client1"
}
]
},
additionalGrants: ["grant1", "grant2"],
clientScopesGranted: ["scope1", "scope2"],
effectiveUrl: "#",
client: {
clientId: "application1",
name: "Application 1",
consentRequired: true
}
},
{
realmRolesAvailable: [
{
name: "Realm Role Name 1"
}
],
resourceRolesAvailable: {},
additionalGrants: [],
clientScopesGranted: [],
effectiveUrl: "#",
client: {
clientId: "application2",
name: "Application 2"
}
}
]
}
}}
/>
)
};

View File

@ -212,7 +212,7 @@ export const WithErrorMessage: Story = {
<KcPageStory
kcContext={{
message: {
summary: "The time allotted for the connection has elapsed. The login process will restart from the beginning.",
summary: "The time allotted for the connection has elapsed.<br/>The login process will restart from the beginning.",
type: "error"
}
}}

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
}