Compare commits

...

84 Commits

Author SHA1 Message Date
20937c4f72 Relase candidate 2023-03-19 15:53:13 +01:00
dbbfa07639 Feature new script: Eject-keycloak-page 2023-03-19 15:52:41 +01:00
9e1a4cad5c Update homepage 2023-03-19 15:49:27 +01:00
02bbedcfca Remove no longer used tools 2023-03-19 14:56:30 +01:00
cd70d90914 Refactor completed 2023-03-19 14:48:01 +01:00
819f297de8 Better i18n API 2023-03-19 14:03:06 +01:00
0608adde89 Better naming convention for i18n API 2023-03-19 13:54:39 +01:00
ad7bcf4669 Fix lining script 2023-03-18 19:05:27 +01:00
2eccc86e83 Remove eventEmitter warning 2023-03-18 19:02:17 +01:00
16d18f23a1 Refactor of the main component and i18n 2023-03-18 18:54:33 +01:00
5631ae1b6c Better naming convention 2023-03-18 18:27:50 +01:00
5fb29992f6 Merge branch 'main' of https://github.com/InseeFrLab/keycloakify 2023-03-18 16:58:21 +01:00
910d633ac2 Fix build test app 2023-03-18 16:57:58 +01:00
32f8380e56 Fix build:test 2023-03-18 16:20:21 +01:00
43e4dd6bb6 Fix build for real 2023-03-18 16:17:33 +01:00
4f0b1688db Thank you @justkey007 for tsc-alias 2023-03-18 15:49:45 +01:00
9e75ee09bb fix(deps): update garronej_modules_update 2023-03-18 09:11:03 +00:00
9ae8822e00 Fix build 2023-03-18 06:25:19 +01:00
babffd1fe6 Make the project compile 2023-03-18 06:14:05 +01:00
5615d62032 Scripts dir outside of the src dir 2023-03-18 02:12:12 +01:00
4b89d15c1e progressing 2023-03-17 20:40:29 +01:00
815f510d5f Exclude tsbuild info of the bin dir from the bundle 2023-03-16 23:03:18 +01:00
199ba193be Rename getKcContext dir to kcContext 2023-03-16 23:02:06 +01:00
4ae9bd3f9a Fix repo url in package.json 2023-03-16 22:57:24 +01:00
1c9cf639ea Remove console log 2023-03-16 22:44:44 +01:00
0040464ca1 Move lib up one level 2023-03-16 22:43:09 +01:00
79997efbb6 First commit towars supporting account theme 2023-03-16 22:13:46 +01:00
0e42009798 Fix bin test script 2023-03-16 14:39:40 +01:00
93fdcb8739 Merge branch 'main' of https://github.com/InseeFrLab/keycloakify 2023-03-16 13:33:01 +01:00
aca926e202 Fix link script command 2023-03-16 13:31:56 +01:00
9941027b10 Update package.json 2023-03-15 12:45:58 +01:00
9104de4290 Merge pull request #263 from mkreuzmayr/main
Fix start container script paths for windows
2023-03-15 12:45:43 +01:00
5dc692809c Fix start container script paths for windows 2023-03-15 10:07:01 +01:00
8dc1d1bd21 Merge branch 'main' of https://github.com/InseeFrLab/keycloakify 2023-03-13 10:47:10 +01:00
fe588485a9 Update changelog 2023-03-13 10:47:02 +01:00
19ef1d7025 Bump version 2023-03-13 10:44:46 +01:00
62523a8662 Merge pull request #260 from InseeFrLab/lordvlad/issue257
Run keycloakify behind corporate proxy
2023-03-13 10:44:08 +01:00
6e97665e2e Merge branch 'main' into lordvlad/issue257 2023-03-09 18:33:18 +01:00
4988680353 Update description 2023-03-09 18:30:58 +01:00
c5de5c20c7 Bump version 2023-03-08 23:21:44 +01:00
1a0fee1aa2 Make things cleaner 2023-03-08 23:21:32 +01:00
06a44603cd Release candidate 2023-03-08 22:32:45 +01:00
e48459762e Merge branch 'main' of https://github.com/InseeFrLab/keycloakify 2023-03-08 22:28:46 +01:00
235ebeae97 Bump version 2023-03-08 22:25:10 +01:00
dfe909606e Deprecate useFormValidationSlice 2023-03-08 22:24:51 +01:00
6fd0c7726c Merge branch 'lordvlad/issue257' of github.com:InseeFrLab/keycloakify into lordvlad/issue257
* 'lordvlad/issue257' of github.com:InseeFrLab/keycloakify:
  Only test build on LTS node version
  Update dependency evt to ^2.4.15
  Update README.md
  Bump version
  Avoid passing unessesary realm values in the error.ftl page
2023-03-08 10:25:10 +01:00
819e045811 feat(proxy): respect XDG_CACHE_HOME if set 2023-03-08 10:24:52 +01:00
1ba780598d Relase candidate 2023-03-07 18:18:37 +01:00
aeb0cb3110 Only test build on LTS node version 2023-03-07 18:18:04 +01:00
88923838c5 Bump version 2023-03-07 17:29:30 +01:00
df9f6fd7fd Merge branch 'main' into lordvlad/issue257 2023-03-07 17:16:24 +01:00
98e46d6ac9 Update dependency evt to ^2.4.15 2023-03-07 17:07:49 +01:00
daff614fb4 Update README.md 2023-03-07 17:07:48 +01:00
5ea324c7f2 Bump version 2023-03-07 17:07:48 +01:00
23fedbf94a Avoid passing unessesary realm values in the error.ftl page 2023-03-07 17:07:45 +01:00
593d66d8d6 style: remove unused dependency 2023-03-07 16:45:17 +01:00
851dcd5bf7 Run keycloakify behind corporate proxy
Fixes #257

Use make-fetch-happen for the download step. This lib will use `PROXY`
and `HTTPS_PROXY` and `NO_PROXY` env vars out of the box.

Additionally we'll try and get proxy config from npm. Unfortunately,
the most straightforward options is to call npm config to do this, since
npm  config is not easily extracted as a lib and we don't want to
replicate the resolution mechanisms.
2023-03-07 16:43:12 +01:00
2e919681ae Update dependency evt to ^2.4.15 2023-03-07 10:14:46 +00:00
5da68cd48c Update README.md 2023-03-05 23:37:37 +01:00
27fdaeff46 Bump version 2023-02-27 11:55:39 +01:00
53c0079656 Use extract instead of subtype to ease copy paste into theme repo 2023-02-27 11:55:25 +01:00
93780b77e0 Bump version 2023-02-27 11:32:14 +01:00
b712ed0421 Avoid using tsafe utils to avoid forcing user to install tsafe 2023-02-27 11:32:00 +01:00
ee96f1b345 Bump version 2023-02-27 11:29:23 +01:00
d13464df3d Get rid of the ReactComponent type, classes based component are no longer used 2023-02-27 11:29:05 +01:00
6bde2e4d96 Merge branch 'main' of https://github.com/InseeFrLab/keycloakify 2023-02-27 10:39:44 +01:00
0a4953c020 Bump version 2023-02-27 10:39:37 +01:00
96c488880c Abstract away Template logic 2023-02-27 10:39:22 +01:00
7e0adf3f66 Update README.md 2023-02-26 17:32:35 +01:00
09f716440a Bump version 2023-02-26 16:41:47 +01:00
2251c84171 Use the new syntax for importing as type 2023-02-26 16:37:06 +01:00
5cfe78dcd1 Update prettier config 2023-02-26 15:39:03 +01:00
6a48325132 Be more relax on the type safety to avoir headache 2023-02-26 15:37:52 +01:00
294be0a79a see prev commit 2023-02-26 15:36:52 +01:00
c94b264b44 Don't need a dir for a single file 2023-02-26 15:36:35 +01:00
7220c4e3e3 Fix deepAssign 2023-02-26 15:35:57 +01:00
5aadeba2ec fix clsx 2023-02-26 15:35:30 +01:00
0f47a5b6ba Small Template refactor 2023-02-25 20:11:55 +01:00
36f32d28f2 Stop auto updating powerhooks 2023-02-25 19:21:55 +01:00
6d69ccf229 Merge branch 'main' of https://github.com/InseeFrLab/keycloakify 2023-02-25 19:21:17 +01:00
37073b42be Avoir introducing breaking changes for CSS only setup 2023-02-25 19:19:46 +01:00
837501c948 Refactor 2023-02-25 18:26:39 +01:00
b300966fa8 Refactor and get rid of unessesary dependencies 2023-02-25 18:11:23 +01:00
730eb06c84 fix(deps): update dependency powerhooks to ^0.26.2 2023-02-14 12:08:41 +00:00
326 changed files with 3696 additions and 3074 deletions

