Compare commits

...

232 Commits

Author SHA1 Message Date
48501407fc Release v10 🎉 2024-08-26 04:05:40 +02:00
01cbdee2ca Release candidate 2024-08-25 19:02:33 +02:00
b70c0af0a9 Add users to provided realm configuration if none exists 2024-08-25 19:02:00 +02:00
dcaee9cb7f Release candidate 2024-08-25 03:19:58 +02:00
1d8b6c7792 Fix logical error in multivalued attributes 2024-08-25 03:19:34 +02:00
c98dbe84c6 Add missing space before the * 2024-08-25 02:54:46 +02:00
1785916d32 download and extract actually just for downloading and extracting 2024-08-24 23:15:54 +02:00
c6cf564842 Release candidate 2024-08-23 19:01:56 +02:00
380b739017 Don't pin the patch version in the docker tag 2024-08-23 19:01:37 +02:00
c3f3c55303 Release candidate 2024-08-23 18:45:56 +02:00
2c01018529 #618 2024-08-23 18:36:40 +02:00
dd2edf3013 Merge pull request #616 from keycloakify/all-contributors/add-oliviergoulet5
docs: add oliviergoulet5 as a contributor for code
2024-08-22 00:56:15 +02:00
7f3cdf9fac Release candidate 2024-08-22 00:55:39 +02:00
f75a91fbc1 docs: update .all-contributorsrc [skip ci] 2024-08-21 22:55:00 +00:00
f151086bb1 docs: update README.md [skip ci] 2024-08-21 22:54:59 +00:00
7c833e6f10 Merge pull request #615 from oliviergoulet5/fix-array-operations
Fix array comparison and improve type check
2024-08-22 00:53:48 +02:00
885e8314e8 Fix array comparison and type check 2024-08-21 17:13:06 -04:00
3bdd955ab6 Release candidate 2024-08-19 02:11:31 +02:00
9499587bad Fix formating bug of Docker command being run 2024-08-19 02:10:59 +02:00
0879ddba7c Release candidate 2024-08-19 00:25:54 +02:00
106a1dd4c7 Support parsing of the KC_HTTP_RELATIVE_PATH option 2024-08-19 00:25:41 +02:00
5580248bcd Release candidate 2024-08-19 00:00:22 +02:00
c9c10b8fba Fix issue with the port in the start-keycloak command 2024-08-19 00:00:08 +02:00
ed254922e9 Relase candidate 2024-08-18 23:46:12 +02:00
4b7d1e2cec Fix bug in docker command 2024-08-18 23:45:58 +02:00
775ae57258 Release candidate 2024-08-18 21:10:37 +02:00
96e4cd79ee Enable to configure the port via the build options 2024-08-18 21:10:18 +02:00
bb70f7df4f Release candidate 2024-08-18 20:56:34 +02:00
602de2e407 Fix bug with spaces in docker run command 2024-08-18 20:56:25 +02:00
225ced989c Release candidate 2024-08-18 19:20:57 +02:00
ab53698f34 Merge pull request #612 from keycloakify/extensions
keycloak start command options support in config
2024-08-18 19:20:31 +02:00
02f2124126 keycloak start command options support in config 2024-08-18 19:19:35 +02:00
66623e3324 Release candidate 2024-08-16 08:48:21 +02:00
4cc886fd04 Update misleading note in the readme 2024-08-16 08:48:06 +02:00
a10b490245 Release candidate 2024-08-15 22:40:34 +02:00
b947b8a00d Display name and displayNameHtml are always provided 2024-08-15 22:40:08 +02:00
60fa240a4d #611 2024-08-15 22:38:45 +02:00
e05cd87b7c Release candidate 2024-08-14 18:32:21 +02:00
8e41c905ed Add the icons to the social provider in the story 2024-08-14 18:31:55 +02:00
e21f607ab0 Merge pull request #609 from keycloakify/all-contributors/add-madmadson
docs: add madmadson as a contributor for code
2024-08-14 16:36:59 +02:00
34af5abb82 docs: update .all-contributorsrc [skip ci] 2024-08-14 14:36:45 +00:00
fc1cdb5dc9 docs: update README.md [skip ci] 2024-08-14 14:36:44 +00:00
069a0cc980 Release candidate 2024-08-14 16:34:45 +02:00
78363727e1 Add correct fetch options to octokit 2024-08-14 16:34:22 +02:00
23b16746f6 Release candidate 2024-08-14 15:48:37 +02:00
6edf9c3d15 Fix div duplication 2024-08-14 15:48:16 +02:00
2e371d2078 Fix linking script for windows 2024-08-14 07:11:16 +02:00
b70b478e25 Pin cheerio to a given version 2024-08-13 14:58:46 +02:00
97ad132086 Update to latest typescript v4 release 2024-08-13 09:31:23 +02:00
2c5c54bf46 Don't use default import for cheerio (prepare for v1) 2024-08-13 09:25:06 +02:00
c0ca078b43 Release candidate 2024-08-13 00:20:54 +02:00
53e94d04f6 Improve message related to pnpm dlx 2024-08-13 00:17:26 +02:00
dd198f9f06 Tell pepole they can explicitely provide the keycloak version 2024-08-13 00:17:26 +02:00
43f455f4d0 Provide the proxy options to oktokit 2024-08-13 00:17:26 +02:00
d9132ea5a5 Merge pull request #603 from keycloakify/debug_fetch_proxy
Debug fetch proxy
2024-08-07 19:28:04 +02:00
d5c7e2547b Release candidate 2024-08-07 19:01:15 +02:00
13b87de06c Remove debug log 2024-08-07 19:00:57 +02:00
83bdbb7a7e Release candidate 2024-08-07 16:07:25 +02:00
89320b8d51 Fix get proxy option 2024-08-07 16:07:07 +02:00
5fa9c3879c Release candidate 2024-08-07 11:48:02 +02:00
c0cd76d40e Debug log for proxy config 2024-08-07 11:46:05 +02:00
01f60f8013 Release candidate 2024-08-07 07:51:07 +02:00
91ad0712af Make defaultuser english in keycloak 25 2024-08-07 07:33:48 +02:00
2cb1b36725 Release candidate 2024-08-07 06:22:22 +02:00
67ce66765f Enable delete account in default Keycloak realm configuration 2024-08-07 06:21:59 +02:00
c8cc453942 Release candidate 2024-08-06 06:42:02 +02:00
3f835f152f #602 2024-08-06 06:41:25 +02:00
35e8a853e0 Release candidate 2024-08-02 14:09:29 +02:00
d084a4bf4a Fix bug spaces in path keycloak-start 2024-08-02 14:09:09 +02:00
2a6b79e097 Release candidate 2024-07-31 18:46:10 +02:00
5d786c922f Enable the errors to be displayed immediately and not after focus is lost 2024-07-31 18:45:48 +02:00
26bd5dd534 Release candidate 2024-07-31 11:57:38 +02:00
b4df0ce52c Set the default user locale to english 2024-07-31 11:57:13 +02:00
386a8d7cd7 Rework the storybook 2024-07-29 05:12:31 +02:00
5221fb3479 Prevent reload loop in storybook 2024-07-29 02:48:57 +02:00
2871f63f25 Mention account Single Page in the storybook 2024-07-29 00:29:36 +02:00
4c282d0559 Release candidate 2024-07-28 20:01:27 +02:00
4ac14dc074 Prevent exposing too much information in the kcContext.realm of the single page account UI 2024-07-28 20:01:11 +02:00
fcdbb04ea6 Do not make select theme type when there's only one option 2024-07-28 19:37:15 +02:00
14f283cf49 Do not enable to add story when single page account theme 2024-07-28 19:33:27 +02:00
efc459663a Adapt eject-page for Single-Page account ui 2024-07-28 19:24:00 +02:00
d459aaf943 Add hint on how to enable 2024-07-28 18:32:22 +02:00
921c7d5441 Restore the CI setup for main 2024-07-27 18:03:15 +02:00
7d7e648968 Merge pull request #538 from keycloakify/keycloak_24
Keycloakify v10 (Keycloak v24 & 25 support and much more)
2024-07-27 17:56:06 +02:00
96fc779ec8 Release candidate 2024-07-27 17:50:58 +02:00
9605e17e96 Fix generaion of entrypoint 2024-07-27 17:50:31 +02:00
111c1675f9 Release candidate 2024-07-27 01:14:03 +02:00
d547ec3126 #596 2024-07-27 01:13:47 +02:00
0ce6a7be7f #597 2024-07-27 01:07:04 +02:00
1e5eae69e9 Update README.md 2024-07-27 00:52:57 +02:00
89d9208f44 Fix storybook build 2024-07-27 00:44:59 +02:00
3e80aaf242 Fix vitest setup 2024-07-25 20:03:03 +02:00
86c3159ded Release candidate 2024-07-25 19:58:24 +02:00
230e05abc0 Fix tsconfig exclusion 2024-07-25 19:53:36 +02:00
ff2e6e6432 Fix spelling in directory structure 2024-07-25 19:53:36 +02:00
dc00be9be6 Don't run npm install when linked 2024-07-25 19:53:36 +02:00
77249d8a58 Feat: Initialize account theme (before debug) 2024-07-25 19:53:36 +02:00
b9ee0afe7f Fix bug in download and extract archive 2024-07-25 19:53:36 +02:00
db23ab0bc2 Introduce build option: accountThemeImplementation 2024-07-25 19:53:36 +02:00
13dc47533c Release candidate 2024-07-24 17:00:11 +02:00
0091a888bc #594 2024-07-24 16:59:54 +02:00
724b585004 Release candidate 2024-07-23 14:58:54 +02:00
c0d127e4f4 #593 2024-07-23 14:58:39 +02:00
1638577d98 Update sponsors section 2024-07-20 12:37:05 +02:00
951c202fd0 Merge pull request #589 from lokmeinmatz/keycloak_24
fix: Typo InputFiledByType to InputFieldByType
2024-07-17 14:30:17 +02:00
a578b86715 Release candidate 2024-07-17 13:58:55 +02:00
b6b384854e Remove tsafe usage from ejectable page 2024-07-17 13:58:39 +02:00
dac937060d fix: Typo InputFiledByType to InputFieldByType 2024-07-17 09:35:59 +02:00
c628183773 Release candidate 2024-07-14 17:55:02 +02:00
eaacaa6966 (BREAKING CHANGE) When classes are overloaded disable default paterlyfly classes 2024-07-14 17:54:44 +02:00
9a09e280c9 Release candidate 2024-07-14 17:45:54 +02:00
70ac07d861 css replace: Don't choke on parenthesis in urls 2024-07-14 17:45:34 +02:00
dabe372360 Release candidate 2024-07-14 16:58:51 +02:00
d8e3fdeb14 Always use quotes in CSS urls 2024-07-14 16:58:35 +02:00
a147084458 Release candidate 2024-07-14 08:39:36 +02:00
b25e171412 Add the CLEAR special class to remove Paterlyfly classes 2024-07-14 08:39:19 +02:00
60aaa03202 Annotate i18n nodes 2024-07-14 08:11:17 +02:00
3392ab8385 Release candidate 2024-07-13 19:34:23 +02:00
f172b94467 Use uppercase for constants 2024-07-13 19:33:59 +02:00
ca549fe8d8 There's no need to decodeHtmlEntities on everything 2024-07-13 19:02:13 +02:00
0b6f56a774 Naming convention consistency 2024-07-13 18:42:27 +02:00
e5bcff12cb Exclude ream.attributes since it's never used 2024-07-13 18:29:30 +02:00
2754900f7a Refactor of the FreeMarker template 2024-07-13 18:17:21 +02:00
54f43d3331 Remove debug message 2024-07-13 11:02:12 +02:00
4900200c06 Release candidate 2024-07-13 09:27:10 +02:00
6d82a74db4 Improve incremental build time 2024-07-13 09:26:48 +02:00
2d7f21b021 Release candidate 2024-07-13 09:07:36 +02:00
4292c0c642 Rework i18n 2024-07-13 09:07:11 +02:00
9dca515a42 Add group annotations to Attribute 2024-07-13 08:00:16 +02:00
b577cd9829 Update storybook story SelectAuthenticator.stories.ts 2024-07-11 18:23:08 +02:00
704682cbbe Release candidate 2024-07-11 17:58:41 +02:00
858f0d77c0 #585 2024-07-11 17:58:26 +02:00
31ef6063f2 Add missing font and optimize keycloak theme resources extraction 2024-07-11 17:49:58 +02:00
f3bd81c55b Release candidate 2024-07-10 22:18:46 +02:00
24bb4902c2 Includes in the kcContext missing realm defined translations #582 2024-07-10 22:18:24 +02:00
ca7821cfad Remove unused import 2024-07-10 01:26:55 +02:00
a73b25580e Release candidate 2024-07-09 15:00:21 +02:00
82e179730e Fix error related to npm config get 2024-07-09 14:59:59 +02:00
b5d5002061 Mock kcContext.url.resourcePath for account v3 2024-07-09 14:04:25 +02:00
2ab2c9e05e Release candidate 2024-07-08 15:21:23 +02:00
b1e9ba3ac6 Fix buil when paths with spaces 2024-07-08 15:21:03 +02:00
5822ed0185 Relase candidate 2024-07-08 14:54:37 +02:00
17b295788d Generate i18n messages for account v3 2024-07-08 14:54:09 +02:00
6cd5b958c7 Fix typo 2024-07-08 00:21:40 +02:00
df92cc5f73 Fix 2024-07-08 00:21:31 +02:00
03106cdee3 Release candidate 2024-07-07 18:45:38 +02:00
c4638daf1b Support building account v3 2024-07-07 18:45:14 +02:00
e2f5eb79ad Release candidate 2024-07-05 19:55:14 +02:00
b6c8e9bca0 Remove debug console log 2024-07-05 19:55:02 +02:00
573839019e Release candidate 2024-07-04 20:00:03 +02:00
815bf10ae0 Add line break 2024-07-04 19:59:28 +02:00
7c257d97a7 #577 2024-07-04 19:53:57 +02:00
59f8814660 Add missing patternfly image 2024-07-04 19:26:48 +02:00
1a6993099f Release candidate 2024-07-01 19:15:01 +02:00
f62ded3c8e Fix saml-post-form.ftl storybook 2024-07-01 19:15:01 +02:00
4eca6366cc Merge branch 'main' into keycloak_24 2024-06-30 07:13:20 +00:00
51a45b355d Remove script only used in CI 2024-06-29 19:41:36 +02:00
e5765cb902 Release candidate 2024-06-29 19:38:57 +02:00
6e922d2033 Merge pull request #575 from keycloakify/fix/keycloak_24-added-applications-story
Added & Fixed Applications Page Under Account
2024-06-29 17:36:55 +00:00
5d1695ada8 fix: added in applications.ftl story and fixed issue with double comma when realmRolesAvailable and resourceRolesAvailable both present 2024-06-28 16:36:56 -05:00
6e3ce29067 Relase candidate 2024-06-28 19:03:37 +02:00
2b9bbc4cef Ensure pnpm dlx isn't used 2024-06-28 19:03:19 +02:00
9557145f72 Bump version 2024-06-28 19:01:13 +02:00
249877b9c5 Better wording for assertNoPnpmDlx 2024-06-28 19:01:00 +02:00
ff2321fde5 Bump version 2024-06-28 18:51:06 +02:00
1edd6e4193 No pnpm dlx 2024-06-28 18:50:45 +02:00
c7d47f128e Release candidate 2024-06-28 07:16:39 +02:00
14cb07efb2 Make terms acceptance a required field on the Register page 2024-06-28 07:16:17 +02:00
a51724208c Release candidate 2024-06-28 06:47:02 +02:00
050e2b2b99 Improve Register page default stories 2024-06-28 06:46:26 +02:00
3706f15f7e Fix bug resolving user profile translations 2024-06-28 06:46:12 +02:00
bdde9162d9 Merge pull request #572 from keycloakify/fix/readme-update-discord
Fix/readme update discord
2024-06-25 16:38:31 -05:00
99b4933536 Merge branch 'main' into fix/readme-update-discord 2024-06-25 16:32:59 -05:00
c5caf7e0da fix: fix for previous discord readme addition, forgot the <a> tag 2024-06-25 16:32:21 -05:00
bcc5308cfb Merge pull request #571 from keycloakify/fix/readme-update-discord
Modified Readme.md to have a more visible discord invitation link
2024-06-25 21:29:50 +00:00
9fb902db5c fix: modified the readme using a slightly more visible discord invitation link 2024-06-25 16:27:22 -05:00
7461e38034 Release candidate 2024-06-25 22:51:21 +02:00
dccd85a151 Fix readExtraPage 2024-06-25 22:51:07 +02:00
910604fdad Use vite template by default 2024-06-25 22:50:51 +02:00
508cb9158e Release candidate 2024-06-24 03:58:55 +02:00
915c500d32 Feedback when running keycloakfy build 2024-06-24 03:58:42 +02:00
60bd6621c8 Release candidate 2024-06-24 02:43:03 +02:00
b5f6262763 Support cd in running build script in webpack 2024-06-24 02:42:44 +02:00
2b8c4422de Release candidate 2024-06-23 22:48:01 +02:00
a686432c65 Shell: true for windows 2024-06-23 22:47:45 +02:00
449e625877 Fix storybook build 2024-06-23 22:39:29 +02:00
1ac07dafde Release candidate 2024-06-23 21:23:44 +02:00
3878e28b56 Improve monorepo project support, work if there only a package.json at the root (like NX) 2024-06-23 21:23:06 +02:00
cf6bc8666b Include fsevents.node in npm bundle 2024-06-23 21:10:11 +02:00
f76063eb40 Make it easier to link to another starter 2024-06-23 20:54:08 +02:00
ed52c5824d Give immediate feedback if projectDirPath is wrong 2024-06-23 16:56:24 +02:00
9333400322 Remove unused buildContext prop 2024-06-23 02:07:34 +02:00
3689cfcc0d Consistency 2024-06-23 02:06:45 +02:00
b73eceb535 Release candidate 2024-06-23 00:46:01 +02:00
5dc3453fc9 Enable user profile in default keycloak 23 configuration 2024-06-23 00:45:26 +02:00
cef1139a4b Release candidate 2024-06-23 00:37:26 +02:00
ac96959947 Add missing fieldNames from synthetic user attributes 2024-06-23 00:37:06 +02:00
4d73d877ba move used defined exclusions down 2024-06-23 00:18:03 +02:00
9f1186302e Release candidate 2024-06-22 20:12:22 +02:00
319dcc0d15 Stable i18n messages across Keycloak versions 2024-06-22 20:12:02 +02:00
e99fdb8561 Log what file have changed when linking dynamically in starter 2024-06-22 20:11:34 +02:00
f37a342a63 Release candidate 2024-06-22 17:18:52 +02:00
09a039894d Remove React as peer dpendency so that Keycloakify can be more easily used in Vue and Angular projects 2024-06-22 17:18:08 +02:00
3efbb1a9fd Release candidate 2024-06-22 17:05:37 +02:00
920ee62ee3 Implement fallback to english for messages bundle provided via Keycloakify 2024-06-22 17:05:14 +02:00
1ace44fe31 Rename extraMessages -> messageBundle 2024-06-22 17:03:59 +02:00
a60f05415b Export fallback language tag ("en") as a constant 2024-06-22 17:03:44 +02:00
42c9d39e02 Release candidate 2024-06-22 17:01:48 +02:00
a8186f1ed9 Don't use tsafe directly in ejectable components 2024-06-22 17:01:45 +02:00
c2ff515a17 Enable termsText to be extended via local message bundle 2024-06-22 14:09:11 +02:00
960c3ba558 Release candidate 2024-06-22 02:53:51 +02:00
454a9cd01c Remove useDownloadTerms see: https://docs.keycloakify.dev/terms-and-conditions, remove react-markdown 2024-06-22 02:53:30 +02:00
7d42ce1c87 Release candidate 2024-06-21 22:07:50 +02:00
57f6f980cf Update terms storybook 2024-06-21 22:07:36 +02:00
8cba3aae2c Release candidate 2024-06-21 21:25:41 +02:00
01b32f78ed Allow to override termsText 2024-06-21 21:24:04 +02:00
b6066dfd5f Release candidate 2024-06-21 20:28:32 +02:00
3ad554ed59 #569 2024-06-21 20:28:14 +02:00
6aacc6361b Release candidate 2024-06-21 02:13:48 +02:00
638e4e6410 Set the terms to empty string when building 2024-06-21 02:13:31 +02:00
aa9b7cccc7 Rework Terms 2024-06-21 02:01:55 +02:00
41739c8528 Bump version 2024-06-20 04:28:33 +02:00
89b32dc7fc Fix wrong code snippet 2024-06-20 04:28:12 +02:00
44aec23251 Release candidate 2024-06-19 22:41:42 +02:00
12fd6160c5 Fix inline CSS in html 2024-06-19 22:41:25 +02:00
8819abc418 Release candidate 2024-06-19 03:56:13 +02:00
96b627095c https://github.com/adbayb/termost/pull/31 2024-06-19 03:52:57 +02:00
239f98aa9c fmt 2024-06-19 01:49:13 +00:00
f5d0511662 Update README.md 2024-06-19 03:36:00 +02:00
75582d2a26 Update README.md 2024-06-19 03:33:44 +02:00
dba004f924 Release candidate 2024-06-19 01:41:45 +02:00
5423a07c47 Patch CSS for Keycloak by using relative paths instead of css variables 2024-06-19 01:41:22 +02:00
145 changed files with 5102 additions and 4742 deletions

View File

