Compare commits

...

224 Commits

Author SHA1 Message Date
481131d58b update ci 2024-02-17 03:56:44 +01:00
0a4e71e149 Bump version 2024-02-17 03:54:28 +01:00
96f5139421 Follow up on #406 2024-02-17 03:54:11 +01:00
1a326bf7e4 Bump version 2023-11-22 18:58:04 +01:00
e1afc1cf7a Add themeVersion in KcContext type 2023-11-22 18:57:43 +01:00
bb007ddce5 fmt 2023-11-22 11:44:58 +01:00
b5dd0317c7 Update README.md 2023-11-22 11:39:10 +01:00
3c54541a73 Bump version 2023-11-19 03:27:54 +01:00
2657f01135 Enable to ignore part of the HTML 2023-11-19 03:27:40 +01:00
7223409eb1 Bump version 2023-11-07 16:33:33 +01:00
c41eae63e7 Fix info.ftl page rendering in storybook 2023-11-07 16:33:19 +01:00
c8b85c43aa Bump version 2023-11-04 16:47:58 +01:00
e918788c3f Reverse previous change, it breaks cra build 2023-11-04 16:47:13 +01:00
b53f4f997c Bump version 2023-11-04 16:29:36 +01:00
481d93ebc4 Create symlink to build in keycloak-resource for test env that better reflect prod 2023-11-04 16:29:09 +01:00
1ce666f136 Bump version 2023-11-04 15:50:51 +01:00
49a8e702bc Merge pull request #447 from celinepelletier/add-oauth2-device-flow-pages
feat: add login-oauth2-device-verify-user-code and login-oauth-grant pages
2023-11-04 00:14:52 +01:00
5d59e652d7 feat: add login-oauth2-device-verify-user-code and login-oauth-grant in storybook 2023-11-03 16:48:40 -04:00
02af8c7311 Merge pull request #448 from keycloakify/all-contributors/add-celinepelletier
docs: add celinepelletier as a contributor for code
2023-11-03 21:31:55 +01:00
fadf4e867c docs: update .all-contributorsrc [skip ci] 2023-11-03 20:29:27 +00:00
0839859fef docs: update README.md [skip ci] 2023-11-03 20:29:26 +00:00
c122b48e35 feat: add login-oauth2-device-verify-user-code and login-oauth-grant pages 2023-11-03 16:06:46 -04:00
cebb297bbf Merge branch 'main' of https://github.com/keycloakify/keycloakify 2023-10-26 12:56:47 +02:00
2e31b796f7 Bump version 2023-10-26 12:52:06 +02:00
e0a61b51cb Little fix on LoginConfigTotp.tsx 2023-10-26 12:51:43 +02:00
46e50e622b Bump version 2023-10-24 16:23:28 +02:00
7cfa1df0b2 Merge pull request #440 from keycloakify/discussion_432
Discussion 432
2023-10-24 16:22:57 +02:00
8a63648339 Release candidate 2023-10-22 16:39:34 +02:00
bb6b026720 https://github.com/keycloakify/keycloakify/discussions/432#discussioncomment-7332729 2023-10-22 16:39:15 +02:00
2a13b314dc Bump version 2023-10-22 16:05:09 +02:00
4506b3f6d4 Remove some dead code 2023-10-22 16:04:47 +02:00
804abef0de #433 2023-10-22 15:58:11 +02:00
7e932b920e Bump version 2023-10-20 15:06:29 +02:00
46fdfbc507 Merge pull request #438 from keycloakify/all-contributors/add-rome-user
docs: add rome-user as a contributor for code
2023-10-20 15:05:48 +02:00
a4ff8607c5 docs: update .all-contributorsrc [skip ci] 2023-10-20 13:05:34 +00:00
7fe4eeda57 docs: update README.md [skip ci] 2023-10-20 13:05:33 +00:00
9f25cddaa4 Allow "keycloak_theme" as a theme source directory
Fixes #429.
2023-10-08 18:12:57 -07:00
eb64fe60d0 Bump version 2023-10-09 00:49:50 +02:00
36f404e17d Preserve css order 2023-10-09 00:49:35 +02:00
5398590939 https://github.com/keycloakify/keycloakify/discussions/371#discussioncomment-7223711 2023-10-09 00:49:18 +02:00
96d5cfea14 Bump version 2023-10-02 22:49:31 +02:00
79007ebd55 #427 2023-10-02 22:49:04 +02:00
fcb519dac3 Bump version 2023-09-27 04:08:13 +02:00
2b487aa959 Merge pull request #423 from zavoloklom/fix/js-code-path-replacer
fix: change JS path transformation for static resources
2023-09-27 04:07:41 +02:00
733feadcb2 Merge pull request #425 from keycloakify/all-contributors/add-zavoloklom
docs: add zavoloklom as a contributor for test, and code
2023-09-27 03:59:00 +02:00
5ae568f19c docs: update .all-contributorsrc [skip ci] 2023-09-27 01:58:05 +00:00
0e51807856 docs: update README.md [skip ci] 2023-09-27 01:58:04 +00:00
b6eb165207 https://github.com/keycloakify/keycloakify/discussions/422 2023-09-25 13:41:51 +02:00
d26dbf4b3d fix: change JS path transformation for static resources
- Handle both arrow functions and traditional function expressions
- Add tests to ensure correctness of transformations
2023-09-24 23:42:10 +02:00
a722582709 Bump version 2023-09-22 15:51:52 +02:00
de64deb5c5 #421 2023-09-22 15:51:18 +02:00
402c6fc64a Fix log message when prompting which version to download 2023-09-03 01:38:38 +02:00
a1f934466c Update resolution with Keycloak 22 and up 2023-09-01 17:45:33 +02:00
15aa114579 Release v8 2023-08-28 20:11:32 +02:00
b9cc82e37d Show how to persist cache between builds 2023-08-28 20:09:48 +02:00
8af9c8b150 Release candidate 2023-08-28 19:25:31 +02:00
7dcc985222 Remove debug log 2023-08-28 19:25:15 +02:00
9c2bc19897 Give futher instruction for migrating to v8 2023-08-28 19:17:32 +02:00
801b08359a Release candidate 2023-08-28 18:35:55 +02:00
c469dee158 Accomodate https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 and #406 2023-08-28 18:35:37 +02:00
2aa7eda1e9 Fix typo and formatting 2023-08-25 08:42:00 +02:00
f1246c9e00 Add version note 2023-08-24 09:44:00 +02:00
2749cbe4d1 Disable test with starter for now 2023-08-24 09:17:12 +02:00
d2a9280ab3 Update CI 2023-08-24 09:07:09 +02:00
8e25ee0fc9 Release candidate for v8 2023-08-24 09:06:42 +02:00
55026f913b Remove debug console.log 2023-08-24 09:05:58 +02:00
7cc40e2453 Merge pull request #404 from keycloakify/smaller_jar_size
Smaller jar size
2023-08-24 09:02:03 +02:00
cb6b19952d Merge pull request #396 from ddubrava/remove-message-from-kc-context-mock
feat: remove message from kcContextCommonMock
2023-08-24 09:00:16 +02:00
983af57842 Actually remove non used resources 2023-08-24 08:58:00 +02:00
3c2820dc31 Meta progaming for detecting static assets usage a build time 2023-08-23 08:13:09 +02:00
1c25b69160 Remove --external-assets option 2023-08-21 05:54:17 +02:00
641cc38ae4 Remove unused parameter 2023-08-21 04:29:32 +02:00
cd68b07e19 Build keycloak static assets and improve cache mechanism to keep build time in check https://github.com/xgp/keycloak-account-v1/issues/3 2023-08-21 04:26:58 +02:00
2b252c9abb npm install missing resources 2023-08-20 03:00:45 +02:00
e2e8370bb9 Bump version 2023-08-20 02:58:29 +02:00
e9e31394c4 #380 2023-08-20 02:58:10 +02:00
2825ccbcd5 Update README.md 2023-08-19 19:44:49 +02:00
377a14ff72 Update README.md 2023-08-19 19:44:13 +02:00
a83997b9b4 Update README.md 2023-08-19 08:53:29 +02:00
3e155d8e80 Restore starter test build step 2023-08-15 20:27:09 +02:00
6953b72ee6 Temporarely comment the test with the starter App in order to be able to release 2023-08-15 19:50:10 +02:00
ab370a1dda Merge pull request #400 from keycloakify/fix/usernameEditDisabled-usernameHidden
fix: update WithImmutablePresetUsername story parameters
2023-08-15 19:20:05 +02:00
20845e5860 fix: update WithImmutablePresetUsername story parameters 2023-08-15 08:59:00 +02:00
9ed3257006 Bump version 2023-08-14 21:57:37 +02:00
2221e30c0a fmt 2023-08-14 21:57:19 +02:00
ce43dca23b Merge pull request #399 from keycloakify/fix/usernameEditDisabled-usernameHidden
fix: usernameEditDisabled renamed to usernameHidden
2023-08-14 21:56:03 +02:00
4acf5d0931 fix: usernameEditDisabled renamed to usernameHidden
- update KcContext definition for login.ftl
- update the username field rendering conditions in Login.tsx

Closes #397
2023-08-14 15:53:05 +02:00
b742ed73aa feat: remove message from kcContextCommonMock 2023-08-11 12:54:09 +02:00
5156b2e0cc Bump version 2023-08-07 15:21:43 +02:00
6b81cf4a24 #391 2023-08-07 15:21:22 +02:00
cca3a68fe4 Merge branch 'main' of https://github.com/keycloakify/keycloakify 2023-08-07 15:10:48 +02:00
adb2904872 Bump version 2023-08-07 15:10:43 +02:00
d68b8d03dd #392 2023-08-07 15:10:25 +02:00
e7afb88f22 Update README.md 2023-08-02 12:55:08 +02:00
48cbfc64c0 Add warning about Keycloak 22 2023-08-02 09:15:10 +02:00
0b067858bc Bump version 2023-08-02 09:04:05 +02:00
2d44d98f17 Test with Keycloak 21.1.2 by default, use --features=declarative-user-profile for enabling User Profile features 2023-08-02 08:56:47 +02:00
74ef3096ae Can't support Account theme in Keycloak 22 2023-08-02 08:48:38 +02:00
8f1163fd75 Release candidate 2023-08-01 19:10:25 +02:00
a240d503c5 Hotfix for #388 2023-08-01 19:09:18 +02:00
e331a641b2 Bump version 2023-07-29 03:45:50 +02:00
85db4b8e0a Export the default PageId type 2023-07-29 03:45:33 +02:00
0aa139cf4a Bump version 2023-07-26 19:33:55 +02:00
4140ca6fbd #385 2023-07-26 19:33:38 +02:00
a8ce9da9ee Bump version 2023-07-24 23:22:51 +02:00
476a33c0ab #380 2023-07-24 23:22:16 +02:00
8e868c9fda Bump version 2023-07-24 22:39:16 +02:00
17c8b1a172 When no path PUBLIC_URL is empty string 2023-07-24 22:38:53 +02:00
b374c04d73 Bump version 2023-07-24 20:45:31 +02:00
e750d824ad #47 #384 2023-07-24 20:45:02 +02:00
dd4c50c3eb Bump version 2023-07-24 01:11:40 +02:00
20cc869299 #375 2023-07-24 01:11:34 +02:00
7214dbccdb Bump version 2023-07-07 17:03:03 +02:00
e6cebdd546 #377 2023-07-07 17:02:18 +02:00
0301003ccf Bump version 2023-06-27 17:52:18 +02:00
de2efe0c01 #369 2023-06-27 17:51:59 +02:00
90d765d7f6 #366: fix tests 2023-06-21 18:23:12 +02:00
3e0a1721ce Merge pull request #366 from Gravity-Software-srl/main
prevent crawlRec from crashing when dir_path does not exist
2023-06-21 18:17:11 +02:00
7214fbcd4c Bump version 2023-06-21 18:08:02 +02:00
4b8aecfe91 #364 2023-06-21 18:06:12 +02:00
387c71c0aa prevent crawlRec from crashing when dir_path does not exist 2023-06-21 16:24:58 +02:00
8d5ce21df4 Note about compatibility 2023-06-21 14:19:28 +02:00
f6dfcfbae9 Fix scripts build 2023-06-21 04:01:11 +02:00
69e9595db9 Bump version 2023-06-21 03:56:30 +02:00
de390678fd Deprecate the extraPages options, analyze the code to detect extra pages 2023-06-21 03:54:43 +02:00
cf9a7b8c60 Update json-schema 2023-06-21 02:56:55 +02:00
73e9c16a8d Fix some types approximations 2023-06-21 02:55:44 +02:00
9775623981 Bump version 2023-06-19 03:17:26 +02:00
20b7bb3c99 Fix error with inital select state 2023-06-19 03:17:12 +02:00
3defc16658 Bump version 2023-06-19 02:00:13 +02:00
0dbe592182 Match even if there's a cariage return after pritIfExists... 2023-06-19 02:00:02 +02:00
a7c0e5bdaa Bump version 2023-06-19 01:37:20 +02:00
3b051cbbea Fix build 2023-06-19 01:37:03 +02:00
f7edfd1c29 Add release note 2023-06-19 01:37:03 +02:00
b182c43965 Be more lax on the detection of field name. 2023-06-19 01:37:03 +02:00
4639e7ad2e Better exception message 2023-06-19 01:37:03 +02:00
56241203a0 Merge branch 'main' of https://github.com/keycloakify/keycloakify 2023-06-19 00:09:33 +02:00
8c8540de5d Analyze the code to see what field names are acutally used. Deprecates the customUserAttributes option, it's no longer needed 2023-06-19 00:09:21 +02:00
b45af78322 Bump version 2023-06-18 16:13:36 +02:00
98bcf3bf7e #362: otpCredentials is an array! 2023-06-18 16:13:18 +02:00
e28bcfced3 Bump version 2023-06-18 00:27:36 +02:00
a5bd990245 #362 2023-06-18 00:27:20 +02:00
58301e0844 Bump version 2023-06-17 00:52:35 +02:00
c9213fb6cd Bump version 2023-06-16 23:44:21 +02:00
641819a364 #359: fix: 'Local variable assigned outside a macro.' 2023-06-16 23:44:04 +02:00
3ee3a8b41d #359: Remove comment 2023-06-16 23:39:49 +02:00
5600403088 Bump version 2023-06-16 23:30:58 +02:00
3b00bace23 #359: Wrongely named FTL variable name fix 2023-06-16 23:30:36 +02:00
fcba470aad Release candidate 2023-06-16 11:31:40 +02:00
206e602d73 Accomodate #218 and #359 2023-06-16 11:29:04 +02:00
f98d1aaade Bump version 2023-06-12 21:34:42 +02:00
310f857257 #357 2023-06-12 21:34:24 +02:00
a2b1055094 Merge branch 'main' of https://github.com/keycloakify/keycloakify 2023-06-10 10:40:19 +02:00
f23ddecef3 Bump version 2023-06-10 10:40:10 +02:00
54687ec3c0 #355 2023-06-10 10:39:47 +02:00
545f0fcea5 Update discord link 2023-06-09 12:17:17 +02:00
5db8ce3043 Bump version 2023-06-08 23:25:30 +02:00
ed48669ae1 #354: Feature theme variant 2023-06-08 23:09:14 +02:00
69c3befb2d Wording 2023-06-05 06:01:47 +02:00
fc39e837ea Bump version 2023-05-25 07:40:41 +02:00
6df9f28c02 #277 fix storybook 2023-05-25 07:40:20 +02:00
f3d0947427 Bump version 2023-05-23 13:25:46 +02:00
3326a4cf2a Merge pull request #350 from abdurrahmanekr/patch-1
Change node.js 16.6.0 dependency that Array.prototype.at
2023-05-23 13:24:19 +02:00
9a6ea87b0c Change node.js 16.6.0 dependency that Array.prototype.at 2023-05-23 13:15:02 +03:00
12179d0ec0 Merge pull request #349 from keycloakify/all-contributors/add-kpoelhekke
docs: add kpoelhekke as a contributor for code
2023-05-15 16:56:38 +02:00
d4141fc51e Bump version 2023-05-15 16:43:16 +02:00
c32ab6181c docs: update .all-contributorsrc [skip ci] 2023-05-15 14:42:31 +00:00
3847882599 Merge pull request #348 from kpoelhekke/main
Parse datetime objects as iso strings
2023-05-15 16:42:31 +02:00
4db157f663 docs: update README.md [skip ci] 2023-05-15 14:42:30 +00:00
351b4e84c9 Parse datetime objects as iso strings 2023-05-15 16:09:15 +02:00
0c65561bcb Merge branch 'main' of https://github.com/keycloakify/keycloakify 2023-05-02 18:14:52 +02:00
00200f75a0 Fix cloud iam link 2023-05-02 18:14:41 +02:00
58614a74f5 Merge pull request #343 from keycloakify/all-contributors/add-satanshiro
docs: add satanshiro as a contributor for code
2023-05-02 16:22:00 +02:00
f3d64663a0 docs: update .all-contributorsrc [skip ci] 2023-05-02 14:21:20 +00:00
8be8c270f8 docs: update README.md [skip ci] 2023-05-02 14:21:19 +00:00
a56037f1c9 Bump version 2023-05-02 16:18:21 +02:00
2ff7955ec3 fmt 2023-05-02 16:17:53 +02:00
f2044c4d26 change name 2023-05-02 16:53:43 +03:00
4113f0faea fix-saml-post-form 2023-05-02 16:50:44 +03:00
bacd09484a Bump version 2023-05-02 04:51:36 +02:00
8253eb62bd Fix typo 2023-05-02 04:51:23 +02:00
70b659a0a0 Brag less 2023-05-02 04:36:20 +02:00
79ed74ab17 Somewhat dissociate from Keycloakify from React 2023-05-02 04:22:06 +02:00
93bb3ebd69 Bump version 2023-04-28 18:47:55 +02:00
e8e516159c Merge branch 'main' of https://github.com/keycloakify/keycloakify 2023-04-28 18:47:30 +02:00
1431c031a0 #340 2023-04-28 18:47:25 +02:00
209c2183e1 Bump version 2023-04-28 17:58:09 +02:00
0c98c282a0 Merge pull request #339 from keycloakify/fix/fix-broken-jar
fix: fix broken jar
2023-04-28 17:57:41 +02:00
58c10796a1 fix: fix broken jar
Many tools will handle zipfiles which lack directory entries
just fine, others will not. Looks like the JDKs JAR libs are
not  handling  it well. This commit will make sure to create
folder entries.
2023-04-28 16:59:06 +02:00
603e6a99f3 Update package.json 2023-04-27 18:00:14 +02:00
6622ebc04e Merge pull request #337 from keycloakify/fix/restore-missing-pom-xml-in-jar
Fix/restore missing pom xml in jar
2023-04-27 17:59:53 +02:00
465dbb4a8d fix formatting 2023-04-27 14:47:15 +02:00
08ae908453 fix: adjust test after adjusting jar.ts 2023-04-27 14:44:45 +02:00
c35a1e7c50 fix: fix paths after changing root path param meaning 2023-04-27 14:33:21 +02:00
ecb22c3829 fix: restore missing pom.xml in jar archive 2023-04-27 14:33:19 +02:00
eebf969f7e Bump version 2023-04-27 11:52:28 +02:00
5816f25c3e #334 2023-04-27 11:52:02 +02:00
b2a81d880d fmt 2023-04-25 01:42:42 +02:00
b10c1476a6 Bump version 2023-04-25 01:40:07 +02:00
e11cd09a12 Bump version 2023-04-25 01:40:07 +02:00
27575eda68 fix: collapse empty ca list to undefined 2023-04-25 01:40:07 +02:00
f33b9a1ec6 feat: honor npmrc settings for ssl ca, cert and strict ssh handling 2023-04-25 01:40:07 +02:00
7c45fff7ba Update README.md 2023-04-25 01:39:45 +02:00
ecdb0775cd Update CloudIAM logo 2023-04-25 01:39:21 +02:00
6ef90a56ed Bump version 2023-04-21 01:00:39 +02:00
71b86ff43b Realtime validation for account/password.ftl 2023-04-21 01:00:18 +02:00
0535e06ae1 Update Cloud IAM wording 2023-04-20 22:15:02 +02:00
6261f5e7cc Update CloudIAM logo and referal link 2023-04-20 22:11:52 +02:00
f256b74929 Bump version 2023-04-20 20:52:23 +02:00
4f1182a230 Feat sml-post-form.ftl #277 2023-04-20 20:51:46 +02:00
e7c20547f8 Bump version 2023-04-20 20:09:29 +02:00
9ab4c510fe #209 2023-04-20 20:08:47 +02:00
7d78c52064 Bump version 2023-04-20 18:15:18 +02:00
6223d91291 Update post build instructions 2023-04-20 18:15:03 +02:00
840b5e1312 Bump version 2023-04-20 13:18:17 +02:00
e69813f6e3 Fix small logical error 2023-04-20 13:17:57 +02:00
3c0c057e06 Bump version 2023-04-20 13:10:46 +02:00
984d12b3f2 Fix bad types in Account kcContext 2023-04-20 13:10:33 +02:00
61dc54f115 Bump version 2023-04-20 13:05:33 +02:00
34e47cccc1 Enable to redirect back to the application from the account pages 2023-04-20 13:05:01 +02:00
c170345550 Update README 2023-04-20 05:52:04 +02:00
1e40706f72 Update CNAME 2023-04-20 05:44:16 +02:00
ea1a747ebf Add all missing pages to the storybook 2023-04-20 05:41:34 +02:00
a14e967020 Add IdpReviewUserProfile.tsx story 2023-04-20 05:19:38 +02:00
107 changed files with 3614 additions and 1073 deletions

