Compare commits

..

121 Commits

Author SHA1 Message Date
Joseph Garrone
d24a8e99cc
Fix badge 2025-04-09 16:33:19 +02:00
Joseph Garrone
59d2d56091
Fix badge 2025-04-09 16:30:27 +02:00
Joseph Garrone
8515b7060a
Merge pull request #833 from keycloakify/all-contributors/add-wnmzzzz
docs: add wnmzzzz as a contributor for test
2025-04-09 16:29:50 +02:00
allcontributors[bot]
a076b3f4d0
docs: update .all-contributorsrc [skip ci] 2025-04-09 14:29:32 +00:00
allcontributors[bot]
1e3240ef35
docs: update README.md [skip ci] 2025-04-09 14:29:31 +00:00
Joseph Garrone
d767080dfe
Merge pull request #831 from wnmzzzz/patch-1
Add Story for AppInitiatedAction to UpdatePassword
2025-04-09 16:29:02 +02:00
wnmzzzz
b228eda488
Add Story for AppInitiatedAction to UpdatePassword 2025-04-07 08:42:46 +02:00
garronej
35f54964ce Bump version 2025-04-04 23:57:42 +02:00
garronej
759b834ccc Follow up on #827 2025-04-04 23:57:13 +02:00
Joseph Garrone
137e12cbbb
Merge pull request #830 from keycloakify/all-contributors/add-kodebach
docs: add kodebach as a contributor for code
2025-04-04 23:57:08 +02:00
allcontributors[bot]
57b08d9dea
docs: update .all-contributorsrc [skip ci] 2025-04-04 21:56:54 +00:00
allcontributors[bot]
1dd7b673a1
docs: update README.md [skip ci] 2025-04-04 21:56:53 +00:00
Joseph Garrone
b00ffc50c3
Merge pull request #827 from kodebach/fix-keycloak-36012
Fix double submit bug in OTP Form
2025-04-04 23:34:01 +02:00
Klemens Böswirth
f9db40d33d fix: https://github.com/keycloak/keycloak/issues/36012
adapted from https://github.com/keycloak/keycloak/pull/36096
2025-04-02 12:08:37 +00:00
Joseph Garrone
4bc6a843d8
Merge pull request #820 from kingjan1999/fix-required-actions-whitespace
fix: add whitespace before required actions in info page
2025-03-21 13:21:02 +01:00
Jan Beckmann
da3e7514f0
fix: add whitespace before required actions in info page 2025-03-21 11:37:08 +01:00
garronej
bc396bc41b Update runPrettier so that it will still work if we ever switch to ESM 2025-03-16 02:17:11 +01:00
garronej
947efe8d63 Bump version 2025-03-16 00:49:08 +01:00
garronej
64189bf8fe #815 2025-03-16 00:48:50 +01:00
garronej
400c630418 Bump version 2025-03-13 22:03:16 +01:00
garronej
402360b436 #814 https://github.com/keycloak/keycloak/issues/38029 2025-03-13 22:03:02 +01:00
garronej
9f001f1521 Bump version 2025-03-13 13:32:23 +01:00
garronej
368e3a32c5 #772 2025-03-13 13:32:08 +01:00
garronej
002e3d4b3d Bump version 2025-03-12 19:22:58 +01:00
garronej
f94f9b51c9 Fix Vitest VSCode extention 2025-03-12 19:22:32 +01:00
garronej
055b15bd46 Bump version 2025-03-12 00:53:05 +01:00
garronej
0e70b0b0de https://github.com/keycloak/keycloak/issues/38029 2025-03-12 00:52:50 +01:00
garronej
8faf9a3eed Bump version 2025-02-27 12:21:32 +01:00
garronej
075d9f9de5 #802 2025-02-27 12:21:32 +01:00
Joseph Garrone
840079be32
Merge pull request #797 from keycloakify/all-contributors/add-bacongobbler
docs: add bacongobbler as a contributor for doc
2025-02-24 19:43:18 +01:00
allcontributors[bot]
50ae962f09
docs: update .all-contributorsrc [skip ci] 2025-02-24 18:43:07 +00:00
allcontributors[bot]
61aa1f9896
docs: update README.md [skip ci] 2025-02-24 18:43:06 +00:00
garronej
d88e0e4dd5 Bump version 2025-02-24 18:46:06 +01:00
garronej
18c36eb4de Help pepole debug when mvn build fails 2025-02-24 18:06:28 +01:00
Joseph Garrone
80aeabad51
Bump version 2025-02-17 12:59:49 +01:00
Joseph Garrone
419e1f473a
Merge pull request #791 from keycloakify/all-contributors/add-EternalSide
docs: add EternalSide as a contributor for code
2025-02-17 12:59:19 +01:00
Joseph Garrone
80988125e8
Merge pull request #789 from EternalSide/fix/add-passwordPolicy-maxLength
fix: add maxLength passwordPolicy in the getUserProfileApi
2025-02-17 12:59:08 +01:00
allcontributors[bot]
271ad2da71
docs: update .all-contributorsrc [skip ci] 2025-02-17 11:56:31 +00:00
allcontributors[bot]
b2732f2595
docs: update README.md [skip ci] 2025-02-17 11:56:30 +00:00
Alexey Titov
53820e1e34 fix: add maxLength passwordPolicy in the getUserProfileApi 2025-02-17 13:21:55 +03:00
garronej
09dd45e437 Bump version 2025-02-11 15:56:03 +01:00
garronej
1f654a7820 #785 2025-02-11 15:55:47 +01:00
Joseph Garrone
0690f40bad Bump version 2025-02-02 18:26:31 +01:00
Joseph Garrone
2285883149 Fix typo #778 2025-02-02 18:26:08 +01:00
Joseph Garrone
af87e41bb8 Bump version 2025-01-25 18:31:05 +01:00
Joseph Garrone
9ba884483d keycloakify-email isn't strictly bound to jsx-email 2025-01-25 18:30:52 +01:00
Joseph Garrone
f5a300953a Bump version 2025-01-24 21:27:03 +01:00
Joseph Garrone
ab9a962f58 #771 2025-01-24 21:26:43 +01:00
Joseph Garrone
484adb607f Bump version 2025-01-23 16:41:49 +01:00
Joseph Garrone
e1f38d4196 Fix 767 2025-01-23 16:41:02 +01:00
Joseph Garrone
5de629acf2
Merge pull request #767 from mislavperi/main
Expanded podman support
2025-01-23 15:36:21 +00:00
Mislav Perić
8b4b24a036 consisteny 2025-01-23 14:42:53 +01:00
Mislav Perić
75ab130249 refactor to shorten the code 2025-01-23 14:42:03 +01:00
Mislav Perić
981ca7e9a4 expanded podman support 2025-01-23 10:55:51 +01:00
Joseph Garrone
acb4e260a7 Bump version 2025-01-11 16:08:38 +01:00
Joseph Garrone
ff20b0a844 Avoid re-building when shared files from SPAs are changed 2025-01-11 16:08:17 +01:00
Joseph Garrone
1b77c69a01 Bump version 2025-01-10 19:07:35 +01:00
Joseph Garrone
158275f5c2 Workaround bug with the versions subcomand of older npm versions 2025-01-10 19:07:11 +01:00
Joseph Garrone
a085c8093e Bump version 2025-01-08 00:07:34 +01:00
Joseph Garrone
cb358bd745 #754: PasswordWrapper fix for React 19 2025-01-08 00:06:45 +01:00
Joseph Garrone
e788c46601 Bump version 2025-01-07 20:51:49 +01:00
Joseph Garrone
d551b4bffb Add missing Patternfly fonts 2025-01-07 20:51:27 +01:00
Joseph Garrone
c168c7b156 Bump version 2025-01-06 02:47:42 +01:00
Joseph Garrone
7a46115042 Enable persisting email change on test user 2025-01-06 02:47:28 +01:00
Joseph Garrone
249a7bde89 Fix adding comment to certain ftl files 2025-01-06 02:31:46 +01:00
Joseph Garrone
813740a002 Bump version 2025-01-05 21:34:34 +01:00
Joseph Garrone
7840c2a6f5 Fix previous release 2025-01-05 21:34:16 +01:00
Joseph Garrone
8f6c0d36d9 Bump version 2025-01-05 21:09:24 +01:00
Joseph Garrone
12690b892b Fix replacing of xKeycloakify.themeName in theme variant 2025-01-05 21:09:06 +01:00
Joseph Garrone
d01b4b71c9 Bump version 2025-01-05 21:00:53 +01:00
Joseph Garrone
c29e600786 Fix generateResource bug 2025-01-05 21:00:34 +01:00
Joseph Garrone
6309b7c45d Bump version 2025-01-05 04:27:11 +01:00
Joseph Garrone
7e7996e40c Fix auto enable themes when using start-keycloak 2025-01-05 04:26:45 +01:00
Joseph Garrone
deaeab0f61 Infer META-INF/keycloak-themes.jar 2025-01-05 03:14:34 +01:00
Joseph Garrone
6bd5451230 Bump version 2025-01-05 02:09:33 +01:00
Joseph Garrone
fb2d651a6f Rely on @keycloakify/email-native for email initialization 2025-01-05 02:08:18 +01:00
Joseph Garrone
4845d7c32d Support incorporating theme native theme, with theme variant support #733 2025-01-05 02:08:13 +01:00
Joseph Garrone
c33c315120 Bump version 2025-01-03 22:58:12 +01:00
Joseph Garrone
99b8f1e789 Update i18n account multi-page boilerplate 2025-01-03 22:57:57 +01:00
Joseph Garrone
6af13e1405 Bump version 2025-01-03 22:39:01 +01:00
Joseph Garrone
f59fa4238c Link to the documentation for implementing non builtin pages 2025-01-03 22:38:45 +01:00
Joseph Garrone
248effc57d Bump version 2025-01-03 02:47:31 +01:00
Joseph Garrone
9e540b2c1f Fix assets import from public in .svelte files 2025-01-03 02:47:31 +01:00
Joseph Garrone
ab7b5ff490
Remove ringerhq 2025-01-03 01:06:29 +01:00
Joseph Garrone
486f944e0f Bump version 2025-01-02 10:25:16 +01:00
Joseph Garrone
6cc3d4c442 Fix conflict in --path shorthand with --project, --path shorthand is now -t (target) 2025-01-02 10:25:16 +01:00
Joseph Garrone
083290c6d4
update broken link 2024-12-30 02:04:09 +01:00
Joseph Garrone
cd1b55b850 Fix test 2024-12-27 01:49:38 +01:00
Joseph Garrone
482ba6c639 Bump version 2024-12-27 01:41:29 +01:00
Joseph Garrone
e2921b7e37 Fix auto add of postinstall script 2024-12-27 01:38:11 +01:00
Joseph Garrone
c87b6153bb Fix some errors implementing the new account SPA feature 2024-12-27 01:36:29 +01:00
Joseph Garrone
488dd2c6b9 Migrate to extention model for Account SPA 2024-12-26 15:55:59 +01:00
Joseph Garrone
1ac678a368 Remove dead code 2024-12-26 15:24:12 +01:00
Joseph Garrone
5866c802e5 Always initialize email with the latest keycloak version 2024-12-26 15:24:03 +01:00
Joseph Garrone
fe892c840b Bump version 2024-12-24 17:40:31 +01:00
Joseph Garrone
9685dfb55a Fix git integration bug 2024-12-24 17:39:54 +01:00
Joseph Garrone
c1dc899bc1 Better naming convention 'uiModules' -> 'extensionModules' 2024-12-24 16:43:42 +01:00
Joseph Garrone
d2da43c617 Implement --revert for own command 2024-12-24 01:10:32 +01:00
Joseph Garrone
6de5fd4f96 Optimization and potentially prevend leaving the repo in a broken state 2024-12-23 18:49:00 +01:00
Joseph Garrone
cc3d0d61dd Consistent naming scheme 'eject' -> 'own' 2024-12-23 18:34:42 +01:00
Joseph Garrone
4403f00274 Reneame the 'eject-file' command to 'own' 2024-12-23 17:55:40 +01:00
Joseph Garrone
eddfb8e634 Bump version 2024-12-22 21:58:07 +01:00
Joseph Garrone
4f2790f6d3 Fixes on the admin initialization cmd 2024-12-22 21:57:52 +01:00
Joseph Garrone
96690e1354 Generate the postinstall script as the first entry of the package.json 2024-12-22 21:25:51 +01:00
Joseph Garrone
982f216a01 Do not take into account react in the resolution of ui modules peer dependencies for supporting React 19 2024-12-22 21:21:20 +01:00
Joseph Garrone
13c21e8910 Implement initialize-admin-theme command 2024-12-22 17:09:15 +01:00
Joseph Garrone
94b7d2b85b Bump version 2024-12-21 19:40:07 +01:00
Joseph Garrone
9a4f89e69d Sort out version of keycloak not supported depending on which theme is implemented (start-keycloak cmd) 2024-12-21 19:39:10 +01:00
Joseph Garrone
a5ba03cca0 Reload when .properties files are updated 2024-12-21 19:02:13 +01:00
Joseph Garrone
5203813e7b Rebuild the theme when properties files changes 2024-12-21 15:22:19 +01:00
Joseph Garrone
0e461fd072 Fix bugs in svg assets commenting for mirror files 2024-12-21 14:25:47 +01:00
Joseph Garrone
326411ca5d Correctly generate i18n messages for admin UI 2024-12-21 12:09:29 +01:00
Joseph Garrone
c39c450e90 Support generating eject comments for .svg files 2024-12-20 13:22:15 +01:00
Joseph Garrone
3191954dda Support generating eject comment for .properties file 2024-12-20 13:03:58 +01:00
Joseph Garrone
20c6d2ea86 Bump version 2024-12-19 19:07:24 +01:00
Joseph Garrone
f43544e134 ensure no diff if config hasn't changed 2024-12-19 19:06:54 +01:00
Joseph Garrone
474a863708 Correctly patch the security-admin-console client so that it can be run with HMR 2024-12-18 20:57:42 +01:00
Joseph Garrone
0bacdca8fe Bump version 2024-12-17 18:04:22 +01:00
Joseph Garrone
f023d6bca7 Fixes windows issues #747 2024-12-17 18:04:06 +01:00
Joseph Garrone
150b01f1f3 Bump version 2024-12-17 10:44:38 +01:00
Joseph Garrone
2b2bb20658 #746 2024-12-17 10:44:24 +01:00
100 changed files with 5329 additions and 2140 deletions

View File

@ -327,6 +327,42 @@
"contributions": [ "contributions": [
"doc" "doc"
] ]
},
{
"login": "EternalSide",
"name": "Lesha",
"avatar_url": "https://avatars.githubusercontent.com/u/118743608?v=4",
"profile": "http://t.me/AAT_L",
"contributions": [
"code"
]
},
{
"login": "bacongobbler",
"name": "Matthew Fisher",
"avatar_url": "https://avatars.githubusercontent.com/u/1360539?v=4",
"profile": "https://blog.bacongobbler.com",
"contributions": [
"doc"
]
},
{
"login": "kodebach",
"name": "Klemens Böswirth",
"avatar_url": "https://avatars.githubusercontent.com/u/23529132?v=4",
"profile": "https://github.com/kodebach",
"contributions": [
"code"
]
},
{
"login": "wnmzzzz",
"name": "wnmzzzz",
"avatar_url": "https://avatars.githubusercontent.com/u/117174301?v=4",
"profile": "https://github.com/wnmzzzz",
"contributions": [
"test"
]
} }
], ],
"contributorsPerLine": 7, "contributorsPerLine": 7,

View File

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

View File

@ -6,7 +6,7 @@
<br> <br>
<br> <br>
<a href="https://github.com/garronej/keycloakify/actions"> <a href="https://github.com/garronej/keycloakify/actions">
<img src="https://github.com/garronej/keycloakify/workflows/ci/badge.svg?branch=main"> <img src="https://github.com/keycloakify/keycloakify/actions/workflows/ci.yaml/badge.svg">
</a> </a>
<a href="https://www.npmjs.com/package/keycloakify"> <a href="https://www.npmjs.com/package/keycloakify">
<img src="https://img.shields.io/npm/dm/keycloakify"> <img src="https://img.shields.io/npm/dm/keycloakify">
@ -46,7 +46,7 @@ Keycloakify is fully compatible with Keycloak from version 11 to 26...[and beyon
> 📣 **Keycloakify 26 Released** > 📣 **Keycloakify 26 Released**
> Themes built with Keycloakify versions **prior** to Keycloak 26 are **incompatible** with Keycloak 26. > Themes built with Keycloakify versions **prior** to Keycloak 26 are **incompatible** with Keycloak 26.
> To ensure compatibility, simply upgrade to the latest Keycloakify version for your major release (v10 or v11) and rebuild your theme. > To ensure compatibility, simply upgrade to the latest Keycloakify version for your major release (v10 or v11) and rebuild your theme.
> No breaking changes have been introduced, but the target version ranges have been updated. For more details, see [this guide](https://docs.keycloakify.dev/targeting-specific-keycloak-versions). > No breaking changes have been introduced, but the target version ranges have been updated. For more details, see [this guide](https://docs.keycloakify.dev/features/compiler-options/keycloakversiontargets).
## Sponsors ## Sponsors
@ -168,6 +168,12 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zvn2060"><img src="https://avatars.githubusercontent.com/u/45450852?v=4?s=100" width="100px;" alt="HI_OuO"/><br /><sub><b>HI_OuO</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=zvn2060" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/zvn2060"><img src="https://avatars.githubusercontent.com/u/45450852?v=4?s=100" width="100px;" alt="HI_OuO"/><br /><sub><b>HI_OuO</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=zvn2060" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tripheo0412"><img src="https://avatars.githubusercontent.com/u/25382052?v=4?s=100" width="100px;" alt="Tri Hoang"/><br /><sub><b>Tri Hoang</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=tripheo0412" title="Documentation">📖</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/tripheo0412"><img src="https://avatars.githubusercontent.com/u/25382052?v=4?s=100" width="100px;" alt="Tri Hoang"/><br /><sub><b>Tri Hoang</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=tripheo0412" title="Documentation">📖</a></td>
</tr> </tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://t.me/AAT_L"><img src="https://avatars.githubusercontent.com/u/118743608?v=4?s=100" width="100px;" alt="Lesha"/><br /><sub><b>Lesha</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=EternalSide" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://blog.bacongobbler.com"><img src="https://avatars.githubusercontent.com/u/1360539?v=4?s=100" width="100px;" alt="Matthew Fisher"/><br /><sub><b>Matthew Fisher</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=bacongobbler" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kodebach"><img src="https://avatars.githubusercontent.com/u/23529132?v=4?s=100" width="100px;" alt="Klemens Böswirth"/><br /><sub><b>Klemens Böswirth</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kodebach" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wnmzzzz"><img src="https://avatars.githubusercontent.com/u/117174301?v=4?s=100" width="100px;" alt="wnmzzzz"/><br /><sub><b>wnmzzzz</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=wnmzzzz" title="Tests">⚠️</a></td>
</tr>
</tbody> </tbody>
</table> </table>

View File

@ -1,6 +1,6 @@
{ {
"name": "keycloakify", "name": "keycloakify",
"version": "11.5.1", "version": "11.8.23",
"description": "Framework to create custom Keycloak UIs", "description": "Framework to create custom Keycloak UIs",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -67,7 +67,9 @@ export function vendorFrontendDependencies(params: { distDirPath: string }) {
) )
); );
run(`npx webpack --config ${webpackConfigJsFilePath}`); run(`npx webpack --config ${pathBasename(webpackConfigJsFilePath)}`, {
cwd: pathDirname(webpackConfigJsFilePath)
});
fs.readdirSync(webpackOutputDirPath) fs.readdirSync(webpackOutputDirPath)
.filter(fileBasename => !fileBasename.endsWith(".txt")) .filter(fileBasename => !fileBasename.endsWith(".txt"))

View File

@ -3,10 +3,9 @@ import child_process from "child_process";
import { SemVer } from "../src/bin/tools/SemVer"; import { SemVer } from "../src/bin/tools/SemVer";
import { dumpContainerConfig } from "../src/bin/start-keycloak/realmConfig/dumpContainerConfig"; import { dumpContainerConfig } from "../src/bin/start-keycloak/realmConfig/dumpContainerConfig";
import { cacheDirPath } from "./shared/cacheDirPath"; import { cacheDirPath } from "./shared/cacheDirPath";
import { runPrettier } from "../src/bin/tools/runPrettier";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath"; import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { writeRealmJsonFile } from "../src/bin/start-keycloak/realmConfig/ParsedRealmJson";
import { join as pathJoin } from "path"; import { join as pathJoin } from "path";
import * as fs from "fs";
import chalk from "chalk"; import chalk from "chalk";
(async () => { (async () => {
@ -26,9 +25,7 @@ import chalk from "chalk";
realmName: "myrealm" realmName: "myrealm"
}); });
let sourceCode = JSON.stringify(parsedRealmJson, null, 2); const realmJsonFilePath = pathJoin(
const filePath = pathJoin(
getThisCodebaseRootDirPath(), getThisCodebaseRootDirPath(),
"src", "src",
"bin", "bin",
@ -38,12 +35,11 @@ import chalk from "chalk";
`realm-kc-${keycloakMajorVersionNumber}.json` `realm-kc-${keycloakMajorVersionNumber}.json`
); );
sourceCode = await runPrettier({ await writeRealmJsonFile({
sourceCode, parsedRealmJson,
filePath realmJsonFilePath,
keycloakMajorVersionNumber
}); });
fs.writeFileSync(filePath, Buffer.from(sourceCode, "utf8")); console.log(chalk.green(`Realm config dumped to ${realmJsonFilePath}`));
console.log(chalk.green(`Realm config dumped to ${filePath}`));
})(); })();

View File

@ -45,7 +45,10 @@ const commonThirdPartyDeps = [
.replace(/"!\.\/dist\//g, '"!./'); .replace(/"!\.\/dist\//g, '"!./');
modifiedPackageJsonContent = JSON.stringify( modifiedPackageJsonContent = JSON.stringify(
{ ...JSON.parse(modifiedPackageJsonContent), version: "0.0.0" }, {
...JSON.parse(modifiedPackageJsonContent),
version: `0.0.0-rc.${~~(Math.random() * 1000000)}`
},
null, null,
4 4
); );

View File

@ -280,6 +280,24 @@ export async function downloadKeycloakDefaultTheme(params: {
"fonts", "fonts",
"OpenSans-Semibold-webfont.woff2" "OpenSans-Semibold-webfont.woff2"
), ),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-SemiboldItalic-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-SemiboldItalic-webfont.woff"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-SemiboldItalic-webfont.ttf"
),
pathJoin("patternfly", "dist", "img", "bg-login.jpg"), pathJoin("patternfly", "dist", "img", "bg-login.jpg"),
pathJoin("jquery", "dist", "jquery.min.js"), pathJoin("jquery", "dist", "jquery.min.js"),
pathJoin("rfc4648", "lib", "rfc4648.js") pathJoin("rfc4648", "lib", "rfc4648.js")

View File

@ -20,7 +20,7 @@ import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_dele
export async function command(params: { buildContext: BuildContext }) { export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params; const { buildContext } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "add-story", commandName: "add-story",
buildContext buildContext
}); });
@ -74,7 +74,7 @@ export async function command(params: { buildContext: BuildContext }) {
if (themeType === "admin") { if (themeType === "admin") {
console.log( console.log(
`${chalk.red("✗")} Sorry, there is no Storybook support for the Account UI.` `${chalk.red("✗")} Sorry, there is no Storybook support for the Admin UI.`
); );
process.exit(0); process.exit(0);

View File

@ -11,7 +11,7 @@ import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
export async function command(params: { buildContext: BuildContext }) { export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params; const { buildContext } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "copy-keycloak-resources-to-public", commandName: "copy-keycloak-resources-to-public",
buildContext buildContext
}); });

View File

@ -1,68 +0,0 @@
import type { BuildContext } from "./shared/buildContext";
import { getUiModuleFileSourceCodeReadyToBeCopied } from "./postinstall/getUiModuleFileSourceCodeReadyToBeCopied";
import { getAbsoluteAndInOsFormatPath } from "./tools/getAbsoluteAndInOsFormatPath";
import { relative as pathRelative, dirname as pathDirname, join as pathJoin } from "path";
import { getUiModuleMetas } from "./postinstall/uiModuleMeta";
import { getInstalledModuleDirPath } from "./tools/getInstalledModuleDirPath";
import * as fsPr from "fs/promises";
import {
readManagedGitignoreFile,
writeManagedGitignoreFile
} from "./postinstall/managedGitignoreFile";
export async function command(params: {
buildContext: BuildContext;
cliCommandOptions: {
file: string;
};
}) {
const { buildContext, cliCommandOptions } = params;
const fileRelativePath = pathRelative(
buildContext.themeSrcDirPath,
getAbsoluteAndInOsFormatPath({
cwd: buildContext.themeSrcDirPath,
pathIsh: cliCommandOptions.file
})
);
const uiModuleMetas = await getUiModuleMetas({ buildContext });
const uiModuleMeta = uiModuleMetas.find(({ files }) =>
files.map(({ fileRelativePath }) => fileRelativePath).includes(fileRelativePath)
);
if (!uiModuleMeta) {
throw new Error(`No UI module found for the file ${fileRelativePath}`);
}
const uiModuleDirPath = await getInstalledModuleDirPath({
moduleName: uiModuleMeta.moduleName,
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath),
projectDirPath: buildContext.projectDirPath
});
const sourceCode = await getUiModuleFileSourceCodeReadyToBeCopied({
buildContext,
fileRelativePath,
isForEjection: true,
uiModuleName: uiModuleMeta.moduleName,
uiModuleDirPath,
uiModuleVersion: uiModuleMeta.version
});
await fsPr.writeFile(
pathJoin(buildContext.themeSrcDirPath, fileRelativePath),
sourceCode
);
const { ejectedFilesRelativePaths } = await readManagedGitignoreFile({
buildContext
});
await writeManagedGitignoreFile({
buildContext,
uiModuleMetas,
ejectedFilesRelativePaths: [...ejectedFilesRelativePaths, fileRelativePath]
});
}

View File

