Compare commits

..

219 Commits

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

View File

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

View File

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

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

33
.storybook/customTheme.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

285
README.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,30 +1,26 @@
import * as child_process from "child_process"; import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange"; import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
import { copyKeycloakResourcesToStorybookStaticDir } from "./copyKeycloakResourcesToStorybookStaticDir";
run("yarn build"); (async () => {
run("yarn build");
run(`node ${join("dist", "bin", "main.js")} copy-keycloak-resources-to-public`, { await copyKeycloakResourcesToStorybookStaticDir();
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();
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 }) { function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`); console.log(`$ ${command}`);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,26 +1,29 @@
import cheerio from "cheerio"; import * as cheerio from "cheerio";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode"; import {
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode"; replaceImportsInJsCode,
BuildContextLike as BuildContextLike_replaceImportsInJsCode
} from "../replacers/replaceImportsInJsCode";
import {
replaceImportsInCssCode,
BuildContextLike as BuildContextLike_replaceImportsInCssCode
} from "../replacers/replaceImportsInCssCode";
import * as fs from "fs"; import * as fs from "fs";
import { join as pathJoin } from "path"; import { join as pathJoin } from "path";
import type { BuildContext } from "../../shared/buildContext"; import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import { import {
type ThemeType, type ThemeType,
basenameOfTheKeycloakifyResourcesDir, BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR,
resources_common, RESOURCES_COMMON
nameOfTheLocalizationRealmOverridesUserProfileProperty
} from "../../shared/constants"; } from "../../shared/constants";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath"; import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
export type BuildContextLike = { export type BuildContextLike = BuildContextLike_replaceImportsInJsCode &
bundler: "vite" | "webpack"; BuildContextLike_replaceImportsInCssCode & {
themeVersion: string; urlPathname: string | undefined;
urlPathname: string | undefined; themeVersion: string;
projectBuildDirPath: string; kcContextExclusionsFtlCode: string | undefined;
assetsDirPath: string; };
kcContextExclusionsFtlCode: string | undefined;
};
assert<BuildContext extends BuildContextLike ? true : false>(); assert<BuildContext extends BuildContextLike ? true : false>();
@ -74,7 +77,8 @@ export function generateFtlFilesCodeFactory(params: {
( (
[ [
["link", "href"], ["link", "href"],
["script", "src"] ["script", "src"],
["script", "data-src"]
] as const ] as const
).forEach(([selector, attrName]) => ).forEach(([selector, attrName]) =>
$(selector).each((...[, element]) => { $(selector).each((...[, element]) => {
@ -90,7 +94,7 @@ export function generateFtlFilesCodeFactory(params: {
new RegExp( new RegExp(
`^${(buildContext.urlPathname ?? "/").replace(/\//g, "\\/")}` `^${(buildContext.urlPathname ?? "/").replace(/\//g, "\\/")}`
), ),
`\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/` `\${xKeycloakify.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/`
) )
); );
}) })
@ -110,23 +114,17 @@ export function generateFtlFilesCodeFactory(params: {
) )
) )
.toString("utf8") .toString("utf8")
.replace("{{themeType}}", themeType)
.replace("{{themeName}}", themeName)
.replace("{{keycloakifyVersion}}", keycloakifyVersion)
.replace("{{themeVersion}}", buildContext.themeVersion)
.replace("{{fieldNames}}", fieldNames.map(name => `"${name}"`).join(", "))
.replace("{{RESOURCES_COMMON}}", RESOURCES_COMMON)
.replace( .replace(
"FIELD_NAMES_eKsIY4ZsZ4xeM", "{{userDefinedExclusions}}",
fieldNames.map(name => `"${name}"`).join(", ")
)
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildContext.themeVersion)
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", themeName)
.replace("RESOURCES_COMMON_cLsLsMrtDkpVv", resources_common)
.replace(
"lOCALIZATION_REALM_OVERRIDES_USER_PROFILE_PROPERTY_KEY_aaGLsPgGIdeeX",
nameOfTheLocalizationRealmOverridesUserProfileProperty
)
.replace(
"USER_DEFINED_EXCLUSIONS_eKsaY4ZsZ4eMr2",
buildContext.kcContextExclusionsFtlCode ?? "" buildContext.kcContextExclusionsFtlCode ?? ""
); );
const ftlObjectToJsCodeDeclaringAnObjectPlaceholder = const ftlObjectToJsCodeDeclaringAnObjectPlaceholder =
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }'; '{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }';
@ -171,7 +169,8 @@ export function generateFtlFilesCodeFactory(params: {
Object.entries({ Object.entries({
[ftlObjectToJsCodeDeclaringAnObjectPlaceholder]: [ftlObjectToJsCodeDeclaringAnObjectPlaceholder]:
kcContextDeclarationTemplateFtl, kcContextDeclarationTemplateFtl,
PAGE_ID_xIgLsPgGId9D8e: pageId "{{pageId}}": pageId,
"{{ftlTemplateFileName}}": pageId
}).map( }).map(
([searchValue, replaceValue]) => ([searchValue, replaceValue]) =>
(ftlCode = ftlCode.replace(searchValue, replaceValue)) (ftlCode = ftlCode.replace(searchValue, replaceValue))

View File

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

View File

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

View File

@ -4,7 +4,8 @@ import {
join as pathJoin, join as pathJoin,
resolve as pathResolve, resolve as pathResolve,
relative as pathRelative, relative as pathRelative,
dirname as pathDirname dirname as pathDirname,
basename as pathBasename
} from "path"; } from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode"; import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode"; import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
@ -14,12 +15,12 @@ import {
} from "../generateFtl"; } from "../generateFtl";
import { import {
type ThemeType, type ThemeType,
lastKeycloakVersionWithAccountV1, LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1,
keycloak_resources, KEYCLOAK_RESOURCES,
accountV1ThemeName, ACCOUNT_V1_THEME_NAME,
basenameOfTheKeycloakifyResourcesDir, BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR,
loginThemePageIds, LOGIN_THEME_PAGE_IDS,
accountThemePageIds ACCOUNT_THEME_PAGE_IDS
} from "../../shared/constants"; } from "../../shared/constants";
import type { BuildContext } from "../../shared/buildContext"; import type { BuildContext } from "../../shared/buildContext";
import { assert, type Equals } from "tsafe/assert"; import { assert, type Equals } from "tsafe/assert";
@ -42,6 +43,7 @@ import {
} from "../../shared/metaInfKeycloakThemes"; } from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries"; import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile"; import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import { downloadAndExtractArchive } from "../../tools/downloadAndExtractArchive";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode & export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_downloadKeycloakStaticResources & BuildContextLike_downloadKeycloakStaticResources &
@ -51,8 +53,9 @@ export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
projectDirPath: string; projectDirPath: string;
projectBuildDirPath: string; projectBuildDirPath: string;
environmentVariables: { name: string; default: string }[]; environmentVariables: { name: string; default: string }[];
recordIsImplementedByThemeType: BuildContext["recordIsImplementedByThemeType"]; implementedThemeTypes: BuildContext["implementedThemeTypes"];
themeSrcDirPath: string; themeSrcDirPath: string;
bundler: "vite" | "webpack";
}; };
assert<BuildContext extends BuildContextLike ? true : false>(); assert<BuildContext extends BuildContextLike ? true : false>();
@ -70,17 +73,22 @@ export async function generateResourcesForMainTheme(params: {
}; };
for (const themeType of ["login", "account"] as const) { for (const themeType of ["login", "account"] as const) {
if (!buildContext.recordIsImplementedByThemeType[themeType]) { if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
continue; continue;
} }
const isForAccountSpa =
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page");
const themeTypeDirPath = getThemeTypeDirPath({ themeType }); const themeTypeDirPath = getThemeTypeDirPath({ themeType });
apply_replacers_and_move_to_theme_resources: { apply_replacers_and_move_to_theme_resources: {
const destDirPath = pathJoin( const destDirPath = pathJoin(
themeTypeDirPath, themeTypeDirPath,
"resources", "resources",
basenameOfTheKeycloakifyResourcesDir BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR
); );
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up. // NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
@ -88,7 +96,7 @@ export async function generateResourcesForMainTheme(params: {
if ( if (
themeType === "account" && themeType === "account" &&
buildContext.recordIsImplementedByThemeType.login buildContext.implementedThemeTypes.login.isImplemented
) { ) {
// NOTE: We prevent doing it twice, it has been done for the login theme. // NOTE: We prevent doing it twice, it has been done for the login theme.
@ -98,7 +106,7 @@ export async function generateResourcesForMainTheme(params: {
themeType: "login" themeType: "login"
}), }),
"resources", "resources",
basenameOfTheKeycloakifyResourcesDir BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR
), ),
destDirPath destDirPath
}); });
@ -109,7 +117,7 @@ export async function generateResourcesForMainTheme(params: {
{ {
const dirPath = pathJoin( const dirPath = pathJoin(
buildContext.projectBuildDirPath, buildContext.projectBuildDirPath,
keycloak_resources KEYCLOAK_RESOURCES
); );
if (fs.existsSync(dirPath)) { if (fs.existsSync(dirPath)) {
@ -117,7 +125,7 @@ export async function generateResourcesForMainTheme(params: {
throw new Error( throw new Error(
[ [
`Keycloakify build error: The ${keycloak_resources} directory shouldn't exist in your build directory.`, `Keycloakify build error: The ${KEYCLOAK_RESOURCES} directory shouldn't exist in your build directory.`,
`(${pathRelative(process.cwd(), dirPath)}).\n`, `(${pathRelative(process.cwd(), dirPath)}).\n`,
`Theses assets are only required for local development with Storybook.", `Theses assets are only required for local development with Storybook.",
"Please remove this directory as an additional step of your command.\n`, "Please remove this directory as an additional step of your command.\n`,
@ -177,15 +185,17 @@ export async function generateResourcesForMainTheme(params: {
...(() => { ...(() => {
switch (themeType) { switch (themeType) {
case "login": case "login":
return loginThemePageIds; return LOGIN_THEME_PAGE_IDS;
case "account": case "account":
return accountThemePageIds; return isForAccountSpa ? ["index.ftl"] : ACCOUNT_THEME_PAGE_IDS;
} }
})(), })(),
...readExtraPagesNames({ ...(isForAccountSpa
themeType, ? []
themeSrcDirPath: buildContext.themeSrcDirPath : readExtraPagesNames({
}) themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => { ].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId }); const { ftlCode } = generateFtlFilesCode({ pageId });
@ -195,40 +205,52 @@ export async function generateResourcesForMainTheme(params: {
); );
}); });
generateMessageProperties({ i18n_messages_generation: {
themeSrcDirPath: buildContext.themeSrcDirPath, if (isForAccountSpa) {
themeType break i18n_messages_generation;
}).forEach(({ languageTag, propertiesFileSource }) => { }
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), { generateMessageProperties({
recursive: true themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), {
recursive: true
});
const propertiesFilePath = pathJoin(
messagesDirPath,
`messages_${languageTag}.properties`
);
fs.writeFileSync(
propertiesFilePath,
Buffer.from(propertiesFileSource, "utf8")
);
}); });
}
const propertiesFilePath = pathJoin( keycloak_static_resources: {
messagesDirPath, if (isForAccountSpa) {
`messages_${languageTag}.properties` break keycloak_static_resources;
); }
fs.writeFileSync( await downloadKeycloakStaticResources({
propertiesFilePath, keycloakVersion: (() => {
Buffer.from(propertiesFileSource, "utf8") switch (themeType) {
); case "account":
}); return LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1;
case "login":
await downloadKeycloakStaticResources({ return buildContext.loginThemeResourcesFromKeycloakVersion;
keycloakVersion: (() => { }
switch (themeType) { })(),
case "account": themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
return lastKeycloakVersionWithAccountV1; themeType,
case "login": buildContext
return buildContext.loginThemeResourcesFromKeycloakVersion; });
} }
})(),
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildContext
});
fs.writeFileSync( fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"), pathJoin(themeTypeDirPath, "theme.properties"),
@ -237,12 +259,13 @@ export async function generateResourcesForMainTheme(params: {
`parent=${(() => { `parent=${(() => {
switch (themeType) { switch (themeType) {
case "account": case "account":
return accountV1ThemeName; return isForAccountSpa ? "base" : ACCOUNT_V1_THEME_NAME;
case "login": case "login":
return "keycloak"; return "keycloak";
} }
assert<Equals<typeof themeType, never>>(false); assert<Equals<typeof themeType, never>>(false);
})()}`, })()}`,
...(isForAccountSpa ? ["deprecatedMode=false"] : []),
...(buildContext.extraThemeProperties ?? []), ...(buildContext.extraThemeProperties ?? []),
...buildContext.environmentVariables.map( ...buildContext.environmentVariables.map(
({ name, default: defaultValue }) => ({ name, default: defaultValue }) =>
@ -255,7 +278,7 @@ export async function generateResourcesForMainTheme(params: {
} }
email: { email: {
if (!buildContext.recordIsImplementedByThemeType.email) { if (!buildContext.implementedThemeTypes.email.isImplemented) {
break email; break email;
} }
@ -267,26 +290,71 @@ export async function generateResourcesForMainTheme(params: {
}); });
} }
if (buildContext.recordIsImplementedByThemeType.account) { bring_in_account_v1: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v1;
}
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
break bring_in_account_v1;
}
await bringInAccountV1({ await bringInAccountV1({
resourcesDirPath, resourcesDirPath,
buildContext buildContext
}); });
} }
bring_in_account_v3_i18n_messages: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v3_i18n_messages;
}
if (buildContext.implementedThemeTypes.account.type !== "Single-Page") {
break bring_in_account_v3_i18n_messages;
}
const { extractedDirPath } = await downloadAndExtractArchive({
urlOrPath:
"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: [] }; const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
metaInfKeycloakThemes.themes.push({ metaInfKeycloakThemes.themes.push({
name: themeName, name: themeName,
types: objectEntries(buildContext.recordIsImplementedByThemeType) types: objectEntries(buildContext.implementedThemeTypes)
.filter(([, isImplemented]) => isImplemented) .filter(([, { isImplemented }]) => isImplemented)
.map(([themeType]) => themeType) .map(([themeType]) => themeType)
}); });
if (buildContext.recordIsImplementedByThemeType.account) { if (buildContext.implementedThemeTypes.account.isImplemented) {
metaInfKeycloakThemes.themes.push({ metaInfKeycloakThemes.themes.push({
name: accountV1ThemeName, name: ACCOUNT_V1_THEME_NAME,
types: ["account"] types: ["account"]
}); });
} }

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import type { BuildContext } from "../../shared/buildContext"; import type { BuildContext } from "../../shared/buildContext";
import { basenameOfTheKeycloakifyResourcesDir } from "../../shared/constants"; import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "../../shared/constants";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import { posix } from "path"; import { posix } from "path";
@ -18,35 +18,49 @@ export function replaceImportsInCssCode(params: {
} { } {
const { cssCode, cssFileRelativeDirPath, buildContext } = params; const { cssCode, cssFileRelativeDirPath, buildContext } = params;
const fixedCssCode = cssCode.replace( let fixedCssCode = cssCode;
/url\(["']?(\/[^/][^)"']+)["']?\)/g,
(match, assetFileAbsoluteUrlPathname) => { [
if (buildContext.urlPathname !== undefined) { /url\("(\/[^/][^"]+)"\)/g,
if (!assetFileAbsoluteUrlPathname.startsWith(buildContext.urlPathname)) { /url\('(\/[^/][^']+)'\)/g,
// NOTE: Should never happen /url\((\/[^/][^)]+)\)/g
return match; ].forEach(
regex =>
(fixedCssCode = fixedCssCode.replace(
regex,
(match, assetFileAbsoluteUrlPathname) => {
if (buildContext.urlPathname !== undefined) {
if (
!assetFileAbsoluteUrlPathname.startsWith(
buildContext.urlPathname
)
) {
// NOTE: Should never happen
return match;
}
assetFileAbsoluteUrlPathname =
assetFileAbsoluteUrlPathname.replace(
buildContext.urlPathname,
"/"
);
}
inline_style_in_html: {
if (cssFileRelativeDirPath !== undefined) {
break inline_style_in_html;
}
return `url("\${xKeycloakify.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}${assetFileAbsoluteUrlPathname}")`;
}
const assetFileRelativeUrlPathname = posix.relative(
cssFileRelativeDirPath.replace(/\\/g, "/"),
assetFileAbsoluteUrlPathname.replace(/^\//, "")
);
return `url("${assetFileRelativeUrlPathname}")`;
} }
assetFileAbsoluteUrlPathname = assetFileAbsoluteUrlPathname.replace( ))
buildContext.urlPathname,
"/"
);
}
inline_style_in_html: {
if (cssFileRelativeDirPath !== undefined) {
break inline_style_in_html;
}
return `url(\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}${assetFileAbsoluteUrlPathname})`;
}
const assetFileRelativeUrlPathname = posix.relative(
cssFileRelativeDirPath.replace(/\\/g, "/"),
assetFileAbsoluteUrlPathname.replace(/^\//, "")
);
return `url(${assetFileRelativeUrlPathname})`;
}
); );
return { fixedCssCode }; return { fixedCssCode };

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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