Compare commits

...

136 Commits

Author SHA1 Message Date
d92b89ae31 Release candidate 2024-05-14 06:12:52 +02:00
c99d9ccbdd Fix error rending 2024-05-14 06:12:36 +02:00
3dded16f0a Fix useUserProfileForm reducer 2024-05-14 06:06:17 +02:00
f59ee55be5 Release candidate 2024-05-14 05:53:46 +02:00
bc549af64c Less verbose js coments (for start) #542 2024-05-14 05:53:22 +02:00
39add772f7 Fix vim motion typo 2024-05-14 05:44:39 +02:00
f19e622d39 Improve a little bit the readability of the rendered template 2024-05-14 05:30:03 +02:00
7eb13db467 Fix error in ftl templat 2024-05-14 05:05:18 +02:00
ef503e271d #545 2024-05-14 04:57:38 +02:00
7d24e2716f Update build scripts 2024-05-14 04:56:06 +02:00
9bbcc21f9c Remove debug file 2024-05-14 04:55:42 +02:00
fcfabb0c3f Update build script 2024-05-14 03:55:41 +02:00
9ca3cadd10 Converts all functions without arguments at the same place in the ftl template 2024-05-14 03:30:27 +02:00
f156fec1c3 Forget to add displayRequiredFields on some pages 2024-05-14 02:39:43 +02:00
e962b37948 Fix language menu select in templates 2024-05-14 02:32:03 +02:00
3a8f1a0ed1 Remove --feature=declarative-user-profile from testing container launch script 2024-05-14 01:47:08 +02:00
e3a7bb13f5 Fix inputs using value instead of defaultValue 2024-05-14 01:33:31 +02:00
29b45497ba Add missing mock value 2024-05-14 00:03:48 +02:00
a748e8d8ec Relase candidate 2024-05-14 00:01:06 +02:00
f2fcb553a5 Pass totp.policy.getAlgorithmKey() to the freemarker template 2024-05-14 00:00:17 +02:00
a9dc11c60d Fix path error in generate theme variant 2024-05-13 23:47:28 +02:00
ee9df31b18 Release candidate 2024-05-13 23:39:39 +02:00
69d1e86a8a Fix build jar script 2024-05-13 23:39:18 +02:00
06761807a3 Fix non closed tag 2024-05-13 23:39:09 +02:00
a6c1e9bb61 Fix several logical errors 2024-05-13 23:21:27 +02:00
b70dfe96f6 Remove debug log 2024-05-13 23:20:58 +02:00
5f2b1484b5 Update the exceptions 2024-05-13 23:20:40 +02:00
d4595c999f Better portability 2024-05-13 22:32:17 +02:00
373850e32a Fix storybook build 2024-05-13 04:00:52 +02:00
6013781594 Merge branch 'main' into keycloak_24 2024-05-13 03:38:13 +02:00
1712d7e2f3 Release candidate 2024-05-13 03:37:11 +02:00
970572c441 route the pages removed in kc 24 at low level 2024-05-13 03:35:38 +02:00
38a9779a2f Done with multi target build 2024-05-13 00:40:16 +02:00
988b96825f Only buildJar function left to implement 2024-05-12 21:41:49 +02:00
10bbc8ae74 Implement generateThemeVariants 2024-05-12 21:23:20 +02:00
434b03e070 Refactor 2024-05-12 20:47:03 +02:00
9d4543a611 Checkpoint 2024-05-12 19:38:48 +02:00
b675ce7142 Checkpoint 2024-05-12 19:37:16 +02:00
04461cd660 Multi target build (checkpoint before futher refactor) 2024-05-12 19:17:38 +02:00
9b8952336b Remove retrocompatiblity before re-introducing it 2024-05-11 23:20:15 +02:00
d15ae225b1 Use Keycloak 24.0.4 assets (and use kc 24 in testing container) 2024-05-11 23:11:52 +02:00
87ed9884ff Update the getKcContext function 2024-05-11 22:48:15 +02:00
9bbfac0896 Update account theme template 2024-05-11 22:21:34 +02:00
6a487b11ce More sensible mock date for UpdateEmail 2024-05-11 22:15:28 +02:00
e00ab03c19 Provide mocks data for the new pages 2024-05-11 21:39:07 +02:00
6fff769636 Forgot to actually insert the script 2024-05-11 21:39:01 +02:00
7904627653 Add webauthn-error.ftl page 2024-05-11 19:18:52 +02:00
7a2d7e5a9f Update saml-post-form.ftl page 2024-05-11 19:04:51 +02:00
31940102f2 Update the logout-confirm.ftl page 2024-05-11 19:00:23 +02:00
2c36bfe3bb Add the login-x509-info.ftl page 2024-05-11 18:54:11 +02:00
3af2eae618 Update login-verify-email.ftl page 2024-05-11 18:27:19 +02:00
a362b0fe2c Update login-username.ftl page 2024-05-11 18:15:00 +02:00
891d190787 Update login-update-password.ftl page 2024-05-11 17:37:33 +02:00
f228e50443 Update login-reset-password.ftl page 2024-05-11 17:17:35 +02:00
898a82cae1 Add login-reset-otp.ftl page 2024-05-11 16:58:45 +02:00
9512e5dab1 Add login-recovery-authn-code-input.ftl page 2024-05-11 16:43:18 +02:00
dade9a7460 Fix type confusion 2024-05-11 16:32:21 +02:00
060b1ea8b8 Add login-recovery-authn-code-config.ftl page 2024-05-11 16:25:12 +02:00
834ae4e45b fmt 2024-05-11 16:24:13 +02:00
18c82a58a7 Fix ftl template not correctly parsing numbers 2024-05-11 16:06:42 +02:00
632641a067 Update page login-password.ftl 2024-05-11 01:30:23 +02:00
fa051e4665 Update the login-page-expired.ftl page 2024-05-11 01:15:25 +02:00
259dbbae36 Update the login-otp.ftl page 2024-05-11 01:13:09 +02:00
87d9eb34a2 Rename src/login/pages/LoginDeviceVerifyUserCode.tsx to src/login/pages/LoginOauth2DeviceVerifyUserCode.tsx to respect naming convention 2024-05-11 00:54:52 +02:00
f9c55f5a43 Update login-oauth-grant.ftl page 2024-05-11 00:47:18 +02:00
aa880219c1 Update login-config-totp.ftl 2024-05-11 00:27:47 +02:00
01899c034b Update info.ftl 2024-05-11 00:18:18 +02:00
4f930a9fba Update idp-review-user-profile.ftl page 2024-05-11 00:11:50 +02:00
4a0ca9ff3b add frontchannel-logout.ftl page 2024-05-11 00:05:58 +02:00
9cb6b73607 Remove the usePrepareTemplate hook 2024-05-10 22:15:33 +02:00
695cdd5c63 Update error.ftl 2024-05-10 21:51:46 +02:00
c2fd92f516 Add delete-account-confirm.ftl page 2024-05-10 21:48:47 +02:00
1911763fcb Add code.ftl page 2024-05-10 21:40:23 +02:00
784bc71416 Prevent multiple loading of the same script 2024-05-10 21:32:16 +02:00
d3e065591b Add webauthn-register.ftl page 2024-05-10 21:12:35 +02:00
1d87e8fe8b update webauthn-autenticate.ftl 2024-05-10 18:30:48 +02:00
f8bf54835d Effort toward reconsiliating the server templating and the react world 2024-05-10 02:45:01 +02:00
9e21b5cb93 New mechanism for dynamically loading css and js (checkpoint) 2024-05-09 18:04:31 +02:00
771d6328af Do not restrict to any perticular version of React 2024-05-08 19:50:23 +02:00
1a145a49ed update evt 2024-05-08 19:48:53 +02:00
de2d4ac497 Refactor terms 2024-05-08 19:48:16 +02:00
4baeca58de Remove Register_legacy 2024-05-08 19:24:52 +02:00
0d36ddd6d3 Update update-email.ftl page 2024-05-08 19:24:18 +02:00
ef3c190747 Update SelectAuthenticator.tsx 2024-05-08 17:11:58 +02:00
20565038f5 Remove misleading comment 2024-05-08 16:57:56 +02:00
d6a302f6a3 Add delete-credential.ftl page 2024-05-08 16:54:04 +02:00
70589b6442 Factorise LoginUserProfile and LoginUpdateProfile 2024-05-08 16:10:03 +02:00
027c8f38d8 Refactor and handle legacy login-update-profile.ftl 2024-05-08 16:04:12 +02:00
a6f7f8ff49 Remove comment 2024-05-07 20:56:56 +02:00
fa24fb41a1 Remove unused variable 2024-05-07 20:56:48 +02:00
b1bec4a343 Login page overhaul 2024-05-07 20:46:02 +02:00
03e728fe04 Load scripts after component rendered #470 2024-05-07 20:04:27 +02:00
58580555a4 Update readFieldNameUsage for new messagePerField methods 2024-05-07 18:10:22 +02:00
30362df078 Handle password field hide/reveal 2024-05-07 15:58:54 +02:00
a70c651a11 Remove dead file 2024-05-06 21:28:39 +02:00
d902859b00 Fully retrocompatible, factorized Register page 🚀 2024-05-06 21:27:36 +02:00
3abb32ec82 File structure update 2024-05-06 19:16:17 +02:00
081376cdd3 Done with the new Register page (not yet retrocompatible) 2024-05-06 18:16:32 +02:00
25621182c9 Download terms when kcContext.termsAcceptanceRequired is set to true 2024-05-06 17:44:04 +02:00
c2b990ac53 Do not inject password field when password isn't required 2024-05-06 17:41:08 +02:00
bcb70a1851 Add TermsAcceptance component 2024-05-06 17:23:24 +02:00
879e376bd4 Actually use the doUseDefaultCss param in useClassName 2024-05-06 17:00:29 +02:00
4793d6dd23 Complete UserProfileFormFields 2024-05-06 16:11:36 +02:00
4794e35989 Apply number unformat during validation if any 2024-05-06 16:07:49 +02:00
7f55bb5ce3 Load number unformat for pre form submission 2024-05-06 15:25:43 +02:00
aab1b7d490 Almost done with UserProfileFormField.tsx 2024-05-05 20:58:27 +02:00
7d8db7f48c Good progress on UserProfileFormFields component 2024-05-05 20:47:23 +02:00
12225c1265 Done with select tag 2024-05-05 18:51:33 +02:00
f81ef406fb Multivalued attributes that uses a single field have an inputType that starts with "multiselect" 2024-05-04 22:57:34 +02:00
3770ec5f0d If required multivalued single file must have at least one value 2024-05-04 22:40:51 +02:00
52a6edc9ca use valueOrValues to simplify type definitions 2024-05-04 21:27:08 +02:00
6846a683b0 We have a polyfill for Array.every 2024-05-04 20:40:45 +02:00
98a0055490 Register form hook finally completed 2024-05-04 20:36:54 +02:00
a612fce78b Checkpoint validation supporting various multi valued fields 2024-05-02 15:33:48 +02:00
fc040d8302 Progress checkpoint on useUserProfileForm 2024-05-01 18:18:34 +02:00
2b0febce19 Checkpoint before refactor again 2024-04-30 12:07:35 +02:00
d143e2d3ad Feature TextArea 2024-04-28 19:35:16 +02:00
0b72724cb4 Extract field errors into a separate component 2024-04-27 19:35:42 +02:00
9e0e8acda0 Extract form group label into a separate component 2024-04-27 19:31:28 +02:00
998f18a3ce Progress on form reactivity 2024-04-27 19:09:22 +02:00
500f558658 Simplify the API of useUserProfileForm 2024-04-22 06:53:08 +02:00
49f46c758a Start refactor of UserProfilesFormFields 2024-04-22 06:36:41 +02:00
83548afa0a Implement password policy validation 2024-04-22 06:34:50 +02:00
cf42f5b2a0 do not use custom validator to check if password confirmation matches password 2024-04-22 04:34:54 +02:00
dfdc90b686 Dot not create fake attribute field, hide password confirm at an higher level 2024-04-22 04:06:06 +02:00
2e9d2b8bd2 Big refactor of useFormValidator into useUserProfileForm 2024-04-22 04:00:39 +02:00
95c27dd97e Add multivalued field validator 2024-04-22 03:55:50 +02:00
b871c3ecc3 Update KcContext type def, use an ext to get password policies. 2024-04-21 20:29:18 +02:00
82ffa801d6 Refactor useFormValidation 2024-04-21 08:12:25 +02:00
b42bf24935 checkpoint update on useFormValidation 2024-04-20 22:12:39 +02:00
9909971316 Update Login template for Keycloak 24 2024-04-13 04:46:13 +02:00
05bd0885af Feat polifill for getFirstError and make existsError accept more than one field (kcContext.messagePerField) 2024-04-13 04:28:28 +02:00
b4abe5a22e Drop compat with Keycloak prior to v12 #359 2024-04-13 04:15:23 +02:00
c5c54cb807 Fully sync login template with Keycloak 24 2024-04-13 03:26:15 +02:00
51ec342f6f Update css classes keys to reflect Keycloak 24 2024-04-13 02:18:06 +02:00
8d1c19bf1c Update prepare template for Keycloak 24 2024-04-13 01:26:41 +02:00
84 changed files with 6302 additions and 3592 deletions

View File