@ -11,12 +11,7 @@ import {
} from "./shared/constants"; } from "./shared/constants";
import { capitalize } from "tsafe/capitalize"; import { capitalize } from "tsafe/capitalize";
import * as fs from "fs"; import * as fs from "fs";
import { import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
join as pathJoin,
relative as pathRelative,
dirname as pathDirname,
basename as pathBasename
} from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase"; import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert"; import { assert, Equals } from "tsafe/assert";
import type { BuildContext } from "./shared/buildContext"; import type { BuildContext } from "./shared/buildContext";
@ -27,7 +22,7 @@ import { runPrettier, getIsPrettierAvailable } from "./tools/runPrettier";
export async function command(params: { buildContext: BuildContext }) { export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params; const { buildContext } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "eject-page", commandName: "eject-page",
buildContext buildContext
}); });
@ -67,9 +62,7 @@ export async function command(params: { buildContext: BuildContext }) {
})(); })();
if (themeType === "admin") { if (themeType === "admin") {
console.log( console.log("Use `npx keycloakify own` command instead, see documentation");
"Use `npx keycloakify eject-file` command instead, see documentation"
);
process.exit(-1); process.exit(-1);
} }
@ -79,85 +72,16 @@ export async function command(params: { buildContext: BuildContext }) {
(assert(buildContext.implementedThemeTypes.account.isImplemented), (assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page") buildContext.implementedThemeTypes.account.type === "Single-Page")
) { ) {
const srcDirPath = pathJoin(
pathDirname(buildContext.packageJsonFilePath),
"node_modules",
"@keycloakify",
`keycloak-account-ui`,
"src"
);
console.log( console.log(
[ chalk.yellow(
`There isn't an interactive CLI to eject components of the Account SPA UI.`, [
`You can however copy paste into your codebase the any file or directory from the following source directory:`, "You are implementing a Single-Page Account theme.",
``, "The eject-page command isn't applicable in this context"
`${chalk.bold(pathJoin(pathRelative(process.cwd(), srcDirPath)))}`, ].join("\n")
`` )
].join("\n")
); );
eject_entrypoint: { process.exit(1);
const kcUiTsxFileRelativePath = `KcAccountUi.tsx` as const;
const themeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
const targetFilePath = pathJoin(themeSrcDirPath, kcUiTsxFileRelativePath);
if (fs.existsSync(targetFilePath)) {
break eject_entrypoint;
}
fs.cpSync(pathJoin(srcDirPath, kcUiTsxFileRelativePath), targetFilePath);
{
const kcPageTsxFilePath = pathJoin(themeSrcDirPath, "KcPage.tsx");
const kcPageTsxCode = fs.readFileSync(kcPageTsxFilePath).toString("utf8");
const componentName = pathBasename(kcUiTsxFileRelativePath).replace(
/.tsx$/,
""
);
let modifiedKcPageTsxCode = kcPageTsxCode.replace(
`@keycloakify/keycloak-account-ui/${componentName}`,
`./${componentName}`
);
run_prettier: {
if (!(await getIsPrettierAvailable())) {
break run_prettier;
}
modifiedKcPageTsxCode = await runPrettier({
filePath: kcPageTsxFilePath,
sourceCode: modifiedKcPageTsxCode
});
}
fs.writeFileSync(
kcPageTsxFilePath,
Buffer.from(modifiedKcPageTsxCode, "utf8")
);
}
const routesTsxFilePath = pathRelative(
process.cwd(),
pathJoin(srcDirPath, "routes.tsx")
);
console.log(
[
`To help you get started ${chalk.bold(pathRelative(process.cwd(), targetFilePath))} has been copied into your project.`,
`The next step is usually to eject ${chalk.bold(routesTsxFilePath)}`,
`with \`cp ${routesTsxFilePath} ${pathRelative(process.cwd(), themeSrcDirPath)}\``,
`then update the import of routes in ${kcUiTsxFileRelativePath}.`
].join("\n")
);
}
process.exit(0);
return; return;
} }
@ -168,12 +92,14 @@ export async function command(params: { buildContext: BuildContext }) {
const templateValue = "Template.tsx (Layout common to every page)"; const templateValue = "Template.tsx (Layout common to every page)";
const userProfileFormFieldsValue = const userProfileFormFieldsValue =
"UserProfileFormFields.tsx (Renders the form of the register.ftl, login-update-profile.ftl, update-email.ftl and idp-review-user-profile.ftl)"; "UserProfileFormFields.tsx (Renders the form of the register.ftl, login-update-profile.ftl, update-email.ftl and idp-review-user-profile.ftl)";
const otherPageValue = "The page you're looking for isn't listed here";
const { value: pageIdOrComponent } = await cliSelect< const { value: pageIdOrComponent } = await cliSelect<
| LoginThemePageId | LoginThemePageId
| AccountThemePageId | AccountThemePageId
| typeof templateValue | typeof templateValue
| typeof userProfileFormFieldsValue | typeof userProfileFormFieldsValue
| typeof otherPageValue
>({ >({
values: (() => { values: (() => {
switch (themeType) { switch (themeType) {
@ -181,10 +107,11 @@ export async function command(params: { buildContext: BuildContext }) {
return [ return [
templateValue, templateValue,
userProfileFormFieldsValue, userProfileFormFieldsValue,
...LOGIN_THEME_PAGE_IDS ...LOGIN_THEME_PAGE_IDS,
otherPageValue
]; ];
case "account": case "account":
return [templateValue, ...ACCOUNT_THEME_PAGE_IDS]; return [templateValue, ...ACCOUNT_THEME_PAGE_IDS, otherPageValue];
} }
assert<Equals<typeof themeType, never>>(false); assert<Equals<typeof themeType, never>>(false);
})() })()
@ -192,6 +119,17 @@ export async function command(params: { buildContext: BuildContext }) {
process.exit(-1); process.exit(-1);
}); });
if (pageIdOrComponent === otherPageValue) {
console.log(
[
"To style a page not included in the base Keycloak, such as one added by a third-party Keycloak extension,",
"refer to the documentation: https://docs.keycloakify.dev/features/styling-a-custom-page-not-included-in-base-keycloak"
].join(" ")
);
process.exit(0);
}
console.log(`${pageIdOrComponent}`); console.log(`${pageIdOrComponent}`);
const componentBasename = (() => { const componentBasename = (() => {

View File

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

View File

@ -7,11 +7,12 @@ import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeIm
import { command as updateKcGenCommand } from "../update-kc-gen"; import { command as updateKcGenCommand } from "../update-kc-gen";
import { maybeDelegateCommandToCustomHandler } from "../shared/customHandler_delegate"; import { maybeDelegateCommandToCustomHandler } from "../shared/customHandler_delegate";
import { exitIfUncommittedChanges } from "../shared/exitIfUncommittedChanges"; import { exitIfUncommittedChanges } from "../shared/exitIfUncommittedChanges";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
export async function command(params: { buildContext: BuildContext }) { export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params; const { buildContext } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "initialize-account-theme", commandName: "initialize-account-theme",
buildContext buildContext
}); });
@ -22,22 +23,6 @@ export async function command(params: { buildContext: BuildContext }) {
const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account"); const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
if (
fs.existsSync(accountThemeSrcDirPath) &&
fs.readdirSync(accountThemeSrcDirPath).length > 0
) {
console.warn(
chalk.red(
`There is already a ${pathRelative(
process.cwd(),
accountThemeSrcDirPath
)} directory in your project. Aborting.`
)
);
process.exit(-1);
}
exitIfUncommittedChanges({ exitIfUncommittedChanges({
projectDirPath: buildContext.projectDirPath projectDirPath: buildContext.projectDirPath
}); });
@ -51,23 +36,41 @@ export async function command(params: { buildContext: BuildContext }) {
switch (accountThemeType) { switch (accountThemeType) {
case "Multi-Page": case "Multi-Page":
{ {
const { initializeAccountTheme_multiPage } = await import( if (
"./initializeAccountTheme_multiPage" fs.existsSync(accountThemeSrcDirPath) &&
); fs.readdirSync(accountThemeSrcDirPath).length > 0
) {
console.warn(
chalk.red(
`There is already a ${pathRelative(
process.cwd(),
accountThemeSrcDirPath
)} directory in your project. Aborting.`
)
);
await initializeAccountTheme_multiPage({ process.exit(-1);
accountThemeSrcDirPath }
});
fs.cpSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"initialize-account-theme",
"multi-page-boilerplate"
),
accountThemeSrcDirPath,
{ recursive: true }
);
} }
break; break;
case "Single-Page": case "Single-Page":
{ {
const { initializeAccountTheme_singlePage } = await import( const { initializeSpa } = await import("../shared/initializeSpa");
"./initializeAccountTheme_singlePage"
);
await initializeAccountTheme_singlePage({ await initializeSpa({
accountThemeSrcDirPath, themeType: "account",
buildContext buildContext
}); });
} }

View File

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

View File

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

View File

@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { i18nBuilder } from "keycloakify/account"; import { i18nBuilder } from "keycloakify/account";
import type { ThemeName } from "../kc.gen"; import type { ThemeName } from "../kc.gen";
/** @see: https://docs.keycloakify.dev/i18n */ /** @see: https://docs.keycloakify.dev/features/i18n */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { useI18n, ofTypeI18n } = i18nBuilder.withThemeName<ThemeName>().build(); const { useI18n, ofTypeI18n } = i18nBuilder.withThemeName<ThemeName>().build();
type I18n = typeof ofTypeI18n; type I18n = typeof ofTypeI18n;

View File

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

View File

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

View File

@ -0,0 +1,39 @@
import type { BuildContext } from "./shared/buildContext";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import { initializeSpa } from "./shared/initializeSpa";
import { exitIfUncommittedChanges } from "./shared/exitIfUncommittedChanges";
import { command as updateKcGenCommand } from "./update-kc-gen";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "initialize-admin-theme",
buildContext
});
if (hasBeenHandled) {
return;
}
exitIfUncommittedChanges({
projectDirPath: buildContext.projectDirPath
});
await initializeSpa({
themeType: "admin",
buildContext
});
await updateKcGenCommand({
buildContext: {
...buildContext,
implementedThemeTypes: {
...buildContext.implementedThemeTypes,
admin: {
isImplemented: true
}
}
}
});
}

View File

@ -1,18 +1,23 @@
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
import type { BuildContext } from "./shared/buildContext"; import type { BuildContext } from "./shared/buildContext";
import * as fs from "fs"; import cliSelect from "cli-select";
import { downloadAndExtractArchive } from "./tools/downloadAndExtractArchive";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate"; import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import fetch from "make-fetch-happen"; import { exitIfUncommittedChanges } from "./shared/exitIfUncommittedChanges";
import { SemVer } from "./tools/SemVer";
import { assert } from "tsafe/assert"; import { dirname as pathDirname, join as pathJoin, relative as pathRelative } from "path";
import * as fs from "fs";
import { assert, is, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
import { addSyncExtensionsToPostinstallScript } from "./shared/addSyncExtensionsToPostinstallScript";
import { getIsPrettierAvailable, runPrettier } from "./tools/runPrettier";
import { npmInstall } from "./tools/npmInstall";
import * as child_process from "child_process";
import { z } from "zod";
import chalk from "chalk";
export async function command(params: { buildContext: BuildContext }) { export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params; const { buildContext } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "initialize-email-theme", commandName: "initialize-email-theme",
buildContext buildContext
}); });
@ -21,6 +26,10 @@ export async function command(params: { buildContext: BuildContext }) {
return; return;
} }
exitIfUncommittedChanges({
projectDirPath: buildContext.projectDirPath
});
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email"); const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
if ( if (
@ -28,93 +37,120 @@ export async function command(params: { buildContext: BuildContext }) {
fs.readdirSync(emailThemeSrcDirPath).length > 0 fs.readdirSync(emailThemeSrcDirPath).length > 0
) { ) {
console.warn( console.warn(
`There is already a non empty ${pathRelative( chalk.red(
process.cwd(), `There is already a ${pathRelative(
emailThemeSrcDirPath process.cwd(),
)} directory in your project. Aborting.` emailThemeSrcDirPath
)} directory in your project. Aborting.`
)
); );
process.exit(-1); process.exit(-1);
} }
console.log("Initialize with the base email theme from which version of Keycloak?"); const { value: emailThemeType } = await cliSelect({
values: [
"native (FreeMarker)" as const,
"Another email templating solution" as const
]
}).catch(() => {
process.exit(-1);
});
let { keycloakVersion } = await promptKeycloakVersion({ if (emailThemeType === "Another email templating solution") {
// NOTE: This is arbitrary console.log(
startingFromMajor: 17, [
excludeMajorVersions: [], "There is currently no automated support for keycloakify-email, it has to be done manually, see documentation:",
doOmitPatch: false, "https://docs.keycloakify.dev/theme-types/email-theme"
].join("\n")
);
process.exit(0);
}
const parsedPackageJson = (() => {
type ParsedPackageJson = {
scripts?: Record<string, string | undefined>;
dependencies?: Record<string, string | undefined>;
devDependencies?: Record<string, string | undefined>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
scripts: z.record(z.union([z.string(), z.undefined()])).optional(),
dependencies: z.record(z.union([z.string(), z.undefined()])).optional(),
devDependencies: z.record(z.union([z.string(), z.undefined()])).optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>;
return id<z.ZodType<TargetType>>(zTargetType);
})();
const parsedPackageJson = JSON.parse(
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
})();
addSyncExtensionsToPostinstallScript({
parsedPackageJson,
buildContext buildContext
}); });
const getUrl = (keycloakVersion: string) => { const moduleName = `@keycloakify/email-native`;
return `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`;
};
keycloakVersion = await (async () => { const [version] = ((): string[] => {
const keycloakVersionParsed = SemVer.parse(keycloakVersion); const cmdOutput = child_process
.execSync(`npm show ${moduleName} versions --json`)
.toString("utf8")
.trim();
while (true) { const versions = JSON.parse(cmdOutput) as string | string[];
const url = getUrl(SemVer.stringify(keycloakVersionParsed));
const response = await fetch(url, buildContext.fetchOptions); // NOTE: Bug in some older npm versions
if (typeof versions === "string") {
if (response.ok) { return [versions];
break;
}
assert(keycloakVersionParsed.patch !== 0);
keycloakVersionParsed.patch--;
} }
return SemVer.stringify(keycloakVersionParsed); return versions;
})(); })()
.reverse()
.filter(version => !version.includes("-"));
const { extractedDirPath } = await downloadAndExtractArchive({ assert(version !== undefined);
url: getUrl(keycloakVersion),
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "extractOnlyEmailTheme",
onArchiveFile: async ({ fileRelativePath, writeFile }) => {
const fileRelativePath_target = pathRelative(
pathJoin("theme", "base", "email"),
fileRelativePath
);
if (fileRelativePath_target.startsWith("..")) { (parsedPackageJson.dependencies ??= {})[moduleName] = `~${version}`;
return;
}
await writeFile({ fileRelativePath: fileRelativePath_target }); if (parsedPackageJson.devDependencies !== undefined) {
} delete parsedPackageJson.devDependencies[moduleName];
}); }
transformCodebase({
srcDirPath: extractedDirPath,
destDirPath: emailThemeSrcDirPath
});
{ {
const themePropertyFilePath = pathJoin(emailThemeSrcDirPath, "theme.properties"); let sourceCode = JSON.stringify(parsedPackageJson, undefined, 2);
if (await getIsPrettierAvailable()) {
sourceCode = await runPrettier({
sourceCode,
filePath: buildContext.packageJsonFilePath
});
}
fs.writeFileSync( fs.writeFileSync(
themePropertyFilePath, buildContext.packageJsonFilePath,
Buffer.from( Buffer.from(sourceCode, "utf8")
[
`parent=base`,
fs.readFileSync(themePropertyFilePath).toString("utf8")
].join("\n"),
"utf8"
)
); );
} }
console.log( await npmInstall({
`The \`${pathJoin( packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
".", });
pathRelative(process.cwd(), emailThemeSrcDirPath)
)}\` directory have been created.` console.log(chalk.green("Email theme initialized."));
);
console.log("You can delete any file you don't modify.");
} }

View File

