Compare commits

..

60 Commits

Author SHA1 Message Date
44402c9571 Bump version 2023-04-01 13:31:56 +02:00
ffefb38161 #40 2023-04-01 13:31:35 +02:00
6d667f653e Bump version 2023-03-31 17:46:01 +02:00
1c75fed727 Merge pull request #290 from keycloakify/fix/unzip
refactor: use yauzl for unzipping
2023-03-31 17:45:13 +02:00
9c133be779 fix: create cache dir if it doesn't already exist 2023-03-31 09:36:59 -06:00
71eb953fd3 Minor changes 2023-03-31 13:25:48 +02:00
f49ef21fed Merge branch 'main' into fix/unzip 2023-03-31 12:29:10 +02:00
6a6fa04ba0 Merge pull request #287 from keycloakify/vitest-integration
Vitest integration
2023-03-31 12:00:25 +02:00
83b0838c94 Minor fixes to the Vitest setup 2023-03-31 11:56:54 +02:00
4ebc1e671f feat(config): add ability to customize input/output directory 2023-03-30 21:24:11 -06:00
08c7e38587 refactor: use yauzl for unzipping 2023-03-30 22:56:58 +02:00
b863d9feb3 chore: add .devcontainer file 2023-03-30 02:47:54 -06:00
e527f043b0 test: add test for valid jar artifacts 2023-03-30 02:46:44 -06:00
58bb403787 test: refactor existing tests to vitest 2023-03-30 02:46:25 -06:00
e4725c23eb feat: add vitest testing 2023-03-30 02:45:43 -06:00
b0db8caf65 Bump version 2023-03-30 08:01:08 +02:00
3bcc6bdf93 Merge pull request #286 from willwill96/KEYCLOAKIFY-285
fix: pass only strings to trimIndent
2023-03-30 07:38:21 +02:00
eafb75a958 fix: do not swallow errors 2023-03-29 18:48:10 -06:00
31ca0939aa fix: pass only strings to trimIndent 2023-03-29 18:07:43 -06:00
7784fdcd6a Bump version #284 2023-03-29 21:36:27 +02:00
8247eef735 Merge pull request #269 from keycloakify/fix-download-cache
fix(download): fix download cache not behaving as expected
2023-03-29 21:35:44 +02:00
cb6629f301 fix(test): fix test after changes to downloadAndUnzip 2023-03-29 09:59:54 +02:00
3a6fe1b374 fix(cache): fix download caches
* also fix npm config running 4 times in the worst case
* factor out unzip methods
* factor and enhance trimindent
* factor out more utils
* restore windows build, which failed cause generate-i18n-messages did not write any files
2023-03-29 09:54:29 +02:00
0ba2f37004 Merge branch 'main' into fix-download-cache 2023-03-29 09:22:55 +02:00
e052dee753 Bump version 2023-03-28 10:01:15 +02:00
9c2ec32d12 Merge pull request #282 from juffe/add-update-email-page
feat: add update-email.ftl page
2023-03-28 10:00:23 +02:00
1669c38bc9 feat: add update-email.ftl page 2023-03-28 10:26:24 +03:00
c6ce6d1b49 #281: Add location and occupation to user attribute (as a patch until https://github.com/keycloakify/keycloakify/issues/40%23issuecomment-1202102662) 2023-03-28 05:19:35 +02:00
bc242b0aa7 fmt 2023-03-27 21:03:11 +02:00
41b67f6af4 Merge pull request #279 from bralandealmeida/fix/add-url-to-login-reset-password
Fix: add  to login reset password page
2023-03-27 21:01:43 +02:00
bef21e1cb9 fix: add url to login reset password page
fix: add  to login reset password page

fix: add urls to kc context mocks
2023-03-27 15:16:19 -03:00
8c73630f5a Update reamde 2023-03-27 17:47:26 +02:00
724953d5b7 Bump version 2023-03-25 05:11:47 +01:00
a22b231982 Mute max listener warning 2023-03-25 05:11:25 +01:00
910bfe2318 Fix previous release 2023-03-25 05:09:28 +01:00
70a524da46 Bump version 2023-03-25 04:56:28 +01:00
bf6c846fac Use locate theme dir in eject script 2023-03-25 04:56:17 +01:00
b83e4bef3f Bump version 2023-03-25 04:43:40 +01:00
9f7fe0d8f7 Fix error initialization email 2023-03-25 04:42:44 +01:00
741dee57e4 Bump version 2023-03-25 04:28:47 +01:00
fff4dba708 #276: Add build option to select keycloak default assets version 2023-03-25 04:28:10 +01:00
f4f7ab3e49 Make email theme initialization work with theme-only projects 2023-03-25 04:20:10 +01:00
88fe99b1b8 Bump version 2023-03-24 06:25:45 +01:00
92c1486f6a Fix previous release 2023-03-24 06:25:25 +01:00
caea64cef3 Fix build 2023-03-24 06:01:07 +01:00
90783d8ee8 Bump version 2023-03-24 05:51:01 +01:00
be57801e21 Fix email theme path 2023-03-24 05:50:40 +01:00
ff84786b4e Bump version 2023-03-24 05:44:00 +01:00
1e863672cb Find the email theme in src 2023-03-24 05:43:34 +01:00
fb98a9c383 Bump version 2023-03-24 04:14:57 +01:00
05163f22cb Rename InseeFrLab to Keycloakify 2023-03-24 04:14:41 +01:00
160f12d7d3 Rename UserProfileCommon to UserProfileFormFields 2023-03-24 04:12:52 +01:00
49e4e36184 Update README.md 2023-03-22 21:33:41 +01:00
c4f8879cda Bump version 2023-03-22 04:49:30 +01:00
8f54166653 Merge branch 'main' of https://github.com/InseeFrLab/keycloakify 2023-03-22 04:48:08 +01:00
b9f020c447 Merge pull request #272 from willwill96/update-login-types
feat(login context): improve login typings
2023-03-22 04:22:13 +01:00
c357f3eb4d Mention storybook in the changelog 2023-03-22 03:48:21 +01:00
7ebbb0417a feat(login context): improve login typings 2023-03-21 19:47:05 -07:00
6e4b4173b5 Add link to storybook #274 2023-03-22 03:46:30 +01:00
253825a35e fix(download): fix download cache not behaving as expected 2023-03-21 14:48:16 +01:00
48 changed files with 1770 additions and 935 deletions

11
.gitignore vendored
View File

@ -41,14 +41,15 @@ jspm_packages
.DS_Store
/dist
/dist_test
/keycloakify_starter_test/
/sample_custom_react_project/
/sample_react_project/
/.yarn_home/
.idea
/keycloak_email
/build_keycloak
/src/login/i18n/baseMessages/
/src/account/i18n/baseMessages/
/src/account/i18n/baseMessages/
# VS Code devcontainers
.devcontainer

View File

@ -1,12 +1,15 @@
node_modules/
/dist/
/dist_test/
/CHANGELOG.md
/.yarn_home/
/src/test/apps/
/src/tools/types/
/sample_react_project
/build_keycloak/
/.vscode/
/src/login/i18n/baseMessages/
/src/account/i18n/baseMessages/
# Test Build Directories
/dist_test
/sample_react_project/
/sample_custom_react_project/
/keycloakify_starter_test/

View File

