Compare commits
941 Commits
v8.1.2
...
v10.0.0-rc
Author | SHA1 | Date | |
---|---|---|---|
4c282d0559 | |||
4ac14dc074 | |||
fcdbb04ea6 | |||
14f283cf49 | |||
efc459663a | |||
d459aaf943 | |||
921c7d5441 | |||
7d7e648968 | |||
96fc779ec8 | |||
9605e17e96 | |||
111c1675f9 | |||
d547ec3126 | |||
0ce6a7be7f | |||
1e5eae69e9 | |||
89d9208f44 | |||
3e80aaf242 | |||
86c3159ded | |||
230e05abc0 | |||
ff2e6e6432 | |||
dc00be9be6 | |||
77249d8a58 | |||
b9ee0afe7f | |||
db23ab0bc2 | |||
13dc47533c | |||
0091a888bc | |||
724b585004 | |||
c0d127e4f4 | |||
1638577d98 | |||
951c202fd0 | |||
a578b86715 | |||
b6b384854e | |||
dac937060d | |||
c628183773 | |||
eaacaa6966 | |||
9a09e280c9 | |||
70ac07d861 | |||
dabe372360 | |||
d8e3fdeb14 | |||
a147084458 | |||
b25e171412 | |||
60aaa03202 | |||
3392ab8385 | |||
f172b94467 | |||
ca549fe8d8 | |||
0b6f56a774 | |||
e5bcff12cb | |||
2754900f7a | |||
54f43d3331 | |||
4900200c06 | |||
6d82a74db4 | |||
2d7f21b021 | |||
4292c0c642 | |||
9dca515a42 | |||
b577cd9829 | |||
704682cbbe | |||
858f0d77c0 | |||
31ef6063f2 | |||
f3bd81c55b | |||
24bb4902c2 | |||
ca7821cfad | |||
a73b25580e | |||
82e179730e | |||
b5d5002061 | |||
2ab2c9e05e | |||
b1e9ba3ac6 | |||
5822ed0185 | |||
17b295788d | |||
6cd5b958c7 | |||
df92cc5f73 | |||
03106cdee3 | |||
c4638daf1b | |||
e2f5eb79ad | |||
b6c8e9bca0 | |||
573839019e | |||
815bf10ae0 | |||
7c257d97a7 | |||
59f8814660 | |||
1a6993099f | |||
f62ded3c8e | |||
4eca6366cc | |||
51a45b355d | |||
e5765cb902 | |||
6e922d2033 | |||
5d1695ada8 | |||
6e3ce29067 | |||
2b9bbc4cef | |||
9557145f72 | |||
249877b9c5 | |||
ff2321fde5 | |||
1edd6e4193 | |||
c7d47f128e | |||
14cb07efb2 | |||
a51724208c | |||
050e2b2b99 | |||
3706f15f7e | |||
bdde9162d9 | |||
99b4933536 | |||
c5caf7e0da | |||
bcc5308cfb | |||
9fb902db5c | |||
7461e38034 | |||
dccd85a151 | |||
910604fdad | |||
508cb9158e | |||
915c500d32 | |||
60bd6621c8 | |||
b5f6262763 | |||
2b8c4422de | |||
a686432c65 | |||
449e625877 | |||
1ac07dafde | |||
3878e28b56 | |||
cf6bc8666b | |||
f76063eb40 | |||
ed52c5824d | |||
9333400322 | |||
3689cfcc0d | |||
b73eceb535 | |||
5dc3453fc9 | |||
cef1139a4b | |||
ac96959947 | |||
4d73d877ba | |||
9f1186302e | |||
319dcc0d15 | |||
e99fdb8561 | |||
f37a342a63 | |||
09a039894d | |||
3efbb1a9fd | |||
920ee62ee3 | |||
1ace44fe31 | |||
a60f05415b | |||
42c9d39e02 | |||
a8186f1ed9 | |||
c2ff515a17 | |||
960c3ba558 | |||
454a9cd01c | |||
7d42ce1c87 | |||
57f6f980cf | |||
8cba3aae2c | |||
01b32f78ed | |||
b6066dfd5f | |||
3ad554ed59 | |||
6aacc6361b | |||
638e4e6410 | |||
aa9b7cccc7 | |||
41739c8528 | |||
89b32dc7fc | |||
44aec23251 | |||
12fd6160c5 | |||
8819abc418 | |||
96b627095c | |||
239f98aa9c | |||
f5d0511662 | |||
75582d2a26 | |||
dba004f924 | |||
5423a07c47 | |||
aba725372e | |||
a61aa9dd5d | |||
74349b20ce | |||
09ab9a1c8f | |||
abfe5789a3 | |||
67ebac496d | |||
60a2bf173b | |||
4e03f07864 | |||
aef1709d7f | |||
2f590f7be2 | |||
d5fa6ca89a | |||
8eaaffb25a | |||
28c5e2bab2 | |||
e212039f2c | |||
99b0b67f77 | |||
6ec9ba3c01 | |||
d7960a7dcf | |||
2a6e9af9c9 | |||
327e4d1f90 | |||
fffadd7b9e | |||
aaaf0d2e77 | |||
9f9a9b8c90 | |||
1f6edb3c0c | |||
142efb4f99 | |||
532655d2d5 | |||
287edabd90 | |||
7aaedbe2ce | |||
4cae1c673c | |||
8e01d836a9 | |||
f6dc8f0741 | |||
3a976d08d2 | |||
50e83b1eb5 | |||
61fbbb0b09 | |||
9e70e5c12e | |||
69d9b64468 | |||
0620d29880 | |||
b52dc74d9b | |||
a46aef2e7e | |||
736806a53d | |||
f1475e5cdf | |||
d04724c70a | |||
bacaadc16d | |||
c51dd235f0 | |||
92f2c9857e | |||
3998cc7f8b | |||
c126d080bc | |||
bc05f1714d | |||
e98becb94b | |||
250b94c8b5 | |||
47f03f6833 | |||
6e7ae48f78 | |||
526dbcc0e7 | |||
1abc5a5643 | |||
c81c350136 | |||
f90dc8bc7e | |||
072e22d072 | |||
59807c1bb0 | |||
7c19e1f1f7 | |||
3b9f915f57 | |||
d85cc530d4 | |||
2bb27c7642 | |||
e90e003204 | |||
b1e58e1add | |||
0fd836314a | |||
0bc3f08cc1 | |||
a78af5080a | |||
074e465284 | |||
bc8165d0ae | |||
ba8561d75a | |||
b2d381ba4b | |||
d39353d332 | |||
ee916af48e | |||
da1dc0309b | |||
30f4e7d833 | |||
cf3a86fb9b | |||
e1633f43f4 | |||
5b64cfc23c | |||
19709cf085 | |||
b8bb6c4f02 | |||
b7a543f8cb | |||
04b4e19563 | |||
ffb27fc66d | |||
8b5f7eefda | |||
c750bf4ee8 | |||
aa74019ef6 | |||
9be6d9f75f | |||
81ebb9b552 | |||
5e13b8c41f | |||
dd1ed948ec | |||
8b93f701cf | |||
2f0084de5b | |||
2ef9828625 | |||
89db8983a7 | |||
287dd9bd31 | |||
9a92054c1a | |||
4189036213 | |||
2c0a427ba5 | |||
77b488d624 | |||
5249e05746 | |||
1e7a0dd7a6 | |||
fd67f2402a | |||
60a65ede2f | |||
1fa659ce61 | |||
0ab903dbc7 | |||
70b0a04793 | |||
c0df9aa939 | |||
60a1886942 | |||
1ebf97871b | |||
72e321aa32 | |||
b0f602b565 | |||
84c774503d | |||
9bbc7cc651 | |||
458083fb6d | |||
8dcfc840b4 | |||
9d06a3a6ad | |||
86cd08b954 | |||
144c3cc082 | |||
802cef41a6 | |||
e128e8f0a9 | |||
8a25b93ab2 | |||
7a040935e9 | |||
2015882688 | |||
379301eb9d | |||
5d86b05cdb | |||
73c99d3157 | |||
acba197c94 | |||
2441d8ed8a | |||
9c123f37c8 | |||
b48dbd99cf | |||
25c8599d8f | |||
3453a17c15 | |||
6e95dacd3a | |||
a286e252e9 | |||
a8997e92c3 | |||
89137153a0 | |||
e3382de8e0 | |||
1a48681591 | |||
8f006f0009 | |||
77e32aad2a | |||
8d365dae53 | |||
01fb89674c | |||
e3144adc61 | |||
c9fb0ca6ae | |||
82d7e1371e | |||
e1341dfdba | |||
7f917311d8 | |||
2bfb856f07 | |||
702f52f1c9 | |||
7ba8649940 | |||
485ca28a29 | |||
33460afaf2 | |||
2421ac2c11 | |||
f0cdb0b80b | |||
2af953927e | |||
dcb9fbd0f7 | |||
5bc1f6479d | |||
f3e4bca468 | |||
54645f5cff | |||
a7f3e00821 | |||
108c281b0c | |||
58892cbb56 | |||
dae1053ca8 | |||
83a9778c30 | |||
c52157bfb9 | |||
62bf846d5f | |||
148f7fa316 | |||
f488327885 | |||
593b929254 | |||
9218e97315 | |||
beb0e8bd77 | |||
cace66e9f8 | |||
ef850c71fd | |||
aa8dc1919f | |||
c7c9b19853 | |||
68c26e0f5b | |||
6bcdf286ef | |||
d9345396e8 | |||
4c423900d4 | |||
504419b26d | |||
6e058eafed | |||
08fc9d8631 | |||
e8a11991a0 | |||
3e6d679838 | |||
4dad859c4d | |||
ef9c933ca8 | |||
0461190a67 | |||
06b3211b08 | |||
2033a9ce0c | |||
fca18d9209 | |||
4f99088449 | |||
b1da684008 | |||
89fb6de2d5 | |||
b665bae3bb | |||
0b5a7544ca | |||
183826ca0d | |||
e507aace6b | |||
43c93ef0b4 | |||
093e51e092 | |||
17e1655eaf | |||
6b570f2b9a | |||
f239d105a7 | |||
776d8378e3 | |||
dd770cd7c6 | |||
4b3de54e18 | |||
5741cd1b2b | |||
b780d7136e | |||
3c28a05746 | |||
57ac5badba | |||
e873eb5123 | |||
c1a63edd71 | |||
37a060c4db | |||
157e4ac485 | |||
ba4d9675a8 | |||
e011fb094c | |||
f55a934939 | |||
96a88fe865 | |||
6cdb83d730 | |||
95f06df45d | |||
ec52b357d5 | |||
d84546cd7d | |||
4eee4156da | |||
70f475d13e | |||
3a50a61b12 | |||
a217f617d8 | |||
fdfcd78f02 | |||
56d6d8001a | |||
c3ee8e10e6 | |||
2f42732deb | |||
956b8260e7 | |||
b7954f87e0 | |||
540ce55dc2 | |||
d71a2c98d1 | |||
cb9cec676d | |||
9f2755bc7f | |||
fbe5a1f477 | |||
338642094d | |||
a3270d10f0 | |||
4c5924556a | |||
99a9b62c6c | |||
1497672a4e | |||
01161fd8ef | |||
68f5ee42e6 | |||
53955a0713 | |||
2271fd43b8 | |||
6a44cfb876 | |||
37c90d53e0 | |||
9a5ac5f13f | |||
6603852355 | |||
5670a71e6b | |||
332b1f74d9 | |||
c28caaa495 | |||
74fed835e8 | |||
6bb7f7dc16 | |||
84bb2338d1 | |||
caa42538a1 | |||
f5b9a8de55 | |||
dd33f554da | |||
7e84d0b108 | |||
9e1217fbf0 | |||
26d3c7f9e0 | |||
76542e6859 | |||
3c4bbf8aa7 | |||
ccc5ac6a1f | |||
b34f86d2f0 | |||
ee5f73519a | |||
22e7ff1424 | |||
7a89888d11 | |||
64fe15cf8c | |||
336813646f | |||
53a18c462a | |||
b893eee086 | |||
792020dd18 | |||
0c11ba05af | |||
2cf82f510e | |||
7c0cbe3a31 | |||
08e659cf01 | |||
97c3f4fa5f | |||
06a24d35cb | |||
9b6d1a957f | |||
189bd4697a | |||
d52252cd55 | |||
303bbc8431 | |||
727dc471c2 | |||
45b5c21ab5 | |||
adddce7764 | |||
b35a9f8f61 | |||
34b46a9280 | |||
383a9953e2 | |||
94bc7127fa | |||
289f0efd24 | |||
3f15586dae | |||
b8a33dabd4 | |||
3a4b44a83c | |||
8c6303f016 | |||
b71f3f559a | |||
6541460821 | |||
e63cd68a3e | |||
5602dc58ff | |||
8ed3561a55 | |||
4e72bc3a72 | |||
45c41ad321 | |||
14eccc761b | |||
6b24c4284f | |||
ba44de87d6 | |||
846181cfc7 | |||
2a8849dd11 | |||
5a879ece3c | |||
ffd734cc2d | |||
ac414489ff | |||
10da0cab47 | |||
59f8119047 | |||
a28a1531d9 | |||
977d0dc761 | |||
a0de1b38ce | |||
401f390e5b | |||
e69febe0c0 | |||
eba4ddbbd8 | |||
49bb276c78 | |||
b48bccb706 | |||
a2160fc8ce | |||
af829e9ac9 | |||
2c5473da27 | |||
e248a58201 | |||
d85ab889b3 | |||
49eae307cd | |||
a2563f0b7d | |||
c8d2866ada | |||
2f8d89012b | |||
a3d9016cfe | |||
24c14ea8f6 | |||
c93e787393 | |||
b6cc3ee022 | |||
30e4112f79 | |||
067e148897 | |||
7fc6f7a7ae | |||
60fcb5fe10 | |||
627ccd024c | |||
9fa1460400 | |||
c780e9b9f5 | |||
c2f15a569f | |||
8311eaba1d | |||
22aa48e343 | |||
87cd37c467 | |||
a78a884c6e | |||
0bfd73bcc6 | |||
113bb35a2b | |||
9b27f25f6c | |||
e09acedf67 | |||
a80449333c | |||
64f71d4265 | |||
fb44700dd5 | |||
7d61be231e | |||
f935922241 | |||
d5bb7679ca | |||
b2a00737d3 | |||
3cd3e08ede | |||
14fe55e5c4 | |||
8cf0f96401 | |||
7da612c083 | |||
afcc3fd0ce | |||
ab9e163105 | |||
63f9c815e0 | |||
6b7e5b6bc3 | |||
2ec22f07d5 | |||
d4f03b6b9e | |||
931e002b12 | |||
247f9b0c9d | |||
0a72791022 | |||
4fbb5f2023 | |||
7589b204fe | |||
aa43abb544 | |||
85df0c2c6d | |||
9bcfa58ec0 | |||
11b2c6651d | |||
6d57872e85 | |||
8f98eff9b5 | |||
e18a34c987 | |||
19f1b4b14e | |||
497f747d69 | |||
5e2debc695 | |||
3ce571daab | |||
1165477360 | |||
05a223d5f0 | |||
0b21c04d82 | |||
21454b9168 | |||
699a3c8bf6 | |||
f9fc0cda95 | |||
9249932a25 | |||
4f6e60683b | |||
ad3cf3fab8 | |||
a1b4ef10db | |||
10fd863408 | |||
908ca9feda | |||
4c4987ee7c | |||
bbe23b911a | |||
8d21425ae0 | |||
78517164d4 | |||
3ad694d4de | |||
824076f730 | |||
5a7d452429 | |||
88fa63d848 | |||
a26a813ad5 | |||
385cb85309 | |||
d4f5a1fff4 | |||
771322aa97 | |||
f1177469c2 | |||
b122957ec0 | |||
ec421e62ff | |||
e9f7f9d091 | |||
cf39095351 | |||
94b618626d | |||
1e50427d62 | |||
73dfb7b91b | |||
88aaa18a24 | |||
3909e50d49 | |||
8683cf88fe | |||
d5376b80c2 | |||
3ec5aa84ad | |||
c80c399e6b | |||
9001e254cc | |||
754b5be768 | |||
064f3b2041 | |||
c6874194a0 | |||
43092ce81a | |||
1abf0bb0d7 | |||
1eb01ca9ff | |||
324b1fed5d | |||
ac238ff2b0 | |||
1d4cf2a446 | |||
89ddfa18b7 | |||
7b60ab50b1 | |||
250d1d66dd | |||
08cd62d924 | |||
4f7a1c784f | |||
8d0d17910c | |||
34e1621b84 | |||
93d90d0ba6 | |||
d5f3c789df | |||
652643f189 | |||
5cfb289736 | |||
570fbd5632 | |||
88efe4a523 | |||
a1db79ff47 | |||
bac159a42c | |||
54b129630e | |||
fdead071e7 | |||
0cfa8de0ad | |||
9adfa2200a | |||
f5ab145906 | |||
3eeba99152 | |||
58dfd3c25c | |||
f97d33ffc1 | |||
75212e643c | |||
22a0c9f401 | |||
7772550438 | |||
a887844a37 | |||
b61f442a15 | |||
0e20a26d6c | |||
b629af8dee | |||
f0ffb3fc10 | |||
96f0e6df2a | |||
fb4a7d2ba3 | |||
0b0321474d | |||
a633423b72 | |||
f5781e8ee7 | |||
2c318cf64f | |||
be330886da | |||
73a39bedf5 | |||
d04950cbc9 | |||
b4d924adfa | |||
3f1316183d | |||
b17724fdda | |||
41c2685dc4 | |||
b450e3db65 | |||
352d2a7bc8 | |||
47f2bc9cd7 | |||
2db0e8f68a | |||
f7d733b407 | |||
4b78ef52e0 | |||
f42e6764b7 | |||
8f627aa382 | |||
9c6e3da304 | |||
319927e1dc | |||
4909928d3a | |||
423d031210 | |||
ab5269ddaf | |||
96a6e81235 | |||
6d8b0e0539 | |||
f09ea971cf | |||
8030bf42ff | |||
008fa2b0c4 | |||
9040704659 | |||
7e793cabe8 | |||
f1a0887e9b | |||
f6bdd92f9e | |||
a0367066b4 | |||
00651c0c3c | |||
a7a3ec711b | |||
de5bc82382 | |||
138208bf82 | |||
0796b3dedf | |||
662a76bbb6 | |||
a664195625 | |||
e533e127bf | |||
346e3df009 | |||
19ba0873f5 | |||
fb4acc62c4 | |||
fd538e95ca | |||
def2d8b75b | |||
586b28af1c | |||
585c279d10 | |||
51bc65e671 | |||
ff1758cdce | |||
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 | |||
e06ef01f72 | |||
7de54a2cc4 | |||
c788b8cc82 | |||
cb8db1a541 | |||
8a7a551c3b | |||
84d180b810 | |||
de261a27ca | |||
28288a8f7b | |||
cd8548fc32 | |||
37dbd49589 | |||
5af8d67b62 | |||
72e6309c4a | |||
18f0f3cce1 | |||
8c3e9ff192 | |||
21d6d27435 | |||
39ff7913d6 |
@ -168,6 +168,69 @@
|
||||
"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,
|
||||
|
25
.github/release.yaml
vendored
25
.github/release.yaml
vendored
@ -1,25 +0,0 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
authors:
|
||||
- octocat
|
||||
categories:
|
||||
- title: Breaking Changes 🛠
|
||||
labels:
|
||||
- breaking
|
||||
- title: Exciting New Features 🎉
|
||||
labels:
|
||||
- feature
|
||||
- title: Fixes 🔧
|
||||
labels:
|
||||
- fix
|
||||
- title: Documentation 🔧
|
||||
labels:
|
||||
- docs
|
||||
- title: CI 👷
|
||||
labels:
|
||||
- ci
|
||||
- title: Other Changes
|
||||
labels:
|
||||
- '*'
|
40
.github/workflows/ci.yaml
vendored
40
.github/workflows/ci.yaml
vendored
@ -3,7 +3,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- v*
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@ -14,11 +13,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !github.event.created && github.repository != 'garronej/ts-ci' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: bahmutov/npm-install@v1
|
||||
- name: If this step fails run 'yarn format' then commit again.
|
||||
run: yarn format:check
|
||||
- name: If this step fails run 'npm run format' then commit again.
|
||||
run: npm run _format --list-different
|
||||
test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: test_lint
|
||||
@ -28,30 +27,29 @@ jobs:
|
||||
os: [ ubuntu-latest ]
|
||||
name: Test with Node v${{ matrix.node }} on ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- uses: bahmutov/npm-install@v1
|
||||
- run: yarn build
|
||||
- run: yarn test
|
||||
#- run: yarn test:keycloakify-starter
|
||||
- run: npm run build
|
||||
- run: npm run test
|
||||
|
||||
storybook:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
- uses: bahmutov/npm-install@v1
|
||||
- run: yarn build-storybook -o ./build_storybook
|
||||
- run: npm run build-storybook
|
||||
- run: git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${{github.repository}}.git
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: npx -y -p gh-pages@3.1.0 gh-pages -d ./build_storybook -u "github-actions-bot <actions@github.com>"
|
||||
- run: npx -y -p gh-pages@3.1.0 gh-pages -d ./storybook-static -u "github-actions-bot <actions@github.com>"
|
||||
|
||||
check_if_version_upgraded:
|
||||
name: Check if version upgrade
|
||||
@ -71,7 +69,7 @@ jobs:
|
||||
is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }}
|
||||
is_pre_release: ${{steps.step1.outputs.is_pre_release }}
|
||||
steps:
|
||||
- uses: garronej/ts-ci@v2.1.0
|
||||
- uses: garronej/ts-ci@v2.1.2
|
||||
id: step1
|
||||
with:
|
||||
action_name: is_package_json_version_upgraded
|
||||
@ -89,7 +87,7 @@ jobs:
|
||||
needs:
|
||||
- check_if_version_upgraded
|
||||
steps:
|
||||
- uses: softprops/action-gh-release@v1
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: Release v${{ needs.check_if_version_upgraded.outputs.to_version }}
|
||||
tag_name: v${{ needs.check_if_version_upgraded.outputs.to_version }}
|
||||
@ -106,18 +104,18 @@ jobs:
|
||||
- create_github_release
|
||||
- check_if_version_upgraded
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
registry-url: https://registry.npmjs.org/
|
||||
- uses: bahmutov/npm-install@v1
|
||||
- run: yarn build
|
||||
- run: npx -y -p denoify@1.3.0 enable_short_npm_import_path
|
||||
- run: npm run build
|
||||
- run: npx -y -p denoify@1.6.12 enable_short_npm_import_path
|
||||
env:
|
||||
DRY_RUN: "0"
|
||||
- uses: garronej/ts-ci@v2.1.0
|
||||
- uses: garronej/ts-ci@v2.1.2
|
||||
with:
|
||||
action_name: remove_dark_mode_specific_images_from_readme
|
||||
- name: Publishing on NPM
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -48,8 +48,8 @@ jspm_packages
|
||||
|
||||
.idea
|
||||
|
||||
/src/login/i18n/baseMessages/
|
||||
/src/account/i18n/baseMessages/
|
||||
/src/login/i18n/messages_defaultSet/
|
||||
/src/account/i18n/messages_defaultSet/
|
||||
|
||||
# VS Code devcontainers
|
||||
.devcontainer
|
||||
|
@ -6,8 +6,8 @@ node_modules/
|
||||
/src/tools/types/
|
||||
/build_keycloak/
|
||||
/.vscode/
|
||||
/src/login/i18n/baseMessages/
|
||||
/src/account/i18n/baseMessages/
|
||||
/src/login/i18n/messages_defaultSet/
|
||||
/src/account/i18n/messages_defaultSet/
|
||||
/dist_test
|
||||
/sample_react_project/
|
||||
/sample_custom_react_project/
|
||||
|
@ -1,11 +1,24 @@
|
||||
{
|
||||
"printWidth": 150,
|
||||
"printWidth": 90,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "preserve",
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
"arrowParens": "avoid",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.tsx",
|
||||
"options": {
|
||||
"printWidth": 150
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "useUserProfileForm.tsx",
|
||||
"options": {
|
||||
"printWidth": 150
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -34,7 +34,6 @@ export function DocsContainer({ children, context }) {
|
||||
.docblock-argstable-head th:nth-child(3), .docblock-argstable-body tr > td:nth-child(2) p {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
`}</style>
|
||||
<BaseContainer
|
||||
context={{
|
||||
@ -64,11 +63,6 @@ export function CanvasContainer({ children }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
`}</style>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
17
.storybook/preview-head.html
Normal file
17
.storybook/preview-head.html
Normal file
@ -0,0 +1,17 @@
|
||||
<style>
|
||||
body.sb-show-main.sb-main-padded {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body:not(.kcBodyClass) {
|
||||
background-color: #393939;
|
||||
}
|
||||
|
||||
body.sb-show-preparing-docs > .sb-wrapper {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
body .sb-preparing-story {
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
@ -116,10 +116,45 @@ const { getHardCodedWeight } = (() => {
|
||||
const orderedPagesPrefix = [
|
||||
"Introduction",
|
||||
"login/login.ftl",
|
||||
"login/register-user-profile.ftl",
|
||||
"login/register.ftl",
|
||||
"login/terms.ftl",
|
||||
"login/error.ftl",
|
||||
"login/code.ftl",
|
||||
"login/delete-account-confirm.ftl",
|
||||
"login/delete-credential.ftl",
|
||||
"login/frontchannel-logout.ftl",
|
||||
"login/idp-review-user-profile.ftl",
|
||||
"login/info.ftl",
|
||||
"login/login-config-totp.ftl",
|
||||
"login/login-idp-link-confirm.ftl",
|
||||
"login/login-idp-link-email.ftl",
|
||||
"login/login-oauth-grant.ftl",
|
||||
"login/login-otp.ftl",
|
||||
"login/login-page-expired.ftl",
|
||||
"login/login-password.ftl",
|
||||
"login/login-reset-otp.ftl",
|
||||
"login/login-reset-password.ftl",
|
||||
"login/login-update-password.ftl",
|
||||
"login/login-update-profile.ftl",
|
||||
"login/login-username.ftl",
|
||||
"login/login-verify-email.ftl",
|
||||
"login/login-x509-info.ftl",
|
||||
"login/logout-confirm.ftl",
|
||||
"login/saml-post-form.ftl",
|
||||
"login/select-authenticator.ftl",
|
||||
"login/update-email.ftl",
|
||||
"login/webauthn-authenticate.ftl",
|
||||
"login/webauthn-error.ftl",
|
||||
"login/webauthn-register.ftl",
|
||||
"login/login-oauth2-device-verify-user-code.ftl",
|
||||
"login/login-recovery-authn-code-config.ftl",
|
||||
"login/login-recovery-authn-code-input.ftl",
|
||||
"account/account.ftl",
|
||||
"account/password.ftl",
|
||||
"account/federatedIdentity.ftl",
|
||||
"account/log.ftl",
|
||||
"account/sessions.ftl",
|
||||
"account/totp.ftl",
|
||||
];
|
||||
|
||||
function getHardCodedWeight(kind) {
|
||||
|
49
.storybook/static/terms/en.md
Normal file
49
.storybook/static/terms/en.md
Normal file
@ -0,0 +1,49 @@
|
||||
## Overview
|
||||
|
||||
This Terms of Service document outlines the rules and regulations for the use of **Example Company's** Services.
|
||||
|
||||
## Acceptance of Terms
|
||||
|
||||
By accessing and using our services, you acknowledge that you have read, understood, and agree to be bound by these terms. If you do not accept these terms, you are not authorized to use our services.
|
||||
|
||||
## Description of Service
|
||||
|
||||
**Example Service** (hereinafter referred to as "the Service") is a web-based solution offered by **Example Company** (hereinafter referred to as "the Company"). Our service provides users with access to [documentation](https://example.com/docs) and support for managing their projects effectively.
|
||||
|
||||
## Modifications to the Terms of Service
|
||||
|
||||
The Company reserves the right to modify these terms at any time. Such modifications will be effective immediately upon posting the updated terms on our website. Your continued use of the Service after any such changes shall constitute your consent to such changes.
|
||||
|
||||
## Account Registration
|
||||
|
||||
You may be required to register with the Service to access certain features. When registering, you agree to provide accurate, current, and complete information about yourself as requested.
|
||||
|
||||
## User Responsibilities
|
||||
|
||||
- **Data Security**: Users are responsible for safeguarding their login credentials and should not disclose their passwords to any third party.
|
||||
- **Acceptable Use**: Users are expected to use the Service in a responsible manner that does not infringe upon the rights of others.
|
||||
- **Content Ownership**: Users retain all rights to the content they upload to the Service but grant the Company a license to use and distribute this content as part of the Service.
|
||||
|
||||
## Intellectual Property
|
||||
|
||||
All intellectual property rights related to the Service and its original content, features, and functionality are owned by the Company.
|
||||
|
||||
## Termination
|
||||
|
||||
The Company may terminate or suspend access to our Service immediately, without prior notice or liability, for any reason whatsoever, including, without limitation, breach of these Terms.
|
||||
|
||||
## Governing Law
|
||||
|
||||
These Terms shall be governed and construed in accordance with the laws of [Your Country], without regard to its conflict of law provisions.
|
||||
|
||||
## Contact Information
|
||||
|
||||
For any questions about these Terms, please contact us at [support@example.com](mailto:support@example.com) or visit our [FAQ page](https://example.com/faq).
|
||||
|
||||
## Changes to Terms of Service
|
||||
|
||||
We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is material, we will provide at least 30 days' notice prior to any new terms taking effect.
|
||||
|
||||
## Effective Date
|
||||
|
||||
These terms are effective as of **[Insert Date]**.
|
49
.storybook/static/terms/es.md
Normal file
49
.storybook/static/terms/es.md
Normal file
@ -0,0 +1,49 @@
|
||||
## Resumen
|
||||
|
||||
Este documento de Términos de Servicio detalla las reglas y regulaciones para el uso de los servicios de **Empresa Ejemplo**.
|
||||
|
||||
## Aceptación de Términos
|
||||
|
||||
Al acceder y utilizar nuestros servicios, usted reconoce que ha leído, entendido y acepta estar vinculado por estos términos. Si no acepta estos términos, no está autorizado para usar nuestros servicios.
|
||||
|
||||
## Descripción del Servicio
|
||||
|
||||
**Servicio Ejemplo** (en adelante denominado "el Servicio") es una solución basada en la web ofrecida por **Empresa Ejemplo** (en adelante denominada "la Empresa"). Nuestro servicio proporciona a los usuarios acceso a [documentación](https://ejemplo.com/docs) y soporte para gestionar sus proyectos de manera efectiva.
|
||||
|
||||
## Modificaciones a los Términos de Servicio
|
||||
|
||||
La Empresa se reserva el derecho de modificar estos términos en cualquier momento. Dichas modificaciones entrarán en vigor inmediatamente después de la publicación de los términos actualizados en nuestro sitio web. Su uso continuado del Servicio después de tales cambios constituirá su consentimiento a dichos cambios.
|
||||
|
||||
## Registro de Cuenta
|
||||
|
||||
Puede ser necesario que se registre en el Servicio para acceder a ciertas características. Al registrarse, usted acepta proporcionar información precisa, actual y completa sobre sí mismo como se solicita.
|
||||
|
||||
## Responsabilidades del Usuario
|
||||
|
||||
- **Seguridad de Datos**: Los usuarios son responsables de salvaguardar sus credenciales de inicio de sesión y no deben divulgar sus contraseñas a terceros.
|
||||
- **Uso Aceptable**: Se espera que los usuarios utilicen el Servicio de manera responsable que no infrinja los derechos de otros.
|
||||
- **Propiedad del Contenido**: Los usuarios retienen todos los derechos sobre el contenido que cargan en el Servicio, pero otorgan a la Empresa una licencia para usar y distribuir este contenido como parte del Servicio.
|
||||
|
||||
## Propiedad Intelectual
|
||||
|
||||
Todos los derechos de propiedad intelectual relacionados con el Servicio y su contenido original, características y funcionalidad son propiedad de la Empresa.
|
||||
|
||||
## Terminación
|
||||
|
||||
La Empresa puede terminar o suspender su acceso a nuestro Servicio de inmediato, sin previo aviso ni responsabilidad, por cualquier motivo, incluido, entre otros, una violación de estos Términos.
|
||||
|
||||
## Ley Aplicable
|
||||
|
||||
Estos Términos se regirán e interpretarán de acuerdo con las leyes de [Su País], sin tener en cuenta sus disposiciones de conflicto de leyes.
|
||||
|
||||
## Información de Contacto
|
||||
|
||||
Para cualquier pregunta sobre estos Términos, contáctenos en [support@ejemplo.com](mailto:support@ejemplo.com) o visite nuestra [página de FAQ](https://ejemplo.com/faq).
|
||||
|
||||
## Cambios a los Términos de Servicio
|
||||
|
||||
Nos reservamos el derecho, a nuestra única discreción, de modificar o reemplazar estos Términos en cualquier momento. Si una revisión es material, proporcionaremos al menos 30 días de aviso antes de que los nuevos términos entren en vigor.
|
||||
|
||||
## Fecha de Efectividad
|
||||
|
||||
Estos términos son efectivos a partir del **[Insertar Fecha]**.
|
49
.storybook/static/terms/fr.md
Normal file
49
.storybook/static/terms/fr.md
Normal file
@ -0,0 +1,49 @@
|
||||
## Vue d'ensemble
|
||||
|
||||
Ce document des Conditions Générales d'Utilisation détaille les règles et réglementations pour l'utilisation des services de **l'Entreprise Exemple**.
|
||||
|
||||
## Acceptation des Conditions
|
||||
|
||||
En accédant et en utilisant nos services, vous reconnaissez avoir lu, compris et accepté d'être lié par ces conditions. Si vous n'acceptez pas ces termes, vous n'êtes pas autorisé à utiliser nos services.
|
||||
|
||||
## Description du Service
|
||||
|
||||
**Service Exemple** (ci-après dénommé "le Service") est une solution basée sur le web offerte par **l'Entreprise Exemple** (ci-après dénommée "l'Entreprise"). Notre service offre aux utilisateurs un accès à la [documentation](https://exemple.com/docs) et un support pour gérer efficacement leurs projets.
|
||||
|
||||
## Modifications des Conditions de Service
|
||||
|
||||
L'Entreprise se réserve le droit de modifier ces conditions à tout moment. De telles modifications entreront en vigueur immédiatement après la publication des termes mis à jour sur notre site web. Votre utilisation continue du Service après de tels changements constitue votre consentement à ces modifications.
|
||||
|
||||
## Inscription au Compte
|
||||
|
||||
Vous devrez peut-être vous inscrire au Service pour accéder à certaines fonctionnalités. Lors de l'inscription, vous acceptez de fournir des informations précises, actuelles et complètes vous concernant, comme demandé.
|
||||
|
||||
## Responsabilités des Utilisateurs
|
||||
|
||||
- **Sécurité des Données** : Les utilisateurs sont responsables de la sauvegarde de leurs identifiants de connexion et ne doivent divulguer leurs mots de passe à aucun tiers.
|
||||
- **Utilisation Acceptable** : Les utilisateurs sont censés utiliser le Service de manière responsable qui ne porte pas atteinte aux droits d'autrui.
|
||||
- **Propriété du Contenu** : Les utilisateurs conservent tous les droits sur le contenu qu'ils téléchargent sur le Service mais accordent à l'Entreprise une licence pour utiliser et distribuer ce contenu dans le cadre du Service.
|
||||
|
||||
## Propriété Intellectuelle
|
||||
|
||||
Tous les droits de propriété intellectuelle relatifs au Service et à son contenu original, fonctionnalités et fonctionnement sont détenus par l'Entreprise.
|
||||
|
||||
## Résiliation
|
||||
|
||||
L'Entreprise peut résilier ou suspendre votre accès à notre Service immédiatement, sans préavis ni responsabilité, pour quelque raison que ce soit, y compris, sans limitation, en cas de violation de ces Conditions.
|
||||
|
||||
## Loi Applicable
|
||||
|
||||
Ces Conditions seront régies et interprétées conformément aux lois de [Votre Pays], sans égard à ses dispositions de conflit de lois.
|
||||
|
||||
## Informations de Contact
|
||||
|
||||
Pour toute question concernant ces Conditions, veuillez nous contacter à [support@exemple.com](mailto:support@exemple.com) ou visitez notre [page FAQ](https://exemple.com/faq).
|
||||
|
||||
## Modifications des Conditions de Service
|
||||
|
||||
Nous nous réservons le droit, à notre seule discrétion, de modifier ou de remplacer ces Conditions à tout moment. Si une révision est importante, nous vous fournirons un préavis d'au moins 30 jours avant que les nouveaux termes prennent effet.
|
||||
|
||||
## Date d'Effet
|
||||
|
||||
Ces conditions sont effectives à partir du **[Insérer la Date]**.
|
334
README.md
334
README.md
@ -2,7 +2,7 @@
|
||||
<img src="https://user-images.githubusercontent.com/6702424/109387840-eba11f80-7903-11eb-9050-db1dad883f78.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<i>🔏 Create Keycloak themes using React 🔏</i>
|
||||
<i>🔏 Keycloak Theming for the Modern Web 🔏</i>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://github.com/garronej/keycloakify/actions">
|
||||
@ -14,15 +14,15 @@
|
||||
<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/kYFZG7fQmn">
|
||||
<img src="https://img.shields.io/discord/1097708346976505977"/>
|
||||
</a>
|
||||
<p align="center">
|
||||
Check out our discord server!<br/>
|
||||
<a href="https://discord.gg/mJdYJSdcm4">
|
||||
<img src="https://dcbadge.limes.pink/api/server/kYFZG7fQmn"/>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://www.keycloakify.dev">Home</a>
|
||||
-
|
||||
@ -36,34 +36,40 @@
|
||||
|
||||
<p align="center">
|
||||
<i>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">
|
||||
<br/>
|
||||
<br/>
|
||||
<img width="400" src="https://github.com/user-attachments/assets/6bf3bef9-00b0-4460-97b9-0d2da8500798">
|
||||
</p>
|
||||
|
||||
> Whether or not React is your preferred framework, Keycloakify
|
||||
> offers a solid option for building Keycloak themes.
|
||||
> It's not just a convenient way to create a Keycloak theme
|
||||
> when using React; it's a well-regarded solution that many
|
||||
> developers appreciate.
|
||||
Keycloakify is fully compatible with Keycloak 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, [~~22~~](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1822509763), 23, 24, 25...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
|
||||
|
||||
> 📣 🛑 Account themes generated by Keycloakify are not currently compatible with Keycloak 22.
|
||||
> We are working on a solution. [Follow progress](https://github.com/keycloakify/keycloakify/issues/389).
|
||||
> Login and email themes are not affected.
|
||||
> UPDATE: [The PR](https://github.com/keycloak/keycloak/pull/22317) that should future proof Keycloakify account themes has been greenlighted
|
||||
> by the Keycloak team. Resolution is only a matter of time.
|
||||
> NOTE: Keycloakify 10 is still in release-candidate state. [Follow progress](https://github.com/keycloakify/keycloakify/pull/538).
|
||||
|
||||
Keycloakify is fully compatible with Keycloak, starting from version 11 and is anticipated to maintain compatibility with all future versions.
|
||||
You can update your Keycloak, your Keycloakify generated theme won't break.
|
||||
To understand the basis of my confidence in this, you can [visit this discussion thread where I've explained in detail](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791).
|
||||
## Sponsors
|
||||
|
||||
## Sponsor 👼
|
||||
Friends for the project, we trust and recommend their services.
|
||||
|
||||
We are exclusively sponsored by [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), a French company offering Keycloak as a service.
|
||||
Their dedicated support helps us continue the development and maintenance of this project.
|
||||
<br/>
|
||||
|
||||
[Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github) provides the following services:
|
||||
<div align="center">
|
||||
|
||||
- Simplify and secure your Keycloak Identity and Access Management. Keycloak as a Service.
|
||||
- Custom theme building for your brand using Keycloakify.
|
||||

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

|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.zone2.tech/services/keycloak-consulting">
|
||||
<i><strong>Keycloak Consulting Services</strong> - Your partner in Keycloak deployment, configuration, and extension development for optimized identity management solutions.</i>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
@ -78,13 +84,11 @@ Their dedicated support helps us continue the development and maintenance of thi
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<i>Checkout <a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github">Cloud IAM</a> and use promo code <code>keycloakify5</code></i>
|
||||
<br/>
|
||||
<i>5% of your annual subscription will be donated to us, and you'll get 5% off too.</i>
|
||||
<a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github"><strong>Managed Keycloak Provider</strong> - With Cloud-IAM powering your Keycloak clusters, you can sleep easy knowing you've got the software and the experts you need for operational excellence. </a>
|
||||
<br/>
|
||||
Use code <code>keycloakify5</code> at checkout for a 5% discount.
|
||||
</p>
|
||||
|
||||
Thank you, [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), for your support!
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
@ -117,6 +121,16 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<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>
|
||||
@ -125,259 +139,3 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
# Changelog highlights
|
||||
|
||||
## 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.
|
||||
|
||||
- The [`--external-assets` build option has been removed](https://docs.keycloakify.dev/v/v7/build-options#external-assets-deprecated) it was a performance optimization that is no longer relevant now that
|
||||
we have lazy loading.
|
||||
|
||||
- `kcContext.usernameEditDisabled` is now `kcContext.usernameHidden`, the type was lying, it has been updated to reflect what's actually on the `kcContext` at runtime.
|
||||
If you want to see in detail what should be updated [see issue](https://github.com/keycloakify/keycloakify/pull/399), or you can search and replace `usernameEditDisabled` -> `usernameHidden` it'll do the trick.
|
||||
|
||||
- The `usePrepareTemplate` prototype has been changed, you can search and replace:
|
||||
|
||||
`src/keycloak-theme/login/Template.tsx`
|
||||
|
||||
```ts
|
||||
url,
|
||||
"stylesCommon": [
|
||||
"node_modules/patternfly/dist/css/patternfly.min.css",
|
||||
"node_modules/patternfly/dist/css/patternfly-additions.min.css",
|
||||
"lib/zocial/zocial.css"
|
||||
],
|
||||
"styles": ["css/login.css"],
|
||||
```
|
||||
|
||||
by
|
||||
|
||||
```ts
|
||||
"styles": [
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
|
||||
`${url.resourcesCommonPath}/lib/zocial/zocial.css`,
|
||||
`${url.resourcesPath}/css/login.css`
|
||||
],
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
`src/keycloak-theme/account/Template.css`
|
||||
|
||||
```ts
|
||||
url,
|
||||
"stylesCommon": ["node_modules/patternfly/dist/css/patternfly.min.css", "node_modules/patternfly/dist/css/patternfly-additions.min.css"],
|
||||
"styles": ["css/account.css"],
|
||||
```
|
||||
|
||||
by
|
||||
|
||||
```ts
|
||||
"styles": [
|
||||
`${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`
|
||||
],
|
||||
```
|
||||
|
||||
## 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.extrathemenames).
|
||||
|
||||
## 7.9
|
||||
|
||||
- Separate script for copying the default theme static assets to the public directory.
|
||||
Theses assets are only needed for testing your theme locally in Storybook or with a `mockPageId`.
|
||||
You are now expected to have a `"prepare": "copy-keycloak-resources-to-public",` in your package.json scripts.
|
||||
This script will create `public/keycloak-assets` when you run `yarn install` (If you are using another package manager
|
||||
like `pnpm` makes sure that `"prepare"` is actually ran.)
|
||||
[See the updated starter](https://github.com/keycloakify/keycloakify-starter/blob/94532fcf10bf8b19e0873be8575fd28a8958a806/package.json#L11). `public/keycloak-assets` shouldn't be tracked by GIT and is automatically ignored.
|
||||
|
||||
## 7.7
|
||||
|
||||
- Better storybook support, see [the starter project](https://github.com/keycloakify/keycloakify-starter).
|
||||
|
||||
## 7.0 🍾
|
||||
|
||||
- Account theme support 🚀
|
||||
- It's much easier to customize pages at the CSS level, you can now see in the browser dev tool the customizable classes.
|
||||
- New interactive CLI tool `npx eject-keycloak-page`, that enables to select the page you want to customize at the component level.
|
||||
- There is [a Storybook](https://storybook.keycloakify.dev)
|
||||
- [Remember me is fixed](https://github.com/keycloakify/keycloakify/pull/272)
|
||||
|
||||
## 6.13
|
||||
|
||||
- Build work behind corporate proxies, [see issue](https://github.com/keycloakify/keycloakify/issues/257).
|
||||
|
||||
## 6.12
|
||||
|
||||
Massive improvement in the developer experience:
|
||||
|
||||
- There is now only one starter repo: https://github.com/codegouvfr/keycloakify-starter
|
||||
- A lot of comments have been added in the code of the starter to make it easier to get started.
|
||||
- The doc has been updated: https://docs.keycloakify.dev
|
||||
- A lot of improvements in the type system.
|
||||
|
||||
## 6.11.4
|
||||
|
||||
- You no longer need to have Maven installed to build the theme. Thanks to @lordvlad, [see PR](https://github.com/keycloakify/keycloakify/pull/239).
|
||||
- Feature new build options: [`bundler`](https://docs.keycloakify.dev/build-options#keycloakify.bundler), [`groupId`](https://docs.keycloakify.dev/build-options#keycloakify.groupid), [`artifactId`](https://docs.keycloakify.dev/build-options#keycloakify.artifactid), [`version`](https://docs.keycloakify.dev/build-options#version).
|
||||
Theses options can be user to customize the output name of the .jar. You can use environnement variables to overrides the values read in the package.json. Thanks to @lordvlad.
|
||||
|
||||
## 6.10.0
|
||||
|
||||
- Widows compat (thanks to @lordvlad, [see PR](https://github.com/keycloakify/keycloakify/pull/226)). WSL is no longer required 🎉
|
||||
|
||||
## 6.8.4
|
||||
|
||||
- `@emotion/react` is no longer a peer dependency of Keycloakify.
|
||||
|
||||
## 6.8.0
|
||||
|
||||
- It is now possible to pass a custom `<Template />` component as a prop to `<KcApp />` and every
|
||||
individual page (`<Login />`, `<RegisterUserProfile />`, ...) it enables to customize only the header and footer for
|
||||
example without having to switch to a full-component level customization. [See issue](https://github.com/keycloakify/keycloakify/issues/191).
|
||||
|
||||
## 6.7.0
|
||||
|
||||
- Add support for `webauthn-authenticate.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/185).
|
||||
|
||||
## 6.6.0
|
||||
|
||||
- Add support for `login-password.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/184).
|
||||
|
||||
## 6.5.0
|
||||
|
||||
- Add support for `login-username.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/183).
|
||||
|
||||
## 6.4.0
|
||||
|
||||
- You can now optionally pass a `doFetchDefaultThemeResources: boolean` prop to every page component and the default `<KcApp />`
|
||||
This enables you to prevent the default CSS and JS that comes with the builtin Keycloak theme to be downloaded.
|
||||
You'll get [a black slate](https://user-images.githubusercontent.com/6702424/192619083-4baa5df4-4a21-4ec7-8e28-d200d1208299.png).
|
||||
|
||||
## 6.0.0
|
||||
|
||||
- Bundle size drastically reduced, locals and component dynamically loaded.
|
||||
- First print much quicker, use of React.lazy() everywhere.
|
||||
- Real i18n API.
|
||||
- Actual documentation for build options.
|
||||
|
||||
Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6)
|
||||
|
||||
## 5.8.0
|
||||
|
||||
- [React.lazy()](https://reactjs.org/docs/code-splitting.html#reactlazy) support 🎉. [#141](https://github.com/keycloakify/keycloakify/issues/141)
|
||||
|
||||
## 5.7.0
|
||||
|
||||
- Feat `logout-confirm.ftl`. [PR](https://github.com/keycloakify/keycloakify/pull/120)
|
||||
|
||||
## 5.6.4
|
||||
|
||||
Fix `login-verify-email.ftl` page. [Before](https://user-images.githubusercontent.com/6702424/177436014-0bad22c4-5bfb-45bb-8fc9-dad65143cd0c.png) - [After](https://user-images.githubusercontent.com/6702424/177435797-ec5d7db3-84cf-49cb-8efc-3427a81f744e.png)
|
||||
|
||||
## v5.6.0
|
||||
|
||||
Add support for `login-config-totp.ftl` page [#127](https://github.com/keycloakify/keycloakify/pull/127).
|
||||
|
||||
## v5.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
|
||||
|
||||
[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
|
||||
|
||||
Add `login-idp-link-email.ftl` page [See PR](https://github.com/keycloakify/keycloakify/pull/92).
|
||||
|
||||
## v4.8.0
|
||||
|
||||
[Email template customization.](#email-template-customization)
|
||||
|
||||
## v4.7.4
|
||||
|
||||
**M1 Mac** support (for testing locally with a dockerized Keycloak).
|
||||
|
||||
## v4.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
|
||||
|
||||
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
|
||||
|
||||
`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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- Out of the box [frontend form validation](#user-profile-and-frontend-form-validation) 🥳
|
||||
- Improvements (and breaking changes in `import { useKcMessage } from "keycloakify"`.
|
||||
|
||||
## v3
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
- It's now possible to implement custom `.ftl` pages.
|
||||
- Support for Keycloak plugins that introduce non standard ftl values.
|
||||
(Like for example [this plugin](https://github.com/micedre/keycloak-mail-whitelisting) that define `authorizedMailDomains` in `register.ftl`).
|
||||
|
@ -1,73 +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"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"themeName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["name", "version"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
115
package.json
115
package.json
@ -1,39 +1,26 @@
|
||||
{
|
||||
"name": "keycloakify",
|
||||
"version": "8.1.2",
|
||||
"version": "10.0.0-rc.122",
|
||||
"description": "Create Keycloak themes using React",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/keycloakify/keycloakify.git"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"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/ && 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",
|
||||
"prepare": "tsx scripts/generate-i18n-messages.ts",
|
||||
"build": "tsx scripts/build.ts",
|
||||
"storybook": "tsx scripts/start-storybook.ts",
|
||||
"link-in-starter": "tsx scripts/link-in-starter.ts",
|
||||
"test": "yarn test:types && vitest run",
|
||||
"test:keycloakify-starter": "ts-node scripts/test-keycloakify-starter",
|
||||
"test:types": "tsc -p test/tsconfig.json --noEmit",
|
||||
"_format": "prettier '**/*.{ts,tsx,json,md}'",
|
||||
"format": "yarn _format --write",
|
||||
"format:check": "yarn _format --list-different",
|
||||
"generate-i18n-messages": "ts-node --skipProject scripts/generate-i18n-messages.ts",
|
||||
"link-in-app": "ts-node --skipProject scripts/link-in-app.ts",
|
||||
"link-in-starter": "yarn link-in-app keycloakify-starter",
|
||||
"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\")",
|
||||
"copy-keycloak-resources-to-storybook-static": "PUBLIC_DIR_PATH=.storybook/static node dist/bin/copy-keycloak-resources-to-public.js",
|
||||
"storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && start-storybook -p 6006",
|
||||
"build-storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && build-storybook"
|
||||
"link-in-app": "tsx scripts/link-in-app.ts",
|
||||
"build-storybook": "tsx scripts/build-storybook.ts",
|
||||
"dump-keycloak-realm": "tsx scripts/dump-keycloak-realm.ts"
|
||||
},
|
||||
"bin": {
|
||||
"copy-keycloak-resources-to-public": "dist/bin/copy-keycloak-resources-to-public.js",
|
||||
"download-builtin-keycloak-theme": "dist/bin/download-builtin-keycloak-theme.js",
|
||||
"eject-keycloak-page": "dist/bin/eject-keycloak-page.js",
|
||||
"initialize-email-theme": "dist/bin/initialize-email-theme.js",
|
||||
"keycloakify": "dist/bin/keycloakify/index.js"
|
||||
"keycloakify": "dist/bin/main.js"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,json,md}": [
|
||||
@ -48,28 +35,42 @@
|
||||
"author": "u/garronej",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"src/",
|
||||
"dist/",
|
||||
"!dist/tsconfig.tsbuildinfo",
|
||||
"!dist/bin/tsconfig.tsbuildinfo"
|
||||
"!dist/bin/",
|
||||
"dist/bin/main.js",
|
||||
"dist/bin/*.index.js",
|
||||
"dist/bin/*.node",
|
||||
"dist/bin/shared/constants.js",
|
||||
"dist/bin/shared/*.d.ts",
|
||||
"dist/bin/shared/*.js.map",
|
||||
"!dist/vite-plugin/",
|
||||
"dist/vite-plugin/index.js",
|
||||
"dist/vite-plugin/index.d.ts",
|
||||
"dist/vite-plugin/vite-plugin.d.ts"
|
||||
],
|
||||
"keywords": [
|
||||
"bluehats",
|
||||
"keycloak",
|
||||
"react",
|
||||
"theme",
|
||||
"FreeMarker",
|
||||
"ftl",
|
||||
"login",
|
||||
"register"
|
||||
"register",
|
||||
"account",
|
||||
"bluehats"
|
||||
],
|
||||
"homepage": "https://www.keycloakify.dev",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
"dependencies": {
|
||||
"tsafe": "^1.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@babel/core": "^7.24.5",
|
||||
"@babel/generator": "^7.24.5",
|
||||
"@babel/parser": "^7.24.5",
|
||||
"@babel/types": "^7.24.5",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@octokit/rest": "^20.1.1",
|
||||
"@storybook/addon-a11y": "^6.5.16",
|
||||
"@storybook/addon-actions": "^6.5.13",
|
||||
"@storybook/addon-essentials": "^6.5.13",
|
||||
@ -85,46 +86,34 @@
|
||||
"@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",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"chalk": "^4.1.2",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"chokidar-cli": "^3.0.0",
|
||||
"cli-select": "^1.1.2",
|
||||
"eslint-plugin-storybook": "^0.6.7",
|
||||
"husky": "^4.3.8",
|
||||
"lint-staged": "^11.0.0",
|
||||
"powerhooks": "^0.26.7",
|
||||
"prettier": "^2.3.0",
|
||||
"magic-string": "^0.30.7",
|
||||
"make-fetch-happen": "^11.0.3",
|
||||
"powerhooks": "^1.0.10",
|
||||
"prettier": "^3.2.5",
|
||||
"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",
|
||||
"tsc-alias": "^1.8.3",
|
||||
"tss-react": "^4.8.2",
|
||||
"typescript": "^4.9.1-beta",
|
||||
"vitest": "^0.29.8",
|
||||
"zod-to-json-schema": "^3.20.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.22.9",
|
||||
"@babel/parser": "^7.22.7",
|
||||
"@babel/types": "^7.22.5",
|
||||
"@octokit/rest": "^18.12.0",
|
||||
"cheerio": "^1.0.0-rc.5",
|
||||
"cli-select": "^1.1.2",
|
||||
"evt": "^2.4.18",
|
||||
"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",
|
||||
"run-exclusive": "^2.2.19",
|
||||
"storybook-dark-mode": "^1.1.2",
|
||||
"termost": "^v0.12.1",
|
||||
"tsc-alias": "^1.8.10",
|
||||
"tss-react": "^4.9.10",
|
||||
"typescript": "^4.9.1-beta",
|
||||
"vite": "^5.2.11",
|
||||
"vitest": "^1.6.0",
|
||||
"yauzl": "^2.10.0",
|
||||
"yazl": "^2.5.1",
|
||||
"zod": "^3.17.10"
|
||||
"zod": "^3.17.10",
|
||||
"evt": "^2.5.7",
|
||||
"tsx": "^4.15.5"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"baseBranches": ["main", "landingpage"],
|
||||
"baseBranches": ["main"],
|
||||
"extends": ["config:base"],
|
||||
"dependencyDashboard": false,
|
||||
"bumpVersion": "patch",
|
||||
|
16
scripts/build-storybook.ts
Normal file
16
scripts/build-storybook.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import * as child_process from "child_process";
|
||||
import { copyKeycloakResourcesToStorybookStaticDir } from "./copyKeycloakResourcesToStorybookStaticDir";
|
||||
|
||||
(async () => {
|
||||
run("yarn build");
|
||||
|
||||
await copyKeycloakResourcesToStorybookStaticDir();
|
||||
|
||||
run("npx build-storybook");
|
||||
})();
|
||||
|
||||
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
|
||||
console.log(`$ ${command}`);
|
||||
|
||||
child_process.execSync(command, { stdio: "inherit", ...options });
|
||||
}
|
176
scripts/build.ts
Normal file
176
scripts/build.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import * as child_process from "child_process";
|
||||
import * as fs from "fs";
|
||||
import { join } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { transformCodebase } from "../src/bin/tools/transformCodebase";
|
||||
import chalk from "chalk";
|
||||
|
||||
console.log(chalk.cyan("Building Keycloakify..."));
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
if (fs.existsSync(join("dist", "bin", "main.original.js"))) {
|
||||
fs.renameSync(
|
||||
join("dist", "bin", "main.original.js"),
|
||||
join("dist", "bin", "main.js")
|
||||
);
|
||||
|
||||
fs.readdirSync(join("dist", "bin")).forEach(fileBasename => {
|
||||
if (/[0-9]\.index.js/.test(fileBasename) || fileBasename.endsWith(".node")) {
|
||||
fs.rmSync(join("dist", "bin", fileBasename));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
run(`npx tsc -p ${join("src", "bin", "tsconfig.json")}`);
|
||||
|
||||
if (
|
||||
!fs
|
||||
.readFileSync(join("dist", "bin", "main.js"))
|
||||
.toString("utf8")
|
||||
.includes("__nccwpck_require__")
|
||||
) {
|
||||
fs.cpSync(join("dist", "bin", "main.js"), join("dist", "bin", "main.original.js"));
|
||||
}
|
||||
|
||||
run(`npx ncc build ${join("dist", "bin", "main.js")} -o ${join("dist", "ncc_out")}`);
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: join("dist", "ncc_out"),
|
||||
destDirPath: join("dist", "bin"),
|
||||
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
|
||||
if (fileRelativePath === "index.js") {
|
||||
return {
|
||||
newFileName: "main.js",
|
||||
modifiedSourceCode: sourceCode
|
||||
};
|
||||
}
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
});
|
||||
|
||||
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
|
||||
|
||||
{
|
||||
let hasBeenPatched = false;
|
||||
|
||||
fs.readdirSync(join("dist", "bin")).forEach(fileBasename => {
|
||||
if (fileBasename !== "main.js" && !fileBasename.endsWith(".index.js")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { hasBeenPatched: hasBeenPatched_i } = patchDeprecatedBufferApiUsage(
|
||||
join("dist", "bin", fileBasename)
|
||||
);
|
||||
|
||||
if (hasBeenPatched_i) {
|
||||
hasBeenPatched = true;
|
||||
}
|
||||
});
|
||||
|
||||
assert(hasBeenPatched);
|
||||
}
|
||||
|
||||
fs.chmodSync(
|
||||
join("dist", "bin", "main.js"),
|
||||
fs.statSync(join("dist", "bin", "main.js")).mode |
|
||||
fs.constants.S_IXUSR |
|
||||
fs.constants.S_IXGRP |
|
||||
fs.constants.S_IXOTH
|
||||
);
|
||||
|
||||
run(`npx tsc -p ${join("src", "tsconfig.json")}`);
|
||||
run(`npx tsc-alias -p ${join("src", "tsconfig.json")}`);
|
||||
|
||||
if (fs.existsSync(join("dist", "vite-plugin", "index.original.js"))) {
|
||||
fs.renameSync(
|
||||
join("dist", "vite-plugin", "index.original.js"),
|
||||
join("dist", "vite-plugin", "index.js")
|
||||
);
|
||||
}
|
||||
|
||||
run(`npx tsc -p ${join("src", "vite-plugin", "tsconfig.json")}`);
|
||||
|
||||
if (
|
||||
!fs
|
||||
.readFileSync(join("dist", "vite-plugin", "index.js"))
|
||||
.toString("utf8")
|
||||
.includes("__nccwpck_require__")
|
||||
) {
|
||||
fs.cpSync(
|
||||
join("dist", "vite-plugin", "index.js"),
|
||||
join("dist", "vite-plugin", "index.original.js")
|
||||
);
|
||||
}
|
||||
|
||||
run(
|
||||
`npx ncc build ${join("dist", "vite-plugin", "index.js")} -o ${join(
|
||||
"dist",
|
||||
"ncc_out"
|
||||
)}`
|
||||
);
|
||||
|
||||
fs.readdirSync(join("dist", "ncc_out")).forEach(fileBasename => {
|
||||
assert(!fileBasename.endsWith(".index.js"));
|
||||
assert(!fileBasename.endsWith(".node"));
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: join("dist", "ncc_out"),
|
||||
destDirPath: join("dist", "vite-plugin"),
|
||||
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
|
||||
assert(fileRelativePath === "index.js");
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
});
|
||||
|
||||
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
|
||||
|
||||
{
|
||||
const { hasBeenPatched } = patchDeprecatedBufferApiUsage(
|
||||
join("dist", "vite-plugin", "index.js")
|
||||
);
|
||||
|
||||
assert(hasBeenPatched);
|
||||
}
|
||||
|
||||
fs.rmSync(join("dist", "src"), { recursive: true, force: true });
|
||||
|
||||
fs.cpSync("src", join("dist", "src"), { recursive: true });
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: join("stories"),
|
||||
destDirPath: join("dist", "stories"),
|
||||
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
|
||||
if (!fileRelativePath.endsWith(".stories.tsx")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
});
|
||||
|
||||
console.log(chalk.green(`✓ built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`));
|
||||
|
||||
function run(command: string) {
|
||||
console.log(chalk.grey(`$ ${command}`));
|
||||
|
||||
child_process.execSync(command, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
function patchDeprecatedBufferApiUsage(filePath: string) {
|
||||
const before = fs.readFileSync(filePath).toString("utf8");
|
||||
|
||||
const after = before.replace(
|
||||
`var buffer = new Buffer(toRead);`,
|
||||
`var buffer = Buffer.allocUnsafe ? Buffer.allocUnsafe(toRead) : new Buffer(toRead);`
|
||||
);
|
||||
|
||||
fs.writeFileSync(filePath, Buffer.from(after, "utf8"));
|
||||
|
||||
const hasBeenPatched = after !== before;
|
||||
|
||||
return { hasBeenPatched };
|
||||
}
|
18
scripts/copyKeycloakResourcesToStorybookStaticDir.ts
Normal file
18
scripts/copyKeycloakResourcesToStorybookStaticDir.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { join as pathJoin } from "path";
|
||||
import { copyKeycloakResourcesToPublic } from "../src/bin/shared/copyKeycloakResourcesToPublic";
|
||||
import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions";
|
||||
import { LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT } from "../src/bin/shared/constants";
|
||||
|
||||
export async function copyKeycloakResourcesToStorybookStaticDir() {
|
||||
await copyKeycloakResourcesToPublic({
|
||||
buildContext: {
|
||||
cacheDirPath: pathJoin(__dirname, "..", "node_modules", ".cache", "scripts"),
|
||||
fetchOptions: getProxyFetchOptions({
|
||||
npmConfigGetCwd: pathJoin(__dirname, "..")
|
||||
}),
|
||||
loginThemeResourcesFromKeycloakVersion:
|
||||
LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT,
|
||||
publicDirPath: pathJoin(__dirname, "..", ".storybook", "static")
|
||||
}
|
||||
});
|
||||
}
|
92
scripts/dump-keycloak-realm.ts
Normal file
92
scripts/dump-keycloak-realm.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { CONTAINER_NAME } from "../src/bin/shared/constants";
|
||||
import child_process from "child_process";
|
||||
import { SemVer } from "../src/bin/tools/SemVer";
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
import chalk from "chalk";
|
||||
import { Deferred } from "evt/tools/Deferred";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { is } from "tsafe/is";
|
||||
|
||||
(async () => {
|
||||
{
|
||||
const dCompleted = new Deferred<void>();
|
||||
|
||||
const child = child_process.spawn(
|
||||
"docker",
|
||||
[
|
||||
...["exec", CONTAINER_NAME],
|
||||
...["/opt/keycloak/bin/kc.sh", "export"],
|
||||
...["--dir", "/tmp"],
|
||||
...["--realm", "myrealm"],
|
||||
...["--users", "realm_file"]
|
||||
],
|
||||
{ shell: true }
|
||||
);
|
||||
|
||||
let output = "";
|
||||
|
||||
const onExit = (code: number | null) => {
|
||||
dCompleted.reject(new Error(`Exited with code ${code}`));
|
||||
};
|
||||
|
||||
child.on("exit", onExit);
|
||||
|
||||
child.stdout.on("data", data => {
|
||||
const outputStr = data.toString("utf8");
|
||||
|
||||
if (outputStr.includes("Export finished successfully")) {
|
||||
child.removeListener("exit", onExit);
|
||||
|
||||
child.kill();
|
||||
|
||||
dCompleted.resolve();
|
||||
}
|
||||
|
||||
output += outputStr;
|
||||
});
|
||||
|
||||
child.stderr.on("data", data => (output += chalk.red(data.toString("utf8"))));
|
||||
|
||||
try {
|
||||
await dCompleted.pr;
|
||||
} catch (error) {
|
||||
assert(is<Error>(error));
|
||||
|
||||
console.log(chalk.red(error.message));
|
||||
|
||||
console.log(output);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const keycloakMajorVersionNumber = SemVer.parse(
|
||||
child_process
|
||||
.execSync(`docker inspect --format '{{.Config.Image}}' ${CONTAINER_NAME}`)
|
||||
.toString("utf8")
|
||||
.trim()
|
||||
.split(":")[1]
|
||||
).major;
|
||||
|
||||
const targetFilePath = pathRelative(
|
||||
process.cwd(),
|
||||
pathJoin(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"bin",
|
||||
"start-keycloak",
|
||||
`myrealm-realm-${keycloakMajorVersionNumber}.json`
|
||||
)
|
||||
);
|
||||
|
||||
run(`docker cp ${CONTAINER_NAME}:/tmp/myrealm-realm.json ${targetFilePath}`);
|
||||
|
||||
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
|
||||
})();
|
||||
|
||||
function run(command: string) {
|
||||
console.log(chalk.grey(`$ ${command}`));
|
||||
|
||||
return child_process.execSync(command, { stdio: "inherit" });
|
||||
}
|
@ -1,10 +1,18 @@
|
||||
import "minimal-polyfills/Object.fromEntries";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, relative as pathRelative, dirname as pathDirname, sep as pathSep } from "path";
|
||||
import {
|
||||
join as pathJoin,
|
||||
relative as pathRelative,
|
||||
dirname as pathDirname,
|
||||
sep as pathSep
|
||||
} from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { same } from "evt/tools/inDepth";
|
||||
import { crawl } from "../src/bin/tools/crawl";
|
||||
import { downloadBuiltinKeycloakTheme } from "../src/bin/download-builtin-keycloak-theme";
|
||||
import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
|
||||
import { getLogger } from "../src/bin/tools/logger";
|
||||
import { downloadKeycloakDefaultTheme } from "../src/bin/shared/downloadKeycloakDefaultTheme";
|
||||
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
|
||||
import { deepAssign } from "../src/tools/deepAssign";
|
||||
import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions";
|
||||
|
||||
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
|
||||
// update the version array for generating for newer version.
|
||||
@ -12,21 +20,24 @@ import { getLogger } from "../src/bin/tools/logger";
|
||||
//@ts-ignore
|
||||
const propertiesParser = require("properties-parser");
|
||||
|
||||
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();
|
||||
|
||||
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
"projectDirPath": getProjectRoot(),
|
||||
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
|
||||
keycloakVersion,
|
||||
"destDirPath": tmpDirPath
|
||||
buildContext: {
|
||||
cacheDirPath: pathJoin(
|
||||
thisCodebaseRootDirPath,
|
||||
"node_modules",
|
||||
".cache",
|
||||
"keycloakify"
|
||||
),
|
||||
fetchOptions: getProxyFetchOptions({
|
||||
npmConfigGetCwd: thisCodebaseRootDirPath
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
type Dictionary = { [idiomId: string]: string };
|
||||
@ -34,12 +45,14 @@ async function main() {
|
||||
const record: { [typeOfPage: string]: { [language: string]: Dictionary } } = {};
|
||||
|
||||
{
|
||||
const baseThemeDirPath = pathJoin(tmpDirPath, "base");
|
||||
const re = new RegExp(`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`);
|
||||
const baseThemeDirPath = pathJoin(defaultThemeDirPath, "base");
|
||||
const re = new RegExp(
|
||||
`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`
|
||||
);
|
||||
|
||||
crawl({
|
||||
"dirPath": baseThemeDirPath,
|
||||
"returnedPathsType": "relative to dirPath"
|
||||
dirPath: baseThemeDirPath,
|
||||
returnedPathsType: "relative to dirPath"
|
||||
}).forEach(filePath => {
|
||||
const match = filePath.match(re);
|
||||
|
||||
@ -50,44 +63,86 @@ async function main() {
|
||||
const [, typeOfPage, language] = match;
|
||||
|
||||
(record[typeOfPage] ??= {})[language.replace(/_/g, "-")] = Object.fromEntries(
|
||||
Object.entries(propertiesParser.parse(fs.readFileSync(pathJoin(baseThemeDirPath, filePath)).toString("utf8"))).map(
|
||||
([key, value]: any) => [key, value.replace(/''/g, "'")]
|
||||
Object.entries(
|
||||
propertiesParser.parse(
|
||||
fs
|
||||
.readFileSync(pathJoin(baseThemeDirPath, filePath))
|
||||
.toString("utf8")
|
||||
) as Record<string, string>
|
||||
)
|
||||
.map(([key, value]) => [key, value.replace(/''/g, "'")])
|
||||
.map(([key, value]) => [
|
||||
key === "locale_pt_BR" ? "locale_pt-BR" : key,
|
||||
value
|
||||
])
|
||||
.map(([key, value]) => [key, key === "termsText" ? "" : value])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fs.rmSync(tmpDirPath, { recursive: true, force: true });
|
||||
|
||||
Object.keys(record).forEach(themeType => {
|
||||
const recordForPageType = record[themeType];
|
||||
|
||||
if (themeType !== "login" && themeType !== "account") {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseMessagesDirPath = pathJoin(getProjectRoot(), "src", themeType, "i18n", "baseMessages");
|
||||
const recordForThemeType = record[themeType];
|
||||
|
||||
const languages = Object.keys(recordForPageType);
|
||||
const languages = Object.keys(recordForThemeType);
|
||||
|
||||
const keycloakifyExtraMessages = (() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return keycloakifyExtraMessages_login;
|
||||
case "account":
|
||||
return keycloakifyExtraMessages_account;
|
||||
}
|
||||
assert(false);
|
||||
})();
|
||||
|
||||
assert(
|
||||
same(languages, Object.keys(keycloakifyExtraMessages), {
|
||||
takeIntoAccountArraysOrdering: false
|
||||
})
|
||||
);
|
||||
|
||||
deepAssign({
|
||||
target: recordForThemeType,
|
||||
source: keycloakifyExtraMessages
|
||||
});
|
||||
|
||||
const messagesDirPath = pathJoin(
|
||||
thisCodebaseRootDirPath,
|
||||
"src",
|
||||
themeType,
|
||||
"i18n",
|
||||
"messages_defaultSet"
|
||||
);
|
||||
|
||||
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 => {
|
||||
const filePath = pathJoin(baseMessagesDirPath, `${language}.ts`);
|
||||
const filePath = pathJoin(messagesDirPath, `${language}.ts`);
|
||||
|
||||
fs.mkdirSync(pathDirname(filePath), { "recursive": true });
|
||||
fs.mkdirSync(pathDirname(filePath), { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
Buffer.from(
|
||||
[
|
||||
generatedFileHeader,
|
||||
"",
|
||||
"/* spell-checker: disable */",
|
||||
`const messages= ${JSON.stringify(recordForPageType[language], null, 2)};`,
|
||||
`const messages= ${JSON.stringify(
|
||||
recordForThemeType[language],
|
||||
null,
|
||||
2
|
||||
)};`,
|
||||
"",
|
||||
"export default messages;",
|
||||
"/* spell-checker: enable */"
|
||||
@ -96,22 +151,30 @@ async function main() {
|
||||
)
|
||||
);
|
||||
|
||||
logger.log(`${filePath} wrote`);
|
||||
//console.log(`${filePath} wrote`);
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(baseMessagesDirPath, "index.ts"),
|
||||
pathJoin(messagesDirPath, "index.ts"),
|
||||
Buffer.from(
|
||||
[
|
||||
generatedFileHeader,
|
||||
"export async function getMessages(currentLanguageTag: string) {",
|
||||
" const { default: messages } = await (() => {",
|
||||
`import * as en from "./en";`,
|
||||
"",
|
||||
"export async function fetchMessages_defaultSet(currentLanguageTag: string) {",
|
||||
" const { default: messages_defaultSet } = 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": {} };',
|
||||
" }",
|
||||
" })();",
|
||||
" return messages;",
|
||||
" return messages_defaultSet;",
|
||||
"}"
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
@ -120,6 +183,491 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const keycloakifyExtraMessages_login: Record<
|
||||
| "en"
|
||||
| "ar"
|
||||
| "ca"
|
||||
| "cs"
|
||||
| "da"
|
||||
| "de"
|
||||
| "el"
|
||||
| "es"
|
||||
| "fa"
|
||||
| "fi"
|
||||
| "fr"
|
||||
| "hu"
|
||||
| "it"
|
||||
| "ja"
|
||||
| "lt"
|
||||
| "lv"
|
||||
| "nl"
|
||||
| "no"
|
||||
| "pl"
|
||||
| "pt-BR"
|
||||
| "ru"
|
||||
| "sk"
|
||||
| "sv"
|
||||
| "th"
|
||||
| "tr"
|
||||
| "uk"
|
||||
| "zh-CN",
|
||||
Record<
|
||||
| "shouldBeEqual"
|
||||
| "shouldBeDifferent"
|
||||
| "shouldMatchPattern"
|
||||
| "mustBeAnInteger"
|
||||
| "notAValidOption"
|
||||
| "selectAnOption"
|
||||
| "remove"
|
||||
| "addValue"
|
||||
| "languages",
|
||||
string
|
||||
>
|
||||
> = {
|
||||
en: {
|
||||
shouldBeEqual: "{0} should be equal to {1}",
|
||||
shouldBeDifferent: "{0} should be different to {1}",
|
||||
shouldMatchPattern: "Pattern should match: `/{0}/`",
|
||||
mustBeAnInteger: "Must be an integer",
|
||||
notAValidOption: "Not a valid option",
|
||||
selectAnOption: "Select an option",
|
||||
remove: "Remove",
|
||||
addValue: "Add value",
|
||||
languages: "Languages"
|
||||
},
|
||||
/* spell-checker: disable */
|
||||
ar: {
|
||||
shouldBeEqual: "{0} يجب أن يكون مساويًا لـ {1}",
|
||||
shouldBeDifferent: "{0} يجب أن يكون مختلفًا عن {1}",
|
||||
shouldMatchPattern: "`/يجب أن يطابق النمط: `/{0}/",
|
||||
mustBeAnInteger: "يجب أن يكون عددًا صحيحًا",
|
||||
notAValidOption: "ليس خيارًا صالحًا",
|
||||
selectAnOption: "اختر خيارًا",
|
||||
remove: "إزالة",
|
||||
addValue: "أضف قيمة",
|
||||
languages: "اللغات"
|
||||
},
|
||||
ca: {
|
||||
shouldBeEqual: "{0} hauria de ser igual a {1}",
|
||||
shouldBeDifferent: "{0} hauria de ser diferent de {1}",
|
||||
shouldMatchPattern: "El patró hauria de coincidir: `/{0}/`",
|
||||
mustBeAnInteger: "Ha de ser un enter",
|
||||
notAValidOption: "No és una opció vàlida",
|
||||
selectAnOption: "Selecciona una opció",
|
||||
remove: "Elimina",
|
||||
addValue: "Afegeix valor",
|
||||
languages: "Idiomes"
|
||||
},
|
||||
cs: {
|
||||
shouldBeEqual: "{0} by měl být roven {1}",
|
||||
shouldBeDifferent: "{0} by měl být odlišný od {1}",
|
||||
shouldMatchPattern: "Vzor by měl odpovídat: `/{0}/`",
|
||||
mustBeAnInteger: "Musí být celé číslo",
|
||||
notAValidOption: "Není platná možnost",
|
||||
selectAnOption: "Vyberte možnost",
|
||||
remove: "Odstranit",
|
||||
addValue: "Přidat hodnotu",
|
||||
languages: "Jazyky"
|
||||
},
|
||||
da: {
|
||||
shouldBeEqual: "{0} bør være lig med {1}",
|
||||
shouldBeDifferent: "{0} bør være forskellig fra {1}",
|
||||
shouldMatchPattern: "Mønsteret bør matche: `/{0}/`",
|
||||
mustBeAnInteger: "Skal være et heltal",
|
||||
notAValidOption: "Ikke en gyldig mulighed",
|
||||
selectAnOption: "Vælg en mulighed",
|
||||
remove: "Fjern",
|
||||
addValue: "Tilføj værdi",
|
||||
languages: "Sprog"
|
||||
},
|
||||
de: {
|
||||
shouldBeEqual: "{0} sollte gleich {1} sein",
|
||||
shouldBeDifferent: "{0} sollte sich von {1} unterscheiden",
|
||||
shouldMatchPattern: "Muster sollte übereinstimmen: `/{0}/`",
|
||||
mustBeAnInteger: "Muss eine ganze Zahl sein",
|
||||
notAValidOption: "Keine gültige Option",
|
||||
selectAnOption: "Wählen Sie eine Option",
|
||||
remove: "Entfernen",
|
||||
addValue: "Wert hinzufügen",
|
||||
languages: "Sprachen"
|
||||
},
|
||||
el: {
|
||||
shouldBeEqual: "Το {0} πρέπει να είναι ίσο με {1}",
|
||||
shouldBeDifferent: "Το {0} πρέπει να διαφέρει από το {1}",
|
||||
shouldMatchPattern: "Το πρότυπο πρέπει να ταιριάζει: `/{0}/`",
|
||||
mustBeAnInteger: "Πρέπει να είναι ακέραιος",
|
||||
notAValidOption: "Δεν είναι μια έγκυρη επιλογή",
|
||||
selectAnOption: "Επιλέξτε μια επιλογή",
|
||||
remove: "Αφαίρεση",
|
||||
addValue: "Προσθήκη τιμής",
|
||||
languages: "Γλώσσες"
|
||||
},
|
||||
es: {
|
||||
shouldBeEqual: "{0} debería ser igual a {1}",
|
||||
shouldBeDifferent: "{0} debería ser diferente a {1}",
|
||||
shouldMatchPattern: "El patrón debería coincidir: `/{0}/`",
|
||||
mustBeAnInteger: "Debe ser un número entero",
|
||||
notAValidOption: "No es una opción válida",
|
||||
selectAnOption: "Selecciona una opción",
|
||||
remove: "Eliminar",
|
||||
addValue: "Añadir valor",
|
||||
languages: "Idiomas"
|
||||
},
|
||||
fa: {
|
||||
shouldBeEqual: "{0} باید برابر باشد با {1}",
|
||||
shouldBeDifferent: "{0} باید متفاوت باشد از {1}",
|
||||
shouldMatchPattern: "الگو باید مطابقت داشته باشد: `/{0}/`",
|
||||
mustBeAnInteger: "باید یک عدد صحیح باشد",
|
||||
notAValidOption: "یک گزینه معتبر نیست",
|
||||
selectAnOption: "یک گزینه انتخاب کنید",
|
||||
remove: "حذف",
|
||||
addValue: "افزودن مقدار",
|
||||
languages: "زبانها"
|
||||
},
|
||||
fi: {
|
||||
shouldBeEqual: "{0} pitäisi olla yhtä suuri kuin {1}",
|
||||
shouldBeDifferent: "{0} pitäisi olla erilainen kuin {1}",
|
||||
shouldMatchPattern: "Mallin tulisi vastata: `/{0}/`",
|
||||
mustBeAnInteger: "On oltava kokonaisluku",
|
||||
notAValidOption: "Ei ole kelvollinen vaihtoehto",
|
||||
selectAnOption: "Valitse vaihtoehto",
|
||||
remove: "Poista",
|
||||
addValue: "Lisää arvo",
|
||||
languages: "Kielet"
|
||||
},
|
||||
fr: {
|
||||
shouldBeEqual: "{0} devrait être égal à {1}",
|
||||
shouldBeDifferent: "{0} devrait être différent de {1}",
|
||||
shouldMatchPattern: "Le motif devrait correspondre: `/{0}/`",
|
||||
mustBeAnInteger: "Doit être un entier",
|
||||
notAValidOption: "Pas une option valide",
|
||||
selectAnOption: "Sélectionnez une option",
|
||||
remove: "Supprimer",
|
||||
addValue: "Ajouter une valeur",
|
||||
languages: "Langues"
|
||||
},
|
||||
hu: {
|
||||
shouldBeEqual: "{0} egyenlő kell legyen {1}-vel",
|
||||
shouldBeDifferent: "{0} különbözőnek kell lennie, mint {1}",
|
||||
shouldMatchPattern: "A mintának egyeznie kell: `/{0}/`",
|
||||
mustBeAnInteger: "Egész számnak kell lennie",
|
||||
notAValidOption: "Nem érvényes opció",
|
||||
selectAnOption: "Válasszon egy lehetőséget",
|
||||
remove: "Eltávolítás",
|
||||
addValue: "Érték hozzáadása",
|
||||
languages: "Nyelvek"
|
||||
},
|
||||
it: {
|
||||
shouldBeEqual: "{0} dovrebbe essere uguale a {1}",
|
||||
shouldBeDifferent: "{0} dovrebbe essere diverso da {1}",
|
||||
shouldMatchPattern: "Il modello dovrebbe corrispondere: `/{0}/`",
|
||||
mustBeAnInteger: "Deve essere un numero intero",
|
||||
notAValidOption: "Non è un'opzione valida",
|
||||
selectAnOption: "Seleziona un'opzione",
|
||||
remove: "Rimuovi",
|
||||
addValue: "Aggiungi valore",
|
||||
languages: "Lingue"
|
||||
},
|
||||
ja: {
|
||||
shouldBeEqual: "{0} は {1} と等しい必要があります",
|
||||
shouldBeDifferent: "{0} は {1} と異なる必要があります",
|
||||
shouldMatchPattern: "パターンは一致する必要があります: `/{0}/`",
|
||||
mustBeAnInteger: "整数である必要があります",
|
||||
notAValidOption: "有効なオプションではありません",
|
||||
selectAnOption: "オプションを選択",
|
||||
remove: "削除",
|
||||
addValue: "値を追加",
|
||||
languages: "言語"
|
||||
},
|
||||
lt: {
|
||||
shouldBeEqual: "{0} turėtų būti lygus {1}",
|
||||
shouldBeDifferent: "{0} turėtų skirtis nuo {1}",
|
||||
shouldMatchPattern: "Šablonas turėtų atitikti: `/{0}/`",
|
||||
mustBeAnInteger: "Turi būti sveikasis skaičius",
|
||||
notAValidOption: "Netinkama parinktis",
|
||||
selectAnOption: "Pasirinkite parinktį",
|
||||
remove: "Pašalinti",
|
||||
addValue: "Pridėti reikšmę",
|
||||
languages: "Kalbos"
|
||||
},
|
||||
lv: {
|
||||
shouldBeEqual: "{0} jābūt vienādam ar {1}",
|
||||
shouldBeDifferent: "{0} jābūt atšķirīgam no {1}",
|
||||
shouldMatchPattern: "Mustrim jāsakrīt: `/{0}/`",
|
||||
mustBeAnInteger: "Jābūt veselam skaitlim",
|
||||
notAValidOption: "Nav derīga opcija",
|
||||
selectAnOption: "Izvēlieties opciju",
|
||||
remove: "Noņemt",
|
||||
addValue: "Pievienot vērtību",
|
||||
languages: "Valodas"
|
||||
},
|
||||
nl: {
|
||||
shouldBeEqual: "{0} moet gelijk zijn aan {1}",
|
||||
shouldBeDifferent: "{0} moet verschillen van {1}",
|
||||
shouldMatchPattern: "Patroon moet overeenkomen: `/{0}/`",
|
||||
mustBeAnInteger: "Moet een geheel getal zijn",
|
||||
notAValidOption: "Geen geldige optie",
|
||||
selectAnOption: "Selecteer een optie",
|
||||
remove: "Verwijderen",
|
||||
addValue: "Waarde toevoegen",
|
||||
languages: "Talen"
|
||||
},
|
||||
no: {
|
||||
shouldBeEqual: "{0} skal være lik {1}",
|
||||
shouldBeDifferent: "{0} skal være forskjellig fra {1}",
|
||||
shouldMatchPattern: "Mønsteret skal matche: `/{0}/`",
|
||||
mustBeAnInteger: "Må være et heltall",
|
||||
notAValidOption: "Ikke et gyldig alternativ",
|
||||
selectAnOption: "Velg et alternativ",
|
||||
remove: "Fjern",
|
||||
addValue: "Legg til verdi",
|
||||
languages: "Språk"
|
||||
},
|
||||
pl: {
|
||||
shouldBeEqual: "{0} powinno być równe {1}",
|
||||
shouldBeDifferent: "{0} powinno być różne od {1}",
|
||||
shouldMatchPattern: "Wzór pow inien pasować: `/{0}/`",
|
||||
mustBeAnInteger: "Musi być liczbą całkowitą",
|
||||
notAValidOption: "Nieprawidłowa opcja",
|
||||
selectAnOption: "Wybierz opcję",
|
||||
remove: "Usuń",
|
||||
addValue: "Dodaj wartość",
|
||||
languages: "Języki"
|
||||
},
|
||||
"pt-BR": {
|
||||
shouldBeEqual: "{0} deve ser igual a {1}",
|
||||
shouldBeDifferent: "{0} deve ser diferente de {1}",
|
||||
shouldMatchPattern: "O padrão deve corresponder: `/{0}/`",
|
||||
mustBeAnInteger: "Deve ser um número inteiro",
|
||||
notAValidOption: "Não é uma opção válida",
|
||||
selectAnOption: "Selecione uma opção",
|
||||
remove: "Remover",
|
||||
addValue: "Adicionar valor",
|
||||
languages: "Idiomas"
|
||||
},
|
||||
ru: {
|
||||
shouldBeEqual: "{0} должно быть равно {1}",
|
||||
shouldBeDifferent: "{0} должно отличаться от {1}",
|
||||
shouldMatchPattern: "Шаблон должен соответствовать: `/{0}/`",
|
||||
mustBeAnInteger: "Должно быть целым числом",
|
||||
notAValidOption: "Недопустимый вариант",
|
||||
selectAnOption: "Выберите вариант",
|
||||
remove: "Удалить",
|
||||
addValue: "Добавить значение",
|
||||
languages: "Языки"
|
||||
},
|
||||
sk: {
|
||||
shouldBeEqual: "{0} by mal byť rovnaký ako {1}",
|
||||
shouldBeDifferent: "{0} by mal byť odlišný od {1}",
|
||||
shouldMatchPattern: "Vzor by mal zodpovedať: `/{0}/`",
|
||||
mustBeAnInteger: "Musí byť celé číslo",
|
||||
notAValidOption: "Nie je platná možnosť",
|
||||
selectAnOption: "Vyberte možnosť",
|
||||
remove: "Odstrániť",
|
||||
addValue: "Pridať hodnotu",
|
||||
languages: "Jazyky"
|
||||
},
|
||||
sv: {
|
||||
shouldBeEqual: "{0} bör vara lika med {1}",
|
||||
shouldBeDifferent: "{0} bör vara annorlunda än {1}",
|
||||
shouldMatchPattern: "Mönstret bör matcha: `/{0}/`",
|
||||
mustBeAnInteger: "Måste vara ett heltal",
|
||||
notAValidOption: "Inte ett giltigt alternativ",
|
||||
selectAnOption: "Välj ett alternativ",
|
||||
remove: "Ta bort",
|
||||
addValue: "Lägg till värde",
|
||||
languages: "Språk"
|
||||
},
|
||||
th: {
|
||||
shouldBeEqual: "{0} ควรเท่ากับ {1}",
|
||||
shouldBeDifferent: "{0} ควรแตกต่างจาก {1}",
|
||||
shouldMatchPattern: "รูปแบบควรตรงกับ: `/{0}/`",
|
||||
mustBeAnInteger: "ต้องเป็นจำนวนเต็ม",
|
||||
notAValidOption: "ไม่ใช่ตัวเลือกที่ถูกต้อง",
|
||||
selectAnOption: "เลือกตัวเลือก",
|
||||
remove: "ลบ",
|
||||
addValue: "เพิ่มค่า",
|
||||
languages: "ภาษา"
|
||||
},
|
||||
tr: {
|
||||
shouldBeEqual: "{0} {1} eşit olmalıdır",
|
||||
shouldBeDifferent: "{0} {1} farklı olmalıdır",
|
||||
shouldMatchPattern: "Desen eşleşmelidir: `/{0}/`",
|
||||
mustBeAnInteger: "Tam sayı olmalıdır",
|
||||
notAValidOption: "Geçerli bir seçenek değil",
|
||||
selectAnOption: "Bir seçenek seçin",
|
||||
remove: "Kaldır",
|
||||
addValue: "Değer ekle",
|
||||
languages: "Diller"
|
||||
},
|
||||
uk: {
|
||||
shouldBeEqual: "{0} повинно бути рівним {1}",
|
||||
shouldBeDifferent: "{0} повинно відрізнятися від {1}",
|
||||
shouldMatchPattern: "Шаблон повинен відповідати: `/{0}/`",
|
||||
mustBeAnInteger: "Повинно бути цілим числом",
|
||||
notAValidOption: "Не є дійсною опцією",
|
||||
selectAnOption: "Виберіть опцію",
|
||||
remove: "Видалити",
|
||||
addValue: "Додати значення",
|
||||
languages: "Мови"
|
||||
},
|
||||
"zh-CN": {
|
||||
shouldBeEqual: "{0} 应该等于 {1}",
|
||||
shouldBeDifferent: "{0} 应该不同于 {1}",
|
||||
shouldMatchPattern: "模式应匹配: `/{0}/`",
|
||||
mustBeAnInteger: "必须是整数",
|
||||
notAValidOption: "不是有效选项",
|
||||
selectAnOption: "选择一个选项",
|
||||
remove: "移除",
|
||||
addValue: "添加值",
|
||||
languages: "语言"
|
||||
}
|
||||
/* spell-checker: enable */
|
||||
};
|
||||
|
||||
const keycloakifyExtraMessages_account: Record<
|
||||
| "en"
|
||||
| "ar"
|
||||
| "ca"
|
||||
| "cs"
|
||||
| "da"
|
||||
| "de"
|
||||
| "el"
|
||||
| "es"
|
||||
| "fa"
|
||||
| "fi"
|
||||
| "fr"
|
||||
| "hu"
|
||||
| "it"
|
||||
| "ja"
|
||||
| "lt"
|
||||
| "lv"
|
||||
| "nl"
|
||||
| "no"
|
||||
| "pl"
|
||||
| "pt-BR"
|
||||
| "ru"
|
||||
| "sk"
|
||||
| "sv"
|
||||
| "th"
|
||||
| "tr"
|
||||
| "uk"
|
||||
| "zh-CN",
|
||||
Record<"newPasswordSameAsOld" | "passwordConfirmNotMatch", string>
|
||||
> = {
|
||||
en: {
|
||||
newPasswordSameAsOld: "New password must be different from the old one",
|
||||
passwordConfirmNotMatch: "Password confirmation does not match"
|
||||
},
|
||||
/* spell-checker: disable */
|
||||
ar: {
|
||||
newPasswordSameAsOld: "يجب أن تكون كلمة المرور الجديدة مختلفة عن القديمة",
|
||||
passwordConfirmNotMatch: "تأكيد كلمة المرور لا يتطابق"
|
||||
},
|
||||
ca: {
|
||||
newPasswordSameAsOld: "La nova contrasenya ha de ser diferent de l'anterior",
|
||||
passwordConfirmNotMatch: "La confirmació de la contrasenya no coincideix"
|
||||
},
|
||||
cs: {
|
||||
newPasswordSameAsOld: "Nové heslo musí být odlišné od starého",
|
||||
passwordConfirmNotMatch: "Potvrzení hesla se neshoduje"
|
||||
},
|
||||
da: {
|
||||
newPasswordSameAsOld: "Det nye kodeord skal være forskelligt fra det gamle",
|
||||
passwordConfirmNotMatch: "Adgangskodebekræftelse matcher ikke"
|
||||
},
|
||||
de: {
|
||||
newPasswordSameAsOld: "Das neue Passwort muss sich vom alten unterscheiden",
|
||||
passwordConfirmNotMatch: "Passwortbestätigung stimmt nicht überein"
|
||||
},
|
||||
el: {
|
||||
newPasswordSameAsOld: "Ο νέος κωδικός πρόσβασης πρέπει να διαφέρει από τον παλιό",
|
||||
passwordConfirmNotMatch: "Η επιβεβαίωση του κωδικού πρόσβασης δεν ταιριάζει"
|
||||
},
|
||||
es: {
|
||||
newPasswordSameAsOld: "La nueva contraseña debe ser diferente de la anterior",
|
||||
passwordConfirmNotMatch: "La confirmación de la contraseña no coincide"
|
||||
},
|
||||
fa: {
|
||||
newPasswordSameAsOld: "رمز عبور جدید باید با رمز عبور قبلی متفاوت باشد",
|
||||
passwordConfirmNotMatch: "تأیید رمز عبور مطابقت ندارد"
|
||||
},
|
||||
fi: {
|
||||
newPasswordSameAsOld: "Uusi salasana on oltava erilainen kuin vanha",
|
||||
passwordConfirmNotMatch: "Salasanan vahvistus ei täsmää"
|
||||
},
|
||||
fr: {
|
||||
newPasswordSameAsOld: "Le nouveau mot de passe doit être différent de l'ancien",
|
||||
passwordConfirmNotMatch: "La confirmation du mot de passe ne correspond pas"
|
||||
},
|
||||
hu: {
|
||||
newPasswordSameAsOld: "Az új jelszónak különböznie kell az előzőtől",
|
||||
passwordConfirmNotMatch: "A jelszó megerősítése nem egyezik"
|
||||
},
|
||||
it: {
|
||||
newPasswordSameAsOld:
|
||||
"La nuova password deve essere diversa da quella precedente",
|
||||
passwordConfirmNotMatch: "La conferma della password non corrisponde"
|
||||
},
|
||||
ja: {
|
||||
newPasswordSameAsOld: "新しいパスワードは古いパスワードと異なる必要があります",
|
||||
passwordConfirmNotMatch: "パスワード確認が一致しません"
|
||||
},
|
||||
lt: {
|
||||
newPasswordSameAsOld: "Naujas slaptažodis turi skirtis nuo seno",
|
||||
passwordConfirmNotMatch: "Slaptažodžio patvirtinimas neatitinka"
|
||||
},
|
||||
lv: {
|
||||
newPasswordSameAsOld: "Jaunajam parolam jābūt atšķirīgam no vecā",
|
||||
passwordConfirmNotMatch: "Paroles apstiprināšana neatbilst"
|
||||
},
|
||||
nl: {
|
||||
newPasswordSameAsOld: "Het nieuwe wachtwoord moet verschillend zijn van het oude",
|
||||
passwordConfirmNotMatch: "Wachtwoordbevestiging komt niet overeen"
|
||||
},
|
||||
no: {
|
||||
newPasswordSameAsOld: "Det nye passordet må være forskjellig fra det gamle",
|
||||
passwordConfirmNotMatch: "Passordbekreftelsen stemmer ikke"
|
||||
},
|
||||
pl: {
|
||||
newPasswordSameAsOld: "Nowe hasło musi być inne niż stare",
|
||||
passwordConfirmNotMatch: "Potwierdzenie hasła nie pasuje"
|
||||
},
|
||||
"pt-BR": {
|
||||
newPasswordSameAsOld: "A nova senha deve ser diferente da antiga",
|
||||
passwordConfirmNotMatch: "A confirmação da senha não corresponde"
|
||||
},
|
||||
ru: {
|
||||
newPasswordSameAsOld: "Новый пароль должен отличаться от старого",
|
||||
passwordConfirmNotMatch: "Подтверждение пароля не совпадает"
|
||||
},
|
||||
sk: {
|
||||
newPasswordSameAsOld: "Nové heslo musí byť odlišné od starého",
|
||||
passwordConfirmNotMatch: "Potvrdenie hesla sa nezhoduje"
|
||||
},
|
||||
sv: {
|
||||
newPasswordSameAsOld: "Det nya lösenordet måste skilja sig från det gamla",
|
||||
passwordConfirmNotMatch: "Lösenordsbekräftelsen matchar inte"
|
||||
},
|
||||
th: {
|
||||
newPasswordSameAsOld: "รหัสผ่านใหม่ต้องต่างจากรหัสผ่านเดิม",
|
||||
passwordConfirmNotMatch: "การยืนยันรหัสผ่านไม่ตรงกัน"
|
||||
},
|
||||
tr: {
|
||||
newPasswordSameAsOld: "Yeni şifre eskisinden farklı olmalıdır",
|
||||
passwordConfirmNotMatch: "Şifre doğrulama eşleşmiyor"
|
||||
},
|
||||
uk: {
|
||||
newPasswordSameAsOld: "Новий пароль повинен відрізнятися від старого",
|
||||
passwordConfirmNotMatch: "Підтвердження пароля не співпадає"
|
||||
},
|
||||
"zh-CN": {
|
||||
newPasswordSameAsOld: "新密码必须与旧密码不同",
|
||||
passwordConfirmNotMatch: "密码确认不匹配"
|
||||
}
|
||||
/* spell-checker: enable */
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
@ -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,15 +1,17 @@
|
||||
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;
|
||||
const newMode =
|
||||
oldMode | constants.S_IXUSR | constants.S_IXGRP | constants.S_IXOTH;
|
||||
await chmod(fullPath, newMode);
|
||||
});
|
||||
|
@ -1,79 +1,84 @@
|
||||
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";
|
||||
import * as os from "os";
|
||||
|
||||
const singletonDependencies: string[] = ["react", "@types/react"];
|
||||
|
||||
const rootDirPath = getProjectRoot();
|
||||
// For example [ "@emotion" ] it's more convenient than
|
||||
// having to list every sub emotion packages (@emotion/css @emotion/utils ...)
|
||||
// in singletonDependencies
|
||||
const namespaceSingletonDependencies: string[] = [];
|
||||
|
||||
const rootDirPath = getThisCodebaseRootDirPath();
|
||||
|
||||
const commonThirdPartyDeps = [
|
||||
...namespaceSingletonDependencies
|
||||
.map(namespaceModuleName =>
|
||||
fs
|
||||
.readdirSync(pathJoin(rootDirPath, "node_modules", namespaceModuleName))
|
||||
.map(submoduleName => `${namespaceModuleName}/${submoduleName}`)
|
||||
)
|
||||
.reduce((prev, curr) => [...prev, ...curr], []),
|
||||
...singletonDependencies
|
||||
];
|
||||
|
||||
//NOTE: This is only required because of: https://github.com/garronej/ts-ci/blob/c0e207b9677523d4ec97fe672ddd72ccbb3c1cc4/README.md?plain=1#L54-L58
|
||||
fs.writeFileSync(
|
||||
pathJoin(rootDirPath, "dist", "package.json"),
|
||||
Buffer.from(
|
||||
JSON.stringify(
|
||||
(() => {
|
||||
const packageJsonParsed = JSON.parse(fs.readFileSync(pathJoin(rootDirPath, "package.json")).toString("utf8"));
|
||||
{
|
||||
let modifiedPackageJsonContent = fs
|
||||
.readFileSync(pathJoin(rootDirPath, "package.json"))
|
||||
.toString("utf8");
|
||||
|
||||
return {
|
||||
...packageJsonParsed,
|
||||
"main": packageJsonParsed["main"]?.replace(/^dist\//, ""),
|
||||
"types": packageJsonParsed["types"]?.replace(/^dist\//, ""),
|
||||
"module": packageJsonParsed["module"]?.replace(/^dist\//, ""),
|
||||
"exports": !("exports" in packageJsonParsed)
|
||||
? undefined
|
||||
: Object.fromEntries(
|
||||
Object.entries(packageJsonParsed["exports"]).map(([key, value]) => [
|
||||
key,
|
||||
(value as string).replace(/^\.\/dist\//, "./")
|
||||
])
|
||||
)
|
||||
};
|
||||
})(),
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
modifiedPackageJsonContent = (() => {
|
||||
const o = JSON.parse(modifiedPackageJsonContent);
|
||||
|
||||
fs.cpSync(pathJoin(rootDirPath, "src"), pathJoin(rootDirPath, "dist", "src"), { "recursive": true });
|
||||
delete o.files;
|
||||
|
||||
const commonThirdPartyDeps = (() => {
|
||||
// For example [ "@emotion" ] it's more convenient than
|
||||
// having to list every sub emotion packages (@emotion/css @emotion/utils ...)
|
||||
// in singletonDependencies
|
||||
const namespaceSingletonDependencies: string[] = [];
|
||||
return JSON.stringify(o, null, 2);
|
||||
})();
|
||||
|
||||
return [
|
||||
...namespaceSingletonDependencies
|
||||
.map(namespaceModuleName =>
|
||||
fs
|
||||
.readdirSync(pathJoin(rootDirPath, "node_modules", namespaceModuleName))
|
||||
.map(submoduleName => `${namespaceModuleName}/${submoduleName}`)
|
||||
)
|
||||
.reduce((prev, curr) => [...prev, ...curr], []),
|
||||
...singletonDependencies
|
||||
];
|
||||
})();
|
||||
modifiedPackageJsonContent = modifiedPackageJsonContent
|
||||
.replace(/"dist\//g, '"')
|
||||
.replace(/"\.\/dist\//g, '"./')
|
||||
.replace(/"!dist\//g, '"!')
|
||||
.replace(/"!\.\/dist\//g, '"!./');
|
||||
|
||||
modifiedPackageJsonContent = JSON.stringify(
|
||||
{ ...JSON.parse(modifiedPackageJsonContent), version: "0.0.0" },
|
||||
null,
|
||||
4
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(rootDirPath, "dist", "package.json"),
|
||||
Buffer.from(modifiedPackageJsonContent, "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
const yarnGlobalDirPath = pathJoin(rootDirPath, ".yarn_home");
|
||||
|
||||
fs.rmSync(yarnGlobalDirPath, { "recursive": true, "force": true });
|
||||
fs.rmSync(yarnGlobalDirPath, { recursive: true, force: true });
|
||||
fs.mkdirSync(yarnGlobalDirPath);
|
||||
|
||||
const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
|
||||
const { targetModuleName, cwd } = params;
|
||||
|
||||
const cmd = ["yarn", "link", ...(targetModuleName !== undefined ? [targetModuleName] : ["--no-bin-links"])].join(" ");
|
||||
const cmd = [
|
||||
"yarn",
|
||||
"link",
|
||||
...(targetModuleName !== undefined ? [targetModuleName] : ["--no-bin-links"])
|
||||
].join(" ");
|
||||
|
||||
console.log(`$ cd ${pathRelative(rootDirPath, cwd) || "."} && ${cmd}`);
|
||||
|
||||
execSync(cmd, {
|
||||
cwd,
|
||||
"env": {
|
||||
env: {
|
||||
...process.env,
|
||||
"HOME": yarnGlobalDirPath
|
||||
...(os.platform() === "win32"
|
||||
? { USERPROFILE: yarnGlobalDirPath }
|
||||
: { HOME: yarnGlobalDirPath })
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -89,7 +94,9 @@ const testAppPaths = (() => {
|
||||
return testAppPath;
|
||||
}
|
||||
|
||||
console.warn(`Skipping ${testAppName} since it cant be found here: ${testAppPath}`);
|
||||
console.warn(
|
||||
`Skipping ${testAppName} since it cant be found here: ${testAppPath}`
|
||||
);
|
||||
|
||||
return undefined;
|
||||
})
|
||||
@ -101,7 +108,7 @@ if (testAppPaths.length === 0) {
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
testAppPaths.forEach(testAppPath => execSync("yarn install", { "cwd": testAppPath }));
|
||||
testAppPaths.forEach(testAppPath => execSync("yarn install", { cwd: testAppPath }));
|
||||
|
||||
console.log("=== Linking common dependencies ===");
|
||||
|
||||
@ -114,29 +121,37 @@ commonThirdPartyDeps.forEach(commonThirdPartyDep => {
|
||||
console.log(`${current}/${total} ${commonThirdPartyDep}`);
|
||||
|
||||
const localInstallPath = pathJoin(
|
||||
...[rootDirPath, "node_modules", ...(commonThirdPartyDep.startsWith("@") ? commonThirdPartyDep.split("/") : [commonThirdPartyDep])]
|
||||
...[
|
||||
rootDirPath,
|
||||
"node_modules",
|
||||
...(commonThirdPartyDep.startsWith("@")
|
||||
? commonThirdPartyDep.split("/")
|
||||
: [commonThirdPartyDep])
|
||||
]
|
||||
);
|
||||
|
||||
execYarnLink({ "cwd": localInstallPath });
|
||||
execYarnLink({ cwd: localInstallPath });
|
||||
});
|
||||
|
||||
commonThirdPartyDeps.forEach(commonThirdPartyDep =>
|
||||
testAppPaths.forEach(testAppPath =>
|
||||
execYarnLink({
|
||||
"cwd": testAppPath,
|
||||
"targetModuleName": commonThirdPartyDep
|
||||
cwd: testAppPath,
|
||||
targetModuleName: commonThirdPartyDep
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
console.log("=== Linking in house dependencies ===");
|
||||
|
||||
execYarnLink({ "cwd": pathJoin(rootDirPath, "dist") });
|
||||
execYarnLink({ cwd: pathJoin(rootDirPath, "dist") });
|
||||
|
||||
testAppPaths.forEach(testAppPath =>
|
||||
execYarnLink({
|
||||
"cwd": testAppPath,
|
||||
"targetModuleName": JSON.parse(fs.readFileSync(pathJoin(rootDirPath, "package.json")).toString("utf8"))["name"]
|
||||
cwd: testAppPath,
|
||||
targetModuleName: JSON.parse(
|
||||
fs.readFileSync(pathJoin(rootDirPath, "package.json")).toString("utf8")
|
||||
)["name"]
|
||||
})
|
||||
);
|
||||
|
||||
|
30
scripts/link-in-starter.ts
Normal file
30
scripts/link-in-starter.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import * as child_process from "child_process";
|
||||
import * as fs from "fs";
|
||||
import { join } from "path";
|
||||
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
|
||||
|
||||
fs.rmSync("node_modules", { recursive: true, force: true });
|
||||
fs.rmSync("dist", { recursive: true, force: true });
|
||||
fs.rmSync(".yarn_home", { recursive: true, force: true });
|
||||
|
||||
run("yarn install");
|
||||
run("yarn build");
|
||||
|
||||
const starterName = "keycloakify-starter";
|
||||
|
||||
fs.rmSync(join("..", starterName, "node_modules"), {
|
||||
recursive: true,
|
||||
force: true
|
||||
});
|
||||
|
||||
run("yarn install", { cwd: join("..", starterName) });
|
||||
|
||||
run(`npx tsx ${join("scripts", "link-in-app.ts")} ${starterName}`);
|
||||
|
||||
startRebuildOnSrcChange();
|
||||
|
||||
function run(command: string, options?: { cwd: string }) {
|
||||
console.log(`$ ${command}`);
|
||||
|
||||
child_process.execSync(command, { stdio: "inherit", ...options });
|
||||
}
|
29
scripts/start-storybook.ts
Normal file
29
scripts/start-storybook.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import * as child_process from "child_process";
|
||||
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
|
||||
import { copyKeycloakResourcesToStorybookStaticDir } from "./copyKeycloakResourcesToStorybookStaticDir";
|
||||
|
||||
(async () => {
|
||||
run("yarn build");
|
||||
|
||||
await copyKeycloakResourcesToStorybookStaticDir();
|
||||
|
||||
{
|
||||
const child = child_process.spawn("npx", ["start-storybook", "-p", "6006"], {
|
||||
shell: true
|
||||
});
|
||||
|
||||
child.stdout.on("data", data => process.stdout.write(data));
|
||||
|
||||
child.stderr.on("data", data => process.stderr.write(data));
|
||||
|
||||
child.on("exit", process.exit.bind(process));
|
||||
}
|
||||
|
||||
startRebuildOnSrcChange();
|
||||
})();
|
||||
|
||||
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
|
||||
console.log(`$ ${command}`);
|
||||
|
||||
child_process.execSync(command, { stdio: "inherit", ...options });
|
||||
}
|
40
scripts/startRebuildOnSrcChange.ts
Normal file
40
scripts/startRebuildOnSrcChange.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import * as child_process from "child_process";
|
||||
import { waitForDebounceFactory } from "powerhooks/tools/waitForDebounce";
|
||||
import chokidar from "chokidar";
|
||||
import * as runExclusive from "run-exclusive";
|
||||
import { Deferred } from "evt/tools/Deferred";
|
||||
import chalk from "chalk";
|
||||
|
||||
export function startRebuildOnSrcChange() {
|
||||
const { waitForDebounce } = waitForDebounceFactory({ delay: 400 });
|
||||
|
||||
const runYarnBuild = runExclusive.build(async () => {
|
||||
console.log(chalk.green("Running `yarn build`"));
|
||||
|
||||
const dCompleted = new Deferred<void>();
|
||||
|
||||
const child = child_process.spawn("yarn", ["build"], { shell: true });
|
||||
|
||||
child.stdout.on("data", data => process.stdout.write(data));
|
||||
|
||||
child.stderr.on("data", data => process.stderr.write(data));
|
||||
|
||||
child.on("exit", () => dCompleted.resolve());
|
||||
|
||||
await dCompleted.pr;
|
||||
|
||||
console.log("\n\n");
|
||||
});
|
||||
|
||||
console.log(chalk.green("Watching for changes in src/"));
|
||||
|
||||
chokidar
|
||||
.watch(["src", "stories"], { ignoreInitial: true })
|
||||
.on("all", async (event, path) => {
|
||||
console.log(chalk.bold(`${event}: ${path}`));
|
||||
|
||||
await waitForDebounce();
|
||||
|
||||
runYarnBuild();
|
||||
});
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
import { execSync } from "child_process";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
const testDir = "keycloakify_starter_test";
|
||||
|
||||
if (existsSync(path.join(process.cwd(), testDir))) {
|
||||
rmSync(path.join(process.cwd(), testDir), { recursive: true });
|
||||
}
|
||||
// Build and link package
|
||||
execSync("yarn build");
|
||||
const pkgJSON = JSON.parse(readFileSync(path.join(process.cwd(), "package.json")).toString("utf8"));
|
||||
pkgJSON.main = "./index.js";
|
||||
pkgJSON.types = "./index.d.ts";
|
||||
pkgJSON.scripts.prepare = undefined;
|
||||
writeFileSync(path.join(process.cwd(), "dist", "package.json"), JSON.stringify(pkgJSON));
|
||||
// Wrapped in a try/catch because unlink errors if the package isn't linked
|
||||
try {
|
||||
execSync("yarn unlink");
|
||||
} catch {}
|
||||
execSync("yarn link", { "cwd": path.join(process.cwd(), "dist") });
|
||||
|
||||
// Clone latest keycloakify-starter and link to keycloakify output
|
||||
execSync(`git clone https://github.com/keycloakify/keycloakify-starter.git ${testDir}`);
|
||||
execSync("yarn install", { "cwd": path.join(process.cwd(), testDir) });
|
||||
execSync("yarn link keycloakify", { "cwd": path.join(process.cwd(), testDir) });
|
||||
|
||||
//Ensure keycloak theme can be built
|
||||
execSync("yarn build-keycloak-theme", { "cwd": path.join(process.cwd(), testDir) });
|
21
src/PUBLIC_URL.ts
Normal file
21
src/PUBLIC_URL.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "keycloakify/bin/shared/constants";
|
||||
import { assert } from "tsafe/assert";
|
||||
|
||||
/**
|
||||
* This is an equivalent of process.env.PUBLIC_URL that you can use in Webpack projects.
|
||||
* This works both in your main app and in your Keycloak theme.
|
||||
*/
|
||||
export const PUBLIC_URL = (() => {
|
||||
const kcContext = (window as any).kcContext;
|
||||
|
||||
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}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}`;
|
||||
})();
|
41
src/account/DefaultPage.tsx
Normal file
41
src/account/DefaultPage.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import type { KcContext } from "keycloakify/account/KcContext";
|
||||
import { I18n } from "keycloakify/account/i18n";
|
||||
|
||||
const Password = lazy(() => import("keycloakify/account/pages/Password"));
|
||||
const Account = lazy(() => import("keycloakify/account/pages/Account"));
|
||||
const Sessions = lazy(() => import("keycloakify/account/pages/Sessions"));
|
||||
const Totp = lazy(() => import("keycloakify/account/pages/Totp"));
|
||||
const Applications = lazy(() => import("keycloakify/account/pages/Applications"));
|
||||
const Log = lazy(() => import("keycloakify/account/pages/Log"));
|
||||
const FederatedIdentity = lazy(() => import("keycloakify/account/pages/FederatedIdentity"));
|
||||
|
||||
export default function DefaultPage(props: PageProps<KcContext, I18n>) {
|
||||
const { kcContext, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
{(() => {
|
||||
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} />;
|
||||
case "federatedIdentity.ftl":
|
||||
return <FederatedIdentity kcContext={kcContext} {...rest} />;
|
||||
}
|
||||
assert<Equals<typeof kcContext, never>>(false);
|
||||
})()}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import type { I18n } from "keycloakify/account/i18n";
|
||||
import type { KcContext } from "./kcContext";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
|
||||
const Password = lazy(() => import("keycloakify/account/pages/Password"));
|
||||
const Account = lazy(() => import("keycloakify/account/pages/Account"));
|
||||
|
||||
export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
const { kcContext, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
{(() => {
|
||||
switch (kcContext.pageId) {
|
||||
case "password.ftl":
|
||||
return <Password kcContext={kcContext} {...rest} />;
|
||||
case "account.ftl":
|
||||
return <Account kcContext={kcContext} {...rest} />;
|
||||
}
|
||||
assert<Equals<typeof kcContext, never>>(false);
|
||||
})()}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
310
src/account/KcContext/KcContext.ts
Normal file
310
src/account/KcContext/KcContext.ts
Normal file
@ -0,0 +1,310 @@
|
||||
import type { ThemeType, AccountThemePageId } from "keycloakify/bin/shared/constants";
|
||||
import type { ValueOf } from "keycloakify/tools/ValueOf";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
|
||||
export type ExtendKcContext<
|
||||
KcContextExtension extends { properties?: Record<string, string | undefined> },
|
||||
KcContextExtensionPerPage extends Record<string, Record<string, unknown>>
|
||||
> = ValueOf<{
|
||||
[PageId in keyof KcContextExtensionPerPage | KcContext["pageId"]]: Extract<
|
||||
KcContext,
|
||||
{ pageId: PageId }
|
||||
> extends never
|
||||
? KcContext.Common &
|
||||
KcContextExtension & {
|
||||
pageId: PageId;
|
||||
} & KcContextExtensionPerPage[PageId]
|
||||
: Extract<KcContext, { pageId: PageId }> &
|
||||
KcContextExtension &
|
||||
KcContextExtensionPerPage[PageId];
|
||||
}>;
|
||||
|
||||
export type KcContext =
|
||||
| KcContext.Password
|
||||
| KcContext.Account
|
||||
| KcContext.Sessions
|
||||
| KcContext.Totp
|
||||
| KcContext.Applications
|
||||
| KcContext.Log
|
||||
| KcContext.FederatedIdentity;
|
||||
|
||||
export declare namespace KcContext {
|
||||
export type Common = {
|
||||
themeVersion: string;
|
||||
keycloakifyVersion: string;
|
||||
themeType: "account";
|
||||
themeName: string;
|
||||
locale?: {
|
||||
supported: {
|
||||
url: string;
|
||||
label: string;
|
||||
languageTag: string;
|
||||
}[];
|
||||
currentLanguageTag: string;
|
||||
};
|
||||
url: {
|
||||
accountUrl: string;
|
||||
passwordUrl: string;
|
||||
totpUrl: string;
|
||||
socialUrl: string;
|
||||
sessionsUrl: string;
|
||||
applicationsUrl: string;
|
||||
logUrl: string;
|
||||
logoutUrl: string;
|
||||
resourceUrl: string;
|
||||
resourcesCommonPath: string;
|
||||
resourcesPath: string;
|
||||
/** @deprecated, not present in recent keycloak version apparently, use kcContext.referrer instead */
|
||||
referrerURI?: string;
|
||||
getLogoutUrl: () => string;
|
||||
};
|
||||
features: {
|
||||
passwordUpdateSupported: boolean;
|
||||
identityFederation: boolean;
|
||||
log: boolean;
|
||||
authorization: boolean;
|
||||
};
|
||||
realm: {
|
||||
internationalizationEnabled: boolean;
|
||||
userManagedAccessAllowed: boolean;
|
||||
};
|
||||
// Present only if redirected to account page with ?referrer=xxx&referrer_uri=http...
|
||||
message?: {
|
||||
type: "success" | "warning" | "error" | "info";
|
||||
summary: string;
|
||||
};
|
||||
referrer?: {
|
||||
url: string; // The url of the App
|
||||
name: string; // Client id
|
||||
};
|
||||
messagesPerField: {
|
||||
/**
|
||||
* 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: {
|
||||
email?: string;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
username?: string;
|
||||
};
|
||||
properties: {};
|
||||
"x-keycloakify": {
|
||||
messages: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
export type Password = Common & {
|
||||
pageId: "password.ftl";
|
||||
password: {
|
||||
passwordSet: boolean;
|
||||
};
|
||||
stateChecker: string;
|
||||
};
|
||||
|
||||
export type Account = Common & {
|
||||
pageId: "account.ftl";
|
||||
url: {
|
||||
accountUrl: string;
|
||||
};
|
||||
realm: {
|
||||
registrationEmailAsUsername: boolean;
|
||||
editUsernameAllowed: boolean;
|
||||
};
|
||||
stateChecker: string;
|
||||
};
|
||||
|
||||
export type Sessions = Common & {
|
||||
pageId: "sessions.ftl";
|
||||
sessions: {
|
||||
sessions: {
|
||||
expires: string;
|
||||
clients: string[];
|
||||
ipAddress: string;
|
||||
started: string;
|
||||
lastAccess: string;
|
||||
id: 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;
|
||||
getAlgorithmKey: () => string;
|
||||
} & (
|
||||
| {
|
||||
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;
|
||||
compositesStream?: Record<string, unknown>;
|
||||
clientRole?: boolean;
|
||||
composite?: boolean;
|
||||
id?: string;
|
||||
containerId?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
}[];
|
||||
resourceRolesAvailable: Record<
|
||||
string,
|
||||
{
|
||||
roleName: string;
|
||||
roleDescription?: string;
|
||||
clientName: string;
|
||||
clientId: string;
|
||||
}[]
|
||||
>;
|
||||
additionalGrants: string[];
|
||||
clientScopesGranted: string[];
|
||||
effectiveUrl?: string;
|
||||
client: {
|
||||
alwaysDisplayInConsole: boolean;
|
||||
attributes: Record<string, unknown>;
|
||||
authenticationFlowBindingOverrides: Record<string, unknown>;
|
||||
baseUrl?: string;
|
||||
bearerOnly: boolean;
|
||||
clientAuthenticatorType: string;
|
||||
clientId: string;
|
||||
consentRequired: boolean;
|
||||
consentScreenText: string;
|
||||
description: string;
|
||||
directAccessGrantsEnabled: boolean;
|
||||
displayOnConsentScreen: boolean;
|
||||
dynamicScope: boolean;
|
||||
enabled: boolean;
|
||||
frontchannelLogout: boolean;
|
||||
fullScopeAllowed: boolean;
|
||||
id: string;
|
||||
implicitFlowEnabled: boolean;
|
||||
includeInTokenScope: boolean;
|
||||
managementUrl: string;
|
||||
name?: string;
|
||||
nodeReRegistrationTimeout: string;
|
||||
notBefore: string;
|
||||
protocol: string;
|
||||
protocolMappersStream: Record<string, unknown>;
|
||||
publicClient: boolean;
|
||||
realm: Record<string, unknown>;
|
||||
realmScopeMappingsStream: Record<string, unknown>;
|
||||
redirectUris: string[];
|
||||
registeredNodes: Record<string, unknown>;
|
||||
rolesStream: Record<string, unknown>;
|
||||
rootUrl?: string;
|
||||
scopeMappingsStream: Record<string, unknown>;
|
||||
secret: string;
|
||||
serviceAccountsEnabled: boolean;
|
||||
standardFlowEnabled: boolean;
|
||||
surrogateAuthRequired: boolean;
|
||||
webOrigins: string[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type Log = Common & {
|
||||
pageId: "log.ftl";
|
||||
log: {
|
||||
events: {
|
||||
date: string | number | Date;
|
||||
event: string;
|
||||
ipAddress: string;
|
||||
client: string;
|
||||
details: { value: string; key: string }[];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type FederatedIdentity = Common & {
|
||||
pageId: "federatedIdentity.ftl";
|
||||
stateChecker: string;
|
||||
federatedIdentity: {
|
||||
identities: {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
userName: string;
|
||||
connected: boolean;
|
||||
}[];
|
||||
removeLinkPossible: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
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>();
|
69
src/account/KcContext/getKcContextMock.ts
Normal file
69
src/account/KcContext/getKcContextMock.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import type { ExtendKcContext, KcContext as KcContextBase } from "./KcContext";
|
||||
import type { AccountThemePageId } from "keycloakify/bin/shared/constants";
|
||||
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
|
||||
import { deepAssign } from "keycloakify/tools/deepAssign";
|
||||
import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions";
|
||||
import { kcContextMocks, kcContextCommonMock } from "./kcContextMocks";
|
||||
import { exclude } from "tsafe/exclude";
|
||||
|
||||
export function createGetKcContextMock<
|
||||
KcContextExtension extends { properties?: Record<string, string | undefined> },
|
||||
KcContextExtensionPerPage extends Record<`${string}.ftl`, Record<string, unknown>>
|
||||
>(params: {
|
||||
kcContextExtension: KcContextExtension;
|
||||
kcContextExtensionPerPage: KcContextExtensionPerPage;
|
||||
overrides?: DeepPartial<KcContextExtension & KcContextBase.Common>;
|
||||
overridesPerPage?: {
|
||||
[PageId in AccountThemePageId | keyof KcContextExtensionPerPage]?: DeepPartial<
|
||||
Extract<
|
||||
ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>,
|
||||
{ pageId: PageId }
|
||||
>
|
||||
>;
|
||||
};
|
||||
}) {
|
||||
const {
|
||||
kcContextExtension,
|
||||
kcContextExtensionPerPage,
|
||||
overrides: overrides_global,
|
||||
overridesPerPage: overridesPerPage_global
|
||||
} = params;
|
||||
|
||||
type KcContext = ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>;
|
||||
|
||||
function getKcContextMock<
|
||||
PageId extends AccountThemePageId | keyof KcContextExtensionPerPage
|
||||
>(params: {
|
||||
pageId: PageId;
|
||||
overrides?: DeepPartial<Extract<KcContext, { pageId: PageId }>>;
|
||||
}): Extract<KcContext, { pageId: PageId }> {
|
||||
const { pageId, overrides } = params;
|
||||
|
||||
const kcContextMock = structuredCloneButFunctions(
|
||||
kcContextMocks.find(kcContextMock => kcContextMock.pageId === pageId) ?? {
|
||||
...kcContextCommonMock,
|
||||
pageId
|
||||
}
|
||||
);
|
||||
|
||||
[
|
||||
kcContextExtension,
|
||||
kcContextExtensionPerPage[pageId],
|
||||
overrides_global,
|
||||
overridesPerPage_global?.[pageId],
|
||||
overrides
|
||||
]
|
||||
.filter(exclude(undefined))
|
||||
.forEach(overrides =>
|
||||
deepAssign({
|
||||
target: kcContextMock,
|
||||
source: overrides
|
||||
})
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
return kcContextMock;
|
||||
}
|
||||
|
||||
return { getKcContextMock };
|
||||
}
|
2
src/account/KcContext/index.ts
Normal file
2
src/account/KcContext/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export type { ExtendKcContext, KcContext } from "./KcContext";
|
||||
export { createGetKcContextMock } from "./getKcContextMock";
|
193
src/account/KcContext/kcContextMocks.ts
Normal file
193
src/account/KcContext/kcContextMocks.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import "keycloakify/tools/Object.fromEntries";
|
||||
import { RESOURCES_COMMON, KEYCLOAK_RESOURCES } from "keycloakify/bin/shared/constants";
|
||||
import { id } from "tsafe/id";
|
||||
import type { KcContext } from "./KcContext";
|
||||
import { BASE_URL } from "keycloakify/lib/BASE_URL";
|
||||
|
||||
const resourcesPath = `${BASE_URL}${KEYCLOAK_RESOURCES}/account/resources`;
|
||||
|
||||
export const kcContextCommonMock: KcContext.Common = {
|
||||
themeVersion: "0.0.0",
|
||||
keycloakifyVersion: "0.0.0",
|
||||
themeType: "account",
|
||||
themeName: "my-theme-name",
|
||||
url: {
|
||||
resourcesPath,
|
||||
resourcesCommonPath: `${resourcesPath}/${RESOURCES_COMMON}`,
|
||||
resourceUrl: "#",
|
||||
accountUrl: "#",
|
||||
applicationsUrl: "#",
|
||||
logoutUrl: "#",
|
||||
getLogoutUrl: () => "#",
|
||||
logUrl: "#",
|
||||
passwordUrl: "#",
|
||||
sessionsUrl: "#",
|
||||
socialUrl: "#",
|
||||
totpUrl: "#"
|
||||
},
|
||||
realm: {
|
||||
internationalizationEnabled: true,
|
||||
userManagedAccessAllowed: true
|
||||
},
|
||||
messagesPerField: {
|
||||
printIfExists: () => {
|
||||
return undefined;
|
||||
},
|
||||
existsError: () => false,
|
||||
get: key => `Fake error for ${key}`,
|
||||
exists: () => false
|
||||
},
|
||||
locale: {
|
||||
supported: [
|
||||
/* spell-checker: disable */
|
||||
["de", "Deutsch"],
|
||||
["no", "Norsk"],
|
||||
["ru", "Русский"],
|
||||
["sv", "Svenska"],
|
||||
["pt-BR", "Português (Brasil)"],
|
||||
["lt", "Lietuvių"],
|
||||
["en", "English"],
|
||||
["it", "Italiano"],
|
||||
["fr", "Français"],
|
||||
["zh-CN", "中文简体"],
|
||||
["es", "Español"],
|
||||
["cs", "Čeština"],
|
||||
["ja", "日本語"],
|
||||
["sk", "Slovenčina"],
|
||||
["pl", "Polski"],
|
||||
["ca", "Català"],
|
||||
["nl", "Nederlands"],
|
||||
["tr", "Türkçe"]
|
||||
/* spell-checker: enable */
|
||||
].map(
|
||||
([languageTag, label]) =>
|
||||
({
|
||||
languageTag,
|
||||
label,
|
||||
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
|
||||
}) as const
|
||||
),
|
||||
currentLanguageTag: "en"
|
||||
},
|
||||
features: {
|
||||
authorization: true,
|
||||
identityFederation: true,
|
||||
log: true,
|
||||
passwordUpdateSupported: true
|
||||
},
|
||||
referrer: undefined,
|
||||
account: {
|
||||
firstName: "john",
|
||||
lastName: "doe",
|
||||
email: "john.doe@code.gouv.fr",
|
||||
username: "doe_j"
|
||||
},
|
||||
properties: {},
|
||||
"x-keycloakify": {
|
||||
messages: {}
|
||||
}
|
||||
};
|
||||
|
||||
export const kcContextMocks: KcContext[] = [
|
||||
id<KcContext.Password>({
|
||||
...kcContextCommonMock,
|
||||
pageId: "password.ftl",
|
||||
password: {
|
||||
passwordSet: true
|
||||
},
|
||||
stateChecker: "state checker"
|
||||
}),
|
||||
id<KcContext.Account>({
|
||||
...kcContextCommonMock,
|
||||
pageId: "account.ftl",
|
||||
url: {
|
||||
...kcContextCommonMock.url,
|
||||
referrerURI: "#",
|
||||
accountUrl: "#"
|
||||
},
|
||||
realm: {
|
||||
...kcContextCommonMock.realm,
|
||||
registrationEmailAsUsername: true,
|
||||
editUsernameAllowed: true
|
||||
},
|
||||
stateChecker: ""
|
||||
}),
|
||||
id<KcContext.Sessions>({
|
||||
...kcContextCommonMock,
|
||||
pageId: "sessions.ftl",
|
||||
sessions: {
|
||||
sessions: [
|
||||
{
|
||||
ipAddress: "127.0.0.1",
|
||||
started: new Date().toString(),
|
||||
lastAccess: new Date().toString(),
|
||||
expires: new Date().toString(),
|
||||
clients: ["Chrome", "Firefox"],
|
||||
id: "f8951177-817d-4a70-9c02-86d3c170fe51"
|
||||
}
|
||||
]
|
||||
},
|
||||
stateChecker: "g6WB1FaYnKotTkiy7ZrlxvFztSqS0U8jvHsOOOb2z4g"
|
||||
}),
|
||||
id<KcContext.Totp>({
|
||||
...kcContextCommonMock,
|
||||
pageId: "totp.ftl",
|
||||
totp: {
|
||||
enabled: true,
|
||||
totpSecretEncoded: "KVVF G2BY N4YX S6LB IUYT K2LH IFYE 4SBV",
|
||||
qrUrl: "#",
|
||||
totpSecretQrCode:
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAPYAAAD2AQAAAADNaUdlAAACM0lEQVR4Xu3OIZJgOQwDUDFd2UxiurLAVnnbHw4YGDKtSiWOn4Gxf81//7r/+q8b4HfLGBZDK9d85NmNR+sB42sXvOYrN5P1DcgYYFTGfOlbzE8gzwy3euweGizw7cfdl34/GRhlkxjKNV+5AebPXPORX1JuB9x8ZfbyyD2y1krWAKsbMq1HnqQDaLfa77p4+MqvzEGSqvSAD/2IHW2yHaigR9tX3m8dDIYGcNf3f+gDpVBZbZU77zyJ6Rlcy+qoTMG887KAPD9hsh6a1Sv3gJUHGHUAxSMzj7zqDDe7Phmt2eG+8UsMxjRGm816MAO+8VMl1R1jGHOrZB/5Zo/WXAPgxixm9Mo96vDGrM1eOto8c4Ax4wF437mifOXlpiPzCnN7Y9l95NnEMxgMY9AAGA8fucH14Y1aVb6N/cqrmyh0BVht7k1e+bU8LK0Cg5vmVq9c5vHIjOfqxDIfeTraNVTwewa4wVe+SW5N+uP1qACeudUZbqGOfA6VZV750Noq2Xx3kpveV44ZelSV1V7KFHzkWyVrrlUwG0Pl9pWnoy3vsQoME6vKI69i5osVgwWzHT7zjmJtMcNUSVn1oYMd7ZodbgowZl45VG0uVuLPUr1yc79uaQBag/mqR34xhlWyHm1prplHboCWdZ4TeZjsK8+dI+jbz1C5hl65mcpgB5dhcj8+dGO+0Ko68+lD37JDD83dpDLzzK+TrQyaVwGj6pUboGV+7+AyN8An/pf84/7rv/4/1l4OCc/1BYMAAAAASUVORK5CYII=",
|
||||
manualUrl: "#",
|
||||
totpSecret: "G4nsI8lQagRMUchH8jEG",
|
||||
otpCredentials: [],
|
||||
supportedApplications: [
|
||||
"totpAppFreeOTPName",
|
||||
"totpAppMicrosoftAuthenticatorName",
|
||||
"totpAppGoogleName"
|
||||
],
|
||||
policy: {
|
||||
algorithm: "HmacSHA1",
|
||||
digits: 6,
|
||||
lookAheadWindow: 1,
|
||||
type: "totp",
|
||||
period: 30,
|
||||
getAlgorithmKey: () => "SHA1"
|
||||
}
|
||||
},
|
||||
mode: "qr",
|
||||
isAppInitiatedAction: false,
|
||||
stateChecker: ""
|
||||
}),
|
||||
id<KcContext.Log>({
|
||||
...kcContextCommonMock,
|
||||
pageId: "log.ftl",
|
||||
log: {
|
||||
events: [
|
||||
{
|
||||
date: "2/21/2024, 1:28:39 PM",
|
||||
event: "login",
|
||||
ipAddress: "172.17.0.1",
|
||||
client: "security-admin-console",
|
||||
details: [{ key: "openid-connect", value: "admin" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
id<KcContext.FederatedIdentity>({
|
||||
...kcContextCommonMock,
|
||||
stateChecker: "",
|
||||
pageId: "federatedIdentity.ftl",
|
||||
federatedIdentity: {
|
||||
identities: [
|
||||
{
|
||||
providerId: "keycloak-oidc",
|
||||
displayName: "keycloak-oidc",
|
||||
userName: "John",
|
||||
connected: true
|
||||
}
|
||||
],
|
||||
removeLinkPossible: true
|
||||
}
|
||||
})
|
||||
];
|
@ -1,32 +1,60 @@
|
||||
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 type { KcContext } from "./kcContext";
|
||||
import type { I18n } from "./i18n";
|
||||
import { useEffect } from "react";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
||||
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
|
||||
import { useSetClassName } from "keycloakify/tools/useSetClassName";
|
||||
import type { TemplateProps } from "keycloakify/account/TemplateProps";
|
||||
import type { I18n } from "./i18n";
|
||||
import type { KcContext } from "./KcContext";
|
||||
|
||||
export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props;
|
||||
|
||||
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
|
||||
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
|
||||
|
||||
const { msg, changeLocale, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
|
||||
const { msg, msgStr, getChangeLocaleUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
|
||||
|
||||
const { locale, url, features, realm, message, referrer } = kcContext;
|
||||
|
||||
const { isReady } = usePrepareTemplate({
|
||||
"doFetchDefaultThemeResources": doUseDefaultCss,
|
||||
"styles": [
|
||||
`${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`
|
||||
],
|
||||
"htmlClassName": getClassName("kcHtmlClass"),
|
||||
"bodyClassName": clsx("admin-console", "user", getClassName("kcBodyClass"))
|
||||
useEffect(() => {
|
||||
document.title = msgStr("accountManagementTitle");
|
||||
}, []);
|
||||
|
||||
useSetClassName({
|
||||
qualifiedName: "html",
|
||||
className: kcClsx("kcHtmlClass")
|
||||
});
|
||||
|
||||
if (!isReady) {
|
||||
useSetClassName({
|
||||
qualifiedName: "body",
|
||||
className: clsx("admin-console", "user", kcClsx("kcBodyClass"))
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { currentLanguageTag } = locale ?? {};
|
||||
|
||||
if (currentLanguageTag === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = document.querySelector("html");
|
||||
assert(html !== null);
|
||||
html.lang = currentLanguageTag;
|
||||
}, []);
|
||||
|
||||
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
|
||||
componentOrHookName: "Template",
|
||||
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;
|
||||
}
|
||||
|
||||
@ -45,17 +73,13 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
|
||||
<li>
|
||||
<div className="kc-dropdown" id="kc-locale-dropdown">
|
||||
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
||||
<a href="#" id="kc-current-locale-link">
|
||||
{labelBySupportedLanguageTag[currentLanguageTag]}
|
||||
</a>
|
||||
<ul>
|
||||
{locale.supported.map(({ languageTag }) => (
|
||||
<li key={languageTag} className="kc-dropdown-item">
|
||||
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
||||
<a href="#" onClick={() => changeLocale(languageTag)}>
|
||||
{labelBySupportedLanguageTag[languageTag]}
|
||||
</a>
|
||||
<a href={getChangeLocaleUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -121,7 +145,12 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
<div className={clsx("alert", `alert-${message.type}`)}>
|
||||
{message.type === "success" && <span className="pficon pficon-ok"></span>}
|
||||
{message.type === "error" && <span className="pficon pficon-error-circle-o"></span>}
|
||||
<span className="kc-feedback-text">{message.summary}</span>
|
||||
<span
|
||||
className="kc-feedback-text"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: message.summary
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -1,14 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { KcContext } from "./kcContext";
|
||||
import type { I18n } from "./i18n";
|
||||
|
||||
export type TemplateProps<KcContext extends KcContext.Common, I18nExtended extends I18n> = {
|
||||
export type TemplateProps<KcContext, I18n> = {
|
||||
kcContext: KcContext;
|
||||
i18n: I18nExtended;
|
||||
i18n: I18n;
|
||||
doUseDefaultCss: boolean;
|
||||
active: string;
|
||||
classes?: Partial<Record<ClassKey, string>>;
|
||||
children: ReactNode;
|
||||
|
||||
active: string;
|
||||
};
|
||||
|
||||
export type ClassKey = "kcHtmlClass" | "kcBodyClass" | "kcButtonClass" | "kcButtonPrimaryClass" | "kcButtonLargeClass" | "kcButtonDefaultClass";
|
||||
export type ClassKey =
|
||||
| "kcHtmlClass"
|
||||
| "kcBodyClass"
|
||||
| "kcButtonClass"
|
||||
| "kcButtonPrimaryClass"
|
||||
| "kcButtonLargeClass"
|
||||
| "kcButtonDefaultClass"
|
||||
| "kcContentWrapperClass"
|
||||
| "kcFormClass"
|
||||
| "kcFormGroupClass"
|
||||
| "kcInputWrapperClass"
|
||||
| "kcLabelClass"
|
||||
| "kcInputClass"
|
||||
| "kcInputErrorMessageClass";
|
||||
|
6
src/account/i18n/GenericI18n.tsx
Normal file
6
src/account/i18n/GenericI18n.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import type { GenericI18n_noJsx } from "./i18n";
|
||||
|
||||
export type GenericI18n<MessageKey extends string> = GenericI18n_noJsx<MessageKey> & {
|
||||
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
|
||||
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
|
||||
};
|
@ -1,26 +1,24 @@
|
||||
import "minimal-polyfills/Object.fromEntries";
|
||||
//NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import fallbackMessages from "./baseMessages/en";
|
||||
import { getMessages } from "./baseMessages";
|
||||
import "keycloakify/tools/Object.fromEntries";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { KcContext } from "../kcContext/KcContext";
|
||||
import { Markdown } from "keycloakify/tools/Markdown";
|
||||
|
||||
export const fallbackLanguageTag = "en";
|
||||
import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en";
|
||||
import { fetchMessages_defaultSet } from "./messages_defaultSet";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
|
||||
import { id } from "tsafe/id";
|
||||
|
||||
export type KcContextLike = {
|
||||
locale?: {
|
||||
currentLanguageTag: string;
|
||||
supported: { languageTag: string; url: string; label: string }[];
|
||||
};
|
||||
"x-keycloakify": {
|
||||
messages: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
assert<KcContext extends KcContextLike ? true : false>();
|
||||
|
||||
export type MessageKey = keyof typeof fallbackMessages | keyof (typeof keycloakifyExtraMessages)[typeof fallbackLanguageTag];
|
||||
|
||||
export type GenericI18n<MessageKey extends string> = {
|
||||
export type GenericI18n_noJsx<MessageKey extends string> = {
|
||||
/**
|
||||
* e.g: "en", "fr", "zh-CN"
|
||||
*
|
||||
@ -28,11 +26,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;
|
||||
getChangeLocaleUrl: (newLanguageTag: string) => string;
|
||||
/**
|
||||
* e.g. "en" => "English", "fr" => "Français", ...
|
||||
*
|
||||
@ -41,113 +38,165 @@ export type GenericI18n<MessageKey extends string> = {
|
||||
* */
|
||||
labelBySupportedLanguageTag: Record<string, string>;
|
||||
/**
|
||||
* Examples assuming currentLanguageTag === "en"
|
||||
*
|
||||
* msg("access-denied") === <span>Access denied</span>
|
||||
* msg("impersonateTitleHtml", "Foo") === <span><strong>Foo</strong> Impersonate User</span>
|
||||
*/
|
||||
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
|
||||
/**
|
||||
* It's the same thing as msg() but instead of returning a JSX.Element it returns a string.
|
||||
* It can be more convenient to manipulate strings but if there are HTML tags it wont render.
|
||||
* Examples assuming currentLanguageTag === "en"
|
||||
* {
|
||||
* en: {
|
||||
* "access-denied": "Access denied",
|
||||
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
|
||||
* "bar": "Bar {0}"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* msgStr("access-denied") === "Access denied"
|
||||
* msgStr("not-a-message-key") Throws an error
|
||||
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
|
||||
* msgStr("${bar}", "<strong>c</strong>") === "Bar <strong>XXX</strong>"
|
||||
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
|
||||
*/
|
||||
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
|
||||
/**
|
||||
* This is meant to be used when the key argument is variable, something that might have been configured by the user
|
||||
* in the Keycloak admin for example.
|
||||
*
|
||||
* Examples assuming currentLanguageTag === "en"
|
||||
* advancedMsg("${access-denied} foo bar") === <span>${msgStr("access-denied")} foo bar<span> === <span>Access denied foo bar</span>
|
||||
* advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied") === <span>Access denied</span>
|
||||
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === <span>not-a-message-key</span>
|
||||
*/
|
||||
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
|
||||
/**
|
||||
* Examples assuming currentLanguageTag === "en"
|
||||
* advancedMsg("${access-denied} foo bar") === msg("access-denied") + " foo bar" === "Access denied foo bar"
|
||||
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === "not-a-message-key"
|
||||
* {
|
||||
* en: {
|
||||
* "access-denied": "Access denied",
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
|
||||
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
|
||||
*/
|
||||
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
|
||||
|
||||
/**
|
||||
* Initially the messages are in english (fallback language).
|
||||
* The translations in the current language are being fetched dynamically.
|
||||
* This property is true while the translations are being fetched.
|
||||
*/
|
||||
isFetchingTranslations: boolean;
|
||||
};
|
||||
|
||||
export type I18n = GenericI18n<MessageKey>;
|
||||
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
|
||||
|
||||
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
|
||||
[languageTag: string]: { [key in ExtraMessageKey]: string };
|
||||
export function createGetI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag_themeDefined: {
|
||||
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
|
||||
}) {
|
||||
function useI18n(params: { kcContext: KcContextLike }): GenericI18n<MessageKey | ExtraMessageKey> | null {
|
||||
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>;
|
||||
|
||||
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
|
||||
|
||||
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
|
||||
|
||||
function getI18n(params: { kcContext: KcContextLike }): Result {
|
||||
const { kcContext } = params;
|
||||
|
||||
const [i18n, setI18n] = useState<GenericI18n<ExtraMessageKey | MessageKey> | undefined>(undefined);
|
||||
use_cache: {
|
||||
const cachedResult = cachedResultByKcContext.get(kcContext);
|
||||
|
||||
const refHasStartedFetching = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (refHasStartedFetching.current) {
|
||||
return;
|
||||
if (cachedResult === undefined) {
|
||||
break use_cache;
|
||||
}
|
||||
|
||||
refHasStartedFetching.current = true;
|
||||
|
||||
(async () => {
|
||||
const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {};
|
||||
|
||||
setI18n({
|
||||
...createI18nTranslationFunctions({
|
||||
"fallbackMessages": {
|
||||
...fallbackMessages,
|
||||
...(keycloakifyExtraMessages[fallbackLanguageTag] ?? {}),
|
||||
...(extraMessages[fallbackLanguageTag] ?? {})
|
||||
} as any,
|
||||
"messages": {
|
||||
...(await getMessages(currentLanguageTag)),
|
||||
...((keycloakifyExtraMessages as any)[currentLanguageTag] ?? {}),
|
||||
...(extraMessages[currentLanguageTag] ?? {})
|
||||
} as any
|
||||
}),
|
||||
currentLanguageTag,
|
||||
"changeLocale": newLanguageTag => {
|
||||
const { locale } = kcContext;
|
||||
|
||||
assert(locale !== undefined, "Internationalization not enabled");
|
||||
|
||||
const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag);
|
||||
|
||||
assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`);
|
||||
|
||||
window.location.href = targetSupportedLocale.url;
|
||||
|
||||
assert(false, "never");
|
||||
},
|
||||
"labelBySupportedLanguageTag": Object.fromEntries(
|
||||
(kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])
|
||||
)
|
||||
});
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return i18n ?? null;
|
||||
}
|
||||
|
||||
return { useI18n };
|
||||
}
|
||||
|
||||
function createI18nTranslationFunctions<MessageKey extends string>(params: {
|
||||
fallbackMessages: Record<MessageKey, string>;
|
||||
messages: Record<MessageKey, string>;
|
||||
}): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
|
||||
const { fallbackMessages, messages } = params;
|
||||
|
||||
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): string | JSX.Element | undefined {
|
||||
const { key, args, doRenderMarkdown } = props;
|
||||
|
||||
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key];
|
||||
|
||||
if (messageOrUndefined === undefined) {
|
||||
return undefined;
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const message = messageOrUndefined;
|
||||
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocaleUrl" | "labelBySupportedLanguageTag"> = {
|
||||
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG,
|
||||
getChangeLocaleUrl: newLanguageTag => {
|
||||
const { locale } = kcContext;
|
||||
|
||||
assert(locale !== undefined, "Internationalization not enabled");
|
||||
|
||||
const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag);
|
||||
|
||||
assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`);
|
||||
|
||||
return targetSupportedLocale.url;
|
||||
},
|
||||
labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]))
|
||||
};
|
||||
|
||||
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
|
||||
messages_themeDefined:
|
||||
messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ??
|
||||
messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG] ??
|
||||
(() => {
|
||||
const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0];
|
||||
if (firstLanguageTag === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return messagesByLanguageTag_themeDefined[firstLanguageTag];
|
||||
})(),
|
||||
messages_fromKcServer: kcContext["x-keycloakify"].messages
|
||||
});
|
||||
|
||||
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === FALLBACK_LANGUAGE_TAG;
|
||||
|
||||
const result: Result = {
|
||||
i18n: {
|
||||
...partialI18n,
|
||||
...createI18nTranslationFunctions({
|
||||
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
|
||||
}),
|
||||
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
|
||||
},
|
||||
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
|
||||
? undefined
|
||||
: (async () => {
|
||||
const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag);
|
||||
|
||||
const i18n_currentLanguage: I18n = {
|
||||
...partialI18n,
|
||||
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
|
||||
isFetchingTranslations: false
|
||||
};
|
||||
|
||||
// NOTE: This promise.resolve is just because without it we TypeScript
|
||||
// gives a Variable 'result' is used before being assigned. error
|
||||
await Promise.resolve().then(() => {
|
||||
result.i18n = i18n_currentLanguage;
|
||||
result.prI18n_currentLanguage = undefined;
|
||||
});
|
||||
|
||||
return i18n_currentLanguage;
|
||||
})()
|
||||
};
|
||||
|
||||
cachedResultByKcContext.set(kcContext, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return { getI18n };
|
||||
}
|
||||
|
||||
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
|
||||
messages_themeDefined: Record<MessageKey_themeDefined, string> | undefined;
|
||||
messages_fromKcServer: Record<string, string>;
|
||||
}) {
|
||||
const { messages_themeDefined, messages_fromKcServer } = params;
|
||||
|
||||
function createI18nTranslationFunctions(params: {
|
||||
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
|
||||
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>, "msgStr" | "advancedMsgStr"> {
|
||||
const { messages_defaultSet_currentLanguage } = params;
|
||||
|
||||
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
|
||||
const { key, args } = props;
|
||||
|
||||
const message =
|
||||
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
|
||||
id<Record<string, string | undefined> | undefined>(messages_themeDefined)?.[key] ??
|
||||
id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
|
||||
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];
|
||||
|
||||
if (message === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const messageWithArgsInjectedIfAny = (() => {
|
||||
const startIndex = message
|
||||
.match(/{[0-9]+}/g)
|
||||
?.map(g => g.match(/{([0-9]+)}/)![1])
|
||||
@ -166,68 +215,36 @@ function createI18nTranslationFunctions<MessageKey extends string>(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
messageWithArgsInjected = messageWithArgsInjected.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg);
|
||||
messageWithArgsInjected = messageWithArgsInjected.replace(
|
||||
new RegExp(`\\{${i + startIndex}\\}`, "g"),
|
||||
arg.replace(/</g, "<").replace(/>/g, ">")
|
||||
);
|
||||
});
|
||||
|
||||
return messageWithArgsInjected;
|
||||
})();
|
||||
}
|
||||
|
||||
return doRenderMarkdown ? (
|
||||
<Markdown allowDangerousHtml renderers={{ "paragraph": "span" }}>
|
||||
{messageWithArgsInjectedIfAny}
|
||||
</Markdown>
|
||||
) : (
|
||||
messageWithArgsInjectedIfAny
|
||||
);
|
||||
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
|
||||
const { key, args } = props;
|
||||
|
||||
const match = key.match(/^\$\{(.+)\}$/);
|
||||
|
||||
if (match === null) {
|
||||
return key;
|
||||
}
|
||||
|
||||
return resolveMsg({ key: match[1], args }) ?? key;
|
||||
}
|
||||
|
||||
return {
|
||||
msgStr: (key, ...args) => {
|
||||
const resolvedMessage = resolveMsg({ key, args });
|
||||
assert(resolvedMessage !== undefined, `Message with key "${key}" not found`);
|
||||
return resolvedMessage;
|
||||
},
|
||||
advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args })
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): JSX.Element | string {
|
||||
const { key, args, doRenderMarkdown } = props;
|
||||
|
||||
const match = key.match(/^\$\{([^{]+)\}$/);
|
||||
|
||||
const keyUnwrappedFromCurlyBraces = match === null ? key : match[1];
|
||||
|
||||
const out = resolveMsg({
|
||||
"key": keyUnwrappedFromCurlyBraces,
|
||||
args,
|
||||
doRenderMarkdown
|
||||
});
|
||||
|
||||
return (out !== undefined ? out : doRenderMarkdown ? <span>{keyUnwrappedFromCurlyBraces}</span> : keyUnwrappedFromCurlyBraces) as any;
|
||||
}
|
||||
|
||||
return {
|
||||
"msgStr": (key, ...args) => resolveMsg({ key, args, "doRenderMarkdown": false }) as string,
|
||||
"msg": (key, ...args) => resolveMsg({ key, args, "doRenderMarkdown": true }) as JSX.Element,
|
||||
"advancedMsg": (key, ...args) => resolveMsgAdvanced({ key, args, "doRenderMarkdown": true }) as JSX.Element,
|
||||
"advancedMsgStr": (key, ...args) => resolveMsgAdvanced({ key, args, "doRenderMarkdown": false }) as string
|
||||
};
|
||||
return { createI18nTranslationFunctions };
|
||||
}
|
||||
|
||||
const keycloakifyExtraMessages = {
|
||||
"en": {
|
||||
"shouldBeEqual": "{0} should be equal to {1}",
|
||||
"shouldBeDifferent": "{0} should be different to {1}",
|
||||
"shouldMatchPattern": "Pattern should match: `/{0}/`",
|
||||
"mustBeAnInteger": "Must be an integer",
|
||||
"notAValidOption": "Not a valid option",
|
||||
"newPasswordSameAsOld": "New password must be different from the old one",
|
||||
"passwordConfirmNotMatch": "Password confirmation does not match"
|
||||
},
|
||||
"fr": {
|
||||
/* spell-checker: disable */
|
||||
"shouldBeEqual": "{0} doit être égal à {1}",
|
||||
"shouldBeDifferent": "{0} doit être différent de {1}",
|
||||
"shouldMatchPattern": "Dois respecter le schéma: `/{0}/`",
|
||||
"mustBeAnInteger": "Doit être un nombre entier",
|
||||
"notAValidOption": "N'est pas une option valide",
|
||||
|
||||
"logoutConfirmTitle": "Déconnexion",
|
||||
"logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
|
||||
"doLogout": "Se déconnecter",
|
||||
"newPasswordSameAsOld": "Le nouveau mot de passe doit être différent de l'ancien",
|
||||
"passwordConfirmNotMatch": "La confirmation du mot de passe ne correspond pas"
|
||||
/* spell-checker: enable */
|
||||
}
|
||||
};
|
||||
|
@ -1 +1,5 @@
|
||||
export type { I18n } from "./i18n";
|
||||
import type { GenericI18n } from "./GenericI18n";
|
||||
import type { MessageKey_defaultSet, KcContextLike } from "./i18n";
|
||||
export type { MessageKey_defaultSet, KcContextLike };
|
||||
export type I18n = GenericI18n<MessageKey_defaultSet>;
|
||||
export { createUseI18n } from "./useI18n";
|
||||
|
95
src/account/i18n/useI18n.tsx
Normal file
95
src/account/i18n/useI18n.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n";
|
||||
import { GenericI18n } from "./GenericI18n";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
|
||||
export function createUseI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag: {
|
||||
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
|
||||
}) {
|
||||
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
|
||||
|
||||
type I18n = GenericI18n<MessageKey>;
|
||||
|
||||
const { withJsx } = (() => {
|
||||
const cache = new WeakMap<GenericI18n_noJsx<MessageKey>, GenericI18n<MessageKey>>();
|
||||
|
||||
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
|
||||
const { htmlString, msgKey } = params;
|
||||
return (
|
||||
<div
|
||||
data-kc-msg={msgKey}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: htmlString
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey>): I18n {
|
||||
use_cache: {
|
||||
const i18n = cache.get(i18n_noJsx);
|
||||
|
||||
if (i18n === undefined) {
|
||||
break use_cache;
|
||||
}
|
||||
|
||||
return i18n;
|
||||
}
|
||||
|
||||
const i18n: I18n = {
|
||||
...i18n_noJsx,
|
||||
msg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.msgStr(msgKey, ...args), msgKey }),
|
||||
advancedMsg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.advancedMsgStr(msgKey, ...args), msgKey })
|
||||
};
|
||||
|
||||
cache.set(i18n_noJsx, i18n);
|
||||
|
||||
return i18n;
|
||||
}
|
||||
|
||||
return { withJsx };
|
||||
})();
|
||||
|
||||
add_style: {
|
||||
const attributeName = "data-kc-i18n";
|
||||
|
||||
// Check if already exists in head
|
||||
if (document.querySelector(`style[${attributeName}]`) !== null) {
|
||||
break add_style;
|
||||
}
|
||||
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.attributes.setNamedItem(document.createAttribute(attributeName));
|
||||
(styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement);
|
||||
}
|
||||
|
||||
const { getI18n } = createGetI18n(messagesByLanguageTag);
|
||||
|
||||
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
|
||||
const { kcContext } = params;
|
||||
|
||||
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
|
||||
|
||||
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(withJsx(i18n));
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
prI18n_currentLanguage?.then(i18n => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setI18n_toReturn(withJsx(i18n));
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { i18n: i18n_toReturn };
|
||||
}
|
||||
|
||||
return { useI18n, ofTypeI18n: Reflect<I18n>() };
|
||||
}
|
@ -1,10 +1,3 @@
|
||||
import Fallback from "keycloakify/account/Fallback";
|
||||
|
||||
export default Fallback;
|
||||
|
||||
export { getKcContext } from "keycloakify/account/kcContext/getKcContext";
|
||||
export { createGetKcContext } from "keycloakify/account/kcContext/createGetKcContext";
|
||||
export type { AccountThemePageId as PageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
export { createUseI18n } from "keycloakify/account/i18n/i18n";
|
||||
|
||||
export type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
export type { ExtendKcContext } from "keycloakify/account/KcContext";
|
||||
export type { ClassKey } from "keycloakify/account/TemplateProps";
|
||||
export { createUseI18n } from "keycloakify/account/i18n";
|
||||
|
@ -1,125 +0,0 @@
|
||||
import type { AccountThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
|
||||
export type KcContext = KcContext.Password | KcContext.Account;
|
||||
|
||||
export declare namespace KcContext {
|
||||
export type Common = {
|
||||
keycloakifyVersion: string;
|
||||
themeType: "account";
|
||||
themeName: string;
|
||||
locale?: {
|
||||
supported: {
|
||||
url: string;
|
||||
label: string;
|
||||
languageTag: string;
|
||||
}[];
|
||||
currentLanguageTag: string;
|
||||
};
|
||||
url: {
|
||||
accountUrl: string;
|
||||
passwordUrl: string;
|
||||
totpUrl: string;
|
||||
socialUrl: string;
|
||||
sessionsUrl: string;
|
||||
applicationsUrl: string;
|
||||
logUrl: string;
|
||||
resourceUrl: string;
|
||||
resourcesCommonPath: string;
|
||||
resourcesPath: string;
|
||||
/** @deprecated, not present in recent keycloak version apparently, use kcContext.referrer instead */
|
||||
referrerURI?: string;
|
||||
getLogoutUrl: () => string;
|
||||
};
|
||||
features: {
|
||||
passwordUpdateSupported: boolean;
|
||||
identityFederation: boolean;
|
||||
log: boolean;
|
||||
authorization: boolean;
|
||||
};
|
||||
realm: {
|
||||
internationalizationEnabled: boolean;
|
||||
userManagedAccessAllowed: boolean;
|
||||
};
|
||||
// Present only if redirected to account page with ?referrer=xxx&referrer_uri=http...
|
||||
message?: {
|
||||
type: "success" | "warning" | "error" | "info";
|
||||
summary: string;
|
||||
};
|
||||
referrer?: {
|
||||
url: string; // The url of the App
|
||||
name: string; // Client id
|
||||
};
|
||||
messagesPerField: {
|
||||
/**
|
||||
* 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: {
|
||||
email?: string;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
username?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Password = Common & {
|
||||
pageId: "password.ftl";
|
||||
password: {
|
||||
passwordSet: boolean;
|
||||
};
|
||||
stateChecker: string;
|
||||
};
|
||||
|
||||
export type Account = Common & {
|
||||
pageId: "account.ftl";
|
||||
url: {
|
||||
accountUrl: string;
|
||||
};
|
||||
realm: {
|
||||
registrationEmailAsUsername: boolean;
|
||||
editUsernameAllowed: boolean;
|
||||
};
|
||||
stateChecker: string;
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
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,102 +0,0 @@
|
||||
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
|
||||
import { deepAssign } from "keycloakify/tools/deepAssign";
|
||||
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";
|
||||
|
||||
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
|
||||
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
|
||||
}) {
|
||||
const { mockData } = params ?? {};
|
||||
|
||||
function getKcContext<PageId extends ExtendKcContext<KcContextExtension>["pageId"] | undefined = undefined>(params?: {
|
||||
mockPageId?: PageId;
|
||||
storyPartialKcContext?: DeepPartial<Extract<ExtendKcContext<KcContextExtension>, { pageId: PageId }>>;
|
||||
}): {
|
||||
kcContext: PageId extends undefined
|
||||
? ExtendKcContext<KcContextExtension> | undefined
|
||||
: Extract<ExtendKcContext<KcContextExtension>, { pageId: PageId }>;
|
||||
} {
|
||||
const { mockPageId, storyPartialKcContext } = params ?? {};
|
||||
|
||||
const realKcContext = getKcContextFromWindow<KcContextExtension>();
|
||||
|
||||
if (mockPageId !== undefined && realKcContext === undefined) {
|
||||
//TODO maybe trow if no mock fo custom page
|
||||
|
||||
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
|
||||
|
||||
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
|
||||
|
||||
const partialKcContextCustomMock = (() => {
|
||||
const out: DeepPartial<ExtendKcContext<KcContextExtension>> = {};
|
||||
|
||||
const mockDataPick = mockData?.find(({ pageId }) => pageId === mockPageId);
|
||||
|
||||
if (mockDataPick !== undefined) {
|
||||
deepAssign({
|
||||
"target": out,
|
||||
"source": mockDataPick
|
||||
});
|
||||
}
|
||||
|
||||
if (storyPartialKcContext !== undefined) {
|
||||
deepAssign({
|
||||
"target": out,
|
||||
"source": storyPartialKcContext
|
||||
});
|
||||
}
|
||||
|
||||
return Object.keys(out).length === 0 ? undefined : out;
|
||||
})();
|
||||
|
||||
if (kcContextDefaultMock === undefined && partialKcContextCustomMock === undefined) {
|
||||
console.warn(
|
||||
[
|
||||
`WARNING: You declared the non build in page ${mockPageId} but you didn't `,
|
||||
`provide mock data needed to debug the page outside of Keycloak as you are trying to do now.`,
|
||||
`Please check the documentation of the getKcContext function`
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
const kcContext: any = {};
|
||||
|
||||
deepAssign({
|
||||
"target": kcContext,
|
||||
"source": kcContextDefaultMock !== undefined ? kcContextDefaultMock : { "pageId": mockPageId, ...kcContextCommonMock }
|
||||
});
|
||||
|
||||
if (partialKcContextCustomMock !== undefined) {
|
||||
deepAssign({
|
||||
"target": kcContext,
|
||||
"source": partialKcContextCustomMock
|
||||
});
|
||||
}
|
||||
|
||||
return { kcContext };
|
||||
}
|
||||
|
||||
if (realKcContext === undefined) {
|
||||
return { "kcContext": undefined as any };
|
||||
}
|
||||
|
||||
if (realKcContext.themeType !== "account") {
|
||||
return { "kcContext": undefined as any };
|
||||
}
|
||||
|
||||
{
|
||||
const { url } = realKcContext;
|
||||
|
||||
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(resourcesCommonDirPathRelativeToPublicDir));
|
||||
}
|
||||
|
||||
return { "kcContext": realKcContext as any };
|
||||
}
|
||||
|
||||
return { getKcContext };
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
|
||||
import type { ExtendKcContext } from "./getKcContextFromWindow";
|
||||
import { createGetKcContext } from "./createGetKcContext";
|
||||
|
||||
/** NOTE: We now recommend using createGetKcContext instead of this function to make storybook integration easier
|
||||
* See: https://github.com/keycloakify/keycloakify-starter/blob/main/src/keycloak-theme/account/kcContext.ts
|
||||
*/
|
||||
export function getKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
|
||||
mockPageId?: ExtendKcContext<KcContextExtension>["pageId"];
|
||||
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
|
||||
}): { kcContext: ExtendKcContext<KcContextExtension> | undefined } {
|
||||
const { mockPageId, mockData } = params ?? {};
|
||||
|
||||
const { getKcContext } = createGetKcContext({
|
||||
mockData
|
||||
});
|
||||
|
||||
const { kcContext } = getKcContext({ mockPageId });
|
||||
|
||||
return { kcContext };
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
import type { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey";
|
||||
import { ftlValuesGlobalName } from "keycloakify/bin/keycloakify/ftlValuesGlobalName";
|
||||
import type { KcContext } from "./KcContext";
|
||||
|
||||
export type ExtendKcContext<KcContextExtension extends { pageId: string }> = [KcContextExtension] extends [never]
|
||||
? KcContext
|
||||
: AndByDiscriminatingKey<"pageId", KcContextExtension & KcContext.Common, KcContext>;
|
||||
|
||||
export function getKcContextFromWindow<KcContextExtension extends { pageId: string } = never>(): ExtendKcContext<KcContextExtension> | undefined {
|
||||
return typeof window === "undefined" ? undefined : (window as any)[ftlValuesGlobalName];
|
||||
}
|
@ -1 +0,0 @@
|
||||
export type { KcContext } from "./KcContext";
|
@ -1,174 +0,0 @@
|
||||
import "minimal-polyfills/Object.fromEntries";
|
||||
import { resourcesCommonDirPathRelativeToPublicDir, resourcesDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
|
||||
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
|
||||
import { id } from "tsafe/id";
|
||||
import type { KcContext } from "./KcContext";
|
||||
|
||||
const PUBLIC_URL = (typeof process !== "object" ? undefined : process.env?.["PUBLIC_URL"]) || "/";
|
||||
|
||||
export const kcContextCommonMock: KcContext.Common = {
|
||||
"keycloakifyVersion": "0.0.0",
|
||||
"themeType": "account",
|
||||
"themeName": "my-theme-name",
|
||||
"url": {
|
||||
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
|
||||
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
|
||||
"resourceUrl": "#",
|
||||
"accountUrl": "#",
|
||||
"applicationsUrl": "#",
|
||||
"getLogoutUrl": () => "#",
|
||||
"logUrl": "#",
|
||||
"passwordUrl": "#",
|
||||
"sessionsUrl": "#",
|
||||
"socialUrl": "#",
|
||||
"totpUrl": "#"
|
||||
},
|
||||
"realm": {
|
||||
"internationalizationEnabled": true,
|
||||
"userManagedAccessAllowed": true
|
||||
},
|
||||
"messagesPerField": {
|
||||
"printIfExists": () => {
|
||||
return undefined;
|
||||
},
|
||||
"existsError": () => false,
|
||||
"get": key => `Fake error for ${key}`,
|
||||
"exists": () => false
|
||||
},
|
||||
"locale": {
|
||||
"supported": [
|
||||
/* spell-checker: disable */
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=de",
|
||||
"label": "Deutsch",
|
||||
"languageTag": "de"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=no",
|
||||
"label": "Norsk",
|
||||
"languageTag": "no"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ru",
|
||||
"label": "Русский",
|
||||
"languageTag": "ru"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sv",
|
||||
"label": "Svenska",
|
||||
"languageTag": "sv"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pt-BR",
|
||||
"label": "Português (Brasil)",
|
||||
"languageTag": "pt-BR"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=lt",
|
||||
"label": "Lietuvių",
|
||||
"languageTag": "lt"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=en",
|
||||
"label": "English",
|
||||
"languageTag": "en"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=it",
|
||||
"label": "Italiano",
|
||||
"languageTag": "it"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=fr",
|
||||
"label": "Français",
|
||||
"languageTag": "fr"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=zh-CN",
|
||||
"label": "中文简体",
|
||||
"languageTag": "zh-CN"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=es",
|
||||
"label": "Español",
|
||||
"languageTag": "es"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=cs",
|
||||
"label": "Čeština",
|
||||
"languageTag": "cs"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ja",
|
||||
"label": "日本語",
|
||||
"languageTag": "ja"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sk",
|
||||
"label": "Slovenčina",
|
||||
"languageTag": "sk"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pl",
|
||||
"label": "Polski",
|
||||
"languageTag": "pl"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ca",
|
||||
"label": "Català",
|
||||
"languageTag": "ca"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=nl",
|
||||
"label": "Nederlands",
|
||||
"languageTag": "nl"
|
||||
},
|
||||
{
|
||||
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=tr",
|
||||
"label": "Türkçe",
|
||||
"languageTag": "tr"
|
||||
}
|
||||
/* spell-checker: enable */
|
||||
],
|
||||
"currentLanguageTag": "en"
|
||||
},
|
||||
"features": {
|
||||
"authorization": true,
|
||||
"identityFederation": true,
|
||||
"log": true,
|
||||
"passwordUpdateSupported": true
|
||||
},
|
||||
"referrer": undefined,
|
||||
"account": {
|
||||
"firstName": "john",
|
||||
"lastName": "doe",
|
||||
"email": "john.doe@code.gouv.fr",
|
||||
"username": "doe_j"
|
||||
}
|
||||
};
|
||||
|
||||
export const kcContextMocks: KcContext[] = [
|
||||
id<KcContext.Password>({
|
||||
...kcContextCommonMock,
|
||||
"pageId": "password.ftl",
|
||||
"password": {
|
||||
"passwordSet": true
|
||||
},
|
||||
"stateChecker": "state checker"
|
||||
}),
|
||||
id<KcContext.Account>({
|
||||
...kcContextCommonMock,
|
||||
"pageId": "account.ftl",
|
||||
"url": {
|
||||
...kcContextCommonMock.url,
|
||||
"referrerURI": "#",
|
||||
"accountUrl": "#"
|
||||
},
|
||||
"realm": {
|
||||
...kcContextCommonMock.realm,
|
||||
"registrationEmailAsUsername": true,
|
||||
"editUsernameAllowed": true
|
||||
},
|
||||
"stateChecker": ""
|
||||
})
|
||||
];
|
25
src/account/lib/kcClsx.ts
Normal file
25
src/account/lib/kcClsx.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
|
||||
import type { ClassKey } from "keycloakify/account/TemplateProps";
|
||||
|
||||
export const { getKcClsx } = createGetKcClsx<ClassKey>({
|
||||
defaultClasses: {
|
||||
kcHtmlClass: undefined,
|
||||
kcBodyClass: undefined,
|
||||
kcButtonClass: "btn",
|
||||
kcContentWrapperClass: "row",
|
||||
kcButtonPrimaryClass: "btn-primary",
|
||||
kcButtonLargeClass: "btn-lg",
|
||||
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"
|
||||
}
|
||||
});
|
||||
|
||||
export type { ClassKey };
|
||||
|
||||
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];
|
@ -1,13 +0,0 @@
|
||||
import { createUseClassName } from "keycloakify/lib/useGetClassName";
|
||||
import type { ClassKey } from "keycloakify/account/TemplateProps";
|
||||
|
||||
export const { useGetClassName } = createUseClassName<ClassKey>({
|
||||
"defaultClasses": {
|
||||
"kcHtmlClass": undefined,
|
||||
"kcBodyClass": undefined,
|
||||
"kcButtonClass": "btn",
|
||||
"kcButtonPrimaryClass": "btn-primary",
|
||||
"kcButtonLargeClass": "btn-lg",
|
||||
"kcButtonDefaultClass": "btn-default"
|
||||
}
|
||||
});
|
@ -1,18 +1,20 @@
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export default function Account(props: PageProps<Extract<KcContext, { pageId: "account.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
|
||||
const { kcContext, i18n, doUseDefaultCss, Template } = props;
|
||||
|
||||
const { getClassName } = useGetClassName({
|
||||
const classes = {
|
||||
...props.classes,
|
||||
kcBodyClass: clsx(props.classes?.kcBodyClass, "user")
|
||||
};
|
||||
|
||||
const { kcClsx } = getKcClsx({
|
||||
doUseDefaultCss,
|
||||
"classes": {
|
||||
...classes,
|
||||
"kcBodyClass": clsx(classes?.kcBodyClass, "user")
|
||||
}
|
||||
classes
|
||||
});
|
||||
|
||||
const { url, realm, messagesPerField, stateChecker, account, referrer } = kcContext;
|
||||
@ -51,7 +53,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 +68,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 +81,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 +94,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>
|
||||
|
||||
@ -102,11 +104,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
|
||||
{referrer !== undefined && <a href={referrer?.url}>{msg("backToApplication")}</a>}
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
getClassName("kcButtonClass"),
|
||||
getClassName("kcButtonPrimaryClass"),
|
||||
getClassName("kcButtonLargeClass")
|
||||
)}
|
||||
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
|
||||
name="submitAction"
|
||||
value="Save"
|
||||
>
|
||||
@ -114,11 +112,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
getClassName("kcButtonClass"),
|
||||
getClassName("kcButtonDefaultClass"),
|
||||
getClassName("kcButtonLargeClass")
|
||||
)}
|
||||
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonLargeClass")}
|
||||
name="submitAction"
|
||||
value="Cancel"
|
||||
>
|
||||
|
136
src/account/pages/Applications.tsx
Normal file
136
src/account/pages/Applications.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export default function Applications(props: PageProps<Extract<KcContext, { pageId: "applications.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
|
||||
|
||||
const { kcClsx } = getKcClsx({
|
||||
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, index) => (
|
||||
<span key={role.name}>
|
||||
{role.description ? advancedMsg(role.description) : advancedMsg(role.name)}
|
||||
{index < application.realmRolesAvailable.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
{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={kcClsx("kcButtonPrimaryClass", "kcButtonClass")}
|
||||
id={`revoke-${application.client.clientId}`}
|
||||
name="clientId"
|
||||
value={application.client.id}
|
||||
>
|
||||
{msg("revoke")}
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
</Template>
|
||||
);
|
||||
}
|
||||
|
||||
function isArrayWithEmptyObject(variable: any): boolean {
|
||||
return Array.isArray(variable) && variable.length === 1 && typeof variable[0] === "object" && Object.keys(variable[0]).length === 0;
|
||||
}
|
58
src/account/pages/FederatedIdentity.tsx
Normal file
58
src/account/pages/FederatedIdentity.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export default function FederatedIdentity(props: PageProps<Extract<KcContext, { pageId: "federatedIdentity.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
|
||||
|
||||
const { url, federatedIdentity, stateChecker } = kcContext;
|
||||
const { msg } = i18n;
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="social">
|
||||
<div className="main-layout social">
|
||||
<div className="row">
|
||||
<div className="col-md-10">
|
||||
<h2>{msg("federatedIdentitiesHtmlTitle")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="federated-identities">
|
||||
{federatedIdentity.identities.map(identity => (
|
||||
<div key={identity.providerId} className="row margin-bottom">
|
||||
<div className="col-sm-2 col-md-2">
|
||||
<label htmlFor={identity.providerId} className="control-label">
|
||||
{identity.displayName}
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-sm-5 col-md-5">
|
||||
<input disabled className="form-control" value={identity.userName} />
|
||||
</div>
|
||||
<div className="col-sm-5 col-md-5">
|
||||
{identity.connected ? (
|
||||
federatedIdentity.removeLinkPossible && (
|
||||
<form action={url.socialUrl} method="post" className="form-inline">
|
||||
<input type="hidden" name="stateChecker" value={stateChecker} />
|
||||
<input type="hidden" name="action" value="remove" />
|
||||
<input type="hidden" name="providerId" value={identity.providerId} />
|
||||
<button id={`remove-link-${identity.providerId}`} className="btn btn-default">
|
||||
{msg("doRemove")}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
) : (
|
||||
<form action={url.socialUrl} method="post" className="form-inline">
|
||||
<input type="hidden" name="stateChecker" value={stateChecker} />
|
||||
<input type="hidden" name="action" value="add" />
|
||||
<input type="hidden" name="providerId" value={identity.providerId} />
|
||||
<button id={`add-link-${identity.providerId}`} className="btn btn-default">
|
||||
{msg("doAdd")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Template>
|
||||
);
|
||||
}
|
70
src/account/pages/Log.tsx
Normal file
70
src/account/pages/Log.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import type { Key } from "react";
|
||||
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export default function Log(props: PageProps<Extract<KcContext, { pageId: "log.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
|
||||
|
||||
const { kcClsx } = getKcClsx({
|
||||
doUseDefaultCss,
|
||||
classes
|
||||
});
|
||||
|
||||
const { log } = kcContext;
|
||||
|
||||
const { msg } = i18n;
|
||||
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="log">
|
||||
<div className={kcClsx("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,11 +1,10 @@
|
||||
import type { LazyExoticComponent } from "react";
|
||||
import type { I18n } from "keycloakify/account/i18n";
|
||||
import type { TemplateProps, ClassKey } from "keycloakify/account/TemplateProps";
|
||||
import { type TemplateProps, type ClassKey } from "keycloakify/account/TemplateProps";
|
||||
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
|
||||
|
||||
export type PageProps<KcContext, I18nExtended extends I18n> = {
|
||||
Template: LazyExoticComponent<(props: TemplateProps<any, any>) => JSX.Element | null>;
|
||||
kcContext: KcContext;
|
||||
i18n: I18nExtended;
|
||||
export type PageProps<NarrowedKcContext, I18n> = {
|
||||
Template: LazyOrNot<(props: TemplateProps<any, any>) => JSX.Element | null>;
|
||||
kcContext: NarrowedKcContext;
|
||||
i18n: I18n;
|
||||
doUseDefaultCss: boolean;
|
||||
classes?: Partial<Record<ClassKey, string>>;
|
||||
};
|
||||
|
@ -1,19 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export default function Password(props: PageProps<Extract<KcContext, { pageId: "password.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
|
||||
const { kcContext, i18n, doUseDefaultCss, Template } = props;
|
||||
|
||||
const { getClassName } = useGetClassName({
|
||||
const classes = {
|
||||
...props.classes,
|
||||
kcBodyClass: clsx(props.classes?.kcBodyClass, "password")
|
||||
};
|
||||
|
||||
const { kcClsx } = getKcClsx({
|
||||
doUseDefaultCss,
|
||||
"classes": {
|
||||
...classes,
|
||||
"kcBodyClass": clsx(classes?.kcBodyClass, "password")
|
||||
}
|
||||
classes
|
||||
});
|
||||
|
||||
const { url, password, account, stateChecker } = kcContext;
|
||||
@ -57,18 +59,18 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
|
||||
{...{
|
||||
kcContext: {
|
||||
...kcContext,
|
||||
"message": (() => {
|
||||
message: (() => {
|
||||
if (newPasswordError !== "") {
|
||||
return {
|
||||
"type": "error",
|
||||
"summary": newPasswordError
|
||||
type: "error",
|
||||
summary: newPasswordError
|
||||
};
|
||||
}
|
||||
|
||||
if (newPasswordConfirmError !== "") {
|
||||
return {
|
||||
"type": "error",
|
||||
"summary": newPasswordConfirmError
|
||||
type: "error",
|
||||
summary: newPasswordConfirmError
|
||||
};
|
||||
}
|
||||
|
||||
@ -98,7 +100,7 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
|
||||
value={account.username ?? ""}
|
||||
autoComplete="username"
|
||||
readOnly
|
||||
style={{ "display": "none" }}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
{password.passwordSet && (
|
||||
@ -192,11 +194,7 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
|
||||
<button
|
||||
disabled={newPasswordError !== "" || newPasswordConfirmError !== ""}
|
||||
type="submit"
|
||||
className={clsx(
|
||||
getClassName("kcButtonClass"),
|
||||
getClassName("kcButtonPrimaryClass"),
|
||||
getClassName("kcButtonLargeClass")
|
||||
)}
|
||||
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
|
||||
name="submitAction"
|
||||
value="Save"
|
||||
>
|
||||
|
64
src/account/pages/Sessions.tsx
Normal file
64
src/account/pages/Sessions.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
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 { kcClsx } = getKcClsx({
|
||||
doUseDefaultCss,
|
||||
classes
|
||||
});
|
||||
|
||||
const { url, stateChecker, sessions } = kcContext;
|
||||
|
||||
const { msg } = i18n;
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="sessions">
|
||||
<div className={kcClsx("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={kcClsx("kcButtonDefaultClass", "kcButtonClass")}>
|
||||
{msg("doLogOutAllSessions")}
|
||||
</button>
|
||||
</form>
|
||||
</Template>
|
||||
);
|
||||
}
|
225
src/account/pages/Totp.tsx
Normal file
225
src/account/pages/Totp.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
|
||||
|
||||
const { kcClsx } = getKcClsx({
|
||||
doUseDefaultCss,
|
||||
classes
|
||||
});
|
||||
|
||||
const { totp, mode, url, messagesPerField, stateChecker } = kcContext;
|
||||
|
||||
const { msg, msgStr, advancedMsg } = i18n;
|
||||
|
||||
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}>{advancedMsg(app)}</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>
|
||||
<ul>
|
||||
<li id="kc-totp-type">
|
||||
{msg("totpType")}: {msg(`totp.${totp.policy.type}`)}
|
||||
</li>
|
||||
<li id="kc-totp-algorithm">
|
||||
{msg("totpAlgorithm")}: {totp.policy.getAlgorithmKey()}
|
||||
</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>
|
||||
</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={kcClsx("kcFormClass")} id="kc-totp-settings-form" method="post">
|
||||
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
|
||||
<div className={kcClsx("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={kcClsx("kcInputClass")}
|
||||
aria-invalid={messagesPerField.existsError("totp")}
|
||||
/>
|
||||
|
||||
{messagesPerField.existsError("totp") && (
|
||||
<span
|
||||
id="input-error-otp-code"
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: messagesPerField.get("totp")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<input type="hidden" id="totpSecret" name="totpSecret" value={totp.totpSecret} />
|
||||
{mode && <input type="hidden" id="mode" value={mode} />}
|
||||
</div>
|
||||
|
||||
<div className={kcClsx("kcFormGroupClass")}>
|
||||
<div className="col-sm-2 col-md-2">
|
||||
<label htmlFor="userLabel" className={kcClsx("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={kcClsx("kcInputClass")}
|
||||
aria-invalid={messagesPerField.existsError("userLabel")}
|
||||
/>
|
||||
{messagesPerField.existsError("userLabel") && (
|
||||
<span
|
||||
id="input-error-otp-label"
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: messagesPerField.get("userLabel")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="kc-form-buttons" className={clsx(kcClsx("kcFormGroupClass"), "text-right")}>
|
||||
<div className={kcClsx("kcInputWrapperClass")}>
|
||||
<input
|
||||
type="submit"
|
||||
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
|
||||
id="saveTOTPBtn"
|
||||
value={msgStr("doSave")}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonLargeClass", "kcButtonLargeClass")}
|
||||
id="cancelTOTPBtn"
|
||||
name="submitAction"
|
||||
value="Cancel"
|
||||
>
|
||||
{msg("doCancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Template>
|
||||
);
|
||||
}
|
137
src/bin/add-story.ts
Normal file
137
src/bin/add-story.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
|
||||
import cliSelect from "cli-select";
|
||||
import {
|
||||
LOGIN_THEME_PAGE_IDS,
|
||||
ACCOUNT_THEME_PAGE_IDS,
|
||||
type LoginThemePageId,
|
||||
type AccountThemePageId,
|
||||
THEME_TYPES,
|
||||
type ThemeType
|
||||
} from "./shared/constants";
|
||||
import { capitalize } from "tsafe/capitalize";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
|
||||
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
|
||||
import { assert, Equals } from "tsafe/assert";
|
||||
import type { CliCommandOptions } from "./main";
|
||||
import { getBuildContext } from "./shared/buildContext";
|
||||
import chalk from "chalk";
|
||||
|
||||
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
|
||||
const { cliCommandOptions } = params;
|
||||
|
||||
const buildContext = getBuildContext({
|
||||
cliCommandOptions
|
||||
});
|
||||
|
||||
console.log(chalk.cyan("Theme type:"));
|
||||
|
||||
const themeType = await (async () => {
|
||||
const values = THEME_TYPES.filter(themeType => {
|
||||
switch (themeType) {
|
||||
case "account":
|
||||
return buildContext.implementedThemeTypes.account.isImplemented;
|
||||
case "login":
|
||||
return buildContext.implementedThemeTypes.login.isImplemented;
|
||||
}
|
||||
assert<Equals<typeof themeType, never>>(false);
|
||||
});
|
||||
|
||||
assert(values.length > 0, "No theme is implemented in this project");
|
||||
|
||||
if (values.length === 1) {
|
||||
return values[0];
|
||||
}
|
||||
|
||||
const { value } = await cliSelect<ThemeType>({
|
||||
values
|
||||
}).catch(() => {
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
return value;
|
||||
})();
|
||||
|
||||
if (
|
||||
themeType === "account" &&
|
||||
(assert(buildContext.implementedThemeTypes.account.isImplemented),
|
||||
buildContext.implementedThemeTypes.account.type === "Single-Page")
|
||||
) {
|
||||
console.log(
|
||||
`${chalk.red("✗")} Sorry, there is no Storybook support for Single-Page Account themes.`
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`→ ${themeType}`);
|
||||
|
||||
console.log(chalk.cyan("Select the page you want to create a Storybook for:"));
|
||||
|
||||
const { value: pageId } = await cliSelect<LoginThemePageId | AccountThemePageId>({
|
||||
values: (() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return [...LOGIN_THEME_PAGE_IDS];
|
||||
case "account":
|
||||
return [...ACCOUNT_THEME_PAGE_IDS];
|
||||
}
|
||||
assert<Equals<typeof themeType, never>>(false);
|
||||
})()
|
||||
}).catch(() => {
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
console.log(`→ ${pageId}`);
|
||||
|
||||
const componentBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(
|
||||
/ftl$/,
|
||||
"stories.tsx"
|
||||
);
|
||||
|
||||
const targetFilePath = pathJoin(
|
||||
buildContext.themeSrcDirPath,
|
||||
themeType,
|
||||
"pages",
|
||||
componentBasename
|
||||
);
|
||||
|
||||
if (fs.existsSync(targetFilePath)) {
|
||||
console.log(`${pathRelative(process.cwd(), targetFilePath)} already exists`);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
const componentCode = fs
|
||||
.readFileSync(
|
||||
pathJoin(
|
||||
getThisCodebaseRootDirPath(),
|
||||
"stories",
|
||||
themeType,
|
||||
"pages",
|
||||
componentBasename
|
||||
)
|
||||
)
|
||||
.toString("utf8")
|
||||
.replace('import React from "react";\n', "")
|
||||
.replace(/from "[./]+dist\//, 'from "keycloakify/');
|
||||
|
||||
{
|
||||
const targetDirPath = pathDirname(targetFilePath);
|
||||
|
||||
if (!fs.existsSync(targetDirPath)) {
|
||||
fs.mkdirSync(targetDirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
|
||||
|
||||
console.log(
|
||||
[
|
||||
`${chalk.green("✓")} ${chalk.bold(
|
||||
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
|
||||
)} copy pasted from the Keycloakify source code into your project`,
|
||||
`You can start storybook with ${chalk.bold("npm run storybook")}`
|
||||
].join("\n")
|
||||
);
|
||||
}
|
@ -1,49 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import { copyKeycloakResourcesToPublic } from "./shared/copyKeycloakResourcesToPublic";
|
||||
import { getBuildContext } from "./shared/buildContext";
|
||||
import type { CliCommandOptions } from "./main";
|
||||
|
||||
import { downloadKeycloakStaticResources } 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 * as fs from "fs";
|
||||
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
|
||||
const { cliCommandOptions } = params;
|
||||
|
||||
(async () => {
|
||||
const projectDirPath = process.cwd();
|
||||
const buildContext = getBuildContext({ cliCommandOptions });
|
||||
|
||||
const buildOptions = readBuildOptions({
|
||||
"processArgv": process.argv.slice(2),
|
||||
"projectDirPath": process.cwd()
|
||||
await copyKeycloakResourcesToPublic({
|
||||
buildContext
|
||||
});
|
||||
|
||||
const keycloakDirInPublicDir = pathJoin(process.env["PUBLIC_DIR_PATH"] || pathJoin(projectDirPath, "public"), basenameOfKeycloakDirInPublicDir);
|
||||
|
||||
if (fs.existsSync(keycloakDirInPublicDir)) {
|
||||
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const themeType of themeTypes) {
|
||||
await downloadKeycloakStaticResources({
|
||||
projectDirPath,
|
||||
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
|
||||
"themeType": themeType,
|
||||
"themeDirPath": keycloakDirInPublicDir,
|
||||
"usedResources": undefined
|
||||
});
|
||||
}
|
||||
|
||||
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"));
|
||||
|
||||
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} directory created.`);
|
||||
})();
|
||||
}
|
||||
|
@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { join as pathJoin } from "path";
|
||||
import { downloadAndUnzip } from "./tools/downloadAndUnzip";
|
||||
import { promptKeycloakVersion } from "./promptKeycloakVersion";
|
||||
import { getLogger } from "./tools/logger";
|
||||
import { readBuildOptions } from "./keycloakify/BuildOptions";
|
||||
import * as child_process from "child_process";
|
||||
import * as fs from "fs";
|
||||
|
||||
export async function downloadBuiltinKeycloakTheme(params: { projectDirPath: string; keycloakVersion: string; destDirPath: string }) {
|
||||
const { projectDirPath, keycloakVersion, destDirPath } = params;
|
||||
|
||||
await downloadAndUnzip({
|
||||
"doUseCache": true,
|
||||
projectDirPath,
|
||||
destDirPath,
|
||||
"url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`,
|
||||
"specificDirsToExtract": ["", "-community"].map(ext => `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`),
|
||||
"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"
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
child_process.execSync("npm 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("npm run check-types", "true")
|
||||
.replace("npm run babel", "true");
|
||||
|
||||
fs.writeFileSync(packageJsonFilePath, Buffer.from(JSON.stringify(parsedPackageJson, null, 2), "utf8"));
|
||||
|
||||
child_process.execSync("npm run build", { "cwd": accountV2DirSrcDirPath, "stdio": "ignore" });
|
||||
|
||||
fs.writeFileSync(packageJsonFilePath, packageJsonRaw);
|
||||
|
||||
fs.rmSync(pathJoin(accountV2DirSrcDirPath, "node_modules"), { "recursive": true });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const buildOptions = readBuildOptions({
|
||||
"projectDirPath": process.cwd(),
|
||||
"processArgv": process.argv.slice(2)
|
||||
});
|
||||
|
||||
const logger = getLogger({ "isSilent": buildOptions.isSilent });
|
||||
const { keycloakVersion } = await promptKeycloakVersion();
|
||||
|
||||
const destDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources", "theme");
|
||||
|
||||
logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`);
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
"projectDirPath": process.cwd(),
|
||||
keycloakVersion,
|
||||
destDirPath
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { getProjectRoot } from "./tools/getProjectRoot";
|
||||
import cliSelect from "cli-select";
|
||||
import {
|
||||
loginThemePageIds,
|
||||
accountThemePageIds,
|
||||
type LoginThemePageId,
|
||||
type AccountThemePageId,
|
||||
themeTypes,
|
||||
type ThemeType
|
||||
} 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";
|
||||
|
||||
(async () => {
|
||||
console.log("Select a theme type");
|
||||
|
||||
const { value: themeType } = await cliSelect<ThemeType>({
|
||||
"values": [...themeTypes]
|
||||
}).catch(() => {
|
||||
console.log("Aborting");
|
||||
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
console.log("Select a page you would like to eject");
|
||||
|
||||
const { value: pageId } = await cliSelect<LoginThemePageId | AccountThemePageId>({
|
||||
"values": (() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return [...loginThemePageIds];
|
||||
case "account":
|
||||
return [...accountThemePageIds];
|
||||
}
|
||||
assert<Equals<typeof themeType, never>>(false);
|
||||
})()
|
||||
}).catch(() => {
|
||||
console.log("Aborting");
|
||||
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
const pageBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(/ftl$/, "tsx");
|
||||
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({ "projectDirPath": process.cwd() });
|
||||
|
||||
const targetFilePath = pathJoin(themeSrcDirPath, themeType, "pages", pageBasename);
|
||||
|
||||
if (existsSync(targetFilePath)) {
|
||||
console.log(`${pageId} is already ejected, ${pathRelative(process.cwd(), targetFilePath)} already exists`);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
await writeFile(targetFilePath, await readFile(pathJoin(getProjectRoot(), "src", themeType, "pages", pageBasename)));
|
||||
|
||||
console.log(`${pathRelative(process.cwd(), targetFilePath)} created`);
|
||||
})();
|
364
src/bin/eject-page.ts
Normal file
364
src/bin/eject-page.ts
Normal file
@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
|
||||
import cliSelect from "cli-select";
|
||||
import {
|
||||
LOGIN_THEME_PAGE_IDS,
|
||||
ACCOUNT_THEME_PAGE_IDS,
|
||||
type LoginThemePageId,
|
||||
type AccountThemePageId,
|
||||
THEME_TYPES,
|
||||
type ThemeType
|
||||
} from "./shared/constants";
|
||||
import { capitalize } from "tsafe/capitalize";
|
||||
import * as fs from "fs";
|
||||
import {
|
||||
join as pathJoin,
|
||||
relative as pathRelative,
|
||||
dirname as pathDirname,
|
||||
basename as pathBasename
|
||||
} from "path";
|
||||
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
|
||||
import { assert, Equals } from "tsafe/assert";
|
||||
import type { CliCommandOptions } from "./main";
|
||||
import { getBuildContext } from "./shared/buildContext";
|
||||
import chalk from "chalk";
|
||||
|
||||
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
|
||||
const { cliCommandOptions } = params;
|
||||
|
||||
const buildContext = getBuildContext({
|
||||
cliCommandOptions
|
||||
});
|
||||
|
||||
console.log(chalk.cyan("Theme type:"));
|
||||
|
||||
const themeType = await (async () => {
|
||||
const values = THEME_TYPES.filter(themeType => {
|
||||
switch (themeType) {
|
||||
case "account":
|
||||
return buildContext.implementedThemeTypes.account.isImplemented;
|
||||
case "login":
|
||||
return buildContext.implementedThemeTypes.login.isImplemented;
|
||||
}
|
||||
assert<Equals<typeof themeType, never>>(false);
|
||||
});
|
||||
|
||||
assert(values.length > 0, "No theme is implemented in this project");
|
||||
|
||||
if (values.length === 1) {
|
||||
return values[0];
|
||||
}
|
||||
|
||||
const { value } = await cliSelect<ThemeType>({
|
||||
values
|
||||
}).catch(() => {
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
return value;
|
||||
})();
|
||||
|
||||
if (
|
||||
themeType === "account" &&
|
||||
(assert(buildContext.implementedThemeTypes.account.isImplemented),
|
||||
buildContext.implementedThemeTypes.account.type === "Single-Page")
|
||||
) {
|
||||
const srcDirPath = pathJoin(
|
||||
pathDirname(buildContext.packageJsonFilePath),
|
||||
"node_modules",
|
||||
"@keycloakify",
|
||||
"keycloak-account-ui",
|
||||
"src"
|
||||
);
|
||||
|
||||
console.log(
|
||||
[
|
||||
`There isn't an interactive CLI to eject components of the Single-Page Account theme.`,
|
||||
`You can however copy paste into your codebase the any file or directory from the following source directory:`,
|
||||
``,
|
||||
`${chalk.bold(pathJoin(pathRelative(process.cwd(), srcDirPath)))}`,
|
||||
``
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
eject_entrypoint: {
|
||||
const kcAccountUiTsxFileRelativePath = "KcAccountUi.tsx";
|
||||
|
||||
const accountThemeSrcDirPath = pathJoin(
|
||||
buildContext.themeSrcDirPath,
|
||||
"account"
|
||||
);
|
||||
|
||||
const targetFilePath = pathJoin(
|
||||
accountThemeSrcDirPath,
|
||||
kcAccountUiTsxFileRelativePath
|
||||
);
|
||||
|
||||
if (fs.existsSync(targetFilePath)) {
|
||||
break eject_entrypoint;
|
||||
}
|
||||
|
||||
fs.cpSync(
|
||||
pathJoin(srcDirPath, kcAccountUiTsxFileRelativePath),
|
||||
targetFilePath
|
||||
);
|
||||
|
||||
{
|
||||
const kcPageTsxFilePath = pathJoin(accountThemeSrcDirPath, "KcPage.tsx");
|
||||
|
||||
const kcPageTsxCode = fs.readFileSync(kcPageTsxFilePath).toString("utf8");
|
||||
|
||||
const componentName = pathBasename(
|
||||
kcAccountUiTsxFileRelativePath
|
||||
).replace(/.tsx$/, "");
|
||||
|
||||
const modifiedKcPageTsxCode = kcPageTsxCode.replace(
|
||||
`@keycloakify/keycloak-account-ui/${componentName}`,
|
||||
`./${componentName}`
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
kcPageTsxFilePath,
|
||||
Buffer.from(modifiedKcPageTsxCode, "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
const routesTsxFilePath = pathRelative(
|
||||
process.cwd(),
|
||||
pathJoin(srcDirPath, "routes.tsx")
|
||||
);
|
||||
|
||||
console.log(
|
||||
[
|
||||
`To help you get started ${chalk.bold(pathRelative(process.cwd(), targetFilePath))} has been copied into your project.`,
|
||||
`The next step is usually to eject ${chalk.bold(routesTsxFilePath)}`,
|
||||
`with \`cp ${routesTsxFilePath} ${pathRelative(process.cwd(), accountThemeSrcDirPath)}\``,
|
||||
`then update the import of routes in ${kcAccountUiTsxFileRelativePath}.`
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`→ ${themeType}`);
|
||||
|
||||
console.log(chalk.cyan("Select the page you want to customize:"));
|
||||
|
||||
const templateValue = "Template.tsx (Layout common to every page)";
|
||||
const userProfileFormFieldsValue =
|
||||
"UserProfileFormFields.tsx (Renders the form of the register.ftl, login-update-profile.ftl, update-email.ftl and idp-review-user-profile.ftl)";
|
||||
|
||||
const { value: pageIdOrComponent } = await cliSelect<
|
||||
| LoginThemePageId
|
||||
| AccountThemePageId
|
||||
| typeof templateValue
|
||||
| typeof userProfileFormFieldsValue
|
||||
>({
|
||||
values: (() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return [
|
||||
templateValue,
|
||||
userProfileFormFieldsValue,
|
||||
...LOGIN_THEME_PAGE_IDS
|
||||
];
|
||||
case "account":
|
||||
return [templateValue, ...ACCOUNT_THEME_PAGE_IDS];
|
||||
}
|
||||
assert<Equals<typeof themeType, never>>(false);
|
||||
})()
|
||||
}).catch(() => {
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
console.log(`→ ${pageIdOrComponent}`);
|
||||
|
||||
const componentBasename = (() => {
|
||||
if (pageIdOrComponent === templateValue) {
|
||||
return "Template.tsx";
|
||||
}
|
||||
|
||||
if (pageIdOrComponent === userProfileFormFieldsValue) {
|
||||
return "UserProfileFormFields.tsx";
|
||||
}
|
||||
|
||||
return capitalize(kebabCaseToCamelCase(pageIdOrComponent)).replace(/ftl$/, "tsx");
|
||||
})();
|
||||
|
||||
const pagesOrDot = (() => {
|
||||
if (
|
||||
pageIdOrComponent === templateValue ||
|
||||
pageIdOrComponent === userProfileFormFieldsValue
|
||||
) {
|
||||
return ".";
|
||||
}
|
||||
|
||||
return "pages";
|
||||
})();
|
||||
|
||||
const targetFilePath = pathJoin(
|
||||
buildContext.themeSrcDirPath,
|
||||
themeType,
|
||||
pagesOrDot,
|
||||
componentBasename
|
||||
);
|
||||
|
||||
if (fs.existsSync(targetFilePath)) {
|
||||
console.log(
|
||||
`${pageIdOrComponent} is already ejected, ${pathRelative(
|
||||
process.cwd(),
|
||||
targetFilePath
|
||||
)} already exists`
|
||||
);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
const componentCode = fs
|
||||
.readFileSync(
|
||||
pathJoin(
|
||||
getThisCodebaseRootDirPath(),
|
||||
"src",
|
||||
themeType,
|
||||
pagesOrDot,
|
||||
componentBasename
|
||||
)
|
||||
)
|
||||
.toString("utf8");
|
||||
|
||||
{
|
||||
const targetDirPath = pathDirname(targetFilePath);
|
||||
|
||||
if (!fs.existsSync(targetDirPath)) {
|
||||
fs.mkdirSync(targetDirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
|
||||
|
||||
console.log(
|
||||
`${chalk.green("✓")} ${chalk.bold(
|
||||
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
|
||||
)} copy pasted from the Keycloakify source code into your project`
|
||||
);
|
||||
|
||||
edit_KcApp: {
|
||||
if (
|
||||
pageIdOrComponent !== templateValue &&
|
||||
pageIdOrComponent !== userProfileFormFieldsValue
|
||||
) {
|
||||
break edit_KcApp;
|
||||
}
|
||||
|
||||
const kcAppTsxPath = pathJoin(
|
||||
buildContext.themeSrcDirPath,
|
||||
themeType,
|
||||
"KcPage.tsx"
|
||||
);
|
||||
|
||||
const kcAppTsxCode = fs.readFileSync(kcAppTsxPath).toString("utf8");
|
||||
|
||||
const modifiedKcAppTsxCode = (() => {
|
||||
switch (pageIdOrComponent) {
|
||||
case templateValue:
|
||||
return kcAppTsxCode.replace(
|
||||
`keycloakify/${themeType}/Template`,
|
||||
"./Template"
|
||||
);
|
||||
case userProfileFormFieldsValue:
|
||||
return kcAppTsxCode.replace(
|
||||
`keycloakify/login/UserProfileFormFields`,
|
||||
"./UserProfileFormFields"
|
||||
);
|
||||
}
|
||||
assert<Equals<typeof pageIdOrComponent, never>>(false);
|
||||
})();
|
||||
|
||||
if (kcAppTsxCode === modifiedKcAppTsxCode) {
|
||||
console.log(
|
||||
chalk.red(
|
||||
"Unable to automatically update KcPage.tsx, please update it manually"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(kcAppTsxPath, Buffer.from(modifiedKcAppTsxCode, "utf8"));
|
||||
|
||||
console.log(
|
||||
`${chalk.green("✓")} ${chalk.bold(
|
||||
pathJoin(".", pathRelative(process.cwd(), kcAppTsxPath))
|
||||
)} Updated`
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const userProfileFormFieldComponentName = "UserProfileFormFields";
|
||||
|
||||
const componentName = componentBasename.replace(/.tsx$/, "");
|
||||
|
||||
console.log(
|
||||
[
|
||||
``,
|
||||
`You now need to update your page router:`,
|
||||
``,
|
||||
`${chalk.bold(
|
||||
pathJoin(
|
||||
".",
|
||||
pathRelative(process.cwd(), buildContext.themeSrcDirPath),
|
||||
themeType,
|
||||
"KcPage.tsx"
|
||||
)
|
||||
)}:`,
|
||||
chalk.grey("```"),
|
||||
`// ...`,
|
||||
``,
|
||||
chalk.green(
|
||||
`+const ${componentName} = lazy(() => import("./pages/${componentName}"));`
|
||||
),
|
||||
...[
|
||||
``,
|
||||
` export default function KcPage(props: { kcContext: KcContext; }) {`,
|
||||
``,
|
||||
` // ...`,
|
||||
``,
|
||||
` return (`,
|
||||
` <Suspense>`,
|
||||
` {(() => {`,
|
||||
` switch (kcContext.pageId) {`,
|
||||
` // ...`,
|
||||
`+ case "${pageIdOrComponent}": return (`,
|
||||
`+ <${componentName}`,
|
||||
`+ {...{ kcContext, i18n, classes }}`,
|
||||
`+ Template={Template}`,
|
||||
`+ doUseDefaultCss={true}`,
|
||||
...(!componentCode.includes(userProfileFormFieldComponentName)
|
||||
? []
|
||||
: [
|
||||
`+ ${userProfileFormFieldComponentName}={${userProfileFormFieldComponentName}}`,
|
||||
`+ doMakeUserConfirmPassword={doMakeUserConfirmPassword}`
|
||||
]),
|
||||
`+ />`,
|
||||
`+ );`,
|
||||
` default: return <Fallback /* .. */ />;`,
|
||||
` }`,
|
||||
` })()}`,
|
||||
` </Suspense>`,
|
||||
` );`,
|
||||
` }`
|
||||
].map(line => {
|
||||
if (line.startsWith("+")) {
|
||||
return chalk.green(line);
|
||||
}
|
||||
if (line.startsWith("-")) {
|
||||
return chalk.red(line);
|
||||
}
|
||||
return chalk.grey(line);
|
||||
}),
|
||||
chalk.grey("```")
|
||||
].join("\n")
|
||||
);
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import { exclude } from "tsafe";
|
||||
import { crawl } from "./tools/crawl";
|
||||
import { join as pathJoin } from "path";
|
||||
import { themeTypes } from "./keycloakify/generateFtl";
|
||||
|
||||
const themeSrcDirBasename = "keycloak-theme";
|
||||
|
||||
/** Can't catch error, if the directory isn't found, this function will just exit the process with an error message. */
|
||||
export function getThemeSrcDirPath(params: { projectDirPath: string }) {
|
||||
const { projectDirPath } = params;
|
||||
|
||||
const srcDirPath = pathJoin(projectDirPath, "src");
|
||||
|
||||
const themeSrcDirPath: string | undefined = crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })
|
||||
.map(fileRelativePath => {
|
||||
const split = fileRelativePath.split(themeSrcDirBasename);
|
||||
|
||||
if (split.length !== 2) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return pathJoin(srcDirPath, split[0] + themeSrcDirBasename);
|
||||
})
|
||||
.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.",
|
||||
"Example in the starter: https://github.com/keycloakify/keycloakify-starter/tree/main/src/keycloak-theme"
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
32
src/bin/initialize-account-theme/copyBoilerplate.ts
Normal file
32
src/bin/initialize-account-theme/copyBoilerplate.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
|
||||
export function copyBoilerplate(params: {
|
||||
accountThemeType: "Single-Page" | "Multi-Page";
|
||||
accountThemeSrcDirPath: string;
|
||||
}) {
|
||||
const { accountThemeType, accountThemeSrcDirPath } = params;
|
||||
|
||||
fs.cpSync(
|
||||
pathJoin(
|
||||
getThisCodebaseRootDirPath(),
|
||||
"src",
|
||||
"bin",
|
||||
"initialize-account-theme",
|
||||
"src",
|
||||
(() => {
|
||||
switch (accountThemeType) {
|
||||
case "Single-Page":
|
||||
return "single-page";
|
||||
case "Multi-Page":
|
||||
return "multi-page";
|
||||
}
|
||||
assert<Equals<typeof accountThemeType, never>>(false);
|
||||
})()
|
||||
),
|
||||
accountThemeSrcDirPath,
|
||||
{ recursive: true }
|
||||
);
|
||||
}
|
1
src/bin/initialize-account-theme/index.ts
Normal file
1
src/bin/initialize-account-theme/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./initialize-account-theme";
|
112
src/bin/initialize-account-theme/initialize-account-theme.ts
Normal file
112
src/bin/initialize-account-theme/initialize-account-theme.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { getBuildContext } from "../shared/buildContext";
|
||||
import type { CliCommandOptions } from "../main";
|
||||
import cliSelect from "cli-select";
|
||||
import child_process from "child_process";
|
||||
import chalk from "chalk";
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
import * as fs from "fs";
|
||||
import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeImplementationInConfig";
|
||||
import { generateKcGenTs } from "../shared/generateKcGenTs";
|
||||
|
||||
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
|
||||
const { cliCommandOptions } = params;
|
||||
|
||||
const buildContext = getBuildContext({ cliCommandOptions });
|
||||
|
||||
const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
|
||||
|
||||
if (
|
||||
fs.existsSync(accountThemeSrcDirPath) &&
|
||||
fs.readdirSync(accountThemeSrcDirPath).length > 0
|
||||
) {
|
||||
console.warn(
|
||||
chalk.red(
|
||||
`There is already a ${pathRelative(
|
||||
process.cwd(),
|
||||
accountThemeSrcDirPath
|
||||
)} directory in your project. Aborting.`
|
||||
)
|
||||
);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
exit_if_uncommitted_changes: {
|
||||
let hasUncommittedChanges: boolean | undefined = undefined;
|
||||
|
||||
try {
|
||||
hasUncommittedChanges =
|
||||
child_process
|
||||
.execSync(`git status --porcelain`, {
|
||||
cwd: buildContext.projectDirPath
|
||||
})
|
||||
.toString()
|
||||
.trim() !== "";
|
||||
} catch {
|
||||
// Probably not a git repository
|
||||
break exit_if_uncommitted_changes;
|
||||
}
|
||||
|
||||
if (!hasUncommittedChanges) {
|
||||
break exit_if_uncommitted_changes;
|
||||
}
|
||||
console.warn(
|
||||
[
|
||||
chalk.red(
|
||||
"Please commit or stash your changes before running this command.\n"
|
||||
),
|
||||
"This command will modify your project's files so it's better to have a clean working directory",
|
||||
"so that you can easily see what has been changed and revert if needed."
|
||||
].join(" ")
|
||||
);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
const { value: accountThemeType } = await cliSelect({
|
||||
values: ["Single-Page" as const, "Multi-Page" as const]
|
||||
}).catch(() => {
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
switch (accountThemeType) {
|
||||
case "Multi-Page":
|
||||
{
|
||||
const { initializeAccountTheme_multiPage } = await import(
|
||||
"./initializeAccountTheme_multiPage"
|
||||
);
|
||||
|
||||
await initializeAccountTheme_multiPage({
|
||||
accountThemeSrcDirPath
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "Single-Page":
|
||||
{
|
||||
const { initializeAccountTheme_singlePage } = await import(
|
||||
"./initializeAccountTheme_singlePage"
|
||||
);
|
||||
|
||||
await initializeAccountTheme_singlePage({
|
||||
accountThemeSrcDirPath,
|
||||
buildContext
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
updateAccountThemeImplementationInConfig({ buildContext, accountThemeType });
|
||||
|
||||
await generateKcGenTs({
|
||||
buildContext: {
|
||||
...buildContext,
|
||||
implementedThemeTypes: {
|
||||
...buildContext.implementedThemeTypes,
|
||||
account: {
|
||||
isImplemented: true,
|
||||
type: accountThemeType
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { relative as pathRelative } from "path";
|
||||
import chalk from "chalk";
|
||||
import { copyBoilerplate } from "./copyBoilerplate";
|
||||
|
||||
export async function initializeAccountTheme_multiPage(params: {
|
||||
accountThemeSrcDirPath: string;
|
||||
}) {
|
||||
const { accountThemeSrcDirPath } = params;
|
||||
|
||||
copyBoilerplate({
|
||||
accountThemeType: "Multi-Page",
|
||||
accountThemeSrcDirPath
|
||||
});
|
||||
|
||||
console.log(
|
||||
[
|
||||
chalk.green("The Multi-Page account theme has been initialized."),
|
||||
`Directory created: ${chalk.bold(pathRelative(process.cwd(), accountThemeSrcDirPath))}`
|
||||
].join("\n")
|
||||
);
|
||||
}
|
@ -0,0 +1,150 @@
|
||||
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
|
||||
import type { BuildContext } from "../shared/buildContext";
|
||||
import * as fs from "fs";
|
||||
import chalk from "chalk";
|
||||
import { getLatestsSemVersionedTag } from "../shared/getLatestsSemVersionedTag";
|
||||
import fetch from "make-fetch-happen";
|
||||
import { z } from "zod";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import { is } from "tsafe/is";
|
||||
import { id } from "tsafe/id";
|
||||
import { npmInstall } from "../tools/npmInstall";
|
||||
import { copyBoilerplate } from "./copyBoilerplate";
|
||||
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
|
||||
|
||||
type BuildContextLike = {
|
||||
cacheDirPath: string;
|
||||
fetchOptions: BuildContext["fetchOptions"];
|
||||
packageJsonFilePath: string;
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export async function initializeAccountTheme_singlePage(params: {
|
||||
accountThemeSrcDirPath: string;
|
||||
buildContext: BuildContextLike;
|
||||
}) {
|
||||
const { accountThemeSrcDirPath, buildContext } = params;
|
||||
|
||||
const OWNER = "keycloakify";
|
||||
const REPO = "keycloak-account-ui";
|
||||
|
||||
const [semVersionedTag] = await getLatestsSemVersionedTag({
|
||||
cacheDirPath: buildContext.cacheDirPath,
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
count: 1,
|
||||
doIgnoreReleaseCandidates: false
|
||||
});
|
||||
|
||||
const dependencies = await fetch(
|
||||
`https://raw.githubusercontent.com/${OWNER}/${REPO}/${semVersionedTag.tag}/dependencies.gen.json`,
|
||||
buildContext.fetchOptions
|
||||
)
|
||||
.then(r => r.json())
|
||||
.then(
|
||||
(() => {
|
||||
type Dependencies = {
|
||||
dependencies: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
const zDependencies = (() => {
|
||||
type TargetType = Dependencies;
|
||||
|
||||
const zTargetType = z.object({
|
||||
dependencies: z.record(z.string()),
|
||||
devDependencies: z.record(z.string()).optional()
|
||||
});
|
||||
|
||||
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
|
||||
|
||||
return id<z.ZodType<TargetType>>(zTargetType);
|
||||
})();
|
||||
|
||||
return o => zDependencies.parse(o);
|
||||
})()
|
||||
);
|
||||
|
||||
dependencies.dependencies["@keycloakify/keycloak-account-ui"] = semVersionedTag.tag;
|
||||
|
||||
const parsedPackageJson = (() => {
|
||||
type ParsedPackageJson = {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
const zParsedPackageJson = (() => {
|
||||
type TargetType = ParsedPackageJson;
|
||||
|
||||
const zTargetType = z.object({
|
||||
dependencies: z.record(z.string()).optional(),
|
||||
devDependencies: z.record(z.string()).optional()
|
||||
});
|
||||
|
||||
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
|
||||
|
||||
return id<z.ZodType<TargetType>>(zTargetType);
|
||||
})();
|
||||
const parsedPackageJson = JSON.parse(
|
||||
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
|
||||
);
|
||||
|
||||
zParsedPackageJson.parse(parsedPackageJson);
|
||||
|
||||
assert(is<ParsedPackageJson>(parsedPackageJson));
|
||||
|
||||
return parsedPackageJson;
|
||||
})();
|
||||
|
||||
parsedPackageJson.dependencies = {
|
||||
...parsedPackageJson.dependencies,
|
||||
...dependencies.dependencies
|
||||
};
|
||||
|
||||
parsedPackageJson.devDependencies = {
|
||||
...parsedPackageJson.devDependencies,
|
||||
...dependencies.devDependencies
|
||||
};
|
||||
|
||||
if (Object.keys(parsedPackageJson.devDependencies).length === 0) {
|
||||
delete parsedPackageJson.devDependencies;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
buildContext.packageJsonFilePath,
|
||||
JSON.stringify(parsedPackageJson, undefined, 4)
|
||||
);
|
||||
|
||||
run_npm_install: {
|
||||
if (
|
||||
JSON.parse(
|
||||
fs
|
||||
.readFileSync(pathJoin(getThisCodebaseRootDirPath(), "package.json"))
|
||||
.toString("utf8")
|
||||
)["version"] === "0.0.0"
|
||||
) {
|
||||
//NOTE: Linked version
|
||||
break run_npm_install;
|
||||
}
|
||||
|
||||
npmInstall({ packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath) });
|
||||
}
|
||||
|
||||
copyBoilerplate({
|
||||
accountThemeType: "Single-Page",
|
||||
accountThemeSrcDirPath
|
||||
});
|
||||
|
||||
console.log(
|
||||
[
|
||||
chalk.green(
|
||||
"The Single-Page account theme has been successfully initialized."
|
||||
),
|
||||
`Using Account UI of Keycloak version: ${chalk.bold(semVersionedTag.tag.split("-")[0])}`,
|
||||
`Directory created: ${chalk.bold(pathRelative(process.cwd(), accountThemeSrcDirPath))}`,
|
||||
`Dependencies added to your project's package.json: `,
|
||||
chalk.bold(JSON.stringify(dependencies, null, 2))
|
||||
].join("\n")
|
||||
);
|
||||
}
|
12
src/bin/initialize-account-theme/src/multi-page/KcContext.ts
Normal file
12
src/bin/initialize-account-theme/src/multi-page/KcContext.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
import type { ExtendKcContext } from "keycloakify/account";
|
||||
import type { KcEnvName, ThemeName } from "../kc.gen";
|
||||
|
||||
export type KcContextExtension = {
|
||||
themeName: ThemeName;
|
||||
properties: Record<KcEnvName, string> & {};
|
||||
};
|
||||
|
||||
export type KcContextExtensionPerPage = {};
|
||||
|
||||
export type KcContext = ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>;
|
25
src/bin/initialize-account-theme/src/multi-page/KcPage.tsx
Normal file
25
src/bin/initialize-account-theme/src/multi-page/KcPage.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Suspense } from "react";
|
||||
import type { ClassKey } from "keycloakify/account";
|
||||
import type { KcContext } from "./KcContext";
|
||||
import { useI18n } from "./i18n";
|
||||
import DefaultPage from "keycloakify/account/DefaultPage";
|
||||
import Template from "keycloakify/account/Template";
|
||||
|
||||
export default function KcPage(props: { kcContext: KcContext }) {
|
||||
const { kcContext } = props;
|
||||
|
||||
const { i18n } = useI18n({ kcContext });
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
{(() => {
|
||||
switch (kcContext.pageId) {
|
||||
default:
|
||||
return <DefaultPage kcContext={kcContext} i18n={i18n} classes={classes} Template={Template} doUseDefaultCss={true} />;
|
||||
}
|
||||
})()}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const classes = {} satisfies { [key in ClassKey]?: string };
|
@ -0,0 +1,38 @@
|
||||
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
|
||||
import type { KcContext } from "./KcContext";
|
||||
import { createGetKcContextMock } from "keycloakify/account/KcContext";
|
||||
import type { KcContextExtension, KcContextExtensionPerPage } from "./KcContext";
|
||||
import KcPage from "./KcPage";
|
||||
import { themeNames, kcEnvDefaults } from "../kc.gen";
|
||||
|
||||
const kcContextExtension: KcContextExtension = {
|
||||
themeName: themeNames[0],
|
||||
properties: {
|
||||
...kcEnvDefaults
|
||||
}
|
||||
};
|
||||
const kcContextExtensionPerPage: KcContextExtensionPerPage = {};
|
||||
|
||||
export const { getKcContextMock } = createGetKcContextMock({
|
||||
kcContextExtension,
|
||||
kcContextExtensionPerPage,
|
||||
overrides: {},
|
||||
overridesPerPage: {}
|
||||
});
|
||||
|
||||
export function createKcPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) {
|
||||
const { pageId } = params;
|
||||
|
||||
function KcPageStory(props: { kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>> }) {
|
||||
const { kcContext: overrides } = props;
|
||||
|
||||
const kcContextMock = getKcContextMock({
|
||||
pageId,
|
||||
overrides
|
||||
});
|
||||
|
||||
return <KcPage kcContext={kcContextMock} />;
|
||||
}
|
||||
|
||||
return { KcPageStory };
|
||||
}
|
5
src/bin/initialize-account-theme/src/multi-page/i18n.ts
Normal file
5
src/bin/initialize-account-theme/src/multi-page/i18n.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createUseI18n } from "keycloakify/account";
|
||||
|
||||
export const { useI18n, ofTypeI18n } = createUseI18n({});
|
||||
|
||||
export type I18n = typeof ofTypeI18n;
|
@ -0,0 +1,7 @@
|
||||
import type { KcContextLike } from "@keycloakify/keycloak-account-ui";
|
||||
import type { KcEnvName } from "../kc.gen";
|
||||
|
||||
export type KcContext = KcContextLike & {
|
||||
themeType: "account";
|
||||
properties: Record<KcEnvName, string>;
|
||||
};
|
11
src/bin/initialize-account-theme/src/single-page/KcPage.tsx
Normal file
11
src/bin/initialize-account-theme/src/single-page/KcPage.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { lazy } from "react";
|
||||
import { KcAccountUiLoader } from "@keycloakify/keycloak-account-ui";
|
||||
import type { KcContext } from "./KcContext";
|
||||
|
||||
const KcAccountUi = lazy(() => import("@keycloakify/keycloak-account-ui/KcAccountUi"));
|
||||
|
||||
export default function KcPage(props: { kcContext: KcContext }) {
|
||||
const { kcContext } = props;
|
||||
|
||||
return <KcAccountUiLoader kcContext={kcContext} KcAccountUi={KcAccountUi} />;
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
import { join as pathJoin } from "path";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import type { BuildContext } from "../shared/buildContext";
|
||||
import * as fs from "fs";
|
||||
import chalk from "chalk";
|
||||
import { z } from "zod";
|
||||
import { id } from "tsafe/id";
|
||||
|
||||
export type BuildContextLike = {
|
||||
bundler: BuildContext["bundler"];
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export function updateAccountThemeImplementationInConfig(params: {
|
||||
buildContext: BuildContext;
|
||||
accountThemeType: "Single-Page" | "Multi-Page";
|
||||
}) {
|
||||
const { buildContext, accountThemeType } = params;
|
||||
|
||||
switch (buildContext.bundler) {
|
||||
case "vite":
|
||||
{
|
||||
const viteConfigPath = pathJoin(
|
||||
buildContext.projectDirPath,
|
||||
"vite.config.ts"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(viteConfigPath)) {
|
||||
console.log(
|
||||
chalk.bold(
|
||||
`You must manually set the accountThemeImplementation to "${accountThemeType}" in your vite config`
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const viteConfigContent = fs
|
||||
.readFileSync(viteConfigPath)
|
||||
.toString("utf8");
|
||||
|
||||
const modifiedViteConfigContent = viteConfigContent.replace(
|
||||
/accountThemeImplementation\s*:\s*"none"/,
|
||||
`accountThemeImplementation: "${accountThemeType}"`
|
||||
);
|
||||
|
||||
if (modifiedViteConfigContent === viteConfigContent) {
|
||||
console.log(
|
||||
chalk.bold(
|
||||
`You must manually set the accountThemeImplementation to "${accountThemeType}" in your vite.config.ts`
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
fs.writeFileSync(viteConfigPath, modifiedViteConfigContent);
|
||||
}
|
||||
break;
|
||||
case "webpack":
|
||||
{
|
||||
const parsedPackageJson = (() => {
|
||||
type ParsedPackageJson = {
|
||||
keycloakify: Record<string, string>;
|
||||
};
|
||||
|
||||
const zParsedPackageJson = (() => {
|
||||
type TargetType = ParsedPackageJson;
|
||||
|
||||
const zTargetType = z.object({
|
||||
keycloakify: z.record(z.string())
|
||||
});
|
||||
|
||||
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
|
||||
|
||||
return id<z.ZodType<TargetType>>(zTargetType);
|
||||
})();
|
||||
|
||||
return zParsedPackageJson.parse(
|
||||
JSON.parse(
|
||||
fs
|
||||
.readFileSync(buildContext.packageJsonFilePath)
|
||||
.toString("utf8")
|
||||
)
|
||||
);
|
||||
})();
|
||||
|
||||
parsedPackageJson.keycloakify.accountThemeImplementation =
|
||||
accountThemeType;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
@ -1,62 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { downloadBuiltinKeycloakTheme } from "./download-builtin-keycloak-theme";
|
||||
import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTheme";
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
import { transformCodebase } from "./tools/transformCodebase";
|
||||
import { promptKeycloakVersion } from "./promptKeycloakVersion";
|
||||
import { readBuildOptions } from "./keycloakify/BuildOptions";
|
||||
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
|
||||
import { getBuildContext } from "./shared/buildContext";
|
||||
import * as fs from "fs";
|
||||
import { getLogger } from "./tools/logger";
|
||||
import { getThemeSrcDirPath } from "./getSrcDirPath";
|
||||
import type { CliCommandOptions } from "./main";
|
||||
|
||||
export async function main() {
|
||||
const projectDirPath = process.cwd();
|
||||
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
|
||||
const { cliCommandOptions } = params;
|
||||
|
||||
const { isSilent } = readBuildOptions({
|
||||
projectDirPath,
|
||||
"processArgv": process.argv.slice(2)
|
||||
});
|
||||
const buildContext = getBuildContext({ cliCommandOptions });
|
||||
|
||||
const logger = getLogger({ isSilent });
|
||||
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({
|
||||
projectDirPath
|
||||
});
|
||||
|
||||
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
|
||||
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
|
||||
|
||||
if (fs.existsSync(emailThemeSrcDirPath)) {
|
||||
logger.warn(`There is already a ${pathRelative(process.cwd(), emailThemeSrcDirPath)} directory in your project. Aborting.`);
|
||||
console.warn(
|
||||
`There is already a ${pathRelative(
|
||||
process.cwd(),
|
||||
emailThemeSrcDirPath
|
||||
)} directory in your project. Aborting.`
|
||||
);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
const { keycloakVersion } = await promptKeycloakVersion();
|
||||
console.log("Initialize with the base email theme from which version of Keycloak?");
|
||||
|
||||
const builtinKeycloakThemeTmpDirPath = pathJoin(emailThemeSrcDirPath, "..", "tmp_xIdP3_builtin_keycloak_theme");
|
||||
const { keycloakVersion } = await promptKeycloakVersion({
|
||||
// NOTE: This is arbitrary
|
||||
startingFromMajor: 17,
|
||||
excludeMajorVersions: [],
|
||||
cacheDirPath: buildContext.cacheDirPath
|
||||
});
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
projectDirPath,
|
||||
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
|
||||
keycloakVersion,
|
||||
"destDirPath": builtinKeycloakThemeTmpDirPath
|
||||
buildContext
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "base", "email"),
|
||||
"destDirPath": emailThemeSrcDirPath
|
||||
srcDirPath: pathJoin(defaultThemeDirPath, "base", "email"),
|
||||
destDirPath: emailThemeSrcDirPath
|
||||
});
|
||||
|
||||
{
|
||||
const themePropertyFilePath = pathJoin(emailThemeSrcDirPath, "theme.properties");
|
||||
|
||||
fs.writeFileSync(themePropertyFilePath, Buffer.from(`parent=base\n${fs.readFileSync(themePropertyFilePath).toString("utf8")}`, "utf8"));
|
||||
fs.writeFileSync(
|
||||
themePropertyFilePath,
|
||||
Buffer.from(
|
||||
`parent=base\n${fs.readFileSync(themePropertyFilePath).toString("utf8")}`,
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
console.log(
|
||||
`The \`${pathJoin(
|
||||
".",
|
||||
pathRelative(process.cwd(), emailThemeSrcDirPath)
|
||||
)}\` directory have been created.`
|
||||
);
|
||||
console.log("You can delete any file you don't modify.");
|
||||
}
|
||||
|
@ -1,138 +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 { 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 = {
|
||||
isSilent: boolean;
|
||||
themeVersion: string;
|
||||
themeName: string;
|
||||
extraThemeNames: string[];
|
||||
extraThemeProperties: string[] | undefined;
|
||||
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;
|
||||
/** 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;
|
||||
};
|
||||
|
||||
export function readBuildOptions(params: { projectDirPath: string; processArgv: string[] }): BuildOptions {
|
||||
const { projectDirPath, processArgv } = params;
|
||||
|
||||
const { isSilentCliParamProvided } = (() => {
|
||||
const argv = parseArgv(processArgv);
|
||||
|
||||
return {
|
||||
"isSilentCliParamProvided": typeof argv["silent"] === "boolean" ? argv["silent"] : false
|
||||
};
|
||||
})();
|
||||
|
||||
const parsedPackageJson = getParsedPackageJson({ projectDirPath });
|
||||
|
||||
const { name, keycloakify = {}, version, homepage } = parsedPackageJson;
|
||||
|
||||
const { extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets, extraThemeNames = [] } = keycloakify ?? {};
|
||||
|
||||
const themeName =
|
||||
keycloakify.themeName ??
|
||||
name
|
||||
.replace(/^@(.*)/, "$1")
|
||||
.split("/")
|
||||
.join("-");
|
||||
|
||||
return {
|
||||
themeName,
|
||||
extraThemeNames,
|
||||
"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",
|
||||
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;
|
||||
})(),
|
||||
"urlPathname": (() => {
|
||||
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;
|
||||
})()
|
||||
};
|
||||
}
|
245
src/bin/keycloakify/buildJars/buildJar.ts
Normal file
245
src/bin/keycloakify/buildJars/buildJar.ts
Normal file
@ -0,0 +1,245 @@
|
||||
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 { BuildContext } from "../../shared/buildContext";
|
||||
import * as fs from "fs/promises";
|
||||
import { ACCOUNT_V1_THEME_NAME } from "../../shared/constants";
|
||||
import {
|
||||
generatePom,
|
||||
BuildContextLike as BuildContextLike_generatePom
|
||||
} from "./generatePom";
|
||||
import { readFileSync } from "fs";
|
||||
import { isInside } from "../../tools/isInside";
|
||||
import child_process from "child_process";
|
||||
import { rmSync } from "../../tools/fs.rmSync";
|
||||
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_generatePom & {
|
||||
keycloakifyBuildDirPath: string;
|
||||
themeNames: string[];
|
||||
artifactId: string;
|
||||
themeVersion: string;
|
||||
cacheDirPath: string;
|
||||
implementedThemeTypes: BuildContext["implementedThemeTypes"];
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export async function buildJar(params: {
|
||||
jarFileBasename: string;
|
||||
keycloakAccountV1Version: KeycloakAccountV1Version;
|
||||
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
|
||||
resourcesDirPath: string;
|
||||
doesImplementAccountV1Theme: boolean;
|
||||
buildContext: BuildContextLike;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
jarFileBasename,
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion,
|
||||
resourcesDirPath,
|
||||
doesImplementAccountV1Theme,
|
||||
buildContext
|
||||
} = params;
|
||||
|
||||
const keycloakifyBuildCacheDirPath = pathJoin(
|
||||
buildContext.cacheDirPath,
|
||||
"maven",
|
||||
jarFileBasename.replace(".jar", "")
|
||||
);
|
||||
|
||||
const tmpResourcesDirPath = pathJoin(
|
||||
keycloakifyBuildCacheDirPath,
|
||||
"src",
|
||||
"main",
|
||||
"resources"
|
||||
);
|
||||
|
||||
rmSync(tmpResourcesDirPath, { recursive: true, force: true });
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: resourcesDirPath,
|
||||
destDirPath: tmpResourcesDirPath,
|
||||
transformSourceCode:
|
||||
!doesImplementAccountV1Theme || keycloakAccountV1Version !== null
|
||||
? undefined
|
||||
: (params: {
|
||||
fileRelativePath: string;
|
||||
sourceCode: Buffer;
|
||||
}): { modifiedSourceCode: Buffer } | undefined => {
|
||||
const { fileRelativePath, sourceCode } = params;
|
||||
|
||||
if (
|
||||
isInside({
|
||||
dirPath: pathJoin("theme", ACCOUNT_V1_THEME_NAME),
|
||||
filePath: fileRelativePath
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const themeName of buildContext.themeNames) {
|
||||
if (
|
||||
fileRelativePath ===
|
||||
pathJoin("theme", themeName, "account", "theme.properties")
|
||||
) {
|
||||
const modifiedSourceCode = Buffer.from(
|
||||
sourceCode
|
||||
.toString("utf8")
|
||||
.replace(
|
||||
`parent=${ACCOUNT_V1_THEME_NAME}`,
|
||||
"parent=keycloak"
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert(
|
||||
Buffer.compare(modifiedSourceCode, sourceCode) !== 0
|
||||
);
|
||||
|
||||
return { modifiedSourceCode };
|
||||
}
|
||||
}
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
});
|
||||
|
||||
remove_account_v1_in_meta_inf: {
|
||||
if (!doesImplementAccountV1Theme) {
|
||||
// NOTE: We do not have account v1 anyway
|
||||
break remove_account_v1_in_meta_inf;
|
||||
}
|
||||
|
||||
if (keycloakAccountV1Version !== null) {
|
||||
// NOTE: No, we need to keep account-v1 in meta-inf
|
||||
break remove_account_v1_in_meta_inf;
|
||||
}
|
||||
|
||||
writeMetaInfKeycloakThemes({
|
||||
resourcesDirPath: tmpResourcesDirPath,
|
||||
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => {
|
||||
assert(metaInfKeycloakTheme !== undefined);
|
||||
|
||||
metaInfKeycloakTheme.themes = metaInfKeycloakTheme.themes.filter(
|
||||
({ name }) => name !== ACCOUNT_V1_THEME_NAME
|
||||
);
|
||||
|
||||
return metaInfKeycloakTheme;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
route_legacy_pages: {
|
||||
if (!buildContext.implementedThemeTypes.login.isImplemented) {
|
||||
break route_legacy_pages;
|
||||
}
|
||||
|
||||
// NOTE: If there's no account theme there is no special target for keycloak 24 and up so we create
|
||||
// the pages anyway. If there is an account pages, since we know that account-v1 is only support keycloak
|
||||
// 24 in version 0.4 and up, we can safely break the route for legacy pages.
|
||||
const doBreak: boolean = (() => {
|
||||
switch (keycloakAccountV1Version) {
|
||||
case null:
|
||||
return false;
|
||||
case "0.3":
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
|
||||
// TODO: Remove this optimization, it's a bit hacky.
|
||||
if (doBreak) {
|
||||
break route_legacy_pages;
|
||||
}
|
||||
|
||||
(["register.ftl", "login-update-profile.ftl"] as const).forEach(pageId =>
|
||||
buildContext.themeNames.map(themeName => {
|
||||
const ftlFilePath = pathJoin(
|
||||
tmpResourcesDirPath,
|
||||
"theme",
|
||||
themeName,
|
||||
"login",
|
||||
pageId
|
||||
);
|
||||
|
||||
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
|
||||
|
||||
const ftlFileBasename = (() => {
|
||||
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(
|
||||
`"ftlTemplateFileName": "${pageId}"`,
|
||||
`"ftlTemplateFileName": "${ftlFileBasename}"`
|
||||
);
|
||||
|
||||
assert(modifiedFtlFileContent !== ftlFileContent);
|
||||
|
||||
fs.writeFile(
|
||||
pathJoin(pathDirname(ftlFilePath), ftlFileBasename),
|
||||
Buffer.from(modifiedFtlFileContent, "utf8")
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const { pomFileCode } = generatePom({
|
||||
buildContext,
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion
|
||||
});
|
||||
|
||||
await fs.writeFile(
|
||||
pathJoin(keycloakifyBuildCacheDirPath, "pom.xml"),
|
||||
Buffer.from(pomFileCode, "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
child_process.exec(
|
||||
`mvn install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`,
|
||||
{ cwd: keycloakifyBuildCacheDirPath },
|
||||
error => {
|
||||
if (error !== null) {
|
||||
console.error(
|
||||
`Build jar failed: ${JSON.stringify(
|
||||
{
|
||||
jarFileBasename,
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
await fs.rename(
|
||||
pathJoin(
|
||||
keycloakifyBuildCacheDirPath,
|
||||
"target",
|
||||
`${buildContext.artifactId}-${buildContext.themeVersion}.jar`
|
||||
),
|
||||
pathJoin(buildContext.keycloakifyBuildDirPath, jarFileBasename)
|
||||
);
|
||||
}
|
68
src/bin/keycloakify/buildJars/buildJars.ts
Normal file
68
src/bin/keycloakify/buildJars/buildJars.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { assert } from "tsafe/assert";
|
||||
import {
|
||||
keycloakAccountV1Versions,
|
||||
keycloakThemeAdditionalInfoExtensionVersions
|
||||
} from "./extensionVersions";
|
||||
import { getKeycloakVersionRangeForJar } from "./getKeycloakVersionRangeForJar";
|
||||
import { buildJar, BuildContextLike as BuildContextLike_buildJar } from "./buildJar";
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_buildJar & {
|
||||
projectDirPath: string;
|
||||
keycloakifyBuildDirPath: string;
|
||||
implementedThemeTypes: BuildContext["implementedThemeTypes"];
|
||||
jarTargets: BuildContext["jarTargets"];
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export async function buildJars(params: {
|
||||
resourcesDirPath: string;
|
||||
buildContext: BuildContextLike;
|
||||
}): Promise<void> {
|
||||
const { resourcesDirPath, buildContext } = params;
|
||||
|
||||
const doesImplementAccountV1Theme =
|
||||
buildContext.implementedThemeTypes.account.isImplemented &&
|
||||
buildContext.implementedThemeTypes.account.type === "Multi-Page";
|
||||
|
||||
await Promise.all(
|
||||
keycloakAccountV1Versions
|
||||
.map(keycloakAccountV1Version =>
|
||||
keycloakThemeAdditionalInfoExtensionVersions.map(
|
||||
keycloakThemeAdditionalInfoExtensionVersion => {
|
||||
const keycloakVersionRange = getKeycloakVersionRangeForJar({
|
||||
doesImplementAccountV1Theme,
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion
|
||||
});
|
||||
|
||||
if (keycloakVersionRange === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const jarTarget = buildContext.jarTargets.find(
|
||||
jarTarget =>
|
||||
jarTarget.keycloakVersionRange === keycloakVersionRange
|
||||
);
|
||||
|
||||
if (jarTarget === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { jarFileBasename } = jarTarget;
|
||||
|
||||
return buildJar({
|
||||
jarFileBasename,
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion,
|
||||
resourcesDirPath,
|
||||
doesImplementAccountV1Theme,
|
||||
buildContext
|
||||
});
|
||||
}
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
);
|
||||
}
|
17
src/bin/keycloakify/buildJars/extensionVersions.ts
Normal file
17
src/bin/keycloakify/buildJars/extensionVersions.ts
Normal file
@ -0,0 +1,17 @@
|
||||
// NOTE: v0.5 is a dummy version.
|
||||
export const keycloakAccountV1Versions = [null, "0.3", "0.4", "0.6"] 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];
|
94
src/bin/keycloakify/buildJars/generatePom.ts
Normal file
94
src/bin/keycloakify/buildJars/generatePom.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
import type {
|
||||
KeycloakAccountV1Version,
|
||||
KeycloakThemeAdditionalInfoExtensionVersion
|
||||
} from "./extensionVersions";
|
||||
|
||||
export type BuildContextLike = {
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
themeVersion: string;
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export function generatePom(params: {
|
||||
keycloakAccountV1Version: KeycloakAccountV1Version;
|
||||
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
|
||||
buildContext: BuildContextLike;
|
||||
}) {
|
||||
const {
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion,
|
||||
buildContext
|
||||
} = 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>${buildContext.groupId}</groupId>`,
|
||||
` <artifactId>${buildContext.artifactId}</artifactId>`,
|
||||
` <version>${buildContext.themeVersion}</version>`,
|
||||
` <name>${buildContext.artifactId}</name>`,
|
||||
` <description />`,
|
||||
` <packaging>jar</packaging>`,
|
||||
` <properties>`,
|
||||
` <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>`,
|
||||
` </properties>`,
|
||||
...(keycloakAccountV1Version !== null ||
|
||||
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,96 @@
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import type {
|
||||
KeycloakAccountV1Version,
|
||||
KeycloakThemeAdditionalInfoExtensionVersion
|
||||
} from "./extensionVersions";
|
||||
import type { KeycloakVersionRange } from "../../shared/KeycloakVersionRange";
|
||||
|
||||
export function getKeycloakVersionRangeForJar(params: {
|
||||
doesImplementAccountV1Theme: boolean;
|
||||
keycloakAccountV1Version: KeycloakAccountV1Version;
|
||||
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
|
||||
}): KeycloakVersionRange | undefined {
|
||||
const {
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion,
|
||||
doesImplementAccountV1Theme
|
||||
} = params;
|
||||
|
||||
if (doesImplementAccountV1Theme) {
|
||||
const keycloakVersionRange = (() => {
|
||||
switch (keycloakAccountV1Version) {
|
||||
case null:
|
||||
switch (keycloakThemeAdditionalInfoExtensionVersion) {
|
||||
case null:
|
||||
return "21-and-below" as const;
|
||||
case "1.1.5":
|
||||
return undefined;
|
||||
}
|
||||
assert<
|
||||
Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>
|
||||
>(false);
|
||||
case "0.3":
|
||||
switch (keycloakThemeAdditionalInfoExtensionVersion) {
|
||||
case null:
|
||||
return undefined;
|
||||
case "1.1.5":
|
||||
return "23" as const;
|
||||
}
|
||||
assert<
|
||||
Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>
|
||||
>(false);
|
||||
case "0.4":
|
||||
switch (keycloakThemeAdditionalInfoExtensionVersion) {
|
||||
case null:
|
||||
return undefined;
|
||||
case "1.1.5":
|
||||
return "24" as const;
|
||||
}
|
||||
assert<
|
||||
Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>
|
||||
>(false);
|
||||
case "0.6":
|
||||
switch (keycloakThemeAdditionalInfoExtensionVersion) {
|
||||
case null:
|
||||
return undefined;
|
||||
case "1.1.5":
|
||||
return "25-and-above" as const;
|
||||
}
|
||||
}
|
||||
assert<Equals<typeof keycloakAccountV1Version, never>>(false);
|
||||
})();
|
||||
|
||||
assert<
|
||||
Equals<
|
||||
typeof keycloakVersionRange,
|
||||
KeycloakVersionRange.WithAccountV1Theme | undefined
|
||||
>
|
||||
>();
|
||||
|
||||
return keycloakVersionRange;
|
||||
} else {
|
||||
const keycloakVersionRange = (() => {
|
||||
if (keycloakAccountV1Version !== null) {
|
||||
return undefined;
|
||||
}
|
||||
switch (keycloakThemeAdditionalInfoExtensionVersion) {
|
||||
case null:
|
||||
return "21-and-below";
|
||||
case "1.1.5":
|
||||
return "22-and-above";
|
||||
}
|
||||
assert<Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>>(
|
||||
false
|
||||
);
|
||||
})();
|
||||
|
||||
assert<
|
||||
Equals<
|
||||
typeof keycloakVersionRange,
|
||||
KeycloakVersionRange.WithoutAccountV1Theme | undefined
|
||||
>
|
||||
>();
|
||||
|
||||
return keycloakVersionRange;
|
||||
}
|
||||
}
|
1
src/bin/keycloakify/buildJars/index.ts
Normal file
1
src/bin/keycloakify/buildJars/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./buildJars";
|
@ -1 +0,0 @@
|
||||
export const ftlValuesGlobalName = "kcContext";
|
@ -1,682 +0,0 @@
|
||||
<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 = [ 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/359 Compat with Keycloak prior v12 -->
|
||||
<#if !messagesPerField.existsError??>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistMessageForUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
return <#if doExistMessageForUsernameOrPassword>text<#else>undefined</#if>;
|
||||
|
||||
<#else>
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>text<#else>undefined</#if>;
|
||||
|
||||
</#if>
|
||||
|
||||
<#else>
|
||||
|
||||
<#-- 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>
|
||||
|
||||
</#if>
|
||||
|
||||
}
|
||||
</#list>
|
||||
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
</#if>
|
||||
|
||||
},
|
||||
"existsError": function (fieldName) {
|
||||
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
|
||||
<#if !messagesPerField.existsError??>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistMessageForUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
return <#if doExistMessageForUsernameOrPassword>true<#else>false</#if>;
|
||||
|
||||
<#else>
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>true<#else>false</#if>;
|
||||
|
||||
</#if>
|
||||
|
||||
<#else>
|
||||
|
||||
<#-- 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>
|
||||
|
||||
</#if>
|
||||
|
||||
}
|
||||
</#list>
|
||||
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
|
||||
</#if>
|
||||
|
||||
},
|
||||
"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/359 Compat with Keycloak prior v12 -->
|
||||
<#if !messagesPerField.existsError??>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistMessageForUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
return "";
|
||||
<#else>
|
||||
<#attempt>
|
||||
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}";
|
||||
<#recover>
|
||||
return "Invalid username or password.";
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#else>
|
||||
|
||||
<#attempt>
|
||||
return "${messagesPerField.get('${fieldName}')?no_esc}";
|
||||
<#recover>
|
||||
return "invalid field";
|
||||
</#attempt>
|
||||
|
||||
</#if>
|
||||
|
||||
<#else>
|
||||
|
||||
<#-- 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>
|
||||
|
||||
</#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/359 Compat with Keycloak prior v12 -->
|
||||
<#if !messagesPerField.existsError??>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistMessageForUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
return <#if doExistMessageForUsernameOrPassword>true<#else>false</#if>;
|
||||
|
||||
<#else>
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>true<#else>false</#if>;
|
||||
|
||||
</#if>
|
||||
|
||||
<#else>
|
||||
|
||||
<#-- 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>
|
||||
|
||||
</#if>
|
||||
|
||||
}
|
||||
</#list>
|
||||
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
</#if>
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
<#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}";
|
||||
|
||||
return out;
|
||||
|
||||
})()
|
||||
<#function ftl_object_to_js_code_declaring_an_object object path>
|
||||
|
||||
<#local isHash = "">
|
||||
<#attempt>
|
||||
<#local isHash = object?is_hash || object?is_hash_ex>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't evaluate if " + path?join(".") + " is hash">
|
||||
</#attempt>
|
||||
|
||||
<#if isHash>
|
||||
|
||||
<#if path?size gt 10>
|
||||
<#return "ABORT: Too many recursive calls">
|
||||
</#if>
|
||||
|
||||
<#local keys = "">
|
||||
|
||||
<#attempt>
|
||||
<#local keys = object?keys>
|
||||
<#recover>
|
||||
<#return "ABORT: We can't list keys on this object">
|
||||
</#attempt>
|
||||
|
||||
|
||||
<#local out_seq = []>
|
||||
|
||||
<#list keys as key>
|
||||
|
||||
<#if ["class","declaredConstructors","superclass","declaringClass" ]?seq_contains(key) >
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#if
|
||||
(
|
||||
["loginUpdatePasswordUrl", "loginUpdateProfileUrl", "loginUsernameReminderUrl", "loginUpdateTotpUrl"]?seq_contains(key) &&
|
||||
are_same_path(path, ["url"])
|
||||
) || (
|
||||
key == "updateProfileCtx" &&
|
||||
are_same_path(path, [])
|
||||
) || (
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
|
||||
key == "loginAction" &&
|
||||
are_same_path(path, ["url"]) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl"]?seq_contains(pageId) &&
|
||||
!(auth?has_content && auth.showTryAnotherWayLink())
|
||||
) || (
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
|
||||
["secretData", "value"]?seq_contains(key) &&
|
||||
are_same_path(path, [ "totp", "otpCredentials", "*" ])
|
||||
) || (
|
||||
["contextData", "idpConfig", "idp", "authenticationSession"]?seq_contains(key) &&
|
||||
are_same_path(path, ["brokerContext"]) &&
|
||||
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(pageId)
|
||||
) || (
|
||||
key == "identityProviderBrokerCtx" &&
|
||||
are_same_path(path, []) &&
|
||||
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(pageId)
|
||||
) || (
|
||||
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
|
||||
are_same_path(path, ["realm"])
|
||||
) || (
|
||||
"error.ftl" == pageId &&
|
||||
are_same_path(path, ["realm"]) &&
|
||||
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
|
||||
)
|
||||
>
|
||||
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#if pageId == "register.ftl" && 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 -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
|
||||
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
|
||||
<#continue>
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#attempt>
|
||||
<#if !object[key]??>
|
||||
<#continue>
|
||||
</#if>
|
||||
<#recover>
|
||||
<#local out_seq += ["/*Couldn't test if '" + key + "' is available on this object*/"]>
|
||||
<#continue>
|
||||
</#attempt>
|
||||
|
||||
<#local propertyValue = "">
|
||||
|
||||
<#attempt>
|
||||
<#local propertyValue = object[key]>
|
||||
<#recover>
|
||||
<#local out_seq += ["/*Couldn't dereference '" + key + "' on this object*/"]>
|
||||
<#continue>
|
||||
</#attempt>
|
||||
|
||||
<#local rec_out = ftl_object_to_js_code_declaring_an_object(propertyValue, path + [ key ])>
|
||||
|
||||
<#if rec_out?starts_with("ABORT:")>
|
||||
|
||||
<#local errorMessage = rec_out?remove_beginning("ABORT:")>
|
||||
|
||||
<#if errorMessage != " It's a method" >
|
||||
<#local out_seq += ["/*" + key + ": " + errorMessage + "*/"]>
|
||||
</#if>
|
||||
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#local out_seq += ['"' + key + '": ' + rec_out + ","]>
|
||||
|
||||
</#list>
|
||||
|
||||
<#return (["{"] + out_seq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "}"])?join("\n")>
|
||||
|
||||
</#if>
|
||||
|
||||
<#local isMethod = "">
|
||||
<#attempt>
|
||||
<#local isMethod = object?is_method>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it'sa method.">
|
||||
</#attempt>
|
||||
|
||||
<#if isMethod>
|
||||
|
||||
<#if are_same_path(path, ["auth", "showUsername"])>
|
||||
<#attempt>
|
||||
<#return auth.showUsername()?c>
|
||||
<#recover>
|
||||
<#return "ABORT: Couldn't evaluate auth.showUsername()">
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#if are_same_path(path, ["auth", "showResetCredentials"])>
|
||||
<#attempt>
|
||||
<#return auth.showResetCredentials()?c>
|
||||
<#recover>
|
||||
<#return "ABORT: Couldn't evaluate auth.showResetCredentials()">
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#if are_same_path(path, ["auth", "showTryAnotherWayLink"])>
|
||||
<#attempt>
|
||||
<#return auth.showTryAnotherWayLink()?c>
|
||||
<#recover>
|
||||
<#return "ABORT: Couldn't evaluate auth.showTryAnotherWayLink()">
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#return "ABORT: It's a method">
|
||||
</#if>
|
||||
|
||||
<#local isBoolean = "">
|
||||
<#attempt>
|
||||
<#local isBoolean = object?is_boolean>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it's a boolean">
|
||||
</#attempt>
|
||||
|
||||
<#if isBoolean>
|
||||
<#return object?c>
|
||||
</#if>
|
||||
|
||||
<#local isEnumerable = "">
|
||||
<#attempt>
|
||||
<#local isEnumerable = object?is_enumerable>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it's an enumerable">
|
||||
</#attempt>
|
||||
|
||||
|
||||
<#if isEnumerable>
|
||||
|
||||
<#local out_seq = []>
|
||||
|
||||
<#local i = 0>
|
||||
|
||||
<#list object as array_item>
|
||||
|
||||
<#if !array_item??>
|
||||
<#local out_seq += ["null,"]>
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#local rec_out = ftl_object_to_js_code_declaring_an_object(array_item, path + [ i ])>
|
||||
|
||||
<#local i = i + 1>
|
||||
|
||||
<#if rec_out?starts_with("ABORT:")>
|
||||
|
||||
<#local errorMessage = rec_out?remove_beginning("ABORT:")>
|
||||
|
||||
<#if errorMessage != " It's a method" >
|
||||
<#local out_seq += ["/*" + i?string + ": " + errorMessage + "*/"]>
|
||||
</#if>
|
||||
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#local out_seq += [rec_out + ","]>
|
||||
|
||||
</#list>
|
||||
|
||||
<#return (["["] + out_seq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "]"])?join("\n")>
|
||||
|
||||
</#if>
|
||||
|
||||
<#local isDate = "">
|
||||
<#attempt>
|
||||
<#local isDate = object?is_date_like>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it's a date">
|
||||
</#attempt>
|
||||
|
||||
<#if isDate>
|
||||
<#return '"' + object?datetime?iso_utc + '"'>
|
||||
</#if>
|
||||
|
||||
<#attempt>
|
||||
<#return '"' + object?js_string + '"'>;
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non enumerable object">
|
||||
|
||||
</#function>
|
||||
<#function are_same_path path searchedPath>
|
||||
|
||||
<#if path?size != searchedPath?size>
|
||||
<#return false>
|
||||
</#if>
|
||||
|
||||
<#local i=0>
|
||||
|
||||
<#list path as property>
|
||||
|
||||
<#local searchedProperty=searchedPath[i]>
|
||||
|
||||
<#if searchedProperty?is_string && searchedProperty == "*">
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#if searchedProperty?is_string && !property?is_string>
|
||||
<#return false>
|
||||
</#if>
|
||||
|
||||
<#if searchedProperty?is_number && !property?is_number>
|
||||
<#return false>
|
||||
</#if>
|
||||
|
||||
<#if searchedProperty?string != property?string>
|
||||
<#return false>
|
||||
</#if>
|
||||
|
||||
<#local i+= 1>
|
||||
|
||||
</#list>
|
||||
|
||||
<#return true>
|
||||
|
||||
</#function>
|
||||
</script>
|
@ -1,36 +1,48 @@
|
||||
import cheerio from "cheerio";
|
||||
import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode";
|
||||
import { generateCssCodeToDefineGlobals } from "../replacers/replaceImportsInCssCode";
|
||||
import { replaceImportsInInlineCssCode } from "../replacers/replaceImportsInInlineCssCode";
|
||||
import {
|
||||
replaceImportsInJsCode,
|
||||
BuildContextLike as BuildContextLike_replaceImportsInJsCode
|
||||
} from "../replacers/replaceImportsInJsCode";
|
||||
import {
|
||||
replaceImportsInCssCode,
|
||||
BuildContextLike as BuildContextLike_replaceImportsInCssCode
|
||||
} from "../replacers/replaceImportsInCssCode";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { objectKeys } from "tsafe/objectKeys";
|
||||
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
import { assert } from "tsafe/assert";
|
||||
import {
|
||||
type ThemeType,
|
||||
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR,
|
||||
RESOURCES_COMMON
|
||||
} from "../../shared/constants";
|
||||
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
|
||||
|
||||
export const themeTypes = ["login", "account"] as const;
|
||||
export type BuildContextLike = BuildContextLike_replaceImportsInJsCode &
|
||||
BuildContextLike_replaceImportsInCssCode & {
|
||||
urlPathname: string | undefined;
|
||||
themeVersion: string;
|
||||
kcContextExclusionsFtlCode: string | undefined;
|
||||
};
|
||||
|
||||
export type ThemeType = (typeof themeTypes)[number];
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
themeName: string;
|
||||
themeVersion: string;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
assert<BuildContext extends BuildContextLike ? 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;
|
||||
buildContext: BuildContextLike;
|
||||
keycloakifyVersion: string;
|
||||
themeType: ThemeType;
|
||||
fieldNames: string[];
|
||||
}) {
|
||||
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType, fieldNames } = params;
|
||||
const {
|
||||
themeName,
|
||||
indexHtmlCode,
|
||||
buildContext,
|
||||
keycloakifyVersion,
|
||||
themeType,
|
||||
fieldNames
|
||||
} = params;
|
||||
|
||||
const $ = cheerio.load(indexHtmlCode);
|
||||
|
||||
@ -40,7 +52,10 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
|
||||
assert(jsCode !== null);
|
||||
|
||||
const { fixedJsCode } = replaceImportsFromStaticInJsCode({ jsCode });
|
||||
const { fixedJsCode } = replaceImportsInJsCode({
|
||||
jsCode,
|
||||
buildContext
|
||||
});
|
||||
|
||||
$(element).text(fixedJsCode);
|
||||
});
|
||||
@ -50,9 +65,10 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
|
||||
assert(cssCode !== null);
|
||||
|
||||
const { fixedCssCode } = replaceImportsInInlineCssCode({
|
||||
const { fixedCssCode } = replaceImportsInCssCode({
|
||||
cssCode,
|
||||
buildOptions
|
||||
cssFileRelativeDirPath: undefined,
|
||||
buildContext
|
||||
});
|
||||
|
||||
$(element).text(fixedCssCode);
|
||||
@ -73,57 +89,71 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
|
||||
$(element).attr(
|
||||
attrName,
|
||||
href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/")
|
||||
href.replace(
|
||||
new RegExp(
|
||||
`^${(buildContext.urlPathname ?? "/").replace(/\//g, "\\/")}`
|
||||
),
|
||||
`\${xKeycloakify.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/`
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
if (Object.keys(cssGlobalsToDefine).length !== 0) {
|
||||
$("head").prepend(
|
||||
[
|
||||
"",
|
||||
"<style>",
|
||||
generateCssCodeToDefineGlobals({
|
||||
cssGlobalsToDefine,
|
||||
buildOptions
|
||||
}).cssCodeToPrependInHead,
|
||||
"</style>",
|
||||
""
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//FTL is no valid html, we can't insert with cheerio, we put placeholder for injecting later.
|
||||
const 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("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", buildOptions.themeName),
|
||||
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
|
||||
"<#if scripts??>",
|
||||
" <#list scripts as script>",
|
||||
' <script src="${script}" type="text/javascript"></script>',
|
||||
" </#list>",
|
||||
"</#if>"
|
||||
].join("\n")
|
||||
};
|
||||
const kcContextDeclarationTemplateFtl = fs
|
||||
.readFileSync(
|
||||
pathJoin(
|
||||
getThisCodebaseRootDirPath(),
|
||||
"src",
|
||||
"bin",
|
||||
"keycloakify",
|
||||
"generateFtl",
|
||||
"kcContextDeclarationTemplate.ftl"
|
||||
)
|
||||
)
|
||||
.toString("utf8")
|
||||
.replace("{{themeType}}", themeType)
|
||||
.replace("{{themeName}}", themeName)
|
||||
.replace("{{keycloakifyVersion}}", keycloakifyVersion)
|
||||
.replace("{{themeVersion}}", buildContext.themeVersion)
|
||||
.replace("{{fieldNames}}", fieldNames.map(name => `"${name}"`).join(", "))
|
||||
.replace("{{RESOURCES_COMMON}}", RESOURCES_COMMON)
|
||||
.replace(
|
||||
"{{userDefinedExclusions}}",
|
||||
buildContext.kcContextExclusionsFtlCode ?? ""
|
||||
);
|
||||
|
||||
const ftlObjectToJsCodeDeclaringAnObjectPlaceholder =
|
||||
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }';
|
||||
|
||||
$("head").prepend(
|
||||
[
|
||||
"<script>",
|
||||
` window.${ftlValuesGlobalName}= ${objectKeys(replaceValueBySearchValue)[0]};`,
|
||||
"</script>",
|
||||
"",
|
||||
objectKeys(replaceValueBySearchValue)[1]
|
||||
].join("\n")
|
||||
`<script>\n${ftlObjectToJsCodeDeclaringAnObjectPlaceholder}\n</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();
|
||||
|
||||
function generateFtlFilesCode(params: { pageId: string }): {
|
||||
@ -136,9 +166,14 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
let ftlCode = $.html();
|
||||
|
||||
Object.entries({
|
||||
...replaceValueBySearchValue,
|
||||
"PAGE_ID_xIgLsPgGId9D8e": pageId
|
||||
}).map(([searchValue, replaceValue]) => (ftlCode = ftlCode.replace(searchValue, replaceValue)));
|
||||
[ftlObjectToJsCodeDeclaringAnObjectPlaceholder]:
|
||||
kcContextDeclarationTemplateFtl,
|
||||
"{{pageId}}": pageId,
|
||||
"{{ftlTemplateFileName}}": pageId
|
||||
}).map(
|
||||
([searchValue, replaceValue]) =>
|
||||
(ftlCode = ftlCode.replace(searchValue, replaceValue))
|
||||
);
|
||||
|
||||
return { ftlCode };
|
||||
}
|
||||
|
@ -1,2 +1 @@
|
||||
export * from "./generateFtl";
|
||||
export * from "./pageId";
|
||||
|
667
src/bin/keycloakify/generateFtl/kcContextDeclarationTemplate.ftl
Normal file
667
src/bin/keycloakify/generateFtl/kcContextDeclarationTemplate.ftl
Normal file
@ -0,0 +1,667 @@
|
||||
<#assign xKeycloakify={
|
||||
"messages": {},
|
||||
"pageId": "{{pageId}}",
|
||||
"ftlTemplateFileName": "{{ftlTemplateFileName}}",
|
||||
"themeType": "{{themeType}}",
|
||||
"themeName": "{{themeName}}",
|
||||
"keycloakifyVersion": "{{keycloakifyVersion}}",
|
||||
"themeVersion": "{{themeVersion}}",
|
||||
"resourcesPath": ""
|
||||
}>
|
||||
|
||||
<#if url?? && url?is_hash && url.resourcesPath?? && url.resourcesPath?is_string>
|
||||
<#assign xKeycloakify = xKeycloakify + { "resourcesPath": url.resourcesPath }>
|
||||
</#if>
|
||||
<#if resourceUrl?? && resourceUrl?is_string>
|
||||
<#assign xKeycloakify = xKeycloakify + { "resourcesPath": resourceUrl }>
|
||||
</#if>
|
||||
|
||||
const kcContext = ${toJsDeclarationString(.data_model, [])?no_esc};
|
||||
kcContext.keycloakifyVersion = "${xKeycloakify.keycloakifyVersion}";
|
||||
kcContext.themeVersion = "${xKeycloakify.themeVersion}";
|
||||
kcContext.themeType = "${xKeycloakify.themeType}";
|
||||
kcContext.themeName = "${xKeycloakify.themeName}";
|
||||
kcContext.pageId = "${xKeycloakify.pageId}";
|
||||
kcContext.ftlTemplateFileName = "${xKeycloakify.ftlTemplateFileName}";
|
||||
|
||||
<@addNonAutomaticallyGatherableMessagesToXKeycloakifyMessages />
|
||||
|
||||
kcContext["x-keycloakify"] = {};
|
||||
|
||||
kcContext["x-keycloakify"].resourcesPath = "${xKeycloakify.resourcesPath}";
|
||||
|
||||
{
|
||||
var messages = {};
|
||||
<#list xKeycloakify.messages as key, resolvedMsg>
|
||||
messages["${key}"] = decodeHtmlEntities("${resolvedMsg?js_string}");
|
||||
</#list>
|
||||
kcContext["x-keycloakify"].messages = messages;
|
||||
}
|
||||
|
||||
if(
|
||||
kcContext.url instanceof Object &&
|
||||
typeof kcContext.url.resourcesPath === "string"
|
||||
){
|
||||
kcContext.url.resourcesCommonPath = kcContext.url.resourcesPath + "/{{RESOURCES_COMMON}}";
|
||||
}
|
||||
|
||||
if( kcContext.messagesPerField ){
|
||||
var existsError_singleFieldName = kcContext.messagesPerField.existsError;
|
||||
kcContext.messagesPerField.existsError = function (){
|
||||
for( let i = 0; i < arguments.length; i++ ){
|
||||
if( existsError_singleFieldName(arguments[i]) ){
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
kcContext.messagesPerField.exists = function (fieldName) {
|
||||
return kcContext.messagesPerField.get(fieldName) !== "";
|
||||
};
|
||||
kcContext.messagesPerField.printIfExists = function (fieldName, text) {
|
||||
return kcContext.messagesPerField.exists(fieldName) ? text : undefined;
|
||||
};
|
||||
kcContext.messagesPerField.getFirstError = function () {
|
||||
for( let i = 0; i < arguments.length; i++ ){
|
||||
const fieldName = arguments[i];
|
||||
if( kcContext.messagesPerField.existsError(fieldName) ){
|
||||
return kcContext.messagesPerField.get(fieldName);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
attributes_to_attributesByName: {
|
||||
if( !kcContext.profile ){
|
||||
break attributes_to_attributesByName;
|
||||
}
|
||||
if( !kcContext.profile.attributes ){
|
||||
break attributes_to_attributesByName;
|
||||
}
|
||||
var attributes = kcContext.profile.attributes;
|
||||
delete kcContext.profile.attributes;
|
||||
kcContext.profile.attributesByName = {};
|
||||
attributes.forEach(function(attribute){
|
||||
kcContext.profile.attributesByName[attribute.name] = attribute;
|
||||
});
|
||||
}
|
||||
window.kcContext = kcContext;
|
||||
function decodeHtmlEntities(htmlStr){
|
||||
var element = decodeHtmlEntities.element;
|
||||
if (!element) {
|
||||
element = document.createElement("textarea");
|
||||
decodeHtmlEntities.element = element;
|
||||
}
|
||||
element.innerHTML = htmlStr;
|
||||
return element.value;
|
||||
}
|
||||
|
||||
<#function toJsDeclarationString object path>
|
||||
<#local isHash = -1>
|
||||
<#attempt>
|
||||
<#local isHash = object?is_hash || object?is_hash_ex>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't evaluate if " + path?join(".") + " is a hash">
|
||||
</#attempt>
|
||||
|
||||
<#if isHash>
|
||||
<#if path?size gt 10>
|
||||
<#return "ABORT: Too many recursive calls, path: " + path?join(".")>
|
||||
</#if>
|
||||
<#local keys = -1>
|
||||
|
||||
<#attempt>
|
||||
<#local keys = object?keys>
|
||||
<#recover>
|
||||
<#return "ABORT: We can't list keys on object">
|
||||
</#attempt>
|
||||
|
||||
<#local outSeq = []>
|
||||
|
||||
<#list keys as key>
|
||||
<#if ["class","declaredConstructors","superclass","declaringClass" ]?seq_contains(key) >
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#if (
|
||||
areSamePath(path, ["url"]) &&
|
||||
["loginUpdatePasswordUrl", "loginUpdateProfileUrl", "loginUsernameReminderUrl", "loginUpdateTotpUrl"]?seq_contains(key)
|
||||
) || (
|
||||
key == "updateProfileCtx" &&
|
||||
areSamePath(path, [])
|
||||
) || (
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
|
||||
key == "loginAction" &&
|
||||
areSamePath(path, ["url"]) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(xKeycloakify.pageId) &&
|
||||
!(auth?has_content && auth.showTryAnotherWayLink())
|
||||
) || (
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
|
||||
["secretData", "value"]?seq_contains(key) &&
|
||||
areSamePath(path, [ "totp", "otpCredentials", "*" ])
|
||||
) || (
|
||||
["contextData", "idpConfig", "idp", "authenticationSession"]?seq_contains(key) &&
|
||||
areSamePath(path, ["brokerContext"]) &&
|
||||
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(xKeycloakify.pageId)
|
||||
) || (
|
||||
key == "identityProviderBrokerCtx" &&
|
||||
areSamePath(path, []) &&
|
||||
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(xKeycloakify.pageId)
|
||||
) || (
|
||||
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
|
||||
areSamePath(path, ["realm"])
|
||||
) || (
|
||||
xKeycloakify.pageId == "error.ftl" &&
|
||||
areSamePath(path, ["realm"]) &&
|
||||
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
|
||||
) || (
|
||||
xKeycloakify.pageId == "applications.ftl" &&
|
||||
(
|
||||
key == "realm" ||
|
||||
key == "container"
|
||||
) &&
|
||||
isSubpath(path, ["applications", "applications"])
|
||||
) || (
|
||||
key == "delegateForUpdate" &&
|
||||
areSamePath(path, ["user"])
|
||||
) || (
|
||||
<#-- Security audit forwarded by Garth (Gmail) -->
|
||||
key == "saml.signing.private.key" &&
|
||||
areSamePath(path, ["client", "attributes"])
|
||||
) || (
|
||||
<#-- See: https://github.com/keycloakify/keycloakify/issues/534 -->
|
||||
key == "password" &&
|
||||
areSamePath(path, ["login"])
|
||||
) || (
|
||||
<#-- Remove realmAttributes added by https://github.com/jcputney/keycloak-theme-additional-info-extension for peace of mind. -->
|
||||
key == "realmAttributes" &&
|
||||
areSamePath(path, [])
|
||||
) || (
|
||||
<#-- attributesByName adds a lot of noise to the output and is not needed, we already have profile.attributes -->
|
||||
key == "attributesByName" &&
|
||||
areSamePath(path, ["profile"])
|
||||
) || (
|
||||
<#-- We already have the attributes in profile speedup the rendering by filtering it out from the register object -->
|
||||
(key == "attributes" || key == "attributesByName") &&
|
||||
areSamePath(path, ["register"])
|
||||
) || (
|
||||
areSamePath(path, ["properties"]) &&
|
||||
(
|
||||
key?starts_with("kc") ||
|
||||
key == "locales" ||
|
||||
key == "import" ||
|
||||
key == "parent" ||
|
||||
key == "meta" ||
|
||||
key == "stylesCommon" ||
|
||||
key == "styles" ||
|
||||
key == "accountResourceProvider"
|
||||
)
|
||||
) || (
|
||||
key == "execution" &&
|
||||
areSamePath(path, [])
|
||||
) || (
|
||||
key == "entity" &&
|
||||
areSamePath(path, ["user"])
|
||||
) || (
|
||||
key == "attributes" &&
|
||||
areSamePath(path, ["realm"])
|
||||
) || (
|
||||
xKeycloakify.pageId == "index.ftl" &&
|
||||
xKeycloakify.themeType == "account" &&
|
||||
areSamePath(path, ["realm"]) &&
|
||||
![
|
||||
"name",
|
||||
"registrationEmailAsUsername",
|
||||
"editUsernameAllowed",
|
||||
"isInternationalizationEnabled",
|
||||
"identityFederationEnabled",
|
||||
"userManagedAccessAllowed"
|
||||
]?seq_contains(key)
|
||||
)
|
||||
>
|
||||
<#-- <#local outSeq += ["/*" + path?join(".") + "." + key + " excluded*/"]> -->
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
|
||||
<#if (
|
||||
key == "attemptedUsername" &&
|
||||
areSamePath(path, ["auth"]) &&
|
||||
[
|
||||
"register.ftl", "terms.ftl", "info.ftl", "login.ftl",
|
||||
"login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"
|
||||
]?seq_contains(xKeycloakify.pageId)
|
||||
)>
|
||||
<#attempt>
|
||||
<#-- https://github.com/keycloak/keycloak/blob/3a2bf0c04bcde185e497aaa32d0bb7ab7520cf4a/themes/src/main/resources/theme/base/login/template.ftl#L63 -->
|
||||
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
|
||||
<#local outSeq += ["/*" + path?join(".") + "." + key + " excluded*/"]>
|
||||
<#continue>
|
||||
</#if>
|
||||
<#recover>
|
||||
<#local outSeq += ["/*Accessing attemptedUsername throwed an exception */"]>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
{{userDefinedExclusions}}
|
||||
|
||||
<#attempt>
|
||||
<#if !object[key]??>
|
||||
<#continue>
|
||||
</#if>
|
||||
<#recover>
|
||||
<#local outSeq += ["/*Couldn't test if '" + key + "' is available on this object*/"]>
|
||||
<#continue>
|
||||
</#attempt>
|
||||
|
||||
<#local propertyValue = -1>
|
||||
|
||||
<#attempt>
|
||||
<#local propertyValue = object[key]>
|
||||
<#recover>
|
||||
<#local outSeq += ["/*Couldn't dereference '" + key + "' on this object*/"]>
|
||||
<#continue>
|
||||
</#attempt>
|
||||
|
||||
<#local recOut = toJsDeclarationString(propertyValue, path + [ key ])>
|
||||
|
||||
<#if recOut?starts_with("ABORT:")>
|
||||
|
||||
<#local errorMessage = recOut?remove_beginning("ABORT:")>
|
||||
|
||||
<#if errorMessage != " It's a method" >
|
||||
<#local outSeq += ["/*" + key + ": " + errorMessage + "*/"]>
|
||||
</#if>
|
||||
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#local outSeq += ['"' + key + '": ' + recOut + ","]>
|
||||
|
||||
</#list>
|
||||
|
||||
<#return (["{"] + outSeq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "}"])?join("\n")>
|
||||
|
||||
</#if>
|
||||
|
||||
<#local isMethod = -1>
|
||||
<#attempt>
|
||||
<#local isMethod = object?is_method>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it'sa method.">
|
||||
</#attempt>
|
||||
|
||||
<#if isMethod>
|
||||
|
||||
<#if areSamePath(path, ["auth", "showUsername"])>
|
||||
<#attempt>
|
||||
<#return auth.showUsername()?c>
|
||||
<#recover>
|
||||
<#return "ABORT: Couldn't evaluate auth.showUsername()">
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#if areSamePath(path, ["auth", "showResetCredentials"])>
|
||||
<#attempt>
|
||||
<#return auth.showResetCredentials()?c>
|
||||
<#recover>
|
||||
<#return "ABORT: Couldn't evaluate auth.showResetCredentials()">
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#if areSamePath(path, ["auth", "showTryAnotherWayLink"])>
|
||||
<#attempt>
|
||||
<#return auth.showTryAnotherWayLink()?c>
|
||||
<#recover>
|
||||
<#return "ABORT: Couldn't evaluate auth.showTryAnotherWayLink()">
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#if areSamePath(path, ["url", "getLogoutUrl"])>
|
||||
<#local returnValue = -1>
|
||||
<#attempt>
|
||||
<#local returnValue = url.getLogoutUrl()>
|
||||
<#recover>
|
||||
<#return "ABORT: Couldn't evaluate url.getLogoutUrl()">
|
||||
</#attempt>
|
||||
<#return 'function(){ return "' + returnValue + '"; }'>
|
||||
</#if>
|
||||
|
||||
<#if areSamePath(path, ["totp", "policy", "getAlgorithmKey"])>
|
||||
<#local returnValue = "error">
|
||||
<#if mode?? && mode = "manual">
|
||||
<#attempt>
|
||||
<#local returnValue = totp.policy.getAlgorithmKey()>
|
||||
<#recover>
|
||||
<#return "ABORT: Couldn't evaluate totp.policy.getAlgorithmKey()">
|
||||
</#attempt>
|
||||
</#if>
|
||||
<#return 'function(){ return "' + returnValue + '"; }'>
|
||||
</#if>
|
||||
|
||||
<#assign fieldNames = [{{fieldNames}}]>
|
||||
<#if profile?? && profile.attributes??>
|
||||
<#list profile.attributes as attribute>
|
||||
<#if fieldNames?seq_contains(attribute.name)>
|
||||
<#continue>
|
||||
</#if>
|
||||
<#assign fieldNames += [attribute.name]>
|
||||
</#list>
|
||||
</#if>
|
||||
|
||||
<#if areSamePath(path, ["messagesPerField", "get"])>
|
||||
|
||||
<#local jsFunctionCode = "function (fieldName) { ">
|
||||
|
||||
<#list fieldNames as fieldName>
|
||||
|
||||
<#-- See: https://github.com/keycloakify/keycloakify/issues/217 -->
|
||||
<#if xKeycloakify.pageId == "login.ftl" >
|
||||
|
||||
<#if fieldName == "username">
|
||||
|
||||
<#local jsFunctionCode += "if(fieldName === 'username' || fieldName === 'password' ){ ">
|
||||
|
||||
<#if messagesPerField.exists('username') || messagesPerField.exists('password')>
|
||||
<#local jsFunctionCode += "return kcContext.message && kcContext.message.summary ? kcContext.message.summary : 'error'; ">
|
||||
<#else>
|
||||
<#local jsFunctionCode += "return ''; ">
|
||||
</#if>
|
||||
|
||||
<#local jsFunctionCode += "} ">
|
||||
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#if fieldName == "password">
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
</#if>
|
||||
|
||||
<#local jsFunctionCode += "if(fieldName === '" + fieldName + "'){ ">
|
||||
|
||||
<#if messagesPerField.exists('${fieldName}')>
|
||||
<#local jsFunctionCode += 'return decodeHtmlEntities("' + messagesPerField.get('${fieldName}')?js_string + '"); '>
|
||||
<#else>
|
||||
<#local jsFunctionCode += "return ''; ">
|
||||
</#if>
|
||||
|
||||
<#local jsFunctionCode += "} ">
|
||||
|
||||
</#list>
|
||||
|
||||
<#local jsFunctionCode += "}">
|
||||
|
||||
<#return jsFunctionCode>
|
||||
|
||||
</#if>
|
||||
|
||||
<#if areSamePath(path, ["messagesPerField", "existsError"])>
|
||||
|
||||
<#local jsFunctionCode = "function (fieldName) { ">
|
||||
|
||||
<#list fieldNames as fieldName>
|
||||
|
||||
<#-- See: https://github.com/keycloakify/keycloakify/issues/217 -->
|
||||
<#if xKeycloakify.pageId == "login.ftl" >
|
||||
<#if fieldName == "username">
|
||||
|
||||
<#local jsFunctionCode += "if(fieldName === 'username' || fieldName === 'password' ){ ">
|
||||
|
||||
<#if messagesPerField.existsError('username') || messagesPerField.existsError('password')>
|
||||
<#local jsFunctionCode += "return true; ">
|
||||
<#else>
|
||||
<#local jsFunctionCode += "return false; ">
|
||||
</#if>
|
||||
|
||||
<#local jsFunctionCode += "} ">
|
||||
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#if fieldName == "password">
|
||||
<#continue>
|
||||
</#if>
|
||||
</#if>
|
||||
|
||||
<#local jsFunctionCode += "if(fieldName === '" + fieldName + "' ){ ">
|
||||
|
||||
<#if messagesPerField.existsError('${fieldName}')>
|
||||
<#local jsFunctionCode += 'return true; '>
|
||||
<#else>
|
||||
<#local jsFunctionCode += "return false; ">
|
||||
</#if>
|
||||
|
||||
<#local jsFunctionCode += "}">
|
||||
|
||||
</#list>
|
||||
|
||||
<#local jsFunctionCode += "}">
|
||||
|
||||
<#return jsFunctionCode>
|
||||
|
||||
</#if>
|
||||
|
||||
<#if xKeycloakify.themeType == "account" && areSamePath(path, ["realm", "isInternationalizationEnabled"])>
|
||||
<#attempt>
|
||||
<#return realm.isInternationalizationEnabled()?c>
|
||||
<#recover>
|
||||
<#return "ABORT: Couldn't evaluate realm.isInternationalizationEnabled()">
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#return "ABORT: It's a method">
|
||||
</#if>
|
||||
|
||||
<#local isBoolean = -1>
|
||||
<#attempt>
|
||||
<#local isBoolean = object?is_boolean>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it's a boolean">
|
||||
</#attempt>
|
||||
|
||||
<#if isBoolean>
|
||||
<#return object?c>
|
||||
</#if>
|
||||
|
||||
<#local isEnumerable = -1>
|
||||
<#attempt>
|
||||
<#local isEnumerable = object?is_enumerable>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it's an enumerable">
|
||||
</#attempt>
|
||||
|
||||
|
||||
<#if isEnumerable>
|
||||
|
||||
<#local outSeq = []>
|
||||
|
||||
<#local i = 0>
|
||||
|
||||
<#list object as array_item>
|
||||
|
||||
<#if !array_item??>
|
||||
<#local outSeq += ["null,"]>
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#local recOut = toJsDeclarationString(array_item, path + [ i ])>
|
||||
|
||||
<#local i = i + 1>
|
||||
|
||||
<#if recOut?starts_with("ABORT:")>
|
||||
|
||||
<#local errorMessage = recOut?remove_beginning("ABORT:")>
|
||||
|
||||
<#if errorMessage != " It's a method" >
|
||||
<#local outSeq += ["/*" + i?string + ": " + errorMessage + "*/"]>
|
||||
</#if>
|
||||
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#local outSeq += [recOut + ","]>
|
||||
|
||||
</#list>
|
||||
|
||||
<#return (["["] + outSeq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "]"])?join("\n")>
|
||||
|
||||
</#if>
|
||||
|
||||
<#local isDate = -1>
|
||||
<#attempt>
|
||||
<#local isDate = object?is_date_like>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it's a date">
|
||||
</#attempt>
|
||||
|
||||
<#if isDate>
|
||||
<#return '"' + object?datetime?iso_utc + '"'>
|
||||
</#if>
|
||||
|
||||
<#local isNumber = -1>
|
||||
<#attempt>
|
||||
<#local isNumber = object?is_number>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it's a number">
|
||||
</#attempt>
|
||||
|
||||
<#if isNumber>
|
||||
<#return object?c>
|
||||
</#if>
|
||||
|
||||
<#local isString = -1>
|
||||
<#attempt>
|
||||
<#local isString = object?is_string>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it's a string">
|
||||
</#attempt>
|
||||
|
||||
<#if isString>
|
||||
<@addToXKeycloakifyMessagesIfMessageKey str=object />
|
||||
</#if>
|
||||
|
||||
<#attempt>
|
||||
<#return '"' + object?js_string + '"'>;
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non number, non enumerable object">
|
||||
|
||||
</#function>
|
||||
<#function isSubpath path searchedPath>
|
||||
|
||||
<#if path?size < searchedPath?size>
|
||||
<#return false>
|
||||
</#if>
|
||||
|
||||
<#local i=0>
|
||||
|
||||
<#list path as property>
|
||||
|
||||
<#if i == searchedPath?size >
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#local searchedProperty=searchedPath[i]>
|
||||
|
||||
<#local i+= 1>
|
||||
|
||||
<#if searchedProperty?is_string && searchedProperty == "*">
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#if searchedProperty?is_string && !property?is_string>
|
||||
<#return false>
|
||||
</#if>
|
||||
|
||||
<#if searchedProperty?is_number && !property?is_number>
|
||||
<#return false>
|
||||
</#if>
|
||||
|
||||
<#if searchedProperty?string != property?string>
|
||||
<#return false>
|
||||
</#if>
|
||||
|
||||
</#list>
|
||||
|
||||
<#return true>
|
||||
|
||||
</#function>
|
||||
|
||||
<#function areSamePath path searchedPath>
|
||||
<#return path?size == searchedPath?size && isSubpath(path, searchedPath)>
|
||||
</#function>
|
||||
|
||||
<#macro addToXKeycloakifyMessagesIfMessageKey str>
|
||||
<#if !msg?? || !msg?is_method>
|
||||
<#return>
|
||||
</#if>
|
||||
<#if (str?length > 200)>
|
||||
<#return>
|
||||
</#if>
|
||||
<#local key=removeBrackets(str)>
|
||||
<#if key?length==0>
|
||||
<#return>
|
||||
</#if>
|
||||
<#if !(key?matches(r"^[a-zA-Z0-9-_.]*$"))>
|
||||
<#return>
|
||||
</#if>
|
||||
<#local resolvedMsg=msg(key)>
|
||||
<#if resolvedMsg==key>
|
||||
<#return>
|
||||
</#if>
|
||||
<#local messages=xKeycloakify.messages>
|
||||
<#local messages = messages + { key: resolvedMsg }>
|
||||
<#assign xKeycloakify = xKeycloakify + { "messages": messages }>
|
||||
</#macro>
|
||||
|
||||
<#function removeBrackets str>
|
||||
<#if str?starts_with("${") && str?ends_with("}")>
|
||||
<#return str[2..(str?length-2)]>
|
||||
<#else>
|
||||
<#return str>
|
||||
</#if>
|
||||
</#function>
|
||||
|
||||
<#macro addNonAutomaticallyGatherableMessagesToXKeycloakifyMessages>
|
||||
<#if profile?? && profile?is_hash && profile.attributes?? && profile.attributes?is_enumerable>
|
||||
<#list profile.attributes as attribute>
|
||||
<#if !(
|
||||
attribute.annotations?? && attribute.annotations?is_hash &&
|
||||
attribute.annotations.inputOptionLabelsI18nPrefix?? && attribute.annotations.inputOptionLabelsI18nPrefix?is_string
|
||||
)>
|
||||
<#continue>
|
||||
</#if>
|
||||
<#local prefix=attribute.annotations.inputOptionLabelsI18nPrefix>
|
||||
<#if !(
|
||||
attribute.validators?? && attribute.validators?is_hash &&
|
||||
attribute.validators.options?? && attribute.validators.options?is_hash &&
|
||||
attribute.validators.options.options?? && attribute.validators.options.options?is_enumerable
|
||||
)>
|
||||
<#continue>
|
||||
</#if>
|
||||
<#list attribute.validators.options.options as option>
|
||||
<#if !option?is_string>
|
||||
<#continue>
|
||||
</#if>
|
||||
<@addToXKeycloakifyMessagesIfMessageKey str="${prefix}.${option}" />
|
||||
</#list>
|
||||
</#list>
|
||||
</#if>
|
||||
<#if xKeycloakify.pageId == "terms.ftl" || termsAcceptanceRequired?? && termsAcceptanceRequired>
|
||||
<@addToXKeycloakifyMessagesIfMessageKey str="termsText" />
|
||||
</#if>
|
||||
<#if requiredActions?? && requiredActions?is_enumerable>
|
||||
<#list requiredActions as requiredAction>
|
||||
<#if !requiredAction?is_string>
|
||||
<#continue>
|
||||
</#if>
|
||||
<@addToXKeycloakifyMessagesIfMessageKey str="requiredAction.${requiredAction}" />
|
||||
</#list>
|
||||
</#if>
|
||||
</#macro>
|
@ -1,31 +0,0 @@
|
||||
export const loginThemePageIds = [
|
||||
"login.ftl",
|
||||
"login-username.ftl",
|
||||
"login-password.ftl",
|
||||
"webauthn-authenticate.ftl",
|
||||
"register.ftl",
|
||||
"register-user-profile.ftl",
|
||||
"info.ftl",
|
||||
"error.ftl",
|
||||
"login-reset-password.ftl",
|
||||
"login-verify-email.ftl",
|
||||
"terms.ftl",
|
||||
"login-otp.ftl",
|
||||
"login-update-profile.ftl",
|
||||
"login-update-password.ftl",
|
||||
"login-idp-link-confirm.ftl",
|
||||
"login-idp-link-email.ftl",
|
||||
"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"
|
||||
] as const;
|
||||
|
||||
export const accountThemePageIds = ["password.ftl", "account.ftl"] as const;
|
||||
|
||||
export type LoginThemePageId = (typeof loginThemePageIds)[number];
|
||||
export type AccountThemePageId = (typeof accountThemePageIds)[number];
|
@ -1,84 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, dirname as pathDirname } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { BuildOptions } from "./BuildOptions";
|
||||
import type { ThemeType } from "./generateFtl";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
themeName: string;
|
||||
extraThemeNames: string[];
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
themeVersion: string;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export function generateJavaStackFiles(params: {
|
||||
keycloakThemeBuildingDirPath: string;
|
||||
implementedThemeTypes: Record<ThemeType | "email", boolean>;
|
||||
buildOptions: BuildOptionsLike;
|
||||
}): {
|
||||
jarFilePath: string;
|
||||
} {
|
||||
const {
|
||||
buildOptions: { groupId, themeName, extraThemeNames, themeVersion, artifactId },
|
||||
keycloakThemeBuildingDirPath,
|
||||
implementedThemeTypes
|
||||
} = 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": [themeName, ...extraThemeNames].map(themeName => ({
|
||||
"name": themeName,
|
||||
"types": Object.entries(implementedThemeTypes)
|
||||
.filter(([, isImplemented]) => isImplemented)
|
||||
.map(([themeType]) => themeType)
|
||||
}))
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
"jarFilePath": pathJoin(keycloakThemeBuildingDirPath, "target", `${artifactId}-${themeVersion}.jar`)
|
||||
};
|
||||
}
|
89
src/bin/keycloakify/generateResources/bringInAccountV1.ts
Normal file
89
src/bin/keycloakify/generateResources/bringInAccountV1.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
import {
|
||||
RESOURCES_COMMON,
|
||||
LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1,
|
||||
ACCOUNT_V1_THEME_NAME
|
||||
} from "../../shared/constants";
|
||||
import {
|
||||
downloadKeycloakDefaultTheme,
|
||||
BuildContextLike as BuildContextLike_downloadKeycloakDefaultTheme
|
||||
} from "../../shared/downloadKeycloakDefaultTheme";
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_downloadKeycloakDefaultTheme;
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export async function bringInAccountV1(params: {
|
||||
resourcesDirPath: string;
|
||||
buildContext: BuildContextLike;
|
||||
}) {
|
||||
const { resourcesDirPath, buildContext } = params;
|
||||
|
||||
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
|
||||
keycloakVersion: LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1,
|
||||
buildContext
|
||||
});
|
||||
|
||||
const accountV1DirPath = pathJoin(
|
||||
resourcesDirPath,
|
||||
"theme",
|
||||
ACCOUNT_V1_THEME_NAME,
|
||||
"account"
|
||||
);
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: pathJoin(defaultThemeDirPath, "base", "account"),
|
||||
destDirPath: accountV1DirPath
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: pathJoin(defaultThemeDirPath, "keycloak", "account", "resources"),
|
||||
destDirPath: pathJoin(accountV1DirPath, "resources")
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: pathJoin(defaultThemeDirPath, "keycloak", "common", "resources"),
|
||||
destDirPath: pathJoin(accountV1DirPath, "resources", RESOURCES_COMMON)
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(accountV1DirPath, "theme.properties"),
|
||||
Buffer.from(
|
||||
[
|
||||
"accountResourceProvider=account-v1",
|
||||
"",
|
||||
"locales=ar,ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN",
|
||||
"",
|
||||
"styles=" +
|
||||
[
|
||||
"css/account.css",
|
||||
"img/icon-sidebar-active.png",
|
||||
"img/logo.png",
|
||||
...[
|
||||
"patternfly.min.css",
|
||||
"patternfly-additions.min.css",
|
||||
"patternfly-additions.min.css"
|
||||
].map(
|
||||
fileBasename =>
|
||||
`${RESOURCES_COMMON}/node_modules/patternfly/dist/css/${fileBasename}`
|
||||
)
|
||||
].join(" "),
|
||||
"",
|
||||
"##### css classes for form buttons",
|
||||
"# main class used for all buttons",
|
||||
"kcButtonClass=btn",
|
||||
"# classes defining priority of the button - primary or default (there is typically only one priority button for the form)",
|
||||
"kcButtonPrimaryClass=btn-primary",
|
||||
"kcButtonDefaultClass=btn-default",
|
||||
"# classes defining size of the button",
|
||||
"kcButtonLargeClass=btn-lg",
|
||||
""
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
@ -0,0 +1,192 @@
|
||||
import { type ThemeType, FALLBACK_LANGUAGE_TAG } from "../../shared/constants";
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { join as pathJoin } from "path";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
import * as recast from "recast";
|
||||
import * as babelParser from "@babel/parser";
|
||||
import babelGenerate from "@babel/generator";
|
||||
import * as babelTypes from "@babel/types";
|
||||
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
|
||||
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
|
||||
import * as fs from "fs";
|
||||
import { assert } from "tsafe/assert";
|
||||
|
||||
export function generateMessageProperties(params: {
|
||||
themeSrcDirPath: string;
|
||||
themeType: ThemeType;
|
||||
}): { languageTag: string; propertiesFileSource: string }[] {
|
||||
const { themeSrcDirPath, themeType } = params;
|
||||
|
||||
const baseMessagesDirPath = pathJoin(
|
||||
getThisCodebaseRootDirPath(),
|
||||
"src",
|
||||
themeType,
|
||||
"i18n",
|
||||
"messages_defaultSet"
|
||||
);
|
||||
|
||||
const baseMessageBundle: { [languageTag: string]: Record<string, string> } =
|
||||
Object.fromEntries(
|
||||
fs
|
||||
.readdirSync(baseMessagesDirPath)
|
||||
.filter(baseName => baseName !== "index.ts")
|
||||
.map(basename => ({
|
||||
languageTag: basename.replace(/\.ts$/, ""),
|
||||
filePath: pathJoin(baseMessagesDirPath, basename)
|
||||
}))
|
||||
.map(({ languageTag, filePath }) => {
|
||||
const lines = fs
|
||||
.readFileSync(filePath)
|
||||
.toString("utf8")
|
||||
.split(/\r?\n/);
|
||||
|
||||
let messagesJson = "{";
|
||||
|
||||
let isInDeclaration = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!isInDeclaration) {
|
||||
if (line.startsWith("const messages")) {
|
||||
isInDeclaration = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("}")) {
|
||||
messagesJson += "}";
|
||||
break;
|
||||
}
|
||||
|
||||
messagesJson += line;
|
||||
}
|
||||
|
||||
const messages = JSON.parse(messagesJson) as Record<string, string>;
|
||||
|
||||
return [languageTag, messages];
|
||||
})
|
||||
);
|
||||
|
||||
const { i18nTsFilePath } = (() => {
|
||||
let files = crawl({
|
||||
dirPath: pathJoin(themeSrcDirPath, themeType),
|
||||
returnedPathsType: "absolute"
|
||||
});
|
||||
|
||||
files = files.filter(file => {
|
||||
const regex = /\.(js|ts|tsx)$/;
|
||||
return regex.test(file);
|
||||
});
|
||||
|
||||
files = files.sort((a, b) => {
|
||||
const regex = /\.i18n\.(ts|js|tsx)$/;
|
||||
const aIsI18nFile = regex.test(a);
|
||||
const bIsI18nFile = regex.test(b);
|
||||
return aIsI18nFile === bIsI18nFile ? 0 : aIsI18nFile ? -1 : 1;
|
||||
});
|
||||
|
||||
files = files.sort((a, b) => a.length - b.length);
|
||||
|
||||
files = files.filter(file =>
|
||||
fs.readFileSync(file).toString("utf8").includes("createUseI18n(")
|
||||
);
|
||||
|
||||
const i18nTsFilePath: string | undefined = files[0];
|
||||
|
||||
return { i18nTsFilePath };
|
||||
})();
|
||||
|
||||
const messageBundle: { [languageTag: string]: Record<string, string> } | undefined =
|
||||
(() => {
|
||||
if (i18nTsFilePath === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const root = recast.parse(fs.readFileSync(i18nTsFilePath).toString("utf8"), {
|
||||
parser: {
|
||||
parse: (code: string) =>
|
||||
babelParser.parse(code, {
|
||||
sourceType: "module",
|
||||
plugins: ["typescript"]
|
||||
}),
|
||||
generator: babelGenerate,
|
||||
types: babelTypes
|
||||
}
|
||||
});
|
||||
|
||||
let messageBundleDeclarationTsCode: string | undefined = undefined;
|
||||
|
||||
recast.visit(root, {
|
||||
visitCallExpression: function (path) {
|
||||
if (
|
||||
path.node.callee.type === "Identifier" &&
|
||||
path.node.callee.name === "createUseI18n"
|
||||
) {
|
||||
messageBundleDeclarationTsCode = babelGenerate(
|
||||
path.node.arguments[0] as any
|
||||
).code;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.traverse(path);
|
||||
}
|
||||
});
|
||||
|
||||
assert(messageBundleDeclarationTsCode !== undefined);
|
||||
|
||||
let messageBundle: {
|
||||
[languageTag: string]: Record<string, string>;
|
||||
} = {};
|
||||
|
||||
try {
|
||||
eval(
|
||||
`${symToStr({ messageBundle })} = ${messageBundleDeclarationTsCode}`
|
||||
);
|
||||
} catch {
|
||||
console.warn(
|
||||
[
|
||||
"WARNING: Make sure the messageBundle your provided as argument of createUseI18n can be statically evaluated.",
|
||||
"This is important because we need to put your i18n messages in messages_*.properties files",
|
||||
"or they won't be available server side.",
|
||||
"\n",
|
||||
"The following code could not be evaluated:",
|
||||
"\n",
|
||||
messageBundleDeclarationTsCode
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
return messageBundle;
|
||||
})();
|
||||
|
||||
const mergedMessageBundle: { [languageTag: string]: Record<string, string> } =
|
||||
Object.fromEntries(
|
||||
Object.entries(baseMessageBundle).map(([languageTag, messages]) => [
|
||||
languageTag,
|
||||
{
|
||||
...messages,
|
||||
...(messageBundle === undefined
|
||||
? {}
|
||||
: messageBundle[languageTag] ??
|
||||
messageBundle[FALLBACK_LANGUAGE_TAG] ??
|
||||
messageBundle[Object.keys(messageBundle)[0]] ??
|
||||
{})
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
const messageProperties: { languageTag: string; propertiesFileSource: string }[] =
|
||||
Object.entries(mergedMessageBundle).map(([languageTag, messages]) => ({
|
||||
languageTag,
|
||||
propertiesFileSource: [
|
||||
"",
|
||||
...(themeType !== "account" ? ["parent=base"] : []),
|
||||
...Object.entries(messages).map(
|
||||
([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`
|
||||
),
|
||||
""
|
||||
].join("\n")
|
||||
}));
|
||||
|
||||
return messageProperties;
|
||||
}
|
42
src/bin/keycloakify/generateResources/generateResources.ts
Normal file
42
src/bin/keycloakify/generateResources/generateResources.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
import { assert } from "tsafe/assert";
|
||||
import {
|
||||
generateResourcesForMainTheme,
|
||||
type BuildContextLike as BuildContextLike_generateResourcesForMainTheme
|
||||
} from "./generateResourcesForMainTheme";
|
||||
import { generateResourcesForThemeVariant } from "./generateResourcesForThemeVariant";
|
||||
import fs from "fs";
|
||||
import { rmSync } from "../../tools/fs.rmSync";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_generateResourcesForMainTheme & {
|
||||
themeNames: string[];
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export async function generateResources(params: {
|
||||
buildContext: BuildContextLike;
|
||||
resourcesDirPath: string;
|
||||
}): Promise<void> {
|
||||
const { resourcesDirPath, buildContext } = params;
|
||||
|
||||
const [themeName, ...themeVariantNames] = buildContext.themeNames;
|
||||
|
||||
if (fs.existsSync(resourcesDirPath)) {
|
||||
rmSync(resourcesDirPath, { recursive: true });
|
||||
}
|
||||
|
||||
await generateResourcesForMainTheme({
|
||||
resourcesDirPath,
|
||||
themeName,
|
||||
buildContext
|
||||
});
|
||||
|
||||
for (const themeVariantName of themeVariantNames) {
|
||||
generateResourcesForThemeVariant({
|
||||
resourcesDirPath,
|
||||
themeName,
|
||||
themeVariantName
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,366 @@
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import * as fs from "fs";
|
||||
import {
|
||||
join as pathJoin,
|
||||
resolve as pathResolve,
|
||||
relative as pathRelative,
|
||||
dirname as pathDirname,
|
||||
basename as pathBasename
|
||||
} from "path";
|
||||
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
|
||||
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
|
||||
import {
|
||||
generateFtlFilesCodeFactory,
|
||||
type BuildContextLike as BuildContextLike_kcContextExclusionsFtlCode
|
||||
} from "../generateFtl";
|
||||
import {
|
||||
type ThemeType,
|
||||
LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1,
|
||||
KEYCLOAK_RESOURCES,
|
||||
ACCOUNT_V1_THEME_NAME,
|
||||
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR,
|
||||
LOGIN_THEME_PAGE_IDS,
|
||||
ACCOUNT_THEME_PAGE_IDS
|
||||
} from "../../shared/constants";
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import {
|
||||
downloadKeycloakStaticResources,
|
||||
type BuildContextLike as BuildContextLike_downloadKeycloakStaticResources
|
||||
} from "../../shared/downloadKeycloakStaticResources";
|
||||
import { readFieldNameUsage } from "./readFieldNameUsage";
|
||||
import { readExtraPagesNames } from "./readExtraPageNames";
|
||||
import { generateMessageProperties } from "./generateMessageProperties";
|
||||
import {
|
||||
bringInAccountV1,
|
||||
type BuildContextLike as BuildContextLike_bringInAccountV1
|
||||
} from "./bringInAccountV1";
|
||||
import { rmSync } from "../../tools/fs.rmSync";
|
||||
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
|
||||
import {
|
||||
writeMetaInfKeycloakThemes,
|
||||
type MetaInfKeycloakTheme
|
||||
} from "../../shared/metaInfKeycloakThemes";
|
||||
import { objectEntries } from "tsafe/objectEntries";
|
||||
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
|
||||
import { downloadAndExtractArchive } from "../../tools/downloadAndExtractArchive";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
|
||||
BuildContextLike_downloadKeycloakStaticResources &
|
||||
BuildContextLike_bringInAccountV1 & {
|
||||
extraThemeProperties: string[] | undefined;
|
||||
loginThemeResourcesFromKeycloakVersion: string;
|
||||
projectDirPath: string;
|
||||
projectBuildDirPath: string;
|
||||
environmentVariables: { name: string; default: string }[];
|
||||
implementedThemeTypes: BuildContext["implementedThemeTypes"];
|
||||
themeSrcDirPath: string;
|
||||
bundler: "vite" | "webpack";
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export async function generateResourcesForMainTheme(params: {
|
||||
themeName: string;
|
||||
resourcesDirPath: string;
|
||||
buildContext: BuildContextLike;
|
||||
}): Promise<void> {
|
||||
const { themeName, resourcesDirPath, buildContext } = params;
|
||||
|
||||
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => {
|
||||
const { themeType } = params;
|
||||
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
|
||||
};
|
||||
|
||||
for (const themeType of ["login", "account"] as const) {
|
||||
if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isForAccountSpa =
|
||||
themeType === "account" &&
|
||||
(assert(buildContext.implementedThemeTypes.account.isImplemented),
|
||||
buildContext.implementedThemeTypes.account.type === "Single-Page");
|
||||
|
||||
const themeTypeDirPath = getThemeTypeDirPath({ themeType });
|
||||
|
||||
apply_replacers_and_move_to_theme_resources: {
|
||||
const destDirPath = pathJoin(
|
||||
themeTypeDirPath,
|
||||
"resources",
|
||||
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR
|
||||
);
|
||||
|
||||
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
|
||||
rmSync(destDirPath, { recursive: true, force: true });
|
||||
|
||||
if (
|
||||
themeType === "account" &&
|
||||
buildContext.implementedThemeTypes.login.isImplemented
|
||||
) {
|
||||
// NOTE: We prevent doing it twice, it has been done for the login theme.
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: pathJoin(
|
||||
getThemeTypeDirPath({
|
||||
themeType: "login"
|
||||
}),
|
||||
"resources",
|
||||
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR
|
||||
),
|
||||
destDirPath
|
||||
});
|
||||
|
||||
break apply_replacers_and_move_to_theme_resources;
|
||||
}
|
||||
|
||||
{
|
||||
const dirPath = pathJoin(
|
||||
buildContext.projectBuildDirPath,
|
||||
KEYCLOAK_RESOURCES
|
||||
);
|
||||
|
||||
if (fs.existsSync(dirPath)) {
|
||||
assert(buildContext.bundler === "webpack");
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
`Keycloakify build error: The ${KEYCLOAK_RESOURCES} directory shouldn't exist in your build directory.`,
|
||||
`(${pathRelative(process.cwd(), dirPath)}).\n`,
|
||||
`Theses assets are only required for local development with Storybook.",
|
||||
"Please remove this directory as an additional step of your command.\n`,
|
||||
`For example: \`"build": "... && rimraf ${pathRelative(buildContext.projectDirPath, dirPath)}"\``
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: buildContext.projectBuildDirPath,
|
||||
destDirPath,
|
||||
transformSourceCode: ({ filePath, fileRelativePath, sourceCode }) => {
|
||||
if (filePath.endsWith(".css")) {
|
||||
const { fixedCssCode } = replaceImportsInCssCode({
|
||||
cssCode: sourceCode.toString("utf8"),
|
||||
cssFileRelativeDirPath: pathDirname(fileRelativePath),
|
||||
buildContext
|
||||
});
|
||||
|
||||
return {
|
||||
modifiedSourceCode: Buffer.from(fixedCssCode, "utf8")
|
||||
};
|
||||
}
|
||||
|
||||
if (filePath.endsWith(".js")) {
|
||||
const { fixedJsCode } = replaceImportsInJsCode({
|
||||
jsCode: sourceCode.toString("utf8"),
|
||||
buildContext
|
||||
});
|
||||
|
||||
return {
|
||||
modifiedSourceCode: Buffer.from(fixedJsCode, "utf8")
|
||||
};
|
||||
}
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
|
||||
themeName,
|
||||
indexHtmlCode: fs
|
||||
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
|
||||
.toString("utf8"),
|
||||
buildContext,
|
||||
keycloakifyVersion: readThisNpmPackageVersion(),
|
||||
themeType,
|
||||
fieldNames: readFieldNameUsage({
|
||||
themeSrcDirPath: buildContext.themeSrcDirPath,
|
||||
themeType
|
||||
})
|
||||
});
|
||||
|
||||
[
|
||||
...(() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return LOGIN_THEME_PAGE_IDS;
|
||||
case "account":
|
||||
return isForAccountSpa ? ["index.ftl"] : ACCOUNT_THEME_PAGE_IDS;
|
||||
}
|
||||
})(),
|
||||
...(isForAccountSpa
|
||||
? []
|
||||
: readExtraPagesNames({
|
||||
themeType,
|
||||
themeSrcDirPath: buildContext.themeSrcDirPath
|
||||
}))
|
||||
].forEach(pageId => {
|
||||
const { ftlCode } = generateFtlFilesCode({ pageId });
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(themeTypeDirPath, pageId),
|
||||
Buffer.from(ftlCode, "utf8")
|
||||
);
|
||||
});
|
||||
|
||||
i18n_messages_generation: {
|
||||
if (isForAccountSpa) {
|
||||
break i18n_messages_generation;
|
||||
}
|
||||
|
||||
generateMessageProperties({
|
||||
themeSrcDirPath: buildContext.themeSrcDirPath,
|
||||
themeType
|
||||
}).forEach(({ languageTag, propertiesFileSource }) => {
|
||||
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
|
||||
|
||||
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), {
|
||||
recursive: true
|
||||
});
|
||||
|
||||
const propertiesFilePath = pathJoin(
|
||||
messagesDirPath,
|
||||
`messages_${languageTag}.properties`
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
propertiesFilePath,
|
||||
Buffer.from(propertiesFileSource, "utf8")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
keycloak_static_resources: {
|
||||
if (isForAccountSpa) {
|
||||
break keycloak_static_resources;
|
||||
}
|
||||
|
||||
await downloadKeycloakStaticResources({
|
||||
keycloakVersion: (() => {
|
||||
switch (themeType) {
|
||||
case "account":
|
||||
return LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1;
|
||||
case "login":
|
||||
return buildContext.loginThemeResourcesFromKeycloakVersion;
|
||||
}
|
||||
})(),
|
||||
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
|
||||
themeType,
|
||||
buildContext
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(themeTypeDirPath, "theme.properties"),
|
||||
Buffer.from(
|
||||
[
|
||||
`parent=${(() => {
|
||||
switch (themeType) {
|
||||
case "account":
|
||||
return isForAccountSpa ? "base" : ACCOUNT_V1_THEME_NAME;
|
||||
case "login":
|
||||
return "keycloak";
|
||||
}
|
||||
assert<Equals<typeof themeType, never>>(false);
|
||||
})()}`,
|
||||
...(isForAccountSpa ? ["deprecatedMode=false"] : []),
|
||||
...(buildContext.extraThemeProperties ?? []),
|
||||
...buildContext.environmentVariables.map(
|
||||
({ name, default: defaultValue }) =>
|
||||
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
|
||||
)
|
||||
].join("\n\n"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
email: {
|
||||
if (!buildContext.implementedThemeTypes.email.isImplemented) {
|
||||
break email;
|
||||
}
|
||||
|
||||
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: emailThemeSrcDirPath,
|
||||
destDirPath: getThemeTypeDirPath({ themeType: "email" })
|
||||
});
|
||||
}
|
||||
|
||||
bring_in_account_v1: {
|
||||
if (!buildContext.implementedThemeTypes.account.isImplemented) {
|
||||
break bring_in_account_v1;
|
||||
}
|
||||
|
||||
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
|
||||
break bring_in_account_v1;
|
||||
}
|
||||
|
||||
await bringInAccountV1({
|
||||
resourcesDirPath,
|
||||
buildContext
|
||||
});
|
||||
}
|
||||
|
||||
bring_in_account_v3_i18n_messages: {
|
||||
if (!buildContext.implementedThemeTypes.account.isImplemented) {
|
||||
break bring_in_account_v3_i18n_messages;
|
||||
}
|
||||
if (buildContext.implementedThemeTypes.account.type !== "Single-Page") {
|
||||
break bring_in_account_v3_i18n_messages;
|
||||
}
|
||||
|
||||
const { extractedDirPath } = await downloadAndExtractArchive({
|
||||
url: "https://repo1.maven.org/maven2/org/keycloak/keycloak-account-ui/25.0.1/keycloak-account-ui-25.0.1.jar",
|
||||
cacheDirPath: buildContext.cacheDirPath,
|
||||
fetchOptions: buildContext.fetchOptions,
|
||||
uniqueIdOfOnArchiveFile: "bring_in_account_v3_i18n_messages",
|
||||
onArchiveFile: async ({ fileRelativePath, writeFile }) => {
|
||||
if (
|
||||
!fileRelativePath.startsWith(
|
||||
pathJoin("theme", "keycloak.v3", "account", "messages")
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await writeFile({
|
||||
fileRelativePath: pathBasename(fileRelativePath)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: extractedDirPath,
|
||||
destDirPath: pathJoin(
|
||||
getThemeTypeDirPath({ themeType: "account" }),
|
||||
"messages"
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
|
||||
|
||||
metaInfKeycloakThemes.themes.push({
|
||||
name: themeName,
|
||||
types: objectEntries(buildContext.implementedThemeTypes)
|
||||
.filter(([, { isImplemented }]) => isImplemented)
|
||||
.map(([themeType]) => themeType)
|
||||
});
|
||||
|
||||
if (buildContext.implementedThemeTypes.account.isImplemented) {
|
||||
metaInfKeycloakThemes.themes.push({
|
||||
name: ACCOUNT_V1_THEME_NAME,
|
||||
types: ["account"]
|
||||
});
|
||||
}
|
||||
|
||||
writeMetaInfKeycloakThemes({
|
||||
resourcesDirPath,
|
||||
getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
import { join as pathJoin, extname as pathExtname, sep as pathSep } from "path";
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
|
||||
import { assert } from "tsafe/assert";
|
||||
|
||||
export type BuildContextLike = {
|
||||
keycloakifyBuildDirPath: string;
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export function generateResourcesForThemeVariant(params: {
|
||||
resourcesDirPath: string;
|
||||
themeName: string;
|
||||
themeVariantName: string;
|
||||
}) {
|
||||
const { resourcesDirPath, themeName, themeVariantName } = params;
|
||||
|
||||
const mainThemeDirPath = pathJoin(resourcesDirPath, "theme", themeName);
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: mainThemeDirPath,
|
||||
destDirPath: pathJoin(mainThemeDirPath, "..", themeVariantName),
|
||||
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
|
||||
if (
|
||||
pathExtname(fileRelativePath) === ".ftl" &&
|
||||
fileRelativePath.split(pathSep).length === 2
|
||||
) {
|
||||
const modifiedSourceCode = Buffer.from(
|
||||
Buffer.from(sourceCode)
|
||||
.toString("utf-8")
|
||||
.replace(
|
||||
`"themeName": "${themeName}"`,
|
||||
`"themeName": "${themeVariantName}"`
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
|
||||
|
||||
return { modifiedSourceCode };
|
||||
}
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
});
|
||||
|
||||
writeMetaInfKeycloakThemes({
|
||||
resourcesDirPath,
|
||||
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => {
|
||||
assert(metaInfKeycloakTheme !== undefined);
|
||||
|
||||
const newMetaInfKeycloakTheme = metaInfKeycloakTheme;
|
||||
|
||||
newMetaInfKeycloakTheme.themes.push({
|
||||
name: themeVariantName,
|
||||
types: (() => {
|
||||
const theme = newMetaInfKeycloakTheme.themes.find(
|
||||
({ name }) => name === themeName
|
||||
);
|
||||
assert(theme !== undefined);
|
||||
return theme.types;
|
||||
})()
|
||||
});
|
||||
|
||||
return newMetaInfKeycloakTheme;
|
||||
}
|
||||
});
|
||||
}
|
1
src/bin/keycloakify/generateResources/index.ts
Normal file
1
src/bin/keycloakify/generateResources/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./generateResources";
|
@ -1,19 +1,28 @@
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { type ThemeType, 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,
|
||||
ACCOUNT_THEME_PAGE_IDS,
|
||||
LOGIN_THEME_PAGE_IDS
|
||||
} from "../../shared/constants";
|
||||
|
||||
export function readExtraPagesNames(params: { themeSrcDirPath: string; themeType: ThemeType }): string[] {
|
||||
export function readExtraPagesNames(params: {
|
||||
themeSrcDirPath: string;
|
||||
themeType: ThemeType;
|
||||
}): string[] {
|
||||
const { themeSrcDirPath, themeType } = params;
|
||||
|
||||
const filePaths = crawl({
|
||||
"dirPath": pathJoin(themeSrcDirPath, themeType),
|
||||
"returnedPathsType": "absolute"
|
||||
dirPath: pathJoin(themeSrcDirPath, themeType),
|
||||
returnedPathsType: "absolute"
|
||||
}).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
|
||||
|
||||
const candidateFilePaths = filePaths.filter(filePath => /kcContext\.[^.]+$/.test(filePath));
|
||||
const candidateFilePaths = filePaths.filter(filePath =>
|
||||
/[kK]cContext\.[^.]+$/.test(filePath)
|
||||
);
|
||||
|
||||
if (candidateFilePaths.length === 0) {
|
||||
candidateFilePaths.push(...filePaths);
|
||||
@ -24,15 +33,17 @@ export function readExtraPagesNames(params: { themeSrcDirPath: string; themeType
|
||||
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]));
|
||||
extraPages.push(
|
||||
...Array.from(rawSourceFile.matchAll(/["']([^.\s]+.ftl)["']:/g), m => m[1])
|
||||
);
|
||||
}
|
||||
|
||||
return extraPages.reduce(...removeDuplicates<string>()).filter(pageId => {
|
||||
switch (themeType) {
|
||||
case "account":
|
||||
return !id<readonly string[]>(accountThemePageIds).includes(pageId);
|
||||
return !id<readonly string[]>(ACCOUNT_THEME_PAGE_IDS).includes(pageId);
|
||||
case "login":
|
||||
return !id<readonly string[]>(loginThemePageIds).includes(pageId);
|
||||
return !id<readonly string[]>(LOGIN_THEME_PAGE_IDS).includes(pageId);
|
||||
}
|
||||
});
|
||||
}
|
91
src/bin/keycloakify/generateResources/readFieldNameUsage.ts
Normal file
91
src/bin/keycloakify/generateResources/readFieldNameUsage.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { join as pathJoin } from "path";
|
||||
import * as fs from "fs";
|
||||
import type { ThemeType } from "../../shared/constants";
|
||||
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
|
||||
|
||||
/** Assumes the theme type exists */
|
||||
export function readFieldNameUsage(params: {
|
||||
themeSrcDirPath: string;
|
||||
themeType: ThemeType;
|
||||
}): string[] {
|
||||
const { themeSrcDirPath, themeType } = params;
|
||||
|
||||
// NOTE: We pre-populate with the synthetic user attributes defined in useUserProfileForm (can't be parsed automatically)
|
||||
const fieldNames = new Set<string>([
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"username",
|
||||
"password",
|
||||
"password-confirm"
|
||||
]);
|
||||
|
||||
for (const srcDirPath of [
|
||||
pathJoin(getThisCodebaseRootDirPath(), "src", themeType),
|
||||
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,59 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { BuildOptions } from "./BuildOptions";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
themeName: string;
|
||||
extraThemeNames: string[];
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh";
|
||||
|
||||
const containerName = "keycloak-testing-container";
|
||||
|
||||
/** Files for being able to run a hot reload keycloak container */
|
||||
export function generateStartKeycloakTestingContainer(params: {
|
||||
keycloakVersion: string;
|
||||
keycloakThemeBuildingDirPath: string;
|
||||
buildOptions: BuildOptionsLike;
|
||||
}) {
|
||||
const {
|
||||
keycloakThemeBuildingDirPath,
|
||||
keycloakVersion,
|
||||
buildOptions: { themeName, extraThemeNames }
|
||||
} = params;
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename),
|
||||
Buffer.from(
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"",
|
||||
`docker rm ${containerName} || true`,
|
||||
"",
|
||||
`cd "${keycloakThemeBuildingDirPath.replace(/\\/g, "/")}"`,
|
||||
"",
|
||||
"docker run \\",
|
||||
" -p 8080:8080 \\",
|
||||
` --name ${containerName} \\`,
|
||||
" -e KEYCLOAK_ADMIN=admin \\",
|
||||
" -e KEYCLOAK_ADMIN_PASSWORD=admin \\",
|
||||
...[themeName, ...extraThemeNames].map(
|
||||
themeName =>
|
||||
` -v "${pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace(
|
||||
/\\/g,
|
||||
"/"
|
||||
)}":"/opt/keycloak/themes/${themeName}":rw \\`
|
||||
),
|
||||
` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`,
|
||||
` start-dev --features=declarative-user-profile`,
|
||||
""
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
),
|
||||
{ "mode": 0o755 }
|
||||
);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user