@ -15,7 +15,6 @@ import { readFileSync } from "fs";
import { isInside } from "../../tools/isInside"; import { isInside } from "../../tools/isInside";
import child_process from "child_process"; import child_process from "child_process";
import { rmSync } from "../../tools/fs.rmSync"; import { rmSync } from "../../tools/fs.rmSync";
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
import { existsAsync } from "../../tools/fs.existsAsync"; import { existsAsync } from "../../tools/fs.existsAsync";
export type BuildContextLike = BuildContextLike_generatePom & { export type BuildContextLike = BuildContextLike_generatePom & {
@ -106,29 +105,55 @@ export async function buildJar(params: {
} }
}); });
remove_account_v1_in_meta_inf: { {
if (!doesImplementAccountV1Theme) { const filePath = pathJoin(
// NOTE: We do not have account v1 anyway tmpResourcesDirPath,
break remove_account_v1_in_meta_inf; "META-INF",
} "keycloak-themes.json"
);
if (keycloakAccountV1Version !== null) { await fs.mkdir(pathDirname(filePath));
// NOTE: No, we need to keep account-v1 in meta-inf
break remove_account_v1_in_meta_inf;
}
writeMetaInfKeycloakThemes({ await fs.writeFile(
resourcesDirPath: tmpResourcesDirPath, filePath,
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => { Buffer.from(
assert(metaInfKeycloakTheme !== undefined); JSON.stringify(
{
themes: await (async () => {
const dirPath = pathJoin(tmpResourcesDirPath, "theme");
metaInfKeycloakTheme.themes = metaInfKeycloakTheme.themes.filter( const themeNames = (await fs.readdir(dirPath)).sort(
({ name }) => name !== "account-v1" (a, b) => {
); const indexA = buildContext.themeNames.indexOf(a);
const indexB = buildContext.themeNames.indexOf(b);
return metaInfKeycloakTheme; const orderA = indexA === -1 ? Infinity : indexA;
} const orderB = indexB === -1 ? Infinity : indexB;
});
return orderA - orderB;
}
);
return Promise.all(
themeNames.map(async themeName => {
const types = await fs.readdir(
pathJoin(dirPath, themeName)
);
return {
name: themeName,
types
};
})
);
})()
},
null,
2
),
"utf8"
)
);
} }
route_legacy_pages: { route_legacy_pages: {
@ -195,31 +220,39 @@ export async function buildJar(params: {
); );
} }
await new Promise<void>((resolve, reject) => {
child_process.exec( const mvnBuildCmd = `mvn clean install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`;
`mvn clean install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`,
{ cwd: keycloakifyBuildCacheDirPath },
error => {
if (error !== null) {
console.error(
`Build jar failed: ${JSON.stringify(
{
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
},
null,
2
)}`
);
reject(error); await new Promise<void>((resolve, reject) =>
return; child_process.exec(
mvnBuildCmd,
{ cwd: keycloakifyBuildCacheDirPath },
error => {
if (error !== null) {
console.error(
[
`Build jar failed: ${JSON.stringify(
{
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
},
null,
2
)}`,
"Try running the following command to debug the issue (you are probably under a restricted network and you need to configure your proxy):",
`cd ${keycloakifyBuildCacheDirPath} && ${mvnBuildCmd}`
].join("\n")
);
reject(error);
return;
}
resolve();
} }
resolve(); )
} );
) }
);
await fs.rename( await fs.rename(
pathJoin( pathJoin(

View File

@ -190,7 +190,7 @@ function decodeHtmlEntities(htmlStr){
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 --> <#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
key == "loginAction" && key == "loginAction" &&
areSamePath(path, ["url"]) && areSamePath(path, ["url"]) &&
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(xKeycloakify.pageId) && ["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl", "frontchannel-logout.ftl"]?seq_contains(xKeycloakify.pageId) &&
!(auth?has_content && auth.showTryAnotherWayLink()) !(auth?has_content && auth.showTryAnotherWayLink())
) || ( ) || (
<#-- https://github.com/keycloakify/keycloakify/issues/362 --> <#-- https://github.com/keycloakify/keycloakify/issues/362 -->

View File

@ -6,8 +6,7 @@ import {
join as pathJoin, join as pathJoin,
relative as pathRelative, relative as pathRelative,
dirname as pathDirname, dirname as pathDirname,
extname as pathExtname, basename as pathBasename
sep as pathSep
} from "path"; } from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode"; import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode"; import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
@ -31,15 +30,13 @@ import {
type BuildContextLike as BuildContextLike_generateMessageProperties type BuildContextLike as BuildContextLike_generateMessageProperties
} from "./generateMessageProperties"; } from "./generateMessageProperties";
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion"; import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
import {
writeMetaInfKeycloakThemes,
type MetaInfKeycloakTheme
} from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile"; import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import * as child_process from "child_process";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath"; import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
import propertiesParser from "properties-parser"; import propertiesParser from "properties-parser";
import { createObjectThatThrowsIfAccessed } from "../../tools/createObjectThatThrowsIfAccessed";
import { listInstalledModules } from "../../tools/listInstalledModules";
import { isInside } from "../../tools/isInside";
import { id } from "tsafe/id";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode & export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_generateMessageProperties & { BuildContextLike_generateMessageProperties & {
@ -60,6 +57,8 @@ export async function generateResources(params: {
buildContext: BuildContextLike; buildContext: BuildContextLike;
resourcesDirPath: string; resourcesDirPath: string;
}): Promise<void> { }): Promise<void> {
const start = Date.now();
const { resourcesDirPath, buildContext } = params; const { resourcesDirPath, buildContext } = params;
const [themeName] = buildContext.themeNames; const [themeName] = buildContext.themeNames;
@ -77,12 +76,23 @@ export async function generateResources(params: {
}; };
const writeMessagePropertiesFilesByThemeType: Partial< const writeMessagePropertiesFilesByThemeType: Partial<
Record<ThemeType, (params: { messageDirPath: string; themeName: string }) => void> Record<
ThemeType | "email",
(params: { messageDirPath: string; themeName: string }) => void
>
> = {}; > = {};
for (const themeType of THEME_TYPES) { for (const themeType of [...THEME_TYPES, "email"] as const) {
if (!buildContext.implementedThemeTypes[themeType].isImplemented) { let isNative: boolean;
continue;
{
const v = buildContext.implementedThemeTypes[themeType];
if (!v.isImplemented && !v.isImplemented_native) {
continue;
}
isNative = !v.isImplemented && v.isImplemented_native;
} }
const getAccountThemeType = () => { const getAccountThemeType = () => {
@ -101,12 +111,18 @@ export async function generateResources(params: {
return getAccountThemeType() === "Single-Page"; return getAccountThemeType() === "Single-Page";
case "admin": case "admin":
return true; return true;
case "email":
return false;
} }
})(); })();
const themeTypeDirPath = getThemeTypeDirPath({ themeName, themeType }); const themeTypeDirPath = getThemeTypeDirPath({ themeName, themeType });
apply_replacers_and_move_to_theme_resources: { apply_replacers_and_move_to_theme_resources: {
if (isNative) {
break apply_replacers_and_move_to_theme_resources;
}
const destDirPath = pathJoin( const destDirPath = pathJoin(
themeTypeDirPath, themeTypeDirPath,
"resources", "resources",
@ -190,59 +206,93 @@ export async function generateResources(params: {
}); });
} }
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({ generate_ftl_files: {
themeName, if (isNative) {
indexHtmlCode: fs break generate_ftl_files;
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html")) }
.toString("utf8"),
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: isSpa
? []
: (assert(themeType !== "admin"),
readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}))
});
[ assert(themeType !== "email");
...(() => {
switch (themeType) { const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
case "login": themeName,
return LOGIN_THEME_PAGE_IDS; indexHtmlCode: fs
case "account": .readFileSync(
return getAccountThemeType() === "Single-Page" pathJoin(buildContext.projectBuildDirPath, "index.html")
? ["index.ftl"] )
: ACCOUNT_THEME_PAGE_IDS; .toString("utf8"),
case "admin": buildContext,
return ["index.ftl"]; keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: isSpa
? []
: (assert(themeType !== "admin"),
readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}))
});
[
...(() => {
switch (themeType) {
case "login":
return LOGIN_THEME_PAGE_IDS;
case "account":
return getAccountThemeType() === "Single-Page"
? ["index.ftl"]
: ACCOUNT_THEME_PAGE_IDS;
case "admin":
return ["index.ftl"];
}
})(),
...(isSpa
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
);
});
}
copy_native_theme: {
if (!isNative) {
break copy_native_theme;
}
const dirPath = pathJoin(buildContext.themeSrcDirPath, themeType);
transformCodebase({
srcDirPath: dirPath,
destDirPath: getThemeTypeDirPath({ themeName, themeType }),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (isInside({ dirPath: "messages", filePath: fileRelativePath })) {
return undefined;
}
return { modifiedSourceCode: sourceCode };
} }
})(), });
...(isSpa }
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
);
});
let languageTags: string[] | undefined = undefined; let languageTags: string[] | undefined = undefined;
i18n_messages_generation: { i18n_multi_page: {
if (isSpa) { if (isNative) {
break i18n_messages_generation; break i18n_multi_page;
} }
assert(themeType !== "admin"); if (isSpa) {
break i18n_multi_page;
}
assert(themeType !== "admin" && themeType !== "email");
const wrap = generateMessageProperties({ const wrap = generateMessageProperties({
buildContext, buildContext,
@ -256,21 +306,43 @@ export async function generateResources(params: {
writeMessagePropertiesFiles; writeMessagePropertiesFiles;
} }
bring_in_spas_messages: { let isLegacyAccountSpa = false;
// NOTE: Eventually remove this block.
i18n_single_page_account_legacy: {
if (!isSpa) { if (!isSpa) {
break bring_in_spas_messages; break i18n_single_page_account_legacy;
} }
assert(themeType !== "login"); if (themeType !== "account") {
break i18n_single_page_account_legacy;
}
const accountUiDirPath = child_process const [moduleMeta] = await listInstalledModules({
.execSync(`npm list @keycloakify/keycloak-${themeType}-ui --parseable`, { packageJsonFilePath: buildContext.packageJsonFilePath,
cwd: pathDirname(buildContext.packageJsonFilePath) filter: ({ moduleName }) =>
}) moduleName === "@keycloakify/keycloak-account-ui"
.toString("utf8") });
.trim();
const messageDirPath_defaults = pathJoin(accountUiDirPath, "messages"); assert(
moduleMeta !== undefined,
`@keycloakify/keycloak-account-ui is supposed to be installed`
);
{
const [majorStr] = moduleMeta.version.split(".");
if (majorStr.length === 6) {
// NOTE: Now we use the format MMmmpp (Major, minor, patch) for example for
// 26.0.7 it would be 260007.
break i18n_single_page_account_legacy;
} else {
// 25.0.4-rc.5 or later
isLegacyAccountSpa = true;
}
}
const messageDirPath_defaults = pathJoin(moduleMeta.dirPath, "messages");
if (!fs.existsSync(messageDirPath_defaults)) { if (!fs.existsSync(messageDirPath_defaults)) {
throw new Error( throw new Error(
@ -278,8 +350,10 @@ export async function generateResources(params: {
); );
} }
isLegacyAccountSpa = true;
const messagesDirPath_dest = pathJoin( const messagesDirPath_dest = pathJoin(
getThemeTypeDirPath({ themeName, themeType }), getThemeTypeDirPath({ themeName, themeType: "account" }),
"messages" "messages"
); );
@ -291,7 +365,7 @@ export async function generateResources(params: {
apply_theme_changes: { apply_theme_changes: {
const messagesDirPath_theme = pathJoin( const messagesDirPath_theme = pathJoin(
buildContext.themeSrcDirPath, buildContext.themeSrcDirPath,
themeType, "account",
"messages" "messages"
); );
@ -339,7 +413,167 @@ export async function generateResources(params: {
); );
} }
i18n_for_spas_and_native: {
if (!isSpa && !isNative) {
break i18n_for_spas_and_native;
}
if (isLegacyAccountSpa) {
break i18n_for_spas_and_native;
}
const messagesDirPath_theme = pathJoin(
buildContext.themeSrcDirPath,
themeType,
isNative ? "messages" : "i18n"
);
if (!fs.existsSync(messagesDirPath_theme)) {
break i18n_for_spas_and_native;
}
const propertiesByLang: Record<
string,
{
base: Buffer;
override: Buffer | undefined;
overrideByThemeName: Record<string, Buffer>;
}
> = {};
fs.readdirSync(messagesDirPath_theme).forEach(basename => {
type ParsedBasename = { lang: string } & (
| {
isOverride: false;
}
| {
isOverride: true;
themeName: string | undefined;
}
);
const parsedBasename = ((): ParsedBasename | undefined => {
const match = basename.match(/^messages_([^.]+)\.properties$/);
if (match === null) {
return undefined;
}
const discriminator = match[1];
const split = discriminator.split("_override");
if (split.length === 1) {
return {
lang: discriminator,
isOverride: false
};
}
assert(split.length === 2);
if (split[1] === "") {
return {
lang: split[0],
isOverride: true,
themeName: undefined
};
}
const match2 = split[1].match(/^_(.+)$/);
assert(match2 !== null);
return {
lang: split[0],
isOverride: true,
themeName: match2[1]
};
})();
if (parsedBasename === undefined) {
return;
}
propertiesByLang[parsedBasename.lang] ??= {
base: createObjectThatThrowsIfAccessed<Buffer>({
debugMessage: `No base ${parsedBasename.lang} translation for ${themeType} theme`
}),
override: undefined,
overrideByThemeName: {}
};
const buffer = fs.readFileSync(pathJoin(messagesDirPath_theme, basename));
if (parsedBasename.isOverride === false) {
propertiesByLang[parsedBasename.lang].base = buffer;
return;
}
if (parsedBasename.themeName === undefined) {
propertiesByLang[parsedBasename.lang].override = buffer;
return;
}
propertiesByLang[parsedBasename.lang].overrideByThemeName[
parsedBasename.themeName
] = buffer;
});
languageTags = Object.keys(propertiesByLang);
writeMessagePropertiesFilesByThemeType[themeType] = ({
messageDirPath,
themeName
}) => {
if (!fs.existsSync(messageDirPath)) {
fs.mkdirSync(messageDirPath, { recursive: true });
}
Object.entries(propertiesByLang).forEach(
([lang, { base, override, overrideByThemeName }]) => {
const messages = propertiesParser.parse(base.toString("utf8"));
if (override !== undefined) {
const overrideMessages = propertiesParser.parse(
override.toString("utf8")
);
Object.entries(overrideMessages).forEach(
([key, value]) => (messages[key] = value)
);
}
if (themeName in overrideByThemeName) {
const overrideMessages = propertiesParser.parse(
overrideByThemeName[themeName].toString("utf8")
);
Object.entries(overrideMessages).forEach(
([key, value]) => (messages[key] = value)
);
}
const editor = propertiesParser.createEditor();
Object.entries(messages).forEach(([key, value]) => {
editor.set(key, value);
});
fs.writeFileSync(
pathJoin(messageDirPath, `messages_${lang}.properties`),
Buffer.from(editor.toString(), "utf8")
);
}
);
};
}
keycloak_static_resources: { keycloak_static_resources: {
if (isNative) {
break keycloak_static_resources;
}
if (isSpa) { if (isSpa) {
break keycloak_static_resources; break keycloak_static_resources;
} }
@ -356,183 +590,167 @@ export async function generateResources(params: {
}); });
} }
fs.writeFileSync( bring_in_account_v1: {
pathJoin(themeTypeDirPath, "theme.properties"), if (isNative) {
Buffer.from( break bring_in_account_v1;
[
`parent=${(() => {
switch (themeType) {
case "account":
switch (getAccountThemeType()) {
case "Multi-Page":
return "account-v1";
case "Single-Page":
return "base";
}
case "login":
return "keycloak";
case "admin":
return "base";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(themeType === "account" && getAccountThemeType() === "Single-Page"
? ["deprecatedMode=false"]
: []),
...(buildContext.extraThemeProperties ?? []),
...[
...buildContext.environmentVariables,
{ name: KEYCLOAKIFY_SPA_DEV_SERVER_PORT, default: "" }
].map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
),
...(languageTags === undefined
? []
: [`locales=${languageTags.join(",")}`])
].join("\n\n"),
"utf8"
)
);
}
email: {
if (!buildContext.implementedThemeTypes.email.isImplemented) {
break email;
}
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
transformCodebase({
srcDirPath: emailThemeSrcDirPath,
destDirPath: getThemeTypeDirPath({ themeName, themeType: "email" })
});
}
bring_in_account_v1: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v1;
}
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
break bring_in_account_v1;
}
transformCodebase({
srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
destDirPath: getThemeTypeDirPath({
themeName: "account-v1",
themeType: "account"
})
});
}
{
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
for (const themeName of buildContext.themeNames) {
metaInfKeycloakThemes.themes.push({
name: themeName,
types: objectEntries(buildContext.implementedThemeTypes)
.filter(([, { isImplemented }]) => isImplemented)
.map(([themeType]) => themeType)
});
}
if (buildContext.implementedThemeTypes.account.isImplemented) {
metaInfKeycloakThemes.themes.push({
name: "account-v1",
types: ["account"]
});
}
writeMetaInfKeycloakThemes({
resourcesDirPath,
getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes
});
}
for (const themeVariantName of buildContext.themeNames) {
if (themeVariantName === themeName) {
continue;
}
transformCodebase({
srcDirPath: pathJoin(resourcesDirPath, "theme", themeName),
destDirPath: pathJoin(resourcesDirPath, "theme", themeVariantName),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (
pathExtname(fileRelativePath) === ".ftl" &&
fileRelativePath.split(pathSep).length === 2
) {
const modifiedSourceCode = Buffer.from(
Buffer.from(sourceCode)
.toString("utf-8")
.replace(
`"themeName": "${themeName}"`,
`"themeName": "${themeVariantName}"`
),
"utf8"
);
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
return { modifiedSourceCode };
}
return { modifiedSourceCode: sourceCode };
} }
});
}
for (const themeName of buildContext.themeNames) { if (themeType !== "account") {
for (const [themeType, writeMessagePropertiesFiles] of objectEntries( break bring_in_account_v1;
writeMessagePropertiesFilesByThemeType
)) {
// NOTE: This is just a quirk of the type system: We can't really differentiate in a record
// between the case where the key isn't present and the case where the value is `undefined`.
if (writeMessagePropertiesFiles === undefined) {
return;
} }
writeMessagePropertiesFiles({
messageDirPath: pathJoin(
getThemeTypeDirPath({ themeName, themeType }),
"messages"
),
themeName
});
}
}
modify_email_theme_per_variant: { assert(buildContext.implementedThemeTypes.account.isImplemented);
if (!buildContext.implementedThemeTypes.email.isImplemented) {
break modify_email_theme_per_variant;
}
for (const themeName of buildContext.themeNames) { if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
const emailThemeDirPath = getThemeTypeDirPath({ break bring_in_account_v1;
themeName, }
themeType: "email"
});
transformCodebase({ transformCodebase({
srcDirPath: emailThemeDirPath, srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
destDirPath: emailThemeDirPath, destDirPath: getThemeTypeDirPath({
transformSourceCode: ({ filePath, sourceCode }) => { themeName: "account-v1",
if (!filePath.endsWith(".ftl")) { themeType: "account"
return { modifiedSourceCode: sourceCode }; })
}
return {
modifiedSourceCode: Buffer.from(
sourceCode
.toString("utf8")
.replace(/xKeycloakify\.themeName/g, `"${themeName}"`),
"utf8"
)
};
}
}); });
} }
generate_theme_properties: {
if (isNative) {
break generate_theme_properties;
}
assert(themeType !== "email");
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
switch (getAccountThemeType()) {
case "Multi-Page":
return "account-v1";
case "Single-Page":
return "base";
}
case "login":
return "keycloak";
case "admin":
return "base";
}
assert<Equals<typeof themeType, never>>;
})()}`,
...(themeType === "account" &&
getAccountThemeType() === "Single-Page"
? ["deprecatedMode=false"]
: []),
...(buildContext.extraThemeProperties ?? []),
...[
...buildContext.environmentVariables,
{ name: KEYCLOAKIFY_SPA_DEV_SERVER_PORT, default: "" }
].map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
),
...(languageTags === undefined
? []
: [`locales=${languageTags.join(",")}`])
].join("\n\n"),
"utf8"
)
);
}
} }
for (const themeVariantName of [...buildContext.themeNames].reverse()) {
for (const themeType of [...THEME_TYPES, "email"] as const) {
copy_main_theme_to_theme_variant_theme: {
let isNative: boolean;
{
const v = buildContext.implementedThemeTypes[themeType];
if (!v.isImplemented && !v.isImplemented_native) {
break copy_main_theme_to_theme_variant_theme;
}
isNative = !v.isImplemented && v.isImplemented_native;
}
if (!isNative && themeVariantName === themeName) {
break copy_main_theme_to_theme_variant_theme;
}
transformCodebase({
srcDirPath: getThemeTypeDirPath({ themeName, themeType }),
destDirPath: getThemeTypeDirPath({
themeName: themeVariantName,
themeType
}),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
patch_xKeycloakify_themeName: {
if (!fileRelativePath.endsWith(".ftl")) {
break patch_xKeycloakify_themeName;
}
if (
!isNative &&
pathBasename(fileRelativePath) !== fileRelativePath
) {
break patch_xKeycloakify_themeName;
}
const modifiedSourceCode = Buffer.from(
Buffer.from(sourceCode)
.toString("utf-8")
.replace(
...id<[string | RegExp, string]>(
isNative
? [
/xKeycloakify\.themeName/g,
`"${themeVariantName}"`
]
: [
`"themeName": "${themeName}"`,
`"themeName": "${themeVariantName}"`
]
)
),
"utf8"
);
if (!isNative) {
assert(
Buffer.compare(modifiedSourceCode, sourceCode) !== 0
);
}
return { modifiedSourceCode };
}
return { modifiedSourceCode: sourceCode };
}
});
}
run_writeMessagePropertiesFiles: {
const writeMessagePropertiesFiles =
writeMessagePropertiesFilesByThemeType[themeType];
if (writeMessagePropertiesFiles === undefined) {
break run_writeMessagePropertiesFiles;
}
writeMessagePropertiesFiles({
messageDirPath: pathJoin(
getThemeTypeDirPath({ themeName: themeVariantName, themeType }),
"messages"
),
themeName: themeVariantName
});
}
}
}
console.log(`Generated resources in ${Date.now() - start}ms`);
} }

View File

@ -5,9 +5,6 @@ import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
import * as child_process from "child_process"; import * as child_process from "child_process";
import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx"; import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx";
import { getBuildContext } from "./shared/buildContext"; import { getBuildContext } from "./shared/buildContext";
import { SemVer } from "./tools/SemVer";
import { assert, is } from "tsafe/assert";
import chalk from "chalk";
type CliCommandOptions = { type CliCommandOptions = {
projectDirPath: string | undefined; projectDirPath: string | undefined;
@ -137,47 +134,11 @@ program
handler: async ({ projectDirPath, keycloakVersion, port, realmJsonFilePath }) => { handler: async ({ projectDirPath, keycloakVersion, port, realmJsonFilePath }) => {
const { command } = await import("./start-keycloak"); const { command } = await import("./start-keycloak");
validate_keycloak_version: {
if (keycloakVersion === undefined) {
break validate_keycloak_version;
}
const isValidVersion = (() => {
if (typeof keycloakVersion === "number") {
return false;
}
try {
SemVer.parse(keycloakVersion);
} catch {
return false;
}
return;
})();
if (isValidVersion) {
break validate_keycloak_version;
}
console.log(
chalk.red(
[
`Invalid Keycloak version: ${keycloakVersion}`,
"It should be a valid semver version example: 26.0.4"
].join(" ")
)
);
process.exit(1);
}
assert(is<string | undefined>(keycloakVersion));
await command({ await command({
buildContext: getBuildContext({ projectDirPath }), buildContext: getBuildContext({ projectDirPath }),
cliCommandOptions: { cliCommandOptions: {
keycloakVersion, keycloakVersion:
keycloakVersion === undefined ? undefined : `${keycloakVersion}`,
port, port,
realmJsonFilePath realmJsonFilePath
} }
@ -230,7 +191,7 @@ program
program program
.command({ .command({
name: "initialize-account-theme", name: "initialize-account-theme",
description: "Initialize the account theme." description: "Initialize an Account Single-Page or Multi-Page custom Account UI."
}) })
.task({ .task({
skip, skip,
@ -241,6 +202,20 @@ program
} }
}); });
program
.command({
name: "initialize-admin-theme",
description: "Initialize an Admin Console custom UI."
})
.task({
skip,
handler: async ({ projectDirPath }) => {
const { command } = await import("./initialize-admin-theme");
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
program program
.command({ .command({
name: "copy-keycloak-resources-to-public", name: "copy-keycloak-resources-to-public",
@ -273,13 +248,30 @@ program
program program
.command({ .command({
name: "postinstall", name: "sync-extensions",
description: "Initialize all the Keycloakify UI modules installed in the project." description: [
"Synchronizes all installed Keycloakify extension modules with your project.",
"",
"Example of extension modules: '@keycloakify/keycloak-account-ui', '@keycloakify/keycloak-admin-ui', '@keycloakify/keycloak-ui-shared'",
"",
"This command ensures that:",
"- All required files from installed extensions are copied into your project.",
"- The copied files are correctly ignored by Git to help you distinguish between your custom source files",
" and those provided by the extensions.",
"- Peer dependencies declared by the extensions are automatically added to your package.json.",
"",
"You can safely run this command multiple times. It will only update the files and dependencies if needed,",
"ensuring your project stays in sync with the installed extensions.",
"",
"Typical usage:",
"- Should be run as a postinstall script of your project.",
""
].join("\n")
}) })
.task({ .task({
skip, skip,
handler: async ({ projectDirPath }) => { handler: async ({ projectDirPath }) => {
const { command } = await import("./postinstall"); const { command } = await import("./sync-extensions");
await command({ buildContext: getBuildContext({ projectDirPath }) }); await command({ buildContext: getBuildContext({ projectDirPath }) });
} }
@ -287,36 +279,66 @@ program
program program
.command<{ .command<{
file: string; path: string;
revert: boolean;
}>({ }>({
name: "eject-file", name: "own",
description: [ description: [
"WARNING: Not usable yet, will be used for future features", "Manages ownership of auto-generated files provided by Keycloakify extensions.",
"Take ownership over a given file" "",
"This command allows you to take ownership of a specific file or directory generated",
"by an extension. Once owned, you can freely modify and version-control the file.",
"",
"You can also use the --revert flag to relinquish ownership and restore the file",
"or directory to its original auto-generated state.",
"",
"For convenience, the exact command to take ownership of any file is included as a comment",
"in the header of each extension-generated file.",
"",
"Examples:",
"$ npx keycloakify own --path admin/KcPage.tsx"
].join("\n")
})
.option({
key: "path",
name: (() => {
const long = "path";
const short = "t";
optionsKeys.push(long, short);
return { long, short };
})(),
description: [
"Specifies the relative path of the file or directory to take ownership of.",
"This path should be relative to your theme directory.",
"Example: `--path 'admin/KcPage.tsx'`"
].join(" ") ].join(" ")
}) })
.option({ .option({
key: "file", key: "revert",
name: (() => { name: (() => {
const name = "file"; const long = "revert";
const short = "r";
optionsKeys.push(name); optionsKeys.push(long, short);
return name; return { long, short };
})(), })(),
description: [ description: [
"Relative path of the file relative to the directory of your keycloak theme source", "Restores a file or directory to its original auto-generated state,",
"Example `--file src/login/page/Login.tsx`" "removing your ownership claim and reverting any modifications."
].join(" ") ].join(" "),
defaultValue: false
}) })
.task({ .task({
skip, skip,
handler: async ({ projectDirPath, file }) => { handler: async ({ projectDirPath, path, revert }) => {
const { command } = await import("./eject-file"); const { command } = await import("./own");
await command({ await command({
buildContext: getBuildContext({ projectDirPath }), buildContext: getBuildContext({ projectDirPath }),
cliCommandOptions: { file } cliCommandOptions: { path, isRevert: revert }
}); });
} }
}); });

208
src/bin/own.ts Normal file
View File

@ -0,0 +1,208 @@
import type { BuildContext } from "./shared/buildContext";
import { getExtensionModuleFileSourceCodeReadyToBeCopied } from "./sync-extensions/getExtensionModuleFileSourceCodeReadyToBeCopied";
import type { ExtensionModuleMeta } from "./sync-extensions/extensionModuleMeta";
import { command as command_syncExtensions } from "./sync-extensions/sync-extension";
import {
readManagedGitignoreFile,
writeManagedGitignoreFile
} from "./sync-extensions/managedGitignoreFile";
import { getExtensionModuleMetas } from "./sync-extensions/extensionModuleMeta";
import { getAbsoluteAndInOsFormatPath } from "./tools/getAbsoluteAndInOsFormatPath";
import { relative as pathRelative, dirname as pathDirname, join as pathJoin } from "path";
import { getInstalledModuleDirPath } from "./tools/getInstalledModuleDirPath";
import * as fsPr from "fs/promises";
import { isInside } from "./tools/isInside";
import chalk from "chalk";
export async function command(params: {
buildContext: BuildContext;
cliCommandOptions: {
path: string;
isRevert: boolean;
};
}) {
const { buildContext, cliCommandOptions } = params;
const extensionModuleMetas = await getExtensionModuleMetas({ buildContext });
const { targetFileRelativePathsByExtensionModuleMeta } = await (async () => {
const fileOrDirectoryRelativePath = pathRelative(
buildContext.themeSrcDirPath,
getAbsoluteAndInOsFormatPath({
cwd: buildContext.themeSrcDirPath,
pathIsh: cliCommandOptions.path
})
);
const arr = extensionModuleMetas
.map(extensionModuleMeta => ({
extensionModuleMeta,
fileRelativePaths: extensionModuleMeta.files
.map(({ fileRelativePath }) => fileRelativePath)
.filter(
fileRelativePath =>
fileRelativePath === fileOrDirectoryRelativePath ||
isInside({
dirPath: fileOrDirectoryRelativePath,
filePath: fileRelativePath
})
)
}))
.filter(({ fileRelativePaths }) => fileRelativePaths.length !== 0);
const targetFileRelativePathsByExtensionModuleMeta = new Map<
ExtensionModuleMeta,
string[]
>();
for (const { extensionModuleMeta, fileRelativePaths } of arr) {
targetFileRelativePathsByExtensionModuleMeta.set(
extensionModuleMeta,
fileRelativePaths
);
}
return { targetFileRelativePathsByExtensionModuleMeta };
})();
if (targetFileRelativePathsByExtensionModuleMeta.size === 0) {
console.log(
chalk.yellow(
"There is no Keycloakify extension modules files matching the provided path."
)
);
process.exit(1);
}
const { ownedFilesRelativePaths: ownedFilesRelativePaths_current } =
await readManagedGitignoreFile({
buildContext
});
await (cliCommandOptions.isRevert ? command_revert : command_own)({
extensionModuleMetas,
targetFileRelativePathsByExtensionModuleMeta,
ownedFilesRelativePaths_current,
buildContext
});
}
type Params_subcommands = {
extensionModuleMetas: ExtensionModuleMeta[];
targetFileRelativePathsByExtensionModuleMeta: Map<ExtensionModuleMeta, string[]>;
ownedFilesRelativePaths_current: string[];
buildContext: BuildContext;
};
async function command_own(params: Params_subcommands) {
const {
extensionModuleMetas,
targetFileRelativePathsByExtensionModuleMeta,
ownedFilesRelativePaths_current,
buildContext
} = params;
await writeManagedGitignoreFile({
buildContext,
extensionModuleMetas,
ownedFilesRelativePaths: [
...ownedFilesRelativePaths_current,
...Array.from(targetFileRelativePathsByExtensionModuleMeta.values())
.flat()
.filter(
fileRelativePath =>
!ownedFilesRelativePaths_current.includes(fileRelativePath)
)
]
});
const writeActions: (() => Promise<void>)[] = [];
for (const [
extensionModuleMeta,
fileRelativePaths
] of targetFileRelativePathsByExtensionModuleMeta.entries()) {
const extensionModuleDirPath = await getInstalledModuleDirPath({
moduleName: extensionModuleMeta.moduleName,
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
});
for (const fileRelativePath of fileRelativePaths) {
if (ownedFilesRelativePaths_current.includes(fileRelativePath)) {
console.log(
chalk.grey(`You already have ownership over '${fileRelativePath}'.`)
);
continue;
}
writeActions.push(async () => {
const sourceCode = await getExtensionModuleFileSourceCodeReadyToBeCopied({
buildContext,
fileRelativePath,
isOwnershipAction: true,
extensionModuleName: extensionModuleMeta.moduleName,
extensionModuleDirPath,
extensionModuleVersion: extensionModuleMeta.version
});
await fsPr.writeFile(
pathJoin(buildContext.themeSrcDirPath, fileRelativePath),
sourceCode
);
console.log(chalk.green(`Ownership over '${fileRelativePath}' claimed.`));
});
}
}
if (writeActions.length === 0) {
console.log(chalk.yellow("No new file claimed."));
return;
}
await Promise.all(writeActions.map(action => action()));
}
async function command_revert(params: Params_subcommands) {
const {
extensionModuleMetas,
targetFileRelativePathsByExtensionModuleMeta,
ownedFilesRelativePaths_current,
buildContext
} = params;
const ownedFilesRelativePaths_toRemove = Array.from(
targetFileRelativePathsByExtensionModuleMeta.values()
)
.flat()
.filter(fileRelativePath => {
if (!ownedFilesRelativePaths_current.includes(fileRelativePath)) {
console.log(
chalk.grey(`Ownership over '${fileRelativePath}' wasn't claimed.`)
);
return false;
}
console.log(
chalk.green(`Ownership over '${fileRelativePath}' relinquished.`)
);
return true;
});
if (ownedFilesRelativePaths_toRemove.length === 0) {
console.log(chalk.yellow("No file relinquished."));
return;
}
await writeManagedGitignoreFile({
buildContext,
extensionModuleMetas,
ownedFilesRelativePaths: ownedFilesRelativePaths_current.filter(
fileRelativePath =>
!ownedFilesRelativePaths_toRemove.includes(fileRelativePath)
)
});
await command_syncExtensions({ buildContext });
}

View File

@ -1,82 +0,0 @@
import { getIsPrettierAvailable, runPrettier } from "../tools/runPrettier";
import * as fsPr from "fs/promises";
import { join as pathJoin, sep as pathSep } from "path";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext";
import { KEYCLOAK_THEME } from "../shared/constants";
export type BuildContextLike = {
themeSrcDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function getUiModuleFileSourceCodeReadyToBeCopied(params: {
buildContext: BuildContextLike;
fileRelativePath: string;
isForEjection: boolean;
uiModuleDirPath: string;
uiModuleName: string;
uiModuleVersion: string;
}): Promise<Buffer> {
const {
buildContext,
uiModuleDirPath,
fileRelativePath,
isForEjection,
uiModuleName,
uiModuleVersion
} = params;
let sourceCode = (
await fsPr.readFile(pathJoin(uiModuleDirPath, KEYCLOAK_THEME, fileRelativePath))
).toString("utf8");
const toComment = (lines: string[]) => {
for (const ext of [".ts", ".tsx", ".css", ".less", ".sass", ".js", ".jsx"]) {
if (!fileRelativePath.endsWith(ext)) {
continue;
}
return [`/**`, ...lines.map(line => ` * ${line}`), ` */`].join("\n");
}
if (fileRelativePath.endsWith(".html")) {
return [`<!--`, ...lines.map(line => ` ${line}`), `-->`].join("\n");
}
return undefined;
};
const comment = toComment(
isForEjection
? [`This file was ejected from ${uiModuleName} version ${uiModuleVersion}.`]
: [
`WARNING: Before modifying this file run the following command:`,
``,
`$ npx keycloakify eject-file --file ${fileRelativePath.split(pathSep).join("/")}`,
``,
`This file comes from ${uiModuleName} version ${uiModuleVersion}.`,
`This file has been copied over to your repo by your postinstall script: \`npx keycloakify postinstall\``
]
);
if (comment !== undefined) {
sourceCode = [comment, ``, sourceCode].join("\n");
}
const destFilePath = pathJoin(buildContext.themeSrcDirPath, fileRelativePath);
format: {
if (!(await getIsPrettierAvailable())) {
break format;
}
sourceCode = await runPrettier({
filePath: destFilePath,
sourceCode
});
}
return Buffer.from(sourceCode, "utf8");
}

View File

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

View File

@ -0,0 +1,70 @@
import { dirname as pathDirname, relative as pathRelative, sep as pathSep } from "path";
import { assert } from "tsafe/assert";
import type { BuildContext } from "./buildContext";
export type BuildContextLike = {
projectDirPath: string;
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function addSyncExtensionsToPostinstallScript(params: {
parsedPackageJson: { scripts?: Record<string, string | undefined> };
buildContext: BuildContextLike;
}) {
const { parsedPackageJson, buildContext } = params;
const cmd_base = "keycloakify sync-extensions";
const projectCliOptionValue = (() => {
const packageJsonDirPath = pathDirname(buildContext.packageJsonFilePath);
const relativePath = pathRelative(
packageJsonDirPath,
buildContext.projectDirPath
);
if (relativePath === "") {
return undefined;
}
return relativePath.split(pathSep).join("/");
})();
const generateCmd = (params: { cmd_preexisting: string | undefined }) => {
const { cmd_preexisting } = params;
let cmd = cmd_preexisting === undefined ? "" : `${cmd_preexisting} && `;
cmd += cmd_base;
if (projectCliOptionValue !== undefined) {
cmd += ` -p ${projectCliOptionValue}`;
}
return cmd;
};
{
const scripts = (parsedPackageJson.scripts ??= {});
for (const scriptName of ["postinstall", "prepare"]) {
const cmd_preexisting = scripts[scriptName];
if (cmd_preexisting === undefined) {
continue;
}
if (!cmd_preexisting.includes(cmd_base)) {
scripts[scriptName] = generateCmd({ cmd_preexisting });
return;
}
}
}
parsedPackageJson.scripts = {
postinstall: generateCmd({ cmd_preexisting: undefined }),
...parsedPackageJson.scripts
};
}

View File

@ -45,12 +45,16 @@ export type BuildContext = {
environmentVariables: { name: string; default: string }[]; environmentVariables: { name: string; default: string }[];
themeSrcDirPath: string; themeSrcDirPath: string;
implementedThemeTypes: { implementedThemeTypes: {
login: { isImplemented: boolean }; login:
email: { isImplemented: boolean }; | { isImplemented: true }
| { isImplemented: false; isImplemented_native: boolean };
email: { isImplemented: false; isImplemented_native: boolean };
account: account:
| { isImplemented: false } | { isImplemented: false; isImplemented_native: boolean }
| { isImplemented: true; type: "Single-Page" | "Multi-Page" }; | { isImplemented: true; type: "Single-Page" | "Multi-Page" };
admin: { isImplemented: boolean }; admin:
| { isImplemented: true }
| { isImplemented: false; isImplemented_native: boolean };
}; };
packageJsonFilePath: string; packageJsonFilePath: string;
bundler: "vite" | "webpack"; bundler: "vite" | "webpack";
@ -434,27 +438,68 @@ export function getBuildContext(params: {
assert<Equals<typeof bundler, never>>(false); assert<Equals<typeof bundler, never>>(false);
})(); })();
const implementedThemeTypes: BuildContext["implementedThemeTypes"] = { const implementedThemeTypes: BuildContext["implementedThemeTypes"] = (() => {
login: { const getIsNative = (dirPath: string) =>
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "login")) fs.existsSync(pathJoin(dirPath, "theme.properties"));
},
email: {
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "email"))
},
account: (() => {
if (buildOptions.accountThemeImplementation === "none") {
return { isImplemented: false };
}
return { return {
isImplemented: true, login: (() => {
type: buildOptions.accountThemeImplementation const dirPath = pathJoin(themeSrcDirPath, "login");
};
})(), if (!fs.existsSync(dirPath)) {
admin: { return { isImplemented: false, isImplemented_native: false };
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "admin")) }
}
}; if (getIsNative(dirPath)) {
return { isImplemented: false, isImplemented_native: true };
}
return { isImplemented: true };
})(),
email: (() => {
const dirPath = pathJoin(themeSrcDirPath, "email");
if (!fs.existsSync(dirPath) || !getIsNative(dirPath)) {
return { isImplemented: false, isImplemented_native: false };
}
return { isImplemented: false, isImplemented_native: true };
})(),
account: (() => {
const dirPath = pathJoin(themeSrcDirPath, "account");
if (!fs.existsSync(dirPath)) {
return { isImplemented: false, isImplemented_native: false };
}
if (getIsNative(dirPath)) {
return { isImplemented: false, isImplemented_native: true };
}
if (buildOptions.accountThemeImplementation === "none") {
return { isImplemented: false, isImplemented_native: false };
}
return {
isImplemented: true,
type: buildOptions.accountThemeImplementation
};
})(),
admin: (() => {
const dirPath = pathJoin(themeSrcDirPath, "admin");
if (!fs.existsSync(dirPath)) {
return { isImplemented: false, isImplemented_native: false };
}
if (getIsNative(dirPath)) {
return { isImplemented: false, isImplemented_native: true };
}
return { isImplemented: true };
})()
};
})();
if ( if (
implementedThemeTypes.account.isImplemented && implementedThemeTypes.account.isImplemented &&

View File

@ -12,6 +12,7 @@ export type CommandName =
| "add-story" | "add-story"
| "initialize-account-theme" | "initialize-account-theme"
| "initialize-admin-theme" | "initialize-admin-theme"
| "initialize-admin-theme"
| "initialize-email-theme" | "initialize-email-theme"
| "copy-keycloak-resources-to-public"; | "copy-keycloak-resources-to-public";

View File

@ -13,13 +13,15 @@ import * as fs from "fs";
assert<Equals<ApiVersion, "v1">>(); assert<Equals<ApiVersion, "v1">>();
export function maybeDelegateCommandToCustomHandler(params: { export async function maybeDelegateCommandToCustomHandler(params: {
commandName: CommandName; commandName: CommandName;
buildContext: BuildContext; buildContext: BuildContext;
}): { hasBeenHandled: boolean } { }): Promise<{ hasBeenHandled: boolean }> {
const { commandName, buildContext } = params; const { commandName, buildContext } = params;
const nodeModulesBinDirPath = getNodeModulesBinDirPath(); const nodeModulesBinDirPath = await getNodeModulesBinDirPath({
packageJsonFilePath: buildContext.packageJsonFilePath
});
if (!fs.readdirSync(nodeModulesBinDirPath).includes(BIN_NAME)) { if (!fs.readdirSync(nodeModulesBinDirPath).includes(BIN_NAME)) {
return { hasBeenHandled: false }; return { hasBeenHandled: false };

View File

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

View File

@ -0,0 +1,156 @@
import { dirname as pathDirname, join as pathJoin, relative as pathRelative } from "path";
import type { BuildContext } from "./buildContext";
import * as fs from "fs";
import { assert, is, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
import {
addSyncExtensionsToPostinstallScript,
type BuildContextLike as BuildContextLike_addSyncExtensionsToPostinstallScript
} from "./addSyncExtensionsToPostinstallScript";
import { getIsPrettierAvailable, runPrettier } from "../tools/runPrettier";
import { npmInstall } from "../tools/npmInstall";
import * as child_process from "child_process";
import { z } from "zod";
import chalk from "chalk";
export type BuildContextLike = BuildContextLike_addSyncExtensionsToPostinstallScript & {
themeSrcDirPath: string;
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function initializeSpa(params: {
themeType: "account" | "admin";
buildContext: BuildContextLike;
}) {
const { themeType, buildContext } = params;
{
const themeTypeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, themeType);
if (
fs.existsSync(themeTypeSrcDirPath) &&
fs.readdirSync(themeTypeSrcDirPath).length > 0
) {
console.warn(
chalk.red(
`There is already a ${pathRelative(
process.cwd(),
themeTypeSrcDirPath
)} directory in your project. Aborting.`
)
);
process.exit(-1);
}
}
const parsedPackageJson = (() => {
type ParsedPackageJson = {
scripts?: Record<string, string | undefined>;
dependencies?: Record<string, string | undefined>;
devDependencies?: Record<string, string | undefined>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
scripts: z.record(z.union([z.string(), z.undefined()])).optional(),
dependencies: z.record(z.union([z.string(), z.undefined()])).optional(),
devDependencies: z.record(z.union([z.string(), z.undefined()])).optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>;
return id<z.ZodType<TargetType>>(zTargetType);
})();
const parsedPackageJson = JSON.parse(
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
})();
addSyncExtensionsToPostinstallScript({
parsedPackageJson,
buildContext
});
const uiSharedMajor = (() => {
const dependencies = {
...parsedPackageJson.devDependencies,
...parsedPackageJson.dependencies
};
const version = dependencies["@keycloakify/keycloak-ui-shared"];
if (version === undefined) {
return undefined;
}
const match = version.match(/^[^~]?(\d+)\./);
if (match === null) {
return undefined;
}
return match[1];
})();
const moduleName = `@keycloakify/keycloak-${themeType}-ui`;
const version = ((): string[] => {
const cmdOutput = child_process
.execSync(`npm show ${moduleName} versions --json`)
.toString("utf8")
.trim();
const versions = JSON.parse(cmdOutput) as string | string[];
// NOTE: Bug in some older npm versions
if (typeof versions === "string") {
return [versions];
}
return versions;
})()
.reverse()
.filter(version => !version.includes("-"))
.find(version =>
uiSharedMajor === undefined ? true : version.startsWith(`${uiSharedMajor}.`)
);
assert(version !== undefined);
(parsedPackageJson.dependencies ??= {})[moduleName] = `~${version}`;
if (parsedPackageJson.devDependencies !== undefined) {
delete parsedPackageJson.devDependencies[moduleName];
}
{
let sourceCode = JSON.stringify(parsedPackageJson, undefined, 2);
if (await getIsPrettierAvailable()) {
sourceCode = await runPrettier({
sourceCode,
filePath: buildContext.packageJsonFilePath
});
}
fs.writeFileSync(
buildContext.packageJsonFilePath,
Buffer.from(sourceCode, "utf8")
);
}
await npmInstall({
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
});
}

View File

@ -1,40 +0,0 @@
import { join as pathJoin, dirname as pathDirname } from "path";
import type { ThemeType } from "./constants";
import * as fs from "fs";
export type MetaInfKeycloakTheme = {
themes: { name: string; types: (ThemeType | "email")[] }[];
};
export function writeMetaInfKeycloakThemes(params: {
resourcesDirPath: string;
getNewMetaInfKeycloakTheme: (params: {
metaInfKeycloakTheme: MetaInfKeycloakTheme | undefined;
}) => MetaInfKeycloakTheme;
}) {
const { resourcesDirPath, getNewMetaInfKeycloakTheme } = params;
const filePath = pathJoin(resourcesDirPath, "META-INF", "keycloak-themes.json");
const currentMetaInfKeycloakTheme = !fs.existsSync(filePath)
? undefined
: (JSON.parse(
fs.readFileSync(filePath).toString("utf8")
) as MetaInfKeycloakTheme);
const newMetaInfKeycloakThemes = getNewMetaInfKeycloakTheme({
metaInfKeycloakTheme: currentMetaInfKeycloakTheme
});
{
const dirPath = pathDirname(filePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
fs.writeFileSync(
filePath,
Buffer.from(JSON.stringify(newMetaInfKeycloakThemes, null, 2), "utf8")
);
}

View File

@ -1,72 +0,0 @@
import {
getLatestsSemVersionedTag,
type BuildContextLike as BuildContextLike_getLatestsSemVersionedTag
} from "./getLatestsSemVersionedTag";
import cliSelect from "cli-select";
import { assert } from "tsafe/assert";
import { SemVer } from "../tools/SemVer";
import type { BuildContext } from "./buildContext";
export type BuildContextLike = BuildContextLike_getLatestsSemVersionedTag & {};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function promptKeycloakVersion(params: {
startingFromMajor: number | undefined;
excludeMajorVersions: number[];
doOmitPatch: boolean;
buildContext: BuildContextLike;
}) {
const { startingFromMajor, excludeMajorVersions, doOmitPatch, buildContext } = params;
const semVersionedTagByMajor = new Map<number, { tag: string; version: SemVer }>();
const semVersionedTags = await getLatestsSemVersionedTag({
count: 50,
owner: "keycloak",
repo: "keycloak",
doIgnoreReleaseCandidates: true,
buildContext
});
semVersionedTags.forEach(semVersionedTag => {
if (
startingFromMajor !== undefined &&
semVersionedTag.version.major < startingFromMajor
) {
return;
}
if (excludeMajorVersions.includes(semVersionedTag.version.major)) {
return;
}
const currentSemVersionedTag = semVersionedTagByMajor.get(
semVersionedTag.version.major
);
if (
currentSemVersionedTag !== undefined &&
SemVer.compare(semVersionedTag.version, currentSemVersionedTag.version) === -1
) {
return;
}
semVersionedTagByMajor.set(semVersionedTag.version.major, semVersionedTag);
});
const lastMajorVersions = Array.from(semVersionedTagByMajor.values()).map(
({ version }) =>
`${version.major}.${version.minor}${doOmitPatch ? "" : `.${version.patch}`}`
);
const { value } = await cliSelect<string>({
values: lastMajorVersions
}).catch(() => {
process.exit(-1);
});
const keycloakVersion = value.split(" ")[0];
return { keycloakVersion };
}

View File

@ -10,6 +10,7 @@ import { join as pathJoin, dirname as pathDirname } from "path";
import * as fs from "fs/promises"; import * as fs from "fs/promises";
import { existsAsync } from "../tools/fs.existsAsync"; import { existsAsync } from "../tools/fs.existsAsync";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion"; import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import type { ReturnType } from "tsafe";
export type BuildContextLike = { export type BuildContextLike = {
fetchOptions: BuildContext["fetchOptions"]; fetchOptions: BuildContext["fetchOptions"];
@ -20,7 +21,10 @@ assert<BuildContext extends BuildContextLike ? true : false>;
export async function getSupportedDockerImageTags(params: { export async function getSupportedDockerImageTags(params: {
buildContext: BuildContextLike; buildContext: BuildContextLike;
}) { }): Promise<{
allSupportedTags: string[];
latestMajorTags: string[];
}> {
const { buildContext } = params; const { buildContext } = params;
{ {
@ -31,14 +35,14 @@ export async function getSupportedDockerImageTags(params: {
} }
} }
const tags: string[] = []; const tags_queryResponse: string[] = [];
await (async function callee(url: string) { await (async function callee(url: string) {
const r = await fetch(url, buildContext.fetchOptions); const r = await fetch(url, buildContext.fetchOptions);
await Promise.all([ await Promise.all([
(async () => { (async () => {
tags.push( tags_queryResponse.push(
...z ...z
.object({ .object({
tags: z.array(z.string()) tags: z.array(z.string())
@ -70,7 +74,9 @@ export async function getSupportedDockerImageTags(params: {
]); ]);
})("https://quay.io/v2/keycloak/keycloak/tags/list"); })("https://quay.io/v2/keycloak/keycloak/tags/list");
const arr = tags const supportedKeycloakMajorVersions = getSupportedKeycloakMajorVersions();
const allSupportedTags_withVersion = tags_queryResponse
.map(tag => ({ .map(tag => ({
tag, tag,
version: (() => { version: (() => {
@ -86,28 +92,35 @@ export async function getSupportedDockerImageTags(params: {
return undefined; return undefined;
} }
if (tag.split(".").length !== 3) {
return undefined;
}
if (!supportedKeycloakMajorVersions.includes(version.major)) {
return undefined;
}
return version; return version;
})() })()
})) }))
.map(({ tag, version }) => (version === undefined ? undefined : { tag, version })) .map(({ tag, version }) => (version === undefined ? undefined : { tag, version }))
.filter(exclude(undefined)); .filter(exclude(undefined))
.sort(({ version: a }, { version: b }) => SemVer.compare(b, a));
const versionByMajor: Record<number, SemVer | undefined> = {}; const latestTagByMajor: Record<number, SemVer | undefined> = {};
for (const { version } of arr) { for (const { version } of allSupportedTags_withVersion) {
const version_current = versionByMajor[version.major]; const version_current = latestTagByMajor[version.major];
if ( if (
version_current === undefined || version_current === undefined ||
SemVer.compare(version_current, version) === -1 SemVer.compare(version_current, version) === -1
) { ) {
versionByMajor[version.major] = version; latestTagByMajor[version.major] = version;
} }
} }
const supportedKeycloakMajorVersions = getSupportedKeycloakMajorVersions(); const latestMajorTags = Object.entries(latestTagByMajor)
const result = Object.entries(versionByMajor)
.sort(([a], [b]) => parseInt(b) - parseInt(a)) .sort(([a], [b]) => parseInt(b) - parseInt(a))
.map(([, version]) => version) .map(([, version]) => version)
.map(version => { .map(version => {
@ -121,16 +134,40 @@ export async function getSupportedDockerImageTags(params: {
}) })
.filter(exclude(undefined)); .filter(exclude(undefined));
const allSupportedTags = allSupportedTags_withVersion.map(({ tag }) => tag);
const result = {
latestMajorTags,
allSupportedTags
};
await setCachedValue({ cacheDirPath: buildContext.cacheDirPath, result }); await setCachedValue({ cacheDirPath: buildContext.cacheDirPath, result });
return result; return result;
} }
const { getCachedValue, setCachedValue } = (() => { const { getCachedValue, setCachedValue } = (() => {
type Result = ReturnType<typeof getSupportedDockerImageTags>;
const zResult = (() => {
type TargetType = Result;
const zTargetType = z.object({
allSupportedTags: z.array(z.string()),
latestMajorTags: z.array(z.string())
});
type InferredType = z.infer<typeof zTargetType>;
assert<Equals<TargetType, InferredType>>;
return id<z.ZodType<TargetType>>(zTargetType);
})();
type Cache = { type Cache = {
keycloakifyVersion: string; keycloakifyVersion: string;
time: number; time: number;
result: string[]; result: Result;
}; };
const zCache = (() => { const zCache = (() => {
@ -139,7 +176,7 @@ const { getCachedValue, setCachedValue } = (() => {
const zTargetType = z.object({ const zTargetType = z.object({
keycloakifyVersion: z.string(), keycloakifyVersion: z.string(),
time: z.number(), time: z.number(),
result: z.array(z.string()) result: zResult
}); });
type InferredType = z.infer<typeof zTargetType>; type InferredType = z.infer<typeof zTargetType>;

View File

@ -1,8 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { assert, type Equals } from "tsafe/assert"; import { assert, type Equals } from "tsafe/assert";
import { is } from "tsafe/is";
import { id } from "tsafe/id"; import { id } from "tsafe/id";
import * as fs from "fs";
export type ParsedRealmJson = { export type ParsedRealmJson = {
realm: string; realm: string;
@ -50,7 +48,7 @@ export type ParsedRealmJson = {
}[]; }[];
}; };
const zParsedRealmJson = (() => { export const zParsedRealmJson = (() => {
type TargetType = ParsedRealmJson; type TargetType = ParsedRealmJson;
const zTargetType = z.object({ const zTargetType = z.object({
@ -118,19 +116,3 @@ const zParsedRealmJson = (() => {
return id<z.ZodType<TargetType>>(zTargetType); return id<z.ZodType<TargetType>>(zTargetType);
})(); })();
export function readRealmJsonFile(params: {
realmJsonFilePath: string;
}): ParsedRealmJson {
const { realmJsonFilePath } = params;
const parsedRealmJson = JSON.parse(
fs.readFileSync(realmJsonFilePath).toString("utf8")
) as unknown;
zParsedRealmJson.parse(parsedRealmJson);
assert(is<ParsedRealmJson>(parsedRealmJson));
return parsedRealmJson;
}

View File

@ -0,0 +1,3 @@
export type { ParsedRealmJson } from "./ParsedRealmJson";
export { readRealmJsonFile } from "./readRealmJsonFile";
export { writeRealmJsonFile } from "./writeRealmJsonFile";

View File

@ -0,0 +1,20 @@
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import * as fs from "fs";
import { type ParsedRealmJson, zParsedRealmJson } from "./ParsedRealmJson";
export function readRealmJsonFile(params: {
realmJsonFilePath: string;
}): ParsedRealmJson {
const { realmJsonFilePath } = params;
const parsedRealmJson = JSON.parse(
fs.readFileSync(realmJsonFilePath).toString("utf8")
) as unknown;
zParsedRealmJson.parse(parsedRealmJson);
assert(is<ParsedRealmJson>(parsedRealmJson));
return parsedRealmJson;
}

View File

@ -0,0 +1,29 @@
import * as fsPr from "fs/promises";
import { getIsPrettierAvailable, runPrettier } from "../../../tools/runPrettier";
import { canonicalStringify } from "../../../tools/canonicalStringify";
import type { ParsedRealmJson } from "./ParsedRealmJson";
import { getDefaultConfig } from "../defaultConfig";
export async function writeRealmJsonFile(params: {
realmJsonFilePath: string;
parsedRealmJson: ParsedRealmJson;
keycloakMajorVersionNumber: number;
}): Promise<void> {
const { realmJsonFilePath, parsedRealmJson, keycloakMajorVersionNumber } = params;
let sourceCode = canonicalStringify({
data: parsedRealmJson,
referenceData: getDefaultConfig({
keycloakMajorVersionNumber
})
});
if (await getIsPrettierAvailable()) {
sourceCode = await runPrettier({
sourceCode: sourceCode,
filePath: realmJsonFilePath
});
}
await fsPr.writeFile(realmJsonFilePath, Buffer.from(sourceCode, "utf8"));
}

View File

@ -3,11 +3,10 @@ import { getThisCodebaseRootDirPath } from "../../../tools/getThisCodebaseRootDi
import * as fs from "fs"; import * as fs from "fs";
import { exclude } from "tsafe/exclude"; import { exclude } from "tsafe/exclude";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import { type ParsedRealmJson, readRealmJsonFile } from "../ParsedRealmJson"; import { readRealmJsonFile } from "../ParsedRealmJson/readRealmJsonFile";
import type { ParsedRealmJson } from "../ParsedRealmJson/ParsedRealmJson";
export function getDefaultRealmJsonFilePath(params: { function getDefaultRealmJsonFilePath(params: { keycloakMajorVersionNumber: number }) {
keycloakMajorVersionNumber: number;
}) {
const { keycloakMajorVersionNumber } = params; const { keycloakMajorVersionNumber } = params;
return pathJoin( return pathJoin(

View File

@ -756,6 +756,24 @@
"fullScopeAllowed": false, "fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0, "nodeReRegistrationTimeout": 0,
"protocolMappers": [ "protocolMappers": [
{
"id": "8fd0d584-7052-4d04-a615-d18a71050873",
"name": "allowed-origins",
"protocol": "openid-connect",
"protocolMapper": "oidc-hardcoded-claim-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"id.token.claim": "false",
"access.token.claim": "true",
"claim.name": "allowed-origins",
"jsonType.label": "JSON",
"access.tokenResponse.claim": "false",
"claim.value": "[\"*\"]",
"introspection.token.claim": "true",
"lightweight.claim": "true"
}
},
{ {
"id": "7779f8fa-c2fe-4e68-be56-66ee97bf8f13", "id": "7779f8fa-c2fe-4e68-be56-66ee97bf8f13",
"name": "locale", "name": "locale",
@ -1336,13 +1354,13 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"saml-user-property-mapper",
"oidc-sha256-pairwise-sub-mapper",
"oidc-address-mapper",
"oidc-full-name-mapper",
"oidc-usermodel-attribute-mapper",
"saml-user-attribute-mapper", "saml-user-attribute-mapper",
"oidc-usermodel-property-mapper", "oidc-usermodel-property-mapper",
"oidc-full-name-mapper",
"saml-user-property-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-address-mapper",
"oidc-sha256-pairwise-sub-mapper",
"saml-role-list-mapper" "saml-role-list-mapper"
] ]
} }
@ -1393,13 +1411,13 @@
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"oidc-full-name-mapper", "oidc-full-name-mapper",
"oidc-usermodel-property-mapper",
"saml-user-property-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-sha256-pairwise-sub-mapper",
"saml-role-list-mapper",
"oidc-address-mapper", "oidc-address-mapper",
"saml-user-attribute-mapper" "saml-role-list-mapper",
"oidc-sha256-pairwise-sub-mapper",
"saml-user-attribute-mapper",
"saml-user-property-mapper",
"oidc-usermodel-property-mapper",
"oidc-usermodel-attribute-mapper"
] ]
} }
}, },
@ -1517,7 +1535,7 @@
"defaultLocale": "en", "defaultLocale": "en",
"authenticationFlows": [ "authenticationFlows": [
{ {
"id": "223ce532-2038-4f24-a606-2a5c73f7bd65", "id": "f664efe4-102d-4ec1-bf11-11af67e3f178",
"alias": "Account verification options", "alias": "Account verification options",
"description": "Method with which to verity the existing account", "description": "Method with which to verity the existing account",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1543,7 +1561,7 @@
] ]
}, },
{ {
"id": "57e47732-79cc-4d60-bee7-4f0b8fd44540", "id": "8a5630c5-eca1-4b6a-8e59-459cb6c84535",
"alias": "Authentication Options", "alias": "Authentication Options",
"description": "Authentication options.", "description": "Authentication options.",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1577,7 +1595,7 @@
] ]
}, },
{ {
"id": "c2735d89-60c0-45a4-9b3c-ae5df17df395", "id": "c1a3eed3-25ce-44ae-93d1-f0b8148a0f8c",
"alias": "Browser - Conditional OTP", "alias": "Browser - Conditional OTP",
"description": "Flow to determine if the OTP is required for the authentication", "description": "Flow to determine if the OTP is required for the authentication",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1603,7 +1621,7 @@
] ]
}, },
{ {
"id": "11a5a507-2b9a-443f-961b-dffd66f4318d", "id": "6eb188ad-1041-44dd-bf8f-37cae0d98bf1",
"alias": "Direct Grant - Conditional OTP", "alias": "Direct Grant - Conditional OTP",
"description": "Flow to determine if the OTP is required for the authentication", "description": "Flow to determine if the OTP is required for the authentication",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1629,7 +1647,7 @@
] ]
}, },
{ {
"id": "963bd753-6ea7-4d93-ab56-30f9ab59d597", "id": "4ee215ac-f4e5-4edb-bf76-65dc9e211543",
"alias": "First broker login - Conditional OTP", "alias": "First broker login - Conditional OTP",
"description": "Flow to determine if the OTP is required for the authentication", "description": "Flow to determine if the OTP is required for the authentication",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1655,7 +1673,7 @@
] ]
}, },
{ {
"id": "1db6a489-a3b4-44c4-b480-1d1e8c123d20", "id": "5a1eac7e-06a0-46d8-b9ae-1f2c934331f9",
"alias": "Handle Existing Account", "alias": "Handle Existing Account",
"description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1681,7 +1699,7 @@
] ]
}, },
{ {
"id": "7a38f32d-4f34-450f-8f03-64802d7cb8f1", "id": "ed165166-4521-4a62-b185-c4b51643cbb1",
"alias": "Reset - Conditional OTP", "alias": "Reset - Conditional OTP",
"description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1707,7 +1725,7 @@
] ]
}, },
{ {
"id": "0df88739-3739-4d70-8893-47c546f19003", "id": "4788fb1f-fd81-4f5d-9abe-4199dd641c1e",
"alias": "User creation or linking", "alias": "User creation or linking",
"description": "Flow for the existing/non-existing user alternatives", "description": "Flow for the existing/non-existing user alternatives",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1734,7 +1752,7 @@
] ]
}, },
{ {
"id": "35025424-e291-4c54-8a29-70aadba549ce", "id": "d778a70f-f472-4dd3-ac40-cb5612ddc171",
"alias": "Verify Existing Account by Re-authentication", "alias": "Verify Existing Account by Re-authentication",
"description": "Reauthentication of existing account", "description": "Reauthentication of existing account",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1760,7 +1778,7 @@
] ]
}, },
{ {
"id": "1813b7f2-c3c2-4b92-8ffc-9ff2d12186c6", "id": "9c1ea8ea-7c23-4e60-b02d-1900d9dc4109",
"alias": "browser", "alias": "browser",
"description": "browser based authentication", "description": "browser based authentication",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1802,7 +1820,7 @@
] ]
}, },
{ {
"id": "954283ac-f1c2-40b6-a39f-bf23ff9f3ce8", "id": "0ebdf418-d57d-4318-9359-7bd0cb2381f2",
"alias": "clients", "alias": "clients",
"description": "Base authentication for clients", "description": "Base authentication for clients",
"providerId": "client-flow", "providerId": "client-flow",
@ -1844,7 +1862,7 @@
] ]
}, },
{ {
"id": "52a789ce-2cad-4f0f-93b2-295b7fd519f0", "id": "5cc89293-c72e-4c5e-b31c-15558588a60d",
"alias": "direct grant", "alias": "direct grant",
"description": "OpenID Connect Resource Owner Grant", "description": "OpenID Connect Resource Owner Grant",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1878,7 +1896,7 @@
] ]
}, },
{ {
"id": "5a6a71e1-9105-45b6-b5f0-52538461357b", "id": "5ae5a321-ccac-449e-9c19-d6dc22ab8085",
"alias": "docker auth", "alias": "docker auth",
"description": "Used by Docker clients to authenticate against the IDP", "description": "Used by Docker clients to authenticate against the IDP",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1896,7 +1914,7 @@
] ]
}, },
{ {
"id": "8392b6e7-bdbf-4d7f-97b6-885761c200db", "id": "7737fdd1-0875-47e6-977b-12561cddfdc3",
"alias": "first broker login", "alias": "first broker login",
"description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1923,7 +1941,7 @@
] ]
}, },
{ {
"id": "52136d70-8d08-42ea-b04b-cf40ea2807aa", "id": "90f975c3-9826-461f-88ca-27c697aff86b",
"alias": "forms", "alias": "forms",
"description": "Username, password, otp and other auth forms.", "description": "Username, password, otp and other auth forms.",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1949,7 +1967,7 @@
] ]
}, },
{ {
"id": "26bbc7e6-ef01-4cdb-9dba-520e2f3f8993", "id": "ce2722d5-9f4f-41a2-8f81-e01f7b6cee57",
"alias": "http challenge", "alias": "http challenge",
"description": "An authentication flow based on challenge-response HTTP Authentication Schemes", "description": "An authentication flow based on challenge-response HTTP Authentication Schemes",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1975,7 +1993,7 @@
] ]
}, },
{ {
"id": "f0887979-04eb-4033-8f19-0ffd8c8b7f6a", "id": "31b5bfa7-98ad-47a2-b8e6-0669022cd8cb",
"alias": "registration", "alias": "registration",
"description": "registration flow", "description": "registration flow",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1994,7 +2012,7 @@
] ]
}, },
{ {
"id": "a3b7b94b-bfbf-4760-a8c9-7d9cd98d262e", "id": "bf8a950b-be3b-4e44-8602-64e0bba492eb",
"alias": "registration form", "alias": "registration form",
"description": "registration form", "description": "registration form",
"providerId": "form-flow", "providerId": "form-flow",
@ -2036,7 +2054,7 @@
] ]
}, },
{ {
"id": "dc68a665-2e51-4a22-aaad-bd693ddc77cc", "id": "e3519800-971b-4b1d-b64e-3983ccd02dea",
"alias": "reset credentials", "alias": "reset credentials",
"description": "Reset credentials for a user if they forgot their password or something", "description": "Reset credentials for a user if they forgot their password or something",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -2078,7 +2096,7 @@
] ]
}, },
{ {
"id": "ae6b73aa-1318-4ae8-a3d9-d01b5e7d957e", "id": "9d5a33a2-e777-4beb-95de-b84812f69c56",
"alias": "saml ecp", "alias": "saml ecp",
"description": "SAML ECP Profile Authentication Flow", "description": "SAML ECP Profile Authentication Flow",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -2098,14 +2116,14 @@
], ],
"authenticatorConfig": [ "authenticatorConfig": [
{ {
"id": "0c18de7f-0714-41f4-9a3f-ed4edd53ae9c", "id": "4901c91d-59bd-4727-b585-8e4e44828d0a",
"alias": "create unique user config", "alias": "create unique user config",
"config": { "config": {
"require.password.update.after.registration": "false" "require.password.update.after.registration": "false"
} }
}, },
{ {
"id": "65b3c8bb-34a4-4d19-b578-245dc8ff53ea", "id": "5062a078-83a7-4933-b0d5-3f75cc2a5003",
"alias": "review profile config", "alias": "review profile config",
"config": { "config": {
"update.profile.on.first.login": "missing" "update.profile.on.first.login": "missing"

View File

@ -764,6 +764,24 @@
"fullScopeAllowed": false, "fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0, "nodeReRegistrationTimeout": 0,
"protocolMappers": [ "protocolMappers": [
{
"id": "8fd0d584-7052-4d04-a615-d18a71050873",
"name": "allowed-origins",
"protocol": "openid-connect",
"protocolMapper": "oidc-hardcoded-claim-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"id.token.claim": "false",
"access.token.claim": "true",
"claim.name": "allowed-origins",
"jsonType.label": "JSON",
"access.tokenResponse.claim": "false",
"claim.value": "[\"*\"]",
"introspection.token.claim": "true",
"lightweight.claim": "true"
}
},
{ {
"id": "7779f8fa-c2fe-4e68-be56-66ee97bf8f13", "id": "7779f8fa-c2fe-4e68-be56-66ee97bf8f13",
"name": "locale", "name": "locale",
@ -1344,14 +1362,14 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"saml-user-property-mapper",
"saml-user-attribute-mapper", "saml-user-attribute-mapper",
"oidc-full-name-mapper", "oidc-full-name-mapper",
"oidc-usermodel-property-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-address-mapper", "oidc-address-mapper",
"saml-user-property-mapper",
"oidc-sha256-pairwise-sub-mapper",
"oidc-usermodel-attribute-mapper",
"saml-role-list-mapper", "saml-role-list-mapper",
"oidc-sha256-pairwise-sub-mapper" "oidc-usermodel-property-mapper"
] ]
} }
}, },
@ -1400,14 +1418,14 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"saml-user-property-mapper",
"oidc-sha256-pairwise-sub-mapper", "oidc-sha256-pairwise-sub-mapper",
"oidc-usermodel-attribute-mapper", "oidc-usermodel-attribute-mapper",
"oidc-usermodel-property-mapper",
"saml-role-list-mapper",
"oidc-full-name-mapper", "oidc-full-name-mapper",
"saml-user-property-mapper", "saml-user-attribute-mapper",
"oidc-usermodel-property-mapper",
"oidc-address-mapper", "oidc-address-mapper",
"saml-user-attribute-mapper" "saml-role-list-mapper"
] ]
} }
}, },
@ -1525,7 +1543,7 @@
"defaultLocale": "en", "defaultLocale": "en",
"authenticationFlows": [ "authenticationFlows": [
{ {
"id": "1f4d4e13-1591-4751-8985-17886a8c98a9", "id": "8ccfe057-5ce6-499b-9fae-3cd89b62bf01",
"alias": "Account verification options", "alias": "Account verification options",
"description": "Method with which to verity the existing account", "description": "Method with which to verity the existing account",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1551,7 +1569,7 @@
] ]
}, },
{ {
"id": "126f07c3-1bcb-4a02-bf16-bb44674bf55d", "id": "f3b9ab2e-41c2-4e73-876b-e2c275d6d14e",
"alias": "Authentication Options", "alias": "Authentication Options",
"description": "Authentication options.", "description": "Authentication options.",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1585,7 +1603,7 @@
] ]
}, },
{ {
"id": "eb3a08c8-5f99-49b6-b02b-16b62571f273", "id": "df1329cc-777c-42d8-aa2f-c5d5ddaaf5a4",
"alias": "Browser - Conditional OTP", "alias": "Browser - Conditional OTP",
"description": "Flow to determine if the OTP is required for the authentication", "description": "Flow to determine if the OTP is required for the authentication",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1611,7 +1629,7 @@
] ]
}, },
{ {
"id": "3dc19838-5025-4bbb-b569-b574bd5a8d90", "id": "f78a4cbc-66ff-4caa-8066-67aff94946f4",
"alias": "Direct Grant - Conditional OTP", "alias": "Direct Grant - Conditional OTP",
"description": "Flow to determine if the OTP is required for the authentication", "description": "Flow to determine if the OTP is required for the authentication",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1637,7 +1655,7 @@
] ]
}, },
{ {
"id": "70d6fd40-d740-4dae-b0e6-350f8e9d4a1c", "id": "4b20995b-5553-45db-86b0-05c3fe14edb1",
"alias": "First broker login - Conditional OTP", "alias": "First broker login - Conditional OTP",
"description": "Flow to determine if the OTP is required for the authentication", "description": "Flow to determine if the OTP is required for the authentication",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1663,7 +1681,7 @@
] ]
}, },
{ {
"id": "6e24dcb3-5818-483c-8e44-883858171901", "id": "0a7cc6b7-e427-4f72-b44e-a02133241bad",
"alias": "Handle Existing Account", "alias": "Handle Existing Account",
"description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1689,7 +1707,7 @@
] ]
}, },
{ {
"id": "ac6254cd-403b-457b-b308-22a2a0e4f99d", "id": "e24e73c0-dd51-4fdc-a916-284f11f38487",
"alias": "Reset - Conditional OTP", "alias": "Reset - Conditional OTP",
"description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1715,7 +1733,7 @@
] ]
}, },
{ {
"id": "485e74e6-9b3e-4b2c-a9b9-927802dc4f06", "id": "37ee5a12-01c2-41b0-aafa-e9c6661ff544",
"alias": "User creation or linking", "alias": "User creation or linking",
"description": "Flow for the existing/non-existing user alternatives", "description": "Flow for the existing/non-existing user alternatives",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1742,7 +1760,7 @@
] ]
}, },
{ {
"id": "ff9bb879-1d6a-4d1c-9836-1e4fab6f8997", "id": "8902a1a7-c2ee-4648-869f-dd5ef89184fc",
"alias": "Verify Existing Account by Re-authentication", "alias": "Verify Existing Account by Re-authentication",
"description": "Reauthentication of existing account", "description": "Reauthentication of existing account",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1768,7 +1786,7 @@
] ]
}, },
{ {
"id": "af8b2470-d581-401c-9984-762b966ebcc2", "id": "77c78eed-4bcd-4779-b39f-10135be84946",
"alias": "browser", "alias": "browser",
"description": "browser based authentication", "description": "browser based authentication",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1810,7 +1828,7 @@
] ]
}, },
{ {
"id": "414dbda4-eb3f-4baa-b23a-d3423af1eae6", "id": "c6398883-01e6-47a1-bb97-c09f2983155d",
"alias": "clients", "alias": "clients",
"description": "Base authentication for clients", "description": "Base authentication for clients",
"providerId": "client-flow", "providerId": "client-flow",
@ -1852,7 +1870,7 @@
] ]
}, },
{ {
"id": "1cae0c4b-8dfb-4f5d-a781-e74d0a13c940", "id": "78ab5fb8-f35b-4053-b264-94b208000b13",
"alias": "direct grant", "alias": "direct grant",
"description": "OpenID Connect Resource Owner Grant", "description": "OpenID Connect Resource Owner Grant",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1886,7 +1904,7 @@
] ]
}, },
{ {
"id": "e798b655-7d85-4b6b-aee7-1448a3e1e0ea", "id": "959e154b-034e-413d-9b19-211e7d9ba33d",
"alias": "docker auth", "alias": "docker auth",
"description": "Used by Docker clients to authenticate against the IDP", "description": "Used by Docker clients to authenticate against the IDP",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1904,7 +1922,7 @@
] ]
}, },
{ {
"id": "eb94b723-1041-426a-87bf-f7b4bd2f485d", "id": "001e253d-bdbd-41e2-81c7-1c7b239feeb1",
"alias": "first broker login", "alias": "first broker login",
"description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1931,7 +1949,7 @@
] ]
}, },
{ {
"id": "452d1d5f-7632-44d7-bc89-77ff2b209b3e", "id": "45481bb0-18fe-4a26-a77c-35a5afe58436",
"alias": "forms", "alias": "forms",
"description": "Username, password, otp and other auth forms.", "description": "Username, password, otp and other auth forms.",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1957,7 +1975,7 @@
] ]
}, },
{ {
"id": "7c1b9e8f-6b57-49d1-a9a7-494862f93c0f", "id": "bb47b847-5a55-4c08-909e-9f6f8d8a0636",
"alias": "http challenge", "alias": "http challenge",
"description": "An authentication flow based on challenge-response HTTP Authentication Schemes", "description": "An authentication flow based on challenge-response HTTP Authentication Schemes",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1983,7 +2001,7 @@
] ]
}, },
{ {
"id": "2b38f34a-1739-499e-bb24-1dff96f32009", "id": "77e6e169-05b7-4b89-af00-09cfe1604eed",
"alias": "registration", "alias": "registration",
"description": "registration flow", "description": "registration flow",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -2002,7 +2020,7 @@
] ]
}, },
{ {
"id": "d26ae72b-a933-44dc-9927-1c82757004b2", "id": "aef03fe8-1a70-40c3-879f-25588f75c119",
"alias": "registration form", "alias": "registration form",
"description": "registration form", "description": "registration form",
"providerId": "form-flow", "providerId": "form-flow",
@ -2044,7 +2062,7 @@
] ]
}, },
{ {
"id": "222ee8d6-1892-4768-9ada-720274b6bf9a", "id": "990abff7-e2ba-4217-984e-8890cbc2b3a9",
"alias": "reset credentials", "alias": "reset credentials",
"description": "Reset credentials for a user if they forgot their password or something", "description": "Reset credentials for a user if they forgot their password or something",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -2086,7 +2104,7 @@
] ]
}, },
{ {
"id": "e8b4d92c-27c1-4a9b-9b16-7ceb810fa230", "id": "d9894cf6-2f99-493e-ac47-853f54bfc9c6",
"alias": "saml ecp", "alias": "saml ecp",
"description": "SAML ECP Profile Authentication Flow", "description": "SAML ECP Profile Authentication Flow",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -2106,14 +2124,14 @@
], ],
"authenticatorConfig": [ "authenticatorConfig": [
{ {
"id": "e5847a0b-855d-4d93-85fd-94714be3ed92", "id": "101ed8ff-4383-4539-aa52-2d1e69698b78",
"alias": "create unique user config", "alias": "create unique user config",
"config": { "config": {
"require.password.update.after.registration": "false" "require.password.update.after.registration": "false"
} }
}, },
{ {
"id": "a2a18aa4-bd4c-4c2a-9286-e9d6c64f4812", "id": "049042a5-3551-4c16-81a1-64d86f5aa1e5",
"alias": "review profile config", "alias": "review profile config",
"config": { "config": {
"update.profile.on.first.login": "missing" "update.profile.on.first.login": "missing"

View File

@ -775,6 +775,24 @@
"fullScopeAllowed": false, "fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0, "nodeReRegistrationTimeout": 0,
"protocolMappers": [ "protocolMappers": [
{
"id": "8fd0d584-7052-4d04-a615-d18a71050873",
"name": "allowed-origins",
"protocol": "openid-connect",
"protocolMapper": "oidc-hardcoded-claim-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"id.token.claim": "false",
"access.token.claim": "true",
"claim.name": "allowed-origins",
"jsonType.label": "JSON",
"access.tokenResponse.claim": "false",
"claim.value": "[\"*\"]",
"introspection.token.claim": "true",
"lightweight.claim": "true"
}
},
{ {
"id": "7779f8fa-c2fe-4e68-be56-66ee97bf8f13", "id": "7779f8fa-c2fe-4e68-be56-66ee97bf8f13",
"name": "locale", "name": "locale",
@ -1355,14 +1373,14 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"oidc-address-mapper",
"oidc-full-name-mapper",
"saml-role-list-mapper",
"oidc-sha256-pairwise-sub-mapper", "oidc-sha256-pairwise-sub-mapper",
"oidc-usermodel-property-mapper", "oidc-usermodel-property-mapper",
"oidc-address-mapper",
"oidc-full-name-mapper",
"oidc-usermodel-attribute-mapper", "oidc-usermodel-attribute-mapper",
"saml-user-property-mapper", "saml-user-attribute-mapper",
"saml-user-attribute-mapper" "saml-role-list-mapper",
"saml-user-property-mapper"
] ]
} }
}, },
@ -1411,14 +1429,14 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"saml-user-attribute-mapper",
"saml-role-list-mapper",
"oidc-sha256-pairwise-sub-mapper",
"oidc-full-name-mapper", "oidc-full-name-mapper",
"oidc-usermodel-attribute-mapper",
"saml-role-list-mapper",
"saml-user-attribute-mapper",
"oidc-usermodel-property-mapper", "oidc-usermodel-property-mapper",
"oidc-address-mapper", "oidc-address-mapper",
"saml-user-property-mapper", "oidc-sha256-pairwise-sub-mapper",
"oidc-usermodel-attribute-mapper" "saml-user-property-mapper"
] ]
} }
}, },
@ -1536,7 +1554,7 @@
"defaultLocale": "en", "defaultLocale": "en",
"authenticationFlows": [ "authenticationFlows": [
{ {
"id": "c40791b4-4d59-4df2-bebd-2b71e793704f", "id": "30a878f0-57aa-4d20-bab0-6cf1d7317a5c",
"alias": "Account verification options", "alias": "Account verification options",
"description": "Method with which to verity the existing account", "description": "Method with which to verity the existing account",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1562,7 +1580,7 @@
] ]
}, },
{ {
"id": "8813b6d1-8b88-4672-b29b-8420ce3f3975", "id": "d386affe-d1fe-472a-bee6-54105d0101f5",
"alias": "Authentication Options", "alias": "Authentication Options",
"description": "Authentication options.", "description": "Authentication options.",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1596,7 +1614,7 @@
] ]
}, },
{ {
"id": "a9937c40-a1ee-4c57-adf7-ede0a9983953", "id": "77b95bc0-bd0c-46b7-8240-3182023e9d50",
"alias": "Browser - Conditional OTP", "alias": "Browser - Conditional OTP",
"description": "Flow to determine if the OTP is required for the authentication", "description": "Flow to determine if the OTP is required for the authentication",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1622,7 +1640,7 @@
] ]
}, },
{ {
"id": "2d494b5a-eb73-40d0-94d3-a8d8024a7db4", "id": "bc96d3d6-29a1-42af-a63e-bb67a8c6d78f",
"alias": "Direct Grant - Conditional OTP", "alias": "Direct Grant - Conditional OTP",
"description": "Flow to determine if the OTP is required for the authentication", "description": "Flow to determine if the OTP is required for the authentication",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1648,7 +1666,7 @@
] ]
}, },
{ {
"id": "2e977f5a-8110-412b-b704-3e15164dbb1b", "id": "7697ca74-5c2b-45ab-9335-e0f6dec59b5c",
"alias": "First broker login - Conditional OTP", "alias": "First broker login - Conditional OTP",
"description": "Flow to determine if the OTP is required for the authentication", "description": "Flow to determine if the OTP is required for the authentication",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1674,7 +1692,7 @@
] ]
}, },
{ {
"id": "6f171b4b-8723-4e6d-bb1e-6b4293a7bb3f", "id": "534cb120-f600-4f40-9707-7b781bdbce48",
"alias": "Handle Existing Account", "alias": "Handle Existing Account",
"description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1700,7 +1718,7 @@
] ]
}, },
{ {
"id": "2dbb7f27-757d-4178-8217-4a24fdb0163c", "id": "f884b048-b223-4ed6-ae16-e49a4255131e",
"alias": "Reset - Conditional OTP", "alias": "Reset - Conditional OTP",
"description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1726,7 +1744,7 @@
] ]
}, },
{ {
"id": "7295aaf7-acf4-4b78-8186-d2415ea4ede0", "id": "61c7966c-ad72-49f5-84dd-376152348092",
"alias": "User creation or linking", "alias": "User creation or linking",
"description": "Flow for the existing/non-existing user alternatives", "description": "Flow for the existing/non-existing user alternatives",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1753,7 +1771,7 @@
] ]
}, },
{ {
"id": "e0d34d7c-7bbb-4847-8864-fbd97a1f3e89", "id": "72412d0f-dd1b-49fe-bb0b-9dad99eb0491",
"alias": "Verify Existing Account by Re-authentication", "alias": "Verify Existing Account by Re-authentication",
"description": "Reauthentication of existing account", "description": "Reauthentication of existing account",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1779,7 +1797,7 @@
] ]
}, },
{ {
"id": "5f3d0fb0-d95e-4841-89d3-a27d0cdbbcb4", "id": "6b76613e-0d39-440d-aab4-98eaffb1e96a",
"alias": "browser", "alias": "browser",
"description": "browser based authentication", "description": "browser based authentication",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1821,7 +1839,7 @@
] ]
}, },
{ {
"id": "c246380d-af25-4151-ab19-1f1e5b553008", "id": "0ff60395-fa89-41be-ad22-fab339e67c49",
"alias": "clients", "alias": "clients",
"description": "Base authentication for clients", "description": "Base authentication for clients",
"providerId": "client-flow", "providerId": "client-flow",
@ -1863,7 +1881,7 @@
] ]
}, },
{ {
"id": "abacf398-0f1f-4f28-a310-8d306d588048", "id": "bbb3ece7-7dbf-4aba-80c3-dde4b9cdd0b6",
"alias": "direct grant", "alias": "direct grant",
"description": "OpenID Connect Resource Owner Grant", "description": "OpenID Connect Resource Owner Grant",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1897,7 +1915,7 @@
] ]
}, },
{ {
"id": "a0f87683-619a-44d4-8b4f-4b053bba2346", "id": "f5f2c0f6-7dbf-4978-845e-6cacac23aa13",
"alias": "docker auth", "alias": "docker auth",
"description": "Used by Docker clients to authenticate against the IDP", "description": "Used by Docker clients to authenticate against the IDP",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1915,7 +1933,7 @@
] ]
}, },
{ {
"id": "e8820c7c-22a7-4618-beb7-3e09be72c00c", "id": "cf463104-19e2-41a8-8a53-d3dd30b75344",
"alias": "first broker login", "alias": "first broker login",
"description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1942,7 +1960,7 @@
] ]
}, },
{ {
"id": "cac00c38-ee44-44c9-b95e-cc755bab36ef", "id": "b99b60dc-41ad-487d-be69-a2eefa954a9d",
"alias": "forms", "alias": "forms",
"description": "Username, password, otp and other auth forms.", "description": "Username, password, otp and other auth forms.",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1968,7 +1986,7 @@
] ]
}, },
{ {
"id": "688cde36-507e-4a68-afdf-18ec4ad626a7", "id": "18731296-2c96-4f98-a884-027e629e4f9d",
"alias": "http challenge", "alias": "http challenge",
"description": "An authentication flow based on challenge-response HTTP Authentication Schemes", "description": "An authentication flow based on challenge-response HTTP Authentication Schemes",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -1994,7 +2012,7 @@
] ]
}, },
{ {
"id": "e058697c-f450-4f14-ae64-04e9299fa24f", "id": "9a9dce17-5425-4fd5-b3b8-81410e1dbce4",
"alias": "registration", "alias": "registration",
"description": "registration flow", "description": "registration flow",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -2013,7 +2031,7 @@
] ]
}, },
{ {
"id": "ad768088-32c9-4979-90dd-61bf111fd72e", "id": "d0a24e08-cb69-4949-9518-50ae7a96ee49",
"alias": "registration form", "alias": "registration form",
"description": "registration form", "description": "registration form",
"providerId": "form-flow", "providerId": "form-flow",
@ -2055,7 +2073,7 @@
] ]
}, },
{ {
"id": "47d4b090-f965-4588-b5bc-029ccb59876f", "id": "6a9aa554-afba-487f-9c82-e94c81c15b3b",
"alias": "reset credentials", "alias": "reset credentials",
"description": "Reset credentials for a user if they forgot their password or something", "description": "Reset credentials for a user if they forgot their password or something",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -2097,7 +2115,7 @@
] ]
}, },
{ {
"id": "1f68feec-7f99-4c49-afe6-45d46684ca21", "id": "e0361d46-eab4-41a6-bb2e-1dc6a5a6b073",
"alias": "saml ecp", "alias": "saml ecp",
"description": "SAML ECP Profile Authentication Flow", "description": "SAML ECP Profile Authentication Flow",
"providerId": "basic-flow", "providerId": "basic-flow",
@ -2117,14 +2135,14 @@
], ],
"authenticatorConfig": [ "authenticatorConfig": [
{ {
"id": "bd7365c7-842b-4bc6-a4ca-498cf025c210", "id": "053d6017-e54c-418a-abe7-44dd4752eacb",
"alias": "create unique user config", "alias": "create unique user config",
"config": { "config": {
"require.password.update.after.registration": "false" "require.password.update.after.registration": "false"
} }
}, },
{ {
"id": "b929192d-f650-4a09-9701-3d3216547552", "id": "8b545cf4-ab9e-4226-b3c0-d7ac773eae2f",
"alias": "review profile config", "alias": "review profile config",
"config": { "config": {
"update.profile.on.first.login": "missing" "update.profile.on.first.login": "missing"

View File

@ -408,9 +408,9 @@
"otpPolicyPeriod": 30, "otpPolicyPeriod": 30,
"otpPolicyCodeReusable": false, "otpPolicyCodeReusable": false,
"otpSupportedApplications": [ "otpSupportedApplications": [
"totpAppGoogleName",
"totpAppFreeOTPName", "totpAppFreeOTPName",
"totpAppMicrosoftAuthenticatorName" "totpAppMicrosoftAuthenticatorName",
"totpAppGoogleName"
], ],
"webAuthnPolicyRpEntityName": "keycloak", "webAuthnPolicyRpEntityName": "keycloak",
"webAuthnPolicySignatureAlgorithms": ["ES256"], "webAuthnPolicySignatureAlgorithms": ["ES256"],
@ -779,6 +779,24 @@
"fullScopeAllowed": false, "fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0, "nodeReRegistrationTimeout": 0,
"protocolMappers": [ "protocolMappers": [
{
"id": "8fd0d584-7052-4d04-a615-d18a71050873",
"name": "allowed-origins",
"protocol": "openid-connect",
"protocolMapper": "oidc-hardcoded-claim-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"id.token.claim": "false",
"access.token.claim": "true",
"claim.name": "allowed-origins",
"jsonType.label": "JSON",
"access.tokenResponse.claim": "false",
"claim.value": "[\"*\"]",
"introspection.token.claim": "true",
"lightweight.claim": "true"
}
},
{ {
"id": "7779f8fa-c2fe-4e68-be56-66ee97bf8f13", "id": "7779f8fa-c2fe-4e68-be56-66ee97bf8f13",
"name": "locale", "name": "locale",
@ -1359,13 +1377,13 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"saml-user-attribute-mapper",
"saml-user-property-mapper",
"oidc-sha256-pairwise-sub-mapper",
"saml-role-list-mapper",
"oidc-usermodel-attribute-mapper", "oidc-usermodel-attribute-mapper",
"oidc-full-name-mapper",
"oidc-usermodel-property-mapper", "oidc-usermodel-property-mapper",
"oidc-sha256-pairwise-sub-mapper",
"saml-user-property-mapper",
"saml-role-list-mapper",
"oidc-full-name-mapper",
"saml-user-attribute-mapper",
"oidc-address-mapper" "oidc-address-mapper"
] ]
} }
@ -1415,14 +1433,14 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"oidc-address-mapper",
"oidc-usermodel-property-mapper", "oidc-usermodel-property-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-full-name-mapper",
"oidc-sha256-pairwise-sub-mapper", "oidc-sha256-pairwise-sub-mapper",
"saml-user-property-mapper",
"saml-role-list-mapper", "saml-role-list-mapper",
"saml-user-attribute-mapper" "saml-user-attribute-mapper",
"saml-user-property-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-address-mapper",
"oidc-full-name-mapper"
] ]
} }
}, },