@ -14,7 +14,7 @@
<a href="https://github.com/garronej/keycloakify/blob/main/LICENSE">
<img src="https://img.shields.io/npm/l/keycloakify">
</a>
<a href="https://github.com/InseeFrLab/keycloakify/blob/729503fe31a155a823f46dd66ad4ff34ca274e0a/tsconfig.json#L14">
<a href="https://github.com/keycloakify/keycloakify/blob/729503fe31a155a823f46dd66ad4ff34ca274e0a/tsconfig.json#L14">
<img src="https://camo.githubusercontent.com/0f9fcc0ac1b8617ad4989364f60f78b2d6b32985ad6a508f215f14d8f897b8d3/68747470733a2f2f62616467656e2e6e65742f62616467652f547970655363726970742f7374726963742532302546302539462539322541412f626c7565">
</a>
<a href="https://github.com/thomasdarimont/awesome-keycloak">
@ -25,6 +25,8 @@
-
<a href="https://docs.keycloakify.dev">Documentation</a>
-
<a href="https://storybook.keycloakify.dev/storybook">Storybook</a>
-
<a href="https://github.com/codegouvfr/keycloakify-starter">Starter project</a>
</p>
</p>
@ -34,9 +36,11 @@
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
</p>
> 🗣 V6 have been released 🎉
> [It features major improvements](https://github.com/InseeFrLab/keycloakify#600).
> Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6).
The more ⭐️ the project gets, the more time I spend improving and maintaining it. Thank you for your support 😊
> 🗣 V7 have been released 🎉
> [It features major improvements](https://github.com/keycloakify/keycloakify#70-).
> Checkout [the migration guide](https://docs.keycloakify.dev/migration-guides/v6-greater-than-v7).
# Changelog highlights
@ -45,10 +49,12 @@
- Account theme support 🚀
- It's much easier to customize pages at the CSS level, you can now see in the browser dev tool the customizable classes.
- New interactive CLI tool `npx eject-keycloak-page`, that enables to select the page you want to customize at the component level.
- There is [a Storybook](https://storybook.keycloakify.dev)
- [Remember me is fixed](https://github.com/keycloakify/keycloakify/pull/272)
## 6.13
- Build work behind corporate proxies, [see issue](https://github.com/InseeFrLab/keycloakify/issues/257).
- Build work behind corporate proxies, [see issue](https://github.com/keycloakify/keycloakify/issues/257).
## 6.12
@ -61,13 +67,13 @@ Massive improvement in the developer experience:
## 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).
- You no longer need to have Maven installed to build the theme. Thanks to @lordvlad, [see PR](https://github.com/keycloakify/keycloakify/pull/239).
- Feature new build options: [`bundler`](https://docs.keycloakify.dev/build-options#keycloakify.bundler), [`groupId`](https://docs.keycloakify.dev/build-options#keycloakify.groupid), [`artifactId`](https://docs.keycloakify.dev/build-options#keycloakify.artifactid), [`version`](https://docs.keycloakify.dev/build-options#version).
Theses options can be user to customize the output name of the .jar. You can use environnement variables to overrides the values read in the package.json. Thanks to @lordvlad.
## 6.10.0
- Widows compat (thanks to @lordvlad, [see PR](https://github.com/InseeFrLab/keycloakify/pull/226)). WSL is no longer required 🎉
- Widows compat (thanks to @lordvlad, [see PR](https://github.com/keycloakify/keycloakify/pull/226)). WSL is no longer required 🎉
## 6.8.4
@ -77,19 +83,19 @@ Massive improvement in the developer experience:
- It is now possible to pass a custom `<Template />` component as a prop to `<KcApp />` and every
individual page (`<Login />`, `<RegisterUserProfile />`, ...) it enables to customize only the header and footer for
example without having to switch to a full-component level customization. [See issue](https://github.com/InseeFrLab/keycloakify/issues/191).
example without having to switch to a full-component level customization. [See issue](https://github.com/keycloakify/keycloakify/issues/191).
## 6.7.0
- Add support for `webauthn-authenticate.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/InseeFrLab/keycloakify/pull/185).
- Add support for `webauthn-authenticate.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/185).
## 6.6.0
- Add support for `login-password.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/InseeFrLab/keycloakify/pull/184).
- Add support for `login-password.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/184).
## 6.5.0
- Add support for `login-username.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/InseeFrLab/keycloakify/pull/183).
- Add support for `login-username.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/183).
## 6.4.0
@ -108,11 +114,11 @@ Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6)
## 5.8.0
- [React.lazy()](https://reactjs.org/docs/code-splitting.html#reactlazy) support 🎉. [#141](https://github.com/InseeFrLab/keycloakify/issues/141)
- [React.lazy()](https://reactjs.org/docs/code-splitting.html#reactlazy) support 🎉. [#141](https://github.com/keycloakify/keycloakify/issues/141)
## 5.7.0
- Feat `logout-confirm.ftl`. [PR](https://github.com/InseeFrLab/keycloakify/pull/120)
- Feat `logout-confirm.ftl`. [PR](https://github.com/keycloakify/keycloakify/pull/120)
## 5.6.4
@ -120,7 +126,7 @@ Fix `login-verify-email.ftl` page. [Before](https://user-images.githubuserconten
## v5.6.0
Add support for `login-config-totp.ftl` page [#127](https://github.com/InseeFrLab/keycloakify/pull/127).
Add support for `login-config-totp.ftl` page [#127](https://github.com/keycloakify/keycloakify/pull/127).
## v5.3.0
@ -135,7 +141,7 @@ Import of terms and services have changed. [See example](https://github.com/garr
## v4.10.0
Add `login-idp-link-email.ftl` page [See PR](https://github.com/InseeFrLab/keycloakify/pull/92).
Add `login-idp-link-email.ftl` page [See PR](https://github.com/keycloakify/keycloakify/pull/92).
## v4.8.0
@ -148,7 +154,7 @@ Add `login-idp-link-email.ftl` page [See PR](https://github.com/InseeFrLab/keycl
## v4.7.2
> WARNING: This is broken.
> Testing with local Keycloak container working with M1 Mac. Thanks to [@eduardosanzb](https://github.com/InseeFrLab/keycloakify/issues/43#issuecomment-975699658).
> Testing with local Keycloak container working with M1 Mac. Thanks to [@eduardosanzb](https://github.com/keycloakify/keycloakify/issues/43#issuecomment-975699658).
> Be aware: When running M1s you are testing with Keycloak v15 else the local container spun will be a Keycloak v16.1.0.
## v4.7.0
@ -182,12 +188,12 @@ Change [this](https://github.com/garronej/keycloakify-demo-app/blob/df664c13c77c
No breaking changes except that `@emotion/react`, [`tss-react`](https://www.npmjs.com/package/tss-react) and [`powerhooks`](https://www.npmjs.com/package/powerhooks) are now `peerDependencies` instead of being just dependencies.
It's important to avoid problem when using `keycloakify` alongside [`mui`](https://mui.com) and
[when passing params from the app to the login page](https://github.com/InseeFrLab/keycloakify#implement-context-persistence-optional).
[when passing params from the app to the login page](https://github.com/keycloakify/keycloakify#implement-context-persistence-optional).
## v2.5
- Feature [Use advanced message](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/i18n/useKcMessage.tsx#L53-L66)
and [`messagesPerFields`](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/getKcContext/KcContextBase.ts#L70-L75) (implementation [here](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/bin/build-keycloak-theme/generateFtl/common.ftl#L130-L189))
- Feature [Use advanced message](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/i18n/useKcMessage.tsx#L53-L66)
and [`messagesPerFields`](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/getKcContext/KcContextBase.ts#L70-L75) (implementation [here](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/bin/build-keycloak-theme/generateFtl/common.ftl#L130-L189))
- Test container now uses Keycloak version `15.0.2`.
## v2

View File

@ -1,10 +1,10 @@
{
"name": "keycloakify",
"version": "7.0.2",
"version": "7.4.0",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
"url": "git://github.com/inseefrlab/keycloakify.git"
"url": "git://github.com/keycloakify/keycloakify.git"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
@ -12,11 +12,11 @@
"prepare": "yarn generate-i18n-messages",
"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",
"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",
"test": "yarn test:types && vitest run",
"test:keycloakify-starter": "ts-node scripts/test-keycloakify-starter",
"test:types": "tsc -p test/tsconfig.json --noEmit",
"_format": "prettier '**/*.{ts,tsx,json,md}'",
"format": "yarn _format --write",
"format:check": "yarn _format --list-different",
@ -69,6 +69,7 @@
"@types/minimist": "^1.2.2",
"@types/node": "^18.15.3",
"@types/react": "18.0.9",
"@types/yauzl": "^2.10.0",
"concurrently": "^7.6.0",
"copyfiles": "^2.4.1",
"husky": "^4.3.8",
@ -80,7 +81,8 @@
"scripting-tools": "^0.19.13",
"ts-node": "^10.9.1",
"tsc-alias": "^1.8.3",
"typescript": "^5.0.1-rc"
"typescript": "^5.0.1-rc",
"vitest": "^0.29.8"
},
"dependencies": {
"@octokit/rest": "^18.12.0",
@ -94,6 +96,7 @@
"react-markdown": "^5.0.3",
"rfc4648": "^1.5.2",
"tsafe": "^1.6.0",
"yauzl": "^2.10.0",
"zod": "^3.17.10"
}
}

View File

@ -7,7 +7,7 @@ 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,
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
// update the version array for generating for newer version.
//@ts-ignore
@ -16,7 +16,7 @@ const propertiesParser = require("properties-parser");
const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
(async () => {
async function main() {
const keycloakVersion = "21.0.1";
const tmpDirPath = pathJoin(getProjectRoot(), "tmp_xImOef9dOd44");
@ -37,7 +37,9 @@ const logger = getLogger({ isSilent });
const baseThemeDirPath = pathJoin(tmpDirPath, "base");
crawl(baseThemeDirPath).forEach(filePath => {
const match = filePath.match(/^([^/]+)\/messages\/messages_([^.]+)\.properties$/);
const match =
filePath.match(/^([^/]+)\/messages\/messages_([^.]+)\.properties$/) ||
filePath.match(/^([^\\]+)\\messages\\messages_([^.]+)\.properties$/);
if (match === null) {
return;
@ -114,4 +116,8 @@ const logger = getLogger({ isSilent });
)
);
});
})();
}
if (require.main === module) {
main().catch(e => console.error(e));
}

View File

@ -0,0 +1,29 @@
import { execSync } from "child_process";
import { existsSync, readFileSync, rmSync, writeFileSync } from "fs";
import path from "path";
const testDir = "keycloakify_starter_test";
if (existsSync(path.join(process.cwd(), testDir))) {
rmSync(path.join(process.cwd(), testDir), { recursive: true });
}
// Build and link package
execSync("yarn build");
const pkgJSON = JSON.parse(readFileSync(path.join(process.cwd(), "package.json")).toString("utf8"));
pkgJSON.main = "./index.js";
pkgJSON.types = "./index.d.ts";
pkgJSON.scripts.prepare = undefined;
writeFileSync(path.join(process.cwd(), "dist", "package.json"), JSON.stringify(pkgJSON));
// Wrapped in a try/catch because unlink errors if the package isn't linked
try {
execSync("yarn unlink");
} catch {}
execSync("yarn link", { "cwd": path.join(process.cwd(), "dist") });
// Clone latest keycloakify-starter and link to keycloakify output
execSync(`git clone https://github.com/keycloakify/keycloakify-starter.git ${testDir}`);
execSync("yarn install", { "cwd": path.join(process.cwd(), testDir) });
execSync("yarn link keycloakify", { "cwd": path.join(process.cwd(), testDir) });
//Ensure keycloak theme can be built
execSync("yarn build-keycloak-theme", { "cwd": path.join(process.cwd(), testDir) });

View File

@ -1,42 +1,41 @@
#!/usr/bin/env node
import { keycloakThemeBuildingDirPath } from "./keycloakify";
import { join as pathJoin } from "path";
import { downloadAndUnzip } from "./tools/downloadAndUnzip";
import { promptKeycloakVersion } from "./promptKeycloakVersion";
import { getCliOptions } from "./tools/cliOptions";
import { getLogger } from "./tools/logger";
import { getKeycloakBuildPath } from "./keycloakify/build-paths";
export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: string; destDirPath: string; isSilent: boolean }) {
const { keycloakVersion, destDirPath, isSilent } = params;
const { keycloakVersion, destDirPath } = params;
await Promise.all(
["", "-community"].map(ext =>
downloadAndUnzip({
"destDirPath": destDirPath,
"url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`,
"pathOfDirToExtractInArchive": `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`,
"cacheDirPath": pathJoin(keycloakThemeBuildingDirPath, ".cache"),
isSilent
"pathOfDirToExtractInArchive": `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`
})
)
);
}
if (require.main === module) {
(async () => {
const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
const { keycloakVersion } = await promptKeycloakVersion();
async function main() {
const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
const { keycloakVersion } = await promptKeycloakVersion();
const destDirPath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme");
const destDirPath = pathJoin(getKeycloakBuildPath(), "src", "main", "resources", "theme");
logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`);
logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`);
await downloadBuiltinKeycloakTheme({
keycloakVersion,
destDirPath,
isSilent
});
})();
await downloadBuiltinKeycloakTheme({
keycloakVersion,
destDirPath,
isSilent
});
}
if (require.main === module) {
main();
}

View File

@ -16,6 +16,7 @@ import { existsSync } from "fs";
import { join as pathJoin, relative as pathRelative } from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import { getThemeSrcDirPath } from "./keycloakify/build-paths";
(async () => {
const projectRootDir = getProjectRoot();
@ -50,7 +51,13 @@ import { assert, Equals } from "tsafe/assert";
const pageBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(/ftl$/, "tsx");
const targetFilePath = pathJoin(process.cwd(), "src", "keycloak-theme", themeType, "pages", pageBasename);
const { themeSrcDirPath } = getThemeSrcDirPath();
if (themeSrcDirPath === undefined) {
throw new Error("Couldn't locate your theme sources");
}
const targetFilePath = pathJoin(themeSrcDirPath, themeType, "pages", pageBasename);
if (existsSync(targetFilePath)) {
console.log(`${pageId} is already ejected, ${pathRelative(process.cwd(), targetFilePath)} already exists`);

View File

@ -1,27 +1,35 @@
#!/usr/bin/env node
import { downloadBuiltinKeycloakTheme } from "./download-builtin-keycloak-theme";
import { keycloakThemeEmailDirPath } from "./keycloakify";
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./promptKeycloakVersion";
import * as fs from "fs";
import { getCliOptions } from "./tools/cliOptions";
import { getLogger } from "./tools/logger";
import { getEmailThemeSrcDirPath } from "./keycloakify/build-paths";
(async () => {
export async function main() {
const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
if (fs.existsSync(keycloakThemeEmailDirPath)) {
logger.warn(`There is already a ${pathRelative(process.cwd(), keycloakThemeEmailDirPath)} directory in your project. Aborting.`);
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath();
if (emailThemeSrcDirPath === undefined) {
logger.warn("Couldn't locate your theme source directory");
process.exit(-1);
}
if (fs.existsSync(emailThemeSrcDirPath)) {
logger.warn(`There is already a ${pathRelative(process.cwd(), emailThemeSrcDirPath)} directory in your project. Aborting.`);
process.exit(-1);
}
const { keycloakVersion } = await promptKeycloakVersion();
const builtinKeycloakThemeTmpDirPath = pathJoin(keycloakThemeEmailDirPath, "..", "tmp_xIdP3_builtin_keycloak_theme");
const builtinKeycloakThemeTmpDirPath = pathJoin(emailThemeSrcDirPath, "..", "tmp_xIdP3_builtin_keycloak_theme");
await downloadBuiltinKeycloakTheme({
keycloakVersion,
@ -31,18 +39,20 @@ import { getLogger } from "./tools/logger";
transformCodebase({
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "base", "email"),
"destDirPath": keycloakThemeEmailDirPath
"destDirPath": emailThemeSrcDirPath
});
{
const themePropertyFilePath = pathJoin(keycloakThemeEmailDirPath, "theme.properties");
const themePropertyFilePath = pathJoin(emailThemeSrcDirPath, "theme.properties");
fs.writeFileSync(themePropertyFilePath, Buffer.from(`parent=base\n${fs.readFileSync(themePropertyFilePath).toString("utf8")}`, "utf8"));
}
logger.log(
`${pathRelative(process.cwd(), keycloakThemeEmailDirPath)} ready to be customized, feel free to remove every file you do not customize`
);
logger.log(`${pathRelative(process.cwd(), emailThemeSrcDirPath)} ready to be customized, feel free to remove every file you do not customize`);
fs.rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true, "force": true });
})();
}
if (require.main === module) {
main();
}

View File

@ -1,49 +1,11 @@
import { z } from "zod";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import { id } from "tsafe/id";
import { parse as urlParse } from "url";
import { typeGuard } from "tsafe/typeGuard";
import { symToStr } from "tsafe/symToStr";
const bundlers = ["mvn", "keycloakify", "none"] as const;
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;
groupId?: string;
bundler?: Bundler;
};
};
const zParsedPackageJson = z.object({
"name": z.string(),
"version": z.string(),
"homepage": z.string().optional(),
"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(),
"groupId": z.string().optional(),
"bundler": z.enum(bundlers).optional()
})
.optional()
});
assert<Equals<ReturnType<(typeof zParsedPackageJson)["parse"]>, ParsedPackageJson>>();
import { bundlers, getParsedPackageJson } from "./parsed-package-json";
import type { Bundler } from "./parsed-package-json";
import { getAppInputPath, getKeycloakBuildPath } from "./build-paths";
/** Consolidated build option gathered form CLI arguments and config in package.json */
export type BuildOptions = BuildOptions.Standalone | BuildOptions.ExternalAssets;
@ -59,6 +21,12 @@ export namespace BuildOptions {
groupId: string;
artifactId: string;
bundler: Bundler;
keycloakVersionDefaultAssets: string;
// Directory of your built react project. Defaults to {cwd}/build
appInputPath: string;
// Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak
keycloakBuildPath: string;
customUserAttributes: string[];
};
export type Standalone = Common & {
@ -85,15 +53,10 @@ export namespace BuildOptions {
}
}
export function readBuildOptions(params: {
packageJson: string;
CNAME: string | undefined;
isExternalAssetsCliParamProvided: boolean;
isSilent: boolean;
}): BuildOptions {
const { packageJson, CNAME, isExternalAssetsCliParamProvided, isSilent } = params;
export function readBuildOptions(params: { CNAME: string | undefined; isExternalAssetsCliParamProvided: boolean; isSilent: boolean }): BuildOptions {
const { CNAME, isExternalAssetsCliParamProvided, isSilent } = params;
const parsedPackageJson = zParsedPackageJson.parse(JSON.parse(packageJson));
const parsedPackageJson = getParsedPackageJson();
const url = (() => {
const { homepage } = parsedPackageJson;
@ -125,7 +88,8 @@ export function readBuildOptions(params: {
const common: BuildOptions.Common = (() => {
const { name, keycloakify = {}, version, homepage } = parsedPackageJson;
const { extraPages, extraLoginPages, extraAccountPages, extraThemeProperties, groupId, artifactId, bundler } = keycloakify ?? {};
const { extraPages, extraLoginPages, extraAccountPages, extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets } =
keycloakify ?? {};
const themeName = name
.replace(/^@(.*)/, "$1")
@ -167,7 +131,11 @@ export function readBuildOptions(params: {
"extraLoginPages": [...(extraPages ?? []), ...(extraLoginPages ?? [])],
extraAccountPages,
extraThemeProperties,
isSilent
isSilent,
"keycloakVersionDefaultAssets": keycloakVersionDefaultAssets ?? "11.0.3",
appInputPath: getAppInputPath(),
keycloakBuildPath: getKeycloakBuildPath(),
"customUserAttributes": keycloakify.customUserAttributes ?? []
};
})();

View File

@ -0,0 +1,72 @@
import * as fs from "fs";
import { exclude } from "tsafe";
import { crawl } from "../tools/crawl";
import { pathJoin } from "../tools/pathJoin";
import { getParsedPackageJson } from "./parsed-package-json";
const DEFAULT_APP_INPUT_PATH = "build";
const DEFAULT_KEYCLOAK_BUILD_PATH = "build_keycloak";
const THEME_SRC_DIR_BASENAME = "keycloak-theme";
export const getReactProjectDirPath = () => process.cwd();
export const getCnamePath = () => pathJoin(getReactProjectDirPath(), "public", "CNAME");
const parseAppInputPath = (path?: string) => {
if (!path) {
return pathJoin(process.cwd(), DEFAULT_APP_INPUT_PATH);
} else if (path.startsWith("./")) {
return pathJoin(process.cwd(), path.replace("./", ""));
}
return path;
};
const parseKeycloakBuildPath = (path?: string) => {
if (!path) {
return pathJoin(process.cwd(), DEFAULT_KEYCLOAK_BUILD_PATH);
} else if (path.startsWith("./")) {
return pathJoin(process.cwd(), path.replace("./", ""));
}
return path;
};
export const getAppInputPath = () => {
return parseAppInputPath(getParsedPackageJson().keycloakify?.appInputPath);
};
export const getKeycloakBuildPath = () => {
return parseKeycloakBuildPath(getParsedPackageJson().keycloakify?.keycloakBuildPath);
};
export const getThemeSrcDirPath = () => {
const srcDirPath = pathJoin(getReactProjectDirPath(), "src");
const themeSrcDirPath: string | undefined = crawl(srcDirPath)
.map(fileRelativePath => {
const split = fileRelativePath.split(THEME_SRC_DIR_BASENAME);
if (split.length !== 2) {
return undefined;
}
return pathJoin(srcDirPath, split[0] + THEME_SRC_DIR_BASENAME);
})
.filter(exclude(undefined))[0];
if (themeSrcDirPath === undefined) {
if (fs.existsSync(pathJoin(srcDirPath, "login")) || fs.existsSync(pathJoin(srcDirPath, "account"))) {
return { "themeSrcDirPath": srcDirPath };
}
return { "themeSrcDirPath": undefined };
}
return { themeSrcDirPath };
};
export const getEmailThemeSrcDirPath = () => {
const { themeSrcDirPath } = getThemeSrcDirPath();
const emailThemeSrcDirPath = themeSrcDirPath === undefined ? undefined : pathJoin(themeSrcDirPath, "email");
return { emailThemeSrcDirPath };
};

View File

@ -13,7 +13,7 @@
"totp", "totpSecret", "SAMLRequest", "SAMLResponse", "relayState", "device_user_code", "code",
"password-new", "rememberMe", "login", "authenticationExecution", "cancel-aia", "clientDataJSON",
"authenticatorData", "signature", "credentialId", "userHandle", "error", "authn_use_chk", "authenticationExecution",
"isSetRetry", "try-again", "attestationObject", "publicKeyCredentialId", "authenticatorLabel"
"isSetRetry", "try-again", "attestationObject", "publicKeyCredentialId", "authenticatorLabel"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM
]>
<#attempt>
@ -164,9 +164,9 @@
key == "updateProfileCtx" &&
are_same_path(path, [])
) || (
<#-- https://github.com/InseeFrLab/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
<#-- https://github.com/InseeFrLab/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
<#-- https://github.com/InseeFrLab/keycloakify/issues/109#issuecomment-1134610163 -->
<#-- https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
key == "loginAction" &&
are_same_path(path, ["url"]) &&
["saml-post-form.ftl", "error.ftl", "info.ftl"]?seq_contains(pageId) &&

View File

@ -8,7 +8,6 @@ import { objectKeys } from "tsafe/objectKeys";
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
export const themeTypes = ["login", "account"] as const;
@ -35,7 +34,8 @@ export const loginThemePageIds = [
"login-config-totp.ftl",
"logout-confirm.ftl",
"update-user-profile.ftl",
"idp-review-user-profile.ftl"
"idp-review-user-profile.ftl",
"update-email.ftl"
] as const;
export const accountThemePageIds = ["password.ftl", "account.ftl"] as const;
@ -46,7 +46,11 @@ export type AccountThemePageId = (typeof accountThemePageIds)[number];
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
export namespace BuildOptionsLike {
export type Standalone = {
export type Common = {
customUserAttributes: string[];
};
export type Standalone = Common & {
isStandalone: true;
urlPathname: string | undefined;
};
@ -58,23 +62,21 @@ export namespace BuildOptionsLike {
isStandalone: false;
};
export type SameDomain = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: true;
};
export type SameDomain = Common &
CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: true;
};
export type DifferentDomains = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: false;
urlOrigin: string;
urlPathname: string | undefined;
};
export type DifferentDomains = Common &
CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: false;
urlOrigin: string;
urlPathname: string | undefined;
};
}
}
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function generateFtlFilesCodeFactory(params: {
indexHtmlCode: string;
@ -152,7 +154,11 @@ export function generateFtlFilesCodeFactory(params: {
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }': fs
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
.toString("utf8")
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1],
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1]
.replace(
"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM",
buildOptions.customUserAttributes.length === 0 ? "" : ", " + buildOptions.customUserAttributes.map(name => `"${name}"`).join(", ")
),
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
"<#if scripts??>",
" <#list scripts as script>",

View File

@ -9,8 +9,6 @@ import { mockTestingResourcesCommonPath, mockTestingResourcesPath, mockTestingSu
import { isInside } from "../tools/isInside";
import type { BuildOptions } from "./BuildOptions";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import { getLogger } from "../tools/logger";
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
@ -21,6 +19,7 @@ export namespace BuildOptionsLike {
extraAccountPages?: string[];
extraThemeProperties?: string[];
isSilent: boolean;
customUserAttributes: string[];
};
export type Standalone = Common & {
@ -47,22 +46,16 @@ export namespace BuildOptionsLike {
}
}
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function generateKeycloakThemeResources(params: {
reactAppBuildDirPath: string;
keycloakThemeBuildingDirPath: string;
keycloakThemeEmailDirPath: string;
emailThemeSrcDirPath: string | undefined;
keycloakVersion: string;
buildOptions: BuildOptionsLike;
}): Promise<{ doBundlesEmailTemplate: boolean }> {
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, keycloakThemeEmailDirPath, keycloakVersion, buildOptions } = params;
const logger = getLogger({ isSilent: buildOptions.isSilent });
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, emailThemeSrcDirPath, keycloakVersion, buildOptions } = params;
const getThemeDirPath = (themeType: ThemeType | "email") =>
pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType);
@ -145,7 +138,7 @@ export async function generateKeycloakThemeResources(params: {
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
"cssGlobalsToDefine": allCssGlobalsToDefine,
"buildOptions": buildOptions
buildOptions
});
return generateFtlFilesCode;
@ -228,13 +221,7 @@ export async function generateKeycloakThemeResources(params: {
let doBundlesEmailTemplate: boolean;
email: {
if (!fs.existsSync(keycloakThemeEmailDirPath)) {
logger.log(
[
`Not bundling email template because ${pathBasename(keycloakThemeEmailDirPath)} does not exist`,
`To start customizing the email template, run: 👉 npx create-keycloak-email-directory 👈`
].join("\n")
);
if (emailThemeSrcDirPath === undefined) {
doBundlesEmailTemplate = false;
break email;
}
@ -242,7 +229,7 @@ export async function generateKeycloakThemeResources(params: {
doBundlesEmailTemplate = true;
transformCodebase({
"srcDirPath": keycloakThemeEmailDirPath,
"srcDirPath": emailThemeSrcDirPath,
"destDirPath": getThemeDirPath("email")
});
}

View File

@ -4,5 +4,5 @@ export * from "./keycloakify";
import { main } from "./keycloakify";
if (require.main === module) {
main().catch(e => console.error(e));
main();
}

View File

@ -9,12 +9,9 @@ import { getLogger } from "../tools/logger";
import { getCliOptions } from "../tools/cliOptions";
import jar from "../tools/jar";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
const reactProjectDirPath = process.cwd();
export const keycloakThemeBuildingDirPath = pathJoin(reactProjectDirPath, "build_keycloak");
export const keycloakThemeEmailDirPath = pathJoin(reactProjectDirPath, "src", "keycloak-theme", "email");
import { Equals } from "tsafe";
import { getEmailThemeSrcDirPath } from "./build-paths";
import { getCnamePath, getAppInputPath, getKeycloakBuildPath, getReactProjectDirPath } from "./build-paths";
export async function main() {
const { isSilent, hasExternalAssets } = getCliOptions(process.argv.slice(2));
@ -22,9 +19,8 @@ export async function main() {
logger.log("🔏 Building the keycloak theme...⌚");
const buildOptions = readBuildOptions({
"packageJson": fs.readFileSync(pathJoin(reactProjectDirPath, "package.json")).toString("utf8"),
"CNAME": (() => {
const cnameFilePath = pathJoin(reactProjectDirPath, "public", "CNAME");
const cnameFilePath = getCnamePath();
if (!fs.existsSync(cnameFilePath)) {
return undefined;
@ -37,18 +33,23 @@ export async function main() {
});
const { doBundlesEmailTemplate } = await generateKeycloakThemeResources({
keycloakThemeBuildingDirPath,
keycloakThemeEmailDirPath,
"reactAppBuildDirPath": pathJoin(reactProjectDirPath, "build"),
keycloakThemeBuildingDirPath: buildOptions.keycloakBuildPath,
"emailThemeSrcDirPath": (() => {
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath();
if (emailThemeSrcDirPath === undefined || !fs.existsSync(emailThemeSrcDirPath)) {
return;
}
return emailThemeSrcDirPath;
})(),
"reactAppBuildDirPath": getAppInputPath(),
buildOptions,
//We have to leave it at that otherwise we break our default theme.
//Problem is that we can`t guarantee that the the old resources
//will still be available on the newer keycloak version.
"keycloakVersion": "11.0.3"
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets
});
const { jarFilePath } = generateJavaStackFiles({
keycloakThemeBuildingDirPath,
keycloakThemeBuildingDirPath: buildOptions.keycloakBuildPath,
doBundlesEmailTemplate,
buildOptions
});
@ -60,7 +61,7 @@ export async function main() {
case "keycloakify":
logger.log("🫶 Let keycloakify do its thang");
await jar({
"rootPath": pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources"),
"rootPath": pathJoin(buildOptions.keycloakBuildPath, "src", "main", "resources"),
"version": buildOptions.version,
"groupId": buildOptions.groupId,
"artifactId": buildOptions.artifactId,
@ -69,7 +70,7 @@ export async function main() {
break;
case "mvn":
logger.log("🫙 Run maven to deliver a jar");
child_process.execSync("mvn package", { "cwd": keycloakThemeBuildingDirPath });
child_process.execSync("mvn package", { "cwd": buildOptions.keycloakBuildPath });
break;
default:
assert<Equals<typeof buildOptions.bundler, never>>(false);
@ -79,7 +80,7 @@ export async function main() {
const containerKeycloakVersion = "20.0.1";
generateStartKeycloakTestingContainer({
keycloakThemeBuildingDirPath,
keycloakThemeBuildingDirPath: buildOptions.keycloakBuildPath,
"keycloakVersion": containerKeycloakVersion,
buildOptions
});
@ -87,7 +88,7 @@ export async function main() {
logger.log(
[
"",
`✅ Your keycloak theme has been generated and bundled into ./${pathRelative(reactProjectDirPath, jarFilePath)} 🚀`,
`✅ Your keycloak theme has been generated and bundled into ./${pathRelative(getReactProjectDirPath(), jarFilePath)} 🚀`,
`It is to be placed in "/opt/keycloak/providers" in the container running a quay.io/keycloak/keycloak Docker image.`,
"",
//TODO: Restore when we find a good Helm chart for Keycloak.
@ -122,8 +123,8 @@ export async function main() {
`To test your theme locally you can spin up a Keycloak ${containerKeycloakVersion} container image with the theme pre loaded by running:`,
"",
`👉 $ .${pathSep}${pathRelative(
reactProjectDirPath,
pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename)
getReactProjectDirPath(),
pathJoin(getKeycloakBuildPath(), generateStartKeycloakTestingContainer.basename)
)} 👈`,
"",
`Test with different Keycloak versions by editing the .sh file. see available versions here: https://quay.io/repository/keycloak/keycloak?tab=tags`,

View File

@ -0,0 +1,60 @@
import * as fs from "fs";
import { assert } from "tsafe";
import type { Equals } from "tsafe";
import { z } from "zod";
import { pathJoin } from "../tools/pathJoin";
const reactProjectDirPath = process.cwd();
export const bundlers = ["mvn", "keycloakify", "none"] as const;
export 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;
groupId?: string;
bundler?: Bundler;
keycloakVersionDefaultAssets?: string;
appInputPath?: string;
keycloakBuildPath?: string;
customUserAttributes?: string[];
};
};
const zParsedPackageJson = z.object({
"name": z.string(),
"version": z.string(),
"homepage": z.string().optional(),
"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(),
"groupId": z.string().optional(),
"bundler": z.enum(bundlers).optional(),
"keycloakVersionDefaultAssets": z.string().optional(),
"appInputPath": z.string().optional(),
"keycloakBuildPath": z.string().optional(),
"customUserAttributes": z.array(z.string()).optional()
})
.optional()
});
assert<Equals<ReturnType<(typeof zParsedPackageJson)["parse"]>, ParsedPackageJson>>();
let parsedPackageJson: undefined | ReturnType<(typeof zParsedPackageJson)["parse"]>;
export const getParsedPackageJson = () => {
if (parsedPackageJson) return parsedPackageJson;
parsedPackageJson = zParsedPackageJson.parse(JSON.parse(fs.readFileSync(pathJoin(reactProjectDirPath, "package.json")).toString("utf8")));
return parsedPackageJson;
};

View File

@ -1,15 +1,13 @@
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 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 { createHash } from "crypto";
import { mkdir, stat, writeFile } from "fs/promises";
import fetch, { type FetchOptions } from "make-fetch-happen";
import { dirname as pathDirname, join as pathJoin } from "path";
import { assert } from "tsafe";
import { promisify } from "util";
import { getProjectRoot } from "./getProjectRoot";
import { transformCodebase } from "./transformCodebase";
import { unzip } from "./unzip";
const exec = promisify(execCallback);
@ -17,25 +15,27 @@ function hash(s: string) {
return createHash("sha256").update(s).digest("hex");
}
async function maybeStat(path: string) {
async function exists(path: string) {
try {
return await stat(path);
await stat(path);
return true;
} catch (error) {
if ((error as Error & { code: string }).code === "ENOENT") return undefined;
if ((error as Error & { code: string }).code === "ENOENT") return false;
throw error;
}
}
/**
* Get an npm configuration value as string, undefined if not set.
*
* @param key
* @returns string or undefined
* Get npm configuration as map
*/
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;
async function getNmpConfig(): Promise<Record<string, string>> {
const { stdout } = await exec("npm config get", { encoding: "utf8" });
return stdout
.split("\n")
.filter(line => !line.startsWith(";"))
.map(line => line.trim())
.map(line => line.split("=", 2))
.reduce((cfg, [key, value]) => ({ ...cfg, [key]: value }), {});
}
/**
@ -45,233 +45,43 @@ async function getNmpConfig(key: string): Promise<string | undefined> {
* @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"));
const cfg = await getNmpConfig();
const proxy = cfg["https-proxy"] ?? cfg["proxy"];
const noProxy = cfg["noproxy"] ?? cfg["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, 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, 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);
await mkdir(dir, { recursive: true });
await writeFile(filepath, response.body);
return filepath;
}
export async function downloadAndUnzip(params: { url: string; destDirPath: string; pathOfDirToExtractInArchive?: string }) {
const { url, destDirPath, pathOfDirToExtractInArchive } = params;
/**
* @typedef
* @type MultiError = Error & { cause: Error[] }
*/
const downloadHash = hash(JSON.stringify({ url })).substring(0, 15);
const projectRoot = getProjectRoot();
const cacheRoot = process.env.XDG_CACHE_HOME ?? pathJoin(projectRoot, "node_modules", ".cache");
const zipFilePath = pathJoin(cacheRoot, "keycloakify", "zip", `_${downloadHash}.zip`);
const extractDirPath = pathJoin(cacheRoot, "keycloakify", "unzip", `_${downloadHash}`);
/**
* Extract the archive `zipFile` into the directory `dir`. If `archiveDir` is given,
* only that directory will be extracted, stripping the given path components.
*
* If dir does not exist, it will be created.
*
* If any archive file exists, it will be overwritten.
*
* Will unzip using all available nodejs worker threads.
*
* Will try to clean up extracted files on failure.
*
* If unpacking fails, will either throw an regular error, or
* possibly an `MultiError`, which contains a `cause` field with
* a number of root cause errors.
*
* Warning this method is not optimized for continuous reading of the zip
* archive, but is a trade-off between simplicity and allowing extraction
* of a single directory from the archive.
*
* @param zipFile the file to unzip
* @param dir the target directory
* @param archiveDir if given, unpack only files from this archive directory
* @throws {MultiError} error
* @returns Promise for a list of full file paths pointing to actually extracted files
*/
async function unzip(zipFile: string, dir: string, archiveDir?: string): Promise<string[]> {
await mkdir(dir, { recursive: true });
const promises: Promise<string>[] = [];
// Iterate over all files in the zip, skip files which are not in archiveDir,
// if given.
for await (const record of iterateZipArchive(zipFile)) {
const { path: recordPath, createReadStream: createRecordReadStream } = record;
const filePath = pathJoin(dir, recordPath);
const parent = pathDirname(filePath);
if (archiveDir && !recordPath.startsWith(archiveDir)) continue;
promises.push(
new Promise<string>(async (resolve, reject) => {
await mkdir(parent, { recursive: true });
// 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);
})
);
if (!(await exists(zipFilePath))) {
const proxyOpts = await getNpmProxyConfig();
const response = await fetch(url, proxyOpts);
await mkdir(pathDirname(zipFilePath), { "recursive": true });
/**
* The correct way to fix this is to upgrade node-fetch beyond 3.2.5
* (see https://github.com/node-fetch/node-fetch/issues/1295#issuecomment-1144061991.)
* Unfortunately, octokit (a dependency of keycloakify) also uses node-fetch, and
* does not support node-fetch 3.x. So we stick around with this band-aid until
* octokit upgrades.
*/
response.body?.setMaxListeners(Number.MAX_VALUE);
assert(typeof response.body !== "undefined" && response.body != null);
await writeFile(zipFilePath, response.body);
}
// Wait until _all_ files are either extracted or failed
const results = await Promise.allSettled(promises);
const success = results.filter(r => r.status === "fulfilled").map(r => (r as PromiseFulfilledResult<string>).value);
const failure = results.filter(r => r.status === "rejected").map(r => (r as PromiseRejectedResult).reason);
await unzip(zipFilePath, extractDirPath, pathOfDirToExtractInArchive);
// If any extraction failed, try to clean up, then throw a MultiError,
// which has a `cause` field, containing a list of root cause errors.
if (failure.length) {
await Promise.all(success.map(path => unlink(path)));
await Promise.all(failure.map(e => e && e.path && unlink(e.path as string)));
const e = new Error("Failed to extract: " + failure.map(e => e.message).join(";"));
(e as any).cause = failure;
throw e;
}
return success;
}
/**
*
* @param file file to read
* @param start first byte to read
* @param end last byte to read
* @returns Promise of a buffer of read bytes
*/
async function readFileChunk(file: string, start: number, end: number): Promise<Buffer> {
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));
transformCodebase({
"srcDirPath": extractDirPath,
"destDirPath": destDirPath
});
}
type ZipRecord = {
path: string;
createReadStream: () => Readable;
compressionMethod: "deflate" | undefined;
};
type ZipRecordGenerator = AsyncGenerator<ZipRecord, void, unknown>;
/**
* Iterate over all records of a zipfile, and yield a ZipRecord.
* Use `record.createReadStream()` to actually read the file.
*
* Warning this method will only work with single-disk zip files.
* Warning this method may fail if the zip archive has an crazy amount
* of files and the central directory is not fully contained within the
* last 65k bytes of the zip file.
*
* @param zipFile
* @returns AsyncGenerator which will yield ZipRecords
*/
async function* iterateZipArchive(zipFile: string): ZipRecordGenerator {
// Need to know zip file size before we can do anything else
const { size } = await stat(zipFile);
const chunkSize = 65_535 + 22 + 1; // max comment size + end header size + wiggle
// Read last ~65k bytes. Zip files have an comment up to 65_535 bytes at the very end,
// before that comes the zip central directory end header.
let chunk = await readFileChunk(zipFile, size - chunkSize, size);
const unread = size - chunk.length;
let i = chunk.length - 4;
let found = false;
// Find central directory end header, reading backwards from the end
while (!found && i-- > 0) if (chunk[i] === 0x50 && chunk.readUInt32LE(i) === 0x06054b50) found = true;
if (!found) throw new Error("Not a zip file");
// This method will fail on a multi-disk zip, so bail early.
if (chunk.readUInt16LE(i + 4) !== 0) throw new Error("Multi-disk zip not supported");
let nFiles = chunk.readUint16LE(i + 10);
// Get the position of the central directory
const directorySize = chunk.readUint32LE(i + 12);
const directoryOffset = chunk.readUint32LE(i + 16);
if (directoryOffset === 0xffff_ffff) throw new Error("zip64 not supported");
if (directoryOffset > size) throw new Error(`Central directory offset ${directoryOffset} is outside file`);
i = directoryOffset - unread;
// If i < 0, it means that the central directory is not contained within `chunk`
if (i < 0) {
chunk = await readFileChunk(zipFile, directoryOffset, directoryOffset + directorySize);
i = 0;
}
// Now iterate the central directory records, yield an `ZipRecord` for every entry
while (nFiles-- > 0) {
// Check for marker bytes
if (chunk.readUInt32LE(i) !== 0x02014b50) throw new Error("No central directory record at position " + (unread + i));
const compressionMethod = ({ 8: "deflate" } as const)[chunk.readUint16LE(i + 10)];
const compressedFileSize = chunk.readUint32LE(i + 20);
const filenameLength = chunk.readUint16LE(i + 28);
const extraLength = chunk.readUint16LE(i + 30);
const commentLength = chunk.readUint16LE(i + 32);
// Start of the actual content byte stream is after the 'local' record header,
// which is 30 bytes long plus filename and extra field
const start = chunk.readUint32LE(i + 42) + 30 + filenameLength + extraLength;
const end = start + compressedFileSize;
const filename = chunk.slice(i + 46, i + 46 + filenameLength).toString("utf-8");
const createRecordReadStream = () => {
const input = createReadStream(zipFile, { start, end });
if (compressionMethod === "deflate") {
const inflate = createInflateRaw();
input.pipe(inflate);
return inflate;
}
return input;
};
if (end > start) yield { path: filename, createReadStream: createRecordReadStream, compressionMethod };
// advance pointer to next central directory entry
i += 46 + filenameLength + extraLength + commentLength;
}
}
export async function downloadAndUnzip({
url,
destDirPath,
pathOfDirToExtractInArchive,
cacheDirPath
}: {
isSilent: boolean;
url: string;
destDirPath: string;
pathOfDirToExtractInArchive?: string;
cacheDirPath: string;
}) {
const downloadHash = hash(JSON.stringify({ url, pathOfDirToExtractInArchive })).substring(0, 15);
const extractDirPath = pathJoin(cacheDirPath, `_${downloadHash}`);
const filename = pathBasename(url);
const zipFilepath = await download(url, cacheDirPath, filename);
const zipMtime = (await stat(zipFilepath)).mtimeMs;
const unzipMtime = (await maybeStat(extractDirPath))?.mtimeMs;
if (!unzipMtime || zipMtime > unzipMtime) await unzip(zipFilepath, extractDirPath, pathOfDirToExtractInArchive);
const srcDirPath = pathOfDirToExtractInArchive === undefined ? extractDirPath : pathJoin(extractDirPath, pathOfDirToExtractInArchive);
transformCodebase({ srcDirPath, destDirPath });
}

View File

@ -3,12 +3,9 @@ import { dirname, relative, sep } from "path";
import { createWriteStream } from "fs";
import walk from "./walk";
import type { ZipSource } from "./zip";
import zip from "./zip";
import zip, { type ZipSource } from "./zip";
import { mkdir } from "fs/promises";
/** Trim leading whitespace from every line */
const trimIndent = (s: string) => s.replace(/(\n)\s+/g, "$1");
import trimIndent from "./trimIndent";
type JarArgs = {
rootPath: string;
@ -26,28 +23,23 @@ type JarArgs = {
export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) {
const manifest: ZipSource = {
path: "META-INF/MANIFEST.MF",
data: Buffer.from(
trimIndent(
`Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Created-By: Keycloakify
Built-By: unknown
Build-Jdk: 19.0.0`
)
)
data: Buffer.from(trimIndent`
Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Created-By: Keycloakify
Built-By: unknown
Build-Jdk: 19.0.0
`)
};
const pomProps: ZipSource = {
path: `META-INF/maven/${groupId}/${artifactId}/pom.properties`,
data: Buffer.from(
trimIndent(
`# Generated by keycloakify
# ${new Date()}
artifactId=${artifactId}
groupId=${groupId}
version=${version}`
)
)
data: Buffer.from(trimIndent`# Generated by keycloakify
# ${new Date().toString()}
artifactId=${artifactId}
groupId=${groupId}
version=${version}
`)
};
/**
@ -98,5 +90,5 @@ if (require.main === module) {
groupId: process.env.GROUP_ID ?? "group",
version: process.env.VERSION ?? "1.0.0"
});
main().catch(e => console.error(e));
main();
}

View File

@ -0,0 +1,11 @@
export type PromiseSettledAndPartitioned<T> = [T[], any[]];
export function partitionPromiseSettledResults<T>() {
return [
([successes, failures]: PromiseSettledAndPartitioned<T>, item: PromiseSettledResult<T>) =>
item.status === "rejected"
? ([successes, [item.reason, ...failures]] as PromiseSettledAndPartitioned<T>)
: ([[item.value, ...successes], failures] as PromiseSettledAndPartitioned<T>),
[[], []] as PromiseSettledAndPartitioned<T>
] as const;
}

View File

@ -7,6 +7,8 @@ export default function tee(input: Readable) {
let aFull = false;
let bFull = false;
a.setMaxListeners(Infinity);
a.on("drain", () => {
aFull = false;
if (!aFull && !bFull) input.resume();

View File

@ -0,0 +1,51 @@
/**
* Concatenate the string fragments and interpolated values
* to get a single string.
*/
function populateTemplate(strings: TemplateStringsArray, ...args: any[]) {
const chunks = [];
for (let i = 0; i < strings.length; i++) {
let lastStringLineLength = 0;
if (strings[i]) {
chunks.push(strings[i]);
// remember last indent of the string portion
lastStringLineLength = strings[i].split("\n").at(-1)?.length ?? 0;
}
if (args[i]) {
// if the interpolation value has newlines, indent the interpolation values
// using the last known string indent
chunks.push(args[i].replace(/([\r?\n])/g, "$1" + " ".repeat(lastStringLineLength)));
}
}
return chunks.join("");
}
function trimIndentPrivate(removeEmptyLeadingAndTrailingLines: boolean, strings: TemplateStringsArray, ...args: any[]) {
// Remove initial and final newlines
let string = populateTemplate(strings, ...args);
if (removeEmptyLeadingAndTrailingLines) string = string.replace(/^[\r\n]/, "").replace(/[^\S\r\n]*[\r\n]$/, "");
const dents = string.match(/^([ \t])+/gm)?.map(s => s.length) ?? [];
// No dents? no change required
if (!dents || dents.length == 0) return string;
const minDent = Math.min(...dents);
// The min indentation is 0, no change needed
if (!minDent) return string;
const dedented = string.replace(new RegExp(`^${" ".repeat(minDent)}`, "gm"), "");
return dedented;
}
/**
* Shift all lines left by the *smallest* indentation level,
* and remove initial newline and all trailing spaces.
*/
export default function trimIndent(strings: TemplateStringsArray, ...args: any[]) {
return trimIndentPrivate(true, strings, ...args);
}
/**
* Shift all lines left by the *smallest* indentation level,
* and _keep_ initial newline and all trailing spaces.
*/
trimIndent.keepLeadingAndTrailingNewlines = function (strings: TemplateStringsArray, ...args: any[]) {
return trimIndentPrivate(false, strings, ...args);
};

92
src/bin/tools/unzip.ts Normal file
View File

@ -0,0 +1,92 @@
import fsp from "node:fs/promises";
import fs from "fs";
import path from "node:path";
import yauzl from "yauzl";
import stream from "node:stream";
import { promisify } from "node:util";
const pipeline = promisify(stream.pipeline);
async function pathExists(path: string) {
try {
await fsp.stat(path);
return true;
} catch (error) {
if ((error as { code: string }).code === "ENOENT") {
return false;
}
throw error;
}
}
export async function unzip(file: string, targetFolder: string, unzipSubPath?: string) {
// add trailing slash to unzipSubPath and targetFolder
if (unzipSubPath && (!unzipSubPath.endsWith("/") || !unzipSubPath.endsWith("\\"))) {
unzipSubPath += "/";
}
if (!targetFolder.endsWith("/") || !targetFolder.endsWith("\\")) {
targetFolder += "/";
}
if (!fs.existsSync(targetFolder)) {
fs.mkdirSync(targetFolder, { recursive: true });
}
return new Promise<void>((resolve, reject) => {
yauzl.open(file, { lazyEntries: true }, async (err, zipfile) => {
if (err) {
reject(err);
return;
}
zipfile.readEntry();
zipfile.on("entry", async entry => {
if (unzipSubPath) {
// Skip files outside of the unzipSubPath
if (!entry.fileName.startsWith(unzipSubPath)) {
zipfile.readEntry();
return;
}
// Remove the unzipSubPath from the file name
entry.fileName = entry.fileName.substring(unzipSubPath.length);
}
const target = path.join(targetFolder, entry.fileName);
// Directory file names end with '/'.
// Note that entries for directories themselves are optional.
// An entry's fileName implicitly requires its parent directories to exist.
if (/[\/\\]$/.test(target)) {
await fsp.mkdir(target, { recursive: true });
zipfile.readEntry();
return;
}
// Skip existing files
if (await pathExists(target)) {
zipfile.readEntry();
return;
}
zipfile.openReadStream(entry, async (err, readStream) => {
if (err) {
reject(err);
return;
}
await pipeline(readStream, fs.createWriteStream(target));
zipfile.readEntry();
});
});
zipfile.once("end", function () {
zipfile.close();
resolve();
});
});
});
}

View File

@ -25,6 +25,7 @@ const LoginConfigTotp = lazy(() => import("keycloakify/login/pages/LoginConfigTo
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"));
export default function Fallback(props: PageProps<KcContext, I18n>) {
const { kcContext, ...rest } = props;
@ -75,6 +76,8 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
return <UpdateUserProfile kcContext={kcContext} {...rest} />;
case "idp-review-user-profile.ftl":
return <IdpReviewUserProfile kcContext={kcContext} {...rest} />;
case "update-email.ftl":
return <UpdateEmail kcContext={kcContext} {...rest} />;
}
assert<Equals<typeof kcContext, never>>(false);
})()}

View File

@ -30,7 +30,8 @@ export type KcContext =
| KcContext.LoginConfigTotp
| KcContext.LogoutConfirm
| KcContext.UpdateUserProfile
| KcContext.IdpReviewUserProfile;
| KcContext.IdpReviewUserProfile
| KcContext.UpdateEmail;
export declare namespace KcContext {
export type Common = {
@ -101,7 +102,8 @@ export declare namespace KcContext {
registrationDisabled: boolean;
login: {
username?: string;
rememberMe?: boolean;
rememberMe?: string;
password?: string;
};
usernameEditDisabled: boolean;
social: {
@ -182,6 +184,9 @@ export declare namespace KcContext {
realm: {
loginWithEmailAllowed: boolean;
};
url: {
loginResetCredentialsUrl: string;
};
};
export type LoginVerifyEmail = Common & {
@ -219,7 +224,7 @@ export declare namespace KcContext {
registrationDisabled: boolean;
login: {
username?: string;
rememberMe?: boolean;
rememberMe?: string;
};
usernameHidden?: boolean;
social: {
@ -377,6 +382,13 @@ export declare namespace KcContext {
attributesByName: Record<string, Attribute>;
};
};
export type UpdateEmail = Common & {
pageId: "update-email.ftl";
email: {
value?: string;
};
};
}
export type Attribute = {

View File

@ -260,9 +260,7 @@ export const kcContextMocks: KcContext[] = [
"displayInfo": true
},
"usernameEditDisabled": false,
"login": {
"rememberMe": false
},
"login": {},
"registrationDisabled": false
}),
...(() => {
@ -331,7 +329,8 @@ export const kcContextMocks: KcContext[] = [
"realm": {
...kcContextCommonMock.realm,
"loginWithEmailAllowed": false
}
},
url: loginUrl
}),
id<KcContext.LoginVerifyEmail>({
...kcContextCommonMock,
@ -376,9 +375,7 @@ export const kcContextMocks: KcContext[] = [
"displayInfo": true
},
"usernameHidden": false,
"login": {
"rememberMe": false
},
"login": {},
"registrationDisabled": false
}),
id<KcContext.LoginPassword>({
@ -494,5 +491,12 @@ export const kcContextMocks: KcContext[] = [
attributes,
attributesByName
}
}),
id<KcContext.UpdateEmail>({
...kcContextCommonMock,
"pageId": "update-email.ftl",
"email": {
value: "email@example.com"
}
})
];

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { UserProfileFormFields } from "keycloakify/login/pages/shared/UserProfileCommons";
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";

View File

@ -124,7 +124,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
id="rememberMe"
name="rememberMe"
type="checkbox"
{...(login.rememberMe
{...(login.rememberMe === "on"
? {
"checked": true
}

View File

@ -109,7 +109,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
id="rememberMe"
name="rememberMe"
type="checkbox"
{...(login.rememberMe
{...(login.rememberMe === "on"
? {
"checked": true
}

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { UserProfileFormFields } from "./shared/UserProfileCommons";
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";

View File

@ -0,0 +1,88 @@
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 UpdateEmail(props: PageProps<Extract<KcContext, { pageId: "update-email.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { msg, msgStr } = i18n;
const { url, messagesPerField, isAppInitiatedAction, email } = kcContext;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} 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>
<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"
defaultValue={msgStr("doSubmit")}
/>
<button
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
name="cancel-aia"
value="true"
>
{msg("doCancel")}
</button>
</>
) : (
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
defaultValue={msgStr("doSubmit")}
/>
)}
</div>
</div>
</form>
</Template>
);
}

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { UserProfileFormFields } from "keycloakify/login/pages/shared/UserProfileCommons";
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";

View File

@ -1,20 +0,0 @@
import { join as pathJoin } from "path";
import { generateKeycloakThemeResources } from "keycloakify/bin/keycloakify/generateKeycloakThemeResources";
import { setupSampleReactProject, sampleReactProjectDirPath } from "./setupSampleReactProject";
setupSampleReactProject();
generateKeycloakThemeResources({
"reactAppBuildDirPath": pathJoin(sampleReactProjectDirPath, "build"),
"keycloakThemeBuildingDirPath": pathJoin(sampleReactProjectDirPath, "build_keycloak_theme"),
"keycloakThemeEmailDirPath": pathJoin(sampleReactProjectDirPath, "keycloak_email"),
"keycloakVersion": "11.0.3",
"buildOptions": {
"themeName": "keycloakify-demo-app",
"extraLoginPages": ["my-custom-page.ftl"],
"extraThemeProperties": ["env=test"],
"isStandalone": true,
"urlPathname": "/keycloakify-demo-app/",
"isSilent": false
}
});

View File

@ -1 +0,0 @@
import "./replaceImportFromStatic";

26
test/bin/jar.spec.ts Normal file
View File

@ -0,0 +1,26 @@
import jar from "keycloakify/bin/tools/jar";
import { it, describe, vi } from "vitest";
vi.mock("fs", () => ({ promises: { mkdir: () => {} }, createWriteStream: () => {} }));
vi.mock("stream", async () => {
const readableMock = () => {
const mockDecorators = {
on: () => mockDecorators,
pipe: () => mockDecorators
};
return {
from: () => mockDecorators
};
};
return {
// @ts-ignore
...(await vi.importActual("stream")),
Readable: readableMock()
};
});
describe("jar", () => {
it("creates jar artifacts without error", () => {
jar({ artifactId: "artifactId", groupId: "groupId", rootPath: "rootPath", targetPath: "targetPath", version: "1.0.0" });
});
});

View File

@ -1,26 +0,0 @@
import "./replaceImportFromStatic";
import { setupSampleReactProject, sampleReactProjectDirPath } from "./setupSampleReactProject";
import * as st from "scripting-tools";
import * as fs from "fs";
import { join as pathJoin } from "path";
import { getProjectRoot } from "keycloakify/bin/tools/getProjectRoot.js";
(async () => {
fs.rmSync(sampleReactProjectDirPath, { "recursive": true });
await setupSampleReactProject();
const binDirPath = pathJoin(getProjectRoot(), "dist_test", "src", "bin");
fs.mkdirSync(pathJoin(sampleReactProjectDirPath, "src", "keycloak-theme"), { "recursive": true });
st.execSyncTrace(`node ${pathJoin(binDirPath, "initialize-email-theme")}`, { "cwd": sampleReactProjectDirPath });
st.execSyncTrace(`node ${pathJoin(binDirPath, "download-builtin-keycloak-theme")}`, { "cwd": sampleReactProjectDirPath });
st.execSyncTrace(
//`node ${pathJoin(binDirPath, "keycloakify")} --external-assets`,
`node ${pathJoin(binDirPath, "keycloakify")}`,
{ "cwd": sampleReactProjectDirPath }
);
})();

View File

@ -1,11 +1,12 @@
import { replaceImportsFromStaticInJsCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsFromStaticInJsCode";
import { generateCssCodeToDefineGlobals, replaceImportsInCssCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsInCssCode";
import { replaceImportsInInlineCssCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsInInlineCssCode";
import { assert } from "tsafe/assert";
import { same } from "evt/tools/inDepth/same";
import { assetIsSameCode } from "../tools/assertIsSameCode";
import { expect, it, describe } from "vitest";
{
import { isSameCode } from "../tools/isSameCode";
describe("bin/js-transforms", () => {
const jsCodeUntransformed = `
function f() {
return a.p+"static/js/" + ({}[e] || e) + "." + {
@ -32,8 +33,7 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
}[e]+".chunk.css"
}
`;
{
it("transforms standalone code properly", () => {
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": jsCodeUntransformed,
"buildOptions": {
@ -89,10 +89,9 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
`;
assetIsSameCode(fixedJsCode, fixedJsCodeExpected);
}
{
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
});
it("transforms external app code properly", () => {
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": jsCodeUntransformed,
"buildOptions": {
@ -150,126 +149,128 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
}
`;
assetIsSameCode(fixedJsCode, fixedJsCodeExpected);
}
}
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
});
});
{
const { fixedCssCode, cssGlobalsToDefine } = replaceImportsInCssCode({
"cssCode": `
describe("bin/css-transforms", () => {
it("transforms absolute urls to css globals properly with no urlPathname", () => {
const { fixedCssCode, cssGlobalsToDefine } = replaceImportsInCssCode({
"cssCode": `
.my-div {
background: url(/logo192.png) no-repeat center center;
}
.my-div2 {
background: url(/logo192.png) no-repeat center center;
}
.my-div {
background-image: url(/static/media/something.svg);
}
`
});
const fixedCssCodeExpected = `
.my-div {
background: url(/logo192.png) no-repeat center center;
background: var(--url1f9ef5a892c104c);
}
.my-div2 {
background: url(/logo192.png) no-repeat center center;
background: var(--url1f9ef5a892c104c);
}
.my-div {
background-image: url(/static/media/something.svg);
background-image: var(--urldd75cab58377c19);
}
`
`;
expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
const cssGlobalsToDefineExpected = {
"url1f9ef5a892c104c": "url(/logo192.png) no-repeat center center",
"urldd75cab58377c19": "url(/static/media/something.svg)"
};
expect(same(cssGlobalsToDefine, cssGlobalsToDefineExpected)).toBe(true);
const { cssCodeToPrependInHead } = generateCssCodeToDefineGlobals({
cssGlobalsToDefine,
"buildOptions": {
"urlPathname": undefined
}
});
const cssCodeToPrependInHeadExpected = `
:root {
--url1f9ef5a892c104c: url(\${url.resourcesPath}/build/logo192.png) no-repeat center center;
--urldd75cab58377c19: url(\${url.resourcesPath}/build/static/media/something.svg);
}
`;
expect(isSameCode(cssCodeToPrependInHead, cssCodeToPrependInHeadExpected)).toBe(true);
});
it("transforms absolute urls to css globals properly with custom urlPathname", () => {
const { fixedCssCode, cssGlobalsToDefine } = replaceImportsInCssCode({
"cssCode": `
.my-div {
background: url(/x/y/z/logo192.png) no-repeat center center;
}
.my-div2 {
background: url(/x/y/z/logo192.png) no-repeat center center;
}
.my-div {
background-image: url(/x/y/z/static/media/something.svg);
}
`
});
const fixedCssCodeExpected = `
.my-div {
background: var(--url1f9ef5a892c104c);
}
.my-div2 {
background: var(--url1f9ef5a892c104c);
}
.my-div {
background-image: var(--urldd75cab58377c19);
}
`;
assetIsSameCode(fixedCssCode, fixedCssCodeExpected);
const cssGlobalsToDefineExpected = {
"url1f9ef5a892c104c": "url(/logo192.png) no-repeat center center",
"urldd75cab58377c19": "url(/static/media/something.svg)"
};
assert(same(cssGlobalsToDefine, cssGlobalsToDefineExpected));
const { cssCodeToPrependInHead } = generateCssCodeToDefineGlobals({
cssGlobalsToDefine,
"buildOptions": {
"urlPathname": undefined
}
});
const cssCodeToPrependInHeadExpected = `
:root {
--url1f9ef5a892c104c: url(\${url.resourcesPath}/build/logo192.png) no-repeat center center;
--urldd75cab58377c19: url(\${url.resourcesPath}/build/static/media/something.svg);
}
`;
assetIsSameCode(cssCodeToPrependInHead, cssCodeToPrependInHeadExpected);
}
{
const { fixedCssCode, cssGlobalsToDefine } = replaceImportsInCssCode({
"cssCode": `
const fixedCssCodeExpected = `
.my-div {
background: url(/x/y/z/logo192.png) no-repeat center center;
background: var(--urlf8277cddaa2be78);
}
.my-div2 {
background: url(/x/y/z/logo192.png) no-repeat center center;
background: var(--urlf8277cddaa2be78);
}
.my-div {
background-image: url(/x/y/z/static/media/something.svg);
background-image: var(--url8bdc0887b97ac9a);
}
`
`;
expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
const cssGlobalsToDefineExpected = {
"urlf8277cddaa2be78": "url(/x/y/z/logo192.png) no-repeat center center",
"url8bdc0887b97ac9a": "url(/x/y/z/static/media/something.svg)"
};
expect(same(cssGlobalsToDefine, cssGlobalsToDefineExpected)).toBe(true);
const { cssCodeToPrependInHead } = generateCssCodeToDefineGlobals({
cssGlobalsToDefine,
"buildOptions": {
"urlPathname": "/x/y/z/"
}
});
const cssCodeToPrependInHeadExpected = `
:root {
--urlf8277cddaa2be78: url(\${url.resourcesPath}/build/logo192.png) no-repeat center center;
--url8bdc0887b97ac9a: url(\${url.resourcesPath}/build/static/media/something.svg);
}
`;
expect(isSameCode(cssCodeToPrependInHead, cssCodeToPrependInHeadExpected)).toBe(true);
});
});
const fixedCssCodeExpected = `
.my-div {
background: var(--urlf8277cddaa2be78);
}
.my-div2 {
background: var(--urlf8277cddaa2be78);
}
.my-div {
background-image: var(--url8bdc0887b97ac9a);
}
`;
assetIsSameCode(fixedCssCode, fixedCssCodeExpected);
const cssGlobalsToDefineExpected = {
"urlf8277cddaa2be78": "url(/x/y/z/logo192.png) no-repeat center center",
"url8bdc0887b97ac9a": "url(/x/y/z/static/media/something.svg)"
};
assert(same(cssGlobalsToDefine, cssGlobalsToDefineExpected));
const { cssCodeToPrependInHead } = generateCssCodeToDefineGlobals({
cssGlobalsToDefine,
"buildOptions": {
"urlPathname": "/x/y/z/"
}
});
const cssCodeToPrependInHeadExpected = `
:root {
--urlf8277cddaa2be78: url(\${url.resourcesPath}/build/logo192.png) no-repeat center center;
--url8bdc0887b97ac9a: url(\${url.resourcesPath}/build/static/media/something.svg);
}
`;
assetIsSameCode(cssCodeToPrependInHead, cssCodeToPrependInHeadExpected);
}
{
const cssCode = `
describe("bin/css-inline-transforms", () => {
describe("no url pathName", () => {
const cssCode = `
@font-face {
font-family: "Work Sans";
font-style: normal;
@ -299,17 +300,16 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
src: url("/fonts/WorkSans/worksans-bold-webfont.woff2") format("woff2");
}
`;
it("transforms css for standalone app properly", () => {
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"isStandalone": true,
"urlPathname": undefined
}
});
{
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"isStandalone": true,
"urlPathname": undefined
}
});
const fixedCssCodeExpected = `
const fixedCssCodeExpected = `
@font-face {
font-family: "Work Sans";
font-style: normal;
@ -344,20 +344,19 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
}
`;
assetIsSameCode(fixedCssCode, fixedCssCodeExpected);
}
{
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"isStandalone": false,
"urlOrigin": "https://demo-app.keycloakify.dev",
"urlPathname": undefined
}
expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
});
it("transforms css for external app properly", () => {
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"isStandalone": false,
"urlOrigin": "https://demo-app.keycloakify.dev",
"urlPathname": undefined
}
});
const fixedCssCodeExpected = `
const fixedCssCodeExpected = `
@font-face {
font-family: "Work Sans";
font-style: normal;
@ -392,12 +391,12 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
}
`;
assetIsSameCode(fixedCssCode, fixedCssCodeExpected);
}
}
expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
});
});
{
const cssCode = `
describe("with url pathName", () => {
const cssCode = `
@font-face {
font-family: "Work Sans";
font-style: normal;
@ -427,101 +426,98 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
src: url("/x/y/z/fonts/WorkSans/worksans-bold-webfont.woff2") format("woff2");
}
`;
it("transforms css for standalone app properly", () => {
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"isStandalone": true,
"urlPathname": "/x/y/z/"
}
});
{
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"isStandalone": true,
"urlPathname": "/x/y/z/"
}
const fixedCssCodeExpected = `
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\${url.resourcesPath}/build/fonts/WorkSans/worksans-regular-webfont.woff2)
format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\${url.resourcesPath}/build/fonts/WorkSans/worksans-medium-webfont.woff2)
format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(\${url.resourcesPath}/build/fonts/WorkSans/worksans-semibold-webfont.woff2)
format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(\${url.resourcesPath}/build/fonts/WorkSans/worksans-bold-webfont.woff2)
format("woff2");
}
`;
expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
});
it("transforms css for external app properly", () => {
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"isStandalone": false,
"urlOrigin": "https://demo-app.keycloakify.dev",
"urlPathname": "/x/y/z/"
}
});
const fixedCssCodeExpected = `
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\${url.resourcesPath}/build/fonts/WorkSans/worksans-regular-webfont.woff2)
format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\${url.resourcesPath}/build/fonts/WorkSans/worksans-medium-webfont.woff2)
format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(\${url.resourcesPath}/build/fonts/WorkSans/worksans-semibold-webfont.woff2)
format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(\${url.resourcesPath}/build/fonts/WorkSans/worksans-bold-webfont.woff2)
format("woff2");
}
`;
const fixedCssCodeExpected = `
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-regular-webfont.woff2)
format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-medium-webfont.woff2)
format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-semibold-webfont.woff2)
format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-bold-webfont.woff2)
format("woff2");
}
`;
assetIsSameCode(fixedCssCode, fixedCssCodeExpected);
}
{
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"isStandalone": false,
"urlOrigin": "https://demo-app.keycloakify.dev",
"urlPathname": "/x/y/z/"
}
expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
});
const fixedCssCodeExpected = `
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-regular-webfont.woff2)
format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-medium-webfont.woff2)
format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-semibold-webfont.woff2)
format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-bold-webfont.woff2)
format("woff2");
}
`;
assetIsSameCode(fixedCssCode, fixedCssCodeExpected);
}
}
console.log("PASS replace import from static");
});
});

View File

@ -0,0 +1,77 @@
import * as fs from "fs";
import { getProjectRoot } from "keycloakify/bin/tools/getProjectRoot.js";
import { join as pathJoin } from "path";
import { downloadAndUnzip } from "keycloakify/bin/tools/downloadAndUnzip";
import { main as initializeEmailTheme } from "keycloakify/bin/initialize-email-theme";
import { it, describe, afterAll, beforeAll, beforeEach, vi } from "vitest";
import { getKeycloakBuildPath } from "keycloakify/bin/keycloakify/build-paths";
import { downloadBuiltinKeycloakTheme } from "keycloakify/bin/download-builtin-keycloak-theme";
export const sampleReactProjectDirPath = pathJoin(getProjectRoot(), "sample_react_project");
async function setupSampleReactProject(destDir: string) {
await downloadAndUnzip({
"url": "https://github.com/keycloakify/keycloakify/releases/download/v0.0.1/sample_build_dir_and_package_json.zip",
"destDirPath": destDir
});
}
let parsedPackageJson: Record<string, unknown> = {};
vi.mock("keycloakify/bin/keycloakify/parsed-package-json", async () => ({
...((await vi.importActual("keycloakify/bin/keycloakify/parsed-package-json")) as Record<string, unknown>),
getParsedPackageJson: () => parsedPackageJson
}));
vi.mock("keycloakify/bin/promptKeycloakVersion", async () => ({
...((await vi.importActual("keycloakify/bin/promptKeycloakVersion")) as Record<string, unknown>),
promptKeycloakVersion: () => ({ keycloakVersion: "11.0.3" })
}));
const nativeCwd = process.cwd;
describe("Sample Project", () => {
beforeAll(() => {
// Monkey patching the cwd to the app location for the duration of this test
process.cwd = () => sampleReactProjectDirPath;
});
afterAll(() => {
fs.rmSync(sampleReactProjectDirPath, { "recursive": true });
process.cwd = nativeCwd;
});
beforeEach(() => {
if (fs.existsSync(sampleReactProjectDirPath)) {
fs.rmSync(sampleReactProjectDirPath, { "recursive": true });
}
fs.mkdirSync(pathJoin(sampleReactProjectDirPath, "src", "keycloak-theme"), { "recursive": true });
fs.mkdirSync(pathJoin(sampleReactProjectDirPath, "src", "login"), { "recursive": true });
});
it(
"Sets up the project without error",
async () => {
await setupSampleReactProject(sampleReactProjectDirPath);
await initializeEmailTheme();
const destDirPath = pathJoin(getKeycloakBuildPath(), "src", "main", "resources", "theme");
await downloadBuiltinKeycloakTheme({ destDirPath, keycloakVersion: "11.0.3", isSilent: false });
},
{ timeout: 90000 }
);
it(
"Sets up the project with a custom input and output directory without error",
async () => {
parsedPackageJson = {
"keycloakify": {
"appInputPath": "./custom_input/build",
"keycloakBuildDir": "./custom_output"
}
};
await setupSampleReactProject(pathJoin(sampleReactProjectDirPath, "custom_input"));
await initializeEmailTheme();
const destDirPath = pathJoin(getKeycloakBuildPath(), "src", "main", "resources", "theme");
await downloadBuiltinKeycloakTheme({ destDirPath, keycloakVersion: "11.0.3", isSilent: false });
},
{ timeout: 90000 }
);
});

View File

@ -1,14 +1,8 @@
import { getProjectRoot } from "keycloakify/bin/tools/getProjectRoot.js";
import { join as pathJoin } from "path";
import { downloadAndUnzip } from "keycloakify/bin/tools/downloadAndUnzip";
export const sampleReactProjectDirPath = pathJoin(getProjectRoot(), "sample_react_project");
export async function setupSampleReactProject() {
export async function setupSampleReactProject(destDirPath: string) {
await downloadAndUnzip({
"url": "https://github.com/InseeFrLab/keycloakify/releases/download/v0.0.1/sample_build_dir_and_package_json.zip",
"destDirPath": sampleReactProjectDirPath,
"cacheDirPath": pathJoin(sampleReactProjectDirPath, "build_keycloak", ".cache"),
"isSilent": false
"url": "https://github.com/keycloakify/keycloakify/releases/download/v0.0.1/sample_build_dir_and_package_json.zip",
"destDirPath": destDirPath
});
}

View File

@ -1,13 +1,14 @@
import { getKcContext } from "../../src/login/kcContext/getKcContext";
import type { ExtendKcContext } from "../../src/login/kcContext/getKcContextFromWindow";
import type { KcContext } from "../../src/login/kcContext";
import { getKcContext } from "keycloakify/login/kcContext/getKcContext";
import type { ExtendKcContext } from "keycloakify/login/kcContext/getKcContextFromWindow";
import type { KcContext } from "keycloakify/login/kcContext";
import { same } from "evt/tools/inDepth";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import { kcContextMocks, kcContextCommonMock } from "../../src/login/kcContext/kcContextMocks";
import { deepClone } from "../../src/tools/deepClone";
import { kcContextMocks, kcContextCommonMock } from "keycloakify/login/kcContext/kcContextMocks";
import { deepClone } from "keycloakify/tools/deepClone";
import { expect, it, describe } from "vitest";
{
describe("getKcContext", () => {
const authorizedMailDomains = ["example.com", "another-example.com", "*.yet-another-example.com", "*.example.com", "hello-world.com"];
const displayName = "this is an overwritten common value";
@ -59,8 +60,7 @@ import { deepClone } from "../../src/tools/deepClone";
return { kcContext };
};
{
it("has proper API for login.ftl", () => {
const pageId = "login.ftl";
const { kcContext } = getKcContextProxy({ "mockPageId": pageId });
@ -69,7 +69,7 @@ import { deepClone } from "../../src/tools/deepClone";
assert<Equals<typeof kcContext, KcContext.Login>>();
assert(
expect(
same(
//NOTE: deepClone for printIfExists or other functions...
deepClone(kcContext),
@ -81,12 +81,10 @@ import { deepClone } from "../../src/tools/deepClone";
return mock;
})()
)
);
).toBe(true);
});
console.log(`PASS ${pageId}`);
}
{
it("has a proper API for info.ftl", () => {
const pageId = "info.ftl";
const { kcContext } = getKcContextProxy({ "mockPageId": pageId });
@ -104,7 +102,7 @@ import { deepClone } from "../../src/tools/deepClone";
>
>();
assert(
expect(
same(
deepClone(kcContext),
(() => {
@ -115,12 +113,9 @@ import { deepClone } from "../../src/tools/deepClone";
return mock;
})()
)
);
console.log(`PASS ${pageId}`);
}
{
).toBe(true);
});
it("has a proper API for register.ftl", () => {
const pageId = "register.ftl";
const { kcContext } = getKcContextProxy({ "mockPageId": pageId });
@ -138,7 +133,7 @@ import { deepClone } from "../../src/tools/deepClone";
>
>();
assert(
expect(
same(
deepClone(kcContext),
(() => {
@ -149,12 +144,9 @@ import { deepClone } from "../../src/tools/deepClone";
return mock;
})()
)
);
console.log(`PASS ${pageId}`);
}
{
).toBe(true);
});
it("has a proper API for my-extra-page-2.ftl", () => {
const pageId = "my-extra-page-2.ftl";
const { kcContext } = getKcContextProxy({ "mockPageId": pageId });
@ -173,7 +165,7 @@ import { deepClone } from "../../src/tools/deepClone";
kcContext.aNonStandardValue2;
assert(
expect(
same(
deepClone(kcContext),
(() => {
@ -184,12 +176,9 @@ import { deepClone } from "../../src/tools/deepClone";
return mock;
})()
)
);
console.log(`PASS ${pageId}`);
}
{
).toBe(true);
});
it("has a proper API for my-extra-page-1.ftl", () => {
const pageId = "my-extra-page-1.ftl";
console.log("We expect a warning here =>");
@ -200,7 +189,7 @@ import { deepClone } from "../../src/tools/deepClone";
assert<Equals<typeof kcContext, KcContext.Common & { pageId: typeof pageId }>>();
assert(
expect(
same(
deepClone(kcContext),
(() => {
@ -211,32 +200,24 @@ import { deepClone } from "../../src/tools/deepClone";
return mock;
})()
)
);
console.log(`PASS ${pageId}`);
}
}
{
const pageId = "login.ftl";
const { kcContext } = getKcContext({
"mockPageId": pageId
).toBe(true);
});
it("returns the proper mock for login.ftl", () => {
const pageId = "login.ftl";
assert<Equals<typeof kcContext, KcContext | undefined>>();
const { kcContext } = getKcContext({
"mockPageId": pageId
});
assert(same(deepClone(kcContext), deepClone(kcContextMocks.find(({ pageId: pageId_i }) => pageId_i === pageId)!)));
assert<Equals<typeof kcContext, KcContext | undefined>>();
console.log("PASS no extension");
}
assert(same(deepClone(kcContext), deepClone(kcContextMocks.find(({ pageId: pageId_i }) => pageId_i === pageId)!)));
});
it("returns the proper mock for login.ftl", () => {
const { kcContext } = getKcContext();
{
const { kcContext } = getKcContext();
assert<Equals<typeof kcContext, KcContext | undefined>>();
assert<Equals<typeof kcContext, KcContext | undefined>>();
assert(kcContext === undefined);
console.log("PASS no extension, no mock");
}
assert(kcContext === undefined);
});
});

View File

@ -1 +0,0 @@
import "./getKcContext";

View File

@ -1,4 +1,4 @@
import { AndByDiscriminatingKey } from "../../../src/tools/AndByDiscriminatingKey";
import { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";

View File

@ -1,7 +0,0 @@
import { assert } from "tsafe/assert";
export function assetIsSameCode(code1: string, code2: string, message?: string): void {
const removeSpacesAndNewLines = (code: string) => code.replace(/\s/g, "").replace(/\n/g, "");
assert(removeSpacesAndNewLines(code1) === removeSpacesAndNewLines(code2), message);
}

5
test/tools/isSameCode.ts Normal file
View File

@ -0,0 +1,5 @@
export function isSameCode(code1: string, code2: string): boolean {
const removeSpacesAndNewLines = (code: string) => code.replace(/\s/g, "").replace(/\n/g, "");
return removeSpacesAndNewLines(code1) === removeSpacesAndNewLines(code2);
}

View File

@ -10,7 +10,7 @@
"newLine": "LF",
"noUnusedLocals": true,
"noUnusedParameters": true,
"incremental": true,
"incremental": false,
"strict": true,
"downlevelIteration": true,
"jsx": "react-jsx",

12
vitest.config.ts Normal file
View File

@ -0,0 +1,12 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import path from "path";
export default defineConfig({
"test": {
"alias": {
"keycloakify": path.resolve(__dirname, "./src")
},
"watchExclude": ["**/node_modules/**", "**/dist/**", "**/sample_react_project/**"]
}
});

810
yarn.lock

File diff suppressed because it is too large Load Diff