Compare commits

...

392 Commits

Author SHA1 Message Date
21ee42b5a4 Bump version 2024-09-10 10:41:35 +02:00
d20964ec94 Merge pull request #632 from keycloakify/passkey-conditional-authenticate
Passkey conditional authenticate
2024-09-10 10:40:57 +02:00
3155f5da66 registrationDisabled is optional 2024-09-10 10:12:33 +02:00
50e38b6a10 Complete rework of WebauthnRegister 2024-09-10 09:57:47 +02:00
72c31776d7 Update WebauthnAuthenticate 2024-09-09 08:49:59 +02:00
7456750828 Support the new recapcha without breaking for older keycloak 2024-09-09 08:25:00 +02:00
b8a08f0789 Social is now optional on the kcContext 2024-09-09 07:51:49 +02:00
28990a12da Fix LoginRecoveryAuthnCodeConfig 2024-09-09 07:39:45 +02:00
7e5abe8589 Fix LoginPasskesConditionalAuthenticate 2024-09-09 06:59:11 +02:00
1d57f4b4dc Include missing dependency file 2024-09-08 18:16:49 +02:00
9f875160ea Make template initialization not ejected by default 2024-09-08 17:31:55 +02:00
785ed095bc Repatriate keycloak v24 scripts 2024-09-08 14:41:45 +02:00
359e93a1ba Fix path error 2024-09-08 13:00:40 +02:00
ee6322aae4 Extract only required files 2024-09-08 12:52:29 +02:00
98d3d1967a Fix import error 2024-09-08 12:09:19 +02:00
01c3b148e6 Group all build time generated resource under a 'res' directory 2024-09-08 12:06:49 +02:00
77d3a5190d Refactor checkpoint 2024-09-08 12:00:07 +02:00
93c1c56279 Refactor checkpoint 2024-09-08 00:06:47 +02:00
8340608045 Merge branch 'main' into passkey-conditional-authenticate 2024-09-07 15:27:51 +02:00
5502a74994 Bump version 2024-09-05 01:23:13 +02:00
c5ef4c973b #598 2024-09-05 01:19:50 +02:00
dbae909903 Pulling the message resources of the account theme from the installed keycloak-account-ui version 2024-09-05 00:32:21 +02:00
74317a1f3c Remove extra "v" when initializing single page account theme 2024-09-05 00:31:33 +02:00
569e933f02 Release candidate 2024-09-02 03:37:36 +02:00
46c40d713a Fix broken path 2024-09-02 03:37:16 +02:00
f3602219f3 Release candidate 2024-09-02 03:27:09 +02:00
c6b52acf2f #631 2024-09-02 03:26:39 +02:00
7260589136 Always generates the pages for legacy keycloak no matter what. 2024-08-30 19:27:56 +02:00
b2e9ddaa4f Bump version 2024-08-30 15:35:45 +02:00
4338b3ecb7 #627 2024-08-30 15:35:21 +02:00
0f81d9f146 Update readme 2024-08-29 01:22:16 +02:00
9980b10a83 Bump version 2024-08-28 17:09:21 +02:00
6bfd388827 Fix bug package.json not properly updated when initializing the single page account theme with webpack 2024-08-28 17:08:11 +02:00
8203ed687b Bump version 2024-08-28 14:43:40 +02:00
f394e06e4d Fix bug in type validation for webpack when initializing the account theme 2024-08-28 14:43:25 +02:00
8db35a81da Bump version 2024-08-27 17:41:04 +02:00
2e0ebfcf58 https://github.com/keycloakify/keycloakify-starter/issues/25 2024-08-27 17:40:48 +02:00
51d2ff85e0 Bump version 2024-08-26 18:20:32 +02:00
8b54426b89 Fix invalid dom nesting 2024-08-26 18:20:32 +02:00
fa346c5b1f Merge pull request #621 from keycloakify/all-contributors/add-liamlows
docs: add liamlows as a contributor for code, and doc
2024-08-26 04:27:06 +02:00
d87788980d docs: update .all-contributorsrc [skip ci] 2024-08-26 02:26:54 +00:00
1e4319498c docs: update README.md [skip ci] 2024-08-26 02:26:53 +00:00
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
aba725372e Release candidate 2024-06-18 22:41:08 +02:00
a61aa9dd5d Add missing fonts from the account theme's default assets 2024-06-18 16:41:09 +02:00
74349b20ce Adding missing font from default theme resources 2024-06-17 13:26:32 +02:00
09ab9a1c8f Fix storybook build 2024-06-17 13:03:39 +02:00
abfe5789a3 Publish new storybook 2024-06-17 12:53:06 +02:00
67ebac496d Release candidate 2024-06-17 00:07:53 +02:00
60a2bf173b Add missing base font face 2024-06-17 00:07:38 +02:00
4e03f07864 Do not includes all shared source, it's bundled already 2024-06-17 00:00:41 +02:00
aef1709d7f Release candidate 2024-06-16 18:27:37 +02:00
2f590f7be2 Add missing file in npm bundle 2024-06-16 18:27:18 +02:00
d5fa6ca89a Fix unit tests 2024-06-16 17:55:06 +02:00
8eaaffb25a Release candidate 2024-06-16 15:19:44 +02:00
28c5e2bab2 Rename use 'dist' instead of 'build' for basenameOfTheKeycloakifyResourcesDir 2024-06-16 15:19:27 +02:00
e212039f2c Release cadidate 2024-06-16 14:59:11 +02:00
99b0b67f77 Add publicDirpath option for webpack 2024-06-16 14:58:51 +02:00
6ec9ba3c01 Add version in build options 2024-06-16 14:53:18 +02:00
d7960a7dcf Release candidate 2024-06-16 14:05:38 +02:00
2a6e9af9c9 Enable to use an other directory than build/assets in webpack 2024-06-16 14:05:23 +02:00
327e4d1f90 Add doc link 2024-06-16 11:48:39 +02:00
fffadd7b9e Release candidate 2024-06-16 11:11:53 +02:00
aaaf0d2e77 Add missing declaration files 2024-06-16 11:11:35 +02:00
9f9a9b8c90 Release candidate 2024-06-16 02:30:09 +02:00
1f6edb3c0c Use the configured jar file basename if any 2024-06-16 02:19:56 +02:00
142efb4f99 Do leave artifact in the build directory when using start-keycloak 2024-06-16 01:41:47 +02:00
532655d2d5 Rename jarTargets -> keycloakVersionTargets 2024-06-16 01:34:06 +02:00
287edabd90 Enable to build only for specific keycloak version 2024-06-16 01:29:15 +02:00
7aaedbe2ce Release candidate 2024-06-15 17:40:51 +02:00
4cae1c673c Use getAlgorithmKey in account 2024-06-15 17:33:27 +02:00
8e01d836a9 Cherrypick what resource from the default theme we keep 2024-06-15 17:32:58 +02:00
f6dc8f0741 Memoize getImplementThemeTypes 2024-06-15 14:45:22 +02:00
3a976d08d2 Release candidate 2024-06-15 14:40:56 +02:00
50e83b1eb5 Only build for specific keycloak version in start-keycloak 2024-06-15 14:30:18 +02:00
61fbbb0b09 Refactor how we update META-INF and how we read what theme types are implemented 2024-06-15 14:23:35 +02:00
9e70e5c12e Suggest 'npm run' instead of 'yarn' to be more generic 2024-06-15 11:27:03 +02:00
69d9b64468 Use tsx instead of ts-node 2024-06-15 11:23:53 +02:00
0620d29880 spawn in shell in local scripts 2024-06-15 01:06:06 +02:00
b52dc74d9b Release candidate 2024-06-14 23:59:16 +02:00
a46aef2e7e Use shell for Window resolution of envs 2024-06-14 23:58:54 +02:00
736806a53d Relase candidate 2024-06-14 22:25:23 +02:00
f1475e5cdf Settle on calling the global 'kcContext' and reduce levels of indirections 2024-06-14 22:24:51 +02:00
d04724c70a fetchProxyOptions compatibility Window OS 2024-06-14 21:53:17 +02:00
bacaadc16d Remove dead file 2024-06-14 21:52:46 +02:00
c51dd235f0 Release candidate 2024-06-14 21:31:26 +02:00
92f2c9857e Fix the linking script 2024-06-14 21:31:03 +02:00
3998cc7f8b Fix for the linking script on windows OS 2024-06-14 20:45:52 +02:00
c126d080bc Make tests pass on windows OS 2024-06-14 19:06:48 +02:00
bc05f1714d Fix windows OS compatibility issue 2024-06-14 18:59:25 +02:00
e98becb94b Release candidate 2024-06-13 22:58:50 +02:00
250b94c8b5 Fix missing build option for webpack 2024-06-13 22:58:32 +02:00
47f03f6833 Improve stories 2024-06-13 00:47:18 +02:00
6e7ae48f78 Update sotry 2024-06-13 00:30:07 +02:00
526dbcc0e7 Improve stories 2024-06-12 23:22:21 +02:00
1abc5a5643 Release candidate 2024-06-12 23:11:46 +02:00
c81c350136 Improve mock and stories 2024-06-12 23:11:06 +02:00
f90dc8bc7e fix syntax error 2024-06-12 22:52:53 +02:00
072e22d072 Exclude kcContext.execution 2024-06-12 22:18:55 +02:00
59807c1bb0 Patch only required on the login page 2024-06-12 22:17:58 +02:00
7c19e1f1f7 Fix wrong condition for displaying error in the template 2024-06-12 21:38:48 +02:00
3b9f915f57 Fix logical error in generating pom file 2024-06-12 20:39:03 +02:00
d85cc530d4 remove debug log 2024-06-12 20:25:44 +02:00
2bb27c7642 More compact ftl output 2024-06-12 20:13:44 +02:00
e90e003204 Fully remove comments #542 2024-06-12 20:12:11 +02:00
b1e58e1add Refactor how userFromField is passed down to the client 2024-06-12 19:41:05 +02:00
0fd836314a Release candidate 2024-06-12 14:48:26 +02:00
0bc3f08cc1 Rename generateSrcMainResources -> generateResources 2024-06-12 14:48:08 +02:00
a78af5080a Fix environement variables all on the same line 2024-06-12 14:43:53 +02:00
074e465284 Release candidate 2024-06-12 12:02:13 +02:00
bc8165d0ae Fix usage of dirname instead of basename 2024-06-12 12:01:55 +02:00
ba8561d75a Release candidate 2024-06-12 10:50:13 +02:00
b2d381ba4b Apply the name of the theme in the preconfigured realm 2024-06-12 10:50:00 +02:00
d39353d332 Release candidate 2024-06-12 09:20:25 +02:00
ee916af48e Provide default message for the info page 2024-06-12 09:20:10 +02:00
da1dc0309b Release candidate 2024-06-12 08:57:59 +02:00
30f4e7d833 Add PasswordPolicies on every page where there's user profile 2024-06-12 08:57:40 +02:00
cf3a86fb9b Release candidate 2024-06-11 21:22:34 +02:00
e1633f43f4 Apply same strategy for UserProfileFormField than for TempateProps for extendability 2024-06-11 21:21:58 +02:00
5b64cfc23c Release candidate 2024-06-11 20:50:31 +02:00
19709cf085 Only types are capitalized 2024-06-11 20:50:11 +02:00
b8bb6c4f02 Fix build 2024-06-11 20:40:00 +02:00
b7a543f8cb Do not export PageProps in the index 2024-06-11 20:30:39 +02:00
04b4e19563 Release candidate 2024-06-11 20:27:53 +02:00
ffb27fc66d Extract Props from UserProfileFormFields so it's ejectable 2024-06-11 20:27:03 +02:00
8b5f7eefda Release candidate 2024-06-11 19:14:19 +02:00
c750bf4ee8 Export PageProps 2024-06-11 19:14:04 +02:00
aa74019ef6 Fix build 2024-06-11 19:08:36 +02:00
9be6d9f75f Release candidate 2024-06-11 17:27:40 +02:00
81ebb9b552 Prevent the jar to be corrupted when rebuild 2024-06-11 17:19:36 +02:00
5e13b8c41f Exclude Keycloak 22 from test panel 2024-06-11 17:12:12 +02:00
dd1ed948ec Update Keycloak 25 default realm config 2024-06-11 16:26:03 +02:00
8b93f701cf Add realms configurations for Keycloak majors 2024-06-11 16:19:54 +02:00
2f0084de5b Pass the input options translation to the kcContext 2024-06-11 16:10:54 +02:00
2ef9828625 Start with keycloak 18 for local container 2024-06-11 11:39:03 +02:00
89db8983a7 Fix exception in terms.ftl 2024-06-11 11:37:45 +02:00
287dd9bd31 Refactor + attributes with options rendered by default as select inputs 2024-06-11 09:22:50 +02:00
9a92054c1a Remove unused dependency 2024-06-10 21:06:02 +02:00
4189036213 Fix storybook 2024-06-10 21:05:17 +02:00
2c0a427ba5 Fix the script to export realm 2024-06-10 20:51:00 +02:00
77b488d624 Fix the formatNumber function 2024-06-10 20:14:14 +02:00
5249e05746 Release candidate 2024-06-10 19:36:11 +02:00
1e7a0dd7a6 Enable to add files to the jar with the post build options 2024-06-10 19:35:56 +02:00
fd67f2402a Release candidate 2024-06-10 17:30:20 +02:00
60a65ede2f Preserve ordering on user attributes 2024-06-10 17:30:00 +02:00
1fa659ce61 Release candidate 2024-06-10 16:01:56 +02:00
0ab903dbc7 Add new build target for Kc 25 https://github.com/p2-inc/keycloak-account-v1/pull/13 2024-06-10 15:29:08 +02:00
70b0a04793 Release candidate 2024-06-10 15:08:46 +02:00
c0df9aa939 Remove logs 2024-06-10 09:32:07 +02:00
60a1886942 Fix path error 2024-06-10 09:28:31 +02:00
1ebf97871b Fix logical error 2024-06-10 09:26:47 +02:00
72e321aa32 Fix update of the build process checkpoint 2024-06-10 09:24:16 +02:00
b0f602b565 Fix post build script 2024-06-10 09:12:24 +02:00
84c774503d Build rework checkpoint 2024-06-10 07:57:12 +02:00
9bbc7cc651 Release candidate 2024-06-09 15:04:47 +02:00
458083fb6d Prettier stable generated code 2024-06-09 15:04:31 +02:00
8dcfc840b4 Remove useless 'as const' 2024-06-09 14:34:41 +02:00
9d06a3a6ad Release candidate 2024-06-09 14:33:42 +02:00
86cd08b954 Add missing file to the NPM bundle 2024-06-09 14:33:29 +02:00
144c3cc082 Release candidate 2024-06-09 11:53:41 +02:00
802cef41a6 Rename KcApp to KcPage 2024-06-09 11:53:25 +02:00
226 changed files with 19872 additions and 7891 deletions

View File

@ -231,6 +231,34 @@
"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"
]
},
{
"login": "liamlows",
"name": "Liam Lowsley-Williams",
"avatar_url": "https://avatars.githubusercontent.com/u/1365914?v=4",
"profile": "https://github.com/liamlows",
"contributions": [
"code",
"doc"
]
}
],
"contributorsPerLine": 7,

View File

