Compare commits

...

39 Commits

Author SHA1 Message Date
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
c7d47f128e Release candidate 2024-06-28 07:16:39 +02:00
14cb07efb2 Make terms acceptance a required field on the Register page 2024-06-28 07:16:17 +02:00
a51724208c Release candidate 2024-06-28 06:47:02 +02:00
050e2b2b99 Improve Register page default stories 2024-06-28 06:46:26 +02:00
3706f15f7e Fix bug resolving user profile translations 2024-06-28 06:46:12 +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
7461e38034 Release candidate 2024-06-25 22:51:21 +02:00
dccd85a151 Fix readExtraPage 2024-06-25 22:51:07 +02:00
910604fdad Use vite template by default 2024-06-25 22:50:51 +02:00
508cb9158e Release candidate 2024-06-24 03:58:55 +02:00
915c500d32 Feedback when running keycloakfy build 2024-06-24 03:58:42 +02: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
42 changed files with 532 additions and 219 deletions

View File

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

View File

@ -17,9 +17,12 @@
<a href="https://github.com/thomasdarimont/awesome-keycloak"> <a href="https://github.com/thomasdarimont/awesome-keycloak">
<img src="https://awesome.re/mentioned-badge.svg"/> <img src="https://awesome.re/mentioned-badge.svg"/>
</a> </a>
<a href="https://discord.gg/kYFZG7fQmn"> <p align="center">
<img src="https://img.shields.io/discord/1097708346976505977"/> Check out our discord server!<br/>
</a> <a href="https://discord.gg/mJdYJSdcm4">
<img src="https://dcbadge.limes.pink/api/server/kYFZG7fQmn"/>
</a>
</p>
<p align="center"> <p align="center">
<a href="https://www.keycloakify.dev">Home</a> <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"> <img width="400" src="https://github.com/keycloakify/keycloakify/assets/6702424/e66d105c-c06f-47d1-8a31-a6ab09da4e80">
</p> </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). > NOTE: Keycloakify 10 is still in realase-candidate state. [Follow progress](https://github.com/keycloakify/keycloakify/pull/538).
> We're actively working on incorporating them into Keycloakify. [Follow progress](https://github.com/keycloakify/keycloakify/pull/538).
## Sponsor ## Sponsor

View File

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

View File

@ -10,7 +10,7 @@ fs.rmSync(".yarn_home", { recursive: true, force: true });
run("yarn install"); run("yarn install");
run("yarn build"); run("yarn build");
const starterName = "keycloakify-starter-webpack"; const starterName = "keycloakify-starter";
fs.rmSync(join("..", starterName, "node_modules"), { fs.rmSync(join("..", starterName, "node_modules"), {
recursive: true, recursive: true,

View File

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

View File

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

View File

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

View File

@ -81,7 +81,8 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
) )
) )
.toString("utf8") .toString("utf8")
.replace('import React from "react";\n', ""); .replace('import React from "react";\n', "")
.replace(/from "[./]+dist\//, 'from "keycloakify/');
{ {
const targetDirPath = pathDirname(targetFilePath); const targetDirPath = pathDirname(targetFilePath);

View File

@ -33,6 +33,7 @@ export async function buildJar(params: {
keycloakAccountV1Version: KeycloakAccountV1Version; keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion; keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
resourcesDirPath: string; resourcesDirPath: string;
doesImplementAccountV1Theme: boolean;
buildContext: BuildContextLike; buildContext: BuildContextLike;
}): Promise<void> { }): Promise<void> {
const { const {
@ -40,6 +41,7 @@ export async function buildJar(params: {
keycloakAccountV1Version, keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion, keycloakThemeAdditionalInfoExtensionVersion,
resourcesDirPath, resourcesDirPath,
doesImplementAccountV1Theme,
buildContext buildContext
} = params; } = params;
@ -61,7 +63,7 @@ export async function buildJar(params: {
srcDirPath: resourcesDirPath, srcDirPath: resourcesDirPath,
destDirPath: tmpResourcesDirPath, destDirPath: tmpResourcesDirPath,
transformSourceCode: transformSourceCode:
keycloakAccountV1Version !== null !doesImplementAccountV1Theme || keycloakAccountV1Version !== null
? undefined ? undefined
: (params: { : (params: {
fileRelativePath: string; 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({ writeMetaInfKeycloakThemes({
resourcesDirPath: tmpResourcesDirPath, resourcesDirPath: tmpResourcesDirPath,
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => { getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => {
@ -135,6 +147,7 @@ export async function buildJar(params: {
} }
})(); })();
// TODO: Remove this optimization, it's a bit hacky.
if (doBreak) { if (doBreak) {
break route_legacy_pages; break route_legacy_pages;
} }

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
<#assign pageId="PAGE_ID_xIgLsPgGId9D8e"> <#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}; const kcContext = ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc};
if( kcContext.messagesPerField ){ if( kcContext.messagesPerField ){
var existsError_singleFieldName = kcContext.messagesPerField.existsError; var existsError_singleFieldName = kcContext.messagesPerField.existsError;
@ -27,7 +28,7 @@ if( kcContext.messagesPerField ){
} }
kcContext.keycloakifyVersion = "KEYCLOAKIFY_VERSION_xEdKd3xEdr"; kcContext.keycloakifyVersion = "KEYCLOAKIFY_VERSION_xEdKd3xEdr";
kcContext.themeVersion = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx"; kcContext.themeVersion = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx";
kcContext.themeType = "KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr"; kcContext.themeType = "${themeType}";
kcContext.themeName = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer"; kcContext.themeName = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer";
kcContext.pageId = "${pageId}"; kcContext.pageId = "${pageId}";
if( kcContext.url && kcContext.url.resourcesPath ){ if( kcContext.url && kcContext.url.resourcesPath ){

View File

@ -54,6 +54,7 @@ export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
recordIsImplementedByThemeType: BuildContext["recordIsImplementedByThemeType"]; recordIsImplementedByThemeType: BuildContext["recordIsImplementedByThemeType"];
themeSrcDirPath: string; themeSrcDirPath: string;
bundler: { type: "vite" } | { type: "webpack" }; bundler: { type: "vite" } | { type: "webpack" };
doUseAccountV3: boolean;
}; };
assert<BuildContext extends BuildContextLike ? true : false>(); assert<BuildContext extends BuildContextLike ? true : false>();
@ -71,6 +72,7 @@ export async function generateResourcesForMainTheme(params: {
}; };
for (const themeType of ["login", "account"] as const) { for (const themeType of ["login", "account"] as const) {
const isAccountV3 = themeType === "account" && buildContext.doUseAccountV3;
if (!buildContext.recordIsImplementedByThemeType[themeType]) { if (!buildContext.recordIsImplementedByThemeType[themeType]) {
continue; continue;
} }
@ -136,7 +138,8 @@ export async function generateResourcesForMainTheme(params: {
const { fixedCssCode } = replaceImportsInCssCode({ const { fixedCssCode } = replaceImportsInCssCode({
cssCode: sourceCode.toString("utf8"), cssCode: sourceCode.toString("utf8"),
cssFileRelativeDirPath: pathDirname(fileRelativePath), cssFileRelativeDirPath: pathDirname(fileRelativePath),
buildContext buildContext,
isAccountV3
}); });
return { return {
@ -171,7 +174,8 @@ export async function generateResourcesForMainTheme(params: {
fieldNames: readFieldNameUsage({ fieldNames: readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath, themeSrcDirPath: buildContext.themeSrcDirPath,
themeType themeType
}) }),
isAccountV3
}); });
[ [
@ -180,13 +184,15 @@ export async function generateResourcesForMainTheme(params: {
case "login": case "login":
return loginThemePageIds; return loginThemePageIds;
case "account": case "account":
return accountThemePageIds; return isAccountV3 ? ["index.ftl"] : accountThemePageIds;
} }
})(), })(),
...readExtraPagesNames({ ...(isAccountV3
themeType, ? []
themeSrcDirPath: buildContext.themeSrcDirPath : readExtraPagesNames({
}) themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => { ].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId }); const { ftlCode } = generateFtlFilesCode({ pageId });
@ -196,40 +202,52 @@ export async function generateResourcesForMainTheme(params: {
); );
}); });
generateMessageProperties({ i18n_messages_generation: {
themeSrcDirPath: buildContext.themeSrcDirPath, if (isAccountV3) {
themeType break i18n_messages_generation;
}).forEach(({ languageTag, propertiesFileSource }) => { }
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), { generateMessageProperties({
recursive: true 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( keycloak_static_resources: {
messagesDirPath, if (isAccountV3) {
`messages_${languageTag}.properties` break keycloak_static_resources;
); }
fs.writeFileSync( await downloadKeycloakStaticResources({
propertiesFilePath, keycloakVersion: (() => {
Buffer.from(propertiesFileSource, "utf8") switch (themeType) {
); case "account":
}); return lastKeycloakVersionWithAccountV1;
case "login":
await downloadKeycloakStaticResources({ return buildContext.loginThemeResourcesFromKeycloakVersion;
keycloakVersion: (() => { }
switch (themeType) { })(),
case "account": themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
return lastKeycloakVersionWithAccountV1; themeType,
case "login": buildContext
return buildContext.loginThemeResourcesFromKeycloakVersion; });
} }
})(),
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildContext
});
fs.writeFileSync( fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"), pathJoin(themeTypeDirPath, "theme.properties"),
@ -238,12 +256,13 @@ export async function generateResourcesForMainTheme(params: {
`parent=${(() => { `parent=${(() => {
switch (themeType) { switch (themeType) {
case "account": case "account":
return accountV1ThemeName; return isAccountV3 ? "base" : accountV1ThemeName;
case "login": case "login":
return "keycloak"; return "keycloak";
} }
assert<Equals<typeof themeType, never>>(false); assert<Equals<typeof themeType, never>>(false);
})()}`, })()}`,
...(isAccountV3 ? ["deprecatedMode=false"] : []),
...(buildContext.extraThemeProperties ?? []), ...(buildContext.extraThemeProperties ?? []),
...buildContext.environmentVariables.map( ...buildContext.environmentVariables.map(
({ name, default: defaultValue }) => ({ name, default: defaultValue }) =>
@ -268,7 +287,15 @@ 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({ await bringInAccountV1({
resourcesDirPath, resourcesDirPath,
buildContext buildContext

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import * as child_process from "child_process";
import { Deferred } from "evt/tools/Deferred"; import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext"; import type { BuildContext } from "../shared/buildContext";
import chalk from "chalk";
export type BuildContextLike = { export type BuildContextLike = {
projectDirPath: string; projectDirPath: string;
@ -19,6 +20,8 @@ export async function keycloakifyBuild(params: {
const dResult = new Deferred<{ isSuccess: boolean }>(); const dResult = new Deferred<{ isSuccess: boolean }>();
console.log(chalk.blue("Running: 'npx keycloakify build'"));
const child = child_process.spawn("npx", ["keycloakify", "build"], { const child = child_process.spawn("npx", ["keycloakify", "build"], {
cwd: buildContext.projectDirPath, cwd: buildContext.projectDirPath,
env: { env: {

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

@ -15,7 +15,6 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
displayMessage = true, displayMessage = true,
displayRequiredFields = false, displayRequiredFields = false,
headerNode, headerNode,
showUsernameNode = null,
socialProvidersNode = null, socialProvidersNode = null,
infoNode = null, infoNode = null,
documentTitle, documentTitle,
@ -164,45 +163,10 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div> </div>
</div> </div>
)} )}
{!(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? ( {(() => {
displayRequiredFields ? ( const node = !(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
<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>
) : (
<h1 id="kc-page-title">{headerNode}</h1> <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")}> <div id="kc-username" className={kcClsx("kcFormGroupClass")}>
<label id="kc-attempted-username">{auth.attemptedUsername}</label> <label id="kc-attempted-username">{auth.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}> <a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
@ -212,8 +176,24 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div> </div>
</a> </a>
</div> </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> </header>
<div id="kc-content"> <div id="kc-content">
<div id="kc-content-wrapper"> <div id="kc-content-wrapper">

View File

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

View File

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

View File

@ -259,8 +259,16 @@ function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraM
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string { function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
const { key, args, doRenderAsHtml } = props; const { key, args, doRenderAsHtml } = props;
if (realmMessageBundleUserProfile !== undefined && key in realmMessageBundleUserProfile) { user_profile: {
const resolvedMessage = realmMessageBundleUserProfile[key]; if (realmMessageBundleUserProfile === undefined) {
break user_profile;
}
const resolvedMessage = realmMessageBundleUserProfile[key] ?? realmMessageBundleUserProfile["${" + key + "}"];
if (resolvedMessage === undefined) {
break user_profile;
}
return doRenderAsHtml ? ( return doRenderAsHtml ? (
<span <span

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@ export default function Register(props: RegisterProps) {
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
const [isFormSubmittable, setIsFormSubmittable] = useState(false); const [isFormSubmittable, setIsFormSubmittable] = useState(false);
const [areTermsAccepted, setAreTermsAccepted] = useState(false);
return ( return (
<Template <Template
@ -43,7 +44,15 @@ export default function Register(props: RegisterProps) {
onIsFormSubmittableValueChange={setIsFormSubmittable} onIsFormSubmittableValueChange={setIsFormSubmittable}
doMakeUserConfirmPassword={doMakeUserConfirmPassword} doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/> />
{termsAcceptanceRequired && <TermsAcceptance i18n={i18n} kcClsx={kcClsx} messagesPerField={messagesPerField} />} {termsAcceptanceRequired && (
<TermsAcceptance
i18n={i18n}
kcClsx={kcClsx}
messagesPerField={messagesPerField}
areTermsAccepted={areTermsAccepted}
onAreTermsAcceptedValueChange={setAreTermsAccepted}
/>
)}
{recaptchaRequired && ( {recaptchaRequired && (
<div className="form-group"> <div className="form-group">
<div className={kcClsx("kcInputWrapperClass")}> <div className={kcClsx("kcInputWrapperClass")}>
@ -61,7 +70,7 @@ export default function Register(props: RegisterProps) {
</div> </div>
<div id="kc-form-buttons" className={kcClsx("kcFormButtonsClass")}> <div id="kc-form-buttons" className={kcClsx("kcFormButtonsClass")}>
<input <input
disabled={!isFormSubmittable} disabled={!isFormSubmittable || (termsAcceptanceRequired && !areTermsAccepted)}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonBlockClass", "kcButtonLargeClass")} className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonBlockClass", "kcButtonLargeClass")}
type="submit" type="submit"
value={msgStr("doRegister")} value={msgStr("doRegister")}
@ -73,8 +82,14 @@ export default function Register(props: RegisterProps) {
); );
} }
function TermsAcceptance(props: { i18n: I18n; kcClsx: KcClsx; messagesPerField: Pick<KcContext["messagesPerField"], "existsError" | "get"> }) { function TermsAcceptance(props: {
const { i18n, kcClsx, messagesPerField } = props; i18n: I18n;
kcClsx: KcClsx;
messagesPerField: Pick<KcContext["messagesPerField"], "existsError" | "get">;
areTermsAccepted: boolean;
onAreTermsAcceptedValueChange: (areTermsAccepted: boolean) => void;
}) {
const { i18n, kcClsx, messagesPerField, areTermsAccepted, onAreTermsAcceptedValueChange } = props;
const { msg } = i18n; const { msg } = i18n;
@ -93,6 +108,8 @@ function TermsAcceptance(props: { i18n: I18n; kcClsx: KcClsx; messagesPerField:
id="termsAccepted" id="termsAccepted"
name="termsAccepted" name="termsAccepted"
className={kcClsx("kcCheckboxInputClass")} className={kcClsx("kcCheckboxInputClass")}
checked={areTermsAccepted}
onChange={e => onAreTermsAcceptedValueChange(e.target.checked)}
aria-invalid={messagesPerField.existsError("termsAccepted")} aria-invalid={messagesPerField.existsError("termsAccepted")}
/> />
<label htmlFor="termsAccepted" className={kcClsx("kcLabelClass")}> <label htmlFor="termsAccepted" className={kcClsx("kcLabelClass")}>
@ -101,9 +118,14 @@ function TermsAcceptance(props: { i18n: I18n; kcClsx: KcClsx; messagesPerField:
</div> </div>
{messagesPerField.existsError("termsAccepted") && ( {messagesPerField.existsError("termsAccepted") && (
<div className={kcClsx("kcLabelWrapperClass")}> <div className={kcClsx("kcLabelWrapperClass")}>
<span id="input-error-terms-accepted" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite"> <span
{messagesPerField.get("termsAccepted")} id="input-error-terms-accepted"
</span> className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("termsAccepted")
}}
/>
</div> </div>
)} )}
</div> </div>

View File

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

View File

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory"; import { createKcPageStory } from "../KcPageStory";
import type { Attribute } from "../../../dist/login";
const { KcPageStory } = createKcPageStory({ pageId: "register.ftl" }); const { KcPageStory } = createKcPageStory({ pageId: "register.ftl" });
@ -48,23 +49,84 @@ export const WithEmailAlreadyExists: Story = {
) )
}; };
export const WithEmailAsUsername: Story = { export const WithRestrictedToMITStudents: Story = {
render: () => ( render: () => (
<KcPageStory <KcPageStory
kcContext={{ kcContext={{
realm: { profile: {
registrationEmailAsUsername: true attributesByName: {
email: {
validators: {
pattern: {
pattern: "^[^@]+@([^.]+\\.)*((mit\\.edu)|(berkeley\\.edu))$",
"error-message": "${profile.attributes.email.pattern.error}"
}
},
annotations: {
inputHelperTextBefore: "${profile.attributes.email.inputHelperTextBefore}"
}
}
}
},
"x-keycloakify": {
realmMessageBundleUserProfile: {
"${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."
}
} }
}} }}
/> />
) )
}; };
export const WithoutPassword: Story = { export const WithFavoritePet: Story = {
render: () => ( render: () => (
<KcPageStory <KcPageStory
kcContext={{ kcContext={{
passwordRequired: false profile: {
attributesByName: {
favoritePet: {
name: "favorite-pet",
displayName: "${profile.attributes.favoritePet}",
validators: {
options: {
options: ["cat", "dog", "fish"]
}
},
annotations: {
inputOptionLabelsI18nPrefix: "profile.attributes.favoritePet.options"
},
required: false,
readOnly: false
} satisfies Attribute
}
},
"x-keycloakify": {
realmMessageBundleUserProfile: {
"${profile.attributes.favoritePet}": "Favorite Pet",
"${profile.attributes.favoritePet.options.cat}": "Fluffy Cat",
"${profile.attributes.favoritePet.options.dog}": "Loyal Dog",
"${profile.attributes.favoritePet.options.fish}": "Peaceful Fish"
}
}
}}
/>
)
};
export const WithEmailAsUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: {
registrationEmailAsUsername: true
},
profile: {
attributesByName: {
username: undefined
}
}
}} }}
/> />
) )
@ -97,31 +159,6 @@ export const WithRecaptchaFrench: Story = {
) )
}; };
export const WithPresets: Story = {
render: () => (
<KcPageStory
kcContext={{
profile: {
attributesByName: {
firstName: {
value: "Max"
},
lastName: {
value: "Mustermann"
},
email: {
value: "max.mustermann@gmail.com"
},
username: {
value: "max.mustermann"
}
}
}
}}
/>
)
};
export const WithPasswordMinLength8: Story = { export const WithPasswordMinLength8: Story = {
render: () => ( render: () => (
<KcPageStory <KcPageStory
@ -133,3 +170,16 @@ export const WithPasswordMinLength8: Story = {
/> />
) )
}; };
export const WithTermsAcceptance: Story = {
render: () => (
<KcPageStory
kcContext={{
termsAcceptanceRequired: true,
"x-keycloakify": {
realmMessageBundleTermsText: "<a href='https://example.com/terms'>Service Terms of Use</a>"
}
}}
/>
)
};

View File

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