File diff suppressed because it is too large Load Diff

View File

@ -789,6 +789,24 @@
"fullScopeAllowed": false, "fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0, "nodeReRegistrationTimeout": 0,
"protocolMappers": [ "protocolMappers": [
{
"id": "8fd0d584-7052-4d04-a615-d18a71050873",
"name": "allowed-origins",
"protocol": "openid-connect",
"protocolMapper": "oidc-hardcoded-claim-mapper",
"consentRequired": false,
"config": {
"introspection.token.claim": "true",
"userinfo.token.claim": "true",
"id.token.claim": "false",
"access.token.claim": "true",
"claim.name": "allowed-origins",
"jsonType.label": "JSON",
"access.tokenResponse.claim": "false",
"claim.value": "[\"*\"]",
"lightweight.claim": "true"
}
},
{ {
"id": "59cde7ae-2218-4a8e-83af-cad992c3a700", "id": "59cde7ae-2218-4a8e-83af-cad992c3a700",
"name": "locale", "name": "locale",
@ -1401,14 +1419,14 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"saml-role-list-mapper",
"oidc-sha256-pairwise-sub-mapper", "oidc-sha256-pairwise-sub-mapper",
"oidc-usermodel-attribute-mapper",
"saml-user-attribute-mapper", "saml-user-attribute-mapper",
"oidc-full-name-mapper", "oidc-full-name-mapper",
"oidc-usermodel-property-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-address-mapper", "oidc-address-mapper",
"saml-user-property-mapper", "saml-user-property-mapper",
"oidc-usermodel-property-mapper" "saml-role-list-mapper"
] ]
} }
}, },
@ -1477,14 +1495,14 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"saml-user-attribute-mapper", "oidc-usermodel-property-mapper",
"saml-role-list-mapper", "saml-role-list-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-address-mapper",
"saml-user-property-mapper", "saml-user-property-mapper",
"oidc-usermodel-attribute-mapper",
"saml-user-attribute-mapper",
"oidc-address-mapper",
"oidc-full-name-mapper", "oidc-full-name-mapper",
"oidc-sha256-pairwise-sub-mapper", "oidc-sha256-pairwise-sub-mapper"
"oidc-usermodel-property-mapper"
] ]
} }
} }

