Compare commits
575 Commits
v7.11.1
...
v10.0.0-rc
Author | SHA1 | Date | |
---|---|---|---|
d92b89ae31 | |||
c99d9ccbdd | |||
3dded16f0a | |||
f59ee55be5 | |||
bc549af64c | |||
39add772f7 | |||
f19e622d39 | |||
7eb13db467 | |||
ef503e271d | |||
7d24e2716f | |||
9bbcc21f9c | |||
fcfabb0c3f | |||
9ca3cadd10 | |||
f156fec1c3 | |||
e962b37948 | |||
3a8f1a0ed1 | |||
e3a7bb13f5 | |||
29b45497ba | |||
a748e8d8ec | |||
f2fcb553a5 | |||
a9dc11c60d | |||
ee9df31b18 | |||
69d1e86a8a | |||
06761807a3 | |||
a6c1e9bb61 | |||
b70dfe96f6 | |||
5f2b1484b5 | |||
d4595c999f | |||
373850e32a | |||
6013781594 | |||
1712d7e2f3 | |||
970572c441 | |||
38a9779a2f | |||
988b96825f | |||
10bbc8ae74 | |||
434b03e070 | |||
9d4543a611 | |||
b675ce7142 | |||
04461cd660 | |||
9b8952336b | |||
d15ae225b1 | |||
87ed9884ff | |||
9bbfac0896 | |||
6a487b11ce | |||
e00ab03c19 | |||
6fff769636 | |||
7904627653 | |||
7a2d7e5a9f | |||
31940102f2 | |||
2c36bfe3bb | |||
3af2eae618 | |||
a362b0fe2c | |||
891d190787 | |||
f228e50443 | |||
898a82cae1 | |||
9512e5dab1 | |||
dade9a7460 | |||
060b1ea8b8 | |||
834ae4e45b | |||
18c82a58a7 | |||
632641a067 | |||
fa051e4665 | |||
259dbbae36 | |||
87d9eb34a2 | |||
f9c55f5a43 | |||
aa880219c1 | |||
01899c034b | |||
4f930a9fba | |||
4a0ca9ff3b | |||
9cb6b73607 | |||
695cdd5c63 | |||
c2fd92f516 | |||
1911763fcb | |||
784bc71416 | |||
d3e065591b | |||
1d87e8fe8b | |||
f8bf54835d | |||
9e21b5cb93 | |||
771d6328af | |||
1a145a49ed | |||
de2d4ac497 | |||
4baeca58de | |||
0d36ddd6d3 | |||
ef3c190747 | |||
20565038f5 | |||
d6a302f6a3 | |||
70589b6442 | |||
027c8f38d8 | |||
a6f7f8ff49 | |||
fa24fb41a1 | |||
b1bec4a343 | |||
03e728fe04 | |||
58580555a4 | |||
30362df078 | |||
a70c651a11 | |||
d902859b00 | |||
3abb32ec82 | |||
081376cdd3 | |||
25621182c9 | |||
c2b990ac53 | |||
bcb70a1851 | |||
879e376bd4 | |||
4793d6dd23 | |||
4794e35989 | |||
7f55bb5ce3 | |||
aab1b7d490 | |||
7d8db7f48c | |||
12225c1265 | |||
f81ef406fb | |||
3770ec5f0d | |||
52a6edc9ca | |||
6846a683b0 | |||
98a0055490 | |||
a612fce78b | |||
fc040d8302 | |||
2b0febce19 | |||
d143e2d3ad | |||
0b72724cb4 | |||
9e0e8acda0 | |||
998f18a3ce | |||
500f558658 | |||
49f46c758a | |||
83548afa0a | |||
cf42f5b2a0 | |||
dfdc90b686 | |||
2e9d2b8bd2 | |||
95c27dd97e | |||
b871c3ecc3 | |||
82ffa801d6 | |||
b42bf24935 | |||
585c279d10 | |||
51bc65e671 | |||
ff1758cdce | |||
9909971316 | |||
05bd0885af | |||
b4abe5a22e | |||
c5c54cb807 | |||
51ec342f6f | |||
8d1c19bf1c | |||
72a3c37e84 | |||
c99cdf5566 | |||
ad339710f1 | |||
00d2d12056 | |||
6a7b472c0e | |||
6991d868be | |||
85cc665d17 | |||
5bb22fc345 | |||
5417dc1bed | |||
ee20d33724 | |||
7887bd2b67 | |||
168582efea | |||
2c55d13f91 | |||
23e5f553d4 | |||
bc44eadcec | |||
a3e3136600 | |||
c7bfcee8d2 | |||
12ebd19716 | |||
aec9ffa5db | |||
2e6321342e | |||
6e71da62f0 | |||
5bf33aae75 | |||
06b2dc63ff | |||
1bb0c9dfc2 | |||
a2b167e120 | |||
0909a4b7cc | |||
fd7d2bb9bf | |||
63c40fd816 | |||
0569fa5e58 | |||
ba74952e0b | |||
20c28f785a | |||
e9b249ddc7 | |||
604bb484a3 | |||
010c93793a | |||
dc1d4a66f4 | |||
8ef633d7ef | |||
2176d33da1 | |||
5b794e2d22 | |||
ccd75d56c5 | |||
b700066833 | |||
546ee006d3 | |||
7f333a6a36 | |||
ae757ee371 | |||
69936750d5 | |||
442bfa4ed6 | |||
79e25e69bb | |||
b95c12772d | |||
de47525d7c | |||
f49d20e47c | |||
33b9917229 | |||
2a88e6802f | |||
bcc8b12e13 | |||
9b974505eb | |||
29b1c26771 | |||
02db20d98b | |||
757354df7d | |||
319d7dbe94 | |||
feb8eaf95a | |||
563518cf46 | |||
7c42d9082a | |||
040284af71 | |||
34f64184d9 | |||
b9abd74156 | |||
a1c0bfda6c | |||
617dcef09d | |||
d9c406800a | |||
54b869def1 | |||
d80a583979 | |||
99bfd7379b | |||
5f257382fa | |||
e3e6847c82 | |||
4ee0823acb | |||
d466123b1c | |||
21cbc14a48 | |||
b2f2c3e386 | |||
b03340ed10 | |||
5b563d8e9b | |||
2790487fc7 | |||
ad5a368065 | |||
7c0a631a9a | |||
4a8920749a | |||
8ab118dd06 | |||
e6661cb898 | |||
1671850714 | |||
d568bafe04 | |||
43dcce8478 | |||
ad70a4cffd | |||
6d4a948dd8 | |||
839ba6a964 | |||
b5cfdb9d0a | |||
9706338182 | |||
05f52c3d23 | |||
df3acb6932 | |||
b3c242595e | |||
26985f8d81 | |||
05e5e4efec | |||
e88be30fc8 | |||
4d67f16e94 | |||
334ec1870a | |||
ef5e4fccd3 | |||
8535edcfd4 | |||
bda76200d7 | |||
db0dc96cc7 | |||
6d62b5a150 | |||
217439d673 | |||
1f79a8f7dc | |||
7596786b18 | |||
2540b06c94 | |||
43eeaf3002 | |||
037cd150de | |||
ae0b059217 | |||
8255ce1158 | |||
5bf905723c | |||
3e336f4937 | |||
cd1cc37916 | |||
4ad7183d7e | |||
e1b52e7439 | |||
dca8c9f9d7 | |||
22496e36eb | |||
7e4eba6376 | |||
f642a56eaa | |||
c091089830 | |||
18900d20e1 | |||
6c622b1580 | |||
4290cd23b2 | |||
5076c1e93f | |||
884b701fc6 | |||
73a8ec0295 | |||
a29b6097a4 | |||
a9231e2ed8 | |||
5f4669a7a6 | |||
75c54df109 | |||
2a07f7151d | |||
b6ecff2dd3 | |||
83df27ec99 | |||
ca255985c0 | |||
82f34c38f6 | |||
694b4c8027 | |||
bd25621b2c | |||
fde34be270 | |||
7c7ce159fe | |||
5a57bb59e5 | |||
cd278f4ab5 | |||
8b24e23721 | |||
22fa1411bf | |||
2799a52d0c | |||
4c2e01a7a8 | |||
7267d2ef38 | |||
1eb6b154f7 | |||
f55d61bf0b | |||
5b350274bd | |||
b6d2f9f691 | |||
81106b5deb | |||
66e595e649 | |||
33b7bb6184 | |||
7d9130b2af | |||
482d71743b | |||
1db37a4727 | |||
194d16ff91 | |||
b1e2284c0e | |||
70d1aa70a3 | |||
3b17d6e0ab | |||
9a5819b93b | |||
a260cd67b0 | |||
64111fb0ec | |||
faf2be23d9 | |||
0eb4a6a315 | |||
85673250ed | |||
09daa741ce | |||
55e2379aab | |||
9937977203 | |||
c897e7491a | |||
0a74a95283 | |||
74ef2c3dff | |||
9976dfacc0 | |||
659f8ddc7a | |||
9e4cc2ae57 | |||
a27d78fcdf | |||
e507435bcb | |||
d5f234909f | |||
c17f721625 | |||
600705130f | |||
5c5dce1422 | |||
53585bf2f0 | |||
116f88a503 | |||
aaba8cd2c7 | |||
b67aeb0d3a | |||
f620562d68 | |||
5231d0eaa1 | |||
cb470e3573 | |||
0a0f90aa2e | |||
635207d12c | |||
5e4a829413 | |||
b13b3fd92e | |||
564dc8e6f1 | |||
6e4cced8c6 | |||
29a4a5027c | |||
ee327448b4 | |||
d078960c5c | |||
2e8cd375fc | |||
1f6751cb01 | |||
3cca4e31cd | |||
b93902800c | |||
70f6bb3fda | |||
c075cb6311 | |||
d7db85b062 | |||
b442e7d958 | |||
a495ae637f | |||
94748a96a9 | |||
7657429054 | |||
2ff6dbf975 | |||
4f34628c14 | |||
6ff2111cee | |||
85957980f6 | |||
a6dcfe2c87 | |||
c32d590fbb | |||
ab41462f71 | |||
951f16b1a5 | |||
b5818888bb | |||
1a326bf7e4 | |||
e1afc1cf7a | |||
bb007ddce5 | |||
b5dd0317c7 | |||
3c54541a73 | |||
2657f01135 | |||
7223409eb1 | |||
c41eae63e7 | |||
c8b85c43aa | |||
e918788c3f | |||
b53f4f997c | |||
481d93ebc4 | |||
1ce666f136 | |||
49a8e702bc | |||
5d59e652d7 | |||
02af8c7311 | |||
fadf4e867c | |||
0839859fef | |||
c122b48e35 | |||
cebb297bbf | |||
2e31b796f7 | |||
e0a61b51cb | |||
46e50e622b | |||
7cfa1df0b2 | |||
8a63648339 | |||
bb6b026720 | |||
2a13b314dc | |||
4506b3f6d4 | |||
804abef0de | |||
7e932b920e | |||
46fdfbc507 | |||
a4ff8607c5 | |||
7fe4eeda57 | |||
9f25cddaa4 | |||
eb64fe60d0 | |||
36f404e17d | |||
5398590939 | |||
96d5cfea14 | |||
79007ebd55 | |||
fcb519dac3 | |||
2b487aa959 | |||
733feadcb2 | |||
5ae568f19c | |||
0e51807856 | |||
b6eb165207 | |||
d26dbf4b3d | |||
e06ef01f72 | |||
a722582709 | |||
de64deb5c5 | |||
7de54a2cc4 | |||
c788b8cc82 | |||
cb8db1a541 | |||
8a7a551c3b | |||
84d180b810 | |||
de261a27ca | |||
28288a8f7b | |||
cd8548fc32 | |||
37dbd49589 | |||
5af8d67b62 | |||
72e6309c4a | |||
18f0f3cce1 | |||
8c3e9ff192 | |||
21d6d27435 | |||
39ff7913d6 | |||
402c6fc64a | |||
a1f934466c | |||
15aa114579 | |||
b9cc82e37d | |||
8af9c8b150 | |||
7dcc985222 | |||
9c2bc19897 | |||
801b08359a | |||
c469dee158 | |||
2aa7eda1e9 | |||
f1246c9e00 | |||
2749cbe4d1 | |||
d2a9280ab3 | |||
8e25ee0fc9 | |||
55026f913b | |||
7cc40e2453 | |||
cb6b19952d | |||
983af57842 | |||
3c2820dc31 | |||
1c25b69160 | |||
641cc38ae4 | |||
cd68b07e19 | |||
2b252c9abb | |||
e2e8370bb9 | |||
e9e31394c4 | |||
2825ccbcd5 | |||
377a14ff72 | |||
a83997b9b4 | |||
3e155d8e80 | |||
6953b72ee6 | |||
ab370a1dda | |||
20845e5860 | |||
9ed3257006 | |||
2221e30c0a | |||
ce43dca23b | |||
4acf5d0931 | |||
b742ed73aa | |||
5156b2e0cc | |||
6b81cf4a24 | |||
cca3a68fe4 | |||
adb2904872 | |||
d68b8d03dd | |||
e7afb88f22 | |||
48cbfc64c0 | |||
0b067858bc | |||
2d44d98f17 | |||
74ef3096ae | |||
8f1163fd75 | |||
a240d503c5 | |||
e331a641b2 | |||
85db4b8e0a | |||
0aa139cf4a | |||
4140ca6fbd | |||
a8ce9da9ee | |||
476a33c0ab | |||
8e868c9fda | |||
17c8b1a172 | |||
b374c04d73 | |||
e750d824ad | |||
dd4c50c3eb | |||
20cc869299 | |||
7214dbccdb | |||
e6cebdd546 | |||
0301003ccf | |||
de2efe0c01 | |||
90d765d7f6 | |||
3e0a1721ce | |||
7214fbcd4c | |||
4b8aecfe91 | |||
387c71c0aa | |||
8d5ce21df4 | |||
f6dfcfbae9 | |||
69e9595db9 | |||
de390678fd | |||
cf9a7b8c60 | |||
73e9c16a8d | |||
9775623981 | |||
20b7bb3c99 | |||
3defc16658 | |||
0dbe592182 | |||
a7c0e5bdaa | |||
3b051cbbea | |||
f7edfd1c29 | |||
b182c43965 | |||
4639e7ad2e | |||
56241203a0 | |||
8c8540de5d | |||
b45af78322 | |||
98bcf3bf7e | |||
e28bcfced3 | |||
a5bd990245 | |||
58301e0844 | |||
c9213fb6cd | |||
641819a364 | |||
3ee3a8b41d | |||
5600403088 | |||
3b00bace23 | |||
fcba470aad | |||
206e602d73 | |||
f98d1aaade | |||
310f857257 | |||
a2b1055094 | |||
f23ddecef3 | |||
54687ec3c0 | |||
545f0fcea5 | |||
5db8ce3043 | |||
ed48669ae1 | |||
69c3befb2d | |||
fc39e837ea | |||
6df9f28c02 | |||
f3d0947427 | |||
3326a4cf2a | |||
9a6ea87b0c | |||
12179d0ec0 | |||
d4141fc51e | |||
c32ab6181c | |||
3847882599 | |||
4db157f663 | |||
351b4e84c9 | |||
0c65561bcb | |||
00200f75a0 | |||
58614a74f5 | |||
f3d64663a0 | |||
8be8c270f8 | |||
a56037f1c9 | |||
2ff7955ec3 | |||
f2044c4d26 | |||
4113f0faea | |||
bacd09484a | |||
8253eb62bd | |||
70b659a0a0 | |||
79ed74ab17 | |||
93bb3ebd69 | |||
e8e516159c | |||
1431c031a0 | |||
209c2183e1 | |||
0c98c282a0 | |||
58c10796a1 | |||
603e6a99f3 | |||
6622ebc04e | |||
465dbb4a8d | |||
08ae908453 | |||
c35a1e7c50 | |||
ecb22c3829 | |||
eebf969f7e | |||
5816f25c3e | |||
b2a81d880d | |||
b10c1476a6 | |||
e11cd09a12 | |||
27575eda68 | |||
f33b9a1ec6 | |||
7c45fff7ba | |||
ecdb0775cd |
@ -140,6 +140,97 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "satanshiro",
|
||||
"name": "satanshiro",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/38865738?v=4",
|
||||
"profile": "https://github.com/satanshiro",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "kpoelhekke",
|
||||
"name": "Koen Poelhekke",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1632377?v=4",
|
||||
"profile": "https://poelhekke.dev",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "zavoloklom",
|
||||
"name": "Sergey Kupletsky",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4151869?v=4",
|
||||
"profile": "https://github.com/zavoloklom",
|
||||
"contributions": [
|
||||
"test",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "rome-user",
|
||||
"name": "rome-user",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/114131048?v=4",
|
||||
"profile": "https://github.com/rome-user",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "celinepelletier",
|
||||
"name": "Céline Pelletier",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/82821620?v=4",
|
||||
"profile": "https://github.com/celinepelletier",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "xgp",
|
||||
"name": "Garth",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/244253?v=4",
|
||||
"profile": "https://github.com/xgp",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "BlackVoid",
|
||||
"name": "Felix Gustavsson",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/673720?v=4",
|
||||
"profile": "https://github.com/BlackVoid",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "msiemens",
|
||||
"name": "Markus Siemens",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1873922?v=4",
|
||||
"profile": "https://m-siemens.de/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "law108000",
|
||||
"name": "Rlok",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/8112024?v=4",
|
||||
"profile": "https://github.com/law108000",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Moulyy",
|
||||
"name": "Moulyy",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/115405804?v=4",
|
||||
"profile": "https://github.com/Moulyy",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
@ -147,5 +238,6 @@
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"projectName": "keycloakify",
|
||||
"projectOwner": "keycloakify"
|
||||
"projectOwner": "keycloakify",
|
||||
"commitType": "docs"
|
||||
}
|
||||
|
3
.github/workflows/ci.yaml
vendored
3
.github/workflows/ci.yaml
vendored
@ -34,7 +34,7 @@ jobs:
|
||||
- uses: bahmutov/npm-install@v1
|
||||
- run: yarn build
|
||||
- run: yarn test
|
||||
- run: yarn test:keycloakify-starter
|
||||
#- run: yarn test:keycloakify-starter
|
||||
|
||||
storybook:
|
||||
runs-on: ubuntu-latest
|
||||
@ -74,7 +74,6 @@ jobs:
|
||||
id: step1
|
||||
with:
|
||||
action_name: is_package_json_version_upgraded
|
||||
branch: ${{ github.head_ref || github.ref }}
|
||||
|
||||
create_github_release:
|
||||
runs-on: ubuntu-latest
|
||||
|
142
README.md
142
README.md
@ -14,13 +14,10 @@
|
||||
<a href="https://github.com/garronej/keycloakify/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/npm/l/keycloakify">
|
||||
</a>
|
||||
<a href="https://github.com/keycloakify/keycloakify/blob/729503fe31a155a823f46dd66ad4ff34ca274e0a/tsconfig.json#L14">
|
||||
<img src="https://camo.githubusercontent.com/0f9fcc0ac1b8617ad4989364f60f78b2d6b32985ad6a508f215f14d8f897b8d3/68747470733a2f2f62616467656e2e6e65742f62616467652f547970655363726970742f7374726963742532302546302539462539322541412f626c7565">
|
||||
</a>
|
||||
<a href="https://github.com/thomasdarimont/awesome-keycloak">
|
||||
<img src="https://awesome.re/mentioned-badge.svg"/>
|
||||
</a>
|
||||
<a href="https://discord.gg/rBzsYtUn">
|
||||
<a href="https://discord.gg/kYFZG7fQmn">
|
||||
<img src="https://img.shields.io/discord/1097708346976505977"/>
|
||||
</a>
|
||||
<p align="center">
|
||||
@ -35,13 +32,20 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<i>Ultimately this build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
|
||||
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
|
||||
<i>This build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
|
||||
<br/>
|
||||
<br/>
|
||||
<img width="400" src="https://github.com/keycloakify/keycloakify/assets/6702424/e66d105c-c06f-47d1-8a31-a6ab09da4e80">
|
||||
</p>
|
||||
|
||||
## Sponsor 👼
|
||||
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)!
|
||||
|
||||
We are exclusively sponsored by [Cloud IAM](https://www.cloud-iam.com), a French company offering Keycloak as a service.
|
||||
> NOTE: Keycloak 24 introduces [important changes](https://www.keycloak.org/docs/latest/upgrading/index.html#changes-to-freemarker-templates-to-render-pages-based-on-the-user-profile-and-realm).
|
||||
> We're actively working on incorporating them into Keycloakify. [Follow progress](https://github.com/keycloakify/keycloakify/pull/538).
|
||||
|
||||
## Sponsor
|
||||
|
||||
We are exclusively sponsored by [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), a French company offering Keycloak as a service.
|
||||
Their dedicated support helps us continue the development and maintenance of this project.
|
||||
|
||||
[Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github) provides the following services:
|
||||
@ -49,17 +53,25 @@ Their dedicated support helps us continue the development and maintenance of thi
|
||||
- Simplify and secure your Keycloak Identity and Access Management. Keycloak as a Service.
|
||||
- Custom theme building for your brand using Keycloakify.
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github">
|
||||
<img src="https://user-images.githubusercontent.com/6702424/233476937-e37b1dc6-5a1c-4a0d-ba02-61c2ce62ffb6.png" alt="Cloud IAM Logo" width="350"/>
|
||||
</a>
|
||||
<br/>
|
||||
<i>Use promo code <code>keycloakify5</code> </i>
|
||||
<i>Checkout <a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github">Cloud-IAM</a> and use promo code <code>keycloakify5</code></i>
|
||||
<br/>
|
||||
<i>5% of your annual subscription will be donated to us, and you'll get 5% off too.</i>
|
||||
</p>
|
||||
|
||||
Thank you, [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), for your support!
|
||||
Thank you, [Cloud-IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), for your support!
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
@ -90,6 +102,19 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.gravitysoftware.be"><img src="https://avatars.githubusercontent.com/u/1140574?v=4?s=100" width="100px;" alt="Thomas Silvestre"/><br /><sub><b>Thomas Silvestre</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=thosil" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/satanshiro"><img src="https://avatars.githubusercontent.com/u/38865738?v=4?s=100" width="100px;" alt="satanshiro"/><br /><sub><b>satanshiro</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=satanshiro" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://poelhekke.dev"><img src="https://avatars.githubusercontent.com/u/1632377?v=4?s=100" width="100px;" alt="Koen Poelhekke"/><br /><sub><b>Koen Poelhekke</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kpoelhekke" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zavoloklom"><img src="https://avatars.githubusercontent.com/u/4151869?v=4?s=100" width="100px;" alt="Sergey Kupletsky"/><br /><sub><b>Sergey Kupletsky</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=zavoloklom" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=zavoloklom" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rome-user"><img src="https://avatars.githubusercontent.com/u/114131048?v=4?s=100" width="100px;" alt="rome-user"/><br /><sub><b>rome-user</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=rome-user" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/celinepelletier"><img src="https://avatars.githubusercontent.com/u/82821620?v=4?s=100" width="100px;" alt="Céline Pelletier"/><br /><sub><b>Céline Pelletier</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=celinepelletier" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xgp"><img src="https://avatars.githubusercontent.com/u/244253?v=4?s=100" width="100px;" alt="Garth"/><br /><sub><b>Garth</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=xgp" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/BlackVoid"><img src="https://avatars.githubusercontent.com/u/673720?v=4?s=100" width="100px;" alt="Felix Gustavsson"/><br /><sub><b>Felix Gustavsson</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=BlackVoid" 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/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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -101,6 +126,60 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
|
||||
# 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.
|
||||
@ -108,10 +187,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
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.
|
||||
[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
|
||||
## 7.7
|
||||
|
||||
- Better storybook support, see [the starter project](https://github.com/keycloakify/keycloakify-starter).
|
||||
|
||||
@ -195,79 +273,79 @@ Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6)
|
||||
|
||||
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)
|
||||
|
||||
## v5.6.0
|
||||
## 5.6.0
|
||||
|
||||
Add support for `login-config-totp.ftl` page [#127](https://github.com/keycloakify/keycloakify/pull/127).
|
||||
|
||||
## v5.3.0
|
||||
## 5.3.0
|
||||
|
||||
Rename `keycloak_theme_email` to `keycloak_email`.
|
||||
If you already had a `keycloak_theme_email` you should rename it `keycloak_email`.
|
||||
|
||||
## v5.0.0
|
||||
## 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).
|
||||
|
||||
## v4.10.0
|
||||
## 4.10.0
|
||||
|
||||
Add `login-idp-link-email.ftl` page [See PR](https://github.com/keycloakify/keycloakify/pull/92).
|
||||
|
||||
## v4.8.0
|
||||
## 4.8.0
|
||||
|
||||
[Email template customization.](#email-template-customization)
|
||||
|
||||
## v4.7.4
|
||||
## 4.7.4
|
||||
|
||||
**M1 Mac** support (for testing locally with a dockerized Keycloak).
|
||||
|
||||
## v4.7.2
|
||||
## 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.
|
||||
|
||||
## v4.7.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)
|
||||
|
||||
## v4.6.0
|
||||
## 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.
|
||||
|
||||
## v4.5.3
|
||||
## 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.
|
||||
|
||||
## v4.3.0
|
||||
## 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.
|
||||
|
||||
## v4
|
||||
## 4
|
||||
|
||||
- Out of the box [frontend form validation](#user-profile-and-frontend-form-validation) 🥳
|
||||
- Improvements (and breaking changes in `import { useKcMessage } from "keycloakify"`.
|
||||
|
||||
## v3
|
||||
## 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).
|
||||
|
||||
## v2.5
|
||||
## 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`.
|
||||
|
||||
## v2
|
||||
## 2
|
||||
|
||||
- It's now possible to implement custom `.ftl` pages.
|
||||
- Support for Keycloak plugins that introduce non standard ftl values.
|
||||
|
@ -1,91 +0,0 @@
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "https://json.schemastore.org/package.json"
|
||||
},
|
||||
{
|
||||
"$ref": "keycloakifyPackageJsonSchema"
|
||||
}
|
||||
],
|
||||
"$ref": "#/definitions/keycloakifyPackageJsonSchema",
|
||||
"definitions": {
|
||||
"keycloakifyPackageJsonSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"homepage": {
|
||||
"type": "string"
|
||||
},
|
||||
"keycloakify": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"extraPages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"extraLoginPages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"extraAccountPages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"extraThemeProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"areAppAndKeycloakServerSharingSameDomain": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"artifactId": {
|
||||
"type": "string"
|
||||
},
|
||||
"groupId": {
|
||||
"type": "string"
|
||||
},
|
||||
"bundler": {
|
||||
"type": "string",
|
||||
"enum": ["mvn", "keycloakify", "none"]
|
||||
},
|
||||
"keycloakVersionDefaultAssets": {
|
||||
"type": "string"
|
||||
},
|
||||
"reactAppBuildDirPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"keycloakifyBuildDirPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"customUserAttributes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"themeName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["name", "version"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
28
package.json
28
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "keycloakify",
|
||||
"version": "7.11.1",
|
||||
"version": "10.0.0-rc.4",
|
||||
"description": "Create Keycloak themes using React",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -10,11 +10,10 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "yarn generate-i18n-messages",
|
||||
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/",
|
||||
"watch-in-starter": "yarn build && yarn link-in-starter && (concurrently \"tsc -p src -w\" \"tsc-alias -p src/tsconfig.json\" \"tsc -p src/bin -w\")",
|
||||
"build": "tsc -p src/bin && tsc -p src && tsc -p src/vite-plugin && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn run copy-files && cp -r src dist/",
|
||||
"generate:json-schema": "ts-node scripts/generate-json-schema.ts",
|
||||
"grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js",
|
||||
"copy-files": "copyfiles -u 1 src/**/*.ftl",
|
||||
"grant-exec-perms": "ts-node --skipProject scripts/grant-exec-perms.ts",
|
||||
"copy-files": "copyfiles -u 1 'src/**/*.ftl' dist/",
|
||||
"test": "yarn test:types && vitest run",
|
||||
"test:keycloakify-starter": "ts-node scripts/test-keycloakify-starter",
|
||||
"test:types": "tsc -p test/tsconfig.json --noEmit",
|
||||
@ -65,7 +64,7 @@
|
||||
],
|
||||
"homepage": "https://www.keycloakify.dev",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
"react": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
@ -79,12 +78,14 @@
|
||||
"@storybook/manager-webpack5": "^6.5.13",
|
||||
"@storybook/react": "^6.5.13",
|
||||
"@storybook/testing-library": "^0.0.13",
|
||||
"@types/babel__generator": "^7.6.4",
|
||||
"@types/make-fetch-happen": "^10.0.1",
|
||||
"@types/minimist": "^1.2.2",
|
||||
"@types/node": "^18.15.3",
|
||||
"@types/react": "^18.0.35",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"@types/yazl": "^2.4.2",
|
||||
"concurrently": "^8.0.1",
|
||||
"copyfiles": "^2.4.1",
|
||||
"eslint-plugin-storybook": "^0.6.7",
|
||||
@ -95,7 +96,6 @@
|
||||
"properties-parser": "^0.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"scripting-tools": "^0.19.13",
|
||||
"storybook-dark-mode": "^1.1.2",
|
||||
"ts-node": "^10.9.1",
|
||||
@ -103,23 +103,27 @@
|
||||
"tss-react": "^4.8.2",
|
||||
"typescript": "^4.9.1-beta",
|
||||
"vitest": "^0.29.8",
|
||||
"zod-to-json-schema": "^3.20.4"
|
||||
"zod-to-json-schema": "^3.20.4",
|
||||
"vite": "^5.0.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.22.9",
|
||||
"@babel/parser": "^7.22.7",
|
||||
"@babel/types": "^7.22.5",
|
||||
"@octokit/rest": "^18.12.0",
|
||||
"@types/yazl": "^2.4.2",
|
||||
"cheerio": "^1.0.0-rc.5",
|
||||
"cli-select": "^1.1.2",
|
||||
"evt": "^2.4.18",
|
||||
"evt": "^2.5.7",
|
||||
"make-fetch-happen": "^11.0.3",
|
||||
"minimal-polyfills": "^2.2.2",
|
||||
"minimist": "^1.2.6",
|
||||
"path-browserify": "^1.0.1",
|
||||
"react-markdown": "^5.0.3",
|
||||
"recast": "^0.23.3",
|
||||
"rfc4648": "^1.5.2",
|
||||
"tsafe": "^1.6.0",
|
||||
"yauzl": "^2.10.0",
|
||||
"yazl": "^2.5.1",
|
||||
"zod": "^3.17.10"
|
||||
"zod": "^3.17.10",
|
||||
"magic-string": "^0.30.7"
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import * as fs from "fs";
|
||||
import { join as pathJoin, relative as pathRelative, dirname as pathDirname, sep as pathSep } from "path";
|
||||
import { crawl } from "../src/bin/tools/crawl";
|
||||
import { downloadBuiltinKeycloakTheme } from "../src/bin/download-builtin-keycloak-theme";
|
||||
import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
|
||||
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
|
||||
import { getLogger } from "../src/bin/tools/logger";
|
||||
|
||||
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
|
||||
@ -17,16 +17,25 @@ const isSilent = true;
|
||||
const logger = getLogger({ isSilent });
|
||||
|
||||
async function main() {
|
||||
const keycloakVersion = "21.0.1";
|
||||
const keycloakVersion = "24.0.4";
|
||||
|
||||
const tmpDirPath = pathJoin(getProjectRoot(), "tmp_xImOef9dOd44");
|
||||
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
|
||||
|
||||
const tmpDirPath = pathJoin(thisCodebaseRootDirPath, "tmp_xImOef9dOd44");
|
||||
|
||||
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
|
||||
|
||||
fs.mkdirSync(tmpDirPath);
|
||||
|
||||
fs.writeFileSync(pathJoin(tmpDirPath, ".gitignore"), Buffer.from("/*\n!.gitignore\n", "utf8"));
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
keycloakVersion,
|
||||
"destDirPath": tmpDirPath,
|
||||
isSilent
|
||||
"buildOptions": {
|
||||
"cacheDirPath": pathJoin(thisCodebaseRootDirPath, "node_modules", ".cache", "keycloakify"),
|
||||
"npmWorkspaceRootDirPath": thisCodebaseRootDirPath
|
||||
}
|
||||
});
|
||||
|
||||
type Dictionary = { [idiomId: string]: string };
|
||||
@ -37,7 +46,10 @@ async function main() {
|
||||
const baseThemeDirPath = pathJoin(tmpDirPath, "base");
|
||||
const re = new RegExp(`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`);
|
||||
|
||||
crawl(baseThemeDirPath).forEach(filePath => {
|
||||
crawl({
|
||||
"dirPath": baseThemeDirPath,
|
||||
"returnedPathsType": "relative to dirPath"
|
||||
}).forEach(filePath => {
|
||||
const match = filePath.match(re);
|
||||
|
||||
if (match === null) {
|
||||
@ -63,14 +75,13 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseMessagesDirPath = pathJoin(getProjectRoot(), "src", themeType, "i18n", "baseMessages");
|
||||
const baseMessagesDirPath = pathJoin(thisCodebaseRootDirPath, "src", themeType, "i18n", "baseMessages");
|
||||
|
||||
const languages = Object.keys(recordForPageType);
|
||||
|
||||
const generatedFileHeader = [
|
||||
`//This code was automatically generated by running ${pathRelative(getProjectRoot(), __filename)}`,
|
||||
"//PLEASE DO NOT EDIT MANUALLY",
|
||||
""
|
||||
`//This code was automatically generated by running ${pathRelative(thisCodebaseRootDirPath, __filename)}`,
|
||||
"//PLEASE DO NOT EDIT MANUALLY"
|
||||
].join("\n");
|
||||
|
||||
languages.forEach(language => {
|
||||
@ -83,6 +94,7 @@ async function main() {
|
||||
Buffer.from(
|
||||
[
|
||||
generatedFileHeader,
|
||||
"",
|
||||
"/* spell-checker: disable */",
|
||||
`const messages= ${JSON.stringify(recordForPageType[language], null, 2)};`,
|
||||
"",
|
||||
@ -101,10 +113,15 @@ async function main() {
|
||||
Buffer.from(
|
||||
[
|
||||
generatedFileHeader,
|
||||
`import * as en from "./en";`,
|
||||
"",
|
||||
"export async function getMessages(currentLanguageTag: string) {",
|
||||
" const { default: messages } = await (() => {",
|
||||
" switch (currentLanguageTag) {",
|
||||
...languages.map(language => ` case "${language}": return import("./${language}");`),
|
||||
` case "en": return en;`,
|
||||
...languages
|
||||
.filter(language => language !== "en")
|
||||
.map(language => ` case "${language}": return import("./${language}");`),
|
||||
' default: return { "default": {} };',
|
||||
" }",
|
||||
" })();",
|
||||
|
@ -1,14 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import zodToJsonSchema from "zod-to-json-schema";
|
||||
import { zParsedPackageJson } from "../src/bin/keycloakify/parsedPackageJson";
|
||||
|
||||
const jsonSchemaName = "keycloakifyPackageJsonSchema";
|
||||
const jsonSchema = zodToJsonSchema(zParsedPackageJson, jsonSchemaName);
|
||||
|
||||
const baseProperties = {
|
||||
// merges package.json schema with keycloakify properties
|
||||
"allOf": [{ "$ref": "https://json.schemastore.org/package.json" }, { "$ref": jsonSchemaName }]
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(process.cwd(), "keycloakify-json-schema.json"), JSON.stringify({ ...baseProperties, ...jsonSchema }, null, 2));
|
@ -1,13 +1,14 @@
|
||||
import { getProjectRoot } from "./getProjectRoot";
|
||||
import { join as pathJoin } from "path";
|
||||
import { constants } from "fs";
|
||||
import { chmod, stat } from "fs/promises";
|
||||
|
||||
(async () => {
|
||||
const { bin } = await import(pathJoin(getProjectRoot(), "package.json"));
|
||||
const thisCodebaseRootDirPath = pathJoin(__dirname, "..");
|
||||
|
||||
const { bin } = await import(pathJoin(thisCodebaseRootDirPath, "package.json"));
|
||||
|
||||
const promises = Object.values<string>(bin).map(async scriptPath => {
|
||||
const fullPath = pathJoin(getProjectRoot(), scriptPath);
|
||||
const fullPath = pathJoin(thisCodebaseRootDirPath, scriptPath);
|
||||
const oldMode = (await stat(fullPath)).mode;
|
||||
const newMode = oldMode | constants.S_IXUSR | constants.S_IXGRP | constants.S_IXOTH;
|
||||
await chmod(fullPath, newMode);
|
@ -1,11 +1,11 @@
|
||||
import { execSync } from "child_process";
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
|
||||
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
|
||||
import * as fs from "fs";
|
||||
|
||||
const singletonDependencies: string[] = ["react", "@types/react"];
|
||||
|
||||
const rootDirPath = getProjectRoot();
|
||||
const rootDirPath = getThisCodebaseRootDirPath();
|
||||
|
||||
//NOTE: This is only required because of: https://github.com/garronej/ts-ci/blob/c0e207b9677523d4ec97fe672ddd72ccbb3c1cc4/README.md?plain=1#L54-L58
|
||||
fs.writeFileSync(
|
||||
|
21
src/PUBLIC_URL.ts
Normal file
21
src/PUBLIC_URL.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir } from "keycloakify/bin/constants";
|
||||
import { assert } from "tsafe/assert";
|
||||
|
||||
/**
|
||||
* This is an equivalent of process.env.PUBLIC_URL thay you can use in Webpack projects.
|
||||
* This works both in your main app and in your Keycloak theme.
|
||||
*/
|
||||
export const PUBLIC_URL = (() => {
|
||||
const kcContext = (window as any)[nameOfTheGlobal];
|
||||
|
||||
if (kcContext === undefined || process.env.NODE_ENV === "development") {
|
||||
assert(
|
||||
process.env.PUBLIC_URL !== undefined,
|
||||
`If you use keycloakify/PUBLIC_URL you should be in Webpack and thus process.env.PUBLIC_URL should be defined`
|
||||
);
|
||||
|
||||
return process.env.PUBLIC_URL;
|
||||
}
|
||||
|
||||
return `${kcContext.url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}`;
|
||||
})();
|
@ -6,6 +6,10 @@ import { assert, type Equals } from "tsafe/assert";
|
||||
|
||||
const Password = lazy(() => import("keycloakify/account/pages/Password"));
|
||||
const Account = lazy(() => import("keycloakify/account/pages/Account"));
|
||||
const Sessions = lazy(() => import("keycloakify/account/pages/Sessions"));
|
||||
const Totp = lazy(() => import("keycloakify/account/pages/Totp"));
|
||||
const Applications = lazy(() => import("keycloakify/account/pages/Applications"));
|
||||
const Log = lazy(() => import("keycloakify/account/pages/Log"));
|
||||
|
||||
export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
const { kcContext, ...rest } = props;
|
||||
@ -16,8 +20,16 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
switch (kcContext.pageId) {
|
||||
case "password.ftl":
|
||||
return <Password kcContext={kcContext} {...rest} />;
|
||||
case "sessions.ftl":
|
||||
return <Sessions kcContext={kcContext} {...rest} />;
|
||||
case "account.ftl":
|
||||
return <Account kcContext={kcContext} {...rest} />;
|
||||
case "totp.ftl":
|
||||
return <Totp kcContext={kcContext} {...rest} />;
|
||||
case "applications.ftl":
|
||||
return <Applications kcContext={kcContext} {...rest} />;
|
||||
case "log.ftl":
|
||||
return <Log kcContext={kcContext} {...rest} />;
|
||||
}
|
||||
assert<Equals<typeof kcContext, never>>(false);
|
||||
})()}
|
||||
|
@ -1,30 +1,61 @@
|
||||
import { useEffect } from "react";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate";
|
||||
import { type TemplateProps } from "keycloakify/account/TemplateProps";
|
||||
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
|
||||
import { createUseInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
|
||||
import { useSetClassName } from "keycloakify/tools/useSetClassName";
|
||||
import type { KcContext } from "./kcContext";
|
||||
import type { I18n } from "./i18n";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
|
||||
const { useInsertLinkTags } = createUseInsertLinkTags();
|
||||
|
||||
export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props;
|
||||
|
||||
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
|
||||
|
||||
const { msg, changeLocale, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
|
||||
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
|
||||
|
||||
const { locale, url, features, realm, message, referrer } = kcContext;
|
||||
|
||||
const { isReady } = usePrepareTemplate({
|
||||
"doFetchDefaultThemeResources": doUseDefaultCss,
|
||||
url,
|
||||
"stylesCommon": ["node_modules/patternfly/dist/css/patternfly.min.css", "node_modules/patternfly/dist/css/patternfly-additions.min.css"],
|
||||
"styles": ["css/account.css"],
|
||||
"htmlClassName": undefined,
|
||||
"bodyClassName": clsx("admin-console", "user", getClassName("kcBodyClass"))
|
||||
useEffect(() => {
|
||||
document.title = msgStr("accountManagementTitle");
|
||||
}, []);
|
||||
|
||||
useSetClassName({
|
||||
"qualifiedName": "html",
|
||||
"className": getClassName("kcHtmlClass")
|
||||
});
|
||||
|
||||
if (!isReady) {
|
||||
useSetClassName({
|
||||
"qualifiedName": "body",
|
||||
"className": clsx("admin-console", "user", getClassName("kcBodyClass"))
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { currentLanguageTag } = locale ?? {};
|
||||
|
||||
if (currentLanguageTag === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = document.querySelector("html");
|
||||
assert(html !== null);
|
||||
html.lang = currentLanguageTag;
|
||||
}, []);
|
||||
|
||||
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
|
||||
"hrefs": !doUseDefaultCss
|
||||
? []
|
||||
: [
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
|
||||
`${url.resourcesPath}/css/account.css`
|
||||
]
|
||||
});
|
||||
|
||||
if (!areAllStyleSheetsLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -50,10 +81,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
<ul>
|
||||
{locale.supported.map(({ languageTag }) => (
|
||||
<li key={languageTag} className="kc-dropdown-item">
|
||||
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
||||
<a href="#" onClick={() => changeLocale(languageTag)}>
|
||||
{labelBySupportedLanguageTag[languageTag]}
|
||||
</a>
|
||||
<a href={getChangeLocalUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
@ -11,4 +11,17 @@ export type TemplateProps<KcContext extends KcContext.Common, I18nExtended exten
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export type ClassKey = "kcBodyClass" | "kcButtonClass" | "kcButtonPrimaryClass" | "kcButtonLargeClass" | "kcButtonDefaultClass";
|
||||
export type ClassKey =
|
||||
| "kcHtmlClass"
|
||||
| "kcBodyClass"
|
||||
| "kcButtonClass"
|
||||
| "kcButtonPrimaryClass"
|
||||
| "kcButtonLargeClass"
|
||||
| "kcButtonDefaultClass"
|
||||
| "kcContentWrapperClass"
|
||||
| "kcFormClass"
|
||||
| "kcFormGroupClass"
|
||||
| "kcInputWrapperClass"
|
||||
| "kcLabelClass"
|
||||
| "kcInputClass"
|
||||
| "kcInputErrorMessageClass";
|
||||
|
@ -28,11 +28,10 @@ export type GenericI18n<MessageKey extends string> = {
|
||||
*/
|
||||
currentLanguageTag: string;
|
||||
/**
|
||||
* To call when the user switch language.
|
||||
* This will cause the page to be reloaded,
|
||||
* on next load currentLanguageTag === newLanguageTag
|
||||
* Redirect to this url to change the language.
|
||||
* After reload currentLanguageTag === newLanguageTag
|
||||
*/
|
||||
changeLocale: (newLanguageTag: string) => never;
|
||||
getChangeLocalUrl: (newLanguageTag: string) => string;
|
||||
/**
|
||||
* e.g. "en" => "English", "fr" => "Français", ...
|
||||
*
|
||||
@ -104,7 +103,7 @@ export function createUseI18n<ExtraMessageKey extends string = never>(extraMessa
|
||||
} as any
|
||||
}),
|
||||
currentLanguageTag,
|
||||
"changeLocale": newLanguageTag => {
|
||||
"getChangeLocalUrl": newLanguageTag => {
|
||||
const { locale } = kcContext;
|
||||
|
||||
assert(locale !== undefined, "Internationalization not enabled");
|
||||
@ -113,9 +112,7 @@ export function createUseI18n<ExtraMessageKey extends string = never>(extraMessa
|
||||
|
||||
assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`);
|
||||
|
||||
window.location.href = targetSupportedLocale.url;
|
||||
|
||||
assert(false, "never");
|
||||
return targetSupportedLocale.url;
|
||||
},
|
||||
"labelBySupportedLanguageTag": Object.fromEntries(
|
||||
(kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])
|
||||
|
@ -4,6 +4,7 @@ export default Fallback;
|
||||
|
||||
export { getKcContext } from "keycloakify/account/kcContext/getKcContext";
|
||||
export { createGetKcContext } from "keycloakify/account/kcContext/createGetKcContext";
|
||||
export type { AccountThemePageId as PageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
export { createUseI18n } from "keycloakify/account/i18n/i18n";
|
||||
|
||||
export type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
|
@ -1,12 +1,16 @@
|
||||
import type { AccountThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
import { type ThemeType } from "keycloakify/bin/constants";
|
||||
|
||||
export type KcContext = KcContext.Password | KcContext.Account;
|
||||
export type KcContext = KcContext.Password | KcContext.Account | KcContext.Sessions | KcContext.Totp | KcContext.Applications | KcContext.Log;
|
||||
|
||||
export declare namespace KcContext {
|
||||
export type Common = {
|
||||
themeVersion: string;
|
||||
keycloakifyVersion: string;
|
||||
themeType: "account";
|
||||
themeName: string;
|
||||
locale?: {
|
||||
supported: {
|
||||
url: string;
|
||||
@ -50,9 +54,34 @@ export declare namespace KcContext {
|
||||
name: string; // Client id
|
||||
};
|
||||
messagesPerField: {
|
||||
printIfExists: <T>(fieldName: string, x: T) => T | undefined;
|
||||
/**
|
||||
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.
|
||||
*
|
||||
* @param fieldName to check for
|
||||
* @param text to return
|
||||
* @return text if message exists for given field, else undefined
|
||||
*/
|
||||
printIfExists: <T extends string>(fieldName: string, text: T) => T | undefined;
|
||||
/**
|
||||
* Check if exists error message for given fields
|
||||
*
|
||||
* @param fields
|
||||
* @return boolean
|
||||
*/
|
||||
existsError: (fieldName: string) => boolean;
|
||||
/**
|
||||
* Get message for given field.
|
||||
*
|
||||
* @param fieldName
|
||||
* @return message text or empty string
|
||||
*/
|
||||
get: (fieldName: string) => string;
|
||||
/**
|
||||
* Check if message for given field exists
|
||||
*
|
||||
* @param field
|
||||
* @return boolean
|
||||
*/
|
||||
exists: (fieldName: string) => boolean;
|
||||
};
|
||||
account: {
|
||||
@ -61,6 +90,16 @@ export declare namespace KcContext {
|
||||
lastName?: string;
|
||||
username?: string;
|
||||
};
|
||||
properties: Record<string, string | undefined>;
|
||||
sessions: {
|
||||
sessions: {
|
||||
ipAddress: string;
|
||||
started?: any;
|
||||
lastAccess?: any;
|
||||
expires?: any;
|
||||
clients: string[];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type Password = Common & {
|
||||
@ -82,6 +121,140 @@ export declare namespace KcContext {
|
||||
};
|
||||
stateChecker: string;
|
||||
};
|
||||
|
||||
export type Sessions = Common & {
|
||||
pageId: "sessions.ftl";
|
||||
sessions: {
|
||||
sessions: {
|
||||
ipAddress: string;
|
||||
started?: any;
|
||||
lastAccess?: any;
|
||||
expires?: any;
|
||||
clients: string[];
|
||||
}[];
|
||||
};
|
||||
stateChecker: string;
|
||||
};
|
||||
|
||||
export type Totp = Common & {
|
||||
pageId: "totp.ftl";
|
||||
totp: {
|
||||
enabled: boolean;
|
||||
totpSecretEncoded: string;
|
||||
qrUrl: string;
|
||||
policy: {
|
||||
algorithm: "HmacSHA1" | "HmacSHA256" | "HmacSHA512";
|
||||
digits: number;
|
||||
lookAheadWindow: number;
|
||||
} & (
|
||||
| {
|
||||
type: "totp";
|
||||
period: number;
|
||||
}
|
||||
| {
|
||||
type: "hotp";
|
||||
initialCounter: number;
|
||||
}
|
||||
);
|
||||
supportedApplications: string[];
|
||||
totpSecretQrCode: string;
|
||||
manualUrl: string;
|
||||
totpSecret: string;
|
||||
otpCredentials: { id: string; userLabel: string }[];
|
||||
};
|
||||
mode?: "qr" | "manual" | undefined | null;
|
||||
isAppInitiatedAction: boolean;
|
||||
stateChecker: string;
|
||||
};
|
||||
|
||||
export type Applications = Common & {
|
||||
pageId: "applications.ftl";
|
||||
features: {
|
||||
log: boolean;
|
||||
identityFederation: boolean;
|
||||
authorization: boolean;
|
||||
passwordUpdateSupported: boolean;
|
||||
};
|
||||
stateChecker: string;
|
||||
applications: {
|
||||
applications: {
|
||||
realmRolesAvailable: { name: string; description: string }[];
|
||||
resourceRolesAvailable: Record<
|
||||
string,
|
||||
{
|
||||
roleName: string;
|
||||
roleDescription: string;
|
||||
clientName: string;
|
||||
clientId: string;
|
||||
}[]
|
||||
>;
|
||||
additionalGrants: string[];
|
||||
clientScopesGranted: string[];
|
||||
effectiveUrl?: string;
|
||||
client: {
|
||||
consentScreenText: string;
|
||||
surrogateAuthRequired: boolean;
|
||||
bearerOnly: boolean;
|
||||
id: string;
|
||||
protocolMappersStream: Record<string, unknown>;
|
||||
includeInTokenScope: boolean;
|
||||
redirectUris: string[];
|
||||
fullScopeAllowed: boolean;
|
||||
registeredNodes: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
clientAuthenticatorType: string;
|
||||
realmScopeMappingsStream: Record<string, unknown>;
|
||||
scopeMappingsStream: Record<string, unknown>;
|
||||
displayOnConsentScreen: boolean;
|
||||
clientId: string;
|
||||
rootUrl: string;
|
||||
authenticationFlowBindingOverrides: Record<string, unknown>;
|
||||
standardFlowEnabled: boolean;
|
||||
attributes: Record<string, unknown>;
|
||||
publicClient: boolean;
|
||||
alwaysDisplayInConsole: boolean;
|
||||
consentRequired: boolean;
|
||||
notBefore: string;
|
||||
rolesStream: Record<string, unknown>;
|
||||
protocol: string;
|
||||
dynamicScope: boolean;
|
||||
directAccessGrantsEnabled: boolean;
|
||||
name: string;
|
||||
serviceAccountsEnabled: boolean;
|
||||
frontchannelLogout: boolean;
|
||||
nodeReRegistrationTimeout: string;
|
||||
implicitFlowEnabled: boolean;
|
||||
baseUrl: string;
|
||||
webOrigins: string[];
|
||||
realm: Record<string, unknown>;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type Log = Common & {
|
||||
pageId: "log.ftl";
|
||||
log: {
|
||||
events: {
|
||||
date: string | number | Date;
|
||||
event: string;
|
||||
ipAddress: string;
|
||||
client: any;
|
||||
details: any[];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
assert<Equals<KcContext["pageId"], AccountThemePageId>>();
|
||||
{
|
||||
type Got = KcContext["pageId"];
|
||||
type Expected = AccountThemePageId;
|
||||
|
||||
type OnlyInGot = Exclude<Got, Expected>;
|
||||
type OnlyInExpected = Exclude<Expected, Got>;
|
||||
|
||||
assert<Equals<OnlyInGot, never>>();
|
||||
assert<Equals<OnlyInExpected, never>>();
|
||||
}
|
||||
|
||||
assert<KcContext["themeType"] extends ThemeType ? true : false>();
|
||||
|
@ -1,19 +1,16 @@
|
||||
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
|
||||
import { deepAssign } from "keycloakify/tools/deepAssign";
|
||||
import { isStorybook } from "keycloakify/lib/isStorybook";
|
||||
import type { ExtendKcContext } from "./getKcContextFromWindow";
|
||||
import { getKcContextFromWindow } from "./getKcContextFromWindow";
|
||||
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
|
||||
import { pathBasename } from "keycloakify/tools/pathBasename";
|
||||
import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
import { kcContextMocks, kcContextCommonMock } from "keycloakify/account/kcContext/kcContextMocks";
|
||||
import { id } from "tsafe/id";
|
||||
import { accountThemePageIds } from "keycloakify/bin/keycloakify/generateFtl/pageId";
|
||||
|
||||
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
|
||||
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
|
||||
mockProperties?: Record<string, string>;
|
||||
}) {
|
||||
const { mockData } = params ?? {};
|
||||
const { mockData, mockProperties } = params ?? {};
|
||||
|
||||
function getKcContext<PageId extends ExtendKcContext<KcContextExtension>["pageId"] | undefined = undefined>(params?: {
|
||||
mockPageId?: PageId;
|
||||
@ -30,7 +27,13 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
|
||||
if (mockPageId !== undefined && realKcContext === undefined) {
|
||||
//TODO maybe trow if no mock fo custom page
|
||||
|
||||
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
|
||||
warn_that_mock_is_enbaled: {
|
||||
if (isStorybook) {
|
||||
break warn_that_mock_is_enbaled;
|
||||
}
|
||||
|
||||
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
|
||||
}
|
||||
|
||||
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
|
||||
|
||||
@ -80,6 +83,13 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
|
||||
});
|
||||
}
|
||||
|
||||
if (mockProperties !== undefined) {
|
||||
deepAssign({
|
||||
"target": kcContext.properties,
|
||||
"source": mockProperties
|
||||
});
|
||||
}
|
||||
|
||||
return { kcContext };
|
||||
}
|
||||
|
||||
@ -87,16 +97,10 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
|
||||
return { "kcContext": undefined as any };
|
||||
}
|
||||
|
||||
if (id<readonly string[]>(accountThemePageIds).indexOf(realKcContext.pageId) < 0 && !("account" in realKcContext)) {
|
||||
if (realKcContext.themeType !== "account") {
|
||||
return { "kcContext": undefined as any };
|
||||
}
|
||||
|
||||
{
|
||||
const { url } = realKcContext;
|
||||
|
||||
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(resourcesCommonDirPathRelativeToPublicDir));
|
||||
}
|
||||
|
||||
return { "kcContext": realKcContext as any };
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey";
|
||||
import { ftlValuesGlobalName } from "keycloakify/bin/keycloakify/ftlValuesGlobalName";
|
||||
import { nameOfTheGlobal } from "keycloakify/bin/constants";
|
||||
import type { KcContext } from "./KcContext";
|
||||
|
||||
export type ExtendKcContext<KcContextExtension extends { pageId: string }> = [KcContextExtension] extends [never]
|
||||
@ -7,5 +7,5 @@ export type ExtendKcContext<KcContextExtension extends { pageId: string }> = [Kc
|
||||
: AndByDiscriminatingKey<"pageId", KcContextExtension & KcContext.Common, KcContext>;
|
||||
|
||||
export function getKcContextFromWindow<KcContextExtension extends { pageId: string } = never>(): ExtendKcContext<KcContextExtension> | undefined {
|
||||
return typeof window === "undefined" ? undefined : (window as any)[ftlValuesGlobalName];
|
||||
return typeof window === "undefined" ? undefined : (window as any)[nameOfTheGlobal];
|
||||
}
|
||||
|
@ -1,16 +1,19 @@
|
||||
import "minimal-polyfills/Object.fromEntries";
|
||||
import { resourcesCommonDirPathRelativeToPublicDir, resourcesDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
|
||||
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
|
||||
import { resources_common, keycloak_resources } from "keycloakify/bin/constants";
|
||||
import { id } from "tsafe/id";
|
||||
import type { KcContext } from "./KcContext";
|
||||
import { BASE_URL } from "keycloakify/lib/BASE_URL";
|
||||
|
||||
const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
|
||||
const resourcesPath = `${BASE_URL}${keycloak_resources}/account/resources`;
|
||||
|
||||
export const kcContextCommonMock: KcContext.Common = {
|
||||
"themeVersion": "0.0.0",
|
||||
"keycloakifyVersion": "0.0.0",
|
||||
"themeType": "account",
|
||||
"themeName": "my-theme-name",
|
||||
"url": {
|
||||
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
|
||||
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
|
||||
resourcesPath,
|
||||
"resourcesCommonPath": `${resourcesPath}/${resources_common}`,
|
||||
"resourceUrl": "#",
|
||||
"accountUrl": "#",
|
||||
"applicationsUrl": "#",
|
||||
@ -130,10 +133,6 @@ export const kcContextCommonMock: KcContext.Common = {
|
||||
],
|
||||
"currentLanguageTag": "en"
|
||||
},
|
||||
"message": {
|
||||
"type": "success",
|
||||
"summary": "This is a test message"
|
||||
},
|
||||
"features": {
|
||||
"authorization": true,
|
||||
"identityFederation": true,
|
||||
@ -146,6 +145,28 @@ export const kcContextCommonMock: KcContext.Common = {
|
||||
"lastName": "doe",
|
||||
"email": "john.doe@code.gouv.fr",
|
||||
"username": "doe_j"
|
||||
},
|
||||
"properties": {
|
||||
"parent": "account-v1",
|
||||
"kcButtonLargeClass": "btn-lg",
|
||||
"locales": "ar,ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN",
|
||||
"kcButtonPrimaryClass": "btn-primary",
|
||||
"accountResourceProvider": "account-v1",
|
||||
"styles":
|
||||
"css/account.css img/icon-sidebar-active.png img/logo.png resources-common/node_modules/patternfly/dist/css/patternfly.min.css resources-common/node_modules/patternfly/dist/css/patternfly-additions.min.css resources-common/node_modules/patternfly/dist/css/patternfly-additions.min.css",
|
||||
"kcButtonClass": "btn",
|
||||
"kcButtonDefaultClass": "btn-default"
|
||||
},
|
||||
"sessions": {
|
||||
"sessions": [
|
||||
{
|
||||
"ipAddress": "127.0.0.1",
|
||||
"started": new Date().toString(),
|
||||
"lastAccess": new Date().toString(),
|
||||
"expires": new Date().toString(),
|
||||
"clients": ["Chrome", "Firefox"]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@ -172,5 +193,62 @@ export const kcContextMocks: KcContext[] = [
|
||||
"editUsernameAllowed": true
|
||||
},
|
||||
"stateChecker": ""
|
||||
}),
|
||||
id<KcContext.Sessions>({
|
||||
...kcContextCommonMock,
|
||||
"pageId": "sessions.ftl",
|
||||
"sessions": {
|
||||
"sessions": [
|
||||
{
|
||||
...kcContextCommonMock.sessions,
|
||||
"ipAddress": "127.0.0.1",
|
||||
"started": new Date().toString(),
|
||||
"lastAccess": new Date().toString(),
|
||||
"expires": new Date().toString(),
|
||||
"clients": ["Chrome", "Firefox"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"stateChecker": "g6WB1FaYnKotTkiy7ZrlxvFztSqS0U8jvHsOOOb2z4g"
|
||||
}),
|
||||
id<KcContext.Totp>({
|
||||
...kcContextCommonMock,
|
||||
"pageId": "totp.ftl",
|
||||
"totp": {
|
||||
"enabled": true,
|
||||
"totpSecretEncoded": "KVVF G2BY N4YX S6LB IUYT K2LH IFYE 4SBV",
|
||||
"qrUrl": "#",
|
||||
"totpSecretQrCode":
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAPYAAAD2AQAAAADNaUdlAAACM0lEQVR4Xu3OIZJgOQwDUDFd2UxiurLAVnnbHw4YGDKtSiWOn4Gxf81//7r/+q8b4HfLGBZDK9d85NmNR+sB42sXvOYrN5P1DcgYYFTGfOlbzE8gzwy3euweGizw7cfdl34/GRhlkxjKNV+5AebPXPORX1JuB9x8ZfbyyD2y1krWAKsbMq1HnqQDaLfa77p4+MqvzEGSqvSAD/2IHW2yHaigR9tX3m8dDIYGcNf3f+gDpVBZbZU77zyJ6Rlcy+qoTMG887KAPD9hsh6a1Sv3gJUHGHUAxSMzj7zqDDe7Phmt2eG+8UsMxjRGm816MAO+8VMl1R1jGHOrZB/5Zo/WXAPgxixm9Mo96vDGrM1eOto8c4Ax4wF437mifOXlpiPzCnN7Y9l95NnEMxgMY9AAGA8fucH14Y1aVb6N/cqrmyh0BVht7k1e+bU8LK0Cg5vmVq9c5vHIjOfqxDIfeTraNVTwewa4wVe+SW5N+uP1qACeudUZbqGOfA6VZV750Noq2Xx3kpveV44ZelSV1V7KFHzkWyVrrlUwG0Pl9pWnoy3vsQoME6vKI69i5osVgwWzHT7zjmJtMcNUSVn1oYMd7ZodbgowZl45VG0uVuLPUr1yc79uaQBag/mqR34xhlWyHm1prplHboCWdZ4TeZjsK8+dI+jbz1C5hl65mcpgB5dhcj8+dGO+0Ko68+lD37JDD83dpDLzzK+TrQyaVwGj6pUboGV+7+AyN8An/pf84/7rv/4/1l4OCc/1BYMAAAAASUVORK5CYII=",
|
||||
"manualUrl": "#",
|
||||
"totpSecret": "G4nsI8lQagRMUchH8jEG",
|
||||
"otpCredentials": [],
|
||||
"supportedApplications": ["totpAppFreeOTPName", "totpAppMicrosoftAuthenticatorName", "totpAppGoogleName"],
|
||||
"policy": {
|
||||
"algorithm": "HmacSHA1",
|
||||
"digits": 6,
|
||||
"lookAheadWindow": 1,
|
||||
"type": "totp",
|
||||
"period": 30
|
||||
}
|
||||
},
|
||||
"mode": "qr",
|
||||
"isAppInitiatedAction": false,
|
||||
"stateChecker": ""
|
||||
}),
|
||||
id<KcContext.Log>({
|
||||
...kcContextCommonMock,
|
||||
"pageId": "log.ftl",
|
||||
"log": {
|
||||
"events": [
|
||||
{
|
||||
"date": "2/21/2024, 1:28:39 PM",
|
||||
"event": "login",
|
||||
"ipAddress": "172.17.0.1",
|
||||
"client": "security-admin-console",
|
||||
"details": ["auth_method = openid-connect, username = admin"]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
];
|
||||
|
@ -3,10 +3,18 @@ import type { ClassKey } from "keycloakify/account/TemplateProps";
|
||||
|
||||
export const { useGetClassName } = createUseClassName<ClassKey>({
|
||||
"defaultClasses": {
|
||||
"kcHtmlClass": undefined,
|
||||
"kcBodyClass": undefined,
|
||||
"kcButtonClass": "btn",
|
||||
"kcContentWrapperClass": "row",
|
||||
"kcButtonPrimaryClass": "btn-primary",
|
||||
"kcButtonLargeClass": "btn-lg",
|
||||
"kcButtonDefaultClass": "btn-default"
|
||||
"kcButtonDefaultClass": "btn-default",
|
||||
"kcFormClass": "form-horizontal",
|
||||
"kcFormGroupClass": "form-group",
|
||||
"kcInputWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
|
||||
"kcLabelClass": "control-label",
|
||||
"kcInputClass": "form-control",
|
||||
"kcInputErrorMessageClass": "pf-c-form__helper-text pf-m-error required kc-feedback-text"
|
||||
}
|
||||
});
|
||||
|
@ -51,7 +51,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
|
||||
id="username"
|
||||
name="username"
|
||||
disabled={!realm.editUsernameAllowed}
|
||||
value={account.username ?? ""}
|
||||
defaultValue={account.username ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -66,7 +66,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
|
||||
</div>
|
||||
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input type="text" className="form-control" id="email" name="email" autoFocus value={account.email ?? ""} />
|
||||
<input type="text" className="form-control" id="email" name="email" autoFocus defaultValue={account.email ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -79,7 +79,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
|
||||
</div>
|
||||
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input type="text" className="form-control" id="firstName" name="firstName" value={account.firstName ?? ""} />
|
||||
<input type="text" className="form-control" id="firstName" name="firstName" defaultValue={account.firstName ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -92,7 +92,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
|
||||
</div>
|
||||
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input type="text" className="form-control" id="lastName" name="lastName" value={account.lastName ?? ""} />
|
||||
<input type="text" className="form-control" id="lastName" name="lastName" defaultValue={account.lastName ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
138
src/account/pages/Applications.tsx
Normal file
138
src/account/pages/Applications.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
function isArrayWithEmptyObject(variable: any): boolean {
|
||||
return Array.isArray(variable) && variable.length === 1 && typeof variable[0] === "object" && Object.keys(variable[0]).length === 0;
|
||||
}
|
||||
|
||||
export default function Applications(props: PageProps<Extract<KcContext, { pageId: "applications.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
|
||||
|
||||
const { getClassName } = useGetClassName({
|
||||
doUseDefaultCss,
|
||||
classes
|
||||
});
|
||||
|
||||
const {
|
||||
url,
|
||||
applications: { applications },
|
||||
stateChecker
|
||||
} = kcContext;
|
||||
|
||||
const { msg, advancedMsg } = i18n;
|
||||
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="applications">
|
||||
<div className="row">
|
||||
<div className="col-md-10">
|
||||
<h2>{msg("applicationsHtmlTitle")}</h2>
|
||||
</div>
|
||||
|
||||
<form action={url.applicationsUrl} method="post">
|
||||
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
|
||||
<input type="hidden" id="referrer" name="referrer" value={stateChecker} />
|
||||
|
||||
<table className="table table-striped table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{msg("application")}</td>
|
||||
<td>{msg("availableRoles")}</td>
|
||||
<td>{msg("grantedPermissions")}</td>
|
||||
<td>{msg("additionalGrants")}</td>
|
||||
<td>{msg("action")}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{applications.map(application => (
|
||||
<tr key={application.client.clientId}>
|
||||
<td>
|
||||
{application.effectiveUrl && (
|
||||
<a href={application.effectiveUrl}>
|
||||
{(application.client.name && advancedMsg(application.client.name)) || application.client.clientId}
|
||||
</a>
|
||||
)}
|
||||
{!application.effectiveUrl &&
|
||||
((application.client.name && advancedMsg(application.client.name)) || application.client.clientId)}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{!isArrayWithEmptyObject(application.realmRolesAvailable) &&
|
||||
application.realmRolesAvailable.map(role => (
|
||||
<span key={role.name}>
|
||||
{role.description ? advancedMsg(role.description) : advancedMsg(role.name)}
|
||||
{role !== application.realmRolesAvailable[application.realmRolesAvailable.length - 1] && ", "}
|
||||
</span>
|
||||
))}
|
||||
{!isArrayWithEmptyObject(application.realmRolesAvailable) && application.resourceRolesAvailable && ", "}
|
||||
{application.resourceRolesAvailable &&
|
||||
Object.keys(application.resourceRolesAvailable).map(resource => (
|
||||
<span key={resource}>
|
||||
{!isArrayWithEmptyObject(application.realmRolesAvailable) && ", "}
|
||||
{application.resourceRolesAvailable[resource].map(clientRole => (
|
||||
<span key={clientRole.roleName}>
|
||||
{clientRole.roleDescription
|
||||
? advancedMsg(clientRole.roleDescription)
|
||||
: advancedMsg(clientRole.roleName)}{" "}
|
||||
{msg("inResource")}{" "}
|
||||
<strong>
|
||||
{clientRole.clientName ? advancedMsg(clientRole.clientName) : clientRole.clientId}
|
||||
</strong>
|
||||
{clientRole !==
|
||||
application.resourceRolesAvailable[resource][
|
||||
application.resourceRolesAvailable[resource].length - 1
|
||||
] && ", "}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{application.client.consentRequired ? (
|
||||
application.clientScopesGranted.map(claim => (
|
||||
<span key={claim}>
|
||||
{advancedMsg(claim)}
|
||||
{claim !== application.clientScopesGranted[application.clientScopesGranted.length - 1] && ", "}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<strong>{msg("fullAccess")}</strong>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{application.additionalGrants.map(grant => (
|
||||
<span key={grant}>
|
||||
{advancedMsg(grant)}
|
||||
{grant !== application.additionalGrants[application.additionalGrants.length - 1] && ", "}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{(application.client.consentRequired && application.clientScopesGranted.length > 0) ||
|
||||
application.additionalGrants.length > 0 ? (
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(getClassName("kcButtonPrimaryClass"), getClassName("kcButtonClass"))}
|
||||
id={`revoke-${application.client.clientId}`}
|
||||
name="clientId"
|
||||
value={application.client.id}
|
||||
>
|
||||
{msg("revoke")}
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
</Template>
|
||||
);
|
||||
}
|
70
src/account/pages/Log.tsx
Normal file
70
src/account/pages/Log.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
import { Key } from "react";
|
||||
import { useGetClassName } from "../lib/useGetClassName";
|
||||
|
||||
export default function Log(props: PageProps<Extract<KcContext, { pageId: "log.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
|
||||
|
||||
const { getClassName } = useGetClassName({
|
||||
doUseDefaultCss,
|
||||
classes
|
||||
});
|
||||
|
||||
const { log } = kcContext;
|
||||
|
||||
const { msg } = i18n;
|
||||
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="log">
|
||||
<div className={getClassName("kcContentWrapperClass")}>
|
||||
<div className="col-md-10">
|
||||
<h2>{msg("accountLogHtmlTitle")}</h2>
|
||||
</div>
|
||||
|
||||
<table className="table table-striped table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{msg("date")}</td>
|
||||
<td>{msg("event")}</td>
|
||||
<td>{msg("ip")}</td>
|
||||
<td>{msg("client")}</td>
|
||||
<td>{msg("details")}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{log.events.map(
|
||||
(
|
||||
event: {
|
||||
date: string | number | Date;
|
||||
event: string;
|
||||
ipAddress: string;
|
||||
client: any;
|
||||
details: any[];
|
||||
},
|
||||
index: Key | null | undefined
|
||||
) => (
|
||||
<tr key={index}>
|
||||
<td>{event.date ? new Date(event.date).toLocaleString() : ""}</td>
|
||||
<td>{event.event}</td>
|
||||
<td>{event.ipAddress}</td>
|
||||
<td>{event.client || ""}</td>
|
||||
<td>
|
||||
{event.details.map((detail, detailIndex) => (
|
||||
<span key={detailIndex}>
|
||||
{`${detail.key} = ${detail.value}`}
|
||||
{detailIndex < event.details.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Template>
|
||||
);
|
||||
}
|
@ -1,10 +1,11 @@
|
||||
import type { LazyExoticComponent } from "react";
|
||||
import type { I18n } from "keycloakify/account/i18n";
|
||||
import type { TemplateProps, ClassKey } from "keycloakify/account/TemplateProps";
|
||||
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
|
||||
import type { KcContext } from "keycloakify/account/kcContext";
|
||||
|
||||
export type PageProps<KcContext, I18nExtended extends I18n> = {
|
||||
Template: LazyExoticComponent<(props: TemplateProps<any, any>) => JSX.Element | null>;
|
||||
kcContext: KcContext;
|
||||
export type PageProps<NarowedKcContext = KcContext, I18nExtended extends I18n = I18n> = {
|
||||
Template: LazyOrNot<(props: TemplateProps<any, any>) => JSX.Element | null>;
|
||||
kcContext: NarowedKcContext;
|
||||
i18n: I18nExtended;
|
||||
doUseDefaultCss: boolean;
|
||||
classes?: Partial<Record<ClassKey, string>>;
|
||||
|
68
src/account/pages/Sessions.tsx
Normal file
68
src/account/pages/Sessions.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export default function Sessions(props: PageProps<Extract<KcContext, { pageId: "sessions.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
|
||||
|
||||
const { getClassName } = useGetClassName({
|
||||
doUseDefaultCss,
|
||||
classes
|
||||
});
|
||||
|
||||
console.log({ kcContext });
|
||||
const { url, stateChecker, sessions } = kcContext;
|
||||
|
||||
const { msg } = i18n;
|
||||
console.log({ sdf: kcContext.locale?.supported });
|
||||
console.log({ asdf: "asdf" });
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="sessions">
|
||||
<div className={getClassName("kcContentWrapperClass")}>
|
||||
<div className="col-md-10">
|
||||
<h2>{msg("sessionsHtmlTitle")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="table table-striped table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{msg("ip")}</th>
|
||||
<th>{msg("started")}</th>
|
||||
<th>{msg("lastAccess")}</th>
|
||||
<th>{msg("expires")}</th>
|
||||
<th>{msg("clients")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody role="rowgroup">
|
||||
{sessions.sessions.map((session, index: number) => (
|
||||
<tr key={index}>
|
||||
<td>{session.ipAddress}</td>
|
||||
<td>{session?.started}</td>
|
||||
<td>{session?.lastAccess}</td>
|
||||
<td>{session?.expires}</td>
|
||||
<td>
|
||||
{session.clients.map((client: string, clientIndex: number) => (
|
||||
<div key={clientIndex}>
|
||||
{client}
|
||||
<br />
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form action={url.sessionsUrl} method="post">
|
||||
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
|
||||
<button id="logout-all-sessions" type="submit" className={clsx(getClassName("kcButtonDefaultClass"), getClassName("kcButtonClass"))}>
|
||||
{msg("doLogOutAllSessions")}
|
||||
</button>
|
||||
</form>
|
||||
</Template>
|
||||
);
|
||||
}
|
236
src/account/pages/Totp.tsx
Normal file
236
src/account/pages/Totp.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
import { MessageKey } from "keycloakify/account/i18n/i18n";
|
||||
|
||||
export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
|
||||
const { getClassName } = useGetClassName({
|
||||
doUseDefaultCss,
|
||||
classes
|
||||
});
|
||||
|
||||
const { totp, mode, url, messagesPerField, stateChecker } = kcContext;
|
||||
|
||||
const { msg, msgStr } = i18n;
|
||||
|
||||
const algToKeyUriAlg: Record<(typeof kcContext)["totp"]["policy"]["algorithm"], string> = {
|
||||
"HmacSHA1": "SHA1",
|
||||
"HmacSHA256": "SHA256",
|
||||
"HmacSHA512": "SHA512"
|
||||
};
|
||||
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="totp">
|
||||
<>
|
||||
<div className="row">
|
||||
<div className="col-md-10">
|
||||
<h2>{msg("authenticatorTitle")}</h2>
|
||||
</div>
|
||||
{totp.otpCredentials.length === 0 && (
|
||||
<div className="subtitle col-md-2">
|
||||
<span className="required">*</span>
|
||||
{msg("requiredFields")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totp.enabled && (
|
||||
<table className="table table-bordered table-striped">
|
||||
<thead>
|
||||
{totp.otpCredentials.length > 1 ? (
|
||||
<tr>
|
||||
<th colSpan={4}>{msg("configureAuthenticators")}</th>
|
||||
</tr>
|
||||
) : (
|
||||
<tr>
|
||||
<th colSpan={3}>{msg("configureAuthenticators")}</th>
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{totp.otpCredentials.map((credential, index) => (
|
||||
<tr key={index}>
|
||||
<td className="provider">{msg("mobile")}</td>
|
||||
{totp.otpCredentials.length > 1 && <td className="provider">{credential.id}</td>}
|
||||
<td className="provider">{credential.userLabel || ""}</td>
|
||||
<td className="action">
|
||||
<form action={url.totpUrl} method="post" className="form-inline">
|
||||
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
|
||||
<input type="hidden" id="submitAction" name="submitAction" value="Delete" />
|
||||
<input type="hidden" id="credentialId" name="credentialId" value={credential.id} />
|
||||
<button id={`remove-mobile-${index}`} className="btn btn-default">
|
||||
<i className="pficon pficon-delete"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{!totp.enabled && (
|
||||
<div>
|
||||
<hr />
|
||||
<ol id="kc-totp-settings">
|
||||
<li>
|
||||
<p>{msg("totpStep1")}</p>
|
||||
|
||||
<ul id="kc-totp-supported-apps">
|
||||
{totp.supportedApplications.map(app => (
|
||||
<li key={app}>{msg(app as MessageKey)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
{mode && mode == "manual" ? (
|
||||
<>
|
||||
<li>
|
||||
<p>{msg("totpManualStep2")}</p>
|
||||
<p>
|
||||
<span id="kc-totp-secret-key">{totp.totpSecretEncoded}</span>
|
||||
</p>
|
||||
<p>
|
||||
<a href={totp.qrUrl} id="mode-barcode">
|
||||
{msg("totpScanBarcode")}
|
||||
</a>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>{msg("totpManualStep3")}</p>
|
||||
<p>
|
||||
<ul>
|
||||
<li id="kc-totp-type">
|
||||
{msg("totpType")}: {msg(`totp.${totp.policy.type}`)}
|
||||
</li>
|
||||
<li id="kc-totp-algorithm">
|
||||
{msg("totpAlgorithm")}: {algToKeyUriAlg?.[totp.policy.algorithm] ?? totp.policy.algorithm}
|
||||
</li>
|
||||
<li id="kc-totp-digits">
|
||||
{msg("totpDigits")}: {totp.policy.digits}
|
||||
</li>
|
||||
{totp.policy.type === "totp" ? (
|
||||
<li id="kc-totp-period">
|
||||
{msg("totpInterval")}: {totp.policy.period}
|
||||
</li>
|
||||
) : (
|
||||
<li id="kc-totp-counter">
|
||||
{msg("totpCounter")}: {totp.policy.initialCounter}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</p>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<li>
|
||||
<p>{msg("totpStep2")}</p>
|
||||
<p>
|
||||
<img
|
||||
id="kc-totp-secret-qr-code"
|
||||
src={`data:image/png;base64, ${totp.totpSecretQrCode}`}
|
||||
alt="Figure: Barcode"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<a href={totp.manualUrl} id="mode-manual">
|
||||
{msg("totpUnableToScan")}
|
||||
</a>
|
||||
</p>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<p>{msg("totpStep3")}</p>
|
||||
<p>{msg("totpStep3DeviceName")}</p>
|
||||
</li>
|
||||
</ol>
|
||||
<hr />
|
||||
<form action={url.totpUrl} className={getClassName("kcFormClass")} id="kc-totp-settings-form" method="post">
|
||||
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
|
||||
<div className={getClassName("kcFormGroupClass")}>
|
||||
<div className="col-sm-2 col-md-2">
|
||||
<label htmlFor="totp" className="control-label">
|
||||
{msg("authenticatorCode")}
|
||||
</label>
|
||||
<span className="required">*</span>
|
||||
</div>
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input
|
||||
type="text"
|
||||
id="totp"
|
||||
name="totp"
|
||||
autoComplete="off"
|
||||
className={getClassName("kcInputClass")}
|
||||
aria-invalid={messagesPerField.existsError("totp")}
|
||||
/>
|
||||
|
||||
{messagesPerField.existsError("totp") && (
|
||||
<span id="input-error-otp-code" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
|
||||
{messagesPerField.get("totp")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<input type="hidden" id="totpSecret" name="totpSecret" value={totp.totpSecret} />
|
||||
{mode && <input type="hidden" id="mode" value={mode} />}
|
||||
</div>
|
||||
|
||||
<div className={getClassName("kcFormGroupClass")}>
|
||||
<div className="col-sm-2 col-md-2">
|
||||
<label htmlFor="userLabel" className={getClassName("kcLabelClass")}>
|
||||
{msg("totpDeviceName")}
|
||||
</label>
|
||||
{totp.otpCredentials.length >= 1 && <span className="required">*</span>}
|
||||
</div>
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input
|
||||
type="text"
|
||||
id="userLabel"
|
||||
name="userLabel"
|
||||
autoComplete="off"
|
||||
className={getClassName("kcInputClass")}
|
||||
aria-invalid={messagesPerField.existsError("userLabel")}
|
||||
/>
|
||||
{messagesPerField.existsError("userLabel") && (
|
||||
<span id="input-error-otp-label" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
|
||||
{messagesPerField.get("userLabel")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="kc-form-buttons" className={clsx(getClassName("kcFormGroupClass"), "text-right")}>
|
||||
<div className={getClassName("kcInputWrapperClass")}>
|
||||
<input
|
||||
type="submit"
|
||||
className={clsx(
|
||||
getClassName("kcButtonClass"),
|
||||
getClassName("kcButtonPrimaryClass"),
|
||||
getClassName("kcButtonLargeClass")
|
||||
)}
|
||||
id="saveTOTPBtn"
|
||||
value={msgStr("doSave")}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
getClassName("kcButtonClass"),
|
||||
getClassName("kcButtonDefaultClass"),
|
||||
getClassName("kcButtonLargeClass"),
|
||||
getClassName("kcButtonLargeClass")
|
||||
)}
|
||||
id="cancelTOTPBtn"
|
||||
name="submitAction"
|
||||
value="Cancel"
|
||||
>
|
||||
{msg("doCancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Template>
|
||||
);
|
||||
}
|
13
src/bin/constants.ts
Normal file
13
src/bin/constants.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export const nameOfTheGlobal = "kcContext";
|
||||
export const keycloak_resources = "keycloak-resources";
|
||||
export const resources_common = "resources-common";
|
||||
export const lastKeycloakVersionWithAccountV1 = "21.1.2";
|
||||
export const resolvedViteConfigJsonBasename = "vite.json";
|
||||
export const basenameOfTheKeycloakifyResourcesDir = "build";
|
||||
|
||||
export const themeTypes = ["login", "account"] as const;
|
||||
export const accountV1ThemeName = "account-v1";
|
||||
|
||||
export type ThemeType = (typeof themeTypes)[number];
|
||||
|
||||
export const keycloakifyBuildOptionsForPostPostBuildScriptEnvName = "KEYCLOAKIFY_BUILD_OPTIONS_POST_POST_BUILD_SCRIPT";
|
@ -1,38 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { downloadKeycloakStaticResources } from "./keycloakify/generateTheme/downloadKeycloakStaticResources";
|
||||
import { downloadKeycloakStaticResources, type BuildOptionsLike } from "./keycloakify/generateTheme/downloadKeycloakStaticResources";
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
import { basenameOfKeycloakDirInPublicDir } from "./mockTestingResourcesPath";
|
||||
import { readBuildOptions } from "./keycloakify/BuildOptions";
|
||||
import { themeTypes } from "./keycloakify/generateFtl";
|
||||
import { readBuildOptions } from "./keycloakify/buildOptions";
|
||||
import { themeTypes, keycloak_resources, lastKeycloakVersionWithAccountV1 } from "./constants";
|
||||
import { readThisNpmProjectVersion } from "./tools/readThisNpmProjectVersion";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import * as fs from "fs";
|
||||
import { rmSync } from "./tools/fs.rmSync";
|
||||
|
||||
(async () => {
|
||||
const projectDirPath = process.cwd();
|
||||
export async function copyKeycloakResourcesToPublic(params: { processArgv: string[] }) {
|
||||
const { processArgv } = params;
|
||||
|
||||
const buildOptions = readBuildOptions({
|
||||
"processArgv": process.argv.slice(2),
|
||||
"projectDirPath": process.cwd()
|
||||
const buildOptions = readBuildOptions({ processArgv });
|
||||
|
||||
const destDirPath = pathJoin(buildOptions.publicDirPath, keycloak_resources);
|
||||
|
||||
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
|
||||
|
||||
const { keycloakifyBuildinfoRaw } = generateKeycloakifyBuildinfoRaw({
|
||||
destDirPath,
|
||||
"keycloakifyVersion": readThisNpmProjectVersion(),
|
||||
buildOptions
|
||||
});
|
||||
|
||||
const keycloakDirInPublicDir = pathJoin(process.env["PUBLIC_DIR_PATH"] || pathJoin(projectDirPath, "public"), basenameOfKeycloakDirInPublicDir);
|
||||
skip_if_already_done: {
|
||||
if (!fs.existsSync(keycloakifyBuildinfoFilePath)) {
|
||||
break skip_if_already_done;
|
||||
}
|
||||
|
||||
const keycloakifyBuildinfoRaw_previousRun = fs.readFileSync(keycloakifyBuildinfoFilePath).toString("utf8");
|
||||
|
||||
if (keycloakifyBuildinfoRaw_previousRun !== keycloakifyBuildinfoRaw) {
|
||||
break skip_if_already_done;
|
||||
}
|
||||
|
||||
if (fs.existsSync(keycloakDirInPublicDir)) {
|
||||
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
rmSync(destDirPath, { "force": true, "recursive": true });
|
||||
|
||||
for (const themeType of themeTypes) {
|
||||
await downloadKeycloakStaticResources({
|
||||
"isSilent": false,
|
||||
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
|
||||
"themeType": themeType,
|
||||
"themeDirPath": keycloakDirInPublicDir
|
||||
"keycloakVersion": (() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return buildOptions.loginThemeResourcesFromKeycloakVersion;
|
||||
case "account":
|
||||
return lastKeycloakVersionWithAccountV1;
|
||||
}
|
||||
})(),
|
||||
themeType,
|
||||
"themeDirPath": destDirPath,
|
||||
buildOptions
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(keycloakDirInPublicDir, "README.txt"),
|
||||
pathJoin(destDirPath, "README.txt"),
|
||||
Buffer.from(
|
||||
// prettier-ignore
|
||||
[
|
||||
@ -42,7 +67,46 @@ import * as fs from "fs";
|
||||
)
|
||||
);
|
||||
|
||||
fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8"));
|
||||
fs.writeFileSync(pathJoin(buildOptions.publicDirPath, keycloak_resources, ".gitignore"), Buffer.from("*", "utf8"));
|
||||
|
||||
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} directory created.`);
|
||||
})();
|
||||
fs.writeFileSync(keycloakifyBuildinfoFilePath, Buffer.from(keycloakifyBuildinfoRaw, "utf8"));
|
||||
}
|
||||
|
||||
export function generateKeycloakifyBuildinfoRaw(params: {
|
||||
destDirPath: string;
|
||||
keycloakifyVersion: string;
|
||||
buildOptions: BuildOptionsLike & {
|
||||
loginThemeResourcesFromKeycloakVersion: string;
|
||||
};
|
||||
}) {
|
||||
const { destDirPath, keycloakifyVersion, buildOptions } = params;
|
||||
|
||||
const { cacheDirPath, npmWorkspaceRootDirPath, loginThemeResourcesFromKeycloakVersion, ...rest } = buildOptions;
|
||||
|
||||
assert<Equals<typeof rest, {}>>(true);
|
||||
|
||||
const keycloakifyBuildinfoRaw = JSON.stringify(
|
||||
{
|
||||
keycloakifyVersion,
|
||||
"buildOptions": {
|
||||
loginThemeResourcesFromKeycloakVersion,
|
||||
"cacheDirPath": pathRelative(destDirPath, cacheDirPath),
|
||||
"npmWorkspaceRootDirPath": pathRelative(destDirPath, npmWorkspaceRootDirPath)
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
return { keycloakifyBuildinfoRaw };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await copyKeycloakResourcesToPublic({
|
||||
"processArgv": process.argv.slice(2)
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
@ -1,27 +1,273 @@
|
||||
#!/usr/bin/env node
|
||||
import { join as pathJoin } from "path";
|
||||
import { downloadAndUnzip } from "./tools/downloadAndUnzip";
|
||||
import { downloadAndUnzip } from "./downloadAndUnzip";
|
||||
import { promptKeycloakVersion } from "./promptKeycloakVersion";
|
||||
import { getLogger } from "./tools/logger";
|
||||
import { readBuildOptions } from "./keycloakify/BuildOptions";
|
||||
import { readBuildOptions, type BuildOptions } from "./keycloakify/buildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import * as child_process from "child_process";
|
||||
import * as fs from "fs";
|
||||
import { rmSync } from "./tools/fs.rmSync";
|
||||
import { lastKeycloakVersionWithAccountV1 } from "./constants";
|
||||
import { transformCodebase } from "./tools/transformCodebase";
|
||||
|
||||
export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: string; destDirPath: string; isSilent: boolean }) {
|
||||
const { keycloakVersion, destDirPath } = params;
|
||||
export type BuildOptionsLike = {
|
||||
cacheDirPath: string;
|
||||
npmWorkspaceRootDirPath: string;
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
["", "-community"].map(ext =>
|
||||
downloadAndUnzip({
|
||||
"destDirPath": destDirPath,
|
||||
"url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`,
|
||||
"pathOfDirToExtractInArchive": `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`
|
||||
})
|
||||
)
|
||||
);
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: string; destDirPath: string; buildOptions: BuildOptionsLike }) {
|
||||
const { keycloakVersion, destDirPath, buildOptions } = params;
|
||||
|
||||
await downloadAndUnzip({
|
||||
destDirPath,
|
||||
"url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`,
|
||||
"specificDirsToExtract": ["", "-community"].map(ext => `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`),
|
||||
buildOptions,
|
||||
"preCacheTransform": {
|
||||
"actionCacheId": "npm install and build",
|
||||
"action": async ({ destDirPath }) => {
|
||||
install_common_node_modules: {
|
||||
const commonResourcesDirPath = pathJoin(destDirPath, "keycloak", "common", "resources");
|
||||
|
||||
if (!fs.existsSync(commonResourcesDirPath)) {
|
||||
break install_common_node_modules;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(pathJoin(commonResourcesDirPath, "package.json"))) {
|
||||
break install_common_node_modules;
|
||||
}
|
||||
|
||||
if (fs.existsSync(pathJoin(commonResourcesDirPath, "node_modules"))) {
|
||||
break install_common_node_modules;
|
||||
}
|
||||
|
||||
child_process.execSync("npm install --omit=dev", {
|
||||
"cwd": commonResourcesDirPath,
|
||||
"stdio": "ignore"
|
||||
});
|
||||
}
|
||||
|
||||
repatriate_common_resources_from_base_login_theme: {
|
||||
const baseLoginThemeResourceDir = pathJoin(destDirPath, "base", "login", "resources");
|
||||
|
||||
if (!fs.existsSync(baseLoginThemeResourceDir)) {
|
||||
break repatriate_common_resources_from_base_login_theme;
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": baseLoginThemeResourceDir,
|
||||
"destDirPath": pathJoin(destDirPath, "keycloak", "login", "resources")
|
||||
});
|
||||
}
|
||||
|
||||
install_and_move_to_common_resources_generated_in_keycloak_v2: {
|
||||
if (!fs.readFileSync(pathJoin(destDirPath, "keycloak", "login", "theme.properties")).toString("utf8").includes("web_modules")) {
|
||||
break install_and_move_to_common_resources_generated_in_keycloak_v2;
|
||||
}
|
||||
|
||||
const accountV2DirSrcDirPath = pathJoin(destDirPath, "keycloak.v2", "account", "src");
|
||||
|
||||
if (!fs.existsSync(accountV2DirSrcDirPath)) {
|
||||
break install_and_move_to_common_resources_generated_in_keycloak_v2;
|
||||
}
|
||||
|
||||
const packageManager = fs.existsSync(pathJoin(accountV2DirSrcDirPath, "pnpm-lock.yaml")) ? "pnpm" : "npm";
|
||||
|
||||
if (packageManager === "pnpm") {
|
||||
try {
|
||||
child_process.execSync(`which pnpm`);
|
||||
} catch {
|
||||
console.log(`Installing pnpm globally`);
|
||||
child_process.execSync(`npm install -g pnpm`);
|
||||
}
|
||||
}
|
||||
|
||||
child_process.execSync(`${packageManager} install`, { "cwd": accountV2DirSrcDirPath, "stdio": "ignore" });
|
||||
|
||||
const packageJsonFilePath = pathJoin(accountV2DirSrcDirPath, "package.json");
|
||||
|
||||
const packageJsonRaw = fs.readFileSync(packageJsonFilePath);
|
||||
|
||||
const parsedPackageJson = JSON.parse(packageJsonRaw.toString("utf8"));
|
||||
|
||||
parsedPackageJson.scripts.build = parsedPackageJson.scripts.build
|
||||
.replace(`${packageManager} run check-types`, "true")
|
||||
.replace(`${packageManager} run babel`, "true");
|
||||
|
||||
fs.writeFileSync(packageJsonFilePath, Buffer.from(JSON.stringify(parsedPackageJson, null, 2), "utf8"));
|
||||
|
||||
child_process.execSync(`${packageManager} run build`, { "cwd": accountV2DirSrcDirPath, "stdio": "ignore" });
|
||||
|
||||
fs.writeFileSync(packageJsonFilePath, packageJsonRaw);
|
||||
|
||||
fs.rmSync(pathJoin(accountV2DirSrcDirPath, "node_modules"), { "recursive": true });
|
||||
}
|
||||
|
||||
remove_keycloak_v2: {
|
||||
const keycloakV2DirPath = pathJoin(destDirPath, "keycloak.v2");
|
||||
|
||||
if (!fs.existsSync(keycloakV2DirPath)) {
|
||||
break remove_keycloak_v2;
|
||||
}
|
||||
|
||||
rmSync(keycloakV2DirPath, { "recursive": true });
|
||||
}
|
||||
|
||||
// Note, this is an optimization for reducing the size of the jar
|
||||
remove_unused_node_modules: {
|
||||
const nodeModuleDirPath = pathJoin(destDirPath, "keycloak", "common", "resources", "node_modules");
|
||||
|
||||
if (!fs.existsSync(nodeModuleDirPath)) {
|
||||
break remove_unused_node_modules;
|
||||
}
|
||||
|
||||
const toDeletePerfixes = [
|
||||
"angular",
|
||||
"bootstrap",
|
||||
"rcue",
|
||||
"font-awesome",
|
||||
"ng-file-upload",
|
||||
pathJoin("patternfly", "dist", "sass"),
|
||||
pathJoin("patternfly", "dist", "less"),
|
||||
pathJoin("patternfly", "dist", "js"),
|
||||
"d3",
|
||||
pathJoin("jquery", "src"),
|
||||
"c3",
|
||||
"core-js",
|
||||
"eonasdan-bootstrap-datetimepicker",
|
||||
"moment",
|
||||
"react",
|
||||
"patternfly-bootstrap-treeview",
|
||||
"popper.js",
|
||||
"tippy.js",
|
||||
"jquery-match-height",
|
||||
"google-code-prettify",
|
||||
"patternfly-bootstrap-combobox",
|
||||
"focus-trap",
|
||||
"tabbable",
|
||||
"scheduler",
|
||||
"@types",
|
||||
"datatables.net",
|
||||
"datatables.net-colreorder",
|
||||
"tslib",
|
||||
"prop-types",
|
||||
"file-selector",
|
||||
"datatables.net-colreorder-bs",
|
||||
"object-assign",
|
||||
"warning",
|
||||
"js-tokens",
|
||||
"loose-envify",
|
||||
"prop-types-extra",
|
||||
"attr-accept",
|
||||
"datatables.net-select",
|
||||
"drmonty-datatables-colvis",
|
||||
"datatables.net-bs",
|
||||
pathJoin("@patternfly", "react"),
|
||||
pathJoin("@patternfly", "patternfly", "docs")
|
||||
];
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": nodeModuleDirPath,
|
||||
"destDirPath": nodeModuleDirPath,
|
||||
"transformSourceCode": ({ sourceCode, fileRelativePath }) => {
|
||||
if (fileRelativePath.endsWith(".map")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (toDeletePerfixes.find(prefix => fileRelativePath.startsWith(prefix)) !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (fileRelativePath.startsWith(pathJoin("patternfly", "dist", "fonts"))) {
|
||||
if (
|
||||
!fileRelativePath.endsWith(".woff2") &&
|
||||
!fileRelativePath.endsWith(".woff") &&
|
||||
!fileRelativePath.endsWith(".ttf")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return { "modifiedSourceCode": sourceCode };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Just like node_modules
|
||||
remove_unused_lib: {
|
||||
const libDirPath = pathJoin(destDirPath, "keycloak", "common", "resources", "lib");
|
||||
|
||||
if (!fs.existsSync(libDirPath)) {
|
||||
break remove_unused_lib;
|
||||
}
|
||||
|
||||
const toDeletePerfixes = ["ui-ace", "filesaver", "fileupload", "angular", "ui-ace"];
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": libDirPath,
|
||||
"destDirPath": libDirPath,
|
||||
"transformSourceCode": ({ sourceCode, fileRelativePath }) => {
|
||||
if (fileRelativePath.endsWith(".map")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (toDeletePerfixes.find(prefix => fileRelativePath.startsWith(prefix)) !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { "modifiedSourceCode": sourceCode };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
last_account_v1_transformations: {
|
||||
if (lastKeycloakVersionWithAccountV1 !== keycloakVersion) {
|
||||
break last_account_v1_transformations;
|
||||
}
|
||||
|
||||
{
|
||||
const accountCssFilePath = pathJoin(destDirPath, "keycloak", "account", "resources", "css", "account.css");
|
||||
|
||||
fs.writeFileSync(
|
||||
accountCssFilePath,
|
||||
Buffer.from(fs.readFileSync(accountCssFilePath).toString("utf8").replace("top: -34px;", "top: -34px !important;"), "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
// Note, this is an optimization for reducing the size of the jar,
|
||||
// For this version we know exactly which resources are used.
|
||||
{
|
||||
const nodeModulesDirPath = pathJoin(destDirPath, "keycloak", "common", "resources", "node_modules");
|
||||
|
||||
const toKeepPrefixes = [
|
||||
...["patternfly.min.css", "patternfly-additions.min.css", "patternfly-additions.min.css"].map(fileBasename =>
|
||||
pathJoin("patternfly", "dist", "css", fileBasename)
|
||||
),
|
||||
pathJoin("patternfly", "dist", "fonts")
|
||||
];
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": nodeModulesDirPath,
|
||||
"destDirPath": nodeModulesDirPath,
|
||||
"transformSourceCode": ({ sourceCode, fileRelativePath }) => {
|
||||
if (toKeepPrefixes.find(prefix => fileRelativePath.startsWith(prefix)) === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { "modifiedSourceCode": sourceCode };
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const buildOptions = readBuildOptions({
|
||||
"projectDirPath": process.cwd(),
|
||||
"processArgv": process.argv.slice(2)
|
||||
});
|
||||
|
||||
@ -35,7 +281,7 @@ async function main() {
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
keycloakVersion,
|
||||
destDirPath,
|
||||
"isSilent": buildOptions.isSilent
|
||||
buildOptions
|
||||
});
|
||||
}
|
||||
|
||||
|
203
src/bin/downloadAndUnzip.ts
Normal file
203
src/bin/downloadAndUnzip.ts
Normal file
@ -0,0 +1,203 @@
|
||||
import { createHash } from "crypto";
|
||||
import { mkdir, writeFile, unlink } from "fs/promises";
|
||||
import fetch from "make-fetch-happen";
|
||||
import { dirname as pathDirname, join as pathJoin, basename as pathBasename } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { transformCodebase } from "./tools/transformCodebase";
|
||||
import { unzip, zip } from "./tools/unzip";
|
||||
import { rm } from "./tools/fs.rm";
|
||||
import * as child_process from "child_process";
|
||||
import { existsAsync } from "./tools/fs.existsAsync";
|
||||
import type { BuildOptions } from "./keycloakify/buildOptions";
|
||||
import { getProxyFetchOptions } from "./tools/fetchProxyOptions";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
cacheDirPath: string;
|
||||
npmWorkspaceRootDirPath: string;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export async function downloadAndUnzip(params: {
|
||||
url: string;
|
||||
destDirPath: string;
|
||||
specificDirsToExtract?: string[];
|
||||
preCacheTransform?: {
|
||||
actionCacheId: string;
|
||||
action: (params: { destDirPath: string }) => Promise<void>;
|
||||
};
|
||||
buildOptions: BuildOptionsLike;
|
||||
}) {
|
||||
const { url, destDirPath, specificDirsToExtract, preCacheTransform, buildOptions } = params;
|
||||
|
||||
const { extractDirPath, zipFilePath } = (() => {
|
||||
const zipFileBasenameWithoutExt = generateFileNameFromURL({
|
||||
url,
|
||||
"preCacheTransform":
|
||||
preCacheTransform === undefined
|
||||
? undefined
|
||||
: {
|
||||
"actionCacheId": preCacheTransform.actionCacheId,
|
||||
"actionFootprint": preCacheTransform.action.toString()
|
||||
}
|
||||
});
|
||||
|
||||
const zipFilePath = pathJoin(buildOptions.cacheDirPath, `${zipFileBasenameWithoutExt}.zip`);
|
||||
const extractDirPath = pathJoin(buildOptions.cacheDirPath, `tmp_unzip_${zipFileBasenameWithoutExt}`);
|
||||
|
||||
return { zipFilePath, extractDirPath };
|
||||
})();
|
||||
|
||||
download_zip_and_transform: {
|
||||
if (await existsAsync(zipFilePath)) {
|
||||
break download_zip_and_transform;
|
||||
}
|
||||
|
||||
const { response, isFromRemoteCache } = await (async () => {
|
||||
const proxyFetchOptions = await getProxyFetchOptions({
|
||||
"npmWorkspaceRootDirPath": buildOptions.npmWorkspaceRootDirPath
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`https://github.com/keycloakify/keycloakify/releases/download/v0.0.1/${pathBasename(zipFilePath)}`,
|
||||
proxyFetchOptions
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
return {
|
||||
response,
|
||||
"isFromRemoteCache": true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
"response": await fetch(url, proxyFetchOptions),
|
||||
"isFromRemoteCache": false
|
||||
};
|
||||
})();
|
||||
|
||||
await mkdir(pathDirname(zipFilePath), { "recursive": true });
|
||||
|
||||
/**
|
||||
* The correct way to fix this is to upgrade node-fetch beyond 3.2.5
|
||||
* (see https://github.com/node-fetch/node-fetch/issues/1295#issuecomment-1144061991.)
|
||||
* Unfortunately, octokit (a dependency of keycloakify) also uses node-fetch, and
|
||||
* does not support node-fetch 3.x. So we stick around with this band-aid until
|
||||
* octokit upgrades.
|
||||
*/
|
||||
response.body?.setMaxListeners(Number.MAX_VALUE);
|
||||
assert(typeof response.body !== "undefined" && response.body != null);
|
||||
|
||||
await writeFile(zipFilePath, response.body);
|
||||
|
||||
if (isFromRemoteCache) {
|
||||
break download_zip_and_transform;
|
||||
}
|
||||
|
||||
if (specificDirsToExtract === undefined && preCacheTransform === undefined) {
|
||||
break download_zip_and_transform;
|
||||
}
|
||||
|
||||
await unzip(zipFilePath, extractDirPath, specificDirsToExtract);
|
||||
|
||||
try {
|
||||
await preCacheTransform?.action({
|
||||
"destDirPath": extractDirPath
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.all([rm(extractDirPath, { "recursive": true }), unlink(zipFilePath)]);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
await unlink(zipFilePath);
|
||||
|
||||
await zip(extractDirPath, zipFilePath);
|
||||
|
||||
await rm(extractDirPath, { "recursive": true });
|
||||
|
||||
upload_to_remot_cache_if_admin: {
|
||||
const githubToken = process.env["KEYCLOAKIFY_ADMIN_GITHUB_PERSONAL_ACCESS_TOKEN"];
|
||||
|
||||
if (githubToken === undefined) {
|
||||
break upload_to_remot_cache_if_admin;
|
||||
}
|
||||
|
||||
console.log("uploading to remote cache");
|
||||
|
||||
try {
|
||||
child_process.execSync(`which putasset`);
|
||||
} catch {
|
||||
child_process.execSync(`npm install -g putasset`);
|
||||
}
|
||||
|
||||
try {
|
||||
child_process.execFileSync("putasset", [
|
||||
"--owner",
|
||||
"keycloakify",
|
||||
"--repo",
|
||||
"keycloakify",
|
||||
"--tag",
|
||||
"v0.0.1",
|
||||
"--filename",
|
||||
zipFilePath,
|
||||
"--token",
|
||||
githubToken
|
||||
]);
|
||||
} catch {
|
||||
console.log("upload failed, asset probably already exists in remote cache");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await unzip(zipFilePath, extractDirPath);
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": extractDirPath,
|
||||
"destDirPath": destDirPath
|
||||
});
|
||||
|
||||
await rm(extractDirPath, { "recursive": true });
|
||||
}
|
||||
|
||||
function generateFileNameFromURL(params: {
|
||||
url: string;
|
||||
preCacheTransform:
|
||||
| {
|
||||
actionCacheId: string;
|
||||
actionFootprint: string;
|
||||
}
|
||||
| undefined;
|
||||
}): string {
|
||||
const { preCacheTransform } = params;
|
||||
|
||||
// Parse the URL
|
||||
const url = new URL(params.url);
|
||||
|
||||
// Extract pathname and remove leading slashes
|
||||
let fileName = url.pathname.replace(/^\//, "").replace(/\//g, "_");
|
||||
|
||||
// Optionally, add query parameters replacing special characters
|
||||
if (url.search) {
|
||||
fileName += url.search.replace(/[&=?]/g, "-");
|
||||
}
|
||||
|
||||
// Replace any characters that are not valid in filenames
|
||||
fileName = fileName.replace(/[^a-zA-Z0-9-_]/g, "");
|
||||
|
||||
// Trim or pad the fileName to a specific length
|
||||
fileName = fileName.substring(0, 50);
|
||||
|
||||
add_pre_cache_transform: {
|
||||
if (preCacheTransform === undefined) {
|
||||
break add_pre_cache_transform;
|
||||
}
|
||||
|
||||
// Sanitize actionCacheId the same way as other components
|
||||
const sanitizedActionCacheId = preCacheTransform.actionCacheId.replace(/[^a-zA-Z0-9-_]/g, "_");
|
||||
|
||||
fileName += `_${sanitizedActionCacheId}_${createHash("sha256").update(preCacheTransform.actionFootprint).digest("hex").substring(0, 5)}`;
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
@ -1,26 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { getProjectRoot } from "./tools/getProjectRoot";
|
||||
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
|
||||
import cliSelect from "cli-select";
|
||||
import {
|
||||
loginThemePageIds,
|
||||
accountThemePageIds,
|
||||
type LoginThemePageId,
|
||||
type AccountThemePageId,
|
||||
themeTypes,
|
||||
type ThemeType
|
||||
} from "./keycloakify/generateFtl";
|
||||
import { loginThemePageIds, accountThemePageIds, type LoginThemePageId, type AccountThemePageId } from "./keycloakify/generateFtl";
|
||||
import { capitalize } from "tsafe/capitalize";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
|
||||
import { assert, Equals } from "tsafe/assert";
|
||||
import { getThemeSrcDirPath } from "./getSrcDirPath";
|
||||
import { getThemeSrcDirPath } from "./getThemeSrcDirPath";
|
||||
import { themeTypes, type ThemeType } from "./constants";
|
||||
import { getReactAppRootDirPath } from "./keycloakify/buildOptions/getReactAppRootDirPath";
|
||||
|
||||
(async () => {
|
||||
console.log("Select a theme type");
|
||||
|
||||
const { reactAppRootDirPath } = getReactAppRootDirPath({
|
||||
"processArgv": process.argv.slice(2)
|
||||
});
|
||||
|
||||
const { value: themeType } = await cliSelect<ThemeType>({
|
||||
"values": [...themeTypes]
|
||||
}).catch(() => {
|
||||
@ -49,11 +48,7 @@ import { getThemeSrcDirPath } from "./getSrcDirPath";
|
||||
|
||||
const pageBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(/ftl$/, "tsx");
|
||||
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({ "projectDirPath": process.cwd() });
|
||||
|
||||
if (themeSrcDirPath === undefined) {
|
||||
throw new Error("Couldn't locate your theme sources");
|
||||
}
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({ reactAppRootDirPath });
|
||||
|
||||
const targetFilePath = pathJoin(themeSrcDirPath, themeType, "pages", pageBasename);
|
||||
|
||||
@ -63,7 +58,7 @@ import { getThemeSrcDirPath } from "./getSrcDirPath";
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
await writeFile(targetFilePath, await readFile(pathJoin(getProjectRoot(), "src", themeType, "pages", pageBasename)));
|
||||
await writeFile(targetFilePath, await readFile(pathJoin(getThisCodebaseRootDirPath(), "src", themeType, "pages", pageBasename)));
|
||||
|
||||
console.log(`${pathRelative(process.cwd(), targetFilePath)} created`);
|
||||
})();
|
||||
|
@ -1,43 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import { exclude } from "tsafe";
|
||||
import { crawl } from "./tools/crawl";
|
||||
import { join as pathJoin } from "path";
|
||||
|
||||
const themeSrcDirBasename = "keycloak-theme";
|
||||
|
||||
export function getThemeSrcDirPath(params: { projectDirPath: string }) {
|
||||
const { projectDirPath } = params;
|
||||
|
||||
const srcDirPath = pathJoin(projectDirPath, "src");
|
||||
|
||||
const themeSrcDirPath: string | undefined = crawl(srcDirPath)
|
||||
.map(fileRelativePath => {
|
||||
const split = fileRelativePath.split(themeSrcDirBasename);
|
||||
|
||||
if (split.length !== 2) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return pathJoin(srcDirPath, split[0] + themeSrcDirBasename);
|
||||
})
|
||||
.filter(exclude(undefined))[0];
|
||||
|
||||
if (themeSrcDirPath === undefined) {
|
||||
if (fs.existsSync(pathJoin(srcDirPath, "login")) || fs.existsSync(pathJoin(srcDirPath, "account"))) {
|
||||
return { "themeSrcDirPath": srcDirPath };
|
||||
}
|
||||
return { "themeSrcDirPath": undefined };
|
||||
}
|
||||
|
||||
return { themeSrcDirPath };
|
||||
}
|
||||
|
||||
export function getEmailThemeSrcDirPath(params: { projectDirPath: string }) {
|
||||
const { projectDirPath } = params;
|
||||
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({ projectDirPath });
|
||||
|
||||
const emailThemeSrcDirPath = themeSrcDirPath === undefined ? undefined : pathJoin(themeSrcDirPath, "email");
|
||||
|
||||
return { emailThemeSrcDirPath };
|
||||
}
|
47
src/bin/getThemeSrcDirPath.ts
Normal file
47
src/bin/getThemeSrcDirPath.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import * as fs from "fs";
|
||||
import { exclude } from "tsafe";
|
||||
import { crawl } from "./tools/crawl";
|
||||
import { join as pathJoin } from "path";
|
||||
import { themeTypes } from "./constants";
|
||||
|
||||
const themeSrcDirBasenames = ["keycloak-theme", "keycloak_theme"];
|
||||
|
||||
/** Can't catch error, if the directory isn't found, this function will just exit the process with an error message. */
|
||||
export function getThemeSrcDirPath(params: { reactAppRootDirPath: string }) {
|
||||
const { reactAppRootDirPath } = params;
|
||||
|
||||
const srcDirPath = pathJoin(reactAppRootDirPath, "src");
|
||||
|
||||
const themeSrcDirPath: string | undefined = crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })
|
||||
.map(fileRelativePath => {
|
||||
for (const themeSrcDirBasename of themeSrcDirBasenames) {
|
||||
const split = fileRelativePath.split(themeSrcDirBasename);
|
||||
if (split.length === 2) {
|
||||
return pathJoin(srcDirPath, split[0] + themeSrcDirBasename);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.filter(exclude(undefined))[0];
|
||||
|
||||
if (themeSrcDirPath !== undefined) {
|
||||
return { themeSrcDirPath };
|
||||
}
|
||||
|
||||
for (const themeType of [...themeTypes, "email"]) {
|
||||
if (!fs.existsSync(pathJoin(srcDirPath, themeType))) {
|
||||
continue;
|
||||
}
|
||||
return { "themeSrcDirPath": srcDirPath };
|
||||
}
|
||||
|
||||
console.error(
|
||||
[
|
||||
"Can't locate your theme source directory. It should be either: ",
|
||||
"src/ or src/keycloak-theme or src/keycloak_theme.",
|
||||
"Example in the starter: https://github.com/keycloakify/keycloakify-starter/tree/main/src/keycloak-theme"
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
@ -4,28 +4,24 @@ import { downloadBuiltinKeycloakTheme } from "./download-builtin-keycloak-theme"
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
import { transformCodebase } from "./tools/transformCodebase";
|
||||
import { promptKeycloakVersion } from "./promptKeycloakVersion";
|
||||
import { readBuildOptions } from "./keycloakify/BuildOptions";
|
||||
import { readBuildOptions } from "./keycloakify/buildOptions";
|
||||
import * as fs from "fs";
|
||||
import { getLogger } from "./tools/logger";
|
||||
import { getEmailThemeSrcDirPath } from "./getSrcDirPath";
|
||||
import { getThemeSrcDirPath } from "./getThemeSrcDirPath";
|
||||
import { rmSync } from "./tools/fs.rmSync";
|
||||
|
||||
export async function main() {
|
||||
const { isSilent } = readBuildOptions({
|
||||
"projectDirPath": process.cwd(),
|
||||
const buildOptions = readBuildOptions({
|
||||
"processArgv": process.argv.slice(2)
|
||||
});
|
||||
|
||||
const logger = getLogger({ isSilent });
|
||||
const logger = getLogger({ "isSilent": buildOptions.isSilent });
|
||||
|
||||
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({
|
||||
"projectDirPath": process.cwd()
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({
|
||||
"reactAppRootDirPath": buildOptions.reactAppRootDirPath
|
||||
});
|
||||
|
||||
if (emailThemeSrcDirPath === undefined) {
|
||||
logger.warn("Couldn't locate your theme source directory");
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
|
||||
|
||||
if (fs.existsSync(emailThemeSrcDirPath)) {
|
||||
logger.warn(`There is already a ${pathRelative(process.cwd(), emailThemeSrcDirPath)} directory in your project. Aborting.`);
|
||||
@ -40,7 +36,7 @@ export async function main() {
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
keycloakVersion,
|
||||
"destDirPath": builtinKeycloakThemeTmpDirPath,
|
||||
isSilent
|
||||
buildOptions
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
@ -56,7 +52,7 @@ export async function main() {
|
||||
|
||||
logger.log(`${pathRelative(process.cwd(), emailThemeSrcDirPath)} ready to be customized, feel free to remove every file you do not customize`);
|
||||
|
||||
fs.rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true, "force": true });
|
||||
rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true, "force": true });
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
|
@ -1,236 +0,0 @@
|
||||
import { assert } from "tsafe/assert";
|
||||
import { id } from "tsafe/id";
|
||||
import { parse as urlParse } from "url";
|
||||
import { typeGuard } from "tsafe/typeGuard";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
import { bundlers, getParsedPackageJson, type Bundler } from "./parsedPackageJson";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, sep as pathSep } from "path";
|
||||
import parseArgv from "minimist";
|
||||
|
||||
/** Consolidated build option gathered form CLI arguments and config in package.json */
|
||||
export type BuildOptions = BuildOptions.Standalone | BuildOptions.ExternalAssets;
|
||||
|
||||
export namespace BuildOptions {
|
||||
export type Common = {
|
||||
isSilent: boolean;
|
||||
themeVersion: string;
|
||||
themeName: string;
|
||||
extraLoginPages: string[] | undefined;
|
||||
extraAccountPages: string[] | undefined;
|
||||
extraThemeProperties?: string[];
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
bundler: Bundler;
|
||||
keycloakVersionDefaultAssets: string;
|
||||
/** Directory of your built react project. Defaults to {cwd}/build */
|
||||
reactAppBuildDirPath: string;
|
||||
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
|
||||
keycloakifyBuildDirPath: string;
|
||||
customUserAttributes: string[];
|
||||
};
|
||||
|
||||
export type Standalone = Common & {
|
||||
isStandalone: true;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
|
||||
|
||||
export namespace ExternalAssets {
|
||||
export type CommonExternalAssets = Common & {
|
||||
isStandalone: false;
|
||||
};
|
||||
|
||||
export type SameDomain = CommonExternalAssets & {
|
||||
areAppAndKeycloakServerSharingSameDomain: true;
|
||||
};
|
||||
|
||||
export type DifferentDomains = CommonExternalAssets & {
|
||||
areAppAndKeycloakServerSharingSameDomain: false;
|
||||
urlOrigin: string;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function readBuildOptions(params: { projectDirPath: string; processArgv: string[] }): BuildOptions {
|
||||
const { projectDirPath, processArgv } = params;
|
||||
|
||||
const { isExternalAssetsCliParamProvided, isSilentCliParamProvided } = (() => {
|
||||
const argv = parseArgv(processArgv);
|
||||
|
||||
return {
|
||||
"isSilentCliParamProvided": typeof argv["silent"] === "boolean" ? argv["silent"] : false,
|
||||
"isExternalAssetsCliParamProvided": typeof argv["external-assets"] === "boolean" ? argv["external-assets"] : false
|
||||
};
|
||||
})();
|
||||
|
||||
const parsedPackageJson = getParsedPackageJson({ projectDirPath });
|
||||
|
||||
const url = (() => {
|
||||
const { homepage } = parsedPackageJson;
|
||||
|
||||
let url: URL | undefined = undefined;
|
||||
|
||||
if (homepage !== undefined) {
|
||||
url = new URL(homepage);
|
||||
}
|
||||
|
||||
const CNAME = (() => {
|
||||
const cnameFilePath = pathJoin(projectDirPath, "public", "CNAME");
|
||||
|
||||
if (!fs.existsSync(cnameFilePath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fs.readFileSync(cnameFilePath).toString("utf8");
|
||||
})();
|
||||
|
||||
if (CNAME !== undefined) {
|
||||
url = new URL(`https://${CNAME.replace(/\s+$/, "")}`);
|
||||
}
|
||||
|
||||
if (url === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
"origin": url.origin,
|
||||
"pathname": (() => {
|
||||
const out = url.pathname.replace(/([^/])$/, "$1/");
|
||||
|
||||
return out === "/" ? undefined : out;
|
||||
})()
|
||||
};
|
||||
})();
|
||||
|
||||
const common: BuildOptions.Common = (() => {
|
||||
const { name, keycloakify = {}, version, homepage } = parsedPackageJson;
|
||||
|
||||
const { extraPages, extraLoginPages, extraAccountPages, extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets } =
|
||||
keycloakify ?? {};
|
||||
|
||||
const themeName =
|
||||
keycloakify.themeName ??
|
||||
name
|
||||
.replace(/^@(.*)/, "$1")
|
||||
.split("/")
|
||||
.join("-");
|
||||
|
||||
return {
|
||||
themeName,
|
||||
"bundler": (() => {
|
||||
const { KEYCLOAKIFY_BUNDLER } = process.env;
|
||||
|
||||
assert(
|
||||
typeGuard<Bundler | undefined>(
|
||||
KEYCLOAKIFY_BUNDLER,
|
||||
[undefined, ...id<readonly string[]>(bundlers)].includes(KEYCLOAKIFY_BUNDLER)
|
||||
),
|
||||
`${symToStr({ KEYCLOAKIFY_BUNDLER })} should be one of ${bundlers.join(", ")}`
|
||||
);
|
||||
|
||||
return KEYCLOAKIFY_BUNDLER ?? bundler ?? "keycloakify";
|
||||
})(),
|
||||
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? artifactId ?? `${themeName}-keycloak-theme`,
|
||||
"groupId": (() => {
|
||||
const fallbackGroupId = `${themeName}.keycloak`;
|
||||
|
||||
return (
|
||||
process.env.KEYCLOAKIFY_GROUP_ID ??
|
||||
groupId ??
|
||||
(!homepage
|
||||
? fallbackGroupId
|
||||
: urlParse(homepage)
|
||||
.host?.replace(/:[0-9]+$/, "")
|
||||
?.split(".")
|
||||
.reverse()
|
||||
.join(".") ?? fallbackGroupId) + ".keycloak"
|
||||
);
|
||||
})(),
|
||||
"themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? process.env.KEYCLOAKIFY_VERSION ?? version ?? "0.0.0",
|
||||
"extraLoginPages": [...(extraPages ?? []), ...(extraLoginPages ?? [])],
|
||||
extraAccountPages,
|
||||
extraThemeProperties,
|
||||
"isSilent": isSilentCliParamProvided,
|
||||
"keycloakVersionDefaultAssets": keycloakVersionDefaultAssets ?? "11.0.3",
|
||||
"reactAppBuildDirPath": (() => {
|
||||
let { reactAppBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {};
|
||||
|
||||
if (reactAppBuildDirPath === undefined) {
|
||||
return pathJoin(projectDirPath, "build");
|
||||
}
|
||||
|
||||
if (pathSep === "\\") {
|
||||
reactAppBuildDirPath = reactAppBuildDirPath.replace(/\//g, pathSep);
|
||||
}
|
||||
|
||||
if (reactAppBuildDirPath.startsWith(`.${pathSep}`)) {
|
||||
return pathJoin(projectDirPath, reactAppBuildDirPath);
|
||||
}
|
||||
|
||||
return reactAppBuildDirPath;
|
||||
})(),
|
||||
"keycloakifyBuildDirPath": (() => {
|
||||
let { keycloakifyBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {};
|
||||
|
||||
if (keycloakifyBuildDirPath === undefined) {
|
||||
return pathJoin(projectDirPath, "build_keycloak");
|
||||
}
|
||||
|
||||
if (pathSep === "\\") {
|
||||
keycloakifyBuildDirPath = keycloakifyBuildDirPath.replace(/\//g, pathSep);
|
||||
}
|
||||
|
||||
if (keycloakifyBuildDirPath.startsWith(`.${pathSep}`)) {
|
||||
return pathJoin(projectDirPath, keycloakifyBuildDirPath);
|
||||
}
|
||||
|
||||
return keycloakifyBuildDirPath;
|
||||
})(),
|
||||
"customUserAttributes": keycloakify.customUserAttributes ?? []
|
||||
};
|
||||
})();
|
||||
|
||||
if (isExternalAssetsCliParamProvided) {
|
||||
const commonExternalAssets = id<BuildOptions.ExternalAssets.CommonExternalAssets>({
|
||||
...common,
|
||||
"isStandalone": false
|
||||
});
|
||||
|
||||
if (parsedPackageJson.keycloakify?.areAppAndKeycloakServerSharingSameDomain) {
|
||||
return id<BuildOptions.ExternalAssets.SameDomain>({
|
||||
...commonExternalAssets,
|
||||
"areAppAndKeycloakServerSharingSameDomain": true
|
||||
});
|
||||
} else {
|
||||
assert(
|
||||
url !== undefined,
|
||||
[
|
||||
"Can't compile in external assets mode if we don't know where",
|
||||
"the app will be hosted.",
|
||||
"You should provide a homepage field in the package.json (or create a",
|
||||
"public/CNAME file.",
|
||||
"Alternatively, if your app and the Keycloak server are on the same domain, ",
|
||||
"eg https://example.com is your app and https://example.com/auth is the keycloak",
|
||||
'admin UI, you can set "keycloakify": { "areAppAndKeycloakServerSharingSameDomain": true }',
|
||||
"in your package.json"
|
||||
].join(" ")
|
||||
);
|
||||
|
||||
return id<BuildOptions.ExternalAssets.DifferentDomains>({
|
||||
...commonExternalAssets,
|
||||
"areAppAndKeycloakServerSharingSameDomain": false,
|
||||
"urlOrigin": url.origin,
|
||||
"urlPathname": url.pathname
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return id<BuildOptions.Standalone>({
|
||||
...common,
|
||||
"isStandalone": true,
|
||||
"urlPathname": url?.pathname
|
||||
});
|
||||
}
|
174
src/bin/keycloakify/buildJars/buildJar.ts
Normal file
174
src/bin/keycloakify/buildJars/buildJar.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import type { KeycloakAccountV1Version, KeycloakThemeAdditionalInfoExtensionVersion } from "./extensionVersions";
|
||||
import { join as pathJoin, dirname as pathDirname } from "path";
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import type { BuildOptions } from "../buildOptions";
|
||||
import * as fs from "fs/promises";
|
||||
import { accountV1ThemeName } from "../../constants";
|
||||
import { generatePom, BuildOptionsLike as BuildOptionsLike_generatePom } from "./generatePom";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { isInside } from "../../tools/isInside";
|
||||
import child_process from "child_process";
|
||||
|
||||
export type BuildOptionsLike = BuildOptionsLike_generatePom & {
|
||||
keycloakifyBuildDirPath: string;
|
||||
themeNames: string[];
|
||||
artifactId: string;
|
||||
themeVersion: string;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export async function buildJar(params: {
|
||||
jarFileBasename: string;
|
||||
keycloakAccountV1Version: KeycloakAccountV1Version;
|
||||
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
|
||||
buildOptions: BuildOptionsLike;
|
||||
}): Promise<void> {
|
||||
const { jarFileBasename, keycloakAccountV1Version, keycloakThemeAdditionalInfoExtensionVersion, buildOptions } = params;
|
||||
|
||||
const keycloakifyBuildTmpDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "..", jarFileBasename.replace(".jar", ""));
|
||||
|
||||
if (existsSync(keycloakifyBuildTmpDirPath)) {
|
||||
await fs.rm(keycloakifyBuildTmpDirPath, { "recursive": true });
|
||||
}
|
||||
|
||||
await fs.mkdir(keycloakifyBuildTmpDirPath, { "recursive": true });
|
||||
await fs.writeFile(pathJoin(keycloakifyBuildTmpDirPath, ".gitignore"), Buffer.from("*", "utf8"));
|
||||
|
||||
const srcMainResourcesRelativeDirPath = pathJoin("src", "main", "resources");
|
||||
|
||||
{
|
||||
const keycloakThemesJsonFilePath = pathJoin(srcMainResourcesRelativeDirPath, "META-INF", "keycloak-themes.json");
|
||||
|
||||
const themePropertiesFilePathSet = new Set(
|
||||
...buildOptions.themeNames.map(themeName => pathJoin(srcMainResourcesRelativeDirPath, "theme", themeName, "account", "theme.properties"))
|
||||
);
|
||||
|
||||
const accountV1RelativeDirPath = pathJoin(srcMainResourcesRelativeDirPath, "theme", accountV1ThemeName);
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": buildOptions.keycloakifyBuildDirPath,
|
||||
"destDirPath": keycloakifyBuildTmpDirPath,
|
||||
"transformSourceCode":
|
||||
keycloakAccountV1Version !== null
|
||||
? undefined
|
||||
: ({ fileRelativePath, sourceCode }) => {
|
||||
if (fileRelativePath === keycloakThemesJsonFilePath) {
|
||||
const keycloakThemesJsonParsed = JSON.parse(sourceCode.toString("utf8")) as {
|
||||
themes: { name: string; types: string[] }[];
|
||||
};
|
||||
|
||||
keycloakThemesJsonParsed.themes = keycloakThemesJsonParsed.themes.filter(({ name }) => name !== accountV1ThemeName);
|
||||
|
||||
return { "modifiedSourceCode": Buffer.from(JSON.stringify(keycloakThemesJsonParsed, null, 2), "utf8") };
|
||||
}
|
||||
|
||||
if (isInside({ "dirPath": "target", "filePath": fileRelativePath })) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isInside({ "dirPath": accountV1RelativeDirPath, "filePath": fileRelativePath })) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (themePropertiesFilePathSet.has(fileRelativePath)) {
|
||||
return {
|
||||
"modifiedSourceCode": Buffer.from(
|
||||
sourceCode.toString("utf8").replace(`parent=${accountV1ThemeName}`, "parent=keycloak"),
|
||||
"utf8"
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return { "modifiedSourceCode": sourceCode };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
route_legacy_pages: {
|
||||
// NOTE: If there's no account theme there is no special target for keycloak 24 and up so we create
|
||||
// the pages anyway. If there is an account pages, since we know that account-v1 is only support keycloak
|
||||
// 24 in version 0.4 and up, we can safely break the route for legacy pages.
|
||||
const doBreak: boolean = (() => {
|
||||
switch (keycloakAccountV1Version) {
|
||||
case null:
|
||||
return false;
|
||||
case "0.3":
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
|
||||
if (doBreak) {
|
||||
break route_legacy_pages;
|
||||
}
|
||||
|
||||
(["register.ftl", "login-update-profile.ftl"] as const).forEach(pageId =>
|
||||
buildOptions.themeNames.map(themeName => {
|
||||
const ftlFilePath = pathJoin(keycloakifyBuildTmpDirPath, srcMainResourcesRelativeDirPath, "theme", themeName, "login", pageId);
|
||||
|
||||
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
|
||||
|
||||
const realPageId = (() => {
|
||||
switch (pageId) {
|
||||
case "register.ftl":
|
||||
return "register-user-profile.ftl";
|
||||
case "login-update-profile.ftl":
|
||||
return "update-user-profile.ftl";
|
||||
}
|
||||
assert<Equals<typeof pageId, never>>(false);
|
||||
})();
|
||||
|
||||
const modifiedFtlFileContent = ftlFileContent.replace(
|
||||
`out["pageId"] = "\${pageId}";`,
|
||||
`out["pageId"] = "${pageId}"; out["realPageId"] = "${realPageId}";`
|
||||
);
|
||||
|
||||
assert(modifiedFtlFileContent !== ftlFileContent);
|
||||
|
||||
fs.writeFile(pathJoin(pathDirname(ftlFilePath), realPageId), Buffer.from(modifiedFtlFileContent, "utf8"));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const { pomFileCode } = generatePom({
|
||||
buildOptions,
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion
|
||||
});
|
||||
|
||||
await fs.writeFile(pathJoin(keycloakifyBuildTmpDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8"));
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
child_process.exec("mvn clean install", { "cwd": keycloakifyBuildTmpDirPath }, error => {
|
||||
if (error !== null) {
|
||||
console.error(
|
||||
`Build jar failed: ${JSON.stringify(
|
||||
{
|
||||
jarFileBasename,
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
|
||||
await fs.rename(
|
||||
pathJoin(keycloakifyBuildTmpDirPath, "target", `${buildOptions.artifactId}-${buildOptions.themeVersion}.jar`),
|
||||
pathJoin(buildOptions.keycloakifyBuildDirPath, jarFileBasename)
|
||||
);
|
||||
|
||||
await fs.rm(keycloakifyBuildTmpDirPath, { "recursive": true });
|
||||
}
|
62
src/bin/keycloakify/buildJars/buildJars.ts
Normal file
62
src/bin/keycloakify/buildJars/buildJars.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { assert } from "tsafe/assert";
|
||||
import { exclude } from "tsafe/exclude";
|
||||
import { keycloakAccountV1Versions, keycloakThemeAdditionalInfoExtensionVersions } from "./extensionVersions";
|
||||
import { getKeycloakVersionRangeForJar } from "./getKeycloakVersionRangeForJar";
|
||||
import { buildJar, BuildOptionsLike as BuildOptionsLike_buildJar } from "./buildJar";
|
||||
import type { BuildOptions } from "../buildOptions";
|
||||
|
||||
export type BuildOptionsLike = BuildOptionsLike_buildJar & {
|
||||
keycloakifyBuildDirPath: string;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export async function buildJars(params: {
|
||||
doesImplementAccountTheme: boolean;
|
||||
buildOptions: BuildOptionsLike;
|
||||
}): Promise<{ lastJarFileBasename: string }> {
|
||||
const { doesImplementAccountTheme, buildOptions } = params;
|
||||
|
||||
let lastJarFileBasename: string | undefined = undefined;
|
||||
|
||||
await Promise.all(
|
||||
keycloakAccountV1Versions
|
||||
.map(keycloakAccountV1Version =>
|
||||
keycloakThemeAdditionalInfoExtensionVersions
|
||||
.map(keycloakThemeAdditionalInfoExtensionVersion => {
|
||||
const keycloakVersionRange = getKeycloakVersionRangeForJar({
|
||||
doesImplementAccountTheme,
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion
|
||||
});
|
||||
|
||||
if (keycloakVersionRange === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { keycloakThemeAdditionalInfoExtensionVersion, keycloakVersionRange };
|
||||
})
|
||||
.filter(exclude(undefined))
|
||||
.map(({ keycloakThemeAdditionalInfoExtensionVersion, keycloakVersionRange }) => {
|
||||
const jarFileBasename = `keycloak-theme-for-kc-${keycloakVersionRange}.jar`;
|
||||
|
||||
lastJarFileBasename = jarFileBasename;
|
||||
|
||||
return { keycloakThemeAdditionalInfoExtensionVersion, jarFileBasename };
|
||||
})
|
||||
.map(({ keycloakThemeAdditionalInfoExtensionVersion, jarFileBasename }) =>
|
||||
buildJar({
|
||||
jarFileBasename,
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion,
|
||||
buildOptions
|
||||
})
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
);
|
||||
|
||||
assert(lastJarFileBasename !== undefined);
|
||||
|
||||
return { lastJarFileBasename };
|
||||
}
|
16
src/bin/keycloakify/buildJars/extensionVersions.ts
Normal file
16
src/bin/keycloakify/buildJars/extensionVersions.ts
Normal file
@ -0,0 +1,16 @@
|
||||
// NOTE: v0.5 is a dummy version.
|
||||
export const keycloakAccountV1Versions = [null, "0.3", "0.4"] as const;
|
||||
|
||||
/**
|
||||
* https://central.sonatype.com/artifact/io.phasetwo.keycloak/keycloak-account-v1
|
||||
* https://github.com/p2-inc/keycloak-account-v1
|
||||
*/
|
||||
export type KeycloakAccountV1Version = (typeof keycloakAccountV1Versions)[number];
|
||||
|
||||
export const keycloakThemeAdditionalInfoExtensionVersions = [null, "1.1.5"] as const;
|
||||
|
||||
/**
|
||||
* https://central.sonatype.com/artifact/dev.jcputney/keycloak-theme-additional-info-extension
|
||||
* https://github.com/jcputney/keycloak-theme-additional-info-extension
|
||||
* */
|
||||
export type KeycloakThemeAdditionalInfoExtensionVersion = (typeof keycloakThemeAdditionalInfoExtensionVersions)[number];
|
86
src/bin/keycloakify/buildJars/generatePom.ts
Normal file
86
src/bin/keycloakify/buildJars/generatePom.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { BuildOptions } from "../buildOptions";
|
||||
import type { KeycloakAccountV1Version, KeycloakThemeAdditionalInfoExtensionVersion } from "./extensionVersions";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
themeVersion: string;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export function generatePom(params: {
|
||||
keycloakAccountV1Version: KeycloakAccountV1Version;
|
||||
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
|
||||
buildOptions: BuildOptionsLike;
|
||||
}) {
|
||||
const { keycloakAccountV1Version, keycloakThemeAdditionalInfoExtensionVersion, buildOptions } = params;
|
||||
|
||||
const { pomFileCode } = (function generatePomFileCode(): {
|
||||
pomFileCode: string;
|
||||
} {
|
||||
const pomFileCode = [
|
||||
`<?xml version="1.0"?>`,
|
||||
`<project xmlns="http://maven.apache.org/POM/4.0.0"`,
|
||||
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
|
||||
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
|
||||
` <modelVersion>4.0.0</modelVersion>`,
|
||||
` <groupId>${buildOptions.groupId}</groupId>`,
|
||||
` <artifactId>${buildOptions.artifactId}</artifactId>`,
|
||||
` <version>${buildOptions.themeVersion}</version>`,
|
||||
` <name>${buildOptions.artifactId}</name>`,
|
||||
` <description />`,
|
||||
` <packaging>jar</packaging>`,
|
||||
` <properties>`,
|
||||
` <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>`,
|
||||
` </properties>`,
|
||||
...(keycloakAccountV1Version !== null && keycloakThemeAdditionalInfoExtensionVersion !== null
|
||||
? [
|
||||
` <build>`,
|
||||
` <plugins>`,
|
||||
` <plugin>`,
|
||||
` <groupId>org.apache.maven.plugins</groupId>`,
|
||||
` <artifactId>maven-shade-plugin</artifactId>`,
|
||||
` <version>3.5.1</version>`,
|
||||
` <executions>`,
|
||||
` <execution>`,
|
||||
` <phase>package</phase>`,
|
||||
` <goals>`,
|
||||
` <goal>shade</goal>`,
|
||||
` </goals>`,
|
||||
` </execution>`,
|
||||
` </executions>`,
|
||||
` </plugin>`,
|
||||
` </plugins>`,
|
||||
` </build>`,
|
||||
` <dependencies>`,
|
||||
...(keycloakAccountV1Version !== null
|
||||
? [
|
||||
` <dependency>`,
|
||||
` <groupId>io.phasetwo.keycloak</groupId>`,
|
||||
` <artifactId>keycloak-account-v1</artifactId>`,
|
||||
` <version>${keycloakAccountV1Version}</version>`,
|
||||
` </dependency>`
|
||||
]
|
||||
: []),
|
||||
...(keycloakThemeAdditionalInfoExtensionVersion !== null
|
||||
? [
|
||||
` <dependency>`,
|
||||
` <groupId>dev.jcputney</groupId>`,
|
||||
` <artifactId>keycloak-theme-additional-info-extension</artifactId>`,
|
||||
` <version>${keycloakThemeAdditionalInfoExtensionVersion}</version>`,
|
||||
` </dependency>`
|
||||
]
|
||||
: []),
|
||||
` </dependencies>`
|
||||
]
|
||||
: []),
|
||||
`</project>`
|
||||
].join("\n");
|
||||
|
||||
return { pomFileCode };
|
||||
})();
|
||||
|
||||
return { pomFileCode };
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import type { KeycloakAccountV1Version, KeycloakThemeAdditionalInfoExtensionVersion } from "./extensionVersions";
|
||||
|
||||
export function getKeycloakVersionRangeForJar(params: {
|
||||
doesImplementAccountTheme: boolean;
|
||||
keycloakAccountV1Version: KeycloakAccountV1Version;
|
||||
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
|
||||
}): string | undefined {
|
||||
const { keycloakAccountV1Version, keycloakThemeAdditionalInfoExtensionVersion, doesImplementAccountTheme } = params;
|
||||
|
||||
switch (keycloakAccountV1Version) {
|
||||
case null:
|
||||
switch (keycloakThemeAdditionalInfoExtensionVersion) {
|
||||
case null:
|
||||
return doesImplementAccountTheme ? "21-and-below" : "21-and-below";
|
||||
case "1.1.5":
|
||||
return doesImplementAccountTheme ? undefined : "22-and-above";
|
||||
}
|
||||
assert<Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>>(false);
|
||||
case "0.3":
|
||||
switch (keycloakThemeAdditionalInfoExtensionVersion) {
|
||||
case null:
|
||||
return doesImplementAccountTheme ? undefined : undefined;
|
||||
case "1.1.5":
|
||||
return doesImplementAccountTheme ? "23" : undefined;
|
||||
}
|
||||
assert<Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>>(false);
|
||||
case "0.4":
|
||||
switch (keycloakThemeAdditionalInfoExtensionVersion) {
|
||||
case null:
|
||||
return doesImplementAccountTheme ? undefined : undefined;
|
||||
case "1.1.5":
|
||||
return doesImplementAccountTheme ? "24-and-above" : undefined;
|
||||
}
|
||||
assert<Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>>(false);
|
||||
}
|
||||
}
|
1
src/bin/keycloakify/buildJars/index.ts
Normal file
1
src/bin/keycloakify/buildJars/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./buildJars";
|
21
src/bin/keycloakify/buildOptions/UserProvidedBuildOptions.ts
Normal file
21
src/bin/keycloakify/buildOptions/UserProvidedBuildOptions.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type UserProvidedBuildOptions = {
|
||||
extraThemeProperties?: string[];
|
||||
artifactId?: string;
|
||||
groupId?: string;
|
||||
loginThemeResourcesFromKeycloakVersion?: string;
|
||||
reactAppBuildDirPath?: string;
|
||||
keycloakifyBuildDirPath?: string;
|
||||
themeName?: string | string[];
|
||||
};
|
||||
|
||||
export const zUserProvidedBuildOptions = z.object({
|
||||
"extraThemeProperties": z.array(z.string()).optional(),
|
||||
"artifactId": z.string().optional(),
|
||||
"groupId": z.string().optional(),
|
||||
"loginThemeResourcesFromKeycloakVersion": z.string().optional(),
|
||||
"reactAppBuildDirPath": z.string().optional(),
|
||||
"keycloakifyBuildDirPath": z.string().optional(),
|
||||
"themeName": z.union([z.string(), z.array(z.string())]).optional()
|
||||
});
|
189
src/bin/keycloakify/buildOptions/buildOptions.ts
Normal file
189
src/bin/keycloakify/buildOptions/buildOptions.ts
Normal file
@ -0,0 +1,189 @@
|
||||
import { parse as urlParse } from "url";
|
||||
import { readParsedPackageJson } from "./parsedPackageJson";
|
||||
import { join as pathJoin } from "path";
|
||||
import parseArgv from "minimist";
|
||||
import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath";
|
||||
import { readResolvedViteConfig } from "./resolvedViteConfig";
|
||||
import * as fs from "fs";
|
||||
import { getCacheDirPath } from "./getCacheDirPath";
|
||||
import { getReactAppRootDirPath } from "./getReactAppRootDirPath";
|
||||
import { getNpmWorkspaceRootDirPath } from "./getNpmWorkspaceRootDirPath";
|
||||
|
||||
/** Consolidated build option gathered form CLI arguments and config in package.json */
|
||||
export type BuildOptions = {
|
||||
bundler: "vite" | "webpack";
|
||||
isSilent: boolean;
|
||||
themeVersion: string;
|
||||
themeNames: string[];
|
||||
extraThemeProperties: string[] | undefined;
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
loginThemeResourcesFromKeycloakVersion: string;
|
||||
reactAppRootDirPath: string;
|
||||
reactAppBuildDirPath: string;
|
||||
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
|
||||
keycloakifyBuildDirPath: string;
|
||||
publicDirPath: string;
|
||||
cacheDirPath: string;
|
||||
/** If your app is hosted under a subpath, it's the case in CRA if you have "homepage": "https://example.com/my-app" in your package.json
|
||||
* In this case the urlPathname will be "/my-app/" */
|
||||
urlPathname: string | undefined;
|
||||
assetsDirPath: string;
|
||||
npmWorkspaceRootDirPath: string;
|
||||
};
|
||||
|
||||
export function readBuildOptions(params: { processArgv: string[] }): BuildOptions {
|
||||
const { processArgv } = params;
|
||||
|
||||
const { reactAppRootDirPath } = getReactAppRootDirPath({ processArgv });
|
||||
|
||||
const { cacheDirPath } = getCacheDirPath({ reactAppRootDirPath });
|
||||
|
||||
const { resolvedViteConfig } = readResolvedViteConfig({ cacheDirPath });
|
||||
|
||||
if (resolvedViteConfig === undefined && fs.existsSync(pathJoin(reactAppRootDirPath, "vite.config.ts"))) {
|
||||
throw new Error("Keycloakify's Vite plugin output not found");
|
||||
}
|
||||
|
||||
const { keycloakify: userProvidedBuildOptionsFromPackageJson, ...parsedPackageJson } = readParsedPackageJson({ reactAppRootDirPath });
|
||||
|
||||
const userProvidedBuildOptions = {
|
||||
...userProvidedBuildOptionsFromPackageJson,
|
||||
...resolvedViteConfig?.userProvidedBuildOptions
|
||||
};
|
||||
|
||||
const themeNames = (() => {
|
||||
if (userProvidedBuildOptions.themeName === undefined) {
|
||||
return [
|
||||
parsedPackageJson.name
|
||||
.replace(/^@(.*)/, "$1")
|
||||
.split("/")
|
||||
.join("-")
|
||||
];
|
||||
}
|
||||
|
||||
if (typeof userProvidedBuildOptions.themeName === "string") {
|
||||
return [userProvidedBuildOptions.themeName];
|
||||
}
|
||||
|
||||
return userProvidedBuildOptions.themeName;
|
||||
})();
|
||||
|
||||
const reactAppBuildDirPath = (() => {
|
||||
webpack: {
|
||||
if (resolvedViteConfig !== undefined) {
|
||||
break webpack;
|
||||
}
|
||||
|
||||
if (userProvidedBuildOptions.reactAppBuildDirPath !== undefined) {
|
||||
return getAbsoluteAndInOsFormatPath({
|
||||
"pathIsh": userProvidedBuildOptions.reactAppBuildDirPath,
|
||||
"cwd": reactAppRootDirPath
|
||||
});
|
||||
}
|
||||
|
||||
return pathJoin(reactAppRootDirPath, "build");
|
||||
}
|
||||
|
||||
return pathJoin(reactAppRootDirPath, resolvedViteConfig.buildDir);
|
||||
})();
|
||||
|
||||
const argv = parseArgv(processArgv);
|
||||
|
||||
const { npmWorkspaceRootDirPath } = getNpmWorkspaceRootDirPath({ reactAppRootDirPath });
|
||||
|
||||
return {
|
||||
"bundler": resolvedViteConfig !== undefined ? "vite" : "webpack",
|
||||
"isSilent": typeof argv["silent"] === "boolean" ? argv["silent"] : false,
|
||||
"themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? parsedPackageJson.version ?? "0.0.0",
|
||||
themeNames,
|
||||
"extraThemeProperties": userProvidedBuildOptions.extraThemeProperties,
|
||||
"groupId": (() => {
|
||||
const fallbackGroupId = `${themeNames[0]}.keycloak`;
|
||||
|
||||
return (
|
||||
process.env.KEYCLOAKIFY_GROUP_ID ??
|
||||
userProvidedBuildOptions.groupId ??
|
||||
(parsedPackageJson.homepage === undefined
|
||||
? fallbackGroupId
|
||||
: urlParse(parsedPackageJson.homepage)
|
||||
.host?.replace(/:[0-9]+$/, "")
|
||||
?.split(".")
|
||||
.reverse()
|
||||
.join(".") ?? fallbackGroupId) + ".keycloak"
|
||||
);
|
||||
})(),
|
||||
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? userProvidedBuildOptions.artifactId ?? `${themeNames[0]}-keycloak-theme`,
|
||||
"loginThemeResourcesFromKeycloakVersion": userProvidedBuildOptions.loginThemeResourcesFromKeycloakVersion ?? "24.0.4",
|
||||
reactAppRootDirPath,
|
||||
reactAppBuildDirPath,
|
||||
"keycloakifyBuildDirPath": (() => {
|
||||
if (userProvidedBuildOptions.keycloakifyBuildDirPath !== undefined) {
|
||||
return getAbsoluteAndInOsFormatPath({
|
||||
"pathIsh": userProvidedBuildOptions.keycloakifyBuildDirPath,
|
||||
"cwd": reactAppRootDirPath
|
||||
});
|
||||
}
|
||||
|
||||
return pathJoin(
|
||||
reactAppRootDirPath,
|
||||
resolvedViteConfig?.buildDir === undefined ? "build_keycloak" : `${resolvedViteConfig.buildDir}_keycloak`
|
||||
);
|
||||
})(),
|
||||
"publicDirPath": (() => {
|
||||
webpack: {
|
||||
if (resolvedViteConfig !== undefined) {
|
||||
break webpack;
|
||||
}
|
||||
|
||||
if (process.env.PUBLIC_DIR_PATH !== undefined) {
|
||||
return getAbsoluteAndInOsFormatPath({
|
||||
"pathIsh": process.env.PUBLIC_DIR_PATH,
|
||||
"cwd": reactAppRootDirPath
|
||||
});
|
||||
}
|
||||
|
||||
return pathJoin(reactAppRootDirPath, "public");
|
||||
}
|
||||
|
||||
return pathJoin(reactAppRootDirPath, resolvedViteConfig.publicDir);
|
||||
})(),
|
||||
cacheDirPath,
|
||||
"urlPathname": (() => {
|
||||
webpack: {
|
||||
if (resolvedViteConfig !== undefined) {
|
||||
break webpack;
|
||||
}
|
||||
|
||||
const { homepage } = parsedPackageJson;
|
||||
|
||||
let url: URL | undefined = undefined;
|
||||
|
||||
if (homepage !== undefined) {
|
||||
url = new URL(homepage);
|
||||
}
|
||||
|
||||
if (url === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const out = url.pathname.replace(/([^/])$/, "$1/");
|
||||
return out === "/" ? undefined : out;
|
||||
}
|
||||
|
||||
return resolvedViteConfig.urlPathname;
|
||||
})(),
|
||||
"assetsDirPath": (() => {
|
||||
webpack: {
|
||||
if (resolvedViteConfig !== undefined) {
|
||||
break webpack;
|
||||
}
|
||||
|
||||
return pathJoin(reactAppBuildDirPath, "static");
|
||||
}
|
||||
|
||||
return pathJoin(reactAppBuildDirPath, resolvedViteConfig.assetsDir);
|
||||
})(),
|
||||
npmWorkspaceRootDirPath
|
||||
};
|
||||
}
|
25
src/bin/keycloakify/buildOptions/getCacheDirPath.ts
Normal file
25
src/bin/keycloakify/buildOptions/getCacheDirPath.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { join as pathJoin } from "path";
|
||||
import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath";
|
||||
import { getNpmWorkspaceRootDirPath } from "./getNpmWorkspaceRootDirPath";
|
||||
|
||||
export function getCacheDirPath(params: { reactAppRootDirPath: string }) {
|
||||
const { reactAppRootDirPath } = params;
|
||||
|
||||
const { npmWorkspaceRootDirPath } = getNpmWorkspaceRootDirPath({ reactAppRootDirPath });
|
||||
|
||||
const cacheDirPath = pathJoin(
|
||||
(() => {
|
||||
if (process.env.XDG_CACHE_HOME !== undefined) {
|
||||
return getAbsoluteAndInOsFormatPath({
|
||||
"pathIsh": process.env.XDG_CACHE_HOME,
|
||||
"cwd": reactAppRootDirPath
|
||||
});
|
||||
}
|
||||
|
||||
return pathJoin(npmWorkspaceRootDirPath, "node_modules", ".cache");
|
||||
})(),
|
||||
"keycloakify"
|
||||
);
|
||||
|
||||
return { cacheDirPath };
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
import * as child_process from "child_process";
|
||||
import { join as pathJoin, resolve as pathResolve, sep as pathSep } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
|
||||
let cache:
|
||||
| {
|
||||
reactAppRootDirPath: string;
|
||||
npmWorkspaceRootDirPath: string;
|
||||
}
|
||||
| undefined = undefined;
|
||||
|
||||
export function getNpmWorkspaceRootDirPath(params: { reactAppRootDirPath: string }) {
|
||||
const { reactAppRootDirPath } = params;
|
||||
|
||||
use_cache: {
|
||||
if (cache === undefined || cache.reactAppRootDirPath !== reactAppRootDirPath) {
|
||||
break use_cache;
|
||||
}
|
||||
|
||||
const { npmWorkspaceRootDirPath } = cache;
|
||||
|
||||
return { npmWorkspaceRootDirPath };
|
||||
}
|
||||
|
||||
const npmWorkspaceRootDirPath = (function callee(depth: number): string {
|
||||
const cwd = pathResolve(pathJoin(...[reactAppRootDirPath, ...Array(depth).fill("..")]));
|
||||
|
||||
try {
|
||||
child_process.execSync("npm config get", { cwd, "stdio": ["pipe", "pipe", "pipe"] });
|
||||
} catch (error) {
|
||||
if (String(error).includes("ENOWORKSPACES")) {
|
||||
assert(cwd !== pathSep, "NPM workspace not found");
|
||||
|
||||
return callee(depth + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return cwd;
|
||||
})(0);
|
||||
|
||||
cache = {
|
||||
reactAppRootDirPath,
|
||||
npmWorkspaceRootDirPath
|
||||
};
|
||||
|
||||
return { npmWorkspaceRootDirPath };
|
||||
}
|
23
src/bin/keycloakify/buildOptions/getReactAppRootDirPath.ts
Normal file
23
src/bin/keycloakify/buildOptions/getReactAppRootDirPath.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import parseArgv from "minimist";
|
||||
import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath";
|
||||
|
||||
export function getReactAppRootDirPath(params: { processArgv: string[] }) {
|
||||
const { processArgv } = params;
|
||||
|
||||
const argv = parseArgv(processArgv);
|
||||
|
||||
const reactAppRootDirPath = (() => {
|
||||
const arg = argv["project"] ?? argv["p"];
|
||||
|
||||
if (typeof arg !== "string") {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
return getAbsoluteAndInOsFormatPath({
|
||||
"pathIsh": arg,
|
||||
"cwd": process.cwd()
|
||||
});
|
||||
})();
|
||||
|
||||
return { reactAppRootDirPath };
|
||||
}
|
1
src/bin/keycloakify/buildOptions/index.ts
Normal file
1
src/bin/keycloakify/buildOptions/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./buildOptions";
|
32
src/bin/keycloakify/buildOptions/parsedPackageJson.ts
Normal file
32
src/bin/keycloakify/buildOptions/parsedPackageJson.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import * as fs from "fs";
|
||||
import { assert } from "tsafe";
|
||||
import type { Equals } from "tsafe";
|
||||
import { z } from "zod";
|
||||
import { join as pathJoin } from "path";
|
||||
import { type UserProvidedBuildOptions, zUserProvidedBuildOptions } from "./UserProvidedBuildOptions";
|
||||
|
||||
export type ParsedPackageJson = {
|
||||
name: string;
|
||||
version?: string;
|
||||
homepage?: string;
|
||||
keycloakify?: UserProvidedBuildOptions;
|
||||
};
|
||||
|
||||
const zParsedPackageJson = z.object({
|
||||
"name": z.string(),
|
||||
"version": z.string().optional(),
|
||||
"homepage": z.string().optional(),
|
||||
"keycloakify": zUserProvidedBuildOptions.optional()
|
||||
});
|
||||
|
||||
assert<Equals<ReturnType<(typeof zParsedPackageJson)["parse"]>, ParsedPackageJson>>();
|
||||
|
||||
let parsedPackageJson: undefined | ParsedPackageJson;
|
||||
export function readParsedPackageJson(params: { reactAppRootDirPath: string }) {
|
||||
const { reactAppRootDirPath } = params;
|
||||
if (parsedPackageJson) {
|
||||
return parsedPackageJson;
|
||||
}
|
||||
parsedPackageJson = zParsedPackageJson.parse(JSON.parse(fs.readFileSync(pathJoin(reactAppRootDirPath, "package.json")).toString("utf8")));
|
||||
return parsedPackageJson;
|
||||
}
|
74
src/bin/keycloakify/buildOptions/resolvedViteConfig.ts
Normal file
74
src/bin/keycloakify/buildOptions/resolvedViteConfig.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import * as fs from "fs";
|
||||
import { assert } from "tsafe";
|
||||
import type { Equals } from "tsafe";
|
||||
import { z } from "zod";
|
||||
import { join as pathJoin } from "path";
|
||||
import { resolvedViteConfigJsonBasename } from "../../constants";
|
||||
import type { OptionalIfCanBeUndefined } from "../../tools/OptionalIfCanBeUndefined";
|
||||
import { UserProvidedBuildOptions, zUserProvidedBuildOptions } from "./UserProvidedBuildOptions";
|
||||
|
||||
export type ResolvedViteConfig = {
|
||||
buildDir: string;
|
||||
publicDir: string;
|
||||
assetsDir: string;
|
||||
urlPathname: string | undefined;
|
||||
userProvidedBuildOptions: UserProvidedBuildOptions;
|
||||
};
|
||||
|
||||
const zResolvedViteConfig = z.object({
|
||||
"buildDir": z.string(),
|
||||
"publicDir": z.string(),
|
||||
"assetsDir": z.string(),
|
||||
"urlPathname": z.string().optional(),
|
||||
"userProvidedBuildOptions": zUserProvidedBuildOptions
|
||||
});
|
||||
|
||||
{
|
||||
type Got = ReturnType<(typeof zResolvedViteConfig)["parse"]>;
|
||||
type Expected = OptionalIfCanBeUndefined<ResolvedViteConfig>;
|
||||
|
||||
assert<Equals<Got, Expected>>();
|
||||
}
|
||||
|
||||
export function readResolvedViteConfig(params: { cacheDirPath: string }): {
|
||||
resolvedViteConfig: ResolvedViteConfig | undefined;
|
||||
} {
|
||||
const { cacheDirPath } = params;
|
||||
|
||||
const resolvedViteConfigJsonFilePath = pathJoin(cacheDirPath, resolvedViteConfigJsonBasename);
|
||||
|
||||
if (!fs.existsSync(resolvedViteConfigJsonFilePath)) {
|
||||
return { "resolvedViteConfig": undefined };
|
||||
}
|
||||
|
||||
const resolvedViteConfig = (() => {
|
||||
if (!fs.existsSync(resolvedViteConfigJsonFilePath)) {
|
||||
throw new Error("Missing Keycloakify Vite plugin output.");
|
||||
}
|
||||
|
||||
let out: ResolvedViteConfig;
|
||||
|
||||
try {
|
||||
out = JSON.parse(fs.readFileSync(resolvedViteConfigJsonFilePath).toString("utf8"));
|
||||
} catch {
|
||||
throw new Error("The output of the Keycloakify Vite plugin is not a valid JSON.");
|
||||
}
|
||||
|
||||
try {
|
||||
const zodParseReturn = zResolvedViteConfig.parse(out);
|
||||
|
||||
// So that objectKeys from tsafe return the expected result no matter what.
|
||||
Object.keys(zodParseReturn)
|
||||
.filter(key => !(key in out))
|
||||
.forEach(key => {
|
||||
delete (out as any)[key];
|
||||
});
|
||||
} catch {
|
||||
throw new Error("The output of the Keycloakify Vite plugin do not match the expected schema.");
|
||||
}
|
||||
|
||||
return out;
|
||||
})();
|
||||
|
||||
return { resolvedViteConfig };
|
||||
}
|
@ -1 +0,0 @@
|
||||
export const ftlValuesGlobalName = "kcContext";
|
@ -1,131 +1,189 @@
|
||||
<script>const _=
|
||||
<#assign pageId="PAGE_ID_xIgLsPgGId9D8e">
|
||||
(()=>{
|
||||
|
||||
const out = ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc};
|
||||
|
||||
out["msg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
|
||||
out["advancedMsg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
|
||||
|
||||
out["messagesPerField"]= {
|
||||
<#assign fieldNames = [
|
||||
"global", "userLabel", "username", "email", "firstName", "lastName", "password", "password-confirm",
|
||||
"totp", "totpSecret", "SAMLRequest", "SAMLResponse", "relayState", "device_user_code", "code",
|
||||
"password-new", "rememberMe", "login", "authenticationExecution", "cancel-aia", "clientDataJSON",
|
||||
"authenticatorData", "signature", "credentialId", "userHandle", "error", "authn_use_chk", "authenticationExecution",
|
||||
"isSetRetry", "try-again", "attestationObject", "publicKeyCredentialId", "authenticatorLabel"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM
|
||||
]>
|
||||
|
||||
<#attempt>
|
||||
<#if profile?? && profile.attributes?? && profile.attributes?is_enumerable>
|
||||
<#list profile.attributes as attribute>
|
||||
<#if fieldNames?seq_contains(attribute.name)>
|
||||
<#continue>
|
||||
<#assign pageId="PAGE_ID_xIgLsPgGId9D8e">
|
||||
const out = ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc};
|
||||
out["msg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
|
||||
out["advancedMsg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
|
||||
out["messagesPerField"]= {
|
||||
<#assign fieldNames = [ FIELD_NAMES_eKsIY4ZsZ4xeM ]>
|
||||
<#attempt>
|
||||
<#if profile?? && profile.attributes?? && profile.attributes?is_enumerable>
|
||||
<#list profile.attributes as attribute>
|
||||
<#if fieldNames?seq_contains(attribute.name)>
|
||||
<#continue>
|
||||
</#if>
|
||||
<#assign fieldNames += [attribute.name]>
|
||||
</#list>
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
"printIfExists": function (fieldName, text) {
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
<#assign doExistErrorOnUsernameOrPassword = "">
|
||||
<#attempt>
|
||||
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
|
||||
<#recover>
|
||||
<#assign doExistErrorOnUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
<#if doExistErrorOnUsernameOrPassword>
|
||||
return text;
|
||||
<#else>
|
||||
<#assign doExistMessageForField = "">
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
return <#if doExistMessageForField>text<#else>undefined</#if>;
|
||||
</#if>
|
||||
<#else>
|
||||
<#assign doExistMessageForField = "">
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
return <#if doExistMessageForField>text<#else>undefined</#if>;
|
||||
</#if>
|
||||
<#assign fieldNames += [attribute.name]>
|
||||
</#list>
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
"printIfExists": function (fieldName, x) {
|
||||
<#if !messagesPerField?? >
|
||||
return undefined;
|
||||
}
|
||||
</#list>
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
</#if>
|
||||
},
|
||||
"existsError": function (){
|
||||
function existsError_singleFieldName(fieldName) {
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#attempt>
|
||||
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
|
||||
return <#if messagesPerField.existsError('username', 'password')>x<#else>undefined</#if>;
|
||||
<#else>
|
||||
return <#if messagesPerField.existsError('${fieldName}')>x<#else>undefined</#if>;
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
<#assign doExistErrorOnUsernameOrPassword = "">
|
||||
<#attempt>
|
||||
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
|
||||
<#recover>
|
||||
<#assign doExistErrorOnUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
|
||||
<#else>
|
||||
<#assign doExistErrorMessageForField = "">
|
||||
<#attempt>
|
||||
<#assign doExistErrorMessageForField = messagesPerField.existsError('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistErrorMessageForField = true>
|
||||
</#attempt>
|
||||
return <#if doExistErrorMessageForField>true<#else>false</#if>;
|
||||
</#if>
|
||||
}
|
||||
</#list>
|
||||
throw new Error("There is no " + fieldName + " field");
|
||||
</#if>
|
||||
},
|
||||
"existsError": function (fieldName) {
|
||||
<#if !messagesPerField?? >
|
||||
return false;
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#attempt>
|
||||
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
|
||||
return <#if messagesPerField.existsError('username', 'password')>true<#else>false</#if>;
|
||||
<#else>
|
||||
return <#if messagesPerField.existsError('${fieldName}')>true<#else>false</#if>;
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
}
|
||||
</#list>
|
||||
throw new Error("There is no " + fieldName + " field");
|
||||
</#if>
|
||||
},
|
||||
"get": function (fieldName) {
|
||||
<#if !messagesPerField?? >
|
||||
return '';
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#attempt>
|
||||
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
|
||||
<#if messagesPerField.existsError('username', 'password')>
|
||||
return 'Invalid username or password.';
|
||||
</#if>
|
||||
<#else>
|
||||
<#if messagesPerField.existsError('${fieldName}')>
|
||||
return "${messagesPerField.get('${fieldName}')?no_esc}";
|
||||
</#if>
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
}
|
||||
</#list>
|
||||
throw new Error("There is no " + fieldName + " field");
|
||||
</#if>
|
||||
},
|
||||
"exists": function (fieldName) {
|
||||
<#if !messagesPerField?? >
|
||||
return false;
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#attempt>
|
||||
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
|
||||
return <#if messagesPerField.exists('username') || messagesPerField.exists('password')>true<#else>false</#if>;
|
||||
<#else>
|
||||
return <#if messagesPerField.exists('${fieldName}')>true<#else>false</#if>;
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
}
|
||||
</#list>
|
||||
throw new Error("There is no " + fieldName + " field");
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
</#if>
|
||||
}
|
||||
};
|
||||
for( let i = 0; i < arguments.length; i++ ){
|
||||
if( existsError_singleFieldName(arguments[i]) ){
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
"get": function (fieldName) {
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.get in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
<#assign doExistErrorOnUsernameOrPassword = "">
|
||||
<#attempt>
|
||||
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
|
||||
<#recover>
|
||||
<#assign doExistErrorOnUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
<#if doExistErrorOnUsernameOrPassword>
|
||||
<#attempt>
|
||||
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}";
|
||||
<#recover>
|
||||
return "Invalid username or password.";
|
||||
</#attempt>
|
||||
<#else>
|
||||
<#attempt>
|
||||
return "${messagesPerField.get('${fieldName}')?no_esc}";
|
||||
<#recover>
|
||||
return "";
|
||||
</#attempt>
|
||||
</#if>
|
||||
<#else>
|
||||
<#attempt>
|
||||
return "${messagesPerField.get('${fieldName}')?no_esc}";
|
||||
<#recover>
|
||||
return "invalid field";
|
||||
</#attempt>
|
||||
</#if>
|
||||
}
|
||||
</#list>
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
</#if>
|
||||
},
|
||||
"exists": function (fieldName) {
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.exists in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
<#assign doExistErrorOnUsernameOrPassword = "">
|
||||
<#attempt>
|
||||
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
|
||||
<#recover>
|
||||
<#assign doExistErrorOnUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
|
||||
<#else>
|
||||
<#assign doExistErrorMessageForField = "">
|
||||
<#attempt>
|
||||
<#assign doExistErrorMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistErrorMessageForField = true>
|
||||
</#attempt>
|
||||
return <#if doExistErrorMessageForField>true<#else>false</#if>;
|
||||
</#if>
|
||||
}
|
||||
</#list>
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
</#if>
|
||||
},
|
||||
"getFirstError": function () {
|
||||
for( let i = 0; i < arguments.length; i++ ){
|
||||
const fieldName = arguments[i];
|
||||
if( out.messagesPerField.existsError(fieldName) ){
|
||||
return out.messagesPerField.get(fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
<#if account??>
|
||||
out["url"]["getLogoutUrl"] = function () {
|
||||
<#attempt>
|
||||
return "${url.getLogoutUrl()}";
|
||||
<#recover>
|
||||
</#attempt>
|
||||
};
|
||||
</#if>
|
||||
out["keycloakifyVersion"] = "KEYCLOAKIFY_VERSION_xEdKd3xEdr";
|
||||
out["themeVersion"] = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx";
|
||||
out["themeType"] = "KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr";
|
||||
out["themeName"] = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer";
|
||||
out["pageId"] = "${pageId}";
|
||||
|
||||
out["keycloakifyVersion"] = "KEYCLOAKIFY_VERSION_xEdKd3xEdr";
|
||||
out["themeVersion"] = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx";
|
||||
out["pageId"] = "${pageId}";
|
||||
try {
|
||||
out["url"]["resourcesCommonPath"] = out["url"]["resourcesPath"] + "/" + "RESOURCES_COMMON_cLsLsMrtDkpVv";
|
||||
} catch(error) { }
|
||||
|
||||
return out;
|
||||
return out;
|
||||
|
||||
})()
|
||||
})();
|
||||
<#function ftl_object_to_js_code_declaring_an_object object path>
|
||||
|
||||
<#local isHash = "">
|
||||
@ -138,7 +196,7 @@
|
||||
<#if isHash>
|
||||
|
||||
<#if path?size gt 10>
|
||||
<#return "ABORT: Too many recursive calls">
|
||||
<#return "ABORT: Too many recursive calls, path: " + path?join(".")>
|
||||
</#if>
|
||||
|
||||
<#local keys = "">
|
||||
@ -149,7 +207,6 @@
|
||||
<#return "ABORT: We can't list keys on this object">
|
||||
</#attempt>
|
||||
|
||||
|
||||
<#local out_seq = []>
|
||||
|
||||
<#list keys as key>
|
||||
@ -169,10 +226,16 @@
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
|
||||
key == "loginAction" &&
|
||||
are_same_path(path, ["url"]) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl"]?seq_contains(pageId) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
|
||||
!(auth?has_content && auth.showTryAnotherWayLink())
|
||||
) || (
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
|
||||
["secretData", "value"]?seq_contains(key) &&
|
||||
are_same_path(path, [ "totp", "otpCredentials", "*" ])
|
||||
) || (
|
||||
["contextData", "idpConfig", "idp", "authenticationSession"]?seq_contains(key) &&
|
||||
are_same_path(path, ["brokerContext"]) &&
|
||||
@ -188,22 +251,48 @@
|
||||
"error.ftl" == pageId &&
|
||||
are_same_path(path, ["realm"]) &&
|
||||
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
|
||||
) || (
|
||||
"applications.ftl" == pageId &&
|
||||
is_subpath(path, ["applications", "applications"]) &&
|
||||
(
|
||||
key == "realm" ||
|
||||
key == "container"
|
||||
)
|
||||
) || (
|
||||
are_same_path(path, ["user"]) &&
|
||||
key == "delegateForUpdate"
|
||||
) || (
|
||||
<#-- Security audit forwarded by Garth (Gmail) -->
|
||||
are_same_path(path, ["client", "attributes"]) &&
|
||||
key == "saml.signing.private.key"
|
||||
) || (
|
||||
<#-- See: https://github.com/keycloakify/keycloakify/issues/534 -->
|
||||
are_same_path(path, ["login"]) &&
|
||||
key == "password"
|
||||
) || (
|
||||
<#-- Remove realmAttributes added by https://github.com/jcputney/keycloak-theme-additional-info-extension for peace of mind. -->
|
||||
are_same_path(path, []) &&
|
||||
key == "realmAttributes"
|
||||
)
|
||||
>
|
||||
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
|
||||
<#local out_seq += ["/*" + path?join(".") + "." + key + " excluded*/"]>
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#if key == "attemptedUsername" && are_same_path(path, ["auth"])>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
|
||||
<#if (
|
||||
["register.ftl", "register-user-profile.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
|
||||
key == "attemptedUsername" && are_same_path(path, ["auth"])
|
||||
)>
|
||||
<#attempt>
|
||||
<#-- https://github.com/keycloak/keycloak/blob/3a2bf0c04bcde185e497aaa32d0bb7ab7520cf4a/themes/src/main/resources/theme/base/login/template.ftl#L63 -->
|
||||
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
|
||||
<#local out_seq += ["/*" + path?join(".") + "." + key + " excluded*/"]>
|
||||
<#continue>
|
||||
</#if>
|
||||
<#recover>
|
||||
<#local out_seq += ["/*Accessing attemptedUsername throwed an exception */"]>
|
||||
</#attempt>
|
||||
|
||||
</#if>
|
||||
|
||||
<#attempt>
|
||||
@ -278,6 +367,26 @@
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#if are_same_path(path, ["url", "getLogoutUrl"])>
|
||||
<#local returnValue = "">
|
||||
<#attempt>
|
||||
<#local returnValue = url.getLogoutUrl()>
|
||||
<#recover>
|
||||
<#return "ABORT: Couldn't evaluate url.getLogoutUrl()">
|
||||
</#attempt>
|
||||
<#return 'function(){ return "' + returnValue + '"; }'>
|
||||
</#if>
|
||||
|
||||
<#if are_same_path(path, ["totp", "policy", "getAlgorithmKey"])>
|
||||
<#local returnValue = "">
|
||||
<#attempt>
|
||||
<#local returnValue = totp.policy.getAlgorithmKey()>
|
||||
<#recover>
|
||||
<#return "ABORT: Couldn't evaluate totp.policy.getAlgorithmKey()">
|
||||
</#attempt>
|
||||
<#return 'function(){ return "' + returnValue + '"; }'>
|
||||
</#if>
|
||||
|
||||
<#return "ABORT: It's a method">
|
||||
</#if>
|
||||
|
||||
@ -336,17 +445,39 @@
|
||||
|
||||
</#if>
|
||||
|
||||
<#local isDate = "">
|
||||
<#attempt>
|
||||
<#local isDate = object?is_date_like>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it's a date">
|
||||
</#attempt>
|
||||
|
||||
<#if isDate>
|
||||
<#return '"' + object?datetime?iso_utc + '"'>
|
||||
</#if>
|
||||
|
||||
<#local isNumber = "">
|
||||
<#attempt>
|
||||
<#local isNumber = object?is_number>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it's a number">
|
||||
</#attempt>
|
||||
|
||||
<#if isNumber>
|
||||
<#return object?c>
|
||||
</#if>
|
||||
|
||||
<#attempt>
|
||||
<#return '"' + object?js_string + '"'>;
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non enumerable object">
|
||||
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non number, non enumerable object">
|
||||
|
||||
</#function>
|
||||
<#function are_same_path path searchedPath>
|
||||
<#function is_subpath path searchedPath>
|
||||
|
||||
<#if path?size != searchedPath?size>
|
||||
<#if path?size < searchedPath?size>
|
||||
<#return false>
|
||||
</#if>
|
||||
|
||||
@ -354,8 +485,14 @@
|
||||
|
||||
<#list path as property>
|
||||
|
||||
<#if i == searchedPath?size >
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#local searchedProperty=searchedPath[i]>
|
||||
|
||||
<#local i+= 1>
|
||||
|
||||
<#if searchedProperty?is_string && searchedProperty == "*">
|
||||
<#continue>
|
||||
</#if>
|
||||
@ -372,11 +509,13 @@
|
||||
<#return false>
|
||||
</#if>
|
||||
|
||||
<#local i+= 1>
|
||||
|
||||
</#list>
|
||||
|
||||
<#return true>
|
||||
|
||||
</#function>
|
||||
|
||||
<#function are_same_path path searchedPath>
|
||||
<#return path?size == searchedPath?size && is_subpath(path, searchedPath)>
|
||||
</#function>
|
||||
</script>
|
@ -1,82 +1,54 @@
|
||||
import cheerio from "cheerio";
|
||||
import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode";
|
||||
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
|
||||
import { generateCssCodeToDefineGlobals } from "../replacers/replaceImportsInCssCode";
|
||||
import { replaceImportsInInlineCssCode } from "../replacers/replaceImportsInInlineCssCode";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { objectKeys } from "tsafe/objectKeys";
|
||||
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import type { BuildOptions } from "../buildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { type ThemeType, nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir, resources_common } from "../../constants";
|
||||
|
||||
export const themeTypes = ["login", "account"] as const;
|
||||
|
||||
export type ThemeType = (typeof themeTypes)[number];
|
||||
|
||||
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
|
||||
|
||||
export namespace BuildOptionsLike {
|
||||
export type Common = {
|
||||
customUserAttributes: string[];
|
||||
themeVersion: string;
|
||||
};
|
||||
|
||||
export type Standalone = Common & {
|
||||
isStandalone: true;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
|
||||
|
||||
export namespace ExternalAssets {
|
||||
export type CommonExternalAssets = {
|
||||
isStandalone: false;
|
||||
};
|
||||
|
||||
export type SameDomain = Common &
|
||||
CommonExternalAssets & {
|
||||
areAppAndKeycloakServerSharingSameDomain: true;
|
||||
};
|
||||
|
||||
export type DifferentDomains = Common &
|
||||
CommonExternalAssets & {
|
||||
areAppAndKeycloakServerSharingSameDomain: false;
|
||||
urlOrigin: string;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
}
|
||||
}
|
||||
export type BuildOptionsLike = {
|
||||
bundler: "vite" | "webpack";
|
||||
themeVersion: string;
|
||||
urlPathname: string | undefined;
|
||||
reactAppBuildDirPath: string;
|
||||
assetsDirPath: string;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export function generateFtlFilesCodeFactory(params: {
|
||||
themeName: string;
|
||||
indexHtmlCode: string;
|
||||
//NOTE: Expected to be an empty object if external assets mode is enabled.
|
||||
cssGlobalsToDefine: Record<string, string>;
|
||||
buildOptions: BuildOptionsLike;
|
||||
keycloakifyVersion: string;
|
||||
themeType: ThemeType;
|
||||
fieldNames: string[];
|
||||
}) {
|
||||
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion } = params;
|
||||
const { themeName, cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType, fieldNames } = params;
|
||||
|
||||
const $ = cheerio.load(indexHtmlCode);
|
||||
|
||||
fix_imports_statements: {
|
||||
if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) {
|
||||
break fix_imports_statements;
|
||||
}
|
||||
|
||||
$("script:not([src])").each((...[, element]) => {
|
||||
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
|
||||
"jsCode": $(element).html()!,
|
||||
buildOptions
|
||||
});
|
||||
const jsCode = $(element).html();
|
||||
|
||||
assert(jsCode !== null);
|
||||
|
||||
const { fixedJsCode } = replaceImportsInJsCode({ jsCode, buildOptions });
|
||||
|
||||
$(element).text(fixedJsCode);
|
||||
});
|
||||
|
||||
$("style").each((...[, element]) => {
|
||||
const cssCode = $(element).html();
|
||||
|
||||
assert(cssCode !== null);
|
||||
|
||||
const { fixedCssCode } = replaceImportsInInlineCssCode({
|
||||
"cssCode": $(element).html()!,
|
||||
cssCode,
|
||||
buildOptions
|
||||
});
|
||||
|
||||
@ -98,9 +70,10 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
|
||||
$(element).attr(
|
||||
attrName,
|
||||
buildOptions.isStandalone
|
||||
? href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/")
|
||||
: href.replace(/^\//, `${buildOptions.urlOrigin}/`)
|
||||
href.replace(
|
||||
new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`),
|
||||
`\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
@ -122,35 +95,41 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
}
|
||||
|
||||
//FTL is no valid html, we can't insert with cheerio, we put placeholder for injecting later.
|
||||
const replaceValueBySearchValue = {
|
||||
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }': fs
|
||||
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
|
||||
.toString("utf8")
|
||||
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1]
|
||||
.replace(
|
||||
"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM",
|
||||
buildOptions.customUserAttributes.length === 0 ? "" : ", " + buildOptions.customUserAttributes.map(name => `"${name}"`).join(", ")
|
||||
)
|
||||
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
|
||||
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion),
|
||||
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
|
||||
"<#if scripts??>",
|
||||
" <#list scripts as script>",
|
||||
' <script src="${script}" type="text/javascript"></script>',
|
||||
" </#list>",
|
||||
"</#if>"
|
||||
].join("\n")
|
||||
};
|
||||
const ftlObjectToJsCodeDeclaringAnObject = fs
|
||||
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
|
||||
.toString("utf8")
|
||||
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1]
|
||||
.replace("FIELD_NAMES_eKsIY4ZsZ4xeM", fieldNames.map(name => `"${name}"`).join(", "))
|
||||
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
|
||||
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion)
|
||||
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
|
||||
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", themeName)
|
||||
.replace("RESOURCES_COMMON_cLsLsMrtDkpVv", resources_common);
|
||||
|
||||
$("head").prepend(
|
||||
[
|
||||
"<script>",
|
||||
` window.${ftlValuesGlobalName}= ${objectKeys(replaceValueBySearchValue)[0]};`,
|
||||
"</script>",
|
||||
"",
|
||||
objectKeys(replaceValueBySearchValue)[1]
|
||||
].join("\n")
|
||||
);
|
||||
const ftlObjectToJsCodeDeclaringAnObjectPlaceholder = '{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }';
|
||||
|
||||
$("head").prepend(`<script>\nwindow.${nameOfTheGlobal}=${ftlObjectToJsCodeDeclaringAnObjectPlaceholder}</script>`);
|
||||
|
||||
// Remove part of the document marked as ignored.
|
||||
{
|
||||
const startTags = $('meta[name="keycloakify-ignore-start"]');
|
||||
|
||||
startTags.each((...[, startTag]) => {
|
||||
const $startTag = $(startTag);
|
||||
const $endTag = $startTag.nextAll('meta[name="keycloakify-ignore-end"]').first();
|
||||
|
||||
if ($endTag.length) {
|
||||
let currentNode = $startTag.next();
|
||||
while (currentNode.length && !currentNode.is($endTag)) {
|
||||
currentNode.remove();
|
||||
currentNode = $startTag.next();
|
||||
}
|
||||
|
||||
$startTag.remove();
|
||||
$endTag.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const partiallyFixedIndexHtmlCode = $.html();
|
||||
|
||||
@ -164,7 +143,7 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
let ftlCode = $.html();
|
||||
|
||||
Object.entries({
|
||||
...replaceValueBySearchValue,
|
||||
[ftlObjectToJsCodeDeclaringAnObjectPlaceholder]: ftlObjectToJsCodeDeclaringAnObject,
|
||||
"PAGE_ID_xIgLsPgGId9D8e": pageId
|
||||
}).map(([searchValue, replaceValue]) => (ftlCode = ftlCode.replace(searchValue, replaceValue)));
|
||||
|
||||
|
@ -3,13 +3,15 @@ export const loginThemePageIds = [
|
||||
"login-username.ftl",
|
||||
"login-password.ftl",
|
||||
"webauthn-authenticate.ftl",
|
||||
"webauthn-register.ftl",
|
||||
"register.ftl",
|
||||
"register-user-profile.ftl",
|
||||
"info.ftl",
|
||||
"error.ftl",
|
||||
"login-reset-password.ftl",
|
||||
"login-verify-email.ftl",
|
||||
"terms.ftl",
|
||||
"login-oauth2-device-verify-user-code.ftl",
|
||||
"login-oauth-grant.ftl",
|
||||
"login-otp.ftl",
|
||||
"login-update-profile.ftl",
|
||||
"login-update-password.ftl",
|
||||
@ -18,14 +20,22 @@ export const loginThemePageIds = [
|
||||
"login-page-expired.ftl",
|
||||
"login-config-totp.ftl",
|
||||
"logout-confirm.ftl",
|
||||
"update-user-profile.ftl",
|
||||
"idp-review-user-profile.ftl",
|
||||
"update-email.ftl",
|
||||
"select-authenticator.ftl",
|
||||
"saml-post-form.ftl"
|
||||
"saml-post-form.ftl",
|
||||
"delete-credential.ftl",
|
||||
"code.ftl",
|
||||
"delete-account-confirm.ftl",
|
||||
"frontchannel-logout.ftl",
|
||||
"login-recovery-authn-code-config.ftl",
|
||||
"login-recovery-authn-code-input.ftl",
|
||||
"login-reset-otp.ftl",
|
||||
"login-x509-info.ftl",
|
||||
"webauthn-error.ftl"
|
||||
] as const;
|
||||
|
||||
export const accountThemePageIds = ["password.ftl", "account.ftl"] as const;
|
||||
export const accountThemePageIds = ["password.ftl", "account.ftl", "sessions.ftl", "totp.ftl", "applications.ftl", "log.ftl"] as const;
|
||||
|
||||
export type LoginThemePageId = (typeof loginThemePageIds)[number];
|
||||
export type AccountThemePageId = (typeof accountThemePageIds)[number];
|
||||
|
@ -1,88 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, dirname as pathDirname } from "path";
|
||||
import { themeTypes } from "./generateFtl/generateFtl";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
import type { BuildOptions } from "./BuildOptions";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
themeName: string;
|
||||
groupId: string;
|
||||
artifactId?: string;
|
||||
themeVersion: string;
|
||||
};
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
|
||||
export function generateJavaStackFiles(params: {
|
||||
keycloakThemeBuildingDirPath: string;
|
||||
doBundlesEmailTemplate: boolean;
|
||||
buildOptions: BuildOptionsLike;
|
||||
}): {
|
||||
jarFilePath: string;
|
||||
} {
|
||||
const {
|
||||
buildOptions: { groupId, themeName, themeVersion, artifactId },
|
||||
keycloakThemeBuildingDirPath,
|
||||
doBundlesEmailTemplate
|
||||
} = params;
|
||||
|
||||
{
|
||||
const { pomFileCode } = (function generatePomFileCode(): {
|
||||
pomFileCode: string;
|
||||
} {
|
||||
const pomFileCode = [
|
||||
`<?xml version="1.0"?>`,
|
||||
`<project xmlns="http://maven.apache.org/POM/4.0.0"`,
|
||||
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
|
||||
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
|
||||
` <modelVersion>4.0.0</modelVersion>`,
|
||||
` <groupId>${groupId}</groupId>`,
|
||||
` <artifactId>${artifactId}</artifactId>`,
|
||||
` <version>${themeVersion}</version>`,
|
||||
` <name>${artifactId}</name>`,
|
||||
` <description />`,
|
||||
`</project>`
|
||||
].join("\n");
|
||||
|
||||
return { pomFileCode };
|
||||
})();
|
||||
|
||||
fs.writeFileSync(pathJoin(keycloakThemeBuildingDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8"));
|
||||
}
|
||||
|
||||
{
|
||||
const themeManifestFilePath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "META-INF", "keycloak-themes.json");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(pathDirname(themeManifestFilePath));
|
||||
} catch {}
|
||||
|
||||
fs.writeFileSync(
|
||||
themeManifestFilePath,
|
||||
Buffer.from(
|
||||
JSON.stringify(
|
||||
{
|
||||
"themes": [
|
||||
{
|
||||
"name": themeName,
|
||||
"types": [...themeTypes, ...(doBundlesEmailTemplate ? ["email"] : [])]
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
"jarFilePath": pathJoin(keycloakThemeBuildingDirPath, "target", `${artifactId}-${themeVersion}.jar`)
|
||||
};
|
||||
}
|
@ -1,55 +1,54 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { join as pathJoin, relative as pathRelative, basename as pathBasename } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
import type { BuildOptions } from "./BuildOptions";
|
||||
import type { BuildOptions } from "./buildOptions";
|
||||
import { accountV1ThemeName } from "../constants";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
themeName: string;
|
||||
keycloakifyBuildDirPath: string;
|
||||
themeNames: string[];
|
||||
};
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh";
|
||||
|
||||
const containerName = "keycloak-testing-container";
|
||||
const keycloakVersion = "24.0.4";
|
||||
|
||||
/** Files for being able to run a hot reload keycloak container */
|
||||
export function generateStartKeycloakTestingContainer(params: {
|
||||
keycloakVersion: string;
|
||||
keycloakThemeBuildingDirPath: string;
|
||||
jarFilePath: string;
|
||||
doesImplementAccountTheme: boolean;
|
||||
buildOptions: BuildOptionsLike;
|
||||
}) {
|
||||
const {
|
||||
keycloakThemeBuildingDirPath,
|
||||
keycloakVersion,
|
||||
buildOptions: { themeName }
|
||||
} = params;
|
||||
const { jarFilePath, doesImplementAccountTheme, buildOptions } = params;
|
||||
|
||||
const keycloakThemePath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace(/\\/g, "/");
|
||||
const themeRelativeDirPath = pathJoin("src", "main", "resources", "theme");
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename),
|
||||
|
||||
pathJoin(buildOptions.keycloakifyBuildDirPath, generateStartKeycloakTestingContainer.basename),
|
||||
Buffer.from(
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"",
|
||||
`docker rm ${containerName} || true`,
|
||||
"",
|
||||
`cd "${keycloakThemeBuildingDirPath.replace(/\\/g, "/")}"`,
|
||||
`cd "${buildOptions.keycloakifyBuildDirPath}"`,
|
||||
"",
|
||||
"docker run \\",
|
||||
" -p 8080:8080 \\",
|
||||
` --name ${containerName} \\`,
|
||||
" -e KEYCLOAK_ADMIN=admin \\",
|
||||
" -e KEYCLOAK_ADMIN_PASSWORD=admin \\",
|
||||
" -e JAVA_OPTS=-Dkeycloak.profile=preview \\",
|
||||
` -v "${keycloakThemePath}":"/opt/keycloak/themes/${themeName}":rw \\`,
|
||||
` -v "${pathJoin(
|
||||
"$(pwd)",
|
||||
pathRelative(buildOptions.keycloakifyBuildDirPath, jarFilePath)
|
||||
)}":"/opt/keycloak/providers/${pathBasename(jarFilePath)}" \\`,
|
||||
[...(doesImplementAccountTheme ? [accountV1ThemeName] : []), ...buildOptions.themeNames].map(
|
||||
themeName =>
|
||||
` -v "${pathJoin("$(pwd)", themeRelativeDirPath, themeName).replace(/\\/g, "/")}":"/opt/keycloak/themes/${themeName}":rw \\`
|
||||
),
|
||||
` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`,
|
||||
` start-dev`,
|
||||
""
|
||||
|
78
src/bin/keycloakify/generateTheme/bringInAccountV1.ts
Normal file
78
src/bin/keycloakify/generateTheme/bringInAccountV1.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { BuildOptions } from "../buildOptions";
|
||||
import { resources_common, lastKeycloakVersionWithAccountV1, accountV1ThemeName } from "../../constants";
|
||||
import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme";
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import { rmSync } from "../../tools/fs.rmSync";
|
||||
|
||||
type BuildOptionsLike = {
|
||||
cacheDirPath: string;
|
||||
npmWorkspaceRootDirPath: string;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export async function bringInAccountV1(params: { buildOptions: BuildOptionsLike; srcMainResourcesDirPath: string }) {
|
||||
const { buildOptions, srcMainResourcesDirPath } = params;
|
||||
|
||||
const builtinKeycloakThemeTmpDirPath = pathJoin(srcMainResourcesDirPath, "..", "tmp_yxdE2_builtin_keycloak_theme");
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
"destDirPath": builtinKeycloakThemeTmpDirPath,
|
||||
"keycloakVersion": lastKeycloakVersionWithAccountV1,
|
||||
buildOptions
|
||||
});
|
||||
|
||||
const accountV1DirPath = pathJoin(srcMainResourcesDirPath, "theme", accountV1ThemeName, "account");
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "base", "account"),
|
||||
"destDirPath": accountV1DirPath
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "keycloak", "account", "resources"),
|
||||
"destDirPath": pathJoin(accountV1DirPath, "resources")
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "keycloak", "common", "resources"),
|
||||
"destDirPath": pathJoin(accountV1DirPath, "resources", resources_common)
|
||||
});
|
||||
|
||||
rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true });
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(accountV1DirPath, "theme.properties"),
|
||||
Buffer.from(
|
||||
[
|
||||
"accountResourceProvider=account-v1",
|
||||
"",
|
||||
"locales=ar,ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN",
|
||||
"",
|
||||
"styles=" +
|
||||
[
|
||||
"css/account.css",
|
||||
"img/icon-sidebar-active.png",
|
||||
"img/logo.png",
|
||||
...["patternfly.min.css", "patternfly-additions.min.css", "patternfly-additions.min.css"].map(
|
||||
fileBasename => `${resources_common}/node_modules/patternfly/dist/css/${fileBasename}`
|
||||
)
|
||||
].join(" "),
|
||||
"",
|
||||
"##### css classes for form buttons",
|
||||
"# main class used for all buttons",
|
||||
"kcButtonClass=btn",
|
||||
"# classes defining priority of the button - primary or default (there is typically only one priority button for the form)",
|
||||
"kcButtonPrimaryClass=btn-primary",
|
||||
"kcButtonDefaultClass=btn-default",
|
||||
"# classes defining size of the button",
|
||||
"kcButtonLargeClass=btn-lg",
|
||||
""
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
@ -1,47 +1,49 @@
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
import type { ThemeType } from "../generateFtl";
|
||||
import { join as pathJoin } from "path";
|
||||
import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme";
|
||||
import {
|
||||
resourcesCommonDirPathRelativeToPublicDir,
|
||||
resourcesDirPathRelativeToPublicDir,
|
||||
basenameOfKeycloakDirInPublicDir
|
||||
} from "../../mockTestingResourcesPath";
|
||||
import { resources_common, type ThemeType } from "../../constants";
|
||||
import { BuildOptions } from "../buildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import * as crypto from "crypto";
|
||||
import { rmSync } from "../../tools/fs.rmSync";
|
||||
|
||||
export async function downloadKeycloakStaticResources(
|
||||
// prettier-ignore
|
||||
params: {
|
||||
themeType: ThemeType;
|
||||
themeDirPath: string;
|
||||
isSilent: boolean;
|
||||
keycloakVersion: string;
|
||||
}
|
||||
) {
|
||||
const { themeType, isSilent, themeDirPath, keycloakVersion } = params;
|
||||
export type BuildOptionsLike = {
|
||||
cacheDirPath: string;
|
||||
npmWorkspaceRootDirPath: string;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export async function downloadKeycloakStaticResources(params: {
|
||||
themeType: ThemeType;
|
||||
themeDirPath: string;
|
||||
keycloakVersion: string;
|
||||
buildOptions: BuildOptionsLike;
|
||||
}) {
|
||||
const { themeType, themeDirPath, keycloakVersion, buildOptions } = params;
|
||||
|
||||
const tmpDirPath = pathJoin(
|
||||
themeDirPath,
|
||||
"..",
|
||||
`tmp_suLeKsxId_${crypto.createHash("sha256").update(`${themeType}-${keycloakVersion}`).digest("hex").slice(0, 8)}`
|
||||
);
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
keycloakVersion,
|
||||
"destDirPath": tmpDirPath,
|
||||
isSilent
|
||||
buildOptions
|
||||
});
|
||||
|
||||
const resourcesPath = pathJoin(themeDirPath, themeType, "resources");
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(tmpDirPath, "keycloak", themeType, "resources"),
|
||||
"destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesDirPathRelativeToPublicDir))
|
||||
"destDirPath": resourcesPath
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"),
|
||||
"destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesCommonDirPathRelativeToPublicDir))
|
||||
"destDirPath": pathJoin(resourcesPath, resources_common)
|
||||
});
|
||||
|
||||
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
|
||||
rmSync(tmpDirPath, { "recursive": true, "force": true });
|
||||
}
|
||||
|
179
src/bin/keycloakify/generateTheme/generateMessageProperties.ts
Normal file
179
src/bin/keycloakify/generateTheme/generateMessageProperties.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import type { ThemeType } from "../../constants";
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { join as pathJoin } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
|
||||
import * as recast from "recast";
|
||||
import * as babelParser from "@babel/parser";
|
||||
import babelGenerate from "@babel/generator";
|
||||
import * as babelTypes from "@babel/types";
|
||||
|
||||
export function generateMessageProperties(params: {
|
||||
themeSrcDirPath: string;
|
||||
themeType: ThemeType;
|
||||
}): { languageTag: string; propertiesFileSource: string }[] {
|
||||
const { themeSrcDirPath, themeType } = params;
|
||||
|
||||
let files = crawl({
|
||||
"dirPath": pathJoin(themeSrcDirPath, themeType),
|
||||
"returnedPathsType": "absolute"
|
||||
});
|
||||
|
||||
files = files.filter(file => {
|
||||
const regex = /\.(js|ts|tsx)$/;
|
||||
return regex.test(file);
|
||||
});
|
||||
|
||||
files = files.sort((a, b) => {
|
||||
const regex = /\.i18n\.(ts|js|tsx)$/;
|
||||
const aIsI18nFile = regex.test(a);
|
||||
const bIsI18nFile = regex.test(b);
|
||||
return aIsI18nFile === bIsI18nFile ? 0 : aIsI18nFile ? -1 : 1;
|
||||
});
|
||||
|
||||
files = files.sort((a, b) => a.length - b.length);
|
||||
|
||||
files = files.filter(file => readFileSync(file).toString("utf8").includes("createUseI18n"));
|
||||
|
||||
if (files.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extraMessages = files
|
||||
.map(file => {
|
||||
const root = recast.parse(readFileSync(file).toString("utf8"), {
|
||||
"parser": {
|
||||
"parse": (code: string) => babelParser.parse(code, { "sourceType": "module", "plugins": ["typescript"] }),
|
||||
"generator": babelGenerate,
|
||||
"types": babelTypes
|
||||
}
|
||||
});
|
||||
|
||||
const codes: string[] = [];
|
||||
|
||||
recast.visit(root, {
|
||||
"visitCallExpression": function (path) {
|
||||
if (path.node.callee.type === "Identifier" && path.node.callee.name === "createUseI18n") {
|
||||
codes.push(babelGenerate(path.node.arguments[0] as any).code);
|
||||
}
|
||||
this.traverse(path);
|
||||
}
|
||||
});
|
||||
|
||||
return codes;
|
||||
})
|
||||
.flat()
|
||||
.map(code => {
|
||||
let extraMessages: { [languageTag: string]: Record<string, string> } = {};
|
||||
|
||||
try {
|
||||
eval(`${symToStr({ extraMessages })} = ${code}`);
|
||||
} catch {
|
||||
console.warn(
|
||||
[
|
||||
"WARNING: Make sure that the first argument of createUseI18n can be evaluated in a javascript",
|
||||
"runtime where only the node globals are available.",
|
||||
"This is important because we need to put your i18n messages in messages_*.properties files",
|
||||
"or they won't be available server side.",
|
||||
"\n",
|
||||
"The following code could not be evaluated:",
|
||||
"\n",
|
||||
code
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
return extraMessages;
|
||||
});
|
||||
|
||||
const languageTags = extraMessages
|
||||
.map(extraMessage => Object.keys(extraMessage))
|
||||
.flat()
|
||||
.reduce(...removeDuplicates<string>());
|
||||
|
||||
const keyValueMapByLanguageTag: Record<string, Record<string, string>> = {};
|
||||
|
||||
for (const languageTag of languageTags) {
|
||||
const keyValueMap: Record<string, string> = {};
|
||||
|
||||
for (const extraMessage of extraMessages) {
|
||||
const keyValueMap_i = extraMessage[languageTag];
|
||||
|
||||
if (keyValueMap_i === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(keyValueMap_i)) {
|
||||
if (keyValueMap[key] !== undefined) {
|
||||
console.warn(
|
||||
[
|
||||
"WARNING: The following key is defined multiple times:",
|
||||
"\n",
|
||||
key,
|
||||
"\n",
|
||||
"The following value will be ignored:",
|
||||
"\n",
|
||||
value,
|
||||
"\n",
|
||||
"The following value was already defined:",
|
||||
"\n",
|
||||
keyValueMap[key]
|
||||
].join(" ")
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
keyValueMap[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
keyValueMapByLanguageTag[languageTag] = keyValueMap;
|
||||
}
|
||||
|
||||
const out: { languageTag: string; propertiesFileSource: string }[] = [];
|
||||
|
||||
for (const [languageTag, keyValueMap] of Object.entries(keyValueMapByLanguageTag)) {
|
||||
const propertiesFileSource = Object.entries(keyValueMap)
|
||||
.map(([key, value]) => `${key}=${escapeString(value)}`)
|
||||
.join("\n");
|
||||
|
||||
out.push({
|
||||
languageTag,
|
||||
"propertiesFileSource": ["# This file was generated by keycloakify", "", "parent=base", "", propertiesFileSource, ""].join("\n")
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Convert a JavaScript string to UTF-16 encoding
|
||||
function toUTF16(codePoint: number): string {
|
||||
if (codePoint <= 0xffff) {
|
||||
// BMP character
|
||||
return "\\u" + codePoint.toString(16).padStart(4, "0");
|
||||
} else {
|
||||
// Non-BMP character
|
||||
codePoint -= 0x10000;
|
||||
let highSurrogate = (codePoint >> 10) + 0xd800;
|
||||
let lowSurrogate = (codePoint % 0x400) + 0xdc00;
|
||||
return "\\u" + highSurrogate.toString(16).padStart(4, "0") + "\\u" + lowSurrogate.toString(16).padStart(4, "0");
|
||||
}
|
||||
}
|
||||
|
||||
// Escapes special characters and converts unicode to UTF-16 encoding
|
||||
function escapeString(str: string): string {
|
||||
let escapedStr = "";
|
||||
for (const char of [...str]) {
|
||||
const codePoint = char.codePointAt(0);
|
||||
if (!codePoint) continue;
|
||||
if (char === "'") {
|
||||
escapedStr += "''"; // double single quotes
|
||||
} else if (codePoint > 0x7f) {
|
||||
escapedStr += toUTF16(codePoint); // non-ascii characters
|
||||
} else {
|
||||
escapedStr += char;
|
||||
}
|
||||
}
|
||||
return escapedStr;
|
||||
}
|
267
src/bin/keycloakify/generateTheme/generateSrcMainResources.ts
Normal file
267
src/bin/keycloakify/generateTheme/generateSrcMainResources.ts
Normal file
@ -0,0 +1,267 @@
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, resolve as pathResolve, dirname as pathDirname } from "path";
|
||||
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
|
||||
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
|
||||
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds } from "../generateFtl";
|
||||
import {
|
||||
type ThemeType,
|
||||
lastKeycloakVersionWithAccountV1,
|
||||
keycloak_resources,
|
||||
accountV1ThemeName,
|
||||
basenameOfTheKeycloakifyResourcesDir
|
||||
} from "../../constants";
|
||||
import { isInside } from "../../tools/isInside";
|
||||
import type { BuildOptions } from "../buildOptions";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import { downloadKeycloakStaticResources } from "./downloadKeycloakStaticResources";
|
||||
import { readFieldNameUsage } from "./readFieldNameUsage";
|
||||
import { readExtraPagesNames } from "./readExtraPageNames";
|
||||
import { generateMessageProperties } from "./generateMessageProperties";
|
||||
import { bringInAccountV1 } from "./bringInAccountV1";
|
||||
import { rmSync } from "../../tools/fs.rmSync";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
bundler: "vite" | "webpack";
|
||||
extraThemeProperties: string[] | undefined;
|
||||
themeVersion: string;
|
||||
loginThemeResourcesFromKeycloakVersion: string;
|
||||
reactAppBuildDirPath: string;
|
||||
cacheDirPath: string;
|
||||
assetsDirPath: string;
|
||||
urlPathname: string | undefined;
|
||||
npmWorkspaceRootDirPath: string;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export async function generateSrcMainResources(params: {
|
||||
themeName: string;
|
||||
themeSrcDirPath: string;
|
||||
keycloakifySrcDirPath: string;
|
||||
buildOptions: BuildOptionsLike;
|
||||
keycloakifyVersion: string;
|
||||
srcMainResourcesDirPath: string;
|
||||
}): Promise<{ doesImplementAccountTheme: boolean }> {
|
||||
const { themeName, themeSrcDirPath, keycloakifySrcDirPath, buildOptions, keycloakifyVersion, srcMainResourcesDirPath } = params;
|
||||
|
||||
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => {
|
||||
const { themeType } = params;
|
||||
return pathJoin(srcMainResourcesDirPath, "theme", themeName, themeType);
|
||||
};
|
||||
|
||||
const cssGlobalsToDefine: Record<string, string> = {};
|
||||
|
||||
const implementedThemeTypes: Record<ThemeType | "email", boolean> = {
|
||||
"login": false,
|
||||
"account": false,
|
||||
"email": false
|
||||
};
|
||||
|
||||
for (const themeType of ["login", "account"] as const) {
|
||||
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
implementedThemeTypes[themeType] = true;
|
||||
|
||||
const themeTypeDirPath = getThemeTypeDirPath({ themeType });
|
||||
|
||||
apply_replacers_and_move_to_theme_resources: {
|
||||
const destDirPath = pathJoin(themeTypeDirPath, "resources", basenameOfTheKeycloakifyResourcesDir);
|
||||
|
||||
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
|
||||
rmSync(destDirPath, { "recursive": true, "force": true });
|
||||
|
||||
if (themeType === "account" && implementedThemeTypes.login) {
|
||||
// NOTE: We prevend doing it twice, it has been done for the login theme.
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(
|
||||
getThemeTypeDirPath({
|
||||
"themeType": "login"
|
||||
}),
|
||||
"resources",
|
||||
basenameOfTheKeycloakifyResourcesDir
|
||||
),
|
||||
destDirPath
|
||||
});
|
||||
|
||||
break apply_replacers_and_move_to_theme_resources;
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": buildOptions.reactAppBuildDirPath,
|
||||
destDirPath,
|
||||
"transformSourceCode": ({ filePath, sourceCode }) => {
|
||||
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
|
||||
// This should not happen if users follow the new instruction setup but we keep it for retrocompatibility.
|
||||
if (
|
||||
isInside({
|
||||
"dirPath": pathJoin(buildOptions.reactAppBuildDirPath, keycloak_resources),
|
||||
filePath
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/\.css?$/i.test(filePath)) {
|
||||
const { cssGlobalsToDefine: cssGlobalsToDefineForThisFile, fixedCssCode } = replaceImportsInCssCode({
|
||||
"cssCode": sourceCode.toString("utf8")
|
||||
});
|
||||
|
||||
Object.entries(cssGlobalsToDefineForThisFile).forEach(([key, value]) => {
|
||||
cssGlobalsToDefine[key] = value;
|
||||
});
|
||||
|
||||
return { "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8") };
|
||||
}
|
||||
|
||||
if (/\.js?$/i.test(filePath)) {
|
||||
const { fixedJsCode } = replaceImportsInJsCode({
|
||||
"jsCode": sourceCode.toString("utf8"),
|
||||
buildOptions
|
||||
});
|
||||
|
||||
return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
|
||||
}
|
||||
|
||||
return { "modifiedSourceCode": sourceCode };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
|
||||
themeName,
|
||||
"indexHtmlCode": fs.readFileSync(pathJoin(buildOptions.reactAppBuildDirPath, "index.html")).toString("utf8"),
|
||||
cssGlobalsToDefine,
|
||||
buildOptions,
|
||||
keycloakifyVersion,
|
||||
themeType,
|
||||
"fieldNames": readFieldNameUsage({
|
||||
keycloakifySrcDirPath,
|
||||
themeSrcDirPath,
|
||||
themeType
|
||||
})
|
||||
});
|
||||
|
||||
[
|
||||
...(() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return loginThemePageIds;
|
||||
case "account":
|
||||
return accountThemePageIds;
|
||||
}
|
||||
})(),
|
||||
...readExtraPagesNames({
|
||||
themeType,
|
||||
themeSrcDirPath
|
||||
})
|
||||
].forEach(pageId => {
|
||||
const { ftlCode } = generateFtlFilesCode({ pageId });
|
||||
|
||||
fs.mkdirSync(themeTypeDirPath, { "recursive": true });
|
||||
|
||||
fs.writeFileSync(pathJoin(themeTypeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
|
||||
});
|
||||
|
||||
generateMessageProperties({
|
||||
themeSrcDirPath,
|
||||
themeType
|
||||
}).forEach(({ languageTag, propertiesFileSource }) => {
|
||||
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
|
||||
|
||||
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), { "recursive": true });
|
||||
|
||||
const propertiesFilePath = pathJoin(messagesDirPath, `messages_${languageTag}.properties`);
|
||||
|
||||
fs.writeFileSync(propertiesFilePath, Buffer.from(propertiesFileSource, "utf8"));
|
||||
});
|
||||
|
||||
await downloadKeycloakStaticResources({
|
||||
"keycloakVersion": (() => {
|
||||
switch (themeType) {
|
||||
case "account":
|
||||
return lastKeycloakVersionWithAccountV1;
|
||||
case "login":
|
||||
return buildOptions.loginThemeResourcesFromKeycloakVersion;
|
||||
}
|
||||
})(),
|
||||
"themeDirPath": pathResolve(pathJoin(themeTypeDirPath, "..")),
|
||||
themeType,
|
||||
buildOptions
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(themeTypeDirPath, "theme.properties"),
|
||||
Buffer.from(
|
||||
[
|
||||
`parent=${(() => {
|
||||
switch (themeType) {
|
||||
case "account":
|
||||
return accountV1ThemeName;
|
||||
case "login":
|
||||
return "keycloak";
|
||||
}
|
||||
assert<Equals<typeof themeType, never>>(false);
|
||||
})()}`,
|
||||
...(buildOptions.extraThemeProperties ?? [])
|
||||
].join("\n\n"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
email: {
|
||||
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
|
||||
|
||||
if (!fs.existsSync(emailThemeSrcDirPath)) {
|
||||
break email;
|
||||
}
|
||||
|
||||
implementedThemeTypes.email = true;
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": emailThemeSrcDirPath,
|
||||
"destDirPath": getThemeTypeDirPath({ "themeType": "email" })
|
||||
});
|
||||
}
|
||||
|
||||
const parsedKeycloakThemeJson: { themes: { name: string; types: string[] }[] } = { "themes": [] };
|
||||
|
||||
parsedKeycloakThemeJson.themes.push({
|
||||
"name": themeName,
|
||||
"types": Object.entries(implementedThemeTypes)
|
||||
.filter(([, isImplemented]) => isImplemented)
|
||||
.map(([themeType]) => themeType)
|
||||
});
|
||||
|
||||
account_specific_extra_work: {
|
||||
if (!implementedThemeTypes.account) {
|
||||
break account_specific_extra_work;
|
||||
}
|
||||
|
||||
await bringInAccountV1({
|
||||
srcMainResourcesDirPath,
|
||||
buildOptions
|
||||
});
|
||||
|
||||
parsedKeycloakThemeJson.themes.push({
|
||||
"name": accountV1ThemeName,
|
||||
"types": ["account"]
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const keycloakThemeJsonFilePath = pathJoin(srcMainResourcesDirPath, "META-INF", "keycloak-themes.json");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(pathDirname(keycloakThemeJsonFilePath));
|
||||
} catch {}
|
||||
|
||||
fs.writeFileSync(keycloakThemeJsonFilePath, Buffer.from(JSON.stringify(parsedKeycloakThemeJson, null, 2), "utf8"));
|
||||
}
|
||||
|
||||
return { "doesImplementAccountTheme": implementedThemeTypes.account };
|
||||
}
|
@ -1,239 +1,44 @@
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode";
|
||||
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
|
||||
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds, themeTypes, type ThemeType } from "../generateFtl";
|
||||
import { basenameOfKeycloakDirInPublicDir } from "../../mockTestingResourcesPath";
|
||||
import { isInside } from "../../tools/isInside";
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import type { BuildOptions } from "../buildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { downloadKeycloakStaticResources } from "./downloadKeycloakStaticResources";
|
||||
import { generateSrcMainResources, type BuildOptionsLike as BuildOptionsLike_generateSrcMainResources } from "./generateSrcMainResources";
|
||||
import { generateThemeVariations } from "./generateThemeVariants";
|
||||
|
||||
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
|
||||
|
||||
export namespace BuildOptionsLike {
|
||||
export type Common = {
|
||||
themeName: string;
|
||||
extraLoginPages?: string[];
|
||||
extraAccountPages?: string[];
|
||||
extraThemeProperties?: string[];
|
||||
isSilent: boolean;
|
||||
customUserAttributes: string[];
|
||||
themeVersion: string;
|
||||
keycloakVersionDefaultAssets: string;
|
||||
};
|
||||
|
||||
export type Standalone = Common & {
|
||||
isStandalone: true;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
|
||||
|
||||
export namespace ExternalAssets {
|
||||
export type CommonExternalAssets = Common & {
|
||||
isStandalone: false;
|
||||
};
|
||||
|
||||
export type SameDomain = CommonExternalAssets & {
|
||||
areAppAndKeycloakServerSharingSameDomain: true;
|
||||
};
|
||||
|
||||
export type DifferentDomains = CommonExternalAssets & {
|
||||
areAppAndKeycloakServerSharingSameDomain: false;
|
||||
urlOrigin: string;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
}
|
||||
}
|
||||
export type BuildOptionsLike = BuildOptionsLike_generateSrcMainResources & {
|
||||
keycloakifyBuildDirPath: string;
|
||||
themeNames: string[];
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export async function generateTheme(params: {
|
||||
reactAppBuildDirPath: string;
|
||||
keycloakThemeBuildingDirPath: string;
|
||||
emailThemeSrcDirPath: string | undefined;
|
||||
themeSrcDirPath: string;
|
||||
keycloakifySrcDirPath: string;
|
||||
buildOptions: BuildOptionsLike;
|
||||
keycloakifyVersion: string;
|
||||
}): Promise<{ doBundlesEmailTemplate: boolean }> {
|
||||
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, emailThemeSrcDirPath, buildOptions, keycloakifyVersion } = params;
|
||||
}): Promise<{ doesImplementAccountTheme: boolean }> {
|
||||
const { themeSrcDirPath, keycloakifySrcDirPath, buildOptions, keycloakifyVersion } = params;
|
||||
|
||||
const getThemeDirPath = (themeType: ThemeType | "email") =>
|
||||
pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType);
|
||||
const [themeName, ...themeVariantNames] = buildOptions.themeNames;
|
||||
|
||||
let allCssGlobalsToDefine: Record<string, string> = {};
|
||||
const srcMainResourcesDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources");
|
||||
|
||||
let generateFtlFilesCode_glob: ReturnType<typeof generateFtlFilesCodeFactory>["generateFtlFilesCode"] | undefined = undefined;
|
||||
const { doesImplementAccountTheme } = await generateSrcMainResources({
|
||||
themeName,
|
||||
srcMainResourcesDirPath,
|
||||
themeSrcDirPath,
|
||||
keycloakifySrcDirPath,
|
||||
keycloakifyVersion,
|
||||
buildOptions
|
||||
});
|
||||
|
||||
for (const themeType of themeTypes) {
|
||||
const themeDirPath = getThemeDirPath(themeType);
|
||||
|
||||
copy_app_resources_to_theme_path: {
|
||||
const isFirstPass = themeType.indexOf(themeType) === 0;
|
||||
|
||||
if (!isFirstPass && !buildOptions.isStandalone) {
|
||||
break copy_app_resources_to_theme_path;
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
"destDirPath": buildOptions.isStandalone ? pathJoin(themeDirPath, "resources", "build") : reactAppBuildDirPath,
|
||||
"srcDirPath": reactAppBuildDirPath,
|
||||
"transformSourceCode": ({ filePath, sourceCode }) => {
|
||||
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
|
||||
if (
|
||||
buildOptions.isStandalone &&
|
||||
isInside({
|
||||
"dirPath": pathJoin(reactAppBuildDirPath, basenameOfKeycloakDirInPublicDir),
|
||||
filePath
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/\.css?$/i.test(filePath)) {
|
||||
if (!buildOptions.isStandalone) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { cssGlobalsToDefine, fixedCssCode } = replaceImportsInCssCode({
|
||||
"cssCode": sourceCode.toString("utf8")
|
||||
});
|
||||
|
||||
register_css_variables: {
|
||||
if (!isFirstPass) {
|
||||
break register_css_variables;
|
||||
}
|
||||
|
||||
allCssGlobalsToDefine = {
|
||||
...allCssGlobalsToDefine,
|
||||
...cssGlobalsToDefine
|
||||
};
|
||||
}
|
||||
|
||||
return { "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8") };
|
||||
}
|
||||
|
||||
if (/\.js?$/i.test(filePath)) {
|
||||
if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
|
||||
"jsCode": sourceCode.toString("utf8"),
|
||||
buildOptions
|
||||
});
|
||||
|
||||
return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
|
||||
}
|
||||
|
||||
return buildOptions.isStandalone ? { "modifiedSourceCode": sourceCode } : undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const generateFtlFilesCode = (() => {
|
||||
if (generateFtlFilesCode_glob !== undefined) {
|
||||
return generateFtlFilesCode_glob;
|
||||
}
|
||||
|
||||
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
|
||||
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
|
||||
"cssGlobalsToDefine": allCssGlobalsToDefine,
|
||||
buildOptions,
|
||||
keycloakifyVersion
|
||||
});
|
||||
|
||||
return generateFtlFilesCode;
|
||||
})();
|
||||
|
||||
[
|
||||
...(() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return loginThemePageIds;
|
||||
case "account":
|
||||
return accountThemePageIds;
|
||||
}
|
||||
})(),
|
||||
...((() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return buildOptions.extraLoginPages;
|
||||
case "account":
|
||||
return buildOptions.extraAccountPages;
|
||||
}
|
||||
})() ?? [])
|
||||
].forEach(pageId => {
|
||||
const { ftlCode } = generateFtlFilesCode({ pageId });
|
||||
|
||||
fs.mkdirSync(themeDirPath, { "recursive": true });
|
||||
|
||||
fs.writeFileSync(pathJoin(themeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
|
||||
});
|
||||
|
||||
//TODO: Remove this block we left it for now only for backward compatibility
|
||||
// we now have a separate script for this
|
||||
copy_keycloak_resources_to_public: {
|
||||
const keycloakDirInPublicDir = pathJoin(reactAppBuildDirPath, "..", "public", basenameOfKeycloakDirInPublicDir);
|
||||
|
||||
if (fs.existsSync(keycloakDirInPublicDir)) {
|
||||
break copy_keycloak_resources_to_public;
|
||||
}
|
||||
|
||||
await downloadKeycloakStaticResources({
|
||||
"isSilent": buildOptions.isSilent,
|
||||
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
|
||||
"themeDirPath": keycloakDirInPublicDir,
|
||||
themeType
|
||||
});
|
||||
|
||||
if (themeType !== themeTypes[0]) {
|
||||
break copy_keycloak_resources_to_public;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(keycloakDirInPublicDir, "README.txt"),
|
||||
Buffer.from(
|
||||
// prettier-ignore
|
||||
[
|
||||
"This is just a test folder that helps develop",
|
||||
"the login and register page without having to run a Keycloak container"
|
||||
].join(" ")
|
||||
)
|
||||
);
|
||||
|
||||
fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8"));
|
||||
}
|
||||
|
||||
await downloadKeycloakStaticResources({
|
||||
"isSilent": buildOptions.isSilent,
|
||||
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
|
||||
themeDirPath,
|
||||
themeType
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(themeDirPath, "theme.properties"),
|
||||
Buffer.from(["parent=keycloak", ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
let doBundlesEmailTemplate: boolean;
|
||||
|
||||
email: {
|
||||
if (emailThemeSrcDirPath === undefined) {
|
||||
doBundlesEmailTemplate = false;
|
||||
break email;
|
||||
}
|
||||
|
||||
doBundlesEmailTemplate = true;
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": emailThemeSrcDirPath,
|
||||
"destDirPath": getThemeDirPath("email")
|
||||
for (const themeVariantName of themeVariantNames) {
|
||||
generateThemeVariations({
|
||||
themeName,
|
||||
themeVariantName,
|
||||
srcMainResourcesDirPath
|
||||
});
|
||||
}
|
||||
|
||||
return { doBundlesEmailTemplate };
|
||||
return { doesImplementAccountTheme };
|
||||
}
|
||||
|
50
src/bin/keycloakify/generateTheme/generateThemeVariants.ts
Normal file
50
src/bin/keycloakify/generateTheme/generateThemeVariants.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { join as pathJoin, extname as pathExtname, sep as pathSep } from "path";
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import { assert } from "tsafe/assert";
|
||||
import * as fs from "fs";
|
||||
|
||||
export function generateThemeVariations(params: { themeName: string; themeVariantName: string; srcMainResourcesDirPath: string }) {
|
||||
const { themeName, themeVariantName, srcMainResourcesDirPath } = params;
|
||||
|
||||
const mainThemeDirPath = pathJoin(srcMainResourcesDirPath, "theme", themeName);
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": mainThemeDirPath,
|
||||
"destDirPath": pathJoin(mainThemeDirPath, "..", themeVariantName),
|
||||
"transformSourceCode": ({ fileRelativePath, sourceCode }) => {
|
||||
if (pathExtname(fileRelativePath) === ".ftl" && fileRelativePath.split(pathSep).length === 2) {
|
||||
const modifiedSourceCode = Buffer.from(
|
||||
Buffer.from(sourceCode)
|
||||
.toString("utf-8")
|
||||
.replace(`out["themeName"] = "${themeName}";`, `out["themeName"] = "${themeVariantName}";`),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
|
||||
|
||||
return { modifiedSourceCode };
|
||||
}
|
||||
|
||||
return { "modifiedSourceCode": sourceCode };
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
const keycloakThemeJsonFilePath = pathJoin(srcMainResourcesDirPath, "META-INF", "keycloak-themes.json");
|
||||
|
||||
const modifiedParsedJson = JSON.parse(fs.readFileSync(keycloakThemeJsonFilePath).toString("utf8")) as {
|
||||
themes: { name: string; types: string[] }[];
|
||||
};
|
||||
|
||||
modifiedParsedJson.themes.push({
|
||||
"name": themeVariantName,
|
||||
"types": (() => {
|
||||
const theme = modifiedParsedJson.themes.find(({ name }) => name === themeName);
|
||||
assert(theme !== undefined);
|
||||
return theme.types;
|
||||
})()
|
||||
});
|
||||
|
||||
fs.writeFileSync(keycloakThemeJsonFilePath, Buffer.from(JSON.stringify(modifiedParsedJson, null, 2), "utf8"));
|
||||
}
|
||||
}
|
39
src/bin/keycloakify/generateTheme/readExtraPageNames.ts
Normal file
39
src/bin/keycloakify/generateTheme/readExtraPageNames.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { accountThemePageIds, loginThemePageIds } from "../generateFtl";
|
||||
import { id } from "tsafe/id";
|
||||
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import type { ThemeType } from "../../constants";
|
||||
|
||||
export function readExtraPagesNames(params: { themeSrcDirPath: string; themeType: ThemeType }): string[] {
|
||||
const { themeSrcDirPath, themeType } = params;
|
||||
|
||||
const filePaths = crawl({
|
||||
"dirPath": pathJoin(themeSrcDirPath, themeType),
|
||||
"returnedPathsType": "absolute"
|
||||
}).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
|
||||
|
||||
const candidateFilePaths = filePaths.filter(filePath => /kcContext\.[^.]+$/.test(filePath));
|
||||
|
||||
if (candidateFilePaths.length === 0) {
|
||||
candidateFilePaths.push(...filePaths);
|
||||
}
|
||||
|
||||
const extraPages: string[] = [];
|
||||
|
||||
for (const candidateFilPath of candidateFilePaths) {
|
||||
const rawSourceFile = fs.readFileSync(candidateFilPath).toString("utf8");
|
||||
|
||||
extraPages.push(...Array.from(rawSourceFile.matchAll(/["']?pageId["']?\s*:\s*["']([^.]+.ftl)["']/g), m => m[1]));
|
||||
}
|
||||
|
||||
return extraPages.reduce(...removeDuplicates<string>()).filter(pageId => {
|
||||
switch (themeType) {
|
||||
case "account":
|
||||
return !id<readonly string[]>(accountThemePageIds).includes(pageId);
|
||||
case "login":
|
||||
return !id<readonly string[]>(loginThemePageIds).includes(pageId);
|
||||
}
|
||||
});
|
||||
}
|
55
src/bin/keycloakify/generateTheme/readFieldNameUsage.ts
Normal file
55
src/bin/keycloakify/generateTheme/readFieldNameUsage.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { join as pathJoin } from "path";
|
||||
import * as fs from "fs";
|
||||
import type { ThemeType } from "../../constants";
|
||||
|
||||
/** Assumes the theme type exists */
|
||||
export function readFieldNameUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): string[] {
|
||||
const { keycloakifySrcDirPath, themeSrcDirPath, themeType } = params;
|
||||
|
||||
const fieldNames = new Set<string>();
|
||||
|
||||
for (const srcDirPath of [pathJoin(keycloakifySrcDirPath, themeType), pathJoin(themeSrcDirPath, themeType)]) {
|
||||
const filePaths = crawl({ "dirPath": srcDirPath, "returnedPathsType": "absolute" }).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const rawSourceFile = fs.readFileSync(filePath).toString("utf8");
|
||||
|
||||
if (!rawSourceFile.includes("messagesPerField")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const functionName of ["printIfExists", "existsError", "get", "exists", "getFirstError"] as const) {
|
||||
if (!rawSourceFile.includes(functionName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
rawSourceFile
|
||||
.split(functionName)
|
||||
.filter(part => part.startsWith("("))
|
||||
.map(part => {
|
||||
let [p1] = part.split(")");
|
||||
|
||||
p1 = p1.slice(1);
|
||||
|
||||
return p1;
|
||||
})
|
||||
.map(part => {
|
||||
return part
|
||||
.split(",")
|
||||
.map(a => a.trim())
|
||||
.filter((...[, i]) => (functionName !== "printIfExists" ? true : i === 0))
|
||||
.filter(a => a.startsWith('"') || a.startsWith("'") || a.startsWith("`"))
|
||||
.filter(a => a.endsWith('"') || a.endsWith("'") || a.endsWith("`"))
|
||||
.map(a => a.slice(1).slice(0, -1));
|
||||
})
|
||||
.flat()
|
||||
.forEach(fieldName => fieldNames.add(fieldName));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(fieldNames);
|
||||
}
|
@ -1,144 +1,93 @@
|
||||
import { generateTheme } from "./generateTheme";
|
||||
import { generateJavaStackFiles } from "./generateJavaStackFiles";
|
||||
import { join as pathJoin, relative as pathRelative, basename as pathBasename, sep as pathSep } from "path";
|
||||
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
|
||||
import * as child_process from "child_process";
|
||||
import { generateStartKeycloakTestingContainer } from "./generateStartKeycloakTestingContainer";
|
||||
import * as fs from "fs";
|
||||
import { readBuildOptions } from "./BuildOptions";
|
||||
import { readBuildOptions } from "./buildOptions";
|
||||
import { getLogger } from "../tools/logger";
|
||||
import jar from "../tools/jar";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { Equals } from "tsafe";
|
||||
import { getEmailThemeSrcDirPath } from "../getSrcDirPath";
|
||||
import { getProjectRoot } from "../tools/getProjectRoot";
|
||||
import { getThemeSrcDirPath } from "../getThemeSrcDirPath";
|
||||
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
|
||||
import { readThisNpmProjectVersion } from "../tools/readThisNpmProjectVersion";
|
||||
import { keycloakifyBuildOptionsForPostPostBuildScriptEnvName } from "../constants";
|
||||
import { buildJars } from "./buildJars";
|
||||
|
||||
export async function main() {
|
||||
const projectDirPath = process.cwd();
|
||||
|
||||
const buildOptions = readBuildOptions({
|
||||
projectDirPath,
|
||||
"processArgv": process.argv.slice(2)
|
||||
});
|
||||
|
||||
const logger = getLogger({ "isSilent": buildOptions.isSilent });
|
||||
logger.log("🔏 Building the keycloak theme...⌚");
|
||||
|
||||
const { doBundlesEmailTemplate } = await generateTheme({
|
||||
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
|
||||
"emailThemeSrcDirPath": (() => {
|
||||
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({ projectDirPath });
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({ "reactAppRootDirPath": buildOptions.reactAppRootDirPath });
|
||||
|
||||
if (emailThemeSrcDirPath === undefined || !fs.existsSync(emailThemeSrcDirPath)) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
if (!fs.existsSync(buildOptions.keycloakifyBuildDirPath)) {
|
||||
fs.mkdirSync(buildOptions.keycloakifyBuildDirPath, { "recursive": true });
|
||||
}
|
||||
|
||||
return emailThemeSrcDirPath;
|
||||
})(),
|
||||
"reactAppBuildDirPath": buildOptions.reactAppBuildDirPath,
|
||||
buildOptions,
|
||||
"keycloakifyVersion": (() => {
|
||||
const version = JSON.parse(fs.readFileSync(pathJoin(getProjectRoot(), "package.json")).toString("utf8"))["version"];
|
||||
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, ".gitignore"), Buffer.from("*", "utf8"));
|
||||
}
|
||||
|
||||
assert(typeof version === "string");
|
||||
|
||||
return version;
|
||||
})()
|
||||
});
|
||||
|
||||
const { jarFilePath } = generateJavaStackFiles({
|
||||
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
|
||||
doBundlesEmailTemplate,
|
||||
const { doesImplementAccountTheme } = await generateTheme({
|
||||
themeSrcDirPath,
|
||||
"keycloakifySrcDirPath": pathJoin(getThisCodebaseRootDirPath(), "src"),
|
||||
"keycloakifyVersion": readThisNpmProjectVersion(),
|
||||
buildOptions
|
||||
});
|
||||
|
||||
switch (buildOptions.bundler) {
|
||||
case "none":
|
||||
logger.log("😱 Skipping bundling step, there will be no jar");
|
||||
break;
|
||||
case "keycloakify":
|
||||
logger.log("🫶 Let keycloakify do its thang");
|
||||
await jar({
|
||||
"rootPath": pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources"),
|
||||
"version": buildOptions.themeVersion,
|
||||
"groupId": buildOptions.groupId,
|
||||
"artifactId": buildOptions.artifactId,
|
||||
"targetPath": jarFilePath
|
||||
});
|
||||
break;
|
||||
case "mvn":
|
||||
logger.log("🫙 Run maven to deliver a jar");
|
||||
child_process.execSync("mvn package", { "cwd": buildOptions.keycloakifyBuildDirPath });
|
||||
break;
|
||||
default:
|
||||
assert<Equals<typeof buildOptions.bundler, never>>(false);
|
||||
run_post_build_script: {
|
||||
if (buildOptions.bundler !== "vite") {
|
||||
break run_post_build_script;
|
||||
}
|
||||
|
||||
child_process.execSync("npx vite", {
|
||||
"cwd": buildOptions.reactAppRootDirPath,
|
||||
"env": {
|
||||
...process.env,
|
||||
[keycloakifyBuildOptionsForPostPostBuildScriptEnvName]: JSON.stringify(buildOptions)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// We want, however, to test in a container running the latest Keycloak version
|
||||
const containerKeycloakVersion = "20.0.1";
|
||||
const { lastJarFileBasename } = await buildJars({
|
||||
doesImplementAccountTheme,
|
||||
buildOptions
|
||||
});
|
||||
|
||||
generateStartKeycloakTestingContainer({
|
||||
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
|
||||
"keycloakVersion": containerKeycloakVersion,
|
||||
"jarFilePath": pathJoin(buildOptions.keycloakifyBuildDirPath, lastJarFileBasename),
|
||||
doesImplementAccountTheme,
|
||||
buildOptions
|
||||
});
|
||||
|
||||
logger.log(
|
||||
[
|
||||
`✅ Your keycloak theme has been generated and bundled into .${pathSep}${pathJoin(
|
||||
pathRelative(buildOptions.reactAppRootDirPath, buildOptions.keycloakifyBuildDirPath),
|
||||
"keycloak-theme-for-kc-*.jar"
|
||||
)}`,
|
||||
"",
|
||||
`✅ Your keycloak theme has been generated and bundled into .${pathSep}${pathRelative(projectDirPath, jarFilePath)} 🚀`,
|
||||
`It is to be placed in "/opt/keycloak/providers" in the container running a quay.io/keycloak/keycloak Docker image.`,
|
||||
"",
|
||||
//TODO: Restore when we find a good Helm chart for Keycloak.
|
||||
//"Using Helm (https://github.com/codecentric/helm-charts), edit to reflect:",
|
||||
"",
|
||||
"value.yaml: ",
|
||||
" extraInitContainers: |",
|
||||
" - name: realm-ext-provider",
|
||||
" image: curlimages/curl",
|
||||
" imagePullPolicy: IfNotPresent",
|
||||
" command:",
|
||||
" - sh",
|
||||
" args:",
|
||||
" - -c",
|
||||
` - curl -L -f -S -o /extensions/${pathBasename(jarFilePath)} https://AN.URL.FOR/${pathBasename(jarFilePath)}`,
|
||||
" volumeMounts:",
|
||||
" - name: extensions",
|
||||
" mountPath: /extensions",
|
||||
" ",
|
||||
" extraVolumeMounts: |",
|
||||
" - name: extensions",
|
||||
" mountPath: /opt/keycloak/providers",
|
||||
" extraEnv: |",
|
||||
" - name: KEYCLOAK_USER",
|
||||
" value: admin",
|
||||
" - name: KEYCLOAK_PASSWORD",
|
||||
" value: xxxxxxxxx",
|
||||
" - name: JAVA_OPTS",
|
||||
" value: -Dkeycloak.profile=preview",
|
||||
"",
|
||||
"",
|
||||
`To test your theme locally you can spin up a Keycloak ${containerKeycloakVersion} container image with the theme pre loaded by running:`,
|
||||
`To test your theme locally you can spin up a Keycloak container image with the theme pre loaded by running:`,
|
||||
"",
|
||||
`👉 $ .${pathSep}${pathRelative(
|
||||
projectDirPath,
|
||||
buildOptions.reactAppRootDirPath,
|
||||
pathJoin(buildOptions.keycloakifyBuildDirPath, generateStartKeycloakTestingContainer.basename)
|
||||
)} 👈`,
|
||||
"",
|
||||
`Test with different Keycloak versions by editing the .sh file. see available versions here: https://quay.io/repository/keycloak/keycloak?tab=tags`,
|
||||
``,
|
||||
`Once your container is up and running: `,
|
||||
"- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈",
|
||||
`- Create a realm: Master -> AddRealm -> Name: myrealm`,
|
||||
`- Enable registration: Realm settings -> Login tab -> User registration: on`,
|
||||
`- Enable the Account theme (optional): Realm settings -> Themes tab -> Account theme: ${buildOptions.themeName}`,
|
||||
` Clients -> account -> Login theme: ${buildOptions.themeName}`,
|
||||
`- Enable the email theme (optional): Realm settings -> Themes tab -> Email theme: ${buildOptions.themeName} (option will appear only if you have ran npx initialize-email-theme)`,
|
||||
`- Enable the Account theme (optional): Realm settings -> Themes tab -> Account theme: ${buildOptions.themeNames[0]}`,
|
||||
` Clients -> account -> Login theme: ${buildOptions.themeNames[0]}`,
|
||||
`- Enable the email theme (optional): Realm settings -> Themes tab -> Email theme: ${buildOptions.themeNames[0]} (option will appear only if you have ran npx initialize-email-theme)`,
|
||||
`- Create a client Clients -> Create -> Client ID: myclient`,
|
||||
` Root URL: https://www.keycloak.org/app/`,
|
||||
` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
|
||||
` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
|
||||
` Web origins: *`,
|
||||
` Login Theme: ${buildOptions.themeName}`,
|
||||
` Login Theme: ${buildOptions.themeNames[0]}`,
|
||||
` Save (button at the bottom of the page)`,
|
||||
``,
|
||||
`- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`,
|
||||
|
@ -1,64 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import { assert } from "tsafe";
|
||||
import type { Equals } from "tsafe";
|
||||
import { z } from "zod";
|
||||
import { pathJoin } from "../tools/pathJoin";
|
||||
|
||||
export const bundlers = ["mvn", "keycloakify", "none"] as const;
|
||||
export type Bundler = (typeof bundlers)[number];
|
||||
export type ParsedPackageJson = {
|
||||
name: string;
|
||||
version?: string;
|
||||
homepage?: string;
|
||||
keycloakify?: {
|
||||
/** @deprecated: use extraLoginPages instead */
|
||||
extraPages?: string[];
|
||||
extraLoginPages?: string[];
|
||||
extraAccountPages?: string[];
|
||||
extraThemeProperties?: string[];
|
||||
areAppAndKeycloakServerSharingSameDomain?: boolean;
|
||||
artifactId?: string;
|
||||
groupId?: string;
|
||||
bundler?: Bundler;
|
||||
keycloakVersionDefaultAssets?: string;
|
||||
reactAppBuildDirPath?: string;
|
||||
keycloakifyBuildDirPath?: string;
|
||||
customUserAttributes?: string[];
|
||||
themeName?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const zParsedPackageJson = z.object({
|
||||
"name": z.string(),
|
||||
"version": z.string().optional(),
|
||||
"homepage": z.string().optional(),
|
||||
"keycloakify": z
|
||||
.object({
|
||||
"extraPages": z.array(z.string()).optional(),
|
||||
"extraLoginPages": z.array(z.string()).optional(),
|
||||
"extraAccountPages": z.array(z.string()).optional(),
|
||||
"extraThemeProperties": z.array(z.string()).optional(),
|
||||
"areAppAndKeycloakServerSharingSameDomain": z.boolean().optional(),
|
||||
"artifactId": z.string().optional(),
|
||||
"groupId": z.string().optional(),
|
||||
"bundler": z.enum(bundlers).optional(),
|
||||
"keycloakVersionDefaultAssets": z.string().optional(),
|
||||
"reactAppBuildDirPath": z.string().optional(),
|
||||
"keycloakifyBuildDirPath": z.string().optional(),
|
||||
"customUserAttributes": z.array(z.string()).optional(),
|
||||
"themeName": z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
assert<Equals<ReturnType<(typeof zParsedPackageJson)["parse"]>, ParsedPackageJson>>();
|
||||
|
||||
let parsedPackageJson: undefined | ReturnType<(typeof zParsedPackageJson)["parse"]>;
|
||||
export function getParsedPackageJson(params: { projectDirPath: string }) {
|
||||
const { projectDirPath } = params;
|
||||
if (parsedPackageJson) {
|
||||
return parsedPackageJson;
|
||||
}
|
||||
parsedPackageJson = zParsedPackageJson.parse(JSON.parse(fs.readFileSync(pathJoin(projectDirPath, "package.json")).toString("utf8")));
|
||||
return parsedPackageJson;
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { is } from "tsafe/is";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
|
||||
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
|
||||
|
||||
export namespace BuildOptionsLike {
|
||||
export type Standalone = {
|
||||
isStandalone: true;
|
||||
};
|
||||
|
||||
export type ExternalAssets = {
|
||||
isStandalone: false;
|
||||
urlOrigin: string;
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert(!is<BuildOptions.ExternalAssets.CommonExternalAssets>(buildOptions));
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
|
||||
export function replaceImportsFromStaticInJsCode(params: { jsCode: string; buildOptions: BuildOptionsLike }): { fixedJsCode: string } {
|
||||
/*
|
||||
NOTE:
|
||||
|
||||
When we have urlOrigin defined it means that
|
||||
we are building with --external-assets
|
||||
so we have to make sur that the fixed js code will run
|
||||
inside and outside keycloak.
|
||||
|
||||
When urlOrigin isn't defined we can assume the fixedJsCode
|
||||
will always run in keycloak context.
|
||||
*/
|
||||
|
||||
const { jsCode, buildOptions } = params;
|
||||
|
||||
const getReplaceArgs = (language: "js" | "css"): Parameters<typeof String.prototype.replace> => [
|
||||
new RegExp(`([a-zA-Z_]+)\\.([a-zA-Z]+)=function\\(([a-zA-Z]+)\\){return"static\\/${language}\\/"`, "g"),
|
||||
(...[, n, u, e]) => `
|
||||
${n}[(function(){
|
||||
var pd= Object.getOwnPropertyDescriptor(${n}, "p");
|
||||
if( pd === undefined || pd.configurable ){
|
||||
${
|
||||
buildOptions.isStandalone
|
||||
? `
|
||||
Object.defineProperty(${n}, "p", {
|
||||
get: function() { return window.${ftlValuesGlobalName}.url.resourcesPath; },
|
||||
set: function (){}
|
||||
});
|
||||
`
|
||||
: `
|
||||
var p= "";
|
||||
Object.defineProperty(${n}, "p", {
|
||||
get: function() { return "${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}/" : p; },
|
||||
set: function (value){ p = value;}
|
||||
});
|
||||
`
|
||||
}
|
||||
}
|
||||
return "${u}";
|
||||
})()] = function(${e}) { return "${buildOptions.isStandalone ? "/build/" : ""}static/${language}/"`
|
||||
];
|
||||
|
||||
const fixedJsCode = jsCode
|
||||
.replace(...getReplaceArgs("js"))
|
||||
.replace(...getReplaceArgs("css"))
|
||||
.replace(/([a-zA-Z]+\.[a-zA-Z]+)\+"static\//g, (...[, group]) =>
|
||||
buildOptions.isStandalone
|
||||
? `window.${ftlValuesGlobalName}.url.resourcesPath + "/build/static/`
|
||||
: `("${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}/" : ${group}) + "static/`
|
||||
)
|
||||
//TODO: Write a test case for this
|
||||
.replace(/".chunk.css",([a-zA-Z])+=([a-zA-Z]+\.[a-zA-Z]+)\+([a-zA-Z]+),/, (...[, group1, group2, group3]) =>
|
||||
buildOptions.isStandalone
|
||||
? `".chunk.css",${group1} = window.${ftlValuesGlobalName}.url.resourcesPath + "/build/" + ${group3},`
|
||||
: `".chunk.css",${group1} = ("${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}/" : ${group2}) + ${group3},`
|
||||
);
|
||||
|
||||
return { fixedJsCode };
|
||||
}
|
@ -1,20 +1,13 @@
|
||||
import * as crypto from "crypto";
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import type { BuildOptions } from "../buildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { is } from "tsafe/is";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
import { basenameOfTheKeycloakifyResourcesDir } from "../../constants";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert(!is<BuildOptions.ExternalAssets.CommonExternalAssets>(buildOptions));
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export function replaceImportsInCssCode(params: { cssCode: string }): {
|
||||
fixedCssCode: string;
|
||||
@ -24,7 +17,7 @@ export function replaceImportsInCssCode(params: { cssCode: string }): {
|
||||
|
||||
const cssGlobalsToDefine: Record<string, string> = {};
|
||||
|
||||
new Set(cssCode.match(/url\(["']?\/[^/][^)"']+["']?\)[^;}]*/g) ?? []).forEach(
|
||||
new Set(cssCode.match(/url\(["']?\/[^/][^)"']+["']?\)[^;}]*?/g) ?? []).forEach(
|
||||
match => (cssGlobalsToDefine["url" + crypto.createHash("sha256").update(match).digest("hex").substring(0, 15)] = match)
|
||||
);
|
||||
|
||||
@ -53,7 +46,7 @@ export function generateCssCodeToDefineGlobals(params: { cssGlobalsToDefine: Rec
|
||||
`--${cssVariableName}:`,
|
||||
cssGlobalsToDefine[cssVariableName].replace(
|
||||
new RegExp(`url\\(${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`, "g"),
|
||||
"url(${url.resourcesPath}/build/"
|
||||
`url(\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
|
||||
)
|
||||
].join(" ")
|
||||
)
|
||||
|
@ -1,32 +1,12 @@
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import type { BuildOptions } from "../buildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { is } from "tsafe/is";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
import { basenameOfTheKeycloakifyResourcesDir } from "../../constants";
|
||||
|
||||
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
|
||||
export type BuildOptionsLike = {
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
export namespace BuildOptionsLike {
|
||||
export type Common = {
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
export type Standalone = Common & {
|
||||
isStandalone: true;
|
||||
};
|
||||
|
||||
export type ExternalAssets = Common & {
|
||||
isStandalone: false;
|
||||
urlOrigin: string;
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert(!is<BuildOptions.ExternalAssets.CommonExternalAssets>(buildOptions));
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export function replaceImportsInInlineCssCode(params: { cssCode: string; buildOptions: BuildOptionsLike }): {
|
||||
fixedCssCode: string;
|
||||
@ -37,10 +17,7 @@ export function replaceImportsInInlineCssCode(params: { cssCode: string; buildOp
|
||||
buildOptions.urlPathname === undefined
|
||||
? /url\(["']?\/([^/][^)"']+)["']?\)/g
|
||||
: new RegExp(`url\\(["']?${buildOptions.urlPathname}([^)"']+)["']?\\)`, "g"),
|
||||
(...[, group]) =>
|
||||
`url(${
|
||||
buildOptions.isStandalone ? "${url.resourcesPath}/build/" + group : buildOptions.urlOrigin + (buildOptions.urlPathname ?? "/") + group
|
||||
})`
|
||||
(...[, group]) => `url(\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/${group})`
|
||||
);
|
||||
|
||||
return { fixedCssCode };
|
||||
|
@ -0,0 +1 @@
|
||||
export * from "./replaceImportsInJsCode";
|
@ -0,0 +1,66 @@
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { BuildOptions } from "../../buildOptions";
|
||||
import { replaceImportsInJsCode_vite } from "./vite";
|
||||
import { replaceImportsInJsCode_webpack } from "./webpack";
|
||||
import * as fs from "fs";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
reactAppBuildDirPath: string;
|
||||
assetsDirPath: string;
|
||||
urlPathname: string | undefined;
|
||||
bundler: "vite" | "webpack";
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export function replaceImportsInJsCode(params: { jsCode: string; buildOptions: BuildOptionsLike }) {
|
||||
const { jsCode, buildOptions } = params;
|
||||
|
||||
const { fixedJsCode } = (() => {
|
||||
switch (buildOptions.bundler) {
|
||||
case "vite":
|
||||
return replaceImportsInJsCode_vite({
|
||||
jsCode,
|
||||
buildOptions,
|
||||
"basenameOfAssetsFiles": readAssetsDirSync({
|
||||
"assetsDirPath": params.buildOptions.assetsDirPath
|
||||
})
|
||||
});
|
||||
case "webpack":
|
||||
return replaceImportsInJsCode_webpack({
|
||||
jsCode,
|
||||
buildOptions
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return { fixedJsCode };
|
||||
}
|
||||
|
||||
const { readAssetsDirSync } = (() => {
|
||||
let cache:
|
||||
| {
|
||||
assetsDirPath: string;
|
||||
basenameOfAssetsFiles: string[];
|
||||
}
|
||||
| undefined = undefined;
|
||||
|
||||
function readAssetsDirSync(params: { assetsDirPath: string }): string[] {
|
||||
const { assetsDirPath } = params;
|
||||
|
||||
if (cache !== undefined && cache.assetsDirPath === assetsDirPath) {
|
||||
return cache.basenameOfAssetsFiles;
|
||||
}
|
||||
|
||||
const basenameOfAssetsFiles = fs.readdirSync(assetsDirPath);
|
||||
|
||||
cache = {
|
||||
assetsDirPath,
|
||||
basenameOfAssetsFiles
|
||||
};
|
||||
|
||||
return basenameOfAssetsFiles;
|
||||
}
|
||||
|
||||
return { readAssetsDirSync };
|
||||
})();
|
85
src/bin/keycloakify/replacers/replaceImportsInJsCode/vite.ts
Normal file
85
src/bin/keycloakify/replacers/replaceImportsInJsCode/vite.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir } from "../../../constants";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { BuildOptions } from "../../buildOptions";
|
||||
import * as nodePath from "path";
|
||||
import { replaceAll } from "../../../tools/String.prototype.replaceAll";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
reactAppBuildDirPath: string;
|
||||
assetsDirPath: string;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export function replaceImportsInJsCode_vite(params: {
|
||||
jsCode: string;
|
||||
buildOptions: BuildOptionsLike;
|
||||
basenameOfAssetsFiles: string[];
|
||||
systemType?: "posix" | "win32";
|
||||
}): {
|
||||
fixedJsCode: string;
|
||||
} {
|
||||
const { jsCode, buildOptions, basenameOfAssetsFiles, systemType = nodePath.sep === "/" ? "posix" : "win32" } = params;
|
||||
|
||||
const { relative: pathRelative, sep: pathSep } = nodePath[systemType];
|
||||
|
||||
let fixedJsCode = jsCode;
|
||||
|
||||
replace_base_javacript_import: {
|
||||
if (buildOptions.urlPathname === undefined) {
|
||||
break replace_base_javacript_import;
|
||||
}
|
||||
// Optimization
|
||||
if (!jsCode.includes(buildOptions.urlPathname)) {
|
||||
break replace_base_javacript_import;
|
||||
}
|
||||
|
||||
// Replace `Hv=function(e){return"/abcde12345/"+e}` by `Hv=function(e){return"/"+e}`
|
||||
fixedJsCode = fixedJsCode.replace(
|
||||
new RegExp(
|
||||
`([\\w\\$][\\w\\d\\$]*)=function\\(([\\w\\$][\\w\\d\\$]*)\\)\\{return"${replaceAll(buildOptions.urlPathname, "/", "\\/")}"\\+\\2\\}`,
|
||||
"g"
|
||||
),
|
||||
(...[, funcName, paramName]) => `${funcName}=function(${paramName}){return"/"+${paramName}}`
|
||||
);
|
||||
}
|
||||
|
||||
replace_javascript_relatives_import_paths: {
|
||||
// Example: "assets/ or "foo/bar/"
|
||||
const staticDir = (() => {
|
||||
let out = pathRelative(buildOptions.reactAppBuildDirPath, buildOptions.assetsDirPath);
|
||||
|
||||
out = replaceAll(out, pathSep, "/") + "/";
|
||||
|
||||
if (out === "/") {
|
||||
throw new Error(`The assetsDirPath must be a subdirectory of reactAppBuildDirPath`);
|
||||
}
|
||||
|
||||
return out;
|
||||
})();
|
||||
|
||||
// Optimization
|
||||
if (!jsCode.includes(staticDir)) {
|
||||
break replace_javascript_relatives_import_paths;
|
||||
}
|
||||
|
||||
basenameOfAssetsFiles
|
||||
.map(basenameOfAssetsFile => `${staticDir}${basenameOfAssetsFile}`)
|
||||
.forEach(relativePathOfAssetFile => {
|
||||
fixedJsCode = replaceAll(
|
||||
fixedJsCode,
|
||||
`"${relativePathOfAssetFile}"`,
|
||||
`(window.${nameOfTheGlobal}.url.resourcesPath.substring(1) + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
|
||||
);
|
||||
|
||||
fixedJsCode = replaceAll(
|
||||
fixedJsCode,
|
||||
`"${buildOptions.urlPathname ?? "/"}${relativePathOfAssetFile}"`,
|
||||
`(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return { fixedJsCode };
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
import { nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir } from "../../../constants";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { BuildOptions } from "../../buildOptions";
|
||||
import * as nodePath from "path";
|
||||
import { replaceAll } from "../../../tools/String.prototype.replaceAll";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
reactAppBuildDirPath: string;
|
||||
assetsDirPath: string;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export function replaceImportsInJsCode_webpack(params: { jsCode: string; buildOptions: BuildOptionsLike; systemType?: "posix" | "win32" }): {
|
||||
fixedJsCode: string;
|
||||
} {
|
||||
const { jsCode, buildOptions, systemType = nodePath.sep === "/" ? "posix" : "win32" } = params;
|
||||
|
||||
const { relative: pathRelative, sep: pathSep } = nodePath[systemType];
|
||||
|
||||
let fixedJsCode = jsCode;
|
||||
|
||||
if (buildOptions.urlPathname !== undefined) {
|
||||
// "__esModule",{value:!0})},n.p="/foo-bar/",function(){if("undefined" -> ... n.p="/" ...
|
||||
fixedJsCode = fixedJsCode.replace(
|
||||
new RegExp(`,([a-zA-Z]\\.[a-zA-Z])="${replaceAll(buildOptions.urlPathname, "/", "\\/")}",`, "g"),
|
||||
(...[, assignTo]) => `,${assignTo}="/",`
|
||||
);
|
||||
}
|
||||
|
||||
// Example: "static/ or "foo/bar/"
|
||||
const staticDir = (() => {
|
||||
let out = pathRelative(buildOptions.reactAppBuildDirPath, buildOptions.assetsDirPath);
|
||||
|
||||
out = replaceAll(out, pathSep, "/") + "/";
|
||||
|
||||
if (out === "/") {
|
||||
throw new Error(`The assetsDirPath must be a subdirectory of reactAppBuildDirPath`);
|
||||
}
|
||||
|
||||
return out;
|
||||
})();
|
||||
|
||||
const getReplaceArgs = (language: "js" | "css"): Parameters<typeof String.prototype.replace> => [
|
||||
new RegExp(`([a-zA-Z_]+)\\.([a-zA-Z]+)=(function\\(([a-z]+)\\){return|([a-z]+)=>)"${staticDir.replace(/\//g, "\\/")}${language}\\/"`, "g"),
|
||||
(...[, n, u, matchedFunction, eForFunction]) => {
|
||||
const isArrowFunction = matchedFunction.includes("=>");
|
||||
const e = isArrowFunction ? matchedFunction.replace("=>", "").trim() : eForFunction;
|
||||
|
||||
return `
|
||||
${n}[(function(){
|
||||
var pd = Object.getOwnPropertyDescriptor(${n}, "p");
|
||||
if( pd === undefined || pd.configurable ){
|
||||
Object.defineProperty(${n}, "p", {
|
||||
get: function() { return window.${nameOfTheGlobal}.url.resourcesPath; },
|
||||
set: function() {}
|
||||
});
|
||||
}
|
||||
return "${u}";
|
||||
})()] = ${isArrowFunction ? `${e} =>` : `function(${e}) { return `} "/${basenameOfTheKeycloakifyResourcesDir}/${staticDir}${language}/"`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
];
|
||||
|
||||
fixedJsCode = fixedJsCode
|
||||
.replace(...getReplaceArgs("js"))
|
||||
.replace(...getReplaceArgs("css"))
|
||||
.replace(
|
||||
new RegExp(`[a-zA-Z]+\\.[a-zA-Z]+\\+"${staticDir.replace(/\//g, "\\/")}`, "g"),
|
||||
`window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${staticDir}`
|
||||
);
|
||||
|
||||
return { fixedJsCode };
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import { pathJoin } from "./tools/pathJoin";
|
||||
|
||||
export const basenameOfKeycloakDirInPublicDir = "keycloak-resources";
|
||||
export const resourcesDirPathRelativeToPublicDir = pathJoin(basenameOfKeycloakDirInPublicDir, "resources");
|
||||
export const resourcesCommonDirPathRelativeToPublicDir = pathJoin(resourcesDirPathRelativeToPublicDir, "resources_common");
|
@ -1,6 +1,7 @@
|
||||
import { getLatestsSemVersionedTagFactory } from "./tools/octokit-addons/getLatestsSemVersionedTag";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import cliSelect from "cli-select";
|
||||
import { lastKeycloakVersionWithAccountV1 } from "./constants";
|
||||
|
||||
export async function promptKeycloakVersion() {
|
||||
const { getLatestsSemVersionedTag } = (() => {
|
||||
@ -17,15 +18,16 @@ export async function promptKeycloakVersion() {
|
||||
return { getLatestsSemVersionedTag };
|
||||
})();
|
||||
|
||||
console.log("Initialize the directory with email template from which keycloak version?");
|
||||
console.log("Select Keycloak version?");
|
||||
|
||||
const tags = [
|
||||
...(await getLatestsSemVersionedTag({
|
||||
"count": 10,
|
||||
"doIgnoreBeta": true,
|
||||
"count": 15,
|
||||
"owner": "keycloak",
|
||||
"repo": "keycloak"
|
||||
}).then(arr => arr.map(({ tag }) => tag))),
|
||||
lastKeycloakVersionWithAccountV1,
|
||||
"19.0.1",
|
||||
"11.0.3"
|
||||
];
|
||||
|
||||
|
@ -1,73 +0,0 @@
|
||||
export type NpmModuleVersion = {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
betaPreRelease?: number;
|
||||
};
|
||||
|
||||
export namespace NpmModuleVersion {
|
||||
export function parse(versionStr: string): NpmModuleVersion {
|
||||
const match = versionStr.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-beta.([0-9]+))?/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`${versionStr} is not a valid NPM version`);
|
||||
}
|
||||
|
||||
return {
|
||||
"major": parseInt(match[1]),
|
||||
"minor": parseInt(match[2]),
|
||||
"patch": parseInt(match[3]),
|
||||
...(() => {
|
||||
const str = match[4];
|
||||
return str === undefined ? {} : { "betaPreRelease": parseInt(str) };
|
||||
})()
|
||||
};
|
||||
}
|
||||
|
||||
export function stringify(v: NpmModuleVersion) {
|
||||
return `${v.major}.${v.minor}.${v.patch}${v.betaPreRelease === undefined ? "" : `-beta.${v.betaPreRelease}`}`;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* v1 < v2 => -1
|
||||
* v1 === v2 => 0
|
||||
* v1 > v2 => 1
|
||||
*
|
||||
*/
|
||||
export function compare(v1: NpmModuleVersion, v2: NpmModuleVersion): -1 | 0 | 1 {
|
||||
const sign = (diff: number): -1 | 0 | 1 => (diff === 0 ? 0 : diff < 0 ? -1 : 1);
|
||||
const noUndefined = (n: number | undefined) => n ?? Infinity;
|
||||
|
||||
for (const level of ["major", "minor", "patch", "betaPreRelease"] as const) {
|
||||
if (noUndefined(v1[level]) !== noUndefined(v2[level])) {
|
||||
return sign(noUndefined(v1[level]) - noUndefined(v2[level]));
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
console.log(compare(parse("3.0.0-beta.3"), parse("3.0.0")) === -1 )
|
||||
console.log(compare(parse("3.0.0-beta.3"), parse("3.0.0-beta.4")) === -1 )
|
||||
console.log(compare(parse("3.0.0-beta.3"), parse("4.0.0")) === -1 )
|
||||
*/
|
||||
|
||||
export function bumpType(params: { versionBehindStr: string; versionAheadStr: string }): "major" | "minor" | "patch" | "betaPreRelease" | "same" {
|
||||
const versionAhead = parse(params.versionAheadStr);
|
||||
const versionBehind = parse(params.versionBehindStr);
|
||||
|
||||
if (compare(versionBehind, versionAhead) === 1) {
|
||||
throw new Error(`Version regression ${versionBehind} -> ${versionAhead}`);
|
||||
}
|
||||
|
||||
for (const level of ["major", "minor", "patch", "betaPreRelease"] as const) {
|
||||
if (versionBehind[level] !== versionAhead[level]) {
|
||||
return level;
|
||||
}
|
||||
}
|
||||
|
||||
return "same";
|
||||
}
|
||||
}
|
12
src/bin/tools/OptionalIfCanBeUndefined.ts
Normal file
12
src/bin/tools/OptionalIfCanBeUndefined.ts
Normal file
@ -0,0 +1,12 @@
|
||||
type PropertiesThatCanBeUndefined<T extends Record<string, unknown>> = {
|
||||
[Key in keyof T]: undefined extends T[Key] ? Key : never;
|
||||
}[keyof T];
|
||||
|
||||
/**
|
||||
* OptionalIfCanBeUndefined<{ p1: string | undefined; p2: string; }>
|
||||
* is
|
||||
* { p1?: string | undefined; p2: string }
|
||||
*/
|
||||
export type OptionalIfCanBeUndefined<T extends Record<string, unknown>> = {
|
||||
[K in PropertiesThatCanBeUndefined<T>]?: T[K];
|
||||
} & { [K in Exclude<keyof T, PropertiesThatCanBeUndefined<T>>]: T[K] };
|
99
src/bin/tools/SemVer.ts
Normal file
99
src/bin/tools/SemVer.ts
Normal file
@ -0,0 +1,99 @@
|
||||
export type SemVer = {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
rc?: number;
|
||||
parsedFrom: string;
|
||||
};
|
||||
|
||||
export namespace SemVer {
|
||||
const bumpTypes = ["major", "minor", "patch", "rc", "no bump"] as const;
|
||||
|
||||
export type BumpType = (typeof bumpTypes)[number];
|
||||
|
||||
export function parse(versionStr: string): SemVer {
|
||||
const match = versionStr.match(/^v?([0-9]+)\.([0-9]+)(?:\.([0-9]+))?(?:-rc.([0-9]+))?$/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`${versionStr} is not a valid semantic version`);
|
||||
}
|
||||
|
||||
const semVer: Omit<SemVer, "parsedFrom"> = {
|
||||
"major": parseInt(match[1]),
|
||||
"minor": parseInt(match[2]),
|
||||
"patch": (() => {
|
||||
const str = match[3];
|
||||
|
||||
return str === undefined ? 0 : parseInt(str);
|
||||
})(),
|
||||
...(() => {
|
||||
const str = match[4];
|
||||
return str === undefined ? {} : { "rc": parseInt(str) };
|
||||
})()
|
||||
};
|
||||
|
||||
const initialStr = stringify(semVer);
|
||||
|
||||
Object.defineProperty(semVer, "parsedFrom", {
|
||||
"enumerable": true,
|
||||
"get": function () {
|
||||
const currentStr = stringify(this);
|
||||
|
||||
if (currentStr !== initialStr) {
|
||||
throw new Error(`SemVer.parsedFrom can't be read anymore, the version have been modified from ${initialStr} to ${currentStr}`);
|
||||
}
|
||||
|
||||
return versionStr;
|
||||
}
|
||||
});
|
||||
|
||||
return semVer as any;
|
||||
}
|
||||
|
||||
export function stringify(v: Omit<SemVer, "parsedFrom">): string {
|
||||
return `${v.major}.${v.minor}.${v.patch}${v.rc === undefined ? "" : `-rc.${v.rc}`}`;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* v1 < v2 => -1
|
||||
* v1 === v2 => 0
|
||||
* v1 > v2 => 1
|
||||
*
|
||||
*/
|
||||
export function compare(v1: SemVer, v2: SemVer): -1 | 0 | 1 {
|
||||
const sign = (diff: number): -1 | 0 | 1 => (diff === 0 ? 0 : diff < 0 ? -1 : 1);
|
||||
const noUndefined = (n: number | undefined) => n ?? Infinity;
|
||||
|
||||
for (const level of ["major", "minor", "patch", "rc"] as const) {
|
||||
if (noUndefined(v1[level]) !== noUndefined(v2[level])) {
|
||||
return sign(noUndefined(v1[level]) - noUndefined(v2[level]));
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
console.log(compare(parse("3.0.0-rc.3"), parse("3.0.0")) === -1 )
|
||||
console.log(compare(parse("3.0.0-rc.3"), parse("3.0.0-rc.4")) === -1 )
|
||||
console.log(compare(parse("3.0.0-rc.3"), parse("4.0.0")) === -1 )
|
||||
*/
|
||||
|
||||
export function bumpType(params: { versionBehind: string | SemVer; versionAhead: string | SemVer }): BumpType | "no bump" {
|
||||
const versionAhead = typeof params.versionAhead === "string" ? parse(params.versionAhead) : params.versionAhead;
|
||||
const versionBehind = typeof params.versionBehind === "string" ? parse(params.versionBehind) : params.versionBehind;
|
||||
|
||||
if (compare(versionBehind, versionAhead) === 1) {
|
||||
throw new Error(`Version regression ${stringify(versionBehind)} -> ${stringify(versionAhead)}`);
|
||||
}
|
||||
|
||||
for (const level of ["major", "minor", "patch", "rc"] as const) {
|
||||
if (versionBehind[level] !== versionAhead[level]) {
|
||||
return level;
|
||||
}
|
||||
}
|
||||
|
||||
return "no bump";
|
||||
}
|
||||
}
|
30
src/bin/tools/String.prototype.replaceAll.ts
Normal file
30
src/bin/tools/String.prototype.replaceAll.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export function replaceAll(string: string, searchValue: string | RegExp, replaceValue: string): string {
|
||||
if ((string as any).replaceAll !== undefined) {
|
||||
return (string as any).replaceAll(searchValue, replaceValue);
|
||||
}
|
||||
|
||||
// If the searchValue is a string
|
||||
if (typeof searchValue === "string") {
|
||||
// Escape special characters in the string to be used in a regex
|
||||
var escapedSearchValue = searchValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
var regex = new RegExp(escapedSearchValue, "g");
|
||||
|
||||
return string.replace(regex, replaceValue);
|
||||
}
|
||||
|
||||
// If the searchValue is a global RegExp, use it directly
|
||||
if (searchValue instanceof RegExp && searchValue.global) {
|
||||
return string.replace(searchValue, replaceValue);
|
||||
}
|
||||
|
||||
// If the searchValue is a non-global RegExp, throw an error
|
||||
if (searchValue instanceof RegExp) {
|
||||
throw new TypeError("replaceAll must be called with a global RegExp");
|
||||
}
|
||||
|
||||
// Convert searchValue to string if it's not a string or RegExp
|
||||
var searchString = String(searchValue);
|
||||
var regexFromString = new RegExp(searchString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
|
||||
|
||||
return string.replace(regexFromString, replaceValue);
|
||||
}
|
@ -1,27 +1,32 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
|
||||
const crawlRec = (dirPath: string, filePaths: string[]) => {
|
||||
for (const basename of fs.readdirSync(dirPath)) {
|
||||
const fileOrDirPath = pathJoin(dirPath, basename);
|
||||
|
||||
if (fs.lstatSync(fileOrDirPath).isDirectory()) {
|
||||
crawlRec(fileOrDirPath, filePaths);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
filePaths.push(fileOrDirPath);
|
||||
}
|
||||
};
|
||||
|
||||
/** List all files in a given directory return paths relative to the dir_path */
|
||||
export const crawl = (() => {
|
||||
const crawlRec = (dir_path: string, paths: string[]) => {
|
||||
for (const file_name of fs.readdirSync(dir_path)) {
|
||||
const file_path = path.join(dir_path, file_name);
|
||||
export function crawl(params: { dirPath: string; returnedPathsType: "absolute" | "relative to dirPath" }): string[] {
|
||||
const { dirPath, returnedPathsType } = params;
|
||||
|
||||
if (fs.lstatSync(file_path).isDirectory()) {
|
||||
crawlRec(file_path, paths);
|
||||
const filePaths: string[] = [];
|
||||
|
||||
continue;
|
||||
}
|
||||
crawlRec(dirPath, filePaths);
|
||||
|
||||
paths.push(file_path);
|
||||
}
|
||||
};
|
||||
|
||||
return function crawl(dir_path: string): string[] {
|
||||
const paths: string[] = [];
|
||||
|
||||
crawlRec(dir_path, paths);
|
||||
|
||||
return paths.map(file_path => path.relative(dir_path, file_path));
|
||||
};
|
||||
})();
|
||||
switch (returnedPathsType) {
|
||||
case "absolute":
|
||||
return filePaths;
|
||||
case "relative to dirPath":
|
||||
return filePaths.map(filePath => pathRelative(dirPath, filePath));
|
||||
}
|
||||
}
|
||||
|
@ -1,130 +0,0 @@
|
||||
import { exec as execCallback } from "child_process";
|
||||
import { createHash } from "crypto";
|
||||
import { mkdir, readFile, stat, writeFile } from "fs/promises";
|
||||
import fetch, { type FetchOptions } from "make-fetch-happen";
|
||||
import { dirname as pathDirname, join as pathJoin } from "path";
|
||||
import { assert } from "tsafe";
|
||||
import { promisify } from "util";
|
||||
import { getProjectRoot } from "./getProjectRoot";
|
||||
import { transformCodebase } from "./transformCodebase";
|
||||
import { unzip } from "./unzip";
|
||||
|
||||
const exec = promisify(execCallback);
|
||||
|
||||
function hash(s: string) {
|
||||
return createHash("sha256").update(s).digest("hex");
|
||||
}
|
||||
|
||||
async function exists(path: string) {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as Error & { code: string }).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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[]>;
|
||||
|
||||
const npmConfigReducer = (cfg: NPMConfig, [key, value]: [string, string]) =>
|
||||
key in cfg ? { ...cfg, [key]: [...ensureArray(cfg[key]), value] } : { ...cfg, [key]: value };
|
||||
|
||||
/**
|
||||
* Get npm configuration as map
|
||||
*/
|
||||
async function getNmpConfig() {
|
||||
return readNpmConfig().then(parseNpmConfig);
|
||||
}
|
||||
|
||||
async function readNpmConfig() {
|
||||
const { stdout } = await exec("npm config get", { encoding: "utf8" });
|
||||
return stdout;
|
||||
}
|
||||
|
||||
function parseNpmConfig(stdout: string) {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter(line => !line.startsWith(";"))
|
||||
.map(line => line.trim())
|
||||
.map(line => line.split("=", 2) as [string, string])
|
||||
.reduce(npmConfigReducer, {} as NPMConfig);
|
||||
}
|
||||
|
||||
function maybeBoolean(arg0: string | undefined) {
|
||||
return typeof arg0 === "undefined" ? undefined : Boolean(arg0);
|
||||
}
|
||||
|
||||
function chunks<T>(arr: T[], size: number = 2) {
|
||||
return arr.map((_, i) => i % size == 0 && arr.slice(i, i + size)).filter(Boolean) as T[][];
|
||||
}
|
||||
|
||||
async function readCafile(cafile: string) {
|
||||
const cafileContent = await readFile(cafile, "utf-8");
|
||||
return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(ca => ca.join("").replace(/^\n/, "").replace(/\n/g, "\\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy and ssl configuration from npm config files. Note that we don't care about
|
||||
* proxy config in env vars, because make-fetch-happen will do that for us.
|
||||
*
|
||||
* @returns proxy configuration
|
||||
*/
|
||||
async function getFetchOptions(): Promise<Pick<FetchOptions, "proxy" | "noProxy" | "strictSSL" | "ca" | "cert">> {
|
||||
const cfg = await getNmpConfig();
|
||||
|
||||
const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]);
|
||||
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
|
||||
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 readCafile(cafile)));
|
||||
|
||||
return { proxy, noProxy, strictSSL, cert, ca: ca.length === 0 ? undefined : ca };
|
||||
}
|
||||
|
||||
export async function downloadAndUnzip(params: { url: string; destDirPath: string; pathOfDirToExtractInArchive?: string }) {
|
||||
const { url, destDirPath, pathOfDirToExtractInArchive } = params;
|
||||
|
||||
const downloadHash = hash(JSON.stringify({ url })).substring(0, 15);
|
||||
const projectRoot = getProjectRoot();
|
||||
const cacheRoot = process.env.XDG_CACHE_HOME ?? pathJoin(projectRoot, "node_modules", ".cache");
|
||||
const zipFilePath = pathJoin(cacheRoot, "keycloakify", "zip", `_${downloadHash}.zip`);
|
||||
const extractDirPath = pathJoin(cacheRoot, "keycloakify", "unzip", `_${downloadHash}`);
|
||||
|
||||
if (!(await exists(zipFilePath))) {
|
||||
const opts = await getFetchOptions();
|
||||
const response = await fetch(url, opts);
|
||||
await mkdir(pathDirname(zipFilePath), { "recursive": true });
|
||||
/**
|
||||
* The correct way to fix this is to upgrade node-fetch beyond 3.2.5
|
||||
* (see https://github.com/node-fetch/node-fetch/issues/1295#issuecomment-1144061991.)
|
||||
* Unfortunately, octokit (a dependency of keycloakify) also uses node-fetch, and
|
||||
* does not support node-fetch 3.x. So we stick around with this band-aid until
|
||||
* octokit upgrades.
|
||||
*/
|
||||
response.body?.setMaxListeners(Number.MAX_VALUE);
|
||||
assert(typeof response.body !== "undefined" && response.body != null);
|
||||
await writeFile(zipFilePath, response.body);
|
||||
}
|
||||
|
||||
await unzip(zipFilePath, extractDirPath, pathOfDirToExtractInArchive);
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": extractDirPath,
|
||||
"destDirPath": destDirPath
|
||||
});
|
||||
}
|
73
src/bin/tools/fetchProxyOptions.ts
Normal file
73
src/bin/tools/fetchProxyOptions.ts
Normal file
@ -0,0 +1,73 @@
|
||||
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");
|
||||
return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(ca => ca.join("").replace(/^\n/, "").replace(/\n/g, "\\n"));
|
||||
})())
|
||||
);
|
||||
}
|
||||
|
||||
return { proxy, noProxy, strictSSL, cert, "ca": ca.length === 0 ? undefined : ca };
|
||||
}
|
11
src/bin/tools/fs.existsAsync.ts
Normal file
11
src/bin/tools/fs.existsAsync.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import * as fs from "fs/promises";
|
||||
|
||||
export async function existsAsync(path: string) {
|
||||
try {
|
||||
await fs.stat(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as Error & { code: string }).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
43
src/bin/tools/fs.rm.ts
Normal file
43
src/bin/tools/fs.rm.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import * as fs from "fs/promises";
|
||||
import { join as pathJoin } from "path";
|
||||
import { SemVer } from "./SemVer";
|
||||
|
||||
/**
|
||||
* Polyfill of fs.rm(dirPath, { "recursive": true })
|
||||
* For older version of Node
|
||||
*/
|
||||
export async function rm(dirPath: string, options: { recursive: true; force?: true }) {
|
||||
if (SemVer.compare(SemVer.parse(process.version), SemVer.parse("14.14.0")) > 0) {
|
||||
return fs.rm(dirPath, options);
|
||||
}
|
||||
|
||||
const { force = true } = options;
|
||||
|
||||
if (force && !(await checkDirExists(dirPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removeDir_rec = async (dirPath: string) =>
|
||||
Promise.all(
|
||||
(await fs.readdir(dirPath)).map(async basename => {
|
||||
const fileOrDirpath = pathJoin(dirPath, basename);
|
||||
|
||||
if ((await fs.lstat(fileOrDirpath)).isDirectory()) {
|
||||
await removeDir_rec(fileOrDirpath);
|
||||
} else {
|
||||
await fs.unlink(fileOrDirpath);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await removeDir_rec(dirPath);
|
||||
}
|
||||
|
||||
async function checkDirExists(dirPath: string) {
|
||||
try {
|
||||
await fs.access(dirPath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
34
src/bin/tools/fs.rmSync.ts
Normal file
34
src/bin/tools/fs.rmSync.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { SemVer } from "./SemVer";
|
||||
|
||||
/**
|
||||
* Polyfill of fs.rmSync(dirPath, { "recursive": true })
|
||||
* For older version of Node
|
||||
*/
|
||||
export function rmSync(dirPath: string, options: { recursive: true; force?: true }) {
|
||||
if (SemVer.compare(SemVer.parse(process.version), SemVer.parse("14.14.0")) > 0) {
|
||||
fs.rmSync(dirPath, options);
|
||||
return;
|
||||
}
|
||||
|
||||
const { force = true } = options;
|
||||
|
||||
if (force && !fs.existsSync(dirPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removeDir_rec = (dirPath: string) =>
|
||||
fs.readdirSync(dirPath).forEach(basename => {
|
||||
const fileOrDirpath = pathJoin(dirPath, basename);
|
||||
|
||||
if (fs.lstatSync(fileOrDirpath).isDirectory()) {
|
||||
removeDir_rec(fileOrDirpath);
|
||||
return;
|
||||
} else {
|
||||
fs.unlinkSync(fileOrDirpath);
|
||||
}
|
||||
});
|
||||
|
||||
removeDir_rec(dirPath);
|
||||
}
|
17
src/bin/tools/getAbsoluteAndInOsFormatPath.ts
Normal file
17
src/bin/tools/getAbsoluteAndInOsFormatPath.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { isAbsolute as pathIsAbsolute, sep as pathSep, join as pathJoin } from "path";
|
||||
|
||||
export function getAbsoluteAndInOsFormatPath(params: { pathIsh: string; cwd: string }): string {
|
||||
const { pathIsh, cwd } = params;
|
||||
|
||||
let pathOut = pathIsh;
|
||||
|
||||
pathOut = pathOut.replace(/\//g, pathSep);
|
||||
|
||||
pathOut = pathOut.endsWith(pathSep) ? pathOut.slice(0, -1) : pathOut;
|
||||
|
||||
if (!pathIsAbsolute(pathOut)) {
|
||||
pathOut = pathJoin(cwd, pathOut);
|
||||
}
|
||||
|
||||
return pathOut;
|
||||
}
|
@ -1,19 +1,19 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
function getProjectRootRec(dirPath: string): string {
|
||||
function getThisCodebaseRootDirPath_rec(dirPath: string): string {
|
||||
if (fs.existsSync(path.join(dirPath, "package.json"))) {
|
||||
return dirPath;
|
||||
}
|
||||
return getProjectRootRec(path.join(dirPath, ".."));
|
||||
return getThisCodebaseRootDirPath_rec(path.join(dirPath, ".."));
|
||||
}
|
||||
|
||||
let result: string | undefined = undefined;
|
||||
|
||||
export function getProjectRoot(): string {
|
||||
export function getThisCodebaseRootDirPath(): string {
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return (result = getProjectRootRec(__dirname));
|
||||
return (result = getThisCodebaseRootDirPath_rec(__dirname));
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
import { dirname, relative, sep } from "path";
|
||||
import { createWriteStream } from "fs";
|
||||
|
||||
import walk from "./walk";
|
||||
import { ZipFile } from "yazl";
|
||||
import { mkdir } from "fs/promises";
|
||||
import trimIndent from "./trimIndent";
|
||||
|
||||
export type ZipEntry = { zipPath: string } & ({ fsPath: string } | { buffer: Buffer });
|
||||
export type ZipEntryGenerator = AsyncGenerator<ZipEntry, void, unknown>;
|
||||
|
||||
type CommonJarArgs = {
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type JarStreamArgs = CommonJarArgs & {
|
||||
asyncPathGeneratorFn(): ZipEntryGenerator;
|
||||
};
|
||||
|
||||
export type JarArgs = CommonJarArgs & {
|
||||
targetPath: string;
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
export async function jarStream({ groupId, artifactId, version, asyncPathGeneratorFn }: JarStreamArgs) {
|
||||
const manifestPath = "META-INF/MANIFEST.MF";
|
||||
const manifestData = Buffer.from(trimIndent`
|
||||
Manifest-Version: 1.0
|
||||
Archiver-Version: Plexus Archiver
|
||||
Created-By: Keycloakify
|
||||
Built-By: unknown
|
||||
Build-Jdk: 19.0.0
|
||||
`);
|
||||
|
||||
const pomPropsPath = `META-INF/maven/${groupId}/${artifactId}/pom.properties`;
|
||||
const pomPropsData = Buffer.from(trimIndent`
|
||||
# Generated by keycloakify
|
||||
# ${new Date()}
|
||||
artifactId=${artifactId}
|
||||
groupId=${groupId}
|
||||
version=${version}
|
||||
`);
|
||||
|
||||
const zipFile = new ZipFile();
|
||||
|
||||
for await (const entry of asyncPathGeneratorFn()) {
|
||||
if ("buffer" in entry) {
|
||||
zipFile.addBuffer(entry.buffer, entry.zipPath);
|
||||
} else if ("fsPath" in entry && !entry.fsPath.endsWith(sep)) {
|
||||
zipFile.addFile(entry.fsPath, entry.zipPath);
|
||||
}
|
||||
}
|
||||
|
||||
zipFile.addBuffer(manifestData, manifestPath);
|
||||
zipFile.addBuffer(pomPropsData, pomPropsPath);
|
||||
|
||||
zipFile.end();
|
||||
|
||||
return zipFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a jar archive, using the resources found at `rootPath` (a directory) and write the
|
||||
* archive to `targetPath` (a file). Use `groupId`, `artifactId` and `version` to define
|
||||
* the contents of the pom.properties file which is going to be added to the archive.
|
||||
*/
|
||||
export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) {
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
|
||||
const asyncPathGeneratorFn = async function* (): ZipEntryGenerator {
|
||||
for await (const fsPath of walk(rootPath)) {
|
||||
const zipPath = relative(rootPath, fsPath).split(sep).join("/");
|
||||
yield { fsPath, zipPath };
|
||||
}
|
||||
};
|
||||
|
||||
const zipFile = await jarStream({ groupId, artifactId, version, asyncPathGeneratorFn });
|
||||
|
||||
await new Promise<void>(async (resolve, reject) => {
|
||||
zipFile.outputStream
|
||||
.pipe(createWriteStream(targetPath, { encoding: "binary" }))
|
||||
.on("close", () => resolve())
|
||||
.on("error", e => reject(e));
|
||||
});
|
||||
}
|
@ -1,39 +1,39 @@
|
||||
import { listTagsFactory } from "./listTags";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { NpmModuleVersion } from "../NpmModuleVersion";
|
||||
import { SemVer } from "../SemVer";
|
||||
|
||||
export function getLatestsSemVersionedTagFactory(params: { octokit: Octokit }) {
|
||||
const { octokit } = params;
|
||||
|
||||
async function getLatestsSemVersionedTag(params: { owner: string; repo: string; doIgnoreBeta: boolean; count: number }): Promise<
|
||||
async function getLatestsSemVersionedTag(params: { owner: string; repo: string; count: number }): Promise<
|
||||
{
|
||||
tag: string;
|
||||
version: NpmModuleVersion;
|
||||
version: SemVer;
|
||||
}[]
|
||||
> {
|
||||
const { owner, repo, doIgnoreBeta, count } = params;
|
||||
const { owner, repo, count } = params;
|
||||
|
||||
const semVersionedTags: { tag: string; version: NpmModuleVersion }[] = [];
|
||||
const semVersionedTags: { tag: string; version: SemVer }[] = [];
|
||||
|
||||
const { listTags } = listTagsFactory({ octokit });
|
||||
|
||||
for await (const tag of listTags({ owner, repo })) {
|
||||
let version: NpmModuleVersion;
|
||||
let version: SemVer;
|
||||
|
||||
try {
|
||||
version = NpmModuleVersion.parse(tag.replace(/^[vV]?/, ""));
|
||||
version = SemVer.parse(tag.replace(/^[vV]?/, ""));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (doIgnoreBeta && version.betaPreRelease !== undefined) {
|
||||
if (version.rc !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
semVersionedTags.push({ tag, version });
|
||||
}
|
||||
|
||||
return semVersionedTags.sort(({ version: vX }, { version: vY }) => NpmModuleVersion.compare(vY, vX)).slice(0, count);
|
||||
return semVersionedTags.sort(({ version: vX }, { version: vY }) => SemVer.compare(vY, vX)).slice(0, count);
|
||||
}
|
||||
|
||||
return { getLatestsSemVersionedTag };
|
||||
|
@ -1,6 +0,0 @@
|
||||
export function pathJoin(...path: string[]): string {
|
||||
return path
|
||||
.map((part, i) => (i === 0 ? part : part.replace(/^\/+/, "")))
|
||||
.map((part, i) => (i === path.length - 1 ? part : part.replace(/\/+$/, "")))
|
||||
.join("/");
|
||||
}
|
12
src/bin/tools/readThisNpmProjectVersion.ts
Normal file
12
src/bin/tools/readThisNpmProjectVersion.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { getThisCodebaseRootDirPath } from "./getThisCodebaseRootDirPath";
|
||||
import { assert } from "tsafe/assert";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
|
||||
export function readThisNpmProjectVersion(): string {
|
||||
const version = JSON.parse(fs.readFileSync(pathJoin(getThisCodebaseRootDirPath(), "package.json")).toString("utf8"))["version"];
|
||||
|
||||
assert(typeof version === "string");
|
||||
|
||||
return version;
|
||||
}
|
@ -1,46 +1,69 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { crawl } from "./crawl";
|
||||
import { id } from "tsafe/id";
|
||||
import { rmSync } from "../tools/fs.rmSync";
|
||||
|
||||
type TransformSourceCode = (params: { sourceCode: Buffer; filePath: string }) =>
|
||||
type TransformSourceCode = (params: { sourceCode: Buffer; filePath: string; fileRelativePath: string }) =>
|
||||
| {
|
||||
modifiedSourceCode: Buffer;
|
||||
newFileName?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/** Apply a transformation function to every file of directory */
|
||||
/**
|
||||
* Apply a transformation function to every file of directory
|
||||
* If source and destination are the same this function can be used to apply the transformation in place
|
||||
* like filtering out some files or modifying them.
|
||||
* */
|
||||
export function transformCodebase(params: { srcDirPath: string; destDirPath: string; transformSourceCode?: TransformSourceCode }) {
|
||||
const {
|
||||
srcDirPath,
|
||||
destDirPath,
|
||||
transformSourceCode = id<TransformSourceCode>(({ sourceCode }) => ({
|
||||
"modifiedSourceCode": sourceCode
|
||||
}))
|
||||
} = params;
|
||||
const { srcDirPath, transformSourceCode } = params;
|
||||
|
||||
for (const file_relative_path of crawl(srcDirPath)) {
|
||||
const filePath = path.join(srcDirPath, file_relative_path);
|
||||
const isTargetSameAsSource = path.relative(srcDirPath, params.destDirPath) === "";
|
||||
|
||||
const destDirPath = isTargetSameAsSource ? path.join(srcDirPath, "..", "tmp_xOsPdkPsTdzPs34sOkHs") : params.destDirPath;
|
||||
|
||||
fs.mkdirSync(destDirPath, {
|
||||
"recursive": true
|
||||
});
|
||||
|
||||
for (const fileRelativePath of crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })) {
|
||||
const filePath = path.join(srcDirPath, fileRelativePath);
|
||||
const destFilePath = path.join(destDirPath, fileRelativePath);
|
||||
|
||||
// NOTE: Optimization, if we don't need to transform the file, just copy
|
||||
// it using the lower level implementation.
|
||||
if (transformSourceCode === undefined) {
|
||||
fs.mkdirSync(path.dirname(destFilePath), {
|
||||
"recursive": true
|
||||
});
|
||||
|
||||
fs.copyFileSync(filePath, destFilePath);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const transformSourceCodeResult = transformSourceCode({
|
||||
"sourceCode": fs.readFileSync(filePath),
|
||||
"filePath": path.join(srcDirPath, file_relative_path)
|
||||
filePath,
|
||||
fileRelativePath
|
||||
});
|
||||
|
||||
if (transformSourceCodeResult === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(path.join(destDirPath, file_relative_path)), {
|
||||
fs.mkdirSync(path.dirname(destFilePath), {
|
||||
"recursive": true
|
||||
});
|
||||
|
||||
const { newFileName, modifiedSourceCode } = transformSourceCodeResult;
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(path.dirname(path.join(destDirPath, file_relative_path)), newFileName ?? path.basename(file_relative_path)),
|
||||
modifiedSourceCode
|
||||
);
|
||||
fs.writeFileSync(path.join(path.dirname(destFilePath), newFileName ?? path.basename(destFilePath)), modifiedSourceCode);
|
||||
}
|
||||
|
||||
if (isTargetSameAsSource) {
|
||||
rmSync(srcDirPath, { "recursive": true });
|
||||
|
||||
fs.renameSync(destDirPath, srcDirPath);
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ function populateTemplate(strings: TemplateStringsArray, ...args: unknown[]) {
|
||||
if (strings[i]) {
|
||||
chunks.push(strings[i]);
|
||||
// remember last indent of the string portion
|
||||
lastStringLineLength = strings[i].split("\n").at(-1)?.length ?? 0;
|
||||
lastStringLineLength = strings[i].split("\n").slice(-1)[0]?.length ?? 0;
|
||||
}
|
||||
if (args[i]) {
|
||||
// if the interpolation value has newlines, indent the interpolation values
|
||||
|
@ -2,6 +2,7 @@ import fsp from "node:fs/promises";
|
||||
import fs from "fs";
|
||||
import path from "node:path";
|
||||
import yauzl from "yauzl";
|
||||
import yazl from "yazl";
|
||||
import stream from "node:stream";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
@ -19,11 +20,16 @@ async function pathExists(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function unzip(file: string, targetFolder: string, unzipSubPath?: string) {
|
||||
// add trailing slash to unzipSubPath and targetFolder
|
||||
if (unzipSubPath && (!unzipSubPath.endsWith("/") || !unzipSubPath.endsWith("\\"))) {
|
||||
unzipSubPath += "/";
|
||||
}
|
||||
// Handlings of non posix path is not implemented correctly
|
||||
// it work by coincidence. Don't have the time to fix but it should be fixed.
|
||||
export async function unzip(file: string, targetFolder: string, specificDirsToExtract?: string[]) {
|
||||
specificDirsToExtract = specificDirsToExtract?.map(dirPath => {
|
||||
if (!dirPath.endsWith("/") || !dirPath.endsWith("\\")) {
|
||||
dirPath += "/";
|
||||
}
|
||||
|
||||
return dirPath;
|
||||
});
|
||||
|
||||
if (!targetFolder.endsWith("/") || !targetFolder.endsWith("\\")) {
|
||||
targetFolder += "/";
|
||||
@ -42,15 +48,17 @@ export async function unzip(file: string, targetFolder: string, unzipSubPath?: s
|
||||
zipfile.readEntry();
|
||||
|
||||
zipfile.on("entry", async entry => {
|
||||
if (unzipSubPath) {
|
||||
if (specificDirsToExtract !== undefined) {
|
||||
const dirPath = specificDirsToExtract.find(dirPath => entry.fileName.startsWith(dirPath));
|
||||
|
||||
// Skip files outside of the unzipSubPath
|
||||
if (!entry.fileName.startsWith(unzipSubPath)) {
|
||||
if (dirPath === undefined) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the unzipSubPath from the file name
|
||||
entry.fileName = entry.fileName.substring(unzipSubPath.length);
|
||||
entry.fileName = entry.fileName.substring(dirPath.length);
|
||||
}
|
||||
|
||||
const target = path.join(targetFolder, entry.fileName);
|
||||
@ -77,6 +85,8 @@ export async function unzip(file: string, targetFolder: string, unzipSubPath?: s
|
||||
return;
|
||||
}
|
||||
|
||||
await fsp.mkdir(path.dirname(target), { "recursive": true });
|
||||
|
||||
await pipeline(readStream, fs.createWriteStream(target));
|
||||
|
||||
zipfile.readEntry();
|
||||
@ -90,3 +100,42 @@ export async function unzip(file: string, targetFolder: string, unzipSubPath?: s
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: This code was directly copied from ChatGPT and appears to function as expected.
|
||||
// However, confidence in its complete accuracy and robustness is limited.
|
||||
export async function zip(sourceFolder: string, targetZip: string) {
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
const zipfile = new yazl.ZipFile();
|
||||
const files: string[] = [];
|
||||
|
||||
// Recursive function to explore directories and their subdirectories
|
||||
async function exploreDir(dir: string) {
|
||||
const dirContent = await fsp.readdir(dir);
|
||||
for (const file of dirContent) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = await fsp.stat(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
await exploreDir(filePath);
|
||||
} else if (stat.isFile()) {
|
||||
files.push(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collecting all files to be zipped
|
||||
await exploreDir(sourceFolder);
|
||||
|
||||
// Adding files to zip
|
||||
for (const file of files) {
|
||||
const relativePath = path.relative(sourceFolder, file);
|
||||
zipfile.addFile(file, relativePath);
|
||||
}
|
||||
|
||||
zipfile.outputStream
|
||||
.pipe(fs.createWriteStream(targetZip))
|
||||
.on("close", () => resolve())
|
||||
.on("error", err => reject(err)); // Listen to error events
|
||||
|
||||
zipfile.end();
|
||||
});
|
||||
}
|
||||
|
@ -1,19 +0,0 @@
|
||||
import { readdir } from "fs/promises";
|
||||
import { resolve, sep } from "path";
|
||||
|
||||
/**
|
||||
* Asynchronously and recursively walk a directory tree, yielding every file and directory
|
||||
* found. Directory paths will _always_ end with a path separator.
|
||||
*
|
||||
* @param root the starting directory
|
||||
* @returns AsyncGenerator
|
||||
*/
|
||||
export default async function* walk(root: string): AsyncGenerator<string, void, unknown> {
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
const absolutePath = resolve(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
yield absolutePath.endsWith(sep) ? absolutePath : absolutePath + sep;
|
||||
yield* walk(absolutePath);
|
||||
} else yield absolutePath.endsWith(sep) ? absolutePath.substring(0, absolutePath.length - 1) : absolutePath;
|
||||
}
|
||||
}
|
44
src/lib/BASE_URL.ts
Normal file
44
src/lib/BASE_URL.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { assert } from "tsafe/assert";
|
||||
|
||||
/**
|
||||
* WARNING: Internal use only!!
|
||||
* THIS DOES NOT WORK IN KEYCLOAK! It's only for resolving mock assets.
|
||||
* This is just a way to know what's the base url that works
|
||||
* both in webpack and vite.
|
||||
* You can see this as a polyfill that return `import.meta.env.BASE_URL` when in Vite
|
||||
* and when in Webpack returns the base url in the same format as vite does meaning
|
||||
* "/" if hosted at root or "/foo/" when hosted under a subpath (always start and ends with a "/").
|
||||
*/
|
||||
export const BASE_URL = (() => {
|
||||
vite: {
|
||||
let BASE_URL: string;
|
||||
|
||||
try {
|
||||
// @ts-expect-error
|
||||
BASE_URL = import.meta.env.BASE_URL;
|
||||
|
||||
assert(typeof BASE_URL === "string");
|
||||
} catch {
|
||||
break vite;
|
||||
}
|
||||
|
||||
return BASE_URL;
|
||||
}
|
||||
|
||||
webpack: {
|
||||
let BASE_URL: string;
|
||||
|
||||
try {
|
||||
// @ts-expect-error
|
||||
BASE_URL = process.env.PUBLIC_URL;
|
||||
|
||||
assert(typeof BASE_URL === "string");
|
||||
} catch {
|
||||
break webpack;
|
||||
}
|
||||
|
||||
return BASE_URL === "" ? "/" : `${BASE_URL}/`;
|
||||
}
|
||||
|
||||
return "/";
|
||||
})();
|
1
src/lib/isStorybook.ts
Normal file
1
src/lib/isStorybook.ts
Normal file
@ -0,0 +1 @@
|
||||
export const isStorybook = typeof window === "object" && Object.keys(window).find(key => key.startsWith("__STORYBOOK")) !== undefined;
|
@ -36,6 +36,10 @@ export declare namespace keycloak_js {
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated: This will be removed in the next major version.
|
||||
* If you use this, please copy paste the code into your project.
|
||||
* Better yet migrate away from keycloak-js and use https://docs.oidc-spa.dev instead.
|
||||
*
|
||||
* NOTE: This is just a slightly modified version of the default adapter in keycloak-js
|
||||
* The goal here is just to be able to inject search param in url before keycloak redirect.
|
||||
* Our use case for it is to pass over the login screen the states of useGlobalState
|
||||
|
@ -5,10 +5,10 @@ export function createUseClassName<ClassKey extends string>(params: { defaultCla
|
||||
const { defaultClasses } = params;
|
||||
|
||||
function useGetClassName(params: { doUseDefaultCss: boolean; classes: Partial<Record<ClassKey, string>> | undefined }) {
|
||||
const { classes } = params;
|
||||
const { classes, doUseDefaultCss } = params;
|
||||
|
||||
const getClassName = useConstCallback((classKey: ClassKey): string => {
|
||||
return clsx(classKey, defaultClasses[classKey], classes?.[classKey]);
|
||||
return clsx(classKey, doUseDefaultCss ? defaultClasses[classKey] : undefined, classes?.[classKey]);
|
||||
});
|
||||
|
||||
return { getClassName };
|
||||
|
@ -1,106 +0,0 @@
|
||||
import { useReducer, useEffect } from "react";
|
||||
import { headInsert } from "keycloakify/tools/headInsert";
|
||||
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
|
||||
export function usePrepareTemplate(params: {
|
||||
doFetchDefaultThemeResources: boolean;
|
||||
stylesCommon?: string[];
|
||||
styles?: string[];
|
||||
scripts?: string[];
|
||||
url: {
|
||||
resourcesCommonPath: string;
|
||||
resourcesPath: string;
|
||||
};
|
||||
htmlClassName: string | undefined;
|
||||
bodyClassName: string | undefined;
|
||||
}) {
|
||||
const { doFetchDefaultThemeResources, stylesCommon = [], styles = [], url, scripts = [], htmlClassName, bodyClassName } = params;
|
||||
|
||||
const [isReady, setReady] = useReducer(() => true, !doFetchDefaultThemeResources);
|
||||
|
||||
useEffect(() => {
|
||||
if (!doFetchDefaultThemeResources) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isUnmounted = false;
|
||||
|
||||
const removeArray: (() => void)[] = [];
|
||||
|
||||
(async () => {
|
||||
const prLoadedArray: Promise<void>[] = [];
|
||||
|
||||
[
|
||||
...stylesCommon.map(relativePath => pathJoin(url.resourcesCommonPath, relativePath)),
|
||||
...styles.map(relativePath => pathJoin(url.resourcesPath, relativePath))
|
||||
]
|
||||
.reverse()
|
||||
.forEach(href => {
|
||||
const { prLoaded, remove } = headInsert({
|
||||
"type": "css",
|
||||
"position": "prepend",
|
||||
href
|
||||
});
|
||||
|
||||
removeArray.push(remove);
|
||||
|
||||
prLoadedArray.push(prLoaded);
|
||||
});
|
||||
|
||||
await Promise.all(prLoadedArray);
|
||||
|
||||
if (isUnmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReady();
|
||||
})();
|
||||
|
||||
scripts.forEach(relativePath => {
|
||||
const { remove } = headInsert({
|
||||
"type": "javascript",
|
||||
"src": pathJoin(url.resourcesPath, relativePath)
|
||||
});
|
||||
|
||||
removeArray.push(remove);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
removeArray.forEach(remove => remove());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useSetClassName({
|
||||
"target": "html",
|
||||
"className": htmlClassName
|
||||
});
|
||||
|
||||
useSetClassName({
|
||||
"target": "body",
|
||||
"className": bodyClassName
|
||||
});
|
||||
|
||||
return { isReady };
|
||||
}
|
||||
|
||||
function useSetClassName(params: { target: "html" | "body"; className: string | undefined }) {
|
||||
const { target, className } = params;
|
||||
|
||||
useEffect(() => {
|
||||
if (className === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const htmlClassList = document.getElementsByTagName(target)[0].classList;
|
||||
|
||||
const tokens = clsx(className).split(" ");
|
||||
|
||||
htmlClassList.add(...tokens);
|
||||
|
||||
return () => {
|
||||
htmlClassList.remove(...tokens);
|
||||
};
|
||||
}, [className]);
|
||||
}
|
@ -3,19 +3,23 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import type { I18n } from "./i18n";
|
||||
import type { KcContext } from "./kcContext";
|
||||
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
|
||||
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFields";
|
||||
|
||||
const Login = lazy(() => import("keycloakify/login/pages/Login"));
|
||||
const Register = lazy(() => import("keycloakify/login/pages/Register"));
|
||||
const RegisterUserProfile = lazy(() => import("keycloakify/login/pages/RegisterUserProfile"));
|
||||
const Info = lazy(() => import("keycloakify/login/pages/Info"));
|
||||
const Error = lazy(() => import("keycloakify/login/pages/Error"));
|
||||
const LoginResetPassword = lazy(() => import("keycloakify/login/pages/LoginResetPassword"));
|
||||
const LoginVerifyEmail = lazy(() => import("keycloakify/login/pages/LoginVerifyEmail"));
|
||||
const Terms = lazy(() => import("keycloakify/login/pages/Terms"));
|
||||
const LoginOauth2DeviceVerifyUserCode = lazy(() => import("keycloakify/login/pages/LoginOauth2DeviceVerifyUserCode"));
|
||||
const LoginOauthGrant = lazy(() => import("keycloakify/login/pages/LoginOauthGrant"));
|
||||
const LoginOtp = lazy(() => import("keycloakify/login/pages/LoginOtp"));
|
||||
const LoginPassword = lazy(() => import("keycloakify/login/pages/LoginPassword"));
|
||||
const LoginUsername = lazy(() => import("keycloakify/login/pages/LoginUsername"));
|
||||
const WebauthnAuthenticate = lazy(() => import("keycloakify/login/pages/WebauthnAuthenticate"));
|
||||
const WebauthnRegister = lazy(() => import("keycloakify/login/pages/WebauthnRegister"));
|
||||
const LoginUpdatePassword = lazy(() => import("keycloakify/login/pages/LoginUpdatePassword"));
|
||||
const LoginUpdateProfile = lazy(() => import("keycloakify/login/pages/LoginUpdateProfile"));
|
||||
const LoginIdpLinkConfirm = lazy(() => import("keycloakify/login/pages/LoginIdpLinkConfirm"));
|
||||
@ -23,13 +27,25 @@ const LoginPageExpired = lazy(() => import("keycloakify/login/pages/LoginPageExp
|
||||
const LoginIdpLinkEmail = lazy(() => import("keycloakify/login/pages/LoginIdpLinkEmail"));
|
||||
const LoginConfigTotp = lazy(() => import("keycloakify/login/pages/LoginConfigTotp"));
|
||||
const LogoutConfirm = lazy(() => import("keycloakify/login/pages/LogoutConfirm"));
|
||||
const UpdateUserProfile = lazy(() => import("keycloakify/login/pages/UpdateUserProfile"));
|
||||
const IdpReviewUserProfile = lazy(() => import("keycloakify/login/pages/IdpReviewUserProfile"));
|
||||
const UpdateEmail = lazy(() => import("keycloakify/login/pages/UpdateEmail"));
|
||||
const SelectAuthenticator = lazy(() => import("keycloakify/login/pages/SelectAuthenticator"));
|
||||
const SamlPostForm = lazy(() => import("keycloakify/login/pages/SamlPostForm"));
|
||||
const DeleteCredential = lazy(() => import("keycloakify/login/pages/DeleteCredential"));
|
||||
const Code = lazy(() => import("keycloakify/login/pages/Code"));
|
||||
const DeleteAccountConfirm = lazy(() => import("keycloakify/login/pages/DeleteAccountConfirm"));
|
||||
const FrontchannelLogout = lazy(() => import("keycloakify/login/pages/FrontchannelLogout"));
|
||||
const LoginRecoveryAuthnCodeConfig = lazy(() => import("keycloakify/login/pages/LoginRecoveryAuthnCodeConfig"));
|
||||
const LoginRecoveryAuthnCodeInput = lazy(() => import("keycloakify/login/pages/LoginRecoveryAuthnCodeInput"));
|
||||
const LoginResetOtp = lazy(() => import("keycloakify/login/pages/LoginResetOtp"));
|
||||
const LoginX509Info = lazy(() => import("keycloakify/login/pages/LoginX509Info"));
|
||||
const WebauthnError = lazy(() => import("keycloakify/login/pages/WebauthnError"));
|
||||
|
||||
export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
type FallbackProps = PageProps<KcContext, I18n> & {
|
||||
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
|
||||
};
|
||||
|
||||
export default function Fallback(props: FallbackProps) {
|
||||
const { kcContext, ...rest } = props;
|
||||
|
||||
return (
|
||||
@ -40,8 +56,6 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
return <Login kcContext={kcContext} {...rest} />;
|
||||
case "register.ftl":
|
||||
return <Register kcContext={kcContext} {...rest} />;
|
||||
case "register-user-profile.ftl":
|
||||
return <RegisterUserProfile kcContext={kcContext} {...rest} />;
|
||||
case "info.ftl":
|
||||
return <Info kcContext={kcContext} {...rest} />;
|
||||
case "error.ftl":
|
||||
@ -52,6 +66,10 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
return <LoginVerifyEmail kcContext={kcContext} {...rest} />;
|
||||
case "terms.ftl":
|
||||
return <Terms kcContext={kcContext} {...rest} />;
|
||||
case "login-oauth2-device-verify-user-code.ftl":
|
||||
return <LoginOauth2DeviceVerifyUserCode kcContext={kcContext} {...rest} />;
|
||||
case "login-oauth-grant.ftl":
|
||||
return <LoginOauthGrant kcContext={kcContext} {...rest} />;
|
||||
case "login-otp.ftl":
|
||||
return <LoginOtp kcContext={kcContext} {...rest} />;
|
||||
case "login-username.ftl":
|
||||
@ -60,6 +78,8 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
return <LoginPassword kcContext={kcContext} {...rest} />;
|
||||
case "webauthn-authenticate.ftl":
|
||||
return <WebauthnAuthenticate kcContext={kcContext} {...rest} />;
|
||||
case "webauthn-register.ftl":
|
||||
return <WebauthnRegister kcContext={kcContext} {...rest} />;
|
||||
case "login-update-password.ftl":
|
||||
return <LoginUpdatePassword kcContext={kcContext} {...rest} />;
|
||||
case "login-update-profile.ftl":
|
||||
@ -74,8 +94,6 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
return <LoginConfigTotp kcContext={kcContext} {...rest} />;
|
||||
case "logout-confirm.ftl":
|
||||
return <LogoutConfirm kcContext={kcContext} {...rest} />;
|
||||
case "update-user-profile.ftl":
|
||||
return <UpdateUserProfile kcContext={kcContext} {...rest} />;
|
||||
case "idp-review-user-profile.ftl":
|
||||
return <IdpReviewUserProfile kcContext={kcContext} {...rest} />;
|
||||
case "update-email.ftl":
|
||||
@ -84,6 +102,24 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
return <SelectAuthenticator kcContext={kcContext} {...rest} />;
|
||||
case "saml-post-form.ftl":
|
||||
return <SamlPostForm kcContext={kcContext} {...rest} />;
|
||||
case "delete-credential.ftl":
|
||||
return <DeleteCredential kcContext={kcContext} {...rest} />;
|
||||
case "code.ftl":
|
||||
return <Code kcContext={kcContext} {...rest} />;
|
||||
case "delete-account-confirm.ftl":
|
||||
return <DeleteAccountConfirm kcContext={kcContext} {...rest} />;
|
||||
case "frontchannel-logout.ftl":
|
||||
return <FrontchannelLogout kcContext={kcContext} {...rest} />;
|
||||
case "login-recovery-authn-code-config.ftl":
|
||||
return <LoginRecoveryAuthnCodeConfig kcContext={kcContext} {...rest} />;
|
||||
case "login-recovery-authn-code-input.ftl":
|
||||
return <LoginRecoveryAuthnCodeInput kcContext={kcContext} {...rest} />;
|
||||
case "login-reset-otp.ftl":
|
||||
return <LoginResetOtp kcContext={kcContext} {...rest} />;
|
||||
case "login-x509-info.ftl":
|
||||
return <LoginX509Info kcContext={kcContext} {...rest} />;
|
||||
case "webauthn-error.ftl":
|
||||
return <WebauthnError kcContext={kcContext} {...rest} />;
|
||||
}
|
||||
assert<Equals<typeof kcContext, never>>(false);
|
||||
})()}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user