@ -231,6 +231,24 @@
"contributions": [
"code"
]
},
{
"login": "madmadson",
"name": "Tobias Matt",
"avatar_url": "https://avatars.githubusercontent.com/u/798831?v=4",
"profile": "https://github.com/madmadson",
"contributions": [
"code"
]
},
{
"login": "oliviergoulet5",
"name": "Olivier Goulet",
"avatar_url": "https://avatars.githubusercontent.com/u/17685861?v=4",
"profile": "https://github.com/oliviergoulet5",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

View File

@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v4
- uses: bahmutov/npm-install@v1
- name: If this step fails run 'npm run format' then commit again.
run: npm run format:check
run: npm run _format --list-different
test:
runs-on: ${{ matrix.os }}
needs: test_lint
@ -37,7 +37,7 @@ jobs:
storybook:
runs-on: ubuntu-latest
#if: github.event_name == 'push'
if: github.event_name == 'push'
needs: test
steps:
- uses: actions/checkout@v4

4
.gitignore vendored
View File

@ -48,8 +48,8 @@ jspm_packages
.idea
/src/login/i18n/baseMessages/
/src/account/i18n/baseMessages/
/src/login/i18n/messages_defaultSet/
/src/account/i18n/messages_defaultSet/
# VS Code devcontainers
.devcontainer

View File

@ -6,8 +6,8 @@ node_modules/
/src/tools/types/
/build_keycloak/
/.vscode/
/src/login/i18n/baseMessages/
/src/account/i18n/baseMessages/
/src/login/i18n/messages_defaultSet/
/src/account/i18n/messages_defaultSet/
/dist_test
/sample_react_project/
/sample_custom_react_project/

View File

@ -1,70 +0,0 @@
import React from "react";
import { DocsContainer as BaseContainer } from "@storybook/addon-docs";
import { useDarkMode } from "storybook-dark-mode";
import { darkTheme, lightTheme } from "./customTheme";
import "./static/fonts/WorkSans/font.css";
export function DocsContainer({ children, context }) {
const isStorybookUiDark = useDarkMode();
const theme = isStorybookUiDark ? darkTheme : lightTheme;
const backgroundColor = theme.appBg;
return (
<>
<style>{`
body {
padding: 0 !important;
background-color: ${backgroundColor};
}
.docs-story {
background-color: ${backgroundColor};
}
[id^=story--] .container {
border: 1px dashed #e8e8e8;
}
.docblock-argstable-head th:nth-child(3), .docblock-argstable-body tr > td:nth-child(3) {
visibility: collapse;
}
.docblock-argstable-head th:nth-child(3), .docblock-argstable-body tr > td:nth-child(2) p {
font-size: 13px;
}
`}</style>
<BaseContainer
context={{
...context,
"storyById": id => {
const storyContext = context.storyById(id);
return {
...storyContext,
"parameters": {
...storyContext?.parameters,
"docs": {
...storyContext?.parameters?.docs,
"theme": isStorybookUiDark ? darkTheme : lightTheme
}
}
};
}
}}
>
{children}
</BaseContainer>
</>
);
}
export function CanvasContainer({ children }) {
return (
<>
{children}
</>
);
}

View File

@ -1,35 +0,0 @@
import { create } from "@storybook/theming";
const brandImage = "logo.png";
const brandTitle = "Keycloakify";
const brandUrl = "https://github.com/keycloakify/keycloakify";
const fontBase = '"Work Sans", sans-serif';
const fontCode = "monospace";
export const darkTheme = create({
"base": "dark",
"appBg": "#1E1E1E",
"appContentBg": "#161616",
"barBg": "#161616",
"colorSecondary": "#8585F6",
"textColor": "#FFFFFF",
brandImage,
brandTitle,
brandUrl,
fontBase,
fontCode
});
export const lightTheme = create({
"base": "light",
"appBg": "#F6F6F6",
"appContentBg": "#FFFFFF",
"barBg": "#FFFFFF",
"colorSecondary": "#000091",
"textColor": "#212121",
brandImage,
brandTitle,
brandUrl,
fontBase,
fontCode
});

33
.storybook/customTheme.ts Normal file
View File

@ -0,0 +1,33 @@
const brandImage = "logo.png";
const brandTitle = "Keycloakify";
const brandUrl = "https://github.com/keycloakify/keycloakify";
const fontBase = '"Work Sans", sans-serif';
const fontCode = "monospace";
export const darkTheme = {
base: "dark",
appBg: "#1E1E1E",
appContentBg: "#161616",
barBg: "#161616",
colorSecondary: "#8585F6",
textColor: "#FFFFFF",
brandImage,
brandTitle,
brandUrl,
fontBase,
fontCode
};
export const lightTheme: typeof darkTheme = {
base: "light",
appBg: "#F6F6F6",
appContentBg: "#FFFFFF",
barBg: "#FFFFFF",
colorSecondary: "#000091",
textColor: "#212121",
brandImage,
brandTitle,
brandUrl,
fontBase,
fontCode
};

View File

@ -1,15 +1,13 @@
module.exports = {
"stories": [
"../stories/**/*.stories.@(ts|tsx|mdx)"
stories: [
"../stories/**/*.stories.tsx"
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials",
addons: [
"storybook-dark-mode",
"@storybook/addon-a11y"
],
"core": {
"builder": "webpack5"
core: {
builder: "webpack5"
},
"staticDirs": ["./static"]
staticDirs: ["./static"]
};

View File

@ -1,6 +1,6 @@
import { addons } from '@storybook/addons';
addons.setConfig({
"selectedPanel": 'storybook/a11y/panel',
"showPanel": false,
selectedPanel: 'storybook/a11y/panel',
showPanel: false
});

View File

@ -1,3 +1,9 @@
<link rel="preload" href="/fonts/WorkSans/worksans-bold-webfont.woff2" as="font" crossorigin="anonymous">
<link rel="preload" href="/fonts/WorkSans/worksans-medium-webfont.woff2" as="font" crossorigin="anonymous">
<link rel="preload" href="/fonts/WorkSans/worksans-regular-webfont.woff2" as="font" crossorigin="anonymous">
<link rel="preload" href="/fonts/WorkSans/worksans-semibold-webfont.woff2" as="font" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="/fonts/WorkSans/font.css">
<style>
body.sb-show-main.sb-main-padded {
padding: 0;

View File

@ -1,116 +1,105 @@
import { darkTheme, lightTheme } from "./customTheme";
import { DocsContainer, CanvasContainer } from "./Containers";
import { create as createTheme } from "@storybook/theming";
export const parameters = {
"actions": { "argTypesRegex": "^on[A-Z].*" },
"controls": {
"matchers": {
"color": /(background|color)$/i,
"date": /Date$/,
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
"backgrounds": { "disable": true },
"darkMode": {
"light": lightTheme,
"dark": darkTheme,
backgrounds: { disable: true },
darkMode: {
light: createTheme(lightTheme),
dark: createTheme(darkTheme),
},
"docs": {
"container": DocsContainer
controls: {
disable: true,
},
"controls": {
"disable": true,
actions: {
disable: true
},
"actions": {
"disable": true
},
"viewport": {
"viewports": {
viewport: {
viewports: {
"1440p": {
"name": "1440p",
"styles": {
"width": "2560px",
"height": "1440px",
name: "1440p",
styles: {
width: "2560px",
height: "1440px",
},
},
"fullHD": {
"name": "Full HD",
"styles": {
"width": "1920px",
"height": "1080px",
fullHD: {
name: "Full HD",
styles: {
width: "1920px",
height: "1080px",
},
},
"macBookProBig": {
"name": "MacBook Pro Big",
"styles": {
"width": "1024px",
"height": "640px",
macBookProBig: {
name: "MacBook Pro Big",
styles: {
width: "1024px",
height: "640px",
},
},
"macBookProMedium": {
"name": "MacBook Pro Medium",
"styles": {
"width": "1440px",
"height": "900px",
macBookProMedium: {
name: "MacBook Pro Medium",
styles: {
width: "1440px",
height: "900px",
},
},
"macBookProSmall": {
"name": "MacBook Pro Small",
"styles": {
"width": "1680px",
"height": "1050px",
macBookProSmall: {
name: "MacBook Pro Small",
styles: {
width: "1680px",
height: "1050px",
},
},
"pcAgent": {
"name": "PC Agent",
"styles": {
"width": "960px",
"height": "540px",
pcAgent: {
name: "PC Agent",
styles: {
width: "960px",
height: "540px",
},
},
"iphone12Pro": {
"name": "Iphone 12 pro",
"styles": {
"width": "390px",
"height": "844px",
iphone12Pro: {
name: "Iphone 12 pro",
styles: {
width: "390px",
height: "844px",
},
},
"iphone5se": {
"name": "Iphone 5/SE",
"styles": {
"width": "320px",
"height": "568px",
iphone5se: {
name: "Iphone 5/SE",
styles: {
width: "320px",
height: "568px",
},
},
"ipadPro": {
"name": "Ipad pro",
"styles": {
"width": "1240px",
"height": "1366px",
ipadPro: {
name: "Ipad pro",
styles: {
width: "1240px",
height: "1366px",
},
},
"Galaxy s9+": {
"name": "Galaxy S9+",
"styles": {
"width": "320px",
"height": "658px",
name: "Galaxy S9+",
styles: {
width: "320px",
height: "658px",
},
}
},
},
"options": {
"storySort": (a, b) =>
options: {
storySort: (a, b) =>
getHardCodedWeight(b[1].kind) - getHardCodedWeight(a[1].kind),
},
};
export const decorators = [
(Story) => (
<CanvasContainer>
<Story />
</CanvasContainer>
),
];
const { getHardCodedWeight } = (() => {
const orderedPagesPrefix = [

282
README.md
View File

@ -2,7 +2,7 @@
<img src="https://user-images.githubusercontent.com/6702424/109387840-eba11f80-7903-11eb-9050-db1dad883f78.png">
</p>
<p align="center">
<i>🔏 Create Keycloak themes using React 🔏</i>
<i>🔏 Keycloak Theming for the Modern Web 🔏</i>
<br>
<br>
<a href="https://github.com/garronej/keycloakify/actions">
@ -17,9 +17,12 @@
<a href="https://github.com/thomasdarimont/awesome-keycloak">
<img src="https://awesome.re/mentioned-badge.svg"/>
</a>
<a href="https://discord.gg/kYFZG7fQmn">
<img src="https://img.shields.io/discord/1097708346976505977"/>
</a>
<p align="center">
Check out our discord server!<br/>
<a href="https://discord.gg/mJdYJSdcm4">
<img src="https://dcbadge.limes.pink/api/server/kYFZG7fQmn"/>
</a>
</p>
<p align="center">
<a href="https://www.keycloakify.dev">Home</a>
-
@ -35,23 +38,36 @@
<i>This build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
<br/>
<br/>
<img width="400" src="https://github.com/keycloakify/keycloakify/assets/6702424/e66d105c-c06f-47d1-8a31-a6ab09da4e80">
<img width="400" src="https://github.com/user-attachments/assets/6bf3bef9-00b0-4460-97b9-0d2da8500798">
</p>
Keycloakify is fully compatible with Keycloak 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, [~~22~~](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1822509763), **23** [and up](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)!
Keycloakify is fully compatible with Keycloak 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, [~~22~~](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1822509763), 23, 24, 25...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
> NOTE: Keycloak 24 introduces [important changes](https://www.keycloak.org/docs/latest/upgrading/index.html#changes-to-freemarker-templates-to-render-pages-based-on-the-user-profile-and-realm).
> We're actively working on incorporating them into Keycloakify. [Follow progress](https://github.com/keycloakify/keycloakify/pull/538).
## Sponsors
## Sponsor
Friends for the project, we trust and recommend their services.
We are exclusively sponsored by [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), a French company offering Keycloak as a service.
Their dedicated support helps us continue the development and maintenance of this project.
<br/>
[Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github) provides the following services:
<div align="center">
- Simplify and secure your Keycloak Identity and Access Management. Keycloak as a Service.
- Custom theme building for your brand using Keycloakify.
![Logo Dark](https://github.com/user-attachments/assets/088f6631-b7ef-42ad-812b-df4870dc16ae#gh-dark-mode-only)
</div>
<div align="center">
![Logo Light](https://github.com/user-attachments/assets/53fb16f8-02ef-4523-9c36-b42d6e59837e#gh-light-mode-only)
</div>
<br/>
<p align="center">
<a href="https://www.zone2.tech/services/keycloak-consulting">
<i><strong>Keycloak Consulting Services</strong> - Your partner in Keycloak deployment, configuration, and extension development for optimized identity management solutions.</i>
</a>
</p>
<div align="center">
@ -66,13 +82,11 @@ Their dedicated support helps us continue the development and maintenance of thi
</div>
<p align="center">
<i>Checkout <a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github">Cloud-IAM</a> and use promo code <code>keycloakify5</code></i>
<br/>
<i>5% of your annual subscription will be donated to us, and you'll get 5% off too.</i>
<a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github"><strong>Managed Keycloak Provider</strong> - With Cloud-IAM powering your Keycloak clusters, you can sleep easy knowing you've got the software and the experts you need for operational excellence. </a>
<br/>
Use code <code>keycloakify5</code> at checkout for a 5% discount.
</p>
Thank you, [Cloud-IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), for your support!
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
@ -114,7 +128,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://m-siemens.de/"><img src="https://avatars.githubusercontent.com/u/1873922?v=4?s=100" width="100px;" alt="Markus Siemens"/><br /><sub><b>Markus Siemens</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=msiemens" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/law108000"><img src="https://avatars.githubusercontent.com/u/8112024?v=4?s=100" width="100px;" alt="Rlok"/><br /><sub><b>Rlok</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=law108000" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Moulyy"><img src="https://avatars.githubusercontent.com/u/115405804?v=4?s=100" width="100px;" alt="Moulyy"/><br /><sub><b>Moulyy</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=Moulyy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/giorgoslytos"><img src="https://avatars.githubusercontent.com/u/50946162?v=4?s=100" width="100px;" alt="giorgoslytos"/><br /><sub><b>giorgoslytos</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=giorgoslytos" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/madmadson"><img src="https://avatars.githubusercontent.com/u/798831?v=4?s=100" width="100px;" alt="Tobias Matt"/><br /><sub><b>Tobias Matt</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=madmadson" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/oliviergoulet5"><img src="https://avatars.githubusercontent.com/u/17685861?v=4?s=100" width="100px;" alt="Olivier Goulet"/><br /><sub><b>Olivier Goulet</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=oliviergoulet5" title="Code">💻</a></td>
</tr>
</tbody>
</table>
@ -123,230 +138,3 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
# Changelog highlights
## 9.5
- Post build hook: You can now apply custom transformation to your theme files. [Learn more](https://docs.keycloakify.dev/build-options#postbuild-hook).
- You can now specify your option in the Keycloakify's Vite plugin instead in the package.json. [See example](https://docs.keycloakify.dev/build-options#themename).
## 9.4
**Vite Support! 🎉**
- [The starter is now a Vite project](https://github.com/keycloakify/keycloakify-starter).
The Webpack based starter is accessible [here](https://github.com/keycloakify/keycloakify-starter-cra).
- CRA (Webpack) remains supported for the forseable future.
- If you have a CRA Keycloakify theme that you wish to migrate to Vite checkout [this migration guide](https://docs.keycloakify.dev/migration-guides/cra-greater-than-vite).
## 9.0
Bring back support for account themes in Keycloak v23 and up! [See issue](https://github.com/keycloakify/keycloakify/issues/389).
### Breaking changes
Very few. Check them out [here](https://docs.keycloakify.dev/migration-guides/v8-greater-than-v9).
## 8.0
- Much smaller .jar size. 70.2 MB -> 7.8 MB.
Keycloakify now detects which of the static resources from the default theme are actually used by your theme and only include those in the .jar.
- Build time: The first build is slowed but the subsequent build are faster. [Update your CI so that the cache is persisted across CI build](https://github.com/keycloakify/keycloakify-starter/commit/bc378d5afb67e796f520afbc348185f3e319d9d0).
### Breaking changes
There are very few breaking changes in this major version. [Check them out](https://docs.keycloakify.dev/migration-guides/v7-greater-than-v8).
## 7.15
- The i18n messages you defines in your theme are now also maid available to Keycloak.
In practice this mean that you can now customize the `kcContext.message.summary` that
display a general alert and the values returned by `kcContext.messagesPerField.get()` that
are used to display specific error on some field of the form.
[See video](https://youtu.be/D6tZcemReTI)
## 7.14
- Deprecate the `extraPages` build option. Keycloakify is now able to analyze your code to detect extra pages.
## 7.13
- Deprecate `customUserAttribute`, Keycloakify now analyze your code to predict field name usage. [See doc](https://docs.keycloakify.dev/build-options#customuserattributes).
It's now mandatory to [adopt the new directory structure](https://docs.keycloakify.dev/migration-guides/v6-greater-than-v7).
## 7.12
- You can now pack multiple themes variant in a single `.jar` bundle. In vanilla Keycloak themes you have the ability to extend a base theme.
There is now an idiomatic way of achieving the same result. [Learn more](https://docs.keycloakify.dev/build-options#keycloakify.themeVariantNames).
## 7.9
- Separate script for copying the default theme static assets to the public directory.
Theses assets are only needed for testing your theme locally in Storybook or with a `mockPageId`.
You are now expected to have a `"prepare": "copy-keycloak-resources-to-public",` in your package.json scripts.
This script will create `public/keycloak-assets` when you run `yarn install` (If you are using another package manager
like `pnpm` makes sure that `"prepare"` is actually ran.)
[See the updated starter](https://github.com/keycloakify/keycloakify-starter/blob/94532fcf10bf8b19e0873be8575fd28a8958a806/package.json#L11). `public/keycloak-assets` shouldn't be tracked by GIT and is automatically ignored.
## 7.7
- Better storybook support, see [the starter project](https://github.com/keycloakify/keycloakify-starter).
## 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
- Build work behind corporate proxies, [see issue](https://github.com/keycloakify/keycloakify/issues/257).
## 6.12
Massive improvement in the developer experience:
- There is now only one starter repo: https://github.com/codegouvfr/keycloakify-starter
- A lot of comments have been added in the code of the starter to make it easier to get started.
- The doc has been updated: https://docs.keycloakify.dev
- A lot of improvements in the type system.
## 6.11.4
- You no longer need to have Maven installed to build the theme. Thanks to @lordvlad, [see PR](https://github.com/keycloakify/keycloakify/pull/239).
- Feature new build options: [`bundler`](https://docs.keycloakify.dev/build-options#keycloakify.bundler), [`groupId`](https://docs.keycloakify.dev/build-options#keycloakify.groupid), [`artifactId`](https://docs.keycloakify.dev/build-options#keycloakify.artifactid), [`version`](https://docs.keycloakify.dev/build-options#version).
Theses options can be user to customize the output name of the .jar. You can use environnement variables to overrides the values read in the package.json. Thanks to @lordvlad.
## 6.10.0
- Widows compat (thanks to @lordvlad, [see PR](https://github.com/keycloakify/keycloakify/pull/226)). WSL is no longer required 🎉
## 6.8.4
- `@emotion/react` is no longer a peer dependency of Keycloakify.
## 6.8.0
- It is now possible to pass a custom `<Template />` component as a prop to `<KcApp />` and every
individual page (`<Login />`, `<RegisterUserProfile />`, ...) it enables to customize only the header and footer for
example without having to switch to a full-component level customization. [See issue](https://github.com/keycloakify/keycloakify/issues/191).
## 6.7.0
- Add support for `webauthn-authenticate.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/185).
## 6.6.0
- Add support for `login-password.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/184).
## 6.5.0
- Add support for `login-username.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/183).
## 6.4.0
- You can now optionally pass a `doFetchDefaultThemeResources: boolean` prop to every page component and the default `<KcApp />`
This enables you to prevent the default CSS and JS that comes with the builtin Keycloak theme to be downloaded.
You'll get [a black slate](https://user-images.githubusercontent.com/6702424/192619083-4baa5df4-4a21-4ec7-8e28-d200d1208299.png).
## 6.0.0
- Bundle size drastically reduced, locals and component dynamically loaded.
- First print much quicker, use of React.lazy() everywhere.
- Real i18n API.
- Actual documentation for build options.
Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6)
## 5.8.0
- [React.lazy()](https://reactjs.org/docs/code-splitting.html#reactlazy) support 🎉. [#141](https://github.com/keycloakify/keycloakify/issues/141)
## 5.7.0
- Feat `logout-confirm.ftl`. [PR](https://github.com/keycloakify/keycloakify/pull/120)
## 5.6.4
Fix `login-verify-email.ftl` page. [Before](https://user-images.githubusercontent.com/6702424/177436014-0bad22c4-5bfb-45bb-8fc9-dad65143cd0c.png) - [After](https://user-images.githubusercontent.com/6702424/177435797-ec5d7db3-84cf-49cb-8efc-3427a81f744e.png)
## 5.6.0
Add support for `login-config-totp.ftl` page [#127](https://github.com/keycloakify/keycloakify/pull/127).
## 5.3.0
Rename `keycloak_theme_email` to `keycloak_email`.
If you already had a `keycloak_theme_email` you should rename it `keycloak_email`.
## 5.0.0
[Migration guide](https://github.com/garronej/keycloakify-demo-app/blob/a5b6a50f24bc25e082931f5ad9ebf47492acd12a/src/index.tsx#L46-L63)
New i18n system.
Import of terms and services have changed. [See example](https://github.com/garronej/keycloakify-demo-app/blob/a5b6a50f24bc25e082931f5ad9ebf47492acd12a/src/index.tsx#L46-L63).
## 4.10.0
Add `login-idp-link-email.ftl` page [See PR](https://github.com/keycloakify/keycloakify/pull/92).
## 4.8.0
[Email template customization.](#email-template-customization)
## 4.7.4
**M1 Mac** support (for testing locally with a dockerized Keycloak).
## 4.7.2
> WARNING: This is broken.
> 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.
## 4.7.0
Register with user profile enabled: Out of the box `options` validator support.
[Example](https://user-images.githubusercontent.com/6702424/158911163-81e6bbe8-feb0-4dc8-abff-de199d7a678e.mov)
## 4.6.0
`tss-react` and `powerhooks` are no longer peer dependencies of `keycloakify`.
After updating Keycloakify you can remove `tss-react` and `powerhooks` from your dependencies if you don't use them explicitly.
## 4.5.3
There is a new recommended way to setup highly customized theme. See [here](https://github.com/garronej/keycloakify-demo-app/blob/look_and_feel/src/KcApp/KcApp.tsx).
Unlike with [the previous recommended method](https://github.com/garronej/keycloakify-demo-app/blob/a51660578bea15fb3e506b8a2b78e1056c6d68bb/src/KcApp/KcApp.tsx),
with this new method your theme wont break on minor Keycloakify update.
## 4.3.0
Feature [`login-update-password.ftl`](https://user-images.githubusercontent.com/6702424/147517600-6191cf72-93dd-437b-a35c-47180142063e.png).
Every time a page is added it's a breaking change for non CSS-only theme.
Change [this](https://github.com/garronej/keycloakify-demo-app/blob/df664c13c77ce3c53ac7df0622d94d04e76d3f9f/src/KcApp/KcApp.tsx#L17) and [this](https://github.com/garronej/keycloakify-demo-app/blob/df664c13c77ce3c53ac7df0622d94d04e76d3f9f/src/KcApp/KcApp.tsx#L37) to update.
## 4
- Out of the box [frontend form validation](#user-profile-and-frontend-form-validation) 🥳
- Improvements (and breaking changes in `import { useKcMessage } from "keycloakify"`.
## 3
No breaking changes except that `@emotion/react`, [`tss-react`](https://www.npmjs.com/package/tss-react) and [`powerhooks`](https://www.npmjs.com/package/powerhooks) are now `peerDependencies` instead of being just dependencies.
It's important to avoid problem when using `keycloakify` alongside [`mui`](https://mui.com) and
[when passing params from the app to the login page](https://github.com/keycloakify/keycloakify#implement-context-persistence-optional).
## 2.5
- Feature [Use advanced message](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/i18n/useKcMessage.tsx#L53-L66)
and [`messagesPerFields`](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/getKcContext/KcContextBase.ts#L70-L75) (implementation [here](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/bin/build-keycloak-theme/generateFtl/common.ftl#L130-L189))
- Test container now uses Keycloak version `15.0.2`.
## 2
- It's now possible to implement custom `.ftl` pages.
- Support for Keycloak plugins that introduce non standard ftl values.
(Like for example [this plugin](https://github.com/micedre/keycloak-mail-whitelisting) that define `authorizedMailDomains` in `register.ftl`).

View File

@ -1,13 +1,13 @@
{
"name": "keycloakify",
"version": "10.0.0-rc.73",
"version": "10.0.0",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
"url": "git://github.com/keycloakify/keycloakify.git"
},
"scripts": {
"prepare": "patch-package && tsx scripts/generate-i18n-messages.ts",
"prepare": "tsx scripts/generate-i18n-messages.ts",
"build": "tsx scripts/build.ts",
"storybook": "tsx scripts/start-storybook.ts",
"link-in-starter": "tsx scripts/link-in-starter.ts",
@ -15,7 +15,6 @@
"test:types": "tsc -p test/tsconfig.json --noEmit",
"_format": "prettier '**/*.{ts,tsx,json,md}'",
"format": "yarn _format --write",
"format:check": "yarn _format --list-different",
"link-in-app": "tsx scripts/link-in-app.ts",
"build-storybook": "tsx scripts/build-storybook.ts",
"dump-keycloak-realm": "tsx scripts/dump-keycloak-realm.ts"
@ -41,14 +40,14 @@
"!dist/bin/",
"dist/bin/main.js",
"dist/bin/*.index.js",
"!dist/bin/shared/*.js",
"dist/bin/*.node",
"dist/bin/shared/constants.js",
"dist/bin/shared/*.d.ts",
"dist/bin/shared/*.js.map",
"!dist/vite-plugin/",
"dist/vite-plugin/index.js",
"dist/vite-plugin/index.d.ts",
"dist/vite-plugin/vite-plugin.d.ts",
"dist/vite-plugin/index.js"
"dist/vite-plugin/vite-plugin.d.ts"
],
"keywords": [
"keycloak",
@ -62,11 +61,7 @@
"bluehats"
],
"homepage": "https://www.keycloakify.dev",
"peerDependencies": {
"react": "*"
},
"dependencies": {
"react-markdown": "^5.0.3",
"tsafe": "^1.6.6"
},
"devDependencies": {
@ -77,14 +72,10 @@
"@emotion/react": "^11.11.4",
"@octokit/rest": "^20.1.1",
"@storybook/addon-a11y": "^6.5.16",
"@storybook/addon-actions": "^6.5.13",
"@storybook/addon-essentials": "^6.5.13",
"@storybook/addon-interactions": "^6.5.13",
"@storybook/addon-links": "^6.5.13",
"@storybook/builder-webpack5": "^6.5.13",
"@storybook/manager-webpack5": "^6.5.13",
"@storybook/react": "^6.5.13",
"@storybook/testing-library": "^0.0.13",
"eslint-plugin-storybook": "^0.6.7",
"@types/babel__generator": "^7.6.4",
"@types/make-fetch-happen": "^10.0.1",
"@types/minimist": "^1.2.2",
@ -94,15 +85,13 @@
"@types/yauzl": "^2.10.3",
"@vercel/ncc": "^0.38.1",
"chalk": "^4.1.2",
"cheerio": "^1.0.0-rc.12",
"cheerio": "1.0.0-rc.12",
"chokidar-cli": "^3.0.0",
"cli-select": "^1.1.2",
"eslint-plugin-storybook": "^0.6.7",
"husky": "^4.3.8",
"lint-staged": "^11.0.0",
"magic-string": "^0.30.7",
"make-fetch-happen": "^11.0.3",
"patch-package": "^8.0.0",
"powerhooks": "^1.0.10",
"prettier": "^3.2.5",
"properties-parser": "^0.3.1",
@ -111,10 +100,10 @@
"recast": "^0.23.3",
"run-exclusive": "^2.2.19",
"storybook-dark-mode": "^1.1.2",
"termost": "^0.12.0",
"termost": "^v0.12.1",
"tsc-alias": "^1.8.10",
"tss-react": "^4.9.10",
"typescript": "^4.9.1-beta",
"typescript": "^4.9.4",
"vite": "^5.2.11",
"vitest": "^1.6.0",
"yauzl": "^2.10.0",

File diff suppressed because one or more lines are too long

View File

@ -1,16 +1,13 @@
import * as child_process from "child_process";
import { join } from "path";
import { copyKeycloakResourcesToStorybookStaticDir } from "./copyKeycloakResourcesToStorybookStaticDir";
run("yarn build");
(async () => {
run("yarn build");
run(`node ${join("dist", "bin", "main.js")} copy-keycloak-resources-to-public`, {
env: {
...process.env,
PUBLIC_DIR_PATH: join(".storybook", "static")
}
});
await copyKeycloakResourcesToStorybookStaticDir();
run("npx build-storybook");
run("npx build-storybook");
})();
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`);

View File

@ -1,6 +1,6 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join, relative } from "path";
import { join } from "path";
import { assert } from "tsafe/assert";
import { transformCodebase } from "../src/bin/tools/transformCodebase";
import chalk from "chalk";
@ -16,7 +16,7 @@ if (fs.existsSync(join("dist", "bin", "main.original.js"))) {
);
fs.readdirSync(join("dist", "bin")).forEach(fileBasename => {
if (/[0-9]\.index.js/.test(fileBasename)) {
if (/[0-9]\.index.js/.test(fileBasename) || fileBasename.endsWith(".node")) {
fs.rmSync(join("dist", "bin", fileBasename));
}
});
@ -111,9 +111,10 @@ run(
)}`
);
fs.readdirSync(join("dist", "ncc_out")).forEach(fileBasename =>
assert(!fileBasename.endsWith(".index.js"))
);
fs.readdirSync(join("dist", "ncc_out")).forEach(fileBasename => {
assert(!fileBasename.endsWith(".index.js"));
assert(!fileBasename.endsWith(".node"));
});
transformCodebase({
srcDirPath: join("dist", "ncc_out"),

View File

@ -0,0 +1,18 @@
import { join as pathJoin } from "path";
import { copyKeycloakResourcesToPublic } from "../src/bin/shared/copyKeycloakResourcesToPublic";
import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions";
import { LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT } from "../src/bin/shared/constants";
export async function copyKeycloakResourcesToStorybookStaticDir() {
await copyKeycloakResourcesToPublic({
buildContext: {
cacheDirPath: pathJoin(__dirname, "..", "node_modules", ".cache", "scripts"),
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: pathJoin(__dirname, "..")
}),
loginThemeResourcesFromKeycloakVersion:
LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT,
publicDirPath: pathJoin(__dirname, "..", ".storybook", "static")
}
});
}

View File

@ -1,4 +1,4 @@
import { containerName } from "../src/bin/shared/constants";
import { CONTAINER_NAME } from "../src/bin/shared/constants";
import child_process from "child_process";
import { SemVer } from "../src/bin/tools/SemVer";
import { join as pathJoin, relative as pathRelative } from "path";
@ -14,7 +14,7 @@ import { is } from "tsafe/is";
const child = child_process.spawn(
"docker",
[
...["exec", containerName],
...["exec", CONTAINER_NAME],
...["/opt/keycloak/bin/kc.sh", "export"],
...["--dir", "/tmp"],
...["--realm", "myrealm"],
@ -62,7 +62,7 @@ import { is } from "tsafe/is";
const keycloakMajorVersionNumber = SemVer.parse(
child_process
.execSync(`docker inspect --format '{{.Config.Image}}' ${containerName}`)
.execSync(`docker inspect --format '{{.Config.Image}}' ${CONTAINER_NAME}`)
.toString("utf8")
.trim()
.split(":")[1]
@ -80,7 +80,7 @@ import { is } from "tsafe/is";
)
);
run(`docker cp ${containerName}:/tmp/myrealm-realm.json ${targetFilePath}`);
run(`docker cp ${CONTAINER_NAME}:/tmp/myrealm-realm.json ${targetFilePath}`);
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
})();

View File

@ -12,6 +12,7 @@ import { crawl } from "../src/bin/tools/crawl";
import { downloadKeycloakDefaultTheme } from "../src/bin/shared/downloadKeycloakDefaultTheme";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { deepAssign } from "../src/tools/deepAssign";
import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions";
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
// update the version array for generating for newer version.
@ -33,7 +34,9 @@ async function main() {
".cache",
"keycloakify"
),
npmWorkspaceRootDirPath: thisCodebaseRootDirPath
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: thisCodebaseRootDirPath
})
}
});
@ -65,11 +68,14 @@ async function main() {
fs
.readFileSync(pathJoin(baseThemeDirPath, filePath))
.toString("utf8")
)
).map(([key, value]: any) => [
key === "locale_pt_BR" ? "locale_pt-BR" : key,
value.replace(/''/g, "'")
])
) as Record<string, string>
)
.map(([key, value]) => [key, value.replace(/''/g, "'")])
.map(([key, value]) => [
key === "locale_pt_BR" ? "locale_pt-BR" : key,
value
])
.map(([key, value]) => [key, key === "termsText" ? "" : value])
);
});
}
@ -104,12 +110,12 @@ async function main() {
source: keycloakifyExtraMessages
});
const baseMessagesDirPath = pathJoin(
const messagesDirPath = pathJoin(
thisCodebaseRootDirPath,
"src",
themeType,
"i18n",
"baseMessages"
"messages_defaultSet"
);
const generatedFileHeader = [
@ -121,7 +127,7 @@ async function main() {
].join("\n");
languages.forEach(language => {
const filePath = pathJoin(baseMessagesDirPath, `${language}.ts`);
const filePath = pathJoin(messagesDirPath, `${language}.ts`);
fs.mkdirSync(pathDirname(filePath), { recursive: true });
@ -149,14 +155,14 @@ async function main() {
});
fs.writeFileSync(
pathJoin(baseMessagesDirPath, "index.ts"),
pathJoin(messagesDirPath, "index.ts"),
Buffer.from(
[
generatedFileHeader,
`import * as en from "./en";`,
"",
"export async function getMessages(currentLanguageTag: string) {",
" const { default: messages } = await (() => {",
"export async function fetchMessages_defaultSet(currentLanguageTag: string) {",
" const { default: messages_defaultSet } = await (() => {",
" switch (currentLanguageTag) {",
` case "en": return en;`,
...languages
@ -168,7 +174,7 @@ async function main() {
' default: return { "default": {} };',
" }",
" })();",
" return messages;",
" return messages_defaultSet;",
"}"
].join("\n"),
"utf8"

View File

@ -44,6 +44,12 @@ const commonThirdPartyDeps = [
.replace(/"!dist\//g, '"!')
.replace(/"!\.\/dist\//g, '"!./');
modifiedPackageJsonContent = JSON.stringify(
{ ...JSON.parse(modifiedPackageJsonContent), version: "0.0.0" },
null,
4
);
fs.writeFileSync(
pathJoin(rootDirPath, "dist", "package.json"),
Buffer.from(modifiedPackageJsonContent, "utf8")

View File

@ -2,22 +2,49 @@ import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
import { crawl } from "../src/bin/tools/crawl";
{
const dirPath = "node_modules";
try {
fs.rmSync(dirPath, { recursive: true, force: true });
} catch {
// NOTE: This is a workaround for windows
// we can't remove locked executables.
crawl({
dirPath,
returnedPathsType: "absolute"
}).forEach(filePath => {
try {
fs.rmSync(filePath, { force: true });
} catch (error) {
if (filePath.endsWith(".exe")) {
return;
}
throw error;
}
});
}
}
fs.rmSync("node_modules", { recursive: true, force: true });
fs.rmSync("dist", { recursive: true, force: true });
fs.rmSync(".yarn_home", { recursive: true, force: true });
run("yarn install");
run("yarn build");
fs.rmSync(join("..", "keycloakify-starter", "node_modules"), {
const starterName = "keycloakify-starter";
fs.rmSync(join("..", starterName, "node_modules"), {
recursive: true,
force: true
});
run("yarn install", { cwd: join("..", "keycloakify-starter") });
run("yarn install", { cwd: join("..", starterName) });
run(`npx tsx ${join("scripts", "link-in-app.ts")} keycloakify-starter`);
run(`npx tsx ${join("scripts", "link-in-app.ts")} ${starterName}`);
startRebuildOnSrcChange();

View File

@ -1,30 +1,26 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
import { copyKeycloakResourcesToStorybookStaticDir } from "./copyKeycloakResourcesToStorybookStaticDir";
run("yarn build");
(async () => {
run("yarn build");
run(`node ${join("dist", "bin", "main.js")} copy-keycloak-resources-to-public`, {
env: {
...process.env,
PUBLIC_DIR_PATH: join(".storybook", "static")
await copyKeycloakResourcesToStorybookStaticDir();
{
const child = child_process.spawn("npx", ["start-storybook", "-p", "6006"], {
shell: true
});
child.stdout.on("data", data => process.stdout.write(data));
child.stderr.on("data", data => process.stderr.write(data));
child.on("exit", process.exit.bind(process));
}
});
{
const child = child_process.spawn("npx", ["start-storybook", "-p", "6006"], {
shell: true
});
child.stdout.on("data", data => process.stdout.write(data));
child.stderr.on("data", data => process.stderr.write(data));
child.on("exit", process.exit.bind(process));
}
startRebuildOnSrcChange();
startRebuildOnSrcChange();
})();
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`);

View File

@ -28,9 +28,13 @@ export function startRebuildOnSrcChange() {
console.log(chalk.green("Watching for changes in src/"));
chokidar.watch(["src", "stories"], { ignoreInitial: true }).on("all", async () => {
await waitForDebounce();
chokidar
.watch(["src", "stories"], { ignoreInitial: true })
.on("all", async (event, path) => {
console.log(chalk.bold(`${event}: ${path}`));
runYarnBuild();
});
await waitForDebounce();
runYarnBuild();
});
}

View File

@ -1,4 +1,4 @@
import { basenameOfTheKeycloakifyResourcesDir } from "keycloakify/bin/shared/constants";
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "keycloakify/bin/shared/constants";
import { assert } from "tsafe/assert";
/**
@ -17,5 +17,5 @@ export const PUBLIC_URL = (() => {
return process.env.PUBLIC_URL;
}
return `${kcContext.url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}`;
return `${kcContext.url.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}`;
})();

View File

@ -118,7 +118,10 @@ export declare namespace KcContext {
lastName?: string;
username?: string;
};
properties: Record<string, string | undefined>;
properties: {};
"x-keycloakify": {
messages: Record<string, string>;
};
};
export type Password = Common & {

View File

@ -1,10 +1,10 @@
import "keycloakify/tools/Object.fromEntries";
import { resources_common, keycloak_resources } from "keycloakify/bin/shared/constants";
import { RESOURCES_COMMON, KEYCLOAK_RESOURCES } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
import type { KcContext } from "./KcContext";
import { BASE_URL } from "keycloakify/lib/BASE_URL";
const resourcesPath = `${BASE_URL}${keycloak_resources}/account/resources`;
const resourcesPath = `${BASE_URL}${KEYCLOAK_RESOURCES}/account/resources`;
export const kcContextCommonMock: KcContext.Common = {
themeVersion: "0.0.0",
@ -13,7 +13,7 @@ export const kcContextCommonMock: KcContext.Common = {
themeName: "my-theme-name",
url: {
resourcesPath,
resourcesCommonPath: `${resourcesPath}/${resources_common}`,
resourcesCommonPath: `${resourcesPath}/${RESOURCES_COMMON}`,
resourceUrl: "#",
accountUrl: "#",
applicationsUrl: "#",
@ -82,7 +82,10 @@ export const kcContextCommonMock: KcContext.Common = {
email: "john.doe@code.gouv.fr",
username: "doe_j"
},
properties: {}
properties: {},
"x-keycloakify": {
messages: {}
}
};
export const kcContextMocks: KcContext[] = [

View File

@ -13,7 +13,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { msg, msgStr, getChangeLocaleUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { locale, url, features, realm, message, referrer } = kcContext;
@ -79,7 +79,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<ul>
{locale.supported.map(({ languageTag }) => (
<li key={languageTag} className="kc-dropdown-item">
<a href={getChangeLocalUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
<a href={getChangeLocaleUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
</li>
))}
</ul>
@ -145,7 +145,12 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<div className={clsx("alert", `alert-${message.type}`)}>
{message.type === "success" && <span className="pficon pficon-ok"></span>}
{message.type === "error" && <span className="pficon pficon-error-circle-o"></span>}
<span className="kc-feedback-text">{message.summary}</span>
<span
className="kc-feedback-text"
dangerouslySetInnerHTML={{
__html: message.summary
}}
/>
</div>
)}

View File

@ -0,0 +1,6 @@
import type { GenericI18n_noJsx } from "./i18n";
export type GenericI18n<MessageKey extends string> = GenericI18n_noJsx<MessageKey> & {
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
};

View File

@ -1,25 +1,24 @@
import "keycloakify/tools/Object.fromEntries";
import { useEffect, useState } from "react";
import { assert } from "tsafe/assert";
import messages_fallbackLanguage from "./baseMessages/en";
import { getMessages } from "./baseMessages";
import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en";
import { fetchMessages_defaultSet } from "./messages_defaultSet";
import type { KcContext } from "../KcContext";
import { Reflect } from "tsafe/Reflect";
export const fallbackLanguageTag = "en";
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
export type KcContextLike = {
locale?: {
currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[];
};
"x-keycloakify": {
messages: Record<string, string>;
};
};
assert<KcContext extends KcContextLike ? true : false>();
export type MessageKey = keyof typeof messages_fallbackLanguage;
export type GenericI18n<MessageKey extends string> = {
export type GenericI18n_noJsx<MessageKey extends string> = {
/**
* e.g: "en", "fr", "zh-CN"
*
@ -30,7 +29,7 @@ export type GenericI18n<MessageKey extends string> = {
* Redirect to this url to change the language.
* After reload currentLanguageTag === newLanguageTag
*/
getChangeLocalUrl: (newLanguageTag: string) => string;
getChangeLocaleUrl: (newLanguageTag: string) => string;
/**
* e.g. "en" => "English", "fr" => "Français", ...
*
@ -39,16 +38,21 @@ export type GenericI18n<MessageKey extends string> = {
* */
labelBySupportedLanguageTag: Record<string, string>;
/**
* Examples assuming currentLanguageTag === "en"
*
* msg("access-denied") === <span>Access denied</span>
* msg("impersonateTitleHtml", "Foo") === <span><strong>Foo</strong> Impersonate User</span>
*/
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
/**
* It's the same thing as msg() but instead of returning a JSX.Element it returns a string.
* It can be more convenient to manipulate strings but if there are HTML tags it wont render.
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
* msgStr("${bar}", "<strong>c</strong>") === "Bar &lt;strong&gt;XXX&lt;/strong&gt;"
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
*/
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
/**
@ -59,24 +63,11 @@ export type GenericI18n<MessageKey extends string> = {
* {
* en: {
* "access-denied": "Access denied",
* "foo": "Foo {0} {1}",
* "bar": "Bar {0}"
* }
* }
*
* advancedMsg("${access-denied} foo bar") === <span>{msgStr("access-denied")} foo bar<span> === <span>Access denied foo bar</span>
* advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied") === <span>Access denied</span>
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === <span>not-a-message-key</span>
* advancedMsg("${bar}", "<strong>c</strong>")
* === <span>{msgStr("bar", "<strong>XXX</strong>")}<span>
* === <span>Bar &lt;strong&gt;XXX&lt;/strong&gt;</span> (The html in the arg is partially escaped for security reasons, it might be untrusted)
* advancedMsg("${foo} xx ${bar}", "a", "b", "c")
* === <span>{msgStr("foo", "a", "b")} xx {msgStr("bar")}<span>
* === <span>Foo a b xx Bar {0}</span> (The substitution are only applied in the first message)
*/
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
/**
* See advancedMsg() but instead of returning a JSX.Element it returns a string.
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
@ -88,8 +79,12 @@ export type GenericI18n<MessageKey extends string> = {
isFetchingTranslations: boolean;
};
function createGetI18n<ExtraMessageKey extends string = never>(extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string } }) {
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
export function createGetI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag_themeDefined: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
@ -108,9 +103,9 @@ function createGetI18n<ExtraMessageKey extends string = never>(extraMessages: {
return cachedResult;
}
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocalUrl" | "labelBySupportedLanguageTag"> = {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag,
getChangeLocalUrl: newLanguageTag => {
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocaleUrl" | "labelBySupportedLanguageTag"> = {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG,
getChangeLocaleUrl: newLanguageTag => {
const { locale } = kcContext;
assert(locale !== undefined, "Internationalization not enabled");
@ -124,28 +119,38 @@ function createGetI18n<ExtraMessageKey extends string = never>(extraMessages: {
labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]))
};
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey, ExtraMessageKey>({
messages_fallbackLanguage,
extraMessages_fallbackLanguage: extraMessages[fallbackLanguageTag],
extraMessages: extraMessages[partialI18n.currentLanguageTag]
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
messages_themeDefined:
messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ??
messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG] ??
(() => {
const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0];
if (firstLanguageTag === undefined) {
return undefined;
}
return messagesByLanguageTag_themeDefined[firstLanguageTag];
})(),
messages_fromKcServer: kcContext["x-keycloakify"].messages
});
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === fallbackLanguageTag;
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === FALLBACK_LANGUAGE_TAG;
const result: Result = {
i18n: {
...partialI18n,
...createI18nTranslationFunctions({ messages: undefined }),
...createI18nTranslationFunctions({
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
}),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages = await getMessages(partialI18n.currentLanguageTag);
const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag);
const i18n_currentLanguage: I18n = {
...partialI18n,
...createI18nTranslationFunctions({ messages }),
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
isFetchingTranslations: false
};
@ -168,154 +173,76 @@ function createGetI18n<ExtraMessageKey extends string = never>(extraMessages: {
return { getI18n };
}
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
[languageTag: string]: { [key in ExtraMessageKey]: string };
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
messages_themeDefined: Record<MessageKey_themeDefined, string> | undefined;
messages_fromKcServer: Record<string, string>;
}) {
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
const { getI18n } = createGetI18n(extraMessages);
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params;
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(i18n);
useEffect(() => {
let isActive = true;
prI18n_currentLanguage?.then(i18n => {
if (!isActive) {
return;
}
setI18n_toReturn(i18n);
});
return () => {
isActive = false;
};
}, []);
return { i18n: i18n_toReturn };
}
return { useI18n, ofTypeI18n: Reflect<I18n>() };
}
function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraMessageKey extends string>(params: {
messages_fallbackLanguage: Record<MessageKey, string>;
extraMessages_fallbackLanguage: Record<ExtraMessageKey, string> | undefined;
extraMessages: Partial<Record<ExtraMessageKey, string>> | undefined;
}) {
const { extraMessages } = params;
const messages_fallbackLanguage = {
...params.messages_fallbackLanguage,
...params.extraMessages_fallbackLanguage
};
const { messages_themeDefined, messages_fromKcServer } = params;
function createI18nTranslationFunctions(params: {
messages: Partial<Record<MessageKey, string>> | undefined;
}): Pick<GenericI18n<MessageKey | ExtraMessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const messages = {
...params.messages,
...extraMessages
};
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>, "msgStr" | "advancedMsgStr"> {
const { messages_defaultSet_currentLanguage } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
const { key, args, doRenderAsHtml } = props;
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
const { key, args } = props;
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (messages_fallbackLanguage as any)[key];
const message =
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
id<Record<string, string | undefined> | undefined>(messages_themeDefined)?.[key] ??
id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];
if (messageOrUndefined === undefined) {
if (message === undefined) {
return undefined;
}
const message = messageOrUndefined;
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
const messageWithArgsInjectedIfAny = (() => {
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
if (startIndex === undefined) {
// No {0} in message (no arguments expected)
return message;
}
let messageWithArgsInjected = message;
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
});
return messageWithArgsInjected;
})();
return doRenderAsHtml ? (
<span
// NOTE: The message is trusted. The arguments are not but are escaped.
dangerouslySetInnerHTML={{
__html: messageWithArgsInjectedIfAny
}}
/>
) : (
messageWithArgsInjectedIfAny
);
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
const { key, args, doRenderAsHtml } = props;
if (!/\$\{[^}]+\}/.test(key)) {
const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml });
if (resolvedMessage === undefined) {
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: key }} /> : key;
}
return resolvedMessage;
if (startIndex === undefined) {
// No {0} in message (no arguments expected)
return message;
}
let isFirstMatch = true;
let messageWithArgsInjected = message;
const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => {
const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i;
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
isFirstMatch = false;
return replaceBy;
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
});
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: resolvedComplexMessage }} /> : resolvedComplexMessage;
return messageWithArgsInjected;
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
const { key, args } = props;
const match = key.match(/^\$\{(.+)\}$/);
if (match === null) {
return key;
}
return resolveMsg({ key: match[1], args }) ?? key;
}
return {
msgStr: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: false }) as string,
msg: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: true }) as JSX.Element,
advancedMsg: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderAsHtml: true
}) as JSX.Element,
advancedMsgStr: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderAsHtml: false
}) as string
msgStr: (key, ...args) => {
const resolvedMessage = resolveMsg({ key, args });
assert(resolvedMessage !== undefined, `Message with key "${key}" not found`);
return resolvedMessage;
},
advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args })
};
}

View File

@ -1,5 +1,5 @@
import type { GenericI18n, MessageKey, KcContextLike } from "./i18n";
export type { MessageKey, KcContextLike };
export type I18n = GenericI18n<MessageKey>;
export { createUseI18n } from "./i18n";
export { fallbackLanguageTag } from "./i18n";
import type { GenericI18n } from "./GenericI18n";
import type { MessageKey_defaultSet, KcContextLike } from "./i18n";
export type { MessageKey_defaultSet, KcContextLike };
export type I18n = GenericI18n<MessageKey_defaultSet>;
export { createUseI18n } from "./useI18n";

View File

@ -0,0 +1,95 @@
import { useEffect, useState } from "react";
import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n";
import { GenericI18n } from "./GenericI18n";
import { Reflect } from "tsafe/Reflect";
export function createUseI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
type I18n = GenericI18n<MessageKey>;
const { withJsx } = (() => {
const cache = new WeakMap<GenericI18n_noJsx<MessageKey>, GenericI18n<MessageKey>>();
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
const { htmlString, msgKey } = params;
return (
<div
data-kc-msg={msgKey}
dangerouslySetInnerHTML={{
__html: htmlString
}}
/>
);
}
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey>): I18n {
use_cache: {
const i18n = cache.get(i18n_noJsx);
if (i18n === undefined) {
break use_cache;
}
return i18n;
}
const i18n: I18n = {
...i18n_noJsx,
msg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.msgStr(msgKey, ...args), msgKey }),
advancedMsg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.advancedMsgStr(msgKey, ...args), msgKey })
};
cache.set(i18n_noJsx, i18n);
return i18n;
}
return { withJsx };
})();
add_style: {
const attributeName = "data-kc-i18n";
// Check if already exists in head
if (document.querySelector(`style[${attributeName}]`) !== null) {
break add_style;
}
const styleElement = document.createElement("style");
styleElement.attributes.setNamedItem(document.createAttribute(attributeName));
(styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement);
}
const { getI18n } = createGetI18n(messagesByLanguageTag);
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params;
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(withJsx(i18n));
useEffect(() => {
let isActive = true;
prI18n_currentLanguage?.then(i18n => {
if (!isActive) {
return;
}
setI18n_toReturn(withJsx(i18n));
});
return () => {
isActive = false;
};
}, []);
return { i18n: i18n_toReturn };
}
return { useI18n, ofTypeI18n: Reflect<I18n>() };
}

View File

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

View File

@ -8,7 +8,7 @@ export default function FederatedIdentity(props: PageProps<Extract<KcContext, {
const { url, federatedIdentity, stateChecker } = kcContext;
const { msg } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="federatedIdentity">
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="social">
<div className="main-layout social">
<div className="row">
<div className="col-md-10">

View File

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

View File

@ -1,11 +1,11 @@
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
import cliSelect from "cli-select";
import {
loginThemePageIds,
accountThemePageIds,
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS,
type LoginThemePageId,
type AccountThemePageId,
themeTypes,
THEME_TYPES,
type ThemeType
} from "./shared/constants";
import { capitalize } from "tsafe/capitalize";
@ -26,11 +26,43 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(chalk.cyan("Theme type:"));
const { value: themeType } = await cliSelect<ThemeType>({
values: [...themeTypes]
}).catch(() => {
process.exit(-1);
});
const themeType = await (async () => {
const values = THEME_TYPES.filter(themeType => {
switch (themeType) {
case "account":
return buildContext.implementedThemeTypes.account.isImplemented;
case "login":
return buildContext.implementedThemeTypes.login.isImplemented;
}
assert<Equals<typeof themeType, never>>(false);
});
assert(values.length > 0, "No theme is implemented in this project");
if (values.length === 1) {
return values[0];
}
const { value } = await cliSelect<ThemeType>({
values
}).catch(() => {
process.exit(-1);
});
return value;
})();
if (
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page")
) {
console.log(
`${chalk.red("✗")} Sorry, there is no Storybook support for Single-Page Account themes.`
);
process.exit(0);
}
console.log(`${themeType}`);
@ -40,9 +72,9 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
values: (() => {
switch (themeType) {
case "login":
return [...loginThemePageIds];
return [...LOGIN_THEME_PAGE_IDS];
case "account":
return [...accountThemePageIds];
return [...ACCOUNT_THEME_PAGE_IDS];
}
assert<Equals<typeof themeType, never>>(false);
})()
@ -81,7 +113,8 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
)
)
.toString("utf8")
.replace('import React from "react";\n', "");
.replace('import React from "react";\n', "")
.replace(/from "[./]+dist\//, 'from "keycloakify/');
{
const targetDirPath = pathDirname(targetFilePath);

View File

@ -3,16 +3,21 @@
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
import cliSelect from "cli-select";
import {
loginThemePageIds,
accountThemePageIds,
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS,
type LoginThemePageId,
type AccountThemePageId,
themeTypes,
THEME_TYPES,
type ThemeType
} from "./shared/constants";
import { capitalize } from "tsafe/capitalize";
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,
basename as pathBasename
} from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import type { CliCommandOptions } from "./main";
@ -28,11 +33,114 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(chalk.cyan("Theme type:"));
const { value: themeType } = await cliSelect<ThemeType>({
values: [...themeTypes]
}).catch(() => {
process.exit(-1);
});
const themeType = await (async () => {
const values = THEME_TYPES.filter(themeType => {
switch (themeType) {
case "account":
return buildContext.implementedThemeTypes.account.isImplemented;
case "login":
return buildContext.implementedThemeTypes.login.isImplemented;
}
assert<Equals<typeof themeType, never>>(false);
});
assert(values.length > 0, "No theme is implemented in this project");
if (values.length === 1) {
return values[0];
}
const { value } = await cliSelect<ThemeType>({
values
}).catch(() => {
process.exit(-1);
});
return value;
})();
if (
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page")
) {
const srcDirPath = pathJoin(
pathDirname(buildContext.packageJsonFilePath),
"node_modules",
"@keycloakify",
"keycloak-account-ui",
"src"
);
console.log(
[
`There isn't an interactive CLI to eject components of the Single-Page Account theme.`,
`You can however copy paste into your codebase the any file or directory from the following source directory:`,
``,
`${chalk.bold(pathJoin(pathRelative(process.cwd(), srcDirPath)))}`,
``
].join("\n")
);
eject_entrypoint: {
const kcAccountUiTsxFileRelativePath = "KcAccountUi.tsx";
const accountThemeSrcDirPath = pathJoin(
buildContext.themeSrcDirPath,
"account"
);
const targetFilePath = pathJoin(
accountThemeSrcDirPath,
kcAccountUiTsxFileRelativePath
);
if (fs.existsSync(targetFilePath)) {
break eject_entrypoint;
}
fs.cpSync(
pathJoin(srcDirPath, kcAccountUiTsxFileRelativePath),
targetFilePath
);
{
const kcPageTsxFilePath = pathJoin(accountThemeSrcDirPath, "KcPage.tsx");
const kcPageTsxCode = fs.readFileSync(kcPageTsxFilePath).toString("utf8");
const componentName = pathBasename(
kcAccountUiTsxFileRelativePath
).replace(/.tsx$/, "");
const modifiedKcPageTsxCode = kcPageTsxCode.replace(
`@keycloakify/keycloak-account-ui/${componentName}`,
`./${componentName}`
);
fs.writeFileSync(
kcPageTsxFilePath,
Buffer.from(modifiedKcPageTsxCode, "utf8")
);
}
const routesTsxFilePath = pathRelative(
process.cwd(),
pathJoin(srcDirPath, "routes.tsx")
);
console.log(
[
`To help you get started ${chalk.bold(pathRelative(process.cwd(), targetFilePath))} has been copied into your project.`,
`The next step is usually to eject ${chalk.bold(routesTsxFilePath)}`,
`with \`cp ${routesTsxFilePath} ${pathRelative(process.cwd(), accountThemeSrcDirPath)}\``,
`then update the import of routes in ${kcAccountUiTsxFileRelativePath}.`
].join("\n")
);
}
process.exit(0);
}
console.log(`${themeType}`);
@ -54,10 +162,10 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
return [
templateValue,
userProfileFormFieldsValue,
...loginThemePageIds
...LOGIN_THEME_PAGE_IDS
];
case "account":
return [templateValue, ...accountThemePageIds];
return [templateValue, ...ACCOUNT_THEME_PAGE_IDS];
}
assert<Equals<typeof themeType, never>>(false);
})()
@ -190,6 +298,8 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const userProfileFormFieldComponentName = "UserProfileFormFields";
const componentName = componentBasename.replace(/.tsx$/, "");
console.log(
[
``,
@ -207,10 +317,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
`// ...`,
``,
chalk.green(
`+const ${componentBasename.replace(
/.tsx$/,
""
)} = lazy(() => import("./pages/${componentBasename}"));`
`+const ${componentName} = lazy(() => import("./pages/${componentName}"));`
),
...[
``,
@ -224,7 +331,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
` switch (kcContext.pageId) {`,
` // ...`,
`+ case "${pageIdOrComponent}": return (`,
`+ <${componentBasename}`,
`+ <${componentName}`,
`+ {...{ kcContext, i18n, classes }}`,
`+ Template={Template}`,
`+ doUseDefaultCss={true}`,

View File

@ -0,0 +1,32 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
import { assert, type Equals } from "tsafe/assert";
export function copyBoilerplate(params: {
accountThemeType: "Single-Page" | "Multi-Page";
accountThemeSrcDirPath: string;
}) {
const { accountThemeType, accountThemeSrcDirPath } = params;
fs.cpSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"initialize-account-theme",
"src",
(() => {
switch (accountThemeType) {
case "Single-Page":
return "single-page";
case "Multi-Page":
return "multi-page";
}
assert<Equals<typeof accountThemeType, never>>(false);
})()
),
accountThemeSrcDirPath,
{ recursive: true }
);
}

View File

@ -0,0 +1 @@
export * from "./initialize-account-theme";

View File

@ -0,0 +1,112 @@
import { getBuildContext } from "../shared/buildContext";
import type { CliCommandOptions } from "../main";
import cliSelect from "cli-select";
import child_process from "child_process";
import chalk from "chalk";
import { join as pathJoin, relative as pathRelative } from "path";
import * as fs from "fs";
import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeImplementationInConfig";
import { generateKcGenTs } from "../shared/generateKcGenTs";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildContext = getBuildContext({ cliCommandOptions });
const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
if (
fs.existsSync(accountThemeSrcDirPath) &&
fs.readdirSync(accountThemeSrcDirPath).length > 0
) {
console.warn(
chalk.red(
`There is already a ${pathRelative(
process.cwd(),
accountThemeSrcDirPath
)} directory in your project. Aborting.`
)
);
process.exit(-1);
}
exit_if_uncommitted_changes: {
let hasUncommittedChanges: boolean | undefined = undefined;
try {
hasUncommittedChanges =
child_process
.execSync(`git status --porcelain`, {
cwd: buildContext.projectDirPath
})
.toString()
.trim() !== "";
} catch {
// Probably not a git repository
break exit_if_uncommitted_changes;
}
if (!hasUncommittedChanges) {
break exit_if_uncommitted_changes;
}
console.warn(
[
chalk.red(
"Please commit or stash your changes before running this command.\n"
),
"This command will modify your project's files so it's better to have a clean working directory",
"so that you can easily see what has been changed and revert if needed."
].join(" ")
);
process.exit(-1);
}
const { value: accountThemeType } = await cliSelect({
values: ["Single-Page" as const, "Multi-Page" as const]
}).catch(() => {
process.exit(-1);
});
switch (accountThemeType) {
case "Multi-Page":
{
const { initializeAccountTheme_multiPage } = await import(
"./initializeAccountTheme_multiPage"
);
await initializeAccountTheme_multiPage({
accountThemeSrcDirPath
});
}
break;
case "Single-Page":
{
const { initializeAccountTheme_singlePage } = await import(
"./initializeAccountTheme_singlePage"
);
await initializeAccountTheme_singlePage({
accountThemeSrcDirPath,
buildContext
});
}
break;
}
updateAccountThemeImplementationInConfig({ buildContext, accountThemeType });
await generateKcGenTs({
buildContext: {
...buildContext,
implementedThemeTypes: {
...buildContext.implementedThemeTypes,
account: {
isImplemented: true,
type: accountThemeType
}
}
}
});
}

View File

@ -0,0 +1,21 @@
import { relative as pathRelative } from "path";
import chalk from "chalk";
import { copyBoilerplate } from "./copyBoilerplate";
export async function initializeAccountTheme_multiPage(params: {
accountThemeSrcDirPath: string;
}) {
const { accountThemeSrcDirPath } = params;
copyBoilerplate({
accountThemeType: "Multi-Page",
accountThemeSrcDirPath
});
console.log(
[
chalk.green("The Multi-Page account theme has been initialized."),
`Directory created: ${chalk.bold(pathRelative(process.cwd(), accountThemeSrcDirPath))}`
].join("\n")
);
}

View File

@ -0,0 +1,152 @@
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import type { BuildContext } from "../shared/buildContext";
import * as fs from "fs";
import chalk from "chalk";
import {
getLatestsSemVersionedTag,
type BuildContextLike as BuildContextLike_getLatestsSemVersionedTag
} from "../shared/getLatestsSemVersionedTag";
import fetch from "make-fetch-happen";
import { z } from "zod";
import { assert, type Equals } from "tsafe/assert";
import { is } from "tsafe/is";
import { id } from "tsafe/id";
import { npmInstall } from "../tools/npmInstall";
import { copyBoilerplate } from "./copyBoilerplate";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
type BuildContextLike = BuildContextLike_getLatestsSemVersionedTag & {
fetchOptions: BuildContext["fetchOptions"];
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function initializeAccountTheme_singlePage(params: {
accountThemeSrcDirPath: string;
buildContext: BuildContextLike;
}) {
const { accountThemeSrcDirPath, buildContext } = params;
const OWNER = "keycloakify";
const REPO = "keycloak-account-ui";
const [semVersionedTag] = await getLatestsSemVersionedTag({
owner: OWNER,
repo: REPO,
count: 1,
doIgnoreReleaseCandidates: false,
buildContext
});
const dependencies = await fetch(
`https://raw.githubusercontent.com/${OWNER}/${REPO}/${semVersionedTag.tag}/dependencies.gen.json`,
buildContext.fetchOptions
)
.then(r => r.json())
.then(
(() => {
type Dependencies = {
dependencies: Record<string, string>;
devDependencies?: Record<string, string>;
};
const zDependencies = (() => {
type TargetType = Dependencies;
const zTargetType = z.object({
dependencies: z.record(z.string()),
devDependencies: z.record(z.string()).optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
return o => zDependencies.parse(o);
})()
);
dependencies.dependencies["@keycloakify/keycloak-account-ui"] = semVersionedTag.tag;
const parsedPackageJson = (() => {
type ParsedPackageJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
dependencies: z.record(z.string()).optional(),
devDependencies: z.record(z.string()).optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const parsedPackageJson = JSON.parse(
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
})();
parsedPackageJson.dependencies = {
...parsedPackageJson.dependencies,
...dependencies.dependencies
};
parsedPackageJson.devDependencies = {
...parsedPackageJson.devDependencies,
...dependencies.devDependencies
};
if (Object.keys(parsedPackageJson.devDependencies).length === 0) {
delete parsedPackageJson.devDependencies;
}
fs.writeFileSync(
buildContext.packageJsonFilePath,
JSON.stringify(parsedPackageJson, undefined, 4)
);
run_npm_install: {
if (
JSON.parse(
fs
.readFileSync(pathJoin(getThisCodebaseRootDirPath(), "package.json"))
.toString("utf8")
)["version"] === "0.0.0"
) {
//NOTE: Linked version
break run_npm_install;
}
npmInstall({ packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath) });
}
copyBoilerplate({
accountThemeType: "Single-Page",
accountThemeSrcDirPath
});
console.log(
[
chalk.green(
"The Single-Page account theme has been successfully initialized."
),
`Using Account UI of Keycloak version: ${chalk.bold(semVersionedTag.tag.split("-")[0])}`,
`Directory created: ${chalk.bold(pathRelative(process.cwd(), accountThemeSrcDirPath))}`,
`Dependencies added to your project's package.json: `,
chalk.bold(JSON.stringify(dependencies, null, 2))
].join("\n")
);
}

View File

@ -0,0 +1,12 @@
/* eslint-disable @typescript-eslint/ban-types */
import type { ExtendKcContext } from "keycloakify/account";
import type { KcEnvName, ThemeName } from "../kc.gen";
export type KcContextExtension = {
themeName: ThemeName;
properties: Record<KcEnvName, string> & {};
};
export type KcContextExtensionPerPage = {};
export type KcContext = ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>;

View File

@ -0,0 +1,25 @@
import { Suspense } from "react";
import type { ClassKey } from "keycloakify/account";
import type { KcContext } from "./KcContext";
import { useI18n } from "./i18n";
import DefaultPage from "keycloakify/account/DefaultPage";
import Template from "keycloakify/account/Template";
export default function KcPage(props: { kcContext: KcContext }) {
const { kcContext } = props;
const { i18n } = useI18n({ kcContext });
return (
<Suspense>
{(() => {
switch (kcContext.pageId) {
default:
return <DefaultPage kcContext={kcContext} i18n={i18n} classes={classes} Template={Template} doUseDefaultCss={true} />;
}
})()}
</Suspense>
);
}
const classes = {} satisfies { [key in ClassKey]?: string };

View File

@ -0,0 +1,38 @@
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import type { KcContext } from "./KcContext";
import { createGetKcContextMock } from "keycloakify/account/KcContext";
import type { KcContextExtension, KcContextExtensionPerPage } from "./KcContext";
import KcPage from "./KcPage";
import { themeNames, kcEnvDefaults } from "../kc.gen";
const kcContextExtension: KcContextExtension = {
themeName: themeNames[0],
properties: {
...kcEnvDefaults
}
};
const kcContextExtensionPerPage: KcContextExtensionPerPage = {};
export const { getKcContextMock } = createGetKcContextMock({
kcContextExtension,
kcContextExtensionPerPage,
overrides: {},
overridesPerPage: {}
});
export function createKcPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) {
const { pageId } = params;
function KcPageStory(props: { kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>> }) {
const { kcContext: overrides } = props;
const kcContextMock = getKcContextMock({
pageId,
overrides
});
return <KcPage kcContext={kcContextMock} />;
}
return { KcPageStory };
}

View File

@ -0,0 +1,5 @@
import { createUseI18n } from "keycloakify/account";
export const { useI18n, ofTypeI18n } = createUseI18n({});
export type I18n = typeof ofTypeI18n;

View File

@ -0,0 +1,7 @@
import type { KcContextLike } from "@keycloakify/keycloak-account-ui";
import type { KcEnvName } from "../kc.gen";
export type KcContext = KcContextLike & {
themeType: "account";
properties: Record<KcEnvName, string>;
};

View File

@ -0,0 +1,11 @@
import { lazy } from "react";
import { KcAccountUiLoader } from "@keycloakify/keycloak-account-ui";
import type { KcContext } from "./KcContext";
const KcAccountUi = lazy(() => import("@keycloakify/keycloak-account-ui/KcAccountUi"));
export default function KcPage(props: { kcContext: KcContext }) {
const { kcContext } = props;
return <KcAccountUiLoader kcContext={kcContext} KcAccountUi={KcAccountUi} />;
}

View File

@ -0,0 +1,92 @@
import { join as pathJoin } from "path";
import { assert, type Equals } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext";
import * as fs from "fs";
import chalk from "chalk";
import { z } from "zod";
import { id } from "tsafe/id";
export type BuildContextLike = {
bundler: BuildContext["bundler"];
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function updateAccountThemeImplementationInConfig(params: {
buildContext: BuildContext;
accountThemeType: "Single-Page" | "Multi-Page";
}) {
const { buildContext, accountThemeType } = params;
switch (buildContext.bundler) {
case "vite":
{
const viteConfigPath = pathJoin(
buildContext.projectDirPath,
"vite.config.ts"
);
if (!fs.existsSync(viteConfigPath)) {
console.log(
chalk.bold(
`You must manually set the accountThemeImplementation to "${accountThemeType}" in your vite config`
)
);
break;
}
const viteConfigContent = fs
.readFileSync(viteConfigPath)
.toString("utf8");
const modifiedViteConfigContent = viteConfigContent.replace(
/accountThemeImplementation\s*:\s*"none"/,
`accountThemeImplementation: "${accountThemeType}"`
);
if (modifiedViteConfigContent === viteConfigContent) {
console.log(
chalk.bold(
`You must manually set the accountThemeImplementation to "${accountThemeType}" in your vite.config.ts`
)
);
break;
}
fs.writeFileSync(viteConfigPath, modifiedViteConfigContent);
}
break;
case "webpack":
{
const parsedPackageJson = (() => {
type ParsedPackageJson = {
keycloakify: Record<string, string>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
keycloakify: z.record(z.string())
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
return zParsedPackageJson.parse(
JSON.parse(
fs
.readFileSync(buildContext.packageJsonFilePath)
.toString("utf8")
)
);
})();
parsedPackageJson.keycloakify.accountThemeImplementation =
accountThemeType;
}
break;
}
}

View File

@ -30,7 +30,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
// NOTE: This is arbitrary
startingFromMajor: 17,
excludeMajorVersions: [],
cacheDirPath: buildContext.cacheDirPath
buildContext
});
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({

View File

@ -7,7 +7,7 @@ import { join as pathJoin, dirname as pathDirname } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import type { BuildContext } from "../../shared/buildContext";
import * as fs from "fs/promises";
import { accountV1ThemeName } from "../../shared/constants";
import { ACCOUNT_V1_THEME_NAME } from "../../shared/constants";
import {
generatePom,
BuildContextLike as BuildContextLike_generatePom
@ -24,6 +24,7 @@ export type BuildContextLike = BuildContextLike_generatePom & {
artifactId: string;
themeVersion: string;
cacheDirPath: string;
implementedThemeTypes: BuildContext["implementedThemeTypes"];
};
assert<BuildContext extends BuildContextLike ? true : false>();
@ -33,6 +34,7 @@ export async function buildJar(params: {
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
resourcesDirPath: string;
doesImplementAccountV1Theme: boolean;
buildContext: BuildContextLike;
}): Promise<void> {
const {
@ -40,28 +42,30 @@ export async function buildJar(params: {
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
resourcesDirPath,
doesImplementAccountV1Theme,
buildContext
} = params;
const keycloakifyBuildTmpDirPath = pathJoin(
const keycloakifyBuildCacheDirPath = pathJoin(
buildContext.cacheDirPath,
"maven",
jarFileBasename.replace(".jar", "")
);
rmSync(keycloakifyBuildTmpDirPath, { recursive: true, force: true });
const tmpResourcesDirPath = pathJoin(
keycloakifyBuildTmpDirPath,
keycloakifyBuildCacheDirPath,
"src",
"main",
"resources"
);
rmSync(tmpResourcesDirPath, { recursive: true, force: true });
transformCodebase({
srcDirPath: resourcesDirPath,
destDirPath: tmpResourcesDirPath,
transformSourceCode:
keycloakAccountV1Version !== null
!doesImplementAccountV1Theme || keycloakAccountV1Version !== null
? undefined
: (params: {
fileRelativePath: string;
@ -71,7 +75,7 @@ export async function buildJar(params: {
if (
isInside({
dirPath: pathJoin("theme", accountV1ThemeName),
dirPath: pathJoin("theme", ACCOUNT_V1_THEME_NAME),
filePath: fileRelativePath
})
) {
@ -87,7 +91,7 @@ export async function buildJar(params: {
sourceCode
.toString("utf8")
.replace(
`parent=${accountV1ThemeName}`,
`parent=${ACCOUNT_V1_THEME_NAME}`,
"parent=keycloak"
),
"utf8"
@ -105,14 +109,24 @@ export async function buildJar(params: {
}
});
if (keycloakAccountV1Version === null) {
remove_account_v1_in_meta_inf: {
if (!doesImplementAccountV1Theme) {
// NOTE: We do not have account v1 anyway
break remove_account_v1_in_meta_inf;
}
if (keycloakAccountV1Version !== null) {
// NOTE: No, we need to keep account-v1 in meta-inf
break remove_account_v1_in_meta_inf;
}
writeMetaInfKeycloakThemes({
resourcesDirPath: tmpResourcesDirPath,
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => {
assert(metaInfKeycloakTheme !== undefined);
metaInfKeycloakTheme.themes = metaInfKeycloakTheme.themes.filter(
({ name }) => name !== accountV1ThemeName
({ name }) => name !== ACCOUNT_V1_THEME_NAME
);
return metaInfKeycloakTheme;
@ -121,6 +135,10 @@ export async function buildJar(params: {
}
route_legacy_pages: {
if (!buildContext.implementedThemeTypes.login.isImplemented) {
break route_legacy_pages;
}
// NOTE: If there's no account theme there is no special target for keycloak 24 and up so we create
// the pages anyway. If there is an account pages, since we know that account-v1 is only support keycloak
// 24 in version 0.4 and up, we can safely break the route for legacy pages.
@ -135,6 +153,7 @@ export async function buildJar(params: {
}
})();
// TODO: Remove this optimization, it's a bit hacky.
if (doBreak) {
break route_legacy_pages;
}
@ -142,10 +161,7 @@ export async function buildJar(params: {
(["register.ftl", "login-update-profile.ftl"] as const).forEach(pageId =>
buildContext.themeNames.map(themeName => {
const ftlFilePath = pathJoin(
keycloakifyBuildTmpDirPath,
"src",
"main",
"resources",
tmpResourcesDirPath,
"theme",
themeName,
"login",
@ -154,7 +170,7 @@ export async function buildJar(params: {
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
const realPageId = (() => {
const ftlFileBasename = (() => {
switch (pageId) {
case "register.ftl":
return "register-user-profile.ftl";
@ -165,14 +181,14 @@ export async function buildJar(params: {
})();
const modifiedFtlFileContent = ftlFileContent.replace(
`kcContext.pageId = "\${pageId}";`,
`kcContext.pageId = "${pageId}"; kcContext.realPageId = "${realPageId}";`
`"ftlTemplateFileName": "${pageId}"`,
`"ftlTemplateFileName": "${ftlFileBasename}"`
);
assert(modifiedFtlFileContent !== ftlFileContent);
fs.writeFile(
pathJoin(pathDirname(ftlFilePath), realPageId),
pathJoin(pathDirname(ftlFilePath), ftlFileBasename),
Buffer.from(modifiedFtlFileContent, "utf8")
);
})
@ -187,15 +203,15 @@ export async function buildJar(params: {
});
await fs.writeFile(
pathJoin(keycloakifyBuildTmpDirPath, "pom.xml"),
pathJoin(keycloakifyBuildCacheDirPath, "pom.xml"),
Buffer.from(pomFileCode, "utf8")
);
}
await new Promise<void>((resolve, reject) =>
child_process.exec(
`mvn clean install -Dmaven.repo.local=${pathJoin(keycloakifyBuildTmpDirPath, ".m2")}`,
{ cwd: keycloakifyBuildTmpDirPath },
`mvn install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`,
{ cwd: keycloakifyBuildCacheDirPath },
error => {
if (error !== null) {
console.error(
@ -220,12 +236,10 @@ export async function buildJar(params: {
await fs.rename(
pathJoin(
keycloakifyBuildTmpDirPath,
keycloakifyBuildCacheDirPath,
"target",
`${buildContext.artifactId}-${buildContext.themeVersion}.jar`
),
pathJoin(buildContext.keycloakifyBuildDirPath, jarFileBasename)
);
rmSync(keycloakifyBuildTmpDirPath, { recursive: true });
}

View File

@ -10,7 +10,7 @@ import type { BuildContext } from "../../shared/buildContext";
export type BuildContextLike = BuildContextLike_buildJar & {
projectDirPath: string;
keycloakifyBuildDirPath: string;
recordIsImplementedByThemeType: BuildContext["recordIsImplementedByThemeType"];
implementedThemeTypes: BuildContext["implementedThemeTypes"];
jarTargets: BuildContext["jarTargets"];
};
@ -22,7 +22,9 @@ export async function buildJars(params: {
}): Promise<void> {
const { resourcesDirPath, buildContext } = params;
const doesImplementAccountTheme = buildContext.recordIsImplementedByThemeType.account;
const doesImplementAccountV1Theme =
buildContext.implementedThemeTypes.account.isImplemented &&
buildContext.implementedThemeTypes.account.type === "Multi-Page";
await Promise.all(
keycloakAccountV1Versions
@ -30,7 +32,7 @@ export async function buildJars(params: {
keycloakThemeAdditionalInfoExtensionVersions.map(
keycloakThemeAdditionalInfoExtensionVersion => {
const keycloakVersionRange = getKeycloakVersionRangeForJar({
doesImplementAccountTheme,
doesImplementAccountV1Theme,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
});
@ -55,6 +57,7 @@ export async function buildJars(params: {
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
resourcesDirPath,
doesImplementAccountV1Theme,
buildContext
});
}

View File

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

View File

@ -1,34 +1,35 @@
import cheerio from "cheerio";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { generateCssCodeToDefineGlobals } from "../replacers/replaceImportsInCssCode";
import { replaceImportsInInlineCssCode } from "../replacers/replaceImportsInInlineCssCode";
import * as cheerio from "cheerio";
import {
replaceImportsInJsCode,
BuildContextLike as BuildContextLike_replaceImportsInJsCode
} from "../replacers/replaceImportsInJsCode";
import {
replaceImportsInCssCode,
BuildContextLike as BuildContextLike_replaceImportsInCssCode
} from "../replacers/replaceImportsInCssCode";
import * as fs from "fs";
import { join as pathJoin } from "path";
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import {
type ThemeType,
basenameOfTheKeycloakifyResourcesDir,
resources_common,
nameOfTheLocalizationRealmOverridesUserProfileProperty
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR,
RESOURCES_COMMON
} from "../../shared/constants";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
export type BuildContextLike = {
bundler: "vite" | "webpack";
themeVersion: string;
urlPathname: string | undefined;
projectBuildDirPath: string;
assetsDirPath: string;
kcContextExclusionsFtlCode: string | undefined;
};
export type BuildContextLike = BuildContextLike_replaceImportsInJsCode &
BuildContextLike_replaceImportsInCssCode & {
urlPathname: string | undefined;
themeVersion: string;
kcContextExclusionsFtlCode: string | undefined;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function generateFtlFilesCodeFactory(params: {
themeName: string;
indexHtmlCode: string;
cssGlobalsToDefine: Record<string, string>;
buildContext: BuildContextLike;
keycloakifyVersion: string;
themeType: ThemeType;
@ -36,7 +37,6 @@ export function generateFtlFilesCodeFactory(params: {
}) {
const {
themeName,
cssGlobalsToDefine,
indexHtmlCode,
buildContext,
keycloakifyVersion,
@ -65,8 +65,9 @@ export function generateFtlFilesCodeFactory(params: {
assert(cssCode !== null);
const { fixedCssCode } = replaceImportsInInlineCssCode({
const { fixedCssCode } = replaceImportsInCssCode({
cssCode,
cssFileRelativeDirPath: undefined,
buildContext
});
@ -76,7 +77,8 @@ export function generateFtlFilesCodeFactory(params: {
(
[
["link", "href"],
["script", "src"]
["script", "src"],
["script", "data-src"]
] as const
).forEach(([selector, attrName]) =>
$(selector).each((...[, element]) => {
@ -92,26 +94,11 @@ export function generateFtlFilesCodeFactory(params: {
new RegExp(
`^${(buildContext.urlPathname ?? "/").replace(/\//g, "\\/")}`
),
`\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
`\${xKeycloakify.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/`
)
);
})
);
if (Object.keys(cssGlobalsToDefine).length !== 0) {
$("head").prepend(
[
"",
"<style>",
generateCssCodeToDefineGlobals({
cssGlobalsToDefine,
buildContext
}).cssCodeToPrependInHead,
"</style>",
""
].join("\n")
);
}
}
//FTL is no valid html, we can't insert with cheerio, we put placeholder for injecting later.
@ -127,23 +114,17 @@ export function generateFtlFilesCodeFactory(params: {
)
)
.toString("utf8")
.replace("{{themeType}}", themeType)
.replace("{{themeName}}", themeName)
.replace("{{keycloakifyVersion}}", keycloakifyVersion)
.replace("{{themeVersion}}", buildContext.themeVersion)
.replace("{{fieldNames}}", fieldNames.map(name => `"${name}"`).join(", "))
.replace("{{RESOURCES_COMMON}}", RESOURCES_COMMON)
.replace(
"FIELD_NAMES_eKsIY4ZsZ4xeM",
fieldNames.map(name => `"${name}"`).join(", ")
)
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildContext.themeVersion)
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", themeName)
.replace("RESOURCES_COMMON_cLsLsMrtDkpVv", resources_common)
.replace(
"lOCALIZATION_REALM_OVERRIDES_USER_PROFILE_PROPERTY_KEY_aaGLsPgGIdeeX",
nameOfTheLocalizationRealmOverridesUserProfileProperty
)
.replace(
"USER_DEFINED_EXCLUSIONS_eKsaY4ZsZ4eMr2",
"{{userDefinedExclusions}}",
buildContext.kcContextExclusionsFtlCode ?? ""
);
const ftlObjectToJsCodeDeclaringAnObjectPlaceholder =
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }';
@ -188,7 +169,8 @@ export function generateFtlFilesCodeFactory(params: {
Object.entries({
[ftlObjectToJsCodeDeclaringAnObjectPlaceholder]:
kcContextDeclarationTemplateFtl,
PAGE_ID_xIgLsPgGId9D8e: pageId
"{{pageId}}": pageId,
"{{ftlTemplateFileName}}": pageId
}).map(
([searchValue, replaceValue]) =>
(ftlCode = ftlCode.replace(searchValue, replaceValue))

View File

@ -3,17 +3,17 @@ import { join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../../shared/buildContext";
import {
resources_common,
lastKeycloakVersionWithAccountV1,
accountV1ThemeName
RESOURCES_COMMON,
LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1,
ACCOUNT_V1_THEME_NAME
} from "../../shared/constants";
import { downloadKeycloakDefaultTheme } from "../../shared/downloadKeycloakDefaultTheme";
import {
downloadKeycloakDefaultTheme,
BuildContextLike as BuildContextLike_downloadKeycloakDefaultTheme
} from "../../shared/downloadKeycloakDefaultTheme";
import { transformCodebase } from "../../tools/transformCodebase";
export type BuildContextLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
export type BuildContextLike = BuildContextLike_downloadKeycloakDefaultTheme;
assert<BuildContext extends BuildContextLike ? true : false>();
@ -24,14 +24,14 @@ export async function bringInAccountV1(params: {
const { resourcesDirPath, buildContext } = params;
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion: lastKeycloakVersionWithAccountV1,
keycloakVersion: LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1,
buildContext
});
const accountV1DirPath = pathJoin(
resourcesDirPath,
"theme",
accountV1ThemeName,
ACCOUNT_V1_THEME_NAME,
"account"
);
@ -47,7 +47,7 @@ export async function bringInAccountV1(params: {
transformCodebase({
srcDirPath: pathJoin(defaultThemeDirPath, "keycloak", "common", "resources"),
destDirPath: pathJoin(accountV1DirPath, "resources", resources_common)
destDirPath: pathJoin(accountV1DirPath, "resources", RESOURCES_COMMON)
});
fs.writeFileSync(
@ -69,7 +69,7 @@ export async function bringInAccountV1(params: {
"patternfly-additions.min.css"
].map(
fileBasename =>
`${resources_common}/node_modules/patternfly/dist/css/${fileBasename}`
`${RESOURCES_COMMON}/node_modules/patternfly/dist/css/${fileBasename}`
)
].join(" "),
"",

View File

@ -1,14 +1,15 @@
import type { ThemeType } from "../../shared/constants";
import { type ThemeType, FALLBACK_LANGUAGE_TAG } from "../../shared/constants";
import { crawl } from "../../tools/crawl";
import { join as pathJoin } from "path";
import { readFileSync } from "fs";
import { symToStr } from "tsafe/symToStr";
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
import * as recast from "recast";
import * as babelParser from "@babel/parser";
import babelGenerate from "@babel/generator";
import * as babelTypes from "@babel/types";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
import * as fs from "fs";
import { assert } from "tsafe/assert";
export function generateMessageProperties(params: {
themeSrcDirPath: string;
@ -16,36 +17,92 @@ export function generateMessageProperties(params: {
}): { languageTag: string; propertiesFileSource: string }[] {
const { themeSrcDirPath, themeType } = params;
let files = crawl({
dirPath: pathJoin(themeSrcDirPath, themeType),
returnedPathsType: "absolute"
});
files = files.filter(file => {
const regex = /\.(js|ts|tsx)$/;
return regex.test(file);
});
files = files.sort((a, b) => {
const regex = /\.i18n\.(ts|js|tsx)$/;
const aIsI18nFile = regex.test(a);
const bIsI18nFile = regex.test(b);
return aIsI18nFile === bIsI18nFile ? 0 : aIsI18nFile ? -1 : 1;
});
files = files.sort((a, b) => a.length - b.length);
files = files.filter(file =>
readFileSync(file).toString("utf8").includes("createUseI18n")
const baseMessagesDirPath = pathJoin(
getThisCodebaseRootDirPath(),
"src",
themeType,
"i18n",
"messages_defaultSet"
);
if (files.length === 0) {
return [];
}
const baseMessageBundle: { [languageTag: string]: Record<string, string> } =
Object.fromEntries(
fs
.readdirSync(baseMessagesDirPath)
.filter(baseName => baseName !== "index.ts")
.map(basename => ({
languageTag: basename.replace(/\.ts$/, ""),
filePath: pathJoin(baseMessagesDirPath, basename)
}))
.map(({ languageTag, filePath }) => {
const lines = fs
.readFileSync(filePath)
.toString("utf8")
.split(/\r?\n/);
const extraMessages = files
.map(file => {
const root = recast.parse(readFileSync(file).toString("utf8"), {
let messagesJson = "{";
let isInDeclaration = false;
for (const line of lines) {
if (!isInDeclaration) {
if (line.startsWith("const messages")) {
isInDeclaration = true;
}
continue;
}
if (line.startsWith("}")) {
messagesJson += "}";
break;
}
messagesJson += line;
}
const messages = JSON.parse(messagesJson) as Record<string, string>;
return [languageTag, messages];
})
);
const { i18nTsFilePath } = (() => {
let files = crawl({
dirPath: pathJoin(themeSrcDirPath, themeType),
returnedPathsType: "absolute"
});
files = files.filter(file => {
const regex = /\.(js|ts|tsx)$/;
return regex.test(file);
});
files = files.sort((a, b) => {
const regex = /\.i18n\.(ts|js|tsx)$/;
const aIsI18nFile = regex.test(a);
const bIsI18nFile = regex.test(b);
return aIsI18nFile === bIsI18nFile ? 0 : aIsI18nFile ? -1 : 1;
});
files = files.sort((a, b) => a.length - b.length);
files = files.filter(file =>
fs.readFileSync(file).toString("utf8").includes("createUseI18n(")
);
const i18nTsFilePath: string | undefined = files[0];
return { i18nTsFilePath };
})();
const messageBundle: { [languageTag: string]: Record<string, string> } | undefined =
(() => {
if (i18nTsFilePath === undefined) {
return undefined;
}
const root = recast.parse(fs.readFileSync(i18nTsFilePath).toString("utf8"), {
parser: {
parse: (code: string) =>
babelParser.parse(code, {
@ -57,7 +114,7 @@ export function generateMessageProperties(params: {
}
});
const codes: string[] = [];
let messageBundleDeclarationTsCode: string | undefined = undefined;
recast.visit(root, {
visitCallExpression: function (path) {
@ -65,103 +122,71 @@ export function generateMessageProperties(params: {
path.node.callee.type === "Identifier" &&
path.node.callee.name === "createUseI18n"
) {
codes.push(babelGenerate(path.node.arguments[0] as any).code);
messageBundleDeclarationTsCode = babelGenerate(
path.node.arguments[0] as any
).code;
return false;
}
this.traverse(path);
}
});
return codes;
})
.flat()
.map(code => {
let extraMessages: {
assert(messageBundleDeclarationTsCode !== undefined);
let messageBundle: {
[languageTag: string]: Record<string, string>;
} = {};
try {
eval(`${symToStr({ extraMessages })} = ${code}`);
eval(
`${symToStr({ messageBundle })} = ${messageBundleDeclarationTsCode}`
);
} catch {
console.warn(
[
"WARNING: Make sure that the first argument of createUseI18n can be evaluated in a javascript",
"runtime where only the node globals are available.",
"WARNING: Make sure the messageBundle your provided as argument of createUseI18n can be statically evaluated.",
"This is important because we need to put your i18n messages in messages_*.properties files",
"or they won't be available server side.",
"\n",
"The following code could not be evaluated:",
"\n",
code
messageBundleDeclarationTsCode
].join(" ")
);
}
return extraMessages;
});
return messageBundle;
})();
const languageTags = extraMessages
.map(extraMessage => Object.keys(extraMessage))
.flat()
.reduce(...removeDuplicates<string>());
const keyValueMapByLanguageTag: Record<string, Record<string, string>> = {};
for (const languageTag of languageTags) {
const keyValueMap: Record<string, string> = {};
for (const extraMessage of extraMessages) {
const keyValueMap_i = extraMessage[languageTag];
if (keyValueMap_i === undefined) {
continue;
}
for (const [key, value] of Object.entries(keyValueMap_i)) {
if (keyValueMap[key] !== undefined) {
console.warn(
[
"WARNING: The following key is defined multiple times:",
"\n",
key,
"\n",
"The following value will be ignored:",
"\n",
value,
"\n",
"The following value was already defined:",
"\n",
keyValueMap[key]
].join(" ")
);
continue;
const mergedMessageBundle: { [languageTag: string]: Record<string, string> } =
Object.fromEntries(
Object.entries(baseMessageBundle).map(([languageTag, messages]) => [
languageTag,
{
...messages,
...(messageBundle === undefined
? {}
: messageBundle[languageTag] ??
messageBundle[FALLBACK_LANGUAGE_TAG] ??
messageBundle[Object.keys(messageBundle)[0]] ??
{})
}
])
);
keyValueMap[key] = value;
}
}
keyValueMapByLanguageTag[languageTag] = keyValueMap;
}
const out: { languageTag: string; propertiesFileSource: string }[] = [];
for (const [languageTag, keyValueMap] of Object.entries(keyValueMapByLanguageTag)) {
const propertiesFileSource = Object.entries(keyValueMap)
.map(([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`)
.join("\n");
out.push({
const messageProperties: { languageTag: string; propertiesFileSource: string }[] =
Object.entries(mergedMessageBundle).map(([languageTag, messages]) => ({
languageTag,
propertiesFileSource: [
"# This file was generated by keycloakify",
"",
"parent=base",
"",
propertiesFileSource,
...(themeType !== "account" ? ["parent=base"] : []),
...Object.entries(messages).map(
([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`
),
""
].join("\n")
});
}
}));
return out;
return messageProperties;
}

View File

@ -1,6 +1,12 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin, resolve as pathResolve, relative as pathRelative } from "path";
import {
join as pathJoin,
resolve as pathResolve,
relative as pathRelative,
dirname as pathDirname,
basename as pathBasename
} from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import {
@ -9,12 +15,12 @@ import {
} from "../generateFtl";
import {
type ThemeType,
lastKeycloakVersionWithAccountV1,
keycloak_resources,
accountV1ThemeName,
basenameOfTheKeycloakifyResourcesDir,
loginThemePageIds,
accountThemePageIds
LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1,
KEYCLOAK_RESOURCES,
ACCOUNT_V1_THEME_NAME,
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR,
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS
} from "../../shared/constants";
import type { BuildContext } from "../../shared/buildContext";
import { assert, type Equals } from "tsafe/assert";
@ -37,6 +43,7 @@ import {
} from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import { downloadAndExtractArchive } from "../../tools/downloadAndExtractArchive";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_downloadKeycloakStaticResources &
@ -46,8 +53,9 @@ export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
projectDirPath: string;
projectBuildDirPath: string;
environmentVariables: { name: string; default: string }[];
recordIsImplementedByThemeType: BuildContext["recordIsImplementedByThemeType"];
implementedThemeTypes: BuildContext["implementedThemeTypes"];
themeSrcDirPath: string;
bundler: "vite" | "webpack";
};
assert<BuildContext extends BuildContextLike ? true : false>();
@ -64,20 +72,23 @@ export async function generateResourcesForMainTheme(params: {
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
};
const cssGlobalsToDefine: Record<string, string> = {};
for (const themeType of ["login", "account"] as const) {
if (!buildContext.recordIsImplementedByThemeType[themeType]) {
if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
continue;
}
const isForAccountSpa =
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page");
const themeTypeDirPath = getThemeTypeDirPath({ themeType });
apply_replacers_and_move_to_theme_resources: {
const destDirPath = pathJoin(
themeTypeDirPath,
"resources",
basenameOfTheKeycloakifyResourcesDir
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR
);
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
@ -85,7 +96,7 @@ export async function generateResourcesForMainTheme(params: {
if (
themeType === "account" &&
buildContext.recordIsImplementedByThemeType.login
buildContext.implementedThemeTypes.login.isImplemented
) {
// NOTE: We prevent doing it twice, it has been done for the login theme.
@ -95,7 +106,7 @@ export async function generateResourcesForMainTheme(params: {
themeType: "login"
}),
"resources",
basenameOfTheKeycloakifyResourcesDir
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR
),
destDirPath
});
@ -106,7 +117,7 @@ export async function generateResourcesForMainTheme(params: {
{
const dirPath = pathJoin(
buildContext.projectBuildDirPath,
keycloak_resources
KEYCLOAK_RESOURCES
);
if (fs.existsSync(dirPath)) {
@ -114,7 +125,7 @@ export async function generateResourcesForMainTheme(params: {
throw new Error(
[
`Keycloakify build error: The ${keycloak_resources} directory shouldn't exist in your build directory.`,
`Keycloakify build error: The ${KEYCLOAK_RESOURCES} directory shouldn't exist in your build directory.`,
`(${pathRelative(process.cwd(), dirPath)}).\n`,
`Theses assets are only required for local development with Storybook.",
"Please remove this directory as an additional step of your command.\n`,
@ -127,21 +138,14 @@ export async function generateResourcesForMainTheme(params: {
transformCodebase({
srcDirPath: buildContext.projectBuildDirPath,
destDirPath,
transformSourceCode: ({ filePath, sourceCode }) => {
transformSourceCode: ({ filePath, fileRelativePath, sourceCode }) => {
if (filePath.endsWith(".css")) {
const {
cssGlobalsToDefine: cssGlobalsToDefineForThisFile,
fixedCssCode
} = replaceImportsInCssCode({
cssCode: sourceCode.toString("utf8")
const { fixedCssCode } = replaceImportsInCssCode({
cssCode: sourceCode.toString("utf8"),
cssFileRelativeDirPath: pathDirname(fileRelativePath),
buildContext
});
Object.entries(cssGlobalsToDefineForThisFile).forEach(
([key, value]) => {
cssGlobalsToDefine[key] = value;
}
);
return {
modifiedSourceCode: Buffer.from(fixedCssCode, "utf8")
};
@ -168,7 +172,6 @@ export async function generateResourcesForMainTheme(params: {
indexHtmlCode: fs
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
.toString("utf8"),
cssGlobalsToDefine,
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
@ -182,15 +185,17 @@ export async function generateResourcesForMainTheme(params: {
...(() => {
switch (themeType) {
case "login":
return loginThemePageIds;
return LOGIN_THEME_PAGE_IDS;
case "account":
return accountThemePageIds;
return isForAccountSpa ? ["index.ftl"] : ACCOUNT_THEME_PAGE_IDS;
}
})(),
...readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
})
...(isForAccountSpa
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
@ -200,40 +205,52 @@ export async function generateResourcesForMainTheme(params: {
);
});
generateMessageProperties({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
i18n_messages_generation: {
if (isForAccountSpa) {
break i18n_messages_generation;
}
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), {
recursive: true
generateMessageProperties({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), {
recursive: true
});
const propertiesFilePath = pathJoin(
messagesDirPath,
`messages_${languageTag}.properties`
);
fs.writeFileSync(
propertiesFilePath,
Buffer.from(propertiesFileSource, "utf8")
);
});
}
const propertiesFilePath = pathJoin(
messagesDirPath,
`messages_${languageTag}.properties`
);
keycloak_static_resources: {
if (isForAccountSpa) {
break keycloak_static_resources;
}
fs.writeFileSync(
propertiesFilePath,
Buffer.from(propertiesFileSource, "utf8")
);
});
await downloadKeycloakStaticResources({
keycloakVersion: (() => {
switch (themeType) {
case "account":
return lastKeycloakVersionWithAccountV1;
case "login":
return buildContext.loginThemeResourcesFromKeycloakVersion;
}
})(),
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildContext
});
await downloadKeycloakStaticResources({
keycloakVersion: (() => {
switch (themeType) {
case "account":
return LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1;
case "login":
return buildContext.loginThemeResourcesFromKeycloakVersion;
}
})(),
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildContext
});
}
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
@ -242,12 +259,13 @@ export async function generateResourcesForMainTheme(params: {
`parent=${(() => {
switch (themeType) {
case "account":
return accountV1ThemeName;
return isForAccountSpa ? "base" : ACCOUNT_V1_THEME_NAME;
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(isForAccountSpa ? ["deprecatedMode=false"] : []),
...(buildContext.extraThemeProperties ?? []),
...buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>
@ -260,7 +278,7 @@ export async function generateResourcesForMainTheme(params: {
}
email: {
if (!buildContext.recordIsImplementedByThemeType.email) {
if (!buildContext.implementedThemeTypes.email.isImplemented) {
break email;
}
@ -272,26 +290,70 @@ export async function generateResourcesForMainTheme(params: {
});
}
if (buildContext.recordIsImplementedByThemeType.account) {
bring_in_account_v1: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v1;
}
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
break bring_in_account_v1;
}
await bringInAccountV1({
resourcesDirPath,
buildContext
});
}
bring_in_account_v3_i18n_messages: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v3_i18n_messages;
}
if (buildContext.implementedThemeTypes.account.type !== "Single-Page") {
break bring_in_account_v3_i18n_messages;
}
const { extractedDirPath } = await downloadAndExtractArchive({
url: "https://repo1.maven.org/maven2/org/keycloak/keycloak-account-ui/25.0.1/keycloak-account-ui-25.0.1.jar",
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "bring_in_account_v3_i18n_messages",
onArchiveFile: async ({ fileRelativePath, writeFile }) => {
if (
!fileRelativePath.startsWith(
pathJoin("theme", "keycloak.v3", "account", "messages")
)
) {
return;
}
await writeFile({
fileRelativePath: pathBasename(fileRelativePath)
});
}
});
transformCodebase({
srcDirPath: extractedDirPath,
destDirPath: pathJoin(
getThemeTypeDirPath({ themeType: "account" }),
"messages"
)
});
}
{
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
metaInfKeycloakThemes.themes.push({
name: themeName,
types: objectEntries(buildContext.recordIsImplementedByThemeType)
.filter(([, isImplemented]) => isImplemented)
types: objectEntries(buildContext.implementedThemeTypes)
.filter(([, { isImplemented }]) => isImplemented)
.map(([themeType]) => themeType)
});
if (buildContext.recordIsImplementedByThemeType.account) {
if (buildContext.implementedThemeTypes.account.isImplemented) {
metaInfKeycloakThemes.themes.push({
name: accountV1ThemeName,
name: ACCOUNT_V1_THEME_NAME,
types: ["account"]
});
}

View File

@ -31,8 +31,8 @@ export function generateResourcesForThemeVariant(params: {
Buffer.from(sourceCode)
.toString("utf-8")
.replace(
`kcContext.themeName = "${themeName}";`,
`kcContext.themeName = "${themeVariantName}";`
`"themeName": "${themeName}"`,
`"themeName": "${themeVariantName}"`
),
"utf8"
);

View File

@ -5,8 +5,8 @@ import * as fs from "fs";
import { join as pathJoin } from "path";
import {
type ThemeType,
accountThemePageIds,
loginThemePageIds
ACCOUNT_THEME_PAGE_IDS,
LOGIN_THEME_PAGE_IDS
} from "../../shared/constants";
export function readExtraPagesNames(params: {
@ -34,19 +34,16 @@ export function readExtraPagesNames(params: {
const rawSourceFile = fs.readFileSync(candidateFilPath).toString("utf8");
extraPages.push(
...Array.from(
rawSourceFile.matchAll(/["']?pageId["']?\s*:\s*["']([^.]+.ftl)["']/g),
m => m[1]
)
...Array.from(rawSourceFile.matchAll(/["']([^.\s]+.ftl)["']:/g), m => m[1])
);
}
return extraPages.reduce(...removeDuplicates<string>()).filter(pageId => {
switch (themeType) {
case "account":
return !id<readonly string[]>(accountThemePageIds).includes(pageId);
return !id<readonly string[]>(ACCOUNT_THEME_PAGE_IDS).includes(pageId);
case "login":
return !id<readonly string[]>(loginThemePageIds).includes(pageId);
return !id<readonly string[]>(LOGIN_THEME_PAGE_IDS).includes(pageId);
}
});
}

View File

@ -11,7 +11,15 @@ export function readFieldNameUsage(params: {
}): string[] {
const { themeSrcDirPath, themeType } = params;
const fieldNames = new Set<string>();
// NOTE: We pre-populate with the synthetic user attributes defined in useUserProfileForm (can't be parsed automatically)
const fieldNames = new Set<string>([
"firstName",
"lastName",
"email",
"username",
"password",
"password-confirm"
]);
for (const srcDirPath of [
pathJoin(getThisCodebaseRootDirPath(), "src", themeType),

View File

@ -3,7 +3,7 @@ import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path
import * as child_process from "child_process";
import * as fs from "fs";
import { getBuildContext } from "../shared/buildContext";
import { vitePluginSubScriptEnvNames } from "../shared/constants";
import { VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES } from "../shared/constants";
import { buildJars } from "./buildJars";
import type { CliCommandOptions } from "../main";
import chalk from "chalk";
@ -93,10 +93,12 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
cwd: buildContext.projectDirPath,
env: {
...process.env,
[vitePluginSubScriptEnvNames.runPostBuildScript]: JSON.stringify({
resourcesDirPath,
buildContext
})
[VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES.RUN_POST_BUILD_SCRIPT]: JSON.stringify(
{
resourcesDirPath,
buildContext
}
)
}
});
}

View File

@ -1,7 +1,7 @@
import * as crypto from "crypto";
import type { BuildContext } from "../../shared/buildContext";
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "../../shared/constants";
import { assert } from "tsafe/assert";
import { basenameOfTheKeycloakifyResourcesDir } from "../../shared/constants";
import { posix } from "path";
export type BuildContextLike = {
urlPathname: string | undefined;
@ -9,68 +9,59 @@ export type BuildContextLike = {
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInCssCode(params: { cssCode: string }): {
export function replaceImportsInCssCode(params: {
cssCode: string;
cssFileRelativeDirPath: string | undefined;
buildContext: BuildContextLike;
}): {
fixedCssCode: string;
cssGlobalsToDefine: Record<string, string>;
} {
const { cssCode } = params;
const cssGlobalsToDefine: Record<string, string> = {};
new Set(cssCode.match(/url\(["']?\/[^/][^)"']+["']?\)[^;}]*?/g) ?? []).forEach(
match =>
(cssGlobalsToDefine[
"url" +
crypto
.createHash("sha256")
.update(match)
.digest("hex")
.substring(0, 15)
] = match)
);
const { cssCode, cssFileRelativeDirPath, buildContext } = params;
let fixedCssCode = cssCode;
Object.keys(cssGlobalsToDefine).forEach(
cssVariableName =>
//NOTE: split/join pattern ~ replace all
(fixedCssCode = fixedCssCode
.split(cssGlobalsToDefine[cssVariableName])
.join(`var(--${cssVariableName})`))
[
/url\("(\/[^/][^"]+)"\)/g,
/url\('(\/[^/][^']+)'\)/g,
/url\((\/[^/][^)]+)\)/g
].forEach(
regex =>
(fixedCssCode = fixedCssCode.replace(
regex,
(match, assetFileAbsoluteUrlPathname) => {
if (buildContext.urlPathname !== undefined) {
if (
!assetFileAbsoluteUrlPathname.startsWith(
buildContext.urlPathname
)
) {
// NOTE: Should never happen
return match;
}
assetFileAbsoluteUrlPathname =
assetFileAbsoluteUrlPathname.replace(
buildContext.urlPathname,
"/"
);
}
inline_style_in_html: {
if (cssFileRelativeDirPath !== undefined) {
break inline_style_in_html;
}
return `url("\${xKeycloakify.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}${assetFileAbsoluteUrlPathname}")`;
}
const assetFileRelativeUrlPathname = posix.relative(
cssFileRelativeDirPath.replace(/\\/g, "/"),
assetFileAbsoluteUrlPathname.replace(/^\//, "")
);
return `url("${assetFileRelativeUrlPathname}")`;
}
))
);
return { fixedCssCode, cssGlobalsToDefine };
}
export function generateCssCodeToDefineGlobals(params: {
cssGlobalsToDefine: Record<string, string>;
buildContext: BuildContextLike;
}): {
cssCodeToPrependInHead: string;
} {
const { cssGlobalsToDefine, buildContext } = params;
return {
cssCodeToPrependInHead: [
":root {",
...Object.keys(cssGlobalsToDefine)
.map(cssVariableName =>
[
`--${cssVariableName}:`,
cssGlobalsToDefine[cssVariableName].replace(
new RegExp(
`url\\(${(buildContext.urlPathname ?? "/").replace(
/\//g,
"\\/"
)}`,
"g"
),
`url(\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
)
].join(" ")
)
.map(line => ` ${line};`),
"}"
].join("\n")
};
return { fixedCssCode };
}

View File

@ -1,28 +0,0 @@
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import { basenameOfTheKeycloakifyResourcesDir } from "../../shared/constants";
export type BuildContextLike = {
urlPathname: string | undefined;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInInlineCssCode(params: {
cssCode: string;
buildContext: BuildContextLike;
}): {
fixedCssCode: string;
} {
const { cssCode, buildContext } = params;
const fixedCssCode = cssCode.replace(
buildContext.urlPathname === undefined
? /url\(["']?\/([^/][^)"']+)["']?\)/g
: new RegExp(`url\\(["']?${buildContext.urlPathname}([^)"']+)["']?\\)`, "g"),
(...[, group]) =>
`url(\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/${group})`
);
return { fixedCssCode };
}

View File

@ -1,4 +1,4 @@
import { basenameOfTheKeycloakifyResourcesDir } from "../../../shared/constants";
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "../../../shared/constants";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../../../shared/buildContext";
import * as nodePath from "path";
@ -31,13 +31,13 @@ export function replaceImportsInJsCode_vite(params: {
let fixedJsCode = jsCode;
replace_base_javacript_import: {
replace_base_js_import: {
if (buildContext.urlPathname === undefined) {
break replace_base_javacript_import;
break replace_base_js_import;
}
// Optimization
if (!jsCode.includes(buildContext.urlPathname)) {
break replace_base_javacript_import;
break replace_base_js_import;
}
// Replace `Hv=function(e){return"/abcde12345/"+e}` by `Hv=function(e){return"/"+e}`
@ -85,13 +85,13 @@ export function replaceImportsInJsCode_vite(params: {
fixedJsCode = replaceAll(
fixedJsCode,
`"${relativePathOfAssetFile}"`,
`(window.kcContext.url.resourcesPath.substring(1) + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
`(window.kcContext["x-keycloakify"].resourcesPath.substring(1) + "/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/${relativePathOfAssetFile}")`
);
fixedJsCode = replaceAll(
fixedJsCode,
`"${buildContext.urlPathname ?? "/"}${relativePathOfAssetFile}"`,
`(window.kcContext.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
`(window.kcContext["x-keycloakify"].resourcesPath + "/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/${relativePathOfAssetFile}")`
);
});
}

View File

@ -1,4 +1,4 @@
import { basenameOfTheKeycloakifyResourcesDir } from "../../../shared/constants";
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "../../../shared/constants";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../../../shared/buildContext";
import * as nodePath from "path";
@ -83,14 +83,14 @@ export function replaceImportsInJsCode_webpack(params: {
var pd = Object.getOwnPropertyDescriptor(${n}, "p");
if( pd === undefined || pd.configurable ){
Object.defineProperty(${n}, "p", {
get: function() { return window.kcContext.url.resourcesPath; },
get: function() { return window.kcContext["x-keycloakify"].resourcesPath; },
set: function() {}
});
}
return "${u}";
})()] = ${
isArrowFunction ? `${e} =>` : `function(${e}) { return `
} "/${basenameOfTheKeycloakifyResourcesDir}/${staticDir}${language}/"`
} "/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/${staticDir}${language}/"`
.replace(/\s+/g, " ")
.trim();
}
@ -104,7 +104,7 @@ export function replaceImportsInJsCode_webpack(params: {
`[a-zA-Z]+\\.[a-zA-Z]+\\+"${staticDir.replace(/\//g, "\\/")}`,
"g"
),
`window.kcContext.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${staticDir}`
`window.kcContext["x-keycloakify"].resourcesPath + "/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/${staticDir}`
);
return { fixedJsCode };

View File

@ -3,11 +3,14 @@
import { termost } from "termost";
import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
import * as child_process from "child_process";
import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx";
export type CliCommandOptions = {
projectDirPath: string | undefined;
};
assertNoPnpmDlx();
const program = termost<CliCommandOptions>(
{
name: "keycloakify",
@ -75,7 +78,7 @@ program
program
.command<{
port: number;
port: number | undefined;
keycloakVersion: string | undefined;
realmJsonFilePath: string | undefined;
}>({
@ -93,7 +96,7 @@ program
return name;
})(),
description: ["Keycloak server port.", "Example `--port 8085`"].join(" "),
defaultValue: 8080
defaultValue: undefined
})
.option({
key: "keycloakVersion",
@ -176,6 +179,20 @@ program
}
});
program
.command({
name: "initialize-account-theme",
description: "Initialize the account theme."
})
.task({
skip,
handler: async cliCommandOptions => {
const { command } = await import("./initialize-account-theme");
await command({ cliCommandOptions });
}
});
program
.command({
name: "copy-keycloak-resources-to-public",

View File

@ -1,9 +1,9 @@
export type KeycloakVersionRange =
| KeycloakVersionRange.WithAccountTheme
| KeycloakVersionRange.WithoutAccountTheme;
| KeycloakVersionRange.WithAccountV1Theme
| KeycloakVersionRange.WithoutAccountV1Theme;
export namespace KeycloakVersionRange {
export type WithoutAccountTheme = "21-and-below" | "22-and-above";
export type WithoutAccountV1Theme = "21-and-below" | "22-and-above";
export type WithAccountTheme = "21-and-below" | "23" | "24" | "25-and-above";
export type WithAccountV1Theme = "21-and-below" | "23" | "24" | "25-and-above";
}

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,22 @@
export const nameOfTheLocalizationRealmOverridesUserProfileProperty =
"__localizationRealmOverridesUserProfile";
export const keycloak_resources = "keycloak-resources";
export const resources_common = "resources-common";
export const lastKeycloakVersionWithAccountV1 = "21.1.2";
export const basenameOfTheKeycloakifyResourcesDir = "dist";
export const KEYCLOAK_RESOURCES = "keycloak-resources";
export const RESOURCES_COMMON = "resources-common";
export const LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 = "21.1.2";
export const BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR = "dist";
export const themeTypes = ["login", "account"] as const;
export const accountV1ThemeName = "account-v1";
export const THEME_TYPES = ["login", "account"] as const;
export const ACCOUNT_V1_THEME_NAME = "account-v1";
export type ThemeType = (typeof themeTypes)[number];
export type ThemeType = (typeof THEME_TYPES)[number];
export const vitePluginSubScriptEnvNames = {
runPostBuildScript: "KEYCLOAKIFY_RUN_POST_BUILD_SCRIPT",
resolveViteConfig: "KEYCLOAKIFY_RESOLVE_VITE_CONFIG"
export const VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES = {
RUN_POST_BUILD_SCRIPT: "KEYCLOAKIFY_RUN_POST_BUILD_SCRIPT",
RESOLVE_VITE_CONFIG: "KEYCLOAKIFY_RESOLVE_VITE_CONFIG"
} as const;
export const buildForKeycloakMajorVersionEnvName =
export const BUILD_FOR_KEYCLOAK_MAJOR_VERSION_ENV_NAME =
"KEYCLOAKIFY_BUILD_FOR_KEYCLOAK_MAJOR_VERSION";
export const loginThemePageIds = [
export const LOGIN_THEME_PAGE_IDS = [
"login.ftl",
"login-username.ftl",
"login-password.ftl",
@ -55,7 +53,7 @@ export const loginThemePageIds = [
"webauthn-error.ftl"
] as const;
export const accountThemePageIds = [
export const ACCOUNT_THEME_PAGE_IDS = [
"password.ftl",
"account.ftl",
"sessions.ftl",
@ -65,7 +63,11 @@ export const accountThemePageIds = [
"federatedIdentity.ftl"
] as const;
export type LoginThemePageId = (typeof loginThemePageIds)[number];
export type AccountThemePageId = (typeof accountThemePageIds)[number];
export type LoginThemePageId = (typeof LOGIN_THEME_PAGE_IDS)[number];
export type AccountThemePageId = (typeof ACCOUNT_THEME_PAGE_IDS)[number];
export const containerName = "keycloak-keycloakify";
export const CONTAINER_NAME = "keycloak-keycloakify";
export const FALLBACK_LANGUAGE_TAG = "en";
export const LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT = "24.0.4";

View File

@ -4,9 +4,9 @@ import {
} from "./downloadKeycloakStaticResources";
import { join as pathJoin, relative as pathRelative } from "path";
import {
themeTypes,
keycloak_resources,
lastKeycloakVersionWithAccountV1
THEME_TYPES,
KEYCLOAK_RESOURCES,
LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1
} from "../shared/constants";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import { assert } from "tsafe/assert";
@ -26,7 +26,7 @@ export async function copyKeycloakResourcesToPublic(params: {
}) {
const { buildContext } = params;
const destDirPath = pathJoin(buildContext.publicDirPath, keycloak_resources);
const destDirPath = pathJoin(buildContext.publicDirPath, KEYCLOAK_RESOURCES);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
@ -37,10 +37,7 @@ export async function copyKeycloakResourcesToPublic(params: {
buildContext: {
loginThemeResourcesFromKeycloakVersion: readThisNpmPackageVersion(),
cacheDirPath: pathRelative(destDirPath, buildContext.cacheDirPath),
npmWorkspaceRootDirPath: pathRelative(
destDirPath,
buildContext.npmWorkspaceRootDirPath
)
fetchOptions: buildContext.fetchOptions
}
},
null,
@ -69,14 +66,14 @@ export async function copyKeycloakResourcesToPublic(params: {
fs.writeFileSync(pathJoin(destDirPath, ".gitignore"), Buffer.from("*", "utf8"));
for (const themeType of themeTypes) {
for (const themeType of THEME_TYPES) {
await downloadKeycloakStaticResources({
keycloakVersion: (() => {
switch (themeType) {
case "login":
return buildContext.loginThemeResourcesFromKeycloakVersion;
case "account":
return lastKeycloakVersionWithAccountV1;
return LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1;
}
})(),
themeType,

View File

@ -1,12 +1,12 @@
import { join as pathJoin, relative as pathRelative } from "path";
import { type BuildContext } from "./buildContext";
import { assert } from "tsafe/assert";
import { lastKeycloakVersionWithAccountV1 } from "./constants";
import { LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 } from "./constants";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
export type BuildContextLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
fetchOptions: BuildContext["fetchOptions"];
};
assert<BuildContext extends BuildContextLike ? true : false>();
@ -17,14 +17,14 @@ export async function downloadKeycloakDefaultTheme(params: {
}): Promise<{ defaultThemeDirPath: string }> {
const { keycloakVersion, buildContext } = params;
let kcNodeModulesKeepFilePaths: string[] | undefined = undefined;
let kcNodeModulesKeepFilePaths_lastAccountV1: string[] | undefined = undefined;
let kcNodeModulesKeepFilePaths: Set<string> | undefined = undefined;
let kcNodeModulesKeepFilePaths_lastAccountV1: Set<string> | undefined = undefined;
const { extractedDirPath } = await downloadAndExtractArchive({
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
cacheDirPath: buildContext.cacheDirPath,
npmWorkspaceRootDirPath: buildContext.npmWorkspaceRootDirPath,
uniqueIdOfOnOnArchiveFile: "downloadKeycloakDefaultTheme",
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "downloadKeycloakDefaultTheme",
onArchiveFile: async params => {
const fileRelativePath = pathRelative("theme", params.fileRelativePath);
@ -43,7 +43,7 @@ export async function downloadKeycloakDefaultTheme(params: {
}
last_account_v1_transformations: {
if (lastKeycloakVersionWithAccountV1 !== keycloakVersion) {
if (LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 !== keycloakVersion) {
break last_account_v1_transformations;
}
@ -72,16 +72,19 @@ export async function downloadKeycloakDefaultTheme(params: {
}
skip_node_modules: {
if (
!fileRelativePath.startsWith(
pathJoin("keycloak", "common", "resources", "node_modules")
)
) {
const nodeModulesRelativeDirPath = pathJoin(
"keycloak",
"common",
"resources",
"node_modules"
);
if (!fileRelativePath.startsWith(nodeModulesRelativeDirPath)) {
break skip_node_modules;
}
if (kcNodeModulesKeepFilePaths_lastAccountV1 === undefined) {
kcNodeModulesKeepFilePaths_lastAccountV1 = [
kcNodeModulesKeepFilePaths_lastAccountV1 = new Set([
pathJoin("patternfly", "dist", "css", "patternfly.min.css"),
pathJoin(
"patternfly",
@ -125,13 +128,19 @@ export async function downloadKeycloakDefaultTheme(params: {
"fonts",
"PatternFlyIcons-webfont.woff"
)
];
]);
}
for (const keepPath of kcNodeModulesKeepFilePaths_lastAccountV1) {
if (fileRelativePath.endsWith(keepPath)) {
break skip_node_modules;
}
const fileRelativeToNodeModulesPath = fileRelativePath.substring(
nodeModulesRelativeDirPath.length + 1
);
if (
kcNodeModulesKeepFilePaths_lastAccountV1.has(
fileRelativeToNodeModulesPath
)
) {
break skip_node_modules;
}
return;
@ -165,16 +174,19 @@ export async function downloadKeycloakDefaultTheme(params: {
}
skip_node_modules: {
if (
!fileRelativePath.startsWith(
pathJoin("keycloak", "common", "resources", "node_modules")
)
) {
const nodeModulesRelativeDirPath = pathJoin(
"keycloak",
"common",
"resources",
"node_modules"
);
if (!fileRelativePath.startsWith(nodeModulesRelativeDirPath)) {
break skip_node_modules;
}
if (kcNodeModulesKeepFilePaths === undefined) {
kcNodeModulesKeepFilePaths = [
kcNodeModulesKeepFilePaths = new Set([
pathJoin("@patternfly", "patternfly", "patternfly.min.css"),
pathJoin("patternfly", "dist", "css", "patternfly.min.css"),
pathJoin(
@ -231,14 +243,23 @@ export async function downloadKeycloakDefaultTheme(params: {
"fonts",
"PatternFlyIcons-webfont.woff"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Semibold-webfont.woff2"
),
pathJoin("patternfly", "dist", "img", "bg-login.jpg"),
pathJoin("jquery", "dist", "jquery.min.js")
];
]);
}
for (const keepPath of kcNodeModulesKeepFilePaths) {
if (fileRelativePath.endsWith(keepPath)) {
break skip_node_modules;
}
const fileRelativeToNodeModulesPath = fileRelativePath.substring(
nodeModulesRelativeDirPath.length + 1
);
if (kcNodeModulesKeepFilePaths.has(fileRelativeToNodeModulesPath)) {
break skip_node_modules;
}
return;

View File

@ -4,7 +4,7 @@ import {
downloadKeycloakDefaultTheme,
type BuildContextLike as BuildContextLike_downloadKeycloakDefaultTheme
} from "./downloadKeycloakDefaultTheme";
import { resources_common, type ThemeType } from "./constants";
import { RESOURCES_COMMON, type ThemeType } from "./constants";
import type { BuildContext } from "./buildContext";
import { assert } from "tsafe/assert";
import { existsAsync } from "../tools/fs.existsAsync";
@ -48,6 +48,6 @@ export async function downloadKeycloakStaticResources(params: {
transformCodebase({
srcDirPath: pathJoin(defaultThemeDirPath, "keycloak", "common", "resources"),
destDirPath: pathJoin(resourcesDirPath, resources_common)
destDirPath: pathJoin(resourcesDirPath, RESOURCES_COMMON)
});
}

View File

@ -1,14 +1,21 @@
import { assert } from "tsafe/assert";
import { assert, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
import type { BuildContext } from "./buildContext";
import * as fs from "fs/promises";
import { join as pathJoin } from "path";
import { existsAsync } from "../tools/fs.existsAsync";
import { z } from "zod";
export type BuildContextLike = {
projectDirPath: string;
themeNames: string[];
environmentVariables: { name: string; default: string }[];
themeSrcDirPath: string;
implementedThemeTypes: Pick<
BuildContext["implementedThemeTypes"],
"login" | "account"
>;
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
@ -18,12 +25,53 @@ export async function generateKcGenTs(params: {
}): Promise<void> {
const { buildContext } = params;
const filePath = pathJoin(buildContext.themeSrcDirPath, "kc.gen.ts");
const isReactProject: boolean = await (async () => {
const parsedPackageJson = await (async () => {
type ParsedPackageJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
dependencies: z.record(z.string()).optional(),
devDependencies: z.record(z.string()).optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
return zParsedPackageJson.parse(
JSON.parse(
(await fs.readFile(buildContext.packageJsonFilePath)).toString("utf8")
)
);
})();
return (
{
...parsedPackageJson.dependencies,
...parsedPackageJson.devDependencies
}.react !== undefined
);
})();
const filePath = pathJoin(
buildContext.themeSrcDirPath,
`kc.gen.ts${isReactProject ? "x" : ""}`
);
const currentContent = (await existsAsync(filePath))
? await fs.readFile(filePath)
: undefined;
const hasLoginTheme = buildContext.implementedThemeTypes.login.isImplemented;
const hasAccountTheme = buildContext.implementedThemeTypes.account.isImplemented;
const newContent = Buffer.from(
[
`/* prettier-ignore-start */`,
@ -36,6 +84,8 @@ export async function generateKcGenTs(params: {
``,
`// This file is auto-generated by Keycloakify`,
``,
isReactProject && `import { lazy, Suspense, type ReactNode } from "react";`,
``,
`export type ThemeName = ${buildContext.themeNames.map(themeName => `"${themeName}"`).join(" | ")};`,
``,
`export const themeNames: ThemeName[] = [${buildContext.themeNames.map(themeName => `"${themeName}"`).join(", ")}];`,
@ -54,9 +104,52 @@ export async function generateKcGenTs(params: {
2
)};`,
``,
`export type KcContext =`,
hasLoginTheme && ` | import("./login/KcContext").KcContext`,
hasAccountTheme && ` | import("./account/KcContext").KcContext`,
` ;`,
``,
`declare global {`,
` interface Window {`,
` kcContext?: KcContext;`,
` }`,
`}`,
``,
...(!isReactProject
? []
: [
hasLoginTheme &&
`export const KcLoginPage = lazy(() => import("./login/KcPage"));`,
hasAccountTheme &&
`export const KcAccountPage = lazy(() => import("./account/KcPage"));`,
``,
`export function KcPage(`,
` props: {`,
` kcContext: KcContext;`,
` fallback?: ReactNode;`,
` }`,
`) {`,
` const { kcContext, fallback } = props;`,
` return (`,
` <Suspense fallback={fallback}>`,
` {(() => {`,
` switch (kcContext.themeType) {`,
hasLoginTheme &&
` case "login": return <KcLoginPage kcContext={kcContext} />;`,
hasAccountTheme &&
` case "account": return <KcAccountPage kcContext={kcContext} />;`,
` }`,
` })()}`,
` </Suspense>`,
` );`,
`}`
]),
``,
`/* prettier-ignore-end */`,
``
].join("\n"),
]
.filter(item => typeof item === "string")
.join("\n"),
"utf8"
);
@ -65,4 +158,18 @@ export async function generateKcGenTs(params: {
}
await fs.writeFile(filePath, newContent);
delete_legacy_file: {
if (!isReactProject) {
break delete_legacy_file;
}
const legacyFilePath = filePath.replace(/tsx$/, "ts");
if (!(await existsAsync(legacyFilePath))) {
break delete_legacy_file;
}
await fs.unlink(legacyFilePath);
}
}

View File

@ -0,0 +1,201 @@
import { getLatestsSemVersionedTagFactory } from "../tools/octokit-addons/getLatestsSemVersionedTag";
import { Octokit } from "@octokit/rest";
import type { ReturnType } from "tsafe";
import type { Param0 } from "tsafe";
import { join as pathJoin, dirname as pathDirname } from "path";
import * as fs from "fs";
import { z } from "zod";
import { assert, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
import type { SemVer } from "../tools/SemVer";
import { same } from "evt/tools/inDepth/same";
import type { BuildContext } from "./buildContext";
import fetch from "make-fetch-happen";
type GetLatestsSemVersionedTag = ReturnType<
typeof getLatestsSemVersionedTagFactory
>["getLatestsSemVersionedTag"];
type Params = Param0<GetLatestsSemVersionedTag>;
type R = ReturnType<GetLatestsSemVersionedTag>;
let getLatestsSemVersionedTag_stateless: GetLatestsSemVersionedTag | undefined =
undefined;
const CACHE_VERSION = 1;
type Cache = {
version: typeof CACHE_VERSION;
entries: {
time: number;
params: Params;
result: R;
}[];
};
export type BuildContextLike = {
cacheDirPath: string;
fetchOptions: BuildContext["fetchOptions"];
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function getLatestsSemVersionedTag({
buildContext,
...params
}: Params & {
buildContext: BuildContextLike;
}): Promise<R> {
const cacheFilePath = pathJoin(
buildContext.cacheDirPath,
"latest-sem-versioned-tags.json"
);
const cacheLookupResult = (() => {
const getResult_currentCache = (currentCacheEntries: Cache["entries"]) => ({
hasCachedResult: false as const,
currentCache: {
version: CACHE_VERSION,
entries: currentCacheEntries
}
});
if (!fs.existsSync(cacheFilePath)) {
return getResult_currentCache([]);
}
let cache_json;
try {
cache_json = fs.readFileSync(cacheFilePath).toString("utf8");
} catch {
return getResult_currentCache([]);
}
let cache_json_parsed: unknown;
try {
cache_json_parsed = JSON.parse(cache_json);
} catch {
return getResult_currentCache([]);
}
const zSemVer = (() => {
type TargetType = SemVer;
const zTargetType = z.object({
major: z.number(),
minor: z.number(),
patch: z.number(),
rc: z.number().optional(),
parsedFrom: z.string()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const zCache = (() => {
type TargetType = Cache;
const zTargetType = z.object({
version: z.literal(CACHE_VERSION),
entries: z.array(
z.object({
time: z.number(),
params: z.object({
owner: z.string(),
repo: z.string(),
count: z.number(),
doIgnoreReleaseCandidates: z.boolean()
}),
result: z.array(
z.object({
tag: z.string(),
version: zSemVer
})
)
})
)
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
let cache: Cache;
try {
cache = zCache.parse(cache_json_parsed);
} catch {
return getResult_currentCache([]);
}
const cacheEntry = cache.entries.find(e => same(e.params, params));
if (cacheEntry === undefined) {
return getResult_currentCache(cache.entries);
}
if (Date.now() - cacheEntry.time > 3_600_000) {
return getResult_currentCache(cache.entries.filter(e => e !== cacheEntry));
}
return {
hasCachedResult: true as const,
cachedResult: cacheEntry.result
};
})();
if (cacheLookupResult.hasCachedResult) {
return cacheLookupResult.cachedResult;
}
const { currentCache } = cacheLookupResult;
getLatestsSemVersionedTag_stateless ??= (() => {
const octokit = (() => {
const githubToken = process.env.GITHUB_TOKEN;
const octokit = new Octokit({
...(githubToken === undefined ? {} : { auth: githubToken }),
request: {
fetch: (url: string, options?: any) =>
fetch(url, {
...options,
...buildContext.fetchOptions
})
}
});
return octokit;
})();
const { getLatestsSemVersionedTag } = getLatestsSemVersionedTagFactory({
octokit
});
return getLatestsSemVersionedTag;
})();
const result = await getLatestsSemVersionedTag_stateless(params);
currentCache.entries.push({
time: Date.now(),
params,
result
});
{
const dirPath = pathDirname(cacheFilePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
fs.writeFileSync(cacheFilePath, JSON.stringify(currentCache, null, 2));
return result;
}

View File

@ -1,92 +1,32 @@
import { getLatestsSemVersionedTagFactory } from "../tools/octokit-addons/getLatestsSemVersionedTag";
import { Octokit } from "@octokit/rest";
import {
getLatestsSemVersionedTag,
type BuildContextLike as BuildContextLike_getLatestsSemVersionedTag
} from "./getLatestsSemVersionedTag";
import cliSelect from "cli-select";
import { assert } from "tsafe/assert";
import { SemVer } from "../tools/SemVer";
import { join as pathJoin, dirname as pathDirname } from "path";
import * as fs from "fs";
import type { ReturnType } from "tsafe";
import { id } from "tsafe/id";
import type { BuildContext } from "./buildContext";
export type BuildContextLike = BuildContextLike_getLatestsSemVersionedTag & {};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function promptKeycloakVersion(params: {
startingFromMajor: number | undefined;
excludeMajorVersions: number[];
cacheDirPath: string;
buildContext: BuildContextLike;
}) {
const { startingFromMajor, excludeMajorVersions, cacheDirPath } = params;
const { getLatestsSemVersionedTag } = (() => {
const { octokit } = (() => {
const githubToken = process.env.GITHUB_TOKEN;
const octokit = new Octokit(
githubToken === undefined ? undefined : { auth: githubToken }
);
return { octokit };
})();
const { getLatestsSemVersionedTag } = getLatestsSemVersionedTagFactory({
octokit
});
return { getLatestsSemVersionedTag };
})();
const { startingFromMajor, excludeMajorVersions, buildContext } = params;
const semVersionedTagByMajor = new Map<number, { tag: string; version: SemVer }>();
const semVersionedTags = await (async () => {
const cacheFilePath = pathJoin(cacheDirPath, "keycloak-versions.json");
type Cache = {
time: number;
semVersionedTags: ReturnType<typeof getLatestsSemVersionedTag>;
};
use_cache: {
if (!fs.existsSync(cacheFilePath)) {
break use_cache;
}
const cache: Cache = JSON.parse(
fs.readFileSync(cacheFilePath).toString("utf8")
);
if (Date.now() - cache.time > 3_600_000) {
fs.unlinkSync(cacheFilePath);
break use_cache;
}
return cache.semVersionedTags;
}
const semVersionedTags = await getLatestsSemVersionedTag({
count: 50,
owner: "keycloak",
repo: "keycloak"
});
{
const dirPath = pathDirname(cacheFilePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
fs.writeFileSync(
cacheFilePath,
JSON.stringify(
id<Cache>({
time: Date.now(),
semVersionedTags
}),
null,
2
)
);
return semVersionedTags;
})();
const semVersionedTags = await getLatestsSemVersionedTag({
count: 50,
owner: "keycloak",
repo: "keycloak",
doIgnoreReleaseCandidates: true,
buildContext
});
semVersionedTags.forEach(semVersionedTag => {
if (
@ -115,7 +55,7 @@ export async function promptKeycloakVersion(params: {
});
const lastMajorVersions = Array.from(semVersionedTagByMajor.values()).map(
({ tag }) => tag
({ version }) => `${version.major}.${version.minor}`
);
const { value } = await cliSelect<string>({

View File

@ -1,17 +1,19 @@
import * as child_process from "child_process";
import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import type { BuildContext } from "../shared/buildContext";
import chalk from "chalk";
import { sep as pathSep, join as pathJoin } from "path";
import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath";
import * as fs from "fs";
import { join as pathJoin } from "path";
import { dirname as pathDirname, relative as pathRelative } from "path";
export type BuildContextLike = {
projectDirPath: string;
keycloakifyBuildDirPath: string;
bundler: "vite" | "webpack";
npmWorkspaceRootDirPath: string;
bundler: BuildContext["bundler"];
projectBuildDirPath: string;
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
@ -21,95 +23,29 @@ export async function appBuild(params: {
}): Promise<{ isAppBuildSuccess: boolean }> {
const { buildContext } = params;
const { bundler } = buildContext;
switch (buildContext.bundler) {
case "vite":
return appBuild_vite({ buildContext });
case "webpack":
return appBuild_webpack({ buildContext });
}
}
const { command, args, cwd } = (() => {
switch (bundler) {
case "vite":
return {
command: "npx",
args: ["vite", "build"],
cwd: buildContext.projectDirPath
};
case "webpack": {
for (const dirPath of [
buildContext.projectDirPath,
buildContext.npmWorkspaceRootDirPath
]) {
try {
const parsedPackageJson = JSON.parse(
fs
.readFileSync(pathJoin(dirPath, "package.json"))
.toString("utf8")
);
async function appBuild_vite(params: {
buildContext: BuildContextLike;
}): Promise<{ isAppBuildSuccess: boolean }> {
const { buildContext } = params;
const [scriptName] =
Object.entries(parsedPackageJson.scripts).find(
([, scriptValue]) => {
assert(is<string>(scriptValue));
if (
scriptValue.includes("webpack") &&
scriptValue.includes("--mode production")
) {
return true;
}
assert(buildContext.bundler === "vite");
if (
scriptValue.includes("react-scripts") &&
scriptValue.includes("build")
) {
return true;
}
const dIsSuccess = new Deferred<boolean>();
if (
scriptValue.includes("react-app-rewired") &&
scriptValue.includes("build")
) {
return true;
}
console.log(chalk.blue("$ npx vite build"));
if (
scriptValue.includes("craco") &&
scriptValue.includes("build")
) {
return true;
}
if (
scriptValue.includes("ng") &&
scriptValue.includes("build")
) {
return true;
}
return false;
}
) ?? [];
if (scriptName === undefined) {
continue;
}
return {
command: "npm",
args: ["run", scriptName],
cwd: dirPath
};
} catch {
continue;
}
}
throw new Error(
"Keycloakify was unable to determine which script is responsible for building the app."
);
}
}
})();
const dResult = new Deferred<{ isSuccess: boolean }>();
const child = child_process.spawn(command, args, { cwd, shell: true });
const child = child_process.spawn("npx", ["vite", "build"], {
cwd: buildContext.projectDirPath,
shell: true
});
child.stdout.on("data", data => {
if (data.toString("utf8").includes("gzip:")) {
@ -121,9 +57,128 @@ export async function appBuild(params: {
child.stderr.on("data", data => process.stderr.write(data));
child.on("exit", code => dResult.resolve({ isSuccess: code === 0 }));
child.on("exit", code => dIsSuccess.resolve(code === 0));
const { isSuccess } = await dResult.pr;
const isSuccess = await dIsSuccess.pr;
return { isAppBuildSuccess: isSuccess };
}
async function appBuild_webpack(params: {
buildContext: BuildContextLike;
}): Promise<{ isAppBuildSuccess: boolean }> {
const { buildContext } = params;
assert(buildContext.bundler === "webpack");
const entries = Object.entries(
(JSON.parse(fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8"))
.scripts ?? {}) as Record<string, string>
).filter(([, scriptCommand]) => scriptCommand.includes("keycloakify build"));
if (entries.length === 0) {
console.log(
chalk.red(
[
`You should have a script in your package.json at ${pathRelative(process.cwd(), pathDirname(buildContext.packageJsonFilePath))}`,
`that includes the 'keycloakify build' command`
].join(" ")
)
);
process.exit(-1);
}
const entry =
entries.length === 1
? entries[0]
: entries.find(([scriptName]) => scriptName === "build-keycloak-theme");
if (entry === undefined) {
console.log(
chalk.red(
"There's multiple candidate script for building your app, name one 'build-keycloak-theme'"
)
);
process.exit(-1);
}
const [scriptName, scriptCommand] = entry;
const { appBuildSubCommands } = (() => {
const appBuildSubCommands: string[] = [];
for (const subCmd of scriptCommand.split("&&").map(s => s.trim())) {
if (subCmd.includes("keycloakify build")) {
break;
}
appBuildSubCommands.push(subCmd);
}
return { appBuildSubCommands };
})();
if (appBuildSubCommands.length === 0) {
console.log(
chalk.red(
`Your ${scriptName} script should look like "... && keycloakify build ..."`
)
);
process.exit(-1);
}
let commandCwd = pathDirname(buildContext.packageJsonFilePath);
for (const subCommand of appBuildSubCommands) {
const dIsSuccess = new Deferred<boolean>();
const [command, ...args] = subCommand.split(" ");
if (command === "cd") {
const [pathIsh] = args;
commandCwd = getAbsoluteAndInOsFormatPath({
pathIsh,
cwd: commandCwd
});
continue;
}
console.log(chalk.blue(`$ ${subCommand}`));
const child = child_process.spawn(command, args, {
cwd: commandCwd,
env: {
...process.env,
PATH: (() => {
const separator = pathSep === "/" ? ":" : ";";
return [
pathJoin(
pathDirname(buildContext.packageJsonFilePath),
"node_modules",
".bin"
),
...(process.env.PATH ?? "").split(separator)
].join(separator);
})()
},
shell: true
});
child.stdout.on("data", data => process.stdout.write(data));
child.stderr.on("data", data => process.stderr.write(data));
child.on("exit", code => dIsSuccess.resolve(code === 0));
const isSuccess = await dIsSuccess.pr;
if (!isSuccess) {
return { isAppBuildSuccess: false };
}
}
return { isAppBuildSuccess: true };
}

View File

@ -1,14 +1,13 @@
import { buildForKeycloakMajorVersionEnvName } from "../shared/constants";
import { BUILD_FOR_KEYCLOAK_MAJOR_VERSION_ENV_NAME } from "../shared/constants";
import * as child_process from "child_process";
import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext";
import chalk from "chalk";
export type BuildContextLike = {
projectDirPath: string;
keycloakifyBuildDirPath: string;
bundler: "vite" | "webpack";
npmWorkspaceRootDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
@ -21,11 +20,13 @@ export async function keycloakifyBuild(params: {
const dResult = new Deferred<{ isSuccess: boolean }>();
console.log(chalk.blue("$ npx keycloakify build"));
const child = child_process.spawn("npx", ["keycloakify", "build"], {
cwd: buildContext.projectDirPath,
env: {
...process.env,
[buildForKeycloakMajorVersionEnvName]: `${buildForKeycloakMajorVersionNumber}`
[BUILD_FOR_KEYCLOAK_MAJOR_VERSION_ENV_NAME]: `${buildForKeycloakMajorVersionNumber}`
},
shell: true
});

View File

@ -73,7 +73,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["view-profile", "manage-account"]
"account": ["delete-account", "view-profile", "manage-account"]
}
},
"clientRole": false,
@ -611,8 +611,8 @@
"name": "",
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "",
"baseUrl": "",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2099,7 +2099,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -73,7 +73,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["view-profile", "manage-account"]
"account": ["delete-account", "view-profile", "manage-account"]
}
},
"clientRole": false,
@ -618,8 +618,8 @@
"name": "",
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "",
"baseUrl": "",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2130,7 +2130,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -73,7 +73,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["view-profile", "manage-account"]
"account": ["delete-account", "view-profile", "manage-account"]
}
},
"clientRole": false,
@ -628,8 +628,8 @@
"name": "",
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "",
"baseUrl": "",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2140,7 +2140,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -73,7 +73,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["view-profile", "manage-account"]
"account": ["delete-account", "view-profile", "manage-account"]
}
},
"clientRole": false,
@ -632,8 +632,8 @@
"name": "",
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "",
"baseUrl": "",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2144,7 +2144,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -1,6 +1,8 @@
{
"id": "34c5f904-d66e-4d8f-8876-8f00d9fa9d6c",
"realm": "myrealm",
"displayName": "",
"displayNameHtml": "",
"notBefore": 0,
"defaultSignatureAlgorithm": "RS256",
"revokeRefreshToken": false,
@ -53,7 +55,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["view-profile", "manage-account"]
"account": ["delete-account", "view-profile", "manage-account"]
}
},
"clientRole": false,
@ -642,7 +644,7 @@
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -1356,11 +1358,11 @@
"config": {
"allowed-protocol-mapper-types": [
"oidc-sha256-pairwise-sub-mapper",
"oidc-address-mapper",
"saml-user-property-mapper",
"oidc-address-mapper",
"oidc-full-name-mapper",
"oidc-usermodel-attribute-mapper",
"saml-role-list-mapper",
"oidc-usermodel-attribute-mapper",
"saml-user-attribute-mapper",
"oidc-usermodel-property-mapper"
]
@ -1431,13 +1433,13 @@
"subComponents": {},
"config": {
"allowed-protocol-mapper-types": [
"saml-user-property-mapper",
"saml-role-list-mapper",
"oidc-full-name-mapper",
"oidc-address-mapper",
"saml-user-attribute-mapper",
"oidc-sha256-pairwise-sub-mapper",
"saml-role-list-mapper",
"oidc-address-mapper",
"oidc-usermodel-attribute-mapper",
"saml-user-property-mapper",
"oidc-usermodel-property-mapper"
]
}
@ -2086,7 +2088,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}
@ -2127,17 +2129,20 @@
"dockerAuthenticationFlow": "docker auth",
"attributes": {
"cibaBackchannelTokenDeliveryMode": "poll",
"cibaExpiresIn": "120",
"cibaAuthRequestedUserHint": "login_hint",
"oauth2DeviceCodeLifespan": "600",
"clientOfflineSessionMaxLifespan": "0",
"oauth2DevicePollingInterval": "5",
"clientSessionIdleTimeout": "0",
"parRequestUriLifespan": "60",
"clientSessionMaxLifespan": "0",
"userProfileEnabled": "true",
"clientOfflineSessionIdleTimeout": "0",
"cibaInterval": "5",
"realmReusableOtpCode": "false"
"realmReusableOtpCode": "false",
"cibaExpiresIn": "120",
"oauth2DeviceCodeLifespan": "600",
"parRequestUriLifespan": "60",
"clientSessionMaxLifespan": "0",
"frontendUrl": "",
"acr.loa.map": "{}"
},
"keycloakVersion": "23.0.7",
"userManagedAccessAllowed": false,

View File

@ -63,7 +63,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["manage-account", "view-profile"]
"account": ["delete-account", "manage-account", "view-profile"]
}
},
"clientRole": false,
@ -449,7 +449,7 @@
"gender": ["prefer_not_to_say"],
"bio": ["Hello I'm Test User and I do not exist."],
"phone_number": ["1111111111"],
"locale": ["fr"],
"locale": ["en"],
"favorite_media": ["movies", "series"]
},
"createdTimestamp": 1716183898408,
@ -653,7 +653,7 @@
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2235,7 +2235,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -63,7 +63,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["manage-account", "view-profile"]
"account": ["delete-account", "manage-account", "view-profile"]
}
},
"clientRole": false,
@ -543,7 +543,7 @@
"favourite_pet": ["cat"],
"bio": ["Hello I'm Test User and I do not exist."],
"phone_number": ["1111111111"],
"locale": ["fr"],
"locale": ["en"],
"favorite_media": ["movies", "series"]
},
"createdTimestamp": 1716183898408,
@ -767,7 +767,7 @@
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2315,7 +2315,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -2,15 +2,16 @@ import { getBuildContext } from "../shared/buildContext";
import { exclude } from "tsafe/exclude";
import type { CliCommandOptions as CliCommandOptions_common } from "../main";
import { promptKeycloakVersion } from "../shared/promptKeycloakVersion";
import { accountV1ThemeName, containerName } from "../shared/constants";
import { ACCOUNT_V1_THEME_NAME, CONTAINER_NAME } from "../shared/constants";
import { SemVer } from "../tools/SemVer";
import { assert } from "tsafe/assert";
import { assert, type Equals } from "tsafe/assert";
import * as fs from "fs";
import {
join as pathJoin,
relative as pathRelative,
sep as pathSep,
basename as pathBasename
basename as pathBasename,
dirname as pathDirname
} from "path";
import * as child_process from "child_process";
import chalk from "chalk";
@ -26,9 +27,10 @@ import { keycloakifyBuild } from "./keycloakifyBuild";
import { isInside } from "../tools/isInside";
import { existsAsync } from "../tools/fs.existsAsync";
import { rm } from "../tools/fs.rm";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
export type CliCommandOptions = CliCommandOptions_common & {
port: number;
port: number | undefined;
keycloakVersion: string | undefined;
realmJsonFilePath: string | undefined;
};
@ -88,30 +90,65 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const buildContext = getBuildContext({ cliCommandOptions });
const { keycloakVersion } = await (async () => {
const { dockerImageTag } = await (async () => {
if (cliCommandOptions.keycloakVersion !== undefined) {
return { dockerImageTag: cliCommandOptions.keycloakVersion };
}
if (buildContext.startKeycloakOptions.dockerImage !== undefined) {
return {
keycloakVersion: cliCommandOptions.keycloakVersion,
keycloakMajorNumber: SemVer.parse(cliCommandOptions.keycloakVersion).major
dockerImageTag: buildContext.startKeycloakOptions.dockerImage.tag
};
}
console.log(
chalk.cyan("On which version of Keycloak do you want to test your theme?")
[
chalk.cyan(
"On which version of Keycloak do you want to test your theme?"
),
chalk.gray(
"You can also explicitly provide the version with `npx keycloakify start-keycloak --keycloak-version 25.0.2` (or any other version)"
)
].join("\n")
);
const { keycloakVersion } = await promptKeycloakVersion({
startingFromMajor: 18,
excludeMajorVersions: [22],
cacheDirPath: buildContext.cacheDirPath
buildContext
});
console.log(`${keycloakVersion}`);
return { keycloakVersion };
return { dockerImageTag: keycloakVersion };
})();
const keycloakMajorVersionNumber = SemVer.parse(keycloakVersion).major;
const keycloakMajorVersionNumber = (() => {
if (buildContext.startKeycloakOptions.dockerImage === undefined) {
return SemVer.parse(dockerImageTag).major;
}
const { tag } = buildContext.startKeycloakOptions.dockerImage;
const [wrap] = [18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28]
.map(majorVersionNumber => ({
majorVersionNumber,
index: tag.indexOf(`${majorVersionNumber}`)
}))
.filter(({ index }) => index !== -1)
.sort((a, b) => a.index - b.index);
if (wrap === undefined) {
console.warn(
chalk.yellow(
`Could not determine the major Keycloak version number from the docker image tag ${tag}. Assuming 25`
)
);
return 25;
}
return wrap.majorVersionNumber;
})();
{
const { isAppBuildSuccess } = await appBuild({
@ -121,7 +158,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
if (!isAppBuildSuccess) {
console.log(
chalk.red(
`App build failed, exiting. Try running 'npm run build' and see what's wrong.`
`App build failed, exiting. Try building your app (e.g 'npm run build') and see what's wrong.`
)
);
process.exit(1);
@ -150,41 +187,68 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
assert(jarFilePath !== undefined);
console.log(`Using ${chalk.bold(pathBasename(jarFilePath))}`);
const extensionJarFilePaths = await Promise.all(
buildContext.startKeycloakOptions.extensionJars.map(async extensionJar => {
switch (extensionJar.type) {
case "path": {
assert(
await existsAsync(extensionJar.path),
`${extensionJar.path} does not exist`
);
return extensionJar.path;
}
case "url": {
const { archiveFilePath } = await downloadAndExtractArchive({
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
url: extensionJar.url,
uniqueIdOfOnArchiveFile: "no extraction",
onArchiveFile: async () => {}
});
return archiveFilePath;
}
}
assert<Equals<typeof extensionJar, never>>(false);
})
);
const getRealmJsonFilePath_defaultForKeycloakMajor = (
keycloakMajorVersionNumber: number
) =>
pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"start-keycloak",
`myrealm-realm-${keycloakMajorVersionNumber}.json`
);
const realmJsonFilePath = await (async () => {
if (cliCommandOptions.realmJsonFilePath !== undefined) {
if (cliCommandOptions.realmJsonFilePath === "none") {
return undefined;
}
console.log(
chalk.green(
`Using realm json file: ${cliCommandOptions.realmJsonFilePath}`
)
);
return getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.realmJsonFilePath,
cwd: process.cwd()
});
}
if (buildContext.startKeycloakOptions.realmJsonFilePath !== undefined) {
assert(
await existsAsync(buildContext.startKeycloakOptions.realmJsonFilePath),
`${pathRelative(process.cwd(), buildContext.startKeycloakOptions.realmJsonFilePath)} does not exist`
);
return buildContext.startKeycloakOptions.realmJsonFilePath;
}
const internalFilePath = await (async () => {
const dirPath = pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"start-keycloak"
const defaultFilePath = getRealmJsonFilePath_defaultForKeycloakMajor(
keycloakMajorVersionNumber
);
const filePath = pathJoin(
dirPath,
`myrealm-realm-${keycloakMajorVersionNumber}.json`
);
if (fs.existsSync(filePath)) {
return filePath;
if (fs.existsSync(defaultFilePath)) {
return defaultFilePath;
}
console.log(
@ -195,6 +259,8 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(chalk.cyan("Select what configuration to use:"));
const dirPath = pathDirname(defaultFilePath);
const { value } = await cliSelect<string>({
values: [
...fs
@ -236,6 +302,40 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
return filePath;
})();
add_test_user_if_missing: {
if (realmJsonFilePath === undefined) {
break add_test_user_if_missing;
}
const realm: Record<string, unknown> = JSON.parse(
fs.readFileSync(realmJsonFilePath).toString("utf8")
);
if (realm.users !== undefined) {
break add_test_user_if_missing;
}
const realmJsonFilePath_internal = (() => {
const filePath = getRealmJsonFilePath_defaultForKeycloakMajor(
keycloakMajorVersionNumber
);
if (!fs.existsSync(filePath)) {
return getRealmJsonFilePath_defaultForKeycloakMajor(25);
}
return filePath;
})();
const users = JSON.parse(
fs.readFileSync(realmJsonFilePath_internal).toString("utf8")
).users;
realm.users = users;
fs.writeFileSync(realmJsonFilePath, JSON.stringify(realm, null, 2), "utf8");
}
async function extractThemeResourcesFromJar() {
await extractArchive({
archiveFilePath: jarFilePath,
@ -269,82 +369,110 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
fs.copyFileSync(jarFilePath, jarFilePath_cacheDir);
try {
child_process.execSync(`docker rm --force ${containerName}`, {
child_process.execSync(`docker rm --force ${CONTAINER_NAME}`, {
stdio: "ignore"
});
} catch {}
const spawnArgs = [
"docker",
[
"run",
...["-p", `${cliCommandOptions.port}:8080`],
...["--name", containerName],
...["-e", "KEYCLOAK_ADMIN=admin"],
...["-e", "KEYCLOAK_ADMIN_PASSWORD=admin"],
...(realmJsonFilePath === undefined
? []
: [
"-v",
`${realmJsonFilePath}:/opt/keycloak/data/import/myrealm-realm.json`
]),
...[
"-v",
`${jarFilePath_cacheDir}:/opt/keycloak/providers/keycloak-theme.jar`
],
...(keycloakMajorVersionNumber <= 20
? ["-e", "JAVA_OPTS=-Dkeycloak.profile=preview"]
: []),
...[
...buildContext.themeNames,
...(fs.existsSync(
pathJoin(
buildContext.keycloakifyBuildDirPath,
"theme",
accountV1ThemeName
)
)
? [accountV1ThemeName]
: [])
]
.map(themeName => ({
localDirPath: pathJoin(
buildContext.keycloakifyBuildDirPath,
"theme",
themeName
),
containerDirPath: `/opt/keycloak/themes/${themeName}`
}))
.map(({ localDirPath, containerDirPath }) => [
"-v",
`${localDirPath}:${containerDirPath}:rw`
])
.flat(),
...buildContext.environmentVariables
.map(({ name }) => ({ name, envValue: process.env[name] }))
.map(({ name, envValue }) =>
envValue === undefined ? undefined : { name, envValue }
)
.filter(exclude(undefined))
.map(({ name, envValue }) => [
"--env",
`${name}='${envValue.replace(/'/g, "'\\''")}'`
])
.flat(),
`quay.io/keycloak/keycloak:${keycloakVersion}`,
"start-dev",
...(21 <= keycloakMajorVersionNumber && keycloakMajorVersionNumber < 24
? ["--features=declarative-user-profile"]
: []),
...(realmJsonFilePath === undefined ? [] : ["--import-realm"])
],
{
cwd: buildContext.keycloakifyBuildDirPath,
shell: true
}
] as const;
const DEFAULT_PORT = 8080;
const port =
cliCommandOptions.port ?? buildContext.startKeycloakOptions.port ?? DEFAULT_PORT;
const child = child_process.spawn(...spawnArgs);
const SPACE_PLACEHOLDER = "SPACE_PLACEHOLDER_xKLmdPd";
const dockerRunArgs: string[] = [
`-p${SPACE_PLACEHOLDER}${port}:8080`,
`--name${SPACE_PLACEHOLDER}${CONTAINER_NAME}`,
`-e${SPACE_PLACEHOLDER}KEYCLOAK_ADMIN=admin`,
`-e${SPACE_PLACEHOLDER}KEYCLOAK_ADMIN_PASSWORD=admin`,
...(buildContext.startKeycloakOptions.dockerExtraArgs.length === 0
? []
: [
buildContext.startKeycloakOptions.dockerExtraArgs.join(
SPACE_PLACEHOLDER
)
]),
...(realmJsonFilePath === undefined
? []
: [
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), realmJsonFilePath)}":/opt/keycloak/data/import/myrealm-realm.json`
]),
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), jarFilePath_cacheDir)}":/opt/keycloak/providers/keycloak-theme.jar`,
...extensionJarFilePaths.map(
jarFilePath =>
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), jarFilePath)}":/opt/keycloak/providers/${pathBasename(jarFilePath)}`
),
...(keycloakMajorVersionNumber <= 20
? [`-e${SPACE_PLACEHOLDER}JAVA_OPTS=-Dkeycloak.profile=preview`]
: []),
...[
...buildContext.themeNames,
...(fs.existsSync(
pathJoin(
buildContext.keycloakifyBuildDirPath,
"theme",
ACCOUNT_V1_THEME_NAME
)
)
? [ACCOUNT_V1_THEME_NAME]
: [])
]
.map(themeName => ({
localDirPath: pathJoin(
buildContext.keycloakifyBuildDirPath,
"theme",
themeName
),
containerDirPath: `/opt/keycloak/themes/${themeName}`
}))
.map(
({ localDirPath, containerDirPath }) =>
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), localDirPath)}":${containerDirPath}:rw`
),
...buildContext.environmentVariables
.map(({ name }) => ({ name, envValue: process.env[name] }))
.map(({ name, envValue }) =>
envValue === undefined ? undefined : { name, envValue }
)
.filter(exclude(undefined))
.map(
({ name, envValue }) =>
`--env${SPACE_PLACEHOLDER}${name}='${envValue.replace(/'/g, "'\\''")}'`
),
`${buildContext.startKeycloakOptions.dockerImage?.reference ?? "quay.io/keycloak/keycloak"}:${dockerImageTag}`,
"start-dev",
...(21 <= keycloakMajorVersionNumber && keycloakMajorVersionNumber < 24
? ["--features=declarative-user-profile"]
: []),
...(realmJsonFilePath === undefined ? [] : ["--import-realm"]),
...(buildContext.startKeycloakOptions.keycloakExtraArgs.length === 0
? []
: [
buildContext.startKeycloakOptions.keycloakExtraArgs.join(
SPACE_PLACEHOLDER
)
])
];
console.log(
chalk.blue(
[
`$ docker run \\`,
...dockerRunArgs
.map(arg => arg.replace(new RegExp(SPACE_PLACEHOLDER, "g"), " "))
.map(
(line, i, arr) =>
` ${line}${arr.length - 1 === i ? "" : " \\"}`
)
].join("\n")
)
);
const child = child_process.spawn(
"docker",
["run", ...dockerRunArgs.map(line => line.split(SPACE_PLACEHOLDER)).flat()],
{ shell: true }
);
child.stdout.on("data", data => process.stdout.write(data));
@ -355,6 +483,18 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const srcDirPath = pathJoin(buildContext.projectDirPath, "src");
{
const kcHttpRelativePath = (() => {
const match = buildContext.startKeycloakOptions.dockerExtraArgs
.join(" ")
.match(/KC_HTTP_RELATIVE_PATH=([^ ]+)/);
if (match === null) {
return undefined;
}
return match[1];
})();
const handler = async (data: Buffer) => {
if (!data.toString("utf8").includes("Listening on: http://0.0.0.0:8080")) {
return;
@ -372,7 +512,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
)} are mounted in the Keycloak container.`,
"",
`Keycloak Admin console: ${chalk.cyan.bold(
`http://localhost:${cliCommandOptions.port}`
`http://localhost:${port}${kcHttpRelativePath ?? ""}`
)}`,
`- user: ${chalk.cyan.bold("admin")}`,
`- password: ${chalk.cyan.bold("admin")}`,
@ -380,7 +520,21 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
"",
`${chalk.green("Your theme is accessible at:")}`,
`${chalk.green("➜")} ${chalk.cyan.bold(
`https://my-theme.keycloakify.dev${cliCommandOptions.port === 8080 ? "" : `?port=${cliCommandOptions.port}`}`
(() => {
const url = new URL("https://my-theme.keycloakify.dev");
if (port !== DEFAULT_PORT) {
url.searchParams.set("port", `${port}`);
}
if (kcHttpRelativePath !== undefined) {
url.searchParams.set(
"kcHttpRelativePath",
kcHttpRelativePath
);
}
return url.href;
})()
)}`,
"",
"You can login with the following credentials:",

View File

@ -0,0 +1,17 @@
import { sep as pathSep } from "path";
import chalk from "chalk";
export function assertNoPnpmDlx() {
if (__dirname.includes(`${pathSep}pnpm${pathSep}dlx${pathSep}`)) {
console.log(
[
chalk.red(
"Please don't use `pnpm dlx keycloakify` (download and execute)"
),
"\nUse `npx keycloakify` or `pnpm exec keycloakify` instead since you want to use the keycloakify",
"version that is installed in your project and not download and run the latest NPM version of keycloakify."
].join(" ")
);
process.exit(1);
}
}

View File

@ -1,16 +1,15 @@
import fetch from "make-fetch-happen";
import fetch, { type FetchOptions } from "make-fetch-happen";
import { mkdir, unlink, writeFile, readdir, readFile } from "fs/promises";
import { dirname as pathDirname, join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import { extractArchive } from "../extractArchive";
import { existsAsync } from "../fs.existsAsync";
import { getProxyFetchOptions } from "./fetchProxyOptions";
import { extractArchive } from "./extractArchive";
import { existsAsync } from "./fs.existsAsync";
import * as crypto from "crypto";
import { rm } from "../fs.rm";
import { rm } from "./fs.rm";
export async function downloadAndExtractArchive(params: {
url: string;
uniqueIdOfOnOnArchiveFile: string;
uniqueIdOfOnArchiveFile: string;
onArchiveFile: (params: {
fileRelativePath: string;
readFile: () => Promise<Buffer>;
@ -20,21 +19,18 @@ export async function downloadAndExtractArchive(params: {
}) => Promise<void>;
}) => Promise<void>;
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
}): Promise<{ extractedDirPath: string }> {
const {
url,
uniqueIdOfOnOnArchiveFile,
onArchiveFile,
cacheDirPath,
npmWorkspaceRootDirPath
} = params;
fetchOptions: FetchOptions | undefined;
}): Promise<{ extractedDirPath: string; archiveFilePath: string }> {
const { url, uniqueIdOfOnArchiveFile, onArchiveFile, cacheDirPath, fetchOptions } =
params;
const archiveFileBasename = url.split("?")[0].split("/").reverse()[0];
const archiveFilePath = pathJoin(cacheDirPath, archiveFileBasename);
download: {
await mkdir(pathDirname(archiveFilePath), { recursive: true });
if (await existsAsync(archiveFilePath)) {
const isDownloaded = await SuccessTracker.getIsDownloaded({
cacheDirPath,
@ -53,12 +49,7 @@ export async function downloadAndExtractArchive(params: {
});
}
await mkdir(pathDirname(archiveFilePath), { recursive: true });
const response = await fetch(
url,
await getProxyFetchOptions({ npmWorkspaceRootDirPath })
);
const response = await fetch(url, fetchOptions);
response.body?.setMaxListeners(Number.MAX_VALUE);
assert(typeof response.body !== "undefined" && response.body != null);
@ -71,7 +62,7 @@ export async function downloadAndExtractArchive(params: {
});
}
const extractDirBasename = `${archiveFileBasename.split(".")[0]}_${uniqueIdOfOnOnArchiveFile}_${crypto
const extractDirBasename = `${archiveFileBasename.replace(/\.([^.]+)$/, (...[, ext]) => `_${ext}`)}_${uniqueIdOfOnArchiveFile}_${crypto
.createHash("sha256")
.update(onArchiveFile.toString())
.digest("hex")
@ -93,7 +84,9 @@ export async function downloadAndExtractArchive(params: {
})()
)
.map(async extractDirBasename => {
await rm(pathJoin(cacheDirPath, extractDirBasename), { recursive: true });
await rm(pathJoin(cacheDirPath, extractDirBasename), {
recursive: true
});
await SuccessTracker.removeFromExtracted({
cacheDirPath,
extractDirBasename
@ -142,7 +135,7 @@ export async function downloadAndExtractArchive(params: {
});
}
return { extractedDirPath };
return { extractedDirPath, archiveFilePath };
}
type SuccessTracker = {

View File

@ -1,104 +0,0 @@
import { exec as execCallback } from "child_process";
import { readFile } from "fs/promises";
import { type FetchOptions } from "make-fetch-happen";
import { promisify } from "util";
function ensureArray<T>(arg0: T | T[]) {
return Array.isArray(arg0) ? arg0 : typeof arg0 === "undefined" ? [] : [arg0];
}
function ensureSingleOrNone<T>(arg0: T | T[]) {
if (!Array.isArray(arg0)) return arg0;
if (arg0.length === 0) return undefined;
if (arg0.length === 1) return arg0[0];
throw new Error(
"Illegal configuration, expected a single value but found multiple: " +
arg0.map(String).join(", ")
);
}
type NPMConfig = Record<string, string | string[]>;
/**
* Get npm configuration as map
*/
async function getNmpConfig(params: { npmWorkspaceRootDirPath: string }) {
const { npmWorkspaceRootDirPath } = params;
const exec = promisify(execCallback);
const stdout = await exec("npm config get", {
encoding: "utf8",
cwd: npmWorkspaceRootDirPath
}).then(({ stdout }) => stdout);
const npmConfigReducer = (cfg: NPMConfig, [key, value]: [string, string]) =>
key in cfg
? { ...cfg, [key]: [...ensureArray(cfg[key]), value] }
: { ...cfg, [key]: value };
return stdout
.split("\n")
.filter(line => !line.startsWith(";"))
.map(line => line.trim())
.map(line => line.split("=", 2) as [string, string])
.reduce(npmConfigReducer, {} as NPMConfig);
}
export type ProxyFetchOptions = Pick<
FetchOptions,
"proxy" | "noProxy" | "strictSSL" | "cert" | "ca"
>;
export async function getProxyFetchOptions(params: {
npmWorkspaceRootDirPath: string;
}): Promise<ProxyFetchOptions> {
const { npmWorkspaceRootDirPath } = params;
const cfg = await getNmpConfig({ npmWorkspaceRootDirPath });
const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]);
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
function maybeBoolean(arg0: string | undefined) {
return typeof arg0 === "undefined" ? undefined : Boolean(arg0);
}
const strictSSL = maybeBoolean(ensureSingleOrNone(cfg["strict-ssl"]));
const cert = cfg["cert"];
const ca = ensureArray(cfg["ca"] ?? cfg["ca[]"]);
const cafile = ensureSingleOrNone(cfg["cafile"]);
if (typeof cafile !== "undefined" && cafile !== "null") {
ca.push(
...(await (async () => {
function chunks<T>(arr: T[], size: number = 2) {
return arr
.map((_, i) => i % size == 0 && arr.slice(i, i + size))
.filter(Boolean) as T[][];
}
const cafileContent = await readFile(cafile, "utf-8");
const newLinePlaceholder = "NEW_LINE_PLACEHOLDER_xIsPsK23svt";
return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(
ca =>
ca
.join("")
.replace(/\r?\n/g, newLinePlaceholder)
.replace(new RegExp(`^${newLinePlaceholder}`), "")
.replace(new RegExp(newLinePlaceholder, "g"), "\\n")
);
})())
);
}
return {
proxy,
noProxy,
strictSSL,
cert,
ca: ca.length === 0 ? undefined : ca
};
}

View File

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

View File

@ -0,0 +1,117 @@
import { type FetchOptions } from "make-fetch-happen";
import * as child_process from "child_process";
import * as fs from "fs";
import { exclude } from "tsafe/exclude";
export type ProxyFetchOptions = Pick<
FetchOptions,
"proxy" | "noProxy" | "strictSSL" | "cert" | "ca"
>;
export function getProxyFetchOptions(params: {
npmConfigGetCwd: string;
}): ProxyFetchOptions {
const { npmConfigGetCwd } = params;
const cfg = (() => {
const output = child_process
.execSync("npm config get", {
cwd: npmConfigGetCwd
})
.toString("utf8");
return output
.split("\n")
.filter(line => !line.startsWith(";"))
.map(line => line.trim())
.map(line => {
const [key, value] = line.split("=");
if (key === undefined) {
return undefined;
}
if (value === undefined) {
return undefined;
}
return [key.trim(), value.trim()] as const;
})
.filter(exclude(undefined))
.filter(([key]) => key !== "")
.map(([key, value]) => {
if (value.startsWith('"') && value.endsWith('"')) {
return [key, value.slice(1, -1)] as const;
}
if (value === "true" || value === "false") {
return [key, value] as const;
}
return undefined;
})
.filter(exclude(undefined))
.reduce(
(cfg: Record<string, string | string[]>, [key, value]) =>
key in cfg
? { ...cfg, [key]: [...ensureArray(cfg[key]), value] }
: { ...cfg, [key]: value },
{}
);
})();
const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]);
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
const strictSSL = ensureSingleOrNone(cfg["strict-ssl"]) === "true";
const cert = cfg["cert"];
const ca = ensureArray(cfg["ca"] ?? cfg["ca[]"]);
const cafile = ensureSingleOrNone(cfg["cafile"]);
if (cafile !== undefined) {
ca.push(
...(() => {
const cafileContent = fs.readFileSync(cafile).toString("utf8");
const newLinePlaceholder = "NEW_LINE_PLACEHOLDER_xIsPsK23svt";
const chunks = <T>(arr: T[], size: number = 2) =>
arr
.map((_, i) => i % size == 0 && arr.slice(i, i + size))
.filter(Boolean) as T[][];
return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(
ca =>
ca
.join("")
.replace(/\r?\n/g, newLinePlaceholder)
.replace(new RegExp(`^${newLinePlaceholder}`), "")
.replace(new RegExp(newLinePlaceholder, "g"), "\\n")
);
})()
);
}
return {
proxy,
noProxy,
strictSSL,
cert,
ca: ca.length === 0 ? undefined : ca
};
}
function ensureArray<T>(arg0: T | T[]) {
return Array.isArray(arg0) ? arg0 : arg0 === undefined ? [] : [arg0];
}
function ensureSingleOrNone<T>(arg0: T | T[]) {
if (!Array.isArray(arg0)) return arg0;
if (arg0.length === 0) return undefined;
if (arg0.length === 1) return arg0[0];
throw new Error(
"Illegal configuration, expected a single value but found multiple: " +
arg0.map(String).join(", ")
);
}

View File

@ -1,73 +0,0 @@
import * as child_process from "child_process";
import { join as pathJoin, resolve as pathResolve, sep as pathSep } from "path";
import { assert } from "tsafe/assert";
import * as fs from "fs";
export function getNpmWorkspaceRootDirPath(params: {
projectDirPath: string;
dependencyExpected: string;
}) {
const { projectDirPath, dependencyExpected } = params;
const npmWorkspaceRootDirPath = (function callee(depth: number): string {
const cwd = pathResolve(
pathJoin(...[projectDirPath, ...Array(depth).fill("..")])
);
assert(cwd !== pathSep, "NPM workspace not found");
try {
child_process.execSync("npm config get", {
cwd,
stdio: "ignore"
});
} catch (error) {
if (String(error).includes("ENOWORKSPACES")) {
return callee(depth + 1);
}
throw error;
}
const packageJsonFilePath = pathJoin(cwd, "package.json");
if (!fs.existsSync(packageJsonFilePath)) {
return callee(depth + 1);
}
assert(fs.existsSync(packageJsonFilePath));
const parsedPackageJson = JSON.parse(
fs.readFileSync(packageJsonFilePath).toString("utf8")
);
let isExpectedDependencyFound = false;
for (const dependenciesOrDevDependencies of [
"dependencies",
"devDependencies"
] as const) {
const dependencies = parsedPackageJson[dependenciesOrDevDependencies];
if (dependencies === undefined) {
continue;
}
assert(dependencies instanceof Object);
if (dependencies[dependencyExpected] === undefined) {
continue;
}
isExpectedDependencyFound = true;
}
if (!isExpectedDependencyFound && parsedPackageJson.name !== dependencyExpected) {
return callee(depth + 1);
}
return cwd;
})(0);
return { npmWorkspaceRootDirPath };
}

View File

@ -0,0 +1,63 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import * as child_process from "child_process";
import chalk from "chalk";
export function npmInstall(params: { packageJsonDirPath: string }) {
const { packageJsonDirPath } = params;
const packageManagerBinName = (() => {
const packageMangers = [
{
binName: "yarn",
lockFileBasename: "yarn.lock"
},
{
binName: "npm",
lockFileBasename: "package-lock.json"
},
{
binName: "pnpm",
lockFileBasename: "pnpm-lock.yaml"
},
{
binName: "bun",
lockFileBasename: "bun.lockdb"
}
] as const;
for (const packageManager of packageMangers) {
if (
fs.existsSync(
pathJoin(packageJsonDirPath, packageManager.lockFileBasename)
) ||
fs.existsSync(pathJoin(process.cwd(), packageManager.lockFileBasename))
) {
return packageManager.binName;
}
}
return undefined;
})();
install_dependencies: {
if (packageManagerBinName === undefined) {
break install_dependencies;
}
console.log(`Installing the new dependencies...`);
try {
child_process.execSync(`${packageManagerBinName} install`, {
cwd: packageJsonDirPath,
stdio: "inherit"
});
} catch {
console.log(
chalk.yellow(
`\`${packageManagerBinName} install\` failed, continuing anyway...`
)
);
}
}
}

View File

@ -9,13 +9,14 @@ export function getLatestsSemVersionedTagFactory(params: { octokit: Octokit }) {
owner: string;
repo: string;
count: number;
doIgnoreReleaseCandidates: boolean;
}): Promise<
{
tag: string;
version: SemVer;
}[]
> {
const { owner, repo, count } = params;
const { owner, repo, count, doIgnoreReleaseCandidates } = params;
const semVersionedTags: { tag: string; version: SemVer }[] = [];
@ -30,7 +31,7 @@ export function getLatestsSemVersionedTagFactory(params: { octokit: Octokit }) {
continue;
}
if (version.rc !== undefined) {
if (doIgnoreReleaseCandidates && version.rc !== undefined) {
continue;
}

View File

@ -8,5 +8,7 @@
"moduleResolution": "node",
"outDir": "../../dist/bin",
"rootDir": "."
}
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["initialize-account-theme/src"]
}

View File

@ -73,8 +73,8 @@ export function createGetKcClsx<ClassKey extends string>(params: {
return clsx(
classKey,
doUseDefaultCss ? defaultClasses[classKey] : undefined,
classes?.[classKey]
classes?.[classKey] ??
(doUseDefaultCss ? defaultClasses[classKey] : undefined)
);
}
});

View File

@ -1,13 +1,8 @@
import type {
ThemeType,
LoginThemePageId,
nameOfTheLocalizationRealmOverridesUserProfileProperty
} from "keycloakify/bin/shared/constants";
import type { ExtractAfterStartingWith } from "keycloakify/tools/ExtractAfterStartingWith";
import type { ThemeType, LoginThemePageId } from "keycloakify/bin/shared/constants";
import type { ValueOf } from "keycloakify/tools/ValueOf";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import type { MessageKey } from "../i18n/i18n";
import type { ClassKey } from "keycloakify/login/TemplateProps";
export type ExtendKcContext<
KcContextExtension extends { properties?: Record<string, string | undefined> },
@ -84,8 +79,8 @@ export declare namespace KcContext {
};
realm: {
name: string;
displayName?: string;
displayNameHtml?: string;
displayName: string;
displayNameHtml: string;
internationalizationEnabled: boolean;
registrationEmailAsUsername: boolean;
};
@ -158,7 +153,9 @@ export declare namespace KcContext {
ssoLoginInOtherTabsUrl: string;
};
properties: {};
__localizationRealmOverridesUserProfile?: Record<string, string>;
"x-keycloakify": {
messages: Record<string, string>;
};
};
export type SamlPostForm = Common & {
@ -222,7 +219,7 @@ export declare namespace KcContext {
export type Info = Common & {
pageId: "info.ftl";
messageHeader?: string;
requiredActions?: ExtractAfterStartingWith<"requiredAction.", MessageKey>[];
requiredActions?: string[];
skipLink: boolean;
pageRedirectUri?: string;
actionUri?: string;
@ -276,6 +273,7 @@ export declare namespace KcContext {
lastName?: string;
markedForEviction?: boolean;
};
__localizationRealmOverridesTermsText?: string;
};
export type LoginDeviceVerifyUserCode = Common & {
@ -384,7 +382,7 @@ export declare namespace KcContext {
credentialId: string;
transports: {
iconClass: string;
displayNameProperties?: MessageKey[];
displayNameProperties?: string[];
};
label: string;
createdAt: string;
@ -501,26 +499,9 @@ export declare namespace KcContext {
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";
displayName: string;
helpText: string;
iconCssClass?: ClassKey;
};
}
@ -610,6 +591,7 @@ export type Attribute = {
value?: string;
values?: string[];
group?: {
annotations: Record<string, string>;
html5DataAnnotations: Record<string, string>;
displayHeader?: string;
name: string;
@ -772,11 +754,3 @@ export type PasswordPolicies = {
/** Whether the password can be the email address */
notEmail?: boolean;
};
assert<
KcContext.Common extends Partial<
Record<typeof nameOfTheLocalizationRealmOverridesUserProfileProperty, unknown>
>
? true
: false
>();

View File

@ -1,8 +1,8 @@
import "keycloakify/tools/Object.fromEntries";
import type { KcContext, Attribute } from "./KcContext";
import {
resources_common,
keycloak_resources,
RESOURCES_COMMON,
KEYCLOAK_RESOURCES,
type LoginThemePageId
} from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
@ -76,7 +76,7 @@ const attributesByName = Object.fromEntries(
]).map(attribute => [attribute.name, attribute])
);
const resourcesPath = `${BASE_URL}${keycloak_resources}/login/resources`;
const resourcesPath = `${BASE_URL}${KEYCLOAK_RESOURCES}/login/resources`;
export const kcContextCommonMock: KcContext.Common = {
themeVersion: "0.0.0",
@ -86,7 +86,7 @@ export const kcContextCommonMock: KcContext.Common = {
url: {
loginAction: "#",
resourcesPath,
resourcesCommonPath: `${resourcesPath}/${resources_common}`,
resourcesCommonPath: `${resourcesPath}/${RESOURCES_COMMON}`,
loginRestartFlowUrl: "#",
loginUrl: "#",
ssoLoginInOtherTabsUrl: "#"
@ -161,7 +161,9 @@ export const kcContextCommonMock: KcContext.Common = {
scripts: [],
isAppInitiatedAction: false,
properties: {},
__localizationRealmOverridesUserProfile: {}
"x-keycloakify": {
messages: {}
}
};
const loginUrl = {

View File

@ -15,7 +15,6 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
displayMessage = true,
displayRequiredFields = false,
headerNode,
showUsernameNode = null,
socialProvidersNode = null,
infoNode = null,
documentTitle,
@ -29,7 +28,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { msg, msgStr, getChangeLocaleUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { realm, locale, auth, url, message, isAppInitiatedAction, authenticationSession, scripts } = kcContext;
@ -153,7 +152,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
role="menuitem"
id={`language-${i + 1}`}
className={kcClsx("kcLocaleItemClass")}
href={getChangeLocalUrl(languageTag)}
href={getChangeLocaleUrl(languageTag)}
>
{labelBySupportedLanguageTag[languageTag]}
</a>
@ -164,45 +163,10 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div>
</div>
)}
{!(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
displayRequiredFields ? (
<div className={kcClsx("kcContentWrapperClass")}>
<div className={clsx(kcClsx("kcLabelWrapperClass"), "subtitle")}>
<span className="subtitle">
<span className="required">*</span>
{msg("requiredFields")}
</span>
</div>
<div className="col-md-10">
<h1 id="kc-page-title">{headerNode}</h1>
</div>
</div>
) : (
{(() => {
const node = !(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
<h1 id="kc-page-title">{headerNode}</h1>
)
) : displayRequiredFields ? (
<div className={kcClsx("kcContentWrapperClass")}>
<div className={clsx(kcClsx("kcLabelWrapperClass"), "subtitle")}>
<span className="subtitle">
<span className="required">*</span> {msg("requiredFields")}
</span>
</div>
<div className="col-md-10">
{showUsernameNode}
<div id="kc-username" className={kcClsx("kcFormGroupClass")}>
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
<div className="kc-login-tooltip">
<i className={kcClsx("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</div>
</div>
) : (
<>
{showUsernameNode}
) : (
<div id="kc-username" className={kcClsx("kcFormGroupClass")}>
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
@ -212,8 +176,24 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div>
</a>
</div>
</>
)}
);
if (displayRequiredFields) {
return (
<div className={kcClsx("kcContentWrapperClass")}>
<div className={clsx(kcClsx("kcLabelWrapperClass"), "subtitle")}>
<span className="subtitle">
<span className="required">*</span>
{msg("requiredFields")}
</span>
</div>
<div className="col-md-10">{node}</div>
</div>
);
}
return node;
})()}
</header>
<div id="kc-content">
<div id="kc-content-wrapper">
@ -244,19 +224,17 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
{auth !== undefined && auth.showTryAnotherWayLink && (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<div className={kcClsx("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<input type="hidden" name="tryAnotherWay" value="on" />
<a
href="#"
id="try-another-way"
onClick={() => {
document.forms["kc-select-try-another-way-form" as never].submit();
return false;
}}
>
{msg("doTryAnotherWay")}
</a>
</div>
<input type="hidden" name="tryAnotherWay" value="on" />
<a
href="#"
id="try-another-way"
onClick={() => {
document.forms["kc-select-try-another-way-form" as never].submit();
return false;
}}
>
{msg("doTryAnotherWay")}
</a>
</div>
</form>
)}

View File

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

View File

@ -1,5 +1,5 @@
import { useEffect, useReducer, Fragment } from "react";
import { assert } from "tsafe/assert";
import { assert } from "keycloakify/tools/assert";
import type { KcClsx } from "keycloakify/login/lib/kcClsx";
import {
useUserProfileForm,
@ -58,7 +58,7 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps<
<label htmlFor={attribute.name} className={kcClsx("kcLabelClass")}>
{advancedMsg(attribute.displayName ?? "")}
</label>
{attribute.required && <>*</>}
{attribute.required && <> *</>}
</div>
<div className={kcClsx("kcInputWrapperClass")}>
{attribute.annotations.inputHelperTextBefore !== undefined && (
@ -70,7 +70,7 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps<
{advancedMsg(attribute.annotations.inputHelperTextBefore)}
</div>
)}
<InputFiledByType
<InputFieldByType
attribute={attribute}
valueOrValues={valueOrValues}
displayableErrors={displayableErrors}
@ -188,7 +188,7 @@ function FieldErrors(props: { attribute: Attribute; displayableErrors: FormField
.filter(error => error.fieldIndex === fieldIndex)
.map(({ errorMessage }, i, arr) => (
<Fragment key={i}>
<span key={i}>{errorMessage}</span>
{errorMessage}
{arr.length - 1 !== i && <br />}
</Fragment>
))}
@ -196,7 +196,7 @@ function FieldErrors(props: { attribute: Attribute; displayableErrors: FormField
);
}
type InputFiledByTypeProps = {
type InputFieldByTypeProps = {
attribute: Attribute;
valueOrValues: string | string[];
displayableErrors: FormFieldError[];
@ -205,7 +205,7 @@ type InputFiledByTypeProps = {
kcClsx: KcClsx;
};
function InputFiledByType(props: InputFiledByTypeProps) {
function InputFieldByType(props: InputFieldByTypeProps) {
const { attribute, valueOrValues } = props;
switch (attribute.annotations.inputType) {
@ -274,9 +274,11 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
);
}
function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefined }) {
function InputTag(props: InputFieldByTypeProps & { fieldIndex: number | undefined }) {
const { attribute, fieldIndex, kcClsx, dispatchFormAction, valueOrValues, i18n, displayableErrors } = props;
const { advancedMsgStr } = i18n;
return (
<>
<input
@ -305,7 +307,9 @@ function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefine
aria-invalid={displayableErrors.find(error => error.fieldIndex === fieldIndex) !== undefined}
disabled={attribute.readOnly}
autoComplete={attribute.autocomplete}
placeholder={attribute.annotations.inputTypePlaceholder}
placeholder={
attribute.annotations.inputTypePlaceholder === undefined ? undefined : advancedMsgStr(attribute.annotations.inputTypePlaceholder)
}
pattern={attribute.annotations.inputTypePattern}
size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)}
maxLength={
@ -429,7 +433,7 @@ function AddRemoveButtonsMultiValuedAttribute(props: {
);
}
function InputTagSelects(props: InputFiledByTypeProps) {
function InputTagSelects(props: InputFieldByTypeProps) {
const { attribute, dispatchFormAction, kcClsx, valueOrValues } = props;
const { advancedMsg } = props.i18n;
@ -537,7 +541,7 @@ function InputTagSelects(props: InputFiledByTypeProps) {
);
}
function TextareaTag(props: InputFiledByTypeProps) {
function TextareaTag(props: InputFieldByTypeProps) {
const { attribute, dispatchFormAction, kcClsx, displayableErrors, valueOrValues } = props;
assert(typeof valueOrValues === "string");
@ -573,7 +577,7 @@ function TextareaTag(props: InputFiledByTypeProps) {
);
}
function SelectTag(props: InputFiledByTypeProps) {
function SelectTag(props: InputFieldByTypeProps) {
const { attribute, dispatchFormAction, kcClsx, displayableErrors, i18n, valueOrValues } = props;
const { advancedMsg } = i18n;

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