@ -1,6 +1,6 @@
{
"name": "keycloakify",
"version": "9.6.7",
"version": "10.0.0-rc.4",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
@ -10,10 +10,10 @@
"types": "dist/index.d.ts",
"scripts": {
"prepare": "yarn generate-i18n-messages",
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc -p src/vite-plugin && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/ && cp -r src dist/",
"build": "tsc -p src/bin && tsc -p src && tsc -p src/vite-plugin && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn run copy-files && cp -r src dist/",
"generate:json-schema": "ts-node scripts/generate-json-schema.ts",
"grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js",
"copy-files": "copyfiles -u 1 src/**/*.ftl src/**/*.java",
"grant-exec-perms": "ts-node --skipProject scripts/grant-exec-perms.ts",
"copy-files": "copyfiles -u 1 'src/**/*.ftl' dist/",
"test": "yarn test:types && vitest run",
"test:keycloakify-starter": "ts-node scripts/test-keycloakify-starter",
"test:types": "tsc -p test/tsconfig.json --noEmit",
@ -23,7 +23,6 @@
"generate-i18n-messages": "ts-node --skipProject scripts/generate-i18n-messages.ts",
"link-in-app": "ts-node --skipProject scripts/link-in-app.ts",
"link-in-starter": "yarn link-in-app keycloakify-starter",
"watch-in-starter": "yarn build && yarn link-in-starter && (concurrently \"tsc -p src -w\" \"tsc-alias -p src/tsconfig.json\" \"tsc -p src/bin -w\")",
"copy-keycloak-resources-to-storybook-static": "PUBLIC_DIR_PATH=.storybook/static node dist/bin/copy-keycloak-resources-to-public.js",
"storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && start-storybook -p 6006",
"build-storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && build-storybook"
@ -65,7 +64,7 @@
],
"homepage": "https://www.keycloakify.dev",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
"react": "*"
},
"devDependencies": {
"@babel/core": "^7.0.0",
@ -97,7 +96,6 @@
"properties-parser": "^0.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rimraf": "^3.0.2",
"scripting-tools": "^0.19.13",
"storybook-dark-mode": "^1.1.2",
"ts-node": "^10.9.1",
@ -115,7 +113,7 @@
"@octokit/rest": "^18.12.0",
"cheerio": "^1.0.0-rc.5",
"cli-select": "^1.1.2",
"evt": "^2.4.18",
"evt": "^2.5.7",
"make-fetch-happen": "^11.0.3",
"minimal-polyfills": "^2.2.2",
"minimist": "^1.2.6",

View File

@ -17,7 +17,7 @@ const isSilent = true;
const logger = getLogger({ isSilent });
async function main() {
const keycloakVersion = "23.0.4";
const keycloakVersion = "24.0.4";
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();

View File

@ -1,10 +1,9 @@
import { getThisCodebaseRootDirPath } from "./getThisCodebaseRootDirPath";
import { join as pathJoin } from "path";
import { constants } from "fs";
import { chmod, stat } from "fs/promises";
(async () => {
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
const thisCodebaseRootDirPath = pathJoin(__dirname, "..");
const { bin } = await import(pathJoin(thisCodebaseRootDirPath, "package.json"));

View File

@ -1,34 +1,61 @@
import { useEffect } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate";
import { type TemplateProps } from "keycloakify/account/TemplateProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import { createUseInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import type { KcContext } from "./kcContext";
import type { I18n } from "./i18n";
import { assert } from "keycloakify/tools/assert";
const { useInsertLinkTags } = createUseInsertLinkTags();
export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props;
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const { msg, changeLocale, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { locale, url, features, realm, message, referrer } = kcContext;
const { isReady } = usePrepareTemplate({
"doFetchDefaultThemeResources": doUseDefaultCss,
"styles": [
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesPath}/css/account.css`
],
"htmlClassName": getClassName("kcHtmlClass"),
"bodyClassName": clsx("admin-console", "user", getClassName("kcBodyClass")),
"htmlLangProperty": locale?.currentLanguageTag,
"documentTitle": i18n.msgStr("accountManagementTitle")
useEffect(() => {
document.title = msgStr("accountManagementTitle");
}, []);
useSetClassName({
"qualifiedName": "html",
"className": getClassName("kcHtmlClass")
});
if (!isReady) {
useSetClassName({
"qualifiedName": "body",
"className": clsx("admin-console", "user", getClassName("kcBodyClass"))
});
useEffect(() => {
const { currentLanguageTag } = locale ?? {};
if (currentLanguageTag === undefined) {
return;
}
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
}, []);
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
"hrefs": !doUseDefaultCss
? []
: [
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesPath}/css/account.css`
]
});
if (!areAllStyleSheetsLoaded) {
return null;
}
@ -54,10 +81,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<ul>
{locale.supported.map(({ languageTag }) => (
<li key={languageTag} className="kc-dropdown-item">
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a href="#" onClick={() => changeLocale(languageTag)}>
{labelBySupportedLanguageTag[languageTag]}
</a>
<a href={getChangeLocalUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
</li>
))}
</ul>

View File

@ -28,11 +28,10 @@ export type GenericI18n<MessageKey extends string> = {
*/
currentLanguageTag: string;
/**
* To call when the user switch language.
* This will cause the page to be reloaded,
* on next load currentLanguageTag === newLanguageTag
* Redirect to this url to change the language.
* After reload currentLanguageTag === newLanguageTag
*/
changeLocale: (newLanguageTag: string) => never;
getChangeLocalUrl: (newLanguageTag: string) => string;
/**
* e.g. "en" => "English", "fr" => "Français", ...
*
@ -104,7 +103,7 @@ export function createUseI18n<ExtraMessageKey extends string = never>(extraMessa
} as any
}),
currentLanguageTag,
"changeLocale": newLanguageTag => {
"getChangeLocalUrl": newLanguageTag => {
const { locale } = kcContext;
assert(locale !== undefined, "Internationalization not enabled");
@ -113,9 +112,7 @@ export function createUseI18n<ExtraMessageKey extends string = never>(extraMessa
assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`);
window.location.href = targetSupportedLocale.url;
assert(false, "never");
return targetSupportedLocale.url;
},
"labelBySupportedLanguageTag": Object.fromEntries(
(kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])

View File

@ -164,21 +164,6 @@ export declare namespace KcContext {
};
mode?: "qr" | "manual" | undefined | null;
isAppInitiatedAction: boolean;
url: {
accountUrl: string;
passwordUrl: string;
totpUrl: string;
socialUrl: string;
sessionsUrl: string;
applicationsUrl: string;
logUrl: string;
resourceUrl: string;
resourcesCommonPath: string;
resourcesPath: string;
/** @deprecated, not present in recent keycloak version apparently, use kcContext.referrer instead */
referrerURI?: string;
getLogoutUrl: () => string;
};
stateChecker: string;
};

View File

@ -6,7 +6,6 @@ export const resolvedViteConfigJsonBasename = "vite.json";
export const basenameOfTheKeycloakifyResourcesDir = "build";
export const themeTypes = ["login", "account"] as const;
export const retrocompatPostfix = "_retrocompat";
export const accountV1ThemeName = "account-v1";
export type ThemeType = (typeof themeTypes)[number];

View File

@ -0,0 +1,174 @@
import { assert, type Equals } from "tsafe/assert";
import type { KeycloakAccountV1Version, KeycloakThemeAdditionalInfoExtensionVersion } from "./extensionVersions";
import { join as pathJoin, dirname as pathDirname } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import type { BuildOptions } from "../buildOptions";
import * as fs from "fs/promises";
import { accountV1ThemeName } from "../../constants";
import { generatePom, BuildOptionsLike as BuildOptionsLike_generatePom } from "./generatePom";
import { existsSync, readFileSync } from "fs";
import { isInside } from "../../tools/isInside";
import child_process from "child_process";
export type BuildOptionsLike = BuildOptionsLike_generatePom & {
keycloakifyBuildDirPath: string;
themeNames: string[];
artifactId: string;
themeVersion: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function buildJar(params: {
jarFileBasename: string;
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
buildOptions: BuildOptionsLike;
}): Promise<void> {
const { jarFileBasename, keycloakAccountV1Version, keycloakThemeAdditionalInfoExtensionVersion, buildOptions } = params;
const keycloakifyBuildTmpDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "..", jarFileBasename.replace(".jar", ""));
if (existsSync(keycloakifyBuildTmpDirPath)) {
await fs.rm(keycloakifyBuildTmpDirPath, { "recursive": true });
}
await fs.mkdir(keycloakifyBuildTmpDirPath, { "recursive": true });
await fs.writeFile(pathJoin(keycloakifyBuildTmpDirPath, ".gitignore"), Buffer.from("*", "utf8"));
const srcMainResourcesRelativeDirPath = pathJoin("src", "main", "resources");
{
const keycloakThemesJsonFilePath = pathJoin(srcMainResourcesRelativeDirPath, "META-INF", "keycloak-themes.json");
const themePropertiesFilePathSet = new Set(
...buildOptions.themeNames.map(themeName => pathJoin(srcMainResourcesRelativeDirPath, "theme", themeName, "account", "theme.properties"))
);
const accountV1RelativeDirPath = pathJoin(srcMainResourcesRelativeDirPath, "theme", accountV1ThemeName);
transformCodebase({
"srcDirPath": buildOptions.keycloakifyBuildDirPath,
"destDirPath": keycloakifyBuildTmpDirPath,
"transformSourceCode":
keycloakAccountV1Version !== null
? undefined
: ({ fileRelativePath, sourceCode }) => {
if (fileRelativePath === keycloakThemesJsonFilePath) {
const keycloakThemesJsonParsed = JSON.parse(sourceCode.toString("utf8")) as {
themes: { name: string; types: string[] }[];
};
keycloakThemesJsonParsed.themes = keycloakThemesJsonParsed.themes.filter(({ name }) => name !== accountV1ThemeName);
return { "modifiedSourceCode": Buffer.from(JSON.stringify(keycloakThemesJsonParsed, null, 2), "utf8") };
}
if (isInside({ "dirPath": "target", "filePath": fileRelativePath })) {
return undefined;
}
if (isInside({ "dirPath": accountV1RelativeDirPath, "filePath": fileRelativePath })) {
return undefined;
}
if (themePropertiesFilePathSet.has(fileRelativePath)) {
return {
"modifiedSourceCode": Buffer.from(
sourceCode.toString("utf8").replace(`parent=${accountV1ThemeName}`, "parent=keycloak"),
"utf8"
)
};
}
return { "modifiedSourceCode": sourceCode };
}
});
}
route_legacy_pages: {
// NOTE: If there's no account theme there is no special target for keycloak 24 and up so we create
// the pages anyway. If there is an account pages, since we know that account-v1 is only support keycloak
// 24 in version 0.4 and up, we can safely break the route for legacy pages.
const doBreak: boolean = (() => {
switch (keycloakAccountV1Version) {
case null:
return false;
case "0.3":
return false;
default:
return true;
}
})();
if (doBreak) {
break route_legacy_pages;
}
(["register.ftl", "login-update-profile.ftl"] as const).forEach(pageId =>
buildOptions.themeNames.map(themeName => {
const ftlFilePath = pathJoin(keycloakifyBuildTmpDirPath, srcMainResourcesRelativeDirPath, "theme", themeName, "login", pageId);
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
const realPageId = (() => {
switch (pageId) {
case "register.ftl":
return "register-user-profile.ftl";
case "login-update-profile.ftl":
return "update-user-profile.ftl";
}
assert<Equals<typeof pageId, never>>(false);
})();
const modifiedFtlFileContent = ftlFileContent.replace(
`out["pageId"] = "\${pageId}";`,
`out["pageId"] = "${pageId}"; out["realPageId"] = "${realPageId}";`
);
assert(modifiedFtlFileContent !== ftlFileContent);
fs.writeFile(pathJoin(pathDirname(ftlFilePath), realPageId), Buffer.from(modifiedFtlFileContent, "utf8"));
})
);
}
{
const { pomFileCode } = generatePom({
buildOptions,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
});
await fs.writeFile(pathJoin(keycloakifyBuildTmpDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8"));
}
await new Promise<void>((resolve, reject) =>
child_process.exec("mvn clean install", { "cwd": keycloakifyBuildTmpDirPath }, error => {
if (error !== null) {
console.error(
`Build jar failed: ${JSON.stringify(
{
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
},
null,
2
)}`
);
reject(error);
return;
}
resolve();
})
);
await fs.rename(
pathJoin(keycloakifyBuildTmpDirPath, "target", `${buildOptions.artifactId}-${buildOptions.themeVersion}.jar`),
pathJoin(buildOptions.keycloakifyBuildDirPath, jarFileBasename)
);
await fs.rm(keycloakifyBuildTmpDirPath, { "recursive": true });
}

View File

@ -0,0 +1,62 @@
import { assert } from "tsafe/assert";
import { exclude } from "tsafe/exclude";
import { keycloakAccountV1Versions, keycloakThemeAdditionalInfoExtensionVersions } from "./extensionVersions";
import { getKeycloakVersionRangeForJar } from "./getKeycloakVersionRangeForJar";
import { buildJar, BuildOptionsLike as BuildOptionsLike_buildJar } from "./buildJar";
import type { BuildOptions } from "../buildOptions";
export type BuildOptionsLike = BuildOptionsLike_buildJar & {
keycloakifyBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function buildJars(params: {
doesImplementAccountTheme: boolean;
buildOptions: BuildOptionsLike;
}): Promise<{ lastJarFileBasename: string }> {
const { doesImplementAccountTheme, buildOptions } = params;
let lastJarFileBasename: string | undefined = undefined;
await Promise.all(
keycloakAccountV1Versions
.map(keycloakAccountV1Version =>
keycloakThemeAdditionalInfoExtensionVersions
.map(keycloakThemeAdditionalInfoExtensionVersion => {
const keycloakVersionRange = getKeycloakVersionRangeForJar({
doesImplementAccountTheme,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
});
if (keycloakVersionRange === undefined) {
return undefined;
}
return { keycloakThemeAdditionalInfoExtensionVersion, keycloakVersionRange };
})
.filter(exclude(undefined))
.map(({ keycloakThemeAdditionalInfoExtensionVersion, keycloakVersionRange }) => {
const jarFileBasename = `keycloak-theme-for-kc-${keycloakVersionRange}.jar`;
lastJarFileBasename = jarFileBasename;
return { keycloakThemeAdditionalInfoExtensionVersion, jarFileBasename };
})
.map(({ keycloakThemeAdditionalInfoExtensionVersion, jarFileBasename }) =>
buildJar({
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
buildOptions
})
)
)
.flat()
);
assert(lastJarFileBasename !== undefined);
return { lastJarFileBasename };
}

View File

@ -0,0 +1,16 @@
// NOTE: v0.5 is a dummy version.
export const keycloakAccountV1Versions = [null, "0.3", "0.4"] as const;
/**
* https://central.sonatype.com/artifact/io.phasetwo.keycloak/keycloak-account-v1
* https://github.com/p2-inc/keycloak-account-v1
*/
export type KeycloakAccountV1Version = (typeof keycloakAccountV1Versions)[number];
export const keycloakThemeAdditionalInfoExtensionVersions = [null, "1.1.5"] as const;
/**
* https://central.sonatype.com/artifact/dev.jcputney/keycloak-theme-additional-info-extension
* https://github.com/jcputney/keycloak-theme-additional-info-extension
* */
export type KeycloakThemeAdditionalInfoExtensionVersion = (typeof keycloakThemeAdditionalInfoExtensionVersions)[number];

View File

@ -0,0 +1,86 @@
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../buildOptions";
import type { KeycloakAccountV1Version, KeycloakThemeAdditionalInfoExtensionVersion } from "./extensionVersions";
export type BuildOptionsLike = {
groupId: string;
artifactId: string;
themeVersion: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function generatePom(params: {
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
buildOptions: BuildOptionsLike;
}) {
const { keycloakAccountV1Version, keycloakThemeAdditionalInfoExtensionVersion, buildOptions } = params;
const { pomFileCode } = (function generatePomFileCode(): {
pomFileCode: string;
} {
const pomFileCode = [
`<?xml version="1.0"?>`,
`<project xmlns="http://maven.apache.org/POM/4.0.0"`,
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
` <modelVersion>4.0.0</modelVersion>`,
` <groupId>${buildOptions.groupId}</groupId>`,
` <artifactId>${buildOptions.artifactId}</artifactId>`,
` <version>${buildOptions.themeVersion}</version>`,
` <name>${buildOptions.artifactId}</name>`,
` <description />`,
` <packaging>jar</packaging>`,
` <properties>`,
` <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>`,
` </properties>`,
...(keycloakAccountV1Version !== null && keycloakThemeAdditionalInfoExtensionVersion !== null
? [
` <build>`,
` <plugins>`,
` <plugin>`,
` <groupId>org.apache.maven.plugins</groupId>`,
` <artifactId>maven-shade-plugin</artifactId>`,
` <version>3.5.1</version>`,
` <executions>`,
` <execution>`,
` <phase>package</phase>`,
` <goals>`,
` <goal>shade</goal>`,
` </goals>`,
` </execution>`,
` </executions>`,
` </plugin>`,
` </plugins>`,
` </build>`,
` <dependencies>`,
...(keycloakAccountV1Version !== null
? [
` <dependency>`,
` <groupId>io.phasetwo.keycloak</groupId>`,
` <artifactId>keycloak-account-v1</artifactId>`,
` <version>${keycloakAccountV1Version}</version>`,
` </dependency>`
]
: []),
...(keycloakThemeAdditionalInfoExtensionVersion !== null
? [
` <dependency>`,
` <groupId>dev.jcputney</groupId>`,
` <artifactId>keycloak-theme-additional-info-extension</artifactId>`,
` <version>${keycloakThemeAdditionalInfoExtensionVersion}</version>`,
` </dependency>`
]
: []),
` </dependencies>`
]
: []),
`</project>`
].join("\n");
return { pomFileCode };
})();
return { pomFileCode };
}

View File

@ -0,0 +1,37 @@
import { assert, type Equals } from "tsafe/assert";
import type { KeycloakAccountV1Version, KeycloakThemeAdditionalInfoExtensionVersion } from "./extensionVersions";
export function getKeycloakVersionRangeForJar(params: {
doesImplementAccountTheme: boolean;
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
}): string | undefined {
const { keycloakAccountV1Version, keycloakThemeAdditionalInfoExtensionVersion, doesImplementAccountTheme } = params;
switch (keycloakAccountV1Version) {
case null:
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return doesImplementAccountTheme ? "21-and-below" : "21-and-below";
case "1.1.5":
return doesImplementAccountTheme ? undefined : "22-and-above";
}
assert<Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>>(false);
case "0.3":
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return doesImplementAccountTheme ? undefined : undefined;
case "1.1.5":
return doesImplementAccountTheme ? "23" : undefined;
}
assert<Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>>(false);
case "0.4":
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return doesImplementAccountTheme ? undefined : undefined;
case "1.1.5":
return doesImplementAccountTheme ? "24-and-above" : undefined;
}
assert<Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>>(false);
}
}

View File

@ -0,0 +1 @@
export * from "./buildJars";

View File

@ -4,22 +4,18 @@ export type UserProvidedBuildOptions = {
extraThemeProperties?: string[];
artifactId?: string;
groupId?: string;
doCreateJar?: boolean;
loginThemeResourcesFromKeycloakVersion?: string;
reactAppBuildDirPath?: string;
keycloakifyBuildDirPath?: string;
themeName?: string | string[];
doBuildRetrocompatAccountTheme?: boolean;
};
export const zUserProvidedBuildOptions = z.object({
"extraThemeProperties": z.array(z.string()).optional(),
"artifactId": z.string().optional(),
"groupId": z.string().optional(),
"doCreateJar": z.boolean().optional(),
"loginThemeResourcesFromKeycloakVersion": z.string().optional(),
"reactAppBuildDirPath": z.string().optional(),
"keycloakifyBuildDirPath": z.string().optional(),
"themeName": z.union([z.string(), z.array(z.string())]).optional(),
"doBuildRetrocompatAccountTheme": z.boolean().optional()
"themeName": z.union([z.string(), z.array(z.string())]).optional()
});

View File

@ -18,7 +18,6 @@ export type BuildOptions = {
extraThemeProperties: string[] | undefined;
groupId: string;
artifactId: string;
doCreateJar: boolean;
loginThemeResourcesFromKeycloakVersion: string;
reactAppRootDirPath: string;
reactAppBuildDirPath: string;
@ -30,7 +29,6 @@ export type BuildOptions = {
* In this case the urlPathname will be "/my-app/" */
urlPathname: string | undefined;
assetsDirPath: string;
doBuildRetrocompatAccountTheme: boolean;
npmWorkspaceRootDirPath: string;
};
@ -116,8 +114,7 @@ export function readBuildOptions(params: { processArgv: string[] }): BuildOption
);
})(),
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? userProvidedBuildOptions.artifactId ?? `${themeNames[0]}-keycloak-theme`,
"doCreateJar": userProvidedBuildOptions.doCreateJar ?? true,
"loginThemeResourcesFromKeycloakVersion": userProvidedBuildOptions.loginThemeResourcesFromKeycloakVersion ?? "11.0.3",
"loginThemeResourcesFromKeycloakVersion": userProvidedBuildOptions.loginThemeResourcesFromKeycloakVersion ?? "24.0.4",
reactAppRootDirPath,
reactAppBuildDirPath,
"keycloakifyBuildDirPath": (() => {
@ -187,7 +184,6 @@ export function readBuildOptions(params: { processArgv: string[] }): BuildOption
return pathJoin(reactAppBuildDirPath, resolvedViteConfig.assetsDir);
})(),
"doBuildRetrocompatAccountTheme": userProvidedBuildOptions.doBuildRetrocompatAccountTheme ?? true,
npmWorkspaceRootDirPath
};
}

View File

@ -1,424 +1,189 @@
<script>const _=
<#assign pageId="PAGE_ID_xIgLsPgGId9D8e">
(()=>{
const out = ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc};
out["msg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
out["advancedMsg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
out["messagesPerField"]= {
<#assign fieldNames = [ FIELD_NAMES_eKsIY4ZsZ4xeM ]>
<#attempt>
<#if profile?? && profile.attributes?? && profile.attributes?is_enumerable>
<#list profile.attributes as attribute>
<#if fieldNames?seq_contains(attribute.name)>
<#continue>
<#assign pageId="PAGE_ID_xIgLsPgGId9D8e">
const out = ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc};
out["msg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
out["advancedMsg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
out["messagesPerField"]= {
<#assign fieldNames = [ FIELD_NAMES_eKsIY4ZsZ4xeM ]>
<#attempt>
<#if profile?? && profile.attributes?? && profile.attributes?is_enumerable>
<#list profile.attributes as attribute>
<#if fieldNames?seq_contains(attribute.name)>
<#continue>
</#if>
<#assign fieldNames += [attribute.name]>
</#list>
</#if>
<#recover>
</#attempt>
"printIfExists": function (fieldName, text) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
<#if doExistErrorOnUsernameOrPassword>
return text;
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
<#assign fieldNames += [attribute.name]>
</#list>
</#if>
<#recover>
</#attempt>
"printIfExists": function (fieldName, text) {
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
},
"existsError": function (){
function existsError_singleFieldName(fieldName) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
<#if !messagesPerField.existsError??>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistMessageForUsernameOrPassword = "">
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
<#if !doExistMessageForUsernameOrPassword>
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
</#if>
return <#if doExistMessageForUsernameOrPassword>text<#else>undefined</#if>;
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
<#else>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
<#if doExistErrorOnUsernameOrPassword>
return text;
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
<#assign doExistErrorMessageForField = "">
<#attempt>
<#assign doExistErrorMessageForField = messagesPerField.existsError('${fieldName}')>
<#recover>
<#assign doExistErrorMessageForField = true>
</#attempt>
return <#if doExistErrorMessageForField>true<#else>false</#if>;
</#if>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
},
"existsError": function (fieldName) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
<#if !messagesPerField.existsError??>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistMessageForUsernameOrPassword = "">
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
<#if !doExistMessageForUsernameOrPassword>
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
</#if>
return <#if doExistMessageForUsernameOrPassword>true<#else>false</#if>;
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>true<#else>false</#if>;
</#if>
<#else>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
<#else>
<#assign doExistErrorMessageForField = "">
<#attempt>
<#assign doExistErrorMessageForField = messagesPerField.existsError('${fieldName}')>
<#recover>
<#assign doExistErrorMessageForField = true>
</#attempt>
return <#if doExistErrorMessageForField>true<#else>false</#if>;
</#if>
</#if>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
},
"get": function (fieldName) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.get in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
<#if !messagesPerField.existsError??>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistMessageForUsernameOrPassword = "">
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
<#if !doExistMessageForUsernameOrPassword>
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
</#if>
<#if !doExistMessageForUsernameOrPassword>
return "";
<#else>
<#attempt>
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}";
<#recover>
return "Invalid username or password.";
</#attempt>
</#if>
<#else>
<#attempt>
return "${messagesPerField.get('${fieldName}')?no_esc}";
<#recover>
return "invalid field";
</#attempt>
</#if>
<#else>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
<#if doExistErrorOnUsernameOrPassword>
<#attempt>
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}";
<#recover>
return "Invalid username or password.";
</#attempt>
<#else>
<#attempt>
return "${messagesPerField.get('${fieldName}')?no_esc}";
<#recover>
return "";
</#attempt>
</#if>
<#else>
<#attempt>
return "${messagesPerField.get('${fieldName}')?no_esc}";
<#recover>
return "invalid field";
</#attempt>
</#if>
</#if>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
},
"exists": function (fieldName) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.exists in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
<#if !messagesPerField.existsError??>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistMessageForUsernameOrPassword = "">
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
<#if !doExistMessageForUsernameOrPassword>
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
</#if>
return <#if doExistMessageForUsernameOrPassword>true<#else>false</#if>;
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>true<#else>false</#if>;
</#if>
<#else>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
<#else>
<#assign doExistErrorMessageForField = "">
<#attempt>
<#assign doExistErrorMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistErrorMessageForField = true>
</#attempt>
return <#if doExistErrorMessageForField>true<#else>false</#if>;
</#if>
</#if>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
}
};
<#if account??>
out["url"]["getLogoutUrl"] = function () {
<#attempt>
return "${url.getLogoutUrl()}";
<#recover>
</#attempt>
};
</#if>
out["keycloakifyVersion"] = "KEYCLOAKIFY_VERSION_xEdKd3xEdr";
out["themeVersion"] = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx";
out["themeType"] = "KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr";
out["themeName"] = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer";
out["pageId"] = "${pageId}";
try {
out["url"]["resourcesCommonPath"] = out["url"]["resourcesPath"] + "/" + "RESOURCES_COMMON_cLsLsMrtDkpVv";
} catch(error) {
for( let i = 0; i < arguments.length; i++ ){
if( existsError_singleFieldName(arguments[i]) ){
return true;
}
}
return false;
},
"get": function (fieldName) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.get in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
<#if doExistErrorOnUsernameOrPassword>
<#attempt>
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}";
<#recover>
return "Invalid username or password.";
</#attempt>
<#else>
<#attempt>
return "${messagesPerField.get('${fieldName}')?no_esc}";
<#recover>
return "";
</#attempt>
</#if>
<#else>
<#attempt>
return "${messagesPerField.get('${fieldName}')?no_esc}";
<#recover>
return "invalid field";
</#attempt>
</#if>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
},
"exists": function (fieldName) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.exists in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
<#else>
<#assign doExistErrorMessageForField = "">
<#attempt>
<#assign doExistErrorMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistErrorMessageForField = true>
</#attempt>
return <#if doExistErrorMessageForField>true<#else>false</#if>;
</#if>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
},
"getFirstError": function () {
for( let i = 0; i < arguments.length; i++ ){
const fieldName = arguments[i];
if( out.messagesPerField.existsError(fieldName) ){
return out.messagesPerField.get(fieldName);
}
}
}
};
return out;
out["keycloakifyVersion"] = "KEYCLOAKIFY_VERSION_xEdKd3xEdr";
out["themeVersion"] = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx";
out["themeType"] = "KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr";
out["themeName"] = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer";
out["pageId"] = "${pageId}";
})()
try {
out["url"]["resourcesCommonPath"] = out["url"]["resourcesPath"] + "/" + "RESOURCES_COMMON_cLsLsMrtDkpVv";
} catch(error) { }
return out;
})();
<#function ftl_object_to_js_code_declaring_an_object object path>
<#local isHash = "">
@ -442,7 +207,6 @@
<#return "ABORT: We can't list keys on this object">
</#attempt>
<#local out_seq = []>
<#list keys as key>
@ -505,25 +269,29 @@
<#-- See: https://github.com/keycloakify/keycloakify/issues/534 -->
are_same_path(path, ["login"]) &&
key == "password"
) || (
<#-- Remove realmAttributes added by https://github.com/jcputney/keycloak-theme-additional-info-extension for peace of mind. -->
are_same_path(path, []) &&
key == "realmAttributes"
)
>
<#local out_seq += ["/*If you need '" + path?join(".") + "." + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#local out_seq += ["/*" + path?join(".") + "." + key + " excluded*/"]>
<#continue>
</#if>
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
<#if (
["register.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
["register.ftl", "register-user-profile.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
key == "attemptedUsername" && are_same_path(path, ["auth"])
)>
<#attempt>
<#-- https://github.com/keycloak/keycloak/blob/3a2bf0c04bcde185e497aaa32d0bb7ab7520cf4a/themes/src/main/resources/theme/base/login/template.ftl#L63 -->
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#local out_seq += ["/*" + path?join(".") + "." + key + " excluded*/"]>
<#continue>
</#if>
<#recover>
<#local out_seq += ["/*Testing if attemptedUsername should be skipped throwed an exception */"]>
<#local out_seq += ["/*Accessing attemptedUsername throwed an exception */"]>
</#attempt>
</#if>
@ -599,6 +367,26 @@
</#attempt>
</#if>
<#if are_same_path(path, ["url", "getLogoutUrl"])>
<#local returnValue = "">
<#attempt>
<#local returnValue = url.getLogoutUrl()>
<#recover>
<#return "ABORT: Couldn't evaluate url.getLogoutUrl()">
</#attempt>
<#return 'function(){ return "' + returnValue + '"; }'>
</#if>
<#if are_same_path(path, ["totp", "policy", "getAlgorithmKey"])>
<#local returnValue = "">
<#attempt>
<#local returnValue = totp.policy.getAlgorithmKey()>
<#recover>
<#return "ABORT: Couldn't evaluate totp.policy.getAlgorithmKey()">
</#attempt>
<#return 'function(){ return "' + returnValue + '"; }'>
</#if>
<#return "ABORT: It's a method">
</#if>
@ -668,12 +456,23 @@
<#return '"' + object?datetime?iso_utc + '"'>
</#if>
<#local isNumber = "">
<#attempt>
<#local isNumber = object?is_number>
<#recover>
<#return "ABORT: Can't test if it's a number">
</#attempt>
<#if isNumber>
<#return object?c>
</#if>
<#attempt>
<#return '"' + object?js_string + '"'>;
<#recover>
</#attempt>
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non enumerable object">
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non number, non enumerable object">
</#function>
<#function is_subpath path searchedPath>

View File

@ -4,7 +4,6 @@ import { generateCssCodeToDefineGlobals } from "../replacers/replaceImportsInCss
import { replaceImportsInInlineCssCode } from "../replacers/replaceImportsInInlineCssCode";
import * as fs from "fs";
import { join as pathJoin } from "path";
import { objectKeys } from "tsafe/objectKeys";
import type { BuildOptions } from "../buildOptions";
import { assert } from "tsafe/assert";
import { type ThemeType, nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir, resources_common } from "../../constants";
@ -96,35 +95,20 @@ export function generateFtlFilesCodeFactory(params: {
}
//FTL is no valid html, we can't insert with cheerio, we put placeholder for injecting later.
const replaceValueBySearchValue = {
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }': fs
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
.toString("utf8")
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1]
.replace("FIELD_NAMES_eKsIY4ZsZ4xeM", fieldNames.map(name => `"${name}"`).join(", "))
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion)
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", themeName)
.replace("RESOURCES_COMMON_cLsLsMrtDkpVv", resources_common),
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
"<#if scripts??>",
" <#list scripts as script>",
' <script src="${script}" type="text/javascript"></script>',
" </#list>",
"</#if>"
].join("\n")
};
const ftlObjectToJsCodeDeclaringAnObject = fs
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
.toString("utf8")
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1]
.replace("FIELD_NAMES_eKsIY4ZsZ4xeM", fieldNames.map(name => `"${name}"`).join(", "))
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion)
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", themeName)
.replace("RESOURCES_COMMON_cLsLsMrtDkpVv", resources_common);
$("head").prepend(
[
"<script>",
` window.${nameOfTheGlobal}= ${objectKeys(replaceValueBySearchValue)[0]};`,
"</script>",
"",
objectKeys(replaceValueBySearchValue)[1]
].join("\n")
);
const ftlObjectToJsCodeDeclaringAnObjectPlaceholder = '{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }';
$("head").prepend(`<script>\nwindow.${nameOfTheGlobal}=${ftlObjectToJsCodeDeclaringAnObjectPlaceholder}</script>`);
// Remove part of the document marked as ignored.
{
@ -159,7 +143,7 @@ export function generateFtlFilesCodeFactory(params: {
let ftlCode = $.html();
Object.entries({
...replaceValueBySearchValue,
[ftlObjectToJsCodeDeclaringAnObjectPlaceholder]: ftlObjectToJsCodeDeclaringAnObject,
"PAGE_ID_xIgLsPgGId9D8e": pageId
}).map(([searchValue, replaceValue]) => (ftlCode = ftlCode.replace(searchValue, replaceValue)));

View File

@ -3,8 +3,8 @@ export const loginThemePageIds = [
"login-username.ftl",
"login-password.ftl",
"webauthn-authenticate.ftl",
"webauthn-register.ftl",
"register.ftl",
"register-user-profile.ftl",
"info.ftl",
"error.ftl",
"login-reset-password.ftl",
@ -20,11 +20,19 @@ export const loginThemePageIds = [
"login-page-expired.ftl",
"login-config-totp.ftl",
"logout-confirm.ftl",
"update-user-profile.ftl",
"idp-review-user-profile.ftl",
"update-email.ftl",
"select-authenticator.ftl",
"saml-post-form.ftl"
"saml-post-form.ftl",
"delete-credential.ftl",
"code.ftl",
"delete-account-confirm.ftl",
"frontchannel-logout.ftl",
"login-recovery-authn-code-config.ftl",
"login-recovery-authn-code-input.ftl",
"login-reset-otp.ftl",
"login-x509-info.ftl",
"webauthn-error.ftl"
] as const;
export const accountThemePageIds = ["password.ftl", "account.ftl", "sessions.ftl", "totp.ftl", "applications.ftl", "log.ftl"] as const;

View File

@ -1,70 +0,0 @@
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "./buildOptions";
type BuildOptionsLike = {
groupId: string;
artifactId: string;
themeVersion: string;
keycloakifyBuildDirPath: string;
};
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export function generatePom(params: { buildOptions: BuildOptionsLike }) {
const { buildOptions } = params;
const { pomFileCode } = (function generatePomFileCode(): {
pomFileCode: string;
} {
const pomFileCode = [
`<?xml version="1.0"?>`,
`<project xmlns="http://maven.apache.org/POM/4.0.0"`,
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
` <modelVersion>4.0.0</modelVersion>`,
` <groupId>${buildOptions.groupId}</groupId>`,
` <artifactId>${buildOptions.artifactId}</artifactId>`,
` <version>${buildOptions.themeVersion}</version>`,
` <name>${buildOptions.artifactId}</name>`,
` <description />`,
` <packaging>jar</packaging>`,
` <properties>`,
` <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>`,
` </properties>`,
` <build>`,
` <plugins>`,
` <plugin>`,
` <groupId>org.apache.maven.plugins</groupId>`,
` <artifactId>maven-shade-plugin</artifactId>`,
` <version>3.5.1</version>`,
` <executions>`,
` <execution>`,
` <phase>package</phase>`,
` <goals>`,
` <goal>shade</goal>`,
` </goals>`,
` </execution>`,
` </executions>`,
` </plugin>`,
` </plugins>`,
` </build>`,
` <dependencies>`,
` <dependency>`,
` <groupId>io.phasetwo.keycloak</groupId>`,
` <artifactId>keycloak-account-v1</artifactId>`,
` <version>0.1</version>`,
` </dependency>`,
` </dependencies>`,
`</project>`
].join("\n");
return { pomFileCode };
})();
return { pomFileCode };
}

View File

@ -1,29 +1,30 @@
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, basename as pathBasename } from "path";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "./buildOptions";
import { accountV1ThemeName } from "../constants";
export type BuildOptionsLike = {
keycloakifyBuildDirPath: string;
themeNames: string[];
};
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
assert<BuildOptions extends BuildOptionsLike ? true : false>();
generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh";
const containerName = "keycloak-testing-container";
const keycloakVersion = "24.0.4";
/** Files for being able to run a hot reload keycloak container */
export function generateStartKeycloakTestingContainer(params: { jarFilePath: string; keycloakVersion: string; buildOptions: BuildOptionsLike }) {
const { jarFilePath, keycloakVersion, buildOptions } = params;
export function generateStartKeycloakTestingContainer(params: {
jarFilePath: string;
doesImplementAccountTheme: boolean;
buildOptions: BuildOptionsLike;
}) {
const { jarFilePath, doesImplementAccountTheme, buildOptions } = params;
const themeRelativeDirPath = pathJoin("src", "main", "resources", "theme");
const themeDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, themeRelativeDirPath);
fs.writeFileSync(
pathJoin(buildOptions.keycloakifyBuildDirPath, generateStartKeycloakTestingContainer.basename),
@ -44,18 +45,12 @@ export function generateStartKeycloakTestingContainer(params: { jarFilePath: str
"$(pwd)",
pathRelative(buildOptions.keycloakifyBuildDirPath, jarFilePath)
)}":"/opt/keycloak/providers/${pathBasename(jarFilePath)}" \\`,
...fs
.readdirSync(themeDirPath)
.filter(name => fs.lstatSync(pathJoin(themeDirPath, name)).isDirectory())
.map(
themeName =>
` -v "${pathJoin("$(pwd)", themeRelativeDirPath, themeName).replace(
/\\/g,
"/"
)}":"/opt/keycloak/themes/${themeName}":rw \\`
),
[...(doesImplementAccountTheme ? [accountV1ThemeName] : []), ...buildOptions.themeNames].map(
themeName =>
` -v "${pathJoin("$(pwd)", themeRelativeDirPath, themeName).replace(/\\/g, "/")}":"/opt/keycloak/themes/${themeName}":rw \\`
),
` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`,
` start-dev --features=declarative-user-profile`,
` start-dev`,
""
].join("\n"),
"utf8"

View File

@ -1,7 +1,6 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "../buildOptions";
import { resources_common, lastKeycloakVersionWithAccountV1, accountV1ThemeName } from "../../constants";
import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme";
@ -9,21 +8,16 @@ import { transformCodebase } from "../../tools/transformCodebase";
import { rmSync } from "../../tools/fs.rmSync";
type BuildOptionsLike = {
keycloakifyBuildDirPath: string;
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
{
const buildOptions = Reflect<BuildOptions>();
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export async function bringInAccountV1(params: { buildOptions: BuildOptionsLike; srcMainResourcesDirPath: string }) {
const { buildOptions, srcMainResourcesDirPath } = params;
export async function bringInAccountV1(params: { buildOptions: BuildOptionsLike }) {
const { buildOptions } = params;
const builtinKeycloakThemeTmpDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "..", "tmp_yxdE2_builtin_keycloak_theme");
const builtinKeycloakThemeTmpDirPath = pathJoin(srcMainResourcesDirPath, "..", "tmp_yxdE2_builtin_keycloak_theme");
await downloadBuiltinKeycloakTheme({
"destDirPath": builtinKeycloakThemeTmpDirPath,
@ -31,7 +25,7 @@ export async function bringInAccountV1(params: { buildOptions: BuildOptionsLike
buildOptions
});
const accountV1DirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources", "theme", accountV1ThemeName, "account");
const accountV1DirPath = pathJoin(srcMainResourcesDirPath, "theme", accountV1ThemeName, "account");
transformCodebase({
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "base", "account"),

View File

@ -0,0 +1,267 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin, resolve as pathResolve, dirname as pathDirname } from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds } from "../generateFtl";
import {
type ThemeType,
lastKeycloakVersionWithAccountV1,
keycloak_resources,
accountV1ThemeName,
basenameOfTheKeycloakifyResourcesDir
} from "../../constants";
import { isInside } from "../../tools/isInside";
import type { BuildOptions } from "../buildOptions";
import { assert, type Equals } from "tsafe/assert";
import { downloadKeycloakStaticResources } from "./downloadKeycloakStaticResources";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import { generateMessageProperties } from "./generateMessageProperties";
import { bringInAccountV1 } from "./bringInAccountV1";
import { rmSync } from "../../tools/fs.rmSync";
export type BuildOptionsLike = {
bundler: "vite" | "webpack";
extraThemeProperties: string[] | undefined;
themeVersion: string;
loginThemeResourcesFromKeycloakVersion: string;
reactAppBuildDirPath: string;
cacheDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
npmWorkspaceRootDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function generateSrcMainResources(params: {
themeName: string;
themeSrcDirPath: string;
keycloakifySrcDirPath: string;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
srcMainResourcesDirPath: string;
}): Promise<{ doesImplementAccountTheme: boolean }> {
const { themeName, themeSrcDirPath, keycloakifySrcDirPath, buildOptions, keycloakifyVersion, srcMainResourcesDirPath } = params;
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => {
const { themeType } = params;
return pathJoin(srcMainResourcesDirPath, "theme", themeName, themeType);
};
const cssGlobalsToDefine: Record<string, string> = {};
const implementedThemeTypes: Record<ThemeType | "email", boolean> = {
"login": false,
"account": false,
"email": false
};
for (const themeType of ["login", "account"] as const) {
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
continue;
}
implementedThemeTypes[themeType] = true;
const themeTypeDirPath = getThemeTypeDirPath({ themeType });
apply_replacers_and_move_to_theme_resources: {
const destDirPath = pathJoin(themeTypeDirPath, "resources", basenameOfTheKeycloakifyResourcesDir);
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
rmSync(destDirPath, { "recursive": true, "force": true });
if (themeType === "account" && implementedThemeTypes.login) {
// NOTE: We prevend doing it twice, it has been done for the login theme.
transformCodebase({
"srcDirPath": pathJoin(
getThemeTypeDirPath({
"themeType": "login"
}),
"resources",
basenameOfTheKeycloakifyResourcesDir
),
destDirPath
});
break apply_replacers_and_move_to_theme_resources;
}
transformCodebase({
"srcDirPath": buildOptions.reactAppBuildDirPath,
destDirPath,
"transformSourceCode": ({ filePath, sourceCode }) => {
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
// This should not happen if users follow the new instruction setup but we keep it for retrocompatibility.
if (
isInside({
"dirPath": pathJoin(buildOptions.reactAppBuildDirPath, keycloak_resources),
filePath
})
) {
return undefined;
}
if (/\.css?$/i.test(filePath)) {
const { cssGlobalsToDefine: cssGlobalsToDefineForThisFile, fixedCssCode } = replaceImportsInCssCode({
"cssCode": sourceCode.toString("utf8")
});
Object.entries(cssGlobalsToDefineForThisFile).forEach(([key, value]) => {
cssGlobalsToDefine[key] = value;
});
return { "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8") };
}
if (/\.js?$/i.test(filePath)) {
const { fixedJsCode } = replaceImportsInJsCode({
"jsCode": sourceCode.toString("utf8"),
buildOptions
});
return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
}
return { "modifiedSourceCode": sourceCode };
}
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
"indexHtmlCode": fs.readFileSync(pathJoin(buildOptions.reactAppBuildDirPath, "index.html")).toString("utf8"),
cssGlobalsToDefine,
buildOptions,
keycloakifyVersion,
themeType,
"fieldNames": readFieldNameUsage({
keycloakifySrcDirPath,
themeSrcDirPath,
themeType
})
});
[
...(() => {
switch (themeType) {
case "login":
return loginThemePageIds;
case "account":
return accountThemePageIds;
}
})(),
...readExtraPagesNames({
themeType,
themeSrcDirPath
})
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.mkdirSync(themeTypeDirPath, { "recursive": true });
fs.writeFileSync(pathJoin(themeTypeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
});
generateMessageProperties({
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"));
});
await downloadKeycloakStaticResources({
"keycloakVersion": (() => {
switch (themeType) {
case "account":
return lastKeycloakVersionWithAccountV1;
case "login":
return buildOptions.loginThemeResourcesFromKeycloakVersion;
}
})(),
"themeDirPath": pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildOptions
});
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
return accountV1ThemeName;
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(buildOptions.extraThemeProperties ?? [])
].join("\n\n"),
"utf8"
)
);
}
email: {
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
if (!fs.existsSync(emailThemeSrcDirPath)) {
break email;
}
implementedThemeTypes.email = true;
transformCodebase({
"srcDirPath": emailThemeSrcDirPath,
"destDirPath": getThemeTypeDirPath({ "themeType": "email" })
});
}
const parsedKeycloakThemeJson: { themes: { name: string; types: string[] }[] } = { "themes": [] };
parsedKeycloakThemeJson.themes.push({
"name": themeName,
"types": Object.entries(implementedThemeTypes)
.filter(([, isImplemented]) => isImplemented)
.map(([themeType]) => themeType)
});
account_specific_extra_work: {
if (!implementedThemeTypes.account) {
break account_specific_extra_work;
}
await bringInAccountV1({
srcMainResourcesDirPath,
buildOptions
});
parsedKeycloakThemeJson.themes.push({
"name": accountV1ThemeName,
"types": ["account"]
});
}
{
const keycloakThemeJsonFilePath = pathJoin(srcMainResourcesDirPath, "META-INF", "keycloak-themes.json");
try {
fs.mkdirSync(pathDirname(keycloakThemeJsonFilePath));
} catch {}
fs.writeFileSync(keycloakThemeJsonFilePath, Buffer.from(JSON.stringify(parsedKeycloakThemeJson, null, 2), "utf8"));
}
return { "doesImplementAccountTheme": implementedThemeTypes.account };
}

View File

@ -1,331 +1,44 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin, basename as pathBasename, resolve as pathResolve, dirname as pathDirname } from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds } from "../generateFtl";
import {
type ThemeType,
lastKeycloakVersionWithAccountV1,
keycloak_resources,
retrocompatPostfix,
accountV1ThemeName,
basenameOfTheKeycloakifyResourcesDir
} from "../../constants";
import { isInside } from "../../tools/isInside";
import { join as pathJoin } from "path";
import type { BuildOptions } from "../buildOptions";
import { assert, type Equals } from "tsafe/assert";
import { downloadKeycloakStaticResources } from "./downloadKeycloakStaticResources";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import { generateMessageProperties } from "./generateMessageProperties";
import { bringInAccountV1 } from "./bringInAccountV1";
import { rmSync } from "../../tools/fs.rmSync";
import { assert } from "tsafe/assert";
import { generateSrcMainResources, type BuildOptionsLike as BuildOptionsLike_generateSrcMainResources } from "./generateSrcMainResources";
import { generateThemeVariations } from "./generateThemeVariants";
export type BuildOptionsLike = {
bundler: "vite" | "webpack";
extraThemeProperties: string[] | undefined;
themeVersion: string;
loginThemeResourcesFromKeycloakVersion: string;
export type BuildOptionsLike = BuildOptionsLike_generateSrcMainResources & {
keycloakifyBuildDirPath: string;
reactAppBuildDirPath: string;
cacheDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
doBuildRetrocompatAccountTheme: boolean;
themeNames: string[];
npmWorkspaceRootDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function generateTheme(params: {
themeName: string;
themeSrcDirPath: string;
keycloakifySrcDirPath: string;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
}): Promise<void> {
const { themeName, themeSrcDirPath, keycloakifySrcDirPath, buildOptions, keycloakifyVersion } = params;
}): Promise<{ doesImplementAccountTheme: boolean }> {
const { themeSrcDirPath, keycloakifySrcDirPath, buildOptions, keycloakifyVersion } = params;
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email"; isRetrocompat?: true }) => {
const { themeType, isRetrocompat = false } = params;
return pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme",
`${themeName}${isRetrocompat ? retrocompatPostfix : ""}`,
themeType
);
};
const [themeName, ...themeVariantNames] = buildOptions.themeNames;
const cssGlobalsToDefine: Record<string, string> = {};
const srcMainResourcesDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources");
const implementedThemeTypes: Record<ThemeType | "email", boolean> = {
"login": false,
"account": false,
"email": false
};
const { doesImplementAccountTheme } = await generateSrcMainResources({
themeName,
srcMainResourcesDirPath,
themeSrcDirPath,
keycloakifySrcDirPath,
keycloakifyVersion,
buildOptions
});
for (const themeType of ["login", "account"] as const) {
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
continue;
}
implementedThemeTypes[themeType] = true;
const themeTypeDirPath = getThemeTypeDirPath({ themeType });
apply_replacers_and_move_to_theme_resources: {
const destDirPath = pathJoin(themeTypeDirPath, "resources", basenameOfTheKeycloakifyResourcesDir);
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
rmSync(destDirPath, { "recursive": true, "force": true });
if (themeType === "account" && implementedThemeTypes.login) {
// NOTE: We prevend doing it twice, it has been done for the login theme.
transformCodebase({
"srcDirPath": pathJoin(
getThemeTypeDirPath({
"themeType": "login"
}),
"resources",
basenameOfTheKeycloakifyResourcesDir
),
destDirPath
});
break apply_replacers_and_move_to_theme_resources;
}
transformCodebase({
"srcDirPath": buildOptions.reactAppBuildDirPath,
destDirPath,
"transformSourceCode": ({ filePath, sourceCode }) => {
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
// This should not happen if users follow the new instruction setup but we keep it for retrocompatibility.
if (
isInside({
"dirPath": pathJoin(buildOptions.reactAppBuildDirPath, keycloak_resources),
filePath
})
) {
return undefined;
}
if (/\.css?$/i.test(filePath)) {
const { cssGlobalsToDefine: cssGlobalsToDefineForThisFile, fixedCssCode } = replaceImportsInCssCode({
"cssCode": sourceCode.toString("utf8")
});
Object.entries(cssGlobalsToDefineForThisFile).forEach(([key, value]) => {
cssGlobalsToDefine[key] = value;
});
return { "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8") };
}
if (/\.js?$/i.test(filePath)) {
const { fixedJsCode } = replaceImportsInJsCode({
"jsCode": sourceCode.toString("utf8"),
buildOptions
});
return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
}
return { "modifiedSourceCode": sourceCode };
}
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
for (const themeVariantName of themeVariantNames) {
generateThemeVariations({
themeName,
"indexHtmlCode": fs.readFileSync(pathJoin(buildOptions.reactAppBuildDirPath, "index.html")).toString("utf8"),
cssGlobalsToDefine,
buildOptions,
keycloakifyVersion,
themeType,
"fieldNames": readFieldNameUsage({
keycloakifySrcDirPath,
themeSrcDirPath,
themeType
})
});
[
...(() => {
switch (themeType) {
case "login":
return loginThemePageIds;
case "account":
return accountThemePageIds;
}
})(),
...readExtraPagesNames({
themeType,
themeSrcDirPath
})
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.mkdirSync(themeTypeDirPath, { "recursive": true });
fs.writeFileSync(pathJoin(themeTypeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
});
generateMessageProperties({
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"));
});
await downloadKeycloakStaticResources({
"keycloakVersion": (() => {
switch (themeType) {
case "account":
return lastKeycloakVersionWithAccountV1;
case "login":
return buildOptions.loginThemeResourcesFromKeycloakVersion;
}
})(),
"themeDirPath": pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildOptions
});
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
return accountV1ThemeName;
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(buildOptions.extraThemeProperties ?? [])
].join("\n\n"),
"utf8"
)
);
if (themeType === "account" && buildOptions.doBuildRetrocompatAccountTheme) {
transformCodebase({
"srcDirPath": themeTypeDirPath,
"destDirPath": getThemeTypeDirPath({ themeType, "isRetrocompat": true }),
"transformSourceCode": ({ filePath, sourceCode }) => {
if (pathBasename(filePath) === "theme.properties") {
return {
"modifiedSourceCode": Buffer.from(
sourceCode.toString("utf8").replace(`parent=${accountV1ThemeName}`, "parent=keycloak"),
"utf8"
)
};
}
return { "modifiedSourceCode": sourceCode };
}
});
}
}
email: {
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
if (!fs.existsSync(emailThemeSrcDirPath)) {
break email;
}
implementedThemeTypes.email = true;
transformCodebase({
"srcDirPath": emailThemeSrcDirPath,
"destDirPath": getThemeTypeDirPath({ "themeType": "email" })
themeVariantName,
srcMainResourcesDirPath
});
}
const parsedKeycloakThemeJson: { themes: { name: string; types: string[] }[] } = { "themes": [] };
buildOptions.themeNames.forEach(themeName =>
parsedKeycloakThemeJson.themes.push({
"name": themeName,
"types": Object.entries(implementedThemeTypes)
.filter(([, isImplemented]) => isImplemented)
.map(([themeType]) => themeType)
})
);
account_specific_extra_work: {
if (!implementedThemeTypes.account) {
break account_specific_extra_work;
}
await bringInAccountV1({ buildOptions });
parsedKeycloakThemeJson.themes.push({
"name": accountV1ThemeName,
"types": ["account"]
});
add_retrocompat_account_theme: {
if (!buildOptions.doBuildRetrocompatAccountTheme) {
break add_retrocompat_account_theme;
}
transformCodebase({
"srcDirPath": getThemeTypeDirPath({ "themeType": "account" }),
"destDirPath": getThemeTypeDirPath({ "themeType": "account", "isRetrocompat": true }),
"transformSourceCode": ({ filePath, sourceCode }) => {
if (pathBasename(filePath) === "theme.properties") {
return {
"modifiedSourceCode": Buffer.from(
sourceCode.toString("utf8").replace(`parent=${accountV1ThemeName}`, "parent=keycloak"),
"utf8"
)
};
}
return { "modifiedSourceCode": sourceCode };
}
});
buildOptions.themeNames.forEach(themeName =>
parsedKeycloakThemeJson.themes.push({
"name": `${themeName}${retrocompatPostfix}`,
"types": ["account"]
})
);
}
}
{
const keycloakThemeJsonFilePath = pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"META-INF",
"keycloak-themes.json"
);
try {
fs.mkdirSync(pathDirname(keycloakThemeJsonFilePath));
} catch {}
fs.writeFileSync(keycloakThemeJsonFilePath, Buffer.from(JSON.stringify(parsedKeycloakThemeJson, null, 2), "utf8"));
}
return { doesImplementAccountTheme };
}

View File

@ -0,0 +1,50 @@
import { join as pathJoin, extname as pathExtname, sep as pathSep } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import { assert } from "tsafe/assert";
import * as fs from "fs";
export function generateThemeVariations(params: { themeName: string; themeVariantName: string; srcMainResourcesDirPath: string }) {
const { themeName, themeVariantName, srcMainResourcesDirPath } = params;
const mainThemeDirPath = pathJoin(srcMainResourcesDirPath, "theme", themeName);
transformCodebase({
"srcDirPath": mainThemeDirPath,
"destDirPath": pathJoin(mainThemeDirPath, "..", themeVariantName),
"transformSourceCode": ({ fileRelativePath, sourceCode }) => {
if (pathExtname(fileRelativePath) === ".ftl" && fileRelativePath.split(pathSep).length === 2) {
const modifiedSourceCode = Buffer.from(
Buffer.from(sourceCode)
.toString("utf-8")
.replace(`out["themeName"] = "${themeName}";`, `out["themeName"] = "${themeVariantName}";`),
"utf8"
);
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
return { modifiedSourceCode };
}
return { "modifiedSourceCode": sourceCode };
}
});
{
const keycloakThemeJsonFilePath = pathJoin(srcMainResourcesDirPath, "META-INF", "keycloak-themes.json");
const modifiedParsedJson = JSON.parse(fs.readFileSync(keycloakThemeJsonFilePath).toString("utf8")) as {
themes: { name: string; types: string[] }[];
};
modifiedParsedJson.themes.push({
"name": themeVariantName,
"types": (() => {
const theme = modifiedParsedJson.themes.find(({ name }) => name === themeName);
assert(theme !== undefined);
return theme.types;
})()
});
fs.writeFileSync(keycloakThemeJsonFilePath, Buffer.from(JSON.stringify(modifiedParsedJson, null, 2), "utf8"));
}
}

View File

@ -1,5 +1,4 @@
import { crawl } from "../../tools/crawl";
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
import { join as pathJoin } from "path";
import * as fs from "fs";
import type { ThemeType } from "../../constants";
@ -8,7 +7,7 @@ import type { ThemeType } from "../../constants";
export function readFieldNameUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): string[] {
const { keycloakifySrcDirPath, themeSrcDirPath, themeType } = params;
const fieldNames: string[] = [];
const fieldNames = new Set<string>();
for (const srcDirPath of [pathJoin(keycloakifySrcDirPath, themeType), pathJoin(themeSrcDirPath, themeType)]) {
const filePaths = crawl({ "dirPath": srcDirPath, "returnedPathsType": "absolute" }).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
@ -20,13 +19,37 @@ export function readFieldNameUsage(params: { keycloakifySrcDirPath: string; them
continue;
}
fieldNames.push(
...Array.from(rawSourceFile.matchAll(/(?:(?:printIfExists)|(?:existsError)|(?:get)|(?:exists))\(\s*["']([^"']+)["']/g), m => m[1])
);
for (const functionName of ["printIfExists", "existsError", "get", "exists", "getFirstError"] as const) {
if (!rawSourceFile.includes(functionName)) {
continue;
}
try {
rawSourceFile
.split(functionName)
.filter(part => part.startsWith("("))
.map(part => {
let [p1] = part.split(")");
p1 = p1.slice(1);
return p1;
})
.map(part => {
return part
.split(",")
.map(a => a.trim())
.filter((...[, i]) => (functionName !== "printIfExists" ? true : i === 0))
.filter(a => a.startsWith('"') || a.startsWith("'") || a.startsWith("`"))
.filter(a => a.endsWith('"') || a.endsWith("'") || a.endsWith("`"))
.map(a => a.slice(1).slice(0, -1));
})
.flat()
.forEach(fieldName => fieldNames.add(fieldName));
} catch {}
}
}
}
const out = fieldNames.reduce(...removeDuplicates<string>());
return out;
return Array.from(fieldNames);
}

View File

@ -1,6 +1,5 @@
import { generateTheme } from "./generateTheme";
import { generatePom } from "./generatePom";
import { join as pathJoin, relative as pathRelative, basename as pathBasename, dirname as pathDirname, sep as pathSep } from "path";
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
import * as child_process from "child_process";
import { generateStartKeycloakTestingContainer } from "./generateStartKeycloakTestingContainer";
import * as fs from "fs";
@ -10,6 +9,7 @@ import { getThemeSrcDirPath } from "../getThemeSrcDirPath";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
import { readThisNpmProjectVersion } from "../tools/readThisNpmProjectVersion";
import { keycloakifyBuildOptionsForPostPostBuildScriptEnvName } from "../constants";
import { buildJars } from "./buildJars";
export async function main() {
const buildOptions = readBuildOptions({
@ -21,34 +21,21 @@ export async function main() {
const { themeSrcDirPath } = getThemeSrcDirPath({ "reactAppRootDirPath": buildOptions.reactAppRootDirPath });
for (const themeName of buildOptions.themeNames) {
await generateTheme({
themeName,
themeSrcDirPath,
"keycloakifySrcDirPath": pathJoin(getThisCodebaseRootDirPath(), "src"),
"keycloakifyVersion": readThisNpmProjectVersion(),
buildOptions
});
}
{
const { pomFileCode } = generatePom({ buildOptions });
if (!fs.existsSync(buildOptions.keycloakifyBuildDirPath)) {
fs.mkdirSync(buildOptions.keycloakifyBuildDirPath, { "recursive": true });
}
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8"));
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, ".gitignore"), Buffer.from("*", "utf8"));
}
const containerKeycloakVersion = "23.0.6";
const jarFilePath = pathJoin(buildOptions.keycloakifyBuildDirPath, "target", `${buildOptions.artifactId}-${buildOptions.themeVersion}.jar`);
generateStartKeycloakTestingContainer({
"keycloakVersion": containerKeycloakVersion,
jarFilePath,
const { doesImplementAccountTheme } = await generateTheme({
themeSrcDirPath,
"keycloakifySrcDirPath": pathJoin(getThisCodebaseRootDirPath(), "src"),
"keycloakifyVersion": readThisNpmProjectVersion(),
buildOptions
});
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, ".gitignore"), Buffer.from("*", "utf8"));
run_post_build_script: {
if (buildOptions.bundler !== "vite") {
break run_post_build_script;
@ -63,44 +50,25 @@ export async function main() {
});
}
create_jar: {
if (!buildOptions.doCreateJar) {
break create_jar;
}
const { lastJarFileBasename } = await buildJars({
doesImplementAccountTheme,
buildOptions
});
child_process.execSync("mvn clean install", { "cwd": buildOptions.keycloakifyBuildDirPath });
const jarDirPath = pathDirname(jarFilePath);
const retrocompatJarFilePath = pathJoin(jarDirPath, "retrocompat-" + pathBasename(jarFilePath));
fs.renameSync(pathJoin(jarDirPath, "original-" + pathBasename(jarFilePath)), retrocompatJarFilePath);
fs.writeFileSync(
pathJoin(jarDirPath, "README.md"),
Buffer.from(
[
`- The ${jarFilePath} is to be used in Keycloak 23 and up. `,
`- The ${retrocompatJarFilePath} is to be used in Keycloak 22 and below.`,
` Note that Keycloak 22 is only supported for login and email theme but not for account themes. `
].join("\n"),
"utf8"
)
);
}
generateStartKeycloakTestingContainer({
"jarFilePath": pathJoin(buildOptions.keycloakifyBuildDirPath, lastJarFileBasename),
doesImplementAccountTheme,
buildOptions
});
logger.log(
[
`✅ Your keycloak theme has been generated and bundled into .${pathSep}${pathJoin(
pathRelative(buildOptions.reactAppRootDirPath, buildOptions.keycloakifyBuildDirPath),
"keycloak-theme-for-kc-*.jar"
)}`,
"",
...(!buildOptions.doCreateJar
? []
: [
`✅ Your keycloak theme has been generated and bundled into .${pathSep}${pathRelative(
buildOptions.reactAppRootDirPath,
jarFilePath
)} 🚀`
]),
"",
`To test your theme locally you can spin up a Keycloak ${containerKeycloakVersion} container image with the theme pre loaded by running:`,
`To test your theme locally you can spin up a Keycloak container image with the theme pre loaded by running:`,
"",
`👉 $ .${pathSep}${pathRelative(
buildOptions.reactAppRootDirPath,

View File

@ -5,10 +5,10 @@ export function createUseClassName<ClassKey extends string>(params: { defaultCla
const { defaultClasses } = params;
function useGetClassName(params: { doUseDefaultCss: boolean; classes: Partial<Record<ClassKey, string>> | undefined }) {
const { classes } = params;
const { classes, doUseDefaultCss } = params;
const getClassName = useConstCallback((classKey: ClassKey): string => {
return clsx(classKey, defaultClasses[classKey], classes?.[classKey]);
return clsx(classKey, doUseDefaultCss ? defaultClasses[classKey] : undefined, classes?.[classKey]);
});
return { getClassName };

View File

@ -1,113 +0,0 @@
import { useReducer, useEffect } from "react";
import { headInsert } from "keycloakify/tools/headInsert";
import { clsx } from "keycloakify/tools/clsx";
import { assert } from "tsafe/assert";
export function usePrepareTemplate(params: {
doFetchDefaultThemeResources: boolean;
styles?: string[];
scripts?: string[];
htmlClassName: string | undefined;
bodyClassName: string | undefined;
htmlLangProperty?: string | undefined;
documentTitle?: string;
}) {
const { doFetchDefaultThemeResources, styles = [], scripts = [], htmlClassName, bodyClassName, htmlLangProperty, documentTitle } = params;
const [isReady, setReady] = useReducer(() => true, !doFetchDefaultThemeResources);
useEffect(() => {
if (htmlLangProperty === undefined) {
return;
}
const html = document.querySelector("html");
assert(html !== null);
html.lang = htmlLangProperty;
}, [htmlLangProperty]);
useEffect(() => {
if (documentTitle === undefined) {
return;
}
document.title = documentTitle;
}, [documentTitle]);
useEffect(() => {
if (!doFetchDefaultThemeResources) {
return;
}
let isUnmounted = false;
const removeArray: (() => void)[] = [];
(async () => {
for (const style of [...styles].reverse()) {
const { prLoaded, remove } = headInsert({
"type": "css",
"position": "prepend",
"href": style
});
removeArray.push(remove);
// TODO: Find a way to do that in parallel (without breaking the order)
await prLoaded;
if (isUnmounted) {
return;
}
}
setReady();
})();
scripts.forEach(src => {
const { remove } = headInsert({
"type": "javascript",
src
});
removeArray.push(remove);
});
return () => {
isUnmounted = true;
removeArray.forEach(remove => remove());
};
}, []);
useSetClassName({
"target": "html",
"className": htmlClassName
});
useSetClassName({
"target": "body",
"className": bodyClassName
});
return { isReady };
}
function useSetClassName(params: { target: "html" | "body"; className: string | undefined }) {
const { target, className } = params;
useEffect(() => {
if (className === undefined) {
return;
}
const htmlClassList = document.getElementsByTagName(target)[0].classList;
const tokens = clsx(className).split(" ");
htmlClassList.add(...tokens);
return () => {
htmlClassList.remove(...tokens);
};
}, [className]);
}

View File

@ -3,21 +3,23 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
import { assert, type Equals } from "tsafe/assert";
import type { I18n } from "./i18n";
import type { KcContext } from "./kcContext";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFields";
const Login = lazy(() => import("keycloakify/login/pages/Login"));
const Register = lazy(() => import("keycloakify/login/pages/Register"));
const RegisterUserProfile = lazy(() => import("keycloakify/login/pages/RegisterUserProfile"));
const Info = lazy(() => import("keycloakify/login/pages/Info"));
const Error = lazy(() => import("keycloakify/login/pages/Error"));
const LoginResetPassword = lazy(() => import("keycloakify/login/pages/LoginResetPassword"));
const LoginVerifyEmail = lazy(() => import("keycloakify/login/pages/LoginVerifyEmail"));
const Terms = lazy(() => import("keycloakify/login/pages/Terms"));
const LoginDeviceVerifyUserCode = lazy(() => import("keycloakify/login/pages/LoginDeviceVerifyUserCode"));
const LoginOauth2DeviceVerifyUserCode = lazy(() => import("keycloakify/login/pages/LoginOauth2DeviceVerifyUserCode"));
const LoginOauthGrant = lazy(() => import("keycloakify/login/pages/LoginOauthGrant"));
const LoginOtp = lazy(() => import("keycloakify/login/pages/LoginOtp"));
const LoginPassword = lazy(() => import("keycloakify/login/pages/LoginPassword"));
const LoginUsername = lazy(() => import("keycloakify/login/pages/LoginUsername"));
const WebauthnAuthenticate = lazy(() => import("keycloakify/login/pages/WebauthnAuthenticate"));
const WebauthnRegister = lazy(() => import("keycloakify/login/pages/WebauthnRegister"));
const LoginUpdatePassword = lazy(() => import("keycloakify/login/pages/LoginUpdatePassword"));
const LoginUpdateProfile = lazy(() => import("keycloakify/login/pages/LoginUpdateProfile"));
const LoginIdpLinkConfirm = lazy(() => import("keycloakify/login/pages/LoginIdpLinkConfirm"));
@ -25,13 +27,25 @@ const LoginPageExpired = lazy(() => import("keycloakify/login/pages/LoginPageExp
const LoginIdpLinkEmail = lazy(() => import("keycloakify/login/pages/LoginIdpLinkEmail"));
const LoginConfigTotp = lazy(() => import("keycloakify/login/pages/LoginConfigTotp"));
const LogoutConfirm = lazy(() => import("keycloakify/login/pages/LogoutConfirm"));
const UpdateUserProfile = lazy(() => import("keycloakify/login/pages/UpdateUserProfile"));
const IdpReviewUserProfile = lazy(() => import("keycloakify/login/pages/IdpReviewUserProfile"));
const UpdateEmail = lazy(() => import("keycloakify/login/pages/UpdateEmail"));
const SelectAuthenticator = lazy(() => import("keycloakify/login/pages/SelectAuthenticator"));
const SamlPostForm = lazy(() => import("keycloakify/login/pages/SamlPostForm"));
const DeleteCredential = lazy(() => import("keycloakify/login/pages/DeleteCredential"));
const Code = lazy(() => import("keycloakify/login/pages/Code"));
const DeleteAccountConfirm = lazy(() => import("keycloakify/login/pages/DeleteAccountConfirm"));
const FrontchannelLogout = lazy(() => import("keycloakify/login/pages/FrontchannelLogout"));
const LoginRecoveryAuthnCodeConfig = lazy(() => import("keycloakify/login/pages/LoginRecoveryAuthnCodeConfig"));
const LoginRecoveryAuthnCodeInput = lazy(() => import("keycloakify/login/pages/LoginRecoveryAuthnCodeInput"));
const LoginResetOtp = lazy(() => import("keycloakify/login/pages/LoginResetOtp"));
const LoginX509Info = lazy(() => import("keycloakify/login/pages/LoginX509Info"));
const WebauthnError = lazy(() => import("keycloakify/login/pages/WebauthnError"));
export default function Fallback(props: PageProps<KcContext, I18n>) {
type FallbackProps = PageProps<KcContext, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
};
export default function Fallback(props: FallbackProps) {
const { kcContext, ...rest } = props;
return (
@ -42,8 +56,6 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
return <Login kcContext={kcContext} {...rest} />;
case "register.ftl":
return <Register kcContext={kcContext} {...rest} />;
case "register-user-profile.ftl":
return <RegisterUserProfile kcContext={kcContext} {...rest} />;
case "info.ftl":
return <Info kcContext={kcContext} {...rest} />;
case "error.ftl":
@ -55,7 +67,7 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
case "terms.ftl":
return <Terms kcContext={kcContext} {...rest} />;
case "login-oauth2-device-verify-user-code.ftl":
return <LoginDeviceVerifyUserCode kcContext={kcContext} {...rest} />;
return <LoginOauth2DeviceVerifyUserCode kcContext={kcContext} {...rest} />;
case "login-oauth-grant.ftl":
return <LoginOauthGrant kcContext={kcContext} {...rest} />;
case "login-otp.ftl":
@ -66,6 +78,8 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
return <LoginPassword kcContext={kcContext} {...rest} />;
case "webauthn-authenticate.ftl":
return <WebauthnAuthenticate kcContext={kcContext} {...rest} />;
case "webauthn-register.ftl":
return <WebauthnRegister kcContext={kcContext} {...rest} />;
case "login-update-password.ftl":
return <LoginUpdatePassword kcContext={kcContext} {...rest} />;
case "login-update-profile.ftl":
@ -80,8 +94,6 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
return <LoginConfigTotp kcContext={kcContext} {...rest} />;
case "logout-confirm.ftl":
return <LogoutConfirm kcContext={kcContext} {...rest} />;
case "update-user-profile.ftl":
return <UpdateUserProfile kcContext={kcContext} {...rest} />;
case "idp-review-user-profile.ftl":
return <IdpReviewUserProfile kcContext={kcContext} {...rest} />;
case "update-email.ftl":
@ -90,6 +102,24 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
return <SelectAuthenticator kcContext={kcContext} {...rest} />;
case "saml-post-form.ftl":
return <SamlPostForm kcContext={kcContext} {...rest} />;
case "delete-credential.ftl":
return <DeleteCredential kcContext={kcContext} {...rest} />;
case "code.ftl":
return <Code kcContext={kcContext} {...rest} />;
case "delete-account-confirm.ftl":
return <DeleteAccountConfirm kcContext={kcContext} {...rest} />;
case "frontchannel-logout.ftl":
return <FrontchannelLogout kcContext={kcContext} {...rest} />;
case "login-recovery-authn-code-config.ftl":
return <LoginRecoveryAuthnCodeConfig kcContext={kcContext} {...rest} />;
case "login-recovery-authn-code-input.ftl":
return <LoginRecoveryAuthnCodeInput kcContext={kcContext} {...rest} />;
case "login-reset-otp.ftl":
return <LoginResetOtp kcContext={kcContext} {...rest} />;
case "login-x509-info.ftl":
return <LoginX509Info kcContext={kcContext} {...rest} />;
case "webauthn-error.ftl":
return <WebauthnError kcContext={kcContext} {...rest} />;
}
assert<Equals<typeof kcContext, never>>(false);
})()}

View File

@ -1,21 +1,28 @@
import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate";
import { type TemplateProps } from "keycloakify/login/TemplateProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { createUseInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { createUseInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import type { KcContext } from "./kcContext";
import type { I18n } from "./i18n";
const { useInsertLinkTags } = createUseInsertLinkTags();
const { useInsertScriptTags } = createUseInsertScriptTags();
export default function Template(props: TemplateProps<KcContext, I18n>) {
const {
displayInfo = false,
displayMessage = true,
displayRequiredFields = false,
displayWide = false,
showAnotherWayIfPresent = true,
headerNode,
showUsernameNode = null,
socialProvidersNode = null,
infoNode = null,
documentTitle,
bodyClassName,
kcContext,
i18n,
doUseDefaultCss,
@ -25,25 +32,87 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const { msg, changeLocale, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { realm, locale, auth, url, message, isAppInitiatedAction } = kcContext;
const { realm, locale, auth, url, message, isAppInitiatedAction, authenticationSession, scripts } = kcContext;
const { isReady } = usePrepareTemplate({
"doFetchDefaultThemeResources": doUseDefaultCss,
"styles": [
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesCommonPath}/lib/zocial/zocial.css`,
`${url.resourcesPath}/css/login.css`
],
"htmlClassName": getClassName("kcHtmlClass"),
"bodyClassName": getClassName("kcBodyClass"),
"htmlLangProperty": locale?.currentLanguageTag,
"documentTitle": i18n.msgStr("loginTitle", kcContext.realm.displayName)
useEffect(() => {
document.title = documentTitle ?? msgStr("loginTitle", kcContext.realm.displayName);
}, []);
useSetClassName({
"qualifiedName": "html",
"className": getClassName("kcHtmlClass")
});
if (!isReady) {
useSetClassName({
"qualifiedName": "body",
"className": bodyClassName ?? getClassName("kcBodyClass")
});
useEffect(() => {
const { currentLanguageTag } = locale ?? {};
if (currentLanguageTag === undefined) {
return;
}
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
}, []);
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
"hrefs": !doUseDefaultCss
? []
: [
`${url.resourcesCommonPath}/node_modules/@patternfly/patternfly/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesCommonPath}/lib/pficon/pficon.css`,
`${url.resourcesPath}/css/login.css`
]
});
const { insertScriptTags } = useInsertScriptTags({
"scriptTags": [
{
"type": "module",
"src": `${url.resourcesPath}/js/menu-button-links.js`
},
...(authenticationSession === undefined
? []
: [
{
"type": "module",
"textContent": [
`import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";`,
``,
`checkCookiesAndSetTimer(`,
` "${authenticationSession.authSessionId}",`,
` "${authenticationSession.tabId}",`,
` "${url.ssoLoginInOtherTabsUrl}"`,
`);`
].join("\n")
} as const
]),
...scripts.map(
script =>
({
"type": "text/javascript",
"src": script
} as const)
)
]
});
useEffect(() => {
if (areAllStyleSheetsLoaded) {
insertScriptTags();
}
}, [areAllStyleSheetsLoaded]);
if (!areAllStyleSheetsLoaded) {
return null;
}
@ -55,21 +124,39 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div>
</div>
<div className={clsx(getClassName("kcFormCardClass"), displayWide && getClassName("kcFormCardAccountClass"))}>
<div className={getClassName("kcFormCardClass")}>
<header className={getClassName("kcFormHeaderClass")}>
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
<div id="kc-locale">
{realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && (
<div className={getClassName("kcLocaleMainClass")} id="kc-locale">
<div id="kc-locale-wrapper" className={getClassName("kcLocaleWrapperClass")}>
<div className="kc-dropdown" id="kc-locale-dropdown">
<div id="kc-locale-dropdown" className={clsx("menu-button-links", getClassName("kcLocaleDropDownClass"))}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a href="#" id="kc-current-locale-link">
<button
tabIndex={1}
id="kc-current-locale-link"
aria-label={msgStr("languages" as any)}
aria-haspopup="true"
aria-expanded="false"
aria-controls="language-switch1"
>
{labelBySupportedLanguageTag[currentLanguageTag]}
</a>
<ul>
{locale.supported.map(({ languageTag }) => (
<li key={languageTag} className="kc-dropdown-item">
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a href="#" onClick={() => changeLocale(languageTag)}>
</button>
<ul
role="menu"
tabIndex={-1}
aria-labelledby="kc-current-locale-link"
aria-activedescendant=""
id="language-switch1"
className={getClassName("kcLocaleListClass")}
>
{locale.supported.map(({ languageTag }, i) => (
<li key={languageTag} className={getClassName("kcLocaleListItemClass")} role="none">
<a
role="menuitem"
id={`language-${i + 1}`}
className={getClassName("kcLocaleItemClass")}
href={getChangeLocalUrl(languageTag)}
>
{labelBySupportedLanguageTag[languageTag]}
</a>
</li>
@ -104,26 +191,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div>
<div className="col-md-10">
{showUsernameNode}
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-username">
<label id="kc-attempted-username">{auth?.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl}>
<div className="kc-login-tooltip">
<i className={getClassName("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</div>
</div>
</div>
) : (
<>
{showUsernameNode}
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-username">
<label id="kc-attempted-username">{auth?.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl}>
<div id="kc-username" className={getClassName("kcFormGroupClass")}>
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
<div className="kc-login-tooltip">
<i className={getClassName("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
@ -131,6 +201,19 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</a>
</div>
</div>
</div>
) : (
<>
{showUsernameNode}
<div id="kc-username" className={getClassName("kcFormGroupClass")}>
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
<div className="kc-login-tooltip">
<i className={getClassName("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</>
)}
</header>
@ -138,13 +221,21 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<div id="kc-content-wrapper">
{/* App-initiated actions should not see warning messages about the need to complete the action during login. */}
{displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && (
<div className={clsx("alert", `alert-${message.type}`)}>
{message.type === "success" && <span className={getClassName("kcFeedbackSuccessIcon")}></span>}
{message.type === "warning" && <span className={getClassName("kcFeedbackWarningIcon")}></span>}
{message.type === "error" && <span className={getClassName("kcFeedbackErrorIcon")}></span>}
{message.type === "info" && <span className={getClassName("kcFeedbackInfoIcon")}></span>}
<div
className={clsx(
`alert-${message.type}`,
getClassName("kcAlertClass"),
`pf-m-${message?.type === "error" ? "danger" : message.type}`
)}
>
<div className="pf-c-alert__icon">
{message.type === "success" && <span className={getClassName("kcFeedbackSuccessIcon")}></span>}
{message.type === "warning" && <span className={getClassName("kcFeedbackWarningIcon")}></span>}
{message.type === "error" && <span className={getClassName("kcFeedbackErrorIcon")}></span>}
{message.type === "info" && <span className={getClassName("kcFeedbackInfoIcon")}></span>}
</div>
<span
className="kc-feedback-text"
className={getClassName("kcAlertTitleClass")}
dangerouslySetInnerHTML={{
"__html": message.summary
}}
@ -152,18 +243,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div>
)}
{children}
{auth !== undefined && auth.showTryAnotherWayLink && showAnotherWayIfPresent && (
<form
id="kc-select-try-another-way-form"
action={url.loginAction}
method="post"
className={clsx(displayWide && getClassName("kcContentWrapperClass"))}
>
<div
className={clsx(
displayWide && [getClassName("kcFormSocialAccountContentClass"), getClassName("kcFormSocialAccountClass")]
)}
>
{auth !== undefined && auth.showTryAnotherWayLink && (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcFormGroupClass")}>
<input type="hidden" name="tryAnotherWay" value="on" />
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
@ -181,6 +263,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div>
</form>
)}
{socialProvidersNode}
{displayInfo && (
<div id="kc-info" className={getClassName("kcSignUpClass")}>
<div id="kc-info-wrapper" className={getClassName("kcInfoAreaWrapperClass")}>

View File

@ -11,89 +11,139 @@ export type TemplateProps<KcContext extends KcContext.Common, I18nExtended exten
displayInfo?: boolean;
displayMessage?: boolean;
displayRequiredFields?: boolean;
displayWide?: boolean;
showAnotherWayIfPresent?: boolean;
headerNode: ReactNode;
showUsernameNode?: ReactNode;
socialProvidersNode?: ReactNode;
infoNode?: ReactNode;
documentTitle?: string;
bodyClassName?: string;
children: ReactNode;
};
export type ClassKey =
| "kcBodyClass"
| "kcHtmlClass"
| "kcLoginClass"
| "kcHeaderClass"
| "kcHeaderWrapperClass"
| "kcFormCardClass"
| "kcFormCardAccountClass"
| "kcFormHeaderClass"
| "kcLocaleWrapperClass"
| "kcContentWrapperClass"
| "kcLabelWrapperClass"
| "kcFormGroupClass"
| "kcResetFlowIcon"
| "kcFeedbackSuccessIcon"
| "kcFeedbackWarningIcon"
| "kcFeedbackErrorIcon"
| "kcFeedbackInfoIcon"
| "kcFormSocialAccountContentClass"
| "kcFormSocialAccountClass"
| "kcSignUpClass"
| "kcInfoAreaWrapperClass"
| "kcLogoClass"
| "kcContainerClass"
| "kcContentClass"
| "kcFeedbackAreaClass"
| "kcLocaleClass"
| "kcAlertIconClasserror"
| "kcFormAreaClass"
| "kcFormSocialAccountListClass"
| "kcFormSocialAccountDoubleListClass"
| "kcFormSocialAccountListLinkClass"
| "kcWebAuthnKeyIcon"
| "kcWebAuthnDefaultIcon"
| "kcFormClass"
| "kcFormGroupErrorClass"
| "kcLabelClass"
| "kcInputClass"
| "kcInputErrorMessageClass"
| "kcInputWrapperClass"
| "kcFormOptionsClass"
| "kcFormButtonsClass"
| "kcFormSettingClass"
| "kcTextareaClass"
| "kcInfoAreaClass"
| "kcFormGroupHeader"
| "kcButtonClass"
| "kcButtonPrimaryClass"
| "kcButtonDefaultClass"
| "kcButtonLargeClass"
| "kcButtonBlockClass"
| "kcInputLargeClass"
| "kcSrOnlyClass"
| "kcSelectAuthListClass"
| "kcSelectAuthListItemClass"
| "kcSelectAuthListItemFillClass"
| "kcSelectAuthListItemInfoClass"
| "kcSelectAuthListItemLeftClass"
| "kcSelectAuthListItemBodyClass"
| "kcSelectAuthListItemDescriptionClass"
| "kcSelectAuthListItemHeadingClass"
| "kcSelectAuthListItemHelpTextClass"
| "kcSelectAuthListItemIconPropertyClass"
| "kcSelectAuthListItemIconClass"
| "kcSelectAuthListItemTitle"
| "kcAuthenticatorDefaultClass"
| "kcAuthenticatorPasswordClass"
| "kcAuthenticatorOTPClass"
| "kcAuthenticatorWebAuthnClass"
| "kcAuthenticatorWebAuthnPasswordlessClass"
| "kcSelectOTPListClass"
| "kcSelectOTPListItemClass"
| "kcAuthenticatorOtpCircleClass"
| "kcSelectOTPItemHeadingClass"
| "kcFormOptionsWrapperClass"
| "kcFormButtonsWrapperClass"
| "kcInputGroup";
| "kcFormOptionsWrapperClass"
| "kcCheckboxInputClass"
| "kcLocaleDropDownClass"
| "kcLocaleListItemClass"
| "kcContentWrapperClass"
| "kcLogoIdP-facebook"
| "kcAuthenticatorOTPClass"
| "kcLogoIdP-bitbucket"
| "kcAuthenticatorWebAuthnClass"
| "kcWebAuthnDefaultIcon"
| "kcLogoIdP-stackoverflow"
| "kcSelectAuthListItemClass"
| "kcLogoIdP-microsoft"
| "kcLoginOTPListItemHeaderClass"
| "kcLocaleItemClass"
| "kcLoginOTPListItemIconBodyClass"
| "kcInputHelperTextAfterClass"
| "kcFormClass"
| "kcSelectAuthListClass"
| "kcInputClassRadioCheckboxLabelDisabled"
| "kcSelectAuthListItemIconClass"
| "kcRecoveryCodesWarning"
| "kcFormSettingClass"
| "kcWebAuthnBLE"
| "kcInputWrapperClass"
| "kcSelectAuthListItemArrowIconClass"
| "kcFeedbackAreaClass"
| "kcFormPasswordVisibilityButtonClass"
| "kcLogoIdP-google"
| "kcCheckLabelClass"
| "kcSelectAuthListItemFillClass"
| "kcAuthenticatorDefaultClass"
| "kcLogoIdP-gitlab"
| "kcFormAreaClass"
| "kcFormButtonsClass"
| "kcInputClassRadioLabel"
| "kcAuthenticatorWebAuthnPasswordlessClass"
| "kcSelectAuthListItemHeadingClass"
| "kcInfoAreaClass"
| "kcLogoLink"
| "kcContainerClass"
| "kcSelectAuthListItemTitle"
| "kcHtmlClass"
| "kcLoginOTPListItemTitleClass"
| "kcLogoIdP-openshift-v4"
| "kcWebAuthnUnknownIcon"
| "kcFormSocialAccountNameClass"
| "kcLogoIdP-openshift-v3"
| "kcLoginOTPListInputClass"
| "kcWebAuthnUSB"
| "kcInputClassRadio"
| "kcWebAuthnKeyIcon"
| "kcFeedbackInfoIcon"
| "kcCommonLogoIdP"
| "kcRecoveryCodesActions"
| "kcFormGroupHeader"
| "kcFormSocialAccountSectionClass"
| "kcLogoIdP-instagram"
| "kcAlertClass"
| "kcHeaderClass"
| "kcLabelWrapperClass"
| "kcFormPasswordVisibilityIconShow"
| "kcFormSocialAccountLinkClass"
| "kcLocaleMainClass"
| "kcInputGroup"
| "kcTextareaClass"
| "kcButtonBlockClass"
| "kcButtonClass"
| "kcWebAuthnNFC"
| "kcLocaleClass"
| "kcInputClassCheckboxInput"
| "kcFeedbackErrorIcon"
| "kcInputLargeClass"
| "kcInputErrorMessageClass"
| "kcRecoveryCodesList"
| "kcFormSocialAccountListClass"
| "kcAlertTitleClass"
| "kcAuthenticatorPasswordClass"
| "kcCheckInputClass"
| "kcLogoIdP-linkedin"
| "kcLogoIdP-twitter"
| "kcFeedbackWarningIcon"
| "kcResetFlowIcon"
| "kcSelectAuthListItemIconPropertyClass"
| "kcFeedbackSuccessIcon"
| "kcLoginOTPListClass"
| "kcSrOnlyClass"
| "kcFormSocialAccountListGridClass"
| "kcButtonDefaultClass"
| "kcFormGroupErrorClass"
| "kcSelectAuthListItemDescriptionClass"
| "kcSelectAuthListItemBodyClass"
| "kcWebAuthnInternal"
| "kcSelectAuthListItemArrowClass"
| "kcCheckClass"
| "kcContentClass"
| "kcLogoClass"
| "kcLoginOTPListItemIconClass"
| "kcLoginClass"
| "kcSignUpClass"
| "kcButtonLargeClass"
| "kcFormCardClass"
| "kcLocaleListClass"
| "kcInputClass"
| "kcFormGroupClass"
| "kcLogoIdP-paypal"
| "kcInputClassCheckbox"
| "kcRecoveryCodesConfirmation"
| "kcFormPasswordVisibilityIconHide"
| "kcInputClassRadioInput"
| "kcFormSocialAccountListButtonClass"
| "kcInputClassCheckboxLabel"
| "kcFormOptionsClass"
| "kcFormHeaderClass"
| "kcFormSocialAccountGridItem"
| "kcButtonPrimaryClass"
| "kcInputHelperTextBeforeClass"
| "kcLogoIdP-github"
| "kcLabelClass";

View File

@ -0,0 +1,747 @@
import { useEffect, useReducer, Fragment } from "react";
import type { ClassKey } from "keycloakify/login/TemplateProps";
import { useUserProfileForm, type KcContextLike, type FormAction, type FormFieldError } from "keycloakify/login/lib/useUserProfileForm";
import type { Attribute } from "keycloakify/login/kcContext/KcContext";
import { assert } from "tsafe/assert";
import type { I18n } from "./i18n";
export type UserProfileFormFieldsProps = {
kcContext: KcContextLike;
i18n: I18n;
getClassName: (classKey: ClassKey) => string;
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
BeforeField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
AfterField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
};
type BeforeAfterFieldProps = {
attribute: Attribute;
dispatchFormAction: React.Dispatch<FormAction>;
displayableErrors: FormFieldError[];
i18n: I18n;
valueOrValues: string | string[];
};
// NOTE: Enabled by default but it's a UX best practice to set it to false.
const doMakeUserConfirmPassword = true;
export default function UserProfileFormFields(props: UserProfileFormFieldsProps) {
const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props;
const { advancedMsg } = i18n;
const {
formState: { formFieldStates, isFormSubmittable },
dispatchFormAction
} = useUserProfileForm({
kcContext,
i18n,
doMakeUserConfirmPassword
});
useEffect(() => {
onIsFormSubmittableValueChange(isFormSubmittable);
}, [isFormSubmittable]);
const groupNameRef = { "current": "" };
return (
<>
{formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => {
return (
<Fragment key={attribute.name}>
<GroupLabel attribute={attribute} getClassName={getClassName} i18n={i18n} groupNameRef={groupNameRef} />
{BeforeField !== undefined && (
<BeforeField
attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
i18n={i18n}
valueOrValues={valueOrValues}
/>
)}
<div
className={getClassName("kcFormGroupClass")}
style={{ "display": attribute.name === "password-confirm" && !doMakeUserConfirmPassword ? "none" : undefined }}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor={attribute.name} className={getClassName("kcLabelClass")}>
{advancedMsg(attribute.displayName ?? "")}
</label>
{attribute.required && <>*</>}
</div>
<div className={getClassName("kcInputWrapperClass")}>
{attribute.annotations.inputHelperTextBefore !== undefined && (
<div
className={getClassName("kcInputHelperTextBeforeClass")}
id={`form-help-text-before-${attribute.name}`}
aria-live="polite"
>
{advancedMsg(attribute.annotations.inputHelperTextBefore)}
</div>
)}
<InputFiledByType
attribute={attribute}
valueOrValues={valueOrValues}
displayableErrors={displayableErrors}
formValidationDispatch={dispatchFormAction}
getClassName={getClassName}
i18n={i18n}
/>
<FieldErrors
attribute={attribute}
getClassName={getClassName}
displayableErrors={displayableErrors}
fieldIndex={undefined}
/>
{attribute.annotations.inputHelperTextAfter !== undefined && (
<div
className={getClassName("kcInputHelperTextAfterClass")}
id={`form-help-text-after-${attribute.name}`}
aria-live="polite"
>
{advancedMsg(attribute.annotations.inputHelperTextAfter)}
</div>
)}
{AfterField !== undefined && (
<AfterField
attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
i18n={i18n}
valueOrValues={valueOrValues}
/>
)}
{/* NOTE: Downloading of html5DataAnnotations scripts is done in the useUserProfileForm hook */}
</div>
</div>
</Fragment>
);
})}
</>
);
}
function GroupLabel(props: {
attribute: Attribute;
getClassName: UserProfileFormFieldsProps["getClassName"];
i18n: I18n;
groupNameRef: {
current: string;
};
}) {
const { attribute, getClassName, i18n, groupNameRef } = props;
const { advancedMsg } = i18n;
if (attribute.group?.name !== groupNameRef.current) {
groupNameRef.current = attribute.group?.name ?? "";
if (groupNameRef.current !== "") {
assert(attribute.group !== undefined);
return (
<div
className={getClassName("kcFormGroupClass")}
{...Object.fromEntries(Object.entries(attribute.group.html5DataAnnotations).map(([key, value]) => [`data-${key}`, value]))}
>
{(() => {
const groupDisplayHeader = attribute.group.displayHeader ?? "";
const groupHeaderText = groupDisplayHeader !== "" ? advancedMsg(groupDisplayHeader) : attribute.group.name;
return (
<div className={getClassName("kcContentWrapperClass")}>
<label id={`header-${attribute.group.name}`} className={getClassName("kcFormGroupHeader")}>
{groupHeaderText}
</label>
</div>
);
})()}
{(() => {
const groupDisplayDescription = attribute.group.displayDescription ?? "";
if (groupDisplayDescription !== "") {
const groupDescriptionText = advancedMsg(groupDisplayDescription);
return (
<div className={getClassName("kcLabelWrapperClass")}>
<label id={`description-${attribute.group.name}`} className={getClassName("kcLabelClass")}>
{groupDescriptionText}
</label>
</div>
);
}
return null;
})()}
</div>
);
}
}
return null;
}
function FieldErrors(props: {
attribute: Attribute;
getClassName: UserProfileFormFieldsProps["getClassName"];
displayableErrors: FormFieldError[];
fieldIndex: number | undefined;
}) {
const { attribute, getClassName, fieldIndex } = props;
const displayableErrors = props.displayableErrors.filter(error => error.fieldIndex === fieldIndex);
if (displayableErrors.length === 0) {
return null;
}
return (
<span
id={`input-error-${attribute.name}${fieldIndex === undefined ? "" : `-${fieldIndex}`}`}
className={getClassName("kcInputErrorMessageClass")}
aria-live="polite"
>
{displayableErrors
.filter(error => error.fieldIndex === fieldIndex)
.map(({ errorMessage }, i, arr) => (
<Fragment key={i}>
<span key={i}>{errorMessage}</span>
{arr.length - 1 !== i && <br />}
</Fragment>
))}
</span>
);
}
type InputFiledByTypeProps = {
attribute: Attribute;
valueOrValues: string | string[];
displayableErrors: FormFieldError[];
formValidationDispatch: React.Dispatch<FormAction>;
getClassName: UserProfileFormFieldsProps["getClassName"];
i18n: I18n;
};
function InputFiledByType(props: InputFiledByTypeProps) {
const { attribute, valueOrValues } = props;
switch (attribute.annotations.inputType) {
case "textarea":
return <TextareaTag {...props} />;
case "select":
case "multiselect":
return <SelectTag {...props} />;
case "select-radiobuttons":
case "multiselect-checkboxes":
return <InputTagSelects {...props} />;
default: {
if (valueOrValues instanceof Array) {
return (
<>
{valueOrValues.map((...[, i]) => (
<InputTag key={i} {...props} fieldIndex={i} />
))}
</>
);
}
const inputNode = <InputTag {...props} fieldIndex={undefined} />;
if (attribute.name === "password" || attribute.name === "password-confirm") {
return (
<PasswordWrapper getClassName={props.getClassName} i18n={props.i18n} passwordInputId={attribute.name}>
{inputNode}
</PasswordWrapper>
);
}
return inputNode;
}
}
}
function PasswordWrapper(props: { getClassName: (classKey: ClassKey) => string; i18n: I18n; passwordInputId: string; children: JSX.Element }) {
const { getClassName, i18n, passwordInputId, children } = props;
const { msgStr } = i18n;
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
useEffect(() => {
const passwordInputElement = document.getElementById(passwordInputId);
assert(passwordInputElement instanceof HTMLInputElement);
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
}, [isPasswordRevealed]);
return (
<div className={getClassName("kcInputGroup")}>
{children}
<button
type="button"
className={getClassName("kcFormPasswordVisibilityButtonClass")}
aria-label={msgStr(isPasswordRevealed ? "hidePassword" : "showPassword")}
aria-controls={passwordInputId}
onClick={toggleIsPasswordRevealed}
>
<i
className={getClassName(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")}
aria-hidden
/>
</button>
</div>
);
}
function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefined }) {
const { attribute, fieldIndex, getClassName, formValidationDispatch, valueOrValues, i18n, displayableErrors } = props;
return (
<>
<input
type={(() => {
const { inputType } = attribute.annotations;
if (inputType?.startsWith("html5-")) {
return inputType.slice(6);
}
return inputType ?? "text";
})()}
id={attribute.name}
name={attribute.name}
value={(() => {
if (fieldIndex !== undefined) {
assert(valueOrValues instanceof Array);
return valueOrValues[fieldIndex];
}
assert(typeof valueOrValues === "string");
return valueOrValues;
})()}
className={getClassName("kcInputClass")}
aria-invalid={displayableErrors.find(error => error.fieldIndex === fieldIndex) !== undefined}
disabled={attribute.readOnly}
autoComplete={attribute.autocomplete}
placeholder={attribute.annotations.inputTypePlaceholder}
pattern={attribute.annotations.inputTypePattern}
size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)}
maxLength={
attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`)
}
minLength={
attribute.annotations.inputTypeMinlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMinlength}`)
}
max={attribute.annotations.inputTypeMax}
min={attribute.annotations.inputTypeMin}
step={attribute.annotations.inputTypeStep}
{...Object.fromEntries(Object.entries(attribute.html5DataAnnotations ?? {}).map(([key, value]) => [`data-${key}`, value]))}
onChange={event =>
formValidationDispatch({
"action": "update",
"name": attribute.name,
"valueOrValues": (() => {
if (fieldIndex !== undefined) {
assert(valueOrValues instanceof Array);
return valueOrValues.map((value, i) => {
if (i === fieldIndex) {
return event.target.value;
}
return value;
});
}
return event.target.value;
})()
})
}
onBlur={() =>
props.formValidationDispatch({
"action": "focus lost",
"name": attribute.name,
"fieldIndex": fieldIndex
})
}
/>
{(() => {
if (fieldIndex === undefined) {
return null;
}
assert(valueOrValues instanceof Array);
const values = valueOrValues;
return (
<>
<FieldErrors
attribute={attribute}
getClassName={getClassName}
displayableErrors={displayableErrors}
fieldIndex={fieldIndex}
/>
<AddRemoveButtonsMultiValuedAttribute
attribute={attribute}
values={values}
fieldIndex={fieldIndex}
dispatchFormAction={formValidationDispatch}
i18n={i18n}
/>
</>
);
})()}
</>
);
}
function AddRemoveButtonsMultiValuedAttribute(props: {
attribute: Attribute;
values: string[];
fieldIndex: number;
dispatchFormAction: React.Dispatch<Extract<FormAction, { action: "update" }>>;
i18n: I18n;
}) {
const { attribute, values, fieldIndex, dispatchFormAction, i18n } = props;
const { msg } = i18n;
const hasRemove = (() => {
if (values.length === 1) {
return false;
}
const minCount = (() => {
const { multivalued } = attribute.validators;
if (multivalued === undefined) {
return undefined;
}
const minStr = multivalued.min;
if (minStr === undefined) {
return undefined;
}
return parseInt(`${minStr}`);
})();
if (minCount === undefined) {
return true;
}
if (values.length === minCount) {
return false;
}
return true;
})();
const hasAdd = (() => {
if (fieldIndex + 1 !== values.length) {
return false;
}
const maxCount = (() => {
const { multivalued } = attribute.validators;
if (multivalued === undefined) {
return undefined;
}
const maxStr = multivalued.max;
if (maxStr === undefined) {
return undefined;
}
return parseInt(`${maxStr}`);
})();
if (maxCount === undefined) {
return false;
}
if (values.length === maxCount) {
return false;
}
return true;
})();
return (
<>
{hasRemove && (
<button
id={`kc-remove-${attribute.name}-${fieldIndex + 1}`}
type="button"
className="pf-c-button pf-m-inline pf-m-link"
onClick={() =>
dispatchFormAction({
"action": "update",
"name": attribute.name,
"valueOrValues": values.filter((_, i) => i !== fieldIndex)
})
}
>
{msg("remove")}
{hasRemove ? <>&nbsp;|&nbsp;</> : null}
</button>
)}
{hasAdd && (
<button
id="kc-add-titles-1"
type="button"
className="pf-c-button pf-m-inline pf-m-link"
onClick={() =>
dispatchFormAction({
"action": "update",
"name": attribute.name,
"valueOrValues": [...values, ""]
})
}
>
{msg("addValue")}
</button>
)}
</>
);
}
function InputTagSelects(props: InputFiledByTypeProps) {
const { attribute, formValidationDispatch, getClassName, valueOrValues } = props;
const { advancedMsg } = props.i18n;
const { classDiv, classInput, classLabel, inputType } = (() => {
const { inputType } = attribute.annotations;
assert(inputType === "select-radiobuttons" || inputType === "multiselect-checkboxes");
switch (inputType) {
case "select-radiobuttons":
return {
"inputType": "radio",
"classDiv": getClassName("kcInputClassRadio"),
"classInput": getClassName("kcInputClassRadioInput"),
"classLabel": getClassName("kcInputClassRadioLabel")
};
case "multiselect-checkboxes":
return {
"inputType": "checkbox",
"classDiv": getClassName("kcInputClassCheckbox"),
"classInput": getClassName("kcInputClassCheckboxInput"),
"classLabel": getClassName("kcInputClassCheckboxLabel")
};
}
})();
const options = (() => {
walk: {
const { inputOptionsFromValidation } = attribute.annotations;
if (inputOptionsFromValidation === undefined) {
break walk;
}
const validator = (attribute.validators as Record<string, { options?: string[] }>)[inputOptionsFromValidation];
if (validator === undefined) {
break walk;
}
if (validator.options === undefined) {
break walk;
}
return validator.options;
}
return attribute.validators.options?.options ?? [];
})();
return (
<>
{options.map(option => (
<div key={option} className={classDiv}>
<input
type={inputType}
id={`${attribute.name}-${option}`}
name={attribute.name}
value={option}
className={classInput}
aria-invalid={props.displayableErrors.length !== 0}
disabled={attribute.readOnly}
checked={valueOrValues.includes(option)}
onChange={event =>
formValidationDispatch({
"action": "update",
"name": attribute.name,
"valueOrValues": (() => {
const isChecked = event.target.checked;
if (valueOrValues instanceof Array) {
const newValues = [...valueOrValues];
if (isChecked) {
newValues.push(option);
} else {
newValues.splice(newValues.indexOf(option), 1);
}
return newValues;
}
return event.target.checked ? option : "";
})()
})
}
onBlur={() =>
formValidationDispatch({
"action": "focus lost",
"name": attribute.name,
"fieldIndex": undefined
})
}
/>
<label
htmlFor={`${attribute.name}-${option}`}
className={`${classLabel}${attribute.readOnly ? ` ${getClassName("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
>
{advancedMsg(option)}
</label>
</div>
))}
</>
);
}
function TextareaTag(props: InputFiledByTypeProps) {
const { attribute, formValidationDispatch, getClassName, displayableErrors, valueOrValues } = props;
assert(typeof valueOrValues === "string");
const value = valueOrValues;
return (
<textarea
id={attribute.name}
name={attribute.name}
className={getClassName("kcInputClass")}
aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly}
cols={attribute.annotations.inputTypeCols === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeCols}`)}
rows={attribute.annotations.inputTypeRows === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeRows}`)}
maxLength={attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`)}
value={value}
onChange={event =>
formValidationDispatch({
"action": "update",
"name": attribute.name,
"valueOrValues": event.target.value
})
}
onBlur={() =>
formValidationDispatch({
"action": "focus lost",
"name": attribute.name,
"fieldIndex": undefined
})
}
/>
);
}
function SelectTag(props: InputFiledByTypeProps) {
const { attribute, formValidationDispatch, getClassName, displayableErrors, i18n, valueOrValues } = props;
const { advancedMsg } = i18n;
const isMultiple = attribute.annotations.inputType === "multiselect";
return (
<select
id={attribute.name}
name={attribute.name}
className={getClassName("kcInputClass")}
aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly}
multiple={isMultiple}
size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)}
value={valueOrValues}
onChange={event =>
formValidationDispatch({
"action": "update",
"name": attribute.name,
"valueOrValues": (() => {
if (isMultiple) {
return Array.from(event.target.selectedOptions).map(option => option.value);
}
return event.target.value;
})()
})
}
onBlur={() =>
formValidationDispatch({
"action": "focus lost",
"name": attribute.name,
"fieldIndex": undefined
})
}
>
{!isMultiple && <option value=""></option>}
{(() => {
const options = (() => {
walk: {
const { inputOptionsFromValidation } = attribute.annotations;
assert(typeof inputOptionsFromValidation === "string");
if (inputOptionsFromValidation === undefined) {
break walk;
}
const validator = (attribute.validators as Record<string, { options?: string[] }>)[inputOptionsFromValidation];
if (validator === undefined) {
break walk;
}
if (validator.options === undefined) {
break walk;
}
return validator.options;
}
return attribute.validators.options?.options ?? [];
})();
return options.map(option => (
<option key={option} value={option}>
{(() => {
if (attribute.annotations.inputOptionLabels !== undefined) {
const { inputOptionLabels } = attribute.annotations;
return advancedMsg(inputOptionLabels[option] ?? option);
}
if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) {
return advancedMsg(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
}
return option;
})()}
</option>
));
})()}
</select>
);
}

View File

@ -28,11 +28,10 @@ export type GenericI18n<MessageKey extends string> = {
*/
currentLanguageTag: string;
/**
* To call when the user switch language.
* This will cause the page to be reloaded,
* on next load currentLanguageTag === newLanguageTag
* Redirect to this url to change the language.
* After reload currentLanguageTag === newLanguageTag
*/
changeLocale: (newLanguageTag: string) => never;
getChangeLocalUrl: (newLanguageTag: string) => string;
/**
* e.g. "en" => "English", "fr" => "Français", ...
*
@ -104,7 +103,7 @@ export function createUseI18n<ExtraMessageKey extends string = never>(extraMessa
} as any
}),
currentLanguageTag,
"changeLocale": newLanguageTag => {
"getChangeLocalUrl": newLanguageTag => {
const { locale } = kcContext;
assert(locale !== undefined, "Internationalization not enabled");
@ -113,9 +112,7 @@ export function createUseI18n<ExtraMessageKey extends string = never>(extraMessa
assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`);
window.location.href = targetSupportedLocale.url;
assert(false, "never");
return targetSupportedLocale.url;
},
"labelBySupportedLanguageTag": Object.fromEntries(
(kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])
@ -212,7 +209,9 @@ const keycloakifyExtraMessages = {
"shouldMatchPattern": "Pattern should match: `/{0}/`",
"mustBeAnInteger": "Must be an integer",
"notAValidOption": "Not a valid option",
"selectAnOption": "Select an option"
"selectAnOption": "Select an option",
"remove": "Remove",
"addValue": "Add value"
},
"fr": {
/* spell-checker: disable */
@ -225,7 +224,9 @@ const keycloakifyExtraMessages = {
"logoutConfirmTitle": "Déconnexion",
"logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
"doLogout": "Se déconnecter",
"selectAnOption": "Sélectionner une option"
"selectAnOption": "Sélectionner une option",
"remove": "Supprimer",
"addValue": "Ajouter une valeur"
/* spell-checker: enable */
}
};

View File

@ -13,7 +13,6 @@ type ExtractAfterStartingWith<Prefix extends string, StrEnum> = StrEnum extends
export type KcContext =
| KcContext.Login
| KcContext.Register
| KcContext.RegisterUserProfile
| KcContext.Info
| KcContext.Error
| KcContext.LoginResetPassword
@ -24,6 +23,7 @@ export type KcContext =
| KcContext.LoginOtp
| KcContext.LoginUsername
| KcContext.WebauthnAuthenticate
| KcContext.WebauthnRegister
| KcContext.LoginPassword
| KcContext.LoginUpdatePassword
| KcContext.LoginUpdateProfile
@ -32,11 +32,21 @@ export type KcContext =
| KcContext.LoginPageExpired
| KcContext.LoginConfigTotp
| KcContext.LogoutConfirm
| KcContext.UpdateUserProfile
| KcContext.IdpReviewUserProfile
| KcContext.UpdateEmail
| KcContext.SelectAuthenticator
| KcContext.SamlPostForm;
| KcContext.SamlPostForm
| KcContext.DeleteCredential
| KcContext.Code
| KcContext.DeleteAccountConfirm
| KcContext.FrontchannelLogout
| KcContext.LoginRecoveryAuthnCodeConfig
| KcContext.LoginRecoveryAuthnCodeInput
| KcContext.LoginResetOtp
| KcContext.LoginX509Info
| KcContext.WebauthnError;
assert<KcContext["themeType"] extends ThemeType ? true : false>();
export declare namespace KcContext {
export type Common = {
@ -50,6 +60,7 @@ export declare namespace KcContext {
resourcesCommonPath: string;
loginRestartFlowUrl: string;
loginUrl: string;
ssoLoginInOtherTabsUrl: string;
};
realm: {
name: string;
@ -100,7 +111,7 @@ export declare namespace KcContext {
* @param fields
* @return boolean
*/
existsError: (fieldName: string) => boolean;
existsError: (fieldName: string, ...otherFiledNames: string[]) => boolean;
/**
* Get message for given field.
*
@ -115,8 +126,15 @@ export declare namespace KcContext {
* @return boolean
*/
exists: (fieldName: string) => boolean;
getFirstError: (...fieldNames: string[]) => string;
};
properties: Record<string, string | undefined>;
authenticationSession?: {
authSessionId: string;
tabId: string;
ssoLoginInOtherTabsUrl: string;
};
};
export type SamlPostForm = Common & {
@ -148,7 +166,7 @@ export declare namespace KcContext {
registrationDisabled: boolean;
login: {
username?: string;
rememberMe?: string;
rememberMe?: string; // "on" | undefined
password?: string;
};
usernameHidden?: boolean;
@ -159,52 +177,28 @@ export declare namespace KcContext {
alias: string;
providerId: string;
displayName: string;
iconClasses?: string;
}[];
};
};
export type Register = RegisterUserProfile.CommonWithLegacy & {
export type Register = Common & {
pageId: "register.ftl";
register: {
formData: {
firstName?: string;
displayName?: string;
lastName?: string;
email?: string;
username?: string;
};
profile: UserProfile;
url: {
registrationAction: string;
};
passwordRequired: boolean;
recaptchaRequired: boolean;
recaptchaSiteKey?: string;
/**
* Theses values are added by: https://github.com/jcputney/keycloak-theme-additional-info-extension
* A Keycloak Java extension used as dependency in Keycloakify.
*/
passwordPolicies?: PasswordPolicies;
termsAcceptanceRequired?: boolean;
};
export type RegisterUserProfile = RegisterUserProfile.CommonWithLegacy & {
pageId: "register-user-profile.ftl";
profile: {
context: "REGISTRATION_PROFILE";
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
};
};
export namespace RegisterUserProfile {
export type CommonWithLegacy = Common & {
url: {
registrationAction: string;
};
passwordRequired: boolean;
recaptchaRequired: boolean;
recaptchaSiteKey?: string;
social: {
displayInfo: boolean;
providers?: {
loginUrl: string;
alias: string;
providerId: string;
displayName: string;
}[];
};
};
}
export type Info = Common & {
pageId: "info.ftl";
messageHeader?: string;
@ -223,16 +217,21 @@ export declare namespace KcContext {
baseUrl?: string;
};
message: NonNullable<Common["message"]>;
skipLink?: boolean;
};
export type LoginResetPassword = Common & {
pageId: "login-reset-password.ftl";
realm: {
loginWithEmailAllowed: boolean;
duplicateEmailsAllowed: boolean;
};
url: {
loginResetCredentialsUrl: string;
};
auth: {
attemptedUsername?: string;
};
};
export type LoginVerifyEmail = Common & {
@ -272,6 +271,7 @@ export declare namespace KcContext {
client: string;
clientScopesRequested: {
consentScreenText: string;
dynamicScopeParameter?: string;
}[];
};
url: {
@ -282,7 +282,11 @@ export declare namespace KcContext {
export type LoginOtp = Common & {
pageId: "login-otp.ftl";
otpLogin: {
userOtpCredentials: { id: string; userLabel: string }[];
userOtpCredentials: {
id: string;
userLabel: string;
}[];
selectedCredentialId?: string;
};
};
@ -305,15 +309,7 @@ export declare namespace KcContext {
rememberMe?: string;
};
usernameHidden?: boolean;
social: {
displayInfo: boolean;
providers?: {
loginUrl: string;
alias: string;
providerId: string;
displayName: string;
}[];
};
social: Login["social"];
};
export type LoginPassword = Common & {
@ -334,9 +330,6 @@ export declare namespace KcContext {
social: {
displayInfo: boolean;
};
login: {
password?: string;
};
};
export type WebauthnAuthenticate = Common & {
@ -355,6 +348,14 @@ export declare namespace KcContext {
displayInfo: boolean;
};
login: {};
realm: {
password: boolean;
registrationAllowed: boolean;
};
registrationDisabled?: boolean;
url: {
registrationUrl?: string;
};
};
export namespace WebauthnAuthenticate {
@ -362,27 +363,33 @@ export declare namespace KcContext {
credentialId: string;
transports: {
iconClass: string;
displayNameProperties: MessageKey[];
displayNameProperties?: MessageKey[];
};
label: string;
createdAt: string;
};
}
export type LoginUpdatePassword = Common & {
pageId: "login-update-password.ftl";
export type WebauthnRegister = Common & {
pageId: "webauthn-register.ftl";
challenge: string;
userid: string;
username: string;
signatureAlgorithms: string[];
rpEntityName: string;
rpId: string;
attestationConveyancePreference: string;
authenticatorAttachment: string;
requireResidentKey: string;
userVerificationRequirement: string;
createTimeout: number;
excludeCredentialIds: string;
isSetRetry?: boolean;
isAppInitiatedAction?: boolean;
};
export type LoginUpdateProfile = Common & {
pageId: "login-update-profile.ftl";
user: {
editUsernameAllowed: boolean;
username?: string;
email?: string;
firstName?: string;
lastName?: string;
};
export type LoginUpdatePassword = Common & {
pageId: "login-update-password.ftl";
};
export type LoginIdpLinkConfirm = Common & {
@ -412,6 +419,7 @@ export declare namespace KcContext {
algorithm: "HmacSHA1" | "HmacSHA256" | "HmacSHA512";
digits: number;
lookAheadWindow: number;
getAlgorithmKey: () => string;
} & (
| {
type: "totp";
@ -444,28 +452,19 @@ export declare namespace KcContext {
};
};
export type UpdateUserProfile = Common & {
pageId: "update-user-profile.ftl";
profile: {
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
};
export type LoginUpdateProfile = Common & {
pageId: "login-update-profile.ftl";
profile: UserProfile;
};
export type IdpReviewUserProfile = Common & {
pageId: "idp-review-user-profile.ftl";
profile: {
context: "IDP_REVIEW";
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
};
profile: UserProfile;
};
export type UpdateEmail = Common & {
pageId: "update-email.ftl";
email: {
value?: string;
};
profile: UserProfile;
};
export type SelectAuthenticator = Common & {
@ -500,20 +499,124 @@ export declare namespace KcContext {
| "kcAuthenticatorWebAuthnPasswordlessClass";
};
}
export type DeleteCredential = Common & {
pageId: "delete-credential.ftl";
credentialLabel: string;
};
export type Code = Common & {
pageId: "code.ftl";
code: {
success: boolean;
code?: string;
error?: string;
};
};
export type DeleteAccountConfirm = Common & {
pageId: "delete-account-confirm.ftl";
triggered_from_aia: boolean;
};
export type FrontchannelLogout = Common & {
pageId: "frontchannel-logout.ftl";
logout: {
clients: {
name: string;
frontChannelLogoutUrl: string;
}[];
logoutRedirectUri?: string;
};
};
export type LoginRecoveryAuthnCodeConfig = Common & {
pageId: "login-recovery-authn-code-config.ftl";
recoveryAuthnCodesConfigBean: {
generatedRecoveryAuthnCodesList: string[];
generatedRecoveryAuthnCodesAsString: string;
generatedAt: number;
};
};
export type LoginRecoveryAuthnCodeInput = Common & {
pageId: "login-recovery-authn-code-input.ftl";
recoveryAuthnCodesInputBean: {
codeNumber: number;
};
};
export type LoginResetOtp = Common & {
pageId: "login-reset-otp.ftl";
configuredOtpCredentials: {
userOtpCredentials: {
id: string;
userLabel: string;
}[];
selectedCredentialId: string;
};
};
export type LoginX509Info = Common & {
pageId: "login-x509-info.ftl";
x509: {
formData: {
subjectDN?: string;
isUserEnabled?: boolean;
username?: string;
};
};
};
export type WebauthnError = Common & {
pageId: "webauthn-error.ftl";
isAppInitiatedAction?: boolean;
};
}
export type UserProfile = {
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
html5DataAnnotations?: Record<string, string>;
};
export type Attribute = {
name: string;
displayName?: string;
required: boolean;
value?: string;
group?: string;
groupDisplayHeader?: string;
groupDisplayDescription?: string;
values?: string[];
group?: {
html5DataAnnotations: Record<string, string>;
displayHeader?: string;
name: string;
displayDescription?: string;
};
html5DataAnnotations?: {
kcNumberFormat?: string;
kcNumberUnFormat?: string;
};
readOnly: boolean;
validators: Validators;
annotations: Record<string, string>;
groupAnnotations: Record<string, string>;
annotations: {
inputType?: string;
inputTypeSize?: `${number}` | number;
inputOptionsFromValidation?: string;
inputOptionLabels?: Record<string, string | undefined>;
inputOptionLabelsI18nPrefix?: string;
inputTypeCols?: `${number}` | number;
inputTypeRows?: `${number}` | number;
inputTypeMaxlength?: `${number}` | number;
inputHelperTextBefore?: string;
inputHelperTextAfter?: string;
inputTypePlaceholder?: string;
inputTypePattern?: string;
inputTypeMinlength?: `${number}` | number;
inputTypeMax?: string;
inputTypeMin?: string;
inputTypeStep?: string;
};
multivalued?: boolean;
autocomplete?:
| "on"
| "off"
@ -573,31 +676,28 @@ export type Attribute = {
export type Validators = Partial<{
length: Validators.DoIgnoreEmpty & Validators.Range;
double: Validators.DoIgnoreEmpty & Validators.Range;
integer: Validators.DoIgnoreEmpty & Validators.Range;
email: Validators.DoIgnoreEmpty;
pattern: Validators.DoIgnoreEmpty & Validators.ErrorMessage & { pattern: string };
options: Validators.Options;
multivalued: Validators.DoIgnoreEmpty & Validators.Range;
// NOTE: Following are the validators for which we don't implement client side validation yet
// or for which the validation can't be performed on the client side.
/*
double: Validators.DoIgnoreEmpty & Validators.Range;
"up-immutable-attribute": {};
"up-attribute-required-by-metadata-value": {};
"up-username-has-value": {};
"up-duplicate-username": {};
"up-username-mutation": {};
"up-email-exists-as-username": {};
"up-blank-attribute-value": Validators.ErrorMessage & {
"fail-on-null": boolean;
};
"up-blank-attribute-value": Validators.ErrorMessage & { "fail-on-null": boolean; };
"up-duplicate-email": {};
"local-date": Validators.DoIgnoreEmpty;
pattern: Validators.DoIgnoreEmpty & Validators.ErrorMessage & { pattern: string };
"person-name-prohibited-characters": Validators.DoIgnoreEmpty & Validators.ErrorMessage;
uri: Validators.DoIgnoreEmpty;
"username-prohibited-characters": Validators.DoIgnoreEmpty & Validators.ErrorMessage;
/** Made up validator that only exists in Keycloakify */
_compareToOther: Validators.DoIgnoreEmpty &
Validators.ErrorMessage & {
name: string;
shouldBe: "equal" | "different";
};
options: Validators.Options;
*/
}>;
export declare namespace Validators {
@ -610,9 +710,8 @@ export declare namespace Validators {
};
export type Range = {
/** "0", "1", "2"... yeah I know, don't tell me */
min?: `${number}`;
max?: `${number}`;
min?: `${number}` | number;
max?: `${number}` | number;
};
export type Options = {
options: string[];
@ -630,4 +729,19 @@ export declare namespace Validators {
assert<Equals<OnlyInExpected, never>>();
}
assert<KcContext["themeType"] extends ThemeType ? true : false>();
export type PasswordPolicies = {
/** The minimum length of the password */
length?: number;
/** The minimum number of digits required in the password */
digits?: number;
/** The minimum number of lowercase characters required in the password */
lowerCase?: number;
/** The minimum number of uppercase characters required in the password */
upperCase?: number;
/** The minimum number of special characters required in the password */
specialChars?: number;
/** Whether the password can be the username */
notUsername?: boolean;
/** Whether the password can be the email address */
notEmail?: boolean;
};

View File

@ -86,25 +86,17 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
"source": partialKcContextCustomMock
});
if (
partialKcContextCustomMock.pageId === "register-user-profile.ftl" ||
partialKcContextCustomMock.pageId === "update-user-profile.ftl" ||
partialKcContextCustomMock.pageId === "idp-review-user-profile.ftl"
) {
assert(
kcContextDefaultMock?.pageId === "register-user-profile.ftl" ||
kcContextDefaultMock?.pageId === "update-user-profile.ftl" ||
kcContextDefaultMock?.pageId === "idp-review-user-profile.ftl"
);
if ("profile" in partialKcContextCustomMock) {
assert(kcContextDefaultMock !== undefined && "profile" in kcContextDefaultMock);
const { attributes } = kcContextDefaultMock.profile;
id<KcContext.RegisterUserProfile>(kcContext).profile.attributes = [];
id<KcContext.RegisterUserProfile>(kcContext).profile.attributesByName = {};
id<KcContext.Register>(kcContext).profile.attributes = [];
id<KcContext.Register>(kcContext).profile.attributesByName = {};
const partialAttributes = [
...((partialKcContextCustomMock as DeepPartial<KcContext.RegisterUserProfile>).profile?.attributes ?? [])
].filter(exclude(undefined));
const partialAttributes = [...((partialKcContextCustomMock as DeepPartial<KcContext.Register>).profile?.attributes ?? [])].filter(
exclude(undefined)
);
attributes.forEach(attribute => {
const partialAttribute = partialAttributes.find(({ name }) => name === attribute.name);
@ -125,8 +117,8 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
});
}
id<KcContext.RegisterUserProfile>(kcContext).profile.attributes.push(augmentedAttribute);
id<KcContext.RegisterUserProfile>(kcContext).profile.attributesByName[augmentedAttribute.name] = augmentedAttribute;
id<KcContext.Register>(kcContext).profile.attributes.push(augmentedAttribute);
id<KcContext.Register>(kcContext).profile.attributesByName[augmentedAttribute.name] = augmentedAttribute;
});
partialAttributes
@ -136,8 +128,8 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
assert(name !== undefined, "If you define a mock attribute it must have at least a name");
id<KcContext.RegisterUserProfile>(kcContext).profile.attributes.push(partialAttribute as any);
id<KcContext.RegisterUserProfile>(kcContext).profile.attributesByName[name] = partialAttribute as any;
id<KcContext.Register>(kcContext).profile.attributes.push(partialAttribute as any);
id<KcContext.Register>(kcContext).profile.attributesByName[name] = partialAttribute as any;
});
}
}

View File

@ -9,22 +9,15 @@ import { BASE_URL } from "keycloakify/lib/BASE_URL";
const attributes: Attribute[] = [
{
"validators": {
"username-prohibited-characters": {
"ignore.empty.value": true
},
"up-username-has-value": {},
"length": {
"ignore.empty.value": true,
"min": "3",
"max": "255"
},
"up-duplicate-username": {},
"up-username-mutation": {}
}
},
"displayName": "${username}",
"annotations": {},
"required": true,
"groupAnnotations": {},
"autocomplete": "username",
"readOnly": false,
"name": "username",
@ -32,16 +25,10 @@ const attributes: Attribute[] = [
},
{
"validators": {
"up-email-exists-as-username": {},
"length": {
"max": "255",
"ignore.empty.value": true
},
"up-blank-attribute-value": {
"error-message": "missingEmailMessage",
"fail-on-null": false
},
"up-duplicate-email": {},
"email": {
"ignore.empty.value": true
},
@ -53,7 +40,6 @@ const attributes: Attribute[] = [
"displayName": "${email}",
"annotations": {},
"required": true,
"groupAnnotations": {},
"autocomplete": "email",
"readOnly": false,
"name": "email"
@ -63,17 +49,11 @@ const attributes: Attribute[] = [
"length": {
"max": "255",
"ignore.empty.value": true
},
"person-name-prohibited-characters": {
"ignore.empty.value": true
},
"up-immutable-attribute": {},
"up-attribute-required-by-metadata-value": {}
}
},
"displayName": "${firstName}",
"annotations": {},
"required": true,
"groupAnnotations": {},
"readOnly": false,
"name": "firstName"
},
@ -82,17 +62,11 @@ const attributes: Attribute[] = [
"length": {
"max": "255",
"ignore.empty.value": true
},
"person-name-prohibited-characters": {
"ignore.empty.value": true
},
"up-immutable-attribute": {},
"up-attribute-required-by-metadata-value": {}
}
},
"displayName": "${lastName}",
"annotations": {},
"required": true,
"groupAnnotations": {},
"readOnly": false,
"name": "lastName"
}
@ -112,7 +86,8 @@ export const kcContextCommonMock: KcContext.Common = {
resourcesPath,
"resourcesCommonPath": `${resourcesPath}/${resources_common}`,
"loginRestartFlowUrl": "/auth/realms/myrealm/login-actions/restart?client_id=account&tab_id=HoAx28ja4xg",
"loginUrl": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg"
"loginUrl": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg",
"ssoLoginInOtherTabsUrl": "/auth/realms/myrealm/login-actions/switch?client_id=account&tab_id=HoAx28ja4xg"
},
"realm": {
"name": "myrealm",
@ -126,8 +101,9 @@ export const kcContextCommonMock: KcContext.Common = {
return undefined;
},
"existsError": () => false,
"get": key => `Fake error for ${key}`,
"exists": () => false
"get": fieldName => `Fake error for ${fieldName}`,
"exists": () => false,
"getFirstError": fieldName => `Fake error for ${fieldName}`
},
"locale": {
"supported": [
@ -237,126 +213,7 @@ export const kcContextCommonMock: KcContext.Common = {
},
"scripts": [],
"isAppInitiatedAction": false,
"properties": {
"kcLogoIdP-facebook": "fa fa-facebook",
"parent": "keycloak",
"kcAuthenticatorOTPClass": "fa fa-mobile list-view-pf-icon-lg",
"kcLogoIdP-bitbucket": "fa fa-bitbucket",
"kcAuthenticatorWebAuthnClass": "fa fa-key list-view-pf-icon-lg",
"kcWebAuthnDefaultIcon": "pficon pficon-key",
"kcLogoIdP-stackoverflow": "fa fa-stack-overflow",
"kcSelectAuthListItemClass": "pf-l-stack__item select-auth-box-parent pf-l-split",
"kcLogoIdP-microsoft": "fa fa-windows",
"kcLocaleItemClass": "pf-c-dropdown__menu-item",
"kcLoginOTPListItemHeaderClass": "pf-c-tile__header",
"kcLoginOTPListItemIconBodyClass": "pf-c-tile__icon",
"kcInputHelperTextAfterClass": "pf-c-form__helper-text pf-c-form__helper-text-after",
"kcFormClass": "form-horizontal",
"kcSelectAuthListClass": "pf-l-stack select-auth-container",
"kcInputClassRadioCheckboxLabelDisabled": "pf-m-disabled",
"kcSelectAuthListItemIconClass": "pf-l-split__item select-auth-box-icon",
"kcRecoveryCodesWarning": "kc-recovery-codes-warning",
"kcFormSettingClass": "login-pf-settings",
"kcWebAuthnBLE": "fa fa-bluetooth-b",
"kcInputWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcSelectAuthListItemArrowIconClass": "fa fa-angle-right fa-lg",
"meta": "viewport==width=device-width,initial-scale=1",
"styles": "css/login.css css/tile.css",
"kcFeedbackAreaClass": "col-md-12",
"kcLogoIdP-google": "fa fa-google",
"kcCheckLabelClass": "pf-c-check__label",
"kcSelectAuthListItemFillClass": "pf-l-split__item pf-m-fill",
"kcAuthenticatorDefaultClass": "fa fa-list list-view-pf-icon-lg",
"kcLogoIdP-gitlab": "fa fa-gitlab",
"kcFormAreaClass": "col-sm-10 col-sm-offset-1 col-md-8 col-md-offset-2 col-lg-8 col-lg-offset-2",
"kcFormButtonsClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcInputClassRadioLabel": "pf-c-radio__label",
"kcAuthenticatorWebAuthnPasswordlessClass": "fa fa-key list-view-pf-icon-lg",
"kcSelectAuthListItemHeadingClass": "pf-l-stack__item select-auth-box-headline pf-c-title",
"kcInfoAreaClass": "col-xs-12 col-sm-4 col-md-4 col-lg-5 details",
"kcLogoLink": "http://www.keycloak.org",
"kcContainerClass": "container-fluid",
"kcSelectAuthListItemTitle": "select-auth-box-paragraph",
"kcHtmlClass": "login-pf",
"kcLoginOTPListItemTitleClass": "pf-c-tile__title",
"locales": "ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN",
"serviceTitle": "CodeGouv",
"kcLogoIdP-openshift-v4": "pf-icon pf-icon-openshift",
"kcWebAuthnUnknownIcon": "pficon pficon-key unknown-transport-class",
"kcFormSocialAccountNameClass": "kc-social-provider-name",
"kcLogoIdP-openshift-v3": "pf-icon pf-icon-openshift",
"kcLoginOTPListInputClass": "pf-c-tile__input",
"kcWebAuthnUSB": "fa fa-usb",
"kcInputClassRadio": "pf-c-radio",
"kcWebAuthnKeyIcon": "pficon pficon-key",
"kcFeedbackInfoIcon": "fa fa-fw fa-info-circle",
"kcCommonLogoIdP": "kc-social-provider-logo kc-social-gray",
"stylesCommon":
"web_modules/@patternfly/react-core/dist/styles/base.css web_modules/@patternfly/react-core/dist/styles/app.css node_modules/patternfly/dist/css/patternfly.min.css node_modules/patternfly/dist/css/patternfly-additions.min.css lib/pficon/pficon.css",
"kcRecoveryCodesActions": "kc-recovery-codes-actions",
"kcFormGroupHeader": "pf-c-form__group",
"kcFormSocialAccountSectionClass": "kc-social-section kc-social-gray",
"kcLogoIdP-instagram": "fa fa-instagram",
"kcAlertClass": "pf-c-alert pf-m-inline",
"kcHeaderClass": "login-pf-page-header",
"kcLabelWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcFormSocialAccountLinkClass": "pf-c-login__main-footer-links-item-link",
"kcLocaleMainClass": "pf-c-dropdown",
"kcTextareaClass": "form-control",
"kcButtonBlockClass": "pf-m-block",
"kcButtonClass": "pf-c-button",
"kcWebAuthnNFC": "fa fa-wifi",
"kcLocaleClass": "col-xs-12 col-sm-1",
"kcInputClassCheckboxInput": "pf-c-check__input",
"kcFeedbackErrorIcon": "fa fa-fw fa-exclamation-circle",
"kcInputLargeClass": "input-lg",
"kcInputErrorMessageClass": "pf-c-form__helper-text pf-m-error required kc-feedback-text",
"kcRecoveryCodesList": "kc-recovery-codes-list",
"kcFormSocialAccountListClass": "pf-c-login__main-footer-links kc-social-links",
"kcAlertTitleClass": "pf-c-alert__title kc-feedback-text",
"kcAuthenticatorPasswordClass": "fa fa-unlock list-view-pf-icon-lg",
"kcCheckInputClass": "pf-c-check__input",
"kcLogoIdP-linkedin": "fa fa-linkedin",
"kcLogoIdP-twitter": "fa fa-twitter",
"kcFeedbackWarningIcon": "fa fa-fw fa-exclamation-triangle",
"kcResetFlowIcon": "pficon pficon-arrow fa",
"kcSelectAuthListItemIconPropertyClass": "fa-2x select-auth-box-icon-properties",
"kcFeedbackSuccessIcon": "fa fa-fw fa-check-circle",
"kcLoginOTPListClass": "pf-c-tile",
"kcSrOnlyClass": "sr-only",
"kcFormSocialAccountListGridClass": "pf-l-grid kc-social-grid",
"kcButtonDefaultClass": "btn-default",
"kcFormGroupErrorClass": "has-error",
"kcSelectAuthListItemDescriptionClass": "pf-l-stack__item select-auth-box-desc",
"kcSelectAuthListItemBodyClass": "pf-l-split__item pf-l-stack",
"import": "common/keycloak",
"kcWebAuthnInternal": "pficon pficon-key",
"kcSelectAuthListItemArrowClass": "pf-l-split__item select-auth-box-arrow",
"kcCheckClass": "pf-c-check",
"kcContentClass": "col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3",
"kcLogoClass": "login-pf-brand",
"kcLoginOTPListItemIconClass": "fa fa-mobile",
"kcLoginClass": "login-pf-page",
"kcSignUpClass": "login-pf-signup",
"kcButtonLargeClass": "btn-lg",
"kcFormCardClass": "card-pf",
"kcLocaleListClass": "pf-c-dropdown__menu pf-m-align-right",
"kcInputClass": "pf-c-form-control",
"kcFormGroupClass": "form-group",
"kcLogoIdP-paypal": "fa fa-paypal",
"kcInputClassCheckbox": "pf-c-check",
"kcRecoveryCodesConfirmation": "kc-recovery-codes-confirmation",
"kcInputClassRadioInput": "pf-c-radio__input",
"kcFormSocialAccountListButtonClass": "pf-c-button pf-m-control pf-m-block kc-social-item kc-social-gray",
"kcInputClassCheckboxLabel": "pf-c-check__label",
"kcFormOptionsClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcFormHeaderClass": "login-pf-header",
"kcFormSocialAccountGridItem": "pf-l-grid__item",
"kcButtonPrimaryClass": "pf-m-primary",
"kcInputHelperTextBeforeClass": "pf-c-form__helper-text pf-c-form__helper-text-before",
"kcLogoIdP-github": "fa fa-github",
"kcLabelClass": "pf-c-form__label pf-c-form__label-text"
}
"properties": {}
};
const loginUrl = {
@ -388,42 +245,25 @@ export const kcContextMocks = [
"login": {},
"registrationDisabled": false
}),
...(() => {
const registerCommon: KcContext.RegisterUserProfile.CommonWithLegacy = {
...kcContextCommonMock,
"url": {
...loginUrl,
"registrationAction":
"http://localhost:8080/auth/realms/myrealm/login-actions/registration?session_code=gwZdUeO7pbYpFTRxiIxRg_QtzMbtFTKrNu6XW_f8asM&execution=12146ce0-b139-4bbd-b25b-0eccfee6577e&client_id=account&tab_id=uS8lYfebLa0"
},
"scripts": [],
"isAppInitiatedAction": false,
"passwordRequired": true,
"recaptchaRequired": false,
"social": {
"displayInfo": true
}
};
return [
id<KcContext.Register>({
"pageId": "register.ftl",
...registerCommon,
"register": {
"formData": {}
}
}),
id<KcContext.RegisterUserProfile>({
"pageId": "register-user-profile.ftl",
...registerCommon,
"profile": {
"context": "REGISTRATION_PROFILE" as const,
attributes,
attributesByName
}
})
];
})(),
id<KcContext.Register>({
...kcContextCommonMock,
"url": {
...loginUrl,
"registrationAction":
"http://localhost:8080/auth/realms/myrealm/login-actions/registration?session_code=gwZdUeO7pbYpFTRxiIxRg_QtzMbtFTKrNu6XW_f8asM&execution=12146ce0-b139-4bbd-b25b-0eccfee6577e&client_id=account&tab_id=uS8lYfebLa0"
},
"isAppInitiatedAction": false,
"passwordRequired": true,
"recaptchaRequired": false,
"pageId": "register.ftl",
"profile": {
attributes,
attributesByName
},
"scripts": [
//"https://www.google.com/recaptcha/api.js"
]
}),
id<KcContext.Info>({
...kcContextCommonMock,
"pageId": "info.ftl",
@ -455,9 +295,11 @@ export const kcContextMocks = [
"pageId": "login-reset-password.ftl",
"realm": {
...kcContextCommonMock.realm,
"loginWithEmailAllowed": false
"loginWithEmailAllowed": false,
"duplicateEmailsAllowed": false
},
url: loginUrl
"url": loginUrl,
"auth": {}
}),
id<KcContext.LoginVerifyEmail>({
...kcContextCommonMock,
@ -534,8 +376,7 @@ export const kcContextMocks = [
},
"social": {
"displayInfo": false
},
"login": {}
}
}),
id<KcContext.WebauthnAuthenticate>({
...kcContextCommonMock,
@ -545,7 +386,9 @@ export const kcContextMocks = [
"authenticators": []
},
"realm": {
...kcContextCommonMock.realm
...kcContextCommonMock.realm,
"password": true,
"registrationAllowed": true
},
"challenge": "",
"userVerification": "not specified",
@ -560,18 +403,14 @@ export const kcContextMocks = [
}),
id<KcContext.LoginUpdatePassword>({
...kcContextCommonMock,
"pageId": "login-update-password.ftl",
"username": "anUsername"
"pageId": "login-update-password.ftl"
}),
id<KcContext.LoginUpdateProfile>({
...kcContextCommonMock,
"pageId": "login-update-profile.ftl",
"user": {
"editUsernameAllowed": true,
"username": "anUsername",
"email": "foo@example.com",
"firstName": "aFirstName",
"lastName": "aLastName"
"profile": {
attributes,
attributesByName
}
}),
id<KcContext.LoginIdpLinkConfirm>({
@ -590,21 +429,22 @@ export const kcContextMocks = [
id<KcContext.LoginConfigTotp>({
...kcContextCommonMock,
"pageId": "login-config-totp.ftl",
totp: {
totpSecretEncoded: "KVVF G2BY N4YX S6LB IUYT K2LH IFYE 4SBV",
qrUrl: "#",
totpSecretQrCode:
"totp": {
"totpSecretEncoded": "KVVF G2BY N4YX S6LB IUYT K2LH IFYE 4SBV",
"qrUrl": "#",
"totpSecretQrCode":
"iVBORw0KGgoAAAANSUhEUgAAAPYAAAD2AQAAAADNaUdlAAACM0lEQVR4Xu3OIZJgOQwDUDFd2UxiurLAVnnbHw4YGDKtSiWOn4Gxf81//7r/+q8b4HfLGBZDK9d85NmNR+sB42sXvOYrN5P1DcgYYFTGfOlbzE8gzwy3euweGizw7cfdl34/GRhlkxjKNV+5AebPXPORX1JuB9x8ZfbyyD2y1krWAKsbMq1HnqQDaLfa77p4+MqvzEGSqvSAD/2IHW2yHaigR9tX3m8dDIYGcNf3f+gDpVBZbZU77zyJ6Rlcy+qoTMG887KAPD9hsh6a1Sv3gJUHGHUAxSMzj7zqDDe7Phmt2eG+8UsMxjRGm816MAO+8VMl1R1jGHOrZB/5Zo/WXAPgxixm9Mo96vDGrM1eOto8c4Ax4wF437mifOXlpiPzCnN7Y9l95NnEMxgMY9AAGA8fucH14Y1aVb6N/cqrmyh0BVht7k1e+bU8LK0Cg5vmVq9c5vHIjOfqxDIfeTraNVTwewa4wVe+SW5N+uP1qACeudUZbqGOfA6VZV750Noq2Xx3kpveV44ZelSV1V7KFHzkWyVrrlUwG0Pl9pWnoy3vsQoME6vKI69i5osVgwWzHT7zjmJtMcNUSVn1oYMd7ZodbgowZl45VG0uVuLPUr1yc79uaQBag/mqR34xhlWyHm1prplHboCWdZ4TeZjsK8+dI+jbz1C5hl65mcpgB5dhcj8+dGO+0Ko68+lD37JDD83dpDLzzK+TrQyaVwGj6pUboGV+7+AyN8An/pf84/7rv/4/1l4OCc/1BYMAAAAASUVORK5CYII=",
manualUrl: "#",
totpSecret: "G4nsI8lQagRMUchH8jEG",
otpCredentials: [],
supportedApplications: ["FreeOTP", "Google Authenticator"],
policy: {
algorithm: "HmacSHA1",
digits: 6,
lookAheadWindow: 1,
type: "totp",
period: 30
"manualUrl": "#",
"totpSecret": "G4nsI8lQagRMUchH8jEG",
"otpCredentials": [],
"supportedApplications": ["FreeOTP", "Google Authenticator"],
"policy": {
"algorithm": "HmacSHA1",
"digits": 6,
"lookAheadWindow": 1,
"type": "totp",
"period": 30,
"getAlgorithmKey": () => "SHA1"
}
}
}),
@ -622,19 +462,10 @@ export const kcContextMocks = [
},
"logoutConfirm": { "code": "123", skipLink: false }
}),
id<KcContext.UpdateUserProfile>({
...kcContextCommonMock,
"pageId": "update-user-profile.ftl",
"profile": {
attributes,
attributesByName
}
}),
id<KcContext.IdpReviewUserProfile>({
...kcContextCommonMock,
"pageId": "idp-review-user-profile.ftl",
"profile": {
context: "IDP_REVIEW",
attributes,
attributesByName
}
@ -642,8 +473,11 @@ export const kcContextMocks = [
id<KcContext.UpdateEmail>({
...kcContextCommonMock,
"pageId": "update-email.ftl",
"email": {
value: "email@example.com"
"profile": {
"attributes": attributes.filter(attribute => attribute.name === "email"),
"attributesByName": Object.fromEntries(
attributes.filter(attribute => attribute.name === "email").map(attribute => [attribute.name, attribute])
)
}
}),
id<KcContext.SelectAuthenticator>({
@ -676,6 +510,112 @@ export const kcContextMocks = [
id<KcContext.LoginPageExpired>({
...kcContextCommonMock,
pageId: "login-page-expired.ftl"
}),
id<KcContext.FrontchannelLogout>({
...kcContextCommonMock,
pageId: "frontchannel-logout.ftl",
"logout": {
"clients": [
{
"name": "myApp",
"frontChannelLogoutUrl": "#"
},
{
"name": "myApp2",
"frontChannelLogoutUrl": "#"
}
]
}
}),
id<KcContext.WebauthnRegister>({
"pageId": "webauthn-register.ftl",
...kcContextCommonMock,
"challenge": "random-challenge-string",
"userid": "user123",
"username": "johndoe",
"signatureAlgorithms": ["ES256", "RS256"],
"rpEntityName": "Example Corp",
"rpId": "example.com",
"attestationConveyancePreference": "direct",
"authenticatorAttachment": "platform",
"requireResidentKey": "required",
"userVerificationRequirement": "preferred",
"createTimeout": 60000,
"excludeCredentialIds": "credId123,credId456",
"isSetRetry": false,
"isAppInitiatedAction": true
}),
id<KcContext.DeleteCredential>({
"pageId": "delete-credential.ftl",
...kcContextCommonMock,
"credentialLabel": "myCredential"
}),
id<KcContext.Code>({
"pageId": "code.ftl",
...kcContextCommonMock,
"code": {
"success": true,
"code": "123456"
}
}),
id<KcContext.DeleteAccountConfirm>({
"pageId": "delete-account-confirm.ftl",
...kcContextCommonMock,
"triggered_from_aia": true
}),
id<KcContext.LoginRecoveryAuthnCodeConfig>({
"pageId": "login-recovery-authn-code-config.ftl",
...kcContextCommonMock,
"recoveryAuthnCodesConfigBean": {
"generatedRecoveryAuthnCodesList": ["code123", "code456", "code789"],
"generatedRecoveryAuthnCodesAsString": "code123, code456, code789",
"generatedAt": new Date().getTime()
}
}),
id<KcContext.LoginRecoveryAuthnCodeInput>({
"pageId": "login-recovery-authn-code-input.ftl",
...kcContextCommonMock,
"recoveryAuthnCodesInputBean": {
"codeNumber": 1234
}
}),
id<KcContext.LoginResetOtp>({
"pageId": "login-reset-otp.ftl",
...kcContextCommonMock,
"configuredOtpCredentials": {
"userOtpCredentials": [
{
"id": "otpId1",
"userLabel": "OTP Device 1"
},
{
"id": "otpId2",
"userLabel": "OTP Device 2"
},
{
"id": "otpId3",
"userLabel": "Backup OTP"
}
],
"selectedCredentialId": "otpId2"
}
}),
id<KcContext.LoginX509Info>({
"pageId": "login-x509-info.ftl",
...kcContextCommonMock,
"x509": {
"formData": {
"subjectDN": "CN=John Doe, O=Example Corp, C=US",
"isUserEnabled": true,
"username": "johndoe"
}
}
}),
id<KcContext.WebauthnError>({
"pageId": "webauthn-error.ftl",
...kcContextCommonMock,
"isAppInitiatedAction": true
})
];

View File

@ -5,15 +5,17 @@ import { useConst } from "keycloakify/tools/useConst";
import { useConstCallback } from "keycloakify/tools/useConstCallback";
import { assert } from "tsafe/assert";
import { Evt } from "evt";
import { useRerenderOnStateChange } from "evt/hooks/useRerenderOnStateChange";
import { KcContext } from "../kcContext";
export const evtTermMarkdown = Evt.create<string | undefined>(undefined);
const evtTermsMarkdown = Evt.create<string | undefined>(undefined);
export type KcContextLike = {
pageId: string;
locale?: {
currentLanguageTag: string;
};
termsAcceptanceRequired?: boolean;
};
assert<KcContext extends KcContextLike ? true : false>();
@ -38,12 +40,18 @@ export function useDownloadTerms(params: {
})();
useEffect(() => {
if (kcContext.pageId !== "terms.ftl") {
return;
if (kcContext.pageId === "terms.ftl" || kcContext.termsAcceptanceRequired) {
downloadTermMarkdownMemoized(kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag).then(
thermMarkdown => (evtTermsMarkdown.state = thermMarkdown)
);
}
downloadTermMarkdownMemoized(kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag).then(
thermMarkdown => (evtTermMarkdown.state = thermMarkdown)
);
}, []);
}
export function useTermsMarkdown() {
useRerenderOnStateChange(evtTermsMarkdown);
const termsMarkdown = evtTermsMarkdown.state;
return { termsMarkdown };
}

View File

@ -1,474 +0,0 @@
import "keycloakify/tools/Array.prototype.every";
import { useMemo, useReducer, Fragment } from "react";
import { id } from "tsafe/id";
import type { MessageKey } from "keycloakify/login/i18n/i18n";
import type { Attribute, Validators } from "keycloakify/login/kcContext/KcContext";
import { useConstCallback } from "keycloakify/tools/useConstCallback";
import { emailRegexp } from "keycloakify/tools/emailRegExp";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
/**
* NOTE: The attributesWithPassword returned is actually augmented with
* artificial password related attributes only if kcContext.passwordRequired === true
*/
export function useFormValidation(params: {
kcContext: {
messagesPerField: Pick<KcContext.Common["messagesPerField"], "existsError" | "get">;
profile: {
attributes: Attribute[];
};
passwordRequired?: boolean;
realm: { registrationEmailAsUsername: boolean };
};
/** NOTE: Try to avoid passing a new ref every render for better performances. */
passwordValidators?: Validators;
i18n: I18n;
}) {
const { kcContext, passwordValidators = {}, i18n } = params;
const attributesWithPassword = useMemo(
() =>
!kcContext.passwordRequired
? kcContext.profile.attributes
: (() => {
const name = kcContext.realm.registrationEmailAsUsername ? "email" : "username";
return kcContext.profile.attributes.reduce<Attribute[]>(
(prev, curr) => [
...prev,
...(curr.name !== name
? [curr]
: [
curr,
id<Attribute>({
"name": "password",
"displayName": id<`\${${MessageKey}}`>("${password}"),
"required": true,
"readOnly": false,
"validators": passwordValidators,
"annotations": {},
"groupAnnotations": {},
"autocomplete": "new-password"
}),
id<Attribute>({
"name": "password-confirm",
"displayName": id<`\${${MessageKey}}`>("${passwordConfirm}"),
"required": true,
"readOnly": false,
"validators": {
"_compareToOther": {
"name": "password",
"ignore.empty.value": true,
"shouldBe": "equal",
"error-message": id<`\${${MessageKey}}`>("${invalidPasswordConfirmMessage}")
}
},
"annotations": {},
"groupAnnotations": {},
"autocomplete": "new-password"
})
])
],
[]
);
})(),
[kcContext, passwordValidators]
);
const { getErrors } = useGetErrors({
"kcContext": {
"messagesPerField": kcContext.messagesPerField,
"profile": {
"attributes": attributesWithPassword
}
},
i18n
});
const initialInternalState = useMemo(
() =>
Object.fromEntries(
attributesWithPassword
.map(attribute => ({
attribute,
"errors": getErrors({
"name": attribute.name,
"fieldValueByAttributeName": Object.fromEntries(
attributesWithPassword.map(({ name, value }) => [name, { "value": value ?? "" }])
)
})
}))
.map(({ attribute, errors }) => [
attribute.name,
{
"value": attribute.value ?? "",
errors,
"doDisplayPotentialErrorMessages": errors.length !== 0
}
])
),
[attributesWithPassword]
);
type InternalState = typeof initialInternalState;
const [formValidationInternalState, formValidationDispatch] = useReducer(
(
state: InternalState,
params:
| {
action: "update value";
name: string;
newValue: string;
}
| {
action: "focus lost";
name: string;
}
): InternalState => ({
...state,
[params.name]: {
...state[params.name],
...(() => {
switch (params.action) {
case "focus lost":
return { "doDisplayPotentialErrorMessages": true };
case "update value":
return {
"value": params.newValue,
"errors": getErrors({
"name": params.name,
"fieldValueByAttributeName": {
...state,
[params.name]: { "value": params.newValue }
}
})
};
}
})()
}
}),
initialInternalState
);
const formValidationState = useMemo(
() => ({
"fieldStateByAttributeName": Object.fromEntries(
Object.entries(formValidationInternalState).map(([name, { value, errors, doDisplayPotentialErrorMessages }]) => [
name,
{ value, "displayableErrors": doDisplayPotentialErrorMessages ? errors : [] }
])
),
"isFormSubmittable": Object.entries(formValidationInternalState).every(
([name, { value, errors }]) =>
errors.length === 0 && (value !== "" || !attributesWithPassword.find(attribute => attribute.name === name)!.required)
)
}),
[formValidationInternalState, attributesWithPassword]
);
return {
formValidationState,
formValidationDispatch,
attributesWithPassword
};
}
/** Expect to be used in a component wrapped within a <I18nProvider> */
function useGetErrors(params: {
kcContext: {
messagesPerField: Pick<KcContext.Common["messagesPerField"], "existsError" | "get">;
profile: {
attributes: { name: string; value?: string; validators: Validators }[];
};
};
i18n: I18n;
}) {
const { kcContext, i18n } = params;
const {
messagesPerField,
profile: { attributes }
} = kcContext;
const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n;
const getErrors = useConstCallback((params: { name: string; fieldValueByAttributeName: Record<string, { value: string }> }) => {
const { name, fieldValueByAttributeName } = params;
const { value } = fieldValueByAttributeName[name];
const { value: defaultValue, validators } = attributes.find(attribute => attribute.name === name)!;
block: {
if ((defaultValue ?? "") !== value) {
break block;
}
let doesErrorExist: boolean;
try {
doesErrorExist = messagesPerField.existsError(name);
} catch {
break block;
}
if (!doesErrorExist) {
break block;
}
const errorMessageStr = messagesPerField.get(name);
return [
{
"validatorName": undefined,
errorMessageStr,
"errorMessage": <span key={0}>{errorMessageStr}</span>
}
];
}
const errors: {
errorMessage: JSX.Element;
errorMessageStr: string;
validatorName: keyof Validators | undefined;
}[] = [];
scope: {
const validatorName = "length";
const validator = validators[validatorName];
if (validator === undefined) {
break scope;
}
const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator;
if (ignoreEmptyValue && value === "") {
break scope;
}
if (max !== undefined && value.length > parseInt(max)) {
const msgArgs = ["error-invalid-length-too-long", max] as const;
errors.push({
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs),
validatorName
});
}
if (min !== undefined && value.length < parseInt(min)) {
const msgArgs = ["error-invalid-length-too-short", min] as const;
errors.push({
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs),
validatorName
});
}
}
scope: {
const validatorName = "_compareToOther";
const validator = validators[validatorName];
if (validator === undefined) {
break scope;
}
const { "ignore.empty.value": ignoreEmptyValue = false, name: otherName, shouldBe, "error-message": errorMessageKey } = validator;
if (ignoreEmptyValue && value === "") {
break scope;
}
const { value: otherValue } = fieldValueByAttributeName[otherName];
const isValid = (() => {
switch (shouldBe) {
case "different":
return otherValue !== value;
case "equal":
return otherValue === value;
}
})();
if (isValid) {
break scope;
}
const msgArg = [
errorMessageKey ??
id<MessageKey>(
(() => {
switch (shouldBe) {
case "equal":
return "shouldBeEqual";
case "different":
return "shouldBeDifferent";
}
})()
),
otherName,
name,
shouldBe
] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArg)}</Fragment>,
"errorMessageStr": advancedMsgStr(...msgArg)
});
}
scope: {
const validatorName = "pattern";
const validator = validators[validatorName];
if (validator === undefined) {
break scope;
}
const { "ignore.empty.value": ignoreEmptyValue = false, pattern, "error-message": errorMessageKey } = validator;
if (ignoreEmptyValue && value === "") {
break scope;
}
if (new RegExp(pattern).test(value)) {
break scope;
}
const msgArgs = [errorMessageKey ?? id<MessageKey>("shouldMatchPattern"), pattern] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArgs)}</Fragment>,
"errorMessageStr": advancedMsgStr(...msgArgs)
});
}
scope: {
if ([...errors].reverse()[0]?.validatorName === "pattern") {
break scope;
}
const validatorName = "email";
const validator = validators[validatorName];
if (validator === undefined) {
break scope;
}
const { "ignore.empty.value": ignoreEmptyValue = false } = validator;
if (ignoreEmptyValue && value === "") {
break scope;
}
if (emailRegexp.test(value)) {
break scope;
}
const msgArgs = [id<MessageKey>("invalidEmailMessage")] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs)
});
}
scope: {
const validatorName = "integer";
const validator = validators[validatorName];
if (validator === undefined) {
break scope;
}
const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator;
if (ignoreEmptyValue && value === "") {
break scope;
}
const intValue = parseInt(value);
if (isNaN(intValue)) {
const msgArgs = ["mustBeAnInteger"] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs)
});
break scope;
}
if (max !== undefined && intValue > parseInt(max)) {
const msgArgs = ["error-number-out-of-range-too-big", max] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs)
});
break scope;
}
if (min !== undefined && intValue < parseInt(min)) {
const msgArgs = ["error-number-out-of-range-too-small", min] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs)
});
break scope;
}
}
scope: {
const validatorName = "options";
const validator = validators[validatorName];
if (validator === undefined) {
break scope;
}
if (value === "") {
break scope;
}
if (validator.options.indexOf(value) >= 0) {
break scope;
}
const msgArgs = [id<MessageKey>("notAValidOption")] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArgs)}</Fragment>,
"errorMessageStr": advancedMsgStr(...msgArgs)
});
}
//TODO: Implement missing validators.
return errors;
});
return { getErrors };
}

View File

@ -4,100 +4,129 @@ import type { ClassKey } from "keycloakify/login/TemplateProps";
export const { useGetClassName } = createUseClassName<ClassKey>({
"defaultClasses": {
"kcBodyClass": undefined,
"kcHtmlClass": "login-pf",
"kcLoginClass": "login-pf-page",
"kcContentWrapperClass": "row",
"kcHeaderClass": "login-pf-page-header",
"kcHeaderWrapperClass": undefined,
"kcFormCardClass": "card-pf",
"kcFormCardAccountClass": "login-pf-accounts",
"kcFormSocialAccountClass": "login-pf-social-section",
"kcFormSocialAccountContentClass": "col-xs-12 col-sm-6",
"kcFormHeaderClass": "login-pf-header",
"kcLocaleWrapperClass": undefined,
"kcFeedbackErrorIcon": "pficon pficon-error-circle-o",
"kcFeedbackWarningIcon": "pficon pficon-warning-triangle-o",
"kcFeedbackSuccessIcon": "pficon pficon-ok",
"kcFeedbackInfoIcon": "pficon pficon-info",
"kcResetFlowIcon": "pficon pficon-arrow fa-2x",
"kcFormGroupClass": "form-group",
"kcLabelWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcSignUpClass": "login-pf-signup",
"kcInfoAreaWrapperClass": undefined,
"kcLogoClass": "login-pf-brand",
"kcContainerClass": "container-fluid",
"kcContentClass": "col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3",
"kcFeedbackAreaClass": "col-md-12",
"kcLocaleClass": "col-xs-12 col-sm-1",
"kcAlertIconClasserror": "pficon pficon-error-circle-o",
"kcFormAreaClass": "col-sm-10 col-sm-offset-1 col-md-8 col-md-offset-2 col-lg-8 col-lg-offset-2",
"kcFormSocialAccountListClass": "login-pf-social list-unstyled login-pf-social-all",
"kcFormSocialAccountDoubleListClass": "login-pf-social-double-col",
"kcFormSocialAccountListLinkClass": "login-pf-social-link",
"kcWebAuthnKeyIcon": "pficon pficon-key",
"kcWebAuthnDefaultIcon": "pficon pficon-key",
"kcFormClass": "form-horizontal",
"kcFormGroupErrorClass": "has-error",
"kcLabelClass": "control-label",
"kcInputClass": "form-control",
"kcInputErrorMessageClass": "pf-c-form__helper-text pf-m-error required kc-feedback-text",
"kcInputWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcFormButtonsWrapperClass": undefined,
"kcFormOptionsClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcFormButtonsClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcFormSettingClass": "login-pf-settings",
"kcTextareaClass": "form-control",
"kcFormOptionsWrapperClass": undefined,
"kcLocaleDropDownClass": undefined,
"kcLocaleListItemClass": undefined,
"kcContentWrapperClass": undefined,
"kcCheckboxInputClass": undefined,
"kcInfoAreaClass": "col-xs-12 col-sm-4 col-md-4 col-lg-5 details",
// user-profile grouping
"kcFormGroupHeader": "pf-c-form__group",
// css classes for form buttons main class used for all buttons
"kcButtonClass": "btn",
// classes defining priority of the button - primary or default (there is typically only one priority button for the form)
"kcButtonPrimaryClass": "btn-primary",
"kcButtonDefaultClass": "btn-default",
// classes defining size of the button
"kcButtonLargeClass": "btn-lg",
"kcButtonBlockClass": "btn-block",
// css classes for input
"kcInputLargeClass": "input-lg",
"kcInputGroup": "pf-c-input-group",
// css classes for form accessability
"kcSrOnlyClass": "sr-only",
// css classes for select-authenticator form
"kcSelectAuthListClass": "list-group list-view-pf",
"kcSelectAuthListItemClass": "list-group-item list-view-pf-stacked",
"kcSelectAuthListItemFillClass": "pf-l-split__item pf-m-fill",
"kcSelectAuthListItemIconPropertyClass": "fa-2x select-auth-box-icon-properties",
"kcSelectAuthListItemIconClass": "pf-l-split__item select-auth-box-icon",
"kcSelectAuthListItemTitle": "select-auth-box-paragraph",
"kcSelectAuthListItemInfoClass": "list-view-pf-main-info",
"kcSelectAuthListItemLeftClass": "list-view-pf-left",
"kcSelectAuthListItemBodyClass": "list-view-pf-body",
"kcSelectAuthListItemDescriptionClass": "list-view-pf-description",
"kcSelectAuthListItemHeadingClass": "list-group-item-heading",
"kcSelectAuthListItemHelpTextClass": "list-group-item-text",
// css classes for the authenticators
"kcAuthenticatorDefaultClass": "fa list-view-pf-icon-lg",
"kcAuthenticatorPasswordClass": "fa fa-unlock list-view-pf-icon-lg",
"kcLogoIdP-facebook": "fa fa-facebook",
"kcAuthenticatorOTPClass": "fa fa-mobile list-view-pf-icon-lg",
"kcLogoIdP-bitbucket": "fa fa-bitbucket",
"kcAuthenticatorWebAuthnClass": "fa fa-key list-view-pf-icon-lg",
"kcWebAuthnDefaultIcon": "pficon pficon-key",
"kcLogoIdP-stackoverflow": "fa fa-stack-overflow",
"kcSelectAuthListItemClass": "pf-l-stack__item select-auth-box-parent pf-l-split",
"kcLogoIdP-microsoft": "fa fa-windows",
"kcLoginOTPListItemHeaderClass": "pf-c-tile__header",
"kcLocaleItemClass": "pf-c-dropdown__menu-item",
"kcLoginOTPListItemIconBodyClass": "pf-c-tile__icon",
"kcInputHelperTextAfterClass": "pf-c-form__helper-text pf-c-form__helper-text-after",
"kcFormClass": "form-horizontal",
"kcSelectAuthListClass": "pf-l-stack select-auth-container",
"kcInputClassRadioCheckboxLabelDisabled": "pf-m-disabled",
"kcSelectAuthListItemIconClass": "pf-l-split__item select-auth-box-icon",
"kcRecoveryCodesWarning": "kc-recovery-codes-warning",
"kcFormSettingClass": "login-pf-settings",
"kcWebAuthnBLE": "fa fa-bluetooth-b",
"kcInputWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcSelectAuthListItemArrowIconClass": "fa fa-angle-right fa-lg",
"kcFeedbackAreaClass": "col-md-12",
"kcFormPasswordVisibilityButtonClass": "pf-c-button pf-m-control",
"kcLogoIdP-google": "fa fa-google",
"kcCheckLabelClass": "pf-c-check__label",
"kcSelectAuthListItemFillClass": "pf-l-split__item pf-m-fill",
"kcAuthenticatorDefaultClass": "fa fa-list list-view-pf-icon-lg",
"kcLogoIdP-gitlab": "fa fa-gitlab",
"kcFormAreaClass": "col-sm-10 col-sm-offset-1 col-md-8 col-md-offset-2 col-lg-8 col-lg-offset-2",
"kcFormButtonsClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcInputClassRadioLabel": "pf-c-radio__label",
"kcAuthenticatorWebAuthnPasswordlessClass": "fa fa-key list-view-pf-icon-lg",
//css classes for the OTP Login Form
"kcSelectOTPListClass": "card-pf card-pf-view card-pf-view-select card-pf-view-single-select col-xs-12",
"kcSelectOTPListItemClass": "card-pf-body card-pf-top-element",
"kcAuthenticatorOtpCircleClass": "fa fa-mobile card-pf-icon-circle",
"kcSelectOTPItemHeadingClass": "card-pf-title text-center",
"kcFormOptionsWrapperClass": undefined
"kcSelectAuthListItemHeadingClass": "pf-l-stack__item select-auth-box-headline pf-c-title",
"kcInfoAreaClass": "col-xs-12 col-sm-4 col-md-4 col-lg-5 details",
"kcLogoLink": "http://www.keycloak.org",
"kcContainerClass": "container-fluid",
"kcSelectAuthListItemTitle": "select-auth-box-paragraph",
"kcHtmlClass": "login-pf",
"kcLoginOTPListItemTitleClass": "pf-c-tile__title",
"kcLogoIdP-openshift-v4": "pf-icon pf-icon-openshift",
"kcWebAuthnUnknownIcon": "pficon pficon-key unknown-transport-class",
"kcFormSocialAccountNameClass": "kc-social-provider-name",
"kcLogoIdP-openshift-v3": "pf-icon pf-icon-openshift",
"kcLoginOTPListInputClass": "pf-c-tile__input",
"kcWebAuthnUSB": "fa fa-usb",
"kcInputClassRadio": "pf-c-radio",
"kcWebAuthnKeyIcon": "pficon pficon-key",
"kcFeedbackInfoIcon": "fa fa-fw fa-info-circle",
"kcCommonLogoIdP": "kc-social-provider-logo kc-social-gray",
"kcRecoveryCodesActions": "kc-recovery-codes-actions",
"kcFormGroupHeader": "pf-c-form__group",
"kcFormSocialAccountSectionClass": "kc-social-section kc-social-gray",
"kcLogoIdP-instagram": "fa fa-instagram",
"kcAlertClass": "pf-c-alert pf-m-inline",
"kcHeaderClass": "login-pf-page-header",
"kcLabelWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcFormPasswordVisibilityIconShow": "fa fa-eye",
"kcFormSocialAccountLinkClass": "pf-c-login__main-footer-links-item-link",
"kcLocaleMainClass": "pf-c-dropdown",
"kcInputGroup": "pf-c-input-group",
"kcTextareaClass": "form-control",
"kcButtonBlockClass": "pf-m-block",
"kcButtonClass": "pf-c-button",
"kcWebAuthnNFC": "fa fa-wifi",
"kcLocaleClass": "col-xs-12 col-sm-1",
"kcInputClassCheckboxInput": "pf-c-check__input",
"kcFeedbackErrorIcon": "fa fa-fw fa-exclamation-circle",
"kcInputLargeClass": "input-lg",
"kcInputErrorMessageClass": "pf-c-form__helper-text pf-m-error required kc-feedback-text",
"kcRecoveryCodesList": "kc-recovery-codes-list",
"kcFormSocialAccountListClass": "pf-c-login__main-footer-links kc-social-links",
"kcAlertTitleClass": "pf-c-alert__title kc-feedback-text",
"kcAuthenticatorPasswordClass": "fa fa-unlock list-view-pf-icon-lg",
"kcCheckInputClass": "pf-c-check__input",
"kcLogoIdP-linkedin": "fa fa-linkedin",
"kcLogoIdP-twitter": "fa fa-twitter",
"kcFeedbackWarningIcon": "fa fa-fw fa-exclamation-triangle",
"kcResetFlowIcon": "pficon pficon-arrow fa",
"kcSelectAuthListItemIconPropertyClass": "fa-2x select-auth-box-icon-properties",
"kcFeedbackSuccessIcon": "fa fa-fw fa-check-circle",
"kcLoginOTPListClass": "pf-c-tile",
"kcSrOnlyClass": "sr-only",
"kcFormSocialAccountListGridClass": "pf-l-grid kc-social-grid",
"kcButtonDefaultClass": "btn-default",
"kcFormGroupErrorClass": "has-error",
"kcSelectAuthListItemDescriptionClass": "pf-l-stack__item select-auth-box-desc",
"kcSelectAuthListItemBodyClass": "pf-l-split__item pf-l-stack",
"kcWebAuthnInternal": "pficon pficon-key",
"kcSelectAuthListItemArrowClass": "pf-l-split__item select-auth-box-arrow",
"kcCheckClass": "pf-c-check",
"kcContentClass": "col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3",
"kcLogoClass": "login-pf-brand",
"kcLoginOTPListItemIconClass": "fa fa-mobile",
"kcLoginClass": "login-pf-page",
"kcSignUpClass": "login-pf-signup",
"kcButtonLargeClass": "btn-lg",
"kcFormCardClass": "card-pf",
"kcLocaleListClass": "pf-c-dropdown__menu pf-m-align-right",
"kcInputClass": "pf-c-form-control",
"kcFormGroupClass": "form-group",
"kcLogoIdP-paypal": "fa fa-paypal",
"kcInputClassCheckbox": "pf-c-check",
"kcRecoveryCodesConfirmation": "kc-recovery-codes-confirmation",
"kcFormPasswordVisibilityIconHide": "fa fa-eye-slash",
"kcInputClassRadioInput": "pf-c-radio__input",
"kcFormSocialAccountListButtonClass": "pf-c-button pf-m-control pf-m-block kc-social-item kc-social-gray",
"kcInputClassCheckboxLabel": "pf-c-check__label",
"kcFormOptionsClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcFormHeaderClass": "login-pf-header",
"kcFormSocialAccountGridItem": "pf-l-grid__item",
"kcButtonPrimaryClass": "pf-m-primary",
"kcInputHelperTextBeforeClass": "pf-c-form__helper-text pf-c-form__helper-text-before",
"kcLogoIdP-github": "fa fa-github",
"kcLabelClass": "pf-c-form__label pf-c-form__label-text"
}
});

File diff suppressed because it is too large Load Diff

35
src/login/pages/Code.tsx Normal file
View File

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

View File

@ -0,0 +1,53 @@
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function DeleteAccountConfirm(props: PageProps<Extract<KcContext, { pageId: "delete-account-confirm.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { url, triggered_from_aia } = kcContext;
const { msg, msgStr } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("deleteAccountConfirm")}>
<form action={url.loginAction} className="form-vertical" method="post">
<div className="alert alert-warning" style={{ "marginTop": "0", "marginBottom": "30px" }}>
<span className="pficon pficon-warning-triangle-o"></span>
{msg("irreversibleAction")}
</div>
<p>{msg("deletingImplies")}</p>
<ul style={{ "color": "#72767b", "listStyle": "disc", "listStylePosition": "inside" }}>
<li>{msg("loggingOutImmediately")}</li>
<li>{msg("errasingData")}</li>
</ul>
<p className="delete-account-text">{msg("finalDeletionConfirmation")}</p>
<div id="kc-form-buttons">
<input
className={clsx(getClassName("kcButtonClass"), getClassName("kcButtonPrimaryClass"), getClassName("kcButtonLargeClass"))}
type="submit"
value={msgStr("doConfirmDelete")}
/>
{triggered_from_aia && (
<button
className={clsx(getClassName("kcButtonClass"), getClassName("kcButtonDefaultClass"), getClassName("kcButtonLargeClass"))}
style={{ "marginLeft": "calc(100% - 220px)" }}
type="submit"
name="cancel-aia"
value="true"
>
{msgStr("doCancel")}
</button>
)}
</div>
</form>
</Template>
);
}

View File

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

View File

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

View File

@ -0,0 +1,41 @@
import { useEffect } from "react";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function FrontchannelLogout(props: PageProps<Extract<KcContext, { pageId: "frontchannel-logout.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { logout } = kcContext;
const { msg, msgStr } = i18n;
useEffect(() => {
if (logout.logoutRedirectUri) {
window.location.replace(logout.logoutRedirectUri);
}
}, []);
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
documentTitle={msgStr("frontchannel-logout.title")}
headerNode={msg("frontchannel-logout.title")}
>
<p>{msg("frontchannel-logout.message")}</p>
<ul>
{logout.clients.map(client => (
<li key={client.name}>
{client.name}
<iframe src={client.frontChannelLogoutUrl} style={{ "display": "none" }} />
</li>
))}
</ul>
{logout.logoutRedirectUri && (
<a id="continue" className="btn btn-primary" href={logout.logoutRedirectUri}>
{msg("doContinue")}
</a>
)}
</Template>
);
}

View File

@ -1,13 +1,18 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { UserProfileFormFields } from "keycloakify/login/pages/shared/UserProfileFormFields";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFields";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function IdpReviewUserProfile(props: PageProps<Extract<KcContext, { pageId: "idp-review-user-profile.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
type IdpReviewUserProfileProps = PageProps<Extract<KcContext, { pageId: "idp-review-user-profile.ftl" }>, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
};
export default function IdpReviewUserProfile(props: IdpReviewUserProfileProps) {
const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
@ -16,12 +21,17 @@ export default function IdpReviewUserProfile(props: PageProps<Extract<KcContext,
const { msg, msgStr } = i18n;
const { url } = kcContext;
const { url, messagesPerField } = kcContext;
const [isFomSubmittable, setIsFomSubmittable] = useState(false);
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("loginIdpReviewProfileTitle")}>
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
displayMessage={messagesPerField.exists("global")}
displayRequiredFields
headerNode={msg("loginIdpReviewProfileTitle")}
>
<form id="kc-idp-review-profile-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
<UserProfileFormFields
kcContext={kcContext}

View File

@ -24,26 +24,36 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
<div id="kc-info-message">
<p className="instruction">
{message.summary}
{requiredActions !== undefined && (
<b>{requiredActions.map(requiredAction => msgStr(`requiredAction.${requiredAction}` as const)).join(",")}</b>
)}
{requiredActions && <b>{requiredActions.map(requiredAction => msgStr(`requiredAction.${requiredAction}` as const)).join(",")}</b>}
</p>
{!skipLink && pageRedirectUri !== undefined ? (
<p>
<a href={pageRedirectUri}>{msg("backToApplication")}</a>
</p>
) : actionUri !== undefined ? (
<p>
<a href={actionUri}>{msg("proceedWithAction")}</a>
</p>
) : (
client.baseUrl !== undefined && (
<p>
<a href={client.baseUrl}>{msg("backToApplication")}</a>
</p>
)
)}
{(() => {
if (skipLink) {
return null;
}
if (pageRedirectUri) {
return (
<p>
<a href={pageRedirectUri}>{msg("backToApplication")}</a>
</p>
);
}
if (actionUri) {
return (
<p>
<a href={actionUri}>{msg("proceedWithAction")}</a>
</p>
);
}
if (client.baseUrl) {
return (
<p>
<a href={client.baseUrl}>{msg("backToApplication")}</a>
</p>
);
}
})()}
</div>
</Template>
);

View File

@ -1,6 +1,6 @@
import { useState, type FormEventHandler } from "react";
import { useState, useEffect, useReducer } from "react";
import { assert } from "tsafe/assert";
import { clsx } from "keycloakify/tools/clsx";
import { useConstCallback } from "keycloakify/tools/useConstCallback";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
@ -14,115 +14,144 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
classes
});
const { social, realm, url, usernameHidden, login, auth, registrationDisabled } = kcContext;
const { social, realm, url, usernameHidden, login, auth, registrationDisabled, messagesPerField } = kcContext;
const { msg, msgStr } = i18n;
const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false);
const onSubmit = useConstCallback<FormEventHandler<HTMLFormElement>>(e => {
e.preventDefault();
setIsLoginButtonDisabled(true);
const formElement = e.target as HTMLFormElement;
//NOTE: Even if we login with email Keycloak expect username and password in
//the POST request.
formElement.querySelector("input[name='email']")?.setAttribute("name", "username");
formElement.submit();
});
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
displayMessage={!messagesPerField.existsError("username", "password")}
headerNode={msg("loginAccountTitle")}
displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled}
displayWide={realm.password && social.providers !== undefined}
headerNode={msg("doLogIn")}
infoNode={
<div id="kc-registration">
<span>
{msg("noAccount")}
<a tabIndex={6} href={url.registrationUrl}>
{msg("doRegister")}
</a>
</span>
<div id="kc-registration-container">
<div id="kc-registration">
<span>
{msg("noAccount")}{" "}
<a tabIndex={8} href={url.registrationUrl}>
{msg("doRegister")}
</a>
</span>
</div>
</div>
}
>
<div id="kc-form" className={clsx(realm.password && social.providers !== undefined && getClassName("kcContentWrapperClass"))}>
<div
id="kc-form-wrapper"
className={clsx(
realm.password &&
social.providers && [getClassName("kcFormSocialAccountContentClass"), getClassName("kcFormSocialAccountClass")]
socialProvidersNode={
<>
{realm.password && social.providers?.length && (
<div id="kc-social-providers" className={getClassName("kcFormSocialAccountSectionClass")}>
<hr />
<h2>{msg("identity-provider-login-label")}</h2>
<ul
className={clsx(
getClassName("kcFormSocialAccountListClass"),
social.providers.length > 3 && getClassName("kcFormSocialAccountListGridClass")
)}
>
{social.providers.map((...[p, , providers]) => (
<li key={p.alias}>
<a
id={`social-${p.alias}`}
className={clsx(
getClassName("kcFormSocialAccountListButtonClass"),
providers.length > 3 && getClassName("kcFormSocialAccountGridItem")
)}
type="button"
href={p.loginUrl}
>
{p.iconClasses && (
<i className={clsx(getClassName("kcCommonLogoIdP"), p.iconClasses)} aria-hidden="true"></i>
)}
<span
className={clsx(getClassName("kcFormSocialAccountNameClass"), p.iconClasses && "kc-social-icon-text")}
>
{p.displayName}
</span>
</a>
</li>
))}
</ul>
</div>
)}
>
</>
}
>
<div id="kc-form">
<div id="kc-form-wrapper">
{realm.password && (
<form id="kc-form-login" onSubmit={onSubmit} action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
{!usernameHidden &&
(() => {
const label = !realm.loginWithEmailAllowed
? "username"
: realm.registrationEmailAsUsername
? "email"
: "usernameOrEmail";
<form
id="kc-form-login"
onSubmit={() => {
setIsLoginButtonDisabled(true);
return true;
}}
action={url.loginAction}
method="post"
>
{!usernameHidden && (
<div className={getClassName("kcFormGroupClass")}>
<label htmlFor="username" className={getClassName("kcLabelClass")}>
{!realm.loginWithEmailAllowed
? msg("username")
: !realm.registrationEmailAsUsername
? msg("usernameOrEmail")
: msg("email")}
</label>
<input
tabIndex={2}
id="username"
className={getClassName("kcInputClass")}
name="username"
defaultValue={login.username ?? ""}
type="text"
autoFocus
autoComplete="username"
aria-invalid={messagesPerField.existsError("username", "password")}
/>
{messagesPerField.existsError("username", "password") && (
<span id="input-error" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.getFirstError("username", "password")}
</span>
)}
</div>
)}
const autoCompleteHelper: typeof label = label === "usernameOrEmail" ? "username" : label;
return (
<>
<label htmlFor={autoCompleteHelper} className={getClassName("kcLabelClass")}>
{msg(label)}
</label>
<input
tabIndex={1}
id={autoCompleteHelper}
className={getClassName("kcInputClass")}
//NOTE: This is used by Google Chrome auto fill so we use it to tell
//the browser how to pre fill the form but before submit we put it back
//to username because it is what keycloak expects.
name={autoCompleteHelper}
defaultValue={login.username ?? ""}
type="text"
autoFocus={true}
autoComplete="off"
/>
</>
);
})()}
</div>
<div className={getClassName("kcFormGroupClass")}>
<label htmlFor="password" className={getClassName("kcLabelClass")}>
{msg("password")}
</label>
<input
tabIndex={2}
id="password"
className={getClassName("kcInputClass")}
name="password"
type="password"
autoComplete="off"
/>
<PasswordWrapper getClassName={getClassName} i18n={i18n} passwordInputId="password">
<input
tabIndex={3}
id="password"
className={getClassName("kcInputClass")}
name="password"
type="password"
autoComplete="current-password"
aria-invalid={messagesPerField.existsError("username", "password")}
/>
</PasswordWrapper>
{usernameHidden && messagesPerField.existsError("username", "password") && (
<span id="input-error" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.getFirstError("username", "password")}
</span>
)}
</div>
<div className={clsx(getClassName("kcFormGroupClass"), getClassName("kcFormSettingClass"))}>
<div id="kc-form-options">
{realm.rememberMe && !usernameHidden && (
<div className="checkbox">
<label>
<input
tabIndex={3}
tabIndex={5}
id="rememberMe"
name="rememberMe"
type="checkbox"
{...(login.rememberMe === "on"
? {
"checked": true
}
: {})}
/>
defaultChecked={!!login.rememberMe}
/>{" "}
{msg("rememberMe")}
</label>
</div>
@ -131,26 +160,19 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
<div className={getClassName("kcFormOptionsWrapperClass")}>
{realm.resetPasswordAllowed && (
<span>
<a tabIndex={5} href={url.loginResetCredentialsUrl}>
<a tabIndex={6} href={url.loginResetCredentialsUrl}>
{msg("doForgotPassword")}
</a>
</span>
)}
</div>
</div>
<div id="kc-form-buttons" className={getClassName("kcFormGroupClass")}>
<input type="hidden" id="id-hidden-input" name="credentialId" value={auth.selectedCredential} />
<input
type="hidden"
id="id-hidden-input"
name="credentialId"
{...(auth?.selectedCredential !== undefined
? {
"value": auth.selectedCredential
}
: {})}
/>
<input
tabIndex={4}
tabIndex={7}
disabled={isLoginButtonDisabled}
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
@ -161,34 +183,51 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
id="kc-login"
type="submit"
value={msgStr("doLogIn")}
disabled={isLoginButtonDisabled}
/>
</div>
</form>
)}
</div>
{realm.password && social.providers !== undefined && (
<div
id="kc-social-providers"
className={clsx(getClassName("kcFormSocialAccountContentClass"), getClassName("kcFormSocialAccountClass"))}
>
<ul
className={clsx(
getClassName("kcFormSocialAccountListClass"),
social.providers.length > 4 && getClassName("kcFormSocialAccountDoubleListClass")
)}
>
{social.providers.map(p => (
<li key={p.providerId} className={getClassName("kcFormSocialAccountListLinkClass")}>
<a href={p.loginUrl} id={`zocial-${p.alias}`} className={clsx("zocial", p.providerId)}>
<span>{p.displayName}</span>
</a>
</li>
))}
</ul>
</div>
)}
</div>
</Template>
);
}
function PasswordWrapper(props: {
getClassName: ReturnType<typeof useGetClassName>["getClassName"];
i18n: I18n;
passwordInputId: string;
children: JSX.Element;
}) {
const { getClassName, i18n, passwordInputId, children } = props;
const { msgStr } = i18n;
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
useEffect(() => {
const passwordInputElement = document.getElementById(passwordInputId);
assert(passwordInputElement instanceof HTMLInputElement);
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
}, [isPasswordRevealed]);
return (
<div className={getClassName("kcInputGroup")}>
{children}
<button
type="button"
className={getClassName("kcFormPasswordVisibilityButtonClass")}
aria-label={msgStr(isPasswordRevealed ? "hidePassword" : "showPassword")}
aria-controls={passwordInputId}
onClick={toggleIsPasswordRevealed}
>
<i
className={getClassName(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")}
aria-hidden
/>
</button>
</div>
);
}

View File

@ -17,12 +17,6 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
const { msg, msgStr } = i18n;
const algToKeyUriAlg: Record<(typeof kcContext)["totp"]["policy"]["algorithm"], string> = {
"HmacSHA1": "SHA1",
"HmacSHA256": "SHA256",
"HmacSHA512": "SHA512"
};
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("loginTotpTitle")}>
<>
@ -37,7 +31,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
</ul>
</li>
{mode && mode == "manual" ? (
{mode == "manual" ? (
<>
<li>
<p>{msg("loginTotpManualStep2")}</p>
@ -58,7 +52,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
{msg("loginTotpType")}: {msg(`loginTotp.${totp.policy.type}`)}
</li>
<li id="kc-totp-algorithm">
{msg("loginTotpAlgorithm")}: {algToKeyUriAlg?.[totp.policy.algorithm] ?? totp.policy.algorithm}
{msg("loginTotpAlgorithm")}: {totp.policy.getAlgorithmKey()}
</li>
<li id="kc-totp-digits">
{msg("loginTotpDigits")}: {totp.policy.digits}
@ -146,6 +140,10 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
</div>
</div>
<div className={getClassName("kcFormGroupClass")}>
<LogoutOtherSessions {...{ getClassName, i18n }} />
</div>
{isAppInitiatedAction ? (
<>
<input
@ -186,3 +184,22 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
</Template>
);
}
function LogoutOtherSessions(props: { getClassName: ReturnType<typeof useGetClassName>["getClassName"]; i18n: I18n }) {
const { getClassName, i18n } = props;
const { msg } = i18n;
return (
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}>
<div className="checkbox">
<label>
<input type="checkbox" id="logout-sessions" name="logout-sessions" value="on" defaultChecked={true} />
{msg("logoutOtherSessions")}
</label>
</div>
</div>
</div>
);
}

View File

@ -4,7 +4,9 @@ import { KcContext } from "../kcContext";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { PageProps } from "./PageProps";
export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pageId: "login-oauth2-device-verify-user-code.ftl" }>, I18n>) {
export default function LoginOauth2DeviceVerifyUserCode(
props: PageProps<Extract<KcContext, { pageId: "login-oauth2-device-verify-user-code.ftl" }>, I18n>
) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { url } = kcContext;

View File

@ -18,18 +18,54 @@ export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pa
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
headerNode={msg("oauthGrantTitle", client.name ? advancedMsgStr(client.name) : client.clientId)}
bodyClassName="oauth"
headerNode={
<>
{client.attributes.logoUri && <img src={client.attributes.logoUri} />}
<p>{client.name ? msg("oauthGrantTitle", advancedMsgStr(client.name)) : msg("oauthGrantTitle", client.clientId)}</p>
</>
}
>
<div id="kc-oauth" className="content-area">
<h3>{msg("oauthGrantRequest")}</h3>
<ul>
{oauth.clientScopesRequested.map(clientScope => (
<li key={clientScope.consentScreenText}>
<span>{advancedMsg(clientScope.consentScreenText)}</span>
<span>
{advancedMsg(clientScope.consentScreenText)}
{clientScope.dynamicScopeParameter && (
<>
: <b>{clientScope.dynamicScopeParameter}</b>
</>
)}
</span>
</li>
))}
</ul>
{client.attributes.policyUri ||
(client.attributes.tosUri && (
<h3>
{client.name ? msg("oauthGrantInformation", advancedMsgStr(client.name)) : msg("oauthGrantInformation", client.clientId)}
{client.attributes.tosUri && (
<>
{msg("oauthGrantReview")}
<a href={client.attributes.tosUri} target="_blank">
{msg("oauthGrantTos")}
</a>
</>
)}
{client.attributes.policyUri && (
<>
{msg("oauthGrantReview")}
<a href={client.attributes.policyUri} target="_blank">
{msg("oauthGrantPolicy")}
</a>
</>
)}
</h3>
))}
<form className="form-actions" action={url.oauthAction} method="POST">
<input type="hidden" name="code" value={oauth.code} />
<div className={getClassName("kcFormGroupClass")}>

View File

@ -1,3 +1,4 @@
import { Fragment } from "react";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
@ -12,81 +13,88 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
classes
});
const { otpLogin, url } = kcContext;
const { otpLogin, url, messagesPerField } = kcContext;
const { msg, msgStr } = i18n;
return (
<>
<style>
{`
input[type="radio"]:checked~label.kcSelectOTPListClass{
border: 2px solid #39a5dc;
}`}
</style>
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("doLogIn")}>
<form id="kc-otp-login-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
{otpLogin.userOtpCredentials.length > 1 && (
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcInputWrapperClass")}>
{otpLogin.userOtpCredentials.map((otpCredential, index) => (
<div key={otpCredential.id}>
<input
id={`kc-otp-credential-${index}`}
name="selectedCredentialId"
type="radio"
value={otpCredential.id}
style={{ display: "none" }}
/>
<label
htmlFor={`kc-otp-credential-${index}`}
key={otpCredential.id}
className={getClassName("kcSelectOTPListClass")}
>
<div className={getClassName("kcSelectOTPListItemClass")}>
<span className={getClassName("kcAuthenticatorOtpCircleClass")} />
<h2 className={getClassName("kcSelectOTPItemHeadingClass")}>{otpCredential.userLabel}</h2>
</div>
</label>
</div>
))}
</div>
</div>
)}
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
displayMessage={!messagesPerField.existsError("totp")}
headerNode={msg("doLogIn")}
>
<form id="kc-otp-login-form" className={clsx(getClassName("kcFormClass"))} action={url.loginAction} method="post">
{otpLogin.userOtpCredentials.length > 1 && (
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="otp" className={getClassName("kcLabelClass")}>
{msg("loginOtpOneTime")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input id="otp" name="otp" autoComplete="off" type="text" className={getClassName("kcInputClass")} autoFocus />
{otpLogin.userOtpCredentials.map((otpCredential, index) => (
<Fragment key={index}>
<input
id={`kc-otp-credential-${index}`}
className={getClassName("kcLoginOTPListInputClass")}
type="radio"
name="selectedCredentialId"
value={otpCredential.id}
defaultChecked={otpCredential.id === otpLogin.selectedCredentialId}
/>
<label htmlFor={`kc-otp-credential-${index}`} className={getClassName("kcLoginOTPListClass")} tabIndex={index}>
<span className={getClassName("kcLoginOTPListItemHeaderClass")}>
<span className={getClassName("kcLoginOTPListItemIconBodyClass")}>
<i className={getClassName("kcLoginOTPListItemIconClass")} aria-hidden="true"></i>
</span>
<span className={getClassName("kcLoginOTPListItemTitleClass")}>{otpCredential.userLabel}</span>
</span>
</label>
</Fragment>
))}
</div>
</div>
)}
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")} />
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
name="login"
id="kc-login"
type="submit"
value={msgStr("doLogIn")}
/>
</div>
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="otp" className={getClassName("kcLabelClass")}>
{msg("loginOtpOneTime")}
</label>
</div>
</form>
</Template>
</>
<div className={getClassName("kcInputWrapperClass")}>
<input
id="otp"
name="otp"
autoComplete="off"
type="text"
className={getClassName("kcInputClass")}
autoFocus
aria-invalid={messagesPerField.existsError("totp")}
/>
{messagesPerField.existsError("totp") && (
<span id="input-error-otp-code" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("totp")}
</span>
)}
</div>
</div>
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}></div>
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
name="login"
id="kc-login"
type="submit"
value={msgStr("doLogIn")}
/>
</div>
</div>
</form>
</Template>
);
}

View File

@ -10,7 +10,7 @@ export default function LoginPageExpired(props: PageProps<Extract<KcContext, { p
const { msg } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("pageExpiredTitle")}>
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("pageExpiredTitle")}>
<p id="instruction1" className="instruction">
{msg("pageExpiredMsg1")}
<a id="loginRestartLink" href={url.loginRestartFlowUrl}>

View File

@ -1,13 +1,12 @@
import { useState } from "react";
import { useState, useEffect, useReducer } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { useConstCallback } from "keycloakify/tools/useConstCallback";
import type { FormEventHandler } from "react";
import { assert } from "tsafe/assert";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function LoginPassword(props: PageProps<Extract<KcContext, { "pageId": "login-password.ftl" }>, I18n>) {
export default function LoginPassword(props: PageProps<Extract<KcContext, { pageId: "login-password.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
@ -15,42 +14,53 @@ export default function LoginPassword(props: PageProps<Extract<KcContext, { "pag
classes
});
const { realm, url, login } = kcContext;
const { realm, url, messagesPerField } = kcContext;
const { msg, msgStr } = i18n;
const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false);
const onSubmit = useConstCallback<FormEventHandler<HTMLFormElement>>(e => {
e.preventDefault();
setIsLoginButtonDisabled(true);
const formElement = e.target as HTMLFormElement;
formElement.submit();
});
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("doLogIn")}>
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
headerNode={msg("doLogIn")}
displayMessage={!messagesPerField.existsError("password")}
>
<div id="kc-form">
<div id="kc-form-wrapper">
<form id="kc-form-login" onSubmit={onSubmit} action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
<form
id="kc-form-login"
onSubmit={() => {
setIsLoginButtonDisabled(true);
return true;
}}
action={url.loginAction}
method="post"
>
<div className={clsx(getClassName("kcFormGroupClass"), "no-bottom-margin")}>
<hr />
<label htmlFor="password" className={getClassName("kcLabelClass")}>
{msg("password")}
</label>
<input
tabIndex={2}
id="password"
className={getClassName("kcInputClass")}
name="password"
type="password"
autoFocus={true}
autoComplete="on"
defaultValue={login.password ?? ""}
/>
<PasswordWrapper getClassName={getClassName} i18n={i18n} passwordInputId="password">
<input
tabIndex={2}
id="password"
className={getClassName("kcInputClass")}
name="password"
type="password"
autoFocus
autoComplete="on"
aria-invalid={messagesPerField.existsError("username", "password")}
/>
</PasswordWrapper>
{messagesPerField.existsError("password") && (
<span id="input-error-password" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("password")}
</span>
)}
</div>
<div className={clsx(getClassName("kcFormGroupClass"), getClassName("kcFormSettingClass"))}>
<div id="kc-form-options" />
@ -86,3 +96,42 @@ export default function LoginPassword(props: PageProps<Extract<KcContext, { "pag
</Template>
);
}
function PasswordWrapper(props: {
getClassName: ReturnType<typeof useGetClassName>["getClassName"];
i18n: I18n;
passwordInputId: string;
children: JSX.Element;
}) {
const { getClassName, i18n, passwordInputId, children } = props;
const { msgStr } = i18n;
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
useEffect(() => {
const passwordInputElement = document.getElementById(passwordInputId);
assert(passwordInputElement instanceof HTMLInputElement);
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
}, [isPasswordRevealed]);
return (
<div className={getClassName("kcInputGroup")}>
{children}
<button
type="button"
className={getClassName("kcFormPasswordVisibilityButtonClass")}
aria-label={msgStr(isPasswordRevealed ? "hidePassword" : "showPassword")}
aria-controls={passwordInputId}
onClick={toggleIsPasswordRevealed}
>
<i
className={getClassName(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")}
aria-hidden
/>
</button>
</div>
);
}

View File

@ -0,0 +1,260 @@
import { useEffect } from "react";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { createUseInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
const { useInsertScriptTags } = createUseInsertScriptTags();
export default function LoginRecoveryAuthnCodeConfig(props: PageProps<Extract<KcContext, { pageId: "login-recovery-authn-code-config.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { recoveryAuthnCodesConfigBean, isAppInitiatedAction } = kcContext;
const { msg, msgStr } = i18n;
const { insertScriptTags } = useInsertScriptTags({
"scriptTags": [
{
"type": "text/javascript",
"textContent": `
/* copy recovery codes */
function copyRecoveryCodes() {
var tmpTextarea = document.createElement("textarea");
var codes = document.getElementById("kc-recovery-codes-list").getElementsByTagName("li");
for (i = 0; i < codes.length; i++) {
tmpTextarea.value = tmpTextarea.value + codes[i].innerText + "\n";
}
document.body.appendChild(tmpTextarea);
tmpTextarea.select();
document.execCommand("copy");
document.body.removeChild(tmpTextarea);
}
var copyButton = document.getElementById("copyRecoveryCodes");
copyButton && copyButton.addEventListener("click", function () {
copyRecoveryCodes();
});
/* download recovery codes */
function formatCurrentDateTime() {
var dt = new Date();
var options = {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short'
};
return dt.toLocaleString('en-US', options);
}
function parseRecoveryCodeList() {
var recoveryCodes = document.querySelectorAll(".kc-recovery-codes-list li");
var recoveryCodeList = "";
for (var i = 0; i < recoveryCodes.length; i++) {
var recoveryCodeLiElement = recoveryCodes[i].innerText;
recoveryCodeList += recoveryCodeLiElement + "\r\n";
}
return recoveryCodeList;
}
function buildDownloadContent() {
var recoveryCodeList = parseRecoveryCodeList();
var dt = new Date();
var options = {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short'
};
return fileBodyContent =
"${msgStr("recovery-codes-download-file-header")}\n\n" +
recoveryCodeList + "\n" +
"${msgStr("recovery-codes-download-file-description")}\n\n" +
"${msgStr("recovery-codes-download-file-date")} " + formatCurrentDateTime();
}
function setUpDownloadLinkAndDownload(filename, text) {
var el = document.createElement('a');
el.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
el.setAttribute('download', filename);
el.style.display = 'none';
document.body.appendChild(el);
el.click();
document.body.removeChild(el);
}
function downloadRecoveryCodes() {
setUpDownloadLinkAndDownload('kc-download-recovery-codes.txt', buildDownloadContent());
}
var downloadButton = document.getElementById("downloadRecoveryCodes");
downloadButton && downloadButton.addEventListener("click", downloadRecoveryCodes);
/* print recovery codes */
function buildPrintContent() {
var recoveryCodeListHTML = document.getElementById('kc-recovery-codes-list').innerHTML;
var styles =
\`@page { size: auto; margin-top: 0; }
body { width: 480px; }
div { list-style-type: none; font-family: monospace }
p:first-of-type { margin-top: 48px }\`;
return printFileContent =
"<html><style>" + styles + "</style><body>" +
"<title>kc-download-recovery-codes</title>" +
"<p>${msgStr("recovery-codes-download-file-header")}</p>" +
"<div>" + recoveryCodeListHTML + "</div>" +
"<p>${msgStr("recovery-codes-download-file-description")}</p>" +
"<p>${msgStr("recovery-codes-download-file-date")} " + formatCurrentDateTime() + "</p>" +
"</body></html>";
}
function printRecoveryCodes() {
var w = window.open();
w.document.write(buildPrintContent());
w.print();
w.close();
}
var printButton = document.getElementById("printRecoveryCodes");
printButton && printButton.addEventListener("click", printRecoveryCodes);
`
}
]
});
useEffect(() => {
insertScriptTags();
}, []);
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("recovery-code-config-header")}>
<div className={clsx("pf-c-alert", "pf-m-warning", "pf-m-inline", getClassName("kcRecoveryCodesWarning"))} aria-label="Warning alert">
<div className="pf-c-alert__icon">
<i className="pficon-warning-triangle-o" aria-hidden="true" />
</div>
<h4 className="pf-c-alert__title">
<span className="pf-screen-reader">Warning alert:</span>
{msg("recovery-code-config-warning-title")}
</h4>
<div className="pf-c-alert__description">
<p>{msg("recovery-code-config-warning-message")}</p>
</div>
</div>
<ol id="kc-recovery-codes-list" className={getClassName("kcRecoveryCodesList")}>
{recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList.map((code, index) => (
<li key={index}>
<span>{index + 1}:</span> {code.slice(0, 4)}-{code.slice(4, 8)}-{code.slice(8)}
</li>
))}
</ol>
{/* actions */}
<div className={getClassName("kcRecoveryCodesActions")}>
<button id="printRecoveryCodes" className={clsx("pf-c-button", "pf-m-link")} type="button">
<i className="pficon-print" aria-hidden="true" /> {msg("recovery-codes-print")}
</button>
<button id="downloadRecoveryCodes" className={clsx("pf-c-button", "pf-m-link")} type="button">
<i className="pficon-save" aria-hidden="true" /> {msg("recovery-codes-download")}
</button>
<button id="copyRecoveryCodes" className={clsx("pf-c-button", "pf-m-link")} type="button">
<i className="pficon-blueprint" aria-hidden="true" /> {msg("recovery-codes-copy")}
</button>
</div>
{/* confirmation checkbox */}
<div className={getClassName("kcFormOptionsClass")}>
<input
className={getClassName("kcCheckInputClass")}
type="checkbox"
id="kcRecoveryCodesConfirmationCheck"
name="kcRecoveryCodesConfirmationCheck"
onChange={function () {
//@ts-expect-error: This is code from the original theme, we trust it.
document.getElementById("saveRecoveryAuthnCodesBtn").disabled = !this.checked;
}}
/>
<label htmlFor="kcRecoveryCodesConfirmationCheck">{msg("recovery-codes-confirmation-message")}</label>
</div>
<form action={kcContext.url.loginAction} className={getClassName("kcFormGroupClass")} id="kc-recovery-codes-settings-form" method="post">
<input type="hidden" name="generatedRecoveryAuthnCodes" value={recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesAsString} />
<input type="hidden" name="generatedAt" value={recoveryAuthnCodesConfigBean.generatedAt} />
<input type="hidden" id="userLabel" name="userLabel" value={msgStr("recovery-codes-label-default")} />
<LogoutOtherSessions {...{ getClassName, i18n }} />
{isAppInitiatedAction ? (
<>
<input
type="submit"
className={clsx(getClassName("kcButtonClass"), getClassName("kcButtonPrimaryClass"), getClassName("kcButtonLargeClass"))}
id="saveRecoveryAuthnCodesBtn"
value={msgStr("recovery-codes-action-complete")}
disabled
/>
<button
type="submit"
className={clsx(getClassName("kcButtonClass"), getClassName("kcButtonDefaultClass"), getClassName("kcButtonLargeClass"))}
id="cancelRecoveryAuthnCodesBtn"
name="cancel-aia"
value="true"
>
{msg("recovery-codes-action-cancel")}
</button>
</>
) : (
<input
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
id="saveRecoveryAuthnCodesBtn"
value={msgStr("recovery-codes-action-complete")}
disabled
/>
)}
</form>
</Template>
);
}
function LogoutOtherSessions(props: { getClassName: ReturnType<typeof useGetClassName>["getClassName"]; i18n: I18n }) {
const { getClassName, i18n } = props;
const { msg } = i18n;
return (
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}>
<div className="checkbox">
<label>
<input type="checkbox" id="logout-sessions" name="logout-sessions" value="on" defaultChecked={true} />
{msg("logoutOtherSessions")}
</label>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,73 @@
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function LoginRecoveryAuthnCodeInput(props: PageProps<Extract<KcContext, { pageId: "login-recovery-authn-code-input.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { url, messagesPerField, recoveryAuthnCodesInputBean } = kcContext;
const { msg, msgStr } = i18n;
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
headerNode={msg("auth-recovery-code-header")}
displayMessage={!messagesPerField.existsError("recoveryCodeInput")}
>
<form id="kc-recovery-code-login-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="recoveryCodeInput" className={getClassName("kcLabelClass")}>
{msg("auth-recovery-code-prompt", `${recoveryAuthnCodesInputBean.codeNumber}`)}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
tabIndex={1}
id="recoveryCodeInput"
name="recoveryCodeInput"
aria-invalid={messagesPerField.existsError("recoveryCodeInput")}
autoComplete="off"
type="text"
className={getClassName("kcInputClass")}
autoFocus
/>
{messagesPerField.existsError("recoveryCodeInput") && (
<span id="input-error" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("recoveryCodeInput")}
</span>
)}
</div>
</div>
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsWrapperClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")} />
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
name="login"
id="kc-login"
type="submit"
value={msgStr("doLogIn")}
/>
</div>
</div>
</form>
</Template>
);
}

View File

@ -0,0 +1,70 @@
import { Fragment } from "react";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function LoginResetOtp(props: PageProps<Extract<KcContext, { pageId: "login-reset-otp.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { url, messagesPerField, configuredOtpCredentials } = kcContext;
const { msg, msgStr } = i18n;
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
displayMessage={!messagesPerField.existsError("totp")}
headerNode={msg("doLogIn")}
>
<form id="kc-otp-reset-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
<div className={getClassName("kcInputWrapperClass")}>
<div className={getClassName("kcInfoAreaWrapperClass")}>
<p id="kc-otp-reset-form-description">{msg("otp-reset-description")}</p>
{configuredOtpCredentials.userOtpCredentials.map((otpCredential, index) => (
<Fragment key={otpCredential.id}>
<input
id={`kc-otp-credential-${index}`}
className={getClassName("kcLoginOTPListInputClass")}
type="radio"
name="selectedCredentialId"
value={otpCredential.id}
defaultChecked={otpCredential.id === configuredOtpCredentials.selectedCredentialId}
/>
<label htmlFor={`kc-otp-credential-${index}`} className={getClassName("kcLoginOTPListClass")} tabIndex={index}>
<span className={getClassName("kcLoginOTPListItemHeaderClass")}>
<span className={getClassName("kcLoginOTPListItemIconBodyClass")}>
<i className={getClassName("kcLoginOTPListItemIconClass")} aria-hidden="true"></i>
</span>
<span className={getClassName("kcLoginOTPListItemTitleClass")}>{otpCredential.userLabel}</span>
</span>
</label>
</Fragment>
))}
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<input
id="kc-otp-reset-form-submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
value={msgStr("doSubmit")}
/>
</div>
</div>
</div>
</div>
</form>
</Template>
);
}

View File

@ -12,16 +12,17 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
classes
});
const { url, realm, auth } = kcContext;
const { url, realm, auth, messagesPerField } = kcContext;
const { msg, msgStr } = i18n;
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
displayMessage={false}
displayInfo
displayMessage={!messagesPerField.existsError("username")}
infoNode={realm.duplicateEmailsAllowed ? msg("emailInstructionUsername") : msg("emailInstruction")}
headerNode={msg("emailForgotTitle")}
infoNode={msg("emailInstruction")}
>
<form id="kc-reset-password-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
@ -41,8 +42,14 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
name="username"
className={getClassName("kcInputClass")}
autoFocus
defaultValue={auth !== undefined && auth.showUsername ? auth.attemptedUsername : undefined}
defaultValue={auth.attemptedUsername ?? ""}
aria-invalid={messagesPerField.existsError("username")}
/>
{messagesPerField.existsError("username") && (
<span id="input-error-username" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("username")}
</span>
)}
</div>
</div>
<div className={clsx(getClassName("kcFormGroupClass"), getClassName("kcFormSettingClass"))}>

View File

@ -1,4 +1,6 @@
import { useEffect, useReducer } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { assert } from "tsafe/assert";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
@ -14,93 +16,83 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
const { msg, msgStr } = i18n;
const { url, messagesPerField, isAppInitiatedAction, username } = kcContext;
const { url, messagesPerField, isAppInitiatedAction } = kcContext;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("updatePasswordTitle")}>
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
displayMessage={!messagesPerField.existsError("password", "password-confirm")}
headerNode={msg("updatePasswordTitle")}
>
<form id="kc-passwd-update-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
<input
type="text"
id="username"
name="username"
value={username}
readOnly={true}
autoComplete="username"
style={{ display: "none" }}
/>
<input type="password" id="password" name="password" autoComplete="current-password" style={{ display: "none" }} />
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("password", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="password-new" className={getClassName("kcLabelClass")}>
{msg("passwordNew")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="password"
id="password-new"
name="password-new"
autoFocus
autoComplete="new-password"
className={getClassName("kcInputClass")}
/>
<div className={getClassName("kcInputWrapperClass")}>
<PasswordWrapper {...{ getClassName, i18n }} passwordInputId="password-new">
<input
type="password"
id="password-new"
name="password-new"
className={getClassName("kcInputClass")}
autoFocus
autoComplete="new-password"
aria-invalid={messagesPerField.existsError("password", "password-confirm")}
/>
</PasswordWrapper>
{messagesPerField.existsError("password") && (
<span id="input-error-password" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("password")}
</span>
)}
</div>
</div>
</div>
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("password-confirm", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="password-confirm" className={getClassName("kcLabelClass")}>
{msg("passwordConfirm")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="password"
id="password-confirm"
name="password-confirm"
autoComplete="new-password"
className={getClassName("kcInputClass")}
/>
</div>
</div>
<PasswordWrapper {...{ getClassName, i18n }} passwordInputId="password-confirm">
<input
type="password"
id="password-confirm"
name="password-confirm"
className={getClassName("kcInputClass")}
autoFocus
autoComplete="new-password"
aria-invalid={messagesPerField.existsError("password", "password-confirm")}
/>
</PasswordWrapper>
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}>
{messagesPerField.existsError("password-confirm") && (
<span id="input-error-password-confirm" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("password-confirm")}
</span>
)}
</div>
<div className={getClassName("kcFormGroupClass")}>
<LogoutOtherSessions {...{ getClassName, i18n }} />
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
isAppInitiatedAction && getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
value={msgStr("doSubmit")}
/>
{isAppInitiatedAction && (
<div className="checkbox">
<label>
<input type="checkbox" id="logout-sessions" name="logout-sessions" value="on" checked />
{msgStr("logoutOtherSessions")}
</label>
</div>
)}
</div>
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
{isAppInitiatedAction ? (
<>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
defaultValue={msgStr("doSubmit")}
/>
<button
className={clsx(
getClassName("kcButtonClass"),
@ -113,22 +105,69 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
>
{msg("doCancel")}
</button>
</>
) : (
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
value={msgStr("doSubmit")}
/>
)}
)}
</div>
</div>
</div>
</form>
</Template>
);
}
function LogoutOtherSessions(props: { getClassName: ReturnType<typeof useGetClassName>["getClassName"]; i18n: I18n }) {
const { getClassName, i18n } = props;
const { msg } = i18n;
return (
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}>
<div className="checkbox">
<label>
<input type="checkbox" id="logout-sessions" name="logout-sessions" value="on" defaultChecked={true} />
{msg("logoutOtherSessions")}
</label>
</div>
</div>
</div>
);
}
function PasswordWrapper(props: {
getClassName: ReturnType<typeof useGetClassName>["getClassName"];
i18n: I18n;
passwordInputId: string;
children: JSX.Element;
}) {
const { getClassName, i18n, passwordInputId, children } = props;
const { msgStr } = i18n;
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
useEffect(() => {
const passwordInputElement = document.getElementById(passwordInputId);
assert(passwordInputElement instanceof HTMLInputElement);
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
}, [isPasswordRevealed]);
return (
<div className={getClassName("kcInputGroup")}>
{children}
<button
type="button"
className={getClassName("kcFormPasswordVisibilityButtonClass")}
aria-label={msgStr(isPasswordRevealed ? "hidePassword" : "showPassword")}
aria-controls={passwordInputId}
onClick={toggleIsPasswordRevealed}
>
<i
className={getClassName(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")}
aria-hidden
/>
</button>
</div>
);
}

View File

@ -1,146 +1,72 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFields";
export default function LoginUpdateProfile(props: PageProps<Extract<KcContext, { pageId: "login-update-profile.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
type LoginUpdateProfileProps = PageProps<Extract<KcContext, { pageId: "login-update-profile.ftl" }>, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
};
export default function LoginUpdateProfile(props: LoginUpdateProfileProps) {
const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { url, messagesPerField, isAppInitiatedAction } = kcContext;
const { msg, msgStr } = i18n;
const { url, user, messagesPerField, isAppInitiatedAction } = kcContext;
const [isFormSubmittable, setIsFormSubmittable] = useState(false);
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("loginProfileTitle")}>
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayRequiredFields headerNode={msg("loginProfileTitle")}>
<form id="kc-update-profile-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
{user.editUsernameAllowed && (
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("username", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="username" className={getClassName("kcLabelClass")}>
{msg("username")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="text"
id="username"
name="username"
defaultValue={user.username ?? ""}
className={getClassName("kcInputClass")}
/>
</div>
</div>
)}
<div
className={clsx(getClassName("kcFormGroupClass"), messagesPerField.printIfExists("email", getClassName("kcFormGroupErrorClass")))}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="email" className={getClassName("kcLabelClass")}>
{msg("email")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input type="text" id="email" name="email" defaultValue={user.email ?? ""} className={getClassName("kcInputClass")} />
</div>
</div>
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("firstName", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="firstName" className={getClassName("kcLabelClass")}>
{msg("firstName")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="text"
id="firstName"
name="firstName"
defaultValue={user.firstName ?? ""}
className={getClassName("kcInputClass")}
/>
</div>
</div>
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("lastName", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="lastName" className={getClassName("kcLabelClass")}>
{msg("lastName")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="text"
id="lastName"
name="lastName"
defaultValue={user.lastName ?? ""}
className={getClassName("kcInputClass")}
/>
</div>
</div>
<UserProfileFormFields
{...{
kcContext,
i18n,
getClassName,
messagesPerField
}}
onIsFormSubmittableValueChange={setIsFormSubmittable}
/>
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")} />
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
{isAppInitiatedAction ? (
<>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
defaultValue={msgStr("doSubmit")}
/>
<button
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
name="cancel-aia"
value="true"
>
{msg("doCancel")}
</button>
</>
) : (
<input
<input
disabled={!isFormSubmittable}
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
!isAppInitiatedAction && getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
value={msgStr("doSubmit")}
/>
{isAppInitiatedAction && (
<button
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
defaultValue={msgStr("doSubmit")}
/>
name="cancel-aia"
value="true"
formNoValidate
>
{msg("doCancel")}
</button>
)}
</div>
</div>

View File

@ -1,7 +1,5 @@
import type { FormEventHandler } from "react";
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { useConstCallback } from "keycloakify/tools/useConstCallback";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
@ -15,90 +13,108 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
classes
});
const { social, realm, url, usernameHidden, login, registrationDisabled } = kcContext;
const { social, realm, url, usernameHidden, login, registrationDisabled, messagesPerField } = kcContext;
const { msg, msgStr } = i18n;
const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false);
const onSubmit = useConstCallback<FormEventHandler<HTMLFormElement>>(e => {
e.preventDefault();
setIsLoginButtonDisabled(true);
const formElement = e.target as HTMLFormElement;
//NOTE: Even if we login with email Keycloak expect username and password in
//the POST request.
formElement.querySelector("input[name='email']")?.setAttribute("name", "username");
formElement.submit();
});
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
displayInfo={social.displayInfo}
displayWide={realm.password && social.providers !== undefined}
headerNode={msg("doLogIn")}
displayMessage={!messagesPerField.existsError("username")}
displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled}
infoNode={
realm.password &&
realm.registrationAllowed &&
!registrationDisabled && (
<div id="kc-registration">
<span>
{msg("noAccount")}
<a tabIndex={6} href={url.registrationUrl}>
{msg("doRegister")}
</a>
</span>
</div>
)
<div id="kc-registration">
<span>
{msg("noAccount")}
<a tabIndex={6} href={url.registrationUrl}>
{msg("doRegister")}
</a>
</span>
</div>
}
headerNode={msg("doLogIn")}
socialProvidersNode={
<>
{realm.password && social.providers?.length && (
<div id="kc-social-providers" className={getClassName("kcFormSocialAccountSectionClass")}>
<hr />
<h2>{msg("identity-provider-login-label")}</h2>
<ul
className={clsx(
getClassName("kcFormSocialAccountListClass"),
social.providers.length > 3 && getClassName("kcFormSocialAccountListGridClass")
)}
>
{social.providers.map((...[p, , providers]) => (
<li key={p.alias}>
<a
id={`social-${p.alias}`}
className={clsx(
getClassName("kcFormSocialAccountListButtonClass"),
providers.length > 3 && getClassName("kcFormSocialAccountGridItem")
)}
type="button"
href={p.loginUrl}
>
{p.iconClasses && (
<i className={clsx(getClassName("kcCommonLogoIdP"), p.iconClasses)} aria-hidden="true"></i>
)}
<span
className={clsx(getClassName("kcFormSocialAccountNameClass"), p.iconClasses && "kc-social-icon-text")}
>
{p.displayName}
</span>
</a>
</li>
))}
</ul>
</div>
)}
</>
}
>
<div id="kc-form" className={clsx(realm.password && social.providers !== undefined && getClassName("kcContentWrapperClass"))}>
<div
id="kc-form-wrapper"
className={clsx(
realm.password &&
social.providers && [getClassName("kcFormSocialAccountContentClass"), getClassName("kcFormSocialAccountClass")]
)}
>
<div id="kc-form">
<div id="kc-form-wrapper">
{realm.password && (
<form id="kc-form-login" onSubmit={onSubmit} action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
{!usernameHidden &&
(() => {
const label = !realm.loginWithEmailAllowed
? "username"
: realm.registrationEmailAsUsername
? "email"
: "usernameOrEmail";
<form
id="kc-form-login"
onSubmit={() => {
setIsLoginButtonDisabled(true);
return true;
}}
action={url.loginAction}
method="post"
>
{!usernameHidden && (
<div className={getClassName("kcFormGroupClass")}>
<label htmlFor="username" className={getClassName("kcLabelClass")}>
{!realm.loginWithEmailAllowed
? msg("username")
: !realm.registrationEmailAsUsername
? msg("usernameOrEmail")
: msg("email")}
</label>
<input
tabIndex={2}
id="username"
className={getClassName("kcInputClass")}
name="username"
defaultValue={login.username ?? ""}
type="text"
autoFocus
autoComplete="off"
aria-invalid={messagesPerField.existsError("username")}
/>
{messagesPerField.existsError("username") && (
<span id="input-error" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.getFirstError("username")}
</span>
)}
</div>
)}
const autoCompleteHelper: typeof label = label === "usernameOrEmail" ? "username" : label;
return (
<>
<label htmlFor={autoCompleteHelper} className={getClassName("kcLabelClass")}>
{msg(label)}
</label>
<input
tabIndex={1}
id={autoCompleteHelper}
className={getClassName("kcInputClass")}
//NOTE: This is used by Google Chrome auto fill so we use it to tell
//the browser how to pre fill the form but before submit we put it back
//to username because it is what keycloak expects.
name={autoCompleteHelper}
defaultValue={login.username ?? ""}
type="text"
autoFocus={true}
autoComplete="off"
/>
</>
);
})()}
</div>
<div className={clsx(getClassName("kcFormGroupClass"), getClassName("kcFormSettingClass"))}>
<div id="kc-form-options">
{realm.rememberMe && !usernameHidden && (
@ -109,21 +125,19 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
id="rememberMe"
name="rememberMe"
type="checkbox"
{...(login.rememberMe === "on"
? {
"checked": true
}
: {})}
/>
defaultChecked={!!login.rememberMe}
/>{" "}
{msg("rememberMe")}
</label>
</div>
)}
</div>
</div>
<div id="kc-form-buttons" className={getClassName("kcFormGroupClass")}>
<input
tabIndex={4}
disabled={isLoginButtonDisabled}
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
@ -134,33 +148,11 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
id="kc-login"
type="submit"
value={msgStr("doLogIn")}
disabled={isLoginButtonDisabled}
/>
</div>
</form>
)}
</div>
{realm.password && social.providers !== undefined && (
<div
id="kc-social-providers"
className={clsx(getClassName("kcFormSocialAccountContentClass"), getClassName("kcFormSocialAccountClass"))}
>
<ul
className={clsx(
getClassName("kcFormSocialAccountListClass"),
social.providers.length > 4 && getClassName("kcFormSocialAccountDoubleListClass")
)}
>
{social.providers.map(p => (
<li key={p.providerId} className={getClassName("kcFormSocialAccountListLinkClass")}>
<a href={p.loginUrl} id={`zocial-${p.alias}`} className={clsx("zocial", p.providerId)}>
<span>{p.displayName}</span>
</a>
</li>
))}
</ul>
</div>
)}
</div>
</Template>
);

View File

@ -10,15 +10,21 @@ export default function LoginVerifyEmail(props: PageProps<Extract<KcContext, { p
const { url, user } = kcContext;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("emailVerifyTitle")}>
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
displayInfo
headerNode={msg("emailVerifyTitle")}
infoNode={
<p className="instruction">
{msg("emailVerifyInstruction2")}
<br />
<a href={url.loginAction}>{msg("doClickHere")}</a>
&nbsp;
{msg("emailVerifyInstruction3")}
</p>
}
>
<p className="instruction">{msg("emailVerifyInstruction1", user?.email ?? "")}</p>
<p className="instruction">
{msg("emailVerifyInstruction2")}
<br />
<a href={url.loginAction}>{msg("doClickHere")}</a>
&nbsp;
{msg("emailVerifyInstruction3")}
</p>
</Template>
);
}

View File

@ -0,0 +1,94 @@
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function LoginX509Info(props: PageProps<Extract<KcContext, { pageId: "login-x509-info.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { url, x509 } = kcContext;
const { msg, msgStr } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("doLogIn")}>
<form id="kc-x509-login-info" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="certificate_subjectDN" className={getClassName("kcLabelClass")}>
{msg("clientCertificate")}
</label>
</div>
{x509.formData.subjectDN ? (
<div className={getClassName("kcLabelWrapperClass")}>
<label id="certificate_subjectDN" className={getClassName("kcLabelClass")}>
{x509.formData.subjectDN}
</label>
</div>
) : (
<div className={getClassName("kcLabelWrapperClass")}>
<label id="certificate_subjectDN" className={getClassName("kcLabelClass")}>
{msg("noCertificate")}
</label>
</div>
)}
</div>
<div className={getClassName("kcFormGroupClass")}>
{x509.formData.isUserEnabled && (
<>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="username" className={getClassName("kcLabelClass")}>
{msg("doX509Login")}
</label>
</div>
<div className={getClassName("kcLabelWrapperClass")}>
<label id="username" className={getClassName("kcLabelClass")}>
{x509.formData.username}
</label>
</div>
</>
)}
</div>
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")} />
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<div className={getClassName("kcFormButtonsWrapperClass")}>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
name="login"
id="kc-login"
type="submit"
value={msgStr("doContinue")}
/>
{x509.formData.isUserEnabled && (
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass")
)}
name="cancel"
id="kc-cancel"
type="submit"
value={msgStr("doIgnore")}
/>
)}
</div>
</div>
</div>
</form>
</Template>
);
}

View File

@ -17,7 +17,7 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
const { msg, msgStr } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("logoutConfirmTitle")}>
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("logoutConfirmTitle")}>
<div id="kc-logout-confirm" className="content-area">
<p className="instruction">{msg("logoutConfirmHeader")}</p>
<form className="form-actions" action={url.logoutConfirmAction} method="POST">
@ -46,7 +46,7 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
<div id="kc-info-message">
{!logoutConfirm.skipLink && client.baseUrl && (
<p>
<a href={client.baseUrl} dangerouslySetInnerHTML={{ __html: msgStr("backToApplication") }} />
<a href={client.baseUrl}>{msg("backToApplication")}</a>
</p>
)}
</div>