View File

@ -919,6 +919,24 @@
"claim.name": "locale", "claim.name": "locale",
"jsonType.label": "String" "jsonType.label": "String"
} }
},
{
"id": "8fd0d584-7052-4d04-a615-d18a71050873",
"name": "allowed-origins",
"protocol": "openid-connect",
"protocolMapper": "oidc-hardcoded-claim-mapper",
"consentRequired": false,
"config": {
"introspection.token.claim": "true",
"userinfo.token.claim": "true",
"id.token.claim": "false",
"access.token.claim": "true",
"claim.name": "allowed-origins",
"jsonType.label": "JSON",
"access.tokenResponse.claim": "false",
"claim.value": "[\"*\"]",
"lightweight.claim": "true"
}
} }
], ],
"defaultClientScopes": ["web-origins", "acr", "profile", "roles", "email"], "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "email"],
@ -1545,14 +1563,14 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"oidc-full-name-mapper",
"saml-role-list-mapper", "saml-role-list-mapper",
"saml-user-attribute-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-address-mapper", "oidc-address-mapper",
"oidc-usermodel-property-mapper", "oidc-usermodel-property-mapper",
"saml-user-attribute-mapper",
"saml-user-property-mapper", "saml-user-property-mapper",
"oidc-sha256-pairwise-sub-mapper", "oidc-sha256-pairwise-sub-mapper"
"oidc-usermodel-attribute-mapper",
"oidc-full-name-mapper"
] ]
} }
}, },
@ -1584,14 +1602,14 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"oidc-sha256-pairwise-sub-mapper",
"oidc-address-mapper",
"oidc-full-name-mapper", "oidc-full-name-mapper",
"oidc-usermodel-property-mapper", "oidc-usermodel-property-mapper",
"saml-user-attribute-mapper", "saml-user-attribute-mapper",
"oidc-sha256-pairwise-sub-mapper",
"saml-role-list-mapper", "saml-role-list-mapper",
"oidc-address-mapper", "saml-user-property-mapper",
"oidc-usermodel-attribute-mapper", "oidc-usermodel-attribute-mapper"
"saml-user-property-mapper"
] ]
} }
}, },

View File

@ -964,6 +964,24 @@
"claim.name": "locale", "claim.name": "locale",
"jsonType.label": "String" "jsonType.label": "String"
} }
},
{
"id": "8fd0d584-7052-4d04-a615-d18a71050873",
"name": "allowed-origins",
"protocol": "openid-connect",
"protocolMapper": "oidc-hardcoded-claim-mapper",
"consentRequired": false,
"config": {
"introspection.token.claim": "true",
"userinfo.token.claim": "true",
"id.token.claim": "false",
"access.token.claim": "true",
"claim.name": "allowed-origins",
"jsonType.label": "JSON",
"access.tokenResponse.claim": "false",
"claim.value": "[\"*\"]",
"lightweight.claim": "true"
}
} }
], ],
"defaultClientScopes": [ "defaultClientScopes": [
@ -1618,14 +1636,14 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"saml-role-list-mapper",
"oidc-full-name-mapper",
"saml-user-property-mapper",
"saml-user-attribute-mapper",
"oidc-usermodel-attribute-mapper", "oidc-usermodel-attribute-mapper",
"oidc-address-mapper", "oidc-address-mapper",
"saml-role-list-mapper",
"saml-user-property-mapper",
"oidc-sha256-pairwise-sub-mapper", "oidc-sha256-pairwise-sub-mapper",
"oidc-usermodel-property-mapper" "saml-user-attribute-mapper",
"oidc-usermodel-property-mapper",
"oidc-full-name-mapper"
] ]
} }
}, },
@ -1657,12 +1675,12 @@
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"oidc-address-mapper", "oidc-address-mapper",
"saml-user-attribute-mapper", "saml-user-attribute-mapper",
"oidc-full-name-mapper",
"saml-role-list-mapper", "saml-role-list-mapper",
"oidc-usermodel-property-mapper",
"oidc-sha256-pairwise-sub-mapper", "oidc-sha256-pairwise-sub-mapper",
"oidc-usermodel-attribute-mapper",
"saml-user-property-mapper", "saml-user-property-mapper",
"oidc-usermodel-property-mapper" "oidc-usermodel-attribute-mapper",
"oidc-full-name-mapper"
] ]
} }
}, },

View File

@ -997,7 +997,7 @@
"claim.value": "[\"*\"]", "claim.value": "[\"*\"]",
"userinfo.token.claim": "true", "userinfo.token.claim": "true",
"id.token.claim": "false", "id.token.claim": "false",
"lightweight.claim": "false", "lightweight.claim": "true",
"access.token.claim": "true", "access.token.claim": "true",
"claim.name": "allowed-origins", "claim.name": "allowed-origins",
"jsonType.label": "JSON", "jsonType.label": "JSON",
@ -1628,7 +1628,7 @@
"smtpServer": {}, "smtpServer": {},
"loginTheme": "keycloakify-starter", "loginTheme": "keycloakify-starter",
"accountTheme": "", "accountTheme": "",
"adminTheme": "", "adminTheme": "keycloakify-starter",
"emailTheme": "", "emailTheme": "",
"eventsEnabled": false, "eventsEnabled": false,
"eventsListeners": ["keycloakify-logging", "jboss-logging"], "eventsListeners": ["keycloakify-logging", "jboss-logging"],
@ -1657,13 +1657,13 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"oidc-usermodel-property-mapper",
"saml-user-property-mapper",
"oidc-address-mapper", "oidc-address-mapper",
"saml-user-attribute-mapper", "saml-user-attribute-mapper",
"saml-role-list-mapper", "oidc-usermodel-property-mapper",
"oidc-sha256-pairwise-sub-mapper",
"oidc-usermodel-attribute-mapper", "oidc-usermodel-attribute-mapper",
"saml-user-property-mapper",
"oidc-sha256-pairwise-sub-mapper",
"saml-role-list-mapper",
"oidc-full-name-mapper" "oidc-full-name-mapper"
] ]
} }
@ -1694,14 +1694,14 @@
"subComponents": {}, "subComponents": {},
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
"saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper",
"oidc-full-name-mapper",
"oidc-sha256-pairwise-sub-mapper", "oidc-sha256-pairwise-sub-mapper",
"saml-user-property-mapper",
"oidc-usermodel-property-mapper",
"saml-role-list-mapper", "saml-role-list-mapper",
"oidc-address-mapper", "oidc-address-mapper",
"oidc-usermodel-attribute-mapper" "oidc-full-name-mapper",
"saml-user-property-mapper",
"oidc-usermodel-property-mapper",
"saml-user-attribute-mapper"
] ]
} }
}, },

View File

@ -67,7 +67,7 @@ export async function dumpContainerConfig(params: {
...["--db", "dev-file"], ...["--db", "dev-file"],
...[ ...[
"--db-url", "--db-url",
"'jdbc:h2:file:/tmp/h2/keycloakdb;NON_KEYWORDS=VALUE'" '"jdbc:h2:file:/tmp/h2/keycloakdb;NON_KEYWORDS=VALUE"'
] ]
]) ])
], ],

View File