@ -16,8 +16,8 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: bahmutov/npm-install@v1
- name: If this step fails run 'yarn format' then commit again.
run: yarn format:check
- name: If this step fails run 'npm run format' then commit again.
run: npm run _format --list-different
test:
runs-on: ${{ matrix.os }}
needs: test_lint
@ -32,9 +32,8 @@ jobs:
with:
node-version: ${{ matrix.node }}
- uses: bahmutov/npm-install@v1
- run: yarn build
- run: yarn test
#- run: yarn test:keycloakify-starter
- run: npm run build
- run: npm run test
storybook:
runs-on: ubuntu-latest
@ -46,11 +45,11 @@ jobs:
with:
node-version: '18'
- uses: bahmutov/npm-install@v1
- run: yarn build-storybook -o ./build_storybook
- run: npm run build-storybook
- run: git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${{github.repository}}.git
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npx -y -p gh-pages@3.1.0 gh-pages -d ./build_storybook -u "github-actions-bot <actions@github.com>"
- run: npx -y -p gh-pages@3.1.0 gh-pages -d ./storybook-static -u "github-actions-bot <actions@github.com>"
check_if_version_upgraded:
name: Check if version upgrade
@ -112,7 +111,7 @@ jobs:
with:
registry-url: https://registry.npmjs.org/
- uses: bahmutov/npm-install@v1
- run: yarn build
- run: npm run build
- run: npx -y -p denoify@1.6.12 enable_short_npm_import_path
env:
DRY_RUN: "0"

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", "../dist/res/public"]
};

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 = [

View File

@ -1,49 +0,0 @@
## Overview
This Terms of Service document outlines the rules and regulations for the use of **Example Company's** Services.
## Acceptance of Terms
By accessing and using our services, you acknowledge that you have read, understood, and agree to be bound by these terms. If you do not accept these terms, you are not authorized to use our services.
## Description of Service
**Example Service** (hereinafter referred to as "the Service") is a web-based solution offered by **Example Company** (hereinafter referred to as "the Company"). Our service provides users with access to [documentation](https://example.com/docs) and support for managing their projects effectively.
## Modifications to the Terms of Service
The Company reserves the right to modify these terms at any time. Such modifications will be effective immediately upon posting the updated terms on our website. Your continued use of the Service after any such changes shall constitute your consent to such changes.
## Account Registration
You may be required to register with the Service to access certain features. When registering, you agree to provide accurate, current, and complete information about yourself as requested.
## User Responsibilities
- **Data Security**: Users are responsible for safeguarding their login credentials and should not disclose their passwords to any third party.
- **Acceptable Use**: Users are expected to use the Service in a responsible manner that does not infringe upon the rights of others.
- **Content Ownership**: Users retain all rights to the content they upload to the Service but grant the Company a license to use and distribute this content as part of the Service.
## Intellectual Property
All intellectual property rights related to the Service and its original content, features, and functionality are owned by the Company.
## Termination
The Company may terminate or suspend access to our Service immediately, without prior notice or liability, for any reason whatsoever, including, without limitation, breach of these Terms.
## Governing Law
These Terms shall be governed and construed in accordance with the laws of [Your Country], without regard to its conflict of law provisions.
## Contact Information
For any questions about these Terms, please contact us at [support@example.com](mailto:support@example.com) or visit our [FAQ page](https://example.com/faq).
## Changes to Terms of Service
We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is material, we will provide at least 30 days' notice prior to any new terms taking effect.
## Effective Date
These terms are effective as of **[Insert Date]**.

View File

@ -1,49 +0,0 @@
## Resumen
Este documento de Términos de Servicio detalla las reglas y regulaciones para el uso de los servicios de **Empresa Ejemplo**.
## Aceptación de Términos
Al acceder y utilizar nuestros servicios, usted reconoce que ha leído, entendido y acepta estar vinculado por estos términos. Si no acepta estos términos, no está autorizado para usar nuestros servicios.
## Descripción del Servicio
**Servicio Ejemplo** (en adelante denominado "el Servicio") es una solución basada en la web ofrecida por **Empresa Ejemplo** (en adelante denominada "la Empresa"). Nuestro servicio proporciona a los usuarios acceso a [documentación](https://ejemplo.com/docs) y soporte para gestionar sus proyectos de manera efectiva.
## Modificaciones a los Términos de Servicio
La Empresa se reserva el derecho de modificar estos términos en cualquier momento. Dichas modificaciones entrarán en vigor inmediatamente después de la publicación de los términos actualizados en nuestro sitio web. Su uso continuado del Servicio después de tales cambios constituirá su consentimiento a dichos cambios.
## Registro de Cuenta
Puede ser necesario que se registre en el Servicio para acceder a ciertas características. Al registrarse, usted acepta proporcionar información precisa, actual y completa sobre sí mismo como se solicita.
## Responsabilidades del Usuario
- **Seguridad de Datos**: Los usuarios son responsables de salvaguardar sus credenciales de inicio de sesión y no deben divulgar sus contraseñas a terceros.
- **Uso Aceptable**: Se espera que los usuarios utilicen el Servicio de manera responsable que no infrinja los derechos de otros.
- **Propiedad del Contenido**: Los usuarios retienen todos los derechos sobre el contenido que cargan en el Servicio, pero otorgan a la Empresa una licencia para usar y distribuir este contenido como parte del Servicio.
## Propiedad Intelectual
Todos los derechos de propiedad intelectual relacionados con el Servicio y su contenido original, características y funcionalidad son propiedad de la Empresa.
## Terminación
La Empresa puede terminar o suspender su acceso a nuestro Servicio de inmediato, sin previo aviso ni responsabilidad, por cualquier motivo, incluido, entre otros, una violación de estos Términos.
## Ley Aplicable
Estos Términos se regirán e interpretarán de acuerdo con las leyes de [Su País], sin tener en cuenta sus disposiciones de conflicto de leyes.
## Información de Contacto
Para cualquier pregunta sobre estos Términos, contáctenos en [support@ejemplo.com](mailto:support@ejemplo.com) o visite nuestra [página de FAQ](https://ejemplo.com/faq).
## Cambios a los Términos de Servicio
Nos reservamos el derecho, a nuestra única discreción, de modificar o reemplazar estos Términos en cualquier momento. Si una revisión es material, proporcionaremos al menos 30 días de aviso antes de que los nuevos términos entren en vigor.
## Fecha de Efectividad
Estos términos son efectivos a partir del **[Insertar Fecha]**.

View File

@ -1,49 +0,0 @@
## Vue d'ensemble
Ce document des Conditions Générales d'Utilisation détaille les règles et réglementations pour l'utilisation des services de **l'Entreprise Exemple**.
## Acceptation des Conditions
En accédant et en utilisant nos services, vous reconnaissez avoir lu, compris et accepté d'être lié par ces conditions. Si vous n'acceptez pas ces termes, vous n'êtes pas autorisé à utiliser nos services.
## Description du Service
**Service Exemple** (ci-après dénommé "le Service") est une solution basée sur le web offerte par **l'Entreprise Exemple** (ci-après dénommée "l'Entreprise"). Notre service offre aux utilisateurs un accès à la [documentation](https://exemple.com/docs) et un support pour gérer efficacement leurs projets.
## Modifications des Conditions de Service
L'Entreprise se réserve le droit de modifier ces conditions à tout moment. De telles modifications entreront en vigueur immédiatement après la publication des termes mis à jour sur notre site web. Votre utilisation continue du Service après de tels changements constitue votre consentement à ces modifications.
## Inscription au Compte
Vous devrez peut-être vous inscrire au Service pour accéder à certaines fonctionnalités. Lors de l'inscription, vous acceptez de fournir des informations précises, actuelles et complètes vous concernant, comme demandé.
## Responsabilités des Utilisateurs
- **Sécurité des Données** : Les utilisateurs sont responsables de la sauvegarde de leurs identifiants de connexion et ne doivent divulguer leurs mots de passe à aucun tiers.
- **Utilisation Acceptable** : Les utilisateurs sont censés utiliser le Service de manière responsable qui ne porte pas atteinte aux droits d'autrui.
- **Propriété du Contenu** : Les utilisateurs conservent tous les droits sur le contenu qu'ils téléchargent sur le Service mais accordent à l'Entreprise une licence pour utiliser et distribuer ce contenu dans le cadre du Service.
## Propriété Intellectuelle
Tous les droits de propriété intellectuelle relatifs au Service et à son contenu original, fonctionnalités et fonctionnement sont détenus par l'Entreprise.
## Résiliation
L'Entreprise peut résilier ou suspendre votre accès à notre Service immédiatement, sans préavis ni responsabilité, pour quelque raison que ce soit, y compris, sans limitation, en cas de violation de ces Conditions.
## Loi Applicable
Ces Conditions seront régies et interprétées conformément aux lois de [Votre Pays], sans égard à ses dispositions de conflit de lois.
## Informations de Contact
Pour toute question concernant ces Conditions, veuillez nous contacter à [support@exemple.com](mailto:support@exemple.com) ou visitez notre [page FAQ](https://exemple.com/faq).
## Modifications des Conditions de Service
Nous nous réservons le droit, à notre seule discrétion, de modifier ou de remplacer ces Conditions à tout moment. Si une révision est importante, nous vous fournirons un préavis d'au moins 30 jours avant que les nouveaux termes prennent effet.
## Date d'Effet
Ces conditions sont effectives à partir du **[Insérer la Date]**.

283
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 from version 11 to 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,9 @@ 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>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/liamlows"><img src="https://avatars.githubusercontent.com/u/1365914?v=4?s=100" width="100px;" alt="Liam Lowsley-Williams"/><br /><sub><b>Liam Lowsley-Williams</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=liamlows" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=liamlows" title="Documentation">📖</a></td>
</tr>
</tbody>
</table>
@ -123,230 +139,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,24 +1,23 @@
{
"name": "keycloakify",
"version": "10.0.0-rc.41",
"version": "10.1.0",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
"url": "git://github.com/keycloakify/keycloakify.git"
},
"scripts": {
"prepare": "patch-package && ts-node --skipProject scripts/generate-i18n-messages.ts",
"build": "ts-node --skipProject scripts/build.ts",
"storybook": "ts-node --skipProject scripts/start-storybook.ts",
"link-in-starter": "ts-node --skipProject scripts/link-in-starter.ts",
"prepare": "tsx scripts/generate-i18n-messages.ts",
"build": "tsx scripts/build/main.ts",
"storybook": "tsx scripts/start-storybook.ts",
"link-in-starter": "tsx scripts/link-in-starter.ts",
"test": "yarn test:types && vitest run",
"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": "ts-node --skipProject scripts/link-in-app.ts",
"build-storybook": "ts-node --skipProject scripts/build-storybook.ts",
"dump-keycloak-realm": "ts-node --skipProject scripts/dump-keycloak-realm.ts"
"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"
},
"bin": {
"keycloakify": "dist/bin/main.js"
@ -41,13 +40,14 @@
"!dist/bin/",
"dist/bin/main.js",
"dist/bin/*.index.js",
"dist/bin/*.node",
"dist/bin/shared/constants.js",
"dist/bin/shared/constants.d.ts",
"dist/bin/shared/constants.js.map",
"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",
@ -61,11 +61,7 @@
"bluehats"
],
"homepage": "https://www.keycloakify.dev",
"peerDependencies": {
"react": "*"
},
"dependencies": {
"react-markdown": "^5.0.3",
"tsafe": "^1.6.6"
},
"devDependencies": {
@ -76,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",
@ -93,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",
@ -109,17 +99,16 @@
"react-dom": "^18.2.0",
"recast": "^0.23.3",
"run-exclusive": "^2.2.19",
"scripting-tools": "^0.19.13",
"storybook-dark-mode": "^1.1.2",
"termost": "^0.12.0",
"ts-node": "^10.9.2",
"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",
"zod": "^3.17.10",
"evt": "^2.5.7"
"evt": "^2.5.7",
"tsx": "^4.15.5"
}
}

File diff suppressed because one or more lines are too long

View File

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

View File

@ -1,175 +0,0 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join, relative } from "path";
import { assert } from "tsafe/assert";
import { transformCodebase } from "../src/bin/tools/transformCodebase";
import chalk from "chalk";
console.log(chalk.cyan("Building Keycloakify..."));
const startTime = Date.now();
if (fs.existsSync(join("dist", "bin", "main.original.js"))) {
fs.renameSync(
join("dist", "bin", "main.original.js"),
join("dist", "bin", "main.js")
);
fs.readdirSync(join("dist", "bin")).forEach(fileBasename => {
if (/[0-9]\.index.js/.test(fileBasename)) {
fs.rmSync(join("dist", "bin", fileBasename));
}
});
}
run(`npx tsc -p ${join("src", "bin", "tsconfig.json")}`);
if (
!fs
.readFileSync(join("dist", "bin", "main.js"))
.toString("utf8")
.includes("__nccwpck_require__")
) {
fs.cpSync(join("dist", "bin", "main.js"), join("dist", "bin", "main.original.js"));
}
run(`npx ncc build ${join("dist", "bin", "main.js")} -o ${join("dist", "ncc_out")}`);
transformCodebase({
srcDirPath: join("dist", "ncc_out"),
destDirPath: join("dist", "bin"),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (fileRelativePath === "index.js") {
return {
newFileName: "main.js",
modifiedSourceCode: sourceCode
};
}
return { modifiedSourceCode: sourceCode };
}
});
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
{
let hasBeenPatched = false;
fs.readdirSync(join("dist", "bin")).forEach(fileBasename => {
if (fileBasename !== "main.js" && !fileBasename.endsWith(".index.js")) {
return;
}
const { hasBeenPatched: hasBeenPatched_i } = patchDeprecatedBufferApiUsage(
join("dist", "bin", fileBasename)
);
if (hasBeenPatched_i) {
hasBeenPatched = true;
}
});
assert(hasBeenPatched);
}
fs.chmodSync(
join("dist", "bin", "main.js"),
fs.statSync(join("dist", "bin", "main.js")).mode |
fs.constants.S_IXUSR |
fs.constants.S_IXGRP |
fs.constants.S_IXOTH
);
run(`npx tsc -p ${join("src", "tsconfig.json")}`);
run(`npx tsc-alias -p ${join("src", "tsconfig.json")}`);
if (fs.existsSync(join("dist", "vite-plugin", "index.original.js"))) {
fs.renameSync(
join("dist", "vite-plugin", "index.original.js"),
join("dist", "vite-plugin", "index.js")
);
}
run(`npx tsc -p ${join("src", "vite-plugin", "tsconfig.json")}`);
if (
!fs
.readFileSync(join("dist", "vite-plugin", "index.js"))
.toString("utf8")
.includes("__nccwpck_require__")
) {
fs.cpSync(
join("dist", "vite-plugin", "index.js"),
join("dist", "vite-plugin", "index.original.js")
);
}
run(
`npx ncc build ${join("dist", "vite-plugin", "index.js")} -o ${join(
"dist",
"ncc_out"
)}`
);
fs.readdirSync(join("dist", "ncc_out")).forEach(fileBasename =>
assert(!fileBasename.endsWith(".index.js"))
);
transformCodebase({
srcDirPath: join("dist", "ncc_out"),
destDirPath: join("dist", "vite-plugin"),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
assert(fileRelativePath === "index.js");
return { modifiedSourceCode: sourceCode };
}
});
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
{
const { hasBeenPatched } = patchDeprecatedBufferApiUsage(
join("dist", "vite-plugin", "index.js")
);
assert(hasBeenPatched);
}
fs.rmSync(join("dist", "src"), { recursive: true, force: true });
fs.cpSync("src", join("dist", "src"), { recursive: true });
transformCodebase({
srcDirPath: join("stories"),
destDirPath: join("dist", "stories"),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (!fileRelativePath.endsWith(".stories.tsx")) {
return undefined;
}
return { modifiedSourceCode: sourceCode };
}
});
console.log(chalk.green(`✓ built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`));
function run(command: string) {
console.log(chalk.grey(`$ ${command}`));
child_process.execSync(command, { stdio: "inherit" });
}
function patchDeprecatedBufferApiUsage(filePath: string) {
const before = fs.readFileSync(filePath).toString("utf8");
const after = before.replace(
`var buffer = new Buffer(toRead);`,
`var buffer = Buffer.allocUnsafe ? Buffer.allocUnsafe(toRead) : new Buffer(toRead);`
);
fs.writeFileSync(filePath, Buffer.from(after, "utf8"));
const hasBeenPatched = after !== before;
return { hasBeenPatched };
}

View File

@ -0,0 +1,79 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { transformCodebase } from "../../src/bin/tools/transformCodebase";
import { downloadKeycloakDefaultTheme } from "../shared/downloadKeycloakDefaultTheme";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../../src/bin/shared/constants";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
import { accountMultiPageSupportedLanguages } from "../generate-i18n-messages";
import * as fsPr from "fs/promises";
export async function createAccountV1Dir() {
const { extractedDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersionId: "FOR_ACCOUNT_MULTI_PAGE"
});
const destDirPath = pathJoin(
getThisCodebaseRootDirPath(),
"dist",
"res",
"account-v1"
);
await fsPr.rm(destDirPath, { recursive: true, force: true });
transformCodebase({
srcDirPath: pathJoin(extractedDirPath, "base", "account"),
destDirPath
});
transformCodebase({
srcDirPath: pathJoin(extractedDirPath, "keycloak", "account", "resources"),
destDirPath: pathJoin(destDirPath, "resources")
});
transformCodebase({
srcDirPath: pathJoin(extractedDirPath, "keycloak", "common", "resources"),
destDirPath: pathJoin(
destDirPath,
"resources",
WELL_KNOWN_DIRECTORY_BASE_NAME.RESOURCES_COMMON
)
});
fs.writeFileSync(
pathJoin(destDirPath, "theme.properties"),
Buffer.from(
[
"accountResourceProvider=account-v1",
"",
`locales=${accountMultiPageSupportedLanguages.join(",")}`,
"",
"styles=" +
[
"css/account.css",
"img/icon-sidebar-active.png",
"img/logo.png",
...[
"patternfly.min.css",
"patternfly-additions.min.css",
"patternfly-additions.min.css"
].map(
fileBasename =>
`${WELL_KNOWN_DIRECTORY_BASE_NAME.RESOURCES_COMMON}/node_modules/patternfly/dist/css/${fileBasename}`
)
].join(" "),
"",
"##### css classes for form buttons",
"# main class used for all buttons",
"kcButtonClass=btn",
"# classes defining priority of the button - primary or default (there is typically only one priority button for the form)",
"kcButtonPrimaryClass=btn-primary",
"kcButtonDefaultClass=btn-default",
"# classes defining size of the button",
"kcButtonLargeClass=btn-lg",
""
].join("\n"),
"utf8"
)
);
}

View File

@ -0,0 +1,73 @@
import { join as pathJoin } from "path";
import { downloadKeycloakDefaultTheme } from "../shared/downloadKeycloakDefaultTheme";
import { transformCodebase } from "../../src/bin/tools/transformCodebase";
import { existsAsync } from "../../src/bin/tools/fs.existsAsync";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../../src/bin/shared/constants";
import { assert, type Equals } from "tsafe/assert";
import * as fsPr from "fs/promises";
export async function createPublicDotKeycloakifyDir() {
await Promise.all(
(["login", "account"] as const).map(async themeType => {
const { extractedDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersionId: (() => {
switch (themeType) {
case "login":
return "FOR_LOGIN_THEME";
case "account":
return "FOR_ACCOUNT_MULTI_PAGE";
}
assert<Equals<typeof themeType, never>>();
})()
});
const destDirPath = pathJoin(
getThisCodebaseRootDirPath(),
"dist",
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY,
themeType
);
await fsPr.rm(destDirPath, { recursive: true, force: true });
base_resources: {
const srcDirPath = pathJoin(
extractedDirPath,
"base",
themeType,
"resources"
);
if (!(await existsAsync(srcDirPath))) {
break base_resources;
}
transformCodebase({
srcDirPath,
destDirPath
});
}
transformCodebase({
srcDirPath: pathJoin(
extractedDirPath,
"keycloak",
themeType,
"resources"
),
destDirPath
});
transformCodebase({
srcDirPath: pathJoin(extractedDirPath, "keycloak", "common", "resources"),
destDirPath: pathJoin(
destDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.RESOURCES_COMMON
)
});
})
);
}

186
scripts/build/main.ts Normal file
View File

@ -0,0 +1,186 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { assert } from "tsafe/assert";
import { transformCodebase } from "../../src/bin/tools/transformCodebase";
import { createPublicDotKeycloakifyDir } from "./createPublicDotKeycloakifyDir";
import { createAccountV1Dir } from "./createAccountV1Dir";
import chalk from "chalk";
(async () => {
console.log(chalk.cyan("Building Keycloakify..."));
const startTime = Date.now();
if (fs.existsSync(join("dist", "bin", "main.original.js"))) {
fs.renameSync(
join("dist", "bin", "main.original.js"),
join("dist", "bin", "main.js")
);
fs.readdirSync(join("dist", "bin")).forEach(fileBasename => {
if (/[0-9]\.index.js/.test(fileBasename) || fileBasename.endsWith(".node")) {
fs.rmSync(join("dist", "bin", fileBasename));
}
});
}
run(`npx tsc -p ${join("src", "bin", "tsconfig.json")}`);
if (
!fs
.readFileSync(join("dist", "bin", "main.js"))
.toString("utf8")
.includes("__nccwpck_require__")
) {
fs.cpSync(
join("dist", "bin", "main.js"),
join("dist", "bin", "main.original.js")
);
}
run(`npx ncc build ${join("dist", "bin", "main.js")} -o ${join("dist", "ncc_out")}`);
transformCodebase({
srcDirPath: join("dist", "ncc_out"),
destDirPath: join("dist", "bin"),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (fileRelativePath === "index.js") {
return {
newFileName: "main.js",
modifiedSourceCode: sourceCode
};
}
return { modifiedSourceCode: sourceCode };
}
});
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
{
let hasBeenPatched = false;
fs.readdirSync(join("dist", "bin")).forEach(fileBasename => {
if (fileBasename !== "main.js" && !fileBasename.endsWith(".index.js")) {
return;
}
const { hasBeenPatched: hasBeenPatched_i } = patchDeprecatedBufferApiUsage(
join("dist", "bin", fileBasename)
);
if (hasBeenPatched_i) {
hasBeenPatched = true;
}
});
assert(hasBeenPatched);
}
fs.chmodSync(
join("dist", "bin", "main.js"),
fs.statSync(join("dist", "bin", "main.js")).mode |
fs.constants.S_IXUSR |
fs.constants.S_IXGRP |
fs.constants.S_IXOTH
);
run(`npx tsc -p ${join("src", "tsconfig.json")}`);
run(`npx tsc-alias -p ${join("src", "tsconfig.json")}`);
if (fs.existsSync(join("dist", "vite-plugin", "index.original.js"))) {
fs.renameSync(
join("dist", "vite-plugin", "index.original.js"),
join("dist", "vite-plugin", "index.js")
);
}
run(`npx tsc -p ${join("src", "vite-plugin", "tsconfig.json")}`);
if (
!fs
.readFileSync(join("dist", "vite-plugin", "index.js"))
.toString("utf8")
.includes("__nccwpck_require__")
) {
fs.cpSync(
join("dist", "vite-plugin", "index.js"),
join("dist", "vite-plugin", "index.original.js")
);
}
run(
`npx ncc build ${join("dist", "vite-plugin", "index.js")} -o ${join(
"dist",
"ncc_out"
)}`
);
fs.readdirSync(join("dist", "ncc_out")).forEach(fileBasename => {
assert(!fileBasename.endsWith(".index.js"));
assert(!fileBasename.endsWith(".node"));
});
transformCodebase({
srcDirPath: join("dist", "ncc_out"),
destDirPath: join("dist", "vite-plugin"),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
assert(fileRelativePath === "index.js");
return { modifiedSourceCode: sourceCode };
}
});
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
{
const dirBasename = "src";
const destDirPath = join("dist", dirBasename);
fs.rmSync(destDirPath, { recursive: true, force: true });
fs.cpSync(dirBasename, destDirPath, { recursive: true });
}
await createPublicDotKeycloakifyDir();
await createAccountV1Dir();
transformCodebase({
srcDirPath: join("stories"),
destDirPath: join("dist", "stories"),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (!fileRelativePath.endsWith(".stories.tsx")) {
return undefined;
}
return { modifiedSourceCode: sourceCode };
}
});
console.log(
chalk.green(`✓ built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`)
);
})();
function run(command: string) {
console.log(chalk.grey(`$ ${command}`));
child_process.execSync(command, { stdio: "inherit" });
}
function patchDeprecatedBufferApiUsage(filePath: string) {
const before = fs.readFileSync(filePath).toString("utf8");
const after = before.replace(
`var buffer = new Buffer(toRead);`,
`var buffer = Buffer.allocUnsafe ? Buffer.allocUnsafe(toRead) : new Buffer(toRead);`
);
fs.writeFileSync(filePath, Buffer.from(after, "utf8"));
const hasBeenPatched = after !== before;
return { hasBeenPatched };
}

View File

@ -1,42 +1,89 @@
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";
import chalk from "chalk";
import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
run(
[
`docker exec -it ${containerName}`,
`/opt/keycloak/bin/kc.sh export`,
`--dir /tmp`,
`--realm myrealm`,
`--users realm_file`
].join(" ")
);
(async () => {
{
const dCompleted = new Deferred<void>();
const keycloakMajorVersionNumber = SemVer.parse(
child_process
.execSync(`docker inspect --format '{{.Config.Image}}' ${containerName}`)
.toString("utf8")
.trim()
.split(":")[1]
).major;
const child = child_process.spawn(
"docker",
[
...["exec", CONTAINER_NAME],
...["/opt/keycloak/bin/kc.sh", "export"],
...["--dir", "/tmp"],
...["--realm", "myrealm"],
...["--users", "realm_file"]
],
{ shell: true }
);
const targetFilePath = pathRelative(
process.cwd(),
pathJoin(
__dirname,
"..",
"src",
"bin",
"start-keycloak",
`myrealm-realm-${keycloakMajorVersionNumber}.json`
)
);
let output = "";
run(`docker cp ${containerName}:/tmp/myrealm-realm.json ${targetFilePath}`);
const onExit = (code: number | null) => {
dCompleted.reject(new Error(`Exited with code ${code}`));
};
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
child.on("exit", onExit);
child.stdout.on("data", data => {
const outputStr = data.toString("utf8");
if (outputStr.includes("Export finished successfully")) {
child.removeListener("exit", onExit);
child.kill();
dCompleted.resolve();
}
output += outputStr;
});
child.stderr.on("data", data => (output += chalk.red(data.toString("utf8"))));
try {
await dCompleted.pr;
} catch (error) {
assert(is<Error>(error));
console.log(chalk.red(error.message));
console.log(output);
process.exit(1);
}
}
const keycloakMajorVersionNumber = SemVer.parse(
child_process
.execSync(`docker inspect --format '{{.Config.Image}}' ${CONTAINER_NAME}`)
.toString("utf8")
.trim()
.split(":")[1]
).major;
const targetFilePath = pathRelative(
process.cwd(),
pathJoin(
__dirname,
"..",
"src",
"bin",
"start-keycloak",
`myrealm-realm-${keycloakMajorVersionNumber}.json`
)
);
run(`docker cp ${CONTAINER_NAME}:/tmp/myrealm-realm.json ${targetFilePath}`);
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
})();
function run(command: string) {
console.log(chalk.grey(`$ ${command}`));

View File

@ -6,77 +6,81 @@ import {
dirname as pathDirname,
sep as pathSep
} from "path";
import { assert } from "tsafe/assert";
import { assert, type Equals } from "tsafe/assert";
import { same } from "evt/tools/inDepth";
import { crawl } from "../src/bin/tools/crawl";
import { downloadKeycloakDefaultTheme } from "../src/bin/shared/downloadKeycloakDefaultTheme";
import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTheme";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { deepAssign } from "../src/tools/deepAssign";
import { THEME_TYPES } from "../src/bin/shared/constants";
const propertiesParser: any = require("properties-parser");
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
// update the version array for generating for newer version.
//@ts-ignore
const propertiesParser = require("properties-parser");
async function main() {
const keycloakVersion = "24.0.4";
if (require.main === module) {
generateI18nMessages();
}
async function generateI18nMessages() {
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildContext: {
cacheDirPath: pathJoin(
thisCodebaseRootDirPath,
"node_modules",
".cache",
"keycloakify"
),
npmWorkspaceRootDirPath: thisCodebaseRootDirPath
}
});
type Dictionary = { [idiomId: string]: string };
const record: { [typeOfPage: string]: { [language: string]: Dictionary } } = {};
const record: { [themeType: string]: { [language: string]: Dictionary } } = {};
{
const baseThemeDirPath = pathJoin(defaultThemeDirPath, "base");
const re = new RegExp(
`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`
);
crawl({
dirPath: baseThemeDirPath,
returnedPathsType: "relative to dirPath"
}).forEach(filePath => {
const match = filePath.match(re);
if (match === null) {
return;
}
const [, typeOfPage, language] = match;
(record[typeOfPage] ??= {})[language.replace(/_/g, "-")] = Object.fromEntries(
Object.entries(
propertiesParser.parse(
fs
.readFileSync(pathJoin(baseThemeDirPath, filePath))
.toString("utf8")
)
).map(([key, value]: any) => [
key === "locale_pt_BR" ? "locale_pt-BR" : key,
value.replace(/''/g, "'")
])
);
for (const themeType of THEME_TYPES) {
const { extractedDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersionId: (() => {
switch (themeType) {
case "login":
return "FOR_LOGIN_THEME";
case "account":
return "FOR_ACCOUNT_MULTI_PAGE";
}
assert<Equals<typeof themeType, never>>();
})()
});
}
Object.keys(record).forEach(themeType => {
if (themeType !== "login" && themeType !== "account") {
return;
{
const baseThemeDirPath = pathJoin(extractedDirPath, "base");
const re = new RegExp(
`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`
);
crawl({
dirPath: baseThemeDirPath,
returnedPathsType: "relative to dirPath"
}).forEach(filePath => {
const match = filePath.match(re);
if (match === null) {
return;
}
const [, themeType_here, language] = match;
if (themeType_here !== themeType) {
return;
}
(record[themeType] ??= {})[language.replace(/_/g, "-")] =
Object.fromEntries(
Object.entries(
propertiesParser.parse(
fs
.readFileSync(pathJoin(baseThemeDirPath, filePath))
.toString("utf8")
) 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
])
);
});
}
const recordForThemeType = record[themeType];
@ -93,6 +97,29 @@ async function main() {
assert(false);
})();
/* Migration helper
console.log({ themeType });
{
const all = new Set<string>();
languages.forEach(languages => all.add(languages));
const currentlySupportedLanguages = Object.keys(keycloakifyExtraMessages);
currentlySupportedLanguages.forEach(languages => all.add(languages));
all.forEach(language => {
console.log([
`"${language}": `,
`isInLanguages: ${languages.includes(language)}`,
`isInKeycloakifyExtraMessages: ${currentlySupportedLanguages.includes(language)}`
].join(" "))
});
}
*/
assert(
same(languages, Object.keys(keycloakifyExtraMessages), {
takeIntoAccountArraysOrdering: false
@ -104,12 +131,12 @@ async function main() {
source: keycloakifyExtraMessages
});
const baseMessagesDirPath = pathJoin(
const messagesDirPath = pathJoin(
thisCodebaseRootDirPath,
"src",
themeType,
"i18n",
"baseMessages"
"messages_defaultSet"
);
const generatedFileHeader = [
@ -121,7 +148,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 +176,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,13 +195,13 @@ async function main() {
' default: return { "default": {} };',
" }",
" })();",
" return messages;",
" return messages_defaultSet;",
"}"
].join("\n"),
"utf8"
)
);
});
}
}
const keycloakifyExtraMessages_login: Record<
@ -197,6 +224,7 @@ const keycloakifyExtraMessages_login: Record<
| "nl"
| "no"
| "pl"
| "pt"
| "pt-BR"
| "ru"
| "sk"
@ -204,7 +232,9 @@ const keycloakifyExtraMessages_login: Record<
| "th"
| "tr"
| "uk"
| "zh-CN",
| "ka"
| "zh-CN"
| "zh-TW",
Record<
| "shouldBeEqual"
| "shouldBeDifferent"
@ -428,6 +458,17 @@ const keycloakifyExtraMessages_login: Record<
addValue: "Dodaj wartość",
languages: "Języki"
},
pt: {
shouldBeEqual: "{0} deve ser igual a {1}",
shouldBeDifferent: "{0} deve ser diferente de {1}",
shouldMatchPattern: "O padrão deve corresponder: `/{0}/`",
mustBeAnInteger: "Deve ser um número inteiro",
notAValidOption: "Não é uma opção válida",
selectAnOption: "Selecione uma opção",
remove: "Remover",
addValue: "Adicionar valor",
languages: "Idiomas"
},
"pt-BR": {
shouldBeEqual: "{0} deve ser igual a {1}",
shouldBeDifferent: "{0} deve ser diferente de {1}",
@ -505,6 +546,17 @@ const keycloakifyExtraMessages_login: Record<
addValue: "Додати значення",
languages: "Мови"
},
ka: {
shouldBeEqual: "{0} უნდა იყოს ტოლი {1}-სთვის",
shouldBeDifferent: "{0} უნდა იყოს სხვა {1}-სთვის",
shouldMatchPattern: "შაბლონს უნდა ემთხვევა: `/{0}/`",
mustBeAnInteger: "უნდა იყოს მთელი რიცხვი",
notAValidOption: "არასწორი ვარიანტი",
selectAnOption: "აირჩიეთ ვარიანტი",
remove: "წაშალეთ",
addValue: "დაამატეთ მნიშვნელობა",
languages: "ენები"
},
"zh-CN": {
shouldBeEqual: "{0} 应该等于 {1}",
shouldBeDifferent: "{0} 应该不同于 {1}",
@ -515,38 +567,49 @@ const keycloakifyExtraMessages_login: Record<
remove: "移除",
addValue: "添加值",
languages: "语言"
},
"zh-TW": {
shouldBeEqual: "{0} 應該等於 {1}",
shouldBeDifferent: "{0} 應該不同於 {1}",
shouldMatchPattern: "模式應匹配: `/{0}/`",
mustBeAnInteger: "必須是整數",
notAValidOption: "不是有效選項",
selectAnOption: "選擇一個選項",
remove: "移除",
addValue: "添加值",
languages: "語言"
}
/* spell-checker: enable */
};
export const accountMultiPageSupportedLanguages = [
"en",
"ar",
"ca",
"cs",
"da",
"de",
"es",
"fi",
"fr",
"hu",
"it",
"ja",
"lt",
"lv",
"nl",
"no",
"pl",
"pt-BR",
"ru",
"sk",
"sv",
"tr",
"zh-CN"
] as const;
const keycloakifyExtraMessages_account: Record<
| "en"
| "ar"
| "ca"
| "cs"
| "da"
| "de"
| "el"
| "es"
| "fa"
| "fi"
| "fr"
| "hu"
| "it"
| "ja"
| "lt"
| "lv"
| "nl"
| "no"
| "pl"
| "pt-BR"
| "ru"
| "sk"
| "sv"
| "th"
| "tr"
| "uk"
| "zh-CN",
(typeof accountMultiPageSupportedLanguages)[number],
Record<"newPasswordSameAsOld" | "passwordConfirmNotMatch", string>
> = {
en: {
@ -574,18 +637,10 @@ const keycloakifyExtraMessages_account: Record<
newPasswordSameAsOld: "Das neue Passwort muss sich vom alten unterscheiden",
passwordConfirmNotMatch: "Passwortbestätigung stimmt nicht überein"
},
el: {
newPasswordSameAsOld: "Ο νέος κωδικός πρόσβασης πρέπει να διαφέρει από τον παλιό",
passwordConfirmNotMatch: "Η επιβεβαίωση του κωδικού πρόσβασης δεν ταιριάζει"
},
es: {
newPasswordSameAsOld: "La nueva contraseña debe ser diferente de la anterior",
passwordConfirmNotMatch: "La confirmación de la contraseña no coincide"
},
fa: {
newPasswordSameAsOld: "رمز عبور جدید باید با رمز عبور قبلی متفاوت باشد",
passwordConfirmNotMatch: "تأیید رمز عبور مطابقت ندارد"
},
fi: {
newPasswordSameAsOld: "Uusi salasana on oltava erilainen kuin vanha",
passwordConfirmNotMatch: "Salasanan vahvistus ei täsmää"
@ -643,25 +698,13 @@ const keycloakifyExtraMessages_account: Record<
newPasswordSameAsOld: "Det nya lösenordet måste skilja sig från det gamla",
passwordConfirmNotMatch: "Lösenordsbekräftelsen matchar inte"
},
th: {
newPasswordSameAsOld: "รหัสผ่านใหม่ต้องต่างจากรหัสผ่านเดิม",
passwordConfirmNotMatch: "การยืนยันรหัสผ่านไม่ตรงกัน"
},
tr: {
newPasswordSameAsOld: "Yeni şifre eskisinden farklı olmalıdır",
passwordConfirmNotMatch: "Şifre doğrulama eşleşmiyor"
},
uk: {
newPasswordSameAsOld: "Новий пароль повинен відрізнятися від старого",
passwordConfirmNotMatch: "Підтвердження пароля не співпадає"
},
"zh-CN": {
newPasswordSameAsOld: "新密码必须与旧密码不同",
passwordConfirmNotMatch: "密码确认不匹配"
}
/* spell-checker: enable */
};
if (require.main === module) {
main();
}