View File

@ -28,7 +28,7 @@ jobs:
needs: test_lint
strategy:
matrix:
node: [ '14', '15' ,'16', '17' ]
node: [ '14','16' ]
os: [ windows-latest, ubuntu-latest ]
name: Test with Node v${{ matrix.node }} on ${{ matrix.os }}
steps:
@ -136,4 +136,4 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
VERSION: ${{ needs.check_if_version_upgraded.outputs.to_version }}
IS_PRE_RELEASE: ${{ needs.check_if_version_upgraded.outputs.is_pre_release }}
IS_PRE_RELEASE: ${{ needs.check_if_version_upgraded.outputs.is_pre_release }}

View File

@ -6,4 +6,5 @@ node_modules/
/src/test/apps/
/src/tools/types/
/sample_react_project
/build_keycloak/
/build_keycloak/
/src/lib/i18n/generated_messages/

View File

@ -8,9 +8,6 @@
<a href="https://github.com/garronej/keycloakify/actions">
<img src="https://github.com/garronej/keycloakify/workflows/ci/badge.svg?branch=main">
</a>
<a href="https://bundlephobia.com/package/keycloakify">
<img src="https://img.shields.io/bundlephobia/minzip/keycloakify">
</a>
<a href="https://www.npmjs.com/package/keycloakify">
<img src="https://img.shields.io/npm/dm/keycloakify">
</a>
@ -27,15 +24,9 @@
<a href="https://www.keycloakify.dev">Home</a>
-
<a href="https://docs.keycloakify.dev">Documentation</a>
</p>
<p align="center"> ---- Project starter / Demo setup ---- </p>
<p align="center">
<a href="https://github.com/garronej/keycloakify-starter">CSS Level customization</a>
-
<a href="https://github.com/garronej/keycloakify-advanced-starter">Component Level customization</a>
<a href="https://github.com/codegouvfr/keycloakify-starter">Starter project</a>
</p>
<p align="center"> ---- </p>
</p>
<p align="center">
@ -49,6 +40,19 @@
# Changelog highlights
## 6.13
- Build work behind corporate proxies, [see issue](https://github.com/InseeFrLab/keycloakify/issues/257).
## 6.12
Massive improvement in the developer experience:
- There is now only one starter repo: https://github.com/codegouvfr/keycloakify-starter
- A lot of comments have been added in the code of the starter to make it easier to get started.
- The doc has been updated: https://docs.keycloakify.dev
- A lot of improvements in the type system.
## 6.11.4
- You no longer need to have Maven installed to build the theme. Thanks to @lordvlad, [see PR](https://github.com/InseeFrLab/keycloakify/pull/239).

View File

@ -1,30 +1,34 @@
{
"name": "keycloakify",
"version": "6.11.8",
"description": "Keycloak theme generator for Reacts app",
"version": "7.0.0-rc.0",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
"url": "git://github.com/garronej/keycloakify.git"
"url": "git://github.com/inseefrlab/keycloakify.git"
},
"main": "dist/lib/index.js",
"types": "dist/lib/index.d.ts",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src/lib && yarn grant-exec-perms && yarn copy-files dist/",
"build:test": "rimraf dist_test/ && tsc -p src/test && yarn copy-files dist_test/",
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src/tsconfig.json && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/",
"build:watch": "tsc -p src/tsconfig.json && (concurrently \"tsc -p src/tsconfig.json -w\" \"tsc-alias -p src/tsconfig.json\")",
"build:test": "rimraf dist_test/ && tsc -p test/tsconfig.json && tsc-alias -p test/tsconfig.json && yarn copy-files dist_test/src",
"grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js",
"copy-files": "copyfiles -u 1 src/**/*.ftl",
"pretest": "yarn build:test",
"test": "node dist_test/test/bin && node dist_test/test/lib",
"generate-messages": "node dist/bin/generate-i18n-messages.js",
"link_in_test_app": "node dist/bin/link_in_test_app.js",
"test": "yarn build:test && node dist_test/test/bin && node dist_test/test/lib",
"test:sample-app": "yarn build:test && node dist_test/test/bin/main.js",
"_format": "prettier '**/*.{ts,tsx,json,md}'",
"format": "yarn _format --write",
"format:check": "yarn _format --list-different"
"format:check": "yarn _format --list-different",
"generate-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",
"tsc-watch": "tsc -p src/bin -w & tsc -p src/lib -w "
},
"bin": {
"keycloakify": "dist/bin/keycloakify/index.js",
"create-keycloak-email-directory": "dist/bin/create-keycloak-email-directory.js",
"download-builtin-keycloak-theme": "dist/bin/download-builtin-keycloak-theme.js"
"download-builtin-keycloak-theme": "dist/bin/download-builtin-keycloak-theme.js",
"eject-keycloak-page": "dist/bin/eject-keycloak-page.js"
},
"lint-staged": {
"*.{ts,tsx,json,md}": [
@ -41,7 +45,8 @@
"files": [
"src/",
"dist/",
"!dist/tsconfig.tsbuildinfo"
"!dist/tsconfig.tsbuildinfo",
"!dist/bin/tsconfig.tsbuildinfo"
],
"keywords": [
"bluehats",
@ -53,16 +58,17 @@
"login",
"register"
],
"homepage": "https://github.com/garronej/keycloakify",
"homepage": "https://www.keycloakify.dev",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@types/memoizee": "^0.4.7",
"@types/make-fetch-happen": "^10.0.1",
"@types/minimist": "^1.2.2",
"@types/node": "^17.0.25",
"@types/node": "^18.14.1",
"@types/react": "18.0.9",
"concurrently": "^7.6.0",
"copyfiles": "^2.4.1",
"husky": "^4.3.8",
"lint-staged": "^11.0.0",
@ -70,24 +76,23 @@
"properties-parser": "^0.3.1",
"react": "18.1.0",
"rimraf": "^3.0.2",
"@emotion/react": "^11.10.4",
"typescript": "^4.2.3"
"scripting-tools": "^0.19.13",
"ts-node": "^10.9.1",
"tsc-alias": "^1.8.3",
"typescript": "^5.0.1-rc"
},
"dependencies": {
"@octokit/rest": "^18.12.0",
"cheerio": "^1.0.0-rc.5",
"cli-select": "^1.1.2",
"evt": "^2.4.13",
"memoizee": "^0.4.15",
"evt": "^2.4.18",
"make-fetch-happen": "^11.0.3",
"minimal-polyfills": "^2.2.2",
"minimist": "^1.2.6",
"path-browserify": "^1.0.1",
"powerhooks": "^0.26.1",
"react-markdown": "^5.0.3",
"rfc4648": "^1.5.2",
"scripting-tools": "^0.19.13",
"tsafe": "^1.4.3",
"tss-react": "4.4.1-rc.0",
"tsafe": "^1.6.0",
"zod": "^3.17.10"
}
}

View File

@ -13,11 +13,11 @@
"packageRules": [
{
"packagePatterns": ["*"],
"excludePackagePatterns": ["powerhooks", "tsafe", "evt"],
"excludePackagePatterns": ["tsafe", "evt"],
"enabled": false
},
{
"packagePatterns": ["powerhooks", "tsafe", "evt"],
"packagePatterns": ["tsafe", "evt"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true,
"automergeType": "branch",

View File

@ -1,11 +1,11 @@
import "minimal-polyfills/Object.fromEntries";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import { crawl } from "./tools/crawl";
import { downloadBuiltinKeycloakTheme } from "./download-builtin-keycloak-theme";
import { getProjectRoot } from "./tools/getProjectRoot";
import { getCliOptions } from "./tools/cliOptions";
import { getLogger } from "./tools/logger";
import { crawl } from "../src/bin/tools/crawl";
import { downloadBuiltinKeycloakTheme } from "../src/bin/download-builtin-keycloak-theme";
import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
import { getCliOptions } from "../src/bin/tools/cliOptions";
import { getLogger } from "../src/bin/tools/logger";
//NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
// update the version array for generating for newer version.

141
scripts/link-in-app.ts Normal file
View File

@ -0,0 +1,141 @@
import { execSync } from "child_process";
import { join as pathJoin, relative as pathRelative } from "path";
import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
import * as fs from "fs";
const singletonDependencies: string[] = ["react", "@types/react"];
const rootDirPath = getProjectRoot();
//NOTE: This is only required because of: https://github.com/garronej/ts-ci/blob/c0e207b9677523d4ec97fe672ddd72ccbb3c1cc4/README.md?plain=1#L54-L58
fs.writeFileSync(
pathJoin(rootDirPath, "dist", "package.json"),
Buffer.from(
JSON.stringify(
(() => {
const packageJsonParsed = JSON.parse(fs.readFileSync(pathJoin(rootDirPath, "package.json")).toString("utf8"));
return {
...packageJsonParsed,
"main": packageJsonParsed["main"]?.replace(/^dist\//, ""),
"types": packageJsonParsed["types"]?.replace(/^dist\//, ""),
"module": packageJsonParsed["module"]?.replace(/^dist\//, ""),
"exports": !("exports" in packageJsonParsed)
? undefined
: Object.fromEntries(
Object.entries(packageJsonParsed["exports"]).map(([key, value]) => [
key,
(value as string).replace(/^\.\/dist\//, "./")
])
)
};
})(),
null,
2
),
"utf8"
)
);
const commonThirdPartyDeps = (() => {
// For example [ "@emotion" ] it's more convenient than
// having to list every sub emotion packages (@emotion/css @emotion/utils ...)
// in singletonDependencies
const namespaceSingletonDependencies: string[] = [];
return [
...namespaceSingletonDependencies
.map(namespaceModuleName =>
fs
.readdirSync(pathJoin(rootDirPath, "node_modules", namespaceModuleName))
.map(submoduleName => `${namespaceModuleName}/${submoduleName}`)
)
.reduce((prev, curr) => [...prev, ...curr], []),
...singletonDependencies
];
})();
const yarnGlobalDirPath = pathJoin(rootDirPath, ".yarn_home");
fs.rmSync(yarnGlobalDirPath, { "recursive": true, "force": true });
fs.mkdirSync(yarnGlobalDirPath);
const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
const { targetModuleName, cwd } = params;
const cmd = ["yarn", "link", ...(targetModuleName !== undefined ? [targetModuleName] : ["--no-bin-links"])].join(" ");
console.log(`$ cd ${pathRelative(rootDirPath, cwd) || "."} && ${cmd}`);
execSync(cmd, {
cwd,
"env": {
...process.env,
"HOME": yarnGlobalDirPath
}
});
};
const testAppPaths = (() => {
const [, , ...testAppNames] = process.argv;
return testAppNames
.map(testAppName => {
const testAppPath = pathJoin(rootDirPath, "..", testAppName);
if (fs.existsSync(testAppPath)) {
return testAppPath;
}
console.warn(`Skipping ${testAppName} since it cant be found here: ${testAppPath}`);
return undefined;
})
.filter((path): path is string => path !== undefined);
})();
if (testAppPaths.length === 0) {
console.error("No test app to link into!");
process.exit(-1);
}
testAppPaths.forEach(testAppPath => execSync("yarn install", { "cwd": testAppPath }));
console.log("=== Linking common dependencies ===");
const total = commonThirdPartyDeps.length;
let current = 0;
commonThirdPartyDeps.forEach(commonThirdPartyDep => {
current++;
console.log(`${current}/${total} ${commonThirdPartyDep}`);
const localInstallPath = pathJoin(
...[rootDirPath, "node_modules", ...(commonThirdPartyDep.startsWith("@") ? commonThirdPartyDep.split("/") : [commonThirdPartyDep])]
);
execYarnLink({ "cwd": localInstallPath });
});
commonThirdPartyDeps.forEach(commonThirdPartyDep =>
testAppPaths.forEach(testAppPath =>
execYarnLink({
"cwd": testAppPath,
"targetModuleName": commonThirdPartyDep
})
)
);
console.log("=== Linking in house dependencies ===");
execYarnLink({ "cwd": pathJoin(rootDirPath, "dist") });
testAppPaths.forEach(testAppPath =>
execYarnLink({
"cwd": testAppPath,
"targetModuleName": JSON.parse(fs.readFileSync(pathJoin(rootDirPath, "package.json")).toString("utf8"))["name"]
})
);
export {};

81
src/Fallback.tsx Normal file
View File

@ -0,0 +1,81 @@
import { lazy, Suspense } from "react";
import type { PageProps } from "keycloakify/pages/PageProps";
import type { I18n } from "keycloakify/i18n";
import type { KcContext } from "./kcContext";
const Login = lazy(() => import("keycloakify/pages/Login"));
const Register = lazy(() => import("keycloakify/pages/Register"));
const RegisterUserProfile = lazy(() => import("keycloakify/pages/RegisterUserProfile"));
const Info = lazy(() => import("keycloakify/pages/Info"));
const Error = lazy(() => import("keycloakify/pages/Error"));
const LoginResetPassword = lazy(() => import("keycloakify/pages/LoginResetPassword"));
const LoginVerifyEmail = lazy(() => import("keycloakify/pages/LoginVerifyEmail"));
const Terms = lazy(() => import("keycloakify/pages/Terms"));
const LoginOtp = lazy(() => import("keycloakify/pages/LoginOtp"));
const LoginPassword = lazy(() => import("keycloakify/pages/LoginPassword"));
const LoginUsername = lazy(() => import("keycloakify/pages/LoginUsername"));
const WebauthnAuthenticate = lazy(() => import("keycloakify/pages/WebauthnAuthenticate"));
const LoginUpdatePassword = lazy(() => import("keycloakify/pages/LoginUpdatePassword"));
const LoginUpdateProfile = lazy(() => import("keycloakify/pages/LoginUpdateProfile"));
const LoginIdpLinkConfirm = lazy(() => import("keycloakify/pages/LoginIdpLinkConfirm"));
const LoginPageExpired = lazy(() => import("keycloakify/pages/LoginPageExpired"));
const LoginIdpLinkEmail = lazy(() => import("keycloakify/pages/LoginIdpLinkEmail"));
const LoginConfigTotp = lazy(() => import("keycloakify/pages/LoginConfigTotp"));
const LogoutConfirm = lazy(() => import("keycloakify/pages/LogoutConfirm"));
const UpdateUserProfile = lazy(() => import("keycloakify/pages/UpdateUserProfile"));
const IdpReviewUserProfile = lazy(() => import("keycloakify/pages/IdpReviewUserProfile"));
export default function Fallback(props: PageProps<KcContext, I18n>) {
const { kcContext, ...rest } = props;
return (
<Suspense>
{(() => {
switch (kcContext.pageId) {
case "login.ftl":
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":
return <Error kcContext={kcContext} {...rest} />;
case "login-reset-password.ftl":
return <LoginResetPassword kcContext={kcContext} {...rest} />;
case "login-verify-email.ftl":
return <LoginVerifyEmail kcContext={kcContext} {...rest} />;
case "terms.ftl":
return <Terms kcContext={kcContext} {...rest} />;
case "login-otp.ftl":
return <LoginOtp kcContext={kcContext} {...rest} />;
case "login-username.ftl":
return <LoginUsername kcContext={kcContext} {...rest} />;
case "login-password.ftl":
return <LoginPassword kcContext={kcContext} {...rest} />;
case "webauthn-authenticate.ftl":
return <WebauthnAuthenticate kcContext={kcContext} {...rest} />;
case "login-update-password.ftl":
return <LoginUpdatePassword kcContext={kcContext} {...rest} />;
case "login-update-profile.ftl":
return <LoginUpdateProfile kcContext={kcContext} {...rest} />;
case "login-idp-link-confirm.ftl":
return <LoginIdpLinkConfirm kcContext={kcContext} {...rest} />;
case "login-idp-link-email.ftl":
return <LoginIdpLinkEmail kcContext={kcContext} {...rest} />;
case "login-page-expired.ftl":
return <LoginPageExpired kcContext={kcContext} {...rest} />;
case "login-config-totp.ftl":
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} />;
}
})()}
</Suspense>
);
}

View File

@ -1,32 +1,12 @@
import React, { useReducer, useEffect, memo } from "react";
import type { ReactNode } from "react";
import type { KcContextBase } from "../getKcContext/KcContextBase";
import { assert } from "../tools/assert";
import { useCallbackFactory } from "powerhooks/useCallbackFactory";
import { headInsert } from "../tools/headInsert";
import { pathJoin } from "../../bin/tools/pathJoin";
import { useConstCallback } from "powerhooks/useConstCallback";
import type { KcTemplateProps } from "./KcProps";
import { clsx } from "../tools/clsx";
import type { I18n } from "../i18n";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate";
import { type TemplateProps, defaultTemplateClasses } from "keycloakify/TemplateProps";
import { useGetClassName } from "keycloakify/lib/useGetClassName";
import type { KcContext } from "./kcContext";
import type { I18n } from "./i18n";
export type TemplateProps = {
displayInfo?: boolean;
displayMessage?: boolean;
displayRequiredFields?: boolean;
displayWide?: boolean;
showAnotherWayIfPresent?: boolean;
headerNode: ReactNode;
showUsernameNode?: ReactNode;
formNode: ReactNode;
infoNode?: ReactNode;
/** If you write your own page you probably want
* to avoid pulling the default theme assets.
*/
doFetchDefaultThemeResources: boolean;
} & { kcContext: KcContextBase; i18n: I18n } & KcTemplateProps;
const Template = memo((props: TemplateProps) => {
export default function Template(props: TemplateProps<KcContext, I18n>) {
const {
displayInfo = false,
displayMessage = true,
@ -39,100 +19,58 @@ const Template = memo((props: TemplateProps) => {
infoNode = null,
kcContext,
i18n,
doFetchDefaultThemeResources
doUseDefaultCss,
classes
} = props;
const { getClassName } = useGetClassName({
"defaultClasses": !doUseDefaultCss ? undefined : defaultTemplateClasses,
classes
});
const { msg, changeLocale, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const onChangeLanguageClickFactory = useCallbackFactory(([kcLanguageTag]: [string]) => changeLocale(kcLanguageTag));
const onTryAnotherWayClick = useConstCallback(() => (document.forms["kc-select-try-another-way-form" as never].submit(), false));
const { realm, locale, auth, url, message, isAppInitiatedAction } = kcContext;
const [isExtraCssLoaded, setExtraCssLoaded] = useReducer(() => true, false);
const { isReady } = usePrepareTemplate({
"doFetchDefaultThemeResources": doUseDefaultCss,
url,
"stylesCommon": [
"node_modules/patternfly/dist/css/patternfly.min.css",
"node_modules/patternfly/dist/css/patternfly-additions.min.css",
"lib/zocial/zocial.css"
],
"styles": ["css/login.css"],
"htmlClassName": getClassName("kcHtmlClass")
});
useEffect(() => {
if (!doFetchDefaultThemeResources) {
setExtraCssLoaded();
return;
}
let isUnmounted = false;
const cleanups: (() => void)[] = [];
const toArr = (x: string | readonly string[] | undefined) => (typeof x === "string" ? x.split(" ") : x ?? []);
Promise.all(
[
...toArr(props.stylesCommon).map(relativePath => pathJoin(url.resourcesCommonPath, relativePath)),
...toArr(props.styles).map(relativePath => pathJoin(url.resourcesPath, relativePath))
]
.reverse()
.map(href =>
headInsert({
"type": "css",
href,
"position": "prepend"
})
)
).then(() => {
if (isUnmounted) {
return;
}
setExtraCssLoaded();
});
toArr(props.scripts).forEach(relativePath =>
headInsert({
"type": "javascript",
"src": pathJoin(url.resourcesPath, relativePath)
})
);
if (props.kcHtmlClass !== undefined) {
const htmlClassList = document.getElementsByTagName("html")[0].classList;
const tokens = clsx(props.kcHtmlClass).split(" ");
htmlClassList.add(...tokens);
cleanups.push(() => htmlClassList.remove(...tokens));
}
return () => {
isUnmounted = true;
cleanups.forEach(f => f());
};
}, [props.kcHtmlClass]);
if (!isExtraCssLoaded) {
if (!isReady) {
return null;
}
return (
<div className={clsx(props.kcLoginClass)}>
<div id="kc-header" className={clsx(props.kcHeaderClass)}>
<div id="kc-header-wrapper" className={clsx(props.kcHeaderWrapperClass)}>
<div className={getClassName("kcLoginClass")}>
<div id="kc-header" className={getClassName("kcHeaderClass")}>
<div id="kc-header-wrapper" className={getClassName("kcHeaderWrapperClass")}>
{msg("loginTitleHtml", realm.displayNameHtml)}
</div>
</div>
<div className={clsx(props.kcFormCardClass, displayWide && props.kcFormCardAccountClass)}>
<header className={clsx(props.kcFormHeaderClass)}>
<div className={clsx(getClassName("kcFormCardClass"), displayWide && getClassName("kcFormCardAccountClass"))}>
<header className={getClassName("kcFormHeaderClass")}>
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
<div id="kc-locale">
<div id="kc-locale-wrapper" className={clsx(props.kcLocaleWrapperClass)}>
<div id="kc-locale-wrapper" className={getClassName("kcLocaleWrapperClass")}>
<div className="kc-dropdown" id="kc-locale-dropdown">
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a href="#" id="kc-current-locale-link">
{labelBySupportedLanguageTag[currentLanguageTag]}
</a>
<ul>
{locale.supported.map(({ languageTag }) => (
<li key={languageTag} className="kc-dropdown-item">
<a href="#" onClick={onChangeLanguageClickFactory(languageTag)}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a href="#" onClick={() => changeLocale(languageTag)}>
{labelBySupportedLanguageTag[languageTag]}
</a>
</li>
@ -144,8 +82,8 @@ const Template = memo((props: TemplateProps) => {
)}
{!(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
displayRequiredFields ? (
<div className={clsx(props.kcContentWrapperClass)}>
<div className={clsx(props.kcLabelWrapperClass, "subtitle")}>
<div className={getClassName("kcContentWrapperClass")}>
<div className={clsx(getClassName("kcLabelWrapperClass"), "subtitle")}>
<span className="subtitle">
<span className="required">*</span>
{msg("requiredFields")}
@ -159,20 +97,20 @@ const Template = memo((props: TemplateProps) => {
<h1 id="kc-page-title">{headerNode}</h1>
)
) : displayRequiredFields ? (
<div className={clsx(props.kcContentWrapperClass)}>
<div className={clsx(props.kcLabelWrapperClass, "subtitle")}>
<div className={getClassName("kcContentWrapperClass")}>
<div className={clsx(getClassName("kcLabelWrapperClass"), "subtitle")}>
<span className="subtitle">
<span className="required">*</span> {msg("requiredFields")}
</span>
</div>
<div className="col-md-10">
{showUsernameNode}
<div className={clsx(props.kcFormGroupClass)}>
<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={clsx(props.kcResetFlowIcon)}></i>
<i className={getClassName("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
@ -183,12 +121,12 @@ const Template = memo((props: TemplateProps) => {
) : (
<>
{showUsernameNode}
<div className={clsx(props.kcFormGroupClass)}>
<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={clsx(props.kcResetFlowIcon)}></i>
<i className={getClassName("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
@ -202,10 +140,10 @@ const Template = memo((props: TemplateProps) => {
{/* 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={clsx(props.kcFeedbackSuccessIcon)}></span>}
{message.type === "warning" && <span className={clsx(props.kcFeedbackWarningIcon)}></span>}
{message.type === "error" && <span className={clsx(props.kcFeedbackErrorIcon)}></span>}
{message.type === "info" && <span className={clsx(props.kcFeedbackInfoIcon)}></span>}
{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>}
<span
className="kc-feedback-text"
dangerouslySetInnerHTML={{
@ -220,12 +158,24 @@ const Template = memo((props: TemplateProps) => {
id="kc-select-try-another-way-form"
action={url.loginAction}
method="post"
className={clsx(displayWide && props.kcContentWrapperClass)}
className={clsx(displayWide && getClassName("kcContentWrapperClass"))}
>
<div className={clsx(displayWide && [props.kcFormSocialAccountContentClass, props.kcFormSocialAccountClass])}>
<div className={clsx(props.kcFormGroupClass)}>
<div
className={clsx(
displayWide && [getClassName("kcFormSocialAccountContentClass"), getClassName("kcFormSocialAccountClass")]
)}
>
<div className={getClassName("kcFormGroupClass")}>
<input type="hidden" name="tryAnotherWay" value="on" />
<a href="#" id="try-another-way" onClick={onTryAnotherWayClick}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
href="#"
id="try-another-way"
onClick={() => {
document.forms["kc-select-try-another-way-form" as never].submit();
return false;
}}
>
{msg("doTryAnotherWay")}
</a>
</div>
@ -233,8 +183,8 @@ const Template = memo((props: TemplateProps) => {
</form>
)}
{displayInfo && (
<div id="kc-info" className={clsx(props.kcSignUpClass)}>
<div id="kc-info-wrapper" className={clsx(props.kcInfoAreaWrapperClass)}>
<div id="kc-info" className={getClassName("kcSignUpClass")}>
<div id="kc-info-wrapper" className={getClassName("kcInfoAreaWrapperClass")}>
{infoNode}
</div>
</div>
@ -244,6 +194,4 @@ const Template = memo((props: TemplateProps) => {
</div>
</div>
);
});
export default Template;
}

65
src/TemplateProps.ts Normal file
View File

@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import type { KcContext } from "./kcContext";
import type { I18n } from "./i18n";
export type TemplateProps<KcContext extends KcContext.Common, I18nExtended extends I18n> = {
kcContext: KcContext;
i18n: I18nExtended;
doUseDefaultCss: boolean;
classes?: Partial<Record<TemplateClassKey, string>>;
formNode: ReactNode;
displayInfo?: boolean;
displayMessage?: boolean;
displayRequiredFields?: boolean;
displayWide?: boolean;
showAnotherWayIfPresent?: boolean;
headerNode: ReactNode;
showUsernameNode?: ReactNode;
infoNode?: ReactNode;
};
export type TemplateClassKey =
| "kcHtmlClass"
| "kcLoginClass"
| "kcHeaderClass"
| "kcHeaderWrapperClass"
| "kcFormCardClass"
| "kcFormCardAccountClass"
| "kcFormHeaderClass"
| "kcLocaleWrapperClass"
| "kcContentWrapperClass"
| "kcLabelWrapperClass"
| "kcFormGroupClass"
| "kcResetFlowIcon"
| "kcFeedbackSuccessIcon"
| "kcFeedbackWarningIcon"
| "kcFeedbackErrorIcon"
| "kcFeedbackInfoIcon"
| "kcFormSocialAccountContentClass"
| "kcFormSocialAccountClass"
| "kcSignUpClass"
| "kcInfoAreaWrapperClass";
export const defaultTemplateClasses: Record<TemplateClassKey, string | 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
};

View File

@ -0,0 +1,38 @@
#!/usr/bin/env node
import { getProjectRoot } from "./tools/getProjectRoot";
import cliSelect from "cli-select";
import { loginThemePageIds, type PageId } from "./keycloakify/generateFtl/generateFtl";
import { capitalize } from "tsafe/capitalize";
import { readFile, writeFile } from "fs/promises";
import { existsSync } from "fs";
import { join as pathJoin, relative as pathRelative } from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
(async () => {
const projectRootDir = getProjectRoot();
const { value: pageId } = await cliSelect<PageId>({
"values": [...loginThemePageIds]
}).catch(() => {
console.log("Aborting");
process.exit(-1);
});
const pageBasename = `${capitalize(kebabCaseToCamelCase(pageId))}.tsx`;
console.log(pageId);
const targetFilePath = pathJoin(process.cwd(), "src", "keycloak-theme", "pages", pageBasename);
if (existsSync(targetFilePath)) {
console.log(`${pageId} is already ejected, ${pathRelative(process.cwd(), targetFilePath)} already exists`);
process.exit(-1);
}
writeFile(targetFilePath, await readFile(pathJoin(projectRootDir, "src", "pages", pageBasename)));
console.log(`${pathRelative(process.cwd(), targetFilePath)} created`);
})();

View File

@ -7,13 +7,16 @@ import { typeGuard } from "tsafe/typeGuard";
import { symToStr } from "tsafe/symToStr";
const bundlers = ["mvn", "keycloakify", "none"] as const;
type Bundler = typeof bundlers[number];
type Bundler = (typeof bundlers)[number];
type ParsedPackageJson = {
name: string;
version: string;
homepage?: string;
keycloakify?: {
/** @deprecated: use extraLoginPages instead */
extraPages?: string[];
extraLoginPages?: string[];
extraAccountPages?: string[];
extraThemeProperties?: string[];
areAppAndKeycloakServerSharingSameDomain?: boolean;
artifactId?: string;
@ -29,6 +32,8 @@ const zParsedPackageJson = z.object({
"keycloakify": z
.object({
"extraPages": z.array(z.string()).optional(),
"extraLoginPages": z.array(z.string()).optional(),
"extraAccountPages": z.array(z.string()).optional(),
"extraThemeProperties": z.array(z.string()).optional(),
"areAppAndKeycloakServerSharingSameDomain": z.boolean().optional(),
"artifactId": z.string().optional(),
@ -38,7 +43,7 @@ const zParsedPackageJson = z.object({
.optional()
});
assert<Equals<ReturnType<typeof zParsedPackageJson["parse"]>, ParsedPackageJson>>();
assert<Equals<ReturnType<(typeof zParsedPackageJson)["parse"]>, ParsedPackageJson>>();
/** Consolidated build option gathered form CLI arguments and config in package.json */
export type BuildOptions = BuildOptions.Standalone | BuildOptions.ExternalAssets;
@ -48,7 +53,8 @@ export namespace BuildOptions {
isSilent: boolean;
version: string;
themeName: string;
extraPages?: string[];
extraLoginPages: string[] | undefined;
extraAccountPages: string[] | undefined;
extraThemeProperties?: string[];
groupId: string;
artifactId: string;
@ -119,7 +125,7 @@ export function readBuildOptions(params: {
const common: BuildOptions.Common = (() => {
const { name, keycloakify = {}, version, homepage } = parsedPackageJson;
const { extraPages, extraThemeProperties, groupId, artifactId, bundler } = keycloakify ?? {};
const { extraPages, extraLoginPages, extraAccountPages, extraThemeProperties, groupId, artifactId, bundler } = keycloakify ?? {};
const themeName = name
.replace(/^@(.*)/, "$1")
@ -158,7 +164,8 @@ export function readBuildOptions(params: {
);
})(),
"version": process.env.KEYCLOAKIFY_VERSION ?? version,
extraPages,
"extraLoginPages": [...(extraPages ?? []), ...(extraLoginPages ?? [])],
extraAccountPages,
extraThemeProperties,
isSilent
};

View File

@ -174,6 +174,10 @@ ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc};
) || (
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
are_same_path(path, ["realm"])
) || (
"error.ftl" == pageId &&
are_same_path(path, ["realm"]) &&
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
)
>
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>

View File

@ -10,8 +10,11 @@ import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
// https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java
export const pageIds = [
export const themeTypes = ["login", "account"] as const;
export type ThemeType = (typeof themeTypes)[number];
export const loginThemePageIds = [
"login.ftl",
"login-username.ftl",
"login-password.ftl",
@ -35,6 +38,11 @@ export const pageIds = [
"idp-review-user-profile.ftl"
] as const;
export const accountThemePageIds = ["password.ftl"] as const;
export type PageId = (typeof loginThemePageIds)[number];
export type AccountThemePageId = (typeof accountThemePageIds)[number];
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
export namespace BuildOptionsLike {
@ -68,8 +76,6 @@ export namespace BuildOptionsLike {
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export type PageId = typeof pageIds[number];
export function generateFtlFilesCodeFactory(params: {
indexHtmlCode: string;
//NOTE: Expected to be an empty object if external assets mode is enabled.

View File

@ -1,5 +1,6 @@
import * as fs from "fs";
import { join as pathJoin, dirname as pathDirname } from "path";
import { themeTypes } from "./generateFtl/generateFtl";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "./BuildOptions";
@ -69,7 +70,7 @@ export function generateJavaStackFiles(params: {
"themes": [
{
"name": themeName,
"types": ["login", ...(doBundlesEmailTemplate ? ["email"] : [])]
"types": [...themeTypes, ...(doBundlesEmailTemplate ? ["email"] : [])]
}
]
},

View File

@ -3,7 +3,7 @@ import * as fs from "fs";
import { join as pathJoin, basename as pathBasename } from "path";
import { replaceImportsFromStaticInJsCode } from "./replacers/replaceImportsFromStaticInJsCode";
import { replaceImportsInCssCode } from "./replacers/replaceImportsInCssCode";
import { generateFtlFilesCodeFactory, pageIds } from "./generateFtl";
import { generateFtlFilesCodeFactory, loginThemePageIds, themeTypes, ThemeType } from "./generateFtl";
import { downloadBuiltinKeycloakTheme } from "../download-builtin-keycloak-theme";
import { mockTestingResourcesCommonPath, mockTestingResourcesPath, mockTestingSubDirOfPublicDirBasename } from "../mockTestingResourcesPath";
import { isInside } from "../tools/isInside";
@ -17,7 +17,8 @@ export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.Ex
export namespace BuildOptionsLike {
export type Common = {
themeName: string;
extraPages?: string[];
extraLoginPages?: string[];
extraAccountPages?: string[];
extraThemeProperties?: string[];
isSilent: boolean;
};
@ -62,58 +63,154 @@ export async function generateKeycloakThemeResources(params: {
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, keycloakThemeEmailDirPath, keycloakVersion, buildOptions } = params;
const logger = getLogger({ isSilent: buildOptions.isSilent });
const themeDirPath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, "login");
const getThemeDirPath = (themeType: ThemeType | "email") =>
pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType);
let allCssGlobalsToDefine: Record<string, string> = {};
transformCodebase({
"destDirPath": buildOptions.isStandalone ? pathJoin(themeDirPath, "resources", "build") : reactAppBuildDirPath,
"srcDirPath": reactAppBuildDirPath,
"transformSourceCode": ({ filePath, sourceCode }) => {
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
if (
buildOptions.isStandalone &&
isInside({
"dirPath": pathJoin(reactAppBuildDirPath, mockTestingSubDirOfPublicDirBasename),
filePath
})
) {
return undefined;
}
let generateFtlFilesCode_glob: ReturnType<typeof generateFtlFilesCodeFactory>["generateFtlFilesCode"] | undefined = undefined;
if (/\.css?$/i.test(filePath)) {
if (!buildOptions.isStandalone) {
return undefined;
}
const { cssGlobalsToDefine, fixedCssCode } = replaceImportsInCssCode({
"cssCode": sourceCode.toString("utf8")
});
allCssGlobalsToDefine = {
...allCssGlobalsToDefine,
...cssGlobalsToDefine
};
return { "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8") };
}
if (/\.js?$/i.test(filePath)) {
if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) {
return undefined;
}
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": sourceCode.toString("utf8"),
buildOptions
});
return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
}
return buildOptions.isStandalone ? { "modifiedSourceCode": sourceCode } : undefined;
for (const themeType of themeTypes) {
if (themeType === "account") {
continue;
}
});
const themeDirPath = getThemeDirPath(themeType);
copy_app_resources_to_theme_path: {
const isFirstPass = themeType.indexOf(themeType) === 0;
if (!isFirstPass && !buildOptions.isStandalone) {
break copy_app_resources_to_theme_path;
}
transformCodebase({
"destDirPath": buildOptions.isStandalone ? pathJoin(themeDirPath, "resources", "build") : reactAppBuildDirPath,
"srcDirPath": reactAppBuildDirPath,
"transformSourceCode": ({ filePath, sourceCode }) => {
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
if (
buildOptions.isStandalone &&
isInside({
"dirPath": pathJoin(reactAppBuildDirPath, mockTestingSubDirOfPublicDirBasename),
filePath
})
) {
return undefined;
}
if (/\.css?$/i.test(filePath)) {
if (!buildOptions.isStandalone) {
return undefined;
}
const { cssGlobalsToDefine, fixedCssCode } = replaceImportsInCssCode({
"cssCode": sourceCode.toString("utf8")
});
register_css_variables: {
if (!isFirstPass) {
break register_css_variables;
}
allCssGlobalsToDefine = {
...allCssGlobalsToDefine,
...cssGlobalsToDefine
};
}
return { "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8") };
}
if (/\.js?$/i.test(filePath)) {
if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) {
return undefined;
}
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": sourceCode.toString("utf8"),
buildOptions
});
return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
}
return buildOptions.isStandalone ? { "modifiedSourceCode": sourceCode } : undefined;
}
});
}
const generateFtlFilesCode = (() => {
if (generateFtlFilesCode_glob !== undefined) {
return generateFtlFilesCode_glob;
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
"cssGlobalsToDefine": allCssGlobalsToDefine,
"buildOptions": buildOptions
});
return generateFtlFilesCode;
})();
[...loginThemePageIds, ...(buildOptions.extraLoginPages ?? [])].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.mkdirSync(themeDirPath, { "recursive": true });
fs.writeFileSync(pathJoin(themeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
});
{
const tmpDirPath = pathJoin(themeDirPath, "..", "tmp_xxKdLpdIdLd");
await downloadBuiltinKeycloakTheme({
keycloakVersion,
"destDirPath": tmpDirPath,
isSilent: buildOptions.isSilent
});
const themeResourcesDirPath = pathJoin(themeDirPath, "resources");
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "login", "resources"),
"destDirPath": themeResourcesDirPath
});
const reactAppPublicDirPath = pathJoin(reactAppBuildDirPath, "..", "public");
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"),
"destDirPath": pathJoin(themeResourcesDirPath, pathBasename(mockTestingResourcesCommonPath))
});
transformCodebase({
"srcDirPath": themeResourcesDirPath,
"destDirPath": pathJoin(reactAppPublicDirPath, mockTestingResourcesPath)
});
const keycloakResourcesWithinPublicDirPath = pathJoin(reactAppPublicDirPath, mockTestingSubDirOfPublicDirBasename);
fs.writeFileSync(
pathJoin(keycloakResourcesWithinPublicDirPath, "README.txt"),
Buffer.from(
["This is just a test folder that helps develop", "the login and register page without having to run a Keycloak container"].join(
" "
)
)
);
fs.writeFileSync(pathJoin(keycloakResourcesWithinPublicDirPath, ".gitignore"), Buffer.from("*", "utf8"));
fs.rmSync(tmpDirPath, { recursive: true, force: true });
}
fs.writeFileSync(
pathJoin(themeDirPath, "theme.properties"),
Buffer.from(["parent=keycloak", ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8")
);
}
let doBundlesEmailTemplate: boolean;
@ -133,69 +230,9 @@ export async function generateKeycloakThemeResources(params: {
transformCodebase({
"srcDirPath": keycloakThemeEmailDirPath,
"destDirPath": pathJoin(themeDirPath, "..", "email")
"destDirPath": getThemeDirPath("email")
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
"cssGlobalsToDefine": allCssGlobalsToDefine,
"buildOptions": buildOptions
});
[...pageIds, ...(buildOptions.extraPages ?? [])].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.mkdirSync(themeDirPath, { "recursive": true });
fs.writeFileSync(pathJoin(themeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
});
{
const tmpDirPath = pathJoin(themeDirPath, "..", "tmp_xxKdLpdIdLd");
await downloadBuiltinKeycloakTheme({
keycloakVersion,
"destDirPath": tmpDirPath,
isSilent: buildOptions.isSilent
});
const themeResourcesDirPath = pathJoin(themeDirPath, "resources");
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "login", "resources"),
"destDirPath": themeResourcesDirPath
});
const reactAppPublicDirPath = pathJoin(reactAppBuildDirPath, "..", "public");
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"),
"destDirPath": pathJoin(themeResourcesDirPath, pathBasename(mockTestingResourcesCommonPath))
});
transformCodebase({
"srcDirPath": themeResourcesDirPath,
"destDirPath": pathJoin(reactAppPublicDirPath, mockTestingResourcesPath)
});
const keycloakResourcesWithinPublicDirPath = pathJoin(reactAppPublicDirPath, mockTestingSubDirOfPublicDirBasename);
fs.writeFileSync(
pathJoin(keycloakResourcesWithinPublicDirPath, "README.txt"),
Buffer.from(
["This is just a test folder that helps develop", "the login and register page without having to run a Keycloak container"].join(" ")
)
);
fs.writeFileSync(pathJoin(keycloakResourcesWithinPublicDirPath, ".gitignore"), Buffer.from("*", "utf8"));
fs.rmSync(tmpDirPath, { recursive: true, force: true });
}
fs.writeFileSync(
pathJoin(themeDirPath, "theme.properties"),
Buffer.from(["parent=keycloak", ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8")
);
return { doBundlesEmailTemplate };
}

View File

@ -30,15 +30,18 @@ export function generateStartKeycloakTestingContainer(params: {
buildOptions: { themeName }
} = params;
const keycloakThemePath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace(/\\/g, "/");
fs.writeFileSync(
pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename),
Buffer.from(
[
"#!/usr/bin/env bash",
"",
`docker rm ${containerName} || true`,
"",
`cd ${keycloakThemeBuildingDirPath}`,
`cd "${keycloakThemeBuildingDirPath.replace(/\\/g, "/")}"`,
"",
"docker run \\",
" -p 8080:8080 \\",
@ -46,14 +49,7 @@ export function generateStartKeycloakTestingContainer(params: {
" -e KEYCLOAK_ADMIN=admin \\",
" -e KEYCLOAK_ADMIN_PASSWORD=admin \\",
" -e JAVA_OPTS=-Dkeycloak.profile=preview \\",
` -v ${pathJoin(
keycloakThemeBuildingDirPath,
"src",
"main",
"resources",
"theme",
themeName
)}:/opt/keycloak/themes/${themeName}:rw \\`,
` -v "${keycloakThemePath}":"/opt/keycloak/themes/${themeName}":rw \\`,
` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`,
` start-dev`,
""

View File

@ -1,128 +0,0 @@
import { execSync } from "child_process";
import { join as pathJoin, relative as pathRelative } from "path";
import { exclude } from "tsafe/exclude";
import * as fs from "fs";
const keycloakifyDirPath = pathJoin(__dirname, "..", "..");
fs.writeFileSync(
pathJoin(keycloakifyDirPath, "dist", "package.json"),
Buffer.from(
JSON.stringify(
(() => {
const packageJsonParsed = JSON.parse(fs.readFileSync(pathJoin(keycloakifyDirPath, "package.json")).toString("utf8"));
return {
...packageJsonParsed,
"main": packageJsonParsed["main"].replace(/^dist\//, ""),
"types": packageJsonParsed["types"].replace(/^dist\//, ""),
"bin": Object.fromEntries(Object.entries<string>(packageJsonParsed["bin"]).map(([k, v]) => [k, v.replace(/^dist\//, "")]))
};
})(),
null,
2
),
"utf8"
)
);
const commonThirdPartyDeps = (() => {
const namespaceModuleNames = ["@emotion"];
const standaloneModuleNames = ["react", "@types/react", "powerhooks", "tss-react", "evt"];
return [
...namespaceModuleNames
.map(namespaceModuleName =>
fs
.readdirSync(pathJoin(keycloakifyDirPath, "node_modules", namespaceModuleName))
.map(submoduleName => `${namespaceModuleName}/${submoduleName}`)
)
.reduce((prev, curr) => [...prev, ...curr], []),
...standaloneModuleNames
];
})();
const yarnHomeDirPath = pathJoin(keycloakifyDirPath, ".yarn_home");
fs.rmSync(yarnHomeDirPath, { "recursive": true, "force": true });
fs.mkdirSync(yarnHomeDirPath);
const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
const { targetModuleName, cwd } = params;
const cmd = ["yarn", "link", ...(targetModuleName !== undefined ? [targetModuleName] : [])].join(" ");
console.log(`$ cd ${pathRelative(keycloakifyDirPath, cwd) || "."} && ${cmd}`);
execSync(cmd, {
cwd,
"env": {
...process.env,
"HOME": yarnHomeDirPath
}
});
};
const testAppPaths = (() => {
const arg = process.argv[2];
const testAppNames = arg !== undefined ? [arg] : ["keycloakify-starter", "keycloakify-advanced-starter"];
return testAppNames
.map(testAppName => {
const testAppPath = pathJoin(keycloakifyDirPath, "..", testAppName);
if (fs.existsSync(testAppPath)) {
return testAppPath;
}
console.warn(`Skipping ${testAppName} since it cant be found here: ${testAppPath}`);
return undefined;
})
.filter(exclude(undefined));
})();
if (testAppPaths.length === 0) {
console.error("No test app to link into!");
process.exit(-1);
}
testAppPaths.forEach(testAppPath => execSync("yarn install", { "cwd": testAppPath }));
console.log("=== Linking common dependencies ===");
const total = commonThirdPartyDeps.length;
let current = 0;
commonThirdPartyDeps.forEach(commonThirdPartyDep => {
current++;
console.log(`${current}/${total} ${commonThirdPartyDep}`);
const localInstallPath = pathJoin(
...[keycloakifyDirPath, "node_modules", ...(commonThirdPartyDep.startsWith("@") ? commonThirdPartyDep.split("/") : [commonThirdPartyDep])]
);
execYarnLink({ "cwd": localInstallPath });
});
commonThirdPartyDeps.forEach(commonThirdPartyDep =>
testAppPaths.forEach(testAppPath =>
execYarnLink({
"cwd": testAppPath,
"targetModuleName": commonThirdPartyDep
})
)
);
console.log("=== Linking in house dependencies ===");
execYarnLink({ "cwd": pathJoin(keycloakifyDirPath, "dist") });
testAppPaths.forEach(testAppPath =>
execYarnLink({
"cwd": testAppPath,
"targetModuleName": "keycloakify"
})
);

View File

@ -1,27 +1,22 @@
import { dirname as pathDirname, basename as pathBasename, join as pathJoin } from "path";
import { createReadStream, createWriteStream, unlinkSync } from "fs";
import { stat, mkdir, unlink, readFile, writeFile } from "fs/promises";
import { dirname as pathDirname, basename as pathBasename, join as pathJoin, join } from "path";
import { createReadStream, createWriteStream } from "fs";
import { stat, mkdir, unlink, writeFile } from "fs/promises";
import { transformCodebase } from "./transformCodebase";
import { createHash } from "crypto";
import http from "http";
import https from "https";
import fetch from "make-fetch-happen";
import { createInflateRaw } from "zlib";
import type { Readable } from "stream";
import { homedir } from "os";
import { FetchOptions } from "make-fetch-happen";
import { exec as execCallback } from "child_process";
import { promisify } from "util";
const exec = promisify(execCallback);
function hash(s: string) {
return createHash("sha256").update(s).digest("hex");
}
async function maybeReadFile(path: string) {
try {
return await readFile(path, "utf-8");
} catch (error) {
if ((error as Error & { code: string }).code === "ENOENT") return undefined;
throw error;
}
}
async function maybeStat(path: string) {
try {
return await stat(path);
@ -32,69 +27,59 @@ async function maybeStat(path: string) {
}
/**
* Download a file from `url` to `dir`. Will try to avoid downloading existing
* files by using an `{hash(url)}.etag` file. If this file exists, we add an
* etag headear, so server can tell us if file changed and we should re-download
* or if our file is up-to-date.
* Get an npm configuration value as string, undefined if not set.
*
* Warning, this method assumes that the target filename can be extracted from
* url, content-disposition headers are ignored.
* @param key
* @returns string or undefined
*/
async function getNmpConfig(key: string): Promise<string | undefined> {
const { stdout } = await exec(`npm config get ${key}`);
const value = stdout.trim();
return value && value !== "null" ? value : undefined;
}
/**
* Get proxy configuration from npm config files. Note that we don't care about
* proxy config in env vars, because make-fetch-happen will do that for us.
*
* @returns proxy configuration
*/
async function getNpmProxyConfig(): Promise<Pick<FetchOptions, "proxy" | "noProxy">> {
const proxy = (await getNmpConfig("https-proxy")) ?? (await getNmpConfig("proxy"));
const noProxy = (await getNmpConfig("noproxy")) ?? (await getNmpConfig("no-proxy"));
return { proxy, noProxy };
}
/**
* Download a file from `url` to `dir`. Will try to avoid downloading existing
* files by using the cache directory ~/.keycloakify/cache
*
* If the target directory does not exist, it will be created.
*
* If the target file exists and is out of date, it will be overwritten.
* If the target file exists and there is no etag file, the target file will
* be overwritten.
* If the target file exists, it will be overwritten.
*
* We use make-fetch-happen's internal file cache here, so we don't need to
* worry about redownloading the same file over and over. Unfortunately, that
* cache does not have a single file per entry, but bundles and indexes them,
* so we still need to write the contents to the target directory (possibly
* over and over), cause the current unzip implementation wants random access.
*
* @param url download url
* @param dir target directory
* @param filename target filename
* @returns promise for the full path of the downloaded file
*/
async function download(url: string, dir: string): Promise<string> {
await mkdir(dir, { recursive: true });
const filename = pathBasename(url);
async function download(url: string, dir: string, filename: string): Promise<string> {
const proxyOpts = await getNpmProxyConfig();
const cacheRoot = process.env.XDG_CACHE_HOME ?? homedir();
const cachePath = join(cacheRoot, ".keycloakify/cache");
const opts: FetchOptions = { cachePath, ...proxyOpts };
const response = await fetch(url, opts);
const filepath = pathJoin(dir, filename);
// If downloaded file exists already and has an `.etag` companion file,
// read the etag from that file. This will avoid re-downloading the file
// if it is up to date.
const exists = await maybeStat(filepath);
const etagFilepath = pathJoin(dir, "_" + hash(url).substring(0, 15) + ".etag");
const etag = !exists ? undefined : await maybeReadFile(etagFilepath);
return new Promise((resolve, reject) => {
// use inner method to allow following redirects
function request(url1: URL) {
const headers: Record<string, string> = {};
if (etag) headers["If-None-Match"] = etag;
(url1.protocol === "https:" ? https : http).get(url1, { headers }, response => {
if (response.statusCode === 301 || response.statusCode === 302) {
// follow redirects
request(new URL(response.headers.location!!));
} else if (response.statusCode === 304) {
// up-to-date, resolve now
resolve(filepath);
} else if (response.statusCode !== 200) {
reject(new Error(`Request to ${url1} returned status ${response.statusCode}.`));
} else {
const fp = createWriteStream(filepath, { autoClose: true });
fp.on("err", e => {
fp.close();
unlinkSync(filepath);
reject(e);
});
fp.on("finish", async () => {
// when targetfile has been written, write etag file so that
// next time around we don't need to re-download
const responseEtag = response.headers.etag;
if (responseEtag) await writeFile(etagFilepath, responseEtag, "utf-8");
resolve(filepath);
});
response.pipe(fp);
}
});
}
request(new URL(url));
});
await mkdir(dir, { recursive: true });
await writeFile(filepath, response.body);
return filepath;
}
/**
@ -145,6 +130,7 @@ async function unzip(zipFile: string, dir: string, archiveDir?: string): Promise
// Pull the file out of the archive, write it to the target directory
const input = createRecordReadStream();
const output = createWriteStream(filePath);
output.setMaxListeners(Infinity);
output.on("error", e => reject(Object.assign(e, { filePath })));
output.on("finish", () => resolve(filePath));
input.pipe(output);
@ -181,6 +167,7 @@ async function readFileChunk(file: string, start: number, end: number): Promise<
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
const stream = createReadStream(file, { start, end });
stream.setMaxListeners(Infinity);
stream.on("error", e => reject(e));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("data", chunk => chunks.push(chunk as Buffer));
@ -278,7 +265,8 @@ export async function downloadAndUnzip({
const downloadHash = hash(JSON.stringify({ url, pathOfDirToExtractInArchive })).substring(0, 15);
const extractDirPath = pathJoin(cacheDirPath, `_${downloadHash}`);
const zipFilepath = await download(url, cacheDirPath);
const filename = pathBasename(url);
const zipFilepath = await download(url, cacheDirPath, filename);
const zipMtime = (await stat(zipFilepath)).mtimeMs;
const unzipMtime = (await maybeStat(extractDirPath))?.mtimeMs;

View File

@ -0,0 +1,7 @@
import { capitalize } from "tsafe/capitalize";
export function kebabCaseToCamelCase(kebabCaseString: string): string {
const [first, ...rest] = kebabCaseString.split("-");
return [first, rest.map(capitalize)].join("");
}

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