View File

@ -140,6 +140,52 @@
"contributions": [
"code"
]
},
{
"login": "satanshiro",
"name": "satanshiro",
"avatar_url": "https://avatars.githubusercontent.com/u/38865738?v=4",
"profile": "https://github.com/satanshiro",
"contributions": [
"code"
]
},
{
"login": "kpoelhekke",
"name": "Koen Poelhekke",
"avatar_url": "https://avatars.githubusercontent.com/u/1632377?v=4",
"profile": "https://poelhekke.dev",
"contributions": [
"code"
]
},
{
"login": "zavoloklom",
"name": "Sergey Kupletsky",
"avatar_url": "https://avatars.githubusercontent.com/u/4151869?v=4",
"profile": "https://github.com/zavoloklom",
"contributions": [
"test",
"code"
]
},
{
"login": "rome-user",
"name": "rome-user",
"avatar_url": "https://avatars.githubusercontent.com/u/114131048?v=4",
"profile": "https://github.com/rome-user",
"contributions": [
"code"
]
},
{
"login": "celinepelletier",
"name": "Céline Pelletier",
"avatar_url": "https://avatars.githubusercontent.com/u/82821620?v=4",
"profile": "https://github.com/celinepelletier",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
@ -147,5 +193,6 @@
"repoType": "github",
"repoHost": "https://github.com",
"projectName": "keycloakify",
"projectOwner": "keycloakify"
"projectOwner": "keycloakify",
"commitType": "docs"
}

View File

@ -3,6 +3,7 @@ on:
push:
branches:
- main
- v8
pull_request:
branches:
- main
@ -34,7 +35,7 @@ jobs:
- uses: bahmutov/npm-install@v1
- run: yarn build
- run: yarn test
- run: yarn test:keycloakify-starter
#- run: yarn test:keycloakify-starter
storybook:
runs-on: ubuntu-latest
@ -74,7 +75,6 @@ jobs:
id: step1
with:
action_name: is_package_json_version_upgraded
branch: ${{ github.head_ref || github.ref }}
create_github_release:
runs-on: ubuntu-latest

View File

