Compare commits

..

203 Commits

Author SHA1 Message Date
aca8d3f4b7 fix(deps): update dependency powerhooks to ^0.26.1 2023-02-08 16:38:28 +00:00
b5b3af4659 Bump version 2023-02-07 01:32:36 +01:00
6cd231426d Import Blob from node builtins 2023-02-07 01:32:20 +01:00
0c7cd1cd75 Bump version 2023-02-07 01:21:17 +01:00
2425704ead Merge branch 'main' of https://github.com/InseeFrLab/keycloakify 2023-02-07 01:20:33 +01:00
4e22159206 Bump version 2023-02-07 01:20:26 +01:00
52cf1ba02c Fix tsafe related warnings 2023-02-07 01:20:12 +01:00
516e84182f fix(deps): update dependency powerhooks to ^0.26.0 2023-02-05 15:10:42 +00:00
a3a9853e18 bump version 2023-02-05 14:58:53 +01:00
08e26600fd Use keycloakify as bundler by default 2023-02-05 14:58:38 +01:00
7793c2c6ba Update package.json 2023-02-05 14:41:32 +01:00
9e826d16dd Merge pull request #241 from lordvlad/mvn-begone
Mvn begone addendum
2023-02-05 14:41:13 +01:00
80618bbd9c Merge branch 'main' into mvn-begone 2023-02-05 13:36:52 +01:00
38ad47ea75 use hand-crafted promise, pipeline does not resolve properly 2023-02-05 13:32:24 +01:00
45ed359bef fix keycloak theme source path for internal bundler 2023-02-05 13:31:34 +01:00
fcc26c3e7a now that main is a promise, we shuold catch errors 2023-02-05 13:31:03 +01:00
d4ff6b1f40 fix: bundler fix missing directory 2023-02-05 12:59:05 +01:00
557de34eea fix: bundler fix missing change 2023-02-05 12:56:01 +01:00
e034dc4d90 Merge branch 'mvn-begone' of github.com:lordvlad/keycloakify into mvn-begone
* 'mvn-begone' of github.com:lordvlad/keycloakify:
  fix(deps): update garronej_modules_update
  Update README.md
  Rollback via update
  Bump version
  keycloak test script: use env to launch bash
  fix(deps): update dependency powerhooks to ^0.22.0
  Update dependency powerhooks to ^0.21.0
  Relase candidate
  fmt
  Update README.md
  Bump version
  Update src/bin/tools/downloadAndUnzip.ts
  Bump version
  #232
  Bump version
  keycloak test script: use env to launch bash
  fix(deps): update dependency powerhooks to ^0.22.0
  Update dependency powerhooks to ^0.21.0
2023-02-05 12:35:15 +01:00
cfbd1e5e4b fix(bundler): fix type mismatch introduced in last-minute 'fixes' 2023-02-05 12:34:48 +01:00
0df661819f Bump version 2023-02-04 20:51:06 +01:00
1a9f6d10d4 Actually run the top level await 2023-02-04 20:50:53 +01:00
a787215c95 Bump version 2023-02-04 20:39:38 +01:00
64ab400af5 Temporarly restore mvn as default bundler 2023-02-04 20:38:50 +01:00
a463878bf2 Bump version 2023-02-04 20:22:45 +01:00
9f72024c61 Merge pull request #240 from InseeFrLab/mvn-begone
Mvn begone
2023-02-04 19:47:36 +01:00
243fbd4dc9 Set the artifactId name in the build option 2023-02-04 19:36:42 +01:00
4e6a290693 Make new node based bundler the default 2023-02-04 18:02:39 +01:00
ac05d529ca Minor fixes 2023-02-04 17:44:02 +01:00
b38d79004a Merge branch 'main' into mvn-begone 2023-02-03 14:42:05 +01:00
f4a547df11 introduce options to choose a bundle strategy
Pick from 'none', 'keycloakify' or 'mvn', default to 'mvn'. 'none' will
not create a jar, 'keycloakify' will create a jar file using only tools
available to native nodejs, no additional  system library required.
Choosing 'mvn' will behave as before, starting maven in a subprocess.

The bundler can be chosen in `package.json` or via `KEYCLOAKIFY_BUNDLER`
env var.

This commit also adds `KEYCLOAKIFY_GROUP_ID` and
`KEYCLOAKIFY_ARTIFACT_ID` env vars, which will be used to
define group id and artifact id in pom.xml and pom.properties, if given.
2023-02-03 14:28:06 +01:00
2b87c35058 introduce utils for creating a jar archive 2023-02-03 14:06:24 +01:00
b11833e450 fix typo 2023-02-03 13:48:32 +01:00
fa8e119514 fix(deps): update garronej_modules_update 2023-02-01 15:16:03 +00:00
677cb5c330 Update README.md 2023-01-27 15:46:30 +01:00
6e74c79bfe Merge pull request #233 from InseeFrLab/windows_compat
Windows compat
2023-01-27 15:45:31 +01:00
54474f5908 Merge branch 'main' into windows_compat 2023-01-27 15:45:25 +01:00
99cc0f519b Rollback via update 2023-01-27 01:13:16 +01:00
92a01f89ef Bump version 2023-01-27 01:12:46 +01:00
fd83a0c743 keycloak test script: use env to launch bash
This minor change allows users to use the latest version of `bash` on Mac OS.

`/bin/bash` is very old on Mac OS, and many users will have `bash` from MacPorts or Homebrew. This change allows such users to be able to use the newer `bash` installation.
2023-01-27 01:12:46 +01:00
988e46c875 fix(deps): update dependency powerhooks to ^0.22.0 2023-01-27 01:12:46 +01:00
f081c2fc20 Update dependency powerhooks to ^0.21.0 2023-01-27 01:12:46 +01:00
b4b376a1a5 Relase candidate 2023-01-27 01:09:11 +01:00
0db4179d47 fmt 2023-01-27 00:32:41 +01:00
795b7c6234 Update README.md 2023-01-27 00:27:46 +01:00
091b9a57f5 Bump version 2023-01-27 00:22:28 +01:00
564e1422ac Merge pull request #226 from lordvlad/fix-win-build
windows compatibility
2023-01-27 00:20:12 +01:00
8ed4ed3fc4 Update src/bin/tools/downloadAndUnzip.ts 2023-01-26 22:29:51 +01:00
29fe4566a7 style: remove unused variable 2023-01-26 22:20:16 +01:00
ae3bfb28ed refactor: follow suggesion to make methods read a bit more sync 2023-01-26 22:00:31 +01:00
14aab97d8a Merge branch 'main' of https://github.com/InseeFrLab/keycloakify 2023-01-26 10:50:01 +01:00
52d7a47cd7 Bump version 2023-01-26 10:48:57 +01:00
f338dcbeed #232 2023-01-26 10:48:24 +01:00
dcec058a22 Bump version 2023-01-23 02:57:12 +01:00
2bdc6b156b Merge pull request #231 from rome-user/patch-1
keycloak test script: use env to launch bash
2023-01-23 02:56:32 +01:00
84ca9e6b81 keycloak test script: use env to launch bash
This minor change allows users to use the latest version of `bash` on Mac OS.

`/bin/bash` is very old on Mac OS, and many users will have `bash` from MacPorts or Homebrew. This change allows such users to be able to use the newer `bash` installation.
2023-01-22 17:28:27 -08:00
11cb0fd2db Merge pull request #230 from InseeFrLab/renovate_main-garronej_modules_update
Update dependency powerhooks to ^0.22.0 (main)
2023-01-18 00:58:38 +01:00
3f620ffb6f fix(deps): update dependency powerhooks to ^0.22.0 2023-01-16 18:23:50 +00:00
1a0e05d073 rewrite download and unzip to use nodejs native methods 2023-01-16 14:42:20 +01:00
a4d2de23a1 Update dependency powerhooks to ^0.21.0 2023-01-16 02:38:07 +00:00
85cecc9811 fix(build): _properly_ fix bin paths 2023-01-12 22:56:02 +01:00
9899f742a8 fix(build): also rewrite bin paths in dist/package.json 2023-01-12 22:51:48 +01:00
b5484740b7 fix(build): remove dependency on *nix chown 2023-01-12 22:27:22 +01:00
016b15b437 Bump version 2023-01-10 09:05:33 +01:00
6fb936798e https://github.com/InseeFrLab/keycloakify/issues/217#issuecomment-1376849781 2023-01-10 09:05:19 +01:00
a692b87843 Bump version 2023-01-08 15:01:12 +01:00
19663885a4 Merge pull request #222 from InseeFrLab/pr/lordvlad/221
Pr/lordvlad/221
2023-01-08 14:59:22 +01:00
49b87777f9 Fmt 2023-01-08 14:57:18 +01:00
d4523bb1e6 Merge branch 'main' into patch-1 2023-01-08 12:22:14 +01:00
e3200899e2 fix: replace rm system calls with nodejs native functions; closes #219 2023-01-08 12:19:41 +01:00
36c7a1ab9e Bump version 2023-01-08 00:14:01 +01:00
c54fbd5eca Merge pull request #218 from pedrodsRocha1/improve-secure-login-flow
fix - handle username and password login errors the same way
2023-01-08 00:03:16 +01:00
bbe828071e fix - handle username and password login errors the same 2023-01-06 15:41:01 +01:00
23f6c7db00 Update garronej_modules_update 2022-11-29 18:31:14 +00:00
b1ea9e7a71 Update dependency powerhooks to ^0.20.27 2022-11-16 05:21:45 +00:00
fb71d0e272 Update dependency powerhooks to ^0.20.26 2022-11-13 22:11:59 +00:00
fa72a29999 Bump version 2022-11-11 15:49:05 +01:00
af77b31d54 Update default test container to Keycloak 20.0.1 2022-11-11 15:48:32 +01:00
8280dace26 Update garronej_modules_update 2022-10-25 03:18:01 +00:00
ecaf1c7b7c Bump version 2022-10-16 00:51:11 +02:00
8702ec29a8 Drop dependency to @emotion/react 2022-10-16 00:49:49 +02:00
d8206434bc Bump version 2022-10-15 15:39:56 +02:00
c71c2a8710 Fix breaking change of 6.8 2022-10-15 15:39:32 +02:00
e55b881017 Bump version 2022-10-15 14:22:46 +02:00
ab906ec417 #192: fix bad logic in ftl to js script 2022-10-15 14:22:11 +02:00
0b1ff529f7 Update garronej_modules_update 2022-10-14 17:37:32 +00:00
85a6835748 Fix CI 2022-10-13 12:10:08 +02:00
259271bc0f Update CI 2022-10-13 12:07:32 +02:00
b7bc0f178b Bump version 2022-10-13 12:03:24 +02:00
688455d0aa #191: Enable to only customize the Template 2022-10-13 11:58:31 +02:00
3c96d2ea42 Update garronej_modules_update 2022-10-13 01:16:45 +00:00
ab81481e5a Update garronej_modules_update 2022-10-11 05:10:22 +00:00
a429ad5dcf Bump version 2022-10-06 20:43:42 +02:00
5e1c5b510b Merge pull request #185 from Mstrodl/feature/mstrodl/webauthn-authenticate
Add support for `webauthn-authenticate.ftl`
2022-10-06 20:41:31 +02:00
9e63183f4b WebauthnAuthenticate: refactor authentication flow
Lots of weird syntax here, and we were using `useCallback` rather than
`useConstCallback`.
2022-10-06 14:25:04 -04:00
b1e740f026 Bump version 2022-10-06 01:09:00 +02:00
ce4ea55438 Add a big red warning when kcContext mock is enabled 2022-10-06 01:08:07 +02:00
18ab7cd22f Bump version 2022-10-06 00:37:51 +02:00
8807743daf Ignore mock when in Keycloak: https://github.com/InseeFrLab/keycloakify/discussions/186#discussioncomment-3809320 2022-10-06 00:36:46 +02:00
aad50377ff Merge pull request #187 from InseeFrLab/renovate_main-garronej_modules_update
Update dependency tss-react to ^4.3.4 (main)
2022-10-06 00:01:38 +02:00
4b3ae58ea7 Add support for webauthn-authenticate.ftl
Wow! This one sucks. Certainly more in need of review compared to
`login-username.ftl` and `login-password.ftl`...
2022-10-05 02:54:03 -04:00
ce2c68ecc9 Update dependency tss-react to ^4.3.4 2022-10-04 19:43:22 +00:00
0c155a7a2e Bump version 2022-10-04 21:42:47 +02:00
afddfe8b58 Merge pull request #184 from Mstrodl/feature/mstrodl/login-password
Add support for `login-password.ftl`
2022-10-04 21:38:55 +02:00
5fa0915271 Update changelog 2022-10-04 21:34:42 +02:00
6a0a170b17 Add support for login-password.ftl 2022-10-04 15:01:08 -04:00
4dde5b6e45 Bump version 2022-10-04 18:48:22 +02:00
4b93a1cb9e Merge pull request #183 from Mstrodl/feature/mstrodl/login-username 2022-10-04 18:43:02 +02:00
e3a0639a0c Add login-username support
Shown for the "Username Form" login step.

I would also like to work on:
- `login-password.ftl`
- `webauthn-authenticate.ftl`

But first want to see opinions on this!
2022-10-04 11:22:03 -04:00
4d3220820b Update dependency tss-react to ^4.3.3 2022-10-04 03:50:15 +00:00
a4ac9fb0f3 Update garronej_modules_update 2022-10-01 17:47:21 +00:00
1ff79ecf07 Update garronej_modules_update 2022-09-29 08:53:47 +00:00
1166b16420 Merge branch 'main' of https://github.com/InseeFrLab/keycloakify 2022-09-27 21:35:52 +02:00
213224942f Bump version 2022-09-27 21:35:16 +02:00
ff16e66275 #177: Provide a simple way to disable fetching of default resources 2022-09-27 21:30:33 +02:00
3c338e983f Update garronej_modules_update 2022-09-14 16:33:35 +00:00
2c11ba6520 Bump version 2022-09-13 10:50:36 +02:00
9a21656706 Apply #164 on v6 2022-09-13 10:49:20 +02:00
e96ee5ba53 Bump version 2022-09-12 23:49:14 +02:00
b421633a8a Default test container use 19.0.1 2022-09-12 23:48:34 +02:00
e2e0d62560 Cleaner npm scripts 2022-09-11 01:53:56 +02:00
c71fb06940 Update garronej_modules_update 2022-09-10 20:14:00 +00:00
e2171af99c Bump version 2022-09-09 17:24:58 +02:00
8cebf049d4 Render Markdown in Terms 2022-09-09 17:24:43 +02:00
ef139ed1cc Bump version 2022-09-09 14:37:41 +02:00
d717de006a Update kcContext def 2022-09-09 14:37:27 +02:00
a44f091878 Bump version 2022-09-09 12:56:31 +02:00
1b37ba5339 Feat idp-review-user-profile.ftl #164 2022-09-09 12:56:09 +02:00
bbaa90e997 Rename misnamed default exports, removing doInsertPasswordFields 2022-09-09 10:24:50 +02:00
86e6c4a419 Bump version (changelog ignore) 2022-09-09 02:10:18 +02:00
4159883791 Feature update-user-profile.ftl #164 2022-09-09 02:07:49 +02:00
d8b00da3a1 Update tss-react 2022-09-08 15:27:41 +02:00
a24945bc1b Bump version 2022-09-08 15:18:21 +02:00
158759493f Merge pull request #170 from Tasyp/feature/silent
feat: add silent flag
2022-09-08 15:17:45 +02:00
36e32d6ddc Update src/bin/download-builtin-keycloak-theme.ts 2022-09-08 15:13:21 +02:00
84908e2ec0 Update src/bin/generate-i18n-messages.ts 2022-09-08 15:13:15 +02:00
a2dc51d811 Update src/bin/create-keycloak-email-directory.ts 2022-09-08 15:13:09 +02:00
fb3b0e2c29 fix: make sure external assets flag is a boolean 2022-09-08 15:46:27 +03:00
1a3e4c68bb test: update tests to include silent flag 2022-09-08 15:43:03 +03:00
11b2342da0 feat: add silent flag 2022-09-08 13:52:10 +03:00
80d4a808d3 Update readme 2022-09-07 13:45:34 +02:00
da4146eb59 Bump version (changelog ignore) 2022-09-07 13:40:18 +02:00
a0be35db8b Fix compat with StrictMode react 18 2022-09-07 13:39:54 +02:00
14db9cd523 Merge branch 'main' of https://github.com/InseeFrLab/keycloakify 2022-09-07 12:02:35 +02:00
0c315385dd Update fix tests 2022-09-07 12:00:23 +02:00
c0a0eb02fb Remove Open collectivity 2022-09-07 11:59:42 +02:00
ee407c32ad Create FUNDING.yaml 2022-09-07 11:53:18 +02:00
9262d21829 Bump version 2022-09-07 11:50:41 +02:00
a13f710325 Important fix for --external-assets 2022-09-07 11:50:24 +02:00
eac1a6036f Merge branch 'main' of https://github.com/InseeFrLab/keycloakify 2022-09-07 11:26:18 +02:00
987f3d7586 Bump version (changelog ignore) 2022-09-07 11:26:10 +02:00
875322669c Rename isAppAndKeycloakServerSharingSameDomain to areAppAndKeycloakServerSharingSameDomain #145 2022-09-07 11:25:46 +02:00
33a264b3d0 Update README.md 2022-09-07 00:32:38 +02:00
c059eff170 Update README 2022-09-06 19:14:39 +02:00
b4a22fc9dd Fix readme 2022-09-06 19:13:46 +02:00
6d1cbdc463 Bump version 2022-09-06 19:12:59 +02:00
2bfbba4daf Upgrade tss-react 2022-09-06 17:43:30 +02:00
21ffe82bde Bump version 2022-09-06 17:40:06 +02:00
8e6f597027 Fix bug with --external-assets 2022-09-06 17:39:47 +02:00
16c5065560 Bump version 2022-09-05 00:09:07 +02:00
c4b985f1a4 Fix replacers 2022-09-05 00:08:50 +02:00
042747c7d2 Bump version 2022-09-04 23:19:53 +02:00
e4a46f31de Make it allright not to provide validators object on mock data 2022-09-04 23:19:33 +02:00
6d9e62d2b4 Remove unessesary log 2022-09-04 21:48:46 +02:00
9caaa507b1 Bump version 2022-09-01 22:35:15 +02:00
5c7d3c5b44 lib target ES2017 instead of ES2020 2022-09-01 22:35:01 +02:00
8bac57d87a Bump version 2022-09-01 21:31:41 +02:00
b8d759cd63 Minor refactor for dryness 2022-09-01 21:31:27 +02:00
da72e3e5ac Bump version (changelog ignore) 2022-09-01 21:16:39 +02:00
2afd36fee0 Avoid fetching locale twitch (react 18 useEffect) 2022-09-01 21:16:25 +02:00
b7e75d8828 Bump beta version 2022-09-01 17:22:35 +02:00
30e20f4e7d Avoid redefining the properties 2022-09-01 17:22:15 +02:00
ce0ab8dccf Better linking script 2022-09-01 16:36:38 +02:00
5b20ab2f7c Upgrade to tss-react v4 2022-09-01 15:13:24 +02:00
daaaed43df Rename keycloakify-demo-app in keycloakify-starter 2022-09-01 14:58:59 +02:00
3a4bd791ad Fix release (changelog ignore) 2022-08-28 12:37:03 +07:00
eecddd7f6b Update CI (changelog ignore) 2022-08-28 12:31:58 +07:00
a34eaa136e Update ts-ci (changelog ignore) 2022-08-28 12:16:27 +07:00
53be8b5e96 Updat README 2022-08-28 12:10:45 +07:00
f0ae5ea908 Enable the CI to run on the v6 branch 2022-08-26 16:17:03 +07:00
9910556a8b Bump version (changelog ignore) 2022-08-26 15:43:50 +07:00
5997416e1b Update i18n API JSDoc comments 2022-08-26 15:43:32 +07:00
9a9fc56f85 Improve documentation comment i18n (changelog ignore) 2022-08-23 07:58:39 +07:00
2a5e919f29 Bump version (changelog ignore) 2022-08-22 17:18:09 +07:00
8031d51e15 Rename build-keycloak-theme -> keycloakify 2022-08-22 17:17:35 +07:00
56ce9c0d0d Bump version (changelog ignore) 2022-08-20 14:56:40 +07:00
8cd584cbd5 Implementation of #160 and #103 for v6 2022-08-20 14:56:20 +07:00
f5b87f4669 Fix option parsing 2022-08-20 14:04:47 +07:00
a1a65c5529 Prettier: switch to trailing coma: none 2022-08-20 11:44:48 +07:00
832434095e Restore tsproject.json 2022-08-16 14:45:18 +07:00
b85f1ef351 Merge branch 'v6' of https://github.com/InseeFrLab/keycloakify into v6 2022-08-16 14:42:05 +07:00
8bee5d788e #145 2022-08-16 14:41:06 +07:00
0752d857e2 Merge pull request #155 from schlich/hotfix-ci 2022-08-14 11:58:07 +07:00
07e4056694 change outDir to path at root per NPM CI error message 2022-08-13 23:00:09 -05:00
0eb4ab85b3 Fix lazy loading of css chunks #141 2022-08-02 21:00:52 +02:00
69ef47daf8 Bump beta version (changelog ignore) 2022-08-01 06:04:23 +02:00
6eaa1f69ac Fix dynamic imports 2022-08-01 06:03:48 +02:00
5aab75fae0 Only downoad terms on the Terms page 2022-08-01 04:54:02 +02:00
7407c98005 Stop self promotion in log 2022-08-01 04:49:48 +02:00
dcd4322e44 Fix CI 2022-08-01 04:06:10 +02:00
81a4d46b08 Bump beta version (changelog ignore) 2022-08-01 03:57:57 +02:00
e85895ab55 User switch for dynamic import of local 2022-08-01 03:57:24 +02:00
095bdb16ba Make new build setup compatible with enable short import path 2022-08-01 03:56:53 +02:00
302 changed files with 4586 additions and 1798 deletions

2
.gitattributes vendored
View File

@ -1,3 +1,3 @@
src/lib/i18n/generated_kcMessages/* linguist-documentation src/lib/i18n/generated_kcMessages/* linguist-documentation
src/bin/build-keycloak-theme/index.ts -linguist-detectable src/bin/keycloakify/index.ts -linguist-detectable
src/bin/install-builtin-keycloak-themes.ts -linguist-detectable src/bin/install-builtin-keycloak-themes.ts -linguist-detectable

4
.github/FUNDING.yaml vendored Normal file
View File

@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [garronej]
custom: ['https://www.ringerhq.com/experts/garronej']

View File

@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ !github.event.created && github.repository != 'garronej/ts-ci' }} if: ${{ !github.event.created && github.repository != 'garronej/ts-ci' }}
steps: steps:
- uses: actions/checkout@v2.3.4 - uses: actions/checkout@v3
- uses: actions/setup-node@v2.1.3 - uses: actions/setup-node@v3
- uses: bahmutov/npm-install@v1 - uses: bahmutov/npm-install@v1
- name: If this step fails run 'npm run lint' and 'npm run format' then commit again. - name: If this step fails run 'yarn format' then commit again.
run: | run: |
PACKAGE_MANAGER=npm PACKAGE_MANAGER=npm
if [ -f "./yarn.lock" ]; then if [ -f "./yarn.lock" ]; then
@ -24,22 +24,21 @@ jobs:
fi fi
$PACKAGE_MANAGER run format:check $PACKAGE_MANAGER run format:check
test: test:
runs-on: macos-10.15 runs-on: ${{ matrix.os }}
needs: test_lint needs: test_lint
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
strategy: strategy:
matrix: matrix:
node: [ '15', '14' ] node: [ '14', '15' ,'16', '17' ]
name: Test with Node v${{ matrix.node }} os: [ windows-latest, ubuntu-latest ]
name: Test with Node v${{ matrix.node }} on ${{ matrix.os }}
steps: steps:
- name: Tell if project is using npm or yarn - name: Tell if project is using npm or yarn
id: step1 id: step1
uses: garronej/ts-ci@v1.1.4 uses: garronej/ts-ci@v2.0.2
with: with:
action_name: tell_if_project_uses_npm_or_yarn action_name: tell_if_project_uses_npm_or_yarn
- uses: actions/checkout@v2.3.4 - uses: actions/checkout@v3
- uses: actions/setup-node@v2.1.3 - uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- uses: bahmutov/npm-install@v1 - uses: bahmutov/npm-install@v1
@ -65,9 +64,9 @@ jobs:
from_version: ${{ steps.step1.outputs.from_version }} from_version: ${{ steps.step1.outputs.from_version }}
to_version: ${{ steps.step1.outputs.to_version }} to_version: ${{ steps.step1.outputs.to_version }}
is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }} is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }}
is_release_beta: ${{steps.step1.outputs.is_release_beta }} is_pre_release: ${{steps.step1.outputs.is_pre_release }}
steps: steps:
- uses: garronej/ts-ci@v1.1.4 - uses: garronej/ts-ci@v2.0.2
id: step1 id: step1
with: with:
action_name: is_package_json_version_upgraded action_name: is_package_json_version_upgraded
@ -75,13 +74,13 @@ jobs:
create_github_release: create_github_release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# We create a release only if the version have been upgraded and we are on a default branch # We create a release only if the version have been upgraded and we are on the main branch
# PR on the default branch can release beta but not real release # or if we are on a branch of the repo that has an PR open on main.
if: | if: |
needs.check_if_version_upgraded.outputs.is_upgraded_version == 'true' && needs.check_if_version_upgraded.outputs.is_upgraded_version == 'true' &&
( (
github.event_name == 'push' || github.event_name == 'push' ||
needs.check_if_version_upgraded.outputs.is_release_beta == 'true' needs.check_if_version_upgraded.outputs.is_pre_release == 'true'
) )
needs: needs:
- check_if_version_upgraded - check_if_version_upgraded
@ -93,7 +92,7 @@ jobs:
target_commitish: ${{ github.head_ref || github.ref }} target_commitish: ${{ github.head_ref || github.ref }}
generate_release_notes: true generate_release_notes: true
draft: false draft: false
prerelease: ${{ needs.check_if_version_upgraded.outputs.is_release_beta == 'true' }} prerelease: ${{ needs.check_if_version_upgraded.outputs.is_pre_release == 'true' }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -103,12 +102,11 @@ jobs:
- create_github_release - create_github_release
- check_if_version_upgraded - check_if_version_upgraded
steps: steps:
- uses: actions/checkout@v2.3.4 - uses: actions/checkout@v3
with: with:
ref: ${{ github.ref }} ref: ${{ github.ref }}
- uses: actions/setup-node@v2.1.3 - uses: actions/setup-node@v3
with: with:
node-version: '15'
registry-url: https://registry.npmjs.org/ registry-url: https://registry.npmjs.org/
- uses: bahmutov/npm-install@v1 - uses: bahmutov/npm-install@v1
- run: | - run: |
@ -117,7 +115,7 @@ jobs:
PACKAGE_MANAGER=yarn PACKAGE_MANAGER=yarn
fi fi
$PACKAGE_MANAGER run build $PACKAGE_MANAGER run build
- run: npx -y -p denoify@0.6.5 denoify_enable_short_npm_import_path - run: npx -y -p denoify@1.2.2 enable_short_npm_import_path
env: env:
DRY_RUN: "0" DRY_RUN: "0"
- name: Publishing on NPM - name: Publishing on NPM
@ -131,11 +129,11 @@ jobs:
false false
fi fi
EXTRA_ARGS="" EXTRA_ARGS=""
if [ "$IS_BETA" = "true" ]; then if [ "$IS_PRE_RELEASE" = "true" ]; then
EXTRA_ARGS="--tag beta" EXTRA_ARGS="--tag next"
fi fi
npm publish $EXTRA_ARGS npm publish $EXTRA_ARGS
env: env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
VERSION: ${{ needs.check_if_version_upgraded.outputs.to_version }} VERSION: ${{ needs.check_if_version_upgraded.outputs.to_version }}
IS_BETA: ${{ needs.check_if_version_upgraded.outputs.is_release_beta }} IS_PRE_RELEASE: ${{ needs.check_if_version_upgraded.outputs.is_pre_release }}

View File

@ -5,7 +5,7 @@
"semi": true, "semi": true,
"singleQuote": false, "singleQuote": false,
"quoteProps": "preserve", "quoteProps": "preserve",
"trailingComma": "all", "trailingComma": "none",
"bracketSpacing": true, "bracketSpacing": true,
"arrowParens": "avoid" "arrowParens": "avoid"
} }

View File

@ -2,7 +2,7 @@
<img src="https://user-images.githubusercontent.com/6702424/109387840-eba11f80-7903-11eb-9050-db1dad883f78.png"> <img src="https://user-images.githubusercontent.com/6702424/109387840-eba11f80-7903-11eb-9050-db1dad883f78.png">
</p> </p>
<p align="center"> <p align="center">
<i>🔏 Create Keycloak themes using React 🔏</i> <i>🔏 Create Keycloak themes using React 🔏</i>
<br> <br>
<br> <br>
<a href="https://github.com/garronej/keycloakify/actions"> <a href="https://github.com/garronej/keycloakify/actions">
@ -27,7 +27,14 @@
<a href="https://www.keycloakify.dev">Home</a> <a href="https://www.keycloakify.dev">Home</a>
- -
<a href="https://docs.keycloakify.dev">Documentation</a> <a href="https://docs.keycloakify.dev">Documentation</a>
</p> </p>
<p align="center"> ---- Project starter / Demo setup ---- </p>
<p align="center">
<a href="https://github.com/garronej/keycloakify-starter">CSS Level customization</a>
-
<a href="https://github.com/garronej/keycloakify-advanced-starter">Component Level customization</a>
</p>
<p align="center"> ---- </p>
</p> </p>
@ -36,8 +43,59 @@
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png"> <img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
</p> </p>
> 🗣 V6 have been released 🎉
> [It features major improvements](https://github.com/InseeFrLab/keycloakify#600).
> Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6).
# Changelog highlights # Changelog highlights
## 6.11.4
- You no longer need to have Maven installed to build the theme. Thanks to @lordvlad, [see PR](https://github.com/InseeFrLab/keycloakify/pull/239).
- Feature new build options: [`bundler`](https://docs.keycloakify.dev/build-options#keycloakify.bundler), [`groupId`](https://docs.keycloakify.dev/build-options#keycloakify.groupid), [`artifactId`](https://docs.keycloakify.dev/build-options#keycloakify.artifactid), [`version`](https://docs.keycloakify.dev/build-options#version).
Theses options can be user to customize the output name of the .jar. You can use environnement variables to overrides the values read in the package.json. Thanks to @lordvlad.
## 6.10.0
- Widows compat (thanks to @lordvlad, [see PR](https://github.com/InseeFrLab/keycloakify/pull/226)). WSL is no longer required 🎉
## 6.8.4
- `@emotion/react` is no longer a peer dependency of Keycloakify.
## 6.8.0
- It is now possible to pass a custom `<Template />` component as a prop to `<KcApp />` and every
individual page (`<Login />`, `<RegisterUserProfile />`, ...) it enables to customize only the header and footer for
example without having to switch to a full-component level customization. [See issue](https://github.com/InseeFrLab/keycloakify/issues/191).
## 6.7.0
- Add support for `webauthn-authenticate.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/InseeFrLab/keycloakify/pull/185).
## 6.6.0
- Add support for `login-password.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/InseeFrLab/keycloakify/pull/184).
## 6.5.0
- Add support for `login-username.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/InseeFrLab/keycloakify/pull/183).
## 6.4.0
- You can now optionally pass a `doFetchDefaultThemeResources: boolean` prop to every page component and the default `<KcApp />`
This enables you to prevent the default CSS and JS that comes with the builtin Keycloak theme to be downloaded.
You'll get [a black slate](https://user-images.githubusercontent.com/6702424/192619083-4baa5df4-4a21-4ec7-8e28-d200d1208299.png).
## 6.0.0
- Bundle size drastically reduced, locals and component dynamically loaded.
- First print much quicker, use of React.lazy() everywhere.
- Real i18n API.
- Actual documentation for build options.
Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6)
## 5.8.0 ## 5.8.0
- [React.lazy()](https://reactjs.org/docs/code-splitting.html#reactlazy) support 🎉. [#141](https://github.com/InseeFrLab/keycloakify/issues/141) - [React.lazy()](https://reactjs.org/docs/code-splitting.html#reactlazy) support 🎉. [#141](https://github.com/InseeFrLab/keycloakify/issues/141)

25
package.json Executable file → Normal file
View File

@ -1,6 +1,6 @@
{ {
"name": "keycloakify", "name": "keycloakify",
"version": "6.0.0-beta.2", "version": "6.11.8",
"description": "Keycloak theme generator for Reacts app", "description": "Keycloak theme generator for Reacts app",
"repository": { "repository": {
"type": "git", "type": "git",
@ -13,7 +13,8 @@
"build:test": "rimraf dist_test/ && tsc -p src/test && yarn copy-files dist_test/", "build:test": "rimraf dist_test/ && tsc -p src/test && yarn copy-files dist_test/",
"grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js", "grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js",
"copy-files": "copyfiles -u 1 src/**/*.ftl", "copy-files": "copyfiles -u 1 src/**/*.ftl",
"test": "yarn build:test && node dist_test/test/bin && node dist_test/test/lib", "pretest": "yarn build:test",
"test": "node dist_test/test/bin && node dist_test/test/lib",
"generate-messages": "node dist/bin/generate-i18n-messages.js", "generate-messages": "node dist/bin/generate-i18n-messages.js",
"link_in_test_app": "node dist/bin/link_in_test_app.js", "link_in_test_app": "node dist/bin/link_in_test_app.js",
"_format": "prettier '**/*.{ts,tsx,json,md}'", "_format": "prettier '**/*.{ts,tsx,json,md}'",
@ -21,7 +22,7 @@
"format:check": "yarn _format --list-different" "format:check": "yarn _format --list-different"
}, },
"bin": { "bin": {
"build-keycloak-theme": "dist/bin/build-keycloak-theme/index.js", "keycloakify": "dist/bin/keycloakify/index.js",
"create-keycloak-email-directory": "dist/bin/create-keycloak-email-directory.js", "create-keycloak-email-directory": "dist/bin/create-keycloak-email-directory.js",
"download-builtin-keycloak-theme": "dist/bin/download-builtin-keycloak-theme.js" "download-builtin-keycloak-theme": "dist/bin/download-builtin-keycloak-theme.js"
}, },
@ -54,12 +55,12 @@
], ],
"homepage": "https://github.com/garronej/keycloakify", "homepage": "https://github.com/garronej/keycloakify",
"peerDependencies": { "peerDependencies": {
"@emotion/react": "^11.4.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}, },
"devDependencies": { "devDependencies": {
"@emotion/react": "^11.4.1", "@babel/core": "^7.0.0",
"@types/memoizee": "^0.4.7", "@types/memoizee": "^0.4.7",
"@types/minimist": "^1.2.2",
"@types/node": "^17.0.25", "@types/node": "^17.0.25",
"@types/react": "18.0.9", "@types/react": "18.0.9",
"copyfiles": "^2.4.1", "copyfiles": "^2.4.1",
@ -69,20 +70,24 @@
"properties-parser": "^0.3.1", "properties-parser": "^0.3.1",
"react": "18.1.0", "react": "18.1.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"@emotion/react": "^11.10.4",
"typescript": "^4.2.3" "typescript": "^4.2.3"
}, },
"dependencies": { "dependencies": {
"@octokit/rest": "^18.12.0", "@octokit/rest": "^18.12.0",
"cheerio": "^1.0.0-rc.5", "cheerio": "^1.0.0-rc.5",
"cli-select": "^1.1.2", "cli-select": "^1.1.2",
"evt": "^2.3.1", "evt": "^2.4.13",
"memoizee": "^0.4.15", "memoizee": "^0.4.15",
"minimal-polyfills": "^2.2.1", "minimal-polyfills": "^2.2.2",
"minimist": "^1.2.6",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"powerhooks": "^0.20.10", "powerhooks": "^0.26.1",
"react-markdown": "^5.0.3", "react-markdown": "^5.0.3",
"rfc4648": "^1.5.2",
"scripting-tools": "^0.19.13", "scripting-tools": "^0.19.13",
"tsafe": "^0.10.1", "tsafe": "^1.4.3",
"tss-react": "^3.7.1" "tss-react": "4.4.1-rc.0",
"zod": "^3.17.10"
} }
} }

