Compare commits

..

94 Commits

Author SHA1 Message Date
0b16df7731 Bump version 2023-04-01 23:00:19 +02:00
900125d92e fmt 2023-04-01 23:00:03 +02:00
6aaaf5a9d3 Merge pull request #289 from keycloakify/some-minor-fixes
Some-minor-fixes
2023-04-01 22:59:31 +02:00
bd2f6d8fee style: move loose test into test suite 2023-04-01 22:52:09 +02:00
baae22657e style: fix formatting 2023-04-01 22:44:13 +02:00
46264c85f4 Add unit test and fix some more use cases 2023-04-01 22:36:54 +02:00
2811eb6024 fix: fix typing 2023-04-01 22:08:00 +02:00
218c1a5a50 refactor: use path.sep to be cross-platform 2023-04-01 22:08:00 +02:00
ab5287a3d4 refactor: type-safe trimIndent 2023-04-01 22:07:59 +02:00
d55c62c073 Bump version 2023-04-01 16:32:10 +02:00
4833c34800 Merge pull request #293 from 0x-Void/add-select-authenticator-page
Add support for the select-authenticator.ftl page
2023-04-01 16:31:45 +02:00
fc70e657f0 Bump version 2023-04-01 14:02:48 +02:00
ee23f629f6 Add themeName option 2023-04-01 14:02:32 +02:00
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
e7837aea88 feat: add select-authenticator page 2023-03-31 17:38:22 +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
87ebad7efb Bump version 2023-03-22 03:34:59 +01:00
3294aaed3b Pefrorm Keycloak theme download in paralel 2023-03-22 03:34:44 +01:00
0e21f3eab6 Add missing mock 2023-03-22 03:07:33 +01:00
9fcf692cb8 Bump version 2023-03-22 03:03:24 +01:00
da577ea3cc Add stateChecker to password context 2023-03-22 03:02:44 +01:00
6ae1d8938a Fix eslint 2023-03-22 03:00:44 +01:00
3e18a7390c Bump version: Release v7 🚀 2023-03-22 01:55:49 +01:00
5f43f1afc6 Shot a new post build tutorial video 2023-03-22 01:46:05 +01:00
2fc9c03430 Relase candidate 2023-03-21 23:39:40 +01:00
d951a9ba02 Improve post build instructions 2023-03-21 23:21:30 +01:00
93385af675 Release candidate 2023-03-21 19:44:41 +01:00
dd75d0ece7 Account theme specific instructions 2023-03-21 19:44:01 +01:00
dcd37ed916 Relase candidate 2023-03-21 18:32:21 +01:00
2e4d722d7f Return undefined if the context dosen't match the theme 2023-03-21 18:32:00 +01:00
09543400ca Relase candidate 2023-03-21 16:47:58 +01:00
8b101e5043 Fix error in log related to getLogoUrl 2023-03-21 16:47:30 +01:00
b31fff9c2b Release candidate 2023-03-21 15:16:37 +01:00
0c5b100dd9 Update post build instructions 2023-03-21 15:16:23 +01:00
253825a35e fix(download): fix download cache not behaving as expected 2023-03-21 14:48:16 +01:00
8937d19891 Release candidate 2023-03-21 14:21:27 +01:00
0fdd9e75a6 Fix the helper type that enable to extend the KcContext 2023-03-21 14:21:09 +01:00
59 changed files with 2127 additions and 1044 deletions

11
.gitignore vendored
View File

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

View File