@ -116,6 +116,9 @@ const { getHardCodedWeight } = (() => {
const orderedPagesPrefix = [
"Introduction",
"login/login.ftl",
"login/register-user-profile.ftl",
"login/register.ftl",
"login/terms.ftl",
"login/error.ftl",
];

View File

@ -1 +1 @@
react-dsfr-components.etalab.studio
storybook.keycloakify.dev

141
README.md
View File

@ -20,7 +20,7 @@
<a href="https://github.com/thomasdarimont/awesome-keycloak">
<img src="https://awesome.re/mentioned-badge.svg"/>
</a>
<a href="https://discord.gg/rBzsYtUn">
<a href="https://discord.gg/kYFZG7fQmn">
<img src="https://img.shields.io/discord/1097708346976505977"/>
</a>
<p align="center">
@ -28,38 +28,62 @@
-
<a href="https://docs.keycloakify.dev">Documentation</a>
-
<a href="https://storybook.keycloakify.dev/storybook">Storybook</a>
<a href="https://storybook.keycloakify.dev">Storybook</a>
-
<a href="https://github.com/codegouvfr/keycloakify-starter">Starter project</a>
</p>
</p>
<p align="center">
<i>Ultimately this build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
<i>This build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
</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.
> 📣 🛑 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
> merged into Keycloak! 🥳 Credit to @xgp. We are now waiting for a new Keycloak release to be published.
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. (Well except for Keycloak 22's Account theme obviously but this was hopefully a one time debacle)
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).
## Sponsor 👼
We are exclusively sponsored by [Cloud IAM](https://www.cloud-iam.com), a French company offering Keycloak as a service.
We are exclusively sponsored by [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), a French company offering Keycloak as a service.
Their dedicated support helps us continue the development and maintenance of this project.
[Cloud IAM](https://www.cloud-iam.com/) provides the following services:
[Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github) provides the following services:
- Perfectly configured and optimized Keycloak IAM, ready in seconds.
- Simplify and secure your Keycloak Identity and Access Management. Keycloak as a Service.
- Custom theme building for your brand using Keycloakify.
<div align="center">
![Logo Dark](https://user-images.githubusercontent.com/6702424/234135797-c84d0a90-0526-43e5-a186-70cbebdeb278.png#gh-dark-mode-only)
</div>
<div align="center">
![Logo Light](https://user-images.githubusercontent.com/6702424/234135799-68684c33-4ec5-48d4-8763-0f3922c86643.png#gh-light-mode-only)
</div>
<p align="center">
<a href="https://www.cloud-iam.com/">
<img src="https://user-images.githubusercontent.com/6702424/232165752-17134e68-4a55-4d6e-8672-e9132ecac5d5.svg" alt="Cloud IAM Logo" width="200"/>
</a>
<br/>
<i>Use promo code <code>keycloakify5</code> </i>
<i>Checkout <a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github">Cloud IAM</a> and use promo code <code>keycloakify5</code></i>
<br/>
<i>5% of your annual subscription will be donated to us, and you'll get 5% off too.</i>
</p>
Thank you, [Cloud IAM](https://www.cloud-iam.com/), for your support!
Thank you, [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), for your support!
## Contributors ✨
@ -90,6 +114,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://www.gravitysoftware.be"><img src="https://avatars.githubusercontent.com/u/1140574?v=4?s=100" width="100px;" alt="Thomas Silvestre"/><br /><sub><b>Thomas Silvestre</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=thosil" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/satanshiro"><img src="https://avatars.githubusercontent.com/u/38865738?v=4?s=100" width="100px;" alt="satanshiro"/><br /><sub><b>satanshiro</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=satanshiro" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://poelhekke.dev"><img src="https://avatars.githubusercontent.com/u/1632377?v=4?s=100" width="100px;" alt="Koen Poelhekke"/><br /><sub><b>Koen Poelhekke</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kpoelhekke" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zavoloklom"><img src="https://avatars.githubusercontent.com/u/4151869?v=4?s=100" width="100px;" alt="Sergey Kupletsky"/><br /><sub><b>Sergey Kupletsky</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=zavoloklom" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=zavoloklom" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rome-user"><img src="https://avatars.githubusercontent.com/u/114131048?v=4?s=100" width="100px;" alt="rome-user"/><br /><sub><b>rome-user</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=rome-user" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/celinepelletier"><img src="https://avatars.githubusercontent.com/u/82821620?v=4?s=100" width="100px;" alt="Céline Pelletier"/><br /><sub><b>Céline Pelletier</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=celinepelletier" title="Code">💻</a></td>
</tr>
</tbody>
</table>
@ -101,6 +130,89 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
# 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.
@ -108,10 +220,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
You are now expected to have a `"prepare": "copy-keycloak-resources-to-public",` in your package.json scripts.
This script will create `public/keycloak-assets` when you run `yarn install` (If you are using another package manager
like `pnpm` makes sure that `"prepare"` is actually ran.)
[See the updated starter](https://github.com/keycloakify/keycloakify-starter/blob/94532fcf10bf8b19e0873be8575fd28a8958a806/package.json#L11).
`public/keycloak-assets` shouldn't be tracked by GIT and is automatically ignored.
[See the updated starter](https://github.com/keycloakify/keycloakify-starter/blob/94532fcf10bf8b19e0873be8575fd28a8958a806/package.json#L11). `public/keycloak-assets` shouldn't be tracked by GIT and is automatically ignored.
## 7.7
## 7.7
- Better storybook support, see [the starter project](https://github.com/keycloakify/keycloakify-starter).

View File

@ -30,18 +30,6 @@
"type": "string"
}
},
"extraLoginPages": {
"type": "array",
"items": {
"type": "string"
}
},
"extraAccountPages": {
"type": "array",
"items": {
"type": "string"
}
},
"extraThemeProperties": {
"type": "array",
"items": {
@ -70,12 +58,6 @@
"keycloakifyBuildDirPath": {
"type": "string"
},
"customUserAttributes": {
"type": "array",
"items": {
"type": "string"
}
},
"themeName": {
"type": "string"
}

View File

@ -1,6 +1,6 @@
{
"name": "keycloakify",
"version": "7.9.2",
"version": "8.4.2",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
@ -10,8 +10,7 @@
"types": "dist/index.d.ts",
"scripts": {
"prepare": "yarn generate-i18n-messages",
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/",
"watch-in-starter": "yarn build && yarn link-in-starter && (concurrently \"tsc -p src -w\" \"tsc-alias -p src/tsconfig.json\" \"tsc -p src/bin -w\")",
"build": "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",
@ -24,6 +23,7 @@
"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"
@ -79,12 +79,14 @@
"@storybook/manager-webpack5": "^6.5.13",
"@storybook/react": "^6.5.13",
"@storybook/testing-library": "^0.0.13",
"@types/babel__generator": "^7.6.4",
"@types/make-fetch-happen": "^10.0.1",
"@types/minimist": "^1.2.2",
"@types/node": "^18.15.3",
"@types/react": "^18.0.35",
"@types/react-dom": "^18.0.11",
"@types/yauzl": "^2.10.0",
"@types/yazl": "^2.4.2",
"concurrently": "^8.0.1",
"copyfiles": "^2.4.1",
"eslint-plugin-storybook": "^0.6.7",
@ -106,8 +108,10 @@
"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",
"@types/yazl": "^2.4.2",
"cheerio": "^1.0.0-rc.5",
"cli-select": "^1.1.2",
"evt": "^2.4.18",
@ -116,6 +120,7 @@
"minimist": "^1.2.6",
"path-browserify": "^1.0.1",
"react-markdown": "^5.0.3",
"recast": "^0.23.3",
"rfc4648": "^1.5.2",
"tsafe": "^1.6.0",
"yauzl": "^2.10.0",

View File

@ -24,9 +24,9 @@ async function main() {
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
await downloadBuiltinKeycloakTheme({
"projectDirPath": getProjectRoot(),
keycloakVersion,
"destDirPath": tmpDirPath,
isSilent
"destDirPath": tmpDirPath
});
type Dictionary = { [idiomId: string]: string };
@ -37,7 +37,10 @@ async function main() {
const baseThemeDirPath = pathJoin(tmpDirPath, "base");
const re = new RegExp(`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`);
crawl(baseThemeDirPath).forEach(filePath => {
crawl({
"dirPath": baseThemeDirPath,
"returnedPathsType": "relative to dirPath"
}).forEach(filePath => {
const match = filePath.match(re);
if (match === null) {

View File

@ -17,10 +17,12 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { isReady } = usePrepareTemplate({
"doFetchDefaultThemeResources": doUseDefaultCss,
url,
"stylesCommon": ["node_modules/patternfly/dist/css/patternfly.min.css", "node_modules/patternfly/dist/css/patternfly-additions.min.css"],
"styles": ["css/account.css"],
"htmlClassName": undefined,
"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"))
});
@ -60,7 +62,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div>
</li>
)}
{referrer?.url !== undefined && (
{referrer?.url && (
<li>
<a href={referrer.url} id="referrer">
{msg("backTo", referrer.name)}

View File

@ -11,4 +11,4 @@ export type TemplateProps<KcContext extends KcContext.Common, I18nExtended exten
children: ReactNode;
};
export type ClassKey = "kcBodyClass" | "kcButtonClass" | "kcButtonPrimaryClass" | "kcButtonLargeClass" | "kcButtonDefaultClass";
export type ClassKey = "kcHtmlClass" | "kcBodyClass" | "kcButtonClass" | "kcButtonPrimaryClass" | "kcButtonLargeClass" | "kcButtonDefaultClass";

View File

@ -211,7 +211,9 @@ const keycloakifyExtraMessages = {
"shouldBeDifferent": "{0} should be different to {1}",
"shouldMatchPattern": "Pattern should match: `/{0}/`",
"mustBeAnInteger": "Must be an integer",
"notAValidOption": "Not a valid option"
"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 */
@ -223,7 +225,9 @@ const keycloakifyExtraMessages = {
"logoutConfirmTitle": "Déconnexion",
"logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
"doLogout": "Se déconnecter"
"doLogout": "Se déconnecter",
"newPasswordSameAsOld": "Le nouveau mot de passe doit être différent de l'ancien",
"passwordConfirmNotMatch": "La confirmation du mot de passe ne correspond pas"
/* spell-checker: enable */
}
};

View File

@ -4,6 +4,7 @@ export default Fallback;
export { getKcContext } from "keycloakify/account/kcContext/getKcContext";
export { createGetKcContext } from "keycloakify/account/kcContext/createGetKcContext";
export type { AccountThemePageId as PageId } from "keycloakify/bin/keycloakify/generateFtl";
export { createUseI18n } from "keycloakify/account/i18n/i18n";
export type { PageProps } from "keycloakify/account/pages/PageProps";

View File

@ -1,4 +1,4 @@
import type { AccountThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
import type { AccountThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
@ -6,7 +6,10 @@ export type KcContext = KcContext.Password | KcContext.Account;
export declare namespace KcContext {
export type Common = {
themeVersion: string;
keycloakifyVersion: string;
themeType: "account";
themeName: string;
locale?: {
supported: {
url: string;
@ -26,6 +29,7 @@ export declare namespace KcContext {
resourceUrl: string;
resourcesCommonPath: string;
resourcesPath: string;
/** @deprecated, not present in recent keycloak version apparently, use kcContext.referrer instead */
referrerURI?: string;
getLogoutUrl: () => string;
};
@ -39,18 +43,44 @@ export declare namespace KcContext {
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;
name: string;
url: string; // The url of the App
name: string; // Client id
};
messagesPerField: {
printIfExists: <T>(fieldName: string, x: T) => T | undefined;
/**
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.
*
* @param fieldName to check for
* @param text to return
* @return text if message exists for given field, else undefined
*/
printIfExists: <T extends string>(fieldName: string, text: T) => T | undefined;
/**
* Check if exists error message for given fields
*
* @param fields
* @return boolean
*/
existsError: (fieldName: string) => boolean;
/**
* Get message for given field.
*
* @param fieldName
* @return message text or empty string
*/
get: (fieldName: string) => string;
/**
* Check if message for given field exists
*
* @param field
* @return boolean
*/
exists: (fieldName: string) => boolean;
};
account: {
@ -82,4 +112,15 @@ export declare namespace KcContext {
};
}
assert<Equals<KcContext["pageId"], AccountThemePageId>>();
{
type Got = KcContext["pageId"];
type Expected = AccountThemePageId;
type OnlyInGot = Exclude<Got, Expected>;
type OnlyInExpected = Exclude<Expected, Got>;
assert<Equals<OnlyInGot, never>>();
assert<Equals<OnlyInExpected, never>>();
}
assert<KcContext["themeType"] extends ThemeType ? true : false>();

View File

@ -7,8 +7,6 @@ import { pathBasename } from "keycloakify/tools/pathBasename";
import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { symToStr } from "tsafe/symToStr";
import { kcContextMocks, kcContextCommonMock } from "keycloakify/account/kcContext/kcContextMocks";
import { id } from "tsafe/id";
import { accountThemePageIds } from "keycloakify/bin/keycloakify/generateFtl/pageId";
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
@ -87,7 +85,7 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
return { "kcContext": undefined as any };
}
if (id<readonly string[]>(accountThemePageIds).indexOf(realKcContext.pageId) < 0 && !("account" in realKcContext)) {
if (realKcContext.themeType !== "account") {
return { "kcContext": undefined as any };
}

View File

@ -4,10 +4,13 @@ import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { id } from "tsafe/id";
import type { KcContext } from "./KcContext";
const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
const PUBLIC_URL = (typeof process !== "object" ? undefined : process.env?.["PUBLIC_URL"]) || "/";
export const kcContextCommonMock: KcContext.Common = {
"themeVersion": "0.0.0",
"keycloakifyVersion": "0.0.0",
"themeType": "account",
"themeName": "my-theme-name",
"url": {
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
@ -130,10 +133,6 @@ export const kcContextCommonMock: KcContext.Common = {
],
"currentLanguageTag": "en"
},
"message": {
"type": "success",
"summary": "This is a test message"
},
"features": {
"authorization": true,
"identityFederation": true,

View File

@ -3,6 +3,7 @@ import type { ClassKey } from "keycloakify/account/TemplateProps";
export const { useGetClassName } = createUseClassName<ClassKey>({
"defaultClasses": {
"kcHtmlClass": undefined,
"kcBodyClass": undefined,
"kcButtonClass": "btn",
"kcButtonPrimaryClass": "btn-primary",

View File

@ -15,7 +15,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
}
});
const { url, realm, messagesPerField, stateChecker, account } = kcContext;
const { url, realm, messagesPerField, stateChecker, account, referrer } = kcContext;
const { msg } = i18n;
@ -51,7 +51,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
id="username"
name="username"
disabled={!realm.editUsernameAllowed}
value={account.username ?? ""}
defaultValue={account.username ?? ""}
/>
</div>
</div>
@ -66,7 +66,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
</div>
<div className="col-sm-10 col-md-10">
<input type="text" className="form-control" id="email" name="email" autoFocus value={account.email ?? ""} />
<input type="text" className="form-control" id="email" name="email" autoFocus defaultValue={account.email ?? ""} />
</div>
</div>
@ -79,7 +79,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
</div>
<div className="col-sm-10 col-md-10">
<input type="text" className="form-control" id="firstName" name="firstName" value={account.firstName ?? ""} />
<input type="text" className="form-control" id="firstName" name="firstName" defaultValue={account.firstName ?? ""} />
</div>
</div>
@ -92,14 +92,14 @@ 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>
<div className="form-group">
<div id="kc-form-buttons" className="col-md-offset-2 col-md-10 submit">
<div>
{url.referrerURI !== undefined && <a href={url.referrerURI}>{msg("backToApplication")}</a>}
{referrer !== undefined && <a href={referrer?.url}>{msg("backToApplication")}</a>}
<button
type="submit"
className={clsx(

View File

@ -1,3 +1,4 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
@ -17,10 +18,69 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
const { url, password, account, stateChecker } = kcContext;
const { msg } = i18n;
const { msgStr, msg } = i18n;
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordConfirm, setNewPasswordConfirm] = useState("");
const [newPasswordError, setNewPasswordError] = useState("");
const [newPasswordConfirmError, setNewPasswordConfirmError] = useState("");
const [hasNewPasswordBlurred, setHasNewPasswordBlurred] = useState(false);
const [hasNewPasswordConfirmBlurred, setHasNewPasswordConfirmBlurred] = useState(false);
const checkNewPassword = (newPassword: string) => {
if (!password.passwordSet) {
return;
}
if (newPassword === currentPassword) {
setNewPasswordError(msgStr("newPasswordSameAsOld"));
} else {
setNewPasswordError("");
}
};
const checkNewPasswordConfirm = (newPasswordConfirm: string) => {
if (newPasswordConfirm === "") {
return;
}
if (newPassword !== newPasswordConfirm) {
setNewPasswordConfirmError(msgStr("passwordConfirmNotMatch"));
} else {
setNewPasswordConfirmError("");
}
};
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="password">
<Template
{...{
kcContext: {
...kcContext,
"message": (() => {
if (newPasswordError !== "") {
return {
"type": "error",
"summary": newPasswordError
};
}
if (newPasswordConfirmError !== "") {
return {
"type": "error",
"summary": newPasswordConfirmError
};
}
return kcContext.message;
})()
},
i18n,
doUseDefaultCss,
classes
}}
active="password"
>
<div className="row">
<div className="col-md-10">
<h2>{msg("changePasswordHtmlTitle")}</h2>
@ -48,9 +108,17 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
{msg("password")}
</label>
</div>
<div className="col-sm-10 col-md-10">
<input type="password" className="form-control" id="password" name="password" autoFocus autoComplete="current-password" />
<input
type="password"
className="form-control"
id="password"
name="password"
autoFocus
autoComplete="current-password"
value={currentPassword}
onChange={event => setCurrentPassword(event.target.value)}
/>
</div>
</div>
)}
@ -63,9 +131,27 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
{msg("passwordNew")}
</label>
</div>
<div className="col-sm-10 col-md-10">
<input type="password" className="form-control" id="password-new" name="password-new" autoComplete="new-password" />
<input
type="password"
className="form-control"
id="password-new"
name="password-new"
autoComplete="new-password"
value={newPassword}
onChange={event => {
const newPassword = event.target.value;
setNewPassword(newPassword);
if (hasNewPasswordBlurred) {
checkNewPassword(newPassword);
}
}}
onBlur={() => {
setHasNewPasswordBlurred(true);
checkNewPassword(newPassword);
}}
/>
</div>
</div>
@ -77,7 +163,26 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
</div>
<div className="col-sm-10 col-md-10">
<input type="password" className="form-control" id="password-confirm" name="password-confirm" autoComplete="new-password" />
<input
type="password"
className="form-control"
id="password-confirm"
name="password-confirm"
autoComplete="new-password"
value={newPasswordConfirm}
onChange={event => {
const newPasswordConfirm = event.target.value;
setNewPasswordConfirm(newPasswordConfirm);
if (hasNewPasswordConfirmBlurred) {
checkNewPasswordConfirm(newPasswordConfirm);
}
}}
onBlur={() => {
setHasNewPasswordConfirmBlurred(true);
checkNewPasswordConfirm(newPasswordConfirm);
}}
/>
</div>
</div>
@ -85,6 +190,7 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
<div id="kc-form-buttons" className="col-md-offset-2 col-md-10 submit">
<div>
<button
disabled={newPasswordError !== "" || newPasswordConfirmError !== ""}
type="submit"
className={clsx(
getClassName("kcButtonClass"),

View File

@ -24,10 +24,11 @@ import * as fs from "fs";
for (const themeType of themeTypes) {
await downloadKeycloakStaticResources({
"isSilent": false,
projectDirPath,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
"themeType": themeType,
"themeDirPath": keycloakDirInPublicDir
"themeDirPath": keycloakDirInPublicDir,
"usedResources": undefined
});
}

View File

@ -4,19 +4,72 @@ 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: { keycloakVersion: string; destDirPath: string; isSilent: boolean }) {
const { keycloakVersion, destDirPath } = params;
export async function downloadBuiltinKeycloakTheme(params: { projectDirPath: string; keycloakVersion: string; destDirPath: string }) {
const { projectDirPath, keycloakVersion, destDirPath } = params;
await Promise.all(
["", "-community"].map(ext =>
downloadAndUnzip({
"destDirPath": destDirPath,
"url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`,
"pathOfDirToExtractInArchive": `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`
})
)
);
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() {
@ -33,9 +86,9 @@ async function main() {
logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`);
await downloadBuiltinKeycloakTheme({
"projectDirPath": process.cwd(),
keycloakVersion,
destDirPath,
"isSilent": buildOptions.isSilent
destDirPath
});
}

View File

@ -51,10 +51,6 @@ import { getThemeSrcDirPath } from "./getSrcDirPath";
const { themeSrcDirPath } = getThemeSrcDirPath({ "projectDirPath": process.cwd() });
if (themeSrcDirPath === undefined) {
throw new Error("Couldn't locate your theme sources");
}
const targetFilePath = pathJoin(themeSrcDirPath, themeType, "pages", pageBasename);
if (existsSync(targetFilePath)) {

View File

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

View File

@ -7,25 +7,23 @@ import { promptKeycloakVersion } from "./promptKeycloakVersion";
import { readBuildOptions } from "./keycloakify/BuildOptions";
import * as fs from "fs";
import { getLogger } from "./tools/logger";
import { getEmailThemeSrcDirPath } from "./getSrcDirPath";
import { getThemeSrcDirPath } from "./getSrcDirPath";
export async function main() {
const projectDirPath = process.cwd();
const { isSilent } = readBuildOptions({
"projectDirPath": process.cwd(),
projectDirPath,
"processArgv": process.argv.slice(2)
});
const logger = getLogger({ isSilent });
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({
"projectDirPath": process.cwd()
const { themeSrcDirPath } = getThemeSrcDirPath({
projectDirPath
});
if (emailThemeSrcDirPath === undefined) {
logger.warn("Couldn't locate your theme source directory");
process.exit(-1);
}
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
if (fs.existsSync(emailThemeSrcDirPath)) {
logger.warn(`There is already a ${pathRelative(process.cwd(), emailThemeSrcDirPath)} directory in your project. Aborting.`);
@ -38,9 +36,9 @@ export async function main() {
const builtinKeycloakThemeTmpDirPath = pathJoin(emailThemeSrcDirPath, "..", "tmp_xIdP3_builtin_keycloak_theme");
await downloadBuiltinKeycloakTheme({
projectDirPath,
keycloakVersion,
"destDirPath": builtinKeycloakThemeTmpDirPath,
isSilent
"destDirPath": builtinKeycloakThemeTmpDirPath
});
transformCodebase({

View File

@ -4,233 +4,135 @@ import { parse as urlParse } from "url";
import { typeGuard } from "tsafe/typeGuard";
import { symToStr } from "tsafe/symToStr";
import { bundlers, getParsedPackageJson, type Bundler } from "./parsedPackageJson";
import * as fs from "fs";
import { join as pathJoin, sep as pathSep } from "path";
import parseArgv from "minimist";
/** Consolidated build option gathered form CLI arguments and config in package.json */
export type BuildOptions = BuildOptions.Standalone | BuildOptions.ExternalAssets;
export namespace BuildOptions {
export type Common = {
isSilent: boolean;
themeVersion: string;
themeName: string;
extraLoginPages: string[] | undefined;
extraAccountPages: string[] | undefined;
extraThemeProperties?: string[];
groupId: string;
artifactId: string;
bundler: Bundler;
keycloakVersionDefaultAssets: string;
/** Directory of your built react project. Defaults to {cwd}/build */
reactAppBuildDirPath: string;
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
keycloakifyBuildDirPath: string;
customUserAttributes: string[];
};
export type Standalone = Common & {
isStandalone: true;
urlPathname: string | undefined;
};
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
export namespace ExternalAssets {
export type CommonExternalAssets = Common & {
isStandalone: false;
};
export type SameDomain = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: true;
};
export type DifferentDomains = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: false;
urlOrigin: string;
urlPathname: string | undefined;
};
}
}
export 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 { isExternalAssetsCliParamProvided, isSilentCliParamProvided } = (() => {
const { isSilentCliParamProvided } = (() => {
const argv = parseArgv(processArgv);
return {
"isSilentCliParamProvided": typeof argv["silent"] === "boolean" ? argv["silent"] : false,
"isExternalAssetsCliParamProvided": typeof argv["external-assets"] === "boolean" ? argv["external-assets"] : false
"isSilentCliParamProvided": typeof argv["silent"] === "boolean" ? argv["silent"] : false
};
})();
const parsedPackageJson = getParsedPackageJson({ projectDirPath });
const url = (() => {
const { homepage } = parsedPackageJson;
const { name, keycloakify = {}, version, homepage } = parsedPackageJson;
let url: URL | undefined = undefined;
const { extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets, extraThemeNames = [] } = keycloakify ?? {};
if (homepage !== undefined) {
url = new URL(homepage);
}
const themeName =
keycloakify.themeName ??
name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-");
const CNAME = (() => {
const cnameFilePath = pathJoin(projectDirPath, "public", "CNAME");
return {
themeName,
extraThemeNames,
"bundler": (() => {
const { KEYCLOAKIFY_BUNDLER } = process.env;
if (!fs.existsSync(cnameFilePath)) {
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;
}
return fs.readFileSync(cnameFilePath).toString("utf8");
})();
if (CNAME !== undefined) {
url = new URL(`https://${CNAME.replace(/\s+$/, "")}`);
}
if (url === undefined) {
return undefined;
}
return {
"origin": url.origin,
"pathname": (() => {
const out = url.pathname.replace(/([^/])$/, "$1/");
return out === "/" ? undefined : out;
})()
};
})();
const common: BuildOptions.Common = (() => {
const { name, keycloakify = {}, version, homepage } = parsedPackageJson;
const { extraPages, extraLoginPages, extraAccountPages, extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets } =
keycloakify ?? {};
const themeName =
keycloakify.themeName ??
name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-");
return {
themeName,
"bundler": (() => {
const { KEYCLOAKIFY_BUNDLER } = process.env;
assert(
typeGuard<Bundler | undefined>(
KEYCLOAKIFY_BUNDLER,
[undefined, ...id<readonly string[]>(bundlers)].includes(KEYCLOAKIFY_BUNDLER)
),
`${symToStr({ KEYCLOAKIFY_BUNDLER })} should be one of ${bundlers.join(", ")}`
);
return KEYCLOAKIFY_BUNDLER ?? bundler ?? "keycloakify";
})(),
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? artifactId ?? `${themeName}-keycloak-theme`,
"groupId": (() => {
const fallbackGroupId = `${themeName}.keycloak`;
return (
process.env.KEYCLOAKIFY_GROUP_ID ??
groupId ??
(!homepage
? fallbackGroupId
: urlParse(homepage)
.host?.replace(/:[0-9]+$/, "")
?.split(".")
.reverse()
.join(".") ?? fallbackGroupId) + ".keycloak"
);
})(),
"themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? process.env.KEYCLOAKIFY_VERSION ?? version ?? "0.0.0",
"extraLoginPages": [...(extraPages ?? []), ...(extraLoginPages ?? [])],
extraAccountPages,
extraThemeProperties,
"isSilent": isSilentCliParamProvided,
"keycloakVersionDefaultAssets": keycloakVersionDefaultAssets ?? "11.0.3",
"reactAppBuildDirPath": (() => {
let { reactAppBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {};
if (reactAppBuildDirPath === undefined) {
return pathJoin(projectDirPath, "build");
}
if (pathSep === "\\") {
reactAppBuildDirPath = reactAppBuildDirPath.replace(/\//g, pathSep);
}
if (reactAppBuildDirPath.startsWith(`.${pathSep}`)) {
return pathJoin(projectDirPath, reactAppBuildDirPath);
}
return reactAppBuildDirPath;
})(),
"keycloakifyBuildDirPath": (() => {
let { keycloakifyBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {};
if (keycloakifyBuildDirPath === undefined) {
return pathJoin(projectDirPath, "build_keycloak");
}
if (pathSep === "\\") {
keycloakifyBuildDirPath = keycloakifyBuildDirPath.replace(/\//g, pathSep);
}
if (keycloakifyBuildDirPath.startsWith(`.${pathSep}`)) {
return pathJoin(projectDirPath, keycloakifyBuildDirPath);
}
return keycloakifyBuildDirPath;
})(),
"customUserAttributes": keycloakify.customUserAttributes ?? []
};
})();
if (isExternalAssetsCliParamProvided) {
const commonExternalAssets = id<BuildOptions.ExternalAssets.CommonExternalAssets>({
...common,
"isStandalone": false
});
if (parsedPackageJson.keycloakify?.areAppAndKeycloakServerSharingSameDomain) {
return id<BuildOptions.ExternalAssets.SameDomain>({
...commonExternalAssets,
"areAppAndKeycloakServerSharingSameDomain": true
});
} else {
assert(
url !== undefined,
[
"Can't compile in external assets mode if we don't know where",
"the app will be hosted.",
"You should provide a homepage field in the package.json (or create a",
"public/CNAME file.",
"Alternatively, if your app and the Keycloak server are on the same domain, ",
"eg https://example.com is your app and https://example.com/auth is the keycloak",
'admin UI, you can set "keycloakify": { "areAppAndKeycloakServerSharingSameDomain": true }',
"in your package.json"
].join(" ")
);
return id<BuildOptions.ExternalAssets.DifferentDomains>({
...commonExternalAssets,
"areAppAndKeycloakServerSharingSameDomain": false,
"urlOrigin": url.origin,
"urlPathname": url.pathname
});
}
}
return id<BuildOptions.Standalone>({
...common,
"isStandalone": true,
"urlPathname": url?.pathname
});
const out = url.pathname.replace(/([^/])$/, "$1/");
return out === "/" ? undefined : out;
})()
};
}

View File

@ -8,13 +8,7 @@
out["advancedMsg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
out["messagesPerField"]= {
<#assign fieldNames = [
"global", "userLabel", "username", "email", "firstName", "lastName", "password", "password-confirm",
"totp", "totpSecret", "SAMLRequest", "SAMLResponse", "relayState", "device_user_code", "code",
"password-new", "rememberMe", "login", "authenticationExecution", "cancel-aia", "clientDataJSON",
"authenticatorData", "signature", "credentialId", "userHandle", "error", "authn_use_chk", "authenticationExecution",
"isSetRetry", "try-again", "attestationObject", "publicKeyCredentialId", "authenticatorLabel"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM
]>
<#assign fieldNames = [ FIELD_NAMES_eKsIY4ZsZ4xeM ]>
<#attempt>
<#if profile?? && profile.attributes?? && profile.attributes?is_enumerable>
@ -28,85 +22,374 @@
<#recover>
</#attempt>
"printIfExists": function (fieldName, x) {
<#if !messagesPerField?? >
return undefined;
"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}" ){
<#attempt>
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
return <#if messagesPerField.existsError('username', 'password')>x<#else>undefined</#if>;
<#-- 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>
return <#if messagesPerField.existsError('${fieldName}')>x<#else>undefined</#if>;
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
<#recover>
</#attempt>
<#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("There is no " + fieldName + " field");
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
},
"existsError": function (fieldName) {
<#if !messagesPerField?? >
return false;
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
return <#if messagesPerField.existsError('username', 'password')>true<#else>false</#if>;
<#-- 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>
return <#if messagesPerField.existsError('${fieldName}')>true<#else>false</#if>;
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>true<#else>false</#if>;
</#if>
<#recover>
</#attempt>
<#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("There is no " + fieldName + " field");
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
},
"get": function (fieldName) {
<#if !messagesPerField?? >
return '';
<#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}" ){
<#attempt>
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
<#if messagesPerField.existsError('username', 'password')>
return 'Invalid username or password.';
<#-- 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>
<#if messagesPerField.existsError('${fieldName}')>
<#attempt>
return "${messagesPerField.get('${fieldName}')?no_esc}";
</#if>
<#recover>
return "invalid field";
</#attempt>
</#if>
<#recover>
</#attempt>
<#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("There is no " + fieldName + " field");
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
},
"exists": function (fieldName) {
<#if !messagesPerField?? >
return false;
<#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}" ){
<#attempt>
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
return <#if messagesPerField.exists('username') || messagesPerField.exists('password')>true<#else>false</#if>;
<#-- 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>
return <#if messagesPerField.exists('${fieldName}')>true<#else>false</#if>;
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>true<#else>false</#if>;
</#if>
<#recover>
</#attempt>
<#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("There is no " + fieldName + " field");
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
}
};
@ -121,6 +404,8 @@
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;
@ -169,10 +454,16 @@
<#-- https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
key == "loginAction" &&
are_same_path(path, ["url"]) &&
["saml-post-form.ftl", "error.ftl", "info.ftl"]?seq_contains(pageId) &&
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
!(auth?has_content && auth.showTryAnotherWayLink())
) || (
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
["secretData", "value"]?seq_contains(key) &&
are_same_path(path, [ "totp", "otpCredentials", "*" ])
) || (
["contextData", "idpConfig", "idp", "authenticationSession"]?seq_contains(key) &&
are_same_path(path, ["brokerContext"]) &&
@ -188,22 +479,32 @@
"error.ftl" == pageId &&
are_same_path(path, ["realm"]) &&
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
) || (
"applications.ftl" == pageId &&
are_same_path(path, ["applications", "applications", "*", "client", "realm"])
) || (
"applications.ftl" == pageId &&
"masterAdminClient" == key
)
>
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#continue>
</#if>
<#if key == "attemptedUsername" && are_same_path(path, ["auth"])>
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
<#if (
["register.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
key == "attemptedUsername" && are_same_path(path, ["auth"])
)>
<#attempt>
<#-- https://github.com/keycloak/keycloak/blob/3a2bf0c04bcde185e497aaa32d0bb7ab7520cf4a/themes/src/main/resources/theme/base/login/template.ftl#L63 -->
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#continue>
</#if>
<#recover>
<#local out_seq += ["/*Testing if attemptedUsername should be skipped throwed an exception */"]>
</#attempt>
</#if>
<#attempt>
@ -336,6 +637,17 @@
</#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>

View File

@ -13,39 +13,11 @@ export const themeTypes = ["login", "account"] as const;
export type ThemeType = (typeof themeTypes)[number];
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
export namespace BuildOptionsLike {
export type Common = {
customUserAttributes: string[];
themeVersion: string;
};
export type Standalone = Common & {
isStandalone: true;
urlPathname: string | undefined;
};
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
export namespace ExternalAssets {
export type CommonExternalAssets = {
isStandalone: false;
};
export type SameDomain = Common &
CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: true;
};
export type DifferentDomains = Common &
CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: false;
urlOrigin: string;
urlPathname: string | undefined;
};
}
}
export type BuildOptionsLike = {
themeName: string;
themeVersion: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
@ -55,28 +27,31 @@ export function generateFtlFilesCodeFactory(params: {
cssGlobalsToDefine: Record<string, string>;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
themeType: ThemeType;
fieldNames: string[];
}) {
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion } = params;
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType, fieldNames } = params;
const $ = cheerio.load(indexHtmlCode);
fix_imports_statements: {
if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) {
break fix_imports_statements;
}
$("script:not([src])").each((...[, element]) => {
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": $(element).html()!,
buildOptions
});
const jsCode = $(element).html();
assert(jsCode !== null);
const { fixedJsCode } = replaceImportsFromStaticInJsCode({ jsCode });
$(element).text(fixedJsCode);
});
$("style").each((...[, element]) => {
const cssCode = $(element).html();
assert(cssCode !== null);
const { fixedCssCode } = replaceImportsInInlineCssCode({
"cssCode": $(element).html()!,
cssCode,
buildOptions
});
@ -98,9 +73,7 @@ export function generateFtlFilesCodeFactory(params: {
$(element).attr(
attrName,
buildOptions.isStandalone
? href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/")
: href.replace(/^\//, `${buildOptions.urlOrigin}/`)
href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/")
);
})
);
@ -127,12 +100,11 @@ export function generateFtlFilesCodeFactory(params: {
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
.toString("utf8")
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1]
.replace(
"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM",
buildOptions.customUserAttributes.length === 0 ? "" : ", " + buildOptions.customUserAttributes.map(name => `"${name}"`).join(", ")
)
.replace("FIELD_NAMES_eKsIY4ZsZ4xeM", fieldNames.map(name => `"${name}"`).join(", "))
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion),
.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>",
@ -152,6 +124,27 @@ export function generateFtlFilesCodeFactory(params: {
].join("\n")
);
// 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 }): {

View File

@ -10,6 +10,8 @@ export const loginThemePageIds = [
"login-reset-password.ftl",
"login-verify-email.ftl",
"terms.ftl",
"login-oauth2-device-verify-user-code.ftl",
"login-oauth-grant.ftl",
"login-otp.ftl",
"login-update-profile.ftl",
"login-update-password.ftl",
@ -21,7 +23,8 @@ export const loginThemePageIds = [
"update-user-profile.ftl",
"idp-review-user-profile.ftl",
"update-email.ftl",
"select-authenticator.ftl"
"select-authenticator.ftl",
"saml-post-form.ftl"
] as const;
export const accountThemePageIds = ["password.ftl", "account.ftl"] as const;

View File

@ -1,34 +1,30 @@
import * as fs from "fs";
import { join as pathJoin, dirname as pathDirname } from "path";
import { themeTypes } from "./generateFtl/generateFtl";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "./BuildOptions";
import type { ThemeType } from "./generateFtl";
export type BuildOptionsLike = {
themeName: string;
extraThemeNames: string[];
groupId: string;
artifactId?: string;
artifactId: string;
themeVersion: string;
};
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function generateJavaStackFiles(params: {
keycloakThemeBuildingDirPath: string;
doBundlesEmailTemplate: boolean;
implementedThemeTypes: Record<ThemeType | "email", boolean>;
buildOptions: BuildOptionsLike;
}): {
jarFilePath: string;
} {
const {
buildOptions: { groupId, themeName, themeVersion, artifactId },
buildOptions: { groupId, themeName, extraThemeNames, themeVersion, artifactId },
keycloakThemeBuildingDirPath,
doBundlesEmailTemplate
implementedThemeTypes
} = params;
{
@ -67,12 +63,12 @@ export function generateJavaStackFiles(params: {
Buffer.from(
JSON.stringify(
{
"themes": [
{
"name": themeName,
"types": [...themeTypes, ...(doBundlesEmailTemplate ? ["email"] : [])]
}
]
"themes": [themeName, ...extraThemeNames].map(themeName => ({
"name": themeName,
"types": Object.entries(implementedThemeTypes)
.filter(([, isImplemented]) => isImplemented)
.map(([themeType]) => themeType)
}))
},
null,
2

View File

@ -1,18 +1,14 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "./BuildOptions";
export type BuildOptionsLike = {
themeName: string;
extraThemeNames: string[];
};
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
assert<BuildOptions extends BuildOptionsLike ? true : false>();
generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh";
@ -27,14 +23,11 @@ export function generateStartKeycloakTestingContainer(params: {
const {
keycloakThemeBuildingDirPath,
keycloakVersion,
buildOptions: { themeName }
buildOptions: { themeName, extraThemeNames }
} = params;
const keycloakThemePath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace(/\\/g, "/");
fs.writeFileSync(
pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename),
Buffer.from(
[
"#!/usr/bin/env bash",
@ -48,10 +41,15 @@ export function generateStartKeycloakTestingContainer(params: {
` --name ${containerName} \\`,
" -e KEYCLOAK_ADMIN=admin \\",
" -e KEYCLOAK_ADMIN_PASSWORD=admin \\",
" -e JAVA_OPTS=-Dkeycloak.profile=preview \\",
` -v "${keycloakThemePath}":"/opt/keycloak/themes/${themeName}":rw \\`,
...[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`,
` start-dev --features=declarative-user-profile`,
""
].join("\n"),
"utf8"

View File

@ -1,6 +1,6 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative } from "path";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import type { ThemeType } from "../generateFtl";
import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme";
import {
@ -9,17 +9,46 @@ import {
basenameOfKeycloakDirInPublicDir
} from "../../mockTestingResourcesPath";
import * as crypto from "crypto";
import { assert } from "tsafe/assert";
export async function downloadKeycloakStaticResources(
// prettier-ignore
params: {
projectDirPath: string;
themeType: ThemeType;
themeDirPath: string;
isSilent: boolean;
keycloakVersion: string;
usedResources: {
resourcesCommonFilePaths: string[];
} | undefined
}
) {
const { themeType, isSilent, themeDirPath, keycloakVersion } = params;
const { projectDirPath, themeType, themeDirPath, keycloakVersion } = params;
// NOTE: Hack for 427
const usedResources = (() => {
const { usedResources } = params;
if (usedResources === undefined) {
return undefined;
}
assert(usedResources !== undefined);
return {
"resourcesCommonDirPaths": usedResources.resourcesCommonFilePaths.map(filePath => {
{
const splitArg = "/dist/";
if (filePath.includes(splitArg)) {
return filePath.split(splitArg)[0] + splitArg;
}
}
return pathDirname(filePath);
})
};
})();
const tmpDirPath = pathJoin(
themeDirPath,
@ -28,9 +57,9 @@ export async function downloadKeycloakStaticResources(
);
await downloadBuiltinKeycloakTheme({
projectDirPath,
keycloakVersion,
"destDirPath": tmpDirPath,
isSilent
"destDirPath": tmpDirPath
});
transformCodebase({
@ -40,7 +69,17 @@ export async function downloadKeycloakStaticResources(
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"),
"destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesCommonDirPathRelativeToPublicDir))
"destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesCommonDirPathRelativeToPublicDir)),
"transformSourceCode":
usedResources === undefined
? undefined
: ({ fileRelativePath, sourceCode }) => {
if (usedResources.resourcesCommonDirPaths.find(dirPath => fileRelativePath.startsWith(dirPath)) === undefined) {
return undefined;
}
return { "modifiedSourceCode": sourceCode };
}
});
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });

View File

@ -0,0 +1,179 @@
import type { ThemeType } from "../generateFtl";
import { crawl } from "../../tools/crawl";
import { join as pathJoin } from "path";
import { readFileSync } from "fs";
import { symToStr } from "tsafe/symToStr";
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
import * as recast from "recast";
import * as babelParser from "@babel/parser";
import babelGenerate from "@babel/generator";
import * as babelTypes from "@babel/types";
export function generateMessageProperties(params: {
themeSrcDirPath: string;
themeType: ThemeType;
}): { languageTag: string; propertiesFileSource: string }[] {
const { themeSrcDirPath, themeType } = params;
let files = crawl({
"dirPath": pathJoin(themeSrcDirPath, themeType),
"returnedPathsType": "absolute"
});
files = files.filter(file => {
const regex = /\.(js|ts|tsx)$/;
return regex.test(file);
});
files = files.sort((a, b) => {
const regex = /\.i18n\.(ts|js|tsx)$/;
const aIsI18nFile = regex.test(a);
const bIsI18nFile = regex.test(b);
return aIsI18nFile === bIsI18nFile ? 0 : aIsI18nFile ? -1 : 1;
});
files = files.sort((a, b) => a.length - b.length);
files = files.filter(file => readFileSync(file).toString("utf8").includes("createUseI18n"));
if (files.length === 0) {
return [];
}
const extraMessages = files
.map(file => {
const root = recast.parse(readFileSync(file).toString("utf8"), {
"parser": {
"parse": (code: string) => babelParser.parse(code, { "sourceType": "module", "plugins": ["typescript"] }),
"generator": babelGenerate,
"types": babelTypes
}
});
const codes: string[] = [];
recast.visit(root, {
"visitCallExpression": function (path) {
if (path.node.callee.type === "Identifier" && path.node.callee.name === "createUseI18n") {
codes.push(babelGenerate(path.node.arguments[0] as any).code);
}
this.traverse(path);
}
});
return codes;
})
.flat()
.map(code => {
let extraMessages: { [languageTag: string]: Record<string, string> } = {};
try {
eval(`${symToStr({ extraMessages })} = ${code}`);
} catch {
console.warn(
[
"WARNING: Make sure that the first argument of createUseI18n can be evaluated in a javascript",
"runtime where only the node globals are available.",
"This is important because we need to put your i18n messages in messages_*.properties files",
"or they won't be available server side.",
"\n",
"The following code could not be evaluated:",
"\n",
code
].join(" ")
);
}
return extraMessages;
});
const languageTags = extraMessages
.map(extraMessage => Object.keys(extraMessage))
.flat()
.reduce(...removeDuplicates<string>());
const keyValueMapByLanguageTag: Record<string, Record<string, string>> = {};
for (const languageTag of languageTags) {
const keyValueMap: Record<string, string> = {};
for (const extraMessage of extraMessages) {
const keyValueMap_i = extraMessage[languageTag];
if (keyValueMap_i === undefined) {
continue;
}
for (const [key, value] of Object.entries(keyValueMap_i)) {
if (keyValueMap[key] !== undefined) {
console.warn(
[
"WARNING: The following key is defined multiple times:",
"\n",
key,
"\n",
"The following value will be ignored:",
"\n",
value,
"\n",
"The following value was already defined:",
"\n",
keyValueMap[key]
].join(" ")
);
continue;
}
keyValueMap[key] = value;
}
}
keyValueMapByLanguageTag[languageTag] = keyValueMap;
}
const out: { languageTag: string; propertiesFileSource: string }[] = [];
for (const [languageTag, keyValueMap] of Object.entries(keyValueMapByLanguageTag)) {
const propertiesFileSource = Object.entries(keyValueMap)
.map(([key, value]) => `${key}=${escapeString(value)}`)
.join("\n");
out.push({
languageTag,
"propertiesFileSource": ["# This file was generated by keycloakify", "", "parent=base", "", propertiesFileSource, ""].join("\n")
});
}
return out;
}
// Convert a JavaScript string to UTF-16 encoding
function toUTF16(codePoint: number): string {
if (codePoint <= 0xffff) {
// BMP character
return "\\u" + codePoint.toString(16).padStart(4, "0");
} else {
// Non-BMP character
codePoint -= 0x10000;
let highSurrogate = (codePoint >> 10) + 0xd800;
let lowSurrogate = (codePoint % 0x400) + 0xdc00;
return "\\u" + highSurrogate.toString(16).padStart(4, "0") + "\\u" + lowSurrogate.toString(16).padStart(4, "0");
}
}
// Escapes special characters and converts unicode to UTF-16 encoding
function escapeString(str: string): string {
let escapedStr = "";
for (const char of [...str]) {
const codePoint = char.codePointAt(0);
if (!codePoint) continue;
if (char === "'") {
escapedStr += "''"; // double single quotes
} else if (codePoint > 0x7f) {
escapedStr += toUTF16(codePoint); // non-ascii characters
} else {
escapedStr += char;
}
}
return escapedStr;
}

View File

@ -9,55 +9,39 @@ import { isInside } from "../../tools/isInside";
import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import { downloadKeycloakStaticResources } from "./downloadKeycloakStaticResources";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import { generateMessageProperties } from "./generateMessageProperties";
import { readStaticResourcesUsage } from "./readStaticResourcesUsage";
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
export namespace BuildOptionsLike {
export type Common = {
themeName: string;
extraLoginPages?: string[];
extraAccountPages?: string[];
extraThemeProperties?: string[];
isSilent: boolean;
customUserAttributes: string[];
themeVersion: string;
keycloakVersionDefaultAssets: string;
};
export type Standalone = Common & {
isStandalone: true;
urlPathname: string | undefined;
};
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
export namespace ExternalAssets {
export type CommonExternalAssets = Common & {
isStandalone: false;
};
export type SameDomain = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: true;
};
export type DifferentDomains = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: false;
urlOrigin: string;
urlPathname: string | undefined;
};
}
}
export type BuildOptionsLike = {
themeName: string;
extraThemeProperties: string[] | undefined;
themeVersion: string;
keycloakVersionDefaultAssets: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function generateTheme(params: {
projectDirPath: string;
reactAppBuildDirPath: string;
keycloakThemeBuildingDirPath: string;
emailThemeSrcDirPath: string | undefined;
themeSrcDirPath: string;
keycloakifySrcDirPath: string;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
}): Promise<{ doBundlesEmailTemplate: boolean }> {
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, emailThemeSrcDirPath, buildOptions, keycloakifyVersion } = params;
}): Promise<void> {
const {
projectDirPath,
reactAppBuildDirPath,
keycloakThemeBuildingDirPath,
themeSrcDirPath,
keycloakifySrcDirPath,
buildOptions,
keycloakifyVersion
} = params;
const getThemeDirPath = (themeType: ThemeType | "email") =>
pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType);
@ -67,22 +51,25 @@ export async function generateTheme(params: {
let generateFtlFilesCode_glob: ReturnType<typeof generateFtlFilesCodeFactory>["generateFtlFilesCode"] | undefined = undefined;
for (const themeType of themeTypes) {
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
continue;
}
const themeDirPath = getThemeDirPath(themeType);
copy_app_resources_to_theme_path: {
const isFirstPass = themeType.indexOf(themeType) === 0;
if (!isFirstPass && !buildOptions.isStandalone) {
if (!isFirstPass) {
break copy_app_resources_to_theme_path;
}
transformCodebase({
"destDirPath": buildOptions.isStandalone ? pathJoin(themeDirPath, "resources", "build") : reactAppBuildDirPath,
"destDirPath": pathJoin(themeDirPath, "resources", "build"),
"srcDirPath": reactAppBuildDirPath,
"transformSourceCode": ({ filePath, sourceCode }) => {
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
if (
buildOptions.isStandalone &&
isInside({
"dirPath": pathJoin(reactAppBuildDirPath, basenameOfKeycloakDirInPublicDir),
filePath
@ -92,10 +79,6 @@ export async function generateTheme(params: {
}
if (/\.css?$/i.test(filePath)) {
if (!buildOptions.isStandalone) {
return undefined;
}
const { cssGlobalsToDefine, fixedCssCode } = replaceImportsInCssCode({
"cssCode": sourceCode.toString("utf8")
});
@ -115,37 +98,33 @@ export async function generateTheme(params: {
}
if (/\.js?$/i.test(filePath)) {
if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) {
return undefined;
}
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": sourceCode.toString("utf8"),
buildOptions
"jsCode": sourceCode.toString("utf8")
});
return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
}
return buildOptions.isStandalone ? { "modifiedSourceCode": sourceCode } : undefined;
return { "modifiedSourceCode": sourceCode };
}
});
}
const generateFtlFilesCode = (() => {
if (generateFtlFilesCode_glob !== undefined) {
return generateFtlFilesCode_glob;
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
"cssGlobalsToDefine": allCssGlobalsToDefine,
buildOptions,
keycloakifyVersion
});
return generateFtlFilesCode;
})();
const generateFtlFilesCode =
generateFtlFilesCode_glob !== undefined
? generateFtlFilesCode_glob
: generateFtlFilesCodeFactory({
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
"cssGlobalsToDefine": allCssGlobalsToDefine,
buildOptions,
keycloakifyVersion,
themeType,
"fieldNames": readFieldNameUsage({
keycloakifySrcDirPath,
themeSrcDirPath,
themeType
})
}).generateFtlFilesCode;
[
...(() => {
@ -156,14 +135,10 @@ export async function generateTheme(params: {
return accountThemePageIds;
}
})(),
...((() => {
switch (themeType) {
case "login":
return buildOptions.extraLoginPages;
case "account":
return buildOptions.extraAccountPages;
}
})() ?? [])
...readExtraPagesNames({
themeType,
themeSrcDirPath
})
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
@ -172,6 +147,19 @@ export async function generateTheme(params: {
fs.writeFileSync(pathJoin(themeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
});
generateMessageProperties({
themeSrcDirPath,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeDirPath, "messages");
fs.mkdirSync(pathJoin(themeDirPath, "messages"), { "recursive": true });
const propertiesFilePath = pathJoin(messagesDirPath, `messages_${languageTag}.properties`);
fs.writeFileSync(propertiesFilePath, Buffer.from(propertiesFileSource, "utf8"));
});
//TODO: Remove this block we left it for now only for backward compatibility
// we now have a separate script for this
copy_keycloak_resources_to_public: {
@ -182,10 +170,11 @@ export async function generateTheme(params: {
}
await downloadKeycloakStaticResources({
"isSilent": buildOptions.isSilent,
projectDirPath,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
"themeDirPath": keycloakDirInPublicDir,
themeType
themeType,
"usedResources": undefined
});
if (themeType !== themeTypes[0]) {
@ -207,33 +196,33 @@ export async function generateTheme(params: {
}
await downloadKeycloakStaticResources({
"isSilent": buildOptions.isSilent,
projectDirPath,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
themeDirPath,
themeType
themeType,
"usedResources": readStaticResourcesUsage({
keycloakifySrcDirPath,
themeSrcDirPath,
themeType
})
});
fs.writeFileSync(
pathJoin(themeDirPath, "theme.properties"),
Buffer.from(["parent=keycloak", ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8")
Buffer.from([`parent=keycloak`, ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8")
);
}
let doBundlesEmailTemplate: boolean;
email: {
if (emailThemeSrcDirPath === undefined) {
doBundlesEmailTemplate = false;
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
if (!fs.existsSync(emailThemeSrcDirPath)) {
break email;
}
doBundlesEmailTemplate = true;
transformCodebase({
"srcDirPath": emailThemeSrcDirPath,
"destDirPath": getThemeDirPath("email")
});
}
return { doBundlesEmailTemplate };
}

View File

@ -0,0 +1,38 @@
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";
export function readExtraPagesNames(params: { themeSrcDirPath: string; themeType: ThemeType }): string[] {
const { themeSrcDirPath, themeType } = params;
const filePaths = crawl({
"dirPath": pathJoin(themeSrcDirPath, themeType),
"returnedPathsType": "absolute"
}).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
const candidateFilePaths = filePaths.filter(filePath => /kcContext\.[^.]+$/.test(filePath));
if (candidateFilePaths.length === 0) {
candidateFilePaths.push(...filePaths);
}
const extraPages: string[] = [];
for (const candidateFilPath of candidateFilePaths) {
const rawSourceFile = fs.readFileSync(candidateFilPath).toString("utf8");
extraPages.push(...Array.from(rawSourceFile.matchAll(/["']?pageId["']?\s*:\s*["']([^.]+.ftl)["']/g), m => m[1]));
}
return extraPages.reduce(...removeDuplicates<string>()).filter(pageId => {
switch (themeType) {
case "account":
return !id<readonly string[]>(accountThemePageIds).includes(pageId);
case "login":
return !id<readonly string[]>(loginThemePageIds).includes(pageId);
}
});
}

View File

@ -0,0 +1,32 @@
import { crawl } from "../../tools/crawl";
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
import { join as pathJoin } from "path";
import * as fs from "fs";
import type { ThemeType } from "../generateFtl";
/** Assumes the theme type exists */
export function readFieldNameUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): string[] {
const { keycloakifySrcDirPath, themeSrcDirPath, themeType } = params;
const fieldNames: string[] = [];
for (const srcDirPath of [pathJoin(keycloakifySrcDirPath, themeType), pathJoin(themeSrcDirPath, themeType)]) {
const filePaths = crawl({ "dirPath": srcDirPath, "returnedPathsType": "absolute" }).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
for (const filePath of filePaths) {
const rawSourceFile = fs.readFileSync(filePath).toString("utf8");
if (!rawSourceFile.includes("messagesPerField")) {
continue;
}
fieldNames.push(
...Array.from(rawSourceFile.matchAll(/(?:(?:printIfExists)|(?:existsError)|(?:get)|(?:exists))\(\s*["']([^"']+)["']/g), m => m[1])
);
}
}
const out = fieldNames.reduce(...removeDuplicates<string>());
return out;
}

View File

@ -0,0 +1,76 @@
import { crawl } from "../../tools/crawl";
import { join as pathJoin, sep as pathSep } from "path";
import * as fs from "fs";
import type { ThemeType } from "../generateFtl";
/** Assumes the theme type exists */
export function readStaticResourcesUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): {
resourcesCommonFilePaths: string[];
} {
const { keycloakifySrcDirPath, themeSrcDirPath, themeType } = params;
const resourcesCommonFilePaths = new Set<string>();
for (const srcDirPath of [pathJoin(keycloakifySrcDirPath, themeType), pathJoin(themeSrcDirPath, themeType)]) {
const filePaths = crawl({ "dirPath": srcDirPath, "returnedPathsType": "absolute" }).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
for (const filePath of filePaths) {
const rawSourceFile = fs.readFileSync(filePath).toString("utf8");
if (!rawSourceFile.includes("resourcesCommonPath") && !rawSourceFile.includes("resourcesPath")) {
continue;
}
const wrap = readPaths({ rawSourceFile });
wrap.resourcesCommonFilePaths.forEach(filePath => resourcesCommonFilePaths.add(filePath));
}
}
return {
"resourcesCommonFilePaths": Array.from(resourcesCommonFilePaths)
};
}
/** Exported for testing purpose */
export function readPaths(params: { rawSourceFile: string }): {
resourcesCommonFilePaths: string[];
} {
const { rawSourceFile } = params;
const resourcesCommonFilePaths = new Set<string>();
{
const regexp = new RegExp(`resourcesCommonPath\\s*}([^\`]+)\``, "g");
const matches = [...rawSourceFile.matchAll(regexp)];
for (const match of matches) {
const filePath = match[1];
resourcesCommonFilePaths.add(filePath);
}
}
{
const regexp = new RegExp(`resourcesCommonPath\\s*[+,]\\s*["']([^"'\`]+)["'\`]`, "g");
const matches = [...rawSourceFile.matchAll(regexp)];
for (const match of matches) {
const filePath = match[1];
resourcesCommonFilePaths.add(filePath);
}
}
const normalizePath = (filePath: string) => {
filePath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
filePath = filePath.replace(/\//g, pathSep);
return filePath;
};
return {
"resourcesCommonFilePaths": Array.from(resourcesCommonFilePaths).map(normalizePath)
};
}

View File

@ -9,8 +9,9 @@ import { getLogger } from "../tools/logger";
import jar from "../tools/jar";
import { assert } from "tsafe/assert";
import { Equals } from "tsafe";
import { getEmailThemeSrcDirPath } from "../getSrcDirPath";
import { getThemeSrcDirPath } from "../getSrcDirPath";
import { getProjectRoot } from "../tools/getProjectRoot";
import { objectKeys } from "tsafe/objectKeys";
export async function main() {
const projectDirPath = process.cwd();
@ -23,31 +24,49 @@ export async function main() {
const logger = getLogger({ "isSilent": buildOptions.isSilent });
logger.log("🔏 Building the keycloak theme...⌚");
const { doBundlesEmailTemplate } = await generateTheme({
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
"emailThemeSrcDirPath": (() => {
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({ projectDirPath });
const keycloakifyDirPath = getProjectRoot();
if (emailThemeSrcDirPath === undefined || !fs.existsSync(emailThemeSrcDirPath)) {
return;
}
const { themeSrcDirPath } = getThemeSrcDirPath({ projectDirPath });
return emailThemeSrcDirPath;
})(),
"reactAppBuildDirPath": buildOptions.reactAppBuildDirPath,
buildOptions,
"keycloakifyVersion": (() => {
const version = JSON.parse(fs.readFileSync(pathJoin(getProjectRoot(), "package.json")).toString("utf8"))["version"];
for (const themeName of [buildOptions.themeName, ...buildOptions.extraThemeNames]) {
await generateTheme({
projectDirPath,
"keycloakThemeBuildingDirPath": buildOptions.keycloakifyBuildDirPath,
themeSrcDirPath,
"keycloakifySrcDirPath": pathJoin(keycloakifyDirPath, "src"),
"reactAppBuildDirPath": buildOptions.reactAppBuildDirPath,
"buildOptions": {
...buildOptions,
"themeName": themeName
},
"keycloakifyVersion": (() => {
const version = JSON.parse(fs.readFileSync(pathJoin(keycloakifyDirPath, "package.json")).toString("utf8"))["version"];
assert(typeof version === "string");
assert(typeof version === "string");
return version;
})()
});
return version;
})()
});
}
const { jarFilePath } = generateJavaStackFiles({
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
doBundlesEmailTemplate,
"keycloakThemeBuildingDirPath": buildOptions.keycloakifyBuildDirPath,
"implementedThemeTypes": (() => {
const implementedThemeTypes = {
"login": false,
"account": false,
"email": false
};
for (const themeType of objectKeys(implementedThemeTypes)) {
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
continue;
}
implementedThemeTypes[themeType] = true;
}
return implementedThemeTypes;
})(),
buildOptions
});
@ -58,7 +77,7 @@ export async function main() {
case "keycloakify":
logger.log("🫶 Let keycloakify do its thang");
await jar({
"rootPath": pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources"),
"rootPath": buildOptions.keycloakifyBuildDirPath,
"version": buildOptions.themeVersion,
"groupId": buildOptions.groupId,
"artifactId": buildOptions.artifactId,
@ -74,7 +93,7 @@ export async function main() {
}
// We want, however, to test in a container running the latest Keycloak version
const containerKeycloakVersion = "20.0.1";
const containerKeycloakVersion = "21.1.2";
generateStartKeycloakTestingContainer({
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
@ -128,16 +147,18 @@ export async function main() {
``,
`Once your container is up and running: `,
"- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈",
`- Create a realm: myrealm`,
`- Enable registration: Realm settings -> Login tab -> User registration: on`,
`- Enable the Account theme: Realm settings -> Themes tab -> Account theme, select ${buildOptions.themeName} `,
`- Create a client id myclient`,
` Root URL: https://www.keycloak.org/app/`,
` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
` Web origins: *`,
` Login Theme: ${buildOptions.themeName}`,
` Save (button at the bottom of the page)`,
`- Create a realm: Master -> AddRealm -> Name: myrealm`,
`- Enable registration: Realm settings -> Login tab -> User registration: on`,
`- Enable the Account theme (optional): Realm settings -> Themes tab -> Account theme: ${buildOptions.themeName}`,
` Clients -> account -> Login theme: ${buildOptions.themeName}`,
`- Enable the email theme (optional): Realm settings -> Themes tab -> Email theme: ${buildOptions.themeName} (option will appear only if you have ran npx initialize-email-theme)`,
`- Create a client Clients -> Create -> Client ID: myclient`,
` Root URL: https://www.keycloak.org/app/`,
` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
` Web origins: *`,
` Login Theme: ${buildOptions.themeName}`,
` Save (button at the bottom of the page)`,
``,
`- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`,
`- Got to 👉 http://localhost:8080/realms/myrealm/account 👈 to see your account theme`,

View File

@ -11,10 +11,6 @@ export type ParsedPackageJson = {
version?: string;
homepage?: string;
keycloakify?: {
/** @deprecated: use extraLoginPages instead */
extraPages?: string[];
extraLoginPages?: string[];
extraAccountPages?: string[];
extraThemeProperties?: string[];
areAppAndKeycloakServerSharingSameDomain?: boolean;
artifactId?: string;
@ -23,8 +19,8 @@ export type ParsedPackageJson = {
keycloakVersionDefaultAssets?: string;
reactAppBuildDirPath?: string;
keycloakifyBuildDirPath?: string;
customUserAttributes?: string[];
themeName?: string;
extraThemeNames?: string[];
};
};
@ -34,9 +30,6 @@ export const zParsedPackageJson = z.object({
"homepage": z.string().optional(),
"keycloakify": z
.object({
"extraPages": z.array(z.string()).optional(),
"extraLoginPages": z.array(z.string()).optional(),
"extraAccountPages": z.array(z.string()).optional(),
"extraThemeProperties": z.array(z.string()).optional(),
"areAppAndKeycloakServerSharingSameDomain": z.boolean().optional(),
"artifactId": z.string().optional(),
@ -45,8 +38,8 @@ export const zParsedPackageJson = z.object({
"keycloakVersionDefaultAssets": z.string().optional(),
"reactAppBuildDirPath": z.string().optional(),
"keycloakifyBuildDirPath": z.string().optional(),
"customUserAttributes": z.array(z.string()).optional(),
"themeName": z.string().optional()
"themeName": z.string().optional(),
"extraThemeNames": z.array(z.string()).optional()
})
.optional()
});

View File

@ -1,31 +1,6 @@
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import { Reflect } from "tsafe/Reflect";
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
export namespace BuildOptionsLike {
export type Standalone = {
isStandalone: true;
};
export type ExternalAssets = {
isStandalone: false;
urlOrigin: string;
};
}
{
const buildOptions = Reflect<BuildOptions>();
assert(!is<BuildOptions.ExternalAssets.CommonExternalAssets>(buildOptions));
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export function replaceImportsFromStaticInJsCode(params: { jsCode: string; buildOptions: BuildOptionsLike }): { fixedJsCode: string } {
export function replaceImportsFromStaticInJsCode(params: { jsCode: string }): { fixedJsCode: string } {
/*
NOTE:
@ -38,48 +13,38 @@ export function replaceImportsFromStaticInJsCode(params: { jsCode: string; build
will always run in keycloak context.
*/
const { jsCode, buildOptions } = params;
const { jsCode } = params;
const getReplaceArgs = (language: "js" | "css"): Parameters<typeof String.prototype.replace> => [
new RegExp(`([a-zA-Z_]+)\\.([a-zA-Z]+)=function\\(([a-zA-Z]+)\\){return"static\\/${language}\\/"`, "g"),
(...[, n, u, e]) => `
${n}[(function(){
var pd= Object.getOwnPropertyDescriptor(${n}, "p");
new RegExp(`([a-zA-Z_]+)\\.([a-zA-Z]+)=(function\\(([a-z]+)\\){return|([a-z]+)=>)"static\\/${language}\\/"`, "g"),
(...[, n, u, matchedFunction, eForFunction]) => {
const isArrowFunction = matchedFunction.includes("=>");
const e = isArrowFunction ? matchedFunction.replace("=>", "").trim() : eForFunction;
return `
${n}[(function(){
var pd = Object.getOwnPropertyDescriptor(${n}, "p");
if( pd === undefined || pd.configurable ){
${
buildOptions.isStandalone
? `
Object.defineProperty(${n}, "p", {
get: function() { return window.${ftlValuesGlobalName}.url.resourcesPath; },
set: function (){}
});
`
: `
var p= "";
Object.defineProperty(${n}, "p", {
get: function() { return "${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}/" : p; },
set: function (value){ p = value;}
get: function() { return window.${ftlValuesGlobalName}.url.resourcesPath; },
set: function() {}
});
`
}
}
return "${u}";
})()] = function(${e}) { return "${buildOptions.isStandalone ? "/build/" : ""}static/${language}/"`
return "${u}";
})()] = ${isArrowFunction ? `${e} =>` : `function(${e}) { return `} "/build/static/${language}/"`
.replace(/\s+/g, " ")
.trim();
}
];
const fixedJsCode = jsCode
.replace(...getReplaceArgs("js"))
.replace(...getReplaceArgs("css"))
.replace(/([a-zA-Z]+\.[a-zA-Z]+)\+"static\//g, (...[, group]) =>
buildOptions.isStandalone
? `window.${ftlValuesGlobalName}.url.resourcesPath + "/build/static/`
: `("${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}/" : ${group}) + "static/`
)
.replace(/[a-zA-Z]+\.[a-zA-Z]+\+"static\//g, `window.${ftlValuesGlobalName}.url.resourcesPath + "/build/static/`)
//TODO: Write a test case for this
.replace(/".chunk.css",([a-zA-Z])+=([a-zA-Z]+\.[a-zA-Z]+)\+([a-zA-Z]+),/, (...[, group1, group2, group3]) =>
buildOptions.isStandalone
? `".chunk.css",${group1} = window.${ftlValuesGlobalName}.url.resourcesPath + "/build/" + ${group3},`
: `".chunk.css",${group1} = ("${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}/" : ${group2}) + ${group3},`
.replace(
/".chunk.css",([a-zA-Z])+=[a-zA-Z]+\.[a-zA-Z]+\+([a-zA-Z]+),/,
(...[, group1, group2]) => `".chunk.css",${group1} = window.${ftlValuesGlobalName}.url.resourcesPath + "/build/" + ${group2},`
);
return { fixedJsCode };

View File

@ -1,20 +1,12 @@
import * as crypto from "crypto";
import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import { Reflect } from "tsafe/Reflect";
export type BuildOptionsLike = {
urlPathname: string | undefined;
};
{
const buildOptions = Reflect<BuildOptions>();
assert(!is<BuildOptions.ExternalAssets.CommonExternalAssets>(buildOptions));
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function replaceImportsInCssCode(params: { cssCode: string }): {
fixedCssCode: string;

View File

@ -1,32 +1,11 @@
import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import { Reflect } from "tsafe/Reflect";
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
export type BuildOptionsLike = {
urlPathname: string | undefined;
};
export namespace BuildOptionsLike {
export type Common = {
urlPathname: string | undefined;
};
export type Standalone = Common & {
isStandalone: true;
};
export type ExternalAssets = Common & {
isStandalone: false;
urlOrigin: string;
};
}
{
const buildOptions = Reflect<BuildOptions>();
assert(!is<BuildOptions.ExternalAssets.CommonExternalAssets>(buildOptions));
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function replaceImportsInInlineCssCode(params: { cssCode: string; buildOptions: BuildOptionsLike }): {
fixedCssCode: string;
@ -37,10 +16,7 @@ export function replaceImportsInInlineCssCode(params: { cssCode: string; buildOp
buildOptions.urlPathname === undefined
? /url\(["']?\/([^/][^)"']+)["']?\)/g
: new RegExp(`url\\(["']?${buildOptions.urlPathname}([^)"']+)["']?\\)`, "g"),
(...[, group]) =>
`url(${
buildOptions.isStandalone ? "${url.resourcesPath}/build/" + group : buildOptions.urlOrigin + (buildOptions.urlPathname ?? "/") + group
})`
(...[, group]) => `url(\${url.resourcesPath}/build/${group})`
);
return { fixedCssCode };

View File

@ -17,7 +17,7 @@ export async function promptKeycloakVersion() {
return { getLatestsSemVersionedTag };
})();
console.log("Initialize the directory with email template from which keycloak version?");
console.log("Select Keycloak version?");
const tags = [
...(await getLatestsSemVersionedTag({

View File

@ -1,27 +1,32 @@
import * as fs from "fs";
import * as path from "path";
/** List all files in a given directory return paths relative to the dir_path */
export const crawl = (() => {
const crawlRec = (dir_path: string, paths: string[]) => {
for (const file_name of fs.readdirSync(dir_path)) {
const file_path = path.join(dir_path, file_name);
const crawlRec = (dir_path: string, paths: string[]) => {
for (const file_name of fs.readdirSync(dir_path)) {
const file_path = path.join(dir_path, file_name);
if (fs.lstatSync(file_path).isDirectory()) {
crawlRec(file_path, paths);
if (fs.lstatSync(file_path).isDirectory()) {
crawlRec(file_path, paths);
continue;
}
paths.push(file_path);
continue;
}
};
return function crawl(dir_path: string): string[] {
const paths: string[] = [];
paths.push(file_path);
}
};
crawlRec(dir_path, paths);
/** List all files in a given directory return paths relative to the dir_path */
export function crawl(params: { dirPath: string; returnedPathsType: "absolute" | "relative to dirPath" }): string[] {
const { dirPath, returnedPathsType } = params;
return paths.map(file_path => path.relative(dir_path, file_path));
};
})();
const filePaths: string[] = [];
crawlRec(dirPath, filePaths);
switch (returnedPathsType) {
case "absolute":
return filePaths;
case "relative to dirPath":
return filePaths.map(filePath => path.relative(dirPath, filePath));
}
}

View File

@ -1,18 +1,55 @@
import { exec as execCallback } from "child_process";
import { createHash } from "crypto";
import { mkdir, stat, writeFile } from "fs/promises";
import { mkdir, readFile, stat, writeFile, unlink, rm } from "fs/promises";
import fetch, { type FetchOptions } from "make-fetch-happen";
import { dirname as pathDirname, join as pathJoin } from "path";
import { assert } from "tsafe";
import { dirname as pathDirname, join as pathJoin, resolve as pathResolve, sep as pathSep } from "path";
import { assert } from "tsafe/assert";
import { promisify } from "util";
import { getProjectRoot } from "./getProjectRoot";
import { transformCodebase } from "./transformCodebase";
import { unzip } from "./unzip";
import { unzip, zip } from "./unzip";
const exec = promisify(execCallback);
function hash(s: string) {
return createHash("sha256").update(s).digest("hex");
function generateFileNameFromURL(params: {
url: string;
preCacheTransform:
| {
actionCacheId: string;
actionFootprint: string;
}
| undefined;
}): string {
const { preCacheTransform } = params;
// Parse the URL
const url = new URL(params.url);
// Extract pathname and remove leading slashes
let fileName = url.pathname.replace(/^\//, "").replace(/\//g, "_");
// Optionally, add query parameters replacing special characters
if (url.search) {
fileName += url.search.replace(/[&=?]/g, "-");
}
// Replace any characters that are not valid in filenames
fileName = fileName.replace(/[^a-zA-Z0-9-_]/g, "");
// Trim or pad the fileName to a specific length
fileName = fileName.substring(0, 50);
add_pre_cache_transform: {
if (preCacheTransform === undefined) {
break add_pre_cache_transform;
}
// Sanitize actionCacheId the same way as other components
const sanitizedActionCacheId = preCacheTransform.actionCacheId.replace(/[^a-zA-Z0-9-_]/g, "_");
fileName += `_${sanitizedActionCacheId}_${createHash("sha256").update(preCacheTransform.actionFootprint).digest("hex").substring(0, 5)}`;
}
return fileName;
}
async function exists(path: string) {
@ -25,46 +62,135 @@ async function exists(path: string) {
}
}
function ensureArray<T>(arg0: T | T[]) {
return Array.isArray(arg0) ? arg0 : typeof arg0 === "undefined" ? [] : [arg0];
}
function ensureSingleOrNone<T>(arg0: T | T[]) {
if (!Array.isArray(arg0)) return arg0;
if (arg0.length === 0) return undefined;
if (arg0.length === 1) return arg0[0];
throw new Error("Illegal configuration, expected a single value but found multiple: " + arg0.map(String).join(", "));
}
type NPMConfig = Record<string, string | string[]>;
const npmConfigReducer = (cfg: NPMConfig, [key, value]: [string, string]) =>
key in cfg ? { ...cfg, [key]: [...ensureArray(cfg[key]), value] } : { ...cfg, [key]: value };
/**
* Get npm configuration as map
*/
async function getNmpConfig(): Promise<Record<string, string>> {
const { stdout } = await exec("npm config get", { encoding: "utf8" });
async function getNmpConfig() {
return readNpmConfig().then(parseNpmConfig);
}
function readNpmConfig(): Promise<string> {
return (async function callee(depth: number): Promise<string> {
const cwd = pathResolve(pathJoin(...[process.cwd(), ...Array(depth).fill("..")]));
let stdout: string;
try {
stdout = await exec("npm config get", { "encoding": "utf8", cwd }).then(({ stdout }) => stdout);
} catch (error) {
if (String(error).includes("ENOWORKSPACES")) {
assert(cwd !== pathSep);
return callee(depth + 1);
}
throw error;
}
return stdout;
})(0);
}
function parseNpmConfig(stdout: string) {
return stdout
.split("\n")
.filter(line => !line.startsWith(";"))
.map(line => line.trim())
.map(line => line.split("=", 2))
.reduce((cfg, [key, value]) => ({ ...cfg, [key]: value }), {});
.map(line => line.split("=", 2) as [string, string])
.reduce(npmConfigReducer, {} as NPMConfig);
}
function maybeBoolean(arg0: string | undefined) {
return typeof arg0 === "undefined" ? undefined : Boolean(arg0);
}
function chunks<T>(arr: T[], size: number = 2) {
return arr.map((_, i) => i % size == 0 && arr.slice(i, i + size)).filter(Boolean) as T[][];
}
async function readCafile(cafile: string) {
const cafileContent = await readFile(cafile, "utf-8");
return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(ca => ca.join("").replace(/^\n/, "").replace(/\n/g, "\\n"));
}
/**
* Get proxy configuration from npm config files. Note that we don't care about
* Get proxy and ssl configuration from npm config files. Note that we don't care about
* proxy config in env vars, because make-fetch-happen will do that for us.
*
* @returns proxy configuration
*/
async function getNpmProxyConfig(): Promise<Pick<FetchOptions, "proxy" | "noProxy">> {
async function getFetchOptions(): Promise<Pick<FetchOptions, "proxy" | "noProxy" | "strictSSL" | "ca" | "cert">> {
const cfg = await getNmpConfig();
const proxy = cfg["https-proxy"] ?? cfg["proxy"];
const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]);
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
const strictSSL = maybeBoolean(ensureSingleOrNone(cfg["strict-ssl"]));
const cert = cfg["cert"];
const ca = ensureArray(cfg["ca"] ?? cfg["ca[]"]);
const cafile = ensureSingleOrNone(cfg["cafile"]);
return { proxy, noProxy };
if (typeof cafile !== "undefined" && cafile !== "null") ca.push(...(await readCafile(cafile)));
return { proxy, noProxy, strictSSL, cert, ca: ca.length === 0 ? undefined : ca };
}
export async function downloadAndUnzip(params: { url: string; destDirPath: string; pathOfDirToExtractInArchive?: string }) {
const { url, destDirPath, pathOfDirToExtractInArchive } = params;
export async function downloadAndUnzip(
params: {
url: string;
destDirPath: string;
specificDirsToExtract?: string[];
preCacheTransform?: {
actionCacheId: string;
action: (params: { destDirPath: string }) => Promise<void>;
};
} & (
| {
doUseCache: true;
projectDirPath: string;
}
| {
doUseCache: false;
}
)
) {
const { url, destDirPath, specificDirsToExtract, preCacheTransform, ...rest } = params;
const downloadHash = hash(JSON.stringify({ url })).substring(0, 15);
const projectRoot = getProjectRoot();
const cacheRoot = process.env.XDG_CACHE_HOME ?? pathJoin(projectRoot, "node_modules", ".cache");
const zipFilePath = pathJoin(cacheRoot, "keycloakify", "zip", `_${downloadHash}.zip`);
const extractDirPath = pathJoin(cacheRoot, "keycloakify", "unzip", `_${downloadHash}`);
const zipFileBasename = generateFileNameFromURL({
url,
"preCacheTransform":
preCacheTransform === undefined
? undefined
: {
"actionCacheId": preCacheTransform.actionCacheId,
"actionFootprint": preCacheTransform.action.toString()
}
});
const cacheRoot = !rest.doUseCache
? `tmp_${Math.random().toString().slice(2, 12)}`
: pathJoin(process.env.XDG_CACHE_HOME ?? pathJoin(rest.projectDirPath, "node_modules", ".cache"), "keycloakify");
const zipFilePath = pathJoin(cacheRoot, `${zipFileBasename}.zip`);
const extractDirPath = pathJoin(cacheRoot, `tmp_unzip_${zipFileBasename}`);
if (!(await exists(zipFilePath))) {
const proxyOpts = await getNpmProxyConfig();
const response = await fetch(url, proxyOpts);
const opts = await getFetchOptions();
const response = await fetch(url, opts);
await mkdir(pathDirname(zipFilePath), { "recursive": true });
/**
* The correct way to fix this is to upgrade node-fetch beyond 3.2.5
@ -76,12 +202,32 @@ export async function downloadAndUnzip(params: { url: string; destDirPath: strin
response.body?.setMaxListeners(Number.MAX_VALUE);
assert(typeof response.body !== "undefined" && response.body != null);
await writeFile(zipFilePath, response.body);
if (specificDirsToExtract !== undefined || preCacheTransform !== undefined) {
await unzip(zipFilePath, extractDirPath, specificDirsToExtract);
await preCacheTransform?.action({
"destDirPath": extractDirPath
});
await unlink(zipFilePath);
await zip(extractDirPath, zipFilePath);
await rm(extractDirPath, { "recursive": true });
}
}
await unzip(zipFilePath, extractDirPath, pathOfDirToExtractInArchive);
await unzip(zipFilePath, extractDirPath);
transformCodebase({
"srcDirPath": extractDirPath,
"destDirPath": destDirPath
});
if (!rest.doUseCache) {
await rm(cacheRoot, { "recursive": true });
} else {
await rm(extractDirPath, { "recursive": true });
}
}

View File

@ -1,4 +1,4 @@
import { dirname, relative, sep } from "path";
import { dirname, relative, sep, join } from "path";
import { createWriteStream } from "fs";
import walk from "./walk";
@ -48,8 +48,12 @@ export async function jarStream({ groupId, artifactId, version, asyncPathGenerat
for await (const entry of asyncPathGeneratorFn()) {
if ("buffer" in entry) {
zipFile.addBuffer(entry.buffer, entry.zipPath);
} else if ("fsPath" in entry && !entry.fsPath.endsWith(sep)) {
zipFile.addFile(entry.fsPath, entry.zipPath);
} else if ("fsPath" in entry) {
if (entry.fsPath.endsWith(sep)) {
zipFile.addEmptyDirectory(entry.zipPath);
} else {
zipFile.addFile(entry.fsPath, entry.zipPath);
}
}
}
@ -65,15 +69,23 @@ export async function jarStream({ groupId, artifactId, version, asyncPathGenerat
* Create a jar archive, using the resources found at `rootPath` (a directory) and write the
* archive to `targetPath` (a file). Use `groupId`, `artifactId` and `version` to define
* the contents of the pom.properties file which is going to be added to the archive.
* The root directory is expectedto have a conventional maven/gradle folder structure with a
* single `pom.xml` file at the root and a `src/main/resources` directory containing all
* application resources.
*/
export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) {
await mkdir(dirname(targetPath), { recursive: true });
const asyncPathGeneratorFn = async function* (): ZipEntryGenerator {
for await (const fsPath of walk(rootPath)) {
const zipPath = relative(rootPath, fsPath).split(sep).join("/");
const resourcesPath = join(rootPath, "src", "main", "resources");
for await (const fsPath of walk(resourcesPath)) {
const zipPath = relative(resourcesPath, fsPath).split(sep).join("/");
yield { fsPath, zipPath };
}
yield {
fsPath: join(rootPath, "pom.xml"),
zipPath: `META-INF/maven/${groupId}/${artifactId}/pom.xml`
};
};
const zipFile = await jarStream({ groupId, artifactId, version, asyncPathGeneratorFn });

View File

@ -3,7 +3,7 @@ import * as path from "path";
import { crawl } from "./crawl";
import { id } from "tsafe/id";
type TransformSourceCode = (params: { sourceCode: Buffer; filePath: string }) =>
type TransformSourceCode = (params: { sourceCode: Buffer; filePath: string; fileRelativePath: string }) =>
| {
modifiedSourceCode: Buffer;
newFileName?: string;
@ -20,26 +20,27 @@ export function transformCodebase(params: { srcDirPath: string; destDirPath: str
}))
} = params;
for (const file_relative_path of crawl(srcDirPath)) {
const filePath = path.join(srcDirPath, file_relative_path);
for (const fileRelativePath of crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })) {
const filePath = path.join(srcDirPath, fileRelativePath);
const transformSourceCodeResult = transformSourceCode({
"sourceCode": fs.readFileSync(filePath),
"filePath": path.join(srcDirPath, file_relative_path)
filePath,
fileRelativePath
});
if (transformSourceCodeResult === undefined) {
continue;
}
fs.mkdirSync(path.dirname(path.join(destDirPath, file_relative_path)), {
fs.mkdirSync(path.dirname(path.join(destDirPath, fileRelativePath)), {
"recursive": true
});
const { newFileName, modifiedSourceCode } = transformSourceCodeResult;
fs.writeFileSync(
path.join(path.dirname(path.join(destDirPath, file_relative_path)), newFileName ?? path.basename(file_relative_path)),
path.join(path.dirname(path.join(destDirPath, fileRelativePath)), newFileName ?? path.basename(fileRelativePath)),
modifiedSourceCode
);
}

View File

@ -9,7 +9,7 @@ function populateTemplate(strings: TemplateStringsArray, ...args: unknown[]) {
if (strings[i]) {
chunks.push(strings[i]);
// remember last indent of the string portion
lastStringLineLength = strings[i].split("\n").at(-1)?.length ?? 0;
lastStringLineLength = strings[i].split("\n").slice(-1)[0]?.length ?? 0;
}
if (args[i]) {
// if the interpolation value has newlines, indent the interpolation values

View File

@ -2,6 +2,7 @@ import fsp from "node:fs/promises";
import fs from "fs";
import path from "node:path";
import yauzl from "yauzl";
import yazl from "yazl";
import stream from "node:stream";
import { promisify } from "node:util";
@ -19,11 +20,16 @@ async function pathExists(path: string) {
}
}
export async function unzip(file: string, targetFolder: string, unzipSubPath?: string) {
// add trailing slash to unzipSubPath and targetFolder
if (unzipSubPath && (!unzipSubPath.endsWith("/") || !unzipSubPath.endsWith("\\"))) {
unzipSubPath += "/";
}
// Handlings of non posix path is not implemented correctly
// it work by coincidence. Don't have the time to fix but it should be fixed.
export async function unzip(file: string, targetFolder: string, specificDirsToExtract?: string[]) {
specificDirsToExtract = specificDirsToExtract?.map(dirPath => {
if (!dirPath.endsWith("/") || !dirPath.endsWith("\\")) {
dirPath += "/";
}
return dirPath;
});
if (!targetFolder.endsWith("/") || !targetFolder.endsWith("\\")) {
targetFolder += "/";
@ -42,15 +48,17 @@ export async function unzip(file: string, targetFolder: string, unzipSubPath?: s
zipfile.readEntry();
zipfile.on("entry", async entry => {
if (unzipSubPath) {
if (specificDirsToExtract !== undefined) {
const dirPath = specificDirsToExtract.find(dirPath => entry.fileName.startsWith(dirPath));
// Skip files outside of the unzipSubPath
if (!entry.fileName.startsWith(unzipSubPath)) {
if (dirPath === undefined) {
zipfile.readEntry();
return;
}
// Remove the unzipSubPath from the file name
entry.fileName = entry.fileName.substring(unzipSubPath.length);
entry.fileName = entry.fileName.substring(dirPath.length);
}
const target = path.join(targetFolder, entry.fileName);
@ -77,6 +85,8 @@ export async function unzip(file: string, targetFolder: string, unzipSubPath?: s
return;
}
await fsp.mkdir(path.dirname(target), { "recursive": true });
await pipeline(readStream, fs.createWriteStream(target));
zipfile.readEntry();
@ -90,3 +100,42 @@ export async function unzip(file: string, targetFolder: string, unzipSubPath?: s
});
});
}
// NOTE: This code was directly copied from ChatGPT and appears to function as expected.
// However, confidence in its complete accuracy and robustness is limited.
export async function zip(sourceFolder: string, targetZip: string) {
return new Promise<void>(async (resolve, reject) => {
const zipfile = new yazl.ZipFile();
const files: string[] = [];
// Recursive function to explore directories and their subdirectories
async function exploreDir(dir: string) {
const dirContent = await fsp.readdir(dir);
for (const file of dirContent) {
const filePath = path.join(dir, file);
const stat = await fsp.stat(filePath);
if (stat.isDirectory()) {
await exploreDir(filePath);
} else if (stat.isFile()) {
files.push(filePath);
}
}
}
// Collecting all files to be zipped
await exploreDir(sourceFolder);
// Adding files to zip
for (const file of files) {
const relativePath = path.relative(sourceFolder, file);
zipfile.addFile(file, relativePath);
}
zipfile.outputStream
.pipe(fs.createWriteStream(targetZip))
.on("close", () => resolve())
.on("error", err => reject(err)); // Listen to error events
zipfile.end();
});
}

View File

@ -1,21 +1,15 @@
import { useReducer, useEffect } from "react";
import { headInsert } from "keycloakify/tools/headInsert";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { clsx } from "keycloakify/tools/clsx";
export function usePrepareTemplate(params: {
doFetchDefaultThemeResources: boolean;
stylesCommon?: string[];
styles?: string[];
scripts?: string[];
url: {
resourcesCommonPath: string;
resourcesPath: string;
};
htmlClassName: string | undefined;
bodyClassName: string | undefined;
}) {
const { doFetchDefaultThemeResources, stylesCommon = [], styles = [], url, scripts = [], htmlClassName, bodyClassName } = params;
const { doFetchDefaultThemeResources, styles = [], scripts = [], htmlClassName, bodyClassName } = params;
const [isReady, setReady] = useReducer(() => true, !doFetchDefaultThemeResources);
@ -29,38 +23,30 @@ export function usePrepareTemplate(params: {
const removeArray: (() => void)[] = [];
(async () => {
const prLoadedArray: Promise<void>[] = [];
[
...stylesCommon.map(relativePath => pathJoin(url.resourcesCommonPath, relativePath)),
...styles.map(relativePath => pathJoin(url.resourcesPath, relativePath))
]
.reverse()
.forEach(href => {
const { prLoaded, remove } = headInsert({
"type": "css",
"position": "prepend",
href
});
removeArray.push(remove);
prLoadedArray.push(prLoaded);
for (const style of [...styles].reverse()) {
const { prLoaded, remove } = headInsert({
"type": "css",
"position": "prepend",
"href": style
});
await Promise.all(prLoadedArray);
removeArray.push(remove);
if (isUnmounted) {
return;
// TODO: Find a way to do that in parallel (without breaking the order)
await prLoaded;
if (isUnmounted) {
return;
}
}
setReady();
})();
scripts.forEach(relativePath => {
scripts.forEach(src => {
const { remove } = headInsert({
"type": "javascript",
"src": pathJoin(url.resourcesPath, relativePath)
src
});
removeArray.push(remove);

View File

@ -12,6 +12,8 @@ const Error = lazy(() => import("keycloakify/login/pages/Error"));
const LoginResetPassword = lazy(() => import("keycloakify/login/pages/LoginResetPassword"));
const LoginVerifyEmail = lazy(() => import("keycloakify/login/pages/LoginVerifyEmail"));
const Terms = lazy(() => import("keycloakify/login/pages/Terms"));
const LoginDeviceVerifyUserCode = lazy(() => import("keycloakify/login/pages/LoginDeviceVerifyUserCode"));
const LoginOauthGrant = lazy(() => import("keycloakify/login/pages/LoginOauthGrant"));
const LoginOtp = lazy(() => import("keycloakify/login/pages/LoginOtp"));
const LoginPassword = lazy(() => import("keycloakify/login/pages/LoginPassword"));
const LoginUsername = lazy(() => import("keycloakify/login/pages/LoginUsername"));
@ -27,6 +29,7 @@ const UpdateUserProfile = lazy(() => import("keycloakify/login/pages/UpdateUserP
const IdpReviewUserProfile = lazy(() => import("keycloakify/login/pages/IdpReviewUserProfile"));
const UpdateEmail = lazy(() => import("keycloakify/login/pages/UpdateEmail"));
const SelectAuthenticator = lazy(() => import("keycloakify/login/pages/SelectAuthenticator"));
const SamlPostForm = lazy(() => import("keycloakify/login/pages/SamlPostForm"));
export default function Fallback(props: PageProps<KcContext, I18n>) {
const { kcContext, ...rest } = props;
@ -51,6 +54,10 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
return <LoginVerifyEmail kcContext={kcContext} {...rest} />;
case "terms.ftl":
return <Terms kcContext={kcContext} {...rest} />;
case "login-oauth2-device-verify-user-code.ftl":
return <LoginDeviceVerifyUserCode kcContext={kcContext} {...rest} />;
case "login-oauth-grant.ftl":
return <LoginOauthGrant kcContext={kcContext} {...rest} />;
case "login-otp.ftl":
return <LoginOtp kcContext={kcContext} {...rest} />;
case "login-username.ftl":
@ -81,6 +88,8 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
return <UpdateEmail kcContext={kcContext} {...rest} />;
case "select-authenticator.ftl":
return <SelectAuthenticator kcContext={kcContext} {...rest} />;
case "saml-post-form.ftl":
return <SamlPostForm kcContext={kcContext} {...rest} />;
}
assert<Equals<typeof kcContext, never>>(false);
})()}

View File

@ -31,15 +31,14 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { isReady } = usePrepareTemplate({
"doFetchDefaultThemeResources": doUseDefaultCss,
url,
"stylesCommon": [
"node_modules/patternfly/dist/css/patternfly.min.css",
"node_modules/patternfly/dist/css/patternfly-additions.min.css",
"lib/zocial/zocial.css"
"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`
],
"styles": ["css/login.css"],
"htmlClassName": getClassName("kcHtmlClass"),
"bodyClassName": undefined
"bodyClassName": getClassName("kcBodyClass")
});
if (!isReady) {

View File

@ -21,6 +21,7 @@ export type TemplateProps<KcContext extends KcContext.Common, I18nExtended exten
};
export type ClassKey =
| "kcBodyClass"
| "kcHtmlClass"
| "kcLoginClass"
| "kcHeaderClass"
@ -93,4 +94,5 @@ export type ClassKey =
| "kcSelectOTPListItemClass"
| "kcAuthenticatorOtpCircleClass"
| "kcSelectOTPItemHeadingClass"
| "kcFormOptionsWrapperClass";
| "kcFormOptionsWrapperClass"
| "kcFormButtonsWrapperClass";

View File

@ -211,7 +211,8 @@ const keycloakifyExtraMessages = {
"shouldBeDifferent": "{0} should be different to {1}",
"shouldMatchPattern": "Pattern should match: `/{0}/`",
"mustBeAnInteger": "Must be an integer",
"notAValidOption": "Not a valid option"
"notAValidOption": "Not a valid option",
"selectAnOption": "Select an option"
},
"fr": {
/* spell-checker: disable */
@ -223,7 +224,8 @@ const keycloakifyExtraMessages = {
"logoutConfirmTitle": "Déconnexion",
"logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
"doLogout": "Se déconnecter"
"doLogout": "Se déconnecter",
"selectAnOption": "Sélectionner une option"
/* spell-checker: enable */
}
};

View File

@ -5,6 +5,7 @@ export default Fallback;
export { useDownloadTerms } from "keycloakify/login/lib/useDownloadTerms";
export { getKcContext } from "keycloakify/login/kcContext/getKcContext";
export { createGetKcContext } from "keycloakify/login/kcContext/createGetKcContext";
export type { LoginThemePageId as PageId } from "keycloakify/bin/keycloakify/generateFtl";
export { createUseI18n } from "keycloakify/login/i18n/i18n";
export type { PageProps } from "keycloakify/login/pages/PageProps";

View File

@ -1,4 +1,4 @@
import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
import type { LoginThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import type { MessageKey } from "../i18n/i18n";
@ -18,6 +18,8 @@ export type KcContext =
| KcContext.LoginResetPassword
| KcContext.LoginVerifyEmail
| KcContext.Terms
| KcContext.LoginDeviceVerifyUserCode
| KcContext.LoginOauthGrant
| KcContext.LoginOtp
| KcContext.LoginUsername
| KcContext.WebauthnAuthenticate
@ -32,11 +34,15 @@ export type KcContext =
| KcContext.UpdateUserProfile
| KcContext.IdpReviewUserProfile
| KcContext.UpdateEmail
| KcContext.SelectAuthenticator;
| KcContext.SelectAuthenticator
| KcContext.SamlPostForm;
export declare namespace KcContext {
export type Common = {
themeVersion: string;
keycloakifyVersion: string;
themeType: "login";
themeName: string;
url: {
loginAction: string;
resourcesPath: string;
@ -78,13 +84,48 @@ export declare namespace KcContext {
};
isAppInitiatedAction: boolean;
messagesPerField: {
printIfExists: <T>(fieldName: string, x: T) => T | undefined;
/**
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.
*
* @param fieldName to check for
* @param text to return
* @return text if message exists for given field, else undefined
*/
printIfExists: <T extends string>(fieldName: string, text: T) => T | undefined;
/**
* Check if exists error message for given fields
*
* @param fields
* @return boolean
*/
existsError: (fieldName: string) => boolean;
/**
* Get message for given field.
*
* @param fieldName
* @return message text or empty string
*/
get: (fieldName: string) => string;
/**
* Check if message for given field exists
*
* @param field
* @return boolean
*/
exists: (fieldName: string) => boolean;
};
};
export type SamlPostForm = Common & {
pageId: "saml-post-form.ftl";
samlPost: {
url: string;
SAMLRequest?: string;
SAMLResponse?: string;
relayState?: string;
};
};
export type Login = Common & {
pageId: "login.ftl";
url: {
@ -107,7 +148,7 @@ export declare namespace KcContext {
rememberMe?: string;
password?: string;
};
usernameEditDisabled: boolean;
usernameHidden?: boolean;
social: {
displayInfo: boolean;
providers?: {
@ -203,6 +244,27 @@ export declare namespace KcContext {
pageId: "terms.ftl";
};
export type LoginDeviceVerifyUserCode = Common & {
pageId: "login-oauth2-device-verify-user-code.ftl";
url: {
oauth2DeviceVerificationAction: string;
};
};
export type LoginOauthGrant = Common & {
pageId: "login-oauth-grant.ftl";
oauth: {
code: string;
client: string;
clientScopesRequested: {
consentScreenText: string;
}[];
};
url: {
oauthAction: string;
};
};
export type LoginOtp = Common & {
pageId: "login-otp.ftl";
otpLogin: {
@ -543,4 +605,15 @@ export declare namespace Validators {
};
}
assert<Equals<KcContext["pageId"], LoginThemePageId>>();
{
type Got = KcContext["pageId"];
type Expected = LoginThemePageId;
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>();

View File

@ -11,7 +11,6 @@ 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 { loginThemePageIds } from "keycloakify/bin/keycloakify/generateFtl/pageId";
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
@ -145,7 +144,7 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
return { "kcContext": undefined as any };
}
if (id<readonly string[]>(loginThemePageIds).indexOf(realKcContext.pageId) < 0 && !("login" in realKcContext)) {
if (realKcContext.themeType !== "login") {
return { "kcContext": undefined as any };
}

View File

@ -3,8 +3,10 @@ import type { KcContext, Attribute } from "./KcContext";
import { resourcesCommonDirPathRelativeToPublicDir, resourcesDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { id } from "tsafe/id";
import { assert, type Equals } from "tsafe/assert";
import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
const PUBLIC_URL = (typeof process !== "object" ? undefined : process.env?.["PUBLIC_URL"]) || "/";
const attributes: Attribute[] = [
{
@ -101,7 +103,10 @@ const attributes: Attribute[] = [
const attributesByName = Object.fromEntries(attributes.map(attribute => [attribute.name, attribute])) as any;
export const kcContextCommonMock: KcContext.Common = {
"themeVersion": "0.0.0",
"keycloakifyVersion": "0.0.0",
"themeType": "login",
"themeName": "my-theme-name",
"url": {
"loginAction": "#",
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
@ -230,20 +235,18 @@ export const kcContextCommonMock: KcContext.Common = {
"clientId": "myApp"
},
"scripts": [],
"message": {
"type": "success",
"summary": "This is a test message"
},
"isAppInitiatedAction": false
};
const loginUrl = {
...kcContextCommonMock.url,
"loginResetCredentialsUrl": "/auth/realms/myrealm/login-actions/reset-credentials?client_id=account&tab_id=HoAx28ja4xg",
"registrationUrl": "/auth/realms/myrealm/login-actions/registration?client_id=account&tab_id=HoAx28ja4xg"
"registrationUrl": "/auth/realms/myrealm/login-actions/registration?client_id=account&tab_id=HoAx28ja4xg",
"oauth2DeviceVerificationAction": "/auth/realms/myrealm/device",
"oauthAction": "/auth/realms/myrealm/login-actions/consent?client_id=account&tab_id=HoAx28ja4xg"
};
export const kcContextMocks: KcContext[] = [
export const kcContextMocks = [
id<KcContext.Login>({
...kcContextCommonMock,
"pageId": "login.ftl",
@ -260,7 +263,7 @@ export const kcContextMocks: KcContext[] = [
"social": {
"displayInfo": true
},
"usernameEditDisabled": false,
"usernameHidden": false,
"login": {},
"registrationDisabled": false
}),
@ -344,6 +347,25 @@ export const kcContextMocks: KcContext[] = [
...kcContextCommonMock,
"pageId": "terms.ftl"
}),
id<KcContext.LoginDeviceVerifyUserCode>({
...kcContextCommonMock,
"pageId": "login-oauth2-device-verify-user-code.ftl",
url: loginUrl
}),
id<KcContext.LoginOauthGrant>({
...kcContextCommonMock,
"pageId": "login-oauth-grant.ftl",
oauth: {
code: "5-1N4CIzfi1aprIQjmylI-9e3spLCWW9i5d-GDcs-Sw",
clientScopesRequested: [
{ consentScreenText: "${profileScopeConsentText}" },
{ consentScreenText: "${rolesScopeConsentText}" },
{ consentScreenText: "${emailScopeConsentText}" }
],
client: "account"
},
url: loginUrl
}),
id<KcContext.LoginOtp>({
...kcContextCommonMock,
"pageId": "login-otp.ftl",
@ -519,5 +541,27 @@ export const kcContextMocks: KcContext[] = [
}
]
}
}),
id<KcContext.SamlPostForm>({
...kcContextCommonMock,
pageId: "saml-post-form.ftl",
"samlPost": {
"url": ""
}
}),
id<KcContext.LoginPageExpired>({
...kcContextCommonMock,
pageId: "login-page-expired.ftl"
})
];
{
type Got = (typeof kcContextMocks)[number]["pageId"];
type Expected = LoginThemePageId;
type OnlyInGot = Exclude<Got, Expected>;
type OnlyInExpected = Exclude<Expected, Got>;
assert<Equals<OnlyInGot, never>>();
assert<Equals<OnlyInExpected, never>>();
}

View File

@ -25,16 +25,7 @@ export function useFormValidation(params: {
passwordValidators?: Validators;
i18n: I18n;
}) {
const {
kcContext,
passwordValidators = {
"length": {
"ignore.empty.value": true,
"min": "4"
}
},
i18n
} = params;
const { kcContext, passwordValidators = {}, i18n } = params;
const attributesWithPassword = useMemo(
() =>
@ -211,7 +202,7 @@ function useGetErrors(params: {
const { value: defaultValue, validators } = attributes.find(attribute => attribute.name === name)!;
block: {
if (defaultValue !== value) {
if ((defaultValue ?? "") !== value) {
break block;
}

View File

@ -3,6 +3,7 @@ import type { ClassKey } from "keycloakify/login/TemplateProps";
export const { useGetClassName } = createUseClassName<ClassKey>({
"defaultClasses": {
"kcBodyClass": undefined,
"kcHtmlClass": "login-pf",
"kcLoginClass": "login-pf-page",
"kcContentWrapperClass": "row",
@ -44,6 +45,7 @@ export const { useGetClassName } = createUseClassName<ClassKey>({
"kcInputClass": "form-control",
"kcInputErrorMessageClass": "pf-c-form__helper-text pf-m-error required kc-feedback-text",
"kcInputWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcFormButtonsWrapperClass": undefined,
"kcFormOptionsClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcFormButtonsClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcFormSettingClass": "login-pf-settings",

View File

@ -8,7 +8,10 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
const { msgStr, msg } = i18n;
assert(kcContext.message !== undefined);
assert(
kcContext.message !== undefined,
"No message in kcContext.message, there will always be a message in production context, add it in your mock"
);
const { messageHeader, message, requiredActions, skipLink, pageRedirectUri, actionUri, client } = kcContext;

View File

@ -14,7 +14,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
classes
});
const { social, realm, url, usernameEditDisabled, login, auth, registrationDisabled } = kcContext;
const { social, realm, url, usernameHidden, login, auth, registrationDisabled } = kcContext;
const { msg, msgStr } = i18n;
@ -66,40 +66,37 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
{realm.password && (
<form id="kc-form-login" onSubmit={onSubmit} action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
{(() => {
const label = !realm.loginWithEmailAllowed
? "username"
: realm.registrationEmailAsUsername
? "email"
: "usernameOrEmail";
{!usernameHidden &&
(() => {
const label = !realm.loginWithEmailAllowed
? "username"
: realm.registrationEmailAsUsername
? "email"
: "usernameOrEmail";
const autoCompleteHelper: typeof label = label === "usernameOrEmail" ? "username" : label;
const autoCompleteHelper: typeof label = label === "usernameOrEmail" ? "username" : label;
return (
<>
<label htmlFor={autoCompleteHelper} className={getClassName("kcLabelClass")}>
{msg(label)}
</label>
<input
tabIndex={1}
id={autoCompleteHelper}
className={getClassName("kcInputClass")}
//NOTE: This is used by Google Chrome auto fill so we use it to tell
//the browser how to pre fill the form but before submit we put it back
//to username because it is what keycloak expects.
name={autoCompleteHelper}
defaultValue={login.username ?? ""}
type="text"
{...(usernameEditDisabled
? { "disabled": true }
: {
"autoFocus": true,
"autoComplete": "off"
})}
/>
</>
);
})()}
return (
<>
<label htmlFor={autoCompleteHelper} className={getClassName("kcLabelClass")}>
{msg(label)}
</label>
<input
tabIndex={1}
id={autoCompleteHelper}
className={getClassName("kcInputClass")}
//NOTE: This is used by Google Chrome auto fill so we use it to tell
//the browser how to pre fill the form but before submit we put it back
//to username because it is what keycloak expects.
name={autoCompleteHelper}
defaultValue={login.username ?? ""}
type="text"
autoFocus={true}
autoComplete="off"
/>
</>
);
})()}
</div>
<div className={getClassName("kcFormGroupClass")}>
<label htmlFor="password" className={getClassName("kcLabelClass")}>
@ -116,7 +113,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
</div>
<div className={clsx(getClassName("kcFormGroupClass"), getClassName("kcFormSettingClass"))}>
<div id="kc-form-options">
{realm.rememberMe && !usernameEditDisabled && (
{realm.rememberMe && !usernameHidden && (
<div className="checkbox">
<label>
<input

View File

@ -32,7 +32,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
<ul id="kc-totp-supported-apps">
{totp.supportedApplications.map(app => (
<li>{msgStr(app as MessageKey, app)}</li>
<li>{msg(app as MessageKey)}</li>
))}
</ul>
</li>

View File

@ -0,0 +1,68 @@
import { clsx } from "keycloakify/tools/clsx";
import Template from "../Template";
import { I18n } from "../i18n";
import { KcContext } from "../kcContext";
import { useGetClassName } from "../lib/useGetClassName";
import { PageProps } from "./PageProps";
export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pageId: "login-oauth2-device-verify-user-code.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes } = props;
const { url } = kcContext;
const { msg, msgStr } = i18n;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("oauth2DeviceVerificationTitle")}>
<form
id="kc-user-verify-device-user-code-form"
className={getClassName("kcFormClass")}
action={url.oauth2DeviceVerificationAction}
method="post"
>
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="device-user-code" className={getClassName("kcLabelClass")}>
{msg("verifyOAuth2DeviceUserCode")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
id="device-user-code"
name="device_user_code"
autoComplete="off"
type="text"
className={getClassName("kcInputClass")}
autoFocus
/>
</div>
</div>
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}></div>
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<div className={getClassName("kcFormButtonsWrapperClass")}>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
value={msgStr("doSubmit")}
/>
</div>
</div>
</div>
</form>
</Template>
);
}

View File

@ -0,0 +1,73 @@
import { clsx } from "keycloakify/tools/clsx";
import { PageProps } from "./PageProps";
import { KcContext } from "../kcContext";
import { I18n } from "../i18n";
import Template from "../Template";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pageId: "login-oauth-grant.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes } = props;
const { url, oauth, client } = kcContext;
const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
headerNode={msg("oauthGrantTitle", client.name ? advancedMsgStr(client.name) : client.clientId)}
>
<div id="kc-oauth" className="content-area">
<h3>{msg("oauthGrantRequest")}</h3>
<ul>
{oauth.clientScopesRequested.map(clientScope => (
<li key={clientScope.consentScreenText}>
<span>{advancedMsg(clientScope.consentScreenText)}</span>
</li>
))}
</ul>
<form className="form-actions" action={url.oauthAction} method="POST">
<input type="hidden" name="code" value={oauth.code} />
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options">
<div className={getClassName("kcFormOptionsWrapperClass")}></div>
</div>
<div id="kc-form-buttons">
<div className={getClassName("kcFormButtonsWrapperClass")}>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
name="accept"
id="kc-login"
type="submit"
value={msgStr("doYes")}
/>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass")
)}
name="cancel"
id="kc-cancel"
type="submit"
value={msgStr("doNo")}
/>
</div>
</div>
</div>
</form>
<div className="clearfix"></div>
</div>
</Template>
);
}

View File

@ -1,6 +1,5 @@
import { useEffect } from "react";
import { headInsert } from "keycloakify/tools/headInsert";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
@ -24,7 +23,7 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
const { prLoaded, remove } = headInsert({
"type": "javascript",
"src": pathJoin(kcContext.url.resourcesCommonPath, "node_modules/jquery/dist/jquery.min.js")
"src": `${kcContext.url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js`
});
(async () => {

View File

@ -123,7 +123,7 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
getClassName("kcButtonLargeClass")
)}
type="submit"
defaultValue={msgStr("doSubmit")}
value={msgStr("doSubmit")}
/>
)}
</div>

View File

@ -11,7 +11,7 @@ export default function LoginVerifyEmail(props: PageProps<Extract<KcContext, { p
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("emailVerifyTitle")}>
<p className="instruction">{msg("emailVerifyInstruction1", user?.email)}</p>
<p className="instruction">{msg("emailVerifyInstruction1", user?.email ?? "")}</p>
<p className="instruction">
{msg("emailVerifyInstruction2")}
<br />

View File

@ -14,11 +14,13 @@ export default function RegisterUserProfile(props: PageProps<Extract<KcContext,
classes
});
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey } = kcContext;
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey, realm } = kcContext;
realm.registrationEmailAsUsername;
const { msg, msgStr } = i18n;
const [isFomSubmittable, setIsFomSubmittable] = useState(false);
const [isFormSubmittable, setIsFormSubmittable] = useState(false);
return (
<Template
@ -30,7 +32,7 @@ export default function RegisterUserProfile(props: PageProps<Extract<KcContext,
<form id="kc-register-form" className={getClassName("kcFormClass")} action={url.registrationAction} method="post">
<UserProfileFormFields
kcContext={kcContext}
onIsFormSubmittableValueChange={setIsFomSubmittable}
onIsFormSubmittableValueChange={setIsFormSubmittable}
i18n={i18n}
getClassName={getClassName}
/>
@ -60,7 +62,7 @@ export default function RegisterUserProfile(props: PageProps<Extract<KcContext,
)}
type="submit"
value={msgStr("doRegister")}
disabled={!isFomSubmittable}
disabled={!isFormSubmittable}
/>
</div>
</div>

View File

@ -0,0 +1,42 @@
import { useEffect, useState } from "react";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function SamlPostForm(props: PageProps<Extract<KcContext, { pageId: "saml-post-form.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { msgStr, msg } = i18n;
const { samlPost } = kcContext;
const [htmlFormElement, setHtmlFormElement] = useState<HTMLFormElement | null>(null);
useEffect(() => {
if (htmlFormElement === null) {
return;
}
// Storybook
if (samlPost.url === "") {
alert("In a real Keycloak the user would be redirected immediately");
return;
}
htmlFormElement.submit();
}, [htmlFormElement]);
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("saml.post-form.title")}>
<p>{msg("saml.post-form.message")}</p>
<form name="saml-post-binding" method="post" action={samlPost.url} ref={setHtmlFormElement}>
{samlPost.SAMLRequest && <input type="hidden" name="SAMLRequest" value={samlPost.SAMLRequest} />}
{samlPost.SAMLResponse && <input type="hidden" name="SAMLResponse" value={samlPost.SAMLResponse} />}
{samlPost.relayState && <input type="hidden" name="RelayState" value={samlPost.relayState} />}
<noscript>
<p>{msg("saml.post-form.js-disabled")}</p>
<input type="submit" value={msgStr("doContinue")} />
</noscript>
</form>
</Template>
);
}

View File

@ -7,6 +7,9 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import { typeGuard } from "tsafe/typeGuard";
export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext, { pageId: "webauthn-authenticate.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -21,10 +24,24 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
const createTimeout = Number(kcContext.createTimeout);
const isUserIdentified = kcContext.isUserIdentified == "true";
const formElementRef = useRef<HTMLFormElement>(null);
const webAuthnAuthenticate = useConstCallback(async () => {
if (!isUserIdentified) {
return;
}
const submitForm = async (): Promise<void> => {
const formElement = formElementRef.current;
if (formElement === null) {
await new Promise(resolve => setTimeout(resolve, 100));
return submitForm();
}
formElement.submit();
};
const allowCredentials = authenticators.authenticators.map(
authenticator =>
({
@ -57,30 +74,36 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
}
try {
const resultRaw = await navigator.credentials.get({ publicKey });
if (!resultRaw || resultRaw.type != "public-key") return;
const result = resultRaw as PublicKeyCredential;
if (!("authenticatorData" in result.response)) return;
const response = result.response as AuthenticatorAssertionResponse;
const result = await navigator.credentials.get({ publicKey });
if (!result || result.type != "public-key") {
return;
}
assert(is<PublicKeyCredential>(result));
if (!("authenticatorData" in result.response)) {
return;
}
const response = result.response;
const clientDataJSON = response.clientDataJSON;
assert(
typeGuard<AuthenticatorAssertionResponse>(response, "signature" in response && response.authenticatorData instanceof ArrayBuffer),
"response not an AuthenticatorAssertionResponse"
);
const authenticatorData = response.authenticatorData;
const signature = response.signature;
setClientDataJSON(base64url.stringify(new Uint8Array(clientDataJSON), { pad: false }));
setAuthenticatorData(base64url.stringify(new Uint8Array(authenticatorData), { pad: false }));
setSignature(base64url.stringify(new Uint8Array(signature), { pad: false }));
setClientDataJSON(base64url.stringify(new Uint8Array(clientDataJSON), { "pad": false }));
setAuthenticatorData(base64url.stringify(new Uint8Array(authenticatorData), { "pad": false }));
setSignature(base64url.stringify(new Uint8Array(signature), { "pad": false }));
setCredentialId(result.id);
setUserHandle(base64url.stringify(new Uint8Array(response.userHandle!), { pad: false }));
submitForm();
setUserHandle(base64url.stringify(new Uint8Array(response.userHandle!), { "pad": false }));
} catch (err) {
setError(String(err));
submitForm();
}
});
const webAuthForm = useRef<HTMLFormElement>(null);
const submitForm = useConstCallback(() => {
webAuthForm.current!.submit();
submitForm();
});
const [clientDataJSON, setClientDataJSON] = useState("");
@ -93,7 +116,7 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("webauthn-login-title")}>
<div id="kc-form-webauthn" className={getClassName("kcFormClass")}>
<form id="webauth" action={url.loginAction} ref={webAuthForm} method="post">
<form id="webauth" action={url.loginAction} ref={formElementRef} method="post">
<input type="hidden" id="clientDataJSON" name="clientDataJSON" value={clientDataJSON} />
<input type="hidden" id="authenticatorData" name="authenticatorData" value={authenticatorData} />
<input type="hidden" id="signature" name="signature" value={signature} />

View File

@ -17,7 +17,7 @@ export type UserProfileFormFieldsProps = {
export function UserProfileFormFields(props: UserProfileFormFieldsProps) {
const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props;
const { advancedMsg } = i18n;
const { advancedMsg, msg } = i18n;
const {
formValidationState: { fieldStateByAttributeName, isFormSubmittable },
@ -98,11 +98,16 @@ export function UserProfileFormFields(props: UserProfileFormFieldsProps) {
}
value={value}
>
{options.options.map(option => (
<option key={option} value={option}>
{option}
<>
<option value="" selected disabled hidden>
{msg("selectAnOption")}
</option>
))}
{options.options.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</>
</select>
);
}

View File

@ -22,10 +22,11 @@ const meta: ComponentMeta<any> = {
export default meta;
export const Default = () => <PageStory />;
export const WithNoMessage = () => (
export const WithMessage = () => (
<PageStory
kcContext={{
message: undefined
message: { type: "success", summary: "This is a test message" }
}}
/>
);

View File

@ -2,3 +2,8 @@ declare module "*.png" {
const _default: string;
export default _default;
}
declare module "*.md" {
const _default: string;
export default _default;
}

View File

@ -2,6 +2,9 @@ import React, { lazy, Suspense } from "react";
import Fallback from "../../dist/login";
import type { KcContext } from "./kcContext";
import { useI18n } from "./i18n";
import { useDownloadTerms } from "../../dist/login/lib/useDownloadTerms";
import tos_en_url from "./tos_en.md";
import tos_fr_url from "./tos_fr.md";
const DefaultTemplate = lazy(() => import("../../dist/login/Template"));
@ -10,6 +13,26 @@ export default function KcApp(props: { kcContext: KcContext }) {
const i18n = useI18n({ kcContext });
useDownloadTerms({
"kcContext": kcContext as any,
"downloadTermMarkdown": async ({ currentLanguageTag }) => {
const resource = (() => {
switch (currentLanguageTag) {
case "fr":
return tos_fr_url;
default:
return tos_en_url;
}
})();
// webpack5 (used via storybook) loads markdown as string, not url
if (resource.includes("\n")) return resource;
const response = await fetch(resource);
return response.text();
}
});
if (i18n === null) {
return null;
}

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "idp-review-user-profile.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,33 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "info.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => (
<PageStory
kcContext={{
message: {
summary: "This is the server message",
type: "info"
}
}}
/>
);

View File

@ -74,8 +74,12 @@ export const WithPresetUsername = () => (
export const WithImmutablePresetUsername = () => (
<PageStory
kcContext={{
login: { username: "max.mustermann@mail.com" },
usernameEditDisabled: true
auth: {
attemptedUsername: "max.mustermann@mail.com",
showUsername: true
},
usernameHidden: true,
message: { type: "info", summary: "Please re-authenticate to continue" }
}}
/>
);

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-config-totp.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-oauth2-device-verify-user-code.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-idp-link-confirm.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-idp-link-email.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-oauth-grant.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-otp.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-page-expired.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-password.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-reset-password.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-update-password.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-update-profile.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-username.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-verify-email.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "logout-confirm.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "register.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "register-user-profile.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "saml-post-form.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "select-authenticator.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "terms.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "update-email.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "update-user-profile.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "webauthn-authenticate.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

180
stories/login/tos_en.md Normal file
View File

@ -0,0 +1,180 @@
# Terms of Service
## Presentation / Features
[EC: continuation of today's meeting: this would deserve to differentiate the SSP Cloud from the Onyxia SSP Cloud instance]
The SSP Cloud is a service (hereinafter referred to as "the service") implemented by the National Institute for Statistics and Economic Studies (hereinafter referred to as "Insee").
The SSP Cloud is an implementation of free software [Onyxia] (https://github.com/InseeFrLab/onyxia) created and maintained by the innovation and technical instruction division of INSEE (information system management / innovation unit and information system strategy). The SSP Cloud is hosted by INSEE.
[EC: I will remove the "on open data", since the SSP Cloud can accommodate secure data under the appropriate conditions]
The SSP Cloud is a platform offering a "datalab" intended for _data science_ experiments on open data in which users can orchestrate services dedicated to the practice of _data science_ (development environments, databases, etc.). This service offering thus aims to familiarize users with new collaborative working methods using _open source_ statistical languages (R, python, Julia, etc.), _cloud computing_ type technologies, as well as to allow processing experiments. innovative statistics. The services offered are standard.
The SSP Cloud is aimed at officials of the official statistical system as well as teachers and students of the Group of National Schools of Economics and Statistics, allowing inter-service collaboration and cooperation with their ecosystem. Access can thus be granted on request and after decision of the governance bodies of the Cloud SSP to external collaborators and involved in the realization of experimental projects of the official statistical system. Projects involving non-open data are also subject to the decision of the governing bodies.
The SSP Cloud allows:
- the orchestration of _data science_ trainings
- access to _data science_ services
- secure data storage
- management of secrets, such as encryption keys
- access to a code management service
- orchestration of data processing flows
A user account is also used to connect to the service platform of the Inter-ministerial Mutualization Free Software community (<https://groupes.mim-libre.fr/>).
## Legal Notice
Functional administration of the Cloud SSP: Insee
This site is published by the National Institute for Statistics and Economic Studies (Insee).
INSEE
88 avenue Verdier
CS 70058
92541 Montrouge cedex
Director of publication: Mr. Jean-Luc Tavernier
Administrator: Frédéric Comte
Maintenance of the _open source_ Onyxia project: Insee
Hosting: Insee - Innovation and technical instruction division
## Terms of use of the Service
The SSP Cloud datalab can be accessed from any browser connected to
Internet. The use of a computer is recommended. Use of the datalab services is free.
The user community is accessible on:
- Tchap, salon [SSP Cloud] (https://www.tchap.gouv.fr/#/room/#SSPCloudXDpAw6v:agent.finances.tchap.gouv.fr)
- Rocket Chat at MIM Libre, [SSP Cloud] lounge (https://chat.mim-libre.fr/channel/sspcloud)
## Limits of use of the Service
Public data and data can be processed on the datalab
usual (working data without particular sensitivity). In the absence of specific authorization for a given experimental project, cannot be
"protected" or "sensitive" data processed on the datalab, with or without a
confidentiality intended to restrict distribution to a specific domain
(statistical, commercial, industrial secrecy, etc.).
[EC: seems too "weak" to me, refer to the opinion of the UAJC on this point: if an agent puts sensitive data on the datalab, under his responsibility, what is the responsibility of his employer? from INSEE? can be added "after he has taken a legal opinion on the character 'protected' or 'sensitive' and that he informed his hierarchy ??]
The "protected" or "sensitive" nature of the information stored or processed on the datalab
is subject to the discretion of the user under his own
responsibility.
## Roles, commitments and associated responsibilities
The service is made available by INSEE without other express guarantees or
tacit than those provided herein. The service is based on benchmark open source technologies. However, it is not guaranteed that it
is free from anomalies or errors. The service is therefore made available ** without
guaranteed availability and performance **. As such, INSEE cannot
be held responsible for loss and / or damage of any kind
be, who couldbe caused as a result of a malfunction or
unavailability of the service. Such situations will not give right to any
financial compensation.
Each user has a personal storage space. By default, all the information deposited in a user's storage space is accessible only to him. Each user has the possibility of making public files stored in their personal storage space. Each user is responsible for making their files available to the public.
[EC: take the opinion of the UAJC, I do not know if it is the user specifically who is responsible for the processing or the institution on which he depends]
Each user is responsible for processing all the experimental work he performs on the SSP Cloud.
He must, if necessary, declare the personal processing carried out using the SSP Cloud to the data protection officer of his structure and inform the members thereof. [not sure that it is only the DPD of his structure who must be aware, also the DPD Insee?]
[EC: in the case of a project involving several institutions, users must have previously established a data sharing / provision agreement.]
## Creating an account on the SSP Cloud
Access to the SSP Cloud requires prior registration and authentication.
## Experimental projects on sensitive data
** TODO **
Role of the project security manager
Enrollment of sensitive projects
Creation of collaborative spaces for sensitive projects
Creation and life cycle of spaces
## Processing of personal data
Data processing is based on the performance of the mission of providing a platform dedicated to experimentation and learning about data science for the benefit of the official statistical system.
The Service only collects the data strictly necessary for its implementation.
artwork.
The processing of personal data within the meaning of Articles 9 and 10 of
general data protection regulation (racial or ethnic origin,
political opinions, religious or philosophical beliefs, belonging
union, criminal convictions ...) is banned on the SSP Cloud.
[EC: same remark as above -> have the opinion of the Legal Unit]
Personal data processed as part of an experiment carried out by a user, when there is any, is the responsibility of the entity
administrative office from which the user originated. The
arrangements for their treatment must be communicated by
the user to the data protection officer of his entity
administrative unit.
Regarding the scope of the SSP Cloud service, the purpose of processing
concerns the management of the platform's accounts
(creation / conservation / deletion), operation of the platform (monitoring,
usage statistics) as well as the management of the services offered by the platform. Below is the list of
transverse personal data whose processing is under the
responsibility of INSEE.
** Suite to be managed with the DC POD **
> RL: @Fred, I put it a bit at random, I let you complete / amend
### Profile data
their first name, last name and email address (required);
freely:
- photo (see gitlab)
- ...
### Trace data
They are collected each time a user connects and, for example,
the use of a technical identifier, to trace connection operations and
modification of the objects of the service database.
They are used for technical support purposes. They can also do
subject to periodic review by the directors for control purposes and usage statistics.
### Cookie data
These cookies are only intended to allow the service to function and
to facilitate its use by users according to the constraints of each typology.
- Session cookie: mandatory, it identifies the session of
the user. The cookie is destroyed at the end of the session.
- Reauthentication cookie: optional, it allows you to re-authenticate
the user logged in for the duration of the cookie (one year maximum)
## Modification and evolution of the Service
INSEE reserves the right to develop, modify or suspend,
without notice, the Service for maintenance reasons or for any other
reason deemed necessary. The information is then communicated to users via Tchap.
The terms of these conditions of use may be modified or
completed at any time, without notice, depending on changes
made to the Service, changes in legislation or for any other reason
deemed necessary. These modifications and updates are binding on the user who
should therefore refer regularly to this section to verifythe
general conditions in force (accessible from the home page).
## Contact
For technical problems and / or
functionalities encountered on the platform, it is recommended, first of all
time to solicit communities of peers in collaborative spaces
provided for this purpose on Tchap and Rocket Chat-MIM Libre.
CNIL right of access for: <innovation@insee.fr>

180
stories/login/tos_fr.md Normal file
View File

@ -0,0 +1,180 @@
# Conditions générales d'utilisation
## Présentation / Fonctionnalités
[EC: suite de la réunion d'aujourdhui : cela mériterait de différencier le SSP Cloud de l'instance d'Onyxia SSP Cloud]
Le SSP Cloud est un service (ci après désigné par "le service") mis en œuvre par l'Institut national de la statistique et des études économiques (ci-après dénommé "l'Insee").
Le SSP Cloud est une implémentation du logiciel libre [Onyxia](https://github.com/InseeFrLab/onyxia) créé et maintenu par la division innovation et instruction technique de l'Insee (direction du système d'information/unité innovation et stratégie du système d'information). Lhébergement du SSP Cloud est assuré par l'Insee.
[EC: j'enlèverai le "sur données ouvertes", puisque le SSP Cloud peut accueillir dans les donditions idoines des données sécurisées]
Le SSP Cloud est une plateforme proposant un "datalab" destiné aux expérimentations de _data science_ sur données ouvertes dans lequel les utilisateurs peuvent orchestrer des services dédiés à la pratique de la _data science_ (environnements de développement, bases de données...). Cette offre de services vise ainsi à familiariser les utilisateurs avec de nouvelles méthodes de travail collaboratif mobilisant des langages statistiques _open source_ (R, python, Julia...), des technologies de type _cloud computing_ ainsi qu'à permettre d'expérimenter des traitements statistiques innovants. Les services proposés sont standards.
Le SSP Cloud sadresse aux agents du système statistique public ainsi qu'aux enseignants et étudiants du Groupe des écoles nationales d'économie et de statistique, permettant une collaboration interservices et la coopération avec leur écosystème. Des accès peuvent ainsi être accordés sur demande et après décision des organes de gouvernance du SSP Cloud à des collaborateurs extérieurs et impliqués dans la réalisation de projets expérimentaux du système statistique public. Les projets mobilisant des données non ouvertes sont aussi soumis à la décision des organes de gouvernance.
Le SSP Cloud permet :
- l'orchestration de formations de _data science_
- l'accès à des services de _data science_
- le stockage sécurisé de données
- la gestion de secrets, tels que des clés de chiffrement
- l'accès à un service de gestion de code
- l'orchestration de flux de traitement de données
Un compte utilisateur permet également de se connecter à la plateforme de services de la communauté Mutualisation Inter-ministérielle Logiciels Libres (<https://groupes.mim-libre.fr/>).
## Mentions légales
Administration fonctionnelle du SSP Cloud : Insee
Ce site est édité par l'Institut national de la statistique et des études économiques (Insee).
Insee
88 avenue Verdier
CS 70058
92541 Montrouge cedex
Directeur de la publication : Monsieur Jean-Luc Tavernier
Administrateur : Frédéric Comte
Maintenance du projet _open source_ Onyxia : Insee
Hébergement : Insee - Division innovation et instruction technique
## Modalités dutilisation du Service
Le datalab SSP Cloud est accessible depuis nimporte quel navigateur connecté à
Internet. L'utilisation d'un ordinateur est recommandée. Lutilisation des services du datalab est gratuite.
La communauté d'utilisateurs est accessible sur :
- Tchap, salon [SSP Cloud](https://www.tchap.gouv.fr/#/room/#SSPCloudXDpAw6v:agent.finances.tchap.gouv.fr)
- Rocket Chat du MIM Libre, salon [SSP Cloud](https://chat.mim-libre.fr/channel/sspcloud)
## Limites dutilisation du Service
Peuvent être traitées sur le datalab les données publiques et données
usuelles (données de travail sans sensibilité particulière). En l'absence d'autorisation spécifique pour un projet d'expérimentation donné, ne peuvent être
traitées sur le datalab les données protégées ou sensibles, avec ou sans marque de
confidentialité destinée à restreindre la diffusion à un domaine spécifique
(secret statistique, commercial, industriel..).
[EC: me semble trop "faible", se référer à l'avis de l'UAJC sur ce point : si un agent met des données sensibles sur le datalab, sous sa responsabilité, quelle est la responsabilité de son employeur? de l'Insee ? peut être ajouter "après qu'il ait pris un avis juridique sur le caractère 'protégé' ou 'sensible' et qu'il en ait informé sa hiérarchie??]
Le caractère protégé ou sensible des informations stockées ou traitées sur le datalab
est soumis à lappréciation de lutilisateur sous sa propre
responsabilité.
## Les rôles, engagements et responsabilités associées
Le service est mis à disposition par l'Insee sans autres garanties expresses ou
tacites que celles qui sont prévues par les présentes. Le service sappuie sur des technologies open source de référence. Toutefois, il nest pas garanti quil
soit exempt danomalies ou erreurs. Le service est donc mis à disposition **sans
garantie sur sa disponibilité et ses performances**. A ce titre, l'Insee ne peut
être tenue responsable des pertes et/ou préjudices, de quelque nature quils
soient, qui pourraient être causés à la suite dun dysfonctionnement ou une
indisponibilité du service. De telles situations n'ouvriront droit à aucune
compensation financière.
Chaque utilisateur dispose d'un espace de stockage personnel. Par défaut, toutes les informations déposées dans un espace de stockage d'un utilisateur ne sont accessibles qu'à lui seul. Chaque utilisateur a la possibilité de rendre publics des fichiers stockés dans son espace de stockage personnel. Chaque utilisateur est responsable de la mise à disposition publique de ses fichiers.
[EC : prendre l'avis de l'UAJC, je ne sais pas si c'est l'utilisateur nommément qui est responsable du traitement ou bien l'institution dont il dépend]
Chaque utilisateur est responsable de traitement pour lensemble des travaux d'expérimentation qu'il réalise sur le SSP Cloud.
Il doit, le cas échant, déclarer les traitements à caractère personnel réalisés à l'aide du SSP Cloud au délégué à la protection des données de sa structure et en informer les membres. [pas sur que ce soit uniquement le DPD de sa structure qui doit être au courant, aussi le DPD Insee?]
[EC : dans le cas d'un projet faisant intervenir plusieurs institutions, les utilisateurs doivent avoir au préalable établi un conventionnement de partage/ mise à disposition des données.]
## La création de compte sur le SSP Cloud
L'accès au SSP Cloud nécessite une inscription préalable et une authentification.
## Les projets d'expérimentation sur données sensibles
**TODO**
Rôle du responsable de sécurité du projet
Enrôlement des projets sensibles
Création d'espaces collaboratifs pour les projets sensibles
Création et cycle de vie des espaces
## Traitement des données à caractère personnel
Le traitement des données se fonde sur lexécution de la mission que constitue la mise à disposition d'une plateforme dédiée à l'expérimentation et à l'apprentissage de la datascience au bénéfice du système statistique public.
Le Service ne collecte que les données strictement nécessaires à sa mise en
œuvre.
Le traitement de données à caractère personnel au sens des articles 9 et 10 du
règlement général sur la protection des données (origine raciale ou ethnique,
opinions politiques, convictions religieuses ou philosophiques, appartenance
syndicale, condamnations pénales...) est proscrit sur le SSP Cloud.
[EC: meme remarque que ci-dessus --> avoir l'avis de l'Unité juridique]
Les données à caractère personnel traitées dans le cadre d'une expérimentation réalisée par un utilisateur, quand il y en a, relèvent de la responsabilité de lentité
administrative dont est issu lutilisateur. Les
dispositions relatives à leur traitement doivent être communiquées par
l'utilisateur au délégué à la protection des données de son entité
administrative de rattachement.
Pour ce qui est du périmètre du service SSP Cloud, la finalité de traitement
concerne la gestion des comptes de la plateforme
(création/conservation/suppression), lexploitation de la plateforme (suivi,
statistiques dusages) ainsi que la gestion des services offerts par la plateforme. Ci-dessous la liste des
données à caractère personnel transverses dont le traitement est sous la
responsabilité de l'Insee.
**Suite à gérer avec le DC POD**
> RL : @Fred, je mets un peu au hasard, je te laisse compléter/amender
### Données relatives au profil
ses prénom, nom et adresse mail (obligatoire) ;
de façon libre :
- photo (cf. gitlab)
- ...
### Données de trace
Elles sont collectées à chaque connexion d'un utilisateur et permettent, par
lutilisation dun identifiant technique, de tracer les opérations de connexion et
de modification des objets de la base de données du service.
Elles servent à des fins de support technique. Elles peuvent également faire
l'objet d'une revue périodique de la part des administrateurs à des fins de contrôle et de statistiques d'usage.
### Les données de cookies
Ces cookies nont pour objet que de permettre le fonctionnement du service et
de faciliter son usage par les utilisateurs selon les contraintes chaque typologie.
- Cookie de session : obligatoire , il permet d'identifier la session de
l'utilisateur. Le cookie est détruit à la fin de la session.
- Cookie de réauthentification : optionnel, il permet de ré-authentifier
l'utilisateur connecté pendant la durée du cookie (un an maximum)
## Modification et évolution du Service
L'Insee se réserve la liberté de faire évoluer, de modifier ou de suspendre,
sans préavis, le Service pour des raisons de maintenance ou pour tout autre
motif jugé nécessaire. L'information est alors communiquée aux utilisateurs via Tchap.
Les termes des présentes conditions dutilisation peuvent être modifiés ou
complétés à tout moment, sans préavis, en fonction des modifications
apportées au Service, de lévolution de la législation ou pour tout autre motif
jugé nécessaire. Ces modifications et mises à jour simposent à lutilisateur qui
doit, en conséquence, se référer régulièrement à cette rubrique pour vérifier les
conditions générales en vigueur (accessible depuis la page daccueil).
## Contact
Pour les problèmes techniques et/ou
fonctionnels rencontrés sur la plateforme, il est conseillé, dans un premier
temps de solliciter les communautés de pairs dans les espaces collaboratifs
prévus à cet effet sur Tchap et Rocket Chat-MIM Libre.
Droit daccès CNIL pour : <innovation@insee.fr>

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