View File

@ -13,11 +13,11 @@
"packageRules": [ "packageRules": [
{ {
"packagePatterns": ["*"], "packagePatterns": ["*"],
"excludePackagePatterns": ["tss-react", "powerhooks", "tsafe", "evt"], "excludePackagePatterns": ["powerhooks", "tsafe", "evt"],
"enabled": false "enabled": false
}, },
{ {
"packagePatterns": ["tss-react", "powerhooks", "tsafe", "evt"], "packagePatterns": ["powerhooks", "tsafe", "evt"],
"matchUpdateTypes": ["minor", "patch"], "matchUpdateTypes": ["minor", "patch"],
"automerge": true, "automerge": true,
"automergeType": "branch", "automergeType": "branch",

View File

@ -1,139 +0,0 @@
import cheerio from "cheerio";
import { replaceImportsFromStaticInJsCode, replaceImportsInInlineCssCode, generateCssCodeToDefineGlobals } from "../replaceImportFromStatic";
import * as fs from "fs";
import { join as pathJoin } from "path";
import { objectKeys } from "tsafe/objectKeys";
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
// https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java
export const pageIds = [
"login.ftl",
"register.ftl",
"register-user-profile.ftl",
"info.ftl",
"error.ftl",
"login-reset-password.ftl",
"login-verify-email.ftl",
"terms.ftl",
"login-otp.ftl",
"login-update-profile.ftl",
"login-update-password.ftl",
"login-idp-link-confirm.ftl",
"login-idp-link-email.ftl",
"login-page-expired.ftl",
"login-config-totp.ftl",
"logout-confirm.ftl",
] as const;
export type PageId = typeof pageIds[number];
export function generateFtlFilesCodeFactory(params: {
cssGlobalsToDefine: Record<string, string>;
indexHtmlCode: string;
urlPathname: string;
urlOrigin: undefined | string;
}) {
const { cssGlobalsToDefine, indexHtmlCode, urlPathname, urlOrigin } = params;
const $ = cheerio.load(indexHtmlCode);
$("script:not([src])").each((...[, element]) => {
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": $(element).html()!,
urlOrigin,
});
$(element).text(fixedJsCode);
});
$("style").each((...[, element]) => {
const { fixedCssCode } = replaceImportsInInlineCssCode({
"cssCode": $(element).html()!,
"urlPathname": params.urlPathname,
urlOrigin,
});
$(element).text(fixedCssCode);
});
(
[
["link", "href"],
["script", "src"],
] as const
).forEach(([selector, attrName]) =>
$(selector).each((...[, element]) => {
const href = $(element).attr(attrName);
if (href === undefined) {
return;
}
$(element).attr(
attrName,
urlOrigin !== undefined
? href.replace(/^\//, `${urlOrigin}/`)
: href.replace(new RegExp(`^${urlPathname.replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/"),
);
}),
);
//FTL is no valid html, we can't insert with cheerio, we put placeholder for injecting later.
const replaceValueBySearchValue = {
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }': fs
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
.toString("utf8")
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1],
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
"<#if scripts??>",
" <#list scripts as script>",
' <script src="${script}" type="text/javascript"></script>',
" </#list>",
"</#if>",
].join("\n"),
};
$("head").prepend(
[
...(Object.keys(cssGlobalsToDefine).length === 0
? []
: [
"",
"<style>",
generateCssCodeToDefineGlobals({
cssGlobalsToDefine,
urlPathname,
}).cssCodeToPrependInHead,
"</style>",
"",
]),
"<script>",
` window.${ftlValuesGlobalName}= ${objectKeys(replaceValueBySearchValue)[0]};`,
"</script>",
"",
objectKeys(replaceValueBySearchValue)[1],
].join("\n"),
);
const partiallyFixedIndexHtmlCode = $.html();
function generateFtlFilesCode(params: { pageId: string }): {
ftlCode: string;
} {
const { pageId } = params;
const $ = cheerio.load(partiallyFixedIndexHtmlCode);
let ftlCode = $.html();
Object.entries({
...replaceValueBySearchValue,
//If updated, don't forget to change in the ftl script as well.
"PAGE_ID_xIgLsPgGId9D8e": pageId,
}).map(([searchValue, replaceValue]) => (ftlCode = ftlCode.replace(searchValue, replaceValue)));
return { ftlCode };
}
return { generateFtlFilesCode };
}

View File

@ -1,8 +0,0 @@
#!/usr/bin/env node
export * from "./build-keycloak-theme";
import { main } from "./build-keycloak-theme";
if (require.main === module) {
main();
}

View File

@ -1,116 +0,0 @@
import * as crypto from "crypto";
import { ftlValuesGlobalName } from "./ftlValuesGlobalName";
export function replaceImportsFromStaticInJsCode(params: { jsCode: string; urlOrigin: undefined | string }): { fixedJsCode: string } {
/*
NOTE:
When we have urlOrigin defined it means that
we are building with --external-assets
so we have to make sur that the fixed js code will run
inside and outside keycloak.
When urlOrigin isn't defined we can assume the fixedJsCode
will always run in keycloak context.
*/
const { jsCode, urlOrigin } = params;
const fixedJsCode = jsCode
.replace(
/([a-zA-Z]+)\.([a-zA-Z]+)=function\(([a-zA-Z]+)\){return"static\/js\/"/g,
(...[, n, u, e]) => `
${n}[(function(){
${
urlOrigin === undefined
? `
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 ? "${urlOrigin}" : "") + p; },
set: function (value){ p = value;}
});
`
}
return "${u}";
})()] = function(${e}) { return "${urlOrigin === undefined ? "/build/" : ""}static/js/"`,
)
.replace(/([a-zA-Z]+\.[a-zA-Z]+)\+"static\//g, (...[, group]) =>
urlOrigin === undefined
? `window.${ftlValuesGlobalName}.url.resourcesPath + "/build/static/`
: `("${ftlValuesGlobalName}" in window ? "${urlOrigin}" : "") + ${group} + "static/`,
)
//TODO: Write a test case for this
.replace(/".chunk.css",([a-zA-Z])+=([a-zA-Z]+\.[a-zA-Z]+)\+([a-zA-Z]+),/, (...[, group1, group2, group3]) =>
urlOrigin === undefined
? `".chunk.css",${group1} = window.${ftlValuesGlobalName}.url.resourcesPath + "/build/" + ${group3},`
: `".chunk.css",${group1} = ("${ftlValuesGlobalName}" in window ? "${urlOrigin}" : "") + ${group2} + ${group3},`,
);
return { fixedJsCode };
}
export function replaceImportsInInlineCssCode(params: { cssCode: string; urlPathname: string; urlOrigin: undefined | string }): {
fixedCssCode: string;
} {
const { cssCode, urlPathname, urlOrigin } = params;
const fixedCssCode = cssCode.replace(
urlPathname === "/" ? /url\(["']?\/([^/][^)"']+)["']?\)/g : new RegExp(`url\\(["']?${urlPathname}([^)"']+)["']?\\)`, "g"),
(...[, group]) => `url(${urlOrigin === undefined ? "${url.resourcesPath}/build/" + group : params.urlOrigin + urlPathname + group})`,
);
return { fixedCssCode };
}
export function replaceImportsInCssCode(params: { cssCode: string }): {
fixedCssCode: string;
cssGlobalsToDefine: Record<string, string>;
} {
const { cssCode } = params;
const cssGlobalsToDefine: Record<string, string> = {};
new Set(cssCode.match(/url\(["']?\/[^/][^)"']+["']?\)[^;}]*/g) ?? []).forEach(
match => (cssGlobalsToDefine["url" + crypto.createHash("sha256").update(match).digest("hex").substring(0, 15)] = match),
);
let fixedCssCode = cssCode;
Object.keys(cssGlobalsToDefine).forEach(
cssVariableName =>
//NOTE: split/join pattern ~ replace all
(fixedCssCode = fixedCssCode.split(cssGlobalsToDefine[cssVariableName]).join(`var(--${cssVariableName})`)),
);
return { fixedCssCode, cssGlobalsToDefine };
}
export function generateCssCodeToDefineGlobals(params: { cssGlobalsToDefine: Record<string, string>; urlPathname: string }): {
cssCodeToPrependInHead: string;
} {
const { cssGlobalsToDefine, urlPathname } = params;
return {
"cssCodeToPrependInHead": [
":root {",
...Object.keys(cssGlobalsToDefine)
.map(cssVariableName =>
[
`--${cssVariableName}:`,
cssGlobalsToDefine[cssVariableName].replace(
new RegExp(`url\\(${urlPathname.replace(/\//g, "\\/")}`, "g"),
"url(${url.resourcesPath}/build/",
),
].join(" "),
)
.map(line => ` ${line};`),
"}",
].join("\n"),
};
}

View File

@ -1,16 +1,20 @@
#!/usr/bin/env node #!/usr/bin/env node
import { downloadBuiltinKeycloakTheme } from "./download-builtin-keycloak-theme"; import { downloadBuiltinKeycloakTheme } from "./download-builtin-keycloak-theme";
import { keycloakThemeEmailDirPath } from "./build-keycloak-theme"; import { keycloakThemeEmailDirPath } from "./keycloakify";
import { join as pathJoin, basename as pathBasename } from "path"; import { join as pathJoin, basename as pathBasename } from "path";
import { transformCodebase } from "./tools/transformCodebase"; import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./promptKeycloakVersion"; import { promptKeycloakVersion } from "./promptKeycloakVersion";
import * as fs from "fs"; import * as fs from "fs";
import { getCliOptions } from "./tools/cliOptions";
import { getLogger } from "./tools/logger";
if (require.main === module) { if (require.main === module) {
(async () => { (async () => {
const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
if (fs.existsSync(keycloakThemeEmailDirPath)) { if (fs.existsSync(keycloakThemeEmailDirPath)) {
console.log(`There is already a ./${pathBasename(keycloakThemeEmailDirPath)} directory in your project. Aborting.`); logger.warn(`There is already a ./${pathBasename(keycloakThemeEmailDirPath)} directory in your project. Aborting.`);
process.exit(-1); process.exit(-1);
} }
@ -22,14 +26,15 @@ if (require.main === module) {
downloadBuiltinKeycloakTheme({ downloadBuiltinKeycloakTheme({
keycloakVersion, keycloakVersion,
"destDirPath": builtinKeycloakThemeTmpDirPath, "destDirPath": builtinKeycloakThemeTmpDirPath,
isSilent
}); });
transformCodebase({ transformCodebase({
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "base", "email"), "srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "base", "email"),
"destDirPath": keycloakThemeEmailDirPath, "destDirPath": keycloakThemeEmailDirPath
}); });
console.log(`./${pathBasename(keycloakThemeEmailDirPath)} ready to be customized`); logger.log(`./${pathBasename(keycloakThemeEmailDirPath)} ready to be customized`);
fs.rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true, "force": true }); fs.rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true, "force": true });
})(); })();

View File

@ -1,33 +1,40 @@
#!/usr/bin/env node #!/usr/bin/env node
import { keycloakThemeBuildingDirPath } from "./build-keycloak-theme"; import { keycloakThemeBuildingDirPath } from "./keycloakify";
import { join as pathJoin } from "path"; import { join as pathJoin } from "path";
import { downloadAndUnzip } from "./tools/downloadAndUnzip"; import { downloadAndUnzip } from "./tools/downloadAndUnzip";
import { promptKeycloakVersion } from "./promptKeycloakVersion"; import { promptKeycloakVersion } from "./promptKeycloakVersion";
import { getCliOptions } from "./tools/cliOptions";
import { getLogger } from "./tools/logger";
export function downloadBuiltinKeycloakTheme(params: { keycloakVersion: string; destDirPath: string }) { export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: string; destDirPath: string; isSilent: boolean }) {
const { keycloakVersion, destDirPath } = params; const { keycloakVersion, destDirPath, isSilent } = params;
for (const ext of ["", "-community"]) { for (const ext of ["", "-community"]) {
downloadAndUnzip({ await downloadAndUnzip({
"destDirPath": destDirPath, "destDirPath": destDirPath,
"url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`, "url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`,
"pathOfDirToExtractInArchive": `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`, "pathOfDirToExtractInArchive": `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`,
"cacheDirPath": pathJoin(keycloakThemeBuildingDirPath, ".cache"),
isSilent
}); });
} }
} }
if (require.main === module) { if (require.main === module) {
(async () => { (async () => {
const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
const { keycloakVersion } = await promptKeycloakVersion(); const { keycloakVersion } = await promptKeycloakVersion();
const destDirPath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme"); const destDirPath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme");
console.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`); logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`);
downloadBuiltinKeycloakTheme({ await downloadBuiltinKeycloakTheme({
keycloakVersion, keycloakVersion,
destDirPath, destDirPath,
isSilent
}); });
})(); })();
} }

View File

@ -4,7 +4,8 @@ import { join as pathJoin, relative as pathRelative, dirname as pathDirname } fr
import { crawl } from "./tools/crawl"; import { crawl } from "./tools/crawl";
import { downloadBuiltinKeycloakTheme } from "./download-builtin-keycloak-theme"; import { downloadBuiltinKeycloakTheme } from "./download-builtin-keycloak-theme";
import { getProjectRoot } from "./tools/getProjectRoot"; import { getProjectRoot } from "./tools/getProjectRoot";
import { rm_rf, rm_r } from "./tools/rm"; import { getCliOptions } from "./tools/cliOptions";
import { getLogger } from "./tools/logger";
//NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files, //NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
// update the version array for generating for newer version. // update the version array for generating for newer version.
@ -12,16 +13,20 @@ import { rm_rf, rm_r } from "./tools/rm";
//@ts-ignore //@ts-ignore
const propertiesParser = require("properties-parser"); const propertiesParser = require("properties-parser");
const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
for (const keycloakVersion of ["11.0.3", "15.0.2", "18.0.1"]) { for (const keycloakVersion of ["11.0.3", "15.0.2", "18.0.1"]) {
console.log({ keycloakVersion }); logger.log(JSON.stringify({ keycloakVersion }));
const tmpDirPath = pathJoin(getProjectRoot(), "tmp_xImOef9dOd44"); const tmpDirPath = pathJoin(getProjectRoot(), "tmp_xImOef9dOd44");
rm_rf(tmpDirPath); fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
downloadBuiltinKeycloakTheme({ downloadBuiltinKeycloakTheme({
keycloakVersion, keycloakVersion,
"destDirPath": tmpDirPath, "destDirPath": tmpDirPath,
isSilent
}); });
type Dictionary = { [idiomId: string]: string }; type Dictionary = { [idiomId: string]: string };
@ -42,13 +47,13 @@ for (const keycloakVersion of ["11.0.3", "15.0.2", "18.0.1"]) {
(record[typeOfPage] ??= {})[language.replace(/_/g, "-")] = Object.fromEntries( (record[typeOfPage] ??= {})[language.replace(/_/g, "-")] = Object.fromEntries(
Object.entries(propertiesParser.parse(fs.readFileSync(pathJoin(baseThemeDirPath, filePath)).toString("utf8"))).map( Object.entries(propertiesParser.parse(fs.readFileSync(pathJoin(baseThemeDirPath, filePath)).toString("utf8"))).map(
([key, value]: any) => [key, value.replace(/''/g, "'")], ([key, value]: any) => [key, value.replace(/''/g, "'")]
), )
); );
}); });
} }
rm_r(tmpDirPath); fs.rmSync(tmpDirPath, { recursive: true, force: true });
Object.keys(record).forEach(pageType => { Object.keys(record).forEach(pageType => {
const recordForPageType = record[pageType]; const recordForPageType = record[pageType];
@ -69,13 +74,13 @@ for (const keycloakVersion of ["11.0.3", "15.0.2", "18.0.1"]) {
`const messages= ${JSON.stringify(recordForPageType[language], null, 2)};`, `const messages= ${JSON.stringify(recordForPageType[language], null, 2)};`,
"", "",
"export default messages;", "export default messages;",
"/* spell-checker: enable */", "/* spell-checker: enable */"
].join("\n"), ].join("\n"),
"utf8", "utf8"
), )
); );
console.log(`${filePath} wrote`); logger.log(`${filePath} wrote`);
}); });
}); });
} }

View File

@ -0,0 +1,207 @@
import { z } from "zod";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import { id } from "tsafe/id";
import { parse as urlParse } from "url";
import { typeGuard } from "tsafe/typeGuard";
import { symToStr } from "tsafe/symToStr";
const bundlers = ["mvn", "keycloakify", "none"] as const;
type Bundler = typeof bundlers[number];
type ParsedPackageJson = {
name: string;
version: string;
homepage?: string;
keycloakify?: {
extraPages?: string[];
extraThemeProperties?: string[];
areAppAndKeycloakServerSharingSameDomain?: boolean;
artifactId?: string;
groupId?: string;
bundler?: Bundler;
};
};
const zParsedPackageJson = z.object({
"name": z.string(),
"version": z.string(),
"homepage": z.string().optional(),
"keycloakify": z
.object({
"extraPages": z.array(z.string()).optional(),
"extraThemeProperties": z.array(z.string()).optional(),
"areAppAndKeycloakServerSharingSameDomain": z.boolean().optional(),
"artifactId": z.string().optional(),
"groupId": z.string().optional(),
"bundler": z.enum(bundlers).optional()
})
.optional()
});
assert<Equals<ReturnType<typeof zParsedPackageJson["parse"]>, ParsedPackageJson>>();
/** 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;
version: string;
themeName: string;
extraPages?: string[];
extraThemeProperties?: string[];
groupId: string;
artifactId: string;
bundler: Bundler;
};
export type Standalone = Common & {
isStandalone: true;
urlPathname: string | undefined;
};
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
export namespace ExternalAssets {
export type CommonExternalAssets = Common & {
isStandalone: false;
};
export type SameDomain = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: true;
};
export type DifferentDomains = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: false;
urlOrigin: string;
urlPathname: string | undefined;
};
}
}
export function readBuildOptions(params: {
packageJson: string;
CNAME: string | undefined;
isExternalAssetsCliParamProvided: boolean;
isSilent: boolean;
}): BuildOptions {
const { packageJson, CNAME, isExternalAssetsCliParamProvided, isSilent } = params;
const parsedPackageJson = zParsedPackageJson.parse(JSON.parse(packageJson));
const url = (() => {
const { homepage } = parsedPackageJson;
let url: URL | undefined = undefined;
if (homepage !== undefined) {
url = new URL(homepage);
}
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, extraThemeProperties, groupId, artifactId, bundler } = keycloakify ?? {};
const 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"
);
})(),
"version": process.env.KEYCLOAKIFY_VERSION ?? version,
extraPages,
extraThemeProperties,
isSilent
};
})();
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
});
}

View File

@ -32,60 +32,82 @@ ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc};
"printIfExists": function (fieldName, x) { "printIfExists": function (fieldName, x) {
<#if !messagesPerField?? > <#if !messagesPerField?? >
return undefined; return undefined;
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
return <#if messagesPerField.existsError('username', 'password')>x<#else>undefined</#if>;
<#else>
return <#if messagesPerField.existsError('${fieldName}')>x<#else>undefined</#if>;
</#if>
<#recover>
</#attempt>
}
</#list>
throw new Error("There is no " + fieldName + " field");
</#if> </#if>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
return "${messagesPerField.printIfExists(fieldName,'1')}" ? x : undefined;
<#recover>
</#attempt>
}
</#list>
throw new Error("There is no " + fieldName + " field");
}, },
"existsError": function (fieldName) { "existsError": function (fieldName) {
<#if !messagesPerField?? > <#if !messagesPerField?? >
return false; return false;
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
return <#if messagesPerField.existsError('username', 'password')>true<#else>false</#if>;
<#else>
return <#if messagesPerField.existsError('${fieldName}')>true<#else>false</#if>;
</#if>
<#recover>
</#attempt>
}
</#list>
throw new Error("There is no " + fieldName + " field");
</#if> </#if>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
return <#if messagesPerField.existsError('${fieldName}')>true<#else>false</#if>;
<#recover>
</#attempt>
}
</#list>
throw new Error("There is no " + fieldName + " field");
}, },
"get": function (fieldName) { "get": function (fieldName) {
<#if !messagesPerField?? > <#if !messagesPerField?? >
return ''; return '';
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
<#if messagesPerField.existsError('username', 'password')>
return 'Invalid username or password.';
</#if>
<#else>
<#if messagesPerField.existsError('${fieldName}')>
return "${messagesPerField.get('${fieldName}')?no_esc}";
</#if>
</#if>
<#recover>
</#attempt>
}
</#list>
throw new Error("There is no " + fieldName + " field");
</#if> </#if>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
<#if messagesPerField.existsError('${fieldName}')>
return "${messagesPerField.get('${fieldName}')?no_esc}";
</#if>
<#recover>
</#attempt>
}
</#list>
throw new Error("There is no " + fieldName + " field");
}, },
"exists": function (fieldName) { "exists": function (fieldName) {
<#if !messagesPerField?? > <#if !messagesPerField?? >
return false; return false;
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
return <#if messagesPerField.exists('username') || messagesPerField.exists('password')>true<#else>false</#if>;
<#else>
return <#if messagesPerField.exists('${fieldName}')>true<#else>false</#if>;
</#if>
<#recover>
</#attempt>
}
</#list>
throw new Error("There is no " + fieldName + " field");
</#if> </#if>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
return <#if messagesPerField.exists('${fieldName}')>true<#else>false</#if>;
<#recover>
</#attempt>
}
</#list>
throw new Error("There is no " + fieldName + " field");
} }
}; };
@ -272,6 +294,11 @@ ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc};
<#list object as array_item> <#list object as array_item>
<#if !array_item??>
<#local out_seq += ["null,"]>
<#continue>
</#if>
<#local rec_out = ftl_object_to_js_code_declaring_an_object(array_item, path + [ i ])> <#local rec_out = ftl_object_to_js_code_declaring_an_object(array_item, path + [ i ])>
<#local i = i + 1> <#local i = i + 1>

View File

@ -0,0 +1,190 @@
import cheerio from "cheerio";
import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode";
import { generateCssCodeToDefineGlobals } from "../replacers/replaceImportsInCssCode";
import { replaceImportsInInlineCssCode } from "../replacers/replaceImportsInInlineCssCode";
import * as fs from "fs";
import { join as pathJoin } from "path";
import { objectKeys } from "tsafe/objectKeys";
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
// https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java
export const pageIds = [
"login.ftl",
"login-username.ftl",
"login-password.ftl",
"webauthn-authenticate.ftl",
"register.ftl",
"register-user-profile.ftl",
"info.ftl",
"error.ftl",
"login-reset-password.ftl",
"login-verify-email.ftl",
"terms.ftl",
"login-otp.ftl",
"login-update-profile.ftl",
"login-update-password.ftl",
"login-idp-link-confirm.ftl",
"login-idp-link-email.ftl",
"login-page-expired.ftl",
"login-config-totp.ftl",
"logout-confirm.ftl",
"update-user-profile.ftl",
"idp-review-user-profile.ftl"
] as const;
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
export namespace BuildOptionsLike {
export type Standalone = {
isStandalone: true;
urlPathname: string | undefined;
};
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
export namespace ExternalAssets {
export type CommonExternalAssets = {
isStandalone: false;
};
export type SameDomain = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: true;
};
export type DifferentDomains = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: false;
urlOrigin: string;
urlPathname: string | undefined;
};
}
}
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export type PageId = typeof pageIds[number];
export function generateFtlFilesCodeFactory(params: {
indexHtmlCode: string;
//NOTE: Expected to be an empty object if external assets mode is enabled.
cssGlobalsToDefine: Record<string, string>;
buildOptions: BuildOptionsLike;
}) {
const { cssGlobalsToDefine, indexHtmlCode, buildOptions } = 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
});
$(element).text(fixedJsCode);
});
$("style").each((...[, element]) => {
const { fixedCssCode } = replaceImportsInInlineCssCode({
"cssCode": $(element).html()!,
buildOptions
});
$(element).text(fixedCssCode);
});
(
[
["link", "href"],
["script", "src"]
] as const
).forEach(([selector, attrName]) =>
$(selector).each((...[, element]) => {
const href = $(element).attr(attrName);
if (href === undefined) {
return;
}
$(element).attr(
attrName,
buildOptions.isStandalone
? href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/")
: href.replace(/^\//, `${buildOptions.urlOrigin}/`)
);
})
);
if (Object.keys(cssGlobalsToDefine).length !== 0) {
$("head").prepend(
[
"",
"<style>",
generateCssCodeToDefineGlobals({
cssGlobalsToDefine,
buildOptions
}).cssCodeToPrependInHead,
"</style>",
""
].join("\n")
);
}
}
//FTL is no valid html, we can't insert with cheerio, we put placeholder for injecting later.
const replaceValueBySearchValue = {
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }': fs
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
.toString("utf8")
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1],
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
"<#if scripts??>",
" <#list scripts as script>",
' <script src="${script}" type="text/javascript"></script>',
" </#list>",
"</#if>"
].join("\n")
};
$("head").prepend(
[
"<script>",
` window.${ftlValuesGlobalName}= ${objectKeys(replaceValueBySearchValue)[0]};`,
"</script>",
"",
objectKeys(replaceValueBySearchValue)[1]
].join("\n")
);
const partiallyFixedIndexHtmlCode = $.html();
function generateFtlFilesCode(params: { pageId: string }): {
ftlCode: string;
} {
const { pageId } = params;
const $ = cheerio.load(partiallyFixedIndexHtmlCode);
let ftlCode = $.html();
Object.entries({
...replaceValueBySearchValue,
//If updated, don't forget to change in the ftl script as well.
"PAGE_ID_xIgLsPgGId9D8e": pageId
}).map(([searchValue, replaceValue]) => (ftlCode = ftlCode.replace(searchValue, replaceValue)));
return { ftlCode };
}
return { generateFtlFilesCode };
}

View File

@ -1,39 +1,39 @@
import * as url from "url";
import * as fs from "fs"; import * as fs from "fs";
import { join as pathJoin, dirname as pathDirname } from "path"; import { join as pathJoin, dirname as pathDirname } from "path";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "./BuildOptions";
export type BuildOptionsLike = {
themeName: string;
groupId: string;
artifactId?: string;
version: string;
};
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export function generateJavaStackFiles(params: { export function generateJavaStackFiles(params: {
version: string;
themeName: string;
homepage?: string;
keycloakThemeBuildingDirPath: string; keycloakThemeBuildingDirPath: string;
doBundleEmailTemplate: boolean; doBundlesEmailTemplate: boolean;
buildOptions: BuildOptionsLike;
}): { }): {
jarFilePath: string; jarFilePath: string;
} { } {
const { themeName, version, homepage, keycloakThemeBuildingDirPath, doBundleEmailTemplate } = params; const {
buildOptions: { groupId, themeName, version, artifactId },
keycloakThemeBuildingDirPath,
doBundlesEmailTemplate
} = params;
{ {
const { pomFileCode } = (function generatePomFileCode(): { const { pomFileCode } = (function generatePomFileCode(): {
pomFileCode: string; pomFileCode: string;
} { } {
const groupId = (() => {
const fallbackGroupId = `there.was.no.homepage.field.in.the.package.json.${themeName}`;
return (
(!homepage
? fallbackGroupId
: url
.parse(homepage)
.host?.replace(/:[0-9]+$/, "")
?.split(".")
.reverse()
.join(".") ?? fallbackGroupId) + ".keycloak"
);
})();
const artefactId = `${themeName}-keycloak-theme`;
const pomFileCode = [ const pomFileCode = [
`<?xml version="1.0"?>`, `<?xml version="1.0"?>`,
`<project xmlns="http://maven.apache.org/POM/4.0.0"`, `<project xmlns="http://maven.apache.org/POM/4.0.0"`,
@ -41,11 +41,11 @@ export function generateJavaStackFiles(params: {
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`, ` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
` <modelVersion>4.0.0</modelVersion>`, ` <modelVersion>4.0.0</modelVersion>`,
` <groupId>${groupId}</groupId>`, ` <groupId>${groupId}</groupId>`,
` <artifactId>${artefactId}</artifactId>`, ` <artifactId>${artifactId}</artifactId>`,
` <version>${version}</version>`, ` <version>${version}</version>`,
` <name>${artefactId}</name>`, ` <name>${artifactId}</name>`,
` <description />`, ` <description />`,
`</project>`, `</project>`
].join("\n"); ].join("\n");
return { pomFileCode }; return { pomFileCode };
@ -69,19 +69,19 @@ export function generateJavaStackFiles(params: {
"themes": [ "themes": [
{ {
"name": themeName, "name": themeName,
"types": ["login", ...(doBundleEmailTemplate ? ["email"] : [])], "types": ["login", ...(doBundlesEmailTemplate ? ["email"] : [])]
}, }
], ]
}, },
null, null,
2, 2
), ),
"utf8", "utf8"
), )
); );
} }
return { return {
"jarFilePath": pathJoin(keycloakThemeBuildingDirPath, "target", `${themeName}-${version}.jar`), "jarFilePath": pathJoin(keycloakThemeBuildingDirPath, "target", `${artifactId}-${version}.jar`)
}; };
} }

View File

@ -1,116 +1,149 @@
import { transformCodebase } from "../tools/transformCodebase"; import { transformCodebase } from "../tools/transformCodebase";
import * as fs from "fs"; import * as fs from "fs";
import { join as pathJoin, basename as pathBasename } from "path"; import { join as pathJoin, basename as pathBasename } from "path";
import { replaceImportsInCssCode, replaceImportsFromStaticInJsCode } from "./replaceImportFromStatic"; import { replaceImportsFromStaticInJsCode } from "./replacers/replaceImportsFromStaticInJsCode";
import { replaceImportsInCssCode } from "./replacers/replaceImportsInCssCode";
import { generateFtlFilesCodeFactory, pageIds } from "./generateFtl"; import { generateFtlFilesCodeFactory, pageIds } from "./generateFtl";
import { downloadBuiltinKeycloakTheme } from "../download-builtin-keycloak-theme"; import { downloadBuiltinKeycloakTheme } from "../download-builtin-keycloak-theme";
import * as child_process from "child_process";
import { mockTestingResourcesCommonPath, mockTestingResourcesPath, mockTestingSubDirOfPublicDirBasename } from "../mockTestingResourcesPath"; import { mockTestingResourcesCommonPath, mockTestingResourcesPath, mockTestingSubDirOfPublicDirBasename } from "../mockTestingResourcesPath";
import { isInside } from "../tools/isInside"; import { isInside } from "../tools/isInside";
import type { BuildOptions } from "./BuildOptions";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import { getLogger } from "../tools/logger";
export function generateKeycloakThemeResources(params: { export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
themeName: string;
export namespace BuildOptionsLike {
export type Common = {
themeName: string;
extraPages?: string[];
extraThemeProperties?: string[];
isSilent: boolean;
};
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;
};
}
}
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export async function generateKeycloakThemeResources(params: {
reactAppBuildDirPath: string; reactAppBuildDirPath: string;
keycloakThemeBuildingDirPath: string; keycloakThemeBuildingDirPath: string;
keycloakThemeEmailDirPath: string; keycloakThemeEmailDirPath: string;
urlPathname: string;
//If urlOrigin is not undefined then it means --externals-assets
urlOrigin: undefined | string;
extraPagesId: string[];
extraThemeProperties: string[];
keycloakVersion: string; keycloakVersion: string;
}): { doBundleEmailTemplate: boolean } { buildOptions: BuildOptionsLike;
const { }): Promise<{ doBundlesEmailTemplate: boolean }> {
themeName, const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, keycloakThemeEmailDirPath, keycloakVersion, buildOptions } = params;
reactAppBuildDirPath,
keycloakThemeBuildingDirPath,
keycloakThemeEmailDirPath,
urlPathname,
urlOrigin,
extraPagesId,
extraThemeProperties,
keycloakVersion,
} = params;
const themeDirPath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName, "login"); const logger = getLogger({ isSilent: buildOptions.isSilent });
const themeDirPath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, "login");
let allCssGlobalsToDefine: Record<string, string> = {}; let allCssGlobalsToDefine: Record<string, string> = {};
transformCodebase({ transformCodebase({
"destDirPath": urlOrigin === undefined ? pathJoin(themeDirPath, "resources", "build") : reactAppBuildDirPath, "destDirPath": buildOptions.isStandalone ? pathJoin(themeDirPath, "resources", "build") : reactAppBuildDirPath,
"srcDirPath": reactAppBuildDirPath, "srcDirPath": reactAppBuildDirPath,
"transformSourceCode": ({ filePath, sourceCode }) => { "transformSourceCode": ({ filePath, sourceCode }) => {
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/ //NOTE: Prevent cycles, excludes the folder we generated for debug in public/
if ( if (
urlOrigin === undefined && buildOptions.isStandalone &&
isInside({ isInside({
"dirPath": pathJoin(reactAppBuildDirPath, mockTestingSubDirOfPublicDirBasename), "dirPath": pathJoin(reactAppBuildDirPath, mockTestingSubDirOfPublicDirBasename),
filePath, filePath
}) })
) { ) {
return undefined; return undefined;
} }
if (urlOrigin === undefined && /\.css?$/i.test(filePath)) { if (/\.css?$/i.test(filePath)) {
if (!buildOptions.isStandalone) {
return undefined;
}
const { cssGlobalsToDefine, fixedCssCode } = replaceImportsInCssCode({ const { cssGlobalsToDefine, fixedCssCode } = replaceImportsInCssCode({
"cssCode": sourceCode.toString("utf8"), "cssCode": sourceCode.toString("utf8")
}); });
allCssGlobalsToDefine = { allCssGlobalsToDefine = {
...allCssGlobalsToDefine, ...allCssGlobalsToDefine,
...cssGlobalsToDefine, ...cssGlobalsToDefine
}; };
return { return { "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8") };
"modifiedSourceCode": Buffer.from(fixedCssCode, "utf8"),
};
} }
if (/\.js?$/i.test(filePath)) { if (/\.js?$/i.test(filePath)) {
if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) {
return undefined;
}
const { fixedJsCode } = replaceImportsFromStaticInJsCode({ const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": sourceCode.toString("utf8"), "jsCode": sourceCode.toString("utf8"),
urlOrigin, buildOptions
}); });
return { return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
"modifiedSourceCode": Buffer.from(fixedJsCode, "utf8"),
};
} }
return urlOrigin === undefined ? { "modifiedSourceCode": sourceCode } : undefined; return buildOptions.isStandalone ? { "modifiedSourceCode": sourceCode } : undefined;
}, }
}); });
let doBundleEmailTemplate: boolean; let doBundlesEmailTemplate: boolean;
email: { email: {
if (!fs.existsSync(keycloakThemeEmailDirPath)) { if (!fs.existsSync(keycloakThemeEmailDirPath)) {
console.log( logger.log(
[ [
`Not bundling email template because ${pathBasename(keycloakThemeEmailDirPath)} does not exist`, `Not bundling email template because ${pathBasename(keycloakThemeEmailDirPath)} does not exist`,
`To start customizing the email template, run: 👉 npx create-keycloak-email-directory 👈`, `To start customizing the email template, run: 👉 npx create-keycloak-email-directory 👈`
].join("\n"), ].join("\n")
); );
doBundleEmailTemplate = false; doBundlesEmailTemplate = false;
break email; break email;
} }
doBundleEmailTemplate = true; doBundlesEmailTemplate = true;
transformCodebase({ transformCodebase({
"srcDirPath": keycloakThemeEmailDirPath, "srcDirPath": keycloakThemeEmailDirPath,
"destDirPath": pathJoin(themeDirPath, "..", "email"), "destDirPath": pathJoin(themeDirPath, "..", "email")
}); });
} }
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({ const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
"cssGlobalsToDefine": allCssGlobalsToDefine,
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"), "indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
urlPathname, "cssGlobalsToDefine": allCssGlobalsToDefine,
urlOrigin, "buildOptions": buildOptions
}); });
[...pageIds, ...extraPagesId].forEach(pageId => { [...pageIds, ...(buildOptions.extraPages ?? [])].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId }); const { ftlCode } = generateFtlFilesCode({ pageId });
fs.mkdirSync(themeDirPath, { "recursive": true }); fs.mkdirSync(themeDirPath, { "recursive": true });
@ -121,28 +154,29 @@ export function generateKeycloakThemeResources(params: {
{ {
const tmpDirPath = pathJoin(themeDirPath, "..", "tmp_xxKdLpdIdLd"); const tmpDirPath = pathJoin(themeDirPath, "..", "tmp_xxKdLpdIdLd");
downloadBuiltinKeycloakTheme({ await downloadBuiltinKeycloakTheme({
keycloakVersion, keycloakVersion,
"destDirPath": tmpDirPath, "destDirPath": tmpDirPath,
isSilent: buildOptions.isSilent
}); });
const themeResourcesDirPath = pathJoin(themeDirPath, "resources"); const themeResourcesDirPath = pathJoin(themeDirPath, "resources");
transformCodebase({ transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "login", "resources"), "srcDirPath": pathJoin(tmpDirPath, "keycloak", "login", "resources"),
"destDirPath": themeResourcesDirPath, "destDirPath": themeResourcesDirPath
}); });
const reactAppPublicDirPath = pathJoin(reactAppBuildDirPath, "..", "public"); const reactAppPublicDirPath = pathJoin(reactAppBuildDirPath, "..", "public");
transformCodebase({ transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"), "srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"),
"destDirPath": pathJoin(themeResourcesDirPath, pathBasename(mockTestingResourcesCommonPath)), "destDirPath": pathJoin(themeResourcesDirPath, pathBasename(mockTestingResourcesCommonPath))
}); });
transformCodebase({ transformCodebase({
"srcDirPath": themeResourcesDirPath, "srcDirPath": themeResourcesDirPath,
"destDirPath": pathJoin(reactAppPublicDirPath, mockTestingResourcesPath), "destDirPath": pathJoin(reactAppPublicDirPath, mockTestingResourcesPath)
}); });
const keycloakResourcesWithinPublicDirPath = pathJoin(reactAppPublicDirPath, mockTestingSubDirOfPublicDirBasename); const keycloakResourcesWithinPublicDirPath = pathJoin(reactAppPublicDirPath, mockTestingSubDirOfPublicDirBasename);
@ -150,19 +184,18 @@ export function generateKeycloakThemeResources(params: {
fs.writeFileSync( fs.writeFileSync(
pathJoin(keycloakResourcesWithinPublicDirPath, "README.txt"), pathJoin(keycloakResourcesWithinPublicDirPath, "README.txt"),
Buffer.from( Buffer.from(
["This is just a test folder that helps develop", "the login and register page without having to run a Keycloak container"].join(" "), ["This is just a test folder that helps develop", "the login and register page without having to run a Keycloak container"].join(" ")
), )
); );
fs.writeFileSync(pathJoin(keycloakResourcesWithinPublicDirPath, ".gitignore"), Buffer.from("*", "utf8")); fs.writeFileSync(pathJoin(keycloakResourcesWithinPublicDirPath, ".gitignore"), Buffer.from("*", "utf8"));
fs.rmSync(tmpDirPath, { recursive: true, force: true });
child_process.execSync(`rm -r ${tmpDirPath}`);
} }
fs.writeFileSync( fs.writeFileSync(
pathJoin(themeDirPath, "theme.properties"), pathJoin(themeDirPath, "theme.properties"),
Buffer.from("parent=keycloak".concat("\n\n", extraThemeProperties.join("\n\n")), "utf8"), Buffer.from(["parent=keycloak", ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8")
); );
return { doBundleEmailTemplate }; return { doBundlesEmailTemplate };
} }

View File

@ -1,19 +1,40 @@
import * as fs from "fs"; import * as fs from "fs";
import { join as pathJoin } from "path"; 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;
};
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh"; generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh";
const containerName = "keycloak-testing-container"; const containerName = "keycloak-testing-container";
/** Files for being able to run a hot reload keycloak container */ /** Files for being able to run a hot reload keycloak container */
export function generateStartKeycloakTestingContainer(params: { keycloakVersion: string; themeName: string; keycloakThemeBuildingDirPath: string }) { export function generateStartKeycloakTestingContainer(params: {
const { themeName, keycloakThemeBuildingDirPath, keycloakVersion } = params; keycloakVersion: string;
keycloakThemeBuildingDirPath: string;
buildOptions: BuildOptionsLike;
}) {
const {
keycloakThemeBuildingDirPath,
keycloakVersion,
buildOptions: { themeName }
} = params;
fs.writeFileSync( fs.writeFileSync(
pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename), pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename),
Buffer.from( Buffer.from(
[ [
"#!/bin/bash", "#!/usr/bin/env bash",
"", "",
`docker rm ${containerName} || true`, `docker rm ${containerName} || true`,
"", "",
@ -31,14 +52,14 @@ export function generateStartKeycloakTestingContainer(params: { keycloakVersion:
"main", "main",
"resources", "resources",
"theme", "theme",
themeName, themeName
)}:/opt/keycloak/themes/${themeName}:rw \\`, )}:/opt/keycloak/themes/${themeName}:rw \\`,
` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`, ` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`,
` start-dev`, ` start-dev`,
"", ""
].join("\n"), ].join("\n"),
"utf8", "utf8"
), ),
{ "mode": 0o755 }, { "mode": 0o755 }
); );
} }

View File

@ -0,0 +1,8 @@
#!/usr/bin/env node
export * from "./keycloakify";
import { main } from "./keycloakify";
if (require.main === module) {
main().catch(e => console.error(e));
}

View File

@ -3,104 +3,88 @@ import { generateJavaStackFiles } from "./generateJavaStackFiles";
import { join as pathJoin, relative as pathRelative, basename as pathBasename } from "path"; import { join as pathJoin, relative as pathRelative, basename as pathBasename } from "path";
import * as child_process from "child_process"; import * as child_process from "child_process";
import { generateStartKeycloakTestingContainer } from "./generateStartKeycloakTestingContainer"; import { generateStartKeycloakTestingContainer } from "./generateStartKeycloakTestingContainer";
import { URL } from "url";
import * as fs from "fs"; import * as fs from "fs";
import { readBuildOptions } from "./BuildOptions";
type ParsedPackageJson = { import { getLogger } from "../tools/logger";
name: string; import { getCliOptions } from "../tools/cliOptions";
version: string; import jar from "../tools/jar";
homepage?: string; import { assert } from "tsafe/assert";
}; import type { Equals } from "tsafe";
const reactProjectDirPath = process.cwd(); const reactProjectDirPath = process.cwd();
const doUseExternalAssets = process.argv[2]?.toLowerCase() === "--external-assets";
const parsedPackageJson: ParsedPackageJson = require(pathJoin(reactProjectDirPath, "package.json"));
export const keycloakThemeBuildingDirPath = pathJoin(reactProjectDirPath, "build_keycloak"); export const keycloakThemeBuildingDirPath = pathJoin(reactProjectDirPath, "build_keycloak");
export const keycloakThemeEmailDirPath = pathJoin(keycloakThemeBuildingDirPath, "..", "keycloak_email"); export const keycloakThemeEmailDirPath = pathJoin(keycloakThemeBuildingDirPath, "..", "keycloak_email");
function sanitizeThemeName(name: string) { export async function main() {
return name const { isSilent, hasExternalAssets } = getCliOptions(process.argv.slice(2));
.replace(/^@(.*)/, "$1") const logger = getLogger({ isSilent });
.split("/") logger.log("🔏 Building the keycloak theme...⌚");
.join("-");
}
export function main() { const buildOptions = readBuildOptions({
console.log("🔏 Building the keycloak theme...⌚"); "packageJson": fs.readFileSync(pathJoin(reactProjectDirPath, "package.json")).toString("utf8"),
"CNAME": (() => {
const cnameFilePath = pathJoin(reactProjectDirPath, "public", "CNAME");
const extraPagesId: string[] = (parsedPackageJson as any)["keycloakify"]?.["extraPages"] ?? []; if (!fs.existsSync(cnameFilePath)) {
const extraThemeProperties: string[] = (parsedPackageJson as any)["keycloakify"]?.["extraThemeProperties"] ?? []; return undefined;
const themeName = sanitizeThemeName(parsedPackageJson.name); }
const { doBundleEmailTemplate } = generateKeycloakThemeResources({ return fs.readFileSync(cnameFilePath).toString("utf8");
})(),
"isExternalAssetsCliParamProvided": hasExternalAssets,
"isSilent": isSilent
});
const { doBundlesEmailTemplate } = await generateKeycloakThemeResources({
keycloakThemeBuildingDirPath, keycloakThemeBuildingDirPath,
keycloakThemeEmailDirPath, keycloakThemeEmailDirPath,
"reactAppBuildDirPath": pathJoin(reactProjectDirPath, "build"), "reactAppBuildDirPath": pathJoin(reactProjectDirPath, "build"),
themeName, buildOptions,
...(() => {
const url = (() => {
const { homepage } = parsedPackageJson;
if (homepage !== undefined) {
return new URL(homepage);
}
const cnameFilePath = pathJoin(reactProjectDirPath, "public", "CNAME");
if (fs.existsSync(cnameFilePath)) {
return new URL(`https://${fs.readFileSync(cnameFilePath).toString("utf8").replace(/\s+$/, "")}`);
}
return undefined;
})();
return {
"urlPathname": url === undefined ? "/" : url.pathname.replace(/([^/])$/, "$1/"),
"urlOrigin": !doUseExternalAssets
? undefined
: (() => {
if (url === undefined) {
console.error("ERROR: You must specify 'homepage' in your package.json");
process.exit(-1);
}
return url.origin;
})(),
};
})(),
extraPagesId,
extraThemeProperties,
//We have to leave it at that otherwise we break our default theme. //We have to leave it at that otherwise we break our default theme.
//Problem is that we can't guarantee that the the old resources //Problem is that we can't guarantee that the the old resources
//will still be available on the newer keycloak version. //will still be available on the newer keycloak version.
"keycloakVersion": "11.0.3", "keycloakVersion": "11.0.3"
}); });
const { jarFilePath } = generateJavaStackFiles({ const { jarFilePath } = generateJavaStackFiles({
"version": parsedPackageJson.version,
themeName,
"homepage": parsedPackageJson.homepage,
keycloakThemeBuildingDirPath, keycloakThemeBuildingDirPath,
doBundleEmailTemplate, doBundlesEmailTemplate,
buildOptions
}); });
child_process.execSync("mvn package", { switch (buildOptions.bundler) {
"cwd": keycloakThemeBuildingDirPath, case "none":
}); logger.log("😱 Skipping bundling step, there will be no jar");
break;
case "keycloakify":
logger.log("🫶 Let keycloakify do its thang");
await jar({
"rootPath": pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources"),
"version": buildOptions.version,
"groupId": buildOptions.groupId,
"artifactId": buildOptions.artifactId,
"targetPath": jarFilePath
});
break;
case "mvn":
logger.log("🫙 Run maven to deliver a jar");
child_process.execSync("mvn package", { "cwd": keycloakThemeBuildingDirPath });
break;
default:
assert<Equals<typeof buildOptions.bundler, never>>(false);
}
//We want, however, to test in a container running the latest Keycloak version // We want, however, to test in a container running the latest Keycloak version
const containerKeycloakVersion = "18.0.2"; const containerKeycloakVersion = "20.0.1";
generateStartKeycloakTestingContainer({ generateStartKeycloakTestingContainer({
keycloakThemeBuildingDirPath, keycloakThemeBuildingDirPath,
themeName,
"keycloakVersion": containerKeycloakVersion, "keycloakVersion": containerKeycloakVersion,
buildOptions
}); });
console.log( logger.log(
[ [
"", "",
`✅ Your keycloak theme has been generated and bundled into ./${pathRelative(reactProjectDirPath, jarFilePath)} 🚀`, `✅ Your keycloak theme has been generated and bundled into ./${pathRelative(reactProjectDirPath, jarFilePath)} 🚀`,
@ -145,11 +129,11 @@ export function main() {
"- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈", "- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈",
'- Create a realm named "myrealm"', '- Create a realm named "myrealm"',
'- Create a client with ID: "myclient", "Root URL": "https://www.keycloak.org/app/" and "Valid redirect URIs": "https://www.keycloak.org/app/*"', '- Create a client with ID: "myclient", "Root URL": "https://www.keycloak.org/app/" and "Valid redirect URIs": "https://www.keycloak.org/app/*"',
`- Select Login Theme: ${themeName} (don't forget to save at the bottom of the page)`, `- Select Login Theme: ${buildOptions.themeName} (don't forget to save at the bottom of the page)`,
`- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`, `- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`,
"", "",
"Video demoing this process: https://youtu.be/N3wlBoH4hKg", "Video demoing this process: https://youtu.be/N3wlBoH4hKg",
"", ""
].join("\n"), ].join("\n")
); );
} }

