Compare commits

..

341 Commits

Author SHA1 Message Date
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
e128e8f0a9 Release candidate 2024-06-09 11:25:05 +02:00
8a25b93ab2 Rename Fallback to DefaultPage 2024-06-09 11:24:50 +02:00
7a040935e9 i18n need to be passed as props if we want to be able to ovewrite 2024-06-09 11:20:45 +02:00
2015882688 Avoid loop rebuild in watch mode 2024-06-09 10:28:06 +02:00
379301eb9d Release candidate 2024-06-09 09:50:27 +02:00
5d86b05cdb Fix eject-page script 2024-06-09 09:50:02 +02:00
73c99d3157 Fix scripts 2024-06-09 09:39:16 +02:00
acba197c94 Release candidate 2024-06-09 09:36:31 +02:00
2441d8ed8a Fix tests 2024-06-09 09:36:16 +02:00
9c123f37c8 Make of doMakeUserConfirmPassword a prop of UserProfileFormFields 2024-06-09 09:34:39 +02:00
b48dbd99cf Enable to pass a path to a file for exclusions #525 2024-06-09 09:20:55 +02:00
25c8599d8f Rename BuildOptions -> BuildContext 2024-06-09 09:15:45 +02:00
3453a17c15 Rename reactAppRootDirPath -> projectDirPath and reactAppBuildDirPath -> projectBuildDirPath 2024-06-09 09:03:43 +02:00
6e95dacd3a Remove todo 2024-06-09 08:52:52 +02:00
a286e252e9 Enable to pass environement variables to test docker container 2024-06-09 08:50:59 +02:00
a8997e92c3 Improve cache strategy for getKcClsx 2024-06-09 08:37:23 +02:00
89137153a0 Remove isStorybook util 2024-06-09 08:37:02 +02:00
e3382de8e0 Fix boolean logic error 2024-06-09 08:30:57 +02:00
1a48681591 getClassName -> kcClsx 2024-06-09 08:27:07 +02:00
8f006f0009 Apply the new way i18n is implemented to every pages 2024-06-09 04:43:18 +02:00
77e32aad2a Make useGetClassName not a hook 2024-06-08 18:54:27 +02:00
8d365dae53 Refactor i18n, make component use the hook directly 2024-06-08 17:55:05 +02:00
01fb89674c Refactor i18n so that we don't have to wait for translations to be downloaded to render the page 2024-06-08 15:50:04 +02:00
e3144adc61 Release candidate 2024-06-08 14:21:20 +02:00
c9fb0ca6ae Rename extention types 2024-06-08 14:20:56 +02:00
82d7e1371e Add code gen for environement variables an theme name 2024-06-08 14:02:07 +02:00
e1341dfdba Release candidate 2024-06-08 07:17:29 +02:00
7f917311d8 Export ClassKey in the index instead of PageProps 2024-06-08 07:17:11 +02:00
2bfb856f07 Inconsistency fix 2024-06-08 04:10:04 +02:00
702f52f1c9 Only add the lang annotation if the lang is different from the current 2024-06-07 08:05:35 +02:00
7ba8649940 Release candidate 2024-06-07 08:03:28 +02:00
485ca28a29 Enable the lang of the term to be undefined 2024-06-07 08:03:13 +02:00
33460afaf2 Release candidate 2024-06-07 02:20:27 +02:00
2421ac2c11 Make the user return the actual language of the terms for accesibility 2024-06-07 02:20:12 +02:00
f0cdb0b80b Fix build 2024-06-06 09:31:27 +02:00
2af953927e Release candidate 2024-06-06 09:14:27 +02:00
dcb9fbd0f7 fixes for the add-story command 2024-06-06 09:13:58 +02:00
5bc1f6479d fmt 2024-06-06 09:13:13 +02:00
f3e4bca468 Add script to copy over the stories 2024-06-06 07:41:01 +02:00
54645f5cff Update storybook setup for portability 2024-06-06 07:28:34 +02:00
a7f3e00821 Remove createKcContextMock from the index 2024-06-06 06:27:28 +02:00
108c281b0c Enable to eject Template.tsx and UserProfileFormFields.tsx 2024-06-06 06:12:05 +02:00
58892cbb56 Change first level build target of bin 2024-06-06 06:11:34 +02:00
dae1053ca8 Consistency with the starter 2024-06-06 06:10:41 +02:00
83a9778c30 Release candidate 2024-06-06 04:36:16 +02:00
c52157bfb9 Remove dependency to powerhooks 2024-06-06 04:35:57 +02:00
62bf846d5f Release candidate 2024-06-06 02:29:25 +02:00
148f7fa316 Rollback unarrowing of the getKcContextMock return type 2024-06-06 02:29:09 +02:00
f488327885 Release candidate 2024-06-06 01:31:17 +02:00
593b929254 #525 2024-06-06 01:31:00 +02:00
9218e97315 Relase candidate 2024-06-06 00:33:51 +02:00
beb0e8bd77 Add missing translations 2024-06-06 00:33:34 +02:00
cace66e9f8 No longer need for a eslint rule ignore for with default vite starter 2024-06-05 23:30:28 +02:00
ef850c71fd Release candidate 2024-06-05 23:24:44 +02:00
aa8dc1919f Make getKcContext mock return type less narrow 2024-06-05 23:24:21 +02:00
c7c9b19853 Fix case error 2024-06-05 23:08:40 +02:00
68c26e0f5b Fix case issue 2024-06-05 22:55:09 +02:00
6bcdf286ef Add missing export 2024-06-05 22:53:14 +02:00
d9345396e8 Fix build 2024-06-05 22:48:13 +02:00
4c423900d4 Release candidate 2024-06-05 21:44:54 +02:00
504419b26d Fix storybook 2024-06-05 21:44:26 +02:00
6e058eafed It's less confusing to use "#" as urls in mock kcContext 2024-06-05 21:44:14 +02:00
08fc9d8631 Fix tests by updating vitest 2024-06-05 21:23:34 +02:00
e8a11991a0 Rename kcContext -> KcContext and improve consistency 2024-06-05 21:13:58 +02:00
3e6d679838 Release candidate 2024-06-05 18:50:26 +02:00
4dad859c4d Fix storybook 2024-06-05 18:50:00 +02:00
ef9c933ca8 Relase candidate 2024-06-05 18:42:50 +02:00
0461190a67 Do not export default the Fallback component 2024-06-05 18:42:32 +02:00
06b3211b08 Ease up the instentiation of i18n 2024-06-05 18:41:53 +02:00
2033a9ce0c Release candidate 2024-06-05 18:15:25 +02:00
fca18d9209 Add missing file to the NPM bundle 2024-06-05 18:15:13 +02:00
4f99088449 Release candidate 2024-06-05 06:11:18 +02:00
b1da684008 Re implement asset fetching 2024-06-05 06:10:11 +02:00
89fb6de2d5 Full ordering of stories 2024-06-05 06:09:42 +02:00
b665bae3bb Another improvement on storybook switching from one page to another 2024-06-05 01:32:31 +02:00
0b5a7544ca Address white falshes in storybook 2024-06-05 01:02:17 +02:00
183826ca0d Improve terms story 2024-06-04 04:06:29 +02:00
e507aace6b Change ordering of stories 2024-06-04 04:06:09 +02:00
43c93ef0b4 Update the intro story 2024-06-04 02:05:09 +02:00
093e51e092 Fix escaping error 2024-06-04 01:49:26 +02:00
17e1655eaf Fix recaptcha in storybook 2024-06-04 01:39:54 +02:00
6b570f2b9a Update register story 2024-06-03 23:54:08 +02:00
f239d105a7 Fix missing key 2024-06-03 23:53:53 +02:00
776d8378e3 Shorter white flash when changing stories 2024-06-03 23:40:33 +02:00
dd770cd7c6 Remove unessesary stories 2024-06-03 23:40:21 +02:00
4b3de54e18 Make it more conveignent to run storybook 2024-06-03 23:26:04 +02:00
5741cd1b2b Lower the priority of the without password story. 2024-06-03 23:25:37 +02:00
b780d7136e Fix mistake after using attributesByName instead of attributes 2024-06-03 23:25:02 +02:00
3c28a05746 Fix copy-keycloak-resources-to-public 2024-06-03 22:45:09 +02:00
57ac5badba Update the euristic for getting the NPM workspace root. 2024-06-03 22:37:22 +02:00
e873eb5123 Rollback typescript because updating storybook would add a one month delay to the release 2024-06-03 22:36:54 +02:00
c1a63edd71 Refactor kcContext, avoid having mocks in the dist https://github.com/keycloakify/keycloakify/discussions/299#discussioncomment-9616747 2024-06-03 18:28:34 +02:00
37a060c4db Change ordering of pages 2024-06-03 01:23:41 +02:00
157e4ac485 Add missing storybook pages 2024-06-03 01:23:28 +02:00
ba4d9675a8 More homogeneous storybook setup 2024-06-03 00:11:19 +02:00
e011fb094c Factorize parameters in storybook 2024-06-02 22:37:04 +02:00
f55a934939 Complete migration of storybook from @lordvlad #274 2024-06-02 22:29:53 +02:00
96a88fe865 Fix add remove button for multifield attributes 2024-06-02 00:31:08 +02:00
6cdb83d730 Fix the way we handle multivalued single fileld (multiselct, multiselect-checkboxes) 2024-06-02 00:24:07 +02:00
95f06df45d Extenalize some core logic from the ejectable component 2024-06-01 22:54:17 +02:00
ec52b357d5 Fix logical error with radibuttons 2024-05-30 23:23:16 +02:00
d84546cd7d Correct error validation password policy 2024-05-30 22:50:06 +02:00
4eee4156da Release candidate 2024-05-28 01:27:01 +02:00
70f475d13e Remove some more noise in the kcContext 2024-05-28 01:26:33 +02:00
3a50a61b12 First test against the key for faster ftl rendering 2024-05-28 01:08:02 +02:00
a217f617d8 Remove profile.attributesByName from the kcContext 2024-05-28 01:05:35 +02:00
fdfcd78f02 Watches more files that are relevent to the keycloak theme 2024-05-28 00:55:46 +02:00
56d6d8001a Fix #549 after test 2024-05-28 00:23:48 +02:00
260 changed files with 22116 additions and 10429 deletions

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,13 +32,12 @@ 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
if: github.event_name == 'push'
#if: github.event_name == 'push'
needs: test
steps:
- uses: actions/checkout@v4
@ -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

@ -34,7 +34,6 @@ export function DocsContainer({ children, context }) {
.docblock-argstable-head th:nth-child(3), .docblock-argstable-body tr > td:nth-child(2) p {
font-size: 13px;
}
`}</style>
<BaseContainer
context={{
@ -64,11 +63,6 @@ export function CanvasContainer({ children }) {
return (
<>
<style>{`
body {
padding: 0 !important;
}
`}</style>
{children}
</>
);

View File

@ -0,0 +1,17 @@
<style>
body.sb-show-main.sb-main-padded {
padding: 0;
}
body:not(.kcBodyClass) {
background-color: #393939;
}
body.sb-show-preparing-docs > .sb-wrapper {
visibility: hidden;
}
body .sb-preparing-story {
visibility: hidden;
}
</style>

View File

@ -116,10 +116,45 @@ const { getHardCodedWeight } = (() => {
const orderedPagesPrefix = [
"Introduction",
"login/login.ftl",
"login/register-user-profile.ftl",
"login/register.ftl",
"login/terms.ftl",
"login/error.ftl",
"login/code.ftl",
"login/delete-account-confirm.ftl",
"login/delete-credential.ftl",
"login/frontchannel-logout.ftl",
"login/idp-review-user-profile.ftl",
"login/info.ftl",
"login/login-config-totp.ftl",
"login/login-idp-link-confirm.ftl",
"login/login-idp-link-email.ftl",
"login/login-oauth-grant.ftl",
"login/login-otp.ftl",
"login/login-page-expired.ftl",
"login/login-password.ftl",
"login/login-reset-otp.ftl",
"login/login-reset-password.ftl",
"login/login-update-password.ftl",
"login/login-update-profile.ftl",
"login/login-username.ftl",
"login/login-verify-email.ftl",
"login/login-x509-info.ftl",
"login/logout-confirm.ftl",
"login/saml-post-form.ftl",
"login/select-authenticator.ftl",
"login/update-email.ftl",
"login/webauthn-authenticate.ftl",
"login/webauthn-error.ftl",
"login/webauthn-register.ftl",
"login/login-oauth2-device-verify-user-code.ftl",
"login/login-recovery-authn-code-config.ftl",
"login/login-recovery-authn-code-input.ftl",
"account/account.ftl",
"account/password.ftl",
"account/federatedIdentity.ftl",
"account/log.ftl",
"account/sessions.ftl",
"account/totp.ftl",
];
function getHardCodedWeight(kind) {

View File

@ -0,0 +1,49 @@
## 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

@ -0,0 +1,49 @@
## 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

@ -0,0 +1,49 @@
## 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]**.

View File

@ -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>
-
@ -38,10 +41,9 @@
<img width="400" src="https://github.com/keycloakify/keycloakify/assets/6702424/e66d105c-c06f-47d1-8a31-a6ab09da4e80">
</p>
Keycloakify is fully compatible with Keycloak 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, [~~22~~](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1822509763), **23** [and up](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)!
Keycloakify is fully compatible with Keycloak 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, [~~22~~](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1822509763), 23, 24, 25...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
> NOTE: Keycloak 24 introduces [important changes](https://www.keycloak.org/docs/latest/upgrading/index.html#changes-to-freemarker-templates-to-render-pages-based-on-the-user-profile-and-realm).
> We're actively working on incorporating them into Keycloakify. [Follow progress](https://github.com/keycloakify/keycloakify/pull/538).
> NOTE: Keycloakify 10 is still in realase-candidate state. [Follow progress](https://github.com/keycloakify/keycloakify/pull/538).
## Sponsor

View File

@ -1,25 +1,23 @@
{
"name": "keycloakify",
"version": "10.0.0-rc.22",
"version": "10.0.0-rc.111",
"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": "yarn build && yarn copy-keycloak-resources-to-storybook-static && start-storybook -p 6006",
"link-in-starter": "ts-node --skipProject scripts/link-in-starter.ts",
"prepare": "tsx scripts/generate-i18n-messages.ts",
"build": "tsx scripts/build.ts",
"storybook": "tsx scripts/start-storybook.ts",
"link-in-starter": "tsx scripts/link-in-starter.ts",
"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",
"copy-keycloak-resources-to-storybook-static": "PUBLIC_DIR_PATH=.storybook/static node dist/bin/main.js copy-keycloak-resources-to-public",
"build-storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && build-storybook",
"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"
@ -42,12 +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/index.js"
"dist/vite-plugin/vite-plugin.d.ts"
],
"keywords": [
"keycloak",
@ -61,12 +61,7 @@
"bluehats"
],
"homepage": "https://www.keycloakify.dev",
"peerDependencies": {
"react": "*"
},
"dependencies": {
"minimal-polyfills": "^2.2.3",
"react-markdown": "^5.0.3",
"tsafe": "^1.6.6"
},
"devDependencies": {
@ -102,7 +97,6 @@
"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",
@ -110,17 +104,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": "^5.4.5",
"typescript": "^4.9.1-beta",
"vite": "^5.2.11",
"vitest": "^0.29.8",
"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

@ -0,0 +1,19 @@
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");
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`);
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

@ -1,6 +1,6 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join, relative } from "path";
import { join } from "path";
import { assert } from "tsafe/assert";
import { transformCodebase } from "../src/bin/tools/transformCodebase";
import chalk from "chalk";
@ -16,7 +16,7 @@ if (fs.existsSync(join("dist", "bin", "main.original.js"))) {
);
fs.readdirSync(join("dist", "bin")).forEach(fileBasename => {
if (/[0-9]\.index.js/.test(fileBasename)) {
if (/[0-9]\.index.js/.test(fileBasename) || fileBasename.endsWith(".node")) {
fs.rmSync(join("dist", "bin", fileBasename));
}
});
@ -52,7 +52,25 @@ transformCodebase({
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
patchDeprecatedBufferApiUsage(join("dist", "bin", "main.js"));
{
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"),
@ -93,6 +111,11 @@ run(
)}`
);
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"),
@ -105,12 +128,30 @@ transformCodebase({
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
patchDeprecatedBufferApiUsage(join("dist", "vite-plugin", "index.js"));
{
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) {
@ -127,7 +168,9 @@ function patchDeprecatedBufferApiUsage(filePath: string) {
`var buffer = Buffer.allocUnsafe ? Buffer.allocUnsafe(toRead) : new Buffer(toRead);`
);
assert(after !== before, `Patch failed for ${relative(process.cwd(), filePath)}`);
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,10 +6,13 @@ import {
dirname as pathDirname,
sep as pathSep
} from "path";
import { assert } from "tsafe/assert";
import { same } from "evt/tools/inDepth";
import { crawl } from "../src/bin/tools/crawl";
import { downloadKeycloakDefaultTheme } from "../src/bin/shared/downloadKeycloakDefaultTheme";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { rmSync } from "../src/bin/tools/fs.rmSync";
import { deepAssign } from "../src/tools/deepAssign";
import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions";
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
// update the version array for generating for newer version.
@ -24,14 +27,16 @@ async function main() {
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildOptions: {
buildContext: {
cacheDirPath: pathJoin(
thisCodebaseRootDirPath,
"node_modules",
".cache",
"keycloakify"
),
npmWorkspaceRootDirPath: thisCodebaseRootDirPath
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: thisCodebaseRootDirPath
})
}
});
@ -63,32 +68,56 @@ async function main() {
fs
.readFileSync(pathJoin(baseThemeDirPath, filePath))
.toString("utf8")
)
).map(([key, value]: any) => [
key === "locale_pt_BR" ? "locale_pt-BR" : key,
value.replace(/''/g, "'")
])
) as Record<string, string>
)
.map(([key, value]) => [key, value.replace(/''/g, "'")])
.map(([key, value]) => [
key === "locale_pt_BR" ? "locale_pt-BR" : key,
value
])
.map(([key, value]) => [key, key === "termsText" ? "" : value])
);
});
}
Object.keys(record).forEach(themeType => {
const recordForPageType = record[themeType];
if (themeType !== "login" && themeType !== "account") {
return;
}
const baseMessagesDirPath = pathJoin(
const recordForThemeType = record[themeType];
const languages = Object.keys(recordForThemeType);
const keycloakifyExtraMessages = (() => {
switch (themeType) {
case "login":
return keycloakifyExtraMessages_login;
case "account":
return keycloakifyExtraMessages_account;
}
assert(false);
})();
assert(
same(languages, Object.keys(keycloakifyExtraMessages), {
takeIntoAccountArraysOrdering: false
})
);
deepAssign({
target: recordForThemeType,
source: keycloakifyExtraMessages
});
const messagesDirPath = pathJoin(
thisCodebaseRootDirPath,
"src",
themeType,
"i18n",
"baseMessages"
"messages_defaultSet"
);
const languages = Object.keys(recordForPageType);
const generatedFileHeader = [
`//This code was automatically generated by running ${pathRelative(
thisCodebaseRootDirPath,
@ -98,7 +127,7 @@ async function main() {
].join("\n");
languages.forEach(language => {
const filePath = pathJoin(baseMessagesDirPath, `${language}.ts`);
const filePath = pathJoin(messagesDirPath, `${language}.ts`);
fs.mkdirSync(pathDirname(filePath), { recursive: true });
@ -110,7 +139,7 @@ async function main() {
"",
"/* spell-checker: disable */",
`const messages= ${JSON.stringify(
recordForPageType[language],
recordForThemeType[language],
null,
2
)};`,
@ -126,14 +155,14 @@ async function main() {
});
fs.writeFileSync(
pathJoin(baseMessagesDirPath, "index.ts"),
pathJoin(messagesDirPath, "index.ts"),
Buffer.from(
[
generatedFileHeader,
`import * as en from "./en";`,
"",
"export async function getMessages(currentLanguageTag: string) {",
" const { default: messages } = await (() => {",
"export async function fetchMessages_defaultSet(currentLanguageTag: string) {",
" const { default: messages_defaultSet } = await (() => {",
" switch (currentLanguageTag) {",
` case "en": return en;`,
...languages
@ -145,7 +174,7 @@ async function main() {
' default: return { "default": {} };',
" }",
" })();",
" return messages;",
" return messages_defaultSet;",
"}"
].join("\n"),
"utf8"
@ -154,6 +183,491 @@ async function main() {
});
}
const keycloakifyExtraMessages_login: 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",
Record<
| "shouldBeEqual"
| "shouldBeDifferent"
| "shouldMatchPattern"
| "mustBeAnInteger"
| "notAValidOption"
| "selectAnOption"
| "remove"
| "addValue"
| "languages",
string
>
> = {
en: {
shouldBeEqual: "{0} should be equal to {1}",
shouldBeDifferent: "{0} should be different to {1}",
shouldMatchPattern: "Pattern should match: `/{0}/`",
mustBeAnInteger: "Must be an integer",
notAValidOption: "Not a valid option",
selectAnOption: "Select an option",
remove: "Remove",
addValue: "Add value",
languages: "Languages"
},
/* spell-checker: disable */
ar: {
shouldBeEqual: "{0} يجب أن يكون مساويًا لـ {1}",
shouldBeDifferent: "{0} يجب أن يكون مختلفًا عن {1}",
shouldMatchPattern: "`/يجب أن يطابق النمط: `/{0}/",
mustBeAnInteger: "يجب أن يكون عددًا صحيحًا",
notAValidOption: "ليس خيارًا صالحًا",
selectAnOption: "اختر خيارًا",
remove: "إزالة",
addValue: "أضف قيمة",
languages: "اللغات"
},
ca: {
shouldBeEqual: "{0} hauria de ser igual a {1}",
shouldBeDifferent: "{0} hauria de ser diferent de {1}",
shouldMatchPattern: "El patró hauria de coincidir: `/{0}/`",
mustBeAnInteger: "Ha de ser un enter",
notAValidOption: "No és una opció vàlida",
selectAnOption: "Selecciona una opció",
remove: "Elimina",
addValue: "Afegeix valor",
languages: "Idiomes"
},
cs: {
shouldBeEqual: "{0} by měl být roven {1}",
shouldBeDifferent: "{0} by měl být odlišný od {1}",
shouldMatchPattern: "Vzor by měl odpovídat: `/{0}/`",
mustBeAnInteger: "Musí být celé číslo",
notAValidOption: "Není platná možnost",
selectAnOption: "Vyberte možnost",
remove: "Odstranit",
addValue: "Přidat hodnotu",
languages: "Jazyky"
},
da: {
shouldBeEqual: "{0} bør være lig med {1}",
shouldBeDifferent: "{0} bør være forskellig fra {1}",
shouldMatchPattern: "Mønsteret bør matche: `/{0}/`",
mustBeAnInteger: "Skal være et heltal",
notAValidOption: "Ikke en gyldig mulighed",
selectAnOption: "Vælg en mulighed",
remove: "Fjern",
addValue: "Tilføj værdi",
languages: "Sprog"
},
de: {
shouldBeEqual: "{0} sollte gleich {1} sein",
shouldBeDifferent: "{0} sollte sich von {1} unterscheiden",
shouldMatchPattern: "Muster sollte übereinstimmen: `/{0}/`",
mustBeAnInteger: "Muss eine ganze Zahl sein",
notAValidOption: "Keine gültige Option",
selectAnOption: "Wählen Sie eine Option",
remove: "Entfernen",
addValue: "Wert hinzufügen",
languages: "Sprachen"
},
el: {
shouldBeEqual: "Το {0} πρέπει να είναι ίσο με {1}",
shouldBeDifferent: "Το {0} πρέπει να διαφέρει από το {1}",
shouldMatchPattern: "Το πρότυπο πρέπει να ταιριάζει: `/{0}/`",
mustBeAnInteger: "Πρέπει να είναι ακέραιος",
notAValidOption: "Δεν είναι μια έγκυρη επιλογή",
selectAnOption: "Επιλέξτε μια επιλογή",
remove: "Αφαίρεση",
addValue: "Προσθήκη τιμής",
languages: "Γλώσσες"
},
es: {
shouldBeEqual: "{0} debería ser igual a {1}",
shouldBeDifferent: "{0} debería ser diferente a {1}",
shouldMatchPattern: "El patrón debería coincidir: `/{0}/`",
mustBeAnInteger: "Debe ser un número entero",
notAValidOption: "No es una opción válida",
selectAnOption: "Selecciona una opción",
remove: "Eliminar",
addValue: "Añadir valor",
languages: "Idiomas"
},
fa: {
shouldBeEqual: "{0} باید برابر باشد با {1}",
shouldBeDifferent: "{0} باید متفاوت باشد از {1}",
shouldMatchPattern: "الگو باید مطابقت داشته باشد: `/{0}/`",
mustBeAnInteger: "باید یک عدد صحیح باشد",
notAValidOption: "یک گزینه معتبر نیست",
selectAnOption: "یک گزینه انتخاب کنید",
remove: "حذف",
addValue: "افزودن مقدار",
languages: "زبان‌ها"
},
fi: {
shouldBeEqual: "{0} pitäisi olla yhtä suuri kuin {1}",
shouldBeDifferent: "{0} pitäisi olla erilainen kuin {1}",
shouldMatchPattern: "Mallin tulisi vastata: `/{0}/`",
mustBeAnInteger: "On oltava kokonaisluku",
notAValidOption: "Ei ole kelvollinen vaihtoehto",
selectAnOption: "Valitse vaihtoehto",
remove: "Poista",
addValue: "Lisää arvo",
languages: "Kielet"
},
fr: {
shouldBeEqual: "{0} devrait être égal à {1}",
shouldBeDifferent: "{0} devrait être différent de {1}",
shouldMatchPattern: "Le motif devrait correspondre: `/{0}/`",
mustBeAnInteger: "Doit être un entier",
notAValidOption: "Pas une option valide",
selectAnOption: "Sélectionnez une option",
remove: "Supprimer",
addValue: "Ajouter une valeur",
languages: "Langues"
},
hu: {
shouldBeEqual: "{0} egyenlő kell legyen {1}-vel",
shouldBeDifferent: "{0} különbözőnek kell lennie, mint {1}",
shouldMatchPattern: "A mintának egyeznie kell: `/{0}/`",
mustBeAnInteger: "Egész számnak kell lennie",
notAValidOption: "Nem érvényes opció",
selectAnOption: "Válasszon egy lehetőséget",
remove: "Eltávolítás",
addValue: "Érték hozzáadása",
languages: "Nyelvek"
},
it: {
shouldBeEqual: "{0} dovrebbe essere uguale a {1}",
shouldBeDifferent: "{0} dovrebbe essere diverso da {1}",
shouldMatchPattern: "Il modello dovrebbe corrispondere: `/{0}/`",
mustBeAnInteger: "Deve essere un numero intero",
notAValidOption: "Non è un'opzione valida",
selectAnOption: "Seleziona un'opzione",
remove: "Rimuovi",
addValue: "Aggiungi valore",
languages: "Lingue"
},
ja: {
shouldBeEqual: "{0} は {1} と等しい必要があります",
shouldBeDifferent: "{0} は {1} と異なる必要があります",
shouldMatchPattern: "パターンは一致する必要があります: `/{0}/`",
mustBeAnInteger: "整数である必要があります",
notAValidOption: "有効なオプションではありません",
selectAnOption: "オプションを選択",
remove: "削除",
addValue: "値を追加",
languages: "言語"
},
lt: {
shouldBeEqual: "{0} turėtų būti lygus {1}",
shouldBeDifferent: "{0} turėtų skirtis nuo {1}",
shouldMatchPattern: "Šablonas turėtų atitikti: `/{0}/`",
mustBeAnInteger: "Turi būti sveikasis skaičius",
notAValidOption: "Netinkama parinktis",
selectAnOption: "Pasirinkite parinktį",
remove: "Pašalinti",
addValue: "Pridėti reikšmę",
languages: "Kalbos"
},
lv: {
shouldBeEqual: "{0} jābūt vienādam ar {1}",
shouldBeDifferent: "{0} jābūt atšķirīgam no {1}",
shouldMatchPattern: "Mustrim jāsakrīt: `/{0}/`",
mustBeAnInteger: "Jābūt veselam skaitlim",
notAValidOption: "Nav derīga opcija",
selectAnOption: "Izvēlieties opciju",
remove: "Noņemt",
addValue: "Pievienot vērtību",
languages: "Valodas"
},
nl: {
shouldBeEqual: "{0} moet gelijk zijn aan {1}",
shouldBeDifferent: "{0} moet verschillen van {1}",
shouldMatchPattern: "Patroon moet overeenkomen: `/{0}/`",
mustBeAnInteger: "Moet een geheel getal zijn",
notAValidOption: "Geen geldige optie",
selectAnOption: "Selecteer een optie",
remove: "Verwijderen",
addValue: "Waarde toevoegen",
languages: "Talen"
},
no: {
shouldBeEqual: "{0} skal være lik {1}",
shouldBeDifferent: "{0} skal være forskjellig fra {1}",
shouldMatchPattern: "Mønsteret skal matche: `/{0}/`",
mustBeAnInteger: "Må være et heltall",
notAValidOption: "Ikke et gyldig alternativ",
selectAnOption: "Velg et alternativ",
remove: "Fjern",
addValue: "Legg til verdi",
languages: "Språk"
},
pl: {
shouldBeEqual: "{0} powinno być równe {1}",
shouldBeDifferent: "{0} powinno być różne od {1}",
shouldMatchPattern: "Wzór pow inien pasować: `/{0}/`",
mustBeAnInteger: "Musi być liczbą całkowitą",
notAValidOption: "Nieprawidłowa opcja",
selectAnOption: "Wybierz opcję",
remove: "Usuń",
addValue: "Dodaj wartość",
languages: "Języki"
},
"pt-BR": {
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"
},
ru: {
shouldBeEqual: "{0} должно быть равно {1}",
shouldBeDifferent: "{0} должно отличаться от {1}",
shouldMatchPattern: "Шаблон должен соответствовать: `/{0}/`",
mustBeAnInteger: "Должно быть целым числом",
notAValidOption: "Недопустимый вариант",
selectAnOption: "Выберите вариант",
remove: "Удалить",
addValue: "Добавить значение",
languages: "Языки"
},
sk: {
shouldBeEqual: "{0} by mal byť rovnaký ako {1}",
shouldBeDifferent: "{0} by mal byť odlišný od {1}",
shouldMatchPattern: "Vzor by mal zodpovedať: `/{0}/`",
mustBeAnInteger: "Musí byť celé číslo",
notAValidOption: "Nie je platná možnosť",
selectAnOption: "Vyberte možnosť",
remove: "Odstrániť",
addValue: "Pridať hodnotu",
languages: "Jazyky"
},
sv: {
shouldBeEqual: "{0} bör vara lika med {1}",
shouldBeDifferent: "{0} bör vara annorlunda än {1}",
shouldMatchPattern: "Mönstret bör matcha: `/{0}/`",
mustBeAnInteger: "Måste vara ett heltal",
notAValidOption: "Inte ett giltigt alternativ",
selectAnOption: "Välj ett alternativ",
remove: "Ta bort",
addValue: "Lägg till värde",
languages: "Språk"
},
th: {
shouldBeEqual: "{0} ควรเท่ากับ {1}",
shouldBeDifferent: "{0} ควรแตกต่างจาก {1}",
shouldMatchPattern: "รูปแบบควรตรงกับ: `/{0}/`",
mustBeAnInteger: "ต้องเป็นจำนวนเต็ม",
notAValidOption: "ไม่ใช่ตัวเลือกที่ถูกต้อง",
selectAnOption: "เลือกตัวเลือก",
remove: "ลบ",
addValue: "เพิ่มค่า",
languages: "ภาษา"
},
tr: {
shouldBeEqual: "{0} {1} eşit olmalıdır",
shouldBeDifferent: "{0} {1} farklı olmalıdır",
shouldMatchPattern: "Desen eşleşmelidir: `/{0}/`",
mustBeAnInteger: "Tam sayı olmalıdır",
notAValidOption: "Geçerli bir seçenek değil",
selectAnOption: "Bir seçenek seçin",
remove: "Kaldır",
addValue: "Değer ekle",
languages: "Diller"
},
uk: {
shouldBeEqual: "{0} повинно бути рівним {1}",
shouldBeDifferent: "{0} повинно відрізнятися від {1}",
shouldMatchPattern: "Шаблон повинен відповідати: `/{0}/`",
mustBeAnInteger: "Повинно бути цілим числом",
notAValidOption: "Не є дійсною опцією",
selectAnOption: "Виберіть опцію",
remove: "Видалити",
addValue: "Додати значення",
languages: "Мови"
},
"zh-CN": {
shouldBeEqual: "{0} 应该等于 {1}",
shouldBeDifferent: "{0} 应该不同于 {1}",
shouldMatchPattern: "模式应匹配: `/{0}/`",
mustBeAnInteger: "必须是整数",
notAValidOption: "不是有效选项",
selectAnOption: "选择一个选项",
remove: "移除",
addValue: "添加值",
languages: "语言"
}
/* spell-checker: enable */
};
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",
Record<"newPasswordSameAsOld" | "passwordConfirmNotMatch", string>
> = {
en: {
newPasswordSameAsOld: "New password must be different from the old one",
passwordConfirmNotMatch: "Password confirmation does not match"
},
/* spell-checker: disable */
ar: {
newPasswordSameAsOld: "يجب أن تكون كلمة المرور الجديدة مختلفة عن القديمة",
passwordConfirmNotMatch: "تأكيد كلمة المرور لا يتطابق"
},
ca: {
newPasswordSameAsOld: "La nova contrasenya ha de ser diferent de l'anterior",
passwordConfirmNotMatch: "La confirmació de la contrasenya no coincideix"
},
cs: {
newPasswordSameAsOld: "Nové heslo musí být odlišné od starého",
passwordConfirmNotMatch: "Potvrzení hesla se neshoduje"
},
da: {
newPasswordSameAsOld: "Det nye kodeord skal være forskelligt fra det gamle",
passwordConfirmNotMatch: "Adgangskodebekræftelse matcher ikke"
},
de: {
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ää"
},
fr: {
newPasswordSameAsOld: "Le nouveau mot de passe doit être différent de l'ancien",
passwordConfirmNotMatch: "La confirmation du mot de passe ne correspond pas"
},
hu: {
newPasswordSameAsOld: "Az új jelszónak különböznie kell az előzőtől",
passwordConfirmNotMatch: "A jelszó megerősítése nem egyezik"
},
it: {
newPasswordSameAsOld:
"La nuova password deve essere diversa da quella precedente",
passwordConfirmNotMatch: "La conferma della password non corrisponde"
},
ja: {
newPasswordSameAsOld: "新しいパスワードは古いパスワードと異なる必要があります",
passwordConfirmNotMatch: "パスワード確認が一致しません"
},
lt: {
newPasswordSameAsOld: "Naujas slaptažodis turi skirtis nuo seno",
passwordConfirmNotMatch: "Slaptažodžio patvirtinimas neatitinka"
},
lv: {
newPasswordSameAsOld: "Jaunajam parolam jābūt atšķirīgam no vecā",
passwordConfirmNotMatch: "Paroles apstiprināšana neatbilst"
},
nl: {
newPasswordSameAsOld: "Het nieuwe wachtwoord moet verschillend zijn van het oude",
passwordConfirmNotMatch: "Wachtwoordbevestiging komt niet overeen"
},
no: {
newPasswordSameAsOld: "Det nye passordet må være forskjellig fra det gamle",
passwordConfirmNotMatch: "Passordbekreftelsen stemmer ikke"
},
pl: {
newPasswordSameAsOld: "Nowe hasło musi być inne niż stare",
passwordConfirmNotMatch: "Potwierdzenie hasła nie pasuje"
},
"pt-BR": {
newPasswordSameAsOld: "A nova senha deve ser diferente da antiga",
passwordConfirmNotMatch: "A confirmação da senha não corresponde"
},
ru: {
newPasswordSameAsOld: "Новый пароль должен отличаться от старого",
passwordConfirmNotMatch: "Подтверждение пароля не совпадает"
},
sk: {
newPasswordSameAsOld: "Nové heslo musí byť odlišné od starého",
passwordConfirmNotMatch: "Potvrdenie hesla sa nezhoduje"
},
sv: {
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

@ -2,66 +2,53 @@ 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, '"!./');
fs.writeFileSync(
pathJoin(rootDirPath, "dist", "package.json"),
Buffer.from(modifiedPackageJsonContent, "utf8")
);
}
const yarnGlobalDirPath = pathJoin(rootDirPath, ".yarn_home");
@ -83,7 +70,9 @@ const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
cwd,
env: {
...process.env,
HOME: yarnGlobalDirPath
...(os.platform() === "win32"
? { USERPROFILE: yarnGlobalDirPath }
: { HOME: yarnGlobalDirPath })
}
});
};

View File

@ -1,11 +1,7 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { waitForDebounceFactory } from "powerhooks/tools/waitForDebounce";
import chokidar from "chokidar";
import * as runExclusive from "run-exclusive";
import { Deferred } from "evt/tools/Deferred";
import chalk from "chalk";
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
fs.rmSync("node_modules", { recursive: true, force: true });
fs.rmSync("dist", { recursive: true, force: true });
@ -14,44 +10,18 @@ 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}`);
const { waitForDebounce } = waitForDebounceFactory({ delay: 400 });
const runYarnBuild = runExclusive.build(async () => {
console.log(chalk.green("Running `yarn build`"));
const dCompleted = new Deferred<void>();
const child = child_process.spawn("yarn", ["build"], {
env: process.env
});
child.stdout.on("data", data => process.stdout.write(data));
child.stderr.on("data", data => process.stderr.write(data));
child.on("exit", () => dCompleted.resolve());
await dCompleted.pr;
console.log("\n\n");
});
console.log(chalk.green("Watching for changes in src/"));
chokidar.watch("src", { ignoreInitial: true }).on("all", async () => {
await waitForDebounce();
runYarnBuild();
});
startRebuildOnSrcChange();
function run(command: string, options?: { cwd: string }) {
console.log(`$ ${command}`);

View File

@ -0,0 +1,33 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
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));
}
startRebuildOnSrcChange();
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`);
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

@ -0,0 +1,40 @@
import * as child_process from "child_process";
import { waitForDebounceFactory } from "powerhooks/tools/waitForDebounce";
import chokidar from "chokidar";
import * as runExclusive from "run-exclusive";
import { Deferred } from "evt/tools/Deferred";
import chalk from "chalk";
export function startRebuildOnSrcChange() {
const { waitForDebounce } = waitForDebounceFactory({ delay: 400 });
const runYarnBuild = runExclusive.build(async () => {
console.log(chalk.green("Running `yarn build`"));
const dCompleted = new Deferred<void>();
const child = child_process.spawn("yarn", ["build"], { shell: true });
child.stdout.on("data", data => process.stdout.write(data));
child.stderr.on("data", data => process.stderr.write(data));
child.on("exit", () => dCompleted.resolve());
await dCompleted.pr;
console.log("\n\n");
});
console.log(chalk.green("Watching for changes in src/"));
chokidar
.watch(["src", "stories"], { ignoreInitial: true })
.on("all", async (event, path) => {
console.log(chalk.bold(`${event}: ${path}`));
await waitForDebounce();
runYarnBuild();
});
}

View File

@ -1,15 +1,12 @@
import {
nameOfTheGlobal,
basenameOfTheKeycloakifyResourcesDir
} from "keycloakify/bin/shared/constants";
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "keycloakify/bin/shared/constants";
import { assert } from "tsafe/assert";
/**
* This is an equivalent of process.env.PUBLIC_URL thay you can use in Webpack projects.
* This is an equivalent of process.env.PUBLIC_URL that you can use in Webpack projects.
* This works both in your main app and in your Keycloak theme.
*/
export const PUBLIC_URL = (() => {
const kcContext = (window as any)[nameOfTheGlobal];
const kcContext = (window as any).kcContext;
if (kcContext === undefined || process.env.NODE_ENV === "development") {
assert(
@ -20,5 +17,5 @@ export const PUBLIC_URL = (() => {
return process.env.PUBLIC_URL;
}
return `${kcContext.url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}`;
return `${kcContext.url.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}`;
})();

View File

@ -1,9 +1,8 @@
import { lazy, Suspense } from "react";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { I18n } from "keycloakify/account/i18n";
import type { KcContext } from "./kcContext";
import { assert, type Equals } from "tsafe/assert";
import FederatedIdentity from "./pages/FederatedIdentity";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "keycloakify/account/KcContext";
import { I18n } from "keycloakify/account/i18n";
const Password = lazy(() => import("keycloakify/account/pages/Password"));
const Account = lazy(() => import("keycloakify/account/pages/Account"));
@ -11,8 +10,9 @@ const Sessions = lazy(() => import("keycloakify/account/pages/Sessions"));
const Totp = lazy(() => import("keycloakify/account/pages/Totp"));
const Applications = lazy(() => import("keycloakify/account/pages/Applications"));
const Log = lazy(() => import("keycloakify/account/pages/Log"));
const FederatedIdentity = lazy(() => import("keycloakify/account/pages/FederatedIdentity"));
export default function Fallback(props: PageProps<KcContext, I18n>) {
export default function DefaultPage(props: PageProps<KcContext, I18n>) {
const { kcContext, ...rest } = props;
return (

View File

@ -1,6 +1,24 @@
import type { ThemeType, AccountThemePageId } from "keycloakify/bin/shared/constants";
import type { ValueOf } from "keycloakify/tools/ValueOf";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import type { ThemeType, AccountThemePageId } from "keycloakify/bin/shared/constants";
export type ExtendKcContext<
KcContextExtension extends { properties?: Record<string, string | undefined> },
KcContextExtensionPerPage extends Record<string, Record<string, unknown>>
> = ValueOf<{
[PageId in keyof KcContextExtensionPerPage | KcContext["pageId"]]: Extract<
KcContext,
{ pageId: PageId }
> extends never
? KcContext.Common &
KcContextExtension & {
pageId: PageId;
} & KcContextExtensionPerPage[PageId]
: Extract<KcContext, { pageId: PageId }> &
KcContextExtension &
KcContextExtensionPerPage[PageId];
}>;
export type KcContext =
| KcContext.Password
@ -100,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 & {
@ -148,6 +169,7 @@ export declare namespace KcContext {
algorithm: "HmacSHA1" | "HmacSHA256" | "HmacSHA512";
digits: number;
lookAheadWindow: number;
getAlgorithmKey: () => string;
} & (
| {
type: "totp";

View File

@ -0,0 +1,69 @@
import type { ExtendKcContext, KcContext as KcContextBase } from "./KcContext";
import type { AccountThemePageId } from "keycloakify/bin/shared/constants";
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import { deepAssign } from "keycloakify/tools/deepAssign";
import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions";
import { kcContextMocks, kcContextCommonMock } from "./kcContextMocks";
import { exclude } from "tsafe/exclude";
export function createGetKcContextMock<
KcContextExtension extends { properties?: Record<string, string | undefined> },
KcContextExtensionPerPage extends Record<`${string}.ftl`, Record<string, unknown>>
>(params: {
kcContextExtension: KcContextExtension;
kcContextExtensionPerPage: KcContextExtensionPerPage;
overrides?: DeepPartial<KcContextExtension & KcContextBase.Common>;
overridesPerPage?: {
[PageId in AccountThemePageId | keyof KcContextExtensionPerPage]?: DeepPartial<
Extract<
ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>,
{ pageId: PageId }
>
>;
};
}) {
const {
kcContextExtension,
kcContextExtensionPerPage,
overrides: overrides_global,
overridesPerPage: overridesPerPage_global
} = params;
type KcContext = ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>;
function getKcContextMock<
PageId extends AccountThemePageId | keyof KcContextExtensionPerPage
>(params: {
pageId: PageId;
overrides?: DeepPartial<Extract<KcContext, { pageId: PageId }>>;
}): Extract<KcContext, { pageId: PageId }> {
const { pageId, overrides } = params;
const kcContextMock = structuredCloneButFunctions(
kcContextMocks.find(kcContextMock => kcContextMock.pageId === pageId) ?? {
...kcContextCommonMock,
pageId
}
);
[
kcContextExtension,
kcContextExtensionPerPage[pageId],
overrides_global,
overridesPerPage_global?.[pageId],
overrides
]
.filter(exclude(undefined))
.forEach(overrides =>
deepAssign({
target: kcContextMock,
source: overrides
})
);
// @ts-expect-error
return kcContextMock;
}
return { getKcContextMock };
}

View File

@ -0,0 +1,2 @@
export type { ExtendKcContext, KcContext } from "./KcContext";
export { createGetKcContextMock } from "./getKcContextMock";

View File

@ -0,0 +1,193 @@
import "keycloakify/tools/Object.fromEntries";
import { RESOURCES_COMMON, KEYCLOAK_RESOURCES } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
import type { KcContext } from "./KcContext";
import { BASE_URL } from "keycloakify/lib/BASE_URL";
const resourcesPath = `${BASE_URL}${KEYCLOAK_RESOURCES}/account/resources`;
export const kcContextCommonMock: KcContext.Common = {
themeVersion: "0.0.0",
keycloakifyVersion: "0.0.0",
themeType: "account",
themeName: "my-theme-name",
url: {
resourcesPath,
resourcesCommonPath: `${resourcesPath}/${RESOURCES_COMMON}`,
resourceUrl: "#",
accountUrl: "#",
applicationsUrl: "#",
logoutUrl: "#",
getLogoutUrl: () => "#",
logUrl: "#",
passwordUrl: "#",
sessionsUrl: "#",
socialUrl: "#",
totpUrl: "#"
},
realm: {
internationalizationEnabled: true,
userManagedAccessAllowed: true
},
messagesPerField: {
printIfExists: () => {
return undefined;
},
existsError: () => false,
get: key => `Fake error for ${key}`,
exists: () => false
},
locale: {
supported: [
/* spell-checker: disable */
["de", "Deutsch"],
["no", "Norsk"],
["ru", "Русский"],
["sv", "Svenska"],
["pt-BR", "Português (Brasil)"],
["lt", "Lietuvių"],
["en", "English"],
["it", "Italiano"],
["fr", "Français"],
["zh-CN", "中文简体"],
["es", "Español"],
["cs", "Čeština"],
["ja", "日本語"],
["sk", "Slovenčina"],
["pl", "Polski"],
["ca", "Català"],
["nl", "Nederlands"],
["tr", "Türkçe"]
/* spell-checker: enable */
].map(
([languageTag, label]) =>
({
languageTag,
label,
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
}) as const
),
currentLanguageTag: "en"
},
features: {
authorization: true,
identityFederation: true,
log: true,
passwordUpdateSupported: true
},
referrer: undefined,
account: {
firstName: "john",
lastName: "doe",
email: "john.doe@code.gouv.fr",
username: "doe_j"
},
properties: {},
"x-keycloakify": {
messages: {}
}
};
export const kcContextMocks: KcContext[] = [
id<KcContext.Password>({
...kcContextCommonMock,
pageId: "password.ftl",
password: {
passwordSet: true
},
stateChecker: "state checker"
}),
id<KcContext.Account>({
...kcContextCommonMock,
pageId: "account.ftl",
url: {
...kcContextCommonMock.url,
referrerURI: "#",
accountUrl: "#"
},
realm: {
...kcContextCommonMock.realm,
registrationEmailAsUsername: true,
editUsernameAllowed: true
},
stateChecker: ""
}),
id<KcContext.Sessions>({
...kcContextCommonMock,
pageId: "sessions.ftl",
sessions: {
sessions: [
{
ipAddress: "127.0.0.1",
started: new Date().toString(),
lastAccess: new Date().toString(),
expires: new Date().toString(),
clients: ["Chrome", "Firefox"],
id: "f8951177-817d-4a70-9c02-86d3c170fe51"
}
]
},
stateChecker: "g6WB1FaYnKotTkiy7ZrlxvFztSqS0U8jvHsOOOb2z4g"
}),
id<KcContext.Totp>({
...kcContextCommonMock,
pageId: "totp.ftl",
totp: {
enabled: true,
totpSecretEncoded: "KVVF G2BY N4YX S6LB IUYT K2LH IFYE 4SBV",
qrUrl: "#",
totpSecretQrCode:
"iVBORw0KGgoAAAANSUhEUgAAAPYAAAD2AQAAAADNaUdlAAACM0lEQVR4Xu3OIZJgOQwDUDFd2UxiurLAVnnbHw4YGDKtSiWOn4Gxf81//7r/+q8b4HfLGBZDK9d85NmNR+sB42sXvOYrN5P1DcgYYFTGfOlbzE8gzwy3euweGizw7cfdl34/GRhlkxjKNV+5AebPXPORX1JuB9x8ZfbyyD2y1krWAKsbMq1HnqQDaLfa77p4+MqvzEGSqvSAD/2IHW2yHaigR9tX3m8dDIYGcNf3f+gDpVBZbZU77zyJ6Rlcy+qoTMG887KAPD9hsh6a1Sv3gJUHGHUAxSMzj7zqDDe7Phmt2eG+8UsMxjRGm816MAO+8VMl1R1jGHOrZB/5Zo/WXAPgxixm9Mo96vDGrM1eOto8c4Ax4wF437mifOXlpiPzCnN7Y9l95NnEMxgMY9AAGA8fucH14Y1aVb6N/cqrmyh0BVht7k1e+bU8LK0Cg5vmVq9c5vHIjOfqxDIfeTraNVTwewa4wVe+SW5N+uP1qACeudUZbqGOfA6VZV750Noq2Xx3kpveV44ZelSV1V7KFHzkWyVrrlUwG0Pl9pWnoy3vsQoME6vKI69i5osVgwWzHT7zjmJtMcNUSVn1oYMd7ZodbgowZl45VG0uVuLPUr1yc79uaQBag/mqR34xhlWyHm1prplHboCWdZ4TeZjsK8+dI+jbz1C5hl65mcpgB5dhcj8+dGO+0Ko68+lD37JDD83dpDLzzK+TrQyaVwGj6pUboGV+7+AyN8An/pf84/7rv/4/1l4OCc/1BYMAAAAASUVORK5CYII=",
manualUrl: "#",
totpSecret: "G4nsI8lQagRMUchH8jEG",
otpCredentials: [],
supportedApplications: [
"totpAppFreeOTPName",
"totpAppMicrosoftAuthenticatorName",
"totpAppGoogleName"
],
policy: {
algorithm: "HmacSHA1",
digits: 6,
lookAheadWindow: 1,
type: "totp",
period: 30,
getAlgorithmKey: () => "SHA1"
}
},
mode: "qr",
isAppInitiatedAction: false,
stateChecker: ""
}),
id<KcContext.Log>({
...kcContextCommonMock,
pageId: "log.ftl",
log: {
events: [
{
date: "2/21/2024, 1:28:39 PM",
event: "login",
ipAddress: "172.17.0.1",
client: "security-admin-console",
details: [{ key: "openid-connect", value: "admin" }]
}
]
}
}),
id<KcContext.FederatedIdentity>({
...kcContextCommonMock,
stateChecker: "",
pageId: "federatedIdentity.ftl",
federatedIdentity: {
identities: [
{
providerId: "keycloak-oidc",
displayName: "keycloak-oidc",
userName: "John",
connected: true
}
],
removeLinkPossible: true
}
})
];

View File

@ -1,21 +1,19 @@
import { useEffect } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { type TemplateProps } from "keycloakify/account/TemplateProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import { createUseInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import type { KcContext } from "./kcContext";
import type { I18n } from "./i18n";
import { assert } from "keycloakify/tools/assert";
const { useInsertLinkTags } = createUseInsertLinkTags();
import { clsx } from "keycloakify/tools/clsx";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import type { TemplateProps } from "keycloakify/account/TemplateProps";
import type { I18n } from "./i18n";
import type { KcContext } from "./KcContext";
export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props;
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
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;
@ -25,12 +23,12 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
useSetClassName({
qualifiedName: "html",
className: getClassName("kcHtmlClass")
className: kcClsx("kcHtmlClass")
});
useSetClassName({
qualifiedName: "body",
className: clsx("admin-console", "user", getClassName("kcBodyClass"))
className: clsx("admin-console", "user", kcClsx("kcBodyClass"))
});
useEffect(() => {
@ -46,6 +44,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
}, []);
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
hrefs: !doUseDefaultCss
? []
: [
@ -74,14 +73,13 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
<li>
<div className="kc-dropdown" id="kc-locale-dropdown">
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a href="#" id="kc-current-locale-link">
{labelBySupportedLanguageTag[currentLanguageTag]}
</a>
<ul>
{locale.supported.map(({ languageTag }) => (
<li key={languageTag} className="kc-dropdown-item">
<a href={getChangeLocalUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
<a href={getChangeLocaleUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
</li>
))}
</ul>
@ -147,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

@ -1,17 +1,13 @@
import type { ReactNode } from "react";
import type { KcContext } from "./kcContext";
import type { I18n } from "./i18n";
export type TemplateProps<
KcContext extends KcContext.Common,
I18nExtended extends I18n
> = {
export type TemplateProps<KcContext, I18n> = {
kcContext: KcContext;
i18n: I18nExtended;
i18n: I18n;
doUseDefaultCss: boolean;
active: string;
classes?: Partial<Record<ClassKey, string>>;
children: ReactNode;
active: string;
};
export type ClassKey =

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,24 +1,24 @@
import "minimal-polyfills/Object.fromEntries";
import { useEffect, useState, useRef } from "react";
import fallbackMessages from "./baseMessages/en";
import { getMessages } from "./baseMessages";
import "keycloakify/tools/Object.fromEntries";
import { assert } from "tsafe/assert";
import type { KcContext } from "../kcContext/KcContext";
export const fallbackLanguageTag = "en";
import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en";
import { fetchMessages_defaultSet } from "./messages_defaultSet";
import type { KcContext } from "../KcContext";
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 fallbackMessages | keyof (typeof keycloakifyExtraMessages)[typeof fallbackLanguageTag];
export type GenericI18n<MessageKey extends string> = {
export type GenericI18n_noJsx<MessageKey extends string> = {
/**
* e.g: "en", "fr", "zh-CN"
*
@ -29,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", ...
*
@ -38,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;
/**
@ -58,106 +63,140 @@ 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;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
};
export type I18n = GenericI18n<MessageKey>;
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
[languageTag: string]: { [key in ExtraMessageKey]: string };
export function createGetI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag_themeDefined: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
function useI18n(params: { kcContext: KcContextLike }): GenericI18n<MessageKey | ExtraMessageKey> | null {
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
function getI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
const [i18n, setI18n] = useState<GenericI18n<ExtraMessageKey | MessageKey> | undefined>(undefined);
use_cache: {
const cachedResult = cachedResultByKcContext.get(kcContext);
const refHasStartedFetching = useRef(false);
useEffect(() => {
if (refHasStartedFetching.current) {
return;
if (cachedResult === undefined) {
break use_cache;
}
refHasStartedFetching.current = true;
(async () => {
const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {};
setI18n({
...createI18nTranslationFunctions({
fallbackMessages: {
...fallbackMessages,
...(keycloakifyExtraMessages[fallbackLanguageTag] ?? {}),
...(extraMessages[fallbackLanguageTag] ?? {})
} as any,
messages: {
...(await getMessages(currentLanguageTag)),
...((keycloakifyExtraMessages as any)[currentLanguageTag] ?? {}),
...(extraMessages[currentLanguageTag] ?? {})
} as any
}),
currentLanguageTag,
getChangeLocalUrl: newLanguageTag => {
const { locale } = kcContext;
assert(locale !== undefined, "Internationalization not enabled");
const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag);
assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`);
return targetSupportedLocale.url;
},
labelBySupportedLanguageTag: Object.fromEntries(
(kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])
)
});
})();
}, []);
return i18n ?? null;
}
return { useI18n };
}
function createI18nTranslationFunctions<MessageKey extends string>(params: {
fallbackMessages: Record<MessageKey, string>;
messages: Record<MessageKey, string>;
}): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const { fallbackMessages, messages } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
const { key, args, doRenderAsHtml } = props;
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key];
if (messageOrUndefined === undefined) {
return undefined;
return cachedResult;
}
const message = messageOrUndefined;
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");
const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag);
assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`);
return targetSupportedLocale.url;
},
labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]))
};
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 === FALLBACK_LANGUAGE_TAG;
const result: Result = {
i18n: {
...partialI18n,
...createI18nTranslationFunctions({
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
}),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag);
const i18n_currentLanguage: I18n = {
...partialI18n,
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
isFetchingTranslations: false
};
// NOTE: This promise.resolve is just because without it we TypeScript
// gives a Variable 'result' is used before being assigned. error
await Promise.resolve().then(() => {
result.i18n = i18n_currentLanguage;
result.prI18n_currentLanguage = undefined;
});
return i18n_currentLanguage;
})()
};
cachedResultByKcContext.set(kcContext, result);
return result;
}
return { getI18n };
}
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
messages_themeDefined: Record<MessageKey_themeDefined, string> | undefined;
messages_fromKcServer: Record<string, string>;
}) {
const { messages_themeDefined, messages_fromKcServer } = params;
function createI18nTranslationFunctions(params: {
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)[] }): string | undefined {
const { key, args } = props;
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 (message === undefined) {
return undefined;
}
const messageWithArgsInjectedIfAny = (() => {
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
@ -183,87 +222,29 @@ function createI18nTranslationFunctions<MessageKey extends string>(params: {
});
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;
}
let isFirstMatch = true;
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
const { key, args } = props;
const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => {
const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i;
const match = key.match(/^\$\{(.+)\}$/);
isFirstMatch = false;
if (match === null) {
return key;
}
return replaceBy;
});
return resolveMsg({ key: match[1], args }) ?? key;
}
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: resolvedComplexMessage }} /> : resolvedComplexMessage;
return {
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 })
};
}
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
};
return { createI18nTranslationFunctions };
}
const keycloakifyExtraMessages = {
en: {
shouldBeEqual: "{0} should be equal to {1}",
shouldBeDifferent: "{0} should be different to {1}",
shouldMatchPattern: "Pattern should match: `/{0}/`",
mustBeAnInteger: "Must be an integer",
notAValidOption: "Not a valid option",
newPasswordSameAsOld: "New password must be different from the old one",
passwordConfirmNotMatch: "Password confirmation does not match"
},
fr: {
/* spell-checker: disable */
shouldBeEqual: "{0} doit être égal à {1}",
shouldBeDifferent: "{0} doit être différent de {1}",
shouldMatchPattern: "Dois respecter le schéma: `/{0}/`",
mustBeAnInteger: "Doit être un nombre entier",
notAValidOption: "N'est pas une option valide",
logoutConfirmTitle: "Déconnexion",
logoutConfirmHeader: "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
doLogout: "Se déconnecter",
newPasswordSameAsOld: "Le nouveau mot de passe doit être différent de l'ancien",
passwordConfirmNotMatch: "La confirmation du mot de passe ne correspond pas"
/* spell-checker: enable */
}
};

View File

@ -1 +1,5 @@
export type { I18n } 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,81 @@
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(htmlString: string): JSX.Element {
return (
<div
style={{ display: "inline-block" }}
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: (...args) => renderHtmlString(i18n_noJsx.msgStr(...args)),
advancedMsg: (...args) => renderHtmlString(i18n_noJsx.advancedMsgStr(...args))
};
cache.set(i18n_noJsx, i18n);
return i18n;
}
return { withJsx };
})();
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

@ -1,10 +1,3 @@
import Fallback from "keycloakify/account/Fallback";
export default Fallback;
export { getKcContext } from "keycloakify/account/kcContext/getKcContext";
export { createGetKcContext } from "keycloakify/account/kcContext/createGetKcContext";
export type { AccountThemePageId as PageId } from "keycloakify/bin/shared/constants";
export { createUseI18n } from "keycloakify/account/i18n/i18n";
export type { PageProps } from "keycloakify/account/pages/PageProps";
export type { ExtendKcContext } from "keycloakify/account/KcContext";
export type { ClassKey } from "keycloakify/account/TemplateProps";
export { createUseI18n } from "keycloakify/account/i18n";

View File

@ -1,134 +0,0 @@
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import { deepAssign } from "keycloakify/tools/deepAssign";
import { isStorybook } from "keycloakify/lib/isStorybook";
import type { ExtendKcContext } from "./getKcContextFromWindow";
import { getKcContextFromWindow } from "./getKcContextFromWindow";
import { symToStr } from "tsafe/symToStr";
import {
kcContextMocks,
kcContextCommonMock
} from "keycloakify/account/kcContext/kcContextMocks";
export function createGetKcContext<
KcContextExtension extends { pageId: string } = never
>(params?: {
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
mockProperties?: Record<string, string>;
}) {
const { mockData, mockProperties } = params ?? {};
function getKcContext<
PageId extends
| ExtendKcContext<KcContextExtension>["pageId"]
| undefined = undefined
>(params?: {
mockPageId?: PageId;
storyPartialKcContext?: DeepPartial<
Extract<ExtendKcContext<KcContextExtension>, { pageId: PageId }>
>;
}): {
kcContext: PageId extends undefined
? ExtendKcContext<KcContextExtension> | undefined
: Extract<ExtendKcContext<KcContextExtension>, { pageId: PageId }>;
} {
const { mockPageId, storyPartialKcContext } = params ?? {};
const realKcContext = getKcContextFromWindow<KcContextExtension>();
if (mockPageId !== undefined && realKcContext === undefined) {
//TODO maybe trow if no mock fo custom page
warn_that_mock_is_enbaled: {
if (isStorybook) {
break warn_that_mock_is_enbaled;
}
console.log(
`%cKeycloakify: ${symToStr({
mockPageId
})} set to ${mockPageId}.`,
"background: red; color: yellow; font-size: medium"
);
}
const kcContextDefaultMock = kcContextMocks.find(
({ pageId }) => pageId === mockPageId
);
const partialKcContextCustomMock = (() => {
const out: DeepPartial<ExtendKcContext<KcContextExtension>> = {};
const mockDataPick = mockData?.find(
({ pageId }) => pageId === mockPageId
);
if (mockDataPick !== undefined) {
deepAssign({
target: out,
source: mockDataPick
});
}
if (storyPartialKcContext !== undefined) {
deepAssign({
target: out,
source: storyPartialKcContext
});
}
return Object.keys(out).length === 0 ? undefined : out;
})();
if (
kcContextDefaultMock === undefined &&
partialKcContextCustomMock === undefined
) {
console.warn(
[
`WARNING: You declared the non build in page ${mockPageId} but you didn't `,
`provide mock data needed to debug the page outside of Keycloak as you are trying to do now.`,
`Please check the documentation of the getKcContext function`
].join("\n")
);
}
const kcContext: any = {};
deepAssign({
target: kcContext,
source:
kcContextDefaultMock !== undefined
? kcContextDefaultMock
: { pageId: mockPageId, ...kcContextCommonMock }
});
if (partialKcContextCustomMock !== undefined) {
deepAssign({
target: kcContext,
source: partialKcContextCustomMock
});
}
if (mockProperties !== undefined) {
deepAssign({
target: kcContext.properties,
source: mockProperties
});
}
return { kcContext };
}
if (realKcContext === undefined) {
return { kcContext: undefined as any };
}
if (realKcContext.themeType !== "account") {
return { kcContext: undefined as any };
}
return { kcContext: realKcContext as any };
}
return { getKcContext };
}

View File

@ -1,23 +0,0 @@
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import type { ExtendKcContext } from "./getKcContextFromWindow";
import { createGetKcContext } from "./createGetKcContext";
/** NOTE: We now recommend using createGetKcContext instead of this function to make storybook integration easier
* See: https://github.com/keycloakify/keycloakify-starter/blob/main/src/keycloak-theme/account/kcContext.ts
*/
export function getKcContext<
KcContextExtension extends { pageId: string } = never
>(params?: {
mockPageId?: ExtendKcContext<KcContextExtension>["pageId"];
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
}): { kcContext: ExtendKcContext<KcContextExtension> | undefined } {
const { mockPageId, mockData } = params ?? {};
const { getKcContext } = createGetKcContext({
mockData
});
const { kcContext } = getKcContext({ mockPageId });
return { kcContext };
}

View File

@ -1,15 +0,0 @@
import type { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey";
import { nameOfTheGlobal } from "keycloakify/bin/shared/constants";
import type { KcContext } from "./KcContext";
export type ExtendKcContext<KcContextExtension extends { pageId: string }> = [
KcContextExtension
] extends [never]
? KcContext
: AndByDiscriminatingKey<"pageId", KcContextExtension & KcContext.Common, KcContext>;
export function getKcContextFromWindow<
KcContextExtension extends { pageId: string } = never
>(): ExtendKcContext<KcContextExtension> | undefined {
return typeof window === "undefined" ? undefined : (window as any)[nameOfTheGlobal];
}

View File

@ -1 +0,0 @@
export type { KcContext } from "./KcContext";

View File

@ -1,264 +0,0 @@
import "minimal-polyfills/Object.fromEntries";
import { resources_common, keycloak_resources } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
import type { KcContext } from "./KcContext";
import { BASE_URL } from "keycloakify/lib/BASE_URL";
const resourcesPath = `${BASE_URL}${keycloak_resources}/account/resources`;
export const kcContextCommonMock: KcContext.Common = {
themeVersion: "0.0.0",
keycloakifyVersion: "0.0.0",
themeType: "account",
themeName: "my-theme-name",
url: {
resourcesPath,
resourcesCommonPath: `${resourcesPath}/${resources_common}`,
resourceUrl: "#",
accountUrl: "#",
applicationsUrl: "#",
logoutUrl: "#",
getLogoutUrl: () => "#",
logUrl: "#",
passwordUrl: "#",
sessionsUrl: "#",
socialUrl: "#",
totpUrl: "#"
},
realm: {
internationalizationEnabled: true,
userManagedAccessAllowed: true
},
messagesPerField: {
printIfExists: () => {
return undefined;
},
existsError: () => false,
get: key => `Fake error for ${key}`,
exists: () => false
},
locale: {
supported: [
/* spell-checker: disable */
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=de",
label: "Deutsch",
languageTag: "de"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=no",
label: "Norsk",
languageTag: "no"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ru",
label: "Русский",
languageTag: "ru"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sv",
label: "Svenska",
languageTag: "sv"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pt-BR",
label: "Português (Brasil)",
languageTag: "pt-BR"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=lt",
label: "Lietuvių",
languageTag: "lt"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=en",
label: "English",
languageTag: "en"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=it",
label: "Italiano",
languageTag: "it"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=fr",
label: "Français",
languageTag: "fr"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=zh-CN",
label: "中文简体",
languageTag: "zh-CN"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=es",
label: "Español",
languageTag: "es"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=cs",
label: "Čeština",
languageTag: "cs"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ja",
label: "日本語",
languageTag: "ja"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sk",
label: "Slovenčina",
languageTag: "sk"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pl",
label: "Polski",
languageTag: "pl"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ca",
label: "Català",
languageTag: "ca"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=nl",
label: "Nederlands",
languageTag: "nl"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=tr",
label: "Türkçe",
languageTag: "tr"
}
/* spell-checker: enable */
],
currentLanguageTag: "en"
},
features: {
authorization: true,
identityFederation: true,
log: true,
passwordUpdateSupported: true
},
referrer: undefined,
account: {
firstName: "john",
lastName: "doe",
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"
}
};
export const kcContextMocks: KcContext[] = [
id<KcContext.Password>({
...kcContextCommonMock,
pageId: "password.ftl",
password: {
passwordSet: true
},
stateChecker: "state checker"
}),
id<KcContext.Account>({
...kcContextCommonMock,
pageId: "account.ftl",
url: {
...kcContextCommonMock.url,
referrerURI: "#",
accountUrl: "#"
},
realm: {
...kcContextCommonMock.realm,
registrationEmailAsUsername: true,
editUsernameAllowed: true
},
stateChecker: ""
}),
id<KcContext.Sessions>({
...kcContextCommonMock,
pageId: "sessions.ftl",
sessions: {
sessions: [
{
ipAddress: "127.0.0.1",
started: new Date().toString(),
lastAccess: new Date().toString(),
expires: new Date().toString(),
clients: ["Chrome", "Firefox"],
id: "f8951177-817d-4a70-9c02-86d3c170fe51"
}
]
},
stateChecker: "g6WB1FaYnKotTkiy7ZrlxvFztSqS0U8jvHsOOOb2z4g"
}),
id<KcContext.Totp>({
...kcContextCommonMock,
pageId: "totp.ftl",
totp: {
enabled: true,
totpSecretEncoded: "KVVF G2BY N4YX S6LB IUYT K2LH IFYE 4SBV",
qrUrl: "#",
totpSecretQrCode:
"iVBORw0KGgoAAAANSUhEUgAAAPYAAAD2AQAAAADNaUdlAAACM0lEQVR4Xu3OIZJgOQwDUDFd2UxiurLAVnnbHw4YGDKtSiWOn4Gxf81//7r/+q8b4HfLGBZDK9d85NmNR+sB42sXvOYrN5P1DcgYYFTGfOlbzE8gzwy3euweGizw7cfdl34/GRhlkxjKNV+5AebPXPORX1JuB9x8ZfbyyD2y1krWAKsbMq1HnqQDaLfa77p4+MqvzEGSqvSAD/2IHW2yHaigR9tX3m8dDIYGcNf3f+gDpVBZbZU77zyJ6Rlcy+qoTMG887KAPD9hsh6a1Sv3gJUHGHUAxSMzj7zqDDe7Phmt2eG+8UsMxjRGm816MAO+8VMl1R1jGHOrZB/5Zo/WXAPgxixm9Mo96vDGrM1eOto8c4Ax4wF437mifOXlpiPzCnN7Y9l95NnEMxgMY9AAGA8fucH14Y1aVb6N/cqrmyh0BVht7k1e+bU8LK0Cg5vmVq9c5vHIjOfqxDIfeTraNVTwewa4wVe+SW5N+uP1qACeudUZbqGOfA6VZV750Noq2Xx3kpveV44ZelSV1V7KFHzkWyVrrlUwG0Pl9pWnoy3vsQoME6vKI69i5osVgwWzHT7zjmJtMcNUSVn1oYMd7ZodbgowZl45VG0uVuLPUr1yc79uaQBag/mqR34xhlWyHm1prplHboCWdZ4TeZjsK8+dI+jbz1C5hl65mcpgB5dhcj8+dGO+0Ko68+lD37JDD83dpDLzzK+TrQyaVwGj6pUboGV+7+AyN8An/pf84/7rv/4/1l4OCc/1BYMAAAAASUVORK5CYII=",
manualUrl: "#",
totpSecret: "G4nsI8lQagRMUchH8jEG",
otpCredentials: [],
supportedApplications: [
"totpAppFreeOTPName",
"totpAppMicrosoftAuthenticatorName",
"totpAppGoogleName"
],
policy: {
algorithm: "HmacSHA1",
digits: 6,
lookAheadWindow: 1,
type: "totp",
period: 30
}
},
mode: "qr",
isAppInitiatedAction: false,
stateChecker: ""
}),
id<KcContext.Log>({
...kcContextCommonMock,
pageId: "log.ftl",
log: {
events: [
{
date: "2/21/2024, 1:28:39 PM",
event: "login",
ipAddress: "172.17.0.1",
client: "security-admin-console",
details: [{ key: "openid-connect", value: "admin" }]
}
]
}
}),
id<KcContext.FederatedIdentity>({
...kcContextCommonMock,
stateChecker: "",
pageId: "federatedIdentity.ftl",
federatedIdentity: {
identities: [
{
providerId: "keycloak-oidc",
displayName: "keycloak-oidc",
userName: "John",
connected: true
}
],
removeLinkPossible: true
}
})
];

View File

@ -1,7 +1,7 @@
import { createUseClassName } from "keycloakify/lib/useGetClassName";
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
import type { ClassKey } from "keycloakify/account/TemplateProps";
export const { useGetClassName } = createUseClassName<ClassKey>({
export const { getKcClsx } = createGetKcClsx<ClassKey>({
defaultClasses: {
kcHtmlClass: undefined,
kcBodyClass: undefined,
@ -19,3 +19,7 @@ export const { useGetClassName } = createUseClassName<ClassKey>({
"pf-c-form__helper-text pf-m-error required kc-feedback-text"
}
});
export type { ClassKey };
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];

View File

@ -1,18 +1,20 @@
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function Account(props: PageProps<Extract<KcContext, { pageId: "account.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { kcContext, i18n, doUseDefaultCss, Template } = props;
const { getClassName } = useGetClassName({
const classes = {
...props.classes,
kcBodyClass: clsx(props.classes?.kcBodyClass, "user")
};
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes: {
...classes,
kcBodyClass: clsx(classes?.kcBodyClass, "user")
}
classes
});
const { url, realm, messagesPerField, stateChecker, account, referrer } = kcContext;
@ -102,11 +104,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
{referrer !== undefined && <a href={referrer?.url}>{msg("backToApplication")}</a>}
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
name="submitAction"
value="Save"
>
@ -114,11 +112,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
</button>
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonLargeClass")}
name="submitAction"
value="Cancel"
>

View File

@ -1,17 +1,12 @@
import { clsx } from "keycloakify/tools/clsx";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
function isArrayWithEmptyObject(variable: any): boolean {
return Array.isArray(variable) && variable.length === 1 && typeof variable[0] === "object" && Object.keys(variable[0]).length === 0;
}
export default function Applications(props: PageProps<Extract<KcContext, { pageId: "applications.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -67,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}>
@ -118,7 +112,7 @@ export default function Applications(props: PageProps<Extract<KcContext, { pageI
application.additionalGrants.length > 0 ? (
<button
type="submit"
className={clsx(getClassName("kcButtonPrimaryClass"), getClassName("kcButtonClass"))}
className={kcClsx("kcButtonPrimaryClass", "kcButtonClass")}
id={`revoke-${application.client.clientId}`}
name="clientId"
value={application.client.id}
@ -136,3 +130,7 @@ export default function Applications(props: PageProps<Extract<KcContext, { pageI
</Template>
);
}
function isArrayWithEmptyObject(variable: any): boolean {
return Array.isArray(variable) && variable.length === 1 && typeof variable[0] === "object" && Object.keys(variable[0]).length === 0;
}

View File

@ -1,6 +1,6 @@
import { PageProps } from "keycloakify/account";
import { I18n } from "keycloakify/account/i18n";
import { KcContext } from "keycloakify/account/kcContext";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function FederatedIdentity(props: PageProps<Extract<KcContext, { pageId: "federatedIdentity.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;

View File

@ -1,13 +1,13 @@
import type { Key } from "react";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "../kcContext";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { Key } from "react";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
export default function Log(props: PageProps<Extract<KcContext, { pageId: "log.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -18,7 +18,7 @@ export default function Log(props: PageProps<Extract<KcContext, { pageId: "log.f
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="log">
<div className={getClassName("kcContentWrapperClass")}>
<div className={kcClsx("kcContentWrapperClass")}>
<div className="col-md-10">
<h2>{msg("accountLogHtmlTitle")}</h2>
</div>

View File

@ -1,12 +1,10 @@
import type { I18n } from "keycloakify/account/i18n";
import type { TemplateProps, ClassKey } from "keycloakify/account/TemplateProps";
import { type TemplateProps, type ClassKey } from "keycloakify/account/TemplateProps";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { KcContext } from "keycloakify/account/kcContext";
export type PageProps<NarowedKcContext = KcContext, I18nExtended extends I18n = I18n> = {
export type PageProps<NarrowedKcContext, I18n> = {
Template: LazyOrNot<(props: TemplateProps<any, any>) => JSX.Element | null>;
kcContext: NarowedKcContext;
i18n: I18nExtended;
kcContext: NarrowedKcContext;
i18n: I18n;
doUseDefaultCss: boolean;
classes?: Partial<Record<ClassKey, string>>;
};

View File

@ -1,19 +1,21 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function Password(props: PageProps<Extract<KcContext, { pageId: "password.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { kcContext, i18n, doUseDefaultCss, Template } = props;
const { getClassName } = useGetClassName({
const classes = {
...props.classes,
kcBodyClass: clsx(props.classes?.kcBodyClass, "password")
};
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes: {
...classes,
kcBodyClass: clsx(classes?.kcBodyClass, "password")
}
classes
});
const { url, password, account, stateChecker } = kcContext;
@ -192,11 +194,7 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
<button
disabled={newPasswordError !== "" || newPasswordConfirmError !== ""}
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
name="submitAction"
value="Save"
>

View File

@ -1,13 +1,12 @@
import { clsx } from "keycloakify/tools/clsx";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function Sessions(props: PageProps<Extract<KcContext, { pageId: "sessions.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -17,7 +16,7 @@ export default function Sessions(props: PageProps<Extract<KcContext, { pageId: "
const { msg } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="sessions">
<div className={getClassName("kcContentWrapperClass")}>
<div className={kcClsx("kcContentWrapperClass")}>
<div className="col-md-10">
<h2>{msg("sessionsHtmlTitle")}</h2>
</div>
@ -56,7 +55,7 @@ export default function Sessions(props: PageProps<Extract<KcContext, { pageId: "
<form action={url.sessionsUrl} method="post">
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
<button id="logout-all-sessions" type="submit" className={clsx(getClassName("kcButtonDefaultClass"), getClassName("kcButtonClass"))}>
<button id="logout-all-sessions" type="submit" className={kcClsx("kcButtonDefaultClass", "kcButtonClass")}>
{msg("doLogOutAllSessions")}
</button>
</form>

View File

@ -1,13 +1,13 @@
import { clsx } from "keycloakify/tools/clsx";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -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}
@ -140,9 +134,9 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
</li>
</ol>
<hr />
<form action={url.totpUrl} className={getClassName("kcFormClass")} id="kc-totp-settings-form" method="post">
<form action={url.totpUrl} className={kcClsx("kcFormClass")} id="kc-totp-settings-form" method="post">
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
<div className={getClassName("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<div className="col-sm-2 col-md-2">
<label htmlFor="totp" className="control-label">
{msg("authenticatorCode")}
@ -155,23 +149,28 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
id="totp"
name="totp"
autoComplete="off"
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={messagesPerField.existsError("totp")}
/>
{messagesPerField.existsError("totp") && (
<span id="input-error-otp-code" className={getClassName("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} />
{mode && <input type="hidden" id="mode" value={mode} />}
</div>
<div className={getClassName("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<div className="col-sm-2 col-md-2">
<label htmlFor="userLabel" className={getClassName("kcLabelClass")}>
<label htmlFor="userLabel" className={kcClsx("kcLabelClass")}>
{msg("totpDeviceName")}
</label>
{totp.otpCredentials.length >= 1 && <span className="required">*</span>}
@ -182,37 +181,33 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
id="userLabel"
name="userLabel"
autoComplete="off"
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={messagesPerField.existsError("userLabel")}
/>
{messagesPerField.existsError("userLabel") && (
<span id="input-error-otp-label" className={getClassName("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>
<div id="kc-form-buttons" className={clsx(getClassName("kcFormGroupClass"), "text-right")}>
<div className={getClassName("kcInputWrapperClass")}>
<div id="kc-form-buttons" className={clsx(kcClsx("kcFormGroupClass"), "text-right")}>
<div className={kcClsx("kcInputWrapperClass")}>
<input
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
id="saveTOTPBtn"
value={msgStr("doSave")}
/>
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonLargeClass", "kcButtonLargeClass")}
id="cancelTOTPBtn"
name="submitAction"
value="Cancel"

105
src/bin/add-story.ts Normal file
View File

@ -0,0 +1,105 @@
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
import cliSelect from "cli-select";
import {
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS,
type LoginThemePageId,
type AccountThemePageId,
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 { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import chalk from "chalk";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildContext = getBuildContext({
cliCommandOptions
});
console.log(chalk.cyan("Theme type:"));
const { value: themeType } = await cliSelect<ThemeType>({
values: [...THEME_TYPES]
}).catch(() => {
process.exit(-1);
});
console.log(`${themeType}`);
console.log(chalk.cyan("Select the page you want to create a Storybook for:"));
const { value: pageId } = await cliSelect<LoginThemePageId | AccountThemePageId>({
values: (() => {
switch (themeType) {
case "login":
return [...LOGIN_THEME_PAGE_IDS];
case "account":
return [...ACCOUNT_THEME_PAGE_IDS];
}
assert<Equals<typeof themeType, never>>(false);
})()
}).catch(() => {
process.exit(-1);
});
console.log(`${pageId}`);
const componentBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(
/ftl$/,
"stories.tsx"
);
const targetFilePath = pathJoin(
buildContext.themeSrcDirPath,
themeType,
"pages",
componentBasename
);
if (fs.existsSync(targetFilePath)) {
console.log(`${pathRelative(process.cwd(), targetFilePath)} already exists`);
process.exit(-1);
}
const componentCode = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
"stories",
themeType,
"pages",
componentBasename
)
)
.toString("utf8")
.replace('import React from "react";\n', "")
.replace(/from "[./]+dist\//, 'from "keycloakify/');
{
const targetDirPath = pathDirname(targetFilePath);
if (!fs.existsSync(targetDirPath)) {
fs.mkdirSync(targetDirPath, { recursive: true });
}
}
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
console.log(
[
`${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("npm run storybook")}`
].join("\n")
);
}

View File

@ -1,16 +1,13 @@
import { copyKeycloakResourcesToPublic } from "./shared/copyKeycloakResourcesToPublic";
import { readBuildOptions } from "./shared/buildOptions";
import { getBuildContext } from "./shared/buildContext";
import type { CliCommandOptions } from "./main";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({ cliCommandOptions });
const buildContext = getBuildContext({ cliCommandOptions });
await copyKeycloakResourcesToPublic({
buildOptions: {
...buildOptions,
publicDirPath: buildOptions.reactAppRootDirPath
}
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 { readBuildOptions } from "./shared/buildOptions";
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 buildOptions = readBuildOptions({
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: buildOptions.cacheDirPath
});
console.log(`${keycloakVersion}`);
const destDirPath = pathJoin(
buildOptions.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,
buildOptions
});
transformCodebase({
srcDirPath: defaultThemeDirPath,
destDirPath
});
console.log(chalk.green(`✓ done`));
}

View File

@ -3,11 +3,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";
@ -15,22 +15,21 @@ 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 { readBuildOptions } from "./shared/buildOptions";
import { getBuildContext } from "./shared/buildContext";
import chalk from "chalk";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({
const buildContext = getBuildContext({
cliCommandOptions
});
console.log(chalk.cyan("Theme type:"));
const { value: themeType } = await cliSelect<ThemeType>({
values: [...themeTypes]
values: [...THEME_TYPES]
}).catch(() => {
process.exit(-1);
});
@ -39,13 +38,26 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(chalk.cyan("Select the page you want to customize:"));
const { value: pageId } = await cliSelect<LoginThemePageId | AccountThemePageId>({
const templateValue = "Template.tsx (Layout common to every page)";
const userProfileFormFieldsValue =
"UserProfileFormFields.tsx (Renders the form of the register.ftl, login-update-profile.ftl, update-email.ftl and idp-review-user-profile.ftl)";
const { value: pageIdOrComponent } = await cliSelect<
| LoginThemePageId
| AccountThemePageId
| typeof templateValue
| typeof userProfileFormFieldsValue
>({
values: (() => {
switch (themeType) {
case "login":
return [...loginThemePageIds];
return [
templateValue,
userProfileFormFieldsValue,
...LOGIN_THEME_PAGE_IDS
];
case "account":
return [...accountThemePageIds];
return [templateValue, ...ACCOUNT_THEME_PAGE_IDS];
}
assert<Equals<typeof themeType, never>>(false);
})()
@ -53,27 +65,41 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
process.exit(-1);
});
console.log(`${pageId}`);
console.log(`${pageIdOrComponent}`);
const componentPageBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(
/ftl$/,
"tsx"
);
const componentBasename = (() => {
if (pageIdOrComponent === templateValue) {
return "Template.tsx";
}
const { themeSrcDirPath } = getThemeSrcDirPath({
reactAppRootDirPath: buildOptions.reactAppRootDirPath
});
if (pageIdOrComponent === userProfileFormFieldsValue) {
return "UserProfileFormFields.tsx";
}
return capitalize(kebabCaseToCamelCase(pageIdOrComponent)).replace(/ftl$/, "tsx");
})();
const pagesOrDot = (() => {
if (
pageIdOrComponent === templateValue ||
pageIdOrComponent === userProfileFormFieldsValue
) {
return ".";
}
return "pages";
})();
const targetFilePath = pathJoin(
themeSrcDirPath,
buildContext.themeSrcDirPath,
themeType,
"pages",
componentPageBasename
pagesOrDot,
componentBasename
);
if (fs.existsSync(targetFilePath)) {
console.log(
`${pageId} is already ejected, ${pathRelative(
`${pageIdOrComponent} is already ejected, ${pathRelative(
process.cwd(),
targetFilePath
)} already exists`
@ -82,6 +108,18 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
process.exit(-1);
}
const componentCode = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
themeType,
pagesOrDot,
componentBasename
)
)
.toString("utf8");
{
const targetDirPath = pathDirname(targetFilePath);
@ -90,51 +128,92 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
}
}
const componentPageContent = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
themeType,
"pages",
componentPageBasename
)
)
.toString("utf8");
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
fs.writeFileSync(targetFilePath, Buffer.from(componentPageContent, "utf8"));
console.log(
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
)} copy pasted from the Keycloakify source code into your project`
);
edit_KcApp: {
if (
pageIdOrComponent !== templateValue &&
pageIdOrComponent !== userProfileFormFieldsValue
) {
break edit_KcApp;
}
const kcAppTsxPath = pathJoin(
buildContext.themeSrcDirPath,
themeType,
"KcPage.tsx"
);
const kcAppTsxCode = fs.readFileSync(kcAppTsxPath).toString("utf8");
const modifiedKcAppTsxCode = (() => {
switch (pageIdOrComponent) {
case templateValue:
return kcAppTsxCode.replace(
`keycloakify/${themeType}/Template`,
"./Template"
);
case userProfileFormFieldsValue:
return kcAppTsxCode.replace(
`keycloakify/login/UserProfileFormFields`,
"./UserProfileFormFields"
);
}
assert<Equals<typeof pageIdOrComponent, never>>(false);
})();
if (kcAppTsxCode === modifiedKcAppTsxCode) {
console.log(
chalk.red(
"Unable to automatically update KcPage.tsx, please update it manually"
)
);
return;
}
fs.writeFileSync(kcAppTsxPath, Buffer.from(modifiedKcAppTsxCode, "utf8"));
console.log(
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), kcAppTsxPath))
)} Updated`
);
return;
}
const userProfileFormFieldComponentName = "UserProfileFormFields";
const componentName = componentBasename.replace(/.tsx$/, "");
console.log(
[
``,
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
)} copy pasted from the Keycloakify source code into your project`,
``,
`You now need to update your page router:`,
``,
`${chalk.bold(
pathJoin(
".",
pathRelative(process.cwd(), themeSrcDirPath),
pathRelative(process.cwd(), buildContext.themeSrcDirPath),
themeType,
"KcApp.tsx"
"KcPage.tsx"
)
)}:`,
chalk.grey("```"),
`// ...`,
``,
chalk.green(
`+const ${componentPageBasename.replace(
/.tsx$/,
""
)} = lazy(() => import("./pages/${componentPageBasename}"));`
`+const ${componentName} = lazy(() => import("./pages/${componentName}"));`
),
...[
``,
` export default function KcApp(props: { kcContext: KcContext; }) {`,
` export default function KcPage(props: { kcContext: KcContext; }) {`,
``,
` // ...`,
``,
@ -143,16 +222,17 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
` {(() => {`,
` switch (kcContext.pageId) {`,
` // ...`,
`+ case "${pageId}": return (`,
`+ <Login`,
`+ case "${pageIdOrComponent}": return (`,
`+ <${componentName}`,
`+ {...{ kcContext, i18n, classes }}`,
`+ Template={Template}`,
...(!componentPageContent.includes(userProfileFormFieldComponentName)
`+ doUseDefaultCss={true}`,
...(!componentCode.includes(userProfileFormFieldComponentName)
? []
: [
`+ ${userProfileFormFieldComponentName}={${userProfileFormFieldComponentName}}`
`+ ${userProfileFormFieldComponentName}={${userProfileFormFieldComponentName}}`,
`+ doMakeUserConfirmPassword={doMakeUserConfirmPassword}`
]),
`+ doUseDefaultCss={true}`,
`+ />`,
`+ );`,
` default: return <Fallback /* .. */ />;`,

View File

@ -2,21 +2,16 @@ import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTh
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
import { readBuildOptions } from "./shared/buildOptions";
import { getBuildContext } from "./shared/buildContext";
import * as fs from "fs";
import { getThemeSrcDirPath } from "./shared/getThemeSrcDirPath";
import type { CliCommandOptions } from "./main";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({ cliCommandOptions });
const buildContext = getBuildContext({ cliCommandOptions });
const { themeSrcDirPath } = getThemeSrcDirPath({
reactAppRootDirPath: buildOptions.reactAppRootDirPath
});
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
if (fs.existsSync(emailThemeSrcDirPath)) {
console.warn(
@ -34,12 +29,13 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const { keycloakVersion } = await promptKeycloakVersion({
// NOTE: This is arbitrary
startingFromMajor: 17,
cacheDirPath: buildOptions.cacheDirPath
excludeMajorVersions: [],
cacheDirPath: buildContext.cacheDirPath
});
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildOptions
buildContext
});
transformCodebase({

View File

@ -5,79 +5,67 @@ import type {
} from "./extensionVersions";
import { join as pathJoin, dirname as pathDirname } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import * as fs from "fs/promises";
import { accountV1ThemeName } from "../../shared/constants";
import { ACCOUNT_V1_THEME_NAME } from "../../shared/constants";
import {
generatePom,
BuildOptionsLike as BuildOptionsLike_generatePom
BuildContextLike as BuildContextLike_generatePom
} from "./generatePom";
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 BuildOptionsLike = BuildOptionsLike_generatePom & {
export type BuildContextLike = BuildContextLike_generatePom & {
keycloakifyBuildDirPath: string;
themeNames: string[];
artifactId: string;
themeVersion: string;
cacheDirPath: string;
recordIsImplementedByThemeType: BuildContext["recordIsImplementedByThemeType"];
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function buildJar(params: {
jarFileBasename: string;
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
buildOptions: BuildOptionsLike;
resourcesDirPath: string;
doesImplementAccountV1Theme: boolean;
buildContext: BuildContextLike;
}): Promise<void> {
const {
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
buildOptions
resourcesDirPath,
doesImplementAccountV1Theme,
buildContext
} = params;
const keycloakifyBuildTmpDirPath = pathJoin(
buildOptions.cacheDirPath,
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 [...buildOptions.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,62 +75,23 @@ export async function buildJar(params: {
if (
isInside({
dirPath: pathJoin(
"src",
"main",
"resources",
"theme",
accountV1ThemeName
),
dirPath: pathJoin("theme", ACCOUNT_V1_THEME_NAME),
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 buildOptions.themeNames) {
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=${ACCOUNT_V1_THEME_NAME}`,
"parent=keycloak"
),
"utf8"
@ -157,33 +106,39 @@ export async function buildJar(params: {
}
return { modifiedSourceCode: sourceCode };
};
}
});
transformCodebase({
srcDirPath: buildOptions.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_THEME_NAME
);
return transformCodebase_patchForUsingBuiltinAccountV1({
...params,
sourceCode: modifiedSourceCode
});
return metaInfKeycloakTheme;
}
});
}
route_legacy_pages: {
if (!buildContext.recordIsImplementedByThemeType.login) {
break route_legacy_pages;
}
// NOTE: If there's no account theme there is no special target for keycloak 24 and up so we create
// the pages anyway. If there is an account pages, since we know that account-v1 is only support keycloak
// 24 in version 0.4 and up, we can safely break the route for legacy pages.
@ -198,17 +153,15 @@ export async function buildJar(params: {
}
})();
// TODO: Remove this optimization, it's a bit hacky.
if (doBreak) {
break route_legacy_pages;
}
(["register.ftl", "login-update-profile.ftl"] as const).forEach(pageId =>
buildOptions.themeNames.map(themeName => {
buildContext.themeNames.map(themeName => {
const ftlFilePath = pathJoin(
keycloakifyBuildTmpDirPath,
"src",
"main",
"resources",
tmpResourcesDirPath,
"theme",
themeName,
"login",
@ -217,7 +170,7 @@ export async function buildJar(params: {
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
const realPageId = (() => {
const ftlFileBasename = (() => {
switch (pageId) {
case "register.ftl":
return "register-user-profile.ftl";
@ -228,14 +181,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")
);
})
@ -244,21 +197,21 @@ export async function buildJar(params: {
{
const { pomFileCode } = generatePom({
buildOptions,
buildContext,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
});
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 +236,10 @@ export async function buildJar(params: {
await fs.rename(
pathJoin(
keycloakifyBuildTmpDirPath,
keycloakifyBuildCacheDirPath,
"target",
`${buildOptions.artifactId}-${buildOptions.themeVersion}.jar`
`${buildContext.artifactId}-${buildContext.themeVersion}.jar`
),
pathJoin(buildOptions.keycloakifyBuildDirPath, jarFileBasename)
pathJoin(buildContext.keycloakifyBuildDirPath, jarFileBasename)
);
rmSync(keycloakifyBuildTmpDirPath, { recursive: true });
}

View File

@ -1,38 +1,39 @@
import { assert } from "tsafe/assert";
import { exclude } from "tsafe/exclude";
import {
keycloakAccountV1Versions,
keycloakThemeAdditionalInfoExtensionVersions
} from "./extensionVersions";
import { getKeycloakVersionRangeForJar } from "./getKeycloakVersionRangeForJar";
import { buildJar, BuildOptionsLike as BuildOptionsLike_buildJar } from "./buildJar";
import type { BuildOptions } from "../../shared/buildOptions";
import { getJarFileBasename } from "../../shared/getJarFileBasename";
import { readMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
import { accountV1ThemeName } from "../../shared/constants";
import { buildJar, BuildContextLike as BuildContextLike_buildJar } from "./buildJar";
import type { BuildContext } from "../../shared/buildContext";
export type BuildOptionsLike = BuildOptionsLike_buildJar & {
export type BuildContextLike = BuildContextLike_buildJar & {
projectDirPath: string;
keycloakifyBuildDirPath: string;
recordIsImplementedByThemeType: BuildContext["recordIsImplementedByThemeType"];
jarTargets: BuildContext["jarTargets"];
doUseAccountV3: boolean;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function buildJars(params: {
buildOptions: BuildOptionsLike;
resourcesDirPath: string;
buildContext: BuildContextLike;
}): Promise<void> {
const { buildOptions } = params;
const { resourcesDirPath, buildContext } = params;
const doesImplementAccountTheme = readMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildOptions.keycloakifyBuildDirPath
}).themes.some(({ name }) => name === accountV1ThemeName);
const doesImplementAccountV1Theme =
buildContext.recordIsImplementedByThemeType.account &&
!buildContext.doUseAccountV3;
await Promise.all(
keycloakAccountV1Versions
.map(keycloakAccountV1Version =>
keycloakThemeAdditionalInfoExtensionVersions
.map(keycloakThemeAdditionalInfoExtensionVersion => {
keycloakThemeAdditionalInfoExtensionVersions.map(
keycloakThemeAdditionalInfoExtensionVersion => {
const keycloakVersionRange = getKeycloakVersionRangeForJar({
doesImplementAccountTheme,
doesImplementAccountV1Theme,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
});
@ -41,39 +42,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,
buildOptions
})
)
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

@ -1,27 +1,27 @@
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import type {
KeycloakAccountV1Version,
KeycloakThemeAdditionalInfoExtensionVersion
} from "./extensionVersions";
export type BuildOptionsLike = {
export type BuildContextLike = {
groupId: string;
artifactId: string;
themeVersion: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function generatePom(params: {
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}) {
const {
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
buildOptions
buildContext
} = params;
const { pomFileCode } = (function generatePomFileCode(): {
@ -33,16 +33,16 @@ export function generatePom(params: {
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
` <modelVersion>4.0.0</modelVersion>`,
` <groupId>${buildOptions.groupId}</groupId>`,
` <artifactId>${buildOptions.artifactId}</artifactId>`,
` <version>${buildOptions.themeVersion}</version>`,
` <name>${buildOptions.artifactId}</name>`,
` <groupId>${buildContext.groupId}</groupId>`,
` <artifactId>${buildContext.artifactId}</artifactId>`,
` <version>${buildContext.themeVersion}</version>`,
` <name>${buildContext.artifactId}</name>`,
` <description />`,
` <packaging>jar</packaging>`,
` <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,534 +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 textarea.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 &&
is_subpath(path, ["applications", "applications"]) &&
(
key == "realm" ||
key == "container"
)
) || (
are_same_path(path, ["user"]) &&
key == "delegateForUpdate"
) || (
<#-- Security audit forwarded by Garth (Gmail) -->
are_same_path(path, ["client", "attributes"]) &&
key == "saml.signing.private.key"
) || (
<#-- See: https://github.com/keycloakify/keycloakify/issues/534 -->
are_same_path(path, ["login"]) &&
key == "password"
) || (
<#-- Remove realmAttributes added by https://github.com/jcputney/keycloak-theme-additional-info-extension for peace of mind. -->
are_same_path(path, []) &&
key == "realmAttributes"
)
>
<#local out_seq += ["/*" + path?join(".") + "." + key + " excluded*/"]>
<#continue>
</#if>
<#-- 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,44 +1,44 @@
import cheerio from "cheerio";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { generateCssCodeToDefineGlobals } from "../replacers/replaceImportsInCssCode";
import { replaceImportsInInlineCssCode } from "../replacers/replaceImportsInInlineCssCode";
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 { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import {
type ThemeType,
nameOfTheGlobal,
basenameOfTheKeycloakifyResourcesDir,
resources_common,
nameOfTheLocalizationRealmOverridesUserProfileProperty
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR,
RESOURCES_COMMON
} from "../../shared/constants";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
export type BuildOptionsLike = {
bundler: "vite" | "webpack";
themeVersion: string;
urlPathname: string | undefined;
reactAppBuildDirPath: string;
assetsDirPath: string;
};
export type BuildContextLike = BuildContextLike_replaceImportsInJsCode &
BuildContextLike_replaceImportsInCssCode & {
urlPathname: string | undefined;
themeVersion: string;
kcContextExclusionsFtlCode: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function generateFtlFilesCodeFactory(params: {
themeName: string;
indexHtmlCode: string;
cssGlobalsToDefine: Record<string, string>;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
keycloakifyVersion: string;
themeType: ThemeType;
fieldNames: string[];
}) {
const {
themeName,
cssGlobalsToDefine,
indexHtmlCode,
buildOptions,
buildContext,
keycloakifyVersion,
themeType,
fieldNames
@ -54,7 +54,7 @@ export function generateFtlFilesCodeFactory(params: {
const { fixedJsCode } = replaceImportsInJsCode({
jsCode,
buildOptions
buildContext
});
$(element).text(fixedJsCode);
@ -65,9 +65,10 @@ export function generateFtlFilesCodeFactory(params: {
assert(cssCode !== null);
const { fixedCssCode } = replaceImportsInInlineCssCode({
const { fixedCssCode } = replaceImportsInCssCode({
cssCode,
buildOptions
cssFileRelativeDirPath: undefined,
buildContext
});
$(element).text(fixedCssCode);
@ -90,32 +91,17 @@ export function generateFtlFilesCodeFactory(params: {
attrName,
href.replace(
new RegExp(
`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`
`^${(buildContext.urlPathname ?? "/").replace(/\//g, "\\/")}`
),
`\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
`\${xKeycloakify.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/`
)
);
})
);
if (Object.keys(cssGlobalsToDefine).length !== 0) {
$("head").prepend(
[
"",
"<style>",
generateCssCodeToDefineGlobals({
cssGlobalsToDefine,
buildOptions
}).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(),
@ -123,29 +109,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}}", RESOURCES_COMMON)
.replace(
"FIELD_NAMES_eKsIY4ZsZ4xeM",
fieldNames.map(name => `"${name}"`).join(", ")
)
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.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
"{{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.
@ -184,8 +167,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,655 @@
<#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"])
)
>
<#-- <#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

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

View File

@ -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,360 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import {
join as pathJoin,
resolve as pathResolve,
relative as pathRelative,
dirname as pathDirname,
basename as pathBasename
} from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import {
generateFtlFilesCodeFactory,
type BuildContextLike as BuildContextLike_kcContextExclusionsFtlCode
} from "../generateFtl";
import {
type ThemeType,
LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1,
KEYCLOAK_RESOURCES,
ACCOUNT_V1_THEME_NAME,
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR,
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS
} from "../../shared/constants";
import type { BuildContext } from "../../shared/buildContext";
import { assert, type Equals } from "tsafe/assert";
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 { 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 { downloadAndExtractArchive } from "../../tools/downloadAndExtractArchive";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_downloadKeycloakStaticResources &
BuildContextLike_bringInAccountV1 & {
extraThemeProperties: string[] | undefined;
loginThemeResourcesFromKeycloakVersion: string;
projectDirPath: string;
projectBuildDirPath: string;
environmentVariables: { name: string; default: string }[];
recordIsImplementedByThemeType: BuildContext["recordIsImplementedByThemeType"];
themeSrcDirPath: string;
bundler: { type: "vite" } | { type: "webpack" };
doUseAccountV3: boolean;
};
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) {
const isAccountV3 = themeType === "account" && buildContext.doUseAccountV3;
if (!buildContext.recordIsImplementedByThemeType[themeType]) {
continue;
}
const themeTypeDirPath = getThemeTypeDirPath({ themeType });
apply_replacers_and_move_to_theme_resources: {
const destDirPath = pathJoin(
themeTypeDirPath,
"resources",
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR
);
// 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.recordIsImplementedByThemeType.login
) {
// NOTE: We prevent doing it twice, it has been done for the login theme.
transformCodebase({
srcDirPath: pathJoin(
getThemeTypeDirPath({
themeType: "login"
}),
"resources",
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR
),
destDirPath
});
break apply_replacers_and_move_to_theme_resources;
}
{
const dirPath = pathJoin(
buildContext.projectBuildDirPath,
KEYCLOAK_RESOURCES
);
if (fs.existsSync(dirPath)) {
assert(buildContext.bundler.type === "webpack");
throw new Error(
[
`Keycloakify build error: The ${KEYCLOAK_RESOURCES} directory shouldn't exist in your build directory.`,
`(${pathRelative(process.cwd(), dirPath)}).\n`,
`Theses assets are only required for local development with Storybook.",
"Please remove this directory as an additional step of your command.\n`,
`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 isAccountV3 ? ["index.ftl"] : ACCOUNT_THEME_PAGE_IDS;
}
})(),
...(isAccountV3
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
);
});
i18n_messages_generation: {
if (isAccountV3) {
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")
);
});
}
keycloak_static_resources: {
if (isAccountV3) {
break keycloak_static_resources;
}
await downloadKeycloakStaticResources({
keycloakVersion: (() => {
switch (themeType) {
case "account":
return LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1;
case "login":
return buildContext.loginThemeResourcesFromKeycloakVersion;
}
})(),
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildContext
});
}
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
return isAccountV3 ? "base" : ACCOUNT_V1_THEME_NAME;
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(isAccountV3 ? ["deprecatedMode=false"] : []),
...(buildContext.extraThemeProperties ?? []),
...buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
)
].join("\n\n"),
"utf8"
)
);
}
email: {
if (!buildContext.recordIsImplementedByThemeType.email) {
break email;
}
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
transformCodebase({
srcDirPath: emailThemeSrcDirPath,
destDirPath: getThemeTypeDirPath({ themeType: "email" })
});
}
bring_in_account_v1: {
if (buildContext.doUseAccountV3) {
break bring_in_account_v1;
}
if (!buildContext.recordIsImplementedByThemeType.account) {
break bring_in_account_v1;
}
await bringInAccountV1({
resourcesDirPath,
buildContext
});
}
bring_in_account_v3_i18n_messages: {
if (!buildContext.doUseAccountV3) {
break bring_in_account_v3_i18n_messages;
}
const { extractedDirPath } = await downloadAndExtractArchive({
url: "https://repo1.maven.org/maven2/org/keycloak/keycloak-account-ui/25.0.1/keycloak-account-ui-25.0.1.jar",
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "bring_in_account_v3_i18n_messages",
onArchiveFile: async ({ fileRelativePath, writeFile }) => {
if (
!fileRelativePath.startsWith(
pathJoin("theme", "keycloak.v3", "account", "messages")
)
) {
return;
}
await writeFile({
fileRelativePath: pathBasename(fileRelativePath)
});
}
});
transformCodebase({
srcDirPath: extractedDirPath,
destDirPath: pathJoin(
getThemeTypeDirPath({ themeType: "account" }),
"messages"
)
});
}
{
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
metaInfKeycloakThemes.themes.push({
name: themeName,
types: objectEntries(buildContext.recordIsImplementedByThemeType)
.filter(([, isImplemented]) => isImplemented)
.map(([themeType]) => themeType)
});
if (buildContext.recordIsImplementedByThemeType.account) {
metaInfKeycloakThemes.themes.push({
name: ACCOUNT_V1_THEME_NAME,
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: {
@ -21,7 +21,7 @@ export function readExtraPagesNames(params: {
}).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
const candidateFilePaths = filePaths.filter(filePath =>
/kcContext\.[^.]+$/.test(filePath)
/[kK]cContext\.[^.]+$/.test(filePath)
);
if (candidateFilePaths.length === 0) {
@ -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,231 +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";
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}=${escapeString(value)}`)
.join("\n");
out.push({
languageTag,
propertiesFileSource: [
"# This file was generated by keycloakify",
"",
"parent=base",
"",
propertiesFileSource,
""
].join("\n")
});
}
return out;
}
// Convert a JavaScript string to UTF-16 encoding
function toUTF16(codePoint: number): string {
if (codePoint <= 0xffff) {
// BMP character
return "\\u" + codePoint.toString(16).padStart(4, "0");
} else {
// Non-BMP character
codePoint -= 0x10000;
let highSurrogate = (codePoint >> 10) + 0xd800;
let lowSurrogate = (codePoint % 0x400) + 0xdc00;
return (
"\\u" +
highSurrogate.toString(16).padStart(4, "0") +
"\\u" +
lowSurrogate.toString(16).padStart(4, "0")
);
}
}
// Escapes special characters for use in a .properties file
function escapeString(str: string): string {
let escapedStr = "";
for (const char of [...str]) {
const codePoint = char.codePointAt(0);
if (!codePoint) continue;
switch (char) {
case "\n":
escapedStr += "\\n";
break;
case "\r":
escapedStr += "\\r";
break;
case "\t":
escapedStr += "\\t";
break;
case "\\":
escapedStr += "\\\\";
break;
case ":":
escapedStr += "\\:";
break;
case "=":
escapedStr += "\\=";
break;
case "#":
escapedStr += "\\#";
break;
case "!":
escapedStr += "\\!";
break;
case "'":
escapedStr += "''";
break;
default:
if (codePoint > 0x7f) {
escapedStr += toUTF16(codePoint); // Non-ASCII characters
} else {
escapedStr += char; // ASCII character needs no escape
}
}
}
return escapedStr;
}

View File

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

View File

@ -1,306 +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 } from "../generateFtl";
import {
type ThemeType,
lastKeycloakVersionWithAccountV1,
keycloak_resources,
accountV1ThemeName,
basenameOfTheKeycloakifyResourcesDir,
loginThemePageIds,
accountThemePageIds
} from "../../shared/constants";
import { isInside } from "../../tools/isInside";
import type { BuildOptions } from "../../shared/buildOptions";
import { assert, type Equals } from "tsafe/assert";
import { downloadKeycloakStaticResources } from "../../shared/downloadKeycloakStaticResources";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import { generateMessageProperties } from "./generateMessageProperties";
import { 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";
export type BuildOptionsLike = {
bundler: "vite" | "webpack";
extraThemeProperties: string[] | undefined;
themeVersion: string;
loginThemeResourcesFromKeycloakVersion: string;
reactAppBuildDirPath: string;
cacheDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
npmWorkspaceRootDirPath: string;
reactAppRootDirPath: string;
keycloakifyBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function generateSrcMainResourcesForMainTheme(params: {
themeName: string;
buildOptions: BuildOptionsLike;
}): Promise<void> {
const { themeName, buildOptions } = params;
const { themeSrcDirPath } = getThemeSrcDirPath({
reactAppRootDirPath: buildOptions.reactAppRootDirPath
});
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => {
const { themeType } = params;
return pathJoin(
buildOptions.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: buildOptions.reactAppBuildDirPath,
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(
buildOptions.reactAppBuildDirPath,
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"),
buildOptions
});
return {
modifiedSourceCode: Buffer.from(fixedJsCode, "utf8")
};
}
return { modifiedSourceCode: sourceCode };
}
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
indexHtmlCode: fs
.readFileSync(pathJoin(buildOptions.reactAppBuildDirPath, "index.html"))
.toString("utf8"),
cssGlobalsToDefine,
buildOptions,
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 buildOptions.loginThemeResourcesFromKeycloakVersion;
}
})(),
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildOptions
});
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);
})()}`,
...(buildOptions.extraThemeProperties ?? [])
].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({
buildOptions
});
}
{
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: buildOptions.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 { BuildOptions } from "../../shared/buildOptions";
import {
readMetaInfKeycloakThemes,
writeMetaInfKeycloakThemes
} from "../../shared/metaInfKeycloakThemes";
import { assert } from "tsafe/assert";
export type BuildOptionsLike = {
keycloakifyBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function generateSrcMainResourcesForThemeVariant(params: {
themeName: string;
themeVariantName: string;
buildOptions: BuildOptionsLike;
}) {
const { themeName, themeVariantName, buildOptions } = params;
const mainThemeDirPath = pathJoin(
buildOptions.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: buildOptions.keycloakifyBuildDirPath
});
updatedMetaInfKeycloakThemes.themes.push({
name: themeVariantName,
types: (() => {
const theme = updatedMetaInfKeycloakThemes.themes.find(
({ name }) => name === themeName
);
assert(theme !== undefined);
return theme.types;
})()
});
writeMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildOptions.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 { BuildOptions } from "../shared/buildOptions";
import { accountV1ThemeName } from "../shared/constants";
export type BuildOptionsLike = {
keycloakifyBuildDirPath: string;
themeNames: string[];
};
assert<BuildOptions extends BuildOptionsLike ? 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;
buildOptions: BuildOptionsLike;
}) {
const { jarFilePath, doesImplementAccountTheme, buildOptions } = params;
const themeRelativeDirPath = pathJoin("src", "main", "resources", "theme");
fs.writeFileSync(
pathJoin(
buildOptions.keycloakifyBuildDirPath,
generateStartKeycloakTestingContainer.basename
),
Buffer.from(
[
"#!/usr/bin/env bash",
"",
`docker rm ${containerName} || true`,
"",
`cd "${buildOptions.keycloakifyBuildDirPath}"`,
"",
"docker run \\",
" -p 8080:8080 \\",
` --name ${containerName} \\`,
" -e KEYCLOAK_ADMIN=admin \\",
" -e KEYCLOAK_ADMIN_PASSWORD=admin \\",
` -v "${pathJoin(
"$(pwd)",
pathRelative(buildOptions.keycloakifyBuildDirPath, jarFilePath)
)}":"/opt/keycloak/providers/${pathBasename(jarFilePath)}" \\`,
[
...(doesImplementAccountTheme ? [accountV1ThemeName] : []),
...buildOptions.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,14 +1,15 @@
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 { readBuildOptions } from "../shared/buildOptions";
import { vitePluginSubScriptEnvNames, skipBuildJarsEnvName } from "../shared/constants";
import { getBuildContext } from "../shared/buildContext";
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 }) {
exit_if_maven_not_installed: {
@ -47,7 +48,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({ cliCommandOptions });
const buildContext = getBuildContext({ cliCommandOptions });
console.log(
[
@ -55,7 +56,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
chalk.green(
`Building the keycloak theme in .${pathSep}${pathRelative(
process.cwd(),
buildOptions.keycloakifyBuildDirPath
buildContext.keycloakifyBuildDirPath
)} ...`
)
].join(" ")
@ -64,44 +65,54 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const startTime = Date.now();
{
if (!fs.existsSync(buildOptions.keycloakifyBuildDirPath)) {
fs.mkdirSync(buildOptions.keycloakifyBuildDirPath, {
if (!fs.existsSync(buildContext.keycloakifyBuildDirPath)) {
fs.mkdirSync(buildContext.keycloakifyBuildDirPath, {
recursive: true
});
}
fs.writeFileSync(
pathJoin(buildOptions.keycloakifyBuildDirPath, ".gitignore"),
pathJoin(buildContext.keycloakifyBuildDirPath, ".gitignore"),
Buffer.from("*", "utf8")
);
}
await generateSrcMainResources({ buildOptions });
const resourcesDirPath = pathJoin(buildContext.keycloakifyBuildDirPath, "resources");
await generateResources({
resourcesDirPath,
buildContext
});
run_post_build_script: {
if (buildOptions.bundler !== "vite") {
if (buildContext.bundler.type !== "vite") {
break run_post_build_script;
}
child_process.execSync("npx vite", {
cwd: buildOptions.reactAppRootDirPath,
cwd: buildContext.projectDirPath,
env: {
...process.env,
[vitePluginSubScriptEnvNames.runPostBuildScript]:
JSON.stringify(buildOptions)
[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({ buildOptions });
}
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,76 +1,53 @@
import * as crypto from "crypto";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "../../shared/constants";
import { assert } from "tsafe/assert";
import { basenameOfTheKeycloakifyResourcesDir } from "../../shared/constants";
import { posix } from "path";
export type BuildOptionsLike = {
export type BuildContextLike = {
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInCssCode(params: { cssCode: string }): {
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)
);
let fixedCssCode = cssCode;
Object.keys(cssGlobalsToDefine).forEach(
cssVariableName =>
//NOTE: split/join pattern ~ replace all
(fixedCssCode = fixedCssCode
.split(cssGlobalsToDefine[cssVariableName])
.join(`var(--${cssVariableName})`))
);
return { fixedCssCode, cssGlobalsToDefine };
}
export function generateCssCodeToDefineGlobals(params: {
cssGlobalsToDefine: Record<string, string>;
buildOptions: BuildOptionsLike;
export function replaceImportsInCssCode(params: {
cssCode: string;
cssFileRelativeDirPath: string | undefined;
buildContext: BuildContextLike;
}): {
cssCodeToPrependInHead: string;
fixedCssCode: string;
} {
const { cssGlobalsToDefine, buildOptions } = params;
const { cssCode, cssFileRelativeDirPath, buildContext } = params;
return {
cssCodeToPrependInHead: [
":root {",
...Object.keys(cssGlobalsToDefine)
.map(cssVariableName =>
[
`--${cssVariableName}:`,
cssGlobalsToDefine[cssVariableName].replace(
new RegExp(
`url\\(${(buildOptions.urlPathname ?? "/").replace(
/\//g,
"\\/"
)}`,
"g"
),
`url(\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
)
].join(" ")
)
.map(line => ` ${line};`),
"}"
].join("\n")
};
const fixedCssCode = cssCode.replace(
/url\(["']?(\/[^/][^)"']+)["']?\)/g,
(match, assetFileAbsoluteUrlPathname) => {
if (buildContext.urlPathname !== undefined) {
if (!assetFileAbsoluteUrlPathname.startsWith(buildContext.urlPathname)) {
// NOTE: Should never happen
return match;
}
assetFileAbsoluteUrlPathname = assetFileAbsoluteUrlPathname.replace(
buildContext.urlPathname,
"/"
);
}
inline_style_in_html: {
if (cssFileRelativeDirPath !== undefined) {
break inline_style_in_html;
}
return `url(\${xKeycloakify.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}${assetFileAbsoluteUrlPathname})`;
}
const assetFileRelativeUrlPathname = posix.relative(
cssFileRelativeDirPath.replace(/\\/g, "/"),
assetFileAbsoluteUrlPathname.replace(/^\//, "")
);
return `url(${assetFileRelativeUrlPathname})`;
}
);
return { fixedCssCode };
}

View File

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

View File

@ -1,38 +1,38 @@
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../../shared/buildOptions";
import type { BuildContext } from "../../../shared/buildContext";
import { replaceImportsInJsCode_vite } from "./vite";
import { replaceImportsInJsCode_webpack } from "./webpack";
import * as fs from "fs";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
export type BuildContextLike = {
projectBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
bundler: "vite" | "webpack";
bundler: { type: "vite" } | { type: "webpack" };
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInJsCode(params: {
jsCode: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}) {
const { jsCode, buildOptions } = params;
const { jsCode, buildContext } = params;
const { fixedJsCode } = (() => {
switch (buildOptions.bundler) {
switch (buildContext.bundler.type) {
case "vite":
return replaceImportsInJsCode_vite({
jsCode,
buildOptions,
buildContext,
basenameOfAssetsFiles: readAssetsDirSync({
assetsDirPath: params.buildOptions.assetsDirPath
assetsDirPath: params.buildContext.assetsDirPath
})
});
case "webpack":
return replaceImportsInJsCode_webpack({
jsCode,
buildOptions
buildContext
});
}
})();

View File

@ -1,23 +1,20 @@
import {
nameOfTheGlobal,
basenameOfTheKeycloakifyResourcesDir
} from "../../../shared/constants";
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "../../../shared/constants";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../../shared/buildOptions";
import type { BuildContext } from "../../../shared/buildContext";
import * as nodePath from "path";
import { replaceAll } from "../../../tools/String.prototype.replaceAll";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
export type BuildContextLike = {
projectBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInJsCode_vite(params: {
jsCode: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
basenameOfAssetsFiles: string[];
systemType?: "posix" | "win32";
}): {
@ -25,7 +22,7 @@ export function replaceImportsInJsCode_vite(params: {
} {
const {
jsCode,
buildOptions,
buildContext,
basenameOfAssetsFiles,
systemType = nodePath.sep === "/" ? "posix" : "win32"
} = params;
@ -34,20 +31,20 @@ export function replaceImportsInJsCode_vite(params: {
let fixedJsCode = jsCode;
replace_base_javacript_import: {
if (buildOptions.urlPathname === undefined) {
break replace_base_javacript_import;
replace_base_js_import: {
if (buildContext.urlPathname === undefined) {
break replace_base_js_import;
}
// Optimization
if (!jsCode.includes(buildOptions.urlPathname)) {
break replace_base_javacript_import;
if (!jsCode.includes(buildContext.urlPathname)) {
break replace_base_js_import;
}
// Replace `Hv=function(e){return"/abcde12345/"+e}` by `Hv=function(e){return"/"+e}`
fixedJsCode = fixedJsCode.replace(
new RegExp(
`([\\w\\$][\\w\\d\\$]*)=function\\(([\\w\\$][\\w\\d\\$]*)\\)\\{return"${replaceAll(
buildOptions.urlPathname,
buildContext.urlPathname,
"/",
"\\/"
)}"\\+\\2\\}`,
@ -62,15 +59,15 @@ export function replaceImportsInJsCode_vite(params: {
// Example: "assets/ or "foo/bar/"
const staticDir = (() => {
let out = pathRelative(
buildOptions.reactAppBuildDirPath,
buildOptions.assetsDirPath
buildContext.projectBuildDirPath,
buildContext.assetsDirPath
);
out = replaceAll(out, pathSep, "/") + "/";
if (out === "/") {
throw new Error(
`The assetsDirPath must be a subdirectory of reactAppBuildDirPath`
`The assetsDirPath must be a subdirectory of projectBuildDirPath`
);
}
@ -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) + "/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/${relativePathOfAssetFile}")`
);
fixedJsCode = replaceAll(
fixedJsCode,
`"${buildOptions.urlPathname ?? "/"}${relativePathOfAssetFile}"`,
`(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
`"${buildContext.urlPathname ?? "/"}${relativePathOfAssetFile}"`,
`(window.kcContext["x-keycloakify"].resourcesPath + "/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/${relativePathOfAssetFile}")`
);
});
}

View File

@ -1,30 +1,27 @@
import {
nameOfTheGlobal,
basenameOfTheKeycloakifyResourcesDir
} from "../../../shared/constants";
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "../../../shared/constants";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../../shared/buildOptions";
import type { BuildContext } from "../../../shared/buildContext";
import * as nodePath from "path";
import { replaceAll } from "../../../tools/String.prototype.replaceAll";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
export type BuildContextLike = {
projectBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInJsCode_webpack(params: {
jsCode: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
systemType?: "posix" | "win32";
}): {
fixedJsCode: string;
} {
const {
jsCode,
buildOptions,
buildContext,
systemType = nodePath.sep === "/" ? "posix" : "win32"
} = params;
@ -32,12 +29,12 @@ export function replaceImportsInJsCode_webpack(params: {
let fixedJsCode = jsCode;
if (buildOptions.urlPathname !== undefined) {
if (buildContext.urlPathname !== undefined) {
// "__esModule",{value:!0})},n.p="/foo-bar/",function(){if("undefined" -> ... n.p="/" ...
fixedJsCode = fixedJsCode.replace(
new RegExp(
`,([a-zA-Z]\\.[a-zA-Z])="${replaceAll(
buildOptions.urlPathname,
buildContext.urlPathname,
"/",
"\\/"
)}",`,
@ -50,15 +47,15 @@ export function replaceImportsInJsCode_webpack(params: {
// Example: "static/ or "foo/bar/"
const staticDir = (() => {
let out = pathRelative(
buildOptions.reactAppBuildDirPath,
buildOptions.assetsDirPath
buildContext.projectBuildDirPath,
buildContext.assetsDirPath
);
out = replaceAll(out, pathSep, "/") + "/";
if (out === "/") {
throw new Error(
`The assetsDirPath must be a subdirectory of reactAppBuildDirPath`
`The assetsDirPath must be a subdirectory of projectBuildDirPath`
);
}
@ -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}/"`
} "/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/${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 + "/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/${staticDir}`
);
return { fixedJsCode };

View File

@ -3,11 +3,14 @@
import { termost } from "termost";
import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
import * as child_process from "child_process";
import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx";
export type CliCommandOptions = {
reactAppRootDirPath: string | undefined;
projectDirPath: string | undefined;
};
assertNoPnpmDlx();
const program = termost<CliCommandOptions>(
{
name: "keycloakify",
@ -25,7 +28,7 @@ const program = termost<CliCommandOptions>(
const optionsKeys: string[] = [];
program.option({
key: "reactAppRootDirPath",
key: "projectDirPath",
name: (() => {
const long = "project";
const short = "p";
@ -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",
@ -162,6 +151,20 @@ program
}
});
program
.command({
name: "add-story",
description: "Add *.stories.tsx file for a specific page to in your Storybook."
})
.task({
skip,
handler: async cliCommandOptions => {
const { command } = await import("./add-story");
await command({ cliCommandOptions });
}
});
program
.command({
name: "initialize-email-theme",
@ -191,6 +194,21 @@ program
}
});
program
.command({
name: "update-kc-gen",
description:
"(Webpack/Create-React-App only) Create/update the kc.gen.ts file in your project."
})
.task({
skip,
handler: async cliCommandOptions => {
const { command } = await import("./update-kc-gen");
await command({ cliCommandOptions });
}
});
// Fallback to build command if no command is provided
{
const [, , ...rest] = process.argv;

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

@ -0,0 +1,877 @@
import { parse as urlParse } from "url";
import {
join as pathJoin,
sep as pathSep,
relative as pathRelative,
resolve as pathResolve,
dirname as pathDirname
} from "path";
import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath";
import type { CliCommandOptions } from "../main";
import { z } from "zod";
import * as fs from "fs";
import { assert, type Equals } from "tsafe/assert";
import * as child_process from "child_process";
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 { objectFromEntries } from "tsafe/objectFromEntries";
import { objectEntries } from "tsafe/objectEntries";
import { type ThemeType } from "./constants";
import { id } from "tsafe/id";
import { symToStr } from "tsafe/symToStr";
import chalk from "chalk";
import { getProxyFetchOptions, type ProxyFetchOptions } from "../tools/fetchProxyOptions";
export type BuildContext = {
themeVersion: 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 */
keycloakifyBuildDirPath: string;
publicDirPath: string;
cacheDirPath: string;
/** If your app is hosted under a subpath, it's the case in CRA if you have "homepage": "https://example.com/my-app" in your package.json
* In this case the urlPathname will be "/my-app/" */
urlPathname: string | undefined;
assetsDirPath: string;
fetchOptions: ProxyFetchOptions;
kcContextExclusionsFtlCode: string | undefined;
environmentVariables: { name: string; default: string }[];
themeSrcDirPath: string;
recordIsImplementedByThemeType: Readonly<Record<ThemeType | "email", boolean>>;
jarTargets: {
keycloakVersionRange: KeycloakVersionRange;
jarFileBasename: string;
}[];
bundler:
| {
type: "vite";
}
| {
type: "webpack";
packageJsonDirPath: string;
packageJsonScripts: Record<string, string>;
};
doUseAccountV3: boolean;
};
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;
/** https://docs.keycloakify.dev/v/v10/targetting-specific-keycloak-versions */
keycloakVersionTargets?: BuildOptions.KeycloakVersionTargets;
doUseAccountV3?: boolean;
};
export namespace BuildOptions {
export type KeycloakVersionTargets =
| ({ hasAccountTheme: true } & Record<
KeycloakVersionRange.WithAccountV1Theme,
string | boolean
>)
| ({ hasAccountTheme: false } & Record<
KeycloakVersionRange.WithoutAccountV1Theme,
string | boolean
>);
}
export type ResolvedViteConfig = {
buildDir: string;
publicDir: string;
assetsDir: string;
urlPathname: string | undefined;
buildOptions: BuildOptions;
};
export function getBuildContext(params: {
cliCommandOptions: CliCommandOptions;
}): BuildContext {
const { cliCommandOptions } = params;
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 };
}
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 } = (() => {
if (
fs
.readdirSync(projectDirPath)
.find(fileBasename => fileBasename.startsWith("vite.config")) ===
undefined
) {
return { resolvedViteConfig: undefined };
}
const output = child_process
.execSync("npx vite", {
cwd: projectDirPath,
env: {
...process.env,
[VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES.RESOLVE_VITE_CONFIG]: "true"
}
})
.toString("utf8");
assert(
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(VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES.RESOLVE_VITE_CONFIG)
.reverse()[0];
const resolvedViteConfig: ResolvedViteConfig = JSON.parse(resolvedViteConfigStr);
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 BuildOptions_packageJson = BuildOptions & {
projectBuildDirPath?: string;
staticDirPathInProjectBuildDirPath?: string;
publicDirPath?: string;
doUseAccountV3?: boolean;
};
type ParsedPackageJson = {
name?: string;
version?: string;
homepage?: string;
keycloakify?: BuildOptions_packageJson;
};
const zParsedPackageJson = z.object({
name: z.string().optional(),
version: z.string().optional(),
homepage: z.string().optional(),
keycloakify: id<z.ZodType<BuildOptions_packageJson>>(
(() => {
const zBuildOptions_packageJson = z.object({
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(),
kcContextExclusionsFtl: z.string().optional(),
environmentVariables: z
.array(
z.object({
name: z.string(),
default: z.string()
})
)
.optional(),
themeName: z.union([z.string(), z.array(z.string())]).optional(),
themeVersion: z.string().optional(),
staticDirPathInProjectBuildDirPath: z.string().optional(),
publicDirPath: z.string().optional(),
keycloakVersionTargets: id<
z.ZodType<BuildOptions.KeycloakVersionTargets>
>(
(() => {
const zKeycloakVersionTargets = z.union([
z.object({
hasAccountTheme: z.literal(true),
"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()])
}),
z.object({
hasAccountTheme: z.literal(false),
"21-and-below": z.union([
z.boolean(),
z.string()
]),
"22-and-above": z.union([z.boolean(), z.string()])
})
]);
{
type Got = z.infer<typeof zKeycloakVersionTargets>;
type Expected = BuildOptions.KeycloakVersionTargets;
assert<Equals<Got, Expected>>();
}
return zKeycloakVersionTargets;
})()
).optional(),
doUseAccountV3: z.boolean().optional()
});
{
type Got = z.infer<typeof zBuildOptions_packageJson>;
type Expected = BuildOptions_packageJson;
assert<Equals<Got, Expected>>();
}
return zBuildOptions_packageJson;
})()
).optional()
});
{
type Got = z.infer<typeof zParsedPackageJson>;
type Expected = ParsedPackageJson;
assert<Equals<Got, Expected>>();
}
const configurationPackageJsonFilePath = (() => {
const rootPackageJsonFilePath = pathJoin(projectDirPath, "package.json");
return fs.existsSync(rootPackageJsonFilePath)
? rootPackageJsonFilePath
: packageJsonFilePath;
})();
return zParsedPackageJson.parse(
JSON.parse(fs.readFileSync(configurationPackageJsonFilePath).toString("utf8"))
);
})();
const buildOptions = {
...parsedPackageJson.keycloakify,
...resolvedViteConfig?.buildOptions
};
const recordIsImplementedByThemeType = objectFromEntries(
(["login", "account", "email"] as const).map(themeType => [
themeType,
fs.existsSync(pathJoin(themeSrcDirPath, themeType))
])
);
const themeNames = ((): [string, ...string[]] => {
if (buildOptions.themeName === undefined) {
return parsedPackageJson.name === undefined
? ["keycloakify"]
: [
parsedPackageJson.name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-")
];
}
if (typeof buildOptions.themeName === "string") {
return [buildOptions.themeName];
}
const [mainThemeName, ...themeVariantNames] = buildOptions.themeName;
assert(mainThemeName !== undefined);
return [mainThemeName, ...themeVariantNames];
})();
const projectBuildDirPath = (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
if (buildOptions.projectBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.projectBuildDirPath,
cwd: projectDirPath
});
}
return pathJoin(projectDirPath, "build");
}
return pathJoin(projectDirPath, resolvedViteConfig.buildDir);
})();
const bundler = resolvedViteConfig !== undefined ? "vite" : "webpack";
const doUseAccountV3 = buildOptions.doUseAccountV3 ?? false;
return {
bundler:
resolvedViteConfig !== undefined
? { type: "vite" }
: (() => {
const { scripts } = z
.object({
scripts: z.record(z.string()).optional()
})
.parse(
JSON.parse(
fs.readFileSync(packageJsonFilePath).toString("utf8")
)
);
return {
type: "webpack",
packageJsonDirPath: pathDirname(packageJsonFilePath),
packageJsonScripts: scripts ?? {}
};
})(),
themeVersion: buildOptions.themeVersion ?? parsedPackageJson.version ?? "0.0.0",
themeNames,
extraThemeProperties: buildOptions.extraThemeProperties,
groupId: (() => {
const fallbackGroupId = `${themeNames[0]}.keycloak`;
return (
process.env.KEYCLOAKIFY_GROUP_ID ??
buildOptions.groupId ??
(parsedPackageJson.homepage === undefined
? fallbackGroupId
: urlParse(parsedPackageJson.homepage)
.host?.replace(/:[0-9]+$/, "")
?.split(".")
.reverse()
.join(".") ?? fallbackGroupId) + ".keycloak"
);
})(),
artifactId:
process.env.KEYCLOAKIFY_ARTIFACT_ID ??
buildOptions.artifactId ??
`${themeNames[0]}-keycloak-theme`,
loginThemeResourcesFromKeycloakVersion:
buildOptions.loginThemeResourcesFromKeycloakVersion ?? "24.0.4",
projectDirPath,
projectBuildDirPath,
keycloakifyBuildDirPath: (() => {
if (buildOptions.keycloakifyBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.keycloakifyBuildDirPath,
cwd: projectDirPath
});
}
return pathJoin(
projectDirPath,
resolvedViteConfig?.buildDir === undefined
? "build_keycloak"
: `${resolvedViteConfig.buildDir}_keycloak`
);
})(),
publicDirPath: (() => {
if (process.env.PUBLIC_DIR_PATH !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: process.env.PUBLIC_DIR_PATH,
cwd: projectDirPath
});
}
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
if (buildOptions.publicDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.publicDirPath,
cwd: projectDirPath
});
}
return pathJoin(projectDirPath, "public");
}
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()
});
}
return pathJoin(
pathDirname(packageJsonFilePath),
"node_modules",
".cache"
);
})(),
"keycloakify"
);
return cacheDirPath;
})(),
urlPathname: (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
const { homepage } = parsedPackageJson;
let url: URL | undefined = undefined;
if (homepage !== undefined) {
url = new URL(homepage);
}
if (url === undefined) {
return undefined;
}
const out = url.pathname.replace(/([^/])$/, "$1/");
return out === "/" ? undefined : out;
}
return resolvedViteConfig.urlPathname;
})(),
assetsDirPath: (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
if (buildOptions.staticDirPathInProjectBuildDirPath !== undefined) {
getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.staticDirPathInProjectBuildDirPath,
cwd: projectBuildDirPath
});
}
return pathJoin(projectBuildDirPath, "static");
}
return pathJoin(projectBuildDirPath, resolvedViteConfig.assetsDir);
})(),
kcContextExclusionsFtlCode: (() => {
if (buildOptions.kcContextExclusionsFtl === undefined) {
return undefined;
}
if (buildOptions.kcContextExclusionsFtl.endsWith(".ftl")) {
const kcContextExclusionsFtlPath = getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.kcContextExclusionsFtl,
cwd: projectDirPath
});
return fs.readFileSync(kcContextExclusionsFtlPath).toString("utf8");
}
return buildOptions.kcContextExclusionsFtl;
})(),
environmentVariables: buildOptions.environmentVariables ?? [],
recordIsImplementedByThemeType,
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 = (() => {
const doesImplementAccountV1Theme =
!doUseAccountV3 && recordIsImplementedByThemeType.account;
if (doesImplementAccountV1Theme) {
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 (!doUseAccountV3 && recordIsImplementedByThemeType.account) {
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;
}
if (
buildOptions.keycloakVersionTargets.hasAccountTheme !== doUseAccountV3
? false
: recordIsImplementedByThemeType.account
) {
console.log(
chalk.red(
(() => {
const { keycloakVersionTargets } = buildOptions;
let message = `Bad ${symToStr({ keycloakVersionTargets })} configuration.\n`;
if (keycloakVersionTargets.hasAccountTheme) {
message +=
"Your codebase does not seem to implement an account theme ";
} else {
message += "Your codebase implements an account theme ";
}
const { hasAccountTheme } = keycloakVersionTargets;
message += `but you have set ${symToStr({ keycloakVersionTargets })}.${symToStr({ hasAccountTheme })}`;
message += ` to ${hasAccountTheme} in your `;
message += (() => {
switch (bundler) {
case "vite":
return "vite.config.ts";
case "webpack":
return "package.json";
}
assert<Equals<typeof bundler, never>>(false);
})();
message += `. Please set it to ${!hasAccountTheme} `;
message +=
"and fill up the relevant keycloak version ranges.\n";
message += "Example:\n";
message += JSON.stringify(
id<Pick<BuildOptions, "keycloakVersionTargets">>({
keycloakVersionTargets: {
hasAccountTheme:
recordIsImplementedByThemeType.account,
...objectFromEntries(
jarTargets_default.map(
({
keycloakVersionRange,
jarFileBasename
}) => [
keycloakVersionRange,
jarFileBasename
]
)
)
}
}),
null,
2
);
message +=
"\nSee: https://docs.keycloakify.dev/v/v10/targetting-specific-keycloak-versions";
return message;
})()
)
);
process.exit(1);
}
const jarTargets: BuildContext["jarTargets"] = [];
const { hasAccountTheme, ...rest } = buildOptions.keycloakVersionTargets;
for (const [keycloakVersionRange, jarNameOrBoolean] of objectEntries(rest)) {
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;
})(),
doUseAccountV3
};
}

View File

@ -1,307 +0,0 @@
import { parse as urlParse } from "url";
import { join as pathJoin } 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 * as child_process from "child_process";
import { vitePluginSubScriptEnvNames } from "./constants";
/** Consolidated build option gathered form CLI arguments and config in package.json */
export type BuildOptions = {
bundler: "vite" | "webpack";
themeVersion: string;
themeNames: string[];
extraThemeProperties: string[] | undefined;
groupId: string;
artifactId: string;
loginThemeResourcesFromKeycloakVersion: string;
reactAppRootDirPath: string;
// TODO: Remove from vite type
reactAppBuildDirPath: string;
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
keycloakifyBuildDirPath: string;
publicDirPath: string;
cacheDirPath: string;
/** If your app is hosted under a subpath, it's the case in CRA if you have "homepage": "https://example.com/my-app" in your package.json
* In this case the urlPathname will be "/my-app/" */
urlPathname: string | undefined;
assetsDirPath: string;
npmWorkspaceRootDirPath: string;
};
export type UserProvidedBuildOptions = {
extraThemeProperties?: string[];
artifactId?: string;
groupId?: string;
loginThemeResourcesFromKeycloakVersion?: string;
keycloakifyBuildDirPath?: string;
themeName?: string | string[];
};
export type ResolvedViteConfig = {
buildDir: string;
publicDir: string;
assetsDir: string;
urlPathname: string | undefined;
userProvidedBuildOptions: UserProvidedBuildOptions;
};
export function readBuildOptions(params: {
cliCommandOptions: CliCommandOptions;
}): BuildOptions {
const { cliCommandOptions } = params;
const reactAppRootDirPath = (() => {
if (cliCommandOptions.reactAppRootDirPath === undefined) {
return process.cwd();
}
return getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.reactAppRootDirPath,
cwd: process.cwd()
});
})();
const { resolvedViteConfig } = (() => {
if (
fs
.readdirSync(reactAppRootDirPath)
.find(fileBasename => fileBasename.startsWith("vite.config")) ===
undefined
) {
return { resolvedViteConfig: undefined };
}
const output = child_process
.execSync("npx vite", {
cwd: reactAppRootDirPath,
env: {
...process.env,
[vitePluginSubScriptEnvNames.resolveViteConfig]: "true"
}
})
.toString("utf8");
assert(
output.includes(vitePluginSubScriptEnvNames.resolveViteConfig),
"Seems like the Keycloakify's Vite plugin is not installed."
);
const resolvedViteConfigStr = output
.split(vitePluginSubScriptEnvNames.resolveViteConfig)
.reverse()[0];
const resolvedViteConfig: ResolvedViteConfig = JSON.parse(resolvedViteConfigStr);
return { resolvedViteConfig };
})();
const parsedPackageJson = (() => {
type ParsedPackageJson = {
name: string;
version?: string;
homepage?: string;
keycloakify?: UserProvidedBuildOptions & {
reactAppBuildDirPath?: string;
};
};
const zParsedPackageJson = z.object({
name: z.string(),
version: z.string().optional(),
homepage: z.string().optional(),
keycloakify: z
.object({
extraThemeProperties: z.array(z.string()).optional(),
artifactId: z.string().optional(),
groupId: z.string().optional(),
loginThemeResourcesFromKeycloakVersion: z.string().optional(),
reactAppBuildDirPath: z.string().optional(),
keycloakifyBuildDirPath: z.string().optional(),
themeName: z.union([z.string(), z.array(z.string())]).optional()
})
.optional()
});
{
type Got = ReturnType<(typeof zParsedPackageJson)["parse"]>;
type Expected = ParsedPackageJson;
assert<Got extends Expected ? true : false>();
assert<Expected extends Got ? true : false>();
}
return zParsedPackageJson.parse(
JSON.parse(
fs
.readFileSync(pathJoin(reactAppRootDirPath, "package.json"))
.toString("utf8")
)
);
})();
const userProvidedBuildOptions: UserProvidedBuildOptions = {
...parsedPackageJson.keycloakify,
...resolvedViteConfig?.userProvidedBuildOptions
};
const themeNames = (() => {
if (userProvidedBuildOptions.themeName === undefined) {
return [
parsedPackageJson.name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-")
];
}
if (typeof userProvidedBuildOptions.themeName === "string") {
return [userProvidedBuildOptions.themeName];
}
return userProvidedBuildOptions.themeName;
})();
const reactAppBuildDirPath = (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
if (parsedPackageJson.keycloakify?.reactAppBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: parsedPackageJson.keycloakify.reactAppBuildDirPath,
cwd: reactAppRootDirPath
});
}
return pathJoin(reactAppRootDirPath, "build");
}
return pathJoin(reactAppRootDirPath, resolvedViteConfig.buildDir);
})();
const { npmWorkspaceRootDirPath } = getNpmWorkspaceRootDirPath({
reactAppRootDirPath,
dependencyExpected: "keycloakify"
});
return {
bundler: resolvedViteConfig !== undefined ? "vite" : "webpack",
themeVersion:
process.env.KEYCLOAKIFY_THEME_VERSION ?? parsedPackageJson.version ?? "0.0.0",
themeNames,
extraThemeProperties: userProvidedBuildOptions.extraThemeProperties,
groupId: (() => {
const fallbackGroupId = `${themeNames[0]}.keycloak`;
return (
process.env.KEYCLOAKIFY_GROUP_ID ??
userProvidedBuildOptions.groupId ??
(parsedPackageJson.homepage === undefined
? fallbackGroupId
: urlParse(parsedPackageJson.homepage)
.host?.replace(/:[0-9]+$/, "")
?.split(".")
.reverse()
.join(".") ?? fallbackGroupId) + ".keycloak"
);
})(),
artifactId:
process.env.KEYCLOAKIFY_ARTIFACT_ID ??
userProvidedBuildOptions.artifactId ??
`${themeNames[0]}-keycloak-theme`,
loginThemeResourcesFromKeycloakVersion:
userProvidedBuildOptions.loginThemeResourcesFromKeycloakVersion ?? "24.0.4",
reactAppRootDirPath,
reactAppBuildDirPath,
keycloakifyBuildDirPath: (() => {
if (userProvidedBuildOptions.keycloakifyBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: userProvidedBuildOptions.keycloakifyBuildDirPath,
cwd: reactAppRootDirPath
});
}
return pathJoin(
reactAppRootDirPath,
resolvedViteConfig?.buildDir === undefined
? "build_keycloak"
: `${resolvedViteConfig.buildDir}_keycloak`
);
})(),
publicDirPath: (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
if (process.env.PUBLIC_DIR_PATH !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: process.env.PUBLIC_DIR_PATH,
cwd: reactAppRootDirPath
});
}
return pathJoin(reactAppRootDirPath, "public");
}
return pathJoin(reactAppRootDirPath, resolvedViteConfig.publicDir);
})(),
cacheDirPath: (() => {
const 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;
})(),
urlPathname: (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
const { homepage } = parsedPackageJson;
let url: URL | undefined = undefined;
if (homepage !== undefined) {
url = new URL(homepage);
}
if (url === undefined) {
return undefined;
}
const out = url.pathname.replace(/([^/])$/, "$1/");
return out === "/" ? undefined : out;
}
return resolvedViteConfig.urlPathname;
})(),
assetsDirPath: (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
return pathJoin(reactAppBuildDirPath, "static");
}
return pathJoin(reactAppBuildDirPath, resolvedViteConfig.assetsDir);
})(),
npmWorkspaceRootDirPath
};
}

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 KEYCLOAK_RESOURCES = "keycloak-resources";
export const RESOURCES_COMMON = "resources-common";
export const LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 = "21.1.2";
export const BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR = "dist";
export const themeTypes = ["login", "account"] as const;
export const accountV1ThemeName = "account-v1";
export const THEME_TYPES = ["login", "account"] as const;
export const ACCOUNT_V1_THEME_NAME = "account-v1";
export type ThemeType = (typeof themeTypes)[number];
export type ThemeType = (typeof THEME_TYPES)[number];
export const vitePluginSubScriptEnvNames = {
runPostBuildScript: "KEYCLOAKIFY_RUN_POST_BUILD_SCRIPT",
resolveViteConfig: "KEYCLOAKIFY_RESOLVE_VITE_CONFIG"
export const VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES = {
RUN_POST_BUILD_SCRIPT: "KEYCLOAKIFY_RUN_POST_BUILD_SCRIPT",
RESOLVE_VITE_CONFIG: "KEYCLOAKIFY_RESOLVE_VITE_CONFIG"
} as const;
export const skipBuildJarsEnvName = "KEYCLOAKIFY_SKIP_BUILD_JAR";
export const BUILD_FOR_KEYCLOAK_MAJOR_VERSION_ENV_NAME =
"KEYCLOAKIFY_BUILD_FOR_KEYCLOAK_MAJOR_VERSION";
export const loginThemePageIds = [
export const LOGIN_THEME_PAGE_IDS = [
"login.ftl",
"login-username.ftl",
"login-password.ftl",
@ -55,7 +53,7 @@ export const loginThemePageIds = [
"webauthn-error.ftl"
] as const;
export const accountThemePageIds = [
export const ACCOUNT_THEME_PAGE_IDS = [
"password.ftl",
"account.ftl",
"sessions.ftl",
@ -65,7 +63,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,32 +1,32 @@
import {
downloadKeycloakStaticResources,
type BuildOptionsLike as BuildOptionsLike_downloadKeycloakStaticResources
type BuildContextLike as BuildContextLike_downloadKeycloakStaticResources
} from "./downloadKeycloakStaticResources";
import { join as pathJoin, relative as pathRelative } from "path";
import {
themeTypes,
keycloak_resources,
lastKeycloakVersionWithAccountV1
THEME_TYPES,
KEYCLOAK_RESOURCES,
LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1
} from "../shared/constants";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import { assert } from "tsafe/assert";
import * as fs from "fs";
import { rmSync } from "../tools/fs.rmSync";
import type { BuildOptions } from "./buildOptions";
import type { BuildContext } from "./buildContext";
export type BuildOptionsLike = BuildOptionsLike_downloadKeycloakStaticResources & {
export type BuildContextLike = BuildContextLike_downloadKeycloakStaticResources & {
loginThemeResourcesFromKeycloakVersion: string;
publicDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function copyKeycloakResourcesToPublic(params: {
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}) {
const { buildOptions } = params;
const { buildContext } = params;
const destDirPath = pathJoin(buildOptions.publicDirPath, keycloak_resources);
const destDirPath = pathJoin(buildContext.publicDirPath, KEYCLOAK_RESOURCES);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
@ -34,13 +34,10 @@ export async function copyKeycloakResourcesToPublic(params: {
{
destDirPath,
keycloakifyVersion: readThisNpmPackageVersion(),
buildOptions: {
buildContext: {
loginThemeResourcesFromKeycloakVersion: readThisNpmPackageVersion(),
cacheDirPath: pathRelative(destDirPath, buildOptions.cacheDirPath),
npmWorkspaceRootDirPath: pathRelative(
destDirPath,
buildOptions.npmWorkspaceRootDirPath
)
cacheDirPath: pathRelative(destDirPath, buildContext.cacheDirPath),
fetchOptions: buildContext.fetchOptions
}
},
null,
@ -69,19 +66,19 @@ export async function copyKeycloakResourcesToPublic(params: {
fs.writeFileSync(pathJoin(destDirPath, ".gitignore"), Buffer.from("*", "utf8"));
for (const themeType of themeTypes) {
for (const themeType of THEME_TYPES) {
await downloadKeycloakStaticResources({
keycloakVersion: (() => {
switch (themeType) {
case "login":
return buildOptions.loginThemeResourcesFromKeycloakVersion;
return buildContext.loginThemeResourcesFromKeycloakVersion;
case "account":
return lastKeycloakVersionWithAccountV1;
return LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1;
}
})(),
themeType,
themeDirPath: destDirPath,
buildOptions
buildContext
});
}

View File

@ -1,44 +1,41 @@
import { join as pathJoin, relative as pathRelative } from "path";
import { type BuildOptions } from "./buildOptions";
import { type BuildContext } from "./buildContext";
import { assert } from "tsafe/assert";
import { lastKeycloakVersionWithAccountV1 } from "./constants";
import { LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 } from "./constants";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
import { isInside } from "../tools/isInside";
export type BuildOptionsLike = {
export type BuildContextLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
fetchOptions: BuildContext["fetchOptions"];
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function downloadKeycloakDefaultTheme(params: {
keycloakVersion: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}): Promise<{ defaultThemeDirPath: string }> {
const { keycloakVersion, buildOptions } = params;
const { keycloakVersion, buildContext } = params;
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: buildOptions.cacheDirPath,
npmWorkspaceRootDirPath: buildOptions.npmWorkspaceRootDirPath,
uniqueIdOfOnOnArchiveFile: "downloadKeycloakDefaultTheme",
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "downloadKeycloakDefaultTheme",
onArchiveFile: async params => {
if (!isInside({ dirPath: "theme", filePath: params.fileRelativePath })) {
const fileRelativePath = pathRelative("theme", params.fileRelativePath);
if (fileRelativePath.startsWith("..")) {
return;
}
const { readFile, writeFile } = params;
const fileRelativePath = pathRelative("theme", params.fileRelativePath);
skip_keycloak_v2: {
if (
!isInside({
dirPath: pathJoin("keycloak.v2"),
filePath: fileRelativePath
})
) {
if (!fileRelativePath.startsWith(pathJoin("keycloak.v2"))) {
break skip_keycloak_v2;
}
@ -46,10 +43,109 @@ export async function downloadKeycloakDefaultTheme(params: {
}
last_account_v1_transformations: {
if (lastKeycloakVersionWithAccountV1 !== keycloakVersion) {
if (LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 !== keycloakVersion) {
break last_account_v1_transformations;
}
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 !==
@ -70,69 +166,6 @@ export async function downloadKeycloakDefaultTheme(params: {
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: {
@ -140,61 +173,118 @@ export async function downloadKeycloakDefaultTheme(params: {
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;
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")
]);
}
const fileRelativeToNodeModulesPath = fileRelativePath.substring(
nodeModulesRelativeDirPath.length + 1
);
if (kcNodeModulesKeepFilePaths.has(fileRelativeToNodeModulesPath)) {
break skip_node_modules;
}
return;
}
for (const dirBasename of ["react", "react-dom"]) {
skip_vendor: {
if (
isInside({
dirPath: pathJoin(
"keycloak",
"common",
"resources",
"vendor",
dirBasename
),
filePath: fileRelativePath
})
!fileRelativePath.startsWith(
pathJoin("keycloak", "common", "resources", "vendor")
)
) {
return;
break skip_vendor;
}
return;
}
if (
isInside({
dirPath: pathJoin(
"keycloak",
"common",
"resources",
"node_modules",
"@patternfly",
"react-core"
),
filePath: fileRelativePath
})
) {
skip_rollup_config: {
if (
fileRelativePath !==
pathJoin("keycloak", "common", "resources", "rollup.config.js")
) {
break skip_rollup_config;
}
return;
}
}

View File

@ -2,28 +2,28 @@ import { transformCodebase } from "../tools/transformCodebase";
import { join as pathJoin } from "path";
import {
downloadKeycloakDefaultTheme,
type BuildOptionsLike as BuildOptionsLike_downloadKeycloakDefaultTheme
type BuildContextLike as BuildContextLike_downloadKeycloakDefaultTheme
} from "./downloadKeycloakDefaultTheme";
import { resources_common, type ThemeType } from "./constants";
import type { BuildOptions } from "./buildOptions";
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 BuildOptionsLike = BuildOptionsLike_downloadKeycloakDefaultTheme & {};
export type BuildContextLike = BuildContextLike_downloadKeycloakDefaultTheme & {};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function downloadKeycloakStaticResources(params: {
themeType: ThemeType;
themeDirPath: string;
keycloakVersion: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}) {
const { themeType, themeDirPath, keycloakVersion, buildOptions } = params;
const { themeType, themeDirPath, keycloakVersion, buildContext } = params;
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildOptions
buildContext
});
const resourcesDirPath = pathJoin(themeDirPath, themeType, "resources");
@ -48,6 +48,6 @@ export async function downloadKeycloakStaticResources(params: {
transformCodebase({
srcDirPath: pathJoin(defaultThemeDirPath, "keycloak", "common", "resources"),
destDirPath: pathJoin(resourcesDirPath, resources_common)
destDirPath: pathJoin(resourcesDirPath, RESOURCES_COMMON)
});
}

View File

@ -0,0 +1,68 @@
import { assert } from "tsafe/assert";
import type { BuildContext } from "./buildContext";
import * as fs from "fs/promises";
import { join as pathJoin } from "path";
import { existsAsync } from "../tools/fs.existsAsync";
export type BuildContextLike = {
projectDirPath: string;
themeNames: string[];
environmentVariables: { name: string; default: string }[];
themeSrcDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateKcGenTs(params: {
buildContext: BuildContextLike;
}): Promise<void> {
const { buildContext } = params;
const filePath = pathJoin(buildContext.themeSrcDirPath, "kc.gen.ts");
const currentContent = (await existsAsync(filePath))
? await fs.readFile(filePath)
: undefined;
const newContent = Buffer.from(
[
`/* prettier-ignore-start */`,
``,
`/* eslint-disable */`,
``,
`// @ts-nocheck`,
``,
`// noinspection JSUnusedGlobalSymbols`,
``,
`// This file is auto-generated by Keycloakify`,
``,
`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 kcEnvDefaults: Record<KcEnvName, string> = ${JSON.stringify(
Object.fromEntries(
buildContext.environmentVariables.map(
({ name, default: defaultValue }) => [name, defaultValue]
)
),
null,
2
)};`,
``,
`/* prettier-ignore-end */`,
``
].join("\n"),
"utf8"
);
if (currentContent !== undefined && currentContent.equals(newContent)) {
return;
}
await fs.writeFile(filePath, newContent);
}

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

@ -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: { reactAppRootDirPath: string }) {
const { reactAppRootDirPath } = params;
const srcDirPath = pathJoin(reactAppRootDirPath, "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

@ -9,9 +9,10 @@ import { id } from "tsafe/id";
export async function promptKeycloakVersion(params: {
startingFromMajor: number | undefined;
excludeMajorVersions: number[];
cacheDirPath: string;
}) {
const { startingFromMajor, cacheDirPath } = params;
const { startingFromMajor, excludeMajorVersions, cacheDirPath } = params;
const { getLatestsSemVersionedTag } = (() => {
const { octokit } = (() => {
@ -95,6 +96,10 @@ export async function promptKeycloakVersion(params: {
return;
}
if (excludeMajorVersions.includes(semVersionedTag.version.major)) {
return;
}
const currentSemVersionedTag = semVersionedTagByMajor.get(
semVersionedTag.version.major
);

View File

@ -1,115 +1,48 @@
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 { BuildOptions } from "../shared/buildOptions";
import * as fs from "fs";
import { join as pathJoin } from "path";
import type { BuildContext } from "../shared/buildContext";
import chalk from "chalk";
import { sep as pathSep, join as pathJoin } from "path";
import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath";
export type BuildOptionsLike = {
reactAppRootDirPath: string;
export type BuildContextLike = {
projectDirPath: string;
keycloakifyBuildDirPath: string;
bundler: "vite" | "webpack";
npmWorkspaceRootDirPath: string;
reactAppBuildDirPath: string;
bundler: BuildContext["bundler"];
projectBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function appBuild(params: {
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}): Promise<{ isAppBuildSuccess: boolean }> {
const { buildOptions } = params;
const { buildContext } = params;
const { bundler } = buildOptions;
switch (buildContext.bundler.type) {
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: buildOptions.reactAppRootDirPath
};
case "webpack": {
for (const dirPath of [
buildOptions.reactAppRootDirPath,
buildOptions.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.type === "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("Running: '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 +54,127 @@ 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.type === "webpack");
const entries = Object.entries(buildContext.bundler.packageJsonScripts).filter(
([, scriptCommand]) => scriptCommand.includes("keycloakify build")
);
if (entries.length === 0) {
console.log(
chalk.red(
[
`You should have a script in your package.json at ${buildContext.bundler.packageJsonDirPath}`,
`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 = buildContext.bundler.packageJsonDirPath;
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(`Running: '${subCommand}'`));
const child = child_process.spawn(command, args, {
cwd: commandCwd,
env: {
...process.env,
PATH: (() => {
const separator = pathSep === "/" ? ":" : ";";
return [
pathJoin(
buildContext.bundler.packageJsonDirPath,
"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 { BuildOptions } from "../shared/buildOptions";
import type { BuildContext } from "../shared/buildContext";
import chalk from "chalk";
export type BuildOptionsLike = {
reactAppRootDirPath: string;
export type BuildContextLike = {
projectDirPath: string;
keycloakifyBuildDirPath: string;
bundler: "vite" | "webpack";
npmWorkspaceRootDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function keycloakifyBuild(params: {
doSkipBuildJars: boolean;
buildOptions: BuildOptionsLike;
buildForKeycloakMajorVersionNumber: number;
buildContext: BuildContextLike;
}): Promise<{ isKeycloakifyBuildSuccess: boolean }> {
const { buildOptions, doSkipBuildJars } = params;
const { buildForKeycloakMajorVersionNumber, buildContext } = params;
const dResult = new Deferred<{ isSuccess: boolean }>();
console.log(chalk.blue("Running: 'npx keycloakify build'"));
const child = child_process.spawn("npx", ["keycloakify", "build"], {
cwd: buildOptions.reactAppRootDirPath,
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));

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,8 @@
{
"id": "34c5f904-d66e-4d8f-8876-8f00d9fa9d6c",
"realm": "myrealm",
"displayName": "",
"displayNameHtml": "",
"notBefore": 0,
"defaultSignatureAlgorithm": "RS256",
"revokeRefreshToken": false,
@ -587,7 +589,9 @@
"publicClient": true,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@ -619,7 +623,9 @@
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@ -695,7 +701,9 @@
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@ -783,6 +791,7 @@
"config": {
"introspection.token.claim": "true",
"multivalued": "true",
"userinfo.token.claim": "true",
"user.attribute": "foo",
"id.token.claim": "true",
"access.token.claim": "true",
@ -827,7 +836,8 @@
"config": {
"id.token.claim": "true",
"introspection.token.claim": "true",
"access.token.claim": "true"
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
@ -1348,11 +1358,11 @@
"config": {
"allowed-protocol-mapper-types": [
"oidc-sha256-pairwise-sub-mapper",
"oidc-full-name-mapper",
"oidc-usermodel-attribute-mapper",
"saml-user-property-mapper",
"oidc-address-mapper",
"oidc-full-name-mapper",
"saml-role-list-mapper",
"oidc-usermodel-attribute-mapper",
"saml-user-attribute-mapper",
"oidc-usermodel-property-mapper"
]
@ -1423,12 +1433,12 @@
"subComponents": {},
"config": {
"allowed-protocol-mapper-types": [
"saml-role-list-mapper",
"oidc-full-name-mapper",
"oidc-address-mapper",
"saml-user-attribute-mapper",
"oidc-sha256-pairwise-sub-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-address-mapper",
"saml-role-list-mapper",
"oidc-full-name-mapper",
"saml-user-property-mapper",
"oidc-usermodel-property-mapper"
]
@ -2043,7 +2053,7 @@
"name": "Terms and Conditions",
"providerId": "TERMS_AND_CONDITIONS",
"enabled": true,
"defaultAction": false,
"defaultAction": true,
"priority": 20,
"config": {}
},
@ -2119,17 +2129,20 @@
"dockerAuthenticationFlow": "docker auth",
"attributes": {
"cibaBackchannelTokenDeliveryMode": "poll",
"cibaExpiresIn": "120",
"cibaAuthRequestedUserHint": "login_hint",
"oauth2DeviceCodeLifespan": "600",
"oauth2DevicePollingInterval": "5",
"clientOfflineSessionMaxLifespan": "0",
"oauth2DevicePollingInterval": "5",
"clientSessionIdleTimeout": "0",
"parRequestUriLifespan": "60",
"clientSessionMaxLifespan": "0",
"userProfileEnabled": "true",
"clientOfflineSessionIdleTimeout": "0",
"cibaInterval": "5",
"realmReusableOtpCode": "false"
"realmReusableOtpCode": "false",
"cibaExpiresIn": "120",
"oauth2DeviceCodeLifespan": "600",
"parRequestUriLifespan": "60",
"clientSessionMaxLifespan": "0",
"frontendUrl": "",
"acr.loa.map": "{}"
},
"keycloakVersion": "23.0.7",
"userManagedAccessAllowed": false,

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