View File

@ -1,151 +1,52 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import { useTermsMarkdown } from "keycloakify/login/lib/useDownloadTerms";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFields";
import { Markdown } from "keycloakify/tools/Markdown";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function Register(props: PageProps<Extract<KcContext, { pageId: "register.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
type RegisterProps = PageProps<Extract<KcContext, { pageId: "register.ftl" }>, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
};
export default function Register(props: RegisterProps) {
const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { url, messagesPerField, register, realm, passwordRequired, recaptchaRequired, recaptchaSiteKey } = kcContext;
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey, termsAcceptanceRequired } = kcContext;
const { msg, msgStr } = i18n;
const [isFormSubmittable, setIsFormSubmittable] = useState(false);
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("registerTitle")}>
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("registerTitle")} displayRequiredFields>
<form id="kc-register-form" className={getClassName("kcFormClass")} action={url.registrationAction} method="post">
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("firstName", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="firstName" className={getClassName("kcLabelClass")}>
{msg("firstName")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="text"
id="firstName"
className={getClassName("kcInputClass")}
name="firstName"
defaultValue={register.formData.firstName ?? ""}
/>
</div>
</div>
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("lastName", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="lastName" className={getClassName("kcLabelClass")}>
{msg("lastName")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="text"
id="lastName"
className={getClassName("kcInputClass")}
name="lastName"
defaultValue={register.formData.lastName ?? ""}
/>
</div>
</div>
<div
className={clsx(getClassName("kcFormGroupClass"), messagesPerField.printIfExists("email", getClassName("kcFormGroupErrorClass")))}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="email" className={getClassName("kcLabelClass")}>
{msg("email")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="text"
id="email"
className={getClassName("kcInputClass")}
name="email"
defaultValue={register.formData.email ?? ""}
autoComplete="email"
/>
</div>
</div>
{!realm.registrationEmailAsUsername && (
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("username", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="username" className={getClassName("kcLabelClass")}>
{msg("username")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="text"
id="username"
className={getClassName("kcInputClass")}
name="username"
defaultValue={register.formData.username ?? ""}
autoComplete="username"
/>
</div>
</div>
)}
{passwordRequired && (
<>
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("password", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="password" className={getClassName("kcLabelClass")}>
{msg("password")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="password"
id="password"
className={getClassName("kcInputClass")}
name="password"
autoComplete="new-password"
/>
</div>
</div>
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("password-confirm", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="password-confirm" className={getClassName("kcLabelClass")}>
{msg("passwordConfirm")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input type="password" id="password-confirm" className={getClassName("kcInputClass")} name="password-confirm" />
</div>
</div>
</>
<UserProfileFormFields
{...{
kcContext,
i18n,
getClassName,
messagesPerField
}}
onIsFormSubmittableValueChange={setIsFormSubmittable}
/>
{termsAcceptanceRequired && (
<TermsAcceptance
{...{
i18n,
getClassName,
messagesPerField
}}
/>
)}
{recaptchaRequired && (
<div className="form-group">
@ -162,9 +63,9 @@ export default function Register(props: PageProps<Extract<KcContext, { pageId: "
</span>
</div>
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<input
disabled={!isFormSubmittable}
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
@ -180,3 +81,54 @@ export default function Register(props: PageProps<Extract<KcContext, { pageId: "
</Template>
);
}
function TermsAcceptance(props: {
i18n: I18n;
getClassName: ReturnType<typeof useGetClassName>["getClassName"];
messagesPerField: Pick<KcContext["messagesPerField"], "existsError" | "get">;
}) {
const { i18n, getClassName, messagesPerField } = props;
const { msg } = i18n;
// NOTE: Refer to https://docs.keycloakify.dev/terms-and-conditions to load your terms and conditions.
const { termsMarkdown } = useTermsMarkdown();
if (termsMarkdown === undefined) {
return null;
}
return (
<>
<div className="form-group">
<div className={getClassName("kcInputWrapperClass")}>
{msg("termsTitle")}
<div id="kc-registration-terms-text">
<Markdown>{termsMarkdown}</Markdown>
</div>
</div>
</div>
<div className="form-group">
<div className={getClassName("kcLabelWrapperClass")}>
<input
type="checkbox"
id="termsAccepted"
name="termsAccepted"
className={getClassName("kcCheckboxInputClass")}
aria-invalid={messagesPerField.existsError("termsAccepted")}
/>
<label htmlFor="termsAccepted" className={getClassName("kcLabelClass")}>
{msg("acceptTerms")}
</label>
</div>
{messagesPerField.existsError("termsAccepted") && (
<div className={getClassName("kcLabelWrapperClass")}>
<span id="input-error-terms-accepted" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("termsAccepted")}
</span>
</div>
)}
</div>
</>
);
}

View File

@ -1,72 +0,0 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { UserProfileFormFields } from "./shared/UserProfileFormFields";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function RegisterUserProfile(props: PageProps<Extract<KcContext, { pageId: "register-user-profile.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey, realm } = kcContext;
realm.registrationEmailAsUsername;
const { msg, msgStr } = i18n;
const [isFormSubmittable, setIsFormSubmittable] = useState(false);
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
displayMessage={messagesPerField.exists("global")}
displayRequiredFields={true}
headerNode={msg("registerTitle")}
>
<form id="kc-register-form" className={getClassName("kcFormClass")} action={url.registrationAction} method="post">
<UserProfileFormFields
kcContext={kcContext}
onIsFormSubmittableValueChange={setIsFormSubmittable}
i18n={i18n}
getClassName={getClassName}
/>
{recaptchaRequired && (
<div className="form-group">
<div className={getClassName("kcInputWrapperClass")}>
<div className="g-recaptcha" data-size="compact" data-sitekey={recaptchaSiteKey} />
</div>
</div>
)}
<div className={getClassName("kcFormGroupClass")} style={{ "marginBottom": 30 }}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}>
<span>
<a href={url.loginUrl}>{msg("backToLogin")}</a>
</span>
</div>
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
value={msgStr("doRegister")}
disabled={!isFormSubmittable}
/>
</div>
</div>
</form>
</Template>
);
}

View File

@ -26,7 +26,7 @@ export default function SamlPostForm(props: PageProps<Extract<KcContext, { pageI
htmlFormElement.submit();
}, [htmlFormElement]);
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("saml.post-form.title")}>
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("saml.post-form.title")}>
<p>{msg("saml.post-form.message")}</p>
<form name="saml-post-binding" method="post" action={samlPost.url} ref={setHtmlFormElement}>
{samlPost.SAMLRequest && <input type="hidden" name="SAMLRequest" value={samlPost.SAMLRequest} />}

View File

@ -1,9 +1,8 @@
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "keycloakify/login/kcContext";
import type { I18n } from "keycloakify/login/i18n";
import { MouseEvent, useRef } from "react";
import { useConstCallback } from "keycloakify/tools/useConstCallback";
export default function SelectAuthenticator(props: PageProps<Extract<KcContext, { pageId: "select-authenticator.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -12,60 +11,38 @@ export default function SelectAuthenticator(props: PageProps<Extract<KcContext,
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const { msg } = i18n;
const selectCredentialsForm = useRef<HTMLFormElement>(null);
const authExecIdInput = useRef<HTMLInputElement>(null);
const submitForm = useConstCallback(() => {
selectCredentialsForm.current?.submit();
});
const onSelectedAuthenticator = useConstCallback((event: MouseEvent<HTMLDivElement>) => {
const divElement = event.currentTarget;
const authExecId = divElement.dataset.authExecId;
if (!authExecIdInput.current || !authExecId) {
return;
}
authExecIdInput.current.value = authExecId;
submitForm();
});
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("loginChooseAuthenticator")}>
<form
id="kc-select-credential-form"
className={getClassName("kcFormClass")}
ref={selectCredentialsForm}
action={url.loginAction}
method="post"
>
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayInfo={false} headerNode={msg("loginChooseAuthenticator")}>
<form id="kc-select-credential-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
<div className={getClassName("kcSelectAuthListClass")}>
{auth.authenticationSelections.map((authenticationSelection, index) => (
<div key={index} className={getClassName("kcSelectAuthListItemClass")}>
<div
style={{ cursor: "pointer" }}
onClick={onSelectedAuthenticator}
data-auth-exec-id={authenticationSelection.authExecId}
className={getClassName("kcSelectAuthListItemInfoClass")}
>
<div className={getClassName("kcSelectAuthListItemLeftClass")}>
<span className={getClassName(authenticationSelection.iconCssClass ?? "kcAuthenticatorDefaultClass")}></span>
</div>
<div className={getClassName("kcSelectAuthListItemBodyClass")}>
<div className={getClassName("kcSelectAuthListItemDescriptionClass")}>
<div className={getClassName("kcSelectAuthListItemHeadingClass")}>
{msg(authenticationSelection.displayName)}
</div>
<div className={getClassName("kcSelectAuthListItemHelpTextClass")}>
{msg(authenticationSelection.helpText)}
</div>
</div>
</div>
{auth.authenticationSelections.map((authenticationSelection, i) => (
<button
key={i}
className={getClassName("kcSelectAuthListItemClass")}
type="submit"
name="authenticationExecution"
value={authenticationSelection.authExecId}
>
<div className={getClassName("kcSelectAuthListItemIconClass")}>
<i
className={clsx(
// @ts-expect-error: iconCssClass is a string and not a class key
// however getClassName gracefully handles this case at runtime
getClassName(authenticationSelection.iconCssClass),
getClassName("kcSelectAuthListItemIconPropertyClass")
)}
/>
</div>
</div>
<div className={getClassName("kcSelectAuthListItemBodyClass")}>
<div className={getClassName("kcSelectAuthListItemHeadingClass")}>{msg(authenticationSelection.displayName)}</div>
<div className={getClassName("kcSelectAuthListItemDescriptionClass")}>{msg(authenticationSelection.helpText)}</div>
</div>
<div className={getClassName("kcSelectAuthListItemFillClass")} />
<div className={getClassName("kcSelectAuthListItemArrowClass")}>
<i className={getClassName("kcSelectAuthListItemArrowIconClass")} />
</div>
</button>
))}
<input type="hidden" id="authexec-hidden-input" name="authenticationExecution" ref={authExecIdInput} />
</div>
</form>
</Template>

View File

@ -1,9 +1,8 @@
import { clsx } from "keycloakify/tools/clsx";
import { useRerenderOnStateChange } from "evt/hooks";
import { Markdown } from "keycloakify/tools/Markdown";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { evtTermMarkdown } from "keycloakify/login/lib/useDownloadTerms";
import { useTermsMarkdown } from "keycloakify/login/lib/useDownloadTerms";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
@ -17,20 +16,18 @@ export default function Terms(props: PageProps<Extract<KcContext, { pageId: "ter
const { msg, msgStr } = i18n;
useRerenderOnStateChange(evtTermMarkdown);
const { url } = kcContext;
const termMarkdown = evtTermMarkdown.state;
const { termsMarkdown } = useTermsMarkdown();
if (termMarkdown === undefined) {
if (termsMarkdown === undefined) {
return null;
}
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("termsTitle")}>
<div id="kc-terms-text">
<Markdown>{termMarkdown}</Markdown>
<Markdown>{termsMarkdown}</Markdown>
</div>
<form className="form-actions" action={url.loginAction} method="POST">
<input

View File

@ -1,11 +1,18 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFields";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function UpdateEmail(props: PageProps<Extract<KcContext, { pageId: "update-email.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
type UpdateEmailProps = PageProps<Extract<KcContext, { pageId: "update-email.ftl" }>, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
};
export default function UpdateEmail(props: UpdateEmailProps) {
const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
@ -14,71 +21,60 @@ export default function UpdateEmail(props: PageProps<Extract<KcContext, { pageId
const { msg, msgStr } = i18n;
const { url, messagesPerField, isAppInitiatedAction, email } = kcContext;
const [isFormSubmittable, setIsFormSubmittable] = useState(false);
const { url, messagesPerField, isAppInitiatedAction } = kcContext;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("updateEmailTitle")}>
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
displayMessage={messagesPerField.exists("global")}
displayRequiredFields
headerNode={msg("updateEmailTitle")}
>
<form id="kc-update-email-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
<div
className={clsx(getClassName("kcFormGroupClass"), messagesPerField.printIfExists("email", getClassName("kcFormGroupErrorClass")))}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="email" className={getClassName("kcLabelClass")}>
{msg("email")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="text"
id="email"
name="email"
defaultValue={email.value ?? ""}
className={getClassName("kcInputClass")}
aria-invalid={messagesPerField.existsError("email")}
/>
</div>
</div>
<UserProfileFormFields
{...{
kcContext,
i18n,
getClassName,
messagesPerField
}}
onIsFormSubmittableValueChange={setIsFormSubmittable}
/>
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}></div>
<div className={getClassName("kcFormOptionsWrapperClass")} />
</div>
<LogoutOtherSessions {...{ getClassName, i18n }} />
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
{isAppInitiatedAction ? (
<>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
defaultValue={msgStr("doSubmit")}
/>
<button
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
name="cancel-aia"
value="true"
>
{msg("doCancel")}
</button>
</>
) : (
<input
<input
disabled={!isFormSubmittable}
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
isAppInitiatedAction && getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
value={msgStr("doSubmit")}
/>
{isAppInitiatedAction && (
<button
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
defaultValue={msgStr("doSubmit")}
/>
name="cancel-aia"
value="true"
>
{msg("doCancel")}
</button>
)}
</div>
</div>
@ -86,3 +82,22 @@ export default function UpdateEmail(props: PageProps<Extract<KcContext, { pageId
</Template>
);
}
function LogoutOtherSessions(props: { getClassName: ReturnType<typeof useGetClassName>["getClassName"]; i18n: I18n }) {
const { getClassName, i18n } = props;
const { msg } = i18n;
return (
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}>
<div className="checkbox">
<label>
<input type="checkbox" id="logout-sessions" name="logout-sessions" value="on" defaultChecked={true} />
{msg("logoutOtherSessions")}
</label>
</div>
</div>
</div>
);
}

View File

@ -1,82 +0,0 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { UserProfileFormFields } from "keycloakify/login/pages/shared/UserProfileFormFields";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function UpdateUserProfile(props: PageProps<Extract<KcContext, { pageId: "update-user-profile.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { msg, msgStr } = i18n;
const { url, isAppInitiatedAction } = kcContext;
const [isFomSubmittable, setIsFomSubmittable] = useState(false);
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("loginProfileTitle")}>
<form id="kc-update-profile-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
<UserProfileFormFields
kcContext={kcContext}
onIsFormSubmittableValueChange={setIsFomSubmittable}
i18n={i18n}
getClassName={getClassName}
/>
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}></div>
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
{isAppInitiatedAction ? (
<>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
value={msgStr("doSubmit")}
/>
<button
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
name="cancel-aia"
value="true"
formNoValidate
>
{msg("doCancel")}
</button>
</>
) : (
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
defaultValue={msgStr("doSubmit")}
disabled={!isFomSubmittable}
/>
)}
</div>
</div>
</form>
</Template>
);
}

View File

@ -1,204 +1,244 @@
import { useRef, useState } from "react";
import { useEffect, Fragment } from "react";
import { clsx } from "keycloakify/tools/clsx";
import type { MessageKey } from "keycloakify/login/i18n/i18n";
import { base64url } from "rfc4648";
import { useConstCallback } from "keycloakify/tools/useConstCallback";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { assert } from "tsafe/assert";
import { createUseInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import { typeGuard } from "tsafe/typeGuard";
const { useInsertScriptTags } = createUseInsertScriptTags();
export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext, { pageId: "webauthn-authenticate.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const { url } = kcContext;
const {
url,
isUserIdentified,
challenge,
userVerification,
rpId,
createTimeout,
messagesPerField,
realm,
registrationDisabled,
authenticators,
shouldDisplayAuthenticators
} = kcContext;
const { msg, msgStr } = i18n;
const { authenticators, challenge, shouldDisplayAuthenticators, userVerification, rpId } = kcContext;
const createTimeout = Number(kcContext.createTimeout);
const isUserIdentified = kcContext.isUserIdentified == "true";
const { insertScriptTags } = useInsertScriptTags({
"scriptTags": [
{
"type": "text/javascript",
"src": `${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js`
},
{
"type": "text/javascript",
"src": `${url.resourcesPath}/js/base64url.js`
},
{
"type": "text/javascript",
"textContent": `
const formElementRef = useRef<HTMLFormElement>(null);
function webAuthnAuthenticate() {
let isUserIdentified = ${isUserIdentified};
if (!isUserIdentified) {
doAuthenticate([]);
return;
}
checkAllowCredentials();
}
const webAuthnAuthenticate = useConstCallback(async () => {
if (!isUserIdentified) {
return;
}
function checkAllowCredentials() {
let allowCredentials = [];
let authn_use = document.forms['authn_select'].authn_use_chk;
if (authn_use !== undefined) {
if (authn_use.length === undefined) {
allowCredentials.push({
id: base64url.decode(authn_use.value, {loose: true}),
type: 'public-key',
});
} else {
for (let i = 0; i < authn_use.length; i++) {
allowCredentials.push({
id: base64url.decode(authn_use[i].value, {loose: true}),
type: 'public-key',
});
}
}
}
doAuthenticate(allowCredentials);
}
const submitForm = async (): Promise<void> => {
const formElement = formElementRef.current;
if (formElement === null) {
await new Promise(resolve => setTimeout(resolve, 100));
return submitForm();
function doAuthenticate(allowCredentials) {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
$("#error").val("${msgStr("webauthn-unsupported-browser-text")}");
$("#webauth").submit();
return;
}
let challenge = "${challenge}";
let userVerification = "${userVerification}";
let rpId = "${rpId}";
let publicKey = {
rpId : rpId,
challenge: base64url.decode(challenge, { loose: true })
};
let createTimeout = ${createTimeout};
if (createTimeout !== 0) publicKey.timeout = createTimeout * 1000;
if (allowCredentials.length) {
publicKey.allowCredentials = allowCredentials;
}
if (userVerification !== 'not specified') publicKey.userVerification = userVerification;
navigator.credentials.get({publicKey})
.then((result) => {
window.result = result;
let clientDataJSON = result.response.clientDataJSON;
let authenticatorData = result.response.authenticatorData;
let signature = result.response.signature;
$("#clientDataJSON").val(base64url.encode(new Uint8Array(clientDataJSON), { pad: false }));
$("#authenticatorData").val(base64url.encode(new Uint8Array(authenticatorData), { pad: false }));
$("#signature").val(base64url.encode(new Uint8Array(signature), { pad: false }));
$("#credentialId").val(result.id);
if(result.response.userHandle) {
$("#userHandle").val(base64url.encode(new Uint8Array(result.response.userHandle), { pad: false }));
}
$("#webauth").submit();
})
.catch((err) => {
$("#error").val(err);
$("#webauth").submit();
})
;
}
`
}
formElement.submit();
};
const allowCredentials = authenticators.authenticators.map(
authenticator =>
({
id: base64url.parse(authenticator.credentialId, { loose: true }),
type: "public-key"
} as PublicKeyCredentialDescriptor)
);
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
setError(msgStr("webauthn-unsupported-browser-text"));
submitForm();
return;
}
const publicKey: PublicKeyCredentialRequestOptions = {
rpId,
challenge: base64url.parse(challenge, { loose: true })
};
if (createTimeout !== 0) {
publicKey.timeout = createTimeout * 1000;
}
if (allowCredentials.length) {
publicKey.allowCredentials = allowCredentials;
}
if (userVerification !== "not specified") {
publicKey.userVerification = userVerification;
}
try {
const result = await navigator.credentials.get({ publicKey });
if (!result || result.type != "public-key") {
return;
}
assert(is<PublicKeyCredential>(result));
if (!("authenticatorData" in result.response)) {
return;
}
const response = result.response;
const clientDataJSON = response.clientDataJSON;
assert(
typeGuard<AuthenticatorAssertionResponse>(response, "signature" in response && response.authenticatorData instanceof ArrayBuffer),
"response not an AuthenticatorAssertionResponse"
);
const authenticatorData = response.authenticatorData;
const signature = response.signature;
setClientDataJSON(base64url.stringify(new Uint8Array(clientDataJSON), { "pad": false }));
setAuthenticatorData(base64url.stringify(new Uint8Array(authenticatorData), { "pad": false }));
setSignature(base64url.stringify(new Uint8Array(signature), { "pad": false }));
setCredentialId(result.id);
setUserHandle(base64url.stringify(new Uint8Array(response.userHandle!), { "pad": false }));
} catch (err) {
setError(String(err));
}
submitForm();
]
});
const [clientDataJSON, setClientDataJSON] = useState("");
const [authenticatorData, setAuthenticatorData] = useState("");
const [signature, setSignature] = useState("");
const [credentialId, setCredentialId] = useState("");
const [userHandle, setUserHandle] = useState("");
const [error, setError] = useState("");
useEffect(() => {
insertScriptTags();
}, []);
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("webauthn-login-title")}>
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
displayMessage={!messagesPerField.existsError("username")}
displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled}
infoNode={
<div id="kc-registration">
<span>
{msg("noAccount")}{" "}
<a tabIndex={6} href={url.registrationUrl}>
{msg("doRegister")}
</a>
</span>
</div>
}
headerNode={msg("webauthn-login-title")}
>
<div id="kc-form-webauthn" className={getClassName("kcFormClass")}>
<form id="webauth" action={url.loginAction} ref={formElementRef} method="post">
<input type="hidden" id="clientDataJSON" name="clientDataJSON" value={clientDataJSON} />
<input type="hidden" id="authenticatorData" name="authenticatorData" value={authenticatorData} />
<input type="hidden" id="signature" name="signature" value={signature} />
<input type="hidden" id="credentialId" name="credentialId" value={credentialId} />
<input type="hidden" id="userHandle" name="userHandle" value={userHandle} />
<input type="hidden" id="error" name="error" value={error} />
<form id="webauth" action={url.loginAction} method="post">
<input type="hidden" id="clientDataJSON" name="clientDataJSON" />
<input type="hidden" id="authenticatorData" name="authenticatorData" />
<input type="hidden" id="signature" name="signature" />
<input type="hidden" id="credentialId" name="credentialId" />
<input type="hidden" id="userHandle" name="userHandle" />
<input type="hidden" id="error" name="error" />
</form>
<div className={getClassName("kcFormGroupClass")}>
{authenticators &&
(() => (
<div className={clsx(getClassName("kcFormGroupClass"), "no-bottom-margin")}>
{authenticators && (
<>
<form id="authn_select" className={getClassName("kcFormClass")}>
{authenticators.authenticators.map(authenticator => (
<input type="hidden" name="authn_use_chk" value={authenticator.credentialId} key={authenticator.credentialId} />
<input type="hidden" name="authn_use_chk" value={authenticator.credentialId} />
))}
</form>
))()}
{authenticators &&
shouldDisplayAuthenticators &&
(() => (
<>
{authenticators.authenticators.length > 1 && (
<p className={getClassName("kcSelectAuthListItemTitle")}>{msg("webauthn-available-authenticators")}</p>
)}
<div className={getClassName("kcFormClass")}>
{authenticators.authenticators.map(authenticator => (
<div id="kc-webauthn-authenticator" className={getClassName("kcSelectAuthListItemClass")}>
<div className={getClassName("kcSelectAuthListItemIconClass")}>
<i
className={clsx(
(() => {
const className = getClassName(authenticator.transports.iconClass as any);
return className.includes(" ")
? className
: [className, getClassName("kcWebAuthnDefaultIcon")];
})(),
getClassName("kcSelectAuthListItemIconPropertyClass")
)}
/>
</div>
<div className={getClassName("kcSelectAuthListItemBodyClass")}>
<div
id="kc-webauthn-authenticator-label"
className={getClassName("kcSelectAuthListItemHeadingClass")}
>
{authenticator.label}
</div>
{authenticator.transports && authenticator.transports.displayNameProperties.length && (
<div
id="kc-webauthn-authenticator-transport"
className={getClassName("kcSelectAuthListItemDescriptionClass")}
>
{authenticator.transports.displayNameProperties.map(
(transport: MessageKey, index: number) => (
<>
<span>{msg(transport)}</span>
{index < authenticator.transports.displayNameProperties.length - 1 && (
<span>{", "}</span>
)}
</>
)
{shouldDisplayAuthenticators && (
<>
{authenticators.authenticators.length > 1 && (
<p className={getClassName("kcSelectAuthListItemTitle")}>{msg("webauthn-available-authenticators")}</p>
)}
<div className={getClassName("kcFormOptionsClass")}>
{authenticators.authenticators.map((authenticator, i) => (
<div key={i} id="kc-webauthn-authenticator" className={getClassName("kcSelectAuthListItemClass")}>
<div className={getClassName("kcSelectAuthListItemIconClass")}>
<i
className={clsx(
(() => {
const className = getClassName(authenticator.transports.iconClass as any);
if (className === authenticator.transports.iconClass) {
return getClassName("kcWebAuthnDefaultIcon");
}
return className;
})(),
getClassName("kcSelectAuthListItemIconPropertyClass")
)}
/>
</div>
<div className={getClassName("kcSelectAuthListItemArrowIconClass")}>
<div
id="kc-webauthn-authenticator-label"
className={getClassName("kcSelectAuthListItemHeadingClass")}
>
{msg(authenticator.label as MessageKey)}
</div>
)}
<div className={getClassName("kcSelectAuthListItemDescriptionClass")}>
<span id="kc-webauthn-authenticator-created-label">{msg("webauthn-createdAt-label")}</span>
<span id="kc-webauthn-authenticator-created">{authenticator.createdAt}</span>
{authenticator.transports.displayNameProperties?.length && (
<div
id="kc-webauthn-authenticator-transport"
className={getClassName("kcSelectAuthListItemDescriptionClass")}
>
{authenticator.transports.displayNameProperties
.map((nameProperty, i, arr) => ({ nameProperty, "hasNext": i !== arr.length - 1 }))
.map(({ nameProperty, hasNext }) => (
<Fragment key={nameProperty}>
<span>{msg(nameProperty)}</span>
{hasNext && <span>, </span>}
</Fragment>
))}
</div>
)}
<div className={getClassName("kcSelectAuthListItemDescriptionClass")}>
<span id="kc-webauthn-authenticator-created-label">{msg("webauthn-createdAt-label")}</span>
<span id="kc-webauthn-authenticator-created">{authenticator.createdAt}</span>
</div>
<div className={getClassName("kcSelectAuthListItemFillClass")} />
</div>
</div>
<div className={getClassName("kcSelectAuthListItemFillClass")} />
</div>
))}
</div>
</>
))()}
))}
</div>
</>
)}
</>
)}
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<input
id="authenticateWebAuthnButton"
type="button"
onClick={webAuthnAuthenticate}
autoFocus={true}
onClick={() => {
assert("webAuthnAuthenticate" in window);
assert(typeof window.webAuthnAuthenticate === "function");
window.webAuthnAuthenticate();
}}
autoFocus
value={msgStr("webauthn-doAuthenticate")}
className={clsx(
getClassName("kcButtonClass"),

View File

@ -0,0 +1,66 @@
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { clsx } from "keycloakify/tools/clsx";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function WebauthnError(props: PageProps<Extract<KcContext, { pageId: "webauthn-error.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { url, isAppInitiatedAction } = kcContext;
const { msg, msgStr } = i18n;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage headerNode={msg("webauthn-error-title")}>
<form id="kc-error-credential-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
<input type="hidden" id="executionValue" name="authenticationExecution" />
<input type="hidden" id="isSetRetry" name="isSetRetry" />
</form>
<input
tabIndex={4}
onClick={() => {
// @ts-expect-error: Trusted Keycloak's code
document.getElementById("isSetRetry").value = "retry";
// @ts-expect-error: Trusted Keycloak's code
document.getElementById("executionValue").value = "${execution}";
// @ts-expect-error: Trusted Keycloak's code
document.getElementById("kc-error-credential-form").submit();
}}
type="button"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
name="try-again"
id="kc-try-again"
value={msgStr("doTryAgain")}
/>
{isAppInitiatedAction && (
<form action={url.loginAction} className={getClassName("kcFormClass")} id="kc-webauthn-settings-form" method="post">
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
id="cancelWebAuthnAIA"
name="cancel-aia"
value="true"
>
{msgStr("doCancel")}
</button>
</form>
)}
</Template>
);
}

View File

@ -0,0 +1,285 @@
import { useEffect } from "react";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { assert } from "tsafe/assert";
import { createUseInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
const { useInsertScriptTags } = createUseInsertScriptTags();
export default function WebauthnRegister(props: PageProps<Extract<KcContext, { pageId: "webauthn-register.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const {
url,
challenge,
userid,
username,
signatureAlgorithms,
rpEntityName,
rpId,
attestationConveyancePreference,
authenticatorAttachment,
requireResidentKey,
userVerificationRequirement,
createTimeout,
excludeCredentialIds,
isSetRetry,
isAppInitiatedAction
} = kcContext;
const { msg, msgStr } = i18n;
const { insertScriptTags } = useInsertScriptTags({
"scriptTags": [
{
"type": "text/javascript",
"src": `${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js`
},
{
"type": "text/javascript",
"src": `${url.resourcesPath}/js/base64url.js`
},
{
"type": "text/javascript",
"textContent": `
function registerSecurityKey() {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
$("#error").val("${msgStr("webauthn-unsupported-browser-text")}");
$("#register").submit();
return;
}
// mandatory parameters
let challenge = "${challenge}";
let userid = "${userid}";
let username = "${username}";
let signatureAlgorithms =${JSON.stringify(signatureAlgorithms)};
let pubKeyCredParams = getPubKeyCredParams(signatureAlgorithms);
let rpEntityName = "${rpEntityName}";
let rp = {name: rpEntityName};
let publicKey = {
challenge: base64url.decode(challenge, {loose: true}),
rp: rp,
user: {
id: base64url.decode(userid, {loose: true}),
name: username,
displayName: username
},
pubKeyCredParams: pubKeyCredParams,
};
// optional parameters
let rpId = "${rpId}";
publicKey.rp.id = rpId;
let attestationConveyancePreference = "${attestationConveyancePreference}";
if (attestationConveyancePreference !== 'not specified') publicKey.attestation = attestationConveyancePreference;
let authenticatorSelection = {};
let isAuthenticatorSelectionSpecified = false;
let authenticatorAttachment = "${authenticatorAttachment}";
if (authenticatorAttachment !== 'not specified') {
authenticatorSelection.authenticatorAttachment = authenticatorAttachment;
isAuthenticatorSelectionSpecified = true;
}
let requireResidentKey = "${requireResidentKey}";
if (requireResidentKey !== 'not specified') {
if (requireResidentKey === 'Yes')
authenticatorSelection.requireResidentKey = true;
else
authenticatorSelection.requireResidentKey = false;
isAuthenticatorSelectionSpecified = true;
}
let userVerificationRequirement = "${userVerificationRequirement}";
if (userVerificationRequirement !== 'not specified') {
authenticatorSelection.userVerification = userVerificationRequirement;
isAuthenticatorSelectionSpecified = true;
}
if (isAuthenticatorSelectionSpecified) publicKey.authenticatorSelection = authenticatorSelection;
let createTimeout = ${createTimeout};
if (createTimeout !== 0) publicKey.timeout = createTimeout * 1000;
let excludeCredentialIds = "${excludeCredentialIds}";
let excludeCredentials = getExcludeCredentials(excludeCredentialIds);
if (excludeCredentials.length > 0) publicKey.excludeCredentials = excludeCredentials;
navigator.credentials.create({publicKey})
.then(function (result) {
window.result = result;
let clientDataJSON = result.response.clientDataJSON;
let attestationObject = result.response.attestationObject;
let publicKeyCredentialId = result.rawId;
$("#clientDataJSON").val(base64url.encode(new Uint8Array(clientDataJSON), {pad: false}));
$("#attestationObject").val(base64url.encode(new Uint8Array(attestationObject), {pad: false}));
$("#publicKeyCredentialId").val(base64url.encode(new Uint8Array(publicKeyCredentialId), {pad: false}));
if (typeof result.response.getTransports === "function") {
let transports = result.response.getTransports();
if (transports) {
$("#transports").val(getTransportsAsString(transports));
}
} else {
console.log("Your browser is not able to recognize supported transport media for the authenticator.");
}
let initLabel = "WebAuthn Authenticator (Default Label)";
let labelResult = window.prompt("Please input your registered authenticator's label", initLabel);
if (labelResult === null) labelResult = initLabel;
$("#authenticatorLabel").val(labelResult);
$("#register").submit();
})
.catch(function (err) {
$("#error").val(err);
$("#register").submit();
});
}
function getPubKeyCredParams(signatureAlgorithmsList) {
let pubKeyCredParams = [];
if (signatureAlgorithmsList === []) {
pubKeyCredParams.push({type: "public-key", alg: -7});
return pubKeyCredParams;
}
for (let i = 0; i < signatureAlgorithmsList.length; i++) {
pubKeyCredParams.push({
type: "public-key",
alg: signatureAlgorithmsList[i]
});
}
return pubKeyCredParams;
}
function getExcludeCredentials(excludeCredentialIds) {
let excludeCredentials = [];
if (excludeCredentialIds === "") return excludeCredentials;
let excludeCredentialIdsList = excludeCredentialIds.split(',');
for (let i = 0; i < excludeCredentialIdsList.length; i++) {
excludeCredentials.push({
type: "public-key",
id: base64url.decode(excludeCredentialIdsList[i],
{loose: true})
});
}
return excludeCredentials;
}
function getTransportsAsString(transportsList) {
if (transportsList === '' || transportsList.constructor !== Array) return "";
let transportsString = "";
for (let i = 0; i < transportsList.length; i++) {
transportsString += transportsList[i] + ",";
}
return transportsString.slice(0, -1);
}
`
}
]
});
useEffect(() => {
insertScriptTags();
}, []);
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
headerNode={
<>
<span className={getClassName("kcWebAuthnKeyIcon")} />
{msg("webauthn-registration-title")}
</>
}
>
<form id="register" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
<input type="hidden" id="clientDataJSON" name="clientDataJSON" />
<input type="hidden" id="attestationObject" name="attestationObject" />
<input type="hidden" id="publicKeyCredentialId" name="publicKeyCredentialId" />
<input type="hidden" id="authenticatorLabel" name="authenticatorLabel" />
<input type="hidden" id="transports" name="transports" />
<input type="hidden" id="error" name="error" />
<LogoutOtherSessions {...{ i18n, getClassName }} />
</div>
</form>
<input
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
id="registerWebAuthn"
value={msgStr("doRegisterSecurityKey")}
onClick={() => {
assert("registerSecurityKey" in window);
assert(typeof window.registerSecurityKey === "function");
window.registerSecurityKey();
}}
/>
{!isSetRetry && isAppInitiatedAction && (
<form action={url.loginAction} className={getClassName("kcFormClass")} id="kc-webauthn-settings-form" method="post">
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
id="cancelWebAuthnAIA"
name="cancel-aia"
value="true"
>
{msg("doCancel")}
</button>
</form>
)}
</Template>
);
}
function LogoutOtherSessions(props: { i18n: I18n; getClassName: ReturnType<typeof useGetClassName>["getClassName"] }) {
const { getClassName, i18n } = props;
const { msg } = i18n;
return (
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}>
<div className="checkbox">
<label>
<input type="checkbox" id="logout-sessions" name="logout-sessions" value="on" defaultChecked={true} />
{msg("logoutOtherSessions")}
</label>
</div>
</div>
</div>
);
}

View File

@ -1,177 +0,0 @@
import { useEffect, Fragment } from "react";
import type { ClassKey } from "keycloakify/login/TemplateProps";
import { clsx } from "keycloakify/tools/clsx";
import { useFormValidation } from "keycloakify/login/lib/useFormValidation";
import type { Attribute } from "keycloakify/login/kcContext/KcContext";
import type { I18n } from "../../i18n";
export type UserProfileFormFieldsProps = {
kcContext: Parameters<typeof useFormValidation>[0]["kcContext"];
i18n: I18n;
getClassName: (classKey: ClassKey) => string;
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
BeforeField?: (props: { attribute: Attribute }) => JSX.Element | null;
AfterField?: (props: { attribute: Attribute }) => JSX.Element | null;
};
export function UserProfileFormFields(props: UserProfileFormFieldsProps) {
const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props;
const { advancedMsg, msg } = i18n;
const {
formValidationState: { fieldStateByAttributeName, isFormSubmittable },
formValidationDispatch,
attributesWithPassword
} = useFormValidation({
kcContext,
i18n
});
useEffect(() => {
onIsFormSubmittableValueChange(isFormSubmittable);
}, [isFormSubmittable]);
let currentGroup = "";
return (
<>
{attributesWithPassword.map((attribute, i) => {
const { group = "", groupDisplayHeader = "", groupDisplayDescription = "" } = attribute;
const { value, displayableErrors } = fieldStateByAttributeName[attribute.name];
const formGroupClassName = clsx(
getClassName("kcFormGroupClass"),
displayableErrors.length !== 0 && getClassName("kcFormGroupErrorClass")
);
return (
<Fragment key={i}>
{group !== currentGroup && (currentGroup = group) !== "" && (
<div className={formGroupClassName}>
<div className={getClassName("kcContentWrapperClass")}>
<label id={`header-${group}`} className={getClassName("kcFormGroupHeader")}>
{advancedMsg(groupDisplayHeader) || currentGroup}
</label>
</div>
{groupDisplayDescription !== "" && (
<div className={getClassName("kcLabelWrapperClass")}>
<label id={`description-${group}`} className={getClassName("kcLabelClass")}>
{advancedMsg(groupDisplayDescription)}
</label>
</div>
)}
</div>
)}
{BeforeField && <BeforeField attribute={attribute} />}
<div className={formGroupClassName}>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor={attribute.name} className={getClassName("kcLabelClass")}>
{advancedMsg(attribute.displayName ?? "")}
</label>
{attribute.required && <>*</>}
</div>
<div className={getClassName("kcInputWrapperClass")}>
{(() => {
const { options } = attribute.validators;
if (options !== undefined) {
return (
<select
id={attribute.name}
name={attribute.name}
onChange={event =>
formValidationDispatch({
"action": "update value",
"name": attribute.name,
"newValue": event.target.value
})
}
onBlur={() =>
formValidationDispatch({
"action": "focus lost",
"name": attribute.name
})
}
value={value}
>
<>
<option value="" selected disabled hidden>
{msg("selectAnOption")}
</option>
{options.options.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</>
</select>
);
}
return (
<input
type={(() => {
switch (attribute.name) {
case "password-confirm":
case "password":
return "password";
default:
return "text";
}
})()}
id={attribute.name}
name={attribute.name}
value={value}
onChange={event =>
formValidationDispatch({
"action": "update value",
"name": attribute.name,
"newValue": event.target.value
})
}
onBlur={() =>
formValidationDispatch({
"action": "focus lost",
"name": attribute.name
})
}
className={getClassName("kcInputClass")}
aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly}
autoComplete={attribute.autocomplete}
/>
);
})()}
{displayableErrors.length !== 0 &&
(() => {
const divId = `input-error-${attribute.name}`;
return (
<>
<style>{`#${divId} > span: { display: block; }`}</style>
<span
id={divId}
className={getClassName("kcInputErrorMessageClass")}
style={{
"position": displayableErrors.length === 1 ? "absolute" : undefined
}}
aria-live="polite"
>
{displayableErrors.map(({ errorMessage }) => errorMessage)}
</span>
</>
);
})()}
</div>
</div>
{AfterField && <AfterField attribute={attribute} />}
</Fragment>
);
})}
</>
);
}

48
src/tools/formatNumber.ts Normal file
View File

@ -0,0 +1,48 @@
export const formatNumber = (input: string, format: string): string => {
if (!input) {
return "";
}
// array holding the patterns for the number of expected digits in each part
const digitPattern = format.match(/{\d+}/g);
if (!digitPattern) {
return "";
}
// calculate the maximum size of the given pattern based on the sum of the expected digits
const maxSize = digitPattern.reduce((total, p) => total + parseInt(p.replace("{", "").replace("}", "")), 0);
// keep only digits
let rawValue = input.replace(/\D+/g, "");
// make sure the value is a number
if (`${parseInt(rawValue)}` !== rawValue) {
return "";
}
// make sure the number of digits does not exceed the maximum size
if (rawValue.length > maxSize) {
rawValue = rawValue.substring(0, maxSize);
}
// build the regex based based on the expected digits in each part
const formatter = digitPattern.reduce((result, p) => result + `(\\d${p})`, "^");
// if the current digits match the pattern we have each group of digits in an array
let digits = new RegExp(formatter).exec(rawValue);
// no match, return the raw value without any format
if (!digits) {
return input;
}
let result = format;
// finally format the current digits accordingly to the given format
for (let i = 0; i < digitPattern.length; i++) {
result = result.replace(digitPattern[i], digits[i + 1]);
}
return result;
};

View File

@ -1,73 +0,0 @@
import "./HTMLElement.prototype.prepend";
import { Deferred } from "evt/tools/Deferred";
export function headInsert(
params:
| {
type: "css";
href: string;
position: "append" | "prepend";
}
| {
type: "javascript";
src: string;
}
): { remove: () => void; prLoaded: Promise<void> } {
const htmlElement = document.createElement(
(() => {
switch (params.type) {
case "css":
return "link";
case "javascript":
return "script";
}
})()
);
const dLoaded = new Deferred<void>();
htmlElement.addEventListener("load", () => dLoaded.resolve());
Object.assign(
htmlElement,
(() => {
switch (params.type) {
case "css":
return {
"href": params.href,
"type": "text/css",
"rel": "stylesheet",
"media": "screen,print"
};
case "javascript":
return {
"src": params.src,
"type": "text/javascript"
};
}
})()
);
document.getElementsByTagName("head")[0][
(() => {
switch (params.type) {
case "javascript":
return "appendChild";
case "css":
return (() => {
switch (params.position) {
case "append":
return "appendChild";
case "prepend":
return "prepend";
}
})();
}
})()
](htmlElement);
return {
"prLoaded": dLoaded.pr,
"remove": () => htmlElement.remove()
};
}

View File

@ -0,0 +1,82 @@
import { useReducer, useEffect } from "react";
export function createUseInsertLinkTags() {
let linkTagsContext:
| {
styleSheetHrefs: string[];
prAreAllStyleSheetsLoaded: Promise<void>;
remove: () => void;
}
| undefined = undefined;
/** NOTE: The hrefs can't changes. There should be only one one call on this. */
function useInsertLinkTags(params: { hrefs: string[] }) {
const { hrefs } = params;
const [areAllStyleSheetsLoaded, setAllStyleSheetLoaded] = useReducer(() => true, hrefs.length === 0);
useEffect(() => {
let isActive = true;
mount_link_tags: {
if (linkTagsContext !== undefined) {
if (JSON.stringify(linkTagsContext.styleSheetHrefs) === JSON.stringify(hrefs)) {
break mount_link_tags;
}
linkTagsContext.remove();
linkTagsContext = undefined;
}
let lastMountedHtmlElement: HTMLLinkElement | undefined = undefined;
const prs: Promise<void>[] = [];
const removeFns: (() => void)[] = [];
for (const href of hrefs) {
const htmlElement = document.createElement("link");
prs.push(new Promise<void>(resolve => htmlElement.addEventListener("load", () => resolve())));
htmlElement.rel = "stylesheet";
htmlElement.href = href;
if (lastMountedHtmlElement !== undefined) {
lastMountedHtmlElement.insertAdjacentElement("afterend", htmlElement);
} else {
document.head.prepend(htmlElement);
}
removeFns.push(() => {
htmlElement.remove();
});
lastMountedHtmlElement = htmlElement;
}
linkTagsContext = {
"styleSheetHrefs": hrefs,
"prAreAllStyleSheetsLoaded": Promise.all(prs).then(() => undefined),
"remove": () => removeFns.forEach(fn => fn())
};
}
linkTagsContext.prAreAllStyleSheetsLoaded.then(() => {
if (!isActive) {
return;
}
setAllStyleSheetLoaded();
});
return () => {
isActive = false;
};
}, []);
return { areAllStyleSheetsLoaded };
}
return { useInsertLinkTags };
}

View File

@ -0,0 +1,106 @@
import { useCallback } from "react";
import { useConst } from "keycloakify/tools/useConst";
import { assert } from "tsafe/assert";
export type ScriptTag = ScriptTag.TextContent | ScriptTag.Src;
export namespace ScriptTag {
type Common = {
type: "text/javascript" | "module";
};
export type TextContent = Common & {
textContent: string;
};
export type Src = Common & {
src: string;
};
}
export function createUseInsertScriptTags() {
let areScriptsInserted = false;
function useInsertScriptTags(params: { scriptTags: ScriptTag[] }) {
const { scriptTags } = params;
const currentScriptTagsRef = useConst(() => ({ "current": scriptTags }));
currentScriptTagsRef.current = scriptTags;
const insertScriptTags = useCallback(() => {
{
const getFingerprint = (scriptTags: ScriptTag[]) =>
scriptTags
.map((scriptTag): string => {
if ("textContent" in scriptTag) {
return scriptTag.textContent;
}
if ("src" in scriptTag) {
return scriptTag.src;
}
assert(false);
})
.join("---");
if (getFingerprint(scriptTags) !== getFingerprint(currentScriptTagsRef.current)) {
// NOTE: This is for when the scripts imported in the Template have changed switching
// from one page to another in storybook.
window.location.reload();
return;
}
}
if (areScriptsInserted) {
return;
}
scriptTags.forEach(scriptTag => {
// NOTE: Avoid loading same script twice. (Like jQuery for example)
{
const scripts = document.getElementsByTagName("script");
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i];
if ("textContent" in scriptTag) {
if (script.textContent === scriptTag.textContent) {
return;
}
continue;
}
if ("src" in scriptTag) {
if (script.getAttribute("src") === scriptTag.src) {
return;
}
continue;
}
assert(false);
}
}
const htmlElement = document.createElement("script");
htmlElement.type = scriptTag.type;
(() => {
if ("textContent" in scriptTag) {
htmlElement.textContent = scriptTag.textContent;
return;
}
if ("src" in scriptTag) {
htmlElement.src = scriptTag.src;
return;
}
assert(false);
})();
document.head.appendChild(htmlElement);
});
areScriptsInserted = true;
}, []);
return { insertScriptTags };
}
return { useInsertScriptTags };
}

View File

@ -0,0 +1,21 @@
import { useEffect } from "react";
export function useSetClassName(params: { qualifiedName: "html" | "body"; className: string | undefined }) {
const { qualifiedName, className } = params;
useEffect(() => {
if (className === undefined || className === "") {
return;
}
const htmlClassList = document.getElementsByTagName(qualifiedName)[0].classList;
const tokens = className.split(" ");
htmlClassList.add(...tokens);
return () => {
htmlClassList.remove(...tokens);
};
}, [className]);
}

View File

@ -5,8 +5,8 @@ import { useI18n } from "./i18n";
import { useDownloadTerms } from "../../dist/login/lib/useDownloadTerms";
import tos_en_url from "./tos_en.md";
import tos_fr_url from "./tos_fr.md";
const DefaultTemplate = lazy(() => import("../../dist/login/Template"));
import Template from "../../dist/login/Template";
import UserProfileFormFields from "../../dist/login/UserProfileFormFields";
export default function KcApp(props: { kcContext: KcContext }) {
const { kcContext } = props;
@ -42,7 +42,7 @@ export default function KcApp(props: { kcContext: KcContext }) {
{(() => {
switch (kcContext.pageId) {
default:
return <Fallback {...{ kcContext, i18n }} Template={DefaultTemplate} doUseDefaultCss={true} />;
return <Fallback {...{ kcContext, i18n, Template, UserProfileFormFields }} doUseDefaultCss={true} />;
}
})()}
</Suspense>

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "register-user-profile.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "update-user-profile.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -6201,6 +6201,15 @@ evt@^2.4.18:
run-exclusive "^2.2.18"
tsafe "^1.6.0"
evt@^2.5.7:
version "2.5.7"
resolved "https://registry.yarnpkg.com/evt/-/evt-2.5.7.tgz#55c5f8ff910f4b7531bfac91e963d4cb3231f253"
integrity sha512-dr7Wd16ry5F8WNU1xXLKpFpO3HsoAGg8zC48e08vDdzMzGWCP9/QFGt1PQptEEDh8SwYP3EL8M+d/Gb0kgUp6g==
dependencies:
minimal-polyfills "^2.2.3"
run-exclusive "^2.2.19"
tsafe "^1.6.6"
exec-sh@^0.3.2:
version "0.3.6"
resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc"
@ -8701,6 +8710,11 @@ minimal-polyfills@^2.2.1, minimal-polyfills@^2.2.2:
resolved "https://registry.yarnpkg.com/minimal-polyfills/-/minimal-polyfills-2.2.2.tgz#6b06a004acce420eb91cf94698f5e6e7f2518378"
integrity sha512-eEOUq/LH/DbLWihrxUP050Wi7H/N/I2dQT98Ep6SqOpmIbk4sXOI4wqalve66QoZa+6oljbZWU6I6T4dehQGmw==
minimal-polyfills@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/minimal-polyfills/-/minimal-polyfills-2.2.3.tgz#22af58de16807b325f29b83ca38ffb83e75ec3f4"
integrity sha512-oxdmJ9cL+xV72h0xYxp4tP2d5/fTBpP45H8DIOn9pASuF8a3IYTf+25fMGDYGiWW+MFsuog6KD6nfmhZJQ+uUw==
minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
@ -10579,6 +10593,13 @@ run-exclusive@^2.2.18:
dependencies:
minimal-polyfills "^2.2.1"
run-exclusive@^2.2.19:
version "2.2.19"
resolved "https://registry.yarnpkg.com/run-exclusive/-/run-exclusive-2.2.19.tgz#37a2fb6e3671f8ae0d63521ebd1865fc796cf307"
integrity sha512-K3mdoAi7tjJ/qT7Flj90L7QyPozwUaAG+CVhkdDje4HLKXUYC3N/Jzkau3flHVDLQVhiHBtcimVodMjN9egYbA==
dependencies:
minimal-polyfills "^2.2.3"
run-parallel@^1.1.9:
version "1.2.0"
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
@ -11699,6 +11720,11 @@ tsafe@^1.6.0:
resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.6.0.tgz#48a9bd0a4c43df43d289bdfc1d89f0d7fffbd612"
integrity sha512-wlUeRBnyN3EN2chXznpLm7vBEvJLEOziDU+MN6NRlD99AkwmXgtChNQhp+V97VyRa3Bp05IaL4Cocsc7JlyEUg==
tsafe@^1.6.6:
version "1.6.6"
resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.6.6.tgz#fd93e64d6eb13ef83ed1650669cc24bad4f5df9f"
integrity sha512-gzkapsdbMNwBnTIjgO758GujLCj031IgHK/PKr2mrmkCSJMhSOR5FeOuSxKLMUoYc0vAA4RGEYYbjt/v6afD3g==
tsc-alias@^1.8.3:
version "1.8.5"
resolved "https://registry.yarnpkg.com/tsc-alias/-/tsc-alias-1.8.5.tgz#6b74e938230573354c9118deb58fd341d06d6253"