View File

@ -1,19 +0,0 @@
import { join as pathJoin } from "path";
import { constants } from "fs";
import { chmod, stat } from "fs/promises";
(async () => {
const thisCodebaseRootDirPath = pathJoin(__dirname, "..");
const { bin } = await import(pathJoin(thisCodebaseRootDirPath, "package.json"));
const promises = Object.values<string>(bin).map(async scriptPath => {
const fullPath = pathJoin(thisCodebaseRootDirPath, scriptPath);
const oldMode = (await stat(fullPath)).mode;
const newMode =
oldMode | constants.S_IXUSR | constants.S_IXGRP | constants.S_IXOTH;
await chmod(fullPath, newMode);
});
await Promise.all(promises);
})();

View File

@ -2,66 +2,59 @@ import { execSync } from "child_process";
import { join as pathJoin, relative as pathRelative } from "path";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import * as fs from "fs";
import * as os from "os";
const singletonDependencies: string[] = ["react", "@types/react"];
// For example [ "@emotion" ] it's more convenient than
// having to list every sub emotion packages (@emotion/css @emotion/utils ...)
// in singletonDependencies
const namespaceSingletonDependencies: string[] = [];
const rootDirPath = getThisCodebaseRootDirPath();
const commonThirdPartyDeps = [
...namespaceSingletonDependencies
.map(namespaceModuleName =>
fs
.readdirSync(pathJoin(rootDirPath, "node_modules", namespaceModuleName))
.map(submoduleName => `${namespaceModuleName}/${submoduleName}`)
)
.reduce((prev, curr) => [...prev, ...curr], []),
...singletonDependencies
];
//NOTE: This is only required because of: https://github.com/garronej/ts-ci/blob/c0e207b9677523d4ec97fe672ddd72ccbb3c1cc4/README.md?plain=1#L54-L58
fs.writeFileSync(
pathJoin(rootDirPath, "dist", "package.json"),
Buffer.from(
JSON.stringify(
(() => {
const packageJsonParsed = JSON.parse(
fs
.readFileSync(pathJoin(rootDirPath, "package.json"))
.toString("utf8")
);
{
let modifiedPackageJsonContent = fs
.readFileSync(pathJoin(rootDirPath, "package.json"))
.toString("utf8");
return {
...packageJsonParsed,
main: packageJsonParsed["main"]?.replace(/^dist\//, ""),
types: packageJsonParsed["types"]?.replace(/^dist\//, ""),
module: packageJsonParsed["module"]?.replace(/^dist\//, ""),
exports: !("exports" in packageJsonParsed)
? undefined
: Object.fromEntries(
Object.entries(packageJsonParsed["exports"]).map(
([key, value]) => [
key,
(value as string).replace(/^\.\/dist\//, "./")
]
)
)
};
})(),
null,
2
),
"utf8"
)
);
modifiedPackageJsonContent = (() => {
const o = JSON.parse(modifiedPackageJsonContent);
const commonThirdPartyDeps = (() => {
// For example [ "@emotion" ] it's more convenient than
// having to list every sub emotion packages (@emotion/css @emotion/utils ...)
// in singletonDependencies
const namespaceSingletonDependencies: string[] = [];
delete o.files;
return [
...namespaceSingletonDependencies
.map(namespaceModuleName =>
fs
.readdirSync(
pathJoin(rootDirPath, "node_modules", namespaceModuleName)
)
.map(submoduleName => `${namespaceModuleName}/${submoduleName}`)
)
.reduce((prev, curr) => [...prev, ...curr], []),
...singletonDependencies
];
})();
return JSON.stringify(o, null, 2);
})();
modifiedPackageJsonContent = modifiedPackageJsonContent
.replace(/"dist\//g, '"')
.replace(/"\.\/dist\//g, '"./')
.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")
);
}
const yarnGlobalDirPath = pathJoin(rootDirPath, ".yarn_home");
@ -83,7 +76,9 @@ const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
cwd,
env: {
...process.env,
HOME: yarnGlobalDirPath
...(os.platform() === "win32"
? { USERPROFILE: yarnGlobalDirPath }
: { HOME: yarnGlobalDirPath })
}
});
};

View File

@ -1,23 +1,50 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
import { startRebuildOnSrcChange } from "./shared/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 ts-node --skipProject ${join("scripts", "link-in-app.ts")} keycloakify-starter`);
run(`npx tsx ${join("scripts", "link-in-app.ts")} ${starterName}`);
startRebuildOnSrcChange();

View File

@ -0,0 +1,343 @@
import { relative as pathRelative } from "path";
import { downloadAndExtractArchive } from "../../src/bin/tools/downloadAndExtractArchive";
import { getProxyFetchOptions } from "../../src/bin/tools/fetchProxyOptions";
import { join as pathJoin } from "path";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
import { assert, type Equals } from "tsafe/assert";
const KEYCLOAK_VERSION = {
FOR_LOGIN_THEME: "25.0.4",
FOR_ACCOUNT_MULTI_PAGE: "21.1.2"
} as const;
export async function downloadKeycloakDefaultTheme(params: {
keycloakVersionId: keyof typeof KEYCLOAK_VERSION;
}) {
const { keycloakVersionId } = params;
const keycloakVersion = KEYCLOAK_VERSION[keycloakVersionId];
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: pathJoin(
getThisCodebaseRootDirPath(),
"node_modules",
".cache",
"scripts"
),
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: getThisCodebaseRootDirPath()
}),
uniqueIdOfOnArchiveFile: "extractOnlyRequiredFiles",
onArchiveFile: async params => {
const fileRelativePath = pathRelative("theme", params.fileRelativePath);
if (fileRelativePath.startsWith("..")) {
return;
}
const { readFile, writeFile } = params;
if (
!fileRelativePath.startsWith("base") &&
!fileRelativePath.startsWith("keycloak")
) {
return;
}
switch (keycloakVersion) {
case KEYCLOAK_VERSION.FOR_LOGIN_THEME:
if (
!fileRelativePath.startsWith(pathJoin("base", "login")) &&
!fileRelativePath.startsWith(pathJoin("keycloak", "login")) &&
!fileRelativePath.startsWith(pathJoin("keycloak", "common"))
) {
return;
}
if (fileRelativePath.endsWith(".ftl")) {
return;
}
break;
case KEYCLOAK_VERSION.FOR_ACCOUNT_MULTI_PAGE:
if (
!fileRelativePath.startsWith(pathJoin("base", "account")) &&
!fileRelativePath.startsWith(pathJoin("keycloak", "account")) &&
!fileRelativePath.startsWith(pathJoin("keycloak", "common"))
) {
return;
}
break;
default:
assert<Equals<typeof keycloakVersion, never>>(false);
}
last_account_v1_transformations: {
if (keycloakVersion !== KEYCLOAK_VERSION.FOR_ACCOUNT_MULTI_PAGE) {
break last_account_v1_transformations;
}
skip_web_modules: {
if (
!fileRelativePath.startsWith(
pathJoin("keycloak", "common", "resources", "web_modules")
)
) {
break skip_web_modules;
}
return;
}
skip_lib: {
if (
!fileRelativePath.startsWith(
pathJoin("keycloak", "common", "resources", "lib")
)
) {
break skip_lib;
}
return;
}
skip_node_modules: {
const nodeModulesRelativeDirPath = pathJoin(
"keycloak",
"common",
"resources",
"node_modules"
);
if (!fileRelativePath.startsWith(nodeModulesRelativeDirPath)) {
break skip_node_modules;
}
if (kcNodeModulesKeepFilePaths_lastAccountV1 === undefined) {
kcNodeModulesKeepFilePaths_lastAccountV1 = new Set([
pathJoin("patternfly", "dist", "css", "patternfly.min.css"),
pathJoin(
"patternfly",
"dist",
"css",
"patternfly-additions.min.css"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Regular-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Bold-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Light-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Semibold-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"PatternFlyIcons-webfont.ttf"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"PatternFlyIcons-webfont.woff"
)
]);
}
const fileRelativeToNodeModulesPath = fileRelativePath.substring(
nodeModulesRelativeDirPath.length + 1
);
if (
kcNodeModulesKeepFilePaths_lastAccountV1.has(
fileRelativeToNodeModulesPath
)
) {
break skip_node_modules;
}
return;
}
patch_account_css: {
if (
fileRelativePath !==
pathJoin("keycloak", "account", "resources", "css", "account.css")
) {
break patch_account_css;
}
await writeFile({
fileRelativePath,
modifiedData: Buffer.from(
(await readFile())
.toString("utf8")
.replace("top: -34px;", "top: -34px !important;"),
"utf8"
)
});
return;
}
}
skip_unused_resources: {
if (keycloakVersion !== KEYCLOAK_VERSION.FOR_LOGIN_THEME) {
break skip_unused_resources;
}
skip_node_modules: {
const nodeModulesRelativeDirPath = pathJoin(
"keycloak",
"common",
"resources",
"node_modules"
);
if (!fileRelativePath.startsWith(nodeModulesRelativeDirPath)) {
break skip_node_modules;
}
if (kcNodeModulesKeepFilePaths === undefined) {
kcNodeModulesKeepFilePaths = new Set([
pathJoin("@patternfly", "patternfly", "patternfly.min.css"),
pathJoin("patternfly", "dist", "css", "patternfly.min.css"),
pathJoin(
"patternfly",
"dist",
"css",
"patternfly-additions.min.css"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Regular-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Light-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Bold-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Bold-webfont.woff"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Bold-webfont.ttf"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"fontawesome-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"PatternFlyIcons-webfont.ttf"
),
pathJoin(
"patternfly",
"dist",
"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"),
pathJoin("rfc4648", "lib", "rfc4648.js")
]);
}
const fileRelativeToNodeModulesPath = fileRelativePath.substring(
nodeModulesRelativeDirPath.length + 1
);
if (kcNodeModulesKeepFilePaths.has(fileRelativeToNodeModulesPath)) {
break skip_node_modules;
}
return;
}
skip_vendor: {
if (
!fileRelativePath.startsWith(
pathJoin("keycloak", "common", "resources", "vendor")
)
) {
break skip_vendor;
}
return;
}
skip_rollup_config: {
if (
fileRelativePath !==
pathJoin("keycloak", "common", "resources", "rollup.config.js")
) {
break skip_rollup_config;
}
return;
}
skip_package_json: {
if (
fileRelativePath !==
pathJoin("keycloak", "common", "resources", "package.json")
) {
break skip_package_json;
}
return;
}
}
await writeFile({ fileRelativePath });
}
});
return { extractedDirPath };
}

View File

@ -13,7 +13,7 @@ export function startRebuildOnSrcChange() {
const dCompleted = new Deferred<void>();
const child = child_process.spawn("yarn", ["build"]);
const child = child_process.spawn("yarn", ["build"], { shell: true });
child.stdout.on("data", data => process.stdout.write(data));
@ -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,28 +1,23 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
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")
{
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"]);
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

@ -1,7 +1,4 @@
import {
nameOfTheGlobal,
basenameOfTheKeycloakifyResourcesDir
} from "keycloakify/bin/shared/constants";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "keycloakify/bin/shared/constants";
import { assert } from "tsafe/assert";
/**
@ -9,7 +6,9 @@ import { assert } from "tsafe/assert";
* This works both in your main app and in your Keycloak theme.
*/
export const PUBLIC_URL = (() => {
const kcContext = (window as any)[nameOfTheGlobal];
const kcContext: { "x-keycloakify": { resourcesPath: string } } | undefined = (
window as any
).kcContext;
if (kcContext === undefined || process.env.NODE_ENV === "development") {
assert(
@ -20,5 +19,5 @@ export const PUBLIC_URL = (() => {
return process.env.PUBLIC_URL;
}
return `${kcContext.url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}`;
return `${kcContext["x-keycloakify"].resourcesPath}/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}`;
})();

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 & {
@ -166,6 +169,7 @@ export declare namespace KcContext {
algorithm: "HmacSHA1" | "HmacSHA256" | "HmacSHA512";
digits: number;
lookAheadWindow: number;
getAlgorithmKey: () => string;
} & (
| {
type: "totp";

View File

@ -1,10 +1,10 @@
import "keycloakify/tools/Object.fromEntries";
import { resources_common, keycloak_resources } from "keycloakify/bin/shared/constants";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } 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}${WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY}/account`;
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}/${WELL_KNOWN_DIRECTORY_BASE_NAME.RESOURCES_COMMON}`,
resourceUrl: "#",
accountUrl: "#",
applicationsUrl: "#",
@ -82,16 +82,9 @@ export const kcContextCommonMock: KcContext.Common = {
email: "john.doe@code.gouv.fr",
username: "doe_j"
},
properties: {
parent: "account-v1",
kcButtonLargeClass: "btn-lg",
locales:
"ar,ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN",
kcButtonPrimaryClass: "btn-primary",
accountResourceProvider: "account-v1",
styles: "css/account.css img/icon-sidebar-active.png img/logo.png resources-common/node_modules/patternfly/dist/css/patternfly.min.css resources-common/node_modules/patternfly/dist/css/patternfly-additions.min.css resources-common/node_modules/patternfly/dist/css/patternfly-additions.min.css",
kcButtonClass: "btn",
kcButtonDefaultClass: "btn-default"
properties: {},
"x-keycloakify": {
messages: {}
}
};
@ -158,7 +151,8 @@ export const kcContextMocks: KcContext[] = [
digits: 6,
lookAheadWindow: 1,
type: "totp",
period: 30
period: 30,
getAlgorithmKey: () => "SHA1"
}
},
mode: "qr",

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

@ -16,12 +16,6 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
const { msg, msgStr, advancedMsg } = i18n;
const algToKeyUriAlg: Record<(typeof kcContext)["totp"]["policy"]["algorithm"], string> = {
HmacSHA1: "SHA1",
HmacSHA256: "SHA256",
HmacSHA512: "SHA512"
};
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="totp">
<>
@ -100,7 +94,7 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
{msg("totpType")}: {msg(`totp.${totp.policy.type}`)}
</li>
<li id="kc-totp-algorithm">
{msg("totpAlgorithm")}: {algToKeyUriAlg?.[totp.policy.algorithm] ?? totp.policy.algorithm}
{msg("totpAlgorithm")}: {totp.policy.getAlgorithmKey()}
</li>
<li id="kc-totp-digits">
{msg("totpDigits")}: {totp.policy.digits}
@ -160,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} />
@ -186,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";
@ -13,7 +13,6 @@ import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import { getThemeSrcDirPath } from "./shared/getThemeSrcDirPath";
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import chalk from "chalk";
@ -27,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}`);
@ -41,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);
})()
@ -53,17 +84,13 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(`${pageId}`);
const { themeSrcDirPath } = getThemeSrcDirPath({
projectDirPath: buildContext.projectDirPath
});
const componentBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(
/ftl$/,
"stories.tsx"
);
const targetFilePath = pathJoin(
themeSrcDirPath,
buildContext.themeSrcDirPath,
themeType,
"pages",
componentBasename
@ -86,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);
@ -103,7 +131,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
)} copy pasted from the Keycloakify source code into your project`,
`You can start storybook with ${chalk.bold("yarn storybook")}`
`You can start storybook with ${chalk.bold("npm run storybook")}`
].join("\n")
);
}

View File

@ -7,7 +7,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const buildContext = getBuildContext({ cliCommandOptions });
await copyKeycloakResourcesToPublic({
copyKeycloakResourcesToPublic({
buildContext
});
}

View File

@ -1,63 +0,0 @@
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
import { getBuildContext } from "./shared/buildContext";
import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTheme";
import { transformCodebase } from "./tools/transformCodebase";
import type { CliCommandOptions } from "./main";
import chalk from "chalk";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildContext = getBuildContext({
cliCommandOptions
});
console.log(
chalk.cyan(
"Select the Keycloak version from which you want to download the builtins theme:"
)
);
const { keycloakVersion } = await promptKeycloakVersion({
startingFromMajor: undefined,
cacheDirPath: buildContext.cacheDirPath
});
console.log(`${keycloakVersion}`);
const destDirPath = pathJoin(
buildContext.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme"
);
console.log(
[
`Downloading builtins theme of Keycloak ${keycloakVersion} here:`,
`- ${chalk.bold(
`.${pathSep}${pathJoin(pathRelative(process.cwd(), destDirPath), "base")}`
)}`,
`- ${chalk.bold(
`.${pathSep}${pathJoin(
pathRelative(process.cwd(), destDirPath),
"keycloak"
)}`
)}`
].join("\n")
);
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildContext
});
transformCodebase({
srcDirPath: defaultThemeDirPath,
destDirPath
});
console.log(chalk.green(`✓ done`));
}

View File

@ -3,19 +3,23 @@
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 { getThemeSrcDirPath } from "./shared/getThemeSrcDirPath";
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import chalk from "chalk";
@ -29,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}`);
@ -55,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);
})()
@ -68,10 +175,6 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(`${pageIdOrComponent}`);
const { themeSrcDirPath } = getThemeSrcDirPath({
projectDirPath: buildContext.projectDirPath
});
const componentBasename = (() => {
if (pageIdOrComponent === templateValue) {
return "Template.tsx";
@ -96,7 +199,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
})();
const targetFilePath = pathJoin(
themeSrcDirPath,
buildContext.themeSrcDirPath,
themeType,
pagesOrDot,
componentBasename
@ -149,7 +252,11 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
break edit_KcApp;
}
const kcAppTsxPath = pathJoin(themeSrcDirPath, themeType, "KcApp.tsx");
const kcAppTsxPath = pathJoin(
buildContext.themeSrcDirPath,
themeType,
"KcPage.tsx"
);
const kcAppTsxCode = fs.readFileSync(kcAppTsxPath).toString("utf8");
@ -172,7 +279,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
if (kcAppTsxCode === modifiedKcAppTsxCode) {
console.log(
chalk.red(
"Unable to automatically update KcApp.tsx, please update it manually"
"Unable to automatically update KcPage.tsx, please update it manually"
)
);
return;
@ -191,6 +298,8 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const userProfileFormFieldComponentName = "UserProfileFormFields";
const componentName = componentBasename.replace(/.tsx$/, "");
console.log(
[
``,
@ -199,23 +308,20 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
`${chalk.bold(
pathJoin(
".",
pathRelative(process.cwd(), themeSrcDirPath),
pathRelative(process.cwd(), buildContext.themeSrcDirPath),
themeType,
"KcApp.tsx"
"KcPage.tsx"
)
)}:`,
chalk.grey("```"),
`// ...`,
``,
chalk.green(
`+const ${componentBasename.replace(
/.tsx$/,
""
)} = lazy(() => import("./pages/${componentBasename}"));`
`+const ${componentName} = lazy(() => import("./pages/${componentName}"));`
),
...[
``,
` export default function KcApp(props: { kcContext: KcContext; }) {`,
` export default function KcPage(props: { kcContext: KcContext; }) {`,
``,
` // ...`,
``,
@ -225,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,155 @@
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 { SemVer } from "../tools/SemVer";
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"] = SemVer.stringify(
semVersionedTag.version
);
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,97 @@
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, unknown>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
keycloakify: z.record(z.unknown())
});
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);
return parsedPackageJson;
})();
parsedPackageJson.keycloakify.accountThemeImplementation =
accountThemeType;
fs.writeFileSync(
buildContext.packageJsonFilePath,
Buffer.from(JSON.stringify(parsedPackageJson, undefined, 4), "utf8")
);
}
break;
}
}

View File

@ -1,26 +1,24 @@
import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTheme";
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
import { getBuildContext } from "./shared/buildContext";
import * as fs from "fs";
import { getThemeSrcDirPath } from "./shared/getThemeSrcDirPath";
import type { CliCommandOptions } from "./main";
import { downloadAndExtractArchive } from "./tools/downloadAndExtractArchive";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildContext = getBuildContext({ cliCommandOptions });
const { themeSrcDirPath } = getThemeSrcDirPath({
projectDirPath: buildContext.projectDirPath
});
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
if (fs.existsSync(emailThemeSrcDirPath)) {
if (
fs.existsSync(emailThemeSrcDirPath) &&
fs.readdirSync(emailThemeSrcDirPath).length > 0
) {
console.warn(
`There is already a ${pathRelative(
`There is already a non empty ${pathRelative(
process.cwd(),
emailThemeSrcDirPath
)} directory in your project. Aborting.`
@ -34,16 +32,32 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const { keycloakVersion } = await promptKeycloakVersion({
// NOTE: This is arbitrary
startingFromMajor: 17,
cacheDirPath: buildContext.cacheDirPath
});
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
excludeMajorVersions: [],
doOmitPatch: false,
buildContext
});
const { extractedDirPath } = await downloadAndExtractArchive({
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "extractOnlyEmailTheme",
onArchiveFile: async ({ fileRelativePath, writeFile }) => {
const fileRelativePath_target = pathRelative(
pathJoin("theme", "base", "email"),
fileRelativePath
);
if (fileRelativePath_target.startsWith("..")) {
return;
}
await writeFile({ fileRelativePath: fileRelativePath_target });
}
});
transformCodebase({
srcDirPath: pathJoin(defaultThemeDirPath, "base", "email"),
srcDirPath: extractedDirPath,
destDirPath: emailThemeSrcDirPath
});
@ -53,7 +67,10 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
fs.writeFileSync(
themePropertyFilePath,
Buffer.from(
`parent=base\n${fs.readFileSync(themePropertyFilePath).toString("utf8")}`,
[
`parent=base`,
fs.readFileSync(themePropertyFilePath).toString("utf8")
].join("\n"),
"utf8"
)
);

View File

@ -7,7 +7,6 @@ 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 {
generatePom,
BuildContextLike as BuildContextLike_generatePom
@ -16,7 +15,7 @@ import { readFileSync } from "fs";
import { isInside } from "../../tools/isInside";
import child_process from "child_process";
import { rmSync } from "../../tools/fs.rmSync";
import { getMetaInfKeycloakThemesJsonFilePath } from "../../shared/metaInfKeycloakThemes";
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
export type BuildContextLike = BuildContextLike_generatePom & {
keycloakifyBuildDirPath: string;
@ -24,6 +23,7 @@ export type BuildContextLike = BuildContextLike_generatePom & {
artifactId: string;
themeVersion: string;
cacheDirPath: string;
implementedThemeTypes: BuildContext["implementedThemeTypes"];
};
assert<BuildContext extends BuildContextLike ? true : false>();
@ -32,52 +32,39 @@ export async function buildJar(params: {
jarFileBasename: string;
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
resourcesDirPath: string;
doesImplementAccountV1Theme: boolean;
buildContext: BuildContextLike;
}): Promise<void> {
const {
jarFileBasename,
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(
keycloakifyBuildCacheDirPath,
"src",
"main",
"resources"
);
{
const transformCodebase_common = (params: {
fileRelativePath: string;
sourceCode: Buffer;
}): { modifiedSourceCode: Buffer } | undefined => {
const { fileRelativePath, sourceCode } = params;
rmSync(tmpResourcesDirPath, { recursive: true, force: true });
if (
fileRelativePath ===
getMetaInfKeycloakThemesJsonFilePath({ keycloakifyBuildDirPath: "." })
) {
return { modifiedSourceCode: sourceCode };
}
for (const themeName of [...buildContext.themeNames, accountV1ThemeName]) {
if (
isInside({
dirPath: pathJoin("src", "main", "resources", "theme", themeName),
filePath: fileRelativePath
})
) {
return { modifiedSourceCode: sourceCode };
}
}
return undefined;
};
const transformCodebase_patchForUsingBuiltinAccountV1 =
keycloakAccountV1Version !== null
transformCodebase({
srcDirPath: resourcesDirPath,
destDirPath: tmpResourcesDirPath,
transformSourceCode:
!doesImplementAccountV1Theme || keycloakAccountV1Version !== null
? undefined
: (params: {
fileRelativePath: string;
@ -87,64 +74,22 @@ export async function buildJar(params: {
if (
isInside({
dirPath: pathJoin(
"src",
"main",
"resources",
"theme",
accountV1ThemeName
),
dirPath: pathJoin("theme", "account-v1"),
filePath: fileRelativePath
})
) {
return undefined;
}
if (
fileRelativePath ===
getMetaInfKeycloakThemesJsonFilePath({
keycloakifyBuildDirPath: "."
})
) {
const keycloakThemesJsonParsed = JSON.parse(
sourceCode.toString("utf8")
) as {
themes: { name: string; types: string[] }[];
};
keycloakThemesJsonParsed.themes =
keycloakThemesJsonParsed.themes.filter(
({ name }) => name !== accountV1ThemeName
);
return {
modifiedSourceCode: Buffer.from(
JSON.stringify(keycloakThemesJsonParsed, null, 2),
"utf8"
)
};
}
for (const themeName of buildContext.themeNames) {
if (
fileRelativePath ===
pathJoin(
"src",
"main",
"resources",
"theme",
themeName,
"account",
"theme.properties"
)
pathJoin("theme", themeName, "account", "theme.properties")
) {
const modifiedSourceCode = Buffer.from(
sourceCode
.toString("utf8")
.replace(
`parent=${accountV1ThemeName}`,
"parent=keycloak"
),
.replace(`parent=account-v1`, "parent=keycloak"),
"utf8"
);
@ -157,58 +102,43 @@ export async function buildJar(params: {
}
return { modifiedSourceCode: sourceCode };
};
}
});
transformCodebase({
srcDirPath: buildContext.keycloakifyBuildDirPath,
destDirPath: keycloakifyBuildTmpDirPath,
transformSourceCode: params => {
const resultCommon = transformCodebase_common(params);
remove_account_v1_in_meta_inf: {
if (!doesImplementAccountV1Theme) {
// NOTE: We do not have account v1 anyway
break remove_account_v1_in_meta_inf;
}
if (transformCodebase_patchForUsingBuiltinAccountV1 === undefined) {
return resultCommon;
}
if (keycloakAccountV1Version !== null) {
// NOTE: No, we need to keep account-v1 in meta-inf
break remove_account_v1_in_meta_inf;
}
if (resultCommon === undefined) {
return undefined;
}
writeMetaInfKeycloakThemes({
resourcesDirPath: tmpResourcesDirPath,
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => {
assert(metaInfKeycloakTheme !== undefined);
const { modifiedSourceCode } = resultCommon;
metaInfKeycloakTheme.themes = metaInfKeycloakTheme.themes.filter(
({ name }) => name !== "account-v1"
);
return transformCodebase_patchForUsingBuiltinAccountV1({
...params,
sourceCode: modifiedSourceCode
});
return metaInfKeycloakTheme;
}
});
}
route_legacy_pages: {
// NOTE: If there's no account theme there is no special target for keycloak 24 and up so we create
// the pages anyway. If there is an account pages, since we know that account-v1 is only support keycloak
// 24 in version 0.4 and up, we can safely break the route for legacy pages.
const doBreak: boolean = (() => {
switch (keycloakAccountV1Version) {
case null:
return false;
case "0.3":
return false;
default:
return true;
}
})();
if (doBreak) {
if (!buildContext.implementedThemeTypes.login.isImplemented) {
break route_legacy_pages;
}
(["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",
@ -217,7 +147,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";
@ -228,14 +158,14 @@ export async function buildJar(params: {
})();
const modifiedFtlFileContent = ftlFileContent.replace(
`out["pageId"] = "\${pageId}";`,
`out["pageId"] = "${pageId}"; out["realPageId"] = "${realPageId}";`
`"ftlTemplateFileName": "${pageId}"`,
`"ftlTemplateFileName": "${ftlFileBasename}"`
);
assert(modifiedFtlFileContent !== ftlFileContent);
fs.writeFile(
pathJoin(pathDirname(ftlFilePath), realPageId),
pathJoin(pathDirname(ftlFilePath), ftlFileBasename),
Buffer.from(modifiedFtlFileContent, "utf8")
);
})
@ -250,15 +180,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(
@ -283,12 +213,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

@ -1,5 +1,4 @@
import { assert } from "tsafe/assert";
import { exclude } from "tsafe/exclude";
import {
keycloakAccountV1Versions,
keycloakThemeAdditionalInfoExtensionVersions
@ -7,32 +6,33 @@ import {
import { getKeycloakVersionRangeForJar } from "./getKeycloakVersionRangeForJar";
import { buildJar, BuildContextLike as BuildContextLike_buildJar } from "./buildJar";
import type { BuildContext } from "../../shared/buildContext";
import { getJarFileBasename } from "../../shared/getJarFileBasename";
import { readMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
import { accountV1ThemeName } from "../../shared/constants";
export type BuildContextLike = BuildContextLike_buildJar & {
projectDirPath: string;
keycloakifyBuildDirPath: string;
implementedThemeTypes: BuildContext["implementedThemeTypes"];
jarTargets: BuildContext["jarTargets"];
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function buildJars(params: {
resourcesDirPath: string;
buildContext: BuildContextLike;
}): Promise<void> {
const { buildContext } = params;
const { resourcesDirPath, buildContext } = params;
const doesImplementAccountTheme = readMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildContext.keycloakifyBuildDirPath
}).themes.some(({ name }) => name === accountV1ThemeName);
const doesImplementAccountV1Theme =
buildContext.implementedThemeTypes.account.isImplemented &&
buildContext.implementedThemeTypes.account.type === "Multi-Page";
await Promise.all(
keycloakAccountV1Versions
.map(keycloakAccountV1Version =>
keycloakThemeAdditionalInfoExtensionVersions
.map(keycloakThemeAdditionalInfoExtensionVersion => {
keycloakThemeAdditionalInfoExtensionVersions.map(
keycloakThemeAdditionalInfoExtensionVersion => {
const keycloakVersionRange = getKeycloakVersionRangeForJar({
doesImplementAccountTheme,
doesImplementAccountV1Theme,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
});
@ -41,39 +41,27 @@ export async function buildJars(params: {
return undefined;
}
return {
keycloakThemeAdditionalInfoExtensionVersion,
keycloakVersionRange
};
})
.filter(exclude(undefined))
.map(
({
keycloakThemeAdditionalInfoExtensionVersion,
keycloakVersionRange
}) => {
const { jarFileBasename } = getJarFileBasename({
keycloakVersionRange
});
const jarTarget = buildContext.jarTargets.find(
jarTarget =>
jarTarget.keycloakVersionRange === keycloakVersionRange
);
return {
keycloakThemeAdditionalInfoExtensionVersion,
jarFileBasename
};
if (jarTarget === undefined) {
return undefined;
}
)
.map(
({
const { jarFileBasename } = jarTarget;
return buildJar({
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
jarFileBasename
}) =>
buildJar({
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
buildContext
})
)
resourcesDirPath,
doesImplementAccountV1Theme,
buildContext
});
}
)
)
.flat()
);

View File

@ -1,5 +1,5 @@
// NOTE: v0.5 is a dummy version.
export const keycloakAccountV1Versions = [null, "0.3", "0.4"] as const;
export const keycloakAccountV1Versions = [null, "0.3", "0.4", "0.6"] as const;
/**
* https://central.sonatype.com/artifact/io.phasetwo.keycloak/keycloak-account-v1

View File

@ -42,7 +42,7 @@ export function generatePom(params: {
` <properties>`,
` <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>`,
` </properties>`,
...(keycloakAccountV1Version !== null &&
...(keycloakAccountV1Version !== null ||
keycloakThemeAdditionalInfoExtensionVersion !== null
? [
` <build>`,

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:
@ -44,18 +44,26 @@ export function getKeycloakVersionRangeForJar(params: {
case null:
return undefined;
case "1.1.5":
return "24-and-above" as const;
return "24" as const;
}
assert<
Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>
>(false);
case "0.6":
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return undefined;
case "1.1.5":
return "25-and-above" as const;
}
}
assert<Equals<typeof keycloakAccountV1Version, never>>(false);
})();
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithAccountTheme | undefined
KeycloakVersionRange.WithAccountV1Theme | undefined
>
>();
@ -65,7 +73,6 @@ export function getKeycloakVersionRangeForJar(params: {
if (keycloakAccountV1Version !== null) {
return undefined;
}
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return "21-and-below";
@ -80,7 +87,7 @@ export function getKeycloakVersionRangeForJar(params: {
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithoutAccountTheme | undefined
KeycloakVersionRange.WithoutAccountV1Theme | undefined
>
>();

View File

@ -1,544 +0,0 @@
<script>const _=
(()=>{
<#assign pageId="PAGE_ID_xIgLsPgGId9D8e">
const out = ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc};
out["messagesPerField"]= {
<#assign fieldNames = [ FIELD_NAMES_eKsIY4ZsZ4xeM ]>
<#attempt>
<#if profile?? && profile.attributes?? && profile.attributes?is_enumerable>
<#list profile.attributes as attribute>
<#if fieldNames?seq_contains(attribute.name)>
<#continue>
</#if>
<#assign fieldNames += [attribute.name]>
</#list>
</#if>
<#recover>
</#attempt>
"printIfExists": function (fieldName, text) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
return <#if doExistErrorOnUsernameOrPassword>text<#else>undefined</#if>
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
},
"existsError": function (){
function existsError_singleFieldName(fieldName) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
<#else>
<#assign doExistErrorMessageForField = "">
<#attempt>
<#assign doExistErrorMessageForField = messagesPerField.existsError('${fieldName}')>
<#recover>
<#assign doExistErrorMessageForField = true>
</#attempt>
return <#if doExistErrorMessageForField>true<#else>false</#if>;
</#if>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
}
for( let i = 0; i < arguments.length; i++ ){
if( existsError_singleFieldName(arguments[i]) ){
return true;
}
}
return false;
},
"get": function (fieldName) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.get in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
<#if doExistErrorOnUsernameOrPassword>
<#attempt>
return decodeHtmlEntities("${msg('invalidUserMessage')?js_string}");
<#recover>
return "Invalid username or password.";
</#attempt>
<#else>
return "";
</#if>
<#else>
<#attempt>
return decodeHtmlEntities("${messagesPerField.get('${fieldName}')?js_string}");
<#recover>
return "Invalid field";
</#attempt>
</#if>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
},
"exists": function (fieldName) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.exists in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
<#else>
<#assign doExistErrorMessageForField = "">
<#attempt>
<#assign doExistErrorMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistErrorMessageForField = true>
</#attempt>
return <#if doExistErrorMessageForField>true<#else>false</#if>;
</#if>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
},
"getFirstError": function () {
for( let i = 0; i < arguments.length; i++ ){
const fieldName = arguments[i];
if( out.messagesPerField.existsError(fieldName) ){
return out.messagesPerField.get(fieldName);
}
}
}
};
out["keycloakifyVersion"] = "KEYCLOAKIFY_VERSION_xEdKd3xEdr";
out["themeVersion"] = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx";
out["themeType"] = "KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr";
out["themeName"] = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer";
out["pageId"] = "${pageId}";
try {
out["url"]["resourcesCommonPath"] = out["url"]["resourcesPath"] + "/" + "RESOURCES_COMMON_cLsLsMrtDkpVv";
} catch(error) { }
<#if profile?? && profile.attributes??>
out["lOCALIZATION_REALM_OVERRIDES_USER_PROFILE_PROPERTY_KEY_aaGLsPgGIdeeX"] = {
<#list profile.attributes as attribute>
<#if attribute.annotations?? && attribute.displayName??>
"${attribute.displayName}": decodeHtmlEntities("${advancedMsg(attribute.displayName)?js_string}"),
</#if>
<#if attribute.annotations.inputHelperTextBefore??>
"${attribute.annotations.inputHelperTextBefore}": decodeHtmlEntities("${advancedMsg(attribute.annotations.inputHelperTextBefore)?js_string}"),
</#if>
<#if attribute.annotations.inputHelperTextAfter??>
"${attribute.annotations.inputHelperTextAfter}": decodeHtmlEntities("${advancedMsg(attribute.annotations.inputHelperTextAfter)?js_string}"),
</#if>
<#if attribute.annotations.inputTypePlaceholder??>
"${attribute.annotations.inputTypePlaceholder}": decodeHtmlEntities("${advancedMsg(attribute.annotations.inputTypePlaceholder)?js_string}"),
</#if>
</#list>
};
</#if>
return out;
function decodeHtmlEntities(htmlStr){
var element = decodeHtmlEntities.element;
if (!element) {
element = document.createElement("textarea");
decodeHtmlEntities.element = element;
}
element.innerHTML = htmlStr;
return element.value;
}
})();
<#function ftl_object_to_js_code_declaring_an_object object path>
<#local isHash = "">
<#attempt>
<#local isHash = object?is_hash || object?is_hash_ex>
<#recover>
<#return "ABORT: Can't evaluate if " + path?join(".") + " is hash">
</#attempt>
<#if isHash>
<#if path?size gt 10>
<#return "ABORT: Too many recursive calls, path: " + path?join(".")>
</#if>
<#local keys = "">
<#attempt>
<#local keys = object?keys>
<#recover>
<#return "ABORT: We can't list keys on this object">
</#attempt>
<#local out_seq = []>
<#list keys as key>
<#if ["class","declaredConstructors","superclass","declaringClass" ]?seq_contains(key) >
<#continue>
</#if>
<#if
(
["loginUpdatePasswordUrl", "loginUpdateProfileUrl", "loginUsernameReminderUrl", "loginUpdateTotpUrl"]?seq_contains(key) &&
are_same_path(path, ["url"])
) || (
key == "updateProfileCtx" &&
are_same_path(path, [])
) || (
<#-- https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
key == "loginAction" &&
are_same_path(path, ["url"]) &&
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
!(auth?has_content && auth.showTryAnotherWayLink())
) || (
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
["secretData", "value"]?seq_contains(key) &&
are_same_path(path, [ "totp", "otpCredentials", "*" ])
) || (
["contextData", "idpConfig", "idp", "authenticationSession"]?seq_contains(key) &&
are_same_path(path, ["brokerContext"]) &&
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(pageId)
) || (
key == "identityProviderBrokerCtx" &&
are_same_path(path, []) &&
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(pageId)
) || (
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
are_same_path(path, ["realm"])
) || (
"error.ftl" == pageId &&
are_same_path(path, ["realm"]) &&
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
) || (
"applications.ftl" == pageId &&
(
key == "realm" ||
key == "container"
) &&
is_subpath(path, ["applications", "applications"])
) || (
key == "delegateForUpdate" &&
are_same_path(path, ["user"])
) || (
<#-- Security audit forwarded by Garth (Gmail) -->
key == "saml.signing.private.key" &&
are_same_path(path, ["client", "attributes"])
) || (
<#-- See: https://github.com/keycloakify/keycloakify/issues/534 -->
key == "password" &&
are_same_path(path, ["login"])
) || (
<#-- Remove realmAttributes added by https://github.com/jcputney/keycloak-theme-additional-info-extension for peace of mind. -->
key == "realmAttributes" &&
are_same_path(path, [])
) || (
<#-- attributesByName adds a lot of noise to the output and is not needed -->
key == "attributes" &&
are_same_path(path, ["profile"])
) || (
<#-- We already have the attributes in profile speedup the rendering by filtering it out from the register object -->
(key == "attributes" || key == "attributesByName") &&
are_same_path(path, ["register"])
)
>
<#local out_seq += ["/*" + path?join(".") + "." + key + " excluded*/"]>
<#continue>
</#if>
USER_DEFINED_EXCLUSIONS_eKsaY4ZsZ4eMr2
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
<#if (
["register.ftl", "register-user-profile.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
key == "attemptedUsername" && are_same_path(path, ["auth"])
)>
<#attempt>
<#-- https://github.com/keycloak/keycloak/blob/3a2bf0c04bcde185e497aaa32d0bb7ab7520cf4a/themes/src/main/resources/theme/base/login/template.ftl#L63 -->
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<#local out_seq += ["/*" + path?join(".") + "." + key + " excluded*/"]>
<#continue>
</#if>
<#recover>
<#local out_seq += ["/*Accessing attemptedUsername throwed an exception */"]>
</#attempt>
</#if>
<#attempt>
<#if !object[key]??>
<#continue>
</#if>
<#recover>
<#local out_seq += ["/*Couldn't test if '" + key + "' is available on this object*/"]>
<#continue>
</#attempt>
<#local propertyValue = "">
<#attempt>
<#local propertyValue = object[key]>
<#recover>
<#local out_seq += ["/*Couldn't dereference '" + key + "' on this object*/"]>
<#continue>
</#attempt>
<#local rec_out = ftl_object_to_js_code_declaring_an_object(propertyValue, path + [ key ])>
<#if rec_out?starts_with("ABORT:")>
<#local errorMessage = rec_out?remove_beginning("ABORT:")>
<#if errorMessage != " It's a method" >
<#local out_seq += ["/*" + key + ": " + errorMessage + "*/"]>
</#if>
<#continue>
</#if>
<#local out_seq += ['"' + key + '": ' + rec_out + ","]>
</#list>
<#return (["{"] + out_seq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "}"])?join("\n")>
</#if>
<#local isMethod = "">
<#attempt>
<#local isMethod = object?is_method>
<#recover>
<#return "ABORT: Can't test if it'sa method.">
</#attempt>
<#if isMethod>
<#if are_same_path(path, ["auth", "showUsername"])>
<#attempt>
<#return auth.showUsername()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showUsername()">
</#attempt>
</#if>
<#if are_same_path(path, ["auth", "showResetCredentials"])>
<#attempt>
<#return auth.showResetCredentials()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showResetCredentials()">
</#attempt>
</#if>
<#if are_same_path(path, ["auth", "showTryAnotherWayLink"])>
<#attempt>
<#return auth.showTryAnotherWayLink()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showTryAnotherWayLink()">
</#attempt>
</#if>
<#if are_same_path(path, ["url", "getLogoutUrl"])>
<#local returnValue = "">
<#attempt>
<#local returnValue = url.getLogoutUrl()>
<#recover>
<#return "ABORT: Couldn't evaluate url.getLogoutUrl()">
</#attempt>
<#return 'function(){ return "' + returnValue + '"; }'>
</#if>
<#if are_same_path(path, ["totp", "policy", "getAlgorithmKey"])>
<#local returnValue = "">
<#attempt>
<#local returnValue = totp.policy.getAlgorithmKey()>
<#recover>
<#return "ABORT: Couldn't evaluate totp.policy.getAlgorithmKey()">
</#attempt>
<#return 'function(){ return "' + returnValue + '"; }'>
</#if>
<#return "ABORT: It's a method">
</#if>
<#local isBoolean = "">
<#attempt>
<#local isBoolean = object?is_boolean>
<#recover>
<#return "ABORT: Can't test if it's a boolean">
</#attempt>
<#if isBoolean>
<#return object?c>
</#if>
<#local isEnumerable = "">
<#attempt>
<#local isEnumerable = object?is_enumerable>
<#recover>
<#return "ABORT: Can't test if it's an enumerable">
</#attempt>
<#if isEnumerable>
<#local out_seq = []>
<#local i = 0>
<#list object as array_item>
<#if !array_item??>
<#local out_seq += ["null,"]>
<#continue>
</#if>
<#local rec_out = ftl_object_to_js_code_declaring_an_object(array_item, path + [ i ])>
<#local i = i + 1>
<#if rec_out?starts_with("ABORT:")>
<#local errorMessage = rec_out?remove_beginning("ABORT:")>
<#if errorMessage != " It's a method" >
<#local out_seq += ["/*" + i?string + ": " + errorMessage + "*/"]>
</#if>
<#continue>
</#if>
<#local out_seq += [rec_out + ","]>
</#list>
<#return (["["] + out_seq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "]"])?join("\n")>
</#if>
<#local isDate = "">
<#attempt>
<#local isDate = object?is_date_like>
<#recover>
<#return "ABORT: Can't test if it's a date">
</#attempt>
<#if isDate>
<#return '"' + object?datetime?iso_utc + '"'>
</#if>
<#local isNumber = "">
<#attempt>
<#local isNumber = object?is_number>
<#recover>
<#return "ABORT: Can't test if it's a number">
</#attempt>
<#if isNumber>
<#return object?c>
</#if>
<#attempt>
<#return '"' + object?js_string + '"'>;
<#recover>
</#attempt>
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non number, non enumerable object">
</#function>
<#function is_subpath path searchedPath>
<#if path?size < searchedPath?size>
<#return false>
</#if>
<#local i=0>
<#list path as property>
<#if i == searchedPath?size >
<#continue>
</#if>
<#local searchedProperty=searchedPath[i]>
<#local i+= 1>
<#if searchedProperty?is_string && searchedProperty == "*">
<#continue>
</#if>
<#if searchedProperty?is_string && !property?is_string>
<#return false>
</#if>
<#if searchedProperty?is_number && !property?is_number>
<#return false>
</#if>
<#if searchedProperty?string != property?string>
<#return false>
</#if>
</#list>
<#return true>
</#function>
<#function are_same_path path searchedPath>
<#return path?size == searchedPath?size && is_subpath(path, searchedPath)>
</#function>
</script>

View File

@ -1,35 +1,31 @@
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,
nameOfTheGlobal,
basenameOfTheKeycloakifyResourcesDir,
resources_common,
nameOfTheLocalizationRealmOverridesUserProfileProperty
} from "../../shared/constants";
import { type ThemeType, WELL_KNOWN_DIRECTORY_BASE_NAME } 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;
@ -37,7 +33,6 @@ export function generateFtlFilesCodeFactory(params: {
}) {
const {
themeName,
cssGlobalsToDefine,
indexHtmlCode,
buildContext,
keycloakifyVersion,
@ -66,8 +61,9 @@ export function generateFtlFilesCodeFactory(params: {
assert(cssCode !== null);
const { fixedCssCode } = replaceImportsInInlineCssCode({
const { fixedCssCode } = replaceImportsInCssCode({
cssCode,
cssFileRelativeDirPath: undefined,
buildContext
});
@ -77,7 +73,8 @@ export function generateFtlFilesCodeFactory(params: {
(
[
["link", "href"],
["script", "src"]
["script", "src"],
["script", "data-src"]
] as const
).forEach(([selector, attrName]) =>
$(selector).each((...[, element]) => {
@ -93,30 +90,15 @@ export function generateFtlFilesCodeFactory(params: {
new RegExp(
`^${(buildContext.urlPathname ?? "/").replace(/\//g, "\\/")}`
),
`\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
`\${xKeycloakify.resourcesPath}/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/`
)
);
})
);
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.
const ftlObjectToJsCodeDeclaringAnObject = fs
const kcContextDeclarationTemplateFtl = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
@ -124,33 +106,26 @@ export function generateFtlFilesCodeFactory(params: {
"bin",
"keycloakify",
"generateFtl",
"ftl_object_to_js_code_declaring_an_object.ftl"
"kcContextDeclarationTemplate.ftl"
)
)
.toString("utf8")
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1]
.replace("{{themeType}}", themeType)
.replace("{{themeName}}", themeName)
.replace("{{keycloakifyVersion}}", keycloakifyVersion)
.replace("{{themeVersion}}", buildContext.themeVersion)
.replace("{{fieldNames}}", fieldNames.map(name => `"${name}"`).join(", "))
.replace("{{RESOURCES_COMMON}}", WELL_KNOWN_DIRECTORY_BASE_NAME.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" }';
$("head").prepend(
`<script>\nwindow.${nameOfTheGlobal}=${ftlObjectToJsCodeDeclaringAnObjectPlaceholder}</script>`
`<script>\n${ftlObjectToJsCodeDeclaringAnObjectPlaceholder}\n</script>`
);
// Remove part of the document marked as ignored.
@ -189,8 +164,9 @@ export function generateFtlFilesCodeFactory(params: {
Object.entries({
[ftlObjectToJsCodeDeclaringAnObjectPlaceholder]:
ftlObjectToJsCodeDeclaringAnObject,
PAGE_ID_xIgLsPgGId9D8e: pageId
kcContextDeclarationTemplateFtl,
"{{pageId}}": pageId,
"{{ftlTemplateFileName}}": pageId
}).map(
([searchValue, replaceValue]) =>
(ftlCode = ftlCode.replace(searchValue, replaceValue))

View File

@ -0,0 +1,667 @@
<#assign xKeycloakify={
"messages": {},
"pageId": "{{pageId}}",
"ftlTemplateFileName": "{{ftlTemplateFileName}}",
"themeType": "{{themeType}}",
"themeName": "{{themeName}}",
"keycloakifyVersion": "{{keycloakifyVersion}}",
"themeVersion": "{{themeVersion}}",
"resourcesPath": ""
}>
<#if url?? && url?is_hash && url.resourcesPath?? && url.resourcesPath?is_string>
<#assign xKeycloakify = xKeycloakify + { "resourcesPath": url.resourcesPath }>
</#if>
<#if resourceUrl?? && resourceUrl?is_string>
<#assign xKeycloakify = xKeycloakify + { "resourcesPath": resourceUrl }>
</#if>
const kcContext = ${toJsDeclarationString(.data_model, [])?no_esc};
kcContext.keycloakifyVersion = "${xKeycloakify.keycloakifyVersion}";
kcContext.themeVersion = "${xKeycloakify.themeVersion}";
kcContext.themeType = "${xKeycloakify.themeType}";
kcContext.themeName = "${xKeycloakify.themeName}";
kcContext.pageId = "${xKeycloakify.pageId}";
kcContext.ftlTemplateFileName = "${xKeycloakify.ftlTemplateFileName}";
<@addNonAutomaticallyGatherableMessagesToXKeycloakifyMessages />
kcContext["x-keycloakify"] = {};
kcContext["x-keycloakify"].resourcesPath = "${xKeycloakify.resourcesPath}";
{
var messages = {};
<#list xKeycloakify.messages as key, resolvedMsg>
messages["${key}"] = decodeHtmlEntities("${resolvedMsg?js_string}");
</#list>
kcContext["x-keycloakify"].messages = messages;
}
if(
kcContext.url instanceof Object &&
typeof kcContext.url.resourcesPath === "string"
){
kcContext.url.resourcesCommonPath = kcContext.url.resourcesPath + "/{{RESOURCES_COMMON}}";
}
if( kcContext.messagesPerField ){
var existsError_singleFieldName = kcContext.messagesPerField.existsError;
kcContext.messagesPerField.existsError = function (){
for( let i = 0; i < arguments.length; i++ ){
if( existsError_singleFieldName(arguments[i]) ){
return true;
}
}
return false;
};
kcContext.messagesPerField.exists = function (fieldName) {
return kcContext.messagesPerField.get(fieldName) !== "";
};
kcContext.messagesPerField.printIfExists = function (fieldName, text) {
return kcContext.messagesPerField.exists(fieldName) ? text : undefined;
};
kcContext.messagesPerField.getFirstError = function () {
for( let i = 0; i < arguments.length; i++ ){
const fieldName = arguments[i];
if( kcContext.messagesPerField.existsError(fieldName) ){
return kcContext.messagesPerField.get(fieldName);
}
}
};
}
attributes_to_attributesByName: {
if( !kcContext.profile ){
break attributes_to_attributesByName;
}
if( !kcContext.profile.attributes ){
break attributes_to_attributesByName;
}
var attributes = kcContext.profile.attributes;
delete kcContext.profile.attributes;
kcContext.profile.attributesByName = {};
attributes.forEach(function(attribute){
kcContext.profile.attributesByName[attribute.name] = attribute;
});
}
window.kcContext = kcContext;
function decodeHtmlEntities(htmlStr){
var element = decodeHtmlEntities.element;
if (!element) {
element = document.createElement("textarea");
decodeHtmlEntities.element = element;
}
element.innerHTML = htmlStr;
return element.value;
}
<#function toJsDeclarationString object path>
<#local isHash = -1>
<#attempt>
<#local isHash = object?is_hash || object?is_hash_ex>
<#recover>
<#return "ABORT: Can't evaluate if " + path?join(".") + " is a hash">
</#attempt>
<#if isHash>
<#if path?size gt 10>
<#return "ABORT: Too many recursive calls, path: " + path?join(".")>
</#if>
<#local keys = -1>
<#attempt>
<#local keys = object?keys>
<#recover>
<#return "ABORT: We can't list keys on object">
</#attempt>
<#local outSeq = []>
<#list keys as key>
<#if ["class","declaredConstructors","superclass","declaringClass" ]?seq_contains(key) >
<#continue>
</#if>
<#if (
areSamePath(path, ["url"]) &&
["loginUpdatePasswordUrl", "loginUpdateProfileUrl", "loginUsernameReminderUrl", "loginUpdateTotpUrl"]?seq_contains(key)
) || (
key == "updateProfileCtx" &&
areSamePath(path, [])
) || (
<#-- https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
key == "loginAction" &&
areSamePath(path, ["url"]) &&
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(xKeycloakify.pageId) &&
!(auth?has_content && auth.showTryAnotherWayLink())
) || (
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
["secretData", "value"]?seq_contains(key) &&
areSamePath(path, [ "totp", "otpCredentials", "*" ])
) || (
["contextData", "idpConfig", "idp", "authenticationSession"]?seq_contains(key) &&
areSamePath(path, ["brokerContext"]) &&
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(xKeycloakify.pageId)
) || (
key == "identityProviderBrokerCtx" &&
areSamePath(path, []) &&
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(xKeycloakify.pageId)
) || (
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
areSamePath(path, ["realm"])
) || (
xKeycloakify.pageId == "error.ftl" &&
areSamePath(path, ["realm"]) &&
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
) || (
xKeycloakify.pageId == "applications.ftl" &&
(
key == "realm" ||
key == "container"
) &&
isSubpath(path, ["applications", "applications"])
) || (
key == "delegateForUpdate" &&
areSamePath(path, ["user"])
) || (
<#-- Security audit forwarded by Garth (Gmail) -->
key == "saml.signing.private.key" &&
areSamePath(path, ["client", "attributes"])
) || (
<#-- See: https://github.com/keycloakify/keycloakify/issues/534 -->
key == "password" &&
areSamePath(path, ["login"])
) || (
<#-- Remove realmAttributes added by https://github.com/jcputney/keycloak-theme-additional-info-extension for peace of mind. -->
key == "realmAttributes" &&
areSamePath(path, [])
) || (
<#-- attributesByName adds a lot of noise to the output and is not needed, we already have profile.attributes -->
key == "attributesByName" &&
areSamePath(path, ["profile"])
) || (
<#-- We already have the attributes in profile speedup the rendering by filtering it out from the register object -->
(key == "attributes" || key == "attributesByName") &&
areSamePath(path, ["register"])
) || (
areSamePath(path, ["properties"]) &&
(
key?starts_with("kc") ||
key == "locales" ||
key == "import" ||
key == "parent" ||
key == "meta" ||
key == "stylesCommon" ||
key == "styles" ||
key == "accountResourceProvider"
)
) || (
key == "execution" &&
areSamePath(path, [])
) || (
key == "entity" &&
areSamePath(path, ["user"])
) || (
key == "attributes" &&
areSamePath(path, ["realm"])
) || (
xKeycloakify.pageId == "index.ftl" &&
xKeycloakify.themeType == "account" &&
areSamePath(path, ["realm"]) &&
![
"name",
"registrationEmailAsUsername",
"editUsernameAllowed",
"isInternationalizationEnabled",
"identityFederationEnabled",
"userManagedAccessAllowed"
]?seq_contains(key)
)
>
<#-- <#local outSeq += ["/*" + path?join(".") + "." + key + " excluded*/"]> -->
<#continue>
</#if>
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
<#if (
key == "attemptedUsername" &&
areSamePath(path, ["auth"]) &&
[
"register.ftl", "terms.ftl", "info.ftl", "login.ftl",
"login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"
]?seq_contains(xKeycloakify.pageId)
)>
<#attempt>
<#-- https://github.com/keycloak/keycloak/blob/3a2bf0c04bcde185e497aaa32d0bb7ab7520cf4a/themes/src/main/resources/theme/base/login/template.ftl#L63 -->
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<#local outSeq += ["/*" + path?join(".") + "." + key + " excluded*/"]>
<#continue>
</#if>
<#recover>
<#local outSeq += ["/*Accessing attemptedUsername throwed an exception */"]>
</#attempt>
</#if>
{{userDefinedExclusions}}
<#attempt>
<#if !object[key]??>
<#continue>
</#if>
<#recover>
<#local outSeq += ["/*Couldn't test if '" + key + "' is available on this object*/"]>
<#continue>
</#attempt>
<#local propertyValue = -1>
<#attempt>
<#local propertyValue = object[key]>
<#recover>
<#local outSeq += ["/*Couldn't dereference '" + key + "' on this object*/"]>
<#continue>
</#attempt>
<#local recOut = toJsDeclarationString(propertyValue, path + [ key ])>
<#if recOut?starts_with("ABORT:")>
<#local errorMessage = recOut?remove_beginning("ABORT:")>
<#if errorMessage != " It's a method" >
<#local outSeq += ["/*" + key + ": " + errorMessage + "*/"]>
</#if>
<#continue>
</#if>
<#local outSeq += ['"' + key + '": ' + recOut + ","]>
</#list>
<#return (["{"] + outSeq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "}"])?join("\n")>
</#if>
<#local isMethod = -1>
<#attempt>
<#local isMethod = object?is_method>
<#recover>
<#return "ABORT: Can't test if it'sa method.">
</#attempt>
<#if isMethod>
<#if areSamePath(path, ["auth", "showUsername"])>
<#attempt>
<#return auth.showUsername()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showUsername()">
</#attempt>
</#if>
<#if areSamePath(path, ["auth", "showResetCredentials"])>
<#attempt>
<#return auth.showResetCredentials()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showResetCredentials()">
</#attempt>
</#if>
<#if areSamePath(path, ["auth", "showTryAnotherWayLink"])>
<#attempt>
<#return auth.showTryAnotherWayLink()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showTryAnotherWayLink()">
</#attempt>
</#if>
<#if areSamePath(path, ["url", "getLogoutUrl"])>
<#local returnValue = -1>
<#attempt>
<#local returnValue = url.getLogoutUrl()>
<#recover>
<#return "ABORT: Couldn't evaluate url.getLogoutUrl()">
</#attempt>
<#return 'function(){ return "' + returnValue + '"; }'>
</#if>
<#if areSamePath(path, ["totp", "policy", "getAlgorithmKey"])>
<#local returnValue = "error">
<#if mode?? && mode = "manual">
<#attempt>
<#local returnValue = totp.policy.getAlgorithmKey()>
<#recover>
<#return "ABORT: Couldn't evaluate totp.policy.getAlgorithmKey()">
</#attempt>
</#if>
<#return 'function(){ return "' + returnValue + '"; }'>
</#if>
<#assign fieldNames = [{{fieldNames}}]>
<#if profile?? && profile.attributes??>
<#list profile.attributes as attribute>
<#if fieldNames?seq_contains(attribute.name)>
<#continue>
</#if>
<#assign fieldNames += [attribute.name]>
</#list>
</#if>
<#if areSamePath(path, ["messagesPerField", "get"])>
<#local jsFunctionCode = "function (fieldName) { ">
<#list fieldNames as fieldName>
<#-- See: https://github.com/keycloakify/keycloakify/issues/217 -->
<#if xKeycloakify.pageId == "login.ftl" >
<#if fieldName == "username">
<#local jsFunctionCode += "if(fieldName === 'username' || fieldName === 'password' ){ ">
<#if messagesPerField.exists('username') || messagesPerField.exists('password')>
<#local jsFunctionCode += "return kcContext.message && kcContext.message.summary ? kcContext.message.summary : 'error'; ">
<#else>
<#local jsFunctionCode += "return ''; ">
</#if>
<#local jsFunctionCode += "} ">
<#continue>
</#if>
<#if fieldName == "password">
<#continue>
</#if>
</#if>
<#local jsFunctionCode += "if(fieldName === '" + fieldName + "'){ ">
<#if messagesPerField.exists('${fieldName}')>
<#local jsFunctionCode += 'return decodeHtmlEntities("' + messagesPerField.get('${fieldName}')?js_string + '"); '>
<#else>
<#local jsFunctionCode += "return ''; ">
</#if>
<#local jsFunctionCode += "} ">
</#list>
<#local jsFunctionCode += "}">
<#return jsFunctionCode>
</#if>
<#if areSamePath(path, ["messagesPerField", "existsError"])>
<#local jsFunctionCode = "function (fieldName) { ">
<#list fieldNames as fieldName>
<#-- See: https://github.com/keycloakify/keycloakify/issues/217 -->
<#if xKeycloakify.pageId == "login.ftl" >
<#if fieldName == "username">
<#local jsFunctionCode += "if(fieldName === 'username' || fieldName === 'password' ){ ">
<#if messagesPerField.existsError('username') || messagesPerField.existsError('password')>
<#local jsFunctionCode += "return true; ">
<#else>
<#local jsFunctionCode += "return false; ">
</#if>
<#local jsFunctionCode += "} ">
<#continue>
</#if>
<#if fieldName == "password">
<#continue>
</#if>
</#if>
<#local jsFunctionCode += "if(fieldName === '" + fieldName + "' ){ ">
<#if messagesPerField.existsError('${fieldName}')>
<#local jsFunctionCode += 'return true; '>
<#else>
<#local jsFunctionCode += "return false; ">
</#if>
<#local jsFunctionCode += "}">
</#list>
<#local jsFunctionCode += "}">
<#return jsFunctionCode>
</#if>
<#if xKeycloakify.themeType == "account" && areSamePath(path, ["realm", "isInternationalizationEnabled"])>
<#attempt>
<#return realm.isInternationalizationEnabled()?c>
<#recover>
<#return "ABORT: Couldn't evaluate realm.isInternationalizationEnabled()">
</#attempt>
</#if>
<#return "ABORT: It's a method">
</#if>
<#local isBoolean = -1>
<#attempt>
<#local isBoolean = object?is_boolean>
<#recover>
<#return "ABORT: Can't test if it's a boolean">
</#attempt>
<#if isBoolean>
<#return object?c>
</#if>
<#local isEnumerable = -1>
<#attempt>
<#local isEnumerable = object?is_enumerable>
<#recover>
<#return "ABORT: Can't test if it's an enumerable">
</#attempt>
<#if isEnumerable>
<#local outSeq = []>
<#local i = 0>
<#list object as array_item>
<#if !array_item??>
<#local outSeq += ["null,"]>
<#continue>
</#if>
<#local recOut = toJsDeclarationString(array_item, path + [ i ])>
<#local i = i + 1>
<#if recOut?starts_with("ABORT:")>
<#local errorMessage = recOut?remove_beginning("ABORT:")>
<#if errorMessage != " It's a method" >
<#local outSeq += ["/*" + i?string + ": " + errorMessage + "*/"]>
</#if>
<#continue>
</#if>
<#local outSeq += [recOut + ","]>
</#list>
<#return (["["] + outSeq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "]"])?join("\n")>
</#if>
<#local isDate = -1>
<#attempt>
<#local isDate = object?is_date_like>
<#recover>
<#return "ABORT: Can't test if it's a date">
</#attempt>
<#if isDate>
<#return '"' + object?datetime?iso_utc + '"'>
</#if>
<#local isNumber = -1>
<#attempt>
<#local isNumber = object?is_number>
<#recover>
<#return "ABORT: Can't test if it's a number">
</#attempt>
<#if isNumber>
<#return object?c>
</#if>
<#local isString = -1>
<#attempt>
<#local isString = object?is_string>
<#recover>
<#return "ABORT: Can't test if it's a string">
</#attempt>
<#if isString>
<@addToXKeycloakifyMessagesIfMessageKey str=object />
</#if>
<#attempt>
<#return '"' + object?js_string + '"'>;
<#recover>
</#attempt>
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non number, non enumerable object">
</#function>
<#function isSubpath path searchedPath>
<#if path?size < searchedPath?size>
<#return false>
</#if>
<#local i=0>
<#list path as property>
<#if i == searchedPath?size >
<#continue>
</#if>
<#local searchedProperty=searchedPath[i]>
<#local i+= 1>
<#if searchedProperty?is_string && searchedProperty == "*">
<#continue>
</#if>
<#if searchedProperty?is_string && !property?is_string>
<#return false>
</#if>
<#if searchedProperty?is_number && !property?is_number>
<#return false>
</#if>
<#if searchedProperty?string != property?string>
<#return false>
</#if>
</#list>
<#return true>
</#function>
<#function areSamePath path searchedPath>
<#return path?size == searchedPath?size && isSubpath(path, searchedPath)>
</#function>
<#macro addToXKeycloakifyMessagesIfMessageKey str>
<#if !msg?? || !msg?is_method>
<#return>
</#if>
<#if (str?length > 200)>
<#return>
</#if>
<#local key=removeBrackets(str)>
<#if key?length==0>
<#return>
</#if>
<#if !(key?matches(r"^[a-zA-Z0-9-_.]*$"))>
<#return>
</#if>
<#local resolvedMsg=msg(key)>
<#if resolvedMsg==key>
<#return>
</#if>
<#local messages=xKeycloakify.messages>
<#local messages = messages + { key: resolvedMsg }>
<#assign xKeycloakify = xKeycloakify + { "messages": messages }>
</#macro>
<#function removeBrackets str>
<#if str?starts_with("${") && str?ends_with("}")>
<#return str[2..(str?length-2)]>
<#else>
<#return str>
</#if>
</#function>
<#macro addNonAutomaticallyGatherableMessagesToXKeycloakifyMessages>
<#if profile?? && profile?is_hash && profile.attributes?? && profile.attributes?is_enumerable>
<#list profile.attributes as attribute>
<#if !(
attribute.annotations?? && attribute.annotations?is_hash &&
attribute.annotations.inputOptionLabelsI18nPrefix?? && attribute.annotations.inputOptionLabelsI18nPrefix?is_string
)>
<#continue>
</#if>
<#local prefix=attribute.annotations.inputOptionLabelsI18nPrefix>
<#if !(
attribute.validators?? && attribute.validators?is_hash &&
attribute.validators.options?? && attribute.validators.options?is_hash &&
attribute.validators.options.options?? && attribute.validators.options.options?is_enumerable
)>
<#continue>
</#if>
<#list attribute.validators.options.options as option>
<#if !option?is_string>
<#continue>
</#if>
<@addToXKeycloakifyMessagesIfMessageKey str="${prefix}.${option}" />
</#list>
</#list>
</#if>
<#if xKeycloakify.pageId == "terms.ftl" || termsAcceptanceRequired?? && termsAcceptanceRequired>
<@addToXKeycloakifyMessagesIfMessageKey str="termsText" />
</#if>
<#if requiredActions?? && requiredActions?is_enumerable>
<#list requiredActions as requiredAction>
<#if !requiredAction?is_string>
<#continue>
</#if>
<@addToXKeycloakifyMessagesIfMessageKey str="requiredAction.${requiredAction}" />
</#list>
</#if>
</#macro>

View File

@ -0,0 +1,192 @@
import { type ThemeType, FALLBACK_LANGUAGE_TAG } from "../../shared/constants";
import { crawl } from "../../tools/crawl";
import { join as pathJoin } from "path";
import { symToStr } from "tsafe/symToStr";
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;
themeType: ThemeType;
}): { languageTag: string; propertiesFileSource: string }[] {
const { themeSrcDirPath, themeType } = params;
const baseMessagesDirPath = pathJoin(
getThisCodebaseRootDirPath(),
"src",
themeType,
"i18n",
"messages_defaultSet"
);
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/);
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, {
sourceType: "module",
plugins: ["typescript"]
}),
generator: babelGenerate,
types: babelTypes
}
});
let messageBundleDeclarationTsCode: string | undefined = undefined;
recast.visit(root, {
visitCallExpression: function (path) {
if (
path.node.callee.type === "Identifier" &&
path.node.callee.name === "createUseI18n"
) {
messageBundleDeclarationTsCode = babelGenerate(
path.node.arguments[0] as any
).code;
return false;
}
this.traverse(path);
}
});
assert(messageBundleDeclarationTsCode !== undefined);
let messageBundle: {
[languageTag: string]: Record<string, string>;
} = {};
try {
eval(
`${symToStr({ messageBundle })} = ${messageBundleDeclarationTsCode}`
);
} catch {
console.warn(
[
"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",
messageBundleDeclarationTsCode
].join(" ")
);
}
return messageBundle;
})();
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]] ??
{})
}
])
);
const messageProperties: { languageTag: string; propertiesFileSource: string }[] =
Object.entries(mergedMessageBundle).map(([languageTag, messages]) => ({
languageTag,
propertiesFileSource: [
"",
...(themeType !== "account" ? ["parent=base"] : []),
...Object.entries(messages).map(
([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`
),
""
].join("\n")
}));
return messageProperties;
}

View File

@ -0,0 +1,42 @@
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import {
generateResourcesForMainTheme,
type BuildContextLike as BuildContextLike_generateResourcesForMainTheme
} from "./generateResourcesForMainTheme";
import { generateResourcesForThemeVariant } from "./generateResourcesForThemeVariant";
import fs from "fs";
import { rmSync } from "../../tools/fs.rmSync";
export type BuildContextLike = BuildContextLike_generateResourcesForMainTheme & {
themeNames: string[];
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateResources(params: {
buildContext: BuildContextLike;
resourcesDirPath: string;
}): Promise<void> {
const { resourcesDirPath, buildContext } = params;
const [themeName, ...themeVariantNames] = buildContext.themeNames;
if (fs.existsSync(resourcesDirPath)) {
rmSync(resourcesDirPath, { recursive: true });
}
await generateResourcesForMainTheme({
resourcesDirPath,
themeName,
buildContext
});
for (const themeVariantName of themeVariantNames) {
generateResourcesForThemeVariant({
resourcesDirPath,
themeName,
themeVariantName
});
}
}

View File

@ -0,0 +1,341 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import {
generateFtlFilesCodeFactory,
type BuildContextLike as BuildContextLike_kcContextExclusionsFtlCode
} from "../generateFtl";
import {
type ThemeType,
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS,
WELL_KNOWN_DIRECTORY_BASE_NAME
} from "../../shared/constants";
import type { BuildContext } from "../../shared/buildContext";
import { assert, type Equals } from "tsafe/assert";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import { generateMessageProperties } from "./generateMessageProperties";
import { rmSync } from "../../tools/fs.rmSync";
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
import {
writeMetaInfKeycloakThemes,
type MetaInfKeycloakTheme
} from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import * as child_process from "child_process";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode & {
extraThemeProperties: string[] | undefined;
projectDirPath: string;
projectBuildDirPath: string;
environmentVariables: { name: string; default: string }[];
implementedThemeTypes: BuildContext["implementedThemeTypes"];
themeSrcDirPath: string;
bundler: "vite" | "webpack";
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateResourcesForMainTheme(params: {
themeName: string;
resourcesDirPath: string;
buildContext: BuildContextLike;
}): Promise<void> {
const { themeName, resourcesDirPath, buildContext } = params;
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => {
const { themeType } = params;
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
};
for (const themeType of ["login", "account"] as const) {
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",
WELL_KNOWN_DIRECTORY_BASE_NAME.DIST
);
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
rmSync(destDirPath, { recursive: true, force: true });
if (
themeType === "account" &&
buildContext.implementedThemeTypes.login.isImplemented
) {
// NOTE: We prevent doing it twice, it has been done for the login theme.
transformCodebase({
srcDirPath: pathJoin(
getThemeTypeDirPath({
themeType: "login"
}),
"resources",
WELL_KNOWN_DIRECTORY_BASE_NAME.DIST
),
destDirPath
});
break apply_replacers_and_move_to_theme_resources;
}
{
const dirPath = pathJoin(
buildContext.projectBuildDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY
);
if (fs.existsSync(dirPath)) {
assert(buildContext.bundler === "webpack");
throw new Error(
[
`Keycloakify build error: The ${WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY} 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`,
`For example: \`"build": "... && rimraf ${pathRelative(buildContext.projectDirPath, dirPath)}"\``
].join(" ")
);
}
}
transformCodebase({
srcDirPath: buildContext.projectBuildDirPath,
destDirPath,
transformSourceCode: ({ filePath, fileRelativePath, sourceCode }) => {
if (filePath.endsWith(".css")) {
const { fixedCssCode } = replaceImportsInCssCode({
cssCode: sourceCode.toString("utf8"),
cssFileRelativeDirPath: pathDirname(fileRelativePath),
buildContext
});
return {
modifiedSourceCode: Buffer.from(fixedCssCode, "utf8")
};
}
if (filePath.endsWith(".js")) {
const { fixedJsCode } = replaceImportsInJsCode({
jsCode: sourceCode.toString("utf8"),
buildContext
});
return {
modifiedSourceCode: Buffer.from(fixedJsCode, "utf8")
};
}
return { modifiedSourceCode: sourceCode };
}
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
indexHtmlCode: fs
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
.toString("utf8"),
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
})
});
[
...(() => {
switch (themeType) {
case "login":
return LOGIN_THEME_PAGE_IDS;
case "account":
return isForAccountSpa ? ["index.ftl"] : ACCOUNT_THEME_PAGE_IDS;
}
})(),
...(isForAccountSpa
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
);
});
i18n_messages_generation: {
if (isForAccountSpa) {
break i18n_messages_generation;
}
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")
);
});
}
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 accountUiDirPath = child_process
.execSync("npm list @keycloakify/keycloak-account-ui --parseable", {
cwd: pathDirname(buildContext.packageJsonFilePath)
})
.toString("utf8")
.trim();
const messagesDirPath = pathJoin(accountUiDirPath, "messages");
if (!fs.existsSync(messagesDirPath)) {
throw new Error(
`Please update @keycloakify/keycloak-account-ui to 25.0.4-rc.5 or later.`
);
}
transformCodebase({
srcDirPath: messagesDirPath,
destDirPath: pathJoin(
getThemeTypeDirPath({ themeType: "account" }),
"messages"
)
});
}
keycloak_static_resources: {
if (isForAccountSpa) {
break keycloak_static_resources;
}
transformCodebase({
srcDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY,
themeType
),
destDirPath: pathJoin(themeTypeDirPath, "resources")
});
}
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
return isForAccountSpa ? "base" : "account-v1";
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(isForAccountSpa ? ["deprecatedMode=false"] : []),
...(buildContext.extraThemeProperties ?? []),
...buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
)
].join("\n\n"),
"utf8"
)
);
}
email: {
if (!buildContext.implementedThemeTypes.email.isImplemented) {
break email;
}
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
transformCodebase({
srcDirPath: emailThemeSrcDirPath,
destDirPath: getThemeTypeDirPath({ themeType: "email" })
});
}
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;
}
transformCodebase({
srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
destDirPath: pathJoin(resourcesDirPath, "theme", "account-v1", "account")
});
}
{
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
metaInfKeycloakThemes.themes.push({
name: themeName,
types: objectEntries(buildContext.implementedThemeTypes)
.filter(([, { isImplemented }]) => isImplemented)
.map(([themeType]) => themeType)
});
if (buildContext.implementedThemeTypes.account.isImplemented) {
metaInfKeycloakThemes.themes.push({
name: "account-v1",
types: ["account"]
});
}
writeMetaInfKeycloakThemes({
resourcesDirPath,
getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes
});
}
}

View File

@ -0,0 +1,70 @@
import { join as pathJoin, extname as pathExtname, sep as pathSep } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import type { BuildContext } from "../../shared/buildContext";
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
import { assert } from "tsafe/assert";
export type BuildContextLike = {
keycloakifyBuildDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function generateResourcesForThemeVariant(params: {
resourcesDirPath: string;
themeName: string;
themeVariantName: string;
}) {
const { resourcesDirPath, themeName, themeVariantName } = params;
const mainThemeDirPath = pathJoin(resourcesDirPath, "theme", themeName);
transformCodebase({
srcDirPath: mainThemeDirPath,
destDirPath: pathJoin(mainThemeDirPath, "..", themeVariantName),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (
pathExtname(fileRelativePath) === ".ftl" &&
fileRelativePath.split(pathSep).length === 2
) {
const modifiedSourceCode = Buffer.from(
Buffer.from(sourceCode)
.toString("utf-8")
.replace(
`"themeName": "${themeName}"`,
`"themeName": "${themeVariantName}"`
),
"utf8"
);
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
return { modifiedSourceCode };
}
return { modifiedSourceCode: sourceCode };
}
});
writeMetaInfKeycloakThemes({
resourcesDirPath,
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => {
assert(metaInfKeycloakTheme !== undefined);
const newMetaInfKeycloakTheme = metaInfKeycloakTheme;
newMetaInfKeycloakTheme.themes.push({
name: themeVariantName,
types: (() => {
const theme = newMetaInfKeycloakTheme.themes.find(
({ name }) => name === themeName
);
assert(theme !== undefined);
return theme.types;
})()
});
return newMetaInfKeycloakTheme;
}
});
}

View File

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

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

@ -1,90 +0,0 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../../shared/buildContext";
import {
resources_common,
lastKeycloakVersionWithAccountV1,
accountV1ThemeName
} from "../../shared/constants";
import { downloadKeycloakDefaultTheme } from "../../shared/downloadKeycloakDefaultTheme";
import { transformCodebase } from "../../tools/transformCodebase";
export type BuildContextLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
keycloakifyBuildDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function bringInAccountV1(params: { buildContext: BuildContextLike }) {
const { buildContext } = params;
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion: lastKeycloakVersionWithAccountV1,
buildContext
});
const accountV1DirPath = pathJoin(
buildContext.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme",
accountV1ThemeName,
"account"
);
transformCodebase({
srcDirPath: pathJoin(defaultThemeDirPath, "base", "account"),
destDirPath: accountV1DirPath
});
transformCodebase({
srcDirPath: pathJoin(defaultThemeDirPath, "keycloak", "account", "resources"),
destDirPath: pathJoin(accountV1DirPath, "resources")
});
transformCodebase({
srcDirPath: pathJoin(defaultThemeDirPath, "keycloak", "common", "resources"),
destDirPath: pathJoin(accountV1DirPath, "resources", resources_common)
});
fs.writeFileSync(
pathJoin(accountV1DirPath, "theme.properties"),
Buffer.from(
[
"accountResourceProvider=account-v1",
"",
"locales=ar,ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN",
"",
"styles=" +
[
"css/account.css",
"img/icon-sidebar-active.png",
"img/logo.png",
...[
"patternfly.min.css",
"patternfly-additions.min.css",
"patternfly-additions.min.css"
].map(
fileBasename =>
`${resources_common}/node_modules/patternfly/dist/css/${fileBasename}`
)
].join(" "),
"",
"##### css classes for form buttons",
"# main class used for all buttons",
"kcButtonClass=btn",
"# classes defining priority of the button - primary or default (there is typically only one priority button for the form)",
"kcButtonPrimaryClass=btn-primary",
"kcButtonDefaultClass=btn-default",
"# classes defining size of the button",
"kcButtonLargeClass=btn-lg",
""
].join("\n"),
"utf8"
)
);
}

View File

@ -1,167 +0,0 @@
import type { ThemeType } 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";
export function generateMessageProperties(params: {
themeSrcDirPath: string;
themeType: ThemeType;
}): { 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")
);
if (files.length === 0) {
return [];
}
const extraMessages = files
.map(file => {
const root = recast.parse(readFileSync(file).toString("utf8"), {
parser: {
parse: (code: string) =>
babelParser.parse(code, {
sourceType: "module",
plugins: ["typescript"]
}),
generator: babelGenerate,
types: babelTypes
}
});
const codes: string[] = [];
recast.visit(root, {
visitCallExpression: function (path) {
if (
path.node.callee.type === "Identifier" &&
path.node.callee.name === "createUseI18n"
) {
codes.push(babelGenerate(path.node.arguments[0] as any).code);
}
this.traverse(path);
}
});
return codes;
})
.flat()
.map(code => {
let extraMessages: {
[languageTag: string]: Record<string, string>;
} = {};
try {
eval(`${symToStr({ extraMessages })} = ${code}`);
} 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.",
"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
].join(" ")
);
}
return extraMessages;
});
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;
}
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({
languageTag,
propertiesFileSource: [
"# This file was generated by keycloakify",
"",
"parent=base",
"",
propertiesFileSource,
""
].join("\n")
});
}
return out;
}

View File

@ -1,34 +0,0 @@
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import {
generateSrcMainResourcesForMainTheme,
type BuildContextLike as BuildContextLike_generateSrcMainResourcesForMainTheme
} from "./generateSrcMainResourcesForMainTheme";
import { generateSrcMainResourcesForThemeVariant } from "./generateSrcMainResourcesForThemeVariant";
export type BuildContextLike = BuildContextLike_generateSrcMainResourcesForMainTheme & {
themeNames: string[];
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateSrcMainResources(params: {
buildContext: BuildContextLike;
}): Promise<void> {
const { buildContext } = params;
const [themeName, ...themeVariantNames] = buildContext.themeNames;
await generateSrcMainResourcesForMainTheme({
themeName,
buildContext
});
for (const themeVariantName of themeVariantNames) {
generateSrcMainResourcesForThemeVariant({
themeName,
themeVariantName,
buildContext
});
}
}

View File

@ -1,320 +0,0 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin, resolve as pathResolve } from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import {
generateFtlFilesCodeFactory,
type BuildContextLike as BuildContextLike_kcContextExclusionsFtlCode
} from "../generateFtl";
import {
type ThemeType,
lastKeycloakVersionWithAccountV1,
keycloak_resources,
accountV1ThemeName,
basenameOfTheKeycloakifyResourcesDir,
loginThemePageIds,
accountThemePageIds
} from "../../shared/constants";
import { isInside } from "../../tools/isInside";
import type { BuildContext } from "../../shared/buildContext";
import { assert, type Equals } from "tsafe/assert";
import {
downloadKeycloakStaticResources,
type BuildContextLike as BuildContextLike_downloadKeycloakStaticResources
} from "../../shared/downloadKeycloakStaticResources";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import { generateMessageProperties } from "./generateMessageProperties";
import {
bringInAccountV1,
type BuildContextLike as BuildContextLike_bringInAccountV1
} from "./bringInAccountV1";
import { getThemeSrcDirPath } from "../../shared/getThemeSrcDirPath";
import { rmSync } from "../../tools/fs.rmSync";
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
import {
writeMetaInfKeycloakThemes,
type MetaInfKeycloakTheme
} from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_downloadKeycloakStaticResources &
BuildContextLike_bringInAccountV1 & {
bundler: "vite" | "webpack";
extraThemeProperties: string[] | undefined;
loginThemeResourcesFromKeycloakVersion: string;
projectBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
projectDirPath: string;
keycloakifyBuildDirPath: string;
environmentVariables: { name: string; default: string }[];
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateSrcMainResourcesForMainTheme(params: {
themeName: string;
buildContext: BuildContextLike;
}): Promise<void> {
const { themeName, buildContext } = params;
const { themeSrcDirPath } = getThemeSrcDirPath({
projectDirPath: buildContext.projectDirPath
});
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => {
const { themeType } = params;
return pathJoin(
buildContext.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme",
themeName,
themeType
);
};
const cssGlobalsToDefine: Record<string, string> = {};
const implementedThemeTypes: Record<ThemeType | "email", boolean> = {
login: false,
account: false,
email: false
};
for (const themeType of ["login", "account"] as const) {
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
continue;
}
implementedThemeTypes[themeType] = true;
const themeTypeDirPath = getThemeTypeDirPath({ themeType });
apply_replacers_and_move_to_theme_resources: {
const destDirPath = pathJoin(
themeTypeDirPath,
"resources",
basenameOfTheKeycloakifyResourcesDir
);
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
rmSync(destDirPath, { recursive: true, force: true });
if (themeType === "account" && implementedThemeTypes.login) {
// NOTE: We prevent doing it twice, it has been done for the login theme.
transformCodebase({
srcDirPath: pathJoin(
getThemeTypeDirPath({
themeType: "login"
}),
"resources",
basenameOfTheKeycloakifyResourcesDir
),
destDirPath
});
break apply_replacers_and_move_to_theme_resources;
}
transformCodebase({
srcDirPath: buildContext.projectBuildDirPath,
destDirPath,
transformSourceCode: ({ filePath, sourceCode }) => {
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
// This should not happen if users follow the new instruction setup but we keep it for retrocompatibility.
if (
isInside({
dirPath: pathJoin(
buildContext.projectBuildDirPath,
keycloak_resources
),
filePath
})
) {
return undefined;
}
if (/\.css?$/i.test(filePath)) {
const {
cssGlobalsToDefine: cssGlobalsToDefineForThisFile,
fixedCssCode
} = replaceImportsInCssCode({
cssCode: sourceCode.toString("utf8")
});
Object.entries(cssGlobalsToDefineForThisFile).forEach(
([key, value]) => {
cssGlobalsToDefine[key] = value;
}
);
return {
modifiedSourceCode: Buffer.from(fixedCssCode, "utf8")
};
}
if (/\.js?$/i.test(filePath)) {
const { fixedJsCode } = replaceImportsInJsCode({
jsCode: sourceCode.toString("utf8"),
buildContext
});
return {
modifiedSourceCode: Buffer.from(fixedJsCode, "utf8")
};
}
return { modifiedSourceCode: sourceCode };
}
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
indexHtmlCode: fs
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
.toString("utf8"),
cssGlobalsToDefine,
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: readFieldNameUsage({
themeSrcDirPath,
themeType
})
});
[
...(() => {
switch (themeType) {
case "login":
return loginThemePageIds;
case "account":
return accountThemePageIds;
}
})(),
...readExtraPagesNames({
themeType,
themeSrcDirPath
})
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.mkdirSync(themeTypeDirPath, { recursive: true });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
);
});
generateMessageProperties({
themeSrcDirPath,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), {
recursive: true
});
const propertiesFilePath = pathJoin(
messagesDirPath,
`messages_${languageTag}.properties`
);
fs.writeFileSync(
propertiesFilePath,
Buffer.from(propertiesFileSource, "utf8")
);
});
await downloadKeycloakStaticResources({
keycloakVersion: (() => {
switch (themeType) {
case "account":
return lastKeycloakVersionWithAccountV1;
case "login":
return buildContext.loginThemeResourcesFromKeycloakVersion;
}
})(),
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildContext
});
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
return accountV1ThemeName;
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(buildContext.extraThemeProperties ?? []),
buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
)
].join("\n\n"),
"utf8"
)
);
}
email: {
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
if (!fs.existsSync(emailThemeSrcDirPath)) {
break email;
}
implementedThemeTypes.email = true;
transformCodebase({
srcDirPath: emailThemeSrcDirPath,
destDirPath: getThemeTypeDirPath({ themeType: "email" })
});
}
if (implementedThemeTypes.account) {
await bringInAccountV1({
buildContext
});
}
{
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
metaInfKeycloakThemes.themes.push({
name: themeName,
types: objectEntries(implementedThemeTypes)
.filter(([, isImplemented]) => isImplemented)
.map(([themeType]) => themeType)
});
if (implementedThemeTypes.account) {
metaInfKeycloakThemes.themes.push({
name: accountV1ThemeName,
types: ["account"]
});
}
writeMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildContext.keycloakifyBuildDirPath,
metaInfKeycloakThemes
});
}
}

View File

@ -1,80 +0,0 @@
import { join as pathJoin, extname as pathExtname, sep as pathSep } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import type { BuildContext } from "../../shared/buildContext";
import {
readMetaInfKeycloakThemes,
writeMetaInfKeycloakThemes
} from "../../shared/metaInfKeycloakThemes";
import { assert } from "tsafe/assert";
export type BuildContextLike = {
keycloakifyBuildDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function generateSrcMainResourcesForThemeVariant(params: {
themeName: string;
themeVariantName: string;
buildContext: BuildContextLike;
}) {
const { themeName, themeVariantName, buildContext } = params;
const mainThemeDirPath = pathJoin(
buildContext.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme",
themeName
);
transformCodebase({
srcDirPath: mainThemeDirPath,
destDirPath: pathJoin(mainThemeDirPath, "..", themeVariantName),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (
pathExtname(fileRelativePath) === ".ftl" &&
fileRelativePath.split(pathSep).length === 2
) {
const modifiedSourceCode = Buffer.from(
Buffer.from(sourceCode)
.toString("utf-8")
.replace(
`out["themeName"] = "${themeName}";`,
`out["themeName"] = "${themeVariantName}";`
),
"utf8"
);
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
return { modifiedSourceCode };
}
return { modifiedSourceCode: sourceCode };
}
});
{
const updatedMetaInfKeycloakThemes = readMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildContext.keycloakifyBuildDirPath
});
updatedMetaInfKeycloakThemes.themes.push({
name: themeVariantName,
types: (() => {
const theme = updatedMetaInfKeycloakThemes.themes.find(
({ name }) => name === themeName
);
assert(theme !== undefined);
return theme.types;
})()
});
writeMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildContext.keycloakifyBuildDirPath,
metaInfKeycloakThemes: updatedMetaInfKeycloakThemes
});
}
}

View File

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

View File

@ -1,74 +0,0 @@
import * as fs from "fs";
import {
join as pathJoin,
relative as pathRelative,
basename as pathBasename
} from "path";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext";
import { accountV1ThemeName } from "../shared/constants";
export type BuildContextLike = {
keycloakifyBuildDirPath: string;
themeNames: string[];
};
assert<BuildContext extends BuildContextLike ? true : false>();
generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh";
const containerName = "keycloak-testing-container";
const keycloakVersion = "24.0.4";
/** Files for being able to run a hot reload keycloak container */
export function generateStartKeycloakTestingContainer(params: {
jarFilePath: string;
doesImplementAccountTheme: boolean;
buildContext: BuildContextLike;
}) {
const { jarFilePath, doesImplementAccountTheme, buildContext } = params;
const themeRelativeDirPath = pathJoin("src", "main", "resources", "theme");
fs.writeFileSync(
pathJoin(
buildContext.keycloakifyBuildDirPath,
generateStartKeycloakTestingContainer.basename
),
Buffer.from(
[
"#!/usr/bin/env bash",
"",
`docker rm ${containerName} || true`,
"",
`cd "${buildContext.keycloakifyBuildDirPath}"`,
"",
"docker run \\",
" -p 8080:8080 \\",
` --name ${containerName} \\`,
" -e KEYCLOAK_ADMIN=admin \\",
" -e KEYCLOAK_ADMIN_PASSWORD=admin \\",
` -v "${pathJoin(
"$(pwd)",
pathRelative(buildContext.keycloakifyBuildDirPath, jarFilePath)
)}":"/opt/keycloak/providers/${pathBasename(jarFilePath)}" \\`,
[
...(doesImplementAccountTheme ? [accountV1ThemeName] : []),
...buildContext.themeNames
].map(
themeName =>
` -v "${pathJoin(
"$(pwd)",
themeRelativeDirPath,
themeName
).replace(/\\/g, "/")}":"/opt/keycloak/themes/${themeName}":rw \\`
),
` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`,
` start-dev`,
""
].join("\n"),
"utf8"
),
{ mode: 0o755 }
);
}

View File

@ -1,16 +1,21 @@
import { generateSrcMainResources } from "./generateSrcMainResources";
import { generateResources } from "./generateResources";
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, skipBuildJarsEnvName } 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";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import * as os from "os";
import { rmSync } from "../tools/fs.rmSync";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildContext = getBuildContext({ cliCommandOptions });
exit_if_maven_not_installed: {
let commandOutput: Buffer | undefined = undefined;
@ -24,31 +29,44 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
break exit_if_maven_not_installed;
}
const installationCommand = (() => {
switch (os.platform()) {
case "darwin":
return "brew install mvn";
case "win32":
return "choco install mvn";
case "linux":
default:
return "sudo apt-get install mvn";
}
})();
if (
fs
.readFileSync(buildContext.packageJsonFilePath)
.toString("utf8")
.includes(`"mvn"`)
) {
console.log(
chalk.red(
[
"Please remove the 'mvn' package from your package.json'dependencies list,",
"reinstall your dependencies and try again.",
"We need the Apache Maven CLI, not this: https://www.npmjs.com/package/mvn"
].join(" ")
)
);
} else {
const installationCommand = (() => {
switch (os.platform()) {
case "darwin":
return "brew install mvn";
case "win32":
return "choco install mvn";
case "linux":
default:
return "sudo apt-get install mvn";
}
})();
console.log(
`${chalk.red("Apache Maven required.")} Install it with \`${chalk.bold(
installationCommand
)}\` (for example)`
);
console.log(
`${chalk.red("Apache Maven required.")} Install it with \`${chalk.bold(
installationCommand
)}\` (for example)`
);
}
process.exit(1);
}
const { cliCommandOptions } = params;
const buildContext = getBuildContext({ cliCommandOptions });
console.log(
[
chalk.cyan(`keycloakify v${readThisNpmPackageVersion()}`),
@ -76,7 +94,12 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
);
}
await generateSrcMainResources({ buildContext });
const resourcesDirPath = pathJoin(buildContext.keycloakifyBuildDirPath, "resources");
await generateResources({
resourcesDirPath,
buildContext
});
run_post_build_script: {
if (buildContext.bundler !== "vite") {
@ -87,21 +110,26 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
cwd: buildContext.projectDirPath,
env: {
...process.env,
[vitePluginSubScriptEnvNames.runPostBuildScript]:
JSON.stringify(buildContext)
[VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES.RUN_POST_BUILD_SCRIPT]: JSON.stringify(
{
resourcesDirPath,
buildContext
}
)
}
});
}
build_jars: {
if (process.env[skipBuildJarsEnvName]) {
break build_jars;
}
await buildJars({
resourcesDirPath,
buildContext
});
await buildJars({ buildContext });
}
rmSync(resourcesDirPath, { recursive: true });
console.log(
chalk.green(`✓ built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`)
chalk.green(
`✓ keycloak theme built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`
)
);
}

View File

@ -1,7 +1,7 @@
import * as crypto from "crypto";
import type { BuildContext } from "../../shared/buildContext";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } 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}/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}${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,7 +1,4 @@
import {
nameOfTheGlobal,
basenameOfTheKeycloakifyResourcesDir
} from "../../../shared/constants";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../../../shared/constants";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../../../shared/buildContext";
import * as nodePath from "path";
@ -34,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}`
@ -88,13 +85,13 @@ export function replaceImportsInJsCode_vite(params: {
fixedJsCode = replaceAll(
fixedJsCode,
`"${relativePathOfAssetFile}"`,
`(window.${nameOfTheGlobal}.url.resourcesPath.substring(1) + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
`(window.kcContext["x-keycloakify"].resourcesPath.substring(1) + "/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/${relativePathOfAssetFile}")`
);
fixedJsCode = replaceAll(
fixedJsCode,
`"${buildContext.urlPathname ?? "/"}${relativePathOfAssetFile}"`,
`(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
`(window.kcContext["x-keycloakify"].resourcesPath + "/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/${relativePathOfAssetFile}")`
);
});
}

View File

@ -1,7 +1,4 @@
import {
nameOfTheGlobal,
basenameOfTheKeycloakifyResourcesDir
} from "../../../shared/constants";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../../../shared/constants";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../../../shared/buildContext";
import * as nodePath from "path";
@ -86,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.${nameOfTheGlobal}.url.resourcesPath; },
get: function() { return window.kcContext["x-keycloakify"].resourcesPath; },
set: function() {}
});
}
return "${u}";
})()] = ${
isArrowFunction ? `${e} =>` : `function(${e}) { return `
} "/${basenameOfTheKeycloakifyResourcesDir}/${staticDir}${language}/"`
} "/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/${staticDir}${language}/"`
.replace(/\s+/g, " ")
.trim();
}
@ -107,7 +104,7 @@ export function replaceImportsInJsCode_webpack(params: {
`[a-zA-Z]+\\.[a-zA-Z]+\\+"${staticDir.replace(/\//g, "\\/")}`,
"g"
),
`window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${staticDir}`
`window.kcContext["x-keycloakify"].resourcesPath + "/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/${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",
@ -134,20 +137,6 @@ program
}
});
program
.command({
name: "download-keycloak-default-theme",
description: "Download the built-in Keycloak theme."
})
.task({
skip,
handler: async cliCommandOptions => {
const { command } = await import("./download-keycloak-default-theme");
await command({ cliCommandOptions });
}
});
program
.command({
name: "eject-page",
@ -190,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-and-above";
export type WithAccountV1Theme = "21-and-below" | "23" | "24" | "25-and-above";
}

View File

@ -1,22 +1,37 @@
import { parse as urlParse } from "url";
import { join as pathJoin } from "path";
import {
join as pathJoin,
sep as pathSep,
relative as pathRelative,
resolve as pathResolve,
dirname as pathDirname
} from "path";
import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath";
import { getNpmWorkspaceRootDirPath } from "../tools/getNpmWorkspaceRootDirPath";
import type { CliCommandOptions } from "../main";
import { z } from "zod";
import * as fs from "fs";
import { assert } from "tsafe";
import { assert, type Equals } from "tsafe/assert";
import * as child_process from "child_process";
import { vitePluginSubScriptEnvNames } from "./constants";
import {
VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES,
BUILD_FOR_KEYCLOAK_MAJOR_VERSION_ENV_NAME
} from "./constants";
import type { KeycloakVersionRange } from "./KeycloakVersionRange";
import { exclude } from "tsafe";
import { crawl } from "../tools/crawl";
import { THEME_TYPES } from "./constants";
import { objectEntries } from "tsafe/objectEntries";
import { type ThemeType } from "./constants";
import { id } from "tsafe/id";
import chalk from "chalk";
import { getProxyFetchOptions, type ProxyFetchOptions } from "../tools/fetchProxyOptions";
export type BuildContext = {
bundler: "vite" | "webpack";
themeVersion: string;
themeNames: string[];
themeNames: [string, ...string[]];
extraThemeProperties: string[] | undefined;
groupId: string;
artifactId: string;
loginThemeResourcesFromKeycloakVersion: string;
projectDirPath: string;
projectBuildDirPath: string;
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
@ -27,21 +42,82 @@ export type BuildContext = {
* In this case the urlPathname will be "/my-app/" */
urlPathname: string | undefined;
assetsDirPath: string;
npmWorkspaceRootDirPath: string;
fetchOptions: ProxyFetchOptions;
kcContextExclusionsFtlCode: string | undefined;
environmentVariables: { name: string; default: string }[];
themeSrcDirPath: string;
implementedThemeTypes: {
login: { isImplemented: boolean };
email: { isImplemented: boolean };
account:
| { isImplemented: false }
| { isImplemented: true; type: "Single-Page" | "Multi-Page" };
};
packageJsonFilePath: string;
bundler: "vite" | "webpack";
jarTargets: {
keycloakVersionRange: KeycloakVersionRange;
jarFileBasename: string;
}[];
startKeycloakOptions: {
dockerImage:
| {
reference: string;
tag: string;
}
| undefined;
dockerExtraArgs: string[];
keycloakExtraArgs: string[];
extensionJars: ({ type: "path"; path: string } | { type: "url"; url: string })[];
realmJsonFilePath: string | undefined;
port: number | undefined;
};
};
assert<Equals<keyof BuildContext["implementedThemeTypes"], ThemeType | "email">>();
export type BuildOptions = {
themeName?: string | string[];
themeVersion?: string;
environmentVariables?: { name: string; default: string }[];
extraThemeProperties?: string[];
artifactId?: string;
groupId?: string;
loginThemeResourcesFromKeycloakVersion?: string;
keycloakifyBuildDirPath?: string;
kcContextExclusionsFtl?: string;
};
startKeycloakOptions?: {
dockerImage?: string;
dockerExtraArgs?: string[];
keycloakExtraArgs?: string[];
extensionJars?: string[];
realmJsonFilePath?: string;
port?: number;
};
} & BuildOptions.AccountThemeImplAndKeycloakVersionTargets;
export namespace BuildOptions {
export type AccountThemeImplAndKeycloakVersionTargets =
| AccountThemeImplAndKeycloakVersionTargets.MultiPageApp
| AccountThemeImplAndKeycloakVersionTargets.SinglePageAppOrNone;
export namespace AccountThemeImplAndKeycloakVersionTargets {
export type MultiPageApp = {
accountThemeImplementation: "Multi-Page";
keycloakVersionTargets?: Record<
KeycloakVersionRange.WithAccountV1Theme,
string | boolean
>;
};
export type SinglePageAppOrNone = {
accountThemeImplementation: "Single-Page" | "none";
keycloakVersionTargets?: Record<
KeycloakVersionRange.WithoutAccountV1Theme,
string | boolean
>;
};
}
}
export type ResolvedViteConfig = {
buildDir: string;
@ -56,15 +132,54 @@ export function getBuildContext(params: {
}): BuildContext {
const { cliCommandOptions } = params;
const projectDirPath = (() => {
if (cliCommandOptions.projectDirPath === undefined) {
return process.cwd();
const projectDirPath =
cliCommandOptions.projectDirPath !== undefined
? getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.projectDirPath,
cwd: process.cwd()
})
: process.cwd();
const { themeSrcDirPath } = (() => {
const srcDirPath = pathJoin(projectDirPath, "src");
const themeSrcDirPath: string | undefined = crawl({
dirPath: srcDirPath,
returnedPathsType: "relative to dirPath"
})
.map(fileRelativePath => {
for (const themeSrcDirBasename of ["keycloak-theme", "keycloak_theme"]) {
const split = fileRelativePath.split(themeSrcDirBasename);
if (split.length === 2) {
return pathJoin(srcDirPath, split[0] + themeSrcDirBasename);
}
}
return undefined;
})
.filter(exclude(undefined))[0];
if (themeSrcDirPath !== undefined) {
return { themeSrcDirPath };
}
return getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.projectDirPath,
cwd: process.cwd()
});
for (const themeType of [...THEME_TYPES, "email"]) {
if (!fs.existsSync(pathJoin(srcDirPath, themeType))) {
continue;
}
return { themeSrcDirPath: srcDirPath };
}
console.log(
chalk.red(
[
`Can't locate your Keycloak theme source directory in .${pathSep}${pathRelative(process.cwd(), srcDirPath)}`,
`Make sure to either use the Keycloakify CLI in the root of your Keycloakify project or use the --project CLI option`,
`If you are collocating your Keycloak theme with your app you must have a directory named 'keycloak-theme' or 'keycloak_theme' in your 'src' directory`
].join("\n")
)
);
process.exit(1);
})();
const { resolvedViteConfig } = (() => {
@ -82,18 +197,18 @@ export function getBuildContext(params: {
cwd: projectDirPath,
env: {
...process.env,
[vitePluginSubScriptEnvNames.resolveViteConfig]: "true"
[VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES.RESOLVE_VITE_CONFIG]: "true"
}
})
.toString("utf8");
assert(
output.includes(vitePluginSubScriptEnvNames.resolveViteConfig),
output.includes(VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES.RESOLVE_VITE_CONFIG),
"Seems like the Keycloakify's Vite plugin is not installed."
);
const resolvedViteConfigStr = output
.split(vitePluginSubScriptEnvNames.resolveViteConfig)
.split(VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES.RESOLVE_VITE_CONFIG)
.reverse()[0];
const resolvedViteConfig: ResolvedViteConfig = JSON.parse(resolvedViteConfigStr);
@ -101,76 +216,291 @@ export function getBuildContext(params: {
return { resolvedViteConfig };
})();
const packageJsonFilePath = (function getPackageJSonDirPath(upCount: number): string {
const dirPath = pathResolve(
pathJoin(...[projectDirPath, ...Array(upCount).fill("..")])
);
assert(dirPath !== pathSep, "Root package.json not found");
success: {
const packageJsonFilePath = pathJoin(dirPath, "package.json");
if (!fs.existsSync(packageJsonFilePath)) {
break success;
}
const parsedPackageJson = z
.object({
name: z.string().optional(),
dependencies: z.record(z.string()).optional(),
devDependencies: z.record(z.string()).optional()
})
.parse(JSON.parse(fs.readFileSync(packageJsonFilePath).toString("utf8")));
if (
parsedPackageJson.dependencies?.keycloakify === undefined &&
parsedPackageJson.devDependencies?.keycloakify === undefined &&
parsedPackageJson.name !== "keycloakify" // NOTE: For local storybook build
) {
break success;
}
return packageJsonFilePath;
}
return getPackageJSonDirPath(upCount + 1);
})(0);
const parsedPackageJson = (() => {
type ParsedPackageJson = {
name: string;
version?: string;
homepage?: string;
keycloakify?: BuildOptions & {
projectBuildDirPath?: string;
};
type BuildOptions_packageJson = BuildOptions & {
projectBuildDirPath?: string;
staticDirPathInProjectBuildDirPath?: string;
publicDirPath?: string;
};
const zParsedPackageJson = z.object({
name: z.string(),
version: z.string().optional(),
homepage: z.string().optional(),
keycloakify: z
.object({
type ParsedPackageJson = {
name?: string;
version?: string;
homepage?: string;
keycloakify?: BuildOptions_packageJson;
};
const zMultiPageApp = (() => {
type TargetType =
BuildOptions.AccountThemeImplAndKeycloakVersionTargets.MultiPageApp;
const zTargetType = z.object({
accountThemeImplementation: z.literal("Multi-Page"),
keycloakVersionTargets: z
.object({
"21-and-below": z.union([z.boolean(), z.string()]),
"23": z.union([z.boolean(), z.string()]),
"24": z.union([z.boolean(), z.string()]),
"25-and-above": z.union([z.boolean(), z.string()])
})
.optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const zSinglePageApp = (() => {
type TargetType =
BuildOptions.AccountThemeImplAndKeycloakVersionTargets.SinglePageAppOrNone;
const zTargetType = z.object({
accountThemeImplementation: z.union([
z.literal("Single-Page"),
z.literal("none")
]),
keycloakVersionTargets: z
.object({
"21-and-below": z.union([z.boolean(), z.string()]),
"22-and-above": z.union([z.boolean(), z.string()])
})
.optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const zAccountThemeImplAndKeycloakVersionTargets = (() => {
type TargetType = BuildOptions.AccountThemeImplAndKeycloakVersionTargets;
const zTargetType = z.union([zMultiPageApp, zSinglePageApp]);
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const zStartKeycloakOptions = (() => {
type TargetType = NonNullable<BuildOptions["startKeycloakOptions"]>;
const zTargetType = z.object({
dockerImage: z.string().optional(),
extensionJars: z.array(z.string()).optional(),
realmJsonFilePath: z.string().optional(),
dockerExtraArgs: z.array(z.string()).optional(),
keycloakExtraArgs: z.array(z.string()).optional(),
port: z.number().optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const zBuildOptions = (() => {
type TargetType = BuildOptions;
const zTargetType = z.intersection(
z.object({
themeName: z.union([z.string(), z.array(z.string())]).optional(),
themeVersion: z.string().optional(),
environmentVariables: z
.array(
z.object({
name: z.string(),
default: z.string()
})
)
.optional(),
extraThemeProperties: z.array(z.string()).optional(),
artifactId: z.string().optional(),
groupId: z.string().optional(),
loginThemeResourcesFromKeycloakVersion: z.string().optional(),
projectBuildDirPath: z.string().optional(),
keycloakifyBuildDirPath: z.string().optional(),
themeName: z.union([z.string(), z.array(z.string())]).optional()
})
.optional()
});
kcContextExclusionsFtl: z.string().optional(),
startKeycloakOptions: zStartKeycloakOptions.optional()
}),
zAccountThemeImplAndKeycloakVersionTargets
);
{
type Got = ReturnType<(typeof zParsedPackageJson)["parse"]>;
type Expected = ParsedPackageJson;
assert<Got extends Expected ? true : false>();
assert<Expected extends Got ? true : false>();
}
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const zBuildOptions_packageJson = (() => {
type TargetType = BuildOptions_packageJson;
const zTargetType = z.intersection(
zBuildOptions,
z.object({
projectBuildDirPath: z.string().optional(),
staticDirPathInProjectBuildDirPath: z.string().optional(),
publicDirPath: z.string().optional()
})
);
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
name: z.string().optional(),
version: z.string().optional(),
homepage: z.string().optional(),
keycloakify: zBuildOptions_packageJson.optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const configurationPackageJsonFilePath = (() => {
const rootPackageJsonFilePath = pathJoin(projectDirPath, "package.json");
return fs.existsSync(rootPackageJsonFilePath)
? rootPackageJsonFilePath
: packageJsonFilePath;
})();
return zParsedPackageJson.parse(
JSON.parse(
fs.readFileSync(pathJoin(projectDirPath, "package.json")).toString("utf8")
)
JSON.parse(fs.readFileSync(configurationPackageJsonFilePath).toString("utf8"))
);
})();
const buildOptions: BuildOptions = {
...parsedPackageJson.keycloakify,
...resolvedViteConfig?.buildOptions
const bundler = resolvedViteConfig !== undefined ? "vite" : "webpack";
if (bundler === "vite" && parsedPackageJson.keycloakify !== undefined) {
console.error(
chalk.red(
`In vite projects, provide your Keycloakify options in vite.config.ts, not in package.json`
)
);
process.exit(-1);
}
const buildOptions: BuildOptions = (() => {
switch (bundler) {
case "vite":
assert(resolvedViteConfig !== undefined);
return resolvedViteConfig.buildOptions;
case "webpack":
assert(parsedPackageJson.keycloakify !== undefined);
return parsedPackageJson.keycloakify;
}
assert<Equals<typeof bundler, never>>(false);
})();
const implementedThemeTypes: BuildContext["implementedThemeTypes"] = {
login: {
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "login"))
},
email: {
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "email"))
},
account: (() => {
if (buildOptions.accountThemeImplementation === "none") {
return { isImplemented: false };
}
return {
isImplemented: true,
type: buildOptions.accountThemeImplementation
};
})()
};
const themeNames = (() => {
if (
implementedThemeTypes.account.isImplemented &&
!fs.existsSync(pathJoin(themeSrcDirPath, "account"))
) {
console.error(
chalk.red(
[
`You have set 'accountThemeImplementation' to '${implementedThemeTypes.account.type}'`,
`but the 'account' directory is missing in your theme source directory`,
"Use the `npx keycloakify initialize-account-theme` command to create it"
].join(" ")
)
);
process.exit(-1);
}
const themeNames = ((): [string, ...string[]] => {
if (buildOptions.themeName === undefined) {
return [
parsedPackageJson.name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-")
];
return parsedPackageJson.name === undefined
? ["keycloakify"]
: [
parsedPackageJson.name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-")
];
}
if (typeof buildOptions.themeName === "string") {
return [buildOptions.themeName];
}
return buildOptions.themeName;
const [mainThemeName, ...themeVariantNames] = buildOptions.themeName;
assert(mainThemeName !== undefined);
return [mainThemeName, ...themeVariantNames];
})();
const projectBuildDirPath = (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
if (bundler !== "webpack") {
break webpack;
}
if (parsedPackageJson.keycloakify?.projectBuildDirPath !== undefined) {
assert(parsedPackageJson.keycloakify !== undefined);
if (parsedPackageJson.keycloakify.projectBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: parsedPackageJson.keycloakify.projectBuildDirPath,
cwd: projectDirPath
@ -180,18 +510,16 @@ export function getBuildContext(params: {
return pathJoin(projectDirPath, "build");
}
assert(bundler === "vite");
assert(resolvedViteConfig !== undefined);
return pathJoin(projectDirPath, resolvedViteConfig.buildDir);
})();
const { npmWorkspaceRootDirPath } = getNpmWorkspaceRootDirPath({
projectDirPath,
dependencyExpected: "keycloakify"
});
return {
bundler: resolvedViteConfig !== undefined ? "vite" : "webpack",
themeVersion:
process.env.KEYCLOAKIFY_THEME_VERSION ?? parsedPackageJson.version ?? "0.0.0",
bundler,
packageJsonFilePath,
themeVersion: buildOptions.themeVersion ?? parsedPackageJson.version ?? "0.0.0",
themeNames,
extraThemeProperties: buildOptions.extraThemeProperties,
groupId: (() => {
@ -213,8 +541,6 @@ export function getBuildContext(params: {
process.env.KEYCLOAKIFY_ARTIFACT_ID ??
buildOptions.artifactId ??
`${themeNames[0]}-keycloak-theme`,
loginThemeResourcesFromKeycloakVersion:
buildOptions.loginThemeResourcesFromKeycloakVersion ?? "24.0.4",
projectDirPath,
projectBuildDirPath,
keycloakifyBuildDirPath: (() => {
@ -233,14 +559,23 @@ export function getBuildContext(params: {
);
})(),
publicDirPath: (() => {
if (process.env.PUBLIC_DIR_PATH !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: process.env.PUBLIC_DIR_PATH,
cwd: projectDirPath
});
}
webpack: {
if (resolvedViteConfig !== undefined) {
if (bundler !== "webpack") {
break webpack;
}
if (process.env.PUBLIC_DIR_PATH !== undefined) {
assert(parsedPackageJson.keycloakify !== undefined);
if (parsedPackageJson.keycloakify.publicDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: process.env.PUBLIC_DIR_PATH,
pathIsh: parsedPackageJson.keycloakify.publicDirPath,
cwd: projectDirPath
});
}
@ -248,28 +583,31 @@ export function getBuildContext(params: {
return pathJoin(projectDirPath, "public");
}
assert(bundler === "vite");
assert(resolvedViteConfig !== undefined);
return pathJoin(projectDirPath, resolvedViteConfig.publicDir);
})(),
cacheDirPath: (() => {
const cacheDirPath = pathJoin(
(() => {
if (process.env.XDG_CACHE_HOME !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: process.env.XDG_CACHE_HOME,
cwd: process.cwd()
});
}
cacheDirPath: pathJoin(
(() => {
if (process.env.XDG_CACHE_HOME !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: process.env.XDG_CACHE_HOME,
cwd: process.cwd()
});
}
return pathJoin(npmWorkspaceRootDirPath, "node_modules", ".cache");
})(),
"keycloakify"
);
return cacheDirPath;
})(),
return pathJoin(
pathDirname(packageJsonFilePath),
"node_modules",
".cache"
);
})(),
"keycloakify"
),
urlPathname: (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
if (bundler !== "webpack") {
break webpack;
}
@ -289,20 +627,38 @@ export function getBuildContext(params: {
return out === "/" ? undefined : out;
}
assert(bundler === "vite");
assert(resolvedViteConfig !== undefined);
return resolvedViteConfig.urlPathname;
})(),
assetsDirPath: (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
if (bundler !== "webpack") {
break webpack;
}
assert(parsedPackageJson.keycloakify !== undefined);
if (
parsedPackageJson.keycloakify.staticDirPathInProjectBuildDirPath !==
undefined
) {
getAbsoluteAndInOsFormatPath({
pathIsh:
parsedPackageJson.keycloakify
.staticDirPathInProjectBuildDirPath,
cwd: projectBuildDirPath
});
}
return pathJoin(projectBuildDirPath, "static");
}
assert(bundler === "vite");
assert(resolvedViteConfig !== undefined);
return pathJoin(projectBuildDirPath, resolvedViteConfig.assetsDir);
})(),
npmWorkspaceRootDirPath,
kcContextExclusionsFtlCode: (() => {
if (buildOptions.kcContextExclusionsFtl === undefined) {
return undefined;
@ -319,6 +675,296 @@ export function getBuildContext(params: {
return buildOptions.kcContextExclusionsFtl;
})(),
environmentVariables: buildOptions.environmentVariables ?? []
environmentVariables: buildOptions.environmentVariables ?? [],
implementedThemeTypes,
themeSrcDirPath,
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: (function callee(upCount: number): string {
const dirPath = pathResolve(
pathJoin(...[projectDirPath, ...Array(upCount).fill("..")])
);
assert(
dirPath !== pathSep,
"Couldn't find a place to run 'npm config get'"
);
try {
child_process.execSync("npm config get", {
cwd: dirPath,
stdio: "pipe"
});
} catch (error) {
if (String(error).includes("ENOWORKSPACES")) {
return callee(upCount + 1);
}
throw error;
}
return dirPath;
})(0)
}),
jarTargets: (() => {
const getDefaultJarFileBasename = (range: string) =>
`keycloak-theme-for-kc-${range}.jar`;
build_for_specific_keycloak_major_version: {
const buildForKeycloakMajorVersionNumber = (() => {
const envValue =
process.env[BUILD_FOR_KEYCLOAK_MAJOR_VERSION_ENV_NAME];
if (envValue === undefined) {
return undefined;
}
const major = parseInt(envValue);
assert(!isNaN(major));
return major;
})();
if (buildForKeycloakMajorVersionNumber === undefined) {
break build_for_specific_keycloak_major_version;
}
const keycloakVersionRange: KeycloakVersionRange = (() => {
if (
implementedThemeTypes.account.isImplemented &&
implementedThemeTypes.account.type === "Multi-Page"
) {
const keycloakVersionRange = (() => {
if (buildForKeycloakMajorVersionNumber <= 21) {
return "21-and-below" as const;
}
assert(buildForKeycloakMajorVersionNumber !== 22);
if (buildForKeycloakMajorVersionNumber === 23) {
return "23" as const;
}
if (buildForKeycloakMajorVersionNumber === 24) {
return "24" as const;
}
return "25-and-above" as const;
})();
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithAccountV1Theme
>
>();
return keycloakVersionRange;
} else {
const keycloakVersionRange = (() => {
if (buildForKeycloakMajorVersionNumber <= 21) {
return "21-and-below" as const;
}
return "22-and-above" as const;
})();
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithoutAccountV1Theme
>
>();
return keycloakVersionRange;
}
})();
const jarFileBasename = (() => {
use_custom_jar_basename: {
const { keycloakVersionTargets } = buildOptions;
if (keycloakVersionTargets === undefined) {
break use_custom_jar_basename;
}
const entry = objectEntries(keycloakVersionTargets).find(
([keycloakVersionRange_entry]) =>
keycloakVersionRange_entry === keycloakVersionRange
);
if (entry === undefined) {
break use_custom_jar_basename;
}
const maybeJarFileBasename = entry[1];
if (typeof maybeJarFileBasename !== "string") {
break use_custom_jar_basename;
}
return maybeJarFileBasename;
}
return getDefaultJarFileBasename(keycloakVersionRange);
})();
return [
{
keycloakVersionRange,
jarFileBasename
}
];
}
const jarTargets_default = (() => {
const jarTargets: BuildContext["jarTargets"] = [];
if (
implementedThemeTypes.account.isImplemented &&
implementedThemeTypes.account.type === "Multi-Page"
) {
for (const keycloakVersionRange of [
"21-and-below",
"23",
"24",
"25-and-above"
] as const) {
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithAccountV1Theme
>
>(true);
jarTargets.push({
keycloakVersionRange,
jarFileBasename:
getDefaultJarFileBasename(keycloakVersionRange)
});
}
} else {
for (const keycloakVersionRange of [
"21-and-below",
"22-and-above"
] as const) {
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithoutAccountV1Theme
>
>(true);
jarTargets.push({
keycloakVersionRange,
jarFileBasename:
getDefaultJarFileBasename(keycloakVersionRange)
});
}
}
return jarTargets;
})();
if (buildOptions.keycloakVersionTargets === undefined) {
return jarTargets_default;
}
const jarTargets: BuildContext["jarTargets"] = [];
for (const [keycloakVersionRange, jarNameOrBoolean] of objectEntries(
buildOptions.keycloakVersionTargets
)) {
if (jarNameOrBoolean === false) {
continue;
}
if (jarNameOrBoolean === true) {
jarTargets.push({
keycloakVersionRange: keycloakVersionRange,
jarFileBasename: getDefaultJarFileBasename(keycloakVersionRange)
});
continue;
}
const jarFileBasename = jarNameOrBoolean;
if (!jarFileBasename.endsWith(".jar")) {
console.log(
chalk.red(`Bad ${jarFileBasename} should end with '.jar'\n`)
);
process.exit(1);
}
if (jarFileBasename.includes("/") || jarFileBasename.includes("\\")) {
console.log(
chalk.red(
[
`Invalid ${jarFileBasename}. It's not supposed to be a path,`,
`Only the basename of the jar file is expected.`,
`Example: keycloak-theme.jar`
].join(" ")
)
);
process.exit(1);
}
jarTargets.push({
keycloakVersionRange: keycloakVersionRange,
jarFileBasename: jarNameOrBoolean
});
}
if (jarTargets.length === 0) {
console.log(
chalk.red(
"All jar targets are disabled. Please enable at least one jar target."
)
);
process.exit(1);
}
return jarTargets;
})(),
startKeycloakOptions: {
dockerImage: (() => {
if (buildOptions.startKeycloakOptions?.dockerImage === undefined) {
return undefined;
}
const [reference, tag, ...rest] =
buildOptions.startKeycloakOptions.dockerImage.split(":");
assert(
reference !== undefined && tag !== undefined && rest.length === 0,
`Invalid docker image: ${buildOptions.startKeycloakOptions.dockerImage}`
);
return { reference, tag };
})(),
dockerExtraArgs: buildOptions.startKeycloakOptions?.dockerExtraArgs ?? [],
keycloakExtraArgs: buildOptions.startKeycloakOptions?.keycloakExtraArgs ?? [],
extensionJars: (buildOptions.startKeycloakOptions?.extensionJars ?? []).map(
urlOrPath => {
if (/^https?:\/\//.test(urlOrPath)) {
return { type: "url", url: urlOrPath };
}
return {
type: "path",
path: getAbsoluteAndInOsFormatPath({
pathIsh: urlOrPath,
cwd: projectDirPath
})
};
}
),
realmJsonFilePath:
buildOptions.startKeycloakOptions?.realmJsonFilePath === undefined
? undefined
: getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.startKeycloakOptions.realmJsonFilePath,
cwd: projectDirPath
}),
port: buildOptions.startKeycloakOptions?.port
}
};
}

View File

@ -1,24 +1,22 @@
export const nameOfTheGlobal = "kcContext";
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 = "build";
export const themeTypes = ["login", "account"] as const;
export const accountV1ThemeName = "account-v1";
export type ThemeType = (typeof themeTypes)[number];
export const vitePluginSubScriptEnvNames = {
runPostBuildScript: "KEYCLOAKIFY_RUN_POST_BUILD_SCRIPT",
resolveViteConfig: "KEYCLOAKIFY_RESOLVE_VITE_CONFIG"
export const WELL_KNOWN_DIRECTORY_BASE_NAME = {
DOT_KEYCLOAKIFY: ".keycloakify",
RESOURCES_COMMON: "resources-common",
DIST: "dist"
} as const;
export const skipBuildJarsEnvName = "KEYCLOAKIFY_SKIP_BUILD_JAR";
export const THEME_TYPES = ["login", "account"] as const;
export const loginThemePageIds = [
export type ThemeType = (typeof THEME_TYPES)[number];
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 BUILD_FOR_KEYCLOAK_MAJOR_VERSION_ENV_NAME =
"KEYCLOAKIFY_BUILD_FOR_KEYCLOAK_MAJOR_VERSION";
export const LOGIN_THEME_PAGE_IDS = [
"login.ftl",
"login-username.ftl",
"login-password.ftl",
@ -52,10 +50,12 @@ export const loginThemePageIds = [
"login-recovery-authn-code-input.ftl",
"login-reset-otp.ftl",
"login-x509-info.ftl",
"webauthn-error.ftl"
"webauthn-error.ftl",
"login-passkeys-conditional-authenticate.ftl",
"login-idp-link-confirm-override.ftl"
] as const;
export const accountThemePageIds = [
export const ACCOUNT_THEME_PAGE_IDS = [
"password.ftl",
"account.ftl",
"sessions.ftl",
@ -65,7 +65,9 @@ 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";

View File

@ -1,47 +1,34 @@
import {
downloadKeycloakStaticResources,
type BuildContextLike as BuildContextLike_downloadKeycloakStaticResources
} from "./downloadKeycloakStaticResources";
import { join as pathJoin, relative as pathRelative } from "path";
import {
themeTypes,
keycloak_resources,
lastKeycloakVersionWithAccountV1
} from "../shared/constants";
import { join as pathJoin, dirname as pathDirname } from "path";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../shared/constants";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import { assert } from "tsafe/assert";
import * as fs from "fs";
import { rmSync } from "../tools/fs.rmSync";
import type { BuildContext } from "./buildContext";
import { transformCodebase } from "../tools/transformCodebase";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
export type BuildContextLike = BuildContextLike_downloadKeycloakStaticResources & {
loginThemeResourcesFromKeycloakVersion: string;
export type BuildContextLike = {
publicDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function copyKeycloakResourcesToPublic(params: {
export function copyKeycloakResourcesToPublic(params: {
buildContext: BuildContextLike;
}) {
const { buildContext } = params;
const destDirPath = pathJoin(buildContext.publicDirPath, keycloak_resources);
const destDirPath = pathJoin(
buildContext.publicDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY
);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
const keycloakifyBuildinfoRaw = JSON.stringify(
{
destDirPath,
keycloakifyVersion: readThisNpmPackageVersion(),
buildContext: {
loginThemeResourcesFromKeycloakVersion: readThisNpmPackageVersion(),
cacheDirPath: pathRelative(destDirPath, buildContext.cacheDirPath),
npmWorkspaceRootDirPath: pathRelative(
destDirPath,
buildContext.npmWorkspaceRootDirPath
)
}
keycloakifyVersion: readThisNpmPackageVersion()
},
null,
2
@ -65,35 +52,35 @@ export async function copyKeycloakResourcesToPublic(params: {
rmSync(destDirPath, { force: true, recursive: true });
// NOTE: To remove in a while, remove the legacy keycloak-resources directory
rmSync(pathJoin(pathDirname(destDirPath), "keycloak-resources"), {
force: true,
recursive: true
});
fs.mkdirSync(destDirPath, { recursive: true });
fs.writeFileSync(pathJoin(destDirPath, ".gitignore"), Buffer.from("*", "utf8"));
for (const themeType of themeTypes) {
await downloadKeycloakStaticResources({
keycloakVersion: (() => {
switch (themeType) {
case "login":
return buildContext.loginThemeResourcesFromKeycloakVersion;
case "account":
return lastKeycloakVersionWithAccountV1;
}
})(),
themeType,
themeDirPath: destDirPath,
buildContext
});
}
transformCodebase({
srcDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY
),
destDirPath
});
fs.writeFileSync(
pathJoin(destDirPath, "README.txt"),
Buffer.from(
// prettier-ignore
[
"This is just a test folder that helps develop",
"the login and register page without having to run a Keycloak container\n",
"This directory will be automatically excluded from the final build."
].join(" ")
"This directory is only used in dev mode by Keycloakify",
"It won't be included in your final build.",
"Do not modify anything in this directory.",
].join("\n")
)
);

View File

@ -1,207 +0,0 @@
import { join as pathJoin, relative as pathRelative } from "path";
import { type BuildContext } from "./buildContext";
import { assert } from "tsafe/assert";
import { lastKeycloakVersionWithAccountV1 } from "./constants";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
import { isInside } from "../tools/isInside";
export type BuildContextLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function downloadKeycloakDefaultTheme(params: {
keycloakVersion: string;
buildContext: BuildContextLike;
}): Promise<{ defaultThemeDirPath: string }> {
const { keycloakVersion, buildContext } = params;
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",
onArchiveFile: async params => {
if (!isInside({ dirPath: "theme", filePath: params.fileRelativePath })) {
return;
}
const { readFile, writeFile } = params;
const fileRelativePath = pathRelative("theme", params.fileRelativePath);
skip_keycloak_v2: {
if (
!isInside({
dirPath: pathJoin("keycloak.v2"),
filePath: fileRelativePath
})
) {
break skip_keycloak_v2;
}
return;
}
last_account_v1_transformations: {
if (lastKeycloakVersionWithAccountV1 !== keycloakVersion) {
break last_account_v1_transformations;
}
patch_account_css: {
if (
fileRelativePath !==
pathJoin("keycloak", "account", "resources", "css", "account.css")
) {
break patch_account_css;
}
await writeFile({
fileRelativePath,
modifiedData: Buffer.from(
(await readFile())
.toString("utf8")
.replace("top: -34px;", "top: -34px !important;"),
"utf8"
)
});
return;
}
skip_web_modules: {
if (
!isInside({
dirPath: pathJoin(
"keycloak",
"common",
"resources",
"web_modules"
),
filePath: fileRelativePath
})
) {
break skip_web_modules;
}
return;
}
skip_unused_node_modules: {
const nodeModulesDirPath = pathJoin(
"keycloak",
"common",
"resources",
"node_modules"
);
if (
!isInside({
dirPath: nodeModulesDirPath,
filePath: fileRelativePath
})
) {
break skip_unused_node_modules;
}
const toKeepPrefixes = [
...[
"patternfly.min.css",
"patternfly-additions.min.css",
"patternfly-additions.min.css"
].map(fileBasename =>
pathJoin(
nodeModulesDirPath,
"patternfly",
"dist",
"css",
fileBasename
)
),
pathJoin(nodeModulesDirPath, "patternfly", "dist", "fonts")
];
if (
toKeepPrefixes.find(prefix =>
fileRelativePath.startsWith(prefix)
) !== undefined
) {
break skip_unused_node_modules;
}
return;
}
}
skip_unused_resources: {
if (keycloakVersion !== "24.0.4") {
break skip_unused_resources;
}
for (const dirBasename of [
"@patternfly-v5",
"@rollup",
"rollup",
"react",
"react-dom",
"shx",
".pnpm"
]) {
if (
isInside({
dirPath: pathJoin(
"keycloak",
"common",
"resources",
"node_modules",
dirBasename
),
filePath: fileRelativePath
})
) {
return;
}
}
for (const dirBasename of ["react", "react-dom"]) {
if (
isInside({
dirPath: pathJoin(
"keycloak",
"common",
"resources",
"vendor",
dirBasename
),
filePath: fileRelativePath
})
) {
return;
}
}
if (
isInside({
dirPath: pathJoin(
"keycloak",
"common",
"resources",
"node_modules",
"@patternfly",
"react-core"
),
filePath: fileRelativePath
})
) {
return;
}
}
await writeFile({ fileRelativePath });
}
});
return { defaultThemeDirPath: extractedDirPath };
}

View File

@ -1,53 +0,0 @@
import { transformCodebase } from "../tools/transformCodebase";
import { join as pathJoin } from "path";
import {
downloadKeycloakDefaultTheme,
type BuildContextLike as BuildContextLike_downloadKeycloakDefaultTheme
} from "./downloadKeycloakDefaultTheme";
import { resources_common, type ThemeType } from "./constants";
import type { BuildContext } from "./buildContext";
import { assert } from "tsafe/assert";
import { existsAsync } from "../tools/fs.existsAsync";
export type BuildContextLike = BuildContextLike_downloadKeycloakDefaultTheme & {};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function downloadKeycloakStaticResources(params: {
themeType: ThemeType;
themeDirPath: string;
keycloakVersion: string;
buildContext: BuildContextLike;
}) {
const { themeType, themeDirPath, keycloakVersion, buildContext } = params;
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildContext
});
const resourcesDirPath = pathJoin(themeDirPath, themeType, "resources");
repatriate_base_resources: {
const srcDirPath = pathJoin(defaultThemeDirPath, "base", themeType, "resources");
if (!(await existsAsync(srcDirPath))) {
break repatriate_base_resources;
}
transformCodebase({
srcDirPath,
destDirPath: resourcesDirPath
});
}
transformCodebase({
srcDirPath: pathJoin(defaultThemeDirPath, "keycloak", themeType, "resources"),
destDirPath: resourcesDirPath
});
transformCodebase({
srcDirPath: pathJoin(defaultThemeDirPath, "keycloak", "common", "resources"),
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 { getThemeSrcDirPath } from "./getThemeSrcDirPath";
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,16 +25,53 @@ export async function generateKcGenTs(params: {
}): Promise<void> {
const { buildContext } = params;
const { themeSrcDirPath } = getThemeSrcDirPath({
projectDirPath: buildContext.projectDirPath
});
const isReactProject: boolean = await (async () => {
const parsedPackageJson = await (async () => {
type ParsedPackageJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const filePath = pathJoin(themeSrcDirPath, "kc.gen.ts");
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 */`,
@ -40,13 +84,15 @@ 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(", ")}];`,
``,
`export type KcEnvName = ${buildContext.environmentVariables.length === 0 ? "never" : buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(" | ")};`,
``,
`export const KcEnvNames: KcEnvName[] = [${buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(", ")}];`,
`export const kcEnvNames: KcEnvName[] = [${buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(", ")}];`,
``,
`export const kcEnvDefaults: Record<KcEnvName, string> = ${JSON.stringify(
Object.fromEntries(
@ -58,8 +104,52 @@ export async function generateKcGenTs(params: {
2
)};`,
``,
`/* prettier-ignore-end */`
].join("\n"),
`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 */`,
``
]
.filter(item => typeof item === "string")
.join("\n"),
"utf8"
);
@ -68,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

@ -1,11 +0,0 @@
import type { KeycloakVersionRange } from "./KeycloakVersionRange";
export function getJarFileBasename(params: {
keycloakVersionRange: KeycloakVersionRange;
}) {
const { keycloakVersionRange } = params;
const jarFileBasename = `keycloak-theme-for-kc-${keycloakVersionRange}.jar`;
return { jarFileBasename };
}

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,50 +0,0 @@
import * as fs from "fs";
import { exclude } from "tsafe";
import { crawl } from "../tools/crawl";
import { join as pathJoin } from "path";
import { themeTypes } from "./constants";
const themeSrcDirBasenames = ["keycloak-theme", "keycloak_theme"];
/** Can't catch error, if the directory isn't found, this function will just exit the process with an error message. */
export function getThemeSrcDirPath(params: { projectDirPath: string }) {
const { projectDirPath } = params;
const srcDirPath = pathJoin(projectDirPath, "src");
const themeSrcDirPath: string | undefined = crawl({
dirPath: srcDirPath,
returnedPathsType: "relative to dirPath"
})
.map(fileRelativePath => {
for (const themeSrcDirBasename of themeSrcDirBasenames) {
const split = fileRelativePath.split(themeSrcDirBasename);
if (split.length === 2) {
return pathJoin(srcDirPath, split[0] + themeSrcDirBasename);
}
}
return undefined;
})
.filter(exclude(undefined))[0];
if (themeSrcDirPath !== undefined) {
return { themeSrcDirPath };
}
for (const themeType of [...themeTypes, "email"]) {
if (!fs.existsSync(pathJoin(srcDirPath, themeType))) {
continue;
}
return { themeSrcDirPath: srcDirPath };
}
console.error(
[
"Can't locate your theme source directory. It should be either: ",
"src/ or src/keycloak-theme or src/keycloak_theme.",
"Example in the starter: https://github.com/keycloakify/keycloakify-starter/tree/main/src/keycloak-theme"
].join("\n")
);
process.exit(-1);
}

View File

@ -6,56 +6,35 @@ export type MetaInfKeycloakTheme = {
themes: { name: string; types: (ThemeType | "email")[] }[];
};
export function getMetaInfKeycloakThemesJsonFilePath(params: {
keycloakifyBuildDirPath: string;
}) {
const { keycloakifyBuildDirPath } = params;
return pathJoin(
keycloakifyBuildDirPath === "." ? "" : keycloakifyBuildDirPath,
"src",
"main",
"resources",
"META-INF",
"keycloak-themes.json"
);
}
export function readMetaInfKeycloakThemes(params: {
keycloakifyBuildDirPath: string;
}): MetaInfKeycloakTheme {
const { keycloakifyBuildDirPath } = params;
return JSON.parse(
fs
.readFileSync(
getMetaInfKeycloakThemesJsonFilePath({
keycloakifyBuildDirPath
})
)
.toString("utf8")
) as MetaInfKeycloakTheme;
}
export function writeMetaInfKeycloakThemes(params: {
keycloakifyBuildDirPath: string;
metaInfKeycloakThemes: MetaInfKeycloakTheme;
resourcesDirPath: string;
getNewMetaInfKeycloakTheme: (params: {
metaInfKeycloakTheme: MetaInfKeycloakTheme | undefined;
}) => MetaInfKeycloakTheme;
}) {
const { keycloakifyBuildDirPath, metaInfKeycloakThemes } = params;
const { resourcesDirPath, getNewMetaInfKeycloakTheme } = params;
const metaInfKeycloakThemesJsonPath = getMetaInfKeycloakThemesJsonFilePath({
keycloakifyBuildDirPath
const filePath = pathJoin(resourcesDirPath, "META-INF", "keycloak-themes.json");
const currentMetaInfKeycloakTheme = !fs.existsSync(filePath)
? undefined
: (JSON.parse(
fs.readFileSync(filePath).toString("utf8")
) as MetaInfKeycloakTheme);
const newMetaInfKeycloakThemes = getNewMetaInfKeycloakTheme({
metaInfKeycloakTheme: currentMetaInfKeycloakTheme
});
{
const dirPath = pathDirname(metaInfKeycloakThemesJsonPath);
const dirPath = pathDirname(filePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
fs.writeFileSync(
metaInfKeycloakThemesJsonPath,
Buffer.from(JSON.stringify(metaInfKeycloakThemes, null, 2), "utf8")
filePath,
Buffer.from(JSON.stringify(newMetaInfKeycloakThemes, null, 2), "utf8")
);
}

View File

@ -1,91 +1,33 @@
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;
cacheDirPath: string;
excludeMajorVersions: number[];
doOmitPatch: boolean;
buildContext: BuildContextLike;
}) {
const { startingFromMajor, 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, doOmitPatch, 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 (
@ -95,6 +37,10 @@ export async function promptKeycloakVersion(params: {
return;
}
if (excludeMajorVersions.includes(semVersionedTag.version.major)) {
return;
}
const currentSemVersionedTag = semVersionedTagByMajor.get(
semVersionedTag.version.major
);
@ -110,7 +56,8 @@ export async function promptKeycloakVersion(params: {
});
const lastMajorVersions = Array.from(semVersionedTagByMajor.values()).map(
({ tag }) => tag
({ version }) =>
`${version.major}.${version.minor}${doOmitPatch ? "" : `.${version.patch}`}`
);
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 });
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,32 +1,34 @@
import { skipBuildJarsEnvName } 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>();
export async function keycloakifyBuild(params: {
doSkipBuildJars: boolean;
buildForKeycloakMajorVersionNumber: number;
buildContext: BuildContextLike;
}): Promise<{ isKeycloakifyBuildSuccess: boolean }> {
const { buildContext, doSkipBuildJars } = params;
const { buildForKeycloakMajorVersionNumber, buildContext } = 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,
...(doSkipBuildJars ? { [skipBuildJarsEnvName]: "true" } : {})
}
[BUILD_FOR_KEYCLOAK_MAJOR_VERSION_ENV_NAME]: `${buildForKeycloakMajorVersionNumber}`
},
shell: true
});
child.stdout.on("data", data => process.stdout.write(data));

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