@ -1,12 +1,15 @@
node_modules/ node_modules/
/dist/ /dist/
/dist_test/
/CHANGELOG.md /CHANGELOG.md
/.yarn_home/ /.yarn_home/
/src/test/apps/ /src/test/apps/
/src/tools/types/ /src/tools/types/
/sample_react_project
/build_keycloak/ /build_keycloak/
/.vscode/ /.vscode/
/src/login/i18n/baseMessages/ /src/login/i18n/baseMessages/
/src/account/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"> <a href="https://github.com/garronej/keycloakify/blob/main/LICENSE">
<img src="https://img.shields.io/npm/l/keycloakify"> <img src="https://img.shields.io/npm/l/keycloakify">
</a> </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"> <img src="https://camo.githubusercontent.com/0f9fcc0ac1b8617ad4989364f60f78b2d6b32985ad6a508f215f14d8f897b8d3/68747470733a2f2f62616467656e2e6e65742f62616467652f547970655363726970742f7374726963742532302546302539462539322541412f626c7565">
</a> </a>
<a href="https://github.com/thomasdarimont/awesome-keycloak"> <a href="https://github.com/thomasdarimont/awesome-keycloak">
@ -25,6 +25,8 @@
- -
<a href="https://docs.keycloakify.dev">Documentation</a> <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> <a href="https://github.com/codegouvfr/keycloakify-starter">Starter project</a>
</p> </p>
</p> </p>
@ -34,15 +36,25 @@
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png"> <img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
</p> </p>
> 🗣 V6 have been released 🎉 The more ⭐️ the project gets, the more time I spend improving and maintaining it. Thank you for your support 😊
> [It features major improvements](https://github.com/InseeFrLab/keycloakify#600).
> Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6). > 🗣 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 # Changelog highlights
## 7.0 🍾
- 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 ## 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 ## 6.12
@ -55,13 +67,13 @@ Massive improvement in the developer experience:
## 6.11.4 ## 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). - 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. 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 ## 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 ## 6.8.4
@ -71,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 - 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 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 ## 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 ## 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 ## 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 ## 6.4.0
@ -102,11 +114,11 @@ Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6)
## 5.8.0 ## 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 ## 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 ## 5.6.4
@ -114,7 +126,7 @@ Fix `login-verify-email.ftl` page. [Before](https://user-images.githubuserconten
## v5.6.0 ## 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 ## v5.3.0
@ -129,7 +141,7 @@ Import of terms and services have changed. [See example](https://github.com/garr
## v4.10.0 ## 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 ## v4.8.0
@ -142,7 +154,7 @@ Add `login-idp-link-email.ftl` page [See PR](https://github.com/InseeFrLab/keycl
## v4.7.2 ## v4.7.2
> WARNING: This is broken. > 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. > 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 ## v4.7.0
@ -176,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. 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 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 ## v2.5
- Feature [Use advanced message](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/i18n/useKcMessage.tsx#L53-L66) - Feature [Use advanced message](https://github.com/keycloakify/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)) 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`. - Test container now uses Keycloak version `15.0.2`.
## v2 ## v2

View File

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

View File

@ -1,13 +1,13 @@
import "minimal-polyfills/Object.fromEntries"; import "minimal-polyfills/Object.fromEntries";
import * as fs from "fs"; import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path"; import { join as pathJoin, relative as pathRelative, dirname as pathDirname, sep as pathSep } from "path";
import { crawl } from "../src/bin/tools/crawl"; import { crawl } from "../src/bin/tools/crawl";
import { downloadBuiltinKeycloakTheme } from "../src/bin/download-builtin-keycloak-theme"; import { downloadBuiltinKeycloakTheme } from "../src/bin/download-builtin-keycloak-theme";
import { getProjectRoot } from "../src/bin/tools/getProjectRoot"; import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
import { getCliOptions } from "../src/bin/tools/cliOptions"; import { getCliOptions } from "../src/bin/tools/cliOptions";
import { getLogger } from "../src/bin/tools/logger"; 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. // update the version array for generating for newer version.
//@ts-ignore //@ts-ignore
@ -16,7 +16,7 @@ const propertiesParser = require("properties-parser");
const { isSilent } = getCliOptions(process.argv.slice(2)); const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent }); const logger = getLogger({ isSilent });
(async () => { async function main() {
const keycloakVersion = "21.0.1"; const keycloakVersion = "21.0.1";
const tmpDirPath = pathJoin(getProjectRoot(), "tmp_xImOef9dOd44"); const tmpDirPath = pathJoin(getProjectRoot(), "tmp_xImOef9dOd44");
@ -35,9 +35,10 @@ const logger = getLogger({ isSilent });
{ {
const baseThemeDirPath = pathJoin(tmpDirPath, "base"); const baseThemeDirPath = pathJoin(tmpDirPath, "base");
const re = new RegExp(`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`);
crawl(baseThemeDirPath).forEach(filePath => { crawl(baseThemeDirPath).forEach(filePath => {
const match = filePath.match(/^([^/]+)\/messages\/messages_([^.]+)\.properties$/); const match = filePath.match(re);
if (match === null) { if (match === null) {
return; return;
@ -114,4 +115,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

@ -43,6 +43,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && ( {realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
<li> <li>
<div className="kc-dropdown" id="kc-locale-dropdown"> <div className="kc-dropdown" id="kc-locale-dropdown">
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a href="#" id="kc-current-locale-link"> <a href="#" id="kc-current-locale-link">
{labelBySupportedLanguageTag[currentLanguageTag]} {labelBySupportedLanguageTag[currentLanguageTag]}
</a> </a>

View File

@ -64,6 +64,7 @@ export declare namespace KcContext {
password: { password: {
passwordSet: boolean; passwordSet: boolean;
}; };
stateChecker: string;
}; };
export type Account = Common & { export type Account = Common & {

View File

@ -1,4 +1,3 @@
import { kcContextMocks, kcContextCommonMock } from "./kcContextMocks";
import type { DeepPartial } from "keycloakify/tools/DeepPartial"; import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import { deepAssign } from "keycloakify/tools/deepAssign"; import { deepAssign } from "keycloakify/tools/deepAssign";
import type { ExtendKcContext } from "./getKcContextFromWindow"; import type { ExtendKcContext } from "./getKcContextFromWindow";
@ -7,6 +6,7 @@ import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { pathBasename } from "keycloakify/tools/pathBasename"; import { pathBasename } from "keycloakify/tools/pathBasename";
import { mockTestingResourcesCommonPath } from "keycloakify/bin/mockTestingResourcesPath"; import { mockTestingResourcesCommonPath } from "keycloakify/bin/mockTestingResourcesPath";
import { symToStr } from "tsafe/symToStr"; import { symToStr } from "tsafe/symToStr";
import { kcContextMocks, kcContextCommonMock } from "keycloakify/account/kcContext/kcContextMocks";
export function getKcContext<KcContextExtension extends { pageId: string } = never>(params?: { export function getKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
mockPageId?: ExtendKcContext<KcContextExtension>["pageId"]; mockPageId?: ExtendKcContext<KcContextExtension>["pageId"];
@ -62,6 +62,10 @@ export function getKcContext<KcContextExtension extends { pageId: string } = nev
return { "kcContext": undefined }; return { "kcContext": undefined };
} }
if (!("account" in realKcContext)) {
return { "kcContext": undefined };
}
{ {
const { url } = realKcContext; const { url } = realKcContext;

View File

@ -1,6 +1,6 @@
import type { KcContext } from "./KcContext";
import type { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey"; import type { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey";
import { ftlValuesGlobalName } from "keycloakify/bin/keycloakify/ftlValuesGlobalName"; import { ftlValuesGlobalName } from "keycloakify/bin/keycloakify/ftlValuesGlobalName";
import type { KcContext } from "./KcContext";
export type ExtendKcContext<KcContextExtension extends { pageId: string }> = [KcContextExtension] extends [never] export type ExtendKcContext<KcContextExtension extends { pageId: string }> = [KcContextExtension] extends [never]
? KcContext ? KcContext

View File

@ -1,8 +1,8 @@
import "minimal-polyfills/Object.fromEntries"; import "minimal-polyfills/Object.fromEntries";
import type { KcContext } from "./KcContext";
import { mockTestingResourcesCommonPath, mockTestingResourcesPath } from "keycloakify/bin/mockTestingResourcesPath"; import { mockTestingResourcesCommonPath, mockTestingResourcesPath } from "keycloakify/bin/mockTestingResourcesPath";
import { pathJoin } from "keycloakify/bin/tools/pathJoin"; import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { id } from "tsafe/id"; import { id } from "tsafe/id";
import type { KcContext } from "./KcContext";
const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/"; const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
@ -154,7 +154,8 @@ export const kcContextMocks: KcContext[] = [
"pageId": "password.ftl", "pageId": "password.ftl",
"password": { "password": {
"passwordSet": true "passwordSet": true
} },
"stateChecker": "state checker"
}), }),
id<KcContext.Account>({ id<KcContext.Account>({
...kcContextCommonMock, ...kcContextCommonMock,

View File

@ -15,7 +15,7 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
} }
}); });
const { url, password, account } = kcContext; const { url, password, account, stateChecker } = kcContext;
const { msg } = i18n; const { msg } = i18n;
@ -55,7 +55,7 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
</div> </div>
)} )}
<input type="hidden" id="stateChecker" name="stateChecker" value="${stateChecker}" /> <input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
<div className="form-group"> <div className="form-group">
<div className="col-sm-2 col-md-2"> <div className="col-sm-2 col-md-2">

View File

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

View File

@ -16,6 +16,7 @@ import { existsSync } from "fs";
import { join as pathJoin, relative as pathRelative } from "path"; import { join as pathJoin, relative as pathRelative } from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase"; import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert"; import { assert, Equals } from "tsafe/assert";
import { getThemeSrcDirPath } from "./keycloakify/build-paths";
(async () => { (async () => {
const projectRootDir = getProjectRoot(); const projectRootDir = getProjectRoot();
@ -50,7 +51,13 @@ import { assert, Equals } from "tsafe/assert";
const pageBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(/ftl$/, "tsx"); 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)) { if (existsSync(targetFilePath)) {
console.log(`${pageId} is already ejected, ${pathRelative(process.cwd(), targetFilePath)} already exists`); console.log(`${pageId} is already ejected, ${pathRelative(process.cwd(), targetFilePath)} already exists`);

View File

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

View File

@ -1,49 +1,10 @@
import { z } from "zod";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import { id } from "tsafe/id"; import { id } from "tsafe/id";
import { parse as urlParse } from "url"; import { parse as urlParse } from "url";
import { typeGuard } from "tsafe/typeGuard"; import { typeGuard } from "tsafe/typeGuard";
import { symToStr } from "tsafe/symToStr"; import { symToStr } from "tsafe/symToStr";
import { bundlers, getParsedPackageJson, type Bundler } from "./parsedPackageJson";
const bundlers = ["mvn", "keycloakify", "none"] as const; import { getAppInputPath, getKeycloakBuildPath } from "./build-paths";
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>>();
/** Consolidated build option gathered form CLI arguments and config in package.json */ /** Consolidated build option gathered form CLI arguments and config in package.json */
export type BuildOptions = BuildOptions.Standalone | BuildOptions.ExternalAssets; export type BuildOptions = BuildOptions.Standalone | BuildOptions.ExternalAssets;
@ -59,6 +20,12 @@ export namespace BuildOptions {
groupId: string; groupId: string;
artifactId: string; artifactId: string;
bundler: Bundler; 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 & { export type Standalone = Common & {
@ -85,15 +52,10 @@ export namespace BuildOptions {
} }
} }
export function readBuildOptions(params: { export function readBuildOptions(params: { CNAME: string | undefined; isExternalAssetsCliParamProvided: boolean; isSilent: boolean }): BuildOptions {
packageJson: string; const { CNAME, isExternalAssetsCliParamProvided, isSilent } = params;
CNAME: string | undefined;
isExternalAssetsCliParamProvided: boolean;
isSilent: boolean;
}): BuildOptions {
const { packageJson, CNAME, isExternalAssetsCliParamProvided, isSilent } = params;
const parsedPackageJson = zParsedPackageJson.parse(JSON.parse(packageJson)); const parsedPackageJson = getParsedPackageJson();
const url = (() => { const url = (() => {
const { homepage } = parsedPackageJson; const { homepage } = parsedPackageJson;
@ -125,12 +87,15 @@ export function readBuildOptions(params: {
const common: BuildOptions.Common = (() => { const common: BuildOptions.Common = (() => {
const { name, keycloakify = {}, version, homepage } = parsedPackageJson; 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 const themeName =
.replace(/^@(.*)/, "$1") keycloakify.themeName ??
.split("/") name
.join("-"); .replace(/^@(.*)/, "$1")
.split("/")
.join("-");
return { return {
themeName, themeName,
@ -167,7 +132,11 @@ export function readBuildOptions(params: {
"extraLoginPages": [...(extraPages ?? []), ...(extraLoginPages ?? [])], "extraLoginPages": [...(extraPages ?? []), ...(extraLoginPages ?? [])],
extraAccountPages, extraAccountPages,
extraThemeProperties, 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 "./parsedPackageJson";
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", "totp", "totpSecret", "SAMLRequest", "SAMLResponse", "relayState", "device_user_code", "code",
"password-new", "rememberMe", "login", "authenticationExecution", "cancel-aia", "clientDataJSON", "password-new", "rememberMe", "login", "authenticationExecution", "cancel-aia", "clientDataJSON",
"authenticatorData", "signature", "credentialId", "userHandle", "error", "authn_use_chk", "authenticationExecution", "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> <#attempt>
@ -110,14 +110,16 @@
} }
}; };
out["pageId"] = "${pageId}"; <#if account??>
out["url"]["getLogoutUrl"] = function () {
<#attempt>
return "${url.getLogoutUrl()}";
<#recover>
</#attempt>
};
</#if>
out["url"]["getLogoutUrl"] = function () { out["pageId"] = "${pageId}";
<#attempt>
return "${url.getLogoutUrl()}";
<#recover>
</#attempt>
};
return out; return out;
@ -162,9 +164,9 @@
key == "updateProfileCtx" && key == "updateProfileCtx" &&
are_same_path(path, []) are_same_path(path, [])
) || ( ) || (
<#-- https://github.com/InseeFrLab/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) --> <#-- https://github.com/keycloakify/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/keycloakify/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/issues/109#issuecomment-1134610163 -->
key == "loginAction" && key == "loginAction" &&
are_same_path(path, ["url"]) && are_same_path(path, ["url"]) &&
["saml-post-form.ftl", "error.ftl", "info.ftl"]?seq_contains(pageId) && ["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 { ftlValuesGlobalName } from "../ftlValuesGlobalName";
import type { BuildOptions } from "../BuildOptions"; import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
export const themeTypes = ["login", "account"] as const; export const themeTypes = ["login", "account"] as const;
@ -35,7 +34,9 @@ export const loginThemePageIds = [
"login-config-totp.ftl", "login-config-totp.ftl",
"logout-confirm.ftl", "logout-confirm.ftl",
"update-user-profile.ftl", "update-user-profile.ftl",
"idp-review-user-profile.ftl" "idp-review-user-profile.ftl",
"update-email.ftl",
"select-authenticator.ftl"
] as const; ] as const;
export const accountThemePageIds = ["password.ftl", "account.ftl"] as const; export const accountThemePageIds = ["password.ftl", "account.ftl"] as const;
@ -46,7 +47,11 @@ export type AccountThemePageId = (typeof accountThemePageIds)[number];
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets; export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
export namespace BuildOptionsLike { export namespace BuildOptionsLike {
export type Standalone = { export type Common = {
customUserAttributes: string[];
};
export type Standalone = Common & {
isStandalone: true; isStandalone: true;
urlPathname: string | undefined; urlPathname: string | undefined;
}; };
@ -58,23 +63,21 @@ export namespace BuildOptionsLike {
isStandalone: false; isStandalone: false;
}; };
export type SameDomain = CommonExternalAssets & { export type SameDomain = Common &
areAppAndKeycloakServerSharingSameDomain: true; CommonExternalAssets & {
}; areAppAndKeycloakServerSharingSameDomain: true;
};
export type DifferentDomains = CommonExternalAssets & { export type DifferentDomains = Common &
areAppAndKeycloakServerSharingSameDomain: false; CommonExternalAssets & {
urlOrigin: string; areAppAndKeycloakServerSharingSameDomain: false;
urlPathname: string | undefined; urlOrigin: string;
}; urlPathname: string | undefined;
};
} }
} }
{ assert<BuildOptions extends BuildOptionsLike ? true : false>();
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export function generateFtlFilesCodeFactory(params: { export function generateFtlFilesCodeFactory(params: {
indexHtmlCode: string; indexHtmlCode: string;
@ -152,7 +155,11 @@ export function generateFtlFilesCodeFactory(params: {
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }': fs '{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }': fs
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl")) .readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
.toString("utf8") .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 -->": [ "<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
"<#if scripts??>", "<#if scripts??>",
" <#list scripts as script>", " <#list scripts as script>",

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { generateKeycloakThemeResources } from "./generateKeycloakThemeResources"; import { generateKeycloakThemeResources } from "./generateKeycloakThemeResources";
import { generateJavaStackFiles } from "./generateJavaStackFiles"; import { generateJavaStackFiles } from "./generateJavaStackFiles";
import { join as pathJoin, relative as pathRelative, basename as pathBasename } from "path"; import { join as pathJoin, relative as pathRelative, basename as pathBasename, sep as pathSep } from "path";
import * as child_process from "child_process"; import * as child_process from "child_process";
import { generateStartKeycloakTestingContainer } from "./generateStartKeycloakTestingContainer"; import { generateStartKeycloakTestingContainer } from "./generateStartKeycloakTestingContainer";
import * as fs from "fs"; import * as fs from "fs";
@ -9,12 +9,9 @@ import { getLogger } from "../tools/logger";
import { getCliOptions } from "../tools/cliOptions"; import { getCliOptions } from "../tools/cliOptions";
import jar from "../tools/jar"; import jar from "../tools/jar";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import type { Equals } from "tsafe"; import { Equals } from "tsafe";
import { getEmailThemeSrcDirPath } from "./build-paths";
const reactProjectDirPath = process.cwd(); import { getCnamePath, getAppInputPath, getKeycloakBuildPath, getReactProjectDirPath } from "./build-paths";
export const keycloakThemeBuildingDirPath = pathJoin(reactProjectDirPath, "build_keycloak");
export const keycloakThemeEmailDirPath = pathJoin(reactProjectDirPath, "src", "keycloak-theme", "email");
export async function main() { export async function main() {
const { isSilent, hasExternalAssets } = getCliOptions(process.argv.slice(2)); const { isSilent, hasExternalAssets } = getCliOptions(process.argv.slice(2));
@ -22,9 +19,8 @@ export async function main() {
logger.log("🔏 Building the keycloak theme...⌚"); logger.log("🔏 Building the keycloak theme...⌚");
const buildOptions = readBuildOptions({ const buildOptions = readBuildOptions({
"packageJson": fs.readFileSync(pathJoin(reactProjectDirPath, "package.json")).toString("utf8"),
"CNAME": (() => { "CNAME": (() => {
const cnameFilePath = pathJoin(reactProjectDirPath, "public", "CNAME"); const cnameFilePath = getCnamePath();
if (!fs.existsSync(cnameFilePath)) { if (!fs.existsSync(cnameFilePath)) {
return undefined; return undefined;
@ -37,18 +33,23 @@ export async function main() {
}); });
const { doBundlesEmailTemplate } = await generateKeycloakThemeResources({ const { doBundlesEmailTemplate } = await generateKeycloakThemeResources({
keycloakThemeBuildingDirPath, keycloakThemeBuildingDirPath: buildOptions.keycloakBuildPath,
keycloakThemeEmailDirPath, "emailThemeSrcDirPath": (() => {
"reactAppBuildDirPath": pathJoin(reactProjectDirPath, "build"), const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath();
if (emailThemeSrcDirPath === undefined || !fs.existsSync(emailThemeSrcDirPath)) {
return;
}
return emailThemeSrcDirPath;
})(),
"reactAppBuildDirPath": getAppInputPath(),
buildOptions, buildOptions,
//We have to leave it at that otherwise we break our default theme. "keycloakVersion": buildOptions.keycloakVersionDefaultAssets
//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"
}); });
const { jarFilePath } = generateJavaStackFiles({ const { jarFilePath } = generateJavaStackFiles({
keycloakThemeBuildingDirPath, keycloakThemeBuildingDirPath: buildOptions.keycloakBuildPath,
doBundlesEmailTemplate, doBundlesEmailTemplate,
buildOptions buildOptions
}); });
@ -60,7 +61,7 @@ export async function main() {
case "keycloakify": case "keycloakify":
logger.log("🫶 Let keycloakify do its thang"); logger.log("🫶 Let keycloakify do its thang");
await jar({ await jar({
"rootPath": pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources"), "rootPath": pathJoin(buildOptions.keycloakBuildPath, "src", "main", "resources"),
"version": buildOptions.version, "version": buildOptions.version,
"groupId": buildOptions.groupId, "groupId": buildOptions.groupId,
"artifactId": buildOptions.artifactId, "artifactId": buildOptions.artifactId,
@ -69,7 +70,7 @@ export async function main() {
break; break;
case "mvn": case "mvn":
logger.log("🫙 Run maven to deliver a jar"); logger.log("🫙 Run maven to deliver a jar");
child_process.execSync("mvn package", { "cwd": keycloakThemeBuildingDirPath }); child_process.execSync("mvn package", { "cwd": buildOptions.keycloakBuildPath });
break; break;
default: default:
assert<Equals<typeof buildOptions.bundler, never>>(false); assert<Equals<typeof buildOptions.bundler, never>>(false);
@ -79,7 +80,7 @@ export async function main() {
const containerKeycloakVersion = "20.0.1"; const containerKeycloakVersion = "20.0.1";
generateStartKeycloakTestingContainer({ generateStartKeycloakTestingContainer({
keycloakThemeBuildingDirPath, keycloakThemeBuildingDirPath: buildOptions.keycloakBuildPath,
"keycloakVersion": containerKeycloakVersion, "keycloakVersion": containerKeycloakVersion,
buildOptions buildOptions
}); });
@ -87,7 +88,7 @@ export async function main() {
logger.log( 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.`, `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. //TODO: Restore when we find a good Helm chart for Keycloak.
@ -121,19 +122,31 @@ 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:`, `To test your theme locally you can spin up a Keycloak ${containerKeycloakVersion} container image with the theme pre loaded by running:`,
"", "",
`👉 $ ./${pathRelative(reactProjectDirPath, pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename))} 👈`, `👉 $ .${pathSep}${pathRelative(
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", `Test with different Keycloak versions by editing the .sh file. see available versions here: https://quay.io/repository/keycloak/keycloak?tab=tags`,
"", ``,
"Once your container is up and running: ", `Once your container is up and running: `,
"- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈", "- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈",
'- Create a realm named "myrealm"', `- Create a realm: myrealm`,
'- Create a client with ID: "myclient", "Root URL": "https://www.keycloak.org/app/" and "Valid redirect URIs": "https://www.keycloak.org/app/*"', `- Enable registration: Realm settings -> Login tab -> User registration: on`,
`- Select Login Theme: ${buildOptions.themeName} (don't forget to save at the bottom of the page)`, `- Enable the Account theme: Realm settings -> Themes tab -> Account theme, select ${buildOptions.themeName} `,
`- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`, `- Create a client id myclient`,
"", ` Root URL: https://www.keycloak.org/app/`,
"Video demoing this process: https://youtu.be/N3wlBoH4hKg", ` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
"" ` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
` Web origins: *`,
` Login Theme: ${buildOptions.themeName}`,
` Save (button at the bottom of the page)`,
``,
`- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`,
`- Got to 👉 http://localhost:8080/realms/myrealm/account 👈 to see your account theme`,
``,
`Video tutorial: https://youtu.be/WMyGZNHQkjU`,
``
].join("\n") ].join("\n")
); );
} }

View File

@ -0,0 +1,62 @@
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[];
themeName?: 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(),
"themeName": 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 { 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 { promisify } from "util";
import { getProjectRoot } from "./getProjectRoot";
import { transformCodebase } from "./transformCodebase";
import { unzip } from "./unzip";
const exec = promisify(execCallback); const exec = promisify(execCallback);
@ -17,25 +15,27 @@ function hash(s: string) {
return createHash("sha256").update(s).digest("hex"); return createHash("sha256").update(s).digest("hex");
} }
async function maybeStat(path: string) { async function exists(path: string) {
try { try {
return await stat(path); await stat(path);
return true;
} catch (error) { } catch (error) {
if ((error as Error & { code: string }).code === "ENOENT") return undefined; if ((error as Error & { code: string }).code === "ENOENT") return false;
throw error; throw error;
} }
} }
/** /**
* Get an npm configuration value as string, undefined if not set. * Get npm configuration as map
*
* @param key
* @returns string or undefined
*/ */
async function getNmpConfig(key: string): Promise<string | undefined> { async function getNmpConfig(): Promise<Record<string, string>> {
const { stdout } = await exec(`npm config get ${key}`); const { stdout } = await exec("npm config get", { encoding: "utf8" });
const value = stdout.trim(); return stdout
return value && value !== "null" ? value : undefined; .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 * @returns proxy configuration
*/ */
async function getNpmProxyConfig(): Promise<Pick<FetchOptions, "proxy" | "noProxy">> { async function getNpmProxyConfig(): Promise<Pick<FetchOptions, "proxy" | "noProxy">> {
const proxy = (await getNmpConfig("https-proxy")) ?? (await getNmpConfig("proxy")); const cfg = await getNmpConfig();
const noProxy = (await getNmpConfig("noproxy")) ?? (await getNmpConfig("no-proxy"));
const proxy = cfg["https-proxy"] ?? cfg["proxy"];
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
return { proxy, noProxy }; return { proxy, noProxy };
} }
/** export async function downloadAndUnzip(params: { url: string; destDirPath: string; pathOfDirToExtractInArchive?: string }) {
* Download a file from `url` to `dir`. Will try to avoid downloading existing const { url, destDirPath, pathOfDirToExtractInArchive } = params;
* 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;
}
/** const downloadHash = hash(JSON.stringify({ url })).substring(0, 15);
* @typedef const projectRoot = getProjectRoot();
* @type MultiError = Error & { cause: Error[] } 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}`);
/** if (!(await exists(zipFilePath))) {
* Extract the archive `zipFile` into the directory `dir`. If `archiveDir` is given, const proxyOpts = await getNpmProxyConfig();
* only that directory will be extracted, stripping the given path components. const response = await fetch(url, proxyOpts);
* await mkdir(pathDirname(zipFilePath), { "recursive": true });
* If dir does not exist, it will be created. /**
* * The correct way to fix this is to upgrade node-fetch beyond 3.2.5
* If any archive file exists, it will be overwritten. * (see https://github.com/node-fetch/node-fetch/issues/1295#issuecomment-1144061991.)
* * Unfortunately, octokit (a dependency of keycloakify) also uses node-fetch, and
* Will unzip using all available nodejs worker threads. * does not support node-fetch 3.x. So we stick around with this band-aid until
* * octokit upgrades.
* Will try to clean up extracted files on failure. */
* response.body?.setMaxListeners(Number.MAX_VALUE);
* If unpacking fails, will either throw an regular error, or assert(typeof response.body !== "undefined" && response.body != null);
* possibly an `MultiError`, which contains a `cause` field with await writeFile(zipFilePath, response.body);
* 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);
})
);
} }
// Wait until _all_ files are either extracted or failed await unzip(zipFilePath, extractDirPath, pathOfDirToExtractInArchive);
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);
// If any extraction failed, try to clean up, then throw a MultiError, transformCodebase({
// which has a `cause` field, containing a list of root cause errors. "srcDirPath": extractDirPath,
if (failure.length) { "destDirPath": destDirPath
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));
}); });
} }
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 { createWriteStream } from "fs";
import walk from "./walk"; import walk from "./walk";
import type { ZipSource } from "./zip"; import zip, { type ZipSource } from "./zip";
import zip from "./zip";
import { mkdir } from "fs/promises"; import { mkdir } from "fs/promises";
import trimIndent from "./trimIndent";
/** Trim leading whitespace from every line */
const trimIndent = (s: string) => s.replace(/(\n)\s+/g, "$1");
type JarArgs = { type JarArgs = {
rootPath: string; rootPath: string;
@ -26,28 +23,23 @@ type JarArgs = {
export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) { export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) {
const manifest: ZipSource = { const manifest: ZipSource = {
path: "META-INF/MANIFEST.MF", path: "META-INF/MANIFEST.MF",
data: Buffer.from( data: Buffer.from(trimIndent`
trimIndent( Manifest-Version: 1.0
`Manifest-Version: 1.0 Archiver-Version: Plexus Archiver
Archiver-Version: Plexus Archiver Created-By: Keycloakify
Created-By: Keycloakify Built-By: unknown
Built-By: unknown Build-Jdk: 19.0.0
Build-Jdk: 19.0.0` `)
)
)
}; };
const pomProps: ZipSource = { const pomProps: ZipSource = {
path: `META-INF/maven/${groupId}/${artifactId}/pom.properties`, path: `META-INF/maven/${groupId}/${artifactId}/pom.properties`,
data: Buffer.from( data: Buffer.from(trimIndent`# Generated by keycloakify
trimIndent( # ${new Date().toString()}
`# Generated by keycloakify artifactId=${artifactId}
# ${new Date()} groupId=${groupId}
artifactId=${artifactId} version=${version}
groupId=${groupId} `)
version=${version}`
)
)
}; };
/** /**
@ -98,5 +90,5 @@ if (require.main === module) {
groupId: process.env.GROUP_ID ?? "group", groupId: process.env.GROUP_ID ?? "group",
version: process.env.VERSION ?? "1.0.0" 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 aFull = false;
let bFull = false; let bFull = false;
a.setMaxListeners(Infinity);
a.on("drain", () => { a.on("drain", () => {
aFull = false; aFull = false;
if (!aFull && !bFull) input.resume(); if (!aFull && !bFull) input.resume();

View File

@ -0,0 +1,46 @@
/**
* Concatenate the string fragments and interpolated values
* to get a single string.
*/
function populateTemplate(strings: TemplateStringsArray, ...args: unknown[]) {
const chunks: string[] = [];
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
const chunk = String(args[i]).replace(/([\r?\n])/g, "$1" + " ".repeat(lastStringLineLength));
chunks.push(chunk);
}
}
return chunks.join("");
}
/**
* 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[]) {
// Remove initial and final newlines
let string = populateTemplate(strings, ...args)
.replace(/^[\r\n]/, "")
.replace(/\r?\n *$/, "");
const dents =
string
.match(/^([ \t])+/gm)
?.filter(s => /^\s+$/.test(s))
?.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 re = new RegExp(`^${" ".repeat(minDent)}`, "gm");
const dedented = string.replace(re, "");
return dedented;
}

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

View File

@ -30,7 +30,9 @@ export type KcContext =
| KcContext.LoginConfigTotp | KcContext.LoginConfigTotp
| KcContext.LogoutConfirm | KcContext.LogoutConfirm
| KcContext.UpdateUserProfile | KcContext.UpdateUserProfile
| KcContext.IdpReviewUserProfile; | KcContext.IdpReviewUserProfile
| KcContext.UpdateEmail
| KcContext.SelectAuthenticator;
export declare namespace KcContext { export declare namespace KcContext {
export type Common = { export type Common = {
@ -101,7 +103,8 @@ export declare namespace KcContext {
registrationDisabled: boolean; registrationDisabled: boolean;
login: { login: {
username?: string; username?: string;
rememberMe?: boolean; rememberMe?: string;
password?: string;
}; };
usernameEditDisabled: boolean; usernameEditDisabled: boolean;
social: { social: {
@ -182,6 +185,9 @@ export declare namespace KcContext {
realm: { realm: {
loginWithEmailAllowed: boolean; loginWithEmailAllowed: boolean;
}; };
url: {
loginResetCredentialsUrl: string;
};
}; };
export type LoginVerifyEmail = Common & { export type LoginVerifyEmail = Common & {
@ -219,7 +225,7 @@ export declare namespace KcContext {
registrationDisabled: boolean; registrationDisabled: boolean;
login: { login: {
username?: string; username?: string;
rememberMe?: boolean; rememberMe?: string;
}; };
usernameHidden?: boolean; usernameHidden?: boolean;
social: { social: {
@ -377,6 +383,46 @@ export declare namespace KcContext {
attributesByName: Record<string, Attribute>; attributesByName: Record<string, Attribute>;
}; };
}; };
export type UpdateEmail = Common & {
pageId: "update-email.ftl";
email: {
value?: string;
};
};
export type SelectAuthenticator = Common & {
pageId: "select-authenticator.ftl";
auth: {
authenticationSelections: SelectAuthenticator.AuthenticationSelection[];
};
};
export namespace SelectAuthenticator {
export type AuthenticationSelection = {
authExecId: string;
displayName:
| "otp-display-name"
| "password-display-name"
| "auth-username-form-display-name"
| "auth-username-password-form-display-name"
| "webauthn-display-name"
| "webauthn-passwordless-display-name";
helpText:
| "otp-help-text"
| "password-help-text"
| "auth-username-form-help-text"
| "auth-username-password-form-help-text"
| "webauthn-help-text"
| "webauthn-passwordless-help-text";
iconCssClass?:
| "kcAuthenticatorDefaultClass"
| "kcAuthenticatorPasswordClass"
| "kcAuthenticatorOTPClass"
| "kcAuthenticatorWebAuthnClass"
| "kcAuthenticatorWebAuthnPasswordlessClass";
};
}
} }
export type Attribute = { export type Attribute = {

View File

@ -121,6 +121,10 @@ export function getKcContext<KcContextExtension extends { pageId: string } = nev
return { "kcContext": undefined }; return { "kcContext": undefined };
} }
if (!("login" in realKcContext)) {
return { "kcContext": undefined };
}
{ {
const { url } = realKcContext; const { url } = realKcContext;

View File

@ -260,9 +260,7 @@ export const kcContextMocks: KcContext[] = [
"displayInfo": true "displayInfo": true
}, },
"usernameEditDisabled": false, "usernameEditDisabled": false,
"login": { "login": {},
"rememberMe": false
},
"registrationDisabled": false "registrationDisabled": false
}), }),
...(() => { ...(() => {
@ -331,7 +329,8 @@ export const kcContextMocks: KcContext[] = [
"realm": { "realm": {
...kcContextCommonMock.realm, ...kcContextCommonMock.realm,
"loginWithEmailAllowed": false "loginWithEmailAllowed": false
} },
url: loginUrl
}), }),
id<KcContext.LoginVerifyEmail>({ id<KcContext.LoginVerifyEmail>({
...kcContextCommonMock, ...kcContextCommonMock,
@ -376,9 +375,7 @@ export const kcContextMocks: KcContext[] = [
"displayInfo": true "displayInfo": true
}, },
"usernameHidden": false, "usernameHidden": false,
"login": { "login": {},
"rememberMe": false
},
"registrationDisabled": false "registrationDisabled": false
}), }),
id<KcContext.LoginPassword>({ id<KcContext.LoginPassword>({
@ -494,5 +491,32 @@ export const kcContextMocks: KcContext[] = [
attributes, attributes,
attributesByName attributesByName
} }
}),
id<KcContext.UpdateEmail>({
...kcContextCommonMock,
"pageId": "update-email.ftl",
"email": {
value: "email@example.com"
}
}),
id<KcContext.SelectAuthenticator>({
...kcContextCommonMock,
pageId: "select-authenticator.ftl",
auth: {
authenticationSelections: [
{
authExecId: "f607f83c-537e-42b7-99d7-c52d459afe84",
displayName: "otp-display-name",
helpText: "otp-help-text",
iconCssClass: "kcAuthenticatorOTPClass"
},
{
authExecId: "5ed881b1-84cd-4e9b-b4d9-f329ea61a58c",
displayName: "webauthn-display-name",
helpText: "webauthn-help-text",
iconCssClass: "kcAuthenticatorWebAuthnClass"
}
]
}
}) })
]; ];

View File

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx"; 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 type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext"; import type { KcContext } from "../kcContext";

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx"; 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 type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext"; import type { KcContext } from "../kcContext";

View File

@ -0,0 +1,73 @@
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "keycloakify/login/kcContext";
import type { I18n } from "keycloakify/login/i18n";
import { MouseEvent, useRef } from "react";
import { useConstCallback } from "keycloakify/tools/useConstCallback";
export default function SelectAuthenticator(props: PageProps<Extract<KcContext, { pageId: "select-authenticator.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { url, auth } = kcContext;
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const { msg } = i18n;
const selectCredentialsForm = useRef<HTMLFormElement>(null);
const authExecIdInput = useRef<HTMLInputElement>(null);
const submitForm = useConstCallback(() => {
selectCredentialsForm.current?.submit();
});
const onSelectedAuthenticator = useConstCallback((event: MouseEvent<HTMLDivElement>) => {
const divElement = event.currentTarget;
const authExecId = divElement.dataset.authExecId;
if (!authExecIdInput.current || !authExecId) {
return;
}
authExecIdInput.current.value = authExecId;
submitForm();
});
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("loginChooseAuthenticator")}>
<form
id="kc-select-credential-form"
className={getClassName("kcFormClass")}
ref={selectCredentialsForm}
action={url.loginAction}
method="post"
>
<div className={getClassName("kcSelectAuthListClass")}>
{auth.authenticationSelections.map((authenticationSelection, index) => (
<div key={index} className={getClassName("kcSelectAuthListItemClass")}>
<div
style={{ cursor: "pointer" }}
onClick={onSelectedAuthenticator}
data-auth-exec-id={authenticationSelection.authExecId}
className={getClassName("kcSelectAuthListItemInfoClass")}
>
<div className={getClassName("kcSelectAuthListItemLeftClass")}>
<span className={getClassName(authenticationSelection.iconCssClass ?? "kcAuthenticatorDefaultClass")}></span>
</div>
<div className={getClassName("kcSelectAuthListItemBodyClass")}>
<div className={getClassName("kcSelectAuthListItemDescriptionClass")}>
<div className={getClassName("kcSelectAuthListItemHeadingClass")}>
{msg(authenticationSelection.displayName)}
</div>
<div className={getClassName("kcSelectAuthListItemHelpTextClass")}>
{msg(authenticationSelection.helpText)}
</div>
</div>
</div>
</div>
</div>
))}
<input type="hidden" id="authexec-hidden-input" name="authenticationExecution" ref={authExecIdInput} />
</div>
</form>
</Template>
);
}

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 { useState } from "react";
import { clsx } from "keycloakify/tools/clsx"; 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 type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext"; import type { KcContext } from "../kcContext";

View File

@ -10,7 +10,11 @@ export declare namespace AndByDiscriminatingKey {
U1, U1,
U1Again extends Record<DiscriminatingKey, string>, U1Again extends Record<DiscriminatingKey, string>,
U2 extends Record<DiscriminatingKey, string> U2 extends Record<DiscriminatingKey, string>
> = U1 extends Pick<U2, DiscriminatingKey> ? Tf2<DiscriminatingKey, U1, U2, U1Again> : U1; > = U1 extends Pick<U2, DiscriminatingKey>
? Tf2<DiscriminatingKey, U1, U2, U1Again>
: U1Again[DiscriminatingKey] & U2[DiscriminatingKey] extends never
? U1 | U2
: U1;
export type Tf2< export type Tf2<
DiscriminatingKey extends string, DiscriminatingKey extends string,

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 { replaceImportsFromStaticInJsCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsFromStaticInJsCode";
import { generateCssCodeToDefineGlobals, replaceImportsInCssCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsInCssCode"; import { generateCssCodeToDefineGlobals, replaceImportsInCssCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsInCssCode";
import { replaceImportsInInlineCssCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsInInlineCssCode"; import { replaceImportsInInlineCssCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsInInlineCssCode";
import { assert } from "tsafe/assert";
import { same } from "evt/tools/inDepth/same"; 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 = ` const jsCodeUntransformed = `
function f() { function f() {
return a.p+"static/js/" + ({}[e] || e) + "." + { return a.p+"static/js/" + ({}[e] || e) + "." + {
@ -32,8 +33,7 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
}[e]+".chunk.css" }[e]+".chunk.css"
} }
`; `;
it("transforms standalone code properly", () => {
{
const { fixedJsCode } = replaceImportsFromStaticInJsCode({ const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": jsCodeUntransformed, "jsCode": jsCodeUntransformed,
"buildOptions": { "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({ const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": jsCodeUntransformed, "jsCode": jsCodeUntransformed,
"buildOptions": { "buildOptions": {
@ -150,126 +149,128 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
} }
`; `;
assetIsSameCode(fixedJsCode, fixedJsCodeExpected); expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
} });
} });
{ describe("bin/css-transforms", () => {
const { fixedCssCode, cssGlobalsToDefine } = replaceImportsInCssCode({ it("transforms absolute urls to css globals properly with no urlPathname", () => {
"cssCode": ` 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 { .my-div {
background: url(/logo192.png) no-repeat center center; background: var(--url1f9ef5a892c104c);
} }
.my-div2 { .my-div2 {
background: url(/logo192.png) no-repeat center center; background: var(--url1f9ef5a892c104c);
} }
.my-div { .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 = ` 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": `
.my-div { .my-div {
background: url(/x/y/z/logo192.png) no-repeat center center; background: var(--urlf8277cddaa2be78);
} }
.my-div2 { .my-div2 {
background: url(/x/y/z/logo192.png) no-repeat center center; background: var(--urlf8277cddaa2be78);
} }
.my-div { .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 = ` describe("bin/css-inline-transforms", () => {
.my-div { describe("no url pathName", () => {
background: var(--urlf8277cddaa2be78); const cssCode = `
}
.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 = `
@font-face { @font-face {
font-family: "Work Sans"; font-family: "Work Sans";
font-style: normal; font-style: normal;
@ -299,17 +300,16 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
src: url("/fonts/WorkSans/worksans-bold-webfont.woff2") format("woff2"); 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 fixedCssCodeExpected = `
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"isStandalone": true,
"urlPathname": undefined
}
});
const fixedCssCodeExpected = `
@font-face { @font-face {
font-family: "Work Sans"; font-family: "Work Sans";
font-style: normal; font-style: normal;
@ -344,20 +344,19 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
} }
`; `;
assetIsSameCode(fixedCssCode, fixedCssCodeExpected); expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
}
{
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"isStandalone": false,
"urlOrigin": "https://demo-app.keycloakify.dev",
"urlPathname": undefined
}
}); });
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-face {
font-family: "Work Sans"; font-family: "Work Sans";
font-style: normal; font-style: normal;
@ -392,12 +391,12 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
} }
`; `;
assetIsSameCode(fixedCssCode, fixedCssCodeExpected); expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
} });
} });
{ describe("with url pathName", () => {
const cssCode = ` const cssCode = `
@font-face { @font-face {
font-family: "Work Sans"; font-family: "Work Sans";
font-style: normal; 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"); 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 fixedCssCodeExpected = `
const { fixedCssCode } = replaceImportsInInlineCssCode({ @font-face {
cssCode, font-family: "Work Sans";
"buildOptions": { font-style: normal;
"isStandalone": true, font-weight: 400;
"urlPathname": "/x/y/z/" 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 = ` const fixedCssCodeExpected = `
@font-face { @font-face {
font-family: "Work Sans"; font-family: "Work Sans";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(\${url.resourcesPath}/build/fonts/WorkSans/worksans-regular-webfont.woff2) src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-regular-webfont.woff2)
format("woff2"); format("woff2");
} }
@font-face { @font-face {
font-family: "Work Sans"; font-family: "Work Sans";
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
src: url(\${url.resourcesPath}/build/fonts/WorkSans/worksans-medium-webfont.woff2) src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-medium-webfont.woff2)
format("woff2"); format("woff2");
} }
@font-face { @font-face {
font-family: "Work Sans"; font-family: "Work Sans";
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(\${url.resourcesPath}/build/fonts/WorkSans/worksans-semibold-webfont.woff2) src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-semibold-webfont.woff2)
format("woff2"); format("woff2");
} }
@font-face { @font-face {
font-family: "Work Sans"; font-family: "Work Sans";
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url(\${url.resourcesPath}/build/fonts/WorkSans/worksans-bold-webfont.woff2) src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-bold-webfont.woff2)
format("woff2"); format("woff2");
} }
`; `;
assetIsSameCode(fixedCssCode, fixedCssCodeExpected); expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
}
{
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(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"; import { downloadAndUnzip } from "keycloakify/bin/tools/downloadAndUnzip";
export const sampleReactProjectDirPath = pathJoin(getProjectRoot(), "sample_react_project"); export async function setupSampleReactProject(destDirPath: string) {
export async function setupSampleReactProject() {
await downloadAndUnzip({ await downloadAndUnzip({
"url": "https://github.com/InseeFrLab/keycloakify/releases/download/v0.0.1/sample_build_dir_and_package_json.zip", "url": "https://github.com/keycloakify/keycloakify/releases/download/v0.0.1/sample_build_dir_and_package_json.zip",
"destDirPath": sampleReactProjectDirPath, "destDirPath": destDirPath
"cacheDirPath": pathJoin(sampleReactProjectDirPath, "build_keycloak", ".cache"),
"isSilent": false
}); });
} }

View File

@ -0,0 +1,65 @@
import trimIndent from "keycloakify/bin/tools/trimIndent";
import { it, describe, assert } from "vitest";
describe("trimIndent", () => {
it("does not change a left-aligned string as expected", () => {
const txt = trimIndent`lorem
ipsum`;
assert.equal(txt, ["lorem", "ipsum"].join("\n"));
});
it("removes leading and trailing empty lines from a left-aligned string", () => {
const txt = trimIndent`
lorem
ipsum
`;
assert.equal(txt, ["lorem", "ipsum"].join("\n"));
});
it("removes indent from an aligned string", () => {
const txt = trimIndent`
lorem
ipsum
`;
assert.equal(txt, ["lorem", "ipsum"].join("\n"));
});
it("removes indent from unaligned string", () => {
const txt = trimIndent`
lorem
ipsum
`;
assert.equal(txt, ["lorem", " ipsum"].join("\n"));
});
it("removes only first and last empty line", () => {
const txt = trimIndent`
lorem
ipsum
`;
assert.equal(txt, ["", "lorem", "ipsum", ""].join("\n"));
});
it("interpolates non-strings", () => {
const d = new Date();
const txt = trimIndent`
lorem
${d}
ipsum`;
assert.equal(txt, ["lorem", String(d), "ipsum"].join("\n"));
});
it("inderpolates preserving new-lines in the interpolated bits", () => {
const a = ["ipsum", "dolor", "sit"].join("\n");
const txt = trimIndent`
lorem
${a}
amet
`;
assert.equal(txt, ["lorem", "ipsum", "dolor", "sit", "amet"].join("\n"));
});
});

View File

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

View File

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

View File

@ -0,0 +1,91 @@
import { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
{
type Base = { pageId: "a"; onlyA: string } | { pageId: "b"; onlyB: string } | { pageId: "only base"; onlyBase: string };
type Extension = { pageId: "a"; onlyExtA: string } | { pageId: "b"; onlyExtB: string } | { pageId: "only ext"; onlyExt: string };
type Got = AndByDiscriminatingKey<"pageId", Extension, Base>;
type Expected =
| { pageId: "a"; onlyA: string; onlyExtA: string }
| { pageId: "b"; onlyB: string; onlyExtB: string }
| { pageId: "only base"; onlyBase: string }
| { pageId: "only ext"; onlyExt: string };
assert<Equals<Got, Expected>>();
const x: Got = null as any;
if (x.pageId === "a") {
x.onlyA;
x.onlyExtA;
//@ts-expect-error
x.onlyB;
//@ts-expect-error
x.onlyBase;
//@ts-expect-error
x.onlyExt;
}
if (x.pageId === "b") {
x.onlyB;
x.onlyExtB;
//@ts-expect-error
x.onlyA;
//@ts-expect-error
x.onlyBase;
//@ts-expect-error
x.onlyExt;
}
if (x.pageId === "only base") {
x.onlyBase;
//@ts-expect-error
x.onlyA;
//@ts-expect-error
x.onlyB;
//@ts-expect-error
x.onlyExt;
}
if (x.pageId === "only ext") {
x.onlyExt;
//@ts-expect-error
x.onlyA;
//@ts-expect-error
x.onlyB;
//@ts-expect-error
x.onlyBase;
}
}
{
type Base = { pageId: "a"; onlyA: string } | { pageId: "b"; onlyB: string } | { pageId: "only base"; onlyBase: string };
type Extension = { pageId: "only ext"; onlyExt: string };
type Got = AndByDiscriminatingKey<"pageId", Extension, Base>;
type Expected =
| { pageId: "a"; onlyA: string }
| { pageId: "b"; onlyB: string }
| { pageId: "only base"; onlyBase: string }
| { pageId: "only ext"; onlyExt: string };
assert<Equals<Got, Expected>>();
}

View File

@ -1,73 +0,0 @@
import { AndByDiscriminatingKey } from "../../../src/tools/AndByDiscriminatingKey";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
type Base = { pageId: "a"; onlyA: string } | { pageId: "b"; onlyB: string } | { pageId: "only base"; onlyBase: string };
type Extension = { pageId: "a"; onlyExtA: string } | { pageId: "b"; onlyExtB: string } | { pageId: "only ext"; onlyExt: string };
type Got = AndByDiscriminatingKey<"pageId", Extension, Base>;
type Expected =
| { pageId: "a"; onlyA: string; onlyExtA: string }
| { pageId: "b"; onlyB: string; onlyExtB: string }
| { pageId: "only base"; onlyBase: string }
| { pageId: "only ext"; onlyExt: string };
assert<Equals<Got, Expected>>();
const x: Got = null as any;
if (x.pageId === "a") {
x.onlyA;
x.onlyExtA;
//@ts-expect-error
x.onlyB;
//@ts-expect-error
x.onlyBase;
//@ts-expect-error
x.onlyExt;
}
if (x.pageId === "b") {
x.onlyB;
x.onlyExtB;
//@ts-expect-error
x.onlyA;
//@ts-expect-error
x.onlyBase;
//@ts-expect-error
x.onlyExt;
}
if (x.pageId === "only base") {
x.onlyBase;
//@ts-expect-error
x.onlyA;
//@ts-expect-error
x.onlyB;
//@ts-expect-error
x.onlyExt;
}
if (x.pageId === "only ext") {
x.onlyExt;
//@ts-expect-error
x.onlyA;
//@ts-expect-error
x.onlyB;
//@ts-expect-error
x.onlyBase;
}

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", "newLine": "LF",
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"incremental": true, "incremental": false,
"strict": true, "strict": true,
"downlevelIteration": true, "downlevelIteration": true,
"jsx": "react-jsx", "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