@ -1,28 +1,20 @@
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import type { ParsedRealmJson } from "./ParsedRealmJson"; import type { ParsedRealmJson } from "./ParsedRealmJson";
import { getDefaultConfig } from "./defaultConfig"; import { getDefaultConfig } from "./defaultConfig";
import type { BuildContext } from "../../shared/buildContext"; import { TEST_APP_URL, type ThemeType, THEME_TYPES } from "../../shared/constants";
import { objectKeys } from "tsafe/objectKeys";
import { TEST_APP_URL } from "../../shared/constants";
import { sameFactory } from "evt/tools/inDepth/same"; import { sameFactory } from "evt/tools/inDepth/same";
export type BuildContextLike = {
themeNames: BuildContext["themeNames"];
implementedThemeTypes: BuildContext["implementedThemeTypes"];
};
assert<BuildContext extends BuildContextLike ? true : false>;
export function prepareRealmConfig(params: { export function prepareRealmConfig(params: {
parsedRealmJson: ParsedRealmJson; parsedRealmJson: ParsedRealmJson;
keycloakMajorVersionNumber: number; keycloakMajorVersionNumber: number;
buildContext: BuildContextLike; parsedKeycloakThemesJsonEntry: { name: string; types: (ThemeType | "email")[] };
}): { }): {
realmName: string; realmName: string;
clientName: string; clientName: string;
username: string; username: string;
} { } {
const { parsedRealmJson, keycloakMajorVersionNumber, buildContext } = params; const { parsedRealmJson, keycloakMajorVersionNumber, parsedKeycloakThemesJsonEntry } =
params;
const { username } = addOrEditTestUser({ const { username } = addOrEditTestUser({
parsedRealmJson, parsedRealmJson,
@ -38,8 +30,7 @@ export function prepareRealmConfig(params: {
enableCustomThemes({ enableCustomThemes({
parsedRealmJson, parsedRealmJson,
themeName: buildContext.themeNames[0], parsedKeycloakThemesJsonEntry
implementedThemeTypes: buildContext.implementedThemeTypes
}); });
enable_custom_events_listeners: { enable_custom_events_listeners: {
@ -63,17 +54,15 @@ export function prepareRealmConfig(params: {
function enableCustomThemes(params: { function enableCustomThemes(params: {
parsedRealmJson: ParsedRealmJson; parsedRealmJson: ParsedRealmJson;
themeName: string; parsedKeycloakThemesJsonEntry: { name: string; types: (ThemeType | "email")[] };
implementedThemeTypes: BuildContextLike["implementedThemeTypes"];
}) { }) {
const { parsedRealmJson, themeName, implementedThemeTypes } = params; const { parsedRealmJson, parsedKeycloakThemesJsonEntry } = params;
for (const themeType of objectKeys(implementedThemeTypes)) { for (const themeType of [...THEME_TYPES, "email"] as const) {
if (!implementedThemeTypes[themeType].isImplemented) { parsedRealmJson[`${themeType}Theme` as const] =
continue; !parsedKeycloakThemesJsonEntry.types.includes(themeType)
} ? ""
: parsedKeycloakThemesJsonEntry.name;
parsedRealmJson[`${themeType}Theme` as const] = themeName;
} }
} }
@ -112,7 +101,6 @@ function addOrEditTestUser(params: {
); );
newUser.username = defaultUser_default.username; newUser.username = defaultUser_default.username;
newUser.email = defaultUser_default.email;
delete_existing_password_credential_if_any: { delete_existing_password_credential_if_any: {
const i = newUser.credentials.findIndex( const i = newUser.credentials.findIndex(
@ -333,7 +321,7 @@ function editAccountConsoleAndSecurityAdminConsole(params: {
"claim.value": '["*"]', "claim.value": '["*"]',
"userinfo.token.claim": "true", "userinfo.token.claim": "true",
"id.token.claim": "false", "id.token.claim": "false",
"lightweight.claim": "false", "lightweight.claim": "true",
"access.token.claim": "true", "access.token.claim": "true",
"claim.name": "allowed-origins", "claim.name": "allowed-origins",
"jsonType.label": "JSON", "jsonType.label": "JSON",

View File

@ -1,11 +1,7 @@
import type { BuildContext } from "../../shared/buildContext"; import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import { runPrettier, getIsPrettierAvailable } from "../../tools/runPrettier";
import { getDefaultConfig } from "./defaultConfig"; import { getDefaultConfig } from "./defaultConfig";
import { import { prepareRealmConfig } from "./prepareRealmConfig";
prepareRealmConfig,
type BuildContextLike as BuildContextLike_prepareRealmConfig
} from "./prepareRealmConfig";
import * as fs from "fs"; import * as fs from "fs";
import { import {
join as pathJoin, join as pathJoin,
@ -14,25 +10,30 @@ import {
sep as pathSep sep as pathSep
} from "path"; } from "path";
import { existsAsync } from "../../tools/fs.existsAsync"; import { existsAsync } from "../../tools/fs.existsAsync";
import { readRealmJsonFile, type ParsedRealmJson } from "./ParsedRealmJson"; import {
readRealmJsonFile,
writeRealmJsonFile,
type ParsedRealmJson
} from "./ParsedRealmJson";
import { import {
dumpContainerConfig, dumpContainerConfig,
type BuildContextLike as BuildContextLike_dumpContainerConfig type BuildContextLike as BuildContextLike_dumpContainerConfig
} from "./dumpContainerConfig"; } from "./dumpContainerConfig";
import * as runExclusive from "run-exclusive"; import * as runExclusive from "run-exclusive";
import { waitForDebounceFactory } from "powerhooks/tools/waitForDebounce"; import { waitForDebounceFactory } from "powerhooks/tools/waitForDebounce";
import type { ThemeType } from "../../shared/constants";
import chalk from "chalk"; import chalk from "chalk";
export type BuildContextLike = BuildContextLike_dumpContainerConfig & export type BuildContextLike = BuildContextLike_dumpContainerConfig & {
BuildContextLike_prepareRealmConfig & { projectDirPath: string;
projectDirPath: string; };
};
assert<BuildContext extends BuildContextLike ? true : false>; assert<BuildContext extends BuildContextLike ? true : false>;
export async function getRealmConfig(params: { export async function getRealmConfig(params: {
keycloakMajorVersionNumber: number; keycloakMajorVersionNumber: number;
realmJsonFilePath_userProvided: string | undefined; realmJsonFilePath_userProvided: string | undefined;
parsedKeycloakThemesJsonEntry: { name: string; types: (ThemeType | "email")[] };
buildContext: BuildContextLike; buildContext: BuildContextLike;
}): Promise<{ }): Promise<{
realmJsonFilePath: string; realmJsonFilePath: string;
@ -41,8 +42,12 @@ export async function getRealmConfig(params: {
username: string; username: string;
onRealmConfigChange: () => Promise<void>; onRealmConfigChange: () => Promise<void>;
}> { }> {
const { keycloakMajorVersionNumber, realmJsonFilePath_userProvided, buildContext } = const {
params; keycloakMajorVersionNumber,
realmJsonFilePath_userProvided,
parsedKeycloakThemesJsonEntry,
buildContext
} = params;
const realmJsonFilePath = pathJoin( const realmJsonFilePath = pathJoin(
buildContext.projectDirPath, buildContext.projectDirPath,
@ -68,8 +73,8 @@ export async function getRealmConfig(params: {
const { clientName, realmName, username } = prepareRealmConfig({ const { clientName, realmName, username } = prepareRealmConfig({
parsedRealmJson, parsedRealmJson,
buildContext, keycloakMajorVersionNumber,
keycloakMajorVersionNumber parsedKeycloakThemesJsonEntry
}); });
{ {
@ -80,22 +85,11 @@ export async function getRealmConfig(params: {
} }
} }
const writeRealmJsonFile = async (params: { parsedRealmJson: ParsedRealmJson }) => { await writeRealmJsonFile({
const { parsedRealmJson } = params; realmJsonFilePath,
parsedRealmJson,
let sourceCode = JSON.stringify(parsedRealmJson, null, 2); keycloakMajorVersionNumber
});
if (await getIsPrettierAvailable()) {
sourceCode = await runPrettier({
sourceCode,
filePath: realmJsonFilePath
});
}
fs.writeFileSync(realmJsonFilePath, sourceCode);
};
await writeRealmJsonFile({ parsedRealmJson });
const { onRealmConfigChange } = (() => { const { onRealmConfigChange } = (() => {
const run = runExclusive.build(async () => { const run = runExclusive.build(async () => {
@ -119,7 +113,11 @@ export async function getRealmConfig(params: {
return; return;
} }
await writeRealmJsonFile({ parsedRealmJson }); await writeRealmJsonFile({
realmJsonFilePath,
parsedRealmJson,
keycloakMajorVersionNumber
});
console.log( console.log(
[ [

View File

@ -4,7 +4,8 @@ import {
CONTAINER_NAME, CONTAINER_NAME,
KEYCLOAKIFY_SPA_DEV_SERVER_PORT, KEYCLOAKIFY_SPA_DEV_SERVER_PORT,
KEYCLOAKIFY_LOGIN_JAR_BASENAME, KEYCLOAKIFY_LOGIN_JAR_BASENAME,
TEST_APP_URL TEST_APP_URL,
ThemeType
} from "../shared/constants"; } from "../shared/constants";
import { SemVer } from "../tools/SemVer"; import { SemVer } from "../tools/SemVer";
import { assert, type Equals } from "tsafe/assert"; import { assert, type Equals } from "tsafe/assert";
@ -34,6 +35,7 @@ import { startViteDevServer } from "./startViteDevServer";
import { getSupportedKeycloakMajorVersions } from "./realmConfig/defaultConfig"; import { getSupportedKeycloakMajorVersions } from "./realmConfig/defaultConfig";
import { getSupportedDockerImageTags } from "./getSupportedDockerImageTags"; import { getSupportedDockerImageTags } from "./getSupportedDockerImageTags";
import { getRealmConfig } from "./realmConfig"; import { getRealmConfig } from "./realmConfig";
import { id } from "tsafe/id";
export async function command(params: { export async function command(params: {
buildContext: BuildContext; buildContext: BuildContext;
@ -51,11 +53,17 @@ export async function command(params: {
.execSync("docker --version", { .execSync("docker --version", {
stdio: ["ignore", "pipe", "ignore"] stdio: ["ignore", "pipe", "ignore"]
}) })
?.toString("utf8"); .toString("utf8");
} catch {} } catch {
commandOutput = "";
}
if (commandOutput?.includes("Docker") || commandOutput?.includes("podman")) { commandOutput = commandOutput.trim().toLowerCase();
break exit_if_docker_not_installed;
for (const term of ["docker", "podman"]) {
if (commandOutput.includes(term)) {
break exit_if_docker_not_installed;
}
} }
console.log( console.log(
@ -97,7 +105,7 @@ export async function command(params: {
const { cliCommandOptions, buildContext } = params; const { cliCommandOptions, buildContext } = params;
const availableTags = await getSupportedDockerImageTags({ const { allSupportedTags, latestMajorTags } = await getSupportedDockerImageTags({
buildContext buildContext
}); });
@ -105,7 +113,7 @@ export async function command(params: {
if (cliCommandOptions.keycloakVersion !== undefined) { if (cliCommandOptions.keycloakVersion !== undefined) {
const cliCommandOptions_keycloakVersion = cliCommandOptions.keycloakVersion; const cliCommandOptions_keycloakVersion = cliCommandOptions.keycloakVersion;
const tag = availableTags.find(tag => const tag = allSupportedTags.find(tag =>
tag.startsWith(cliCommandOptions_keycloakVersion) tag.startsWith(cliCommandOptions_keycloakVersion)
); );
@ -142,15 +150,96 @@ export async function command(params: {
].join("\n") ].join("\n")
); );
const { value: tag } = await cliSelect<string>({ const tag_userSelected = await (async () => {
values: availableTags let tag: string;
}).catch(() => {
process.exit(-1);
});
console.log(`${tag}`); let latestMajorTags_copy = [...latestMajorTags];
return { dockerImageTag: tag }; while (true) {
const { value } = await cliSelect<string>({
values: latestMajorTags_copy
}).catch(() => {
process.exit(-1);
});
tag = value;
{
const doImplementAccountMpa =
buildContext.implementedThemeTypes.account.isImplemented &&
buildContext.implementedThemeTypes.account.type === "Multi-Page";
if (doImplementAccountMpa && tag.startsWith("22.")) {
console.log(
chalk.yellow(
`You are implementing a Multi-Page Account theme. Keycloak 22 is not supported, select another version`
)
);
latestMajorTags_copy = latestMajorTags_copy.filter(
tag => !tag.startsWith("22.")
);
continue;
}
}
const readMajor = (tag: string) => {
const major = parseInt(tag.split(".")[0]);
assert(!isNaN(major));
return major;
};
{
const major = readMajor(tag);
const doImplementAdminTheme =
buildContext.implementedThemeTypes.admin.isImplemented;
const getIsSupported = (major: number) => major >= 23;
if (doImplementAdminTheme && !getIsSupported(major)) {
console.log(
chalk.yellow(
`You are implementing an Admin theme. Only Keycloak 23 and later are supported, select another version`
)
);
latestMajorTags_copy = latestMajorTags_copy.filter(tag =>
getIsSupported(readMajor(tag))
);
continue;
}
}
{
const doImplementAccountSpa =
buildContext.implementedThemeTypes.account.isImplemented &&
buildContext.implementedThemeTypes.account.type === "Single-Page";
const major = readMajor(tag);
const getIsSupported = (major: number) => major >= 19;
if (doImplementAccountSpa && !getIsSupported(major)) {
console.log(
chalk.yellow(
`You are implementing a Single-Page Account theme. Only Keycloak 19 and later are supported, select another version`
)
);
latestMajorTags_copy = latestMajorTags_copy.filter(tag =>
getIsSupported(readMajor(tag))
);
continue;
}
}
break;
}
return tag;
})();
console.log(`${tag_userSelected}`);
return { dockerImageTag: tag_userSelected };
})(); })();
const keycloakMajorVersionNumber = (() => { const keycloakMajorVersionNumber = (() => {
@ -189,32 +278,6 @@ export async function command(params: {
return wrap.majorVersionNumber; return wrap.majorVersionNumber;
})(); })();
const { clientName, onRealmConfigChange, realmJsonFilePath, realmName, username } =
await getRealmConfig({
keycloakMajorVersionNumber,
realmJsonFilePath_userProvided: await (async () => {
if (cliCommandOptions.realmJsonFilePath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.realmJsonFilePath,
cwd: process.cwd()
});
}
if (buildContext.startKeycloakOptions.realmJsonFilePath !== undefined) {
assert(
await existsAsync(
buildContext.startKeycloakOptions.realmJsonFilePath
),
`${pathRelative(process.cwd(), buildContext.startKeycloakOptions.realmJsonFilePath)} does not exist`
);
return buildContext.startKeycloakOptions.realmJsonFilePath;
}
return undefined;
})(),
buildContext
});
{ {
const { isAppBuildSuccess } = await appBuild({ const { isAppBuildSuccess } = await appBuild({
buildContext buildContext
@ -295,10 +358,24 @@ export async function command(params: {
)) ))
]; ];
let parsedKeycloakThemesJson = id<
{ themes: { name: string; types: (ThemeType | "email")[] }[] } | undefined
>(undefined);
async function extractThemeResourcesFromJar() { async function extractThemeResourcesFromJar() {
await extractArchive({ await extractArchive({
archiveFilePath: jarFilePath, archiveFilePath: jarFilePath,
onArchiveFile: async ({ relativeFilePathInArchive, writeFile }) => { onArchiveFile: async ({ relativeFilePathInArchive, writeFile, readFile }) => {
if (
relativeFilePathInArchive ===
pathJoin("META-INF", "keycloak-themes.json") &&
parsedKeycloakThemesJson === undefined
) {
parsedKeycloakThemesJson = JSON.parse(
(await readFile()).toString("utf8")
);
}
if (isInside({ dirPath: "theme", filePath: relativeFilePathInArchive })) { if (isInside({ dirPath: "theme", filePath: relativeFilePathInArchive })) {
await writeFile({ await writeFile({
filePath: pathJoin( filePath: pathJoin(
@ -320,6 +397,43 @@ export async function command(params: {
await extractThemeResourcesFromJar(); await extractThemeResourcesFromJar();
assert(parsedKeycloakThemesJson !== undefined);
const { clientName, onRealmConfigChange, realmJsonFilePath, realmName, username } =
await getRealmConfig({
keycloakMajorVersionNumber,
parsedKeycloakThemesJsonEntry: (() => {
const entry = parsedKeycloakThemesJson.themes.find(
({ name }) => name === buildContext.themeNames[0]
);
assert(entry !== undefined);
return entry;
})(),
realmJsonFilePath_userProvided: await (async () => {
if (cliCommandOptions.realmJsonFilePath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.realmJsonFilePath,
cwd: process.cwd()
});
}
if (buildContext.startKeycloakOptions.realmJsonFilePath !== undefined) {
assert(
await existsAsync(
buildContext.startKeycloakOptions.realmJsonFilePath
),
`${pathRelative(process.cwd(), buildContext.startKeycloakOptions.realmJsonFilePath)} does not exist`
);
return buildContext.startKeycloakOptions.realmJsonFilePath;
}
return undefined;
})(),
buildContext
});
const jarFilePath_cacheDir = pathJoin( const jarFilePath_cacheDir = pathJoin(
buildContext.cacheDirPath, buildContext.cacheDirPath,
pathBasename(jarFilePath) pathBasename(jarFilePath)
@ -623,42 +737,90 @@ export async function command(params: {
} }
) )
.on("all", async (...[, filePath]) => { .on("all", async (...[, filePath]) => {
ignore_account_spa: { ignore_path_covered_by_hmr: {
const doImplementAccountSpa = if (filePath.endsWith(".properties")) {
buildContext.implementedThemeTypes.account.isImplemented && break ignore_path_covered_by_hmr;
buildContext.implementedThemeTypes.account.type === "Single-Page";
if (!doImplementAccountSpa) {
break ignore_account_spa;
} }
if ( if (!doStartDevServer) {
!isInside({ break ignore_path_covered_by_hmr;
dirPath: pathJoin(buildContext.themeSrcDirPath, "account"),
filePath
})
) {
break ignore_account_spa;
} }
return; ignore_account_spa: {
} const doImplementAccountSpa =
buildContext.implementedThemeTypes.account.isImplemented &&
buildContext.implementedThemeTypes.account.type ===
"Single-Page";
ignore_admin: { if (!doImplementAccountSpa) {
if (!buildContext.implementedThemeTypes.admin.isImplemented) { break ignore_account_spa;
break ignore_admin; }
if (
!isInside({
dirPath: pathJoin(
buildContext.themeSrcDirPath,
"account"
),
filePath
})
) {
break ignore_account_spa;
}
return;
} }
if ( ignore_admin: {
!isInside({ if (!buildContext.implementedThemeTypes.admin.isImplemented) {
dirPath: pathJoin(buildContext.themeSrcDirPath, "admin"), break ignore_admin;
filePath }
})
) { if (
break ignore_admin; !isInside({
dirPath: pathJoin(buildContext.themeSrcDirPath, "admin"),
filePath
})
) {
break ignore_admin;
}
return;
} }
return; ignore_patternfly: {
if (
!isInside({
dirPath: pathJoin(
buildContext.themeSrcDirPath,
"shared",
"@patternfly"
),
filePath
})
) {
break ignore_patternfly;
}
return;
}
ignore_keycloak_ui_shared: {
if (
!isInside({
dirPath: pathJoin(
buildContext.themeSrcDirPath,
"shared",
"keycloak-ui-shared"
),
filePath
})
) {
break ignore_keycloak_ui_shared;
}
return;
}
} }
console.log(`Detected changes in ${filePath}`); console.log(`Detected changes in ${filePath}`);

View File

@ -10,14 +10,15 @@ import { crawlAsync } from "../tools/crawlAsync";
import { getIsPrettierAvailable, getPrettier } from "../tools/runPrettier"; import { getIsPrettierAvailable, getPrettier } from "../tools/runPrettier";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion"; import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import { import {
getUiModuleFileSourceCodeReadyToBeCopied, getExtensionModuleFileSourceCodeReadyToBeCopied,
type BuildContextLike as BuildContextLike_getUiModuleFileSourceCodeReadyToBeCopied type BuildContextLike as BuildContextLike_getExtensionModuleFileSourceCodeReadyToBeCopied
} from "./getUiModuleFileSourceCodeReadyToBeCopied"; } from "./getExtensionModuleFileSourceCodeReadyToBeCopied";
import * as crypto from "crypto"; import * as crypto from "crypto";
import { KEYCLOAK_THEME } from "../shared/constants"; import { KEYCLOAK_THEME } from "../shared/constants";
import { exclude } from "tsafe/exclude"; import { exclude } from "tsafe/exclude";
import { isAmong } from "tsafe/isAmong";
export type UiModuleMeta = { export type ExtensionModuleMeta = {
moduleName: string; moduleName: string;
version: string; version: string;
files: { files: {
@ -28,8 +29,8 @@ export type UiModuleMeta = {
peerDependencies: Record<string, string>; peerDependencies: Record<string, string>;
}; };
const zUiModuleMeta = (() => { const zExtensionModuleMeta = (() => {
type ExpectedType = UiModuleMeta; type ExpectedType = ExtensionModuleMeta;
const zTargetType = z.object({ const zTargetType = z.object({
moduleName: z.string(), moduleName: z.string(),
@ -55,7 +56,7 @@ type ParsedCacheFile = {
keycloakifyVersion: string; keycloakifyVersion: string;
prettierConfigHash: string | null; prettierConfigHash: string | null;
thisFilePath: string; thisFilePath: string;
uiModuleMetas: UiModuleMeta[]; extensionModuleMetas: ExtensionModuleMeta[];
}; };
const zParsedCacheFile = (() => { const zParsedCacheFile = (() => {
@ -65,7 +66,7 @@ const zParsedCacheFile = (() => {
keycloakifyVersion: z.string(), keycloakifyVersion: z.string(),
prettierConfigHash: z.union([z.string(), z.null()]), prettierConfigHash: z.union([z.string(), z.null()]),
thisFilePath: z.string(), thisFilePath: z.string(),
uiModuleMetas: z.array(zUiModuleMeta) extensionModuleMetas: z.array(zExtensionModuleMeta)
}); });
type InferredType = z.infer<typeof zTargetType>; type InferredType = z.infer<typeof zTargetType>;
@ -75,10 +76,10 @@ const zParsedCacheFile = (() => {
return id<z.ZodType<ExpectedType>>(zTargetType); return id<z.ZodType<ExpectedType>>(zTargetType);
})(); })();
const CACHE_FILE_RELATIVE_PATH = pathJoin("ui-modules", "cache.json"); const CACHE_FILE_RELATIVE_PATH = pathJoin("extension-modules", "cache.json");
export type BuildContextLike = export type BuildContextLike =
BuildContextLike_getUiModuleFileSourceCodeReadyToBeCopied & { BuildContextLike_getExtensionModuleFileSourceCodeReadyToBeCopied & {
cacheDirPath: string; cacheDirPath: string;
packageJsonFilePath: string; packageJsonFilePath: string;
projectDirPath: string; projectDirPath: string;
@ -86,9 +87,9 @@ export type BuildContextLike =
assert<BuildContext extends BuildContextLike ? true : false>(); assert<BuildContext extends BuildContextLike ? true : false>();
export async function getUiModuleMetas(params: { export async function getExtensionModuleMetas(params: {
buildContext: BuildContextLike; buildContext: BuildContextLike;
}): Promise<UiModuleMeta[]> { }): Promise<ExtensionModuleMeta[]> {
const { buildContext } = params; const { buildContext } = params;
const cacheFilePath = pathJoin(buildContext.cacheDirPath, CACHE_FILE_RELATIVE_PATH); const cacheFilePath = pathJoin(buildContext.cacheDirPath, CACHE_FILE_RELATIVE_PATH);
@ -105,10 +106,9 @@ export async function getUiModuleMetas(params: {
return configHash; return configHash;
})(); })();
const installedUiModules = await (async () => { const installedExtensionModules = await (async () => {
const installedModulesWithKeycloakifyInTheName = await listInstalledModules({ const installedModulesWithKeycloakifyInTheName = await listInstalledModules({
packageJsonFilePath: buildContext.packageJsonFilePath, packageJsonFilePath: buildContext.packageJsonFilePath,
projectDirPath: buildContext.packageJsonFilePath,
filter: ({ moduleName }) => filter: ({ moduleName }) =>
moduleName.includes("keycloakify") && moduleName !== "keycloakify" moduleName.includes("keycloakify") && moduleName !== "keycloakify"
}); });
@ -133,7 +133,7 @@ export async function getUiModuleMetas(params: {
return await fsPr.readFile(cacheFilePath); return await fsPr.readFile(cacheFilePath);
})(); })();
const uiModuleMetas_cacheUpToDate: UiModuleMeta[] = await (async () => { const extensionModuleMetas_cacheUpToDate: ExtensionModuleMeta[] = await (async () => {
const parsedCacheFile: ParsedCacheFile | undefined = await (async () => { const parsedCacheFile: ParsedCacheFile | undefined = await (async () => {
if (cacheContent === undefined) { if (cacheContent === undefined) {
return undefined; return undefined;
@ -176,45 +176,51 @@ export async function getUiModuleMetas(params: {
return []; return [];
} }
const uiModuleMetas_cacheUpToDate = parsedCacheFile.uiModuleMetas.filter( const extensionModuleMetas_cacheUpToDate =
uiModuleMeta => { parsedCacheFile.extensionModuleMetas.filter(extensionModuleMeta => {
const correspondingInstalledUiModule = installedUiModules.find( const correspondingInstalledExtensionModule =
installedUiModule => installedExtensionModules.find(
installedUiModule.moduleName === uiModuleMeta.moduleName installedExtensionModule =>
); installedExtensionModule.moduleName ===
extensionModuleMeta.moduleName
);
if (correspondingInstalledUiModule === undefined) { if (correspondingInstalledExtensionModule === undefined) {
return false; return false;
} }
return correspondingInstalledUiModule.version === uiModuleMeta.version; return (
} correspondingInstalledExtensionModule.version ===
); extensionModuleMeta.version
);
});
return uiModuleMetas_cacheUpToDate; return extensionModuleMetas_cacheUpToDate;
})(); })();
const uiModuleMetas = await Promise.all( const extensionModuleMetas = await Promise.all(
installedUiModules.map( installedExtensionModules.map(
async ({ async ({
moduleName, moduleName,
version, version,
peerDependencies, peerDependencies,
dirPath dirPath
}): Promise<UiModuleMeta> => { }): Promise<ExtensionModuleMeta> => {
use_cache: { use_cache: {
const uiModuleMeta_cache = uiModuleMetas_cacheUpToDate.find( const extensionModuleMeta_cache =
uiModuleMeta => uiModuleMeta.moduleName === moduleName extensionModuleMetas_cacheUpToDate.find(
); extensionModuleMeta =>
extensionModuleMeta.moduleName === moduleName
);
if (uiModuleMeta_cache === undefined) { if (extensionModuleMeta_cache === undefined) {
break use_cache; break use_cache;
} }
return uiModuleMeta_cache; return extensionModuleMeta_cache;
} }
const files: UiModuleMeta["files"] = []; const files: ExtensionModuleMeta["files"] = [];
{ {
const srcDirPath = pathJoin(dirPath, KEYCLOAK_THEME); const srcDirPath = pathJoin(dirPath, KEYCLOAK_THEME);
@ -224,13 +230,13 @@ export async function getUiModuleMetas(params: {
returnedPathsType: "relative to dirPath", returnedPathsType: "relative to dirPath",
onFileFound: async fileRelativePath => { onFileFound: async fileRelativePath => {
const sourceCode = const sourceCode =
await getUiModuleFileSourceCodeReadyToBeCopied({ await getExtensionModuleFileSourceCodeReadyToBeCopied({
buildContext, buildContext,
fileRelativePath, fileRelativePath,
isForEjection: false, isOwnershipAction: false,
uiModuleDirPath: dirPath, extensionModuleDirPath: dirPath,
uiModuleName: moduleName, extensionModuleName: moduleName,
uiModuleVersion: version extensionModuleVersion: version
}); });
const hash = computeHash(sourceCode); const hash = computeHash(sourceCode);
@ -260,11 +266,16 @@ export async function getUiModuleMetas(params: {
}); });
} }
return id<UiModuleMeta>({ return id<ExtensionModuleMeta>({
moduleName, moduleName,
version, version,
files, files,
peerDependencies peerDependencies: Object.fromEntries(
Object.entries(peerDependencies).filter(
([moduleName]) =>
!isAmong(["react", "@types/react"], moduleName)
)
)
}); });
} }
) )
@ -275,7 +286,7 @@ export async function getUiModuleMetas(params: {
keycloakifyVersion, keycloakifyVersion,
prettierConfigHash, prettierConfigHash,
thisFilePath: cacheFilePath, thisFilePath: cacheFilePath,
uiModuleMetas extensionModuleMetas
}); });
const cacheContent_new = Buffer.from( const cacheContent_new = Buffer.from(
@ -300,7 +311,7 @@ export async function getUiModuleMetas(params: {
await fsPr.writeFile(cacheFilePath, cacheContent_new); await fsPr.writeFile(cacheFilePath, cacheContent_new);
} }
return uiModuleMetas; return extensionModuleMetas;
} }
export function computeHash(data: Buffer) { export function computeHash(data: Buffer) {

View File

@ -0,0 +1,151 @@
import { getIsPrettierAvailable, runPrettier } from "../tools/runPrettier";
import * as fsPr from "fs/promises";
import { join as pathJoin, sep as pathSep } from "path";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext";
import { KEYCLOAK_THEME } from "../shared/constants";
export type BuildContextLike = {
themeSrcDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function getExtensionModuleFileSourceCodeReadyToBeCopied(params: {
buildContext: BuildContextLike;
fileRelativePath: string;
isOwnershipAction: boolean;
extensionModuleDirPath: string;
extensionModuleName: string;
extensionModuleVersion: string;
}): Promise<Buffer> {
const {
buildContext,
extensionModuleDirPath,
fileRelativePath,
isOwnershipAction,
extensionModuleName,
extensionModuleVersion
} = params;
let sourceCode = (
await fsPr.readFile(
pathJoin(extensionModuleDirPath, KEYCLOAK_THEME, fileRelativePath)
)
).toString("utf8");
sourceCode = addCommentToSourceCode({
sourceCode,
fileRelativePath,
commentLines: (() => {
const path = fileRelativePath.split(pathSep).join("/");
return isOwnershipAction
? [
`This file has been claimed for ownership from ${extensionModuleName} version ${extensionModuleVersion}.`,
`To relinquish ownership and restore this file to its original content, run the following command:`,
``,
`$ npx keycloakify own --path "${path}" --revert`
]
: [
`WARNING: Before modifying this file, run the following command:`,
``,
`$ npx keycloakify own --path "${path}"`,
``,
`This file is provided by ${extensionModuleName} version ${extensionModuleVersion}.`,
`It was copied into your repository by the postinstall script: \`keycloakify sync-extensions\`.`
];
})()
});
const destFilePath = pathJoin(buildContext.themeSrcDirPath, fileRelativePath);
format: {
if (!(await getIsPrettierAvailable())) {
break format;
}
sourceCode = await runPrettier({
filePath: destFilePath,
sourceCode
});
}
return Buffer.from(sourceCode, "utf8");
}
function addCommentToSourceCode(params: {
sourceCode: string;
fileRelativePath: string;
commentLines: string[];
}): string {
const { sourceCode, fileRelativePath, commentLines } = params;
const toResult = (comment: string) => {
return [comment, ``, sourceCode].join("\n");
};
for (const ext of [".ts", ".tsx", ".css", ".less", ".sass", ".js", ".jsx"]) {
if (!fileRelativePath.endsWith(ext)) {
continue;
}
return toResult(
[`/**`, ...commentLines.map(line => ` * ${line}`), ` */`].join("\n")
);
}
if (fileRelativePath.endsWith(".properties")) {
return toResult(commentLines.map(line => `# ${line}`).join("\n"));
}
if (fileRelativePath.endsWith(".ftl")) {
const comment = [`<#--`, ...commentLines.map(line => ` ${line}`), `-->`].join(
"\n"
);
if (sourceCode.trim().startsWith("<#ftl")) {
const [first, ...rest] = sourceCode.split(">");
const last = rest.join(">");
return [`${first}>`, comment, last].join("\n");
}
return toResult(comment);
}
if (fileRelativePath.endsWith(".html") || fileRelativePath.endsWith(".svg")) {
const comment = [
`<!--`,
...commentLines.map(
line =>
` ${line
.replace("--path", "-t")
.replace("--revert", "-r")
.replace("Before modifying", "Before modifying or replacing")}`
),
`-->`
].join("\n");
if (fileRelativePath.endsWith(".html") && sourceCode.trim().startsWith("<!")) {
const [first, ...rest] = sourceCode.split(">");
const last = rest.join(">");
return [`${first}>`, comment, last].join("\n");
}
if (fileRelativePath.endsWith(".svg") && sourceCode.trim().startsWith("<?")) {
const [first, ...rest] = sourceCode.split("?>");
const last = rest.join("?>");
return [`${first}?>`, comment, last].join("\n");
}
return toResult(comment);
}
return sourceCode;
}

View File

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

View File

@ -1,6 +1,6 @@
import { assert, type Equals, is } from "tsafe/assert"; import { assert, type Equals, is } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext"; import type { BuildContext } from "../shared/buildContext";
import type { UiModuleMeta } from "./uiModuleMeta"; import type { ExtensionModuleMeta } from "./extensionModuleMeta";
import { z } from "zod"; import { z } from "zod";
import { id } from "tsafe/id"; import { id } from "tsafe/id";
import * as fsPr from "fs/promises"; import * as fsPr from "fs/promises";
@ -16,29 +16,29 @@ export type BuildContextLike = {
assert<BuildContext extends BuildContextLike ? true : false>(); assert<BuildContext extends BuildContextLike ? true : false>();
export type UiModuleMetaLike = { export type ExtensionModuleMetaLike = {
moduleName: string; moduleName: string;
peerDependencies: Record<string, string>; peerDependencies: Record<string, string>;
}; };
assert<UiModuleMeta extends UiModuleMetaLike ? true : false>(); assert<ExtensionModuleMeta extends ExtensionModuleMetaLike ? true : false>();
export async function installUiModulesPeerDependencies(params: { export async function installExtensionModulesPeerDependencies(params: {
buildContext: BuildContextLike; buildContext: BuildContextLike;
uiModuleMetas: UiModuleMetaLike[]; extensionModuleMetas: ExtensionModuleMetaLike[];
}): Promise<void | never> { }): Promise<void | never> {
const { buildContext, uiModuleMetas } = params; const { buildContext, extensionModuleMetas } = params;
const { uiModulesPerDependencies } = (() => { const { extensionModulesPerDependencies } = (() => {
const uiModulesPerDependencies: Record<string, string> = {}; const extensionModulesPerDependencies: Record<string, string> = {};
for (const { peerDependencies } of uiModuleMetas) { for (const { peerDependencies } of extensionModuleMetas) {
for (const [peerDependencyName, versionRange_candidate] of Object.entries( for (const [peerDependencyName, versionRange_candidate] of Object.entries(
peerDependencies peerDependencies
)) { )) {
const versionRange = (() => { const versionRange = (() => {
const versionRange_current = const versionRange_current =
uiModulesPerDependencies[peerDependencyName]; extensionModulesPerDependencies[peerDependencyName];
if (versionRange_current === undefined) { if (versionRange_current === undefined) {
return versionRange_candidate; return versionRange_candidate;
@ -76,11 +76,11 @@ export async function installUiModulesPeerDependencies(params: {
return versionRange; return versionRange;
})(); })();
uiModulesPerDependencies[peerDependencyName] = versionRange; extensionModulesPerDependencies[peerDependencyName] = versionRange;
} }
} }
return { uiModulesPerDependencies }; return { extensionModulesPerDependencies };
})(); })();
const parsedPackageJson = await (async () => { const parsedPackageJson = await (async () => {
@ -117,7 +117,9 @@ export async function installUiModulesPeerDependencies(params: {
const parsedPackageJson_before = JSON.parse(JSON.stringify(parsedPackageJson)); const parsedPackageJson_before = JSON.parse(JSON.stringify(parsedPackageJson));
for (const [moduleName, versionRange] of Object.entries(uiModulesPerDependencies)) { for (const [moduleName, versionRange] of Object.entries(
extensionModulesPerDependencies
)) {
if (moduleName.startsWith("@types/")) { if (moduleName.startsWith("@types/")) {
(parsedPackageJson.devDependencies ??= {})[moduleName] = versionRange; (parsedPackageJson.devDependencies ??= {})[moduleName] = versionRange;
continue; continue;
@ -149,7 +151,7 @@ export async function installUiModulesPeerDependencies(params: {
await fsPr.writeFile(buildContext.packageJsonFilePath, packageJsonContentStr); await fsPr.writeFile(buildContext.packageJsonFilePath, packageJsonContentStr);
npmInstall({ await npmInstall({
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath) packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
}); });

View File

@ -7,7 +7,7 @@ import {
} from "path"; } from "path";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext"; import type { BuildContext } from "../shared/buildContext";
import type { UiModuleMeta } from "./uiModuleMeta"; import type { ExtensionModuleMeta } from "./extensionModuleMeta";
import { existsAsync } from "../tools/fs.existsAsync"; import { existsAsync } from "../tools/fs.existsAsync";
import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath"; import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath";
@ -17,17 +17,17 @@ export type BuildContextLike = {
assert<BuildContext extends BuildContextLike ? true : false>(); assert<BuildContext extends BuildContextLike ? true : false>();
const DELIMITER_START = `# === Ejected files start ===`; const DELIMITER_START = `# === Owned files start ===`;
const DELIMITER_END = `# === Ejected files end =====`; const DELIMITER_END = `# === Owned files end =====`;
export async function writeManagedGitignoreFile(params: { export async function writeManagedGitignoreFile(params: {
buildContext: BuildContextLike; buildContext: BuildContextLike;
uiModuleMetas: UiModuleMeta[]; extensionModuleMetas: ExtensionModuleMeta[];
ejectedFilesRelativePaths: string[]; ownedFilesRelativePaths: string[];
}): Promise<void> { }): Promise<void> {
const { buildContext, uiModuleMetas, ejectedFilesRelativePaths } = params; const { buildContext, extensionModuleMetas, ownedFilesRelativePaths } = params;
if (uiModuleMetas.length === 0) { if (extensionModuleMetas.length === 0) {
return; return;
} }
@ -38,19 +38,19 @@ export async function writeManagedGitignoreFile(params: {
`# This file is managed by Keycloakify, do not edit it manually.`, `# This file is managed by Keycloakify, do not edit it manually.`,
``, ``,
DELIMITER_START, DELIMITER_START,
...ejectedFilesRelativePaths ...ownedFilesRelativePaths
.map(fileRelativePath => fileRelativePath.split(pathSep).join("/")) .map(fileRelativePath => fileRelativePath.split(pathSep).join("/"))
.map(line => `# ${line}`), .map(line => `# ${line}`),
DELIMITER_END, DELIMITER_END,
``, ``,
...uiModuleMetas ...extensionModuleMetas
.map(uiModuleMeta => [ .map(extensionModuleMeta => [
`# === ${uiModuleMeta.moduleName} v${uiModuleMeta.version} ===`, `# === ${extensionModuleMeta.moduleName} v${extensionModuleMeta.version} ===`,
...uiModuleMeta.files ...extensionModuleMeta.files
.map(({ fileRelativePath }) => fileRelativePath) .map(({ fileRelativePath }) => fileRelativePath)
.filter( .filter(
fileRelativePath => fileRelativePath =>
!ejectedFilesRelativePaths.includes(fileRelativePath) !ownedFilesRelativePaths.includes(fileRelativePath)
) )
.map( .map(
fileRelativePath => fileRelativePath =>
@ -92,14 +92,14 @@ export async function writeManagedGitignoreFile(params: {
export async function readManagedGitignoreFile(params: { export async function readManagedGitignoreFile(params: {
buildContext: BuildContextLike; buildContext: BuildContextLike;
}): Promise<{ }): Promise<{
ejectedFilesRelativePaths: string[]; ownedFilesRelativePaths: string[];
}> { }> {
const { buildContext } = params; const { buildContext } = params;
const filePath = pathJoin(buildContext.themeSrcDirPath, ".gitignore"); const filePath = pathJoin(buildContext.themeSrcDirPath, ".gitignore");
if (!(await existsAsync(filePath))) { if (!(await existsAsync(filePath))) {
return { ejectedFilesRelativePaths: [] }; return { ownedFilesRelativePaths: [] };
} }
const contentStr = (await fsPr.readFile(filePath)).toString("utf8"); const contentStr = (await fsPr.readFile(filePath)).toString("utf8");
@ -116,10 +116,10 @@ export async function readManagedGitignoreFile(params: {
})(); })();
if (payload === undefined) { if (payload === undefined) {
return { ejectedFilesRelativePaths: [] }; return { ownedFilesRelativePaths: [] };
} }
const ejectedFilesRelativePaths = payload const ownedFilesRelativePaths = payload
.split("\n") .split("\n")
.map(line => line.trim()) .map(line => line.trim())
.map(line => line.replace(/^# /, "")) .map(line => line.replace(/^# /, ""))
@ -132,5 +132,5 @@ export async function readManagedGitignoreFile(params: {
) )
.map(filePath => pathRelative(buildContext.themeSrcDirPath, filePath)); .map(filePath => pathRelative(buildContext.themeSrcDirPath, filePath));
return { ejectedFilesRelativePaths }; return { ownedFilesRelativePaths };
} }

View File

@ -1,6 +1,6 @@
import type { BuildContext } from "../shared/buildContext"; import type { BuildContext } from "../shared/buildContext";
import { getUiModuleMetas, computeHash } from "./uiModuleMeta"; import { getExtensionModuleMetas, computeHash } from "./extensionModuleMeta";
import { installUiModulesPeerDependencies } from "./installUiModulesPeerDependencies"; import { installExtensionModulesPeerDependencies } from "./installExtensionModulesPeerDependencies";
import { import {
readManagedGitignoreFile, readManagedGitignoreFile,
writeManagedGitignoreFile writeManagedGitignoreFile
@ -9,36 +9,36 @@ import { dirname as pathDirname } from "path";
import { join as pathJoin } from "path"; import { join as pathJoin } from "path";
import { existsAsync } from "../tools/fs.existsAsync"; import { existsAsync } from "../tools/fs.existsAsync";
import * as fsPr from "fs/promises"; import * as fsPr from "fs/promises";
import { getIsTrackedByGit } from "../tools/isTrackedByGit"; import { getIsKnownByGit } from "../tools/isKnownByGit";
import { untrackFromGit } from "../tools/untrackFromGit"; import { untrackFromGit } from "../tools/untrackFromGit";
export async function command(params: { buildContext: BuildContext }) { export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params; const { buildContext } = params;
const uiModuleMetas = await getUiModuleMetas({ buildContext }); const extensionModuleMetas = await getExtensionModuleMetas({ buildContext });
await installUiModulesPeerDependencies({ await installExtensionModulesPeerDependencies({
buildContext, buildContext,
uiModuleMetas extensionModuleMetas
}); });
const { ejectedFilesRelativePaths } = await readManagedGitignoreFile({ const { ownedFilesRelativePaths } = await readManagedGitignoreFile({
buildContext buildContext
}); });
await writeManagedGitignoreFile({ await writeManagedGitignoreFile({
buildContext, buildContext,
ejectedFilesRelativePaths, ownedFilesRelativePaths,
uiModuleMetas extensionModuleMetas
}); });
await Promise.all( await Promise.all(
uiModuleMetas extensionModuleMetas
.map(uiModuleMeta => .map(extensionModuleMeta =>
Promise.all( Promise.all(
uiModuleMeta.files.map( extensionModuleMeta.files.map(
async ({ fileRelativePath, copyableFilePath, hash }) => { async ({ fileRelativePath, copyableFilePath, hash }) => {
if (ejectedFilesRelativePaths.includes(fileRelativePath)) { if (ownedFilesRelativePaths.includes(fileRelativePath)) {
return; return;
} }
@ -65,19 +65,7 @@ export async function command(params: { buildContext: BuildContext }) {
return; return;
} }
git_untrack: { if (await getIsKnownByGit({ filePath: destFilePath })) {
if (!doesFileExist) {
break git_untrack;
}
const isTracked = await getIsTrackedByGit({
filePath: destFilePath
});
if (!isTracked) {
break git_untrack;
}
await untrackFromGit({ await untrackFromGit({
filePath: destFilePath filePath: destFilePath
}); });

View File

@ -1,12 +0,0 @@
type PropertiesThatCanBeUndefined<T extends Record<string, unknown>> = {
[Key in keyof T]: undefined extends T[Key] ? Key : never;
}[keyof T];
/**
* OptionalIfCanBeUndefined<{ p1: string | undefined; p2: string; }>
* is
* { p1?: string | undefined; p2: string }
*/
export type OptionalIfCanBeUndefined<T extends Record<string, unknown>> = {
[K in PropertiesThatCanBeUndefined<T>]?: T[K];
} & { [K in Exclude<keyof T, PropertiesThatCanBeUndefined<T>>]: T[K] };

View File

@ -0,0 +1,99 @@
import { z } from "zod";
import { same } from "evt/tools/inDepth/same";
import { assert, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
export type Stringifyable =
| StringifyableAtomic
| StringifyableObject
| StringifyableArray;
export type StringifyableAtomic = string | number | boolean | null;
// NOTE: Use Record<string, Stringifyable>
interface StringifyableObject {
[key: string]: Stringifyable;
}
// NOTE: Use Stringifyable[]
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface StringifyableArray extends Array<Stringifyable> {}
export const zStringifyableAtomic = (() => {
type TargetType = StringifyableAtomic;
const zTargetType = z.union([z.string(), z.number(), z.boolean(), z.null()]);
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
export const zStringifyable: z.ZodType<Stringifyable> = z
.any()
.superRefine((val, ctx) => {
const isStringifyable = same(JSON.parse(JSON.stringify(val)), val);
if (!isStringifyable) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Not stringifyable"
});
}
});
export function getIsAtomic(
stringifyable: Stringifyable
): stringifyable is StringifyableAtomic {
return (
["string", "number", "boolean"].includes(typeof stringifyable) ||
stringifyable === null
);
}
export const { getValueAtPath } = (() => {
function getValueAtPath_rec(
stringifyable: Stringifyable,
path: (string | number)[]
): Stringifyable | undefined {
if (path.length === 0) {
return stringifyable;
}
if (getIsAtomic(stringifyable)) {
return undefined;
}
const [first, ...rest] = path;
let dereferenced: Stringifyable | undefined;
if (stringifyable instanceof Array) {
if (typeof first !== "number") {
return undefined;
}
dereferenced = stringifyable[first];
} else {
if (typeof first !== "string") {
return undefined;
}
dereferenced = stringifyable[first];
}
if (dereferenced === undefined) {
return undefined;
}
return getValueAtPath_rec(dereferenced, rest);
}
function getValueAtPath(
stringifyableObjectOrArray: Record<string, Stringifyable> | Stringifyable[],
path: (string | number)[]
): Stringifyable | undefined {
return getValueAtPath_rec(stringifyableObjectOrArray, path);
}
return { getValueAtPath };
})();

View File

@ -0,0 +1,164 @@
import { getIsAtomic, getValueAtPath, type Stringifyable } from "./Stringifyable";
export function canonicalStringify(params: {
data: Record<string, Stringifyable> | Stringifyable[];
referenceData: Record<string, Stringifyable> | Stringifyable[];
}): string {
const { data, referenceData } = params;
return JSON.stringify(
makeDeterministicCopy({
path: [],
data,
getCanonicalKeys: path => {
const referenceValue = (() => {
const path_patched: (string | number)[] = [];
for (let i = 0; i < path.length; i++) {
let value_i = getValueAtPath(referenceData, [
...path_patched,
path[i]
]);
if (value_i !== undefined) {
path_patched.push(path[i]);
continue;
}
if (typeof path[i] !== "number") {
return undefined;
}
value_i = getValueAtPath(referenceData, [...path_patched, 0]);
if (value_i !== undefined) {
path_patched.push(0);
continue;
}
return undefined;
}
return getValueAtPath(referenceData, path_patched);
})();
if (referenceValue === undefined) {
return undefined;
}
if (getIsAtomic(referenceValue)) {
return undefined;
}
if (referenceValue instanceof Array) {
return undefined;
}
return Object.keys(referenceValue);
}
}),
null,
2
);
}
function makeDeterministicCopy(params: {
path: (string | number)[];
data: Stringifyable;
getCanonicalKeys: (path: (string | number)[]) => string[] | undefined;
}): Stringifyable {
const { path, data, getCanonicalKeys } = params;
if (getIsAtomic(data)) {
return data;
}
if (data instanceof Array) {
return makeDeterministicCopy_array({
path,
data,
getCanonicalKeys
});
}
return makeDeterministicCopy_record({
path,
data,
getCanonicalKeys
});
}
function makeDeterministicCopy_record(params: {
path: (string | number)[];
data: Record<string, Stringifyable>;
getCanonicalKeys: (path: (string | number)[]) => string[] | undefined;
}): Record<string, Stringifyable> {
const { path, data, getCanonicalKeys } = params;
const keysOfAtomicValues: string[] = [];
const keysOfNonAtomicValues: string[] = [];
for (const [key, value] of Object.entries(data)) {
if (getIsAtomic(value)) {
keysOfAtomicValues.push(key);
} else {
keysOfNonAtomicValues.push(key);
}
}
keysOfAtomicValues.sort();
keysOfNonAtomicValues.sort();
const keys = [...keysOfAtomicValues, ...keysOfNonAtomicValues];
reorder_according_to_canonical: {
const canonicalKeys = getCanonicalKeys(path);
if (canonicalKeys === undefined) {
break reorder_according_to_canonical;
}
const keys_toPrepend: string[] = [];
for (const key of canonicalKeys) {
const indexOfKey = keys.indexOf(key);
if (indexOfKey === -1) {
continue;
}
keys.splice(indexOfKey, 1);
keys_toPrepend.push(key);
}
keys.unshift(...keys_toPrepend);
}
const result: Record<string, Stringifyable> = {};
for (const key of keys) {
result[key] = makeDeterministicCopy({
path: [...path, key],
data: data[key],
getCanonicalKeys
});
}
return result;
}
function makeDeterministicCopy_array(params: {
path: (string | number)[];
data: Stringifyable[];
getCanonicalKeys: (path: (string | number)[]) => string[] | undefined;
}): Stringifyable[] {
const { path, data, getCanonicalKeys } = params;
return [...data].map((entry, i) =>
makeDeterministicCopy({
path: [...path, i],
data: entry,
getCanonicalKeys
})
);
}

View File

@ -1,73 +0,0 @@
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.setMaxListeners(Infinity);
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);
}
}

View File

@ -0,0 +1,90 @@
const keyIsTrapped = "isTrapped_zSskDe9d";
export class AccessError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export function createObjectThatThrowsIfAccessed<T extends object>(params?: {
debugMessage?: string;
isPropertyWhitelisted?: (prop: string | number | symbol) => boolean;
}): T {
const { debugMessage = "", isPropertyWhitelisted = () => false } = params ?? {};
const get: NonNullable<ProxyHandler<T>["get"]> = (...args) => {
const [, prop] = args;
if (isPropertyWhitelisted(prop)) {
return Reflect.get(...args);
}
if (prop === keyIsTrapped) {
return true;
}
throw new AccessError(`Cannot access ${String(prop)} yet ${debugMessage}`);
};
const trappedObject = new Proxy<T>({} as any, {
get,
set: get
});
return trappedObject;
}
export function createObjectThatThrowsIfAccessedFactory(params: {
isPropertyWhitelisted?: (prop: string | number | symbol) => boolean;
}) {
const { isPropertyWhitelisted } = params;
return {
createObjectThatThrowsIfAccessed: <T extends object>(params?: {
debugMessage?: string;
}) => {
const { debugMessage } = params ?? {};
return createObjectThatThrowsIfAccessed<T>({
debugMessage,
isPropertyWhitelisted
});
}
};
}
export function isObjectThatThrowIfAccessed(obj: object) {
return (obj as any)[keyIsTrapped] === true;
}
export const THROW_IF_ACCESSED = {
__brand: "THROW_IF_ACCESSED"
};
export function createObjectWithSomePropertiesThatThrowIfAccessed<
T extends Record<string, unknown>
>(obj: { [K in keyof T]: T[K] | typeof THROW_IF_ACCESSED }, debugMessage?: string): T {
return Object.defineProperties(
obj,
Object.fromEntries(
Object.entries(obj)
.filter(([, value]) => value === THROW_IF_ACCESSED)
.map(([key]) => {
const getAndSet = () => {
throw new AccessError(
`Cannot access ${key} yet ${debugMessage ?? ""}`
);
};
const pd = {
get: getAndSet,
set: getAndSet,
enumerable: true
};
return [key, pd];
})
)
) as any;
}

View File

@ -1,61 +0,0 @@
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

@ -14,6 +14,8 @@ export function getAbsoluteAndInOsFormatPath(params: {
let pathOut = pathIsh; let pathOut = pathIsh;
pathOut = pathOut.replace(/^['"]/, "").replace(/['"]$/, "");
pathOut = pathOut.replace(/\//g, pathSep); pathOut = pathOut.replace(/\//g, pathSep);
if (pathOut.startsWith("~")) { if (pathOut.startsWith("~")) {

View File

@ -2,40 +2,42 @@ import { join as pathJoin } from "path";
import { existsAsync } from "./fs.existsAsync"; import { existsAsync } from "./fs.existsAsync";
import * as child_process from "child_process"; import * as child_process from "child_process";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import { getIsRootPath } from "../tools/isRootPath";
export async function getInstalledModuleDirPath(params: { export async function getInstalledModuleDirPath(params: {
moduleName: string; moduleName: string;
packageJsonDirPath: string; packageJsonDirPath: string;
projectDirPath: string;
}) { }) {
const { moduleName, packageJsonDirPath, projectDirPath } = params; const { moduleName, packageJsonDirPath } = params;
common_case: { {
const dirPath = pathJoin( let dirPath = packageJsonDirPath;
...[packageJsonDirPath, "node_modules", ...moduleName.split("/")]
);
if (!(await existsAsync(dirPath))) { while (true) {
break common_case; const dirPath_candidate = pathJoin(
dirPath,
"node_modules",
...moduleName.split("/")
);
let doesExist: boolean;
try {
doesExist = await existsAsync(dirPath_candidate);
} catch {
doesExist = false;
}
if (doesExist) {
return dirPath_candidate;
}
if (getIsRootPath(dirPath)) {
break;
}
dirPath = pathJoin(dirPath, "..");
} }
return dirPath;
}
node_modules_at_root_case: {
if (projectDirPath === packageJsonDirPath) {
break node_modules_at_root_case;
}
const dirPath = pathJoin(
...[projectDirPath, "node_modules", ...moduleName.split("/")]
);
if (!(await existsAsync(dirPath))) {
break node_modules_at_root_case;
}
return dirPath;
} }
const dirPath = child_process const dirPath = child_process

View File

@ -0,0 +1,45 @@
import * as child_process from "child_process";
import {
dirname as pathDirname,
basename as pathBasename,
join as pathJoin,
sep as pathSep
} from "path";
import { Deferred } from "evt/tools/Deferred";
import * as fs from "fs";
export function getIsKnownByGit(params: { filePath: string }): Promise<boolean> {
const { filePath } = params;
const dIsKnownByGit = new Deferred<boolean>();
let relativePath = pathBasename(filePath);
let dirPath = pathDirname(filePath);
while (!fs.existsSync(dirPath)) {
relativePath = pathJoin(pathBasename(dirPath), relativePath);
dirPath = pathDirname(dirPath);
}
child_process.exec(
`git ls-files --error-unmatch '${relativePath.split(pathSep).join("/")}'`,
{ cwd: dirPath },
error => {
if (error === null) {
dIsKnownByGit.resolve(true);
return;
}
if (error.code === 1) {
dIsKnownByGit.resolve(false);
return;
}
dIsKnownByGit.reject(error);
}
);
return dIsKnownByGit.pr;
}

View File

@ -0,0 +1,22 @@
import { normalize as pathNormalize } from "path";
export function getIsRootPath(filePath: string): boolean {
const path_normalized = pathNormalize(filePath);
// Unix-like root ("/")
if (path_normalized === "/") {
return true;
}
// Check for Windows drive root (e.g., "C:\\")
if (/^[a-zA-Z]:\\$/.test(path_normalized)) {
return true;
}
// Check for UNC root (e.g., "\\server\share")
if (/^\\\\[^\\]+\\[^\\]+\\?$/.test(path_normalized)) {
return true;
}
return false;
}

View File

@ -1,29 +0,0 @@
import * as child_process from "child_process";
import { dirname as pathDirname, basename as pathBasename } from "path";
import { Deferred } from "evt/tools/Deferred";
export function getIsTrackedByGit(params: { filePath: string }): Promise<boolean> {
const { filePath } = params;
const dIsTracked = new Deferred<boolean>();
child_process.exec(
`git ls-files --error-unmatch ${pathBasename(filePath)}`,
{ cwd: pathDirname(filePath) },
error => {
if (error === null) {
dIsTracked.resolve(true);
return;
}
if (error.code === 1) {
dIsTracked.resolve(false);
return;
}
dIsTracked.reject(error);
}
);
return dIsTracked.pr;
}

View File

@ -8,7 +8,6 @@ import { exclude } from "tsafe/exclude";
export async function listInstalledModules(params: { export async function listInstalledModules(params: {
packageJsonFilePath: string; packageJsonFilePath: string;
projectDirPath: string;
filter: (params: { moduleName: string }) => boolean; filter: (params: { moduleName: string }) => boolean;
}): Promise< }): Promise<
{ {
@ -18,13 +17,13 @@ export async function listInstalledModules(params: {
peerDependencies: Record<string, string>; peerDependencies: Record<string, string>;
}[] }[]
> { > {
const { packageJsonFilePath, projectDirPath, filter } = params; const { packageJsonFilePath, filter } = params;
const parsedPackageJson = await readPackageJsonDependencies({ const parsedPackageJson = await readPackageJsonDependencies({
packageJsonFilePath packageJsonFilePath
}); });
const uiModuleNames = ( const extensionModuleNames = (
[parsedPackageJson.dependencies, parsedPackageJson.devDependencies] as const [parsedPackageJson.dependencies, parsedPackageJson.devDependencies] as const
) )
.filter(exclude(undefined)) .filter(exclude(undefined))
@ -33,11 +32,10 @@ export async function listInstalledModules(params: {
.filter(moduleName => filter({ moduleName })); .filter(moduleName => filter({ moduleName }));
const result = await Promise.all( const result = await Promise.all(
uiModuleNames.map(async moduleName => { extensionModuleNames.map(async moduleName => {
const dirPath = await getInstalledModuleDirPath({ const dirPath = await getInstalledModuleDirPath({
moduleName, moduleName,
packageJsonDirPath: pathDirname(packageJsonFilePath), packageJsonDirPath: pathDirname(packageJsonFilePath)
projectDirPath
}); });
const { version, peerDependencies } = const { version, peerDependencies } =

View File

@ -1,10 +1,29 @@
import { sep as pathSep } from "path"; import { sep as pathSep, dirname as pathDirname, join as pathJoin } from "path";
import { getThisCodebaseRootDirPath } from "./getThisCodebaseRootDirPath";
import { getInstalledModuleDirPath } from "./getInstalledModuleDirPath";
import { existsAsync } from "./fs.existsAsync";
import { z } from "zod";
import * as fs from "fs/promises";
import { assert, is, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
let cache: string | undefined = undefined; let cache_bestEffort: string | undefined = undefined;
export function getNodeModulesBinDirPath() { /** NOTE: Careful, this function can fail when the binary
if (cache !== undefined) { * Used is not in the node_modules directory of the project
return cache; * (for example when running tests with vscode extension we'll get
* '/Users/dylan/.vscode/extensions/vitest.explorer-1.16.0/dist/worker.js'
*
* instead of
* '/Users/joseph/.nvm/versions/node/v22.12.0/bin/node'
* or
* '/Users/joseph/github/keycloakify-starter/node_modules/.bin/vite'
*
* as the value of process.argv[1]
*/
function getNodeModulesBinDirPath_bestEffort() {
if (cache_bestEffort !== undefined) {
return cache_bestEffort;
} }
const binPath = process.argv[1]; const binPath = process.argv[1];
@ -30,9 +49,122 @@ export function getNodeModulesBinDirPath() {
segments.unshift(segment); segments.unshift(segment);
} }
if (!foundNodeModules) {
throw new Error(`Could not find node_modules in path ${binPath}`);
}
const nodeModulesBinDirPath = segments.join(pathSep); const nodeModulesBinDirPath = segments.join(pathSep);
cache = nodeModulesBinDirPath; cache_bestEffort = nodeModulesBinDirPath;
return nodeModulesBinDirPath; return nodeModulesBinDirPath;
} }
let cache_withPackageJsonFileDirPath:
| { packageJsonFilePath: string; nodeModulesBinDirPath: string }
| undefined = undefined;
async function getNodeModulesBinDirPath_withPackageJsonFileDirPath(params: {
packageJsonFilePath: string;
}): Promise<string> {
const { packageJsonFilePath } = params;
use_cache: {
if (cache_withPackageJsonFileDirPath === undefined) {
break use_cache;
}
if (
cache_withPackageJsonFileDirPath.packageJsonFilePath !== packageJsonFilePath
) {
cache_withPackageJsonFileDirPath = undefined;
break use_cache;
}
return cache_withPackageJsonFileDirPath.nodeModulesBinDirPath;
}
// [...]node_modules/keycloakify
const installedModuleDirPath = await getInstalledModuleDirPath({
// Here it will always be "keycloakify" but since we are in tools/ we make something generic
moduleName: await (async () => {
type ParsedPackageJson = {
name: string;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
name: z.string()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>;
return id<z.ZodType<TargetType>>(zTargetType);
})();
const parsedPackageJson = JSON.parse(
(
await fs.readFile(
pathJoin(getThisCodebaseRootDirPath(), "package.json")
)
).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson.name;
})(),
packageJsonDirPath: pathDirname(packageJsonFilePath)
});
const segments = installedModuleDirPath.split(pathSep);
while (true) {
const segment = segments.pop();
if (segment === undefined) {
throw new Error(
`Could not find .bin directory relative to ${packageJsonFilePath}`
);
}
if (segment !== "node_modules") {
continue;
}
const candidate = pathJoin(segments.join(pathSep), segment, ".bin");
if (!(await existsAsync(candidate))) {
continue;
}
cache_withPackageJsonFileDirPath = {
packageJsonFilePath,
nodeModulesBinDirPath: candidate
};
break;
}
return cache_withPackageJsonFileDirPath.nodeModulesBinDirPath;
}
export function getNodeModulesBinDirPath(params: {
packageJsonFilePath: string;
}): Promise<string>;
export function getNodeModulesBinDirPath(params: {
packageJsonFilePath: undefined;
}): string;
export function getNodeModulesBinDirPath(params: {
packageJsonFilePath: string | undefined;
}): string | Promise<string> {
const { packageJsonFilePath } = params ?? {};
return packageJsonFilePath === undefined
? getNodeModulesBinDirPath_bestEffort()
: getNodeModulesBinDirPath_withPackageJsonFileDirPath({ packageJsonFilePath });
}

View File

@ -9,8 +9,9 @@ import { objectKeys } from "tsafe/objectKeys";
import { getAbsoluteAndInOsFormatPath } from "./getAbsoluteAndInOsFormatPath"; import { getAbsoluteAndInOsFormatPath } from "./getAbsoluteAndInOsFormatPath";
import { exclude } from "tsafe/exclude"; import { exclude } from "tsafe/exclude";
import { rmSync } from "./fs.rmSync"; import { rmSync } from "./fs.rmSync";
import { Deferred } from "evt/tools/Deferred";
export function npmInstall(params: { packageJsonDirPath: string }) { export async function npmInstall(params: { packageJsonDirPath: string }) {
const { packageJsonDirPath } = params; const { packageJsonDirPath } = params;
const packageManagerBinName = (() => { const packageManagerBinName = (() => {
@ -68,7 +69,7 @@ export function npmInstall(params: { packageJsonDirPath: string }) {
console.log(chalk.green("Installing in a way that won't break the links...")); console.log(chalk.green("Installing in a way that won't break the links..."));
installWithoutBreakingLinks({ await installWithoutBreakingLinks({
packageJsonDirPath, packageJsonDirPath,
garronejLinkInfos garronejLinkInfos
}); });
@ -77,9 +78,9 @@ export function npmInstall(params: { packageJsonDirPath: string }) {
} }
try { try {
child_process.execSync(`${packageManagerBinName} install`, { await runPackageManagerInstall({
cwd: packageJsonDirPath, packageManagerBinName,
stdio: "inherit" cwd: packageJsonDirPath
}); });
} catch { } catch {
console.log( console.log(
@ -90,6 +91,42 @@ export function npmInstall(params: { packageJsonDirPath: string }) {
} }
} }
async function runPackageManagerInstall(params: {
packageManagerBinName: string;
cwd: string;
}) {
const { packageManagerBinName, cwd } = params;
const dCompleted = new Deferred<void>();
const child = child_process.spawn(packageManagerBinName, ["install"], {
cwd,
env: process.env,
shell: true
});
child.stdout.on("data", data => process.stdout.write(data));
child.stderr.on("data", data => {
if (data.toString("utf8").includes("peer dependency")) {
return;
}
process.stderr.write(data);
});
child.on("exit", code => {
if (code !== 0) {
dCompleted.reject(new Error(`Failed with code ${code}`));
return;
}
dCompleted.resolve();
});
await dCompleted.pr;
}
function getGarronejLinkInfos(params: { function getGarronejLinkInfos(params: {
packageJsonDirPath: string; packageJsonDirPath: string;
}): { linkedModuleNames: string[]; yarnHomeDirPath: string } | undefined { }): { linkedModuleNames: string[]; yarnHomeDirPath: string } | undefined {
@ -180,7 +217,7 @@ function getGarronejLinkInfos(params: {
return { linkedModuleNames, yarnHomeDirPath }; return { linkedModuleNames, yarnHomeDirPath };
} }
function installWithoutBreakingLinks(params: { async function installWithoutBreakingLinks(params: {
packageJsonDirPath: string; packageJsonDirPath: string;
garronejLinkInfos: Exclude<ReturnType<typeof getGarronejLinkInfos>, undefined>; garronejLinkInfos: Exclude<ReturnType<typeof getGarronejLinkInfos>, undefined>;
}) { }) {
@ -261,9 +298,9 @@ function installWithoutBreakingLinks(params: {
pathJoin(tmpProjectDirPath, YARN_LOCK) pathJoin(tmpProjectDirPath, YARN_LOCK)
); );
child_process.execSync(`yarn install`, { await runPackageManagerInstall({
cwd: tmpProjectDirPath, packageManagerBinName: "yarn",
stdio: "inherit" cwd: tmpProjectDirPath
}); });
// NOTE: Moving the modules from the tmp project to the actual project // NOTE: Moving the modules from the tmp project to the actual project

View File

@ -1,47 +0,0 @@
import { listTagsFactory } from "./listTags";
import type { Octokit } from "@octokit/rest";
import { SemVer } from "../SemVer";
export function getLatestsSemVersionedTagFactory(params: { octokit: Octokit }) {
const { octokit } = params;
async function getLatestsSemVersionedTag(params: {
owner: string;
repo: string;
count: number;
doIgnoreReleaseCandidates: boolean;
}): Promise<
{
tag: string;
version: SemVer;
}[]
> {
const { owner, repo, count, doIgnoreReleaseCandidates } = params;
const semVersionedTags: { tag: string; version: SemVer }[] = [];
const { listTags } = listTagsFactory({ octokit });
for await (const tag of listTags({ owner, repo })) {
let version: SemVer;
try {
version = SemVer.parse(tag.replace(/^[vV]?/, ""));
} catch {
continue;
}
if (doIgnoreReleaseCandidates && version.rc !== undefined) {
continue;
}
semVersionedTags.push({ tag, version });
}
return semVersionedTags
.sort(({ version: vX }, { version: vY }) => SemVer.compare(vY, vX))
.slice(0, count);
}
return { getLatestsSemVersionedTag };
}

View File

@ -1,60 +0,0 @@
import type { Octokit } from "@octokit/rest";
const per_page = 99;
export function listTagsFactory(params: { octokit: Octokit }) {
const { octokit } = params;
const octokit_repo_listTags = async (params: {
owner: string;
repo: string;
per_page: number;
page: number;
}) => {
return octokit.repos.listTags(params);
};
async function* listTags(params: {
owner: string;
repo: string;
}): AsyncGenerator<string> {
const { owner, repo } = params;
let page = 1;
while (true) {
const resp = await octokit_repo_listTags({
owner,
repo,
per_page,
page: page++
});
for (const branch of resp.data.map(({ name }) => name)) {
yield branch;
}
if (resp.data.length < 99) {
break;
}
}
}
/** Returns the same "latest" tag as deno.land/x, not actually the latest though */
async function getLatestTag(params: {
owner: string;
repo: string;
}): Promise<string | undefined> {
const { owner, repo } = params;
const itRes = await listTags({ owner, repo }).next();
if (itRes.done) {
return undefined;
}
return itRes.value;
}
return { listTags, getLatestTag };
}

View File

@ -15,7 +15,9 @@ export async function getIsPrettierAvailable(): Promise<boolean> {
return getIsPrettierAvailable.cache; return getIsPrettierAvailable.cache;
} }
const nodeModulesBinDirPath = getNodeModulesBinDirPath(); const nodeModulesBinDirPath = getNodeModulesBinDirPath({
packageJsonFilePath: undefined
});
const prettierBinPath = pathJoin(nodeModulesBinDirPath, "prettier"); const prettierBinPath = pathJoin(nodeModulesBinDirPath, "prettier");
@ -50,10 +52,26 @@ export async function getPrettier(): Promise<PrettierAndConfigHash> {
// So we do a sketchy eval to bypass ncc. // So we do a sketchy eval to bypass ncc.
// We make sure to only do that when linking, otherwise we import properly. // We make sure to only do that when linking, otherwise we import properly.
if (readThisNpmPackageVersion().startsWith("0.0.0")) { if (readThisNpmPackageVersion().startsWith("0.0.0")) {
eval( const prettierDirPath = pathResolve(
`${symToStr({ prettier })} = require("${pathResolve(pathJoin(getNodeModulesBinDirPath(), "..", "prettier"))}")` pathJoin(
getNodeModulesBinDirPath({ packageJsonFilePath: undefined }),
"..",
"prettier"
)
); );
const isCJS = typeof module !== "undefined" && module.exports;
if (isCJS) {
eval(`${symToStr({ prettier })} = require("${prettierDirPath}")`);
} else {
prettier = await new Promise(_resolve => {
eval(
`import("file:///${pathJoin(prettierDirPath, "index.mjs").replace(/\\/g, "/")}").then(prettier => _resolve(prettier))`
);
});
}
assert(!is<undefined>(prettier)); assert(!is<undefined>(prettier));
break import_prettier; break import_prettier;
@ -64,7 +82,7 @@ export async function getPrettier(): Promise<PrettierAndConfigHash> {
const configHash = await (async () => { const configHash = await (async () => {
const configFilePath = await prettier.resolveConfigFile( const configFilePath = await prettier.resolveConfigFile(
pathJoin(getNodeModulesBinDirPath(), "..") pathJoin(getNodeModulesBinDirPath({ packageJsonFilePath: undefined }), "..")
); );
if (configFilePath === null) { if (configFilePath === null) {

View File

@ -1,39 +0,0 @@
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.setMaxListeners(Infinity);
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

@ -1,49 +0,0 @@
/**
* Concatenate the string fragments and interpolated values
* to get a single string.
*/
function populateTemplate(strings: TemplateStringsArray, ...args: unknown[]) {
const chunks: string[] = [];
for (let i = 0; i < strings.length; i++) {
let lastStringLineLength = 0;
if (strings[i]) {
chunks.push(strings[i]);
// remember last indent of the string portion
lastStringLineLength = strings[i].split("\n").slice(-1)[0]?.length ?? 0;
}
if (args[i]) {
// if the interpolation value has newlines, indent the interpolation values
// using the last known string indent
const chunk = String(args[i]).replace(
/([\r?\n])/g,
"$1" + " ".repeat(lastStringLineLength)
);
chunks.push(chunk);
}
}
return chunks.join("");
}
/**
* Shift all lines left by the *smallest* indentation level,
* and remove initial newline and all trailing spaces.
*/
export default function trimIndent(strings: TemplateStringsArray, ...args: any[]) {
// Remove initial and final newlines
let string = populateTemplate(strings, ...args)
.replace(/^[\r\n]/, "")
.replace(/\r?\n *$/, "");
const dents =
string
.match(/^([ \t])+/gm)
?.filter(s => /^\s+$/.test(s))
?.map(s => s.length) ?? [];
// No dents? no change required
if (!dents || dents.length == 0) return string;
const minDent = Math.min(...dents);
// The min indentation is 0, no change needed
if (!minDent) return string;
const re = new RegExp(`^${" ".repeat(minDent)}`, "gm");
const dedented = string.replace(re, "");
return dedented;
}

View File

@ -1,15 +1,31 @@
import * as child_process from "child_process"; import * as child_process from "child_process";
import { dirname as pathDirname, basename as pathBasename } from "path"; import {
dirname as pathDirname,
basename as pathBasename,
join as pathJoin,
sep as pathSep
} from "path";
import { Deferred } from "evt/tools/Deferred"; import { Deferred } from "evt/tools/Deferred";
import { existsAsync } from "./fs.existsAsync";
export async function untrackFromGit(params: { filePath: string }): Promise<void> { export async function untrackFromGit(params: { filePath: string }): Promise<void> {
const { filePath } = params; const { filePath } = params;
const dDone = new Deferred<void>(); const dDone = new Deferred<void>();
let relativePath = pathBasename(filePath);
let dirPath = pathDirname(filePath);
while (!(await existsAsync(dirPath))) {
relativePath = pathJoin(pathBasename(dirPath), relativePath);
dirPath = pathDirname(dirPath);
}
child_process.exec( child_process.exec(
`git rm --cached ${pathBasename(filePath)}`, `git rm --cached '${relativePath.split(pathSep).join("/")}'`,
{ cwd: pathDirname(filePath) }, { cwd: dirPath },
error => { error => {
if (error !== null) { if (error !== null) {
dDone.reject(error); dDone.reject(error);

View File

@ -10,5 +10,5 @@
"rootDir": "." "rootDir": "."
}, },
"include": ["**/*.ts", "**/*.tsx"], "include": ["**/*.ts", "**/*.tsx"],
"exclude": ["initialize-account-theme/src"] "exclude": ["initialize-account-theme/multi-page-boilerplate"]
} }

View File

@ -19,7 +19,7 @@ export async function command(params: { buildContext: BuildContext }) {
await command({ buildContext }); await command({ buildContext });
} }
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "update-kc-gen", commandName: "update-kc-gen",
buildContext buildContext
}); });

View File

@ -769,6 +769,8 @@ export declare namespace Validators {
export type PasswordPolicies = { export type PasswordPolicies = {
/** The minimum length of the password */ /** The minimum length of the password */
length?: number; length?: number;
/** The maximum length of the password */
maxLength?: number;
/** The minimum number of digits required in the password */ /** The minimum number of digits required in the password */
digits?: number; digits?: number;
/** The minimum number of lowercase characters required in the password */ /** The minimum number of lowercase characters required in the password */

View File

@ -1,6 +1,7 @@
import type { JSX } from "keycloakify/tools/JSX"; import type { JSX } from "keycloakify/tools/JSX";
import { useEffect, useReducer, Fragment } from "react"; import { useEffect, Fragment } from "react";
import { assert } from "keycloakify/tools/assert"; import { assert } from "keycloakify/tools/assert";
import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
import type { KcClsx } from "keycloakify/login/lib/kcClsx"; import type { KcClsx } from "keycloakify/login/lib/kcClsx";
import { import {
useUserProfileForm, useUserProfileForm,
@ -89,7 +90,6 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps<
{advancedMsg(attribute.annotations.inputHelperTextAfter)} {advancedMsg(attribute.annotations.inputHelperTextAfter)}
</div> </div>
)} )}
{AfterField !== undefined && ( {AfterField !== undefined && (
<AfterField <AfterField
attribute={attribute} attribute={attribute}
@ -106,6 +106,10 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps<
</Fragment> </Fragment>
); );
})} })}
{/* See: https://github.com/keycloak/keycloak/issues/38029 */}
{kcContext.locale !== undefined && formFieldStates.find(x => x.attribute.name === "locale") === undefined && (
<input type="hidden" name="locale" value={i18n.currentLanguage.languageTag} />
)}
</> </>
); );
} }
@ -249,15 +253,7 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
const { msgStr } = i18n; const { msgStr } = i18n;
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false); const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
useEffect(() => {
const passwordInputElement = document.getElementById(passwordInputId);
assert(passwordInputElement instanceof HTMLInputElement);
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
}, [isPasswordRevealed]);
return ( return (
<div className={kcClsx("kcInputGroup")}> <div className={kcClsx("kcInputGroup")}>

View File

@ -509,6 +509,8 @@ function formStateSelector(params: { state: internal.State }): FormState {
switch (error.source.name) { switch (error.source.name) {
case "length": case "length":
return hasLostFocusAtLeastOnce; return hasLostFocusAtLeastOnce;
case "maxLength":
return hasLostFocusAtLeastOnce;
case "digits": case "digits":
return hasLostFocusAtLeastOnce; return hasLostFocusAtLeastOnce;
case "lowerCase": case "lowerCase":
@ -967,6 +969,34 @@ function createGetErrors(params: { kcContext: KcContextLike_useGetErrors }) {
}); });
} }
check_password_policy_x: {
const policyName = "maxLength";
const policy = passwordPolicies[policyName];
if (!policy) {
break check_password_policy_x;
}
const maxLength = policy;
if (value.length <= maxLength) {
break check_password_policy_x;
}
errors.push({
advancedMsgArgs: [
"invalidPasswordMaxLengthMessage" satisfies MessageKey_defaultSet,
`${maxLength}`
] as const,
fieldIndex: undefined,
source: {
type: "passwordPolicy",
name: policyName
}
});
}
check_password_policy_x: { check_password_policy_x: {
const policyName = "digits"; const policyName = "digits";

View File

@ -31,10 +31,10 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: kcSanitize( __html: kcSanitize(
(() => { (() => {
let html = message.summary; let html = message.summary?.trim();
if (requiredActions) { if (requiredActions) {
html += "<b>"; html += " <b>";
html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(", "); html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(", ");

View File

@ -1,7 +1,7 @@
import type { JSX } from "keycloakify/tools/JSX"; import type { JSX } from "keycloakify/tools/JSX";
import { useState, useEffect, useReducer } from "react"; import { useState } from "react";
import { kcSanitize } from "keycloakify/lib/kcSanitize"; import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { assert } from "keycloakify/tools/assert"; import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
import { clsx } from "keycloakify/tools/clsx"; import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx"; import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
@ -200,15 +200,7 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
const { msgStr } = i18n; const { msgStr } = i18n;
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false); const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
useEffect(() => {
const passwordInputElement = document.getElementById(passwordInputId);
assert(passwordInputElement instanceof HTMLInputElement);
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
}, [isPasswordRevealed]);
return ( return (
<div className={kcClsx("kcInputGroup")}> <div className={kcClsx("kcInputGroup")}>

View File

@ -1,4 +1,4 @@
import { Fragment } from "react"; import { Fragment, useState } from "react";
import { getKcClsx } from "keycloakify/login/lib/kcClsx"; import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize"; import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
@ -17,6 +17,8 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
const [isSubmitting, setIsSubmitting] = useState(false);
return ( return (
<Template <Template
kcContext={kcContext} kcContext={kcContext}
@ -26,7 +28,16 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
displayMessage={!messagesPerField.existsError("totp")} displayMessage={!messagesPerField.existsError("totp")}
headerNode={msg("doLogIn")} headerNode={msg("doLogIn")}
> >
<form id="kc-otp-login-form" className={kcClsx("kcFormClass")} action={url.loginAction} method="post"> <form
id="kc-otp-login-form"
className={kcClsx("kcFormClass")}
action={url.loginAction}
onSubmit={() => {
setIsSubmitting(true);
return true;
}}
method="post"
>
{otpLogin.userOtpCredentials.length > 1 && ( {otpLogin.userOtpCredentials.length > 1 && (
<div className={kcClsx("kcFormGroupClass")}> <div className={kcClsx("kcFormGroupClass")}>
<div className={kcClsx("kcInputWrapperClass")}> <div className={kcClsx("kcInputWrapperClass")}>
@ -94,6 +105,7 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
id="kc-login" id="kc-login"
type="submit" type="submit"
value={msgStr("doLogIn")} value={msgStr("doLogIn")}
disabled={isSubmitting}
/> />
</div> </div>
</div> </div>

View File

@ -1,8 +1,8 @@
import type { JSX } from "keycloakify/tools/JSX"; import type { JSX } from "keycloakify/tools/JSX";
import { useState, useEffect, useReducer } from "react"; import { useState } from "react";
import { kcSanitize } from "keycloakify/lib/kcSanitize"; import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { clsx } from "keycloakify/tools/clsx"; import { clsx } from "keycloakify/tools/clsx";
import { assert } from "keycloakify/tools/assert"; import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx"; import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
@ -107,15 +107,7 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
const { msgStr } = i18n; const { msgStr } = i18n;
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false); const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
useEffect(() => {
const passwordInputElement = document.getElementById(passwordInputId);
assert(passwordInputElement instanceof HTMLInputElement);
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
}, [isPasswordRevealed]);
return ( return (
<div className={kcClsx("kcInputGroup")}> <div className={kcClsx("kcInputGroup")}>

View File

@ -1,7 +1,6 @@
import type { JSX } from "keycloakify/tools/JSX"; import type { JSX } from "keycloakify/tools/JSX";
import { useEffect, useReducer } from "react"; import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
import { kcSanitize } from "keycloakify/lib/kcSanitize"; import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { assert } from "keycloakify/tools/assert";
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx"; import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
@ -146,15 +145,7 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
const { msgStr } = i18n; const { msgStr } = i18n;
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false); const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
useEffect(() => {
const passwordInputElement = document.getElementById(passwordInputId);
assert(passwordInputElement instanceof HTMLInputElement);
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
}, [isPasswordRevealed]);
return ( return (
<div className={kcClsx("kcInputGroup")}> <div className={kcClsx("kcInputGroup")}>

View File

@ -98,7 +98,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
defaultValue={login.username ?? ""} defaultValue={login.username ?? ""}
type="text" type="text"
autoFocus autoFocus
autoComplete="off" autoComplete="username"
aria-invalid={messagesPerField.existsError("username")} aria-invalid={messagesPerField.existsError("username")}
/> />
{messagesPerField.existsError("username") && ( {messagesPerField.existsError("username") && (

View File

@ -0,0 +1,45 @@
import { useEffect, useReducer } from "react";
import { assert } from "keycloakify/tools/assert";
/**
* Initially false, state that enables to dynamically control if
* the type of a password input is "password" (false) or "text" (true).
*/
export function useIsPasswordRevealed(params: { passwordInputId: string }) {
const { passwordInputId } = params;
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer(
(isPasswordRevealed: boolean) => !isPasswordRevealed,
false
);
useEffect(() => {
const passwordInputElement = document.getElementById(passwordInputId);
assert(passwordInputElement instanceof HTMLInputElement);
const type = isPasswordRevealed ? "text" : "password";
passwordInputElement.type = type;
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.attributeName !== "type") {
return;
}
if (passwordInputElement.type === type) {
return;
}
passwordInputElement.type = type;
});
});
observer.observe(passwordInputElement, { attributes: true });
return () => {
observer.disconnect();
};
}, [isPasswordRevealed]);
return { isPasswordRevealed, toggleIsPasswordRevealed };
}

View File

@ -155,8 +155,9 @@ export function keycloakify(params: keycloakify.Params) {
{ {
const isJavascriptFile = id.endsWith(".js") || id.endsWith(".jsx"); const isJavascriptFile = id.endsWith(".js") || id.endsWith(".jsx");
const isTypeScriptFile = id.endsWith(".ts") || id.endsWith(".tsx"); const isTypeScriptFile = id.endsWith(".ts") || id.endsWith(".tsx");
const isSvelteFile = id.endsWith(".svelte");
if (!isTypeScriptFile && !isJavascriptFile) { if (!isTypeScriptFile && !isJavascriptFile && !isSvelteFile) {
return; return;
} }
} }

View File

@ -46,7 +46,7 @@ export const WithRequiredActions: Story = {
kcContext={{ kcContext={{
messageHeader: "Message header", messageHeader: "Message header",
message: { message: {
summary: "Required actions: " summary: "Required actions:"
}, },
requiredActions: ["CONFIGURE_TOTP", "UPDATE_PROFILE", "VERIFY_EMAIL", "CUSTOM_ACTION"], requiredActions: ["CONFIGURE_TOTP", "UPDATE_PROFILE", "VERIFY_EMAIL", "CUSTOM_ACTION"],
"x-keycloakify": { "x-keycloakify": {

View File

@ -62,3 +62,22 @@ export const WithPasswordConfirmError: Story = {
/> />
) )
}; };
/**
* WithAppInitiatedAction:
* - Purpose: Tests when the update password action was triggered by an app.
* - Scenario: Simulates the case where the user presses a 'change password' button in an app and is redirected to Keycloak to change it.
* - Key Aspect: Ensures the 'Cancel' button is shown correctly, which displays only when the action is app initiated.
*/
export const WithAppInitiatedAction: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login-action"
},
isAppInitiatedAction: true
}}
/>
)
};

View File

@ -17,5 +17,5 @@
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["../src", "."], "include": ["../src", "."],
"exclude": ["../src/bin/initialize-account-theme/src"] "exclude": ["../src/bin/initialize-account-theme/multi-page-boilerplate"]
} }