View File

@ -0,0 +1,86 @@
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import { Reflect } from "tsafe/Reflect";
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
export namespace BuildOptionsLike {
export type Standalone = {
isStandalone: true;
};
export type ExternalAssets = {
isStandalone: false;
urlOrigin: string;
};
}
{
const buildOptions = Reflect<BuildOptions>();
assert(!is<BuildOptions.ExternalAssets.CommonExternalAssets>(buildOptions));
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export function replaceImportsFromStaticInJsCode(params: { jsCode: string; buildOptions: BuildOptionsLike }): { fixedJsCode: string } {
/*
NOTE:
When we have urlOrigin defined it means that
we are building with --external-assets
so we have to make sur that the fixed js code will run
inside and outside keycloak.
When urlOrigin isn't defined we can assume the fixedJsCode
will always run in keycloak context.
*/
const { jsCode, buildOptions } = params;
const getReplaceArgs = (language: "js" | "css"): Parameters<typeof String.prototype.replace> => [
new RegExp(`([a-zA-Z_]+)\\.([a-zA-Z]+)=function\\(([a-zA-Z]+)\\){return"static\\/${language}\\/"`, "g"),
(...[, n, u, e]) => `
${n}[(function(){
var pd= Object.getOwnPropertyDescriptor(${n}, "p");
if( pd === undefined || pd.configurable ){
${
buildOptions.isStandalone
? `
Object.defineProperty(${n}, "p", {
get: function() { return window.${ftlValuesGlobalName}.url.resourcesPath; },
set: function (){}
});
`
: `
var p= "";
Object.defineProperty(${n}, "p", {
get: function() { return "${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}/" : p; },
set: function (value){ p = value;}
});
`
}
}
return "${u}";
})()] = function(${e}) { return "${buildOptions.isStandalone ? "/build/" : ""}static/${language}/"`
];
const fixedJsCode = jsCode
.replace(...getReplaceArgs("js"))
.replace(...getReplaceArgs("css"))
.replace(/([a-zA-Z]+\.[a-zA-Z]+)\+"static\//g, (...[, group]) =>
buildOptions.isStandalone
? `window.${ftlValuesGlobalName}.url.resourcesPath + "/build/static/`
: `("${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}/" : ${group}) + "static/`
)
//TODO: Write a test case for this
.replace(/".chunk.css",([a-zA-Z])+=([a-zA-Z]+\.[a-zA-Z]+)\+([a-zA-Z]+),/, (...[, group1, group2, group3]) =>
buildOptions.isStandalone
? `".chunk.css",${group1} = window.${ftlValuesGlobalName}.url.resourcesPath + "/build/" + ${group3},`
: `".chunk.css",${group1} = ("${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}/" : ${group2}) + ${group3},`
);
return { fixedJsCode };
}

View File

@ -0,0 +1,64 @@
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>();
}
export function replaceImportsInCssCode(params: { cssCode: string }): {
fixedCssCode: string;
cssGlobalsToDefine: Record<string, string>;
} {
const { cssCode } = params;
const cssGlobalsToDefine: Record<string, string> = {};
new Set(cssCode.match(/url\(["']?\/[^/][^)"']+["']?\)[^;}]*/g) ?? []).forEach(
match => (cssGlobalsToDefine["url" + crypto.createHash("sha256").update(match).digest("hex").substring(0, 15)] = match)
);
let fixedCssCode = cssCode;
Object.keys(cssGlobalsToDefine).forEach(
cssVariableName =>
//NOTE: split/join pattern ~ replace all
(fixedCssCode = fixedCssCode.split(cssGlobalsToDefine[cssVariableName]).join(`var(--${cssVariableName})`))
);
return { fixedCssCode, cssGlobalsToDefine };
}
export function generateCssCodeToDefineGlobals(params: { cssGlobalsToDefine: Record<string, string>; buildOptions: BuildOptionsLike }): {
cssCodeToPrependInHead: string;
} {
const { cssGlobalsToDefine, buildOptions } = params;
return {
"cssCodeToPrependInHead": [
":root {",
...Object.keys(cssGlobalsToDefine)
.map(cssVariableName =>
[
`--${cssVariableName}:`,
cssGlobalsToDefine[cssVariableName].replace(
new RegExp(`url\\(${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`, "g"),
"url(${url.resourcesPath}/build/"
)
].join(" ")
)
.map(line => ` ${line};`),
"}"
].join("\n")
};
}

View File

@ -0,0 +1,47 @@
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 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>();
}
export function replaceImportsInInlineCssCode(params: { cssCode: string; buildOptions: BuildOptionsLike }): {
fixedCssCode: string;
} {
const { cssCode, buildOptions } = params;
const fixedCssCode = cssCode.replace(
buildOptions.urlPathname === undefined
? /url\(["']?\/([^/][^)"']+)["']?\)/g
: new RegExp(`url\\(["']?${buildOptions.urlPathname}([^)"']+)["']?\\)`, "g"),
(...[, group]) =>
`url(${
buildOptions.isStandalone ? "${url.resourcesPath}/build/" + group : buildOptions.urlOrigin + (buildOptions.urlPathname ?? "/") + group
})`
);
return { fixedCssCode };
}

View File

@ -1,5 +1,6 @@
import { execSync } from "child_process"; import { execSync } from "child_process";
import { join as pathJoin, relative as pathRelative } from "path"; import { join as pathJoin, relative as pathRelative } from "path";
import { exclude } from "tsafe/exclude";
import * as fs from "fs"; import * as fs from "fs";
const keycloakifyDirPath = pathJoin(__dirname, "..", ".."); const keycloakifyDirPath = pathJoin(__dirname, "..", "..");
@ -15,13 +16,14 @@ fs.writeFileSync(
...packageJsonParsed, ...packageJsonParsed,
"main": packageJsonParsed["main"].replace(/^dist\//, ""), "main": packageJsonParsed["main"].replace(/^dist\//, ""),
"types": packageJsonParsed["types"].replace(/^dist\//, ""), "types": packageJsonParsed["types"].replace(/^dist\//, ""),
"bin": Object.fromEntries(Object.entries<string>(packageJsonParsed["bin"]).map(([k, v]) => [k, v.replace(/^dist\//, "")]))
}; };
})(), })(),
null, null,
2, 2
), ),
"utf8", "utf8"
), )
); );
const commonThirdPartyDeps = (() => { const commonThirdPartyDeps = (() => {
@ -33,16 +35,17 @@ const commonThirdPartyDeps = (() => {
.map(namespaceModuleName => .map(namespaceModuleName =>
fs fs
.readdirSync(pathJoin(keycloakifyDirPath, "node_modules", namespaceModuleName)) .readdirSync(pathJoin(keycloakifyDirPath, "node_modules", namespaceModuleName))
.map(submoduleName => `${namespaceModuleName}/${submoduleName}`), .map(submoduleName => `${namespaceModuleName}/${submoduleName}`)
) )
.reduce((prev, curr) => [...prev, ...curr], []), .reduce((prev, curr) => [...prev, ...curr], []),
...standaloneModuleNames, ...standaloneModuleNames
]; ];
})(); })();
const yarnHomeDirPath = pathJoin(keycloakifyDirPath, ".yarn_home"); const yarnHomeDirPath = pathJoin(keycloakifyDirPath, ".yarn_home");
execSync(["rm -rf", "mkdir"].map(cmd => `${cmd} ${yarnHomeDirPath}`).join(" && ")); fs.rmSync(yarnHomeDirPath, { "recursive": true, "force": true });
fs.mkdirSync(yarnHomeDirPath);
const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => { const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
const { targetModuleName, cwd } = params; const { targetModuleName, cwd } = params;
@ -55,16 +58,37 @@ const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
cwd, cwd,
"env": { "env": {
...process.env, ...process.env,
"HOME": yarnHomeDirPath, "HOME": yarnHomeDirPath
}, }
}); });
}; };
const testAppNames = [process.argv[2] ?? "keycloakify-demo-app"] as const; const testAppPaths = (() => {
const arg = process.argv[2];
const getTestAppPath = (testAppName: typeof testAppNames[number]) => pathJoin(keycloakifyDirPath, "..", testAppName); const testAppNames = arg !== undefined ? [arg] : ["keycloakify-starter", "keycloakify-advanced-starter"];
testAppNames.forEach(testAppName => execSync("yarn install", { "cwd": getTestAppPath(testAppName) })); return testAppNames
.map(testAppName => {
const testAppPath = pathJoin(keycloakifyDirPath, "..", testAppName);
if (fs.existsSync(testAppPath)) {
return testAppPath;
}
console.warn(`Skipping ${testAppName} since it cant be found here: ${testAppPath}`);
return undefined;
})
.filter(exclude(undefined));
})();
if (testAppPaths.length === 0) {
console.error("No test app to link into!");
process.exit(-1);
}
testAppPaths.forEach(testAppPath => execSync("yarn install", { "cwd": testAppPath }));
console.log("=== Linking common dependencies ==="); console.log("=== Linking common dependencies ===");
@ -77,26 +101,28 @@ commonThirdPartyDeps.forEach(commonThirdPartyDep => {
console.log(`${current}/${total} ${commonThirdPartyDep}`); console.log(`${current}/${total} ${commonThirdPartyDep}`);
const localInstallPath = pathJoin( const localInstallPath = pathJoin(
...[keycloakifyDirPath, "node_modules", ...(commonThirdPartyDep.startsWith("@") ? commonThirdPartyDep.split("/") : [commonThirdPartyDep])], ...[keycloakifyDirPath, "node_modules", ...(commonThirdPartyDep.startsWith("@") ? commonThirdPartyDep.split("/") : [commonThirdPartyDep])]
); );
execYarnLink({ "cwd": localInstallPath }); execYarnLink({ "cwd": localInstallPath });
testAppNames.forEach(testAppName =>
execYarnLink({
"cwd": getTestAppPath(testAppName),
"targetModuleName": commonThirdPartyDep,
}),
);
}); });
commonThirdPartyDeps.forEach(commonThirdPartyDep =>
testAppPaths.forEach(testAppPath =>
execYarnLink({
"cwd": testAppPath,
"targetModuleName": commonThirdPartyDep
})
)
);
console.log("=== Linking in house dependencies ==="); console.log("=== Linking in house dependencies ===");
execYarnLink({ "cwd": pathJoin(keycloakifyDirPath, "dist") }); execYarnLink({ "cwd": pathJoin(keycloakifyDirPath, "dist") });
testAppNames.forEach(testAppName => testAppPaths.forEach(testAppPath =>
execYarnLink({ execYarnLink({
"cwd": getTestAppPath(testAppName), "cwd": testAppPath,
"targetModuleName": "keycloakify", "targetModuleName": "keycloakify"
}), })
); );

View File

@ -24,9 +24,9 @@ export async function promptKeycloakVersion() {
"count": 10, "count": 10,
"doIgnoreBeta": true, "doIgnoreBeta": true,
"owner": "keycloak", "owner": "keycloak",
"repo": "keycloak", "repo": "keycloak"
}).then(arr => arr.map(({ tag }) => tag))), }).then(arr => arr.map(({ tag }) => tag))),
"11.0.3", "11.0.3"
]; ];
if (process.env["GITHUB_ACTIONS"] === "true") { if (process.env["GITHUB_ACTIONS"] === "true") {
@ -34,7 +34,7 @@ export async function promptKeycloakVersion() {
} }
const { value: keycloakVersion } = await cliSelect<string>({ const { value: keycloakVersion } = await cliSelect<string>({
"values": tags, "values": tags
}).catch(() => { }).catch(() => {
console.log("Aborting"); console.log("Aborting");

View File

@ -20,7 +20,7 @@ export namespace NpmModuleVersion {
...(() => { ...(() => {
const str = match[4]; const str = match[4];
return str === undefined ? {} : { "betaPreRelease": parseInt(str) }; return str === undefined ? {} : { "betaPreRelease": parseInt(str) };
})(), })()
}; };
} }

View File

@ -0,0 +1,15 @@
import parseArgv from "minimist";
export type CliOptions = {
isSilent: boolean;
hasExternalAssets: boolean;
};
export const getCliOptions = (processArgv: string[]): CliOptions => {
const argv = parseArgv(processArgv);
return {
isSilent: typeof argv["silent"] === "boolean" ? argv["silent"] : false,
hasExternalAssets: typeof argv["external-assets"] === "boolean" ? argv["external-assets"] : false
};
};

54
src/bin/tools/crc32.ts Normal file
View File

@ -0,0 +1,54 @@
import { Readable } from "stream";
const crc32tab = [
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,
0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7,
0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172,
0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59,
0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924,
0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e,
0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65,
0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0,
0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f,
0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a,
0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc,
0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236,
0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d,
0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38,
0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2,
0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,
0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d
];
/**
*
* @param input either a byte stream, a string or a buffer, you want to have the checksum for
* @returns a promise for a checksum (uint32)
*/
export function crc32(input: Readable | String | Buffer): Promise<number> {
if (typeof input === "string") {
let crc = ~0;
for (let i = 0; i < input.length; i++) crc = (crc >>> 8) ^ crc32tab[(crc ^ input.charCodeAt(i)) & 0xff];
return Promise.resolve((crc ^ -1) >>> 0);
} else if (input instanceof Buffer) {
let crc = ~0;
for (let i = 0; i < input.length; i++) crc = (crc >>> 8) ^ crc32tab[(crc ^ input[i]) & 0xff];
return Promise.resolve((crc ^ -1) >>> 0);
} else if (input instanceof Readable) {
return new Promise<number>((resolve, reject) => {
let crc = ~0;
input.on("end", () => resolve((crc ^ -1) >>> 0));
input.on("error", e => reject(e));
input.on("data", (chunk: Buffer) => {
for (let i = 0; i < chunk.length; i++) crc = (crc >>> 8) ^ crc32tab[(crc ^ chunk[i]) & 0xff];
});
});
} else {
throw new Error("Unsupported input " + typeof input);
}
}

61
src/bin/tools/deflate.ts Normal file
View File

@ -0,0 +1,61 @@
import { PassThrough, Readable, TransformCallback, Writable } from "stream";
import { pipeline } from "stream/promises";
import { deflateRaw as deflateRawCb, createDeflateRaw } from "zlib";
import { promisify } from "util";
import { crc32 } from "./crc32";
import tee from "./tee";
const deflateRaw = promisify(deflateRawCb);
/**
* A stream transformer that records the number of bytes
* passed in its `size` property.
*/
class ByteCounter extends PassThrough {
size: number = 0;
_transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback) {
if ("length" in chunk) this.size += chunk.length;
super._transform(chunk, encoding, callback);
}
}
/**
* @param data buffer containing the data to be compressed
* @returns a buffer containing the compressed/deflated data and the crc32 checksum
* of the source data
*/
export async function deflateBuffer(data: Buffer) {
const [deflated, checksum] = await Promise.all([deflateRaw(data), crc32(data)]);
return { deflated, crc32: checksum };
}
/**
* @param input a byte stream, containing data to be compressed
* @param sink a method that will accept chunks of compressed data; We don't pass
* a writable here, since we don't want the writablestream to be closed after
* a single file
* @returns a promise, which will resolve with the crc32 checksum and the
* compressed size
*/
export async function deflateStream(input: Readable, sink: (chunk: Buffer) => void) {
const deflateWriter = new Writable({
write(chunk, _, callback) {
sink(chunk);
callback();
}
});
// tee the input stream, so we can compress and calc crc32 in parallel
const [rs1, rs2] = tee(input);
const byteCounter = new ByteCounter();
const [_, crc] = await Promise.all([
// pipe input into zip compressor, count the bytes
// returned and pass compressed data to the sink
pipeline(rs1, createDeflateRaw(), byteCounter, deflateWriter),
// calc checksum
crc32(rs2)
]);
return { crc32: crc, compressedSize: byteCounter.size };
}

View File

@ -1,32 +1,289 @@
import { basename as pathBasename, join as pathJoin } from "path"; import { dirname as pathDirname, basename as pathBasename, join as pathJoin } from "path";
import { execSync } from "child_process"; import { createReadStream, createWriteStream, unlinkSync } from "fs";
import * as fs from "fs"; import { stat, mkdir, unlink, readFile, writeFile } from "fs/promises";
import { transformCodebase } from "./transformCodebase"; import { transformCodebase } from "./transformCodebase";
import { rm_rf, rm, rm_r } from "./rm"; import { createHash } from "crypto";
import http from "http";
import https from "https";
import { createInflateRaw } from "zlib";
/** assert url ends with .zip */ import type { Readable } from "stream";
export function downloadAndUnzip(params: { url: string; destDirPath: string; pathOfDirToExtractInArchive?: string }) {
const { url, destDirPath, pathOfDirToExtractInArchive } = params;
const tmpDirPath = pathJoin(destDirPath, "..", "tmp_xxKdOxnEdx"); function hash(s: string) {
const zipFilePath = pathBasename(url); return createHash("sha256").update(s).digest("hex");
}
rm_rf(tmpDirPath);
async function maybeReadFile(path: string) {
fs.mkdirSync(tmpDirPath, { "recursive": true }); try {
return await readFile(path, "utf-8");
execSync(`curl -L ${url} -o ${zipFilePath}`, { "cwd": tmpDirPath }); } catch (error) {
if ((error as Error & { code: string }).code === "ENOENT") return undefined;
execSync(`unzip -o ${zipFilePath}${pathOfDirToExtractInArchive === undefined ? "" : ` "${pathOfDirToExtractInArchive}/**/*"`}`, { throw error;
"cwd": tmpDirPath, }
}); }
rm(pathBasename(url), { "cwd": tmpDirPath }); async function maybeStat(path: string) {
try {
transformCodebase({ return await stat(path);
"srcDirPath": pathOfDirToExtractInArchive === undefined ? tmpDirPath : pathJoin(tmpDirPath, pathOfDirToExtractInArchive), } catch (error) {
destDirPath, if ((error as Error & { code: string }).code === "ENOENT") return undefined;
}); throw error;
}
rm_r(tmpDirPath); }
/**
* Download a file from `url` to `dir`. Will try to avoid downloading existing
* files by using an `{hash(url)}.etag` file. If this file exists, we add an
* etag headear, so server can tell us if file changed and we should re-download
* or if our file is up-to-date.
*
* Warning, this method assumes that the target filename can be extracted from
* url, content-disposition headers are ignored.
*
* If the target directory does not exist, it will be created.
*
* If the target file exists and is out of date, it will be overwritten.
* If the target file exists and there is no etag file, the target file will
* be overwritten.
*
* @param url download url
* @param dir target directory
* @returns promise for the full path of the downloaded file
*/
async function download(url: string, dir: string): Promise<string> {
await mkdir(dir, { recursive: true });
const filename = pathBasename(url);
const filepath = pathJoin(dir, filename);
// If downloaded file exists already and has an `.etag` companion file,
// read the etag from that file. This will avoid re-downloading the file
// if it is up to date.
const exists = await maybeStat(filepath);
const etagFilepath = pathJoin(dir, "_" + hash(url).substring(0, 15) + ".etag");
const etag = !exists ? undefined : await maybeReadFile(etagFilepath);
return new Promise((resolve, reject) => {
// use inner method to allow following redirects
function request(url1: URL) {
const headers: Record<string, string> = {};
if (etag) headers["If-None-Match"] = etag;
(url1.protocol === "https:" ? https : http).get(url1, { headers }, response => {
if (response.statusCode === 301 || response.statusCode === 302) {
// follow redirects
request(new URL(response.headers.location!!));
} else if (response.statusCode === 304) {
// up-to-date, resolve now
resolve(filepath);
} else if (response.statusCode !== 200) {
reject(new Error(`Request to ${url1} returned status ${response.statusCode}.`));
} else {
const fp = createWriteStream(filepath, { autoClose: true });
fp.on("err", e => {
fp.close();
unlinkSync(filepath);
reject(e);
});
fp.on("finish", async () => {
// when targetfile has been written, write etag file so that
// next time around we don't need to re-download
const responseEtag = response.headers.etag;
if (responseEtag) await writeFile(etagFilepath, responseEtag, "utf-8");
resolve(filepath);
});
response.pipe(fp);
}
});
}
request(new URL(url));
});
}
/**
* @typedef
* @type MultiError = Error & { cause: Error[] }
*/
/**
* Extract the archive `zipFile` into the directory `dir`. If `archiveDir` is given,
* only that directory will be extracted, stripping the given path components.
*
* If dir does not exist, it will be created.
*
* If any archive file exists, it will be overwritten.
*
* Will unzip using all available nodejs worker threads.
*
* Will try to clean up extracted files on failure.
*
* If unpacking fails, will either throw an regular error, or
* possibly an `MultiError`, which contains a `cause` field with
* a number of root cause errors.
*
* Warning this method is not optimized for continuous reading of the zip
* archive, but is a trade-off between simplicity and allowing extraction
* of a single directory from the archive.
*
* @param zipFile the file to unzip
* @param dir the target directory
* @param archiveDir if given, unpack only files from this archive directory
* @throws {MultiError} error
* @returns Promise for a list of full file paths pointing to actually extracted files
*/
async function unzip(zipFile: string, dir: string, archiveDir?: string): Promise<string[]> {
await mkdir(dir, { recursive: true });
const promises: Promise<string>[] = [];
// Iterate over all files in the zip, skip files which are not in archiveDir,
// if given.
for await (const record of iterateZipArchive(zipFile)) {
const { path: recordPath, createReadStream: createRecordReadStream } = record;
const filePath = pathJoin(dir, recordPath);
const parent = pathDirname(filePath);
if (archiveDir && !recordPath.startsWith(archiveDir)) continue;
promises.push(
new Promise<string>(async (resolve, reject) => {
await mkdir(parent, { recursive: true });
// Pull the file out of the archive, write it to the target directory
const input = createRecordReadStream();
const output = createWriteStream(filePath);
output.on("error", e => reject(Object.assign(e, { filePath })));
output.on("finish", () => resolve(filePath));
input.pipe(output);
})
);
}
// Wait until _all_ files are either extracted or failed
const results = await Promise.allSettled(promises);
const success = results.filter(r => r.status === "fulfilled").map(r => (r as PromiseFulfilledResult<string>).value);
const failure = results.filter(r => r.status === "rejected").map(r => (r as PromiseRejectedResult).reason);
// If any extraction failed, try to clean up, then throw a MultiError,
// which has a `cause` field, containing a list of root cause errors.
if (failure.length) {
await Promise.all(success.map(path => unlink(path)));
await Promise.all(failure.map(e => e && e.path && unlink(e.path as string)));
const e = new Error("Failed to extract: " + failure.map(e => e.message).join(";"));
(e as any).cause = failure;
throw e;
}
return success;
}
/**
*
* @param file file to read
* @param start first byte to read
* @param end last byte to read
* @returns Promise of a buffer of read bytes
*/
async function readFileChunk(file: string, start: number, end: number): Promise<Buffer> {
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
const stream = createReadStream(file, { start, end });
stream.on("error", e => reject(e));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("data", chunk => chunks.push(chunk as Buffer));
});
}
type ZipRecord = {
path: string;
createReadStream: () => Readable;
compressionMethod: "deflate" | undefined;
};
type ZipRecordGenerator = AsyncGenerator<ZipRecord, void, unknown>;
/**
* Iterate over all records of a zipfile, and yield a ZipRecord.
* Use `record.createReadStream()` to actually read the file.
*
* Warning this method will only work with single-disk zip files.
* Warning this method may fail if the zip archive has an crazy amount
* of files and the central directory is not fully contained within the
* last 65k bytes of the zip file.
*
* @param zipFile
* @returns AsyncGenerator which will yield ZipRecords
*/
async function* iterateZipArchive(zipFile: string): ZipRecordGenerator {
// Need to know zip file size before we can do anything else
const { size } = await stat(zipFile);
const chunkSize = 65_535 + 22 + 1; // max comment size + end header size + wiggle
// Read last ~65k bytes. Zip files have an comment up to 65_535 bytes at the very end,
// before that comes the zip central directory end header.
let chunk = await readFileChunk(zipFile, size - chunkSize, size);
const unread = size - chunk.length;
let i = chunk.length - 4;
let found = false;
// Find central directory end header, reading backwards from the end
while (!found && i-- > 0) if (chunk[i] === 0x50 && chunk.readUInt32LE(i) === 0x06054b50) found = true;
if (!found) throw new Error("Not a zip file");
// This method will fail on a multi-disk zip, so bail early.
if (chunk.readUInt16LE(i + 4) !== 0) throw new Error("Multi-disk zip not supported");
let nFiles = chunk.readUint16LE(i + 10);
// Get the position of the central directory
const directorySize = chunk.readUint32LE(i + 12);
const directoryOffset = chunk.readUint32LE(i + 16);
if (directoryOffset === 0xffff_ffff) throw new Error("zip64 not supported");
if (directoryOffset > size) throw new Error(`Central directory offset ${directoryOffset} is outside file`);
i = directoryOffset - unread;
// If i < 0, it means that the central directory is not contained within `chunk`
if (i < 0) {
chunk = await readFileChunk(zipFile, directoryOffset, directoryOffset + directorySize);
i = 0;
}
// Now iterate the central directory records, yield an `ZipRecord` for every entry
while (nFiles-- > 0) {
// Check for marker bytes
if (chunk.readUInt32LE(i) !== 0x02014b50) throw new Error("No central directory record at position " + (unread + i));
const compressionMethod = ({ 8: "deflate" } as const)[chunk.readUint16LE(i + 10)];
const compressedFileSize = chunk.readUint32LE(i + 20);
const filenameLength = chunk.readUint16LE(i + 28);
const extraLength = chunk.readUint16LE(i + 30);
const commentLength = chunk.readUint16LE(i + 32);
// Start of the actual content byte stream is after the 'local' record header,
// which is 30 bytes long plus filename and extra field
const start = chunk.readUint32LE(i + 42) + 30 + filenameLength + extraLength;
const end = start + compressedFileSize;
const filename = chunk.slice(i + 46, i + 46 + filenameLength).toString("utf-8");
const createRecordReadStream = () => {
const input = createReadStream(zipFile, { start, end });
if (compressionMethod === "deflate") {
const inflate = createInflateRaw();
input.pipe(inflate);
return inflate;
}
return input;
};
if (end > start) yield { path: filename, createReadStream: createRecordReadStream, compressionMethod };
// advance pointer to next central directory entry
i += 46 + filenameLength + extraLength + commentLength;
}
}
export async function downloadAndUnzip({
url,
destDirPath,
pathOfDirToExtractInArchive,
cacheDirPath
}: {
isSilent: boolean;
url: string;
destDirPath: string;
pathOfDirToExtractInArchive?: string;
cacheDirPath: string;
}) {
const downloadHash = hash(JSON.stringify({ url, pathOfDirToExtractInArchive })).substring(0, 15);
const extractDirPath = pathJoin(cacheDirPath, `_${downloadHash}`);
const zipFilepath = await download(url, cacheDirPath);
const zipMtime = (await stat(zipFilepath)).mtimeMs;
const unzipMtime = (await maybeStat(extractDirPath))?.mtimeMs;
if (!unzipMtime || zipMtime > unzipMtime) await unzip(zipFilepath, extractDirPath, pathOfDirToExtractInArchive);
const srcDirPath = pathOfDirToExtractInArchive === undefined ? extractDirPath : pathJoin(extractDirPath, pathOfDirToExtractInArchive);
transformCodebase({ srcDirPath, destDirPath });
} }

View File

@ -1,10 +1,17 @@
import { getProjectRoot } from "./getProjectRoot"; import { getProjectRoot } from "./getProjectRoot";
import { join as pathJoin } from "path"; import { join as pathJoin } from "path";
import * as child_process from "child_process"; import { constants } from "fs";
import * as fs from "fs"; import { chmod, stat } from "fs/promises";
Object.entries<string>(JSON.parse(fs.readFileSync(pathJoin(getProjectRoot(), "package.json")).toString("utf8"))["bin"]).forEach(([, scriptPath]) => (async () => {
child_process.execSync(`chmod +x ${scriptPath}`, { const { bin } = await import(pathJoin(getProjectRoot(), "package.json"));
"cwd": getProjectRoot(),
}), const promises = Object.values<string>(bin).map(async scriptPath => {
); const fullPath = pathJoin(getProjectRoot(), scriptPath);
const oldMode = (await stat(fullPath)).mode;
const newMode = oldMode | constants.S_IXUSR | constants.S_IXGRP | constants.S_IXOTH;
await chmod(fullPath, newMode);
});
await Promise.all(promises);
})();

102
src/bin/tools/jar.ts Normal file
View File

@ -0,0 +1,102 @@
import { Readable, Transform } from "stream";
import { dirname, relative, sep } from "path";
import { createWriteStream } from "fs";
import walk from "./walk";
import type { ZipSource } from "./zip";
import zip from "./zip";
import { mkdir } from "fs/promises";
/** Trim leading whitespace from every line */
const trimIndent = (s: string) => s.replace(/(\n)\s+/g, "$1");
type JarArgs = {
rootPath: string;
targetPath: string;
groupId: string;
artifactId: string;
version: string;
};
/**
* Create a jar archive, using the resources found at `rootPath` (a directory) and write the
* archive to `targetPath` (a file). Use `groupId`, `artifactId` and `version` to define
* the contents of the pom.properties file which is going to be added to the archive.
*/
export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) {
const manifest: ZipSource = {
path: "META-INF/MANIFEST.MF",
data: Buffer.from(
trimIndent(
`Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Created-By: Keycloakify
Built-By: unknown
Build-Jdk: 19.0.0`
)
)
};
const pomProps: ZipSource = {
path: `META-INF/maven/${groupId}/${artifactId}/pom.properties`,
data: Buffer.from(
trimIndent(
`# Generated by keycloakify
# ${new Date()}
artifactId=${artifactId}
groupId=${groupId}
version=${version}`
)
)
};
/**
* Convert every path entry to a ZipSource record, and when all records are
* processed, append records for MANIFEST.mf and pom.properties
*/
const pathToRecord = () =>
new Transform({
objectMode: true,
transform: function (fsPath, _, cb) {
const path = relative(rootPath, fsPath).split(sep).join("/");
this.push({ path, fsPath });
cb();
},
final: function () {
this.push(manifest);
this.push(pomProps);
this.push(null);
}
});
await mkdir(dirname(targetPath), { recursive: true });
// Create an async pipeline, wait until everything is fully processed
await new Promise<void>((resolve, reject) => {
// walk all files in `rootPath` recursively
Readable.from(walk(rootPath))
// transform every path into a ZipSource object
.pipe(pathToRecord())
// let the zip lib convert all ZipSource objects into a byte stream
.pipe(zip())
// write that byte stream to targetPath
.pipe(createWriteStream(targetPath, { encoding: "binary" }))
.on("finish", () => resolve())
.on("error", e => reject(e));
});
}
/**
* Standalone usage, call e.g. `ts-node jar.ts dirWithSources some-jar.jar`
*/
if (require.main === module) {
const main = () =>
jar({
rootPath: process.argv[2],
targetPath: process.argv[3],
artifactId: process.env.ARTIFACT_ID ?? "artifact",
groupId: process.env.GROUP_ID ?? "group",
version: process.env.VERSION ?? "1.0.0"
});
main().catch(e => console.error(e));
}

27
src/bin/tools/logger.ts Normal file
View File

@ -0,0 +1,27 @@
type LoggerOpts = {
force?: boolean;
};
type Logger = {
log: (message: string, opts?: LoggerOpts) => void;
warn: (message: string) => void;
error: (message: string) => void;
};
export const getLogger = ({ isSilent }: { isSilent?: boolean } = {}): Logger => {
return {
log: (message, { force } = {}) => {
if (isSilent && !force) {
return;
}
console.log(message);
},
warn: message => {
console.warn(message);
},
error: message => {
console.error(message);
}
};
};

View File

@ -19,7 +19,7 @@ export function listTagsFactory(params: { octokit: Octokit }) {
owner, owner,
repo, repo,
per_page, per_page,
"page": page++, "page": page++
}); });
for (const branch of resp.data.map(({ name }) => name)) { for (const branch of resp.data.map(({ name }) => name)) {

View File

@ -1,31 +0,0 @@
import { execSync } from "child_process";
function rmInternal(params: { pathToRemove: string; args: string | undefined; cwd: string | undefined }) {
const { pathToRemove, args, cwd } = params;
execSync(`rm ${args ? `-${args} ` : ""}${pathToRemove.replace(/ /g, "\\ ")}`, cwd !== undefined ? { cwd } : undefined);
}
export function rm(pathToRemove: string, options?: { cwd: string }) {
rmInternal({
pathToRemove,
"args": undefined,
"cwd": options?.cwd,
});
}
export function rm_r(pathToRemove: string, options?: { cwd: string }) {
rmInternal({
pathToRemove,
"args": "r",
"cwd": options?.cwd,
});
}
export function rm_rf(pathToRemove: string, options?: { cwd: string }) {
rmInternal({
pathToRemove,
"args": "rf",
"cwd": options?.cwd,
});
}

37
src/bin/tools/tee.ts Normal file
View File

@ -0,0 +1,37 @@
import { PassThrough, Readable } from "stream";
export default function tee(input: Readable) {
const a = new PassThrough();
const b = new PassThrough();
let aFull = false;
let bFull = false;
a.on("drain", () => {
aFull = false;
if (!aFull && !bFull) input.resume();
});
b.on("drain", () => {
bFull = false;
if (!aFull && !bFull) input.resume();
});
input.on("error", e => {
a.emit("error", e);
b.emit("error", e);
});
input.on("data", chunk => {
aFull = !a.write(chunk);
bFull = !b.write(chunk);
if (aFull || bFull) input.pause();
});
input.on("end", () => {
a.end();
b.end();
});
return [a, b] as const;
}

View File

@ -16,8 +16,8 @@ export function transformCodebase(params: { srcDirPath: string; destDirPath: str
srcDirPath, srcDirPath,
destDirPath, destDirPath,
transformSourceCode = id<TransformSourceCode>(({ sourceCode }) => ({ transformSourceCode = id<TransformSourceCode>(({ sourceCode }) => ({
"modifiedSourceCode": sourceCode, "modifiedSourceCode": sourceCode
})), }))
} = params; } = params;
for (const file_relative_path of crawl(srcDirPath)) { for (const file_relative_path of crawl(srcDirPath)) {
@ -25,7 +25,7 @@ export function transformCodebase(params: { srcDirPath: string; destDirPath: str
const transformSourceCodeResult = transformSourceCode({ const transformSourceCodeResult = transformSourceCode({
"sourceCode": fs.readFileSync(filePath), "sourceCode": fs.readFileSync(filePath),
"filePath": path.join(srcDirPath, file_relative_path), "filePath": path.join(srcDirPath, file_relative_path)
}); });
if (transformSourceCodeResult === undefined) { if (transformSourceCodeResult === undefined) {
@ -33,14 +33,14 @@ export function transformCodebase(params: { srcDirPath: string; destDirPath: str
} }
fs.mkdirSync(path.dirname(path.join(destDirPath, file_relative_path)), { fs.mkdirSync(path.dirname(path.join(destDirPath, file_relative_path)), {
"recursive": true, "recursive": true
}); });
const { newFileName, modifiedSourceCode } = transformSourceCodeResult; const { newFileName, modifiedSourceCode } = transformSourceCodeResult;
fs.writeFileSync( 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, file_relative_path)), newFileName ?? path.basename(file_relative_path)),
modifiedSourceCode, modifiedSourceCode
); );
} }
} }

19
src/bin/tools/walk.ts Normal file
View File

@ -0,0 +1,19 @@
import { readdir } from "fs/promises";
import { resolve } from "path";
/**
* Asynchronously and recursively walk a directory tree, yielding every file and directory
* found
*
* @param root the starting directory
* @returns AsyncGenerator
*/
export default async function* walk(root: string): AsyncGenerator<string, void, void> {
for (const entry of await readdir(root, { withFileTypes: true })) {
const absolutePath = resolve(root, entry.name);
if (entry.isDirectory()) {
yield absolutePath;
yield* walk(absolutePath);
} else yield absolutePath;
}
}

246
src/bin/tools/zip.ts Normal file
View File

@ -0,0 +1,246 @@
import { Transform, TransformOptions } from "stream";
import { createReadStream } from "fs";
import { stat } from "fs/promises";
import { Blob } from "buffer";
import { deflateBuffer, deflateStream } from "./deflate";
/**
* Zip source
* @property filename the name of the entry in the archie
* @property path of the source file, if the source is an actual file
* @property data the actual data buffer, if the source is constructed in-memory
*/
export type ZipSource = { path: string } & ({ fsPath: string } | { data: Buffer });
export type ZipRecord = {
path: string;
compression: "deflate" | undefined;
uncompressedSize: number;
compressedSize?: number;
crc32?: number;
offset?: number;
};
/**
* @returns the actual byte size of an string
*/
function utf8size(s: string) {
return new Blob([s]).size;
}
/**
* @param record
* @returns a buffer representing a Zip local header
* @link https://en.wikipedia.org/wiki/ZIP_(file_format)#Local_file_header
*/
function localHeader(record: ZipRecord) {
const { path, compression, uncompressedSize } = record;
const filenameSize = utf8size(path);
const buf = Buffer.alloc(30 + filenameSize);
buf.writeUInt32LE(0x04_03_4b_50, 0); // local header signature
buf.writeUInt16LE(10, 4); // min version
// we write 0x08 because crc and compressed size are unknown at
buf.writeUInt16LE(0x08, 6); // general purpose bit flag
buf.writeUInt16LE(compression ? ({ "deflate": 8 } as const)[compression] : 0, 8);
buf.writeUInt16LE(0, 10); // modified time
buf.writeUInt16LE(0, 12); // modified date
buf.writeUInt32LE(0, 14); // crc unknown
buf.writeUInt32LE(0, 18); // compressed size unknown
buf.writeUInt32LE(uncompressedSize, 22);
buf.writeUInt16LE(filenameSize, 26);
buf.writeUInt16LE(0, 28); // extra field length
buf.write(path, 30, "utf-8");
return buf;
}
/**
* @param record
* @returns a buffer representing a Zip central header
* @link https://en.wikipedia.org/wiki/ZIP_(file_format)#Central_directory_file_header
*/
function centralHeader(record: ZipRecord) {
const { path, compression, crc32, compressedSize, uncompressedSize, offset } = record;
const filenameSize = utf8size(path);
const buf = Buffer.alloc(46 + filenameSize);
const isFile = !path.endsWith("/");
if (typeof offset === "undefined") throw new Error("Illegal argument");
// we don't want to deal with possibly messed up file or directory
// permissions, so we ignore the original permissions
const externalAttr = isFile ? 0x81a40000 : 0x41ed0000;
buf.writeUInt32LE(0x0201_4b50, 0); // central header signature
buf.writeUInt16LE(10, 4); // version
buf.writeUInt16LE(10, 6); // min version
buf.writeUInt16LE(0, 8); // general purpose bit flag
buf.writeUInt16LE(compression ? ({ "deflate": 8 } as const)[compression] : 0, 10);
buf.writeUInt16LE(0, 12); // modified time
buf.writeUInt16LE(0, 14); // modified date
buf.writeUInt32LE(crc32 || 0, 16);
buf.writeUInt32LE(compressedSize || 0, 20);
buf.writeUInt32LE(uncompressedSize, 24);
buf.writeUInt16LE(filenameSize, 28);
buf.writeUInt16LE(0, 30); // extra field length
buf.writeUInt16LE(0, 32); // comment field length
buf.writeUInt16LE(0, 34); // disk number
buf.writeUInt16LE(0, 36); // internal
buf.writeUInt32LE(externalAttr, 38); // external
buf.writeUInt32LE(offset, 42); // offset where file starts
buf.write(path, 46, "utf-8");
return buf;
}
/**
* @returns a buffer representing an Zip End-Of-Central-Directory block
* @link https://en.wikipedia.org/wiki/ZIP_(file_format)#End_of_central_directory_record_(EOCD)
*/
function eocd({ offset, cdSize, nRecords }: { offset: number; cdSize: number; nRecords: number }) {
const buf = Buffer.alloc(22);
buf.writeUint32LE(0x06054b50, 0); // eocd signature
buf.writeUInt16LE(0, 4); // disc number
buf.writeUint16LE(0, 6); // disc where central directory starts
buf.writeUint16LE(nRecords, 8); // records on this disc
buf.writeUInt16LE(nRecords, 10); // records total
buf.writeUInt32LE(cdSize, 12); // byte size of cd
buf.writeUInt32LE(offset, 16); // cd offset
buf.writeUint16LE(0, 20); // comment length
return buf;
}
/**
* @returns a stream Transform, which reads a stream of ZipRecords and
* writes a bytestream
*/
export default function zip() {
/**
* This is called when the input stream of ZipSource items is finished.
* Will write central directory and end-of-central-direcotry blocks.
*/
const final = () => {
// write central directory
let cdSize = 0;
for (const record of records) {
const head = centralHeader(record);
zipTransform.push(head);
cdSize += head.length;
}
// write end-of-central-directory
zipTransform.push(eocd({ offset, cdSize, nRecords: records.length }));
// signal stream end
zipTransform.push(null);
};
/**
* Write a directory entry to the archive
* @param path
*/
const writeDir = async (path: string) => {
const record: ZipRecord = {
path: path + "/",
offset,
compression: undefined,
uncompressedSize: 0
};
const head = localHeader(record);
zipTransform.push(head);
records.push(record);
offset += head.length;
};
/**
* Write a file entry to the archive
* @param archivePath path of the file in archive
* @param fsPath path to file on filesystem
* @param size of the actual, uncompressed, file
*/
const writeFile = async (archivePath: string, fsPath: string, size: number) => {
const record: ZipRecord = {
path: archivePath,
offset,
compression: "deflate",
uncompressedSize: size
};
const head = localHeader(record);
zipTransform.push(head);
const { crc32, compressedSize } = await deflateStream(createReadStream(fsPath), chunk => zipTransform.push(chunk));
record.crc32 = crc32;
record.compressedSize = compressedSize;
records.push(record);
offset += head.length + compressedSize;
};
/**
* Write archive record based on filesystem file or directory
* @param archivePath path of item in archive
* @param fsPath path to item on filesystem
*/
const writeFromPath = async (archivePath: string, fsPath: string) => {
const fileStats = await stat(fsPath);
fileStats.isDirectory() ? await writeDir(archivePath) /**/ : await writeFile(archivePath, fsPath, fileStats.size) /**/;
};
/**
* Write archive record based on data in a buffer
* @param path
* @param data
*/
const writeFromBuffer = async (path: string, data: Buffer) => {
const { deflated, crc32 } = await deflateBuffer(data);
const record: ZipRecord = {
path,
compression: "deflate",
crc32,
uncompressedSize: data.length,
compressedSize: deflated.length,
offset
};
const head = localHeader(record);
zipTransform.push(head);
zipTransform.push(deflated);
records.push(record);
offset += head.length + deflated.length;
};
/**
* Write an archive record
* @param source
*/
const writeRecord = async (source: ZipSource) => {
if ("fsPath" in source) await writeFromPath(source.path, source.fsPath);
else if ("data" in source) await writeFromBuffer(source.path, source.data);
else throw new Error("Illegal argument " + typeof source + " " + JSON.stringify(source));
};
/**
* The actual stream transform function
* @param source
* @param _ encoding, ignored
* @param cb
*/
const transform: TransformOptions["transform"] = async (source: ZipSource, _, cb) => {
await writeRecord(source);
cb();
};
/** offset and records keep local state during processing */
let offset = 0;
const records: ZipRecord[] = [];
const zipTransform = new Transform({
readableObjectMode: false,
writableObjectMode: true,
transform,
final
});
return zipTransform;
}

View File

@ -1,18 +1,27 @@
import React, { memo } from "react"; import React, { memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const Error = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Error; i18n: I18n } & KcProps) => { export type ErrorProps = KcProps & {
kcContext: KcContextBase.Error;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const Error = memo((props: ErrorProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { message, client } = kcContext; const { message, client } = kcContext;
const { msg } = i18n; const { msg } = i18n;
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
displayMessage={false} displayMessage={false}
headerNode={msg("errorTitle")} headerNode={msg("errorTitle")}
formNode={ formNode={

View File

@ -0,0 +1,58 @@
import React, { useState, memo } from "react";
import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase";
import { clsx } from "../tools/clsx";
import type { I18n } from "../i18n";
import { UserProfileFormFields } from "./shared/UserProfileCommons";
export type IdpReviewUserProfileProps = KcProps & {
kcContext: KcContextBase.IdpReviewUserProfile;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const IdpReviewUserProfile = memo((props: IdpReviewUserProfileProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { msg, msgStr } = i18n;
const { url } = kcContext;
const [isFomSubmittable, setIsFomSubmittable] = useState(false);
return (
<Template
{...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
headerNode={msg("loginIdpReviewProfileTitle")}
formNode={
<form id="kc-idp-review-profile-form" className={clsx(kcProps.kcFormClass)} action={url.loginAction} method="post">
<UserProfileFormFields kcContext={kcContext} onIsFormSubmittableValueChange={setIsFomSubmittable} i18n={i18n} {...kcProps} />
<div className={clsx(kcProps.kcFormGroupClass)}>
<div id="kc-form-options" className={clsx(kcProps.kcFormOptionsClass)}>
<div className={clsx(kcProps.kcFormOptionsWrapperClass)} />
</div>
<div id="kc-form-buttons" className={clsx(kcProps.kcFormButtonsClass)}>
<input
className={clsx(
kcProps.kcButtonClass,
kcProps.kcButtonPrimaryClass,
kcProps.kcButtonBlockClass,
kcProps.kcButtonLargeClass
)}
type="submit"
value={msgStr("doSubmit")}
disabled={!isFomSubmittable}
/>
</div>
</div>
</form>
}
/>
);
});
export default IdpReviewUserProfile;

View File

@ -1,11 +1,21 @@
import React, { memo } from "react"; import React, { memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import { assert } from "../tools/assert"; import { assert } from "../tools/assert";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const Info = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Info; i18n: I18n } & KcProps) => { export type InfoProps = KcProps & {
kcContext: KcContextBase.Info;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const Info = memo((props: InfoProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { msgStr, msg } = i18n; const { msgStr, msg } = i18n;
assert(kcContext.message !== undefined); assert(kcContext.message !== undefined);
@ -14,8 +24,7 @@ const Info = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Inf
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
displayMessage={false} displayMessage={false}
headerNode={messageHeader !== undefined ? <>{messageHeader}</> : <>{message.summary}</>} headerNode={messageHeader !== undefined ? <>{messageHeader}</> : <>{message.summary}</>}
formNode={ formNode={

View File

@ -3,6 +3,8 @@ import type { KcContextBase } from "../getKcContext/KcContextBase";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import { __unsafe_useI18n as useI18n } from "../i18n"; import { __unsafe_useI18n as useI18n } from "../i18n";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
const Login = lazy(() => import("./Login")); const Login = lazy(() => import("./Login"));
const Register = lazy(() => import("./Register")); const Register = lazy(() => import("./Register"));
@ -13,6 +15,9 @@ const LoginResetPassword = lazy(() => import("./LoginResetPassword"));
const LoginVerifyEmail = lazy(() => import("./LoginVerifyEmail")); const LoginVerifyEmail = lazy(() => import("./LoginVerifyEmail"));
const Terms = lazy(() => import("./Terms")); const Terms = lazy(() => import("./Terms"));
const LoginOtp = lazy(() => import("./LoginOtp")); const LoginOtp = lazy(() => import("./LoginOtp"));
const LoginPassword = lazy(() => import("./LoginPassword"));
const LoginUsername = lazy(() => import("./LoginUsername"));
const WebauthnAuthenticate = lazy(() => import("./WebauthnAuthenticate"));
const LoginUpdatePassword = lazy(() => import("./LoginUpdatePassword")); const LoginUpdatePassword = lazy(() => import("./LoginUpdatePassword"));
const LoginUpdateProfile = lazy(() => import("./LoginUpdateProfile")); const LoginUpdateProfile = lazy(() => import("./LoginUpdateProfile"));
const LoginIdpLinkConfirm = lazy(() => import("./LoginIdpLinkConfirm")); const LoginIdpLinkConfirm = lazy(() => import("./LoginIdpLinkConfirm"));
@ -20,13 +25,24 @@ const LoginPageExpired = lazy(() => import("./LoginPageExpired"));
const LoginIdpLinkEmail = lazy(() => import("./LoginIdpLinkEmail")); const LoginIdpLinkEmail = lazy(() => import("./LoginIdpLinkEmail"));
const LoginConfigTotp = lazy(() => import("./LoginConfigTotp")); const LoginConfigTotp = lazy(() => import("./LoginConfigTotp"));
const LogoutConfirm = lazy(() => import("./LogoutConfirm")); const LogoutConfirm = lazy(() => import("./LogoutConfirm"));
const UpdateUserProfile = lazy(() => import("./UpdateUserProfile"));
const IdpReviewUserProfile = lazy(() => import("./IdpReviewUserProfile"));
export type KcAppProps = KcProps & {
kcContext: KcContextBase;
i18n?: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const KcApp = memo((props_: KcAppProps) => {
const { kcContext, i18n: userProvidedI18n, Template = DefaultTemplate, ...kcProps } = props_;
const KcApp = memo(({ kcContext, i18n: userProvidedI18n, ...props }: { kcContext: KcContextBase; i18n?: I18n } & KcProps) => {
const i18n = (function useClosure() { const i18n = (function useClosure() {
const i18n = useI18n({ const i18n = useI18n({
kcContext, kcContext,
"extraMessages": {}, "extraMessages": {},
"doSkip": userProvidedI18n !== undefined, "doSkip": userProvidedI18n !== undefined
}); });
return userProvidedI18n ?? i18n; return userProvidedI18n ?? i18n;
@ -36,42 +52,54 @@ const KcApp = memo(({ kcContext, i18n: userProvidedI18n, ...props }: { kcContext
return null; return null;
} }
const commonProps = { i18n, Template, ...kcProps };
return ( return (
<Suspense> <Suspense>
{(() => { {(() => {
switch (kcContext.pageId) { switch (kcContext.pageId) {
case "login.ftl": case "login.ftl":
return <Login {...{ kcContext, i18n, ...props }} />; return <Login {...{ kcContext, ...commonProps }} />;
case "register.ftl": case "register.ftl":
return <Register {...{ kcContext, i18n, ...props }} />; return <Register {...{ kcContext, ...commonProps }} />;
case "register-user-profile.ftl": case "register-user-profile.ftl":
return <RegisterUserProfile {...{ kcContext, i18n, ...props }} />; return <RegisterUserProfile {...{ kcContext, ...commonProps }} />;
case "info.ftl": case "info.ftl":
return <Info {...{ kcContext, i18n, ...props }} />; return <Info {...{ kcContext, ...commonProps }} />;
case "error.ftl": case "error.ftl":
return <Error {...{ kcContext, i18n, ...props }} />; return <Error {...{ kcContext, ...commonProps }} />;
case "login-reset-password.ftl": case "login-reset-password.ftl":
return <LoginResetPassword {...{ kcContext, i18n, ...props }} />; return <LoginResetPassword {...{ kcContext, ...commonProps }} />;
case "login-verify-email.ftl": case "login-verify-email.ftl":
return <LoginVerifyEmail {...{ kcContext, i18n, ...props }} />; return <LoginVerifyEmail {...{ kcContext, ...commonProps }} />;
case "terms.ftl": case "terms.ftl":
return <Terms {...{ kcContext, i18n, ...props }} />; return <Terms {...{ kcContext, ...commonProps }} />;
case "login-otp.ftl": case "login-otp.ftl":
return <LoginOtp {...{ kcContext, i18n, ...props }} />; return <LoginOtp {...{ kcContext, ...commonProps }} />;
case "login-username.ftl":
return <LoginUsername {...{ kcContext, ...commonProps }} />;
case "login-password.ftl":
return <LoginPassword {...{ kcContext, ...commonProps }} />;
case "webauthn-authenticate.ftl":
return <WebauthnAuthenticate {...{ kcContext, ...commonProps }} />;
case "login-update-password.ftl": case "login-update-password.ftl":
return <LoginUpdatePassword {...{ kcContext, i18n, ...props }} />; return <LoginUpdatePassword {...{ kcContext, ...commonProps }} />;
case "login-update-profile.ftl": case "login-update-profile.ftl":
return <LoginUpdateProfile {...{ kcContext, i18n, ...props }} />; return <LoginUpdateProfile {...{ kcContext, ...commonProps }} />;
case "login-idp-link-confirm.ftl": case "login-idp-link-confirm.ftl":
return <LoginIdpLinkConfirm {...{ kcContext, i18n, ...props }} />; return <LoginIdpLinkConfirm {...{ kcContext, ...commonProps }} />;
case "login-idp-link-email.ftl": case "login-idp-link-email.ftl":
return <LoginIdpLinkEmail {...{ kcContext, i18n, ...props }} />; return <LoginIdpLinkEmail {...{ kcContext, ...commonProps }} />;
case "login-page-expired.ftl": case "login-page-expired.ftl":
return <LoginPageExpired {...{ kcContext, i18n, ...props }} />; return <LoginPageExpired {...{ kcContext, ...commonProps }} />;
case "login-config-totp.ftl": case "login-config-totp.ftl":
return <LoginConfigTotp {...{ kcContext, i18n, ...props }} />; return <LoginConfigTotp {...{ kcContext, ...commonProps }} />;
case "logout-confirm.ftl": case "logout-confirm.ftl":
return <LogoutConfirm {...{ kcContext, i18n, ...props }} />; return <LogoutConfirm {...{ kcContext, ...commonProps }} />;
case "update-user-profile.ftl":
return <UpdateUserProfile {...{ kcContext, ...commonProps }} />;
case "idp-review-user-profile.ftl":
return <IdpReviewUserProfile {...{ kcContext, ...commonProps }} />;
} }
})()} })()}
</Suspense> </Suspense>

View File

@ -37,7 +37,7 @@ export const defaultKcTemplateProps = {
"stylesCommon": [ "stylesCommon": [
"node_modules/patternfly/dist/css/patternfly.min.css", "node_modules/patternfly/dist/css/patternfly.min.css",
"node_modules/patternfly/dist/css/patternfly-additions.min.css", "node_modules/patternfly/dist/css/patternfly-additions.min.css",
"lib/zocial/zocial.css", "lib/zocial/zocial.css"
], ],
"styles": ["css/login.css"], "styles": ["css/login.css"],
"scripts": [], "scripts": [],
@ -60,7 +60,7 @@ export const defaultKcTemplateProps = {
"kcFormGroupClass": ["form-group"], "kcFormGroupClass": ["form-group"],
"kcLabelWrapperClass": ["col-xs-12", "col-sm-12", "col-md-12", "col-lg-12"], "kcLabelWrapperClass": ["col-xs-12", "col-sm-12", "col-md-12", "col-lg-12"],
"kcSignUpClass": ["login-pf-signup"], "kcSignUpClass": ["login-pf-signup"],
"kcInfoAreaWrapperClass": [], "kcInfoAreaWrapperClass": []
} as const; } as const;
assert<typeof defaultKcTemplateProps extends KcTemplateProps ? true : false>(); assert<typeof defaultKcTemplateProps extends KcTemplateProps ? true : false>();
@ -84,6 +84,7 @@ export type KcProps = KcPropsGeneric<
| "kcFormSocialAccountDoubleListClass" | "kcFormSocialAccountDoubleListClass"
| "kcFormSocialAccountListLinkClass" | "kcFormSocialAccountListLinkClass"
| "kcWebAuthnKeyIcon" | "kcWebAuthnKeyIcon"
| "kcWebAuthnDefaultIcon"
| "kcFormClass" | "kcFormClass"
| "kcFormGroupErrorClass" | "kcFormGroupErrorClass"
| "kcLabelClass" | "kcLabelClass"
@ -105,12 +106,16 @@ export type KcProps = KcPropsGeneric<
| "kcSrOnlyClass" | "kcSrOnlyClass"
| "kcSelectAuthListClass" | "kcSelectAuthListClass"
| "kcSelectAuthListItemClass" | "kcSelectAuthListItemClass"
| "kcSelectAuthListItemFillClass"
| "kcSelectAuthListItemInfoClass" | "kcSelectAuthListItemInfoClass"
| "kcSelectAuthListItemLeftClass" | "kcSelectAuthListItemLeftClass"
| "kcSelectAuthListItemBodyClass" | "kcSelectAuthListItemBodyClass"
| "kcSelectAuthListItemDescriptionClass" | "kcSelectAuthListItemDescriptionClass"
| "kcSelectAuthListItemHeadingClass" | "kcSelectAuthListItemHeadingClass"
| "kcSelectAuthListItemHelpTextClass" | "kcSelectAuthListItemHelpTextClass"
| "kcSelectAuthListItemIconPropertyClass"
| "kcSelectAuthListItemIconClass"
| "kcSelectAuthListItemTitle"
| "kcAuthenticatorDefaultClass" | "kcAuthenticatorDefaultClass"
| "kcAuthenticatorPasswordClass" | "kcAuthenticatorPasswordClass"
| "kcAuthenticatorOTPClass" | "kcAuthenticatorOTPClass"
@ -138,6 +143,7 @@ export const defaultKcProps = {
"kcFormSocialAccountDoubleListClass": ["login-pf-social-double-col"], "kcFormSocialAccountDoubleListClass": ["login-pf-social-double-col"],
"kcFormSocialAccountListLinkClass": ["login-pf-social-link"], "kcFormSocialAccountListLinkClass": ["login-pf-social-link"],
"kcWebAuthnKeyIcon": ["pficon", "pficon-key"], "kcWebAuthnKeyIcon": ["pficon", "pficon-key"],
"kcWebAuthnDefaultIcon": ["pficon", "pficon-key"],
"kcFormClass": ["form-horizontal"], "kcFormClass": ["form-horizontal"],
"kcFormGroupErrorClass": ["has-error"], "kcFormGroupErrorClass": ["has-error"],
@ -173,6 +179,10 @@ export const defaultKcProps = {
// css classes for select-authenticator form // css classes for select-authenticator form
"kcSelectAuthListClass": ["list-group", "list-view-pf"], "kcSelectAuthListClass": ["list-group", "list-view-pf"],
"kcSelectAuthListItemClass": ["list-group-item", "list-view-pf-stacked"], "kcSelectAuthListItemClass": ["list-group-item", "list-view-pf-stacked"],
"kcSelectAuthListItemFillClass": ["pf-l-split__item", "pf-m-fill"],
"kcSelectAuthListItemIconPropertyClass": ["fa-2x", "select-auth-box-icon-properties"],
"kcSelectAuthListItemIconClass": ["pf-l-split__item", "select-auth-box-icon"],
"kcSelectAuthListItemTitle": ["select-auth-box-paragraph"],
"kcSelectAuthListItemInfoClass": ["list-view-pf-main-info"], "kcSelectAuthListItemInfoClass": ["list-view-pf-main-info"],
"kcSelectAuthListItemLeftClass": ["list-view-pf-left"], "kcSelectAuthListItemLeftClass": ["list-view-pf-left"],
"kcSelectAuthListItemBodyClass": ["list-view-pf-body"], "kcSelectAuthListItemBodyClass": ["list-view-pf-body"],
@ -192,7 +202,7 @@ export const defaultKcProps = {
"kcSelectOTPListItemClass": ["card-pf-body", "card-pf-top-element"], "kcSelectOTPListItemClass": ["card-pf-body", "card-pf-top-element"],
"kcAuthenticatorOtpCircleClass": ["fa", "fa-mobile", "card-pf-icon-circle"], "kcAuthenticatorOtpCircleClass": ["fa", "fa-mobile", "card-pf-icon-circle"],
"kcSelectOTPItemHeadingClass": ["card-pf-title", "text-center"], "kcSelectOTPItemHeadingClass": ["card-pf-title", "text-center"],
"kcFormOptionsWrapperClass": [], "kcFormOptionsWrapperClass": []
} as const; } as const;
assert<typeof defaultKcProps extends KcProps ? true : false>(); assert<typeof defaultKcProps extends KcProps ? true : false>();

View File

@ -1,19 +1,27 @@
import React, { useState, memo } from "react"; import React, { useState, memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import { useCssAndCx } from "tss-react"; import { clsx } from "../tools/clsx";
import { useConstCallback } from "powerhooks/useConstCallback"; import { useConstCallback } from "powerhooks/useConstCallback";
import type { FormEventHandler } from "react"; import type { FormEventHandler } from "react";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const Login = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Login; i18n: I18n } & KcProps) => { export type LoginProps = KcProps & {
kcContext: KcContextBase.Login;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const Login = memo((props: LoginProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { social, realm, url, usernameEditDisabled, login, auth, registrationDisabled } = kcContext; const { social, realm, url, usernameEditDisabled, login, auth, registrationDisabled } = kcContext;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
const { cx } = useCssAndCx();
const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false); const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false);
const onSubmit = useConstCallback<FormEventHandler<HTMLFormElement>>(e => { const onSubmit = useConstCallback<FormEventHandler<HTMLFormElement>>(e => {
@ -32,20 +40,21 @@ const Login = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Lo
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
displayInfo={social.displayInfo} displayInfo={social.displayInfo}
displayWide={realm.password && social.providers !== undefined} displayWide={realm.password && social.providers !== undefined}
headerNode={msg("doLogIn")} headerNode={msg("doLogIn")}
formNode={ formNode={
<div id="kc-form" className={cx(realm.password && social.providers !== undefined && props.kcContentWrapperClass)}> <div id="kc-form" className={clsx(realm.password && social.providers !== undefined && kcProps.kcContentWrapperClass)}>
<div <div
id="kc-form-wrapper" id="kc-form-wrapper"
className={cx(realm.password && social.providers && [props.kcFormSocialAccountContentClass, props.kcFormSocialAccountClass])} className={clsx(
realm.password && social.providers && [kcProps.kcFormSocialAccountContentClass, kcProps.kcFormSocialAccountClass]
)}
> >
{realm.password && ( {realm.password && (
<form id="kc-form-login" onSubmit={onSubmit} action={url.loginAction} method="post"> <form id="kc-form-login" onSubmit={onSubmit} action={url.loginAction} method="post">
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
{(() => { {(() => {
const label = !realm.loginWithEmailAllowed const label = !realm.loginWithEmailAllowed
? "username" ? "username"
@ -57,13 +66,13 @@ const Login = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Lo
return ( return (
<> <>
<label htmlFor={autoCompleteHelper} className={cx(props.kcLabelClass)}> <label htmlFor={autoCompleteHelper} className={clsx(kcProps.kcLabelClass)}>
{msg(label)} {msg(label)}
</label> </label>
<input <input
tabIndex={1} tabIndex={1}
id={autoCompleteHelper} id={autoCompleteHelper}
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
//NOTE: This is used by Google Chrome auto fill so we use it to tell //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 //the browser how to pre fill the form but before submit we put it back
//to username because it is what keycloak expects. //to username because it is what keycloak expects.
@ -74,27 +83,27 @@ const Login = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Lo
? { "disabled": true } ? { "disabled": true }
: { : {
"autoFocus": true, "autoFocus": true,
"autoComplete": "off", "autoComplete": "off"
})} })}
/> />
</> </>
); );
})()} })()}
</div> </div>
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
<label htmlFor="password" className={cx(props.kcLabelClass)}> <label htmlFor="password" className={clsx(kcProps.kcLabelClass)}>
{msg("password")} {msg("password")}
</label> </label>
<input <input
tabIndex={2} tabIndex={2}
id="password" id="password"
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
name="password" name="password"
type="password" type="password"
autoComplete="off" autoComplete="off"
/> />
</div> </div>
<div className={cx(props.kcFormGroupClass, props.kcFormSettingClass)}> <div className={clsx(kcProps.kcFormGroupClass, kcProps.kcFormSettingClass)}>
<div id="kc-form-options"> <div id="kc-form-options">
{realm.rememberMe && !usernameEditDisabled && ( {realm.rememberMe && !usernameEditDisabled && (
<div className="checkbox"> <div className="checkbox">
@ -106,7 +115,7 @@ const Login = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Lo
type="checkbox" type="checkbox"
{...(login.rememberMe {...(login.rememberMe
? { ? {
"checked": true, "checked": true
} }
: {})} : {})}
/> />
@ -115,7 +124,7 @@ const Login = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Lo
</div> </div>
)} )}
</div> </div>
<div className={cx(props.kcFormOptionsWrapperClass)}> <div className={clsx(kcProps.kcFormOptionsWrapperClass)}>
{realm.resetPasswordAllowed && ( {realm.resetPasswordAllowed && (
<span> <span>
<a tabIndex={5} href={url.loginResetCredentialsUrl}> <a tabIndex={5} href={url.loginResetCredentialsUrl}>
@ -125,24 +134,24 @@ const Login = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Lo
)} )}
</div> </div>
</div> </div>
<div id="kc-form-buttons" className={cx(props.kcFormGroupClass)}> <div id="kc-form-buttons" className={clsx(kcProps.kcFormGroupClass)}>
<input <input
type="hidden" type="hidden"
id="id-hidden-input" id="id-hidden-input"
name="credentialId" name="credentialId"
{...(auth?.selectedCredential !== undefined {...(auth?.selectedCredential !== undefined
? { ? {
"value": auth.selectedCredential, "value": auth.selectedCredential
} }
: {})} : {})}
/> />
<input <input
tabIndex={4} tabIndex={4}
className={cx( className={clsx(
props.kcButtonClass, kcProps.kcButtonClass,
props.kcButtonPrimaryClass, kcProps.kcButtonPrimaryClass,
props.kcButtonBlockClass, kcProps.kcButtonBlockClass,
props.kcButtonLargeClass, kcProps.kcButtonLargeClass
)} )}
name="login" name="login"
id="kc-login" id="kc-login"
@ -155,16 +164,16 @@ const Login = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Lo
)} )}
</div> </div>
{realm.password && social.providers !== undefined && ( {realm.password && social.providers !== undefined && (
<div id="kc-social-providers" className={cx(props.kcFormSocialAccountContentClass, props.kcFormSocialAccountClass)}> <div id="kc-social-providers" className={clsx(kcProps.kcFormSocialAccountContentClass, kcProps.kcFormSocialAccountClass)}>
<ul <ul
className={cx( className={clsx(
props.kcFormSocialAccountListClass, kcProps.kcFormSocialAccountListClass,
social.providers.length > 4 && props.kcFormSocialAccountDoubleListClass, social.providers.length > 4 && kcProps.kcFormSocialAccountDoubleListClass
)} )}
> >
{social.providers.map(p => ( {social.providers.map(p => (
<li key={p.providerId} className={cx(props.kcFormSocialAccountListLinkClass)}> <li key={p.providerId} className={clsx(kcProps.kcFormSocialAccountListLinkClass)}>
<a href={p.loginUrl} id={`zocial-${p.alias}`} className={cx("zocial", p.providerId)}> <a href={p.loginUrl} id={`zocial-${p.alias}`} className={clsx("zocial", p.providerId)}>
<span>{p.displayName}</span> <span>{p.displayName}</span>
</a> </a>
</li> </li>

View File

@ -1,27 +1,34 @@
import React, { memo } from "react"; import React, { memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import { useCssAndCx } from "tss-react"; import { clsx } from "../tools/clsx";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const LoginConfigTotp = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.LoginConfigTotp; i18n: I18n } & KcProps) => { export type LoginConfigTotpProps = KcProps & {
const { url, isAppInitiatedAction, totp, mode, messagesPerField } = kcContext; kcContext: KcContextBase.LoginConfigTotp;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const { cx } = useCssAndCx(); const LoginConfigTotp = memo((props: LoginConfigTotpProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { url, isAppInitiatedAction, totp, mode, messagesPerField } = kcContext;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
const algToKeyUriAlg: Record<KcContextBase.LoginConfigTotp["totp"]["policy"]["algorithm"], string> = { const algToKeyUriAlg: Record<KcContextBase.LoginConfigTotp["totp"]["policy"]["algorithm"], string> = {
HmacSHA1: "SHA1", "HmacSHA1": "SHA1",
HmacSHA256: "SHA256", "HmacSHA256": "SHA256",
HmacSHA512: "SHA512", "HmacSHA512": "SHA512"
}; };
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
headerNode={msg("loginTotpTitle")} headerNode={msg("loginTotpTitle")}
formNode={ formNode={
<> <>
@ -93,26 +100,26 @@ const LoginConfigTotp = memo(({ kcContext, i18n, ...props }: { kcContext: KcCont
</li> </li>
</ol> </ol>
<form action={url.loginAction} className={cx(props.kcFormClass)} id="kc-totp-settings-form" method="post"> <form action={url.loginAction} className={clsx(kcProps.kcFormClass)} id="kc-totp-settings-form" method="post">
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<label htmlFor="totp" className={cx(props.kcLabelClass)}> <label htmlFor="totp" className={clsx(kcProps.kcLabelClass)}>
{msg("authenticatorCode")} {msg("authenticatorCode")}
</label>{" "} </label>{" "}
<span className="required">*</span> <span className="required">*</span>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input <input
type="text" type="text"
id="totp" id="totp"
name="totp" name="totp"
autoComplete="off" autoComplete="off"
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
aria-invalid={messagesPerField.existsError("totp")} aria-invalid={messagesPerField.existsError("totp")}
/> />
{messagesPerField.existsError("totp") && ( {messagesPerField.existsError("totp") && (
<span id="input-error-otp-code" className={cx(props.kcInputErrorMessageClass)} aria-live="polite"> <span id="input-error-otp-code" className={clsx(kcProps.kcInputErrorMessageClass)} aria-live="polite">
{messagesPerField.get("totp")} {messagesPerField.get("totp")}
</span> </span>
)} )}
@ -121,24 +128,24 @@ const LoginConfigTotp = memo(({ kcContext, i18n, ...props }: { kcContext: KcCont
{mode && <input type="hidden" id="mode" value={mode} />} {mode && <input type="hidden" id="mode" value={mode} />}
</div> </div>
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<label htmlFor="userLabel" className={cx(props.kcLabelClass)}> <label htmlFor="userLabel" className={clsx(kcProps.kcLabelClass)}>
{msg("loginTotpDeviceName")} {msg("loginTotpDeviceName")}
</label>{" "} </label>{" "}
{totp.otpCredentials.length >= 1 && <span className="required">*</span>} {totp.otpCredentials.length >= 1 && <span className="required">*</span>}
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input <input
type="text" type="text"
id="userLabel" id="userLabel"
name="userLabel" name="userLabel"
autoComplete="off" autoComplete="off"
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
aria-invalid={messagesPerField.existsError("userLabel")} aria-invalid={messagesPerField.existsError("userLabel")}
/> />
{messagesPerField.existsError("userLabel") && ( {messagesPerField.existsError("userLabel") && (
<span id="input-error-otp-label" className={cx(props.kcInputErrorMessageClass)} aria-live="polite"> <span id="input-error-otp-label" className={clsx(kcProps.kcInputErrorMessageClass)} aria-live="polite">
{messagesPerField.get("userLabel")} {messagesPerField.get("userLabel")}
</span> </span>
)} )}
@ -149,17 +156,17 @@ const LoginConfigTotp = memo(({ kcContext, i18n, ...props }: { kcContext: KcCont
<> <>
<input <input
type="submit" type="submit"
className={cx(props.kcButtonClass, props.kcButtonPrimaryClass, props.kcButtonLargeClass)} className={clsx(kcProps.kcButtonClass, kcProps.kcButtonPrimaryClass, kcProps.kcButtonLargeClass)}
id="saveTOTPBtn" id="saveTOTPBtn"
value={msgStr("doSubmit")} value={msgStr("doSubmit")}
/> />
<button <button
type="submit" type="submit"
className={cx( className={clsx(
props.kcButtonClass, kcProps.kcButtonClass,
props.kcButtonDefaultClass, kcProps.kcButtonDefaultClass,
props.kcButtonLargeClass, kcProps.kcButtonLargeClass,
props.kcButtonLargeClass, kcProps.kcButtonLargeClass
)} )}
id="cancelTOTPBtn" id="cancelTOTPBtn"
name="cancel-aia" name="cancel-aia"
@ -171,7 +178,7 @@ const LoginConfigTotp = memo(({ kcContext, i18n, ...props }: { kcContext: KcCont
) : ( ) : (
<input <input
type="submit" type="submit"
className={cx(props.kcButtonClass, props.kcButtonPrimaryClass, props.kcButtonLargeClass)} className={clsx(kcProps.kcButtonClass, kcProps.kcButtonPrimaryClass, kcProps.kcButtonLargeClass)}
id="saveTOTPBtn" id="saveTOTPBtn"
value={msgStr("doSubmit")} value={msgStr("doSubmit")}
/> />

View File

@ -1,28 +1,40 @@
import React, { memo } from "react"; import React, { memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import { useCssAndCx } from "tss-react"; import { clsx } from "../tools/clsx";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const LoginIdpLinkConfirm = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.LoginIdpLinkConfirm; i18n: I18n } & KcProps) => { export type LoginIdpLinkConfirmProps = KcProps & {
kcContext: KcContextBase.LoginIdpLinkConfirm;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const LoginIdpLinkConfirm = memo((props: LoginIdpLinkConfirmProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { url, idpAlias } = kcContext; const { url, idpAlias } = kcContext;
const { msg } = i18n; const { msg } = i18n;
const { cx } = useCssAndCx();
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
headerNode={msg("confirmLinkIdpTitle")} headerNode={msg("confirmLinkIdpTitle")}
formNode={ formNode={
<form id="kc-register-form" action={url.loginAction} method="post"> <form id="kc-register-form" action={url.loginAction} method="post">
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
<button <button
type="submit" type="submit"
className={cx(props.kcButtonClass, props.kcButtonDefaultClass, props.kcButtonBlockClass, props.kcButtonLargeClass)} className={clsx(
kcProps.kcButtonClass,
kcProps.kcButtonDefaultClass,
kcProps.kcButtonBlockClass,
kcProps.kcButtonLargeClass
)}
name="submitAction" name="submitAction"
id="updateProfile" id="updateProfile"
value="updateProfile" value="updateProfile"
@ -31,7 +43,12 @@ const LoginIdpLinkConfirm = memo(({ kcContext, i18n, ...props }: { kcContext: Kc
</button> </button>
<button <button
type="submit" type="submit"
className={cx(props.kcButtonClass, props.kcButtonDefaultClass, props.kcButtonBlockClass, props.kcButtonLargeClass)} className={clsx(
kcProps.kcButtonClass,
kcProps.kcButtonDefaultClass,
kcProps.kcButtonBlockClass,
kcProps.kcButtonLargeClass
)}
name="submitAction" name="submitAction"
id="linkAccount" id="linkAccount"
value="linkAccount" value="linkAccount"

View File

@ -1,18 +1,27 @@
import React, { memo } from "react"; import React, { memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const LoginIdpLinkEmail = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.LoginIdpLinkEmail; i18n: I18n } & KcProps) => { export type LoginIdpLinkEmailProps = KcProps & {
kcContext: KcContextBase.LoginIdpLinkEmail;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const LoginIdpLinkEmail = memo((props: LoginIdpLinkEmailProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { url, realm, brokerContext, idpAlias } = kcContext; const { url, realm, brokerContext, idpAlias } = kcContext;
const { msg } = i18n; const { msg } = i18n;
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
headerNode={msg("emailLinkIdpTitle", idpAlias)} headerNode={msg("emailLinkIdpTitle", idpAlias)}
formNode={ formNode={
<> <>

View File

@ -1,16 +1,24 @@
import React, { useEffect, memo } from "react"; import React, { useEffect, memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import { headInsert } from "../tools/headInsert"; import { headInsert } from "../tools/headInsert";
import { pathJoin } from "../../bin/tools/pathJoin"; import { pathJoin } from "../../bin/tools/pathJoin";
import { useCssAndCx } from "tss-react"; import { clsx } from "../tools/clsx";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const LoginOtp = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.LoginOtp; i18n: I18n } & KcProps) => { export type LoginOtpProps = KcProps & {
const { otpLogin, url } = kcContext; kcContext: KcContextBase.LoginOtp;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const { cx } = useCssAndCx(); const LoginOtp = memo((props: LoginOtpProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { otpLogin, url } = kcContext;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
@ -19,7 +27,7 @@ const LoginOtp = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase
headInsert({ headInsert({
"type": "javascript", "type": "javascript",
"src": pathJoin(kcContext.url.resourcesCommonPath, "node_modules/jquery/dist/jquery.min.js"), "src": pathJoin(kcContext.url.resourcesCommonPath, "node_modules/jquery/dist/jquery.min.js")
}).then(() => { }).then(() => {
if (isCleanedUp) return; if (isCleanedUp) return;
@ -33,46 +41,50 @@ const LoginOtp = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
headerNode={msg("doLogIn")} headerNode={msg("doLogIn")}
formNode={ formNode={
<form id="kc-otp-login-form" className={cx(props.kcFormClass)} action={url.loginAction} method="post"> <form id="kc-otp-login-form" className={clsx(kcProps.kcFormClass)} action={url.loginAction} method="post">
{otpLogin.userOtpCredentials.length > 1 && ( {otpLogin.userOtpCredentials.length > 1 && (
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
{otpLogin.userOtpCredentials.map(otpCredential => ( {otpLogin.userOtpCredentials.map(otpCredential => (
<div key={otpCredential.id} className={cx(props.kcSelectOTPListClass)}> <div key={otpCredential.id} className={clsx(kcProps.kcSelectOTPListClass)}>
<input type="hidden" value="${otpCredential.id}" /> <input type="hidden" value="${otpCredential.id}" />
<div className={cx(props.kcSelectOTPListItemClass)}> <div className={clsx(kcProps.kcSelectOTPListItemClass)}>
<span className={cx(props.kcAuthenticatorOtpCircleClass)} /> <span className={clsx(kcProps.kcAuthenticatorOtpCircleClass)} />
<h2 className={cx(props.kcSelectOTPItemHeadingClass)}>{otpCredential.userLabel}</h2> <h2 className={clsx(kcProps.kcSelectOTPItemHeadingClass)}>{otpCredential.userLabel}</h2>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
)} )}
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
<div className={cx(props.kcLabelWrapperClass)}> <div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="otp" className={cx(props.kcLabelClass)}> <label htmlFor="otp" className={clsx(kcProps.kcLabelClass)}>
{msg("loginOtpOneTime")} {msg("loginOtpOneTime")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input id="otp" name="otp" autoComplete="off" type="text" className={cx(props.kcInputClass)} autoFocus /> <input id="otp" name="otp" autoComplete="off" type="text" className={clsx(kcProps.kcInputClass)} autoFocus />
</div> </div>
</div> </div>
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
<div id="kc-form-options" className={cx(props.kcFormOptionsClass)}> <div id="kc-form-options" className={clsx(kcProps.kcFormOptionsClass)}>
<div className={cx(props.kcFormOptionsWrapperClass)} /> <div className={clsx(kcProps.kcFormOptionsWrapperClass)} />
</div> </div>
<div id="kc-form-buttons" className={cx(props.kcFormButtonsClass)}> <div id="kc-form-buttons" className={clsx(kcProps.kcFormButtonsClass)}>
<input <input
className={cx(props.kcButtonClass, props.kcButtonPrimaryClass, props.kcButtonBlockClass, props.kcButtonLargeClass)} className={clsx(
kcProps.kcButtonClass,
kcProps.kcButtonPrimaryClass,
kcProps.kcButtonBlockClass,
kcProps.kcButtonLargeClass
)}
name="login" name="login"
id="kc-login" id="kc-login"
type="submit" type="submit"

View File

@ -1,18 +1,27 @@
import React, { memo } from "react"; import React, { memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const LoginPageExpired = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.LoginPageExpired; i18n: I18n } & KcProps) => { export type LoginPageExpired = KcProps & {
kcContext: KcContextBase.LoginPageExpired;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const LoginPageExpired = memo((props: LoginPageExpired) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { url } = kcContext; const { url } = kcContext;
const { msg } = i18n; const { msg } = i18n;
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
displayMessage={false} displayMessage={false}
headerNode={msg("pageExpiredTitle")} headerNode={msg("pageExpiredTitle")}
formNode={ formNode={

View File

@ -0,0 +1,97 @@
import React, { useState, memo } from "react";
import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase";
import { clsx } from "../tools/clsx";
import { useConstCallback } from "powerhooks/useConstCallback";
import type { FormEventHandler } from "react";
import type { I18n } from "../i18n";
export type LoginPasswordProps = KcProps & {
kcContext: KcContextBase.LoginPassword;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const LoginPassword = memo((props: LoginPasswordProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { realm, url, login } = kcContext;
const { msg, msgStr } = i18n;
const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false);
const onSubmit = useConstCallback<FormEventHandler<HTMLFormElement>>(e => {
e.preventDefault();
setIsLoginButtonDisabled(true);
const formElement = e.target as HTMLFormElement;
formElement.submit();
});
return (
<Template
{...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
headerNode={msg("doLogIn")}
formNode={
<div id="kc-form">
<div id="kc-form-wrapper">
<form id="kc-form-login" onSubmit={onSubmit} action={url.loginAction} method="post">
<div className={clsx(kcProps.kcFormGroupClass)}>
<hr />
<label htmlFor="password" className={clsx(kcProps.kcLabelClass)}>
{msg("password")}
</label>
<input
tabIndex={2}
id="password"
className={clsx(kcProps.kcInputClass)}
name="password"
type="password"
autoFocus={true}
autoComplete="on"
defaultValue={login.password ?? ""}
/>
</div>
<div className={clsx(kcProps.kcFormGroupClass, kcProps.kcFormSettingClass)}>
<div id="kc-form-options" />
<div className={clsx(kcProps.kcFormOptionsWrapperClass)}>
{realm.resetPasswordAllowed && (
<span>
<a tabIndex={5} href={url.loginResetCredentialsUrl}>
{msg("doForgotPassword")}
</a>
</span>
)}
</div>
</div>
<div id="kc-form-buttons" className={clsx(kcProps.kcFormGroupClass)}>
<input
tabIndex={4}
className={clsx(
kcProps.kcButtonClass,
kcProps.kcButtonPrimaryClass,
kcProps.kcButtonBlockClass,
kcProps.kcButtonLargeClass
)}
name="login"
id="kc-login"
type="submit"
value={msgStr("doLogIn")}
disabled={isLoginButtonDisabled}
/>
</div>
</form>
</div>
</div>
}
/>
);
});
export default LoginPassword;

View File

@ -1,28 +1,35 @@
import React, { memo } from "react"; import React, { memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import { useCssAndCx } from "tss-react"; import { clsx } from "../tools/clsx";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const LoginResetPassword = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.LoginResetPassword; i18n: I18n } & KcProps) => { export type LoginResetPasswordProps = KcProps & {
kcContext: KcContextBase.LoginResetPassword;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const LoginResetPassword = memo((props: LoginResetPasswordProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { url, realm, auth } = kcContext; const { url, realm, auth } = kcContext;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
const { cx } = useCssAndCx();
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
displayMessage={false} displayMessage={false}
headerNode={msg("emailForgotTitle")} headerNode={msg("emailForgotTitle")}
formNode={ formNode={
<form id="kc-reset-password-form" className={cx(props.kcFormClass)} action={url.loginAction} method="post"> <form id="kc-reset-password-form" className={clsx(kcProps.kcFormClass)} action={url.loginAction} method="post">
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
<div className={cx(props.kcLabelWrapperClass)}> <div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="username" className={cx(props.kcLabelClass)}> <label htmlFor="username" className={clsx(kcProps.kcLabelClass)}>
{!realm.loginWithEmailAllowed {!realm.loginWithEmailAllowed
? msg("username") ? msg("username")
: !realm.registrationEmailAsUsername : !realm.registrationEmailAsUsername
@ -30,29 +37,34 @@ const LoginResetPassword = memo(({ kcContext, i18n, ...props }: { kcContext: KcC
: msg("email")} : msg("email")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input <input
type="text" type="text"
id="username" id="username"
name="username" name="username"
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
autoFocus autoFocus
defaultValue={auth !== undefined && auth.showUsername ? auth.attemptedUsername : undefined} defaultValue={auth !== undefined && auth.showUsername ? auth.attemptedUsername : undefined}
/> />
</div> </div>
</div> </div>
<div className={cx(props.kcFormGroupClass, props.kcFormSettingClass)}> <div className={clsx(kcProps.kcFormGroupClass, kcProps.kcFormSettingClass)}>
<div id="kc-form-options" className={cx(props.kcFormOptionsClass)}> <div id="kc-form-options" className={clsx(kcProps.kcFormOptionsClass)}>
<div className={cx(props.kcFormOptionsWrapperClass)}> <div className={clsx(kcProps.kcFormOptionsWrapperClass)}>
<span> <span>
<a href={url.loginUrl}>{msg("backToLogin")}</a> <a href={url.loginUrl}>{msg("backToLogin")}</a>
</span> </span>
</div> </div>
</div> </div>
<div id="kc-form-buttons" className={cx(props.kcFormButtonsClass)}> <div id="kc-form-buttons" className={clsx(kcProps.kcFormButtonsClass)}>
<input <input
className={cx(props.kcButtonClass, props.kcButtonPrimaryClass, props.kcButtonBlockClass, props.kcButtonLargeClass)} className={clsx(
kcProps.kcButtonClass,
kcProps.kcButtonPrimaryClass,
kcProps.kcButtonBlockClass,
kcProps.kcButtonLargeClass
)}
type="submit" type="submit"
value={msgStr("doSubmit")} value={msgStr("doSubmit")}
/> />

View File

@ -1,12 +1,20 @@
import React, { memo } from "react"; import React, { memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import { useCssAndCx } from "tss-react"; import { clsx } from "../tools/clsx";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const LoginUpdatePassword = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.LoginUpdatePassword; i18n: I18n } & KcProps) => { export type LoginUpdatePasswordProps = KcProps & {
const { cx } = useCssAndCx(); kcContext: KcContextBase.LoginUpdatePassword;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const LoginUpdatePassword = memo((props: LoginUpdatePasswordProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
@ -14,11 +22,10 @@ const LoginUpdatePassword = memo(({ kcContext, i18n, ...props }: { kcContext: Kc
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
headerNode={msg("updatePasswordTitle")} headerNode={msg("updatePasswordTitle")}
formNode={ formNode={
<form id="kc-passwd-update-form" className={cx(props.kcFormClass)} action={url.loginAction} method="post"> <form id="kc-passwd-update-form" className={clsx(kcProps.kcFormClass)} action={url.loginAction} method="post">
<input <input
type="text" type="text"
id="username" id="username"
@ -30,44 +37,46 @@ const LoginUpdatePassword = memo(({ kcContext, i18n, ...props }: { kcContext: Kc
/> />
<input type="password" id="password" name="password" autoComplete="current-password" style={{ display: "none" }} /> <input type="password" id="password" name="password" autoComplete="current-password" style={{ display: "none" }} />
<div className={cx(props.kcFormGroupClass, messagesPerField.printIfExists("password", props.kcFormGroupErrorClass))}> <div className={clsx(kcProps.kcFormGroupClass, messagesPerField.printIfExists("password", kcProps.kcFormGroupErrorClass))}>
<div className={cx(props.kcLabelWrapperClass)}> <div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="password-new" className={cx(props.kcLabelClass)}> <label htmlFor="password-new" className={clsx(kcProps.kcLabelClass)}>
{msg("passwordNew")} {msg("passwordNew")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input <input
type="password" type="password"
id="password-new" id="password-new"
name="password-new" name="password-new"
autoFocus autoFocus
autoComplete="new-password" autoComplete="new-password"
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
/> />
</div> </div>
</div> </div>
<div className={cx(props.kcFormGroupClass, messagesPerField.printIfExists("password-confirm", props.kcFormGroupErrorClass))}> <div
<div className={cx(props.kcLabelWrapperClass)}> className={clsx(kcProps.kcFormGroupClass, messagesPerField.printIfExists("password-confirm", kcProps.kcFormGroupErrorClass))}
<label htmlFor="password-confirm" className={cx(props.kcLabelClass)}> >
<div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="password-confirm" className={clsx(kcProps.kcLabelClass)}>
{msg("passwordConfirm")} {msg("passwordConfirm")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input <input
type="password" type="password"
id="password-confirm" id="password-confirm"
name="password-confirm" name="password-confirm"
autoComplete="new-password" autoComplete="new-password"
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
/> />
</div> </div>
</div> </div>
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
<div id="kc-form-options" className={cx(props.kcFormOptionsClass)}> <div id="kc-form-options" className={clsx(kcProps.kcFormOptionsClass)}>
<div className={cx(props.kcFormOptionsWrapperClass)}> <div className={clsx(kcProps.kcFormOptionsWrapperClass)}>
{isAppInitiatedAction && ( {isAppInitiatedAction && (
<div className="checkbox"> <div className="checkbox">
<label> <label>
@ -79,16 +88,16 @@ const LoginUpdatePassword = memo(({ kcContext, i18n, ...props }: { kcContext: Kc
</div> </div>
</div> </div>
<div id="kc-form-buttons" className={cx(props.kcFormButtonsClass)}> <div id="kc-form-buttons" className={clsx(kcProps.kcFormButtonsClass)}>
{isAppInitiatedAction ? ( {isAppInitiatedAction ? (
<> <>
<input <input
className={cx(props.kcButtonClass, props.kcButtonPrimaryClass, props.kcButtonLargeClass)} className={clsx(kcProps.kcButtonClass, kcProps.kcButtonPrimaryClass, kcProps.kcButtonLargeClass)}
type="submit" type="submit"
defaultValue={msgStr("doSubmit")} defaultValue={msgStr("doSubmit")}
/> />
<button <button
className={cx(props.kcButtonClass, props.kcButtonDefaultClass, props.kcButtonLargeClass)} className={clsx(kcProps.kcButtonClass, kcProps.kcButtonDefaultClass, kcProps.kcButtonLargeClass)}
type="submit" type="submit"
name="cancel-aia" name="cancel-aia"
value="true" value="true"
@ -98,11 +107,11 @@ const LoginUpdatePassword = memo(({ kcContext, i18n, ...props }: { kcContext: Kc
</> </>
) : ( ) : (
<input <input
className={cx( className={clsx(
props.kcButtonClass, kcProps.kcButtonClass,
props.kcButtonPrimaryClass, kcProps.kcButtonPrimaryClass,
props.kcButtonBlockClass, kcProps.kcButtonBlockClass,
props.kcButtonLargeClass, kcProps.kcButtonLargeClass
)} )}
type="submit" type="submit"
defaultValue={msgStr("doSubmit")} defaultValue={msgStr("doSubmit")}

View File

@ -1,12 +1,20 @@
import React, { memo } from "react"; import React, { memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import { useCssAndCx } from "tss-react"; import { clsx } from "../tools/clsx";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const LoginUpdateProfile = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.LoginUpdateProfile; i18n: I18n } & KcProps) => { export type LoginUpdateProfile = KcProps & {
const { cx } = useCssAndCx(); kcContext: KcContextBase.LoginUpdateProfile;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const LoginUpdateProfile = memo((props: LoginUpdateProfile) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
@ -14,84 +22,89 @@ const LoginUpdateProfile = memo(({ kcContext, i18n, ...props }: { kcContext: KcC
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
headerNode={msg("loginProfileTitle")} headerNode={msg("loginProfileTitle")}
formNode={ formNode={
<form id="kc-update-profile-form" className={cx(props.kcFormClass)} action={url.loginAction} method="post"> <form id="kc-update-profile-form" className={clsx(kcProps.kcFormClass)} action={url.loginAction} method="post">
{user.editUsernameAllowed && ( {user.editUsernameAllowed && (
<div className={cx(props.kcFormGroupClass, messagesPerField.printIfExists("username", props.kcFormGroupErrorClass))}> <div className={clsx(kcProps.kcFormGroupClass, messagesPerField.printIfExists("username", kcProps.kcFormGroupErrorClass))}>
<div className={cx(props.kcLabelWrapperClass)}> <div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="username" className={cx(props.kcLabelClass)}> <label htmlFor="username" className={clsx(kcProps.kcLabelClass)}>
{msg("username")} {msg("username")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input <input
type="text" type="text"
id="username" id="username"
name="username" name="username"
defaultValue={user.username ?? ""} defaultValue={user.username ?? ""}
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
/> />
</div> </div>
</div> </div>
)} )}
<div className={cx(props.kcFormGroupClass, messagesPerField.printIfExists("email", props.kcFormGroupErrorClass))}> <div className={clsx(kcProps.kcFormGroupClass, messagesPerField.printIfExists("email", kcProps.kcFormGroupErrorClass))}>
<div className={cx(props.kcLabelWrapperClass)}> <div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="email" className={cx(props.kcLabelClass)}> <label htmlFor="email" className={clsx(kcProps.kcLabelClass)}>
{msg("email")} {msg("email")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input type="text" id="email" name="email" defaultValue={user.email ?? ""} className={cx(props.kcInputClass)} /> <input type="text" id="email" name="email" defaultValue={user.email ?? ""} className={clsx(kcProps.kcInputClass)} />
</div> </div>
</div> </div>
<div className={cx(props.kcFormGroupClass, messagesPerField.printIfExists("firstName", props.kcFormGroupErrorClass))}> <div className={clsx(kcProps.kcFormGroupClass, messagesPerField.printIfExists("firstName", kcProps.kcFormGroupErrorClass))}>
<div className={cx(props.kcLabelWrapperClass)}> <div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="firstName" className={cx(props.kcLabelClass)}> <label htmlFor="firstName" className={clsx(kcProps.kcLabelClass)}>
{msg("firstName")} {msg("firstName")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input <input
type="text" type="text"
id="firstName" id="firstName"
name="firstName" name="firstName"
defaultValue={user.firstName ?? ""} defaultValue={user.firstName ?? ""}
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
/> />
</div> </div>
</div> </div>
<div className={cx(props.kcFormGroupClass, messagesPerField.printIfExists("lastName", props.kcFormGroupErrorClass))}> <div className={clsx(kcProps.kcFormGroupClass, messagesPerField.printIfExists("lastName", kcProps.kcFormGroupErrorClass))}>
<div className={cx(props.kcLabelWrapperClass)}> <div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="lastName" className={cx(props.kcLabelClass)}> <label htmlFor="lastName" className={clsx(kcProps.kcLabelClass)}>
{msg("lastName")} {msg("lastName")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input type="text" id="lastName" name="lastName" defaultValue={user.lastName ?? ""} className={cx(props.kcInputClass)} /> <input
type="text"
id="lastName"
name="lastName"
defaultValue={user.lastName ?? ""}
className={clsx(kcProps.kcInputClass)}
/>
</div> </div>
</div> </div>
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
<div id="kc-form-options" className={cx(props.kcFormOptionsClass)}> <div id="kc-form-options" className={clsx(kcProps.kcFormOptionsClass)}>
<div className={cx(props.kcFormOptionsWrapperClass)} /> <div className={clsx(kcProps.kcFormOptionsWrapperClass)} />
</div> </div>
<div id="kc-form-buttons" className={cx(props.kcFormButtonsClass)}> <div id="kc-form-buttons" className={clsx(kcProps.kcFormButtonsClass)}>
{isAppInitiatedAction ? ( {isAppInitiatedAction ? (
<> <>
<input <input
className={cx(props.kcButtonClass, props.kcButtonPrimaryClass, props.kcButtonLargeClass)} className={clsx(kcProps.kcButtonClass, kcProps.kcButtonPrimaryClass, kcProps.kcButtonLargeClass)}
type="submit" type="submit"
defaultValue={msgStr("doSubmit")} defaultValue={msgStr("doSubmit")}
/> />
<button <button
className={cx(props.kcButtonClass, props.kcButtonDefaultClass, props.kcButtonLargeClass)} className={clsx(kcProps.kcButtonClass, kcProps.kcButtonDefaultClass, kcProps.kcButtonLargeClass)}
type="submit" type="submit"
name="cancel-aia" name="cancel-aia"
value="true" value="true"
@ -101,11 +114,11 @@ const LoginUpdateProfile = memo(({ kcContext, i18n, ...props }: { kcContext: KcC
</> </>
) : ( ) : (
<input <input
className={cx( className={clsx(
props.kcButtonClass, kcProps.kcButtonClass,
props.kcButtonPrimaryClass, kcProps.kcButtonPrimaryClass,
props.kcButtonBlockClass, kcProps.kcButtonBlockClass,
props.kcButtonLargeClass, kcProps.kcButtonLargeClass
)} )}
type="submit" type="submit"
defaultValue={msgStr("doSubmit")} defaultValue={msgStr("doSubmit")}

View File

@ -0,0 +1,169 @@
import React, { useState, memo } from "react";
import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase";
import { clsx } from "../tools/clsx";
import { useConstCallback } from "powerhooks/useConstCallback";
import type { FormEventHandler } from "react";
import type { I18n } from "../i18n";
export type LoginUsernameProps = KcProps & {
kcContext: KcContextBase.LoginUsername;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const LoginUsername = memo((props: LoginUsernameProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { social, realm, url, usernameHidden, login, registrationDisabled } = kcContext;
const { msg, msgStr } = i18n;
const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false);
const onSubmit = useConstCallback<FormEventHandler<HTMLFormElement>>(e => {
e.preventDefault();
setIsLoginButtonDisabled(true);
const formElement = e.target as HTMLFormElement;
//NOTE: Even if we login with email Keycloak expect username and password in
//the POST request.
formElement.querySelector("input[name='email']")?.setAttribute("name", "username");
formElement.submit();
});
return (
<Template
{...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
displayInfo={social.displayInfo}
displayWide={realm.password && social.providers !== undefined}
headerNode={msg("doLogIn")}
formNode={
<div id="kc-form" className={clsx(realm.password && social.providers !== undefined && kcProps.kcContentWrapperClass)}>
<div
id="kc-form-wrapper"
className={clsx(
realm.password && social.providers && [kcProps.kcFormSocialAccountContentClass, kcProps.kcFormSocialAccountClass]
)}
>
{realm.password && (
<form id="kc-form-login" onSubmit={onSubmit} action={url.loginAction} method="post">
<div className={clsx(kcProps.kcFormGroupClass)}>
{!usernameHidden &&
(() => {
const label = !realm.loginWithEmailAllowed
? "username"
: realm.registrationEmailAsUsername
? "email"
: "usernameOrEmail";
const autoCompleteHelper: typeof label = label === "usernameOrEmail" ? "username" : label;
return (
<>
<label htmlFor={autoCompleteHelper} className={clsx(kcProps.kcLabelClass)}>
{msg(label)}
</label>
<input
tabIndex={1}
id={autoCompleteHelper}
className={clsx(kcProps.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={clsx(kcProps.kcFormGroupClass, kcProps.kcFormSettingClass)}>
<div id="kc-form-options">
{realm.rememberMe && !usernameHidden && (
<div className="checkbox">
<label>
<input
tabIndex={3}
id="rememberMe"
name="rememberMe"
type="checkbox"
{...(login.rememberMe
? {
"checked": true
}
: {})}
/>
{msg("rememberMe")}
</label>
</div>
)}
</div>
</div>
<div id="kc-form-buttons" className={clsx(kcProps.kcFormGroupClass)}>
<input
tabIndex={4}
className={clsx(
kcProps.kcButtonClass,
kcProps.kcButtonPrimaryClass,
kcProps.kcButtonBlockClass,
kcProps.kcButtonLargeClass
)}
name="login"
id="kc-login"
type="submit"
value={msgStr("doLogIn")}
disabled={isLoginButtonDisabled}
/>
</div>
</form>
)}
</div>
{realm.password && social.providers !== undefined && (
<div id="kc-social-providers" className={clsx(kcProps.kcFormSocialAccountContentClass, kcProps.kcFormSocialAccountClass)}>
<ul
className={clsx(
kcProps.kcFormSocialAccountListClass,
social.providers.length > 4 && kcProps.kcFormSocialAccountDoubleListClass
)}
>
{social.providers.map(p => (
<li key={p.providerId} className={clsx(kcProps.kcFormSocialAccountListLinkClass)}>
<a href={p.loginUrl} id={`zocial-${p.alias}`} className={clsx("zocial", p.providerId)}>
<span>{p.displayName}</span>
</a>
</li>
))}
</ul>
</div>
)}
</div>
}
infoNode={
realm.password &&
realm.registrationAllowed &&
!registrationDisabled && (
<div id="kc-registration">
<span>
{msg("noAccount")}
<a tabIndex={6} href={url.registrationUrl}>
{msg("doRegister")}
</a>
</span>
</div>
)
}
/>
);
});
export default LoginUsername;

View File

@ -1,18 +1,27 @@
import React, { memo } from "react"; import React, { memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const LoginVerifyEmail = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.LoginVerifyEmail; i18n: I18n } & KcProps) => { export type LoginVerifyEmailProps = KcProps & {
kcContext: KcContextBase.LoginVerifyEmail;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const LoginVerifyEmail = memo((props: LoginVerifyEmailProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { msg } = i18n; const { msg } = i18n;
const { url, user } = kcContext; const { url, user } = kcContext;
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
displayMessage={false} displayMessage={false}
headerNode={msg("emailVerifyTitle")} headerNode={msg("emailVerifyTitle")}
formNode={ formNode={

View File

@ -1,21 +1,28 @@
import React, { memo } from "react"; import React, { memo } from "react";
import { useCssAndCx } from "tss-react"; import { clsx } from "../tools/clsx";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const LogoutConfirm = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.LogoutConfirm; i18n: I18n } & KcProps) => { export type LogoutConfirmProps = KcProps & {
const { url, client, logoutConfirm } = kcContext; kcContext: KcContextBase.LogoutConfirm;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const { cx } = useCssAndCx(); const LogoutConfirm = memo((props: LogoutConfirmProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { url, client, logoutConfirm } = kcContext;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
displayMessage={false} displayMessage={false}
headerNode={msg("logoutConfirmTitle")} headerNode={msg("logoutConfirmTitle")}
formNode={ formNode={
@ -24,18 +31,18 @@ const LogoutConfirm = memo(({ kcContext, i18n, ...props }: { kcContext: KcContex
<p className="instruction">{msg("logoutConfirmHeader")}</p> <p className="instruction">{msg("logoutConfirmHeader")}</p>
<form className="form-actions" action={url.logoutConfirmAction} method="POST"> <form className="form-actions" action={url.logoutConfirmAction} method="POST">
<input type="hidden" name="session_code" value={logoutConfirm.code} /> <input type="hidden" name="session_code" value={logoutConfirm.code} />
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
<div id="kc-form-options"> <div id="kc-form-options">
<div className={cx(props.kcFormOptionsWrapperClass)}></div> <div className={clsx(kcProps.kcFormOptionsWrapperClass)}></div>
</div> </div>
<div id="kc-form-buttons" className={cx(props.kcFormGroupClass)}> <div id="kc-form-buttons" className={clsx(kcProps.kcFormGroupClass)}>
<input <input
tabIndex={4} tabIndex={4}
className={cx( className={clsx(
props.kcButtonClass, kcProps.kcButtonClass,
props.kcButtonPrimaryClass, kcProps.kcButtonPrimaryClass,
props.kcButtonBlockClass, kcProps.kcButtonBlockClass,
props.kcButtonLargeClass, kcProps.kcButtonLargeClass
)} )}
name="confirmLogout" name="confirmLogout"
id="kc-logout" id="kc-logout"

View File

@ -1,69 +1,76 @@
import React, { memo } from "react"; import React, { memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import { useCssAndCx } from "tss-react"; import { clsx } from "../tools/clsx";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const Register = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Register; i18n: I18n } & KcProps) => { export type RegisterProps = KcProps & {
kcContext: KcContextBase.Register;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const Register = memo((props: RegisterProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { url, messagesPerField, register, realm, passwordRequired, recaptchaRequired, recaptchaSiteKey } = kcContext; const { url, messagesPerField, register, realm, passwordRequired, recaptchaRequired, recaptchaSiteKey } = kcContext;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
const { cx } = useCssAndCx();
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
headerNode={msg("registerTitle")} headerNode={msg("registerTitle")}
formNode={ formNode={
<form id="kc-register-form" className={cx(props.kcFormClass)} action={url.registrationAction} method="post"> <form id="kc-register-form" className={clsx(kcProps.kcFormClass)} action={url.registrationAction} method="post">
<div className={cx(props.kcFormGroupClass, messagesPerField.printIfExists("firstName", props.kcFormGroupErrorClass))}> <div className={clsx(kcProps.kcFormGroupClass, messagesPerField.printIfExists("firstName", kcProps.kcFormGroupErrorClass))}>
<div className={cx(props.kcLabelWrapperClass)}> <div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="firstName" className={cx(props.kcLabelClass)}> <label htmlFor="firstName" className={clsx(kcProps.kcLabelClass)}>
{msg("firstName")} {msg("firstName")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input <input
type="text" type="text"
id="firstName" id="firstName"
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
name="firstName" name="firstName"
defaultValue={register.formData.firstName ?? ""} defaultValue={register.formData.firstName ?? ""}
/> />
</div> </div>
</div> </div>
<div className={cx(props.kcFormGroupClass, messagesPerField.printIfExists("lastName", props.kcFormGroupErrorClass))}> <div className={clsx(kcProps.kcFormGroupClass, messagesPerField.printIfExists("lastName", kcProps.kcFormGroupErrorClass))}>
<div className={cx(props.kcLabelWrapperClass)}> <div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="lastName" className={cx(props.kcLabelClass)}> <label htmlFor="lastName" className={clsx(kcProps.kcLabelClass)}>
{msg("lastName")} {msg("lastName")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input <input
type="text" type="text"
id="lastName" id="lastName"
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
name="lastName" name="lastName"
defaultValue={register.formData.lastName ?? ""} defaultValue={register.formData.lastName ?? ""}
/> />
</div> </div>
</div> </div>
<div className={cx(props.kcFormGroupClass, messagesPerField.printIfExists("email", props.kcFormGroupErrorClass))}> <div className={clsx(kcProps.kcFormGroupClass, messagesPerField.printIfExists("email", kcProps.kcFormGroupErrorClass))}>
<div className={cx(props.kcLabelWrapperClass)}> <div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="email" className={cx(props.kcLabelClass)}> <label htmlFor="email" className={clsx(kcProps.kcLabelClass)}>
{msg("email")} {msg("email")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input <input
type="text" type="text"
id="email" id="email"
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
name="email" name="email"
defaultValue={register.formData.email ?? ""} defaultValue={register.formData.email ?? ""}
autoComplete="email" autoComplete="email"
@ -71,17 +78,17 @@ const Register = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase
</div> </div>
</div> </div>
{!realm.registrationEmailAsUsername && ( {!realm.registrationEmailAsUsername && (
<div className={cx(props.kcFormGroupClass, messagesPerField.printIfExists("username", props.kcFormGroupErrorClass))}> <div className={clsx(kcProps.kcFormGroupClass, messagesPerField.printIfExists("username", kcProps.kcFormGroupErrorClass))}>
<div className={cx(props.kcLabelWrapperClass)}> <div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="username" className={cx(props.kcLabelClass)}> <label htmlFor="username" className={clsx(kcProps.kcLabelClass)}>
{msg("username")} {msg("username")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input <input
type="text" type="text"
id="username" id="username"
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
name="username" name="username"
defaultValue={register.formData.username ?? ""} defaultValue={register.formData.username ?? ""}
autoComplete="username" autoComplete="username"
@ -91,17 +98,19 @@ const Register = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase
)} )}
{passwordRequired && ( {passwordRequired && (
<> <>
<div className={cx(props.kcFormGroupClass, messagesPerField.printIfExists("password", props.kcFormGroupErrorClass))}> <div
<div className={cx(props.kcLabelWrapperClass)}> className={clsx(kcProps.kcFormGroupClass, messagesPerField.printIfExists("password", kcProps.kcFormGroupErrorClass))}
<label htmlFor="password" className={cx(props.kcLabelClass)}> >
<div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="password" className={clsx(kcProps.kcLabelClass)}>
{msg("password")} {msg("password")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input <input
type="password" type="password"
id="password" id="password"
className={cx(props.kcInputClass)} className={clsx(kcProps.kcInputClass)}
name="password" name="password"
autoComplete="new-password" autoComplete="new-password"
/> />
@ -109,41 +118,46 @@ const Register = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase
</div> </div>
<div <div
className={cx( className={clsx(
props.kcFormGroupClass, kcProps.kcFormGroupClass,
messagesPerField.printIfExists("password-confirm", props.kcFormGroupErrorClass), messagesPerField.printIfExists("password-confirm", kcProps.kcFormGroupErrorClass)
)} )}
> >
<div className={cx(props.kcLabelWrapperClass)}> <div className={clsx(kcProps.kcLabelWrapperClass)}>
<label htmlFor="password-confirm" className={cx(props.kcLabelClass)}> <label htmlFor="password-confirm" className={clsx(kcProps.kcLabelClass)}>
{msg("passwordConfirm")} {msg("passwordConfirm")}
</label> </label>
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<input type="password" id="password-confirm" className={cx(props.kcInputClass)} name="password-confirm" /> <input type="password" id="password-confirm" className={clsx(kcProps.kcInputClass)} name="password-confirm" />
</div> </div>
</div> </div>
</> </>
)} )}
{recaptchaRequired && ( {recaptchaRequired && (
<div className="form-group"> <div className="form-group">
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<div className="g-recaptcha" data-size="compact" data-sitekey={recaptchaSiteKey}></div> <div className="g-recaptcha" data-size="compact" data-sitekey={recaptchaSiteKey}></div>
</div> </div>
</div> </div>
)} )}
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)}>
<div id="kc-form-options" className={cx(props.kcFormOptionsClass)}> <div id="kc-form-options" className={clsx(kcProps.kcFormOptionsClass)}>
<div className={cx(props.kcFormOptionsWrapperClass)}> <div className={clsx(kcProps.kcFormOptionsWrapperClass)}>
<span> <span>
<a href={url.loginUrl}>{msg("backToLogin")}</a> <a href={url.loginUrl}>{msg("backToLogin")}</a>
</span> </span>
</div> </div>
</div> </div>
<div id="kc-form-buttons" className={cx(props.kcFormButtonsClass)}> <div id="kc-form-buttons" className={clsx(kcProps.kcFormButtonsClass)}>
<input <input
className={cx(props.kcButtonClass, props.kcButtonPrimaryClass, props.kcButtonBlockClass, props.kcButtonLargeClass)} className={clsx(
kcProps.kcButtonClass,
kcProps.kcButtonPrimaryClass,
kcProps.kcButtonBlockClass,
kcProps.kcButtonLargeClass
)}
type="submit" type="submit"
value={msgStr("doRegister")} value={msgStr("doRegister")}
/> />

View File

@ -1,59 +1,61 @@
import React, { useMemo, memo, useEffect, useState, Fragment } from "react"; import React, { memo, useState } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase, Attribute } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import { useCssAndCx } from "tss-react"; import { clsx } from "../tools/clsx";
import type { ReactComponent } from "../tools/ReactComponent";
import { useCallbackFactory } from "powerhooks/useCallbackFactory";
import { useFormValidationSlice } from "../useFormValidationSlice";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
import { UserProfileFormFields } from "./shared/UserProfileCommons";
export type RegisterUserProfileProps = KcProps & {
kcContext: KcContextBase.RegisterUserProfile;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const RegisterUserProfile = memo((props: RegisterUserProfileProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const RegisterUserProfile = memo(({ kcContext, i18n, ...props_ }: { kcContext: KcContextBase.RegisterUserProfile; i18n: I18n } & KcProps) => {
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey } = kcContext; const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey } = kcContext;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
const { cx, css } = useCssAndCx();
const props = useMemo(
() => ({
...props_,
"kcFormGroupClass": cx(props_.kcFormGroupClass, css({ "marginBottom": 20 })),
}),
[cx, css],
);
const [isFomSubmittable, setIsFomSubmittable] = useState(false); const [isFomSubmittable, setIsFomSubmittable] = useState(false);
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
displayMessage={messagesPerField.exists("global")} displayMessage={messagesPerField.exists("global")}
displayRequiredFields={true} displayRequiredFields={true}
doFetchDefaultThemeResources={true}
headerNode={msg("registerTitle")} headerNode={msg("registerTitle")}
formNode={ formNode={
<form id="kc-register-form" className={cx(props.kcFormClass)} action={url.registrationAction} method="post"> <form id="kc-register-form" className={clsx(kcProps.kcFormClass)} action={url.registrationAction} method="post">
<UserProfileFormFields kcContext={kcContext} onIsFormSubmittableValueChange={setIsFomSubmittable} i18n={i18n} {...props} /> <UserProfileFormFields kcContext={kcContext} onIsFormSubmittableValueChange={setIsFomSubmittable} i18n={i18n} {...kcProps} />
{recaptchaRequired && ( {recaptchaRequired && (
<div className="form-group"> <div className="form-group">
<div className={cx(props.kcInputWrapperClass)}> <div className={clsx(kcProps.kcInputWrapperClass)}>
<div className="g-recaptcha" data-size="compact" data-sitekey={recaptchaSiteKey} /> <div className="g-recaptcha" data-size="compact" data-sitekey={recaptchaSiteKey} />
</div> </div>
</div> </div>
)} )}
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(kcProps.kcFormGroupClass)} style={{ "marginBottom": 30 }}>
<div id="kc-form-options" className={cx(props.kcFormOptionsClass)}> <div id="kc-form-options" className={clsx(kcProps.kcFormOptionsClass)}>
<div className={cx(props.kcFormOptionsWrapperClass)}> <div className={clsx(kcProps.kcFormOptionsWrapperClass)}>
<span> <span>
<a href={url.loginUrl}>{msg("backToLogin")}</a> <a href={url.loginUrl}>{msg("backToLogin")}</a>
</span> </span>
</div> </div>
</div> </div>
<div id="kc-form-buttons" className={cx(props.kcFormButtonsClass)}> <div id="kc-form-buttons" className={clsx(kcProps.kcFormButtonsClass)}>
<input <input
className={cx(props.kcButtonClass, props.kcButtonPrimaryClass, props.kcButtonBlockClass, props.kcButtonLargeClass)} className={clsx(
kcProps.kcButtonClass,
kcProps.kcButtonPrimaryClass,
kcProps.kcButtonBlockClass,
kcProps.kcButtonLargeClass
)}
type="submit" type="submit"
value={msgStr("doRegister")} value={msgStr("doRegister")}
disabled={!isFomSubmittable} disabled={!isFomSubmittable}
@ -66,155 +68,4 @@ const RegisterUserProfile = memo(({ kcContext, i18n, ...props_ }: { kcContext: K
); );
}); });
type UserProfileFormFieldsProps = { kcContext: KcContextBase.RegisterUserProfile; i18n: I18n } & KcProps &
Partial<Record<"BeforeField" | "AfterField", ReactComponent<{ attribute: Attribute }>>> & {
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
};
const UserProfileFormFields = memo(({ kcContext, onIsFormSubmittableValueChange, i18n, ...props }: UserProfileFormFieldsProps) => {
const { cx, css } = useCssAndCx();
const { advancedMsg } = i18n;
const {
formValidationState: { fieldStateByAttributeName, isFormSubmittable },
formValidationReducer,
attributesWithPassword,
} = useFormValidationSlice({
kcContext,
i18n,
});
useEffect(() => {
onIsFormSubmittableValueChange(isFormSubmittable);
}, [isFormSubmittable]);
const onChangeFactory = useCallbackFactory(
(
[name]: [string],
[
{
target: { value },
},
]: [React.ChangeEvent<HTMLInputElement | HTMLSelectElement>],
) =>
formValidationReducer({
"action": "update value",
name,
"newValue": value,
}),
);
const onBlurFactory = useCallbackFactory(([name]: [string]) =>
formValidationReducer({
"action": "focus lost",
name,
}),
);
let currentGroup = "";
return (
<>
{attributesWithPassword.map((attribute, i) => {
const { group = "", groupDisplayHeader = "", groupDisplayDescription = "" } = attribute;
const { value, displayableErrors } = fieldStateByAttributeName[attribute.name];
const formGroupClassName = cx(props.kcFormGroupClass, displayableErrors.length !== 0 && props.kcFormGroupErrorClass);
return (
<Fragment key={i}>
{group !== currentGroup && (currentGroup = group) !== "" && (
<div className={formGroupClassName}>
<div className={cx(props.kcContentWrapperClass)}>
<label id={`header-${group}`} className={cx(props.kcFormGroupHeader)}>
{advancedMsg(groupDisplayHeader) || currentGroup}
</label>
</div>
{groupDisplayDescription !== "" && (
<div className={cx(props.kcLabelWrapperClass)}>
<label id={`description-${group}`} className={`${cx(props.kcLabelClass)}`}>
{advancedMsg(groupDisplayDescription)}
</label>
</div>
)}
</div>
)}
<div className={formGroupClassName}>
<div className={cx(props.kcLabelWrapperClass)}>
<label htmlFor={attribute.name} className={cx(props.kcLabelClass)}>
{advancedMsg(attribute.displayName ?? "")}
</label>
{attribute.required && <>*</>}
</div>
<div className={cx(props.kcInputWrapperClass)}>
{(() => {
const { options } = attribute.validators;
if (options !== undefined) {
return (
<select
id={attribute.name}
name={attribute.name}
onChange={onChangeFactory(attribute.name)}
onBlur={onBlurFactory(attribute.name)}
value={value}
>
{options.options.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
);
}
return (
<input
type={(() => {
switch (attribute.name) {
case "password-confirm":
case "password":
return "password";
default:
return "text";
}
})()}
id={attribute.name}
name={attribute.name}
value={value}
onChange={onChangeFactory(attribute.name)}
className={cx(props.kcInputClass)}
aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly}
autoComplete={attribute.autocomplete}
onBlur={onBlurFactory(attribute.name)}
/>
);
})()}
{displayableErrors.length !== 0 && (
<span
id={`input-error-${attribute.name}`}
className={cx(
props.kcInputErrorMessageClass,
css({
"position": displayableErrors.length === 1 ? "absolute" : undefined,
"& > span": { "display": "block" },
}),
)}
aria-live="polite"
>
{displayableErrors.map(({ errorMessage }) => errorMessage)}
</span>
)}
</div>
</div>
</Fragment>
);
})}
</>
);
});
export default RegisterUserProfile; export default RegisterUserProfile;

View File

@ -7,7 +7,7 @@ import { headInsert } from "../tools/headInsert";
import { pathJoin } from "../../bin/tools/pathJoin"; import { pathJoin } from "../../bin/tools/pathJoin";
import { useConstCallback } from "powerhooks/useConstCallback"; import { useConstCallback } from "powerhooks/useConstCallback";
import type { KcTemplateProps } from "./KcProps"; import type { KcTemplateProps } from "./KcProps";
import { useCssAndCx } from "tss-react"; import { clsx } from "../tools/clsx";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
export type TemplateProps = { export type TemplateProps = {
@ -39,15 +39,9 @@ const Template = memo((props: TemplateProps) => {
infoNode = null, infoNode = null,
kcContext, kcContext,
i18n, i18n,
doFetchDefaultThemeResources, doFetchDefaultThemeResources
} = props; } = props;
const { cx } = useCssAndCx();
useEffect(() => {
console.log("Rendering this page with react using keycloakify");
}, []);
const { msg, changeLocale, labelBySupportedLanguageTag, currentLanguageTag } = i18n; const { msg, changeLocale, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const onChangeLanguageClickFactory = useCallbackFactory(([kcLanguageTag]: [string]) => changeLocale(kcLanguageTag)); const onChangeLanguageClickFactory = useCallbackFactory(([kcLanguageTag]: [string]) => changeLocale(kcLanguageTag));
@ -72,16 +66,16 @@ const Template = memo((props: TemplateProps) => {
Promise.all( Promise.all(
[ [
...toArr(props.stylesCommon).map(relativePath => pathJoin(url.resourcesCommonPath, relativePath)), ...toArr(props.stylesCommon).map(relativePath => pathJoin(url.resourcesCommonPath, relativePath)),
...toArr(props.styles).map(relativePath => pathJoin(url.resourcesPath, relativePath)), ...toArr(props.styles).map(relativePath => pathJoin(url.resourcesPath, relativePath))
] ]
.reverse() .reverse()
.map(href => .map(href =>
headInsert({ headInsert({
"type": "css", "type": "css",
href, href,
"position": "prepend", "position": "prepend"
}), })
), )
).then(() => { ).then(() => {
if (isUnmounted) { if (isUnmounted) {
return; return;
@ -93,14 +87,14 @@ const Template = memo((props: TemplateProps) => {
toArr(props.scripts).forEach(relativePath => toArr(props.scripts).forEach(relativePath =>
headInsert({ headInsert({
"type": "javascript", "type": "javascript",
"src": pathJoin(url.resourcesPath, relativePath), "src": pathJoin(url.resourcesPath, relativePath)
}), })
); );
if (props.kcHtmlClass !== undefined) { if (props.kcHtmlClass !== undefined) {
const htmlClassList = document.getElementsByTagName("html")[0].classList; const htmlClassList = document.getElementsByTagName("html")[0].classList;
const tokens = cx(props.kcHtmlClass).split(" "); const tokens = clsx(props.kcHtmlClass).split(" ");
htmlClassList.add(...tokens); htmlClassList.add(...tokens);
@ -119,18 +113,18 @@ const Template = memo((props: TemplateProps) => {
} }
return ( return (
<div className={cx(props.kcLoginClass)}> <div className={clsx(props.kcLoginClass)}>
<div id="kc-header" className={cx(props.kcHeaderClass)}> <div id="kc-header" className={clsx(props.kcHeaderClass)}>
<div id="kc-header-wrapper" className={cx(props.kcHeaderWrapperClass)}> <div id="kc-header-wrapper" className={clsx(props.kcHeaderWrapperClass)}>
{msg("loginTitleHtml", realm.displayNameHtml)} {msg("loginTitleHtml", realm.displayNameHtml)}
</div> </div>
</div> </div>
<div className={cx(props.kcFormCardClass, displayWide && props.kcFormCardAccountClass)}> <div className={clsx(props.kcFormCardClass, displayWide && props.kcFormCardAccountClass)}>
<header className={cx(props.kcFormHeaderClass)}> <header className={clsx(props.kcFormHeaderClass)}>
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && ( {realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
<div id="kc-locale"> <div id="kc-locale">
<div id="kc-locale-wrapper" className={cx(props.kcLocaleWrapperClass)}> <div id="kc-locale-wrapper" className={clsx(props.kcLocaleWrapperClass)}>
<div className="kc-dropdown" id="kc-locale-dropdown"> <div className="kc-dropdown" id="kc-locale-dropdown">
<a href="#" id="kc-current-locale-link"> <a href="#" id="kc-current-locale-link">
{labelBySupportedLanguageTag[currentLanguageTag]} {labelBySupportedLanguageTag[currentLanguageTag]}
@ -150,8 +144,8 @@ const Template = memo((props: TemplateProps) => {
)} )}
{!(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? ( {!(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
displayRequiredFields ? ( displayRequiredFields ? (
<div className={cx(props.kcContentWrapperClass)}> <div className={clsx(props.kcContentWrapperClass)}>
<div className={cx(props.kcLabelWrapperClass, "subtitle")}> <div className={clsx(props.kcLabelWrapperClass, "subtitle")}>
<span className="subtitle"> <span className="subtitle">
<span className="required">*</span> <span className="required">*</span>
{msg("requiredFields")} {msg("requiredFields")}
@ -165,20 +159,20 @@ const Template = memo((props: TemplateProps) => {
<h1 id="kc-page-title">{headerNode}</h1> <h1 id="kc-page-title">{headerNode}</h1>
) )
) : displayRequiredFields ? ( ) : displayRequiredFields ? (
<div className={cx(props.kcContentWrapperClass)}> <div className={clsx(props.kcContentWrapperClass)}>
<div className={cx(props.kcLabelWrapperClass, "subtitle")}> <div className={clsx(props.kcLabelWrapperClass, "subtitle")}>
<span className="subtitle"> <span className="subtitle">
<span className="required">*</span> {msg("requiredFields")} <span className="required">*</span> {msg("requiredFields")}
</span> </span>
</div> </div>
<div className="col-md-10"> <div className="col-md-10">
{showUsernameNode} {showUsernameNode}
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(props.kcFormGroupClass)}>
<div id="kc-username"> <div id="kc-username">
<label id="kc-attempted-username">{auth?.attemptedUsername}</label> <label id="kc-attempted-username">{auth?.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl}> <a id="reset-login" href={url.loginRestartFlowUrl}>
<div className="kc-login-tooltip"> <div className="kc-login-tooltip">
<i className={cx(props.kcResetFlowIcon)}></i> <i className={clsx(props.kcResetFlowIcon)}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span> <span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div> </div>
</a> </a>
@ -189,12 +183,12 @@ const Template = memo((props: TemplateProps) => {
) : ( ) : (
<> <>
{showUsernameNode} {showUsernameNode}
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(props.kcFormGroupClass)}>
<div id="kc-username"> <div id="kc-username">
<label id="kc-attempted-username">{auth?.attemptedUsername}</label> <label id="kc-attempted-username">{auth?.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl}> <a id="reset-login" href={url.loginRestartFlowUrl}>
<div className="kc-login-tooltip"> <div className="kc-login-tooltip">
<i className={cx(props.kcResetFlowIcon)}></i> <i className={clsx(props.kcResetFlowIcon)}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span> <span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div> </div>
</a> </a>
@ -207,15 +201,15 @@ const Template = memo((props: TemplateProps) => {
<div id="kc-content-wrapper"> <div id="kc-content-wrapper">
{/* App-initiated actions should not see warning messages about the need to complete the action during login. */} {/* App-initiated actions should not see warning messages about the need to complete the action during login. */}
{displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && ( {displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && (
<div className={cx("alert", `alert-${message.type}`)}> <div className={clsx("alert", `alert-${message.type}`)}>
{message.type === "success" && <span className={cx(props.kcFeedbackSuccessIcon)}></span>} {message.type === "success" && <span className={clsx(props.kcFeedbackSuccessIcon)}></span>}
{message.type === "warning" && <span className={cx(props.kcFeedbackWarningIcon)}></span>} {message.type === "warning" && <span className={clsx(props.kcFeedbackWarningIcon)}></span>}
{message.type === "error" && <span className={cx(props.kcFeedbackErrorIcon)}></span>} {message.type === "error" && <span className={clsx(props.kcFeedbackErrorIcon)}></span>}
{message.type === "info" && <span className={cx(props.kcFeedbackInfoIcon)}></span>} {message.type === "info" && <span className={clsx(props.kcFeedbackInfoIcon)}></span>}
<span <span
className="kc-feedback-text" className="kc-feedback-text"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
"__html": message.summary, "__html": message.summary
}} }}
/> />
</div> </div>
@ -226,10 +220,10 @@ const Template = memo((props: TemplateProps) => {
id="kc-select-try-another-way-form" id="kc-select-try-another-way-form"
action={url.loginAction} action={url.loginAction}
method="post" method="post"
className={cx(displayWide && props.kcContentWrapperClass)} className={clsx(displayWide && props.kcContentWrapperClass)}
> >
<div className={cx(displayWide && [props.kcFormSocialAccountContentClass, props.kcFormSocialAccountClass])}> <div className={clsx(displayWide && [props.kcFormSocialAccountContentClass, props.kcFormSocialAccountClass])}>
<div className={cx(props.kcFormGroupClass)}> <div className={clsx(props.kcFormGroupClass)}>
<input type="hidden" name="tryAnotherWay" value="on" /> <input type="hidden" name="tryAnotherWay" value="on" />
<a href="#" id="try-another-way" onClick={onTryAnotherWayClick}> <a href="#" id="try-another-way" onClick={onTryAnotherWayClick}>
{msg("doTryAnotherWay")} {msg("doTryAnotherWay")}
@ -239,8 +233,8 @@ const Template = memo((props: TemplateProps) => {
</form> </form>
)} )}
{displayInfo && ( {displayInfo && (
<div id="kc-info" className={cx(props.kcSignUpClass)}> <div id="kc-info" className={clsx(props.kcSignUpClass)}>
<div id="kc-info-wrapper" className={cx(props.kcInfoAreaWrapperClass)}> <div id="kc-info-wrapper" className={clsx(props.kcInfoAreaWrapperClass)}>
{infoNode} {infoNode}
</div> </div>
</div> </div>

View File

@ -1,8 +1,9 @@
import React, { useEffect, memo } from "react"; import React, { useEffect, memo } from "react";
import Template from "./Template"; import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import { useCssAndCx } from "tss-react"; import { clsx } from "../tools/clsx";
import { Evt } from "evt"; import { Evt } from "evt";
import { useRerenderOnStateChange } from "evt/hooks"; import { useRerenderOnStateChange } from "evt/hooks";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
@ -11,16 +12,19 @@ import type { I18n } from "../i18n";
import memoize from "memoizee"; import memoize from "memoizee";
import { useConst } from "powerhooks/useConst"; import { useConst } from "powerhooks/useConst";
import { useConstCallback } from "powerhooks/useConstCallback"; import { useConstCallback } from "powerhooks/useConstCallback";
import { Markdown } from "../tools/Markdown";
import type { Extends } from "tsafe";
export const evtTermMarkdown = Evt.create<string | undefined>(undefined); export const evtTermMarkdown = Evt.create<string | undefined>(undefined);
export type KcContextLike = { export type KcContextLike = {
pageId: KcContextBase["pageId"];
locale?: { locale?: {
currentLanguageTag: string; currentLanguageTag: string;
}; };
}; };
assert<KcContextBase extends KcContextLike ? true : false>(); assert<Extends<KcContextBase, KcContextLike>>();
/** Allow to avoid bundling the terms and download it on demand*/ /** Allow to avoid bundling the terms and download it on demand*/
export function useDownloadTerms(params: { export function useDownloadTerms(params: {
@ -35,26 +39,37 @@ export function useDownloadTerms(params: {
const downloadTermMarkdownConst = useConstCallback(downloadTermMarkdown); const downloadTermMarkdownConst = useConstCallback(downloadTermMarkdown);
const downloadTermMarkdownMemoized = useConst(() => const downloadTermMarkdownMemoized = useConst(() =>
memoize((currentLanguageTag: string) => downloadTermMarkdownConst({ currentLanguageTag }), { "promise": true }), memoize((currentLanguageTag: string) => downloadTermMarkdownConst({ currentLanguageTag }), { "promise": true })
); );
return { downloadTermMarkdownMemoized }; return { downloadTermMarkdownMemoized };
})(); })();
useEffect(() => { useEffect(() => {
if (kcContext.pageId !== "terms.ftl") {
return;
}
downloadTermMarkdownMemoized(kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag).then( downloadTermMarkdownMemoized(kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag).then(
thermMarkdown => (evtTermMarkdown.state = thermMarkdown), thermMarkdown => (evtTermMarkdown.state = thermMarkdown)
); );
}, []); }, []);
} }
const Terms = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Terms; i18n: I18n } & KcProps) => { export type TermsProps = KcProps & {
kcContext: KcContextBase.Terms;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const Terms = memo((props: TermsProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
useRerenderOnStateChange(evtTermMarkdown); useRerenderOnStateChange(evtTermMarkdown);
const { cx } = useCssAndCx();
const { url } = kcContext; const { url } = kcContext;
if (evtTermMarkdown.state === undefined) { if (evtTermMarkdown.state === undefined) {
@ -63,21 +78,20 @@ const Terms = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Te
return ( return (
<Template <Template
{...{ kcContext, i18n, ...props }} {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
doFetchDefaultThemeResources={true}
displayMessage={false} displayMessage={false}
headerNode={msg("termsTitle")} headerNode={msg("termsTitle")}
formNode={ formNode={
<> <>
<div id="kc-terms-text">{evtTermMarkdown.state}</div> <div id="kc-terms-text">{evtTermMarkdown.state && <Markdown>{evtTermMarkdown.state}</Markdown>}</div>
<form className="form-actions" action={url.loginAction} method="POST"> <form className="form-actions" action={url.loginAction} method="POST">
<input <input
className={cx( className={clsx(
props.kcButtonClass, kcProps.kcButtonClass,
props.kcButtonClass, kcProps.kcButtonClass,
props.kcButtonClass, kcProps.kcButtonClass,
props.kcButtonPrimaryClass, kcProps.kcButtonPrimaryClass,
props.kcButtonLargeClass, kcProps.kcButtonLargeClass
)} )}
name="accept" name="accept"
id="kc-accept" id="kc-accept"
@ -85,7 +99,7 @@ const Terms = memo(({ kcContext, i18n, ...props }: { kcContext: KcContextBase.Te
value={msgStr("doAccept")} value={msgStr("doAccept")}
/> />
<input <input
className={cx(props.kcButtonClass, props.kcButtonDefaultClass, props.kcButtonLargeClass)} className={clsx(kcProps.kcButtonClass, kcProps.kcButtonDefaultClass, kcProps.kcButtonLargeClass)}
name="cancel" name="cancel"
id="kc-decline" id="kc-decline"
type="submit" type="submit"

View File

@ -0,0 +1,78 @@
import React, { useState, memo } from "react";
import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase";
import { clsx } from "../tools/clsx";
import type { I18n } from "../i18n";
import { UserProfileFormFields } from "./shared/UserProfileCommons";
export type UpdateUserProfileProps = KcProps & {
kcContext: KcContextBase.UpdateUserProfile;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const UpdateUserProfile = memo((props: UpdateUserProfileProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { msg, msgStr } = i18n;
const { url, isAppInitiatedAction } = kcContext;
const [isFomSubmittable, setIsFomSubmittable] = useState(false);
return (
<Template
{...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
headerNode={msg("loginProfileTitle")}
formNode={
<form id="kc-update-profile-form" className={clsx(kcProps.kcFormClass)} action={url.loginAction} method="post">
<UserProfileFormFields kcContext={kcContext} onIsFormSubmittableValueChange={setIsFomSubmittable} i18n={i18n} {...kcProps} />
<div className={clsx(kcProps.kcFormGroupClass)}>
<div id="kc-form-options" className={clsx(kcProps.kcFormOptionsClass)}>
<div className={clsx(kcProps.kcFormOptionsWrapperClass)}></div>
</div>
<div id="kc-form-buttons" className={clsx(kcProps.kcFormButtonsClass)}>
{isAppInitiatedAction ? (
<>
<input
className={clsx(kcProps.kcButtonClass, kcProps.kcButtonPrimaryClass, kcProps.kcButtonLargeClass)}
type="submit"
value={msgStr("doSubmit")}
/>
<button
className={clsx(kcProps.kcButtonClass, kcProps.kcButtonDefaultClass, kcProps.kcButtonLargeClass)}
type="submit"
name="cancel-aia"
value="true"
formNoValidate
>
{msg("doCancel")}
</button>
</>
) : (
<input
className={clsx(
kcProps.kcButtonClass,
kcProps.kcButtonPrimaryClass,
kcProps.kcButtonBlockClass,
kcProps.kcButtonLargeClass
)}
type="submit"
defaultValue={msgStr("doSubmit")}
disabled={!isFomSubmittable}
/>
)}
</div>
</div>
</form>
}
/>
);
});
export default UpdateUserProfile;

View File

@ -0,0 +1,203 @@
import React, { useRef, useState, memo } from "react";
import DefaultTemplate from "./Template";
import type { TemplateProps } from "./Template";
import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase";
import { clsx } from "../tools/clsx";
import type { I18n, MessageKeyBase } from "../i18n";
import { base64url } from "rfc4648";
import { useConstCallback } from "powerhooks/useConstCallback";
export type WebauthnAuthenticateProps = KcProps & {
kcContext: KcContextBase.WebauthnAuthenticate;
i18n: I18n;
doFetchDefaultThemeResources?: boolean;
Template?: (props: TemplateProps) => JSX.Element | null;
};
const WebauthnAuthenticate = memo((props: WebauthnAuthenticateProps) => {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template = DefaultTemplate, ...kcProps } = props;
const { url } = kcContext;
const { msg, msgStr } = i18n;
const { authenticators, challenge, shouldDisplayAuthenticators, userVerification, rpId } = kcContext;
const createTimeout = Number(kcContext.createTimeout);
const isUserIdentified = kcContext.isUserIdentified == "true";
const webAuthnAuthenticate = useConstCallback(async () => {
if (!isUserIdentified) {
return;
}
const allowCredentials = authenticators.authenticators.map(
authenticator =>
({
id: base64url.parse(authenticator.credentialId, { loose: true }),
type: "public-key"
} as PublicKeyCredentialDescriptor)
);
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
setError(msgStr("webauthn-unsupported-browser-text"));
submitForm();
return;
}
const publicKey: PublicKeyCredentialRequestOptions = {
rpId,
challenge: base64url.parse(challenge, { loose: true })
};
if (createTimeout !== 0) {
publicKey.timeout = createTimeout * 1000;
}
if (allowCredentials.length) {
publicKey.allowCredentials = allowCredentials;
}
if (userVerification !== "not specified") {
publicKey.userVerification = userVerification;
}
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 clientDataJSON = response.clientDataJSON;
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 }));
setCredentialId(result.id);
setUserHandle(base64url.stringify(new Uint8Array(response.userHandle!), { pad: false }));
submitForm();
} catch (err) {
setError(String(err));
submitForm();
}
});
const webAuthForm = useRef<HTMLFormElement>(null);
const submitForm = useConstCallback(() => {
webAuthForm.current!.submit();
});
const [clientDataJSON, setClientDataJSON] = useState("");
const [authenticatorData, setAuthenticatorData] = useState("");
const [signature, setSignature] = useState("");
const [credentialId, setCredentialId] = useState("");
const [userHandle, setUserHandle] = useState("");
const [error, setError] = useState("");
return (
<Template
{...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
headerNode={msg("webauthn-login-title")}
formNode={
<div id="kc-form-webauthn" className={clsx(kcProps.kcFormClass)}>
<form id="webauth" action={url.loginAction} ref={webAuthForm} 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} />
<input type="hidden" id="credentialId" name="credentialId" value={credentialId} />
<input type="hidden" id="userHandle" name="userHandle" value={userHandle} />
<input type="hidden" id="error" name="error" value={error} />
</form>
<div className={clsx(kcProps.kcFormGroupClass)}>
{authenticators &&
(() => (
<form id="authn_select" className={clsx(kcProps.kcFormClass)}>
{authenticators.authenticators.map(authenticator => (
<input
type="hidden"
name="authn_use_chk"
value={authenticator.credentialId}
key={authenticator.credentialId}
/>
))}
</form>
))()}
{authenticators &&
shouldDisplayAuthenticators &&
(() => (
<>
{authenticators.authenticators.length > 1 && (
<p className={clsx(kcProps.kcSelectAuthListItemTitle)}>{msg("webauthn-available-authenticators")}</p>
)}
<div className={clsx(kcProps.kcFormClass)}>
{authenticators.authenticators.map(authenticator => (
<div id="kc-webauthn-authenticator" className={clsx(kcProps.kcSelectAuthListItemClass)}>
<div className={clsx(kcProps.kcSelectAuthListItemIconClass)}>
<i
className={clsx(
kcProps[authenticator.transports.iconClass] ?? kcProps.kcWebAuthnDefaultIcon,
kcProps.kcSelectAuthListItemIconPropertyClass
)}
/>
</div>
<div className={clsx(kcProps.kcSelectAuthListItemBodyClass)}>
<div
id="kc-webauthn-authenticator-label"
className={clsx(kcProps.kcSelectAuthListItemHeadingClass)}
>
{authenticator.label}
</div>
{authenticator.transports && authenticator.transports.displayNameProperties.length && (
<div
id="kc-webauthn-authenticator-transport"
className={clsx(kcProps.kcSelectAuthListItemDescriptionClass)}
>
{authenticator.transports.displayNameProperties.map(
(transport: MessageKeyBase, index: number) => (
<>
<span>{msg(transport)}</span>
{index < authenticator.transports.displayNameProperties.length - 1 && (
<span>{", "}</span>
)}
</>
)
)}
</div>
)}
<div className={clsx(kcProps.kcSelectAuthListItemDescriptionClass)}>
<span id="kc-webauthn-authenticator-created-label">{msg("webauthn-createdAt-label")}</span>
<span id="kc-webauthn-authenticator-created">{authenticator.createdAt}</span>
</div>
</div>
<div className={clsx(kcProps.kcSelectAuthListItemFillClass)} />
</div>
))}
</div>
</>
))()}
<div id="kc-form-buttons" className={clsx(kcProps.kcFormButtonsClass)}>
<input
id="authenticateWebAuthnButton"
type="button"
onClick={webAuthnAuthenticate}
autoFocus={true}
value={msgStr("webauthn-doAuthenticate")}
className={clsx(
kcProps.kcButtonClass,
kcProps.kcButtonPrimaryClass,
kcProps.kcButtonBlockClass,
kcProps.kcButtonLargeClass
)}
/>
</div>
</div>
</div>
}
/>
);
});
export default WebauthnAuthenticate;

View File

@ -0,0 +1,173 @@
import React, { memo, useEffect, Fragment } from "react";
import type { KcProps } from "../KcProps";
import type { Attribute } from "../../getKcContext/KcContextBase";
import { clsx } from "../../tools/clsx";
import type { ReactComponent } from "../../tools/ReactComponent";
import { useCallbackFactory } from "powerhooks/useCallbackFactory";
import { useFormValidationSlice } from "../../useFormValidationSlice";
import type { I18n } from "../../i18n";
import type { Param0 } from "tsafe/Param0";
export type UserProfileFormFieldsProps = {
kcContext: Param0<typeof useFormValidationSlice>["kcContext"];
i18n: I18n;
} & KcProps &
Partial<Record<"BeforeField" | "AfterField", ReactComponent<{ attribute: Attribute }>>> & {
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
};
export const UserProfileFormFields = memo(
({ kcContext, onIsFormSubmittableValueChange, i18n, BeforeField, AfterField, ...props }: UserProfileFormFieldsProps) => {
const { advancedMsg } = i18n;
const {
formValidationState: { fieldStateByAttributeName, isFormSubmittable },
formValidationReducer,
attributesWithPassword
} = useFormValidationSlice({
kcContext,
i18n
});
useEffect(() => {
onIsFormSubmittableValueChange(isFormSubmittable);
}, [isFormSubmittable]);
const onChangeFactory = useCallbackFactory(
(
[name]: [string],
[
{
target: { value }
}
]: [React.ChangeEvent<HTMLInputElement | HTMLSelectElement>]
) =>
formValidationReducer({
"action": "update value",
name,
"newValue": value
})
);
const onBlurFactory = useCallbackFactory(([name]: [string]) =>
formValidationReducer({
"action": "focus lost",
name
})
);
let currentGroup = "";
return (
<>
{attributesWithPassword.map((attribute, i) => {
const { group = "", groupDisplayHeader = "", groupDisplayDescription = "" } = attribute;
const { value, displayableErrors } = fieldStateByAttributeName[attribute.name];
const formGroupClassName = clsx(props.kcFormGroupClass, displayableErrors.length !== 0 && props.kcFormGroupErrorClass);
return (
<Fragment key={i}>
{group !== currentGroup && (currentGroup = group) !== "" && (
<div className={formGroupClassName}>
<div className={clsx(props.kcContentWrapperClass)}>
<label id={`header-${group}`} className={clsx(props.kcFormGroupHeader)}>
{advancedMsg(groupDisplayHeader) || currentGroup}
</label>
</div>
{groupDisplayDescription !== "" && (
<div className={clsx(props.kcLabelWrapperClass)}>
<label id={`description-${group}`} className={`${clsx(props.kcLabelClass)}`}>
{advancedMsg(groupDisplayDescription)}
</label>
</div>
)}
</div>
)}
{BeforeField && <BeforeField attribute={attribute} />}
<div className={formGroupClassName}>
<div className={clsx(props.kcLabelWrapperClass)}>
<label htmlFor={attribute.name} className={clsx(props.kcLabelClass)}>
{advancedMsg(attribute.displayName ?? "")}
</label>
{attribute.required && <>*</>}
</div>
<div className={clsx(props.kcInputWrapperClass)}>
{(() => {
const { options } = attribute.validators;
if (options !== undefined) {
return (
<select
id={attribute.name}
name={attribute.name}
onChange={onChangeFactory(attribute.name)}
onBlur={onBlurFactory(attribute.name)}
value={value}
>
{options.options.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
);
}
return (
<input
type={(() => {
switch (attribute.name) {
case "password-confirm":
case "password":
return "password";
default:
return "text";
}
})()}
id={attribute.name}
name={attribute.name}
value={value}
onChange={onChangeFactory(attribute.name)}
className={clsx(props.kcInputClass)}
aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly}
autoComplete={attribute.autocomplete}
onBlur={onBlurFactory(attribute.name)}
/>
);
})()}
{displayableErrors.length !== 0 &&
(() => {
const divId = `input-error-${attribute.name}`;
return (
<>
<style>{`#${divId} > span: { display: block; }`}</style>
<span
id={divId}
className={clsx(props.kcInputErrorMessageClass)}
style={{
"position": displayableErrors.length === 1 ? "absolute" : undefined
}}
aria-live="polite"
>
{displayableErrors.map(({ errorMessage }) => errorMessage)}
</span>
</>
);
})()}
</div>
</div>
{AfterField && <AfterField attribute={attribute} />}
</Fragment>
);
})}
</>
);
}
);

View File

@ -1,7 +1,8 @@
import type { PageId } from "../../bin/build-keycloak-theme/generateFtl"; import type { PageId } from "../../bin/keycloakify/generateFtl";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import type { Equals } from "tsafe"; import type { Equals } from "tsafe";
import type { MessageKeyBase } from "../i18n"; import type { MessageKeyBase } from "../i18n";
import type { KcTemplateClassKey } from "../components/KcProps";
type ExtractAfterStartingWith<Prefix extends string, StrEnum> = StrEnum extends `${Prefix}${infer U}` ? U : never; type ExtractAfterStartingWith<Prefix extends string, StrEnum> = StrEnum extends `${Prefix}${infer U}` ? U : never;
@ -19,13 +20,28 @@ export type KcContextBase =
| KcContextBase.LoginVerifyEmail | KcContextBase.LoginVerifyEmail
| KcContextBase.Terms | KcContextBase.Terms
| KcContextBase.LoginOtp | KcContextBase.LoginOtp
| KcContextBase.LoginUsername
| KcContextBase.WebauthnAuthenticate
| KcContextBase.LoginPassword
| KcContextBase.LoginUpdatePassword | KcContextBase.LoginUpdatePassword
| KcContextBase.LoginUpdateProfile | KcContextBase.LoginUpdateProfile
| KcContextBase.LoginIdpLinkConfirm | KcContextBase.LoginIdpLinkConfirm
| KcContextBase.LoginIdpLinkEmail | KcContextBase.LoginIdpLinkEmail
| KcContextBase.LoginPageExpired | KcContextBase.LoginPageExpired
| KcContextBase.LoginConfigTotp | KcContextBase.LoginConfigTotp
| KcContextBase.LogoutConfirm; | KcContextBase.LogoutConfirm
| KcContextBase.UpdateUserProfile
| KcContextBase.IdpReviewUserProfile;
export type WebauthnAuthenticator = {
credentialId: string;
transports: {
iconClass: KcTemplateClassKey;
displayNameProperties: MessageKeyBase[];
};
label: string;
createdAt: string;
};
export declare namespace KcContextBase { export declare namespace KcContextBase {
export type Common = { export type Common = {
@ -196,6 +212,77 @@ export declare namespace KcContextBase {
}; };
}; };
export type LoginUsername = Common & {
pageId: "login-username.ftl";
url: {
loginResetCredentialsUrl: string;
registrationUrl: string;
};
realm: {
loginWithEmailAllowed: boolean;
rememberMe: boolean;
password: boolean;
resetPasswordAllowed: boolean;
registrationAllowed: boolean;
};
registrationDisabled: boolean;
login: {
username?: string;
rememberMe?: boolean;
};
usernameHidden?: boolean;
social: {
displayInfo: boolean;
providers?: {
loginUrl: string;
alias: string;
providerId: string;
displayName: string;
}[];
};
};
export type LoginPassword = Common & {
pageId: "login-password.ftl";
url: {
loginResetCredentialsUrl: string;
registrationUrl: string;
};
realm: {
resetPasswordAllowed: boolean;
};
auth?: {
showUsername?: boolean;
showResetCredentials?: boolean;
showTryAnotherWayLink?: boolean;
attemptedUsername?: string;
};
social: {
displayInfo: boolean;
};
login: {
password?: string;
};
};
export type WebauthnAuthenticate = Common & {
pageId: "webauthn-authenticate.ftl";
authenticators: {
authenticators: WebauthnAuthenticator[];
};
challenge: string;
// I hate this:
userVerification: UserVerificationRequirement | "not specified";
rpId: string;
createTimeout: string;
isUserIdentified: "true" | "false";
shouldDisplayAuthenticators: boolean;
social: {
displayInfo: boolean;
};
login: {};
};
export type LoginUpdatePassword = Common & { export type LoginUpdatePassword = Common & {
pageId: "login-update-password.ftl"; pageId: "login-update-password.ftl";
username: string; username: string;
@ -270,6 +357,23 @@ export declare namespace KcContextBase {
skipLink?: boolean; skipLink?: boolean;
}; };
}; };
export type UpdateUserProfile = Common & {
pageId: "update-user-profile.ftl";
profile: {
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
};
};
export type IdpReviewUserProfile = Common & {
pageId: "idp-review-user-profile.ftl";
profile: {
context: "IDP_REVIEW";
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
};
};
} }
export type Attribute = { export type Attribute = {

View File

@ -10,6 +10,7 @@ import { getKcContextFromWindow } from "./getKcContextFromWindow";
import { pathJoin } from "../../bin/tools/pathJoin"; import { pathJoin } from "../../bin/tools/pathJoin";
import { pathBasename } from "../tools/pathBasename"; import { pathBasename } from "../tools/pathBasename";
import { mockTestingResourcesCommonPath } from "../../bin/mockTestingResourcesPath"; import { mockTestingResourcesCommonPath } from "../../bin/mockTestingResourcesPath";
import { symToStr } from "tsafe/symToStr";
export function getKcContext<KcContextExtended extends { pageId: string } = never>(params?: { export function getKcContext<KcContextExtended extends { pageId: string } = never>(params?: {
mockPageId?: ExtendsKcContextBase<KcContextExtended>["pageId"]; mockPageId?: ExtendsKcContextBase<KcContextExtended>["pageId"];
@ -17,9 +18,19 @@ export function getKcContext<KcContextExtended extends { pageId: string } = neve
}): { kcContext: ExtendsKcContextBase<KcContextExtended> | undefined } { }): { kcContext: ExtendsKcContextBase<KcContextExtended> | undefined } {
const { mockPageId, mockData } = params ?? {}; const { mockPageId, mockData } = params ?? {};
if (mockPageId !== undefined) { const realKcContext = getKcContextFromWindow<KcContextExtended>();
if (mockPageId !== undefined && realKcContext === undefined) {
//TODO maybe trow if no mock fo custom page //TODO maybe trow if no mock fo custom page
console.log(
[
`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`,
`If assets are missing make sure you have built your Keycloak theme at least once.`
].join(" "),
"background: red; color: yellow; font-size: medium"
);
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId); const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
const partialKcContextCustomMock = mockData?.find(({ pageId }) => pageId === mockPageId); const partialKcContextCustomMock = mockData?.find(({ pageId }) => pageId === mockPageId);
@ -29,8 +40,8 @@ export function getKcContext<KcContextExtended extends { pageId: string } = neve
[ [
`WARNING: You declared the non build in page ${mockPageId} but you didn't `, `WARNING: You declared the non build in page ${mockPageId} but you didn't `,
`provide mock data needed to debug the page outside of Keycloak as you are trying to do now.`, `provide mock data needed to debug the page outside of Keycloak as you are trying to do now.`,
`Please check the documentation of the getKcContext function`, `Please check the documentation of the getKcContext function`
].join("\n"), ].join("\n")
); );
} }
@ -38,17 +49,25 @@ export function getKcContext<KcContextExtended extends { pageId: string } = neve
deepAssign({ deepAssign({
"target": kcContext, "target": kcContext,
"source": kcContextDefaultMock !== undefined ? kcContextDefaultMock : { "pageId": mockPageId, ...kcContextCommonMock }, "source": kcContextDefaultMock !== undefined ? kcContextDefaultMock : { "pageId": mockPageId, ...kcContextCommonMock }
}); });
if (partialKcContextCustomMock !== undefined) { if (partialKcContextCustomMock !== undefined) {
deepAssign({ deepAssign({
"target": kcContext, "target": kcContext,
"source": partialKcContextCustomMock, "source": partialKcContextCustomMock
}); });
if (partialKcContextCustomMock.pageId === "register-user-profile.ftl") { if (
assert(kcContextDefaultMock?.pageId === "register-user-profile.ftl"); partialKcContextCustomMock.pageId === "register-user-profile.ftl" ||
partialKcContextCustomMock.pageId === "update-user-profile.ftl" ||
partialKcContextCustomMock.pageId === "idp-review-user-profile.ftl"
) {
assert(
kcContextDefaultMock?.pageId === "register-user-profile.ftl" ||
kcContextDefaultMock?.pageId === "update-user-profile.ftl" ||
kcContextDefaultMock?.pageId === "idp-review-user-profile.ftl"
);
const { attributes } = kcContextDefaultMock.profile; const { attributes } = kcContextDefaultMock.profile;
@ -56,7 +75,7 @@ export function getKcContext<KcContextExtended extends { pageId: string } = neve
id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributesByName = {}; id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributesByName = {};
const partialAttributes = [ const partialAttributes = [
...((partialKcContextCustomMock as DeepPartial<KcContextBase.RegisterUserProfile>).profile?.attributes ?? []), ...((partialKcContextCustomMock as DeepPartial<KcContextBase.RegisterUserProfile>).profile?.attributes ?? [])
].filter(exclude(undefined)); ].filter(exclude(undefined));
attributes.forEach(attribute => { attributes.forEach(attribute => {
@ -66,7 +85,7 @@ export function getKcContext<KcContextExtended extends { pageId: string } = neve
deepAssign({ deepAssign({
"target": augmentedAttribute, "target": augmentedAttribute,
"source": attribute, "source": attribute
}); });
if (partialAttribute !== undefined) { if (partialAttribute !== undefined) {
@ -74,7 +93,7 @@ export function getKcContext<KcContextExtended extends { pageId: string } = neve
deepAssign({ deepAssign({
"target": augmentedAttribute, "target": augmentedAttribute,
"source": partialAttribute, "source": partialAttribute
}); });
} }
@ -82,27 +101,31 @@ export function getKcContext<KcContextExtended extends { pageId: string } = neve
id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributesByName[augmentedAttribute.name] = augmentedAttribute; id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributesByName[augmentedAttribute.name] = augmentedAttribute;
}); });
partialAttributes.forEach(partialAttribute => { partialAttributes
const { name } = partialAttribute; .map(partialAttribute => ({ "validators": {}, ...partialAttribute }))
.forEach(partialAttribute => {
const { name } = partialAttribute;
assert(name !== undefined, "If you define a mock attribute it must have at least a name"); assert(name !== undefined, "If you define a mock attribute it must have at least a name");
id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributes.push(partialAttribute as any); id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributes.push(partialAttribute as any);
id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributesByName[name] = partialAttribute as any; id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributesByName[name] = partialAttribute as any;
}); });
} }
} }
return { kcContext }; return { kcContext };
} }
const kcContext = getKcContextFromWindow<KcContextExtended>(); if (realKcContext === undefined) {
return { "kcContext": undefined };
}
if (kcContext !== undefined) { {
const { url } = kcContext; const { url } = realKcContext;
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(mockTestingResourcesCommonPath)); url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(mockTestingResourcesCommonPath));
} }
return { kcContext }; return { "kcContext": realKcContext };
} }

View File

@ -1,6 +1,6 @@
import type { KcContextBase } from "./KcContextBase"; import type { KcContextBase } from "./KcContextBase";
import type { AndByDiscriminatingKey } from "../tools/AndByDiscriminatingKey"; import type { AndByDiscriminatingKey } from "../tools/AndByDiscriminatingKey";
import { ftlValuesGlobalName } from "../../bin/build-keycloak-theme/ftlValuesGlobalName"; import { ftlValuesGlobalName } from "../../bin/keycloakify/ftlValuesGlobalName";
export type ExtendsKcContextBase<KcContextExtended extends { pageId: string }> = [KcContextExtended] extends [never] export type ExtendsKcContextBase<KcContextExtended extends { pageId: string }> = [KcContextExtended] extends [never]
? KcContextBase ? KcContextBase

View File

@ -7,26 +7,120 @@ import { pathJoin } from "../../../bin/tools/pathJoin";
const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/"; const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
const attributes: Attribute[] = [
{
"validators": {
"username-prohibited-characters": {
"ignore.empty.value": true
},
"up-username-has-value": {},
"length": {
"ignore.empty.value": true,
"min": "3",
"max": "255"
},
"up-duplicate-username": {},
"up-username-mutation": {}
},
"displayName": "${username}",
"annotations": {},
"required": true,
"groupAnnotations": {},
"autocomplete": "username",
"readOnly": false,
"name": "username",
"value": "xxxx"
},
{
"validators": {
"up-email-exists-as-username": {},
"length": {
"max": "255",
"ignore.empty.value": true
},
"up-blank-attribute-value": {
"error-message": "missingEmailMessage",
"fail-on-null": false
},
"up-duplicate-email": {},
"email": {
"ignore.empty.value": true
},
"pattern": {
"ignore.empty.value": true,
"pattern": "gmail\\.com$"
}
},
"displayName": "${email}",
"annotations": {},
"required": true,
"groupAnnotations": {},
"autocomplete": "email",
"readOnly": false,
"name": "email"
},
{
"validators": {
"length": {
"max": "255",
"ignore.empty.value": true
},
"person-name-prohibited-characters": {
"ignore.empty.value": true
},
"up-immutable-attribute": {},
"up-attribute-required-by-metadata-value": {}
},
"displayName": "${firstName}",
"annotations": {},
"required": true,
"groupAnnotations": {},
"readOnly": false,
"name": "firstName"
},
{
"validators": {
"length": {
"max": "255",
"ignore.empty.value": true
},
"person-name-prohibited-characters": {
"ignore.empty.value": true
},
"up-immutable-attribute": {},
"up-attribute-required-by-metadata-value": {}
},
"displayName": "${lastName}",
"annotations": {},
"required": true,
"groupAnnotations": {},
"readOnly": false,
"name": "lastName"
}
];
const attributesByName = Object.fromEntries(attributes.map(attribute => [attribute.name, attribute])) as any;
export const kcContextCommonMock: KcContextBase.Common = { export const kcContextCommonMock: KcContextBase.Common = {
"url": { "url": {
"loginAction": "#", "loginAction": "#",
"resourcesPath": pathJoin(PUBLIC_URL, mockTestingResourcesPath), "resourcesPath": pathJoin(PUBLIC_URL, mockTestingResourcesPath),
"resourcesCommonPath": pathJoin(PUBLIC_URL, mockTestingResourcesCommonPath), "resourcesCommonPath": pathJoin(PUBLIC_URL, mockTestingResourcesCommonPath),
"loginRestartFlowUrl": "/auth/realms/myrealm/login-actions/restart?client_id=account&tab_id=HoAx28ja4xg", "loginRestartFlowUrl": "/auth/realms/myrealm/login-actions/restart?client_id=account&tab_id=HoAx28ja4xg",
"loginUrl": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg", "loginUrl": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg"
}, },
"realm": { "realm": {
"name": "myrealm", "name": "myrealm",
"displayName": "myrealm", "displayName": "myrealm",
"displayNameHtml": "myrealm", "displayNameHtml": "myrealm",
"internationalizationEnabled": true, "internationalizationEnabled": true,
"registrationEmailAsUsername": false, "registrationEmailAsUsername": false
}, },
"messagesPerField": { "messagesPerField": {
"printIfExists": (...[, x]) => x, "printIfExists": (...[, x]) => x,
"existsError": () => true, "existsError": () => true,
"get": key => `Fake error for ${key}`, "get": key => `Fake error for ${key}`,
"exists": () => true, "exists": () => true
}, },
"locale": { "locale": {
"supported": [ "supported": [
@ -34,117 +128,117 @@ export const kcContextCommonMock: KcContextBase.Common = {
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=de", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=de",
"label": "Deutsch", "label": "Deutsch",
"languageTag": "de", "languageTag": "de"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=no", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=no",
"label": "Norsk", "label": "Norsk",
"languageTag": "no", "languageTag": "no"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ru", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ru",
"label": "Русский", "label": "Русский",
"languageTag": "ru", "languageTag": "ru"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sv", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sv",
"label": "Svenska", "label": "Svenska",
"languageTag": "sv", "languageTag": "sv"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pt-BR", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pt-BR",
"label": "Português (Brasil)", "label": "Português (Brasil)",
"languageTag": "pt-BR", "languageTag": "pt-BR"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=lt", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=lt",
"label": "Lietuvių", "label": "Lietuvių",
"languageTag": "lt", "languageTag": "lt"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=en", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=en",
"label": "English", "label": "English",
"languageTag": "en", "languageTag": "en"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=it", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=it",
"label": "Italiano", "label": "Italiano",
"languageTag": "it", "languageTag": "it"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=fr", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=fr",
"label": "Français", "label": "Français",
"languageTag": "fr", "languageTag": "fr"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=zh-CN", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=zh-CN",
"label": "中文简体", "label": "中文简体",
"languageTag": "zh-CN", "languageTag": "zh-CN"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=es", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=es",
"label": "Español", "label": "Español",
"languageTag": "es", "languageTag": "es"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=cs", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=cs",
"label": "Čeština", "label": "Čeština",
"languageTag": "cs", "languageTag": "cs"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ja", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ja",
"label": "日本語", "label": "日本語",
"languageTag": "ja", "languageTag": "ja"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sk", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sk",
"label": "Slovenčina", "label": "Slovenčina",
"languageTag": "sk", "languageTag": "sk"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pl", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pl",
"label": "Polski", "label": "Polski",
"languageTag": "pl", "languageTag": "pl"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ca", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ca",
"label": "Català", "label": "Català",
"languageTag": "ca", "languageTag": "ca"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=nl", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=nl",
"label": "Nederlands", "label": "Nederlands",
"languageTag": "nl", "languageTag": "nl"
}, },
{ {
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=tr", "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=tr",
"label": "Türkçe", "label": "Türkçe",
"languageTag": "tr", "languageTag": "tr"
}, }
/* spell-checker: enable */ /* spell-checker: enable */
], ],
"currentLanguageTag": "en", "currentLanguageTag": "en"
}, },
"auth": { "auth": {
"showUsername": false, "showUsername": false,
"showResetCredentials": false, "showResetCredentials": false,
"showTryAnotherWayLink": false, "showTryAnotherWayLink": false
}, },
"client": { "client": {
"clientId": "myApp", "clientId": "myApp"
}, },
"scripts": [], "scripts": [],
"message": { "message": {
"type": "success", "type": "success",
"summary": "This is a test message", "summary": "This is a test message"
}, },
"isAppInitiatedAction": false, "isAppInitiatedAction": false
}; };
const loginUrl = { const loginUrl = {
...kcContextCommonMock.url, ...kcContextCommonMock.url,
"loginResetCredentialsUrl": "/auth/realms/myrealm/login-actions/reset-credentials?client_id=account&tab_id=HoAx28ja4xg", "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"
}; };
export const kcContextMocks: KcContextBase[] = [ export const kcContextMocks: KcContextBase[] = [
@ -158,17 +252,17 @@ export const kcContextMocks: KcContextBase[] = [
"rememberMe": true, "rememberMe": true,
"password": true, "password": true,
"resetPasswordAllowed": true, "resetPasswordAllowed": true,
"registrationAllowed": true, "registrationAllowed": true
}, },
"auth": kcContextCommonMock.auth!, "auth": kcContextCommonMock.auth!,
"social": { "social": {
"displayInfo": true, "displayInfo": true
}, },
"usernameEditDisabled": false, "usernameEditDisabled": false,
"login": { "login": {
"rememberMe": false, "rememberMe": false
}, },
"registrationDisabled": false, "registrationDisabled": false
}), }),
...(() => { ...(() => {
const registerCommon: KcContextBase.RegisterCommon = { const registerCommon: KcContextBase.RegisterCommon = {
@ -176,15 +270,15 @@ export const kcContextMocks: KcContextBase[] = [
"url": { "url": {
...loginUrl, ...loginUrl,
"registrationAction": "registrationAction":
"http://localhost:8080/auth/realms/myrealm/login-actions/registration?session_code=gwZdUeO7pbYpFTRxiIxRg_QtzMbtFTKrNu6XW_f8asM&execution=12146ce0-b139-4bbd-b25b-0eccfee6577e&client_id=account&tab_id=uS8lYfebLa0", "http://localhost:8080/auth/realms/myrealm/login-actions/registration?session_code=gwZdUeO7pbYpFTRxiIxRg_QtzMbtFTKrNu6XW_f8asM&execution=12146ce0-b139-4bbd-b25b-0eccfee6577e&client_id=account&tab_id=uS8lYfebLa0"
}, },
"scripts": [], "scripts": [],
"isAppInitiatedAction": false, "isAppInitiatedAction": false,
"passwordRequired": true, "passwordRequired": true,
"recaptchaRequired": false, "recaptchaRequired": false,
"social": { "social": {
"displayInfo": true, "displayInfo": true
}, }
}; };
return [ return [
@ -192,114 +286,18 @@ export const kcContextMocks: KcContextBase[] = [
"pageId": "register.ftl", "pageId": "register.ftl",
...registerCommon, ...registerCommon,
"register": { "register": {
"formData": {}, "formData": {}
}, }
}), }),
id<KcContextBase.RegisterUserProfile>({ id<KcContextBase.RegisterUserProfile>({
"pageId": "register-user-profile.ftl", "pageId": "register-user-profile.ftl",
...registerCommon, ...registerCommon,
"profile": { "profile": {
"context": "REGISTRATION_PROFILE" as const, "context": "REGISTRATION_PROFILE" as const,
...(() => { attributes,
const attributes: Attribute[] = [ attributesByName
{ }
"validators": { })
"username-prohibited-characters": {
"ignore.empty.value": true,
},
"up-username-has-value": {},
"length": {
"ignore.empty.value": true,
"min": "3",
"max": "255",
},
"up-duplicate-username": {},
"up-username-mutation": {},
},
"displayName": "${username}",
"annotations": {},
"required": true,
"groupAnnotations": {},
"autocomplete": "username",
"readOnly": false,
"name": "username",
"value": "xxxx",
},
{
"validators": {
"up-email-exists-as-username": {},
"length": {
"max": "255",
"ignore.empty.value": true,
},
"up-blank-attribute-value": {
"error-message": "missingEmailMessage",
"fail-on-null": false,
},
"up-duplicate-email": {},
"email": {
"ignore.empty.value": true,
},
"pattern": {
"ignore.empty.value": true,
"pattern": "gmail\\.com$",
},
},
"displayName": "${email}",
"annotations": {},
"required": true,
"groupAnnotations": {},
"autocomplete": "email",
"readOnly": false,
"name": "email",
},
{
"validators": {
"length": {
"max": "255",
"ignore.empty.value": true,
},
"person-name-prohibited-characters": {
"ignore.empty.value": true,
},
"up-immutable-attribute": {},
"up-attribute-required-by-metadata-value": {},
},
"displayName": "${firstName}",
"annotations": {},
"required": true,
"groupAnnotations": {},
"readOnly": false,
"name": "firstName",
},
{
"validators": {
"length": {
"max": "255",
"ignore.empty.value": true,
},
"person-name-prohibited-characters": {
"ignore.empty.value": true,
},
"up-immutable-attribute": {},
"up-attribute-required-by-metadata-value": {},
},
"displayName": "${lastName}",
"annotations": {},
"required": true,
"groupAnnotations": {},
"readOnly": false,
"name": "lastName",
},
];
return {
attributes,
"attributesByName": Object.fromEntries(attributes.map(attribute => [attribute.name, attribute])) as any,
} as any;
})(),
},
}),
]; ];
})(), })(),
id<KcContextBase.Info>({ id<KcContextBase.Info>({
@ -311,39 +309,39 @@ export const kcContextMocks: KcContextBase[] = [
"actionUri": "#", "actionUri": "#",
"client": { "client": {
"clientId": "myApp", "clientId": "myApp",
"baseUrl": "#", "baseUrl": "#"
}, }
}), }),
id<KcContextBase.Error>({ id<KcContextBase.Error>({
...kcContextCommonMock, ...kcContextCommonMock,
"pageId": "error.ftl", "pageId": "error.ftl",
"client": { "client": {
"clientId": "myApp", "clientId": "myApp",
"baseUrl": "#", "baseUrl": "#"
}, },
"message": { "message": {
"type": "error", "type": "error",
"summary": "This is the error message", "summary": "This is the error message"
}, }
}), }),
id<KcContextBase.LoginResetPassword>({ id<KcContextBase.LoginResetPassword>({
...kcContextCommonMock, ...kcContextCommonMock,
"pageId": "login-reset-password.ftl", "pageId": "login-reset-password.ftl",
"realm": { "realm": {
...kcContextCommonMock.realm, ...kcContextCommonMock.realm,
"loginWithEmailAllowed": false, "loginWithEmailAllowed": false
}, }
}), }),
id<KcContextBase.LoginVerifyEmail>({ id<KcContextBase.LoginVerifyEmail>({
...kcContextCommonMock, ...kcContextCommonMock,
"pageId": "login-verify-email.ftl", "pageId": "login-verify-email.ftl",
"user": { "user": {
"email": "john.doe@gmail.com", "email": "john.doe@gmail.com"
}, }
}), }),
id<KcContextBase.Terms>({ id<KcContextBase.Terms>({
...kcContextCommonMock, ...kcContextCommonMock,
"pageId": "terms.ftl", "pageId": "terms.ftl"
}), }),
id<KcContextBase.LoginOtp>({ id<KcContextBase.LoginOtp>({
...kcContextCommonMock, ...kcContextCommonMock,
@ -352,19 +350,74 @@ export const kcContextMocks: KcContextBase[] = [
"userOtpCredentials": [ "userOtpCredentials": [
{ {
"id": "id1", "id": "id1",
"userLabel": "label1", "userLabel": "label1"
}, },
{ {
"id": "id2", "id": "id2",
"userLabel": "label2", "userLabel": "label2"
}, }
], ]
}
}),
id<KcContextBase.LoginUsername>({
...kcContextCommonMock,
"pageId": "login-username.ftl",
"url": loginUrl,
"realm": {
...kcContextCommonMock.realm,
"loginWithEmailAllowed": true,
"rememberMe": true,
"password": true,
"resetPasswordAllowed": true,
"registrationAllowed": true
}, },
"social": {
"displayInfo": true
},
"usernameHidden": false,
"login": {
"rememberMe": false
},
"registrationDisabled": false
}),
id<KcContextBase.LoginPassword>({
...kcContextCommonMock,
"pageId": "login-password.ftl",
"url": loginUrl,
"realm": {
...kcContextCommonMock.realm,
"resetPasswordAllowed": true
},
"social": {
"displayInfo": false
},
"login": {}
}),
id<KcContextBase.WebauthnAuthenticate>({
...kcContextCommonMock,
"pageId": "webauthn-authenticate.ftl",
"url": loginUrl,
"authenticators": {
"authenticators": []
},
"realm": {
...kcContextCommonMock.realm
},
"challenge": "",
"userVerification": "not specified",
"rpId": "",
"createTimeout": "0",
"isUserIdentified": "false",
"shouldDisplayAuthenticators": false,
"social": {
"displayInfo": false
},
"login": {}
}), }),
id<KcContextBase.LoginUpdatePassword>({ id<KcContextBase.LoginUpdatePassword>({
...kcContextCommonMock, ...kcContextCommonMock,
"pageId": "login-update-password.ftl", "pageId": "login-update-password.ftl",
"username": "anUsername", "username": "anUsername"
}), }),
id<KcContextBase.LoginUpdateProfile>({ id<KcContextBase.LoginUpdateProfile>({
...kcContextCommonMock, ...kcContextCommonMock,
@ -374,21 +427,21 @@ export const kcContextMocks: KcContextBase[] = [
"username": "anUsername", "username": "anUsername",
"email": "foo@example.com", "email": "foo@example.com",
"firstName": "aFirstName", "firstName": "aFirstName",
"lastName": "aLastName", "lastName": "aLastName"
}, }
}), }),
id<KcContextBase.LoginIdpLinkConfirm>({ id<KcContextBase.LoginIdpLinkConfirm>({
...kcContextCommonMock, ...kcContextCommonMock,
"pageId": "login-idp-link-confirm.ftl", "pageId": "login-idp-link-confirm.ftl",
"idpAlias": "FranceConnect", "idpAlias": "FranceConnect"
}), }),
id<KcContextBase.LoginIdpLinkEmail>({ id<KcContextBase.LoginIdpLinkEmail>({
...kcContextCommonMock, ...kcContextCommonMock,
"pageId": "login-idp-link-email.ftl", "pageId": "login-idp-link-email.ftl",
"idpAlias": "FranceConnect", "idpAlias": "FranceConnect",
"brokerContext": { "brokerContext": {
"username": "anUsername", "username": "anUsername"
}, }
}), }),
id<KcContextBase.LoginConfigTotp>({ id<KcContextBase.LoginConfigTotp>({
...kcContextCommonMock, ...kcContextCommonMock,
@ -407,21 +460,38 @@ export const kcContextMocks: KcContextBase[] = [
digits: 6, digits: 6,
lookAheadWindow: 1, lookAheadWindow: 1,
type: "totp", type: "totp",
period: 30, period: 30
}, }
}, }
}), }),
id<KcContextBase.LogoutConfirm>({ id<KcContextBase.LogoutConfirm>({
...kcContextCommonMock, ...kcContextCommonMock,
"pageId": "logout-confirm.ftl", "pageId": "logout-confirm.ftl",
"url": { "url": {
...kcContextCommonMock.url, ...kcContextCommonMock.url,
"logoutConfirmAction": "Continuer?", "logoutConfirmAction": "Continuer?"
}, },
"client": { "client": {
"clientId": "myApp", "clientId": "myApp",
"baseUrl": "#", "baseUrl": "#"
}, },
"logoutConfirm": { "code": "123", skipLink: false }, "logoutConfirm": { "code": "123", skipLink: false }
}), }),
id<KcContextBase.UpdateUserProfile>({
...kcContextCommonMock,
"pageId": "update-user-profile.ftl",
"profile": {
attributes,
attributesByName
}
}),
id<KcContextBase.IdpReviewUserProfile>({
...kcContextCommonMock,
"pageId": "idp-review-user-profile.ftl",
"profile": {
context: "IDP_REVIEW",
attributes,
attributesByName
}
})
]; ];

View File

@ -130,7 +130,7 @@ const messages = {
"invalidPasswordMinSpecialCharsMessage": "Contrasenya incorrecta: ha de contenir almenys {0} caràcters especials.", "invalidPasswordMinSpecialCharsMessage": "Contrasenya incorrecta: ha de contenir almenys {0} caràcters especials.",
"invalidPasswordNotUsernameMessage": "Contrasenya incorrecta: no pot ser igual al nom d'usuari.", "invalidPasswordNotUsernameMessage": "Contrasenya incorrecta: no pot ser igual al nom d'usuari.",
"invalidPasswordRegexPatternMessage": "Contrasenya incorrecta: no compleix l'expressió regular.", "invalidPasswordRegexPatternMessage": "Contrasenya incorrecta: no compleix l'expressió regular.",
"invalidPasswordHistoryMessage": "Contrasenya incorrecta: no pot ser igual a cap de les últimes {0} contrasenyes.", "invalidPasswordHistoryMessage": "Contrasenya incorrecta: no pot ser igual a cap de les últimes {0} contrasenyes."
}; };
export default messages; export default messages;

View File

@ -149,7 +149,7 @@ const messages = {
"invalidPasswordRegexPatternMessage": "Neplatné heslo: neshoduje se zadaným regulárním výrazem.", "invalidPasswordRegexPatternMessage": "Neplatné heslo: neshoduje se zadaným regulárním výrazem.",
"invalidPasswordHistoryMessage": "Neplatné heslo: Nesmí se opakovat žádné z posledních {0} hesel.", "invalidPasswordHistoryMessage": "Neplatné heslo: Nesmí se opakovat žádné z posledních {0} hesel.",
"invalidPasswordBlacklistedMessage": "Neplatné heslo: heslo je na černé listině.", "invalidPasswordBlacklistedMessage": "Neplatné heslo: heslo je na černé listině.",
"invalidPasswordGenericMessage": "Neplatné heslo: nové heslo neodpovídá pravidlům hesla.", "invalidPasswordGenericMessage": "Neplatné heslo: nové heslo neodpovídá pravidlům hesla."
}; };
export default messages; export default messages;

View File

@ -149,7 +149,7 @@ const messages = {
"invalidPasswordRegexPatternMessage": "Ungültiges Passwort: Es entspricht nicht dem Regex-Muster.", "invalidPasswordRegexPatternMessage": "Ungültiges Passwort: Es entspricht nicht dem Regex-Muster.",
"invalidPasswordHistoryMessage": "Ungültiges Passwort: Es darf nicht einem der letzten {0} Passwörter entsprechen.", "invalidPasswordHistoryMessage": "Ungültiges Passwort: Es darf nicht einem der letzten {0} Passwörter entsprechen.",
"invalidPasswordBlacklistedMessage": "Ungültiges Passwort: Das Passwort steht auf der Blocklist (schwarzen Liste).", "invalidPasswordBlacklistedMessage": "Ungültiges Passwort: Das Passwort steht auf der Blocklist (schwarzen Liste).",
"invalidPasswordGenericMessge": "Ungültiges Passwort: Das neue Passwort verletzt die Passwort-Richtlinien.", "invalidPasswordGenericMessge": "Ungültiges Passwort: Das neue Passwort verletzt die Passwort-Richtlinien."
}; };
export default messages; export default messages;

View File

@ -318,7 +318,7 @@ const messages = {
"openshift.scope.user_info": "User information", "openshift.scope.user_info": "User information",
"openshift.scope.user_check-access": "User access information", "openshift.scope.user_check-access": "User access information",
"openshift.scope.user_full": "Full Access", "openshift.scope.user_full": "Full Access",
"openshift.scope.list-projects": "List projects", "openshift.scope.list-projects": "List projects"
}; };
export default messages; export default messages;

View File

@ -130,7 +130,7 @@ const messages = {
"invalidPasswordMinSpecialCharsMessage": "Contraseña incorrecta: debe contener al menos {0} caracteres especiales.", "invalidPasswordMinSpecialCharsMessage": "Contraseña incorrecta: debe contener al menos {0} caracteres especiales.",
"invalidPasswordNotUsernameMessage": "Contraseña incorrecta: no puede ser igual al nombre de usuario.", "invalidPasswordNotUsernameMessage": "Contraseña incorrecta: no puede ser igual al nombre de usuario.",
"invalidPasswordRegexPatternMessage": "Contraseña incorrecta: no cumple la expresión regular.", "invalidPasswordRegexPatternMessage": "Contraseña incorrecta: no cumple la expresión regular.",
"invalidPasswordHistoryMessage": "Contraseña incorrecta: no puede ser igual a ninguna de las últimas {0} contraseñas.", "invalidPasswordHistoryMessage": "Contraseña incorrecta: no puede ser igual a ninguna de las últimas {0} contraseñas."
}; };
export default messages; export default messages;

View File

@ -143,7 +143,7 @@ const messages = {
"invalidPasswordMinSpecialCharsMessage": "Mot de passe invalide: doit contenir au moins {0} caractère(s) spéciaux.", "invalidPasswordMinSpecialCharsMessage": "Mot de passe invalide: doit contenir au moins {0} caractère(s) spéciaux.",
"invalidPasswordNotUsernameMessage": "Mot de passe invalide: ne doit pas être identique au nom d'utilisateur.", "invalidPasswordNotUsernameMessage": "Mot de passe invalide: ne doit pas être identique au nom d'utilisateur.",
"invalidPasswordRegexPatternMessage": "Mot de passe invalide: ne valide pas l'expression rationnelle.", "invalidPasswordRegexPatternMessage": "Mot de passe invalide: ne valide pas l'expression rationnelle.",
"invalidPasswordHistoryMessage": "Mot de passe invalide: ne doit pas être égal aux {0} derniers mots de passe.", "invalidPasswordHistoryMessage": "Mot de passe invalide: ne doit pas être égal aux {0} derniers mots de passe."
}; };
export default messages; export default messages;

View File

@ -303,7 +303,7 @@ const messages = {
"openshift.scope.user_info": "Informazioni utente", "openshift.scope.user_info": "Informazioni utente",
"openshift.scope.user_check-access": "Informazioni per l'accesso dell'utente", "openshift.scope.user_check-access": "Informazioni per l'accesso dell'utente",
"openshift.scope.user_full": "Accesso completo", "openshift.scope.user_full": "Accesso completo",
"openshift.scope.list-projects": "Elenca progetti", "openshift.scope.list-projects": "Elenca progetti"
}; };
export default messages; export default messages;

View File

@ -317,7 +317,7 @@ const messages = {
"openshift.scope.user_info": "ユーザー情報", "openshift.scope.user_info": "ユーザー情報",
"openshift.scope.user_check-access": "ユーザーアクセス情報", "openshift.scope.user_check-access": "ユーザーアクセス情報",
"openshift.scope.user_full": "フルアクセス", "openshift.scope.user_full": "フルアクセス",
"openshift.scope.list-projects": "プロジェクトの一覧表示", "openshift.scope.list-projects": "プロジェクトの一覧表示"
}; };
export default messages; export default messages;

View File

@ -136,7 +136,7 @@ const messages = {
"invalidPasswordMinSpecialCharsMessage": "Neteisingas slaptažodis: privaloma įvesti {0} specialų simbolį.", "invalidPasswordMinSpecialCharsMessage": "Neteisingas slaptažodis: privaloma įvesti {0} specialų simbolį.",
"invalidPasswordNotUsernameMessage": "Neteisingas slaptažodis: slaptažodis negali sutapti su naudotojo vardu.", "invalidPasswordNotUsernameMessage": "Neteisingas slaptažodis: slaptažodis negali sutapti su naudotojo vardu.",
"invalidPasswordRegexPatternMessage": "Neteisingas slaptažodis: slaptažodis netenkina regex taisyklės(ių).", "invalidPasswordRegexPatternMessage": "Neteisingas slaptažodis: slaptažodis netenkina regex taisyklės(ių).",
"invalidPasswordHistoryMessage": "Neteisingas slaptažodis: slaptažodis negali sutapti su prieš tai buvusiais {0} slaptažodžiais.", "invalidPasswordHistoryMessage": "Neteisingas slaptažodis: slaptažodis negali sutapti su prieš tai buvusiais {0} slaptažodžiais."
}; };
export default messages; export default messages;

View File

@ -136,7 +136,7 @@ const messages = {
"invalidPasswordNotUsernameMessage": "Ongeldig wachtwoord: het mag niet overeenkomen met de gebruikersnaam.", "invalidPasswordNotUsernameMessage": "Ongeldig wachtwoord: het mag niet overeenkomen met de gebruikersnaam.",
"invalidPasswordRegexPatternMessage": "Ongeldig wachtwoord: het voldoet niet aan het door de beheerder ingestelde patroon.", "invalidPasswordRegexPatternMessage": "Ongeldig wachtwoord: het voldoet niet aan het door de beheerder ingestelde patroon.",
"invalidPasswordHistoryMessage": "Ongeldig wachtwoord: het mag niet overeen komen met een van de laatste {0} wachtwoorden.", "invalidPasswordHistoryMessage": "Ongeldig wachtwoord: het mag niet overeen komen met een van de laatste {0} wachtwoorden.",
"invalidPasswordGenericMessage": "Ongeldig wachtwoord: het nieuwe wachtwoord voldoet niet aan het wachtwoordbeleid.", "invalidPasswordGenericMessage": "Ongeldig wachtwoord: het nieuwe wachtwoord voldoet niet aan het wachtwoordbeleid."
}; };
export default messages; export default messages;

View File

@ -146,7 +146,7 @@ const messages = {
"locale_nl": "Nederlands", "locale_nl": "Nederlands",
"locale_pt-BR": "Português (Brasil)", "locale_pt-BR": "Português (Brasil)",
"locale_ru": "Русский", "locale_ru": "Русский",
"locale_zh-CN": "中文简体", "locale_zh-CN": "中文简体"
}; };
export default messages; export default messages;

View File

@ -133,7 +133,7 @@ const messages = {
"invalidPasswordMinSpecialCharsMessage": "Senha inválida: deve conter pelo menos {0} caractere(s) especial", "invalidPasswordMinSpecialCharsMessage": "Senha inválida: deve conter pelo menos {0} caractere(s) especial",
"invalidPasswordNotUsernameMessage": "Senha inválida: não deve ser igual ao nome de usuário", "invalidPasswordNotUsernameMessage": "Senha inválida: não deve ser igual ao nome de usuário",
"invalidPasswordRegexPatternMessage": "Senha inválida: não corresponde ao padrão da expressão regular.", "invalidPasswordRegexPatternMessage": "Senha inválida: não corresponde ao padrão da expressão regular.",
"invalidPasswordHistoryMessage": "Senha inválida: não pode ser igual a qualquer uma das {0} últimas senhas.", "invalidPasswordHistoryMessage": "Senha inválida: não pode ser igual a qualquer uma das {0} últimas senhas."
}; };
export default messages; export default messages;

View File

@ -136,7 +136,7 @@ const messages = {
"invalidPasswordNotUsernameMessage": "Некорректный пароль: пароль не должен совпадать с именем пользователя.", "invalidPasswordNotUsernameMessage": "Некорректный пароль: пароль не должен совпадать с именем пользователя.",
"invalidPasswordRegexPatternMessage": "Некорректный пароль: пароль не удовлетворяет регулярному выражению.", "invalidPasswordRegexPatternMessage": "Некорректный пароль: пароль не удовлетворяет регулярному выражению.",
"invalidPasswordHistoryMessage": "Некорректный пароль: пароль не должен совпадать с последним(и) {0} паролями.", "invalidPasswordHistoryMessage": "Некорректный пароль: пароль не должен совпадать с последним(и) {0} паролями.",
"invalidPasswordGenericMessage": "Некорректный пароль: новый пароль не соответствует правилам пароля.", "invalidPasswordGenericMessage": "Некорректный пароль: новый пароль не соответствует правилам пароля."
}; };
export default messages; export default messages;

View File

@ -173,7 +173,7 @@ const messages = {
"resourcesSharedWithMe": "Zdroje zdieľané so mnou", "resourcesSharedWithMe": "Zdroje zdieľané so mnou",
"permissionRequestion": "Žiadosti o povolenie", "permissionRequestion": "Žiadosti o povolenie",
"permission": "Oprávnenie", "permission": "Oprávnenie",
"shares": "podiel (y)", "shares": "podiel (y)"
}; };
export default messages; export default messages;

View File

@ -132,7 +132,7 @@ const messages = {
"invalidPasswordNotUsernameMessage": "Ogiltigt lösenord: Får inte vara samma som användarnamnet.", "invalidPasswordNotUsernameMessage": "Ogiltigt lösenord: Får inte vara samma som användarnamnet.",
"invalidPasswordRegexPatternMessage": "Ogiltigt lösenord: matchar inte kravet för lösenordsmönster.", "invalidPasswordRegexPatternMessage": "Ogiltigt lösenord: matchar inte kravet för lösenordsmönster.",
"invalidPasswordHistoryMessage": "Ogiltigt lösenord: Får inte vara samma som de senaste {0} lösenorden.", "invalidPasswordHistoryMessage": "Ogiltigt lösenord: Får inte vara samma som de senaste {0} lösenorden.",
"invalidPasswordGenericMessage": "Ogiltigt lösenord: Det nya lösenordet stämmer inte med lösenordspolicyn.", "invalidPasswordGenericMessage": "Ogiltigt lösenord: Det nya lösenordet stämmer inte med lösenordspolicyn."
}; };
export default messages; export default messages;

View File

@ -302,7 +302,7 @@ const messages = {
"addTeam": "Kaynağınızı paylaşmak için ekip ekleyin", "addTeam": "Kaynağınızı paylaşmak için ekip ekleyin",
"myPermissions": "İzinlerim", "myPermissions": "İzinlerim",
"waitingforApproval": "Onay bekleniyor", "waitingforApproval": "Onay bekleniyor",
"anyPermission": "Herhangi bir izin", "anyPermission": "Herhangi bir izin"
}; };
export default messages; export default messages;

View File

@ -148,7 +148,7 @@ const messages = {
"locale_lt": "Lietuvių", "locale_lt": "Lietuvių",
"locale_pt-BR": "Português (Brasil)", "locale_pt-BR": "Português (Brasil)",
"locale_ru": "Русский", "locale_ru": "Русский",
"locale_zh-CN": "中文简体", "locale_zh-CN": "中文简体"
}; };
export default messages; export default messages;

View File

@ -10,7 +10,7 @@ const messages = {
"invalidPasswordMinSpecialCharsMessage": "Contrasenya incorrecta: ha de contenir almenys {0} caràcters especials.", "invalidPasswordMinSpecialCharsMessage": "Contrasenya incorrecta: ha de contenir almenys {0} caràcters especials.",
"invalidPasswordMinUpperCaseCharsMessage": "Contrasenya incorrecta: ha de contenir almenys {0} lletres majúscules.", "invalidPasswordMinUpperCaseCharsMessage": "Contrasenya incorrecta: ha de contenir almenys {0} lletres majúscules.",
"invalidPasswordNotUsernameMessage": "Contrasenya incorrecta: no pot ser igual al nom d'usuari.", "invalidPasswordNotUsernameMessage": "Contrasenya incorrecta: no pot ser igual al nom d'usuari.",
"invalidPasswordRegexPatternMessage": "Contrasenya incorrecta: no compleix l'expressió regular.", "invalidPasswordRegexPatternMessage": "Contrasenya incorrecta: no compleix l'expressió regular."
}; };
export default messages; export default messages;

View File

@ -12,7 +12,7 @@ const messages = {
"invalidPasswordRegexPatternMessage": "Ungültiges Passwort: stimmt nicht mit Regex-Muster überein.", "invalidPasswordRegexPatternMessage": "Ungültiges Passwort: stimmt nicht mit Regex-Muster überein.",
"invalidPasswordHistoryMessage": "Ungültiges Passwort: darf nicht identisch mit einem der letzten {0} Passwörter sein.", "invalidPasswordHistoryMessage": "Ungültiges Passwort: darf nicht identisch mit einem der letzten {0} Passwörter sein.",
"invalidPasswordBlacklistedMessage": "Ungültiges Passwort: Passwort ist zu bekannt und auf der schwarzen Liste.", "invalidPasswordBlacklistedMessage": "Ungültiges Passwort: Passwort ist zu bekannt und auf der schwarzen Liste.",
"invalidPasswordGenericMessage": "Ungültiges Passwort: neues Passwort erfüllt die Passwort-Anforderungen nicht.", "invalidPasswordGenericMessage": "Ungültiges Passwort: neues Passwort erfüllt die Passwort-Anforderungen nicht."
}; };
export default messages; export default messages;

View File

@ -37,7 +37,7 @@ const messages = {
"Without a configured Sector Identifier URI, client redirect URIs must not contain multiple host components.", "Without a configured Sector Identifier URI, client redirect URIs must not contain multiple host components.",
"pairwiseMalformedSectorIdentifierURI": "Malformed Sector Identifier URI.", "pairwiseMalformedSectorIdentifierURI": "Malformed Sector Identifier URI.",
"pairwiseFailedToGetRedirectURIs": "Failed to get redirect URIs from the Sector Identifier URI.", "pairwiseFailedToGetRedirectURIs": "Failed to get redirect URIs from the Sector Identifier URI.",
"pairwiseRedirectURIsMismatch": "Client redirect URIs does not match redirect URIs fetched from the Sector Identifier URI.", "pairwiseRedirectURIsMismatch": "Client redirect URIs does not match redirect URIs fetched from the Sector Identifier URI."
}; };
export default messages; export default messages;

View File

@ -10,7 +10,7 @@ const messages = {
"invalidPasswordMinSpecialCharsMessage": "Contraseña incorrecta: debe contener al menos {0} caracteres especiales.", "invalidPasswordMinSpecialCharsMessage": "Contraseña incorrecta: debe contener al menos {0} caracteres especiales.",
"invalidPasswordNotUsernameMessage": "Contraseña incorrecta: no puede ser igual al nombre de usuario.", "invalidPasswordNotUsernameMessage": "Contraseña incorrecta: no puede ser igual al nombre de usuario.",
"invalidPasswordRegexPatternMessage": "Contraseña incorrecta: no cumple la expresión regular.", "invalidPasswordRegexPatternMessage": "Contraseña incorrecta: no cumple la expresión regular.",
"invalidPasswordHistoryMessage": "Contraseña incorrecta: no puede ser igual a ninguna de las últimas {0} contraseñas.", "invalidPasswordHistoryMessage": "Contraseña incorrecta: no puede ser igual a ninguna de las últimas {0} contraseñas."
}; };
export default messages; export default messages;

View File

@ -10,7 +10,7 @@ const messages = {
"invalidPasswordMinSpecialCharsMessage": "Mot de passe invalide : doit contenir au moins {0} caractère(s) spéciaux.", "invalidPasswordMinSpecialCharsMessage": "Mot de passe invalide : doit contenir au moins {0} caractère(s) spéciaux.",
"invalidPasswordNotUsernameMessage": "Mot de passe invalide : ne doit pas être identique au nom d'utilisateur.", "invalidPasswordNotUsernameMessage": "Mot de passe invalide : ne doit pas être identique au nom d'utilisateur.",
"invalidPasswordRegexPatternMessage": "Mot de passe invalide : ne valide pas l'expression rationnelle.", "invalidPasswordRegexPatternMessage": "Mot de passe invalide : ne valide pas l'expression rationnelle.",
"invalidPasswordHistoryMessage": "Mot de passe invalide : ne doit pas être égal aux {0} derniers mot de passe.", "invalidPasswordHistoryMessage": "Mot de passe invalide : ne doit pas être égal aux {0} derniers mot de passe."
}; };
export default messages; export default messages;

View File

@ -30,7 +30,7 @@ const messages = {
"設定されたセレクター識別子URIがない場合は、クライアントのリダイレクトURIは複数のホスト・コンポーネントを含むことはできません。", "設定されたセレクター識別子URIがない場合は、クライアントのリダイレクトURIは複数のホスト・コンポーネントを含むことはできません。",
"pairwiseMalformedSectorIdentifierURI": "不正なセレクター識別子URIです。", "pairwiseMalformedSectorIdentifierURI": "不正なセレクター識別子URIです。",
"pairwiseFailedToGetRedirectURIs": "セクター識別子URIからリダイレクトURIを取得できませんでした。", "pairwiseFailedToGetRedirectURIs": "セクター識別子URIからリダイレクトURIを取得できませんでした。",
"pairwiseRedirectURIsMismatch": "クライアントのリダイレクトURIは、セクター識別子URIからフェッチされたリダイレクトURIと一致しません。", "pairwiseRedirectURIsMismatch": "クライアントのリダイレクトURIは、セクター識別子URIからフェッチされたリダイレクトURIと一致しません。"
}; };
export default messages; export default messages;

View File

@ -24,7 +24,7 @@ const messages = {
"Kuomet nesukonfigūruotas sektoriaus identifikatoriaus URL, kliento nukreipimo nuorodos privalo talpinti ne daugiau kaip vieną skirtingą serverio vardo komponentą.", "Kuomet nesukonfigūruotas sektoriaus identifikatoriaus URL, kliento nukreipimo nuorodos privalo talpinti ne daugiau kaip vieną skirtingą serverio vardo komponentą.",
"pairwiseMalformedSectorIdentifierURI": "Neteisinga sektoriaus identifikatoriaus URI.", "pairwiseMalformedSectorIdentifierURI": "Neteisinga sektoriaus identifikatoriaus URI.",
"pairwiseFailedToGetRedirectURIs": "Nepavyko gauti nukreipimo nuorodų iš sektoriaus identifikatoriaus URI.", "pairwiseFailedToGetRedirectURIs": "Nepavyko gauti nukreipimo nuorodų iš sektoriaus identifikatoriaus URI.",
"pairwiseRedirectURIsMismatch": "Kliento nukreipimo nuoroda neatitinka nukreipimo nuorodų iš sektoriaus identifikatoriaus URI.", "pairwiseRedirectURIsMismatch": "Kliento nukreipimo nuoroda neatitinka nukreipimo nuorodų iš sektoriaus identifikatoriaus URI."
}; };
export default messages; export default messages;

View File

@ -27,7 +27,7 @@ const messages = {
"Zonder een geconfigureerde Sector Identifier URI mogen client redirect URIs niet meerdere host componenten hebben.", "Zonder een geconfigureerde Sector Identifier URI mogen client redirect URIs niet meerdere host componenten hebben.",
"pairwiseMalformedSectorIdentifierURI": "Onjuist notatie in Sector Identifier URI.", "pairwiseMalformedSectorIdentifierURI": "Onjuist notatie in Sector Identifier URI.",
"pairwiseFailedToGetRedirectURIs": "Kon geen redirect URIs verkrijgen van de Sector Identifier URI.", "pairwiseFailedToGetRedirectURIs": "Kon geen redirect URIs verkrijgen van de Sector Identifier URI.",
"pairwiseRedirectURIsMismatch": "Client redirect URIs komen niet overeen met redict URIs ontvangen van de Sector Identifier URI.", "pairwiseRedirectURIsMismatch": "Client redirect URIs komen niet overeen met redict URIs ontvangen van de Sector Identifier URI."
}; };
export default messages; export default messages;

View File

@ -15,7 +15,7 @@ const messages = {
"ldapErrorMissingClientId": "KlientID må være tilgjengelig i config når sikkerhetsdomenerollemapping ikke brukes.", "ldapErrorMissingClientId": "KlientID må være tilgjengelig i config når sikkerhetsdomenerollemapping ikke brukes.",
"ldapErrorCantPreserveGroupInheritanceWithUIDMembershipType": "Ikke mulig å bevare gruppearv og samtidig bruke UID medlemskapstype.", "ldapErrorCantPreserveGroupInheritanceWithUIDMembershipType": "Ikke mulig å bevare gruppearv og samtidig bruke UID medlemskapstype.",
"ldapErrorCantWriteOnlyForReadOnlyLdap": "Kan ikke sette write-only når LDAP leverandør-modus ikke er WRITABLE", "ldapErrorCantWriteOnlyForReadOnlyLdap": "Kan ikke sette write-only når LDAP leverandør-modus ikke er WRITABLE",
"ldapErrorCantWriteOnlyAndReadOnly": "Kan ikke sette både write-only og read-only", "ldapErrorCantWriteOnlyAndReadOnly": "Kan ikke sette både write-only og read-only"
}; };
export default messages; export default messages;

View File

@ -18,7 +18,7 @@ const messages = {
"ldapErrorCantWriteOnlyForReadOnlyLdap": "Não é possível definir modo de somente escrita quando o provedor LDAP não suporta escrita", "ldapErrorCantWriteOnlyForReadOnlyLdap": "Não é possível definir modo de somente escrita quando o provedor LDAP não suporta escrita",
"ldapErrorCantWriteOnlyAndReadOnly": "Não é possível definir somente escrita e somente leitura ao mesmo tempo", "ldapErrorCantWriteOnlyAndReadOnly": "Não é possível definir somente escrita e somente leitura ao mesmo tempo",
"clientRedirectURIsFragmentError": "URIs de redirecionamento não podem conter fragmentos", "clientRedirectURIsFragmentError": "URIs de redirecionamento não podem conter fragmentos",
"clientRootURLFragmentError": "URL raiz não pode conter fragmentos", "clientRootURLFragmentError": "URL raiz não pode conter fragmentos"
}; };
export default messages; export default messages;

View File

@ -25,7 +25,7 @@ const messages = {
"Без конфигурации по части идентификатора URI, URI перенаправления клиента не может содержать несколько компонентов хоста.", "Без конфигурации по части идентификатора URI, URI перенаправления клиента не может содержать несколько компонентов хоста.",
"pairwiseMalformedSectorIdentifierURI": "Искаженная часть идентификатора URI.", "pairwiseMalformedSectorIdentifierURI": "Искаженная часть идентификатора URI.",
"pairwiseFailedToGetRedirectURIs": "Не удалось получить идентификаторы URI перенаправления из части идентификатора URI.", "pairwiseFailedToGetRedirectURIs": "Не удалось получить идентификаторы URI перенаправления из части идентификатора URI.",
"pairwiseRedirectURIsMismatch": "Клиент URI переадресации не соответствует URI переадресации, полученной из части идентификатора URI.", "pairwiseRedirectURIsMismatch": "Клиент URI переадресации не соответствует URI переадресации, полученной из части идентификатора URI."
}; };
export default messages; export default messages;

View File

@ -25,7 +25,7 @@ const messages = {
"Without a configured Sector Identifier URI, client redirect URIs must not contain multiple host components.", "Without a configured Sector Identifier URI, client redirect URIs must not contain multiple host components.",
"pairwiseMalformedSectorIdentifierURI": "Malformed Sector Identifier URI.", "pairwiseMalformedSectorIdentifierURI": "Malformed Sector Identifier URI.",
"pairwiseFailedToGetRedirectURIs": "无法从服务器获得重定向URL", "pairwiseFailedToGetRedirectURIs": "无法从服务器获得重定向URL",
"pairwiseRedirectURIsMismatch": "客户端的重定向URI与服务器端获取的URI配置不匹配。", "pairwiseRedirectURIsMismatch": "客户端的重定向URI与服务器端获取的URI配置不匹配。"
}; };
export default messages; export default messages;

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