Compare commits

..

2 Commits

Author SHA1 Message Date
97e720e8f9 Bump version 2023-06-15 17:14:18 +02:00
55bf98f4b8 #359 2023-06-15 17:10:15 +02:00
137 changed files with 2724 additions and 7019 deletions

View File

@ -158,79 +158,6 @@
"contributions": [
"code"
]
},
{
"login": "zavoloklom",
"name": "Sergey Kupletsky",
"avatar_url": "https://avatars.githubusercontent.com/u/4151869?v=4",
"profile": "https://github.com/zavoloklom",
"contributions": [
"test",
"code"
]
},
{
"login": "rome-user",
"name": "rome-user",
"avatar_url": "https://avatars.githubusercontent.com/u/114131048?v=4",
"profile": "https://github.com/rome-user",
"contributions": [
"code"
]
},
{
"login": "celinepelletier",
"name": "Céline Pelletier",
"avatar_url": "https://avatars.githubusercontent.com/u/82821620?v=4",
"profile": "https://github.com/celinepelletier",
"contributions": [
"code"
]
},
{
"login": "xgp",
"name": "Garth",
"avatar_url": "https://avatars.githubusercontent.com/u/244253?v=4",
"profile": "https://github.com/xgp",
"contributions": [
"code"
]
},
{
"login": "BlackVoid",
"name": "Felix Gustavsson",
"avatar_url": "https://avatars.githubusercontent.com/u/673720?v=4",
"profile": "https://github.com/BlackVoid",
"contributions": [
"code"
]
},
{
"login": "msiemens",
"name": "Markus Siemens",
"avatar_url": "https://avatars.githubusercontent.com/u/1873922?v=4",
"profile": "https://m-siemens.de/",
"contributions": [
"code"
]
},
{
"login": "law108000",
"name": "Rlok",
"avatar_url": "https://avatars.githubusercontent.com/u/8112024?v=4",
"profile": "https://github.com/law108000",
"contributions": [
"code"
]
},
{
"login": "Moulyy",
"name": "Moulyy",
"avatar_url": "https://avatars.githubusercontent.com/u/115405804?v=4",
"profile": "https://github.com/Moulyy",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
@ -238,6 +165,5 @@
"repoType": "github",
"repoHost": "https://github.com",
"projectName": "keycloakify",
"projectOwner": "keycloakify",
"commitType": "docs"
"projectOwner": "keycloakify"
}

View File

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

126
README.md
View File

@ -14,15 +14,15 @@
<a href="https://github.com/garronej/keycloakify/blob/main/LICENSE">
<img src="https://img.shields.io/npm/l/keycloakify">
</a>
<a href="https://github.com/keycloakify/keycloakify/blob/729503fe31a155a823f46dd66ad4ff34ca274e0a/tsconfig.json#L14">
<img src="https://camo.githubusercontent.com/0f9fcc0ac1b8617ad4989364f60f78b2d6b32985ad6a508f215f14d8f897b8d3/68747470733a2f2f62616467656e2e6e65742f62616467652f547970655363726970742f7374726963742532302546302539462539322541412f626c7565">
</a>
<a href="https://github.com/thomasdarimont/awesome-keycloak">
<img src="https://awesome.re/mentioned-badge.svg"/>
</a>
<p align="center">
Check out our discord server!<br/>
<a href="https://discord.gg/mJdYJSdcm4">
<img src="https://dcbadge.limes.pink/api/server/kYFZG7fQmn"/>
</a>
</p>
<a href="https://discord.gg/kYFZG7fQmn">
<img src="https://img.shields.io/discord/1097708346976505977"/>
</a>
<p align="center">
<a href="https://www.keycloakify.dev">Home</a>
-
@ -36,20 +36,16 @@
<p align="center">
<i>This build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
<br/>
<br/>
<img width="400" src="https://github.com/keycloakify/keycloakify/assets/6702424/e66d105c-c06f-47d1-8a31-a6ab09da4e80">
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
</p>
> WARNING: Do not use Keycloakify v9 if you are staring out!
> Use the latest release candidate of Keycloakify v10 instead.
> `yarn add keycloakify@next` See [npm](https://www.npmjs.com/package/keycloakify?activeTab=versions).
> Whether or not React is your preferred framework, Keycloakify
> offers a solid option for building Keycloak themes.
> It's not just a convenient way to create a Keycloak theme
> when using React; it's a well-regarded solution that many
> developers appreciate.
Keycloakify is fully compatible with Keycloak 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, [~~22~~](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1822509763), 23, 24, 25...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
> NOTE: Keycloakify 10 is still in realase-candidate state. [Follow progress](https://github.com/keycloakify/keycloakify/pull/538).
## Sponsor
## Sponsor 👼
We are exclusively sponsored by [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), a French company offering Keycloak as a service.
Their dedicated support helps us continue the development and maintenance of this project.
@ -72,12 +68,12 @@ Their dedicated support helps us continue the development and maintenance of thi
</div>
<p align="center">
<i>Checkout <a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github">Cloud-IAM</a> and use promo code <code>keycloakify5</code></i>
<i>Checkout <a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github">Cloud IAM</a> and use promo code <code>keycloakify5</code></i>
<br/>
<i>5% of your annual subscription will be donated to us, and you'll get 5% off too.</i>
</p>
Thank you, [Cloud-IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), for your support!
Thank you, [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), for your support!
## Contributors ✨
@ -110,17 +106,6 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://www.gravitysoftware.be"><img src="https://avatars.githubusercontent.com/u/1140574?v=4?s=100" width="100px;" alt="Thomas Silvestre"/><br /><sub><b>Thomas Silvestre</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=thosil" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/satanshiro"><img src="https://avatars.githubusercontent.com/u/38865738?v=4?s=100" width="100px;" alt="satanshiro"/><br /><sub><b>satanshiro</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=satanshiro" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://poelhekke.dev"><img src="https://avatars.githubusercontent.com/u/1632377?v=4?s=100" width="100px;" alt="Koen Poelhekke"/><br /><sub><b>Koen Poelhekke</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kpoelhekke" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zavoloklom"><img src="https://avatars.githubusercontent.com/u/4151869?v=4?s=100" width="100px;" alt="Sergey Kupletsky"/><br /><sub><b>Sergey Kupletsky</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=zavoloklom" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=zavoloklom" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rome-user"><img src="https://avatars.githubusercontent.com/u/114131048?v=4?s=100" width="100px;" alt="rome-user"/><br /><sub><b>rome-user</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=rome-user" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/celinepelletier"><img src="https://avatars.githubusercontent.com/u/82821620?v=4?s=100" width="100px;" alt="Céline Pelletier"/><br /><sub><b>Céline Pelletier</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=celinepelletier" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xgp"><img src="https://avatars.githubusercontent.com/u/244253?v=4?s=100" width="100px;" alt="Garth"/><br /><sub><b>Garth</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=xgp" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/BlackVoid"><img src="https://avatars.githubusercontent.com/u/673720?v=4?s=100" width="100px;" alt="Felix Gustavsson"/><br /><sub><b>Felix Gustavsson</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=BlackVoid" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://m-siemens.de/"><img src="https://avatars.githubusercontent.com/u/1873922?v=4?s=100" width="100px;" alt="Markus Siemens"/><br /><sub><b>Markus Siemens</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=msiemens" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/law108000"><img src="https://avatars.githubusercontent.com/u/8112024?v=4?s=100" width="100px;" alt="Rlok"/><br /><sub><b>Rlok</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=law108000" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Moulyy"><img src="https://avatars.githubusercontent.com/u/115405804?v=4?s=100" width="100px;" alt="Moulyy"/><br /><sub><b>Moulyy</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=Moulyy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/giorgoslytos"><img src="https://avatars.githubusercontent.com/u/50946162?v=4?s=100" width="100px;" alt="giorgoslytos"/><br /><sub><b>giorgoslytos</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=giorgoslytos" title="Code">💻</a></td>
</tr>
</tbody>
</table>
@ -132,59 +117,10 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
# Changelog highlights
## 9.5
- Post build hook: You can now apply custom transformation to your theme files. [Learn more](https://docs.keycloakify.dev/build-options#postbuild-hook).
- You can now specify your option in the Keycloakify's Vite plugin instead in the package.json. [See example](https://docs.keycloakify.dev/build-options#themename).
## 9.4
**Vite Support! 🎉**
- [The starter is now a Vite project](https://github.com/keycloakify/keycloakify-starter).
The Webpack based starter is accessible [here](https://github.com/keycloakify/keycloakify-starter-cra).
- CRA (Webpack) remains supported for the forseable future.
- If you have a CRA Keycloakify theme that you wish to migrate to Vite checkout [this migration guide](https://docs.keycloakify.dev/migration-guides/cra-greater-than-vite).
## 9.0
Bring back support for account themes in Keycloak v23 and up! [See issue](https://github.com/keycloakify/keycloakify/issues/389).
### Breaking changes
Very few. Check them out [here](https://docs.keycloakify.dev/migration-guides/v8-greater-than-v9).
## 8.0
- Much smaller .jar size. 70.2 MB -> 7.8 MB.
Keycloakify now detects which of the static resources from the default theme are actually used by your theme and only include those in the .jar.
- Build time: The first build is slowed but the subsequent build are faster. [Update your CI so that the cache is persisted across CI build](https://github.com/keycloakify/keycloakify-starter/commit/bc378d5afb67e796f520afbc348185f3e319d9d0).
### Breaking changes
There are very few breaking changes in this major version. [Check them out](https://docs.keycloakify.dev/migration-guides/v7-greater-than-v8).
## 7.15
- The i18n messages you defines in your theme are now also maid available to Keycloak.
In practice this mean that you can now customize the `kcContext.message.summary` that
display a general alert and the values returned by `kcContext.messagesPerField.get()` that
are used to display specific error on some field of the form.
[See video](https://youtu.be/D6tZcemReTI)
## 7.14
- Deprecate the `extraPages` build option. Keycloakify is now able to analyze your code to detect extra pages.
## 7.13
- Deprecate `customUserAttribute`, Keycloakify now analyze your code to predict field name usage. [See doc](https://docs.keycloakify.dev/build-options#customuserattributes).
It's now mandatory to [adopt the new directory structure](https://docs.keycloakify.dev/migration-guides/v6-greater-than-v7).
## 7.12
- You can now pack multiple themes variant in a single `.jar` bundle. In vanilla Keycloak themes you have the ability to extend a base theme.
There is now an idiomatic way of achieving the same result. [Learn more](https://docs.keycloakify.dev/build-options#keycloakify.themeVariantNames).
There is now an idiomatic way of achieving the same result. [Learn more](https://docs.keycloakify.dev/build-options#keycloakify.extrathemenames).
## 7.9
@ -279,79 +215,79 @@ Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6)
Fix `login-verify-email.ftl` page. [Before](https://user-images.githubusercontent.com/6702424/177436014-0bad22c4-5bfb-45bb-8fc9-dad65143cd0c.png) - [After](https://user-images.githubusercontent.com/6702424/177435797-ec5d7db3-84cf-49cb-8efc-3427a81f744e.png)
## 5.6.0
## v5.6.0
Add support for `login-config-totp.ftl` page [#127](https://github.com/keycloakify/keycloakify/pull/127).
## 5.3.0
## v5.3.0
Rename `keycloak_theme_email` to `keycloak_email`.
If you already had a `keycloak_theme_email` you should rename it `keycloak_email`.
## 5.0.0
## v5.0.0
[Migration guide](https://github.com/garronej/keycloakify-demo-app/blob/a5b6a50f24bc25e082931f5ad9ebf47492acd12a/src/index.tsx#L46-L63)
New i18n system.
Import of terms and services have changed. [See example](https://github.com/garronej/keycloakify-demo-app/blob/a5b6a50f24bc25e082931f5ad9ebf47492acd12a/src/index.tsx#L46-L63).
## 4.10.0
## v4.10.0
Add `login-idp-link-email.ftl` page [See PR](https://github.com/keycloakify/keycloakify/pull/92).
## 4.8.0
## v4.8.0
[Email template customization.](#email-template-customization)
## 4.7.4
## v4.7.4
**M1 Mac** support (for testing locally with a dockerized Keycloak).
## 4.7.2
## v4.7.2
> WARNING: This is broken.
> Testing with local Keycloak container working with M1 Mac. Thanks to [@eduardosanzb](https://github.com/keycloakify/keycloakify/issues/43#issuecomment-975699658).
> Be aware: When running M1s you are testing with Keycloak v15 else the local container spun will be a Keycloak v16.1.0.
## 4.7.0
## v4.7.0
Register with user profile enabled: Out of the box `options` validator support.
[Example](https://user-images.githubusercontent.com/6702424/158911163-81e6bbe8-feb0-4dc8-abff-de199d7a678e.mov)
## 4.6.0
## v4.6.0
`tss-react` and `powerhooks` are no longer peer dependencies of `keycloakify`.
After updating Keycloakify you can remove `tss-react` and `powerhooks` from your dependencies if you don't use them explicitly.
## 4.5.3
## v4.5.3
There is a new recommended way to setup highly customized theme. See [here](https://github.com/garronej/keycloakify-demo-app/blob/look_and_feel/src/KcApp/KcApp.tsx).
Unlike with [the previous recommended method](https://github.com/garronej/keycloakify-demo-app/blob/a51660578bea15fb3e506b8a2b78e1056c6d68bb/src/KcApp/KcApp.tsx),
with this new method your theme wont break on minor Keycloakify update.
## 4.3.0
## v4.3.0
Feature [`login-update-password.ftl`](https://user-images.githubusercontent.com/6702424/147517600-6191cf72-93dd-437b-a35c-47180142063e.png).
Every time a page is added it's a breaking change for non CSS-only theme.
Change [this](https://github.com/garronej/keycloakify-demo-app/blob/df664c13c77ce3c53ac7df0622d94d04e76d3f9f/src/KcApp/KcApp.tsx#L17) and [this](https://github.com/garronej/keycloakify-demo-app/blob/df664c13c77ce3c53ac7df0622d94d04e76d3f9f/src/KcApp/KcApp.tsx#L37) to update.
## 4
## v4
- Out of the box [frontend form validation](#user-profile-and-frontend-form-validation) 🥳
- Improvements (and breaking changes in `import { useKcMessage } from "keycloakify"`.
## 3
## v3
No breaking changes except that `@emotion/react`, [`tss-react`](https://www.npmjs.com/package/tss-react) and [`powerhooks`](https://www.npmjs.com/package/powerhooks) are now `peerDependencies` instead of being just dependencies.
It's important to avoid problem when using `keycloakify` alongside [`mui`](https://mui.com) and
[when passing params from the app to the login page](https://github.com/keycloakify/keycloakify#implement-context-persistence-optional).
## 2.5
## v2.5
- Feature [Use advanced message](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/i18n/useKcMessage.tsx#L53-L66)
and [`messagesPerFields`](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/getKcContext/KcContextBase.ts#L70-L75) (implementation [here](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/bin/build-keycloak-theme/generateFtl/common.ftl#L130-L189))
- Test container now uses Keycloak version `15.0.2`.
## 2
## v2
- It's now possible to implement custom `.ftl` pages.
- Support for Keycloak plugins that introduce non standard ftl values.

View File

@ -0,0 +1,91 @@
{
"allOf": [
{
"$ref": "https://json.schemastore.org/package.json"
},
{
"$ref": "keycloakifyPackageJsonSchema"
}
],
"$ref": "#/definitions/keycloakifyPackageJsonSchema",
"definitions": {
"keycloakifyPackageJsonSchema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"version": {
"type": "string"
},
"homepage": {
"type": "string"
},
"keycloakify": {
"type": "object",
"properties": {
"extraPages": {
"type": "array",
"items": {
"type": "string"
}
},
"extraLoginPages": {
"type": "array",
"items": {
"type": "string"
}
},
"extraAccountPages": {
"type": "array",
"items": {
"type": "string"
}
},
"extraThemeProperties": {
"type": "array",
"items": {
"type": "string"
}
},
"areAppAndKeycloakServerSharingSameDomain": {
"type": "boolean"
},
"artifactId": {
"type": "string"
},
"groupId": {
"type": "string"
},
"bundler": {
"type": "string",
"enum": ["mvn", "keycloakify", "none"]
},
"keycloakVersionDefaultAssets": {
"type": "string"
},
"reactAppBuildDirPath": {
"type": "string"
},
"keycloakifyBuildDirPath": {
"type": "string"
},
"customUserAttributes": {
"type": "array",
"items": {
"type": "string"
}
},
"themeName": {
"type": "string"
}
},
"additionalProperties": false
}
},
"required": ["name", "version"],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@ -1,6 +1,6 @@
{
"name": "keycloakify",
"version": "9.7.4",
"version": "7.12.3",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
@ -10,10 +10,11 @@
"types": "dist/index.d.ts",
"scripts": {
"prepare": "yarn generate-i18n-messages",
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc -p src/vite-plugin && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/ && cp -r src dist/",
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/",
"watch-in-starter": "yarn build && yarn link-in-starter && (concurrently \"tsc -p src -w\" \"tsc-alias -p src/tsconfig.json\" \"tsc -p src/bin -w\")",
"generate:json-schema": "ts-node scripts/generate-json-schema.ts",
"grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js",
"copy-files": "copyfiles -u 1 src/**/*.ftl src/**/*.java",
"copy-files": "copyfiles -u 1 src/**/*.ftl",
"test": "yarn test:types && vitest run",
"test:keycloakify-starter": "ts-node scripts/test-keycloakify-starter",
"test:types": "tsc -p test/tsconfig.json --noEmit",
@ -23,7 +24,6 @@
"generate-i18n-messages": "ts-node --skipProject scripts/generate-i18n-messages.ts",
"link-in-app": "ts-node --skipProject scripts/link-in-app.ts",
"link-in-starter": "yarn link-in-app keycloakify-starter",
"watch-in-starter": "yarn build && yarn link-in-starter && (concurrently \"tsc -p src -w\" \"tsc-alias -p src/tsconfig.json\" \"tsc -p src/bin -w\")",
"copy-keycloak-resources-to-storybook-static": "PUBLIC_DIR_PATH=.storybook/static node dist/bin/copy-keycloak-resources-to-public.js",
"storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && start-storybook -p 6006",
"build-storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && build-storybook"
@ -79,14 +79,12 @@
"@storybook/manager-webpack5": "^6.5.13",
"@storybook/react": "^6.5.13",
"@storybook/testing-library": "^0.0.13",
"@types/babel__generator": "^7.6.4",
"@types/make-fetch-happen": "^10.0.1",
"@types/minimist": "^1.2.2",
"@types/node": "^18.15.3",
"@types/react": "^18.0.35",
"@types/react-dom": "^18.0.11",
"@types/yauzl": "^2.10.0",
"@types/yazl": "^2.4.2",
"concurrently": "^8.0.1",
"copyfiles": "^2.4.1",
"eslint-plugin-storybook": "^0.6.7",
@ -105,27 +103,23 @@
"tss-react": "^4.8.2",
"typescript": "^4.9.1-beta",
"vitest": "^0.29.8",
"zod-to-json-schema": "^3.20.4",
"vite": "^5.0.12"
"zod-to-json-schema": "^3.20.4"
},
"dependencies": {
"@babel/generator": "^7.22.9",
"@babel/parser": "^7.22.7",
"@babel/types": "^7.22.5",
"@octokit/rest": "^18.12.0",
"cheerio": "1.0.0-rc.5",
"@types/yazl": "^2.4.2",
"cheerio": "^1.0.0-rc.5",
"cli-select": "^1.1.2",
"evt": "^2.4.18",
"make-fetch-happen": "^11.0.3",
"minimal-polyfills": "^2.2.2",
"minimist": "^1.2.6",
"path-browserify": "^1.0.1",
"react-markdown": "^5.0.3",
"recast": "^0.23.3",
"rfc4648": "^1.5.2",
"tsafe": "^1.6.0",
"yauzl": "^2.10.0",
"yazl": "^2.5.1",
"zod": "^3.17.10",
"magic-string": "^0.30.7"
"zod": "^3.17.10"
}
}

View File

@ -3,7 +3,7 @@ import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname, sep as pathSep } from "path";
import { crawl } from "../src/bin/tools/crawl";
import { downloadBuiltinKeycloakTheme } from "../src/bin/download-builtin-keycloak-theme";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
import { getLogger } from "../src/bin/tools/logger";
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
@ -17,25 +17,16 @@ const isSilent = true;
const logger = getLogger({ isSilent });
async function main() {
const keycloakVersion = "23.0.4";
const keycloakVersion = "21.0.1";
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
const tmpDirPath = pathJoin(thisCodebaseRootDirPath, "tmp_xImOef9dOd44");
const tmpDirPath = pathJoin(getProjectRoot(), "tmp_xImOef9dOd44");
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
fs.mkdirSync(tmpDirPath);
fs.writeFileSync(pathJoin(tmpDirPath, ".gitignore"), Buffer.from("/*\n!.gitignore\n", "utf8"));
await downloadBuiltinKeycloakTheme({
keycloakVersion,
"destDirPath": tmpDirPath,
"buildOptions": {
"cacheDirPath": pathJoin(thisCodebaseRootDirPath, "node_modules", ".cache", "keycloakify"),
"npmWorkspaceRootDirPath": thisCodebaseRootDirPath
}
isSilent
});
type Dictionary = { [idiomId: string]: string };
@ -46,10 +37,7 @@ async function main() {
const baseThemeDirPath = pathJoin(tmpDirPath, "base");
const re = new RegExp(`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`);
crawl({
"dirPath": baseThemeDirPath,
"returnedPathsType": "relative to dirPath"
}).forEach(filePath => {
crawl(baseThemeDirPath).forEach(filePath => {
const match = filePath.match(re);
if (match === null) {
@ -75,13 +63,14 @@ async function main() {
return;
}
const baseMessagesDirPath = pathJoin(thisCodebaseRootDirPath, "src", themeType, "i18n", "baseMessages");
const baseMessagesDirPath = pathJoin(getProjectRoot(), "src", themeType, "i18n", "baseMessages");
const languages = Object.keys(recordForPageType);
const generatedFileHeader = [
`//This code was automatically generated by running ${pathRelative(thisCodebaseRootDirPath, __filename)}`,
"//PLEASE DO NOT EDIT MANUALLY"
`//This code was automatically generated by running ${pathRelative(getProjectRoot(), __filename)}`,
"//PLEASE DO NOT EDIT MANUALLY",
""
].join("\n");
languages.forEach(language => {
@ -94,7 +83,6 @@ async function main() {
Buffer.from(
[
generatedFileHeader,
"",
"/* spell-checker: disable */",
`const messages= ${JSON.stringify(recordForPageType[language], null, 2)};`,
"",
@ -113,15 +101,10 @@ async function main() {
Buffer.from(
[
generatedFileHeader,
`import * as en from "./en";`,
"",
"export async function getMessages(currentLanguageTag: string) {",
" const { default: messages } = await (() => {",
" switch (currentLanguageTag) {",
` case "en": return en;`,
...languages
.filter(language => language !== "en")
.map(language => ` case "${language}": return import("./${language}");`),
...languages.map(language => ` case "${language}": return import("./${language}");`),
' default: return { "default": {} };',
" }",
" })();",

View File

@ -0,0 +1,14 @@
import fs from "fs";
import path from "path";
import zodToJsonSchema from "zod-to-json-schema";
import { zParsedPackageJson } from "../src/bin/keycloakify/parsedPackageJson";
const jsonSchemaName = "keycloakifyPackageJsonSchema";
const jsonSchema = zodToJsonSchema(zParsedPackageJson, jsonSchemaName);
const baseProperties = {
// merges package.json schema with keycloakify properties
"allOf": [{ "$ref": "https://json.schemastore.org/package.json" }, { "$ref": jsonSchemaName }]
};
fs.writeFileSync(path.join(process.cwd(), "keycloakify-json-schema.json"), JSON.stringify({ ...baseProperties, ...jsonSchema }, null, 2));

View File

@ -1,11 +1,11 @@
import { execSync } from "child_process";
import { join as pathJoin, relative as pathRelative } from "path";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
import * as fs from "fs";
const singletonDependencies: string[] = ["react", "@types/react"];
const rootDirPath = getThisCodebaseRootDirPath();
const rootDirPath = getProjectRoot();
//NOTE: This is only required because of: https://github.com/garronej/ts-ci/blob/c0e207b9677523d4ec97fe672ddd72ccbb3c1cc4/README.md?plain=1#L54-L58
fs.writeFileSync(

View File

@ -1,21 +0,0 @@
import { nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir } from "keycloakify/bin/constants";
import { assert } from "tsafe/assert";
/**
* This is an equivalent of process.env.PUBLIC_URL thay you can use in Webpack projects.
* This works both in your main app and in your Keycloak theme.
*/
export const PUBLIC_URL = (() => {
const kcContext = (window as any)[nameOfTheGlobal];
if (kcContext === undefined || process.env.NODE_ENV === "development") {
assert(
process.env.PUBLIC_URL !== undefined,
`If you use keycloakify/PUBLIC_URL you should be in Webpack and thus process.env.PUBLIC_URL should be defined`
);
return process.env.PUBLIC_URL;
}
return `${kcContext.url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}`;
})();

View File

@ -3,14 +3,9 @@ import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { I18n } from "keycloakify/account/i18n";
import type { KcContext } from "./kcContext";
import { assert, type Equals } from "tsafe/assert";
import FederatedIdentity from "./pages/FederatedIdentity";
const Password = lazy(() => import("keycloakify/account/pages/Password"));
const Account = lazy(() => import("keycloakify/account/pages/Account"));
const Sessions = lazy(() => import("keycloakify/account/pages/Sessions"));
const Totp = lazy(() => import("keycloakify/account/pages/Totp"));
const Applications = lazy(() => import("keycloakify/account/pages/Applications"));
const Log = lazy(() => import("keycloakify/account/pages/Log"));
export default function Fallback(props: PageProps<KcContext, I18n>) {
const { kcContext, ...rest } = props;
@ -21,18 +16,8 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
switch (kcContext.pageId) {
case "password.ftl":
return <Password kcContext={kcContext} {...rest} />;
case "sessions.ftl":
return <Sessions kcContext={kcContext} {...rest} />;
case "account.ftl":
return <Account kcContext={kcContext} {...rest} />;
case "totp.ftl":
return <Totp kcContext={kcContext} {...rest} />;
case "applications.ftl":
return <Applications kcContext={kcContext} {...rest} />;
case "log.ftl":
return <Log kcContext={kcContext} {...rest} />;
case "federatedIdentity.ftl":
return <FederatedIdentity kcContext={kcContext} {...rest} />;
}
assert<Equals<typeof kcContext, never>>(false);
})()}

View File

@ -17,15 +17,11 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { isReady } = usePrepareTemplate({
"doFetchDefaultThemeResources": doUseDefaultCss,
"styles": [
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesPath}/css/account.css`
],
"htmlClassName": getClassName("kcHtmlClass"),
"bodyClassName": clsx("admin-console", "user", getClassName("kcBodyClass")),
"htmlLangProperty": locale?.currentLanguageTag,
"documentTitle": i18n.msgStr("accountManagementTitle")
url,
"stylesCommon": ["node_modules/patternfly/dist/css/patternfly.min.css", "node_modules/patternfly/dist/css/patternfly-additions.min.css"],
"styles": ["css/account.css"],
"htmlClassName": undefined,
"bodyClassName": clsx("admin-console", "user", getClassName("kcBodyClass"))
});
if (!isReady) {

View File

@ -11,17 +11,4 @@ export type TemplateProps<KcContext extends KcContext.Common, I18nExtended exten
children: ReactNode;
};
export type ClassKey =
| "kcHtmlClass"
| "kcBodyClass"
| "kcButtonClass"
| "kcButtonPrimaryClass"
| "kcButtonLargeClass"
| "kcButtonDefaultClass"
| "kcContentWrapperClass"
| "kcFormClass"
| "kcFormGroupClass"
| "kcInputWrapperClass"
| "kcLabelClass"
| "kcInputClass"
| "kcInputErrorMessageClass";
export type ClassKey = "kcBodyClass" | "kcButtonClass" | "kcButtonPrimaryClass" | "kcButtonLargeClass" | "kcButtonDefaultClass";

View File

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

View File

@ -1,20 +1,11 @@
import type { AccountThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
import type { AccountThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import { type ThemeType } from "keycloakify/bin/constants";
export type KcContext =
| KcContext.Password
| KcContext.Account
| KcContext.Sessions
| KcContext.Totp
| KcContext.Applications
| KcContext.Log
| KcContext.FederatedIdentity;
export type KcContext = KcContext.Password | KcContext.Account;
export declare namespace KcContext {
export type Common = {
themeVersion: string;
keycloakifyVersion: string;
themeType: "account";
themeName: string;
@ -34,7 +25,6 @@ export declare namespace KcContext {
sessionsUrl: string;
applicationsUrl: string;
logUrl: string;
logoutUrl: string;
resourceUrl: string;
resourcesCommonPath: string;
resourcesPath: string;
@ -62,34 +52,9 @@ export declare namespace KcContext {
name: string; // Client id
};
messagesPerField: {
/**
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.
*
* @param fieldName to check for
* @param text to return
* @return text if message exists for given field, else undefined
*/
printIfExists: <T extends string>(fieldName: string, text: T) => T | undefined;
/**
* Check if exists error message for given fields
*
* @param fields
* @return boolean
*/
printIfExists: <T>(fieldName: string, x: T) => T | undefined;
existsError: (fieldName: string) => boolean;
/**
* Get message for given field.
*
* @param fieldName
* @return message text or empty string
*/
get: (fieldName: string) => string;
/**
* Check if message for given field exists
*
* @param field
* @return boolean
*/
exists: (fieldName: string) => boolean;
};
account: {
@ -98,7 +63,6 @@ export declare namespace KcContext {
lastName?: string;
username?: string;
};
properties: Record<string, string | undefined>;
};
export type Password = Common & {
@ -120,171 +84,6 @@ export declare namespace KcContext {
};
stateChecker: string;
};
export type Sessions = Common & {
pageId: "sessions.ftl";
sessions: {
sessions: {
expires: string;
clients: string[];
ipAddress: string;
started: string;
lastAccess: string;
id: string;
}[];
};
stateChecker: string;
};
export type Totp = Common & {
pageId: "totp.ftl";
totp: {
enabled: boolean;
totpSecretEncoded: string;
qrUrl: string;
policy: {
algorithm: "HmacSHA1" | "HmacSHA256" | "HmacSHA512";
digits: number;
lookAheadWindow: number;
} & (
| {
type: "totp";
period: number;
}
| {
type: "hotp";
initialCounter: number;
}
);
supportedApplications: string[];
totpSecretQrCode: string;
manualUrl: string;
totpSecret: string;
otpCredentials: { id: string; userLabel: string }[];
};
mode?: "qr" | "manual" | undefined | null;
isAppInitiatedAction: boolean;
url: {
accountUrl: string;
passwordUrl: string;
totpUrl: string;
socialUrl: string;
sessionsUrl: string;
applicationsUrl: string;
logUrl: string;
resourceUrl: string;
resourcesCommonPath: string;
resourcesPath: string;
/** @deprecated, not present in recent keycloak version apparently, use kcContext.referrer instead */
referrerURI?: string;
getLogoutUrl: () => string;
};
stateChecker: string;
};
export type Applications = Common & {
pageId: "applications.ftl";
features: {
log: boolean;
identityFederation: boolean;
authorization: boolean;
passwordUpdateSupported: boolean;
};
stateChecker: string;
applications: {
applications: {
realmRolesAvailable: {
name: string;
description: string;
compositesStream?: Record<string, unknown>;
clientRole?: boolean;
composite?: boolean;
id?: string;
containerId?: string;
attributes?: Record<string, unknown>;
}[];
resourceRolesAvailable: Record<
string,
{
roleName: string;
roleDescription?: string;
clientName: string;
clientId: string;
}[]
>;
additionalGrants: string[];
clientScopesGranted: string[];
effectiveUrl?: string;
client: {
alwaysDisplayInConsole: boolean;
attributes: Record<string, unknown>;
authenticationFlowBindingOverrides: Record<string, unknown>;
baseUrl?: string;
bearerOnly: boolean;
clientAuthenticatorType: string;
clientId: string;
consentRequired: boolean;
consentScreenText: string;
description: string;
directAccessGrantsEnabled: boolean;
displayOnConsentScreen: boolean;
dynamicScope: boolean;
enabled: boolean;
frontchannelLogout: boolean;
fullScopeAllowed: boolean;
id: string;
implicitFlowEnabled: boolean;
includeInTokenScope: boolean;
managementUrl: string;
name?: string;
nodeReRegistrationTimeout: string;
notBefore: string;
protocol: string;
protocolMappersStream: Record<string, unknown>;
publicClient: boolean;
realm: Record<string, unknown>;
realmScopeMappingsStream: Record<string, unknown>;
redirectUris: string[];
registeredNodes: Record<string, unknown>;
rolesStream: Record<string, unknown>;
rootUrl?: string;
scopeMappingsStream: Record<string, unknown>;
secret: string;
serviceAccountsEnabled: boolean;
standardFlowEnabled: boolean;
surrogateAuthRequired: boolean;
webOrigins: string[];
};
}[];
};
};
export type Log = Common & {
pageId: "log.ftl";
log: {
events: {
date: string | number | Date;
event: string;
ipAddress: string;
client: string;
details: { value: string; key: string }[];
}[];
};
};
export type FederatedIdentity = Common & {
pageId: "federatedIdentity.ftl";
stateChecker: string;
federatedIdentity: {
identities: {
providerId: string;
displayName: string;
userName: string;
connected: boolean;
}[];
removeLinkPossible: boolean;
};
};
}
{

View File

@ -1,16 +1,17 @@
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import { deepAssign } from "keycloakify/tools/deepAssign";
import { isStorybook } from "keycloakify/lib/isStorybook";
import type { ExtendKcContext } from "./getKcContextFromWindow";
import { getKcContextFromWindow } from "./getKcContextFromWindow";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { pathBasename } from "keycloakify/tools/pathBasename";
import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { symToStr } from "tsafe/symToStr";
import { kcContextMocks, kcContextCommonMock } from "keycloakify/account/kcContext/kcContextMocks";
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
mockProperties?: Record<string, string>;
}) {
const { mockData, mockProperties } = params ?? {};
const { mockData } = params ?? {};
function getKcContext<PageId extends ExtendKcContext<KcContextExtension>["pageId"] | undefined = undefined>(params?: {
mockPageId?: PageId;
@ -27,13 +28,7 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
if (mockPageId !== undefined && realKcContext === undefined) {
//TODO maybe trow if no mock fo custom page
warn_that_mock_is_enbaled: {
if (isStorybook) {
break warn_that_mock_is_enbaled;
}
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
}
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
@ -83,13 +78,6 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
});
}
if (mockProperties !== undefined) {
deepAssign({
"target": kcContext.properties,
"source": mockProperties
});
}
return { kcContext };
}
@ -101,6 +89,12 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
return { "kcContext": undefined as any };
}
{
const { url } = realKcContext;
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(resourcesCommonDirPathRelativeToPublicDir));
}
return { "kcContext": realKcContext as any };
}

View File

@ -1,5 +1,5 @@
import type { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey";
import { nameOfTheGlobal } from "keycloakify/bin/constants";
import { ftlValuesGlobalName } from "keycloakify/bin/keycloakify/ftlValuesGlobalName";
import type { KcContext } from "./KcContext";
export type ExtendKcContext<KcContextExtension extends { pageId: string }> = [KcContextExtension] extends [never]
@ -7,5 +7,5 @@ export type ExtendKcContext<KcContextExtension extends { pageId: string }> = [Kc
: AndByDiscriminatingKey<"pageId", KcContextExtension & KcContext.Common, KcContext>;
export function getKcContextFromWindow<KcContextExtension extends { pageId: string } = never>(): ExtendKcContext<KcContextExtension> | undefined {
return typeof window === "undefined" ? undefined : (window as any)[nameOfTheGlobal];
return typeof window === "undefined" ? undefined : (window as any)[ftlValuesGlobalName];
}

View File

@ -1,23 +1,21 @@
import "minimal-polyfills/Object.fromEntries";
import { resources_common, keycloak_resources } from "keycloakify/bin/constants";
import { resourcesCommonDirPathRelativeToPublicDir, resourcesDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { id } from "tsafe/id";
import type { KcContext } from "./KcContext";
import { BASE_URL } from "keycloakify/lib/BASE_URL";
const resourcesPath = `${BASE_URL}${keycloak_resources}/account/resources`;
const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
export const kcContextCommonMock: KcContext.Common = {
"themeVersion": "0.0.0",
"keycloakifyVersion": "0.0.0",
"themeType": "account",
"themeName": "my-theme-name",
"url": {
resourcesPath,
"resourcesCommonPath": `${resourcesPath}/${resources_common}`,
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
"resourceUrl": "#",
"accountUrl": "#",
"applicationsUrl": "#",
"logoutUrl": "#",
"getLogoutUrl": () => "#",
"logUrl": "#",
"passwordUrl": "#",
@ -134,6 +132,10 @@ export const kcContextCommonMock: KcContext.Common = {
],
"currentLanguageTag": "en"
},
"message": {
"type": "success",
"summary": "This is a test message"
},
"features": {
"authorization": true,
"identityFederation": true,
@ -146,17 +148,6 @@ export const kcContextCommonMock: KcContext.Common = {
"lastName": "doe",
"email": "john.doe@code.gouv.fr",
"username": "doe_j"
},
"properties": {
"parent": "account-v1",
"kcButtonLargeClass": "btn-lg",
"locales": "ar,ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN",
"kcButtonPrimaryClass": "btn-primary",
"accountResourceProvider": "account-v1",
"styles":
"css/account.css img/icon-sidebar-active.png img/logo.png resources-common/node_modules/patternfly/dist/css/patternfly.min.css resources-common/node_modules/patternfly/dist/css/patternfly-additions.min.css resources-common/node_modules/patternfly/dist/css/patternfly-additions.min.css",
"kcButtonClass": "btn",
"kcButtonDefaultClass": "btn-default"
}
};
@ -183,78 +174,5 @@ export const kcContextMocks: KcContext[] = [
"editUsernameAllowed": true
},
"stateChecker": ""
}),
id<KcContext.Sessions>({
...kcContextCommonMock,
"pageId": "sessions.ftl",
"sessions": {
"sessions": [
{
"ipAddress": "127.0.0.1",
"started": new Date().toString(),
"lastAccess": new Date().toString(),
"expires": new Date().toString(),
"clients": ["Chrome", "Firefox"],
"id": "f8951177-817d-4a70-9c02-86d3c170fe51"
}
]
},
"stateChecker": "g6WB1FaYnKotTkiy7ZrlxvFztSqS0U8jvHsOOOb2z4g"
}),
id<KcContext.Totp>({
...kcContextCommonMock,
"pageId": "totp.ftl",
"totp": {
"enabled": true,
"totpSecretEncoded": "KVVF G2BY N4YX S6LB IUYT K2LH IFYE 4SBV",
"qrUrl": "#",
"totpSecretQrCode":
"iVBORw0KGgoAAAANSUhEUgAAAPYAAAD2AQAAAADNaUdlAAACM0lEQVR4Xu3OIZJgOQwDUDFd2UxiurLAVnnbHw4YGDKtSiWOn4Gxf81//7r/+q8b4HfLGBZDK9d85NmNR+sB42sXvOYrN5P1DcgYYFTGfOlbzE8gzwy3euweGizw7cfdl34/GRhlkxjKNV+5AebPXPORX1JuB9x8ZfbyyD2y1krWAKsbMq1HnqQDaLfa77p4+MqvzEGSqvSAD/2IHW2yHaigR9tX3m8dDIYGcNf3f+gDpVBZbZU77zyJ6Rlcy+qoTMG887KAPD9hsh6a1Sv3gJUHGHUAxSMzj7zqDDe7Phmt2eG+8UsMxjRGm816MAO+8VMl1R1jGHOrZB/5Zo/WXAPgxixm9Mo96vDGrM1eOto8c4Ax4wF437mifOXlpiPzCnN7Y9l95NnEMxgMY9AAGA8fucH14Y1aVb6N/cqrmyh0BVht7k1e+bU8LK0Cg5vmVq9c5vHIjOfqxDIfeTraNVTwewa4wVe+SW5N+uP1qACeudUZbqGOfA6VZV750Noq2Xx3kpveV44ZelSV1V7KFHzkWyVrrlUwG0Pl9pWnoy3vsQoME6vKI69i5osVgwWzHT7zjmJtMcNUSVn1oYMd7ZodbgowZl45VG0uVuLPUr1yc79uaQBag/mqR34xhlWyHm1prplHboCWdZ4TeZjsK8+dI+jbz1C5hl65mcpgB5dhcj8+dGO+0Ko68+lD37JDD83dpDLzzK+TrQyaVwGj6pUboGV+7+AyN8An/pf84/7rv/4/1l4OCc/1BYMAAAAASUVORK5CYII=",
"manualUrl": "#",
"totpSecret": "G4nsI8lQagRMUchH8jEG",
"otpCredentials": [],
"supportedApplications": ["totpAppFreeOTPName", "totpAppMicrosoftAuthenticatorName", "totpAppGoogleName"],
"policy": {
"algorithm": "HmacSHA1",
"digits": 6,
"lookAheadWindow": 1,
"type": "totp",
"period": 30
}
},
"mode": "qr",
"isAppInitiatedAction": false,
"stateChecker": ""
}),
id<KcContext.Log>({
...kcContextCommonMock,
"pageId": "log.ftl",
"log": {
"events": [
{
"date": "2/21/2024, 1:28:39 PM",
"event": "login",
"ipAddress": "172.17.0.1",
"client": "security-admin-console",
"details": [{ key: "openid-connect", value: "admin" }]
}
]
}
}),
id<KcContext.FederatedIdentity>({
...kcContextCommonMock,
"stateChecker": "",
"pageId": "federatedIdentity.ftl",
"federatedIdentity": {
"identities": [
{
"providerId": "keycloak-oidc",
"displayName": "keycloak-oidc",
"userName": "John",
"connected": true
}
],
"removeLinkPossible": true
}
})
];

View File

@ -3,18 +3,10 @@ import type { ClassKey } from "keycloakify/account/TemplateProps";
export const { useGetClassName } = createUseClassName<ClassKey>({
"defaultClasses": {
"kcHtmlClass": undefined,
"kcBodyClass": undefined,
"kcButtonClass": "btn",
"kcContentWrapperClass": "row",
"kcButtonPrimaryClass": "btn-primary",
"kcButtonLargeClass": "btn-lg",
"kcButtonDefaultClass": "btn-default",
"kcFormClass": "form-horizontal",
"kcFormGroupClass": "form-group",
"kcInputWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcLabelClass": "control-label",
"kcInputClass": "form-control",
"kcInputErrorMessageClass": "pf-c-form__helper-text pf-m-error required kc-feedback-text"
"kcButtonDefaultClass": "btn-default"
}
});

View File

@ -51,7 +51,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
id="username"
name="username"
disabled={!realm.editUsernameAllowed}
defaultValue={account.username ?? ""}
value={account.username ?? ""}
/>
</div>
</div>
@ -66,7 +66,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
</div>
<div className="col-sm-10 col-md-10">
<input type="text" className="form-control" id="email" name="email" autoFocus defaultValue={account.email ?? ""} />
<input type="text" className="form-control" id="email" name="email" autoFocus value={account.email ?? ""} />
</div>
</div>
@ -79,7 +79,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
</div>
<div className="col-sm-10 col-md-10">
<input type="text" className="form-control" id="firstName" name="firstName" defaultValue={account.firstName ?? ""} />
<input type="text" className="form-control" id="firstName" name="firstName" value={account.firstName ?? ""} />
</div>
</div>
@ -92,7 +92,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
</div>
<div className="col-sm-10 col-md-10">
<input type="text" className="form-control" id="lastName" name="lastName" defaultValue={account.lastName ?? ""} />
<input type="text" className="form-control" id="lastName" name="lastName" value={account.lastName ?? ""} />
</div>
</div>

View File

@ -1,138 +0,0 @@
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
function isArrayWithEmptyObject(variable: any): boolean {
return Array.isArray(variable) && variable.length === 1 && typeof variable[0] === "object" && Object.keys(variable[0]).length === 0;
}
export default function Applications(props: PageProps<Extract<KcContext, { pageId: "applications.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const {
url,
applications: { applications },
stateChecker
} = kcContext;
const { msg, advancedMsg } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="applications">
<div className="row">
<div className="col-md-10">
<h2>{msg("applicationsHtmlTitle")}</h2>
</div>
<form action={url.applicationsUrl} method="post">
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
<input type="hidden" id="referrer" name="referrer" value={stateChecker} />
<table className="table table-striped table-bordered">
<thead>
<tr>
<td>{msg("application")}</td>
<td>{msg("availableRoles")}</td>
<td>{msg("grantedPermissions")}</td>
<td>{msg("additionalGrants")}</td>
<td>{msg("action")}</td>
</tr>
</thead>
<tbody>
{applications.map(application => (
<tr key={application.client.clientId}>
<td>
{application.effectiveUrl && (
<a href={application.effectiveUrl}>
{(application.client.name && advancedMsg(application.client.name)) || application.client.clientId}
</a>
)}
{!application.effectiveUrl &&
((application.client.name && advancedMsg(application.client.name)) || application.client.clientId)}
</td>
<td>
{!isArrayWithEmptyObject(application.realmRolesAvailable) &&
application.realmRolesAvailable.map((role, index) => (
<span key={role.name}>
{role.description ? advancedMsg(role.description) : advancedMsg(role.name)}
{index < application.realmRolesAvailable.length - 1 && ", "}
</span>
))}
{!isArrayWithEmptyObject(application.realmRolesAvailable) && application.resourceRolesAvailable && ", "}
{application.resourceRolesAvailable &&
Object.keys(application.resourceRolesAvailable).map(resource => (
<span key={resource}>
{!isArrayWithEmptyObject(application.realmRolesAvailable) && ", "}
{application.resourceRolesAvailable[resource].map(clientRole => (
<span key={clientRole.roleName}>
{clientRole.roleDescription
? advancedMsg(clientRole.roleDescription)
: advancedMsg(clientRole.roleName)}{" "}
{msg("inResource")}{" "}
<strong>
{clientRole.clientName ? advancedMsg(clientRole.clientName) : clientRole.clientId}
</strong>
{clientRole !==
application.resourceRolesAvailable[resource][
application.resourceRolesAvailable[resource].length - 1
] && ", "}
</span>
))}
</span>
))}
</td>
<td>
{application.client.consentRequired ? (
application.clientScopesGranted.map(claim => (
<span key={claim}>
{advancedMsg(claim)}
{claim !== application.clientScopesGranted[application.clientScopesGranted.length - 1] && ", "}
</span>
))
) : (
<strong>{msg("fullAccess")}</strong>
)}
</td>
<td>
{application.additionalGrants.map(grant => (
<span key={grant}>
{advancedMsg(grant)}
{grant !== application.additionalGrants[application.additionalGrants.length - 1] && ", "}
</span>
))}
</td>
<td>
{(application.client.consentRequired && application.clientScopesGranted.length > 0) ||
application.additionalGrants.length > 0 ? (
<button
type="submit"
className={clsx(getClassName("kcButtonPrimaryClass"), getClassName("kcButtonClass"))}
id={`revoke-${application.client.clientId}`}
name="clientId"
value={application.client.id}
>
{msg("revoke")}
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</form>
</div>
</Template>
);
}

View File

@ -1,58 +0,0 @@
import { PageProps } from "keycloakify/account";
import { I18n } from "keycloakify/account/i18n";
import { KcContext } from "keycloakify/account/kcContext";
export default function FederatedIdentity(props: PageProps<Extract<KcContext, { pageId: "federatedIdentity.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { url, federatedIdentity, stateChecker } = kcContext;
const { msg } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="federatedIdentity">
<div className="main-layout social">
<div className="row">
<div className="col-md-10">
<h2>{msg("federatedIdentitiesHtmlTitle")}</h2>
</div>
</div>
<div id="federated-identities">
{federatedIdentity.identities.map(identity => (
<div key={identity.providerId} className="row margin-bottom">
<div className="col-sm-2 col-md-2">
<label htmlFor={identity.providerId} className="control-label">
{identity.displayName}
</label>
</div>
<div className="col-sm-5 col-md-5">
<input disabled className="form-control" value={identity.userName} />
</div>
<div className="col-sm-5 col-md-5">
{identity.connected ? (
federatedIdentity.removeLinkPossible && (
<form action={url.socialUrl} method="post" className="form-inline">
<input type="hidden" name="stateChecker" value={stateChecker} />
<input type="hidden" name="action" value="remove" />
<input type="hidden" name="providerId" value={identity.providerId} />
<button id={`remove-link-${identity.providerId}`} className="btn btn-default">
{msg("doRemove")}
</button>
</form>
)
) : (
<form action={url.socialUrl} method="post" className="form-inline">
<input type="hidden" name="stateChecker" value={stateChecker} />
<input type="hidden" name="action" value="add" />
<input type="hidden" name="providerId" value={identity.providerId} />
<button id={`add-link-${identity.providerId}`} className="btn btn-default">
{msg("doAdd")}
</button>
</form>
)}
</div>
</div>
))}
</div>
</div>
</Template>
);
}

View File

@ -1,70 +0,0 @@
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
import { Key } from "react";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
export default function Log(props: PageProps<Extract<KcContext, { pageId: "log.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { log } = kcContext;
const { msg } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="log">
<div className={getClassName("kcContentWrapperClass")}>
<div className="col-md-10">
<h2>{msg("accountLogHtmlTitle")}</h2>
</div>
<table className="table table-striped table-bordered">
<thead>
<tr>
<td>{msg("date")}</td>
<td>{msg("event")}</td>
<td>{msg("ip")}</td>
<td>{msg("client")}</td>
<td>{msg("details")}</td>
</tr>
</thead>
<tbody>
{log.events.map(
(
event: {
date: string | number | Date;
event: string;
ipAddress: string;
client: any;
details: any[];
},
index: Key | null | undefined
) => (
<tr key={index}>
<td>{event.date ? new Date(event.date).toLocaleString() : ""}</td>
<td>{event.event}</td>
<td>{event.ipAddress}</td>
<td>{event.client || ""}</td>
<td>
{event.details.map((detail, detailIndex) => (
<span key={detailIndex}>
{`${detail.key} = ${detail.value}`}
{detailIndex < event.details.length - 1 && ", "}
</span>
))}
</td>
</tr>
)
)}
</tbody>
</table>
</div>
</Template>
);
}

View File

@ -1,11 +1,10 @@
import type { LazyExoticComponent } from "react";
import type { I18n } from "keycloakify/account/i18n";
import type { TemplateProps, ClassKey } from "keycloakify/account/TemplateProps";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { KcContext } from "keycloakify/account/kcContext";
export type PageProps<NarowedKcContext = KcContext, I18nExtended extends I18n = I18n> = {
Template: LazyOrNot<(props: TemplateProps<any, any>) => JSX.Element | null>;
kcContext: NarowedKcContext;
export type PageProps<KcContext, I18nExtended extends I18n> = {
Template: LazyExoticComponent<(props: TemplateProps<any, any>) => JSX.Element | null>;
kcContext: KcContext;
i18n: I18nExtended;
doUseDefaultCss: boolean;
classes?: Partial<Record<ClassKey, string>>;

View File

@ -1,65 +0,0 @@
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function Sessions(props: PageProps<Extract<KcContext, { pageId: "sessions.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { url, stateChecker, sessions } = kcContext;
const { msg } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="sessions">
<div className={getClassName("kcContentWrapperClass")}>
<div className="col-md-10">
<h2>{msg("sessionsHtmlTitle")}</h2>
</div>
</div>
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>{msg("ip")}</th>
<th>{msg("started")}</th>
<th>{msg("lastAccess")}</th>
<th>{msg("expires")}</th>
<th>{msg("clients")}</th>
</tr>
</thead>
<tbody role="rowgroup">
{sessions.sessions.map((session, index: number) => (
<tr key={index}>
<td>{session.ipAddress}</td>
<td>{session?.started}</td>
<td>{session?.lastAccess}</td>
<td>{session?.expires}</td>
<td>
{session.clients.map((client: string, clientIndex: number) => (
<div key={clientIndex}>
{client}
<br />
</div>
))}
</td>
</tr>
))}
</tbody>
</table>
<form action={url.sessionsUrl} method="post">
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
<button id="logout-all-sessions" type="submit" className={clsx(getClassName("kcButtonDefaultClass"), getClassName("kcButtonClass"))}>
{msg("doLogOutAllSessions")}
</button>
</form>
</Template>
);
}

View File

@ -1,235 +0,0 @@
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
import { MessageKey } from "keycloakify/account/i18n/i18n";
export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { totp, mode, url, messagesPerField, stateChecker } = kcContext;
const { msg, msgStr } = i18n;
const algToKeyUriAlg: Record<(typeof kcContext)["totp"]["policy"]["algorithm"], string> = {
"HmacSHA1": "SHA1",
"HmacSHA256": "SHA256",
"HmacSHA512": "SHA512"
};
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="totp">
<>
<div className="row">
<div className="col-md-10">
<h2>{msg("authenticatorTitle")}</h2>
</div>
{totp.otpCredentials.length === 0 && (
<div className="subtitle col-md-2">
<span className="required">*</span>
{msg("requiredFields")}
</div>
)}
</div>
{totp.enabled && (
<table className="table table-bordered table-striped">
<thead>
{totp.otpCredentials.length > 1 ? (
<tr>
<th colSpan={4}>{msg("configureAuthenticators")}</th>
</tr>
) : (
<tr>
<th colSpan={3}>{msg("configureAuthenticators")}</th>
</tr>
)}
</thead>
<tbody>
{totp.otpCredentials.map((credential, index) => (
<tr key={index}>
<td className="provider">{msg("mobile")}</td>
{totp.otpCredentials.length > 1 && <td className="provider">{credential.id}</td>}
<td className="provider">{credential.userLabel || ""}</td>
<td className="action">
<form action={url.totpUrl} method="post" className="form-inline">
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
<input type="hidden" id="submitAction" name="submitAction" value="Delete" />
<input type="hidden" id="credentialId" name="credentialId" value={credential.id} />
<button id={`remove-mobile-${index}`} className="btn btn-default">
<i className="pficon pficon-delete"></i>
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
{!totp.enabled && (
<div>
<hr />
<ol id="kc-totp-settings">
<li>
<p>{msg("totpStep1")}</p>
<ul id="kc-totp-supported-apps">
{totp.supportedApplications?.map(app => (
<li key={app}>{msg(app as MessageKey)}</li>
))}
</ul>
</li>
{mode && mode == "manual" ? (
<>
<li>
<p>{msg("totpManualStep2")}</p>
<p>
<span id="kc-totp-secret-key">{totp.totpSecretEncoded}</span>
</p>
<p>
<a href={totp.qrUrl} id="mode-barcode">
{msg("totpScanBarcode")}
</a>
</p>
</li>
<li>
<p>{msg("totpManualStep3")}</p>
<ul>
<li id="kc-totp-type">
{msg("totpType")}: {msg(`totp.${totp.policy.type}`)}
</li>
<li id="kc-totp-algorithm">
{msg("totpAlgorithm")}: {algToKeyUriAlg?.[totp.policy.algorithm] ?? totp.policy.algorithm}
</li>
<li id="kc-totp-digits">
{msg("totpDigits")}: {totp.policy.digits}
</li>
{totp.policy.type === "totp" ? (
<li id="kc-totp-period">
{msg("totpInterval")}: {totp.policy.period}
</li>
) : (
<li id="kc-totp-counter">
{msg("totpCounter")}: {totp.policy.initialCounter}
</li>
)}
</ul>
</li>
</>
) : (
<li>
<p>{msg("totpStep2")}</p>
<p>
<img
id="kc-totp-secret-qr-code"
src={`data:image/png;base64, ${totp.totpSecretQrCode}`}
alt="Figure: Barcode"
/>
</p>
<p>
<a href={totp.manualUrl} id="mode-manual">
{msg("totpUnableToScan")}
</a>
</p>
</li>
)}
<li>
<p>{msg("totpStep3")}</p>
<p>{msg("totpStep3DeviceName")}</p>
</li>
</ol>
<hr />
<form action={url.totpUrl} className={getClassName("kcFormClass")} id="kc-totp-settings-form" method="post">
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
<div className={getClassName("kcFormGroupClass")}>
<div className="col-sm-2 col-md-2">
<label htmlFor="totp" className="control-label">
{msg("authenticatorCode")}
</label>
<span className="required">*</span>
</div>
<div className="col-sm-10 col-md-10">
<input
type="text"
id="totp"
name="totp"
autoComplete="off"
className={getClassName("kcInputClass")}
aria-invalid={messagesPerField.existsError("totp")}
/>
{messagesPerField.existsError("totp") && (
<span id="input-error-otp-code" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("totp")}
</span>
)}
</div>
<input type="hidden" id="totpSecret" name="totpSecret" value={totp.totpSecret} />
{mode && <input type="hidden" id="mode" value={mode} />}
</div>
<div className={getClassName("kcFormGroupClass")}>
<div className="col-sm-2 col-md-2">
<label htmlFor="userLabel" className={getClassName("kcLabelClass")}>
{msg("totpDeviceName")}
</label>
{totp.otpCredentials.length >= 1 && <span className="required">*</span>}
</div>
<div className="col-sm-10 col-md-10">
<input
type="text"
id="userLabel"
name="userLabel"
autoComplete="off"
className={getClassName("kcInputClass")}
aria-invalid={messagesPerField.existsError("userLabel")}
/>
{messagesPerField.existsError("userLabel") && (
<span id="input-error-otp-label" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("userLabel")}
</span>
)}
</div>
</div>
<div id="kc-form-buttons" className={clsx(getClassName("kcFormGroupClass"), "text-right")}>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
id="saveTOTPBtn"
value={msgStr("doSave")}
/>
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass"),
getClassName("kcButtonLargeClass")
)}
id="cancelTOTPBtn"
name="submitAction"
value="Cancel"
>
{msg("doCancel")}
</button>
</div>
</div>
</form>
</div>
)}
</>
</Template>
);
}

View File

@ -1,14 +0,0 @@
export const nameOfTheGlobal = "kcContext";
export const keycloak_resources = "keycloak-resources";
export const resources_common = "resources-common";
export const lastKeycloakVersionWithAccountV1 = "21.1.2";
export const resolvedViteConfigJsonBasename = "vite.json";
export const basenameOfTheKeycloakifyResourcesDir = "build";
export const themeTypes = ["login", "account"] as const;
export const retrocompatPostfix = "_retrocompat";
export const accountV1ThemeName = "account-v1";
export type ThemeType = (typeof themeTypes)[number];
export const keycloakifyBuildOptionsForPostPostBuildScriptEnvName = "KEYCLOAKIFY_BUILD_OPTIONS_POST_POST_BUILD_SCRIPT";

View File

@ -1,63 +1,38 @@
#!/usr/bin/env node
import { downloadKeycloakStaticResources, type BuildOptionsLike } from "./keycloakify/generateTheme/downloadKeycloakStaticResources";
import { downloadKeycloakStaticResources } from "./keycloakify/generateTheme/downloadKeycloakStaticResources";
import { join as pathJoin, relative as pathRelative } from "path";
import { readBuildOptions } from "./keycloakify/buildOptions";
import { themeTypes, keycloak_resources, lastKeycloakVersionWithAccountV1 } from "./constants";
import { readThisNpmProjectVersion } from "./tools/readThisNpmProjectVersion";
import { assert, type Equals } from "tsafe/assert";
import { basenameOfKeycloakDirInPublicDir } from "./mockTestingResourcesPath";
import { readBuildOptions } from "./keycloakify/BuildOptions";
import { themeTypes } from "./keycloakify/generateFtl";
import * as fs from "fs";
import { rmSync } from "./tools/fs.rmSync";
export async function copyKeycloakResourcesToPublic(params: { processArgv: string[] }) {
const { processArgv } = params;
(async () => {
const projectDirPath = process.cwd();
const buildOptions = readBuildOptions({ processArgv });
const destDirPath = pathJoin(buildOptions.publicDirPath, keycloak_resources);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
const { keycloakifyBuildinfoRaw } = generateKeycloakifyBuildinfoRaw({
destDirPath,
"keycloakifyVersion": readThisNpmProjectVersion(),
buildOptions
const buildOptions = readBuildOptions({
"processArgv": process.argv.slice(2),
"projectDirPath": process.cwd()
});
skip_if_already_done: {
if (!fs.existsSync(keycloakifyBuildinfoFilePath)) {
break skip_if_already_done;
}
const keycloakifyBuildinfoRaw_previousRun = fs.readFileSync(keycloakifyBuildinfoFilePath).toString("utf8");
if (keycloakifyBuildinfoRaw_previousRun !== keycloakifyBuildinfoRaw) {
break skip_if_already_done;
}
const keycloakDirInPublicDir = pathJoin(process.env["PUBLIC_DIR_PATH"] || pathJoin(projectDirPath, "public"), basenameOfKeycloakDirInPublicDir);
if (fs.existsSync(keycloakDirInPublicDir)) {
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} already exists.`);
return;
}
rmSync(destDirPath, { "force": true, "recursive": true });
for (const themeType of themeTypes) {
await downloadKeycloakStaticResources({
"keycloakVersion": (() => {
switch (themeType) {
case "login":
return buildOptions.loginThemeResourcesFromKeycloakVersion;
case "account":
return lastKeycloakVersionWithAccountV1;
}
})(),
themeType,
"themeDirPath": destDirPath,
buildOptions
"isSilent": false,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
"themeType": themeType,
"themeDirPath": keycloakDirInPublicDir
});
}
fs.writeFileSync(
pathJoin(destDirPath, "README.txt"),
pathJoin(keycloakDirInPublicDir, "README.txt"),
Buffer.from(
// prettier-ignore
[
@ -67,46 +42,7 @@ export async function copyKeycloakResourcesToPublic(params: { processArgv: strin
)
);
fs.writeFileSync(pathJoin(buildOptions.publicDirPath, keycloak_resources, ".gitignore"), Buffer.from("*", "utf8"));
fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8"));
fs.writeFileSync(keycloakifyBuildinfoFilePath, Buffer.from(keycloakifyBuildinfoRaw, "utf8"));
}
export function generateKeycloakifyBuildinfoRaw(params: {
destDirPath: string;
keycloakifyVersion: string;
buildOptions: BuildOptionsLike & {
loginThemeResourcesFromKeycloakVersion: string;
};
}) {
const { destDirPath, keycloakifyVersion, buildOptions } = params;
const { cacheDirPath, npmWorkspaceRootDirPath, loginThemeResourcesFromKeycloakVersion, ...rest } = buildOptions;
assert<Equals<typeof rest, {}>>(true);
const keycloakifyBuildinfoRaw = JSON.stringify(
{
keycloakifyVersion,
"buildOptions": {
loginThemeResourcesFromKeycloakVersion,
"cacheDirPath": pathRelative(destDirPath, cacheDirPath),
"npmWorkspaceRootDirPath": pathRelative(destDirPath, npmWorkspaceRootDirPath)
}
},
null,
2
);
return { keycloakifyBuildinfoRaw };
}
async function main() {
await copyKeycloakResourcesToPublic({
"processArgv": process.argv.slice(2)
});
}
if (require.main === module) {
main();
}
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} directory created.`);
})();

View File

@ -1,273 +1,27 @@
#!/usr/bin/env node
import { join as pathJoin } from "path";
import { downloadAndUnzip } from "./downloadAndUnzip";
import { downloadAndUnzip } from "./tools/downloadAndUnzip";
import { promptKeycloakVersion } from "./promptKeycloakVersion";
import { getLogger } from "./tools/logger";
import { readBuildOptions, type BuildOptions } from "./keycloakify/buildOptions";
import { assert } from "tsafe/assert";
import * as child_process from "child_process";
import * as fs from "fs";
import { rmSync } from "./tools/fs.rmSync";
import { lastKeycloakVersionWithAccountV1 } from "./constants";
import { transformCodebase } from "./tools/transformCodebase";
import { readBuildOptions } from "./keycloakify/BuildOptions";
export type BuildOptionsLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: string; destDirPath: string; isSilent: boolean }) {
const { keycloakVersion, destDirPath } = params;
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: string; destDirPath: string; buildOptions: BuildOptionsLike }) {
const { keycloakVersion, destDirPath, buildOptions } = params;
await downloadAndUnzip({
destDirPath,
"url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`,
"specificDirsToExtract": ["", "-community"].map(ext => `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`),
buildOptions,
"preCacheTransform": {
"actionCacheId": "npm install and build",
"action": async ({ destDirPath }) => {
install_common_node_modules: {
const commonResourcesDirPath = pathJoin(destDirPath, "keycloak", "common", "resources");
if (!fs.existsSync(commonResourcesDirPath)) {
break install_common_node_modules;
}
if (!fs.existsSync(pathJoin(commonResourcesDirPath, "package.json"))) {
break install_common_node_modules;
}
if (fs.existsSync(pathJoin(commonResourcesDirPath, "node_modules"))) {
break install_common_node_modules;
}
child_process.execSync("npm install --omit=dev", {
"cwd": commonResourcesDirPath,
"stdio": "ignore"
});
}
repatriate_common_resources_from_base_login_theme: {
const baseLoginThemeResourceDir = pathJoin(destDirPath, "base", "login", "resources");
if (!fs.existsSync(baseLoginThemeResourceDir)) {
break repatriate_common_resources_from_base_login_theme;
}
transformCodebase({
"srcDirPath": baseLoginThemeResourceDir,
"destDirPath": pathJoin(destDirPath, "keycloak", "login", "resources")
});
}
install_and_move_to_common_resources_generated_in_keycloak_v2: {
if (!fs.readFileSync(pathJoin(destDirPath, "keycloak", "login", "theme.properties")).toString("utf8").includes("web_modules")) {
break install_and_move_to_common_resources_generated_in_keycloak_v2;
}
const accountV2DirSrcDirPath = pathJoin(destDirPath, "keycloak.v2", "account", "src");
if (!fs.existsSync(accountV2DirSrcDirPath)) {
break install_and_move_to_common_resources_generated_in_keycloak_v2;
}
const packageManager = fs.existsSync(pathJoin(accountV2DirSrcDirPath, "pnpm-lock.yaml")) ? "pnpm" : "npm";
if (packageManager === "pnpm") {
try {
child_process.execSync(`which pnpm`);
} catch {
console.log(`Installing pnpm globally`);
child_process.execSync(`npm install -g pnpm`);
}
}
child_process.execSync(`${packageManager} install`, { "cwd": accountV2DirSrcDirPath, "stdio": "ignore" });
const packageJsonFilePath = pathJoin(accountV2DirSrcDirPath, "package.json");
const packageJsonRaw = fs.readFileSync(packageJsonFilePath);
const parsedPackageJson = JSON.parse(packageJsonRaw.toString("utf8"));
parsedPackageJson.scripts.build = parsedPackageJson.scripts.build
.replace(`${packageManager} run check-types`, "true")
.replace(`${packageManager} run babel`, "true");
fs.writeFileSync(packageJsonFilePath, Buffer.from(JSON.stringify(parsedPackageJson, null, 2), "utf8"));
child_process.execSync(`${packageManager} run build`, { "cwd": accountV2DirSrcDirPath, "stdio": "ignore" });
fs.writeFileSync(packageJsonFilePath, packageJsonRaw);
fs.rmSync(pathJoin(accountV2DirSrcDirPath, "node_modules"), { "recursive": true });
}
remove_keycloak_v2: {
const keycloakV2DirPath = pathJoin(destDirPath, "keycloak.v2");
if (!fs.existsSync(keycloakV2DirPath)) {
break remove_keycloak_v2;
}
rmSync(keycloakV2DirPath, { "recursive": true });
}
// Note, this is an optimization for reducing the size of the jar
remove_unused_node_modules: {
const nodeModuleDirPath = pathJoin(destDirPath, "keycloak", "common", "resources", "node_modules");
if (!fs.existsSync(nodeModuleDirPath)) {
break remove_unused_node_modules;
}
const toDeletePerfixes = [
"angular",
"bootstrap",
"rcue",
"font-awesome",
"ng-file-upload",
pathJoin("patternfly", "dist", "sass"),
pathJoin("patternfly", "dist", "less"),
pathJoin("patternfly", "dist", "js"),
"d3",
pathJoin("jquery", "src"),
"c3",
"core-js",
"eonasdan-bootstrap-datetimepicker",
"moment",
"react",
"patternfly-bootstrap-treeview",
"popper.js",
"tippy.js",
"jquery-match-height",
"google-code-prettify",
"patternfly-bootstrap-combobox",
"focus-trap",
"tabbable",
"scheduler",
"@types",
"datatables.net",
"datatables.net-colreorder",
"tslib",
"prop-types",
"file-selector",
"datatables.net-colreorder-bs",
"object-assign",
"warning",
"js-tokens",
"loose-envify",
"prop-types-extra",
"attr-accept",
"datatables.net-select",
"drmonty-datatables-colvis",
"datatables.net-bs",
pathJoin("@patternfly", "react"),
pathJoin("@patternfly", "patternfly", "docs")
];
transformCodebase({
"srcDirPath": nodeModuleDirPath,
"destDirPath": nodeModuleDirPath,
"transformSourceCode": ({ sourceCode, fileRelativePath }) => {
if (fileRelativePath.endsWith(".map")) {
return undefined;
}
if (toDeletePerfixes.find(prefix => fileRelativePath.startsWith(prefix)) !== undefined) {
return undefined;
}
if (fileRelativePath.startsWith(pathJoin("patternfly", "dist", "fonts"))) {
if (
!fileRelativePath.endsWith(".woff2") &&
!fileRelativePath.endsWith(".woff") &&
!fileRelativePath.endsWith(".ttf")
) {
return undefined;
}
}
return { "modifiedSourceCode": sourceCode };
}
});
}
// Just like node_modules
remove_unused_lib: {
const libDirPath = pathJoin(destDirPath, "keycloak", "common", "resources", "lib");
if (!fs.existsSync(libDirPath)) {
break remove_unused_lib;
}
const toDeletePerfixes = ["ui-ace", "filesaver", "fileupload", "angular", "ui-ace"];
transformCodebase({
"srcDirPath": libDirPath,
"destDirPath": libDirPath,
"transformSourceCode": ({ sourceCode, fileRelativePath }) => {
if (fileRelativePath.endsWith(".map")) {
return undefined;
}
if (toDeletePerfixes.find(prefix => fileRelativePath.startsWith(prefix)) !== undefined) {
return undefined;
}
return { "modifiedSourceCode": sourceCode };
}
});
}
last_account_v1_transformations: {
if (lastKeycloakVersionWithAccountV1 !== keycloakVersion) {
break last_account_v1_transformations;
}
{
const accountCssFilePath = pathJoin(destDirPath, "keycloak", "account", "resources", "css", "account.css");
fs.writeFileSync(
accountCssFilePath,
Buffer.from(fs.readFileSync(accountCssFilePath).toString("utf8").replace("top: -34px;", "top: -34px !important;"), "utf8")
);
}
// Note, this is an optimization for reducing the size of the jar,
// For this version we know exactly which resources are used.
{
const nodeModulesDirPath = pathJoin(destDirPath, "keycloak", "common", "resources", "node_modules");
const toKeepPrefixes = [
...["patternfly.min.css", "patternfly-additions.min.css", "patternfly-additions.min.css"].map(fileBasename =>
pathJoin("patternfly", "dist", "css", fileBasename)
),
pathJoin("patternfly", "dist", "fonts")
];
transformCodebase({
"srcDirPath": nodeModulesDirPath,
"destDirPath": nodeModulesDirPath,
"transformSourceCode": ({ sourceCode, fileRelativePath }) => {
if (toKeepPrefixes.find(prefix => fileRelativePath.startsWith(prefix)) === undefined) {
return undefined;
}
return { "modifiedSourceCode": sourceCode };
}
});
}
}
}
}
});
await Promise.all(
["", "-community"].map(ext =>
downloadAndUnzip({
"destDirPath": destDirPath,
"url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`,
"pathOfDirToExtractInArchive": `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`
})
)
);
}
async function main() {
const buildOptions = readBuildOptions({
"projectDirPath": process.cwd(),
"processArgv": process.argv.slice(2)
});
@ -281,7 +35,7 @@ async function main() {
await downloadBuiltinKeycloakTheme({
keycloakVersion,
destDirPath,
buildOptions
"isSilent": buildOptions.isSilent
});
}

View File

@ -1,203 +0,0 @@
import { createHash } from "crypto";
import { mkdir, writeFile, unlink } from "fs/promises";
import fetch from "make-fetch-happen";
import { dirname as pathDirname, join as pathJoin, basename as pathBasename } from "path";
import { assert } from "tsafe/assert";
import { transformCodebase } from "./tools/transformCodebase";
import { unzip, zip } from "./tools/unzip";
import { rm } from "./tools/fs.rm";
import * as child_process from "child_process";
import { existsAsync } from "./tools/fs.existsAsync";
import type { BuildOptions } from "./keycloakify/buildOptions";
import { getProxyFetchOptions } from "./tools/fetchProxyOptions";
export type BuildOptionsLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function downloadAndUnzip(params: {
url: string;
destDirPath: string;
specificDirsToExtract?: string[];
preCacheTransform?: {
actionCacheId: string;
action: (params: { destDirPath: string }) => Promise<void>;
};
buildOptions: BuildOptionsLike;
}) {
const { url, destDirPath, specificDirsToExtract, preCacheTransform, buildOptions } = params;
const { extractDirPath, zipFilePath } = (() => {
const zipFileBasenameWithoutExt = generateFileNameFromURL({
url,
"preCacheTransform":
preCacheTransform === undefined
? undefined
: {
"actionCacheId": preCacheTransform.actionCacheId,
"actionFootprint": preCacheTransform.action.toString()
}
});
const zipFilePath = pathJoin(buildOptions.cacheDirPath, `${zipFileBasenameWithoutExt}.zip`);
const extractDirPath = pathJoin(buildOptions.cacheDirPath, `tmp_unzip_${zipFileBasenameWithoutExt}`);
return { zipFilePath, extractDirPath };
})();
download_zip_and_transform: {
if (await existsAsync(zipFilePath)) {
break download_zip_and_transform;
}
const { response, isFromRemoteCache } = await (async () => {
const proxyFetchOptions = await getProxyFetchOptions({
"npmWorkspaceRootDirPath": buildOptions.npmWorkspaceRootDirPath
});
const response = await fetch(
`https://github.com/keycloakify/keycloakify/releases/download/v0.0.1/${pathBasename(zipFilePath)}`,
proxyFetchOptions
);
if (response.status === 200) {
return {
response,
"isFromRemoteCache": true
};
}
return {
"response": await fetch(url, proxyFetchOptions),
"isFromRemoteCache": false
};
})();
await mkdir(pathDirname(zipFilePath), { "recursive": true });
/**
* The correct way to fix this is to upgrade node-fetch beyond 3.2.5
* (see https://github.com/node-fetch/node-fetch/issues/1295#issuecomment-1144061991.)
* Unfortunately, octokit (a dependency of keycloakify) also uses node-fetch, and
* does not support node-fetch 3.x. So we stick around with this band-aid until
* octokit upgrades.
*/
response.body?.setMaxListeners(Number.MAX_VALUE);
assert(typeof response.body !== "undefined" && response.body != null);
await writeFile(zipFilePath, response.body);
if (isFromRemoteCache) {
break download_zip_and_transform;
}
if (specificDirsToExtract === undefined && preCacheTransform === undefined) {
break download_zip_and_transform;
}
await unzip(zipFilePath, extractDirPath, specificDirsToExtract);
try {
await preCacheTransform?.action({
"destDirPath": extractDirPath
});
} catch (error) {
await Promise.all([rm(extractDirPath, { "recursive": true }), unlink(zipFilePath)]);
throw error;
}
await unlink(zipFilePath);
await zip(extractDirPath, zipFilePath);
await rm(extractDirPath, { "recursive": true });
upload_to_remot_cache_if_admin: {
const githubToken = process.env["KEYCLOAKIFY_ADMIN_GITHUB_PERSONAL_ACCESS_TOKEN"];
if (githubToken === undefined) {
break upload_to_remot_cache_if_admin;
}
console.log("uploading to remote cache");
try {
child_process.execSync(`which putasset`);
} catch {
child_process.execSync(`npm install -g putasset`);
}
try {
child_process.execFileSync("putasset", [
"--owner",
"keycloakify",
"--repo",
"keycloakify",
"--tag",
"v0.0.1",
"--filename",
zipFilePath,
"--token",
githubToken
]);
} catch {
console.log("upload failed, asset probably already exists in remote cache");
}
}
}
await unzip(zipFilePath, extractDirPath);
transformCodebase({
"srcDirPath": extractDirPath,
"destDirPath": destDirPath
});
await rm(extractDirPath, { "recursive": true });
}
function generateFileNameFromURL(params: {
url: string;
preCacheTransform:
| {
actionCacheId: string;
actionFootprint: string;
}
| undefined;
}): string {
const { preCacheTransform } = params;
// Parse the URL
const url = new URL(params.url);
// Extract pathname and remove leading slashes
let fileName = url.pathname.replace(/^\//, "").replace(/\//g, "_");
// Optionally, add query parameters replacing special characters
if (url.search) {
fileName += url.search.replace(/[&=?]/g, "-");
}
// Replace any characters that are not valid in filenames
fileName = fileName.replace(/[^a-zA-Z0-9-_]/g, "");
// Trim or pad the fileName to a specific length
fileName = fileName.substring(0, 50);
add_pre_cache_transform: {
if (preCacheTransform === undefined) {
break add_pre_cache_transform;
}
// Sanitize actionCacheId the same way as other components
const sanitizedActionCacheId = preCacheTransform.actionCacheId.replace(/[^a-zA-Z0-9-_]/g, "_");
fileName += `_${sanitizedActionCacheId}_${createHash("sha256").update(preCacheTransform.actionFootprint).digest("hex").substring(0, 5)}`;
}
return fileName;
}

View File

@ -1,25 +1,26 @@
#!/usr/bin/env node
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
import { getProjectRoot } from "./tools/getProjectRoot";
import cliSelect from "cli-select";
import { loginThemePageIds, accountThemePageIds, type LoginThemePageId, type AccountThemePageId } from "./keycloakify/generateFtl";
import {
loginThemePageIds,
accountThemePageIds,
type LoginThemePageId,
type AccountThemePageId,
themeTypes,
type ThemeType
} from "./keycloakify/generateFtl";
import { capitalize } from "tsafe/capitalize";
import { readFile, writeFile } from "fs/promises";
import { existsSync } from "fs";
import { join as pathJoin, relative as pathRelative } from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import { getThemeSrcDirPath } from "./getThemeSrcDirPath";
import { themeTypes, type ThemeType } from "./constants";
import { getReactAppRootDirPath } from "./keycloakify/buildOptions/getReactAppRootDirPath";
import { getThemeSrcDirPath } from "./getSrcDirPath";
(async () => {
console.log("Select a theme type");
const { reactAppRootDirPath } = getReactAppRootDirPath({
"processArgv": process.argv.slice(2)
});
const { value: themeType } = await cliSelect<ThemeType>({
"values": [...themeTypes]
}).catch(() => {
@ -48,7 +49,11 @@ import { getReactAppRootDirPath } from "./keycloakify/buildOptions/getReactAppRo
const pageBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(/ftl$/, "tsx");
const { themeSrcDirPath } = getThemeSrcDirPath({ reactAppRootDirPath });
const { themeSrcDirPath } = getThemeSrcDirPath({ "projectDirPath": process.cwd() });
if (themeSrcDirPath === undefined) {
throw new Error("Couldn't locate your theme sources");
}
const targetFilePath = pathJoin(themeSrcDirPath, themeType, "pages", pageBasename);
@ -58,7 +63,7 @@ import { getReactAppRootDirPath } from "./keycloakify/buildOptions/getReactAppRo
process.exit(-1);
}
await writeFile(targetFilePath, await readFile(pathJoin(getThisCodebaseRootDirPath(), "src", themeType, "pages", pageBasename)));
await writeFile(targetFilePath, await readFile(pathJoin(getProjectRoot(), "src", themeType, "pages", pageBasename)));
console.log(`${pathRelative(process.cwd(), targetFilePath)} created`);
})();

43
src/bin/getSrcDirPath.ts Normal file
View File

@ -0,0 +1,43 @@
import * as fs from "fs";
import { exclude } from "tsafe";
import { crawl } from "./tools/crawl";
import { join as pathJoin } from "path";
const themeSrcDirBasename = "keycloak-theme";
export function getThemeSrcDirPath(params: { projectDirPath: string }) {
const { projectDirPath } = params;
const srcDirPath = pathJoin(projectDirPath, "src");
const themeSrcDirPath: string | undefined = crawl(srcDirPath)
.map(fileRelativePath => {
const split = fileRelativePath.split(themeSrcDirBasename);
if (split.length !== 2) {
return undefined;
}
return pathJoin(srcDirPath, split[0] + themeSrcDirBasename);
})
.filter(exclude(undefined))[0];
if (themeSrcDirPath === undefined) {
if (fs.existsSync(pathJoin(srcDirPath, "login")) || fs.existsSync(pathJoin(srcDirPath, "account"))) {
return { "themeSrcDirPath": srcDirPath };
}
return { "themeSrcDirPath": undefined };
}
return { themeSrcDirPath };
}
export function getEmailThemeSrcDirPath(params: { projectDirPath: string }) {
const { projectDirPath } = params;
const { themeSrcDirPath } = getThemeSrcDirPath({ projectDirPath });
const emailThemeSrcDirPath = themeSrcDirPath === undefined ? undefined : pathJoin(themeSrcDirPath, "email");
return { emailThemeSrcDirPath };
}

View File

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

View File

@ -4,24 +4,28 @@ import { downloadBuiltinKeycloakTheme } from "./download-builtin-keycloak-theme"
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./promptKeycloakVersion";
import { readBuildOptions } from "./keycloakify/buildOptions";
import { readBuildOptions } from "./keycloakify/BuildOptions";
import * as fs from "fs";
import { getLogger } from "./tools/logger";
import { getThemeSrcDirPath } from "./getThemeSrcDirPath";
import { rmSync } from "./tools/fs.rmSync";
import { getEmailThemeSrcDirPath } from "./getSrcDirPath";
export async function main() {
const buildOptions = readBuildOptions({
const { isSilent } = readBuildOptions({
"projectDirPath": process.cwd(),
"processArgv": process.argv.slice(2)
});
const logger = getLogger({ "isSilent": buildOptions.isSilent });
const logger = getLogger({ isSilent });
const { themeSrcDirPath } = getThemeSrcDirPath({
"reactAppRootDirPath": buildOptions.reactAppRootDirPath
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({
"projectDirPath": process.cwd()
});
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
if (emailThemeSrcDirPath === undefined) {
logger.warn("Couldn't locate your theme source directory");
process.exit(-1);
}
if (fs.existsSync(emailThemeSrcDirPath)) {
logger.warn(`There is already a ${pathRelative(process.cwd(), emailThemeSrcDirPath)} directory in your project. Aborting.`);
@ -36,7 +40,7 @@ export async function main() {
await downloadBuiltinKeycloakTheme({
keycloakVersion,
"destDirPath": builtinKeycloakThemeTmpDirPath,
buildOptions
isSilent
});
transformCodebase({
@ -52,7 +56,7 @@ export async function main() {
logger.log(`${pathRelative(process.cwd(), emailThemeSrcDirPath)} ready to be customized, feel free to remove every file you do not customize`);
rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true, "force": true });
fs.rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true, "force": true });
}
if (require.main === module) {

View File

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

View File

@ -1,25 +0,0 @@
import { z } from "zod";
export type UserProvidedBuildOptions = {
extraThemeProperties?: string[];
artifactId?: string;
groupId?: string;
doCreateJar?: boolean;
loginThemeResourcesFromKeycloakVersion?: string;
reactAppBuildDirPath?: string;
keycloakifyBuildDirPath?: string;
themeName?: string | string[];
doBuildRetrocompatAccountTheme?: boolean;
};
export const zUserProvidedBuildOptions = z.object({
"extraThemeProperties": z.array(z.string()).optional(),
"artifactId": z.string().optional(),
"groupId": z.string().optional(),
"doCreateJar": z.boolean().optional(),
"loginThemeResourcesFromKeycloakVersion": z.string().optional(),
"reactAppBuildDirPath": z.string().optional(),
"keycloakifyBuildDirPath": z.string().optional(),
"themeName": z.union([z.string(), z.array(z.string())]).optional(),
"doBuildRetrocompatAccountTheme": z.boolean().optional()
});

View File

@ -1,193 +0,0 @@
import { parse as urlParse } from "url";
import { readParsedPackageJson } from "./parsedPackageJson";
import { join as pathJoin } from "path";
import parseArgv from "minimist";
import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath";
import { readResolvedViteConfig } from "./resolvedViteConfig";
import * as fs from "fs";
import { getCacheDirPath } from "./getCacheDirPath";
import { getReactAppRootDirPath } from "./getReactAppRootDirPath";
import { getNpmWorkspaceRootDirPath } from "./getNpmWorkspaceRootDirPath";
/** Consolidated build option gathered form CLI arguments and config in package.json */
export type BuildOptions = {
bundler: "vite" | "webpack";
isSilent: boolean;
themeVersion: string;
themeNames: string[];
extraThemeProperties: string[] | undefined;
groupId: string;
artifactId: string;
doCreateJar: boolean;
loginThemeResourcesFromKeycloakVersion: string;
reactAppRootDirPath: string;
reactAppBuildDirPath: string;
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
keycloakifyBuildDirPath: string;
publicDirPath: string;
cacheDirPath: string;
/** If your app is hosted under a subpath, it's the case in CRA if you have "homepage": "https://example.com/my-app" in your package.json
* In this case the urlPathname will be "/my-app/" */
urlPathname: string | undefined;
assetsDirPath: string;
doBuildRetrocompatAccountTheme: boolean;
npmWorkspaceRootDirPath: string;
};
export function readBuildOptions(params: { processArgv: string[] }): BuildOptions {
const { processArgv } = params;
const { reactAppRootDirPath } = getReactAppRootDirPath({ processArgv });
const { cacheDirPath } = getCacheDirPath({ reactAppRootDirPath });
const { resolvedViteConfig } = readResolvedViteConfig({ cacheDirPath });
if (resolvedViteConfig === undefined && fs.existsSync(pathJoin(reactAppRootDirPath, "vite.config.ts"))) {
throw new Error("Keycloakify's Vite plugin output not found");
}
const { keycloakify: userProvidedBuildOptionsFromPackageJson, ...parsedPackageJson } = readParsedPackageJson({ reactAppRootDirPath });
const userProvidedBuildOptions = {
...userProvidedBuildOptionsFromPackageJson,
...resolvedViteConfig?.userProvidedBuildOptions
};
const themeNames = (() => {
if (userProvidedBuildOptions.themeName === undefined) {
return [
parsedPackageJson.name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-")
];
}
if (typeof userProvidedBuildOptions.themeName === "string") {
return [userProvidedBuildOptions.themeName];
}
return userProvidedBuildOptions.themeName;
})();
const reactAppBuildDirPath = (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
if (userProvidedBuildOptions.reactAppBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
"pathIsh": userProvidedBuildOptions.reactAppBuildDirPath,
"cwd": reactAppRootDirPath
});
}
return pathJoin(reactAppRootDirPath, "build");
}
return pathJoin(reactAppRootDirPath, resolvedViteConfig.buildDir);
})();
const argv = parseArgv(processArgv);
const { npmWorkspaceRootDirPath } = getNpmWorkspaceRootDirPath({ reactAppRootDirPath });
return {
"bundler": resolvedViteConfig !== undefined ? "vite" : "webpack",
"isSilent": typeof argv["silent"] === "boolean" ? argv["silent"] : false,
"themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? parsedPackageJson.version ?? "0.0.0",
themeNames,
"extraThemeProperties": userProvidedBuildOptions.extraThemeProperties,
"groupId": (() => {
const fallbackGroupId = `${themeNames[0]}.keycloak`;
return (
process.env.KEYCLOAKIFY_GROUP_ID ??
userProvidedBuildOptions.groupId ??
(parsedPackageJson.homepage === undefined
? fallbackGroupId
: urlParse(parsedPackageJson.homepage)
.host?.replace(/:[0-9]+$/, "")
?.split(".")
.reverse()
.join(".") ?? fallbackGroupId) + ".keycloak"
);
})(),
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? userProvidedBuildOptions.artifactId ?? `${themeNames[0]}-keycloak-theme`,
"doCreateJar": userProvidedBuildOptions.doCreateJar ?? true,
"loginThemeResourcesFromKeycloakVersion": userProvidedBuildOptions.loginThemeResourcesFromKeycloakVersion ?? "11.0.3",
reactAppRootDirPath,
reactAppBuildDirPath,
"keycloakifyBuildDirPath": (() => {
if (userProvidedBuildOptions.keycloakifyBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
"pathIsh": userProvidedBuildOptions.keycloakifyBuildDirPath,
"cwd": reactAppRootDirPath
});
}
return pathJoin(
reactAppRootDirPath,
resolvedViteConfig?.buildDir === undefined ? "build_keycloak" : `${resolvedViteConfig.buildDir}_keycloak`
);
})(),
"publicDirPath": (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
if (process.env.PUBLIC_DIR_PATH !== undefined) {
return getAbsoluteAndInOsFormatPath({
"pathIsh": process.env.PUBLIC_DIR_PATH,
"cwd": reactAppRootDirPath
});
}
return pathJoin(reactAppRootDirPath, "public");
}
return pathJoin(reactAppRootDirPath, resolvedViteConfig.publicDir);
})(),
cacheDirPath,
"urlPathname": (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
const { homepage } = parsedPackageJson;
let url: URL | undefined = undefined;
if (homepage !== undefined) {
url = new URL(homepage);
}
if (url === undefined) {
return undefined;
}
const out = url.pathname.replace(/([^/])$/, "$1/");
return out === "/" ? undefined : out;
}
return resolvedViteConfig.urlPathname;
})(),
"assetsDirPath": (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
return pathJoin(reactAppBuildDirPath, "static");
}
return pathJoin(reactAppBuildDirPath, resolvedViteConfig.assetsDir);
})(),
"doBuildRetrocompatAccountTheme": userProvidedBuildOptions.doBuildRetrocompatAccountTheme ?? true,
npmWorkspaceRootDirPath
};
}

View File

@ -1,25 +0,0 @@
import { join as pathJoin } from "path";
import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath";
import { getNpmWorkspaceRootDirPath } from "./getNpmWorkspaceRootDirPath";
export function getCacheDirPath(params: { reactAppRootDirPath: string }) {
const { reactAppRootDirPath } = params;
const { npmWorkspaceRootDirPath } = getNpmWorkspaceRootDirPath({ reactAppRootDirPath });
const cacheDirPath = pathJoin(
(() => {
if (process.env.XDG_CACHE_HOME !== undefined) {
return getAbsoluteAndInOsFormatPath({
"pathIsh": process.env.XDG_CACHE_HOME,
"cwd": reactAppRootDirPath
});
}
return pathJoin(npmWorkspaceRootDirPath, "node_modules", ".cache");
})(),
"keycloakify"
);
return { cacheDirPath };
}

View File

@ -1,49 +0,0 @@
import * as child_process from "child_process";
import { join as pathJoin, resolve as pathResolve, sep as pathSep } from "path";
import { assert } from "tsafe/assert";
let cache:
| {
reactAppRootDirPath: string;
npmWorkspaceRootDirPath: string;
}
| undefined = undefined;
export function getNpmWorkspaceRootDirPath(params: { reactAppRootDirPath: string }) {
const { reactAppRootDirPath } = params;
use_cache: {
if (cache === undefined || cache.reactAppRootDirPath !== reactAppRootDirPath) {
break use_cache;
}
const { npmWorkspaceRootDirPath } = cache;
return { npmWorkspaceRootDirPath };
}
const npmWorkspaceRootDirPath = (function callee(depth: number): string {
const cwd = pathResolve(pathJoin(...[reactAppRootDirPath, ...Array(depth).fill("..")]));
try {
child_process.execSync("npm config get", { cwd, "stdio": ["pipe", "pipe", "pipe"] });
} catch (error) {
if (String(error).includes("ENOWORKSPACES")) {
assert(cwd !== pathSep, "NPM workspace not found");
return callee(depth + 1);
}
throw error;
}
return cwd;
})(0);
cache = {
reactAppRootDirPath,
npmWorkspaceRootDirPath
};
return { npmWorkspaceRootDirPath };
}

View File

@ -1,23 +0,0 @@
import parseArgv from "minimist";
import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath";
export function getReactAppRootDirPath(params: { processArgv: string[] }) {
const { processArgv } = params;
const argv = parseArgv(processArgv);
const reactAppRootDirPath = (() => {
const arg = argv["project"] ?? argv["p"];
if (typeof arg !== "string") {
return process.cwd();
}
return getAbsoluteAndInOsFormatPath({
"pathIsh": arg,
"cwd": process.cwd()
});
})();
return { reactAppRootDirPath };
}

View File

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

View File

@ -1,32 +0,0 @@
import * as fs from "fs";
import { assert } from "tsafe";
import type { Equals } from "tsafe";
import { z } from "zod";
import { join as pathJoin } from "path";
import { type UserProvidedBuildOptions, zUserProvidedBuildOptions } from "./UserProvidedBuildOptions";
export type ParsedPackageJson = {
name: string;
version?: string;
homepage?: string;
keycloakify?: UserProvidedBuildOptions;
};
const zParsedPackageJson = z.object({
"name": z.string(),
"version": z.string().optional(),
"homepage": z.string().optional(),
"keycloakify": zUserProvidedBuildOptions.optional()
});
assert<Equals<ReturnType<(typeof zParsedPackageJson)["parse"]>, ParsedPackageJson>>();
let parsedPackageJson: undefined | ParsedPackageJson;
export function readParsedPackageJson(params: { reactAppRootDirPath: string }) {
const { reactAppRootDirPath } = params;
if (parsedPackageJson) {
return parsedPackageJson;
}
parsedPackageJson = zParsedPackageJson.parse(JSON.parse(fs.readFileSync(pathJoin(reactAppRootDirPath, "package.json")).toString("utf8")));
return parsedPackageJson;
}

View File

@ -1,74 +0,0 @@
import * as fs from "fs";
import { assert } from "tsafe";
import type { Equals } from "tsafe";
import { z } from "zod";
import { join as pathJoin } from "path";
import { resolvedViteConfigJsonBasename } from "../../constants";
import type { OptionalIfCanBeUndefined } from "../../tools/OptionalIfCanBeUndefined";
import { UserProvidedBuildOptions, zUserProvidedBuildOptions } from "./UserProvidedBuildOptions";
export type ResolvedViteConfig = {
buildDir: string;
publicDir: string;
assetsDir: string;
urlPathname: string | undefined;
userProvidedBuildOptions: UserProvidedBuildOptions;
};
const zResolvedViteConfig = z.object({
"buildDir": z.string(),
"publicDir": z.string(),
"assetsDir": z.string(),
"urlPathname": z.string().optional(),
"userProvidedBuildOptions": zUserProvidedBuildOptions
});
{
type Got = ReturnType<(typeof zResolvedViteConfig)["parse"]>;
type Expected = OptionalIfCanBeUndefined<ResolvedViteConfig>;
assert<Equals<Got, Expected>>();
}
export function readResolvedViteConfig(params: { cacheDirPath: string }): {
resolvedViteConfig: ResolvedViteConfig | undefined;
} {
const { cacheDirPath } = params;
const resolvedViteConfigJsonFilePath = pathJoin(cacheDirPath, resolvedViteConfigJsonBasename);
if (!fs.existsSync(resolvedViteConfigJsonFilePath)) {
return { "resolvedViteConfig": undefined };
}
const resolvedViteConfig = (() => {
if (!fs.existsSync(resolvedViteConfigJsonFilePath)) {
throw new Error("Missing Keycloakify Vite plugin output.");
}
let out: ResolvedViteConfig;
try {
out = JSON.parse(fs.readFileSync(resolvedViteConfigJsonFilePath).toString("utf8"));
} catch {
throw new Error("The output of the Keycloakify Vite plugin is not a valid JSON.");
}
try {
const zodParseReturn = zResolvedViteConfig.parse(out);
// So that objectKeys from tsafe return the expected result no matter what.
Object.keys(zodParseReturn)
.filter(key => !(key in out))
.forEach(key => {
delete (out as any)[key];
});
} catch {
throw new Error("The output of the Keycloakify Vite plugin do not match the expected schema.");
}
return out;
})();
return { resolvedViteConfig };
}

View File

@ -0,0 +1 @@
export const ftlValuesGlobalName = "kcContext";

View File

@ -8,7 +8,13 @@
out["advancedMsg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
out["messagesPerField"]= {
<#assign fieldNames = [ FIELD_NAMES_eKsIY4ZsZ4xeM ]>
<#assign fieldNames = [
"global", "userLabel", "username", "email", "firstName", "lastName", "password", "password-confirm",
"totp", "totpSecret", "SAMLRequest", "SAMLResponse", "relayState", "device_user_code", "code",
"password-new", "rememberMe", "login", "authenticationExecution", "cancel-aia", "clientDataJSON",
"authenticatorData", "signature", "credentialId", "userHandle", "error", "authn_use_chk", "authenticationExecution",
"isSetRetry", "try-again", "attestationObject", "publicKeyCredentialId", "authenticatorLabel"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM
]>
<#attempt>
<#if profile?? && profile.attributes?? && profile.attributes?is_enumerable>
@ -22,374 +28,85 @@
<#recover>
</#attempt>
"printIfExists": function (fieldName, text) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
"printIfExists": function (fieldName, x) {
<#if !messagesPerField?? >
return undefined;
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
<#if !messagesPerField.existsError??>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistMessageForUsernameOrPassword = "">
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
<#if !doExistMessageForUsernameOrPassword>
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
</#if>
return <#if doExistMessageForUsernameOrPassword>text<#else>undefined</#if>;
<#attempt>
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
return <#if messagesPerField.exists('username', 'password')>x<#else>undefined</#if>;
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
return <#if messagesPerField.exists('${fieldName}')>x<#else>undefined</#if>;
</#if>
<#else>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
<#if doExistErrorOnUsernameOrPassword>
return text;
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
</#if>
<#recover>
</#attempt>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
throw new Error("There is no " + fieldName + " field");
</#if>
},
"existsError": function (fieldName) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
<#if !messagesPerField?? >
return false;
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
<#if !messagesPerField.existsError??>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistMessageForUsernameOrPassword = "">
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
<#if !doExistMessageForUsernameOrPassword>
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
</#if>
return <#if doExistMessageForUsernameOrPassword>true<#else>false</#if>;
<#attempt>
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
return <#if messagesPerField.existsError('username', 'password')>true<#else>false</#if>;
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>true<#else>false</#if>;
return <#if messagesPerField.existsError('${fieldName}')>true<#else>false</#if>;
</#if>
<#else>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
<#else>
<#assign doExistErrorMessageForField = "">
<#attempt>
<#assign doExistErrorMessageForField = messagesPerField.existsError('${fieldName}')>
<#recover>
<#assign doExistErrorMessageForField = true>
</#attempt>
return <#if doExistErrorMessageForField>true<#else>false</#if>;
</#if>
</#if>
<#recover>
</#attempt>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
throw new Error("There is no " + fieldName + " field");
</#if>
},
"get": function (fieldName) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.get in this page");
<#if !messagesPerField?? >
return '';
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
<#if !messagesPerField.existsError??>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistMessageForUsernameOrPassword = "">
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
<#if !doExistMessageForUsernameOrPassword>
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
<#attempt>
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
<#if messagesPerField.existsError('username', 'password')>
return 'Invalid username or password.';
</#if>
<#if !doExistMessageForUsernameOrPassword>
return "";
<#else>
<#attempt>
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}";
<#recover>
return "Invalid username or password.";
</#attempt>
</#if>
<#else>
<#attempt>
<#if messagesPerField.existsError('${fieldName}')>
return "${messagesPerField.get('${fieldName}')?no_esc}";
<#recover>
return "invalid field";
</#attempt>
</#if>
<#else>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
<#if doExistErrorOnUsernameOrPassword>
<#attempt>
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}";
<#recover>
return "Invalid username or password.";
</#attempt>
<#else>
<#attempt>
return "${messagesPerField.get('${fieldName}')?no_esc}";
<#recover>
return "";
</#attempt>
</#if>
<#else>
<#attempt>
return "${messagesPerField.get('${fieldName}')?no_esc}";
<#recover>
return "invalid field";
</#attempt>
</#if>
</#if>
<#recover>
</#attempt>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
throw new Error("There is no " + fieldName + " field");
</#if>
},
"exists": function (fieldName) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.exists in this page");
<#if !messagesPerField?? >
return false;
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
<#if !messagesPerField.existsError??>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistMessageForUsernameOrPassword = "">
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
<#if !doExistMessageForUsernameOrPassword>
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
</#if>
return <#if doExistMessageForUsernameOrPassword>true<#else>false</#if>;
<#attempt>
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
return <#if messagesPerField.exists('username') || messagesPerField.exists('password')>true<#else>false</#if>;
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>true<#else>false</#if>;
return <#if messagesPerField.exists('${fieldName}')>true<#else>false</#if>;
</#if>
<#else>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
<#else>
<#assign doExistErrorMessageForField = "">
<#attempt>
<#assign doExistErrorMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistErrorMessageForField = true>
</#attempt>
return <#if doExistErrorMessageForField>true<#else>false</#if>;
</#if>
</#if>
<#recover>
</#attempt>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
throw new Error("There is no " + fieldName + " field");
</#if>
}
};
@ -408,14 +125,6 @@
out["themeName"] = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer";
out["pageId"] = "${pageId}";
try {
out["url"]["resourcesCommonPath"] = out["url"]["resourcesPath"] + "/" + "RESOURCES_COMMON_cLsLsMrtDkpVv";
} catch(error) {
}
return out;
})()
@ -431,7 +140,7 @@
<#if isHash>
<#if path?size gt 10>
<#return "ABORT: Too many recursive calls, path: " + path?join(".")>
<#return "ABORT: Too many recursive calls">
</#if>
<#local keys = "">
@ -463,15 +172,10 @@
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
key == "loginAction" &&
are_same_path(path, ["url"]) &&
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl"]?seq_contains(pageId) &&
!(auth?has_content && auth.showTryAnotherWayLink())
) || (
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
["secretData", "value"]?seq_contains(key) &&
are_same_path(path, [ "totp", "otpCredentials", "*" ])
) || (
["contextData", "idpConfig", "idp", "authenticationSession"]?seq_contains(key) &&
are_same_path(path, ["brokerContext"]) &&
@ -487,44 +191,22 @@
"error.ftl" == pageId &&
are_same_path(path, ["realm"]) &&
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
) || (
"applications.ftl" == pageId &&
is_subpath(path, ["applications", "applications"]) &&
(
key == "realm" ||
key == "container"
)
) || (
are_same_path(path, ["user"]) &&
key == "delegateForUpdate"
) || (
<#-- Security audit forwarded by Garth (Gmail) -->
are_same_path(path, ["client", "attributes"]) &&
key == "saml.signing.private.key"
) || (
<#-- See: https://github.com/keycloakify/keycloakify/issues/534 -->
are_same_path(path, ["login"]) &&
key == "password"
)
>
<#local out_seq += ["/*If you need '" + path?join(".") + "." + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#continue>
</#if>
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
<#if (
["register.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
key == "attemptedUsername" && are_same_path(path, ["auth"])
)>
<#if key == "attemptedUsername" && are_same_path(path, ["auth"])>
<#attempt>
<#-- https://github.com/keycloak/keycloak/blob/3a2bf0c04bcde185e497aaa32d0bb7ab7520cf4a/themes/src/main/resources/theme/base/login/template.ftl#L63 -->
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#continue>
</#if>
<#recover>
<#local out_seq += ["/*Testing if attemptedUsername should be skipped throwed an exception */"]>
</#attempt>
</#if>
<#attempt>
@ -676,9 +358,9 @@
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non enumerable object">
</#function>
<#function is_subpath path searchedPath>
<#function are_same_path path searchedPath>
<#if path?size < searchedPath?size>
<#if path?size != searchedPath?size>
<#return false>
</#if>
@ -686,14 +368,8 @@
<#list path as property>
<#if i == searchedPath?size >
<#continue>
</#if>
<#local searchedProperty=searchedPath[i]>
<#local i+= 1>
<#if searchedProperty?is_string && searchedProperty == "*">
<#continue>
</#if>
@ -710,13 +386,11 @@
<#return false>
</#if>
<#local i+= 1>
</#list>
<#return true>
</#function>
<#function are_same_path path searchedPath>
<#return path?size == searchedPath?size && is_subpath(path, searchedPath)>
</#function>
</script>

View File

@ -1,55 +1,84 @@
import cheerio from "cheerio";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode";
import { generateCssCodeToDefineGlobals } from "../replacers/replaceImportsInCssCode";
import { replaceImportsInInlineCssCode } from "../replacers/replaceImportsInInlineCssCode";
import * as fs from "fs";
import { join as pathJoin } from "path";
import { objectKeys } from "tsafe/objectKeys";
import type { BuildOptions } from "../buildOptions";
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import { type ThemeType, nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir, resources_common } from "../../constants";
export type BuildOptionsLike = {
bundler: "vite" | "webpack";
themeVersion: string;
urlPathname: string | undefined;
reactAppBuildDirPath: string;
assetsDirPath: string;
};
export const themeTypes = ["login", "account"] as const;
export type ThemeType = (typeof themeTypes)[number];
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
export namespace BuildOptionsLike {
export type Common = {
themeName: string;
customUserAttributes: string[];
themeVersion: string;
};
export type Standalone = Common & {
isStandalone: true;
urlPathname: string | undefined;
};
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
export namespace ExternalAssets {
export type CommonExternalAssets = {
isStandalone: false;
};
export type SameDomain = Common &
CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: true;
};
export type DifferentDomains = Common &
CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: false;
urlOrigin: string;
urlPathname: string | undefined;
};
}
}
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function generateFtlFilesCodeFactory(params: {
themeName: string;
indexHtmlCode: string;
//NOTE: Expected to be an empty object if external assets mode is enabled.
cssGlobalsToDefine: Record<string, string>;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
themeType: ThemeType;
fieldNames: string[];
}) {
const { themeName, cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType, fieldNames } = params;
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType } = params;
const $ = cheerio.load(indexHtmlCode);
fix_imports_statements: {
if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) {
break fix_imports_statements;
}
$("script:not([src])").each((...[, element]) => {
const jsCode = $(element).html();
assert(jsCode !== null);
const { fixedJsCode } = replaceImportsInJsCode({ jsCode, buildOptions });
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": $(element).html()!,
buildOptions
});
$(element).text(fixedJsCode);
});
$("style").each((...[, element]) => {
const cssCode = $(element).html();
assert(cssCode !== null);
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"cssCode": $(element).html()!,
buildOptions
});
@ -71,10 +100,9 @@ export function generateFtlFilesCodeFactory(params: {
$(element).attr(
attrName,
href.replace(
new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`),
`\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
)
buildOptions.isStandalone
? href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/")
: href.replace(/^\//, `${buildOptions.urlOrigin}/`)
);
})
);
@ -101,12 +129,14 @@ export function generateFtlFilesCodeFactory(params: {
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
.toString("utf8")
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1]
.replace("FIELD_NAMES_eKsIY4ZsZ4xeM", fieldNames.map(name => `"${name}"`).join(", "))
.replace(
"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM",
buildOptions.customUserAttributes.length === 0 ? "" : ", " + buildOptions.customUserAttributes.map(name => `"${name}"`).join(", ")
)
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion)
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", themeName)
.replace("RESOURCES_COMMON_cLsLsMrtDkpVv", resources_common),
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", buildOptions.themeName),
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
"<#if scripts??>",
" <#list scripts as script>",
@ -119,34 +149,13 @@ export function generateFtlFilesCodeFactory(params: {
$("head").prepend(
[
"<script>",
` window.${nameOfTheGlobal}= ${objectKeys(replaceValueBySearchValue)[0]};`,
` window.${ftlValuesGlobalName}= ${objectKeys(replaceValueBySearchValue)[0]};`,
"</script>",
"",
objectKeys(replaceValueBySearchValue)[1]
].join("\n")
);
// Remove part of the document marked as ignored.
{
const startTags = $('meta[name="keycloakify-ignore-start"]');
startTags.each((...[, startTag]) => {
const $startTag = $(startTag);
const $endTag = $startTag.nextAll('meta[name="keycloakify-ignore-end"]').first();
if ($endTag.length) {
let currentNode = $startTag.next();
while (currentNode.length && !currentNode.is($endTag)) {
currentNode.remove();
currentNode = $startTag.next();
}
$startTag.remove();
$endTag.remove();
}
});
}
const partiallyFixedIndexHtmlCode = $.html();
function generateFtlFilesCode(params: { pageId: string }): {

View File

@ -10,8 +10,6 @@ export const loginThemePageIds = [
"login-reset-password.ftl",
"login-verify-email.ftl",
"terms.ftl",
"login-oauth2-device-verify-user-code.ftl",
"login-oauth-grant.ftl",
"login-otp.ftl",
"login-update-profile.ftl",
"login-update-password.ftl",
@ -27,15 +25,7 @@ export const loginThemePageIds = [
"saml-post-form.ftl"
] as const;
export const accountThemePageIds = [
"password.ftl",
"account.ftl",
"sessions.ftl",
"totp.ftl",
"applications.ftl",
"log.ftl",
"federatedIdentity.ftl"
] as const;
export const accountThemePageIds = ["password.ftl", "account.ftl"] as const;
export type LoginThemePageId = (typeof loginThemePageIds)[number];
export type AccountThemePageId = (typeof accountThemePageIds)[number];

View File

@ -0,0 +1,87 @@
import * as fs from "fs";
import { join as pathJoin, dirname as pathDirname } from "path";
import { themeTypes } from "./generateFtl/generateFtl";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "./BuildOptions";
export type BuildOptionsLike = {
themeName: string;
extraThemeNames: string[];
groupId: string;
artifactId?: string;
themeVersion: string;
};
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export function generateJavaStackFiles(params: {
keycloakThemeBuildingDirPath: string;
doBundlesEmailTemplate: boolean;
buildOptions: BuildOptionsLike;
}): {
jarFilePath: string;
} {
const {
buildOptions: { groupId, themeName, extraThemeNames, themeVersion, artifactId },
keycloakThemeBuildingDirPath,
doBundlesEmailTemplate
} = params;
{
const { pomFileCode } = (function generatePomFileCode(): {
pomFileCode: string;
} {
const pomFileCode = [
`<?xml version="1.0"?>`,
`<project xmlns="http://maven.apache.org/POM/4.0.0"`,
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
` <modelVersion>4.0.0</modelVersion>`,
` <groupId>${groupId}</groupId>`,
` <artifactId>${artifactId}</artifactId>`,
` <version>${themeVersion}</version>`,
` <name>${artifactId}</name>`,
` <description />`,
`</project>`
].join("\n");
return { pomFileCode };
})();
fs.writeFileSync(pathJoin(keycloakThemeBuildingDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8"));
}
{
const themeManifestFilePath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "META-INF", "keycloak-themes.json");
try {
fs.mkdirSync(pathDirname(themeManifestFilePath));
} catch {}
fs.writeFileSync(
themeManifestFilePath,
Buffer.from(
JSON.stringify(
{
"themes": [themeName, ...extraThemeNames].map(themeName => ({
"name": themeName,
"types": [...themeTypes, ...(doBundlesEmailTemplate ? ["email"] : [])]
}))
},
null,
2
),
"utf8"
)
);
}
return {
"jarFilePath": pathJoin(keycloakThemeBuildingDirPath, "target", `${artifactId}-${themeVersion}.jar`)
};
}

View File

@ -1,70 +0,0 @@
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "./buildOptions";
type BuildOptionsLike = {
groupId: string;
artifactId: string;
themeVersion: string;
keycloakifyBuildDirPath: string;
};
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export function generatePom(params: { buildOptions: BuildOptionsLike }) {
const { buildOptions } = params;
const { pomFileCode } = (function generatePomFileCode(): {
pomFileCode: string;
} {
const pomFileCode = [
`<?xml version="1.0"?>`,
`<project xmlns="http://maven.apache.org/POM/4.0.0"`,
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
` <modelVersion>4.0.0</modelVersion>`,
` <groupId>${buildOptions.groupId}</groupId>`,
` <artifactId>${buildOptions.artifactId}</artifactId>`,
` <version>${buildOptions.themeVersion}</version>`,
` <name>${buildOptions.artifactId}</name>`,
` <description />`,
` <packaging>jar</packaging>`,
` <properties>`,
` <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>`,
` </properties>`,
` <build>`,
` <plugins>`,
` <plugin>`,
` <groupId>org.apache.maven.plugins</groupId>`,
` <artifactId>maven-shade-plugin</artifactId>`,
` <version>3.5.1</version>`,
` <executions>`,
` <execution>`,
` <phase>package</phase>`,
` <goals>`,
` <goal>shade</goal>`,
` </goals>`,
` </execution>`,
` </executions>`,
` </plugin>`,
` </plugins>`,
` </build>`,
` <dependencies>`,
` <dependency>`,
` <groupId>io.phasetwo.keycloak</groupId>`,
` <artifactId>keycloak-account-v1</artifactId>`,
` <version>0.1</version>`,
` </dependency>`,
` </dependencies>`,
`</project>`
].join("\n");
return { pomFileCode };
})();
return { pomFileCode };
}

View File

@ -1,11 +1,12 @@
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, basename as pathBasename } from "path";
import { join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "./buildOptions";
import type { BuildOptions } from "./BuildOptions";
export type BuildOptionsLike = {
keycloakifyBuildDirPath: string;
themeName: string;
extraThemeNames: string[];
};
{
@ -19,43 +20,43 @@ generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_contain
const containerName = "keycloak-testing-container";
/** Files for being able to run a hot reload keycloak container */
export function generateStartKeycloakTestingContainer(params: { jarFilePath: string; keycloakVersion: string; buildOptions: BuildOptionsLike }) {
const { jarFilePath, keycloakVersion, buildOptions } = params;
const themeRelativeDirPath = pathJoin("src", "main", "resources", "theme");
const themeDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, themeRelativeDirPath);
export function generateStartKeycloakTestingContainer(params: {
keycloakVersion: string;
keycloakThemeBuildingDirPath: string;
buildOptions: BuildOptionsLike;
}) {
const {
keycloakThemeBuildingDirPath,
keycloakVersion,
buildOptions: { themeName, extraThemeNames }
} = params;
fs.writeFileSync(
pathJoin(buildOptions.keycloakifyBuildDirPath, generateStartKeycloakTestingContainer.basename),
pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename),
Buffer.from(
[
"#!/usr/bin/env bash",
"",
`docker rm ${containerName} || true`,
"",
`cd "${buildOptions.keycloakifyBuildDirPath}"`,
`cd "${keycloakThemeBuildingDirPath.replace(/\\/g, "/")}"`,
"",
"docker run \\",
" -p 8080:8080 \\",
` --name ${containerName} \\`,
" -e KEYCLOAK_ADMIN=admin \\",
" -e KEYCLOAK_ADMIN_PASSWORD=admin \\",
` -v "${pathJoin(
"$(pwd)",
pathRelative(buildOptions.keycloakifyBuildDirPath, jarFilePath)
)}":"/opt/keycloak/providers/${pathBasename(jarFilePath)}" \\`,
...fs
.readdirSync(themeDirPath)
.filter(name => fs.lstatSync(pathJoin(themeDirPath, name)).isDirectory())
.map(
themeName =>
` -v "${pathJoin("$(pwd)", themeRelativeDirPath, themeName).replace(
/\\/g,
"/"
)}":"/opt/keycloak/themes/${themeName}":rw \\`
),
" -e JAVA_OPTS=-Dkeycloak.profile=preview \\",
...[themeName, ...extraThemeNames].map(
themeName =>
` -v "${pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace(
/\\/g,
"/"
)}":"/opt/keycloak/themes/${themeName}":rw \\`
),
` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`,
` start-dev --features=declarative-user-profile`,
` start-dev`,
""
].join("\n"),
"utf8"

View File

@ -1,84 +0,0 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "../buildOptions";
import { resources_common, lastKeycloakVersionWithAccountV1, accountV1ThemeName } from "../../constants";
import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme";
import { transformCodebase } from "../../tools/transformCodebase";
import { rmSync } from "../../tools/fs.rmSync";
type BuildOptionsLike = {
keycloakifyBuildDirPath: string;
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export async function bringInAccountV1(params: { buildOptions: BuildOptionsLike }) {
const { buildOptions } = params;
const builtinKeycloakThemeTmpDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "..", "tmp_yxdE2_builtin_keycloak_theme");
await downloadBuiltinKeycloakTheme({
"destDirPath": builtinKeycloakThemeTmpDirPath,
"keycloakVersion": lastKeycloakVersionWithAccountV1,
buildOptions
});
const accountV1DirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources", "theme", accountV1ThemeName, "account");
transformCodebase({
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "base", "account"),
"destDirPath": accountV1DirPath
});
transformCodebase({
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "keycloak", "account", "resources"),
"destDirPath": pathJoin(accountV1DirPath, "resources")
});
transformCodebase({
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "keycloak", "common", "resources"),
"destDirPath": pathJoin(accountV1DirPath, "resources", resources_common)
});
rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true });
fs.writeFileSync(
pathJoin(accountV1DirPath, "theme.properties"),
Buffer.from(
[
"accountResourceProvider=account-v1",
"",
"locales=ar,ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN",
"",
"styles=" +
[
"css/account.css",
"img/icon-sidebar-active.png",
"img/logo.png",
...["patternfly.min.css", "patternfly-additions.min.css", "patternfly-additions.min.css"].map(
fileBasename => `${resources_common}/node_modules/patternfly/dist/css/${fileBasename}`
)
].join(" "),
"",
"##### css classes for form buttons",
"# main class used for all buttons",
"kcButtonClass=btn",
"# classes defining priority of the button - primary or default (there is typically only one priority button for the form)",
"kcButtonPrimaryClass=btn-primary",
"kcButtonDefaultClass=btn-default",
"# classes defining size of the button",
"kcButtonLargeClass=btn-lg",
""
].join("\n"),
"utf8"
)
);
}

View File

@ -1,49 +1,47 @@
import { transformCodebase } from "../../tools/transformCodebase";
import { join as pathJoin } from "path";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative } from "path";
import type { ThemeType } from "../generateFtl";
import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme";
import { resources_common, type ThemeType } from "../../constants";
import { BuildOptions } from "../buildOptions";
import { assert } from "tsafe/assert";
import {
resourcesCommonDirPathRelativeToPublicDir,
resourcesDirPathRelativeToPublicDir,
basenameOfKeycloakDirInPublicDir
} from "../../mockTestingResourcesPath";
import * as crypto from "crypto";
import { rmSync } from "../../tools/fs.rmSync";
export type BuildOptionsLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function downloadKeycloakStaticResources(params: {
themeType: ThemeType;
themeDirPath: string;
keycloakVersion: string;
buildOptions: BuildOptionsLike;
}) {
const { themeType, themeDirPath, keycloakVersion, buildOptions } = params;
export async function downloadKeycloakStaticResources(
// prettier-ignore
params: {
themeType: ThemeType;
themeDirPath: string;
isSilent: boolean;
keycloakVersion: string;
}
) {
const { themeType, isSilent, themeDirPath, keycloakVersion } = params;
const tmpDirPath = pathJoin(
themeDirPath,
"..",
`tmp_suLeKsxId_${crypto.createHash("sha256").update(`${themeType}-${keycloakVersion}`).digest("hex").slice(0, 8)}`
);
await downloadBuiltinKeycloakTheme({
keycloakVersion,
"destDirPath": tmpDirPath,
buildOptions
isSilent
});
const resourcesPath = pathJoin(themeDirPath, themeType, "resources");
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", themeType, "resources"),
"destDirPath": resourcesPath
"destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesDirPathRelativeToPublicDir))
});
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"),
"destDirPath": pathJoin(resourcesPath, resources_common)
"destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesCommonDirPathRelativeToPublicDir))
});
rmSync(tmpDirPath, { "recursive": true, "force": true });
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
}

View File

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

View File

@ -1,115 +1,90 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin, basename as pathBasename, resolve as pathResolve, dirname as pathDirname } from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { join as pathJoin } from "path";
import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds } from "../generateFtl";
import {
type ThemeType,
lastKeycloakVersionWithAccountV1,
keycloak_resources,
retrocompatPostfix,
accountV1ThemeName,
basenameOfTheKeycloakifyResourcesDir
} from "../../constants";
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds, themeTypes, type ThemeType } from "../generateFtl";
import { basenameOfKeycloakDirInPublicDir } from "../../mockTestingResourcesPath";
import { isInside } from "../../tools/isInside";
import type { BuildOptions } from "../buildOptions";
import { assert, type Equals } from "tsafe/assert";
import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import { downloadKeycloakStaticResources } from "./downloadKeycloakStaticResources";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import { generateMessageProperties } from "./generateMessageProperties";
import { bringInAccountV1 } from "./bringInAccountV1";
import { rmSync } from "../../tools/fs.rmSync";
export type BuildOptionsLike = {
bundler: "vite" | "webpack";
extraThemeProperties: string[] | undefined;
themeVersion: string;
loginThemeResourcesFromKeycloakVersion: string;
keycloakifyBuildDirPath: string;
reactAppBuildDirPath: string;
cacheDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
doBuildRetrocompatAccountTheme: boolean;
themeNames: string[];
npmWorkspaceRootDirPath: string;
};
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
export namespace BuildOptionsLike {
export type Common = {
themeName: string;
extraLoginPages?: string[];
extraAccountPages?: string[];
extraThemeProperties?: string[];
isSilent: boolean;
customUserAttributes: string[];
themeVersion: string;
keycloakVersionDefaultAssets: string;
};
export type Standalone = Common & {
isStandalone: true;
urlPathname: string | undefined;
};
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
export namespace ExternalAssets {
export type CommonExternalAssets = Common & {
isStandalone: false;
};
export type SameDomain = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: true;
};
export type DifferentDomains = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: false;
urlOrigin: string;
urlPathname: string | undefined;
};
}
}
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function generateTheme(params: {
themeName: string;
themeSrcDirPath: string;
keycloakifySrcDirPath: string;
reactAppBuildDirPath: string;
keycloakThemeBuildingDirPath: string;
emailThemeSrcDirPath: string | undefined;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
}): Promise<void> {
const { themeName, themeSrcDirPath, keycloakifySrcDirPath, buildOptions, keycloakifyVersion } = params;
}): Promise<{ doBundlesEmailTemplate: boolean }> {
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, emailThemeSrcDirPath, buildOptions, keycloakifyVersion } = params;
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email"; isRetrocompat?: true }) => {
const { themeType, isRetrocompat = false } = params;
return pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme",
`${themeName}${isRetrocompat ? retrocompatPostfix : ""}`,
themeType
);
};
const getThemeDirPath = (themeType: ThemeType | "email") =>
pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType);
const cssGlobalsToDefine: Record<string, string> = {};
let allCssGlobalsToDefine: Record<string, string> = {};
const implementedThemeTypes: Record<ThemeType | "email", boolean> = {
"login": false,
"account": false,
"email": false
};
let generateFtlFilesCode_glob: ReturnType<typeof generateFtlFilesCodeFactory>["generateFtlFilesCode"] | undefined = undefined;
for (const themeType of ["login", "account"] as const) {
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
continue;
}
for (const themeType of themeTypes) {
const themeDirPath = getThemeDirPath(themeType);
implementedThemeTypes[themeType] = true;
copy_app_resources_to_theme_path: {
const isFirstPass = themeType.indexOf(themeType) === 0;
const themeTypeDirPath = getThemeTypeDirPath({ themeType });
apply_replacers_and_move_to_theme_resources: {
const destDirPath = pathJoin(themeTypeDirPath, "resources", basenameOfTheKeycloakifyResourcesDir);
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
rmSync(destDirPath, { "recursive": true, "force": true });
if (themeType === "account" && implementedThemeTypes.login) {
// NOTE: We prevend doing it twice, it has been done for the login theme.
transformCodebase({
"srcDirPath": pathJoin(
getThemeTypeDirPath({
"themeType": "login"
}),
"resources",
basenameOfTheKeycloakifyResourcesDir
),
destDirPath
});
break apply_replacers_and_move_to_theme_resources;
if (!isFirstPass && !buildOptions.isStandalone) {
break copy_app_resources_to_theme_path;
}
transformCodebase({
"srcDirPath": buildOptions.reactAppBuildDirPath,
destDirPath,
"destDirPath": buildOptions.isStandalone ? pathJoin(themeDirPath, "resources", "build") : reactAppBuildDirPath,
"srcDirPath": reactAppBuildDirPath,
"transformSourceCode": ({ filePath, sourceCode }) => {
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
// This should not happen if users follow the new instruction setup but we keep it for retrocompatibility.
if (
buildOptions.isStandalone &&
isInside({
"dirPath": pathJoin(buildOptions.reactAppBuildDirPath, keycloak_resources),
"dirPath": pathJoin(reactAppBuildDirPath, basenameOfKeycloakDirInPublicDir),
filePath
})
) {
@ -117,19 +92,34 @@ export async function generateTheme(params: {
}
if (/\.css?$/i.test(filePath)) {
const { cssGlobalsToDefine: cssGlobalsToDefineForThisFile, fixedCssCode } = replaceImportsInCssCode({
if (!buildOptions.isStandalone) {
return undefined;
}
const { cssGlobalsToDefine, fixedCssCode } = replaceImportsInCssCode({
"cssCode": sourceCode.toString("utf8")
});
Object.entries(cssGlobalsToDefineForThisFile).forEach(([key, value]) => {
cssGlobalsToDefine[key] = value;
});
register_css_variables: {
if (!isFirstPass) {
break register_css_variables;
}
allCssGlobalsToDefine = {
...allCssGlobalsToDefine,
...cssGlobalsToDefine
};
}
return { "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8") };
}
if (/\.js?$/i.test(filePath)) {
const { fixedJsCode } = replaceImportsInJsCode({
if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) {
return undefined;
}
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": sourceCode.toString("utf8"),
buildOptions
});
@ -137,24 +127,26 @@ export async function generateTheme(params: {
return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
}
return { "modifiedSourceCode": sourceCode };
return buildOptions.isStandalone ? { "modifiedSourceCode": sourceCode } : undefined;
}
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
"indexHtmlCode": fs.readFileSync(pathJoin(buildOptions.reactAppBuildDirPath, "index.html")).toString("utf8"),
cssGlobalsToDefine,
buildOptions,
keycloakifyVersion,
themeType,
"fieldNames": readFieldNameUsage({
keycloakifySrcDirPath,
themeSrcDirPath,
const generateFtlFilesCode = (() => {
if (generateFtlFilesCode_glob !== undefined) {
return generateFtlFilesCode_glob;
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
"cssGlobalsToDefine": allCssGlobalsToDefine,
buildOptions,
keycloakifyVersion,
themeType
})
});
});
return generateFtlFilesCode;
})();
[
...(() => {
@ -165,167 +157,84 @@ export async function generateTheme(params: {
return accountThemePageIds;
}
})(),
...readExtraPagesNames({
themeType,
themeSrcDirPath
})
...((() => {
switch (themeType) {
case "login":
return buildOptions.extraLoginPages;
case "account":
return buildOptions.extraAccountPages;
}
})() ?? [])
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.mkdirSync(themeTypeDirPath, { "recursive": true });
fs.mkdirSync(themeDirPath, { "recursive": true });
fs.writeFileSync(pathJoin(themeTypeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
fs.writeFileSync(pathJoin(themeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
});
generateMessageProperties({
themeSrcDirPath,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
//TODO: Remove this block we left it for now only for backward compatibility
// we now have a separate script for this
copy_keycloak_resources_to_public: {
const keycloakDirInPublicDir = pathJoin(reactAppBuildDirPath, "..", "public", basenameOfKeycloakDirInPublicDir);
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), { "recursive": true });
if (fs.existsSync(keycloakDirInPublicDir)) {
break copy_keycloak_resources_to_public;
}
const propertiesFilePath = pathJoin(messagesDirPath, `messages_${languageTag}.properties`);
await downloadKeycloakStaticResources({
"isSilent": buildOptions.isSilent,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
"themeDirPath": keycloakDirInPublicDir,
themeType
});
fs.writeFileSync(propertiesFilePath, Buffer.from(propertiesFileSource, "utf8"));
});
if (themeType !== themeTypes[0]) {
break copy_keycloak_resources_to_public;
}
fs.writeFileSync(
pathJoin(keycloakDirInPublicDir, "README.txt"),
Buffer.from(
// prettier-ignore
[
"This is just a test folder that helps develop",
"the login and register page without having to run a Keycloak container"
].join(" ")
)
);
fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8"));
}
await downloadKeycloakStaticResources({
"keycloakVersion": (() => {
switch (themeType) {
case "account":
return lastKeycloakVersionWithAccountV1;
case "login":
return buildOptions.loginThemeResourcesFromKeycloakVersion;
}
})(),
"themeDirPath": pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildOptions
"isSilent": buildOptions.isSilent,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
themeDirPath,
themeType
});
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
return accountV1ThemeName;
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(buildOptions.extraThemeProperties ?? [])
].join("\n\n"),
"utf8"
)
pathJoin(themeDirPath, "theme.properties"),
Buffer.from(["parent=keycloak", ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8")
);
if (themeType === "account" && buildOptions.doBuildRetrocompatAccountTheme) {
transformCodebase({
"srcDirPath": themeTypeDirPath,
"destDirPath": getThemeTypeDirPath({ themeType, "isRetrocompat": true }),
"transformSourceCode": ({ filePath, sourceCode }) => {
if (pathBasename(filePath) === "theme.properties") {
return {
"modifiedSourceCode": Buffer.from(
sourceCode.toString("utf8").replace(`parent=${accountV1ThemeName}`, "parent=keycloak"),
"utf8"
)
};
}
return { "modifiedSourceCode": sourceCode };
}
});
}
}
email: {
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
let doBundlesEmailTemplate: boolean;
if (!fs.existsSync(emailThemeSrcDirPath)) {
email: {
if (emailThemeSrcDirPath === undefined) {
doBundlesEmailTemplate = false;
break email;
}
implementedThemeTypes.email = true;
doBundlesEmailTemplate = true;
transformCodebase({
"srcDirPath": emailThemeSrcDirPath,
"destDirPath": getThemeTypeDirPath({ "themeType": "email" })
"destDirPath": getThemeDirPath("email")
});
}
const parsedKeycloakThemeJson: { themes: { name: string; types: string[] }[] } = { "themes": [] };
buildOptions.themeNames.forEach(themeName =>
parsedKeycloakThemeJson.themes.push({
"name": themeName,
"types": Object.entries(implementedThemeTypes)
.filter(([, isImplemented]) => isImplemented)
.map(([themeType]) => themeType)
})
);
account_specific_extra_work: {
if (!implementedThemeTypes.account) {
break account_specific_extra_work;
}
await bringInAccountV1({ buildOptions });
parsedKeycloakThemeJson.themes.push({
"name": accountV1ThemeName,
"types": ["account"]
});
add_retrocompat_account_theme: {
if (!buildOptions.doBuildRetrocompatAccountTheme) {
break add_retrocompat_account_theme;
}
transformCodebase({
"srcDirPath": getThemeTypeDirPath({ "themeType": "account" }),
"destDirPath": getThemeTypeDirPath({ "themeType": "account", "isRetrocompat": true }),
"transformSourceCode": ({ filePath, sourceCode }) => {
if (pathBasename(filePath) === "theme.properties") {
return {
"modifiedSourceCode": Buffer.from(
sourceCode.toString("utf8").replace(`parent=${accountV1ThemeName}`, "parent=keycloak"),
"utf8"
)
};
}
return { "modifiedSourceCode": sourceCode };
}
});
buildOptions.themeNames.forEach(themeName =>
parsedKeycloakThemeJson.themes.push({
"name": `${themeName}${retrocompatPostfix}`,
"types": ["account"]
})
);
}
}
{
const keycloakThemeJsonFilePath = pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"META-INF",
"keycloak-themes.json"
);
try {
fs.mkdirSync(pathDirname(keycloakThemeJsonFilePath));
} catch {}
fs.writeFileSync(keycloakThemeJsonFilePath, Buffer.from(JSON.stringify(parsedKeycloakThemeJson, null, 2), "utf8"));
}
return { doBundlesEmailTemplate };
}

View File

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

View File

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

View File

@ -1,128 +1,155 @@
import { generateTheme } from "./generateTheme";
import { generatePom } from "./generatePom";
import { join as pathJoin, relative as pathRelative, basename as pathBasename, dirname as pathDirname, sep as pathSep } from "path";
import { generateJavaStackFiles } from "./generateJavaStackFiles";
import { join as pathJoin, relative as pathRelative, basename as pathBasename, sep as pathSep } from "path";
import * as child_process from "child_process";
import { generateStartKeycloakTestingContainer } from "./generateStartKeycloakTestingContainer";
import * as fs from "fs";
import { readBuildOptions } from "./buildOptions";
import { readBuildOptions } from "./BuildOptions";
import { getLogger } from "../tools/logger";
import { getThemeSrcDirPath } from "../getThemeSrcDirPath";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
import { readThisNpmProjectVersion } from "../tools/readThisNpmProjectVersion";
import { keycloakifyBuildOptionsForPostPostBuildScriptEnvName } from "../constants";
import { assertNoPnpmDlx } from "../tools/assertNoPnpmDlx";
import jar from "../tools/jar";
import { assert } from "tsafe/assert";
import { Equals } from "tsafe";
import { getEmailThemeSrcDirPath } from "../getSrcDirPath";
import { getProjectRoot } from "../tools/getProjectRoot";
export async function main() {
assertNoPnpmDlx();
const projectDirPath = process.cwd();
const buildOptions = readBuildOptions({
projectDirPath,
"processArgv": process.argv.slice(2)
});
const logger = getLogger({ "isSilent": buildOptions.isSilent });
logger.log("🔏 Building the keycloak theme...⌚");
const { themeSrcDirPath } = getThemeSrcDirPath({ "reactAppRootDirPath": buildOptions.reactAppRootDirPath });
let doBundlesEmailTemplate: boolean | undefined;
for (const themeName of buildOptions.themeNames) {
await generateTheme({
themeName,
themeSrcDirPath,
"keycloakifySrcDirPath": pathJoin(getThisCodebaseRootDirPath(), "src"),
"keycloakifyVersion": readThisNpmProjectVersion(),
buildOptions
for (const themeName of [buildOptions.themeName, ...buildOptions.extraThemeNames]) {
const { doBundlesEmailTemplate: doBundlesEmailTemplate_ } = await generateTheme({
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
"emailThemeSrcDirPath": (() => {
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({ projectDirPath });
if (emailThemeSrcDirPath === undefined || !fs.existsSync(emailThemeSrcDirPath)) {
return;
}
return emailThemeSrcDirPath;
})(),
"reactAppBuildDirPath": buildOptions.reactAppBuildDirPath,
"buildOptions": {
...buildOptions,
"themeName": themeName
},
"keycloakifyVersion": (() => {
const version = JSON.parse(fs.readFileSync(pathJoin(getProjectRoot(), "package.json")).toString("utf8"))["version"];
assert(typeof version === "string");
return version;
})()
});
doBundlesEmailTemplate ??= doBundlesEmailTemplate_;
}
{
const { pomFileCode } = generatePom({ buildOptions });
assert(doBundlesEmailTemplate !== undefined);
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8"));
}
const containerKeycloakVersion = "23.0.6";
const jarFilePath = pathJoin(buildOptions.keycloakifyBuildDirPath, "target", `${buildOptions.artifactId}-${buildOptions.themeVersion}.jar`);
generateStartKeycloakTestingContainer({
"keycloakVersion": containerKeycloakVersion,
jarFilePath,
const { jarFilePath } = generateJavaStackFiles({
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
doBundlesEmailTemplate,
buildOptions
});
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, ".gitignore"), Buffer.from("*", "utf8"));
run_post_build_script: {
if (buildOptions.bundler !== "vite") {
break run_post_build_script;
}
child_process.execSync("npx vite", {
"cwd": buildOptions.reactAppRootDirPath,
"env": {
...process.env,
[keycloakifyBuildOptionsForPostPostBuildScriptEnvName]: JSON.stringify(buildOptions)
}
});
switch (buildOptions.bundler) {
case "none":
logger.log("😱 Skipping bundling step, there will be no jar");
break;
case "keycloakify":
logger.log("🫶 Let keycloakify do its thang");
await jar({
"rootPath": buildOptions.keycloakifyBuildDirPath,
"version": buildOptions.themeVersion,
"groupId": buildOptions.groupId,
"artifactId": buildOptions.artifactId,
"targetPath": jarFilePath
});
break;
case "mvn":
logger.log("🫙 Run maven to deliver a jar");
child_process.execSync("mvn package", { "cwd": buildOptions.keycloakifyBuildDirPath });
break;
default:
assert<Equals<typeof buildOptions.bundler, never>>(false);
}
create_jar: {
if (!buildOptions.doCreateJar) {
break create_jar;
}
// We want, however, to test in a container running the latest Keycloak version
const containerKeycloakVersion = "20.0.1";
child_process.execSync("mvn clean install", { "cwd": buildOptions.keycloakifyBuildDirPath });
const jarDirPath = pathDirname(jarFilePath);
const retrocompatJarFilePath = pathJoin(jarDirPath, "retrocompat-" + pathBasename(jarFilePath));
fs.renameSync(pathJoin(jarDirPath, "original-" + pathBasename(jarFilePath)), retrocompatJarFilePath);
fs.writeFileSync(
pathJoin(jarDirPath, "README.md"),
Buffer.from(
[
`- The ${jarFilePath} is to be used in Keycloak 23 and up. `,
`- The ${retrocompatJarFilePath} is to be used in Keycloak 22 and below.`,
` Note that Keycloak 22 is only supported for login and email theme but not for account themes. `
].join("\n"),
"utf8"
)
);
}
generateStartKeycloakTestingContainer({
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
"keycloakVersion": containerKeycloakVersion,
buildOptions
});
logger.log(
[
"",
...(!buildOptions.doCreateJar
? []
: [
`✅ Your keycloak theme has been generated and bundled into .${pathSep}${pathRelative(
buildOptions.reactAppRootDirPath,
jarFilePath
)} 🚀`
]),
`✅ Your keycloak theme has been generated and bundled into .${pathSep}${pathRelative(projectDirPath, jarFilePath)} 🚀`,
`It is to be placed in "/opt/keycloak/providers" in the container running a quay.io/keycloak/keycloak Docker image.`,
"",
//TODO: Restore when we find a good Helm chart for Keycloak.
//"Using Helm (https://github.com/codecentric/helm-charts), edit to reflect:",
"",
"value.yaml: ",
" extraInitContainers: |",
" - name: realm-ext-provider",
" image: curlimages/curl",
" imagePullPolicy: IfNotPresent",
" command:",
" - sh",
" args:",
" - -c",
` - curl -L -f -S -o /extensions/${pathBasename(jarFilePath)} https://AN.URL.FOR/${pathBasename(jarFilePath)}`,
" volumeMounts:",
" - name: extensions",
" mountPath: /extensions",
" ",
" extraVolumeMounts: |",
" - name: extensions",
" mountPath: /opt/keycloak/providers",
" extraEnv: |",
" - name: KEYCLOAK_USER",
" value: admin",
" - name: KEYCLOAK_PASSWORD",
" value: xxxxxxxxx",
" - name: JAVA_OPTS",
" value: -Dkeycloak.profile=preview",
"",
"",
`To test your theme locally you can spin up a Keycloak ${containerKeycloakVersion} container image with the theme pre loaded by running:`,
"",
`👉 $ .${pathSep}${pathRelative(
buildOptions.reactAppRootDirPath,
projectDirPath,
pathJoin(buildOptions.keycloakifyBuildDirPath, generateStartKeycloakTestingContainer.basename)
)} 👈`,
"",
`Test with different Keycloak versions by editing the .sh file. see available versions here: https://quay.io/repository/keycloak/keycloak?tab=tags`,
``,
`Once your container is up and running: `,
"- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈",
`- Create a realm: Master -> AddRealm -> Name: myrealm`,
`- Enable registration: Realm settings -> Login tab -> User registration: on`,
`- Enable the Account theme (optional): Realm settings -> Themes tab -> Account theme: ${buildOptions.themeNames[0]}`,
` Clients -> account -> Login theme: ${buildOptions.themeNames[0]}`,
`- Enable the email theme (optional): Realm settings -> Themes tab -> Email theme: ${buildOptions.themeNames[0]} (option will appear only if you have ran npx initialize-email-theme)`,
`- Enable the Account theme (optional): Realm settings -> Themes tab -> Account theme: ${buildOptions.themeName}`,
` Clients -> account -> Login theme: ${buildOptions.themeName}`,
`- Enable the email theme (optional): Realm settings -> Themes tab -> Email theme: ${buildOptions.themeName} (option will appear only if you have ran npx initialize-email-theme)`,
`- Create a client Clients -> Create -> Client ID: myclient`,
` Root URL: https://www.keycloak.org/app/`,
` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
` Web origins: *`,
` Login Theme: ${buildOptions.themeNames[0]}`,
` Login Theme: ${buildOptions.themeName}`,
` Save (button at the bottom of the page)`,
``,
`- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`,

View File

@ -0,0 +1,66 @@
import * as fs from "fs";
import { assert } from "tsafe";
import type { Equals } from "tsafe";
import { z } from "zod";
import { pathJoin } from "../tools/pathJoin";
export const bundlers = ["mvn", "keycloakify", "none"] as const;
export type Bundler = (typeof bundlers)[number];
export type ParsedPackageJson = {
name: string;
version?: string;
homepage?: string;
keycloakify?: {
/** @deprecated: use extraLoginPages instead */
extraPages?: string[];
extraLoginPages?: string[];
extraAccountPages?: string[];
extraThemeProperties?: string[];
areAppAndKeycloakServerSharingSameDomain?: boolean;
artifactId?: string;
groupId?: string;
bundler?: Bundler;
keycloakVersionDefaultAssets?: string;
reactAppBuildDirPath?: string;
keycloakifyBuildDirPath?: string;
customUserAttributes?: string[];
themeName?: string;
extraThemeNames?: string[];
};
};
export const zParsedPackageJson = z.object({
"name": z.string(),
"version": z.string().optional(),
"homepage": z.string().optional(),
"keycloakify": z
.object({
"extraPages": z.array(z.string()).optional(),
"extraLoginPages": z.array(z.string()).optional(),
"extraAccountPages": z.array(z.string()).optional(),
"extraThemeProperties": z.array(z.string()).optional(),
"areAppAndKeycloakServerSharingSameDomain": z.boolean().optional(),
"artifactId": z.string().optional(),
"groupId": z.string().optional(),
"bundler": z.enum(bundlers).optional(),
"keycloakVersionDefaultAssets": z.string().optional(),
"reactAppBuildDirPath": z.string().optional(),
"keycloakifyBuildDirPath": z.string().optional(),
"customUserAttributes": z.array(z.string()).optional(),
"themeName": z.string().optional(),
"extraThemeNames": z.array(z.string()).optional()
})
.optional()
});
assert<Equals<ReturnType<(typeof zParsedPackageJson)["parse"]>, ParsedPackageJson>>();
let parsedPackageJson: undefined | ReturnType<(typeof zParsedPackageJson)["parse"]>;
export function getParsedPackageJson(params: { projectDirPath: string }) {
const { projectDirPath } = params;
if (parsedPackageJson) {
return parsedPackageJson;
}
parsedPackageJson = zParsedPackageJson.parse(JSON.parse(fs.readFileSync(pathJoin(projectDirPath, "package.json")).toString("utf8")));
return parsedPackageJson;
}

View File

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

View File

@ -1,13 +1,20 @@
import * as crypto from "crypto";
import type { BuildOptions } from "../buildOptions";
import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import { basenameOfTheKeycloakifyResourcesDir } from "../../constants";
import { is } from "tsafe/is";
import { Reflect } from "tsafe/Reflect";
export type BuildOptionsLike = {
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
{
const buildOptions = Reflect<BuildOptions>();
assert(!is<BuildOptions.ExternalAssets.CommonExternalAssets>(buildOptions));
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export function replaceImportsInCssCode(params: { cssCode: string }): {
fixedCssCode: string;
@ -17,7 +24,7 @@ export function replaceImportsInCssCode(params: { cssCode: string }): {
const cssGlobalsToDefine: Record<string, string> = {};
new Set(cssCode.match(/url\(["']?\/[^/][^)"']+["']?\)[^;}]*?/g) ?? []).forEach(
new Set(cssCode.match(/url\(["']?\/[^/][^)"']+["']?\)[^;}]*/g) ?? []).forEach(
match => (cssGlobalsToDefine["url" + crypto.createHash("sha256").update(match).digest("hex").substring(0, 15)] = match)
);
@ -46,7 +53,7 @@ export function generateCssCodeToDefineGlobals(params: { cssGlobalsToDefine: Rec
`--${cssVariableName}:`,
cssGlobalsToDefine[cssVariableName].replace(
new RegExp(`url\\(${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`, "g"),
`url(\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
"url(${url.resourcesPath}/build/"
)
].join(" ")
)

View File

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

View File

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

View File

@ -1,66 +0,0 @@
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../buildOptions";
import { replaceImportsInJsCode_vite } from "./vite";
import { replaceImportsInJsCode_webpack } from "./webpack";
import * as fs from "fs";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
bundler: "vite" | "webpack";
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function replaceImportsInJsCode(params: { jsCode: string; buildOptions: BuildOptionsLike }) {
const { jsCode, buildOptions } = params;
const { fixedJsCode } = (() => {
switch (buildOptions.bundler) {
case "vite":
return replaceImportsInJsCode_vite({
jsCode,
buildOptions,
"basenameOfAssetsFiles": readAssetsDirSync({
"assetsDirPath": params.buildOptions.assetsDirPath
})
});
case "webpack":
return replaceImportsInJsCode_webpack({
jsCode,
buildOptions
});
}
})();
return { fixedJsCode };
}
const { readAssetsDirSync } = (() => {
let cache:
| {
assetsDirPath: string;
basenameOfAssetsFiles: string[];
}
| undefined = undefined;
function readAssetsDirSync(params: { assetsDirPath: string }): string[] {
const { assetsDirPath } = params;
if (cache !== undefined && cache.assetsDirPath === assetsDirPath) {
return cache.basenameOfAssetsFiles;
}
const basenameOfAssetsFiles = fs.readdirSync(assetsDirPath);
cache = {
assetsDirPath,
basenameOfAssetsFiles
};
return basenameOfAssetsFiles;
}
return { readAssetsDirSync };
})();

View File

@ -1,85 +0,0 @@
import { nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir } from "../../../constants";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../buildOptions";
import * as nodePath from "path";
import { replaceAll } from "../../../tools/String.prototype.replaceAll";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function replaceImportsInJsCode_vite(params: {
jsCode: string;
buildOptions: BuildOptionsLike;
basenameOfAssetsFiles: string[];
systemType?: "posix" | "win32";
}): {
fixedJsCode: string;
} {
const { jsCode, buildOptions, basenameOfAssetsFiles, systemType = nodePath.sep === "/" ? "posix" : "win32" } = params;
const { relative: pathRelative, sep: pathSep } = nodePath[systemType];
let fixedJsCode = jsCode;
replace_base_javacript_import: {
if (buildOptions.urlPathname === undefined) {
break replace_base_javacript_import;
}
// Optimization
if (!jsCode.includes(buildOptions.urlPathname)) {
break replace_base_javacript_import;
}
// Replace `Hv=function(e){return"/abcde12345/"+e}` by `Hv=function(e){return"/"+e}`
fixedJsCode = fixedJsCode.replace(
new RegExp(
`([\\w\\$][\\w\\d\\$]*)=function\\(([\\w\\$][\\w\\d\\$]*)\\)\\{return"${replaceAll(buildOptions.urlPathname, "/", "\\/")}"\\+\\2\\}`,
"g"
),
(...[, funcName, paramName]) => `${funcName}=function(${paramName}){return"/"+${paramName}}`
);
}
replace_javascript_relatives_import_paths: {
// Example: "assets/ or "foo/bar/"
const staticDir = (() => {
let out = pathRelative(buildOptions.reactAppBuildDirPath, buildOptions.assetsDirPath);
out = replaceAll(out, pathSep, "/") + "/";
if (out === "/") {
throw new Error(`The assetsDirPath must be a subdirectory of reactAppBuildDirPath`);
}
return out;
})();
// Optimization
if (!jsCode.includes(staticDir)) {
break replace_javascript_relatives_import_paths;
}
basenameOfAssetsFiles
.map(basenameOfAssetsFile => `${staticDir}${basenameOfAssetsFile}`)
.forEach(relativePathOfAssetFile => {
fixedJsCode = replaceAll(
fixedJsCode,
`"${relativePathOfAssetFile}"`,
`(window.${nameOfTheGlobal}.url.resourcesPath.substring(1) + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
);
fixedJsCode = replaceAll(
fixedJsCode,
`"${buildOptions.urlPathname ?? "/"}${relativePathOfAssetFile}"`,
`(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
);
});
}
return { fixedJsCode };
}

View File

@ -1,76 +0,0 @@
import { nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir } from "../../../constants";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../buildOptions";
import * as nodePath from "path";
import { replaceAll } from "../../../tools/String.prototype.replaceAll";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function replaceImportsInJsCode_webpack(params: { jsCode: string; buildOptions: BuildOptionsLike; systemType?: "posix" | "win32" }): {
fixedJsCode: string;
} {
const { jsCode, buildOptions, systemType = nodePath.sep === "/" ? "posix" : "win32" } = params;
const { relative: pathRelative, sep: pathSep } = nodePath[systemType];
let fixedJsCode = jsCode;
if (buildOptions.urlPathname !== undefined) {
// "__esModule",{value:!0})},n.p="/foo-bar/",function(){if("undefined" -> ... n.p="/" ...
fixedJsCode = fixedJsCode.replace(
new RegExp(`,([a-zA-Z]\\.[a-zA-Z])="${replaceAll(buildOptions.urlPathname, "/", "\\/")}",`, "g"),
(...[, assignTo]) => `,${assignTo}="/",`
);
}
// Example: "static/ or "foo/bar/"
const staticDir = (() => {
let out = pathRelative(buildOptions.reactAppBuildDirPath, buildOptions.assetsDirPath);
out = replaceAll(out, pathSep, "/") + "/";
if (out === "/") {
throw new Error(`The assetsDirPath must be a subdirectory of reactAppBuildDirPath`);
}
return out;
})();
const getReplaceArgs = (language: "js" | "css"): Parameters<typeof String.prototype.replace> => [
new RegExp(`([a-zA-Z_]+)\\.([a-zA-Z]+)=(function\\(([a-z]+)\\){return|([a-z]+)=>)"${staticDir.replace(/\//g, "\\/")}${language}\\/"`, "g"),
(...[, n, u, matchedFunction, eForFunction]) => {
const isArrowFunction = matchedFunction.includes("=>");
const e = isArrowFunction ? matchedFunction.replace("=>", "").trim() : eForFunction;
return `
${n}[(function(){
var pd = Object.getOwnPropertyDescriptor(${n}, "p");
if( pd === undefined || pd.configurable ){
Object.defineProperty(${n}, "p", {
get: function() { return window.${nameOfTheGlobal}.url.resourcesPath; },
set: function() {}
});
}
return "${u}";
})()] = ${isArrowFunction ? `${e} =>` : `function(${e}) { return `} "/${basenameOfTheKeycloakifyResourcesDir}/${staticDir}${language}/"`
.replace(/\s+/g, " ")
.trim();
}
];
fixedJsCode = fixedJsCode
.replace(...getReplaceArgs("js"))
.replace(...getReplaceArgs("css"))
.replace(
new RegExp(`[a-zA-Z]+\\.[a-zA-Z]+\\+"${staticDir.replace(/\//g, "\\/")}`, "g"),
`window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${staticDir}`
);
return { fixedJsCode };
}

View File

@ -0,0 +1,5 @@
import { pathJoin } from "./tools/pathJoin";
export const basenameOfKeycloakDirInPublicDir = "keycloak-resources";
export const resourcesDirPathRelativeToPublicDir = pathJoin(basenameOfKeycloakDirInPublicDir, "resources");
export const resourcesCommonDirPathRelativeToPublicDir = pathJoin(resourcesDirPathRelativeToPublicDir, "resources_common");

View File

@ -1,7 +1,6 @@
import { getLatestsSemVersionedTagFactory } from "./tools/octokit-addons/getLatestsSemVersionedTag";
import { Octokit } from "@octokit/rest";
import cliSelect from "cli-select";
import { lastKeycloakVersionWithAccountV1 } from "./constants";
export async function promptKeycloakVersion() {
const { getLatestsSemVersionedTag } = (() => {
@ -18,16 +17,15 @@ export async function promptKeycloakVersion() {
return { getLatestsSemVersionedTag };
})();
console.log("Select Keycloak version?");
console.log("Initialize the directory with email template from which keycloak version?");
const tags = [
...(await getLatestsSemVersionedTag({
"count": 15,
"count": 10,
"doIgnoreBeta": true,
"owner": "keycloak",
"repo": "keycloak"
}).then(arr => arr.map(({ tag }) => tag))),
lastKeycloakVersionWithAccountV1,
"19.0.1",
"11.0.3"
];

View File

@ -0,0 +1,73 @@
export type NpmModuleVersion = {
major: number;
minor: number;
patch: number;
betaPreRelease?: number;
};
export namespace NpmModuleVersion {
export function parse(versionStr: string): NpmModuleVersion {
const match = versionStr.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-beta.([0-9]+))?/);
if (!match) {
throw new Error(`${versionStr} is not a valid NPM version`);
}
return {
"major": parseInt(match[1]),
"minor": parseInt(match[2]),
"patch": parseInt(match[3]),
...(() => {
const str = match[4];
return str === undefined ? {} : { "betaPreRelease": parseInt(str) };
})()
};
}
export function stringify(v: NpmModuleVersion) {
return `${v.major}.${v.minor}.${v.patch}${v.betaPreRelease === undefined ? "" : `-beta.${v.betaPreRelease}`}`;
}
/**
*
* v1 < v2 => -1
* v1 === v2 => 0
* v1 > v2 => 1
*
*/
export function compare(v1: NpmModuleVersion, v2: NpmModuleVersion): -1 | 0 | 1 {
const sign = (diff: number): -1 | 0 | 1 => (diff === 0 ? 0 : diff < 0 ? -1 : 1);
const noUndefined = (n: number | undefined) => n ?? Infinity;
for (const level of ["major", "minor", "patch", "betaPreRelease"] as const) {
if (noUndefined(v1[level]) !== noUndefined(v2[level])) {
return sign(noUndefined(v1[level]) - noUndefined(v2[level]));
}
}
return 0;
}
/*
console.log(compare(parse("3.0.0-beta.3"), parse("3.0.0")) === -1 )
console.log(compare(parse("3.0.0-beta.3"), parse("3.0.0-beta.4")) === -1 )
console.log(compare(parse("3.0.0-beta.3"), parse("4.0.0")) === -1 )
*/
export function bumpType(params: { versionBehindStr: string; versionAheadStr: string }): "major" | "minor" | "patch" | "betaPreRelease" | "same" {
const versionAhead = parse(params.versionAheadStr);
const versionBehind = parse(params.versionBehindStr);
if (compare(versionBehind, versionAhead) === 1) {
throw new Error(`Version regression ${versionBehind} -> ${versionAhead}`);
}
for (const level of ["major", "minor", "patch", "betaPreRelease"] as const) {
if (versionBehind[level] !== versionAhead[level]) {
return level;
}
}
return "same";
}
}

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

@ -1,99 +0,0 @@
export type SemVer = {
major: number;
minor: number;
patch: number;
rc?: number;
parsedFrom: string;
};
export namespace SemVer {
const bumpTypes = ["major", "minor", "patch", "rc", "no bump"] as const;
export type BumpType = (typeof bumpTypes)[number];
export function parse(versionStr: string): SemVer {
const match = versionStr.match(/^v?([0-9]+)\.([0-9]+)(?:\.([0-9]+))?(?:-rc.([0-9]+))?$/);
if (!match) {
throw new Error(`${versionStr} is not a valid semantic version`);
}
const semVer: Omit<SemVer, "parsedFrom"> = {
"major": parseInt(match[1]),
"minor": parseInt(match[2]),
"patch": (() => {
const str = match[3];
return str === undefined ? 0 : parseInt(str);
})(),
...(() => {
const str = match[4];
return str === undefined ? {} : { "rc": parseInt(str) };
})()
};
const initialStr = stringify(semVer);
Object.defineProperty(semVer, "parsedFrom", {
"enumerable": true,
"get": function () {
const currentStr = stringify(this);
if (currentStr !== initialStr) {
throw new Error(`SemVer.parsedFrom can't be read anymore, the version have been modified from ${initialStr} to ${currentStr}`);
}
return versionStr;
}
});
return semVer as any;
}
export function stringify(v: Omit<SemVer, "parsedFrom">): string {
return `${v.major}.${v.minor}.${v.patch}${v.rc === undefined ? "" : `-rc.${v.rc}`}`;
}
/**
*
* v1 < v2 => -1
* v1 === v2 => 0
* v1 > v2 => 1
*
*/
export function compare(v1: SemVer, v2: SemVer): -1 | 0 | 1 {
const sign = (diff: number): -1 | 0 | 1 => (diff === 0 ? 0 : diff < 0 ? -1 : 1);
const noUndefined = (n: number | undefined) => n ?? Infinity;
for (const level of ["major", "minor", "patch", "rc"] as const) {
if (noUndefined(v1[level]) !== noUndefined(v2[level])) {
return sign(noUndefined(v1[level]) - noUndefined(v2[level]));
}
}
return 0;
}
/*
console.log(compare(parse("3.0.0-rc.3"), parse("3.0.0")) === -1 )
console.log(compare(parse("3.0.0-rc.3"), parse("3.0.0-rc.4")) === -1 )
console.log(compare(parse("3.0.0-rc.3"), parse("4.0.0")) === -1 )
*/
export function bumpType(params: { versionBehind: string | SemVer; versionAhead: string | SemVer }): BumpType | "no bump" {
const versionAhead = typeof params.versionAhead === "string" ? parse(params.versionAhead) : params.versionAhead;
const versionBehind = typeof params.versionBehind === "string" ? parse(params.versionBehind) : params.versionBehind;
if (compare(versionBehind, versionAhead) === 1) {
throw new Error(`Version regression ${stringify(versionBehind)} -> ${stringify(versionAhead)}`);
}
for (const level of ["major", "minor", "patch", "rc"] as const) {
if (versionBehind[level] !== versionAhead[level]) {
return level;
}
}
return "no bump";
}
}

View File

@ -1,30 +0,0 @@
export function replaceAll(string: string, searchValue: string | RegExp, replaceValue: string): string {
if ((string as any).replaceAll !== undefined) {
return (string as any).replaceAll(searchValue, replaceValue);
}
// If the searchValue is a string
if (typeof searchValue === "string") {
// Escape special characters in the string to be used in a regex
var escapedSearchValue = searchValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
var regex = new RegExp(escapedSearchValue, "g");
return string.replace(regex, replaceValue);
}
// If the searchValue is a global RegExp, use it directly
if (searchValue instanceof RegExp && searchValue.global) {
return string.replace(searchValue, replaceValue);
}
// If the searchValue is a non-global RegExp, throw an error
if (searchValue instanceof RegExp) {
throw new TypeError("replaceAll must be called with a global RegExp");
}
// Convert searchValue to string if it's not a string or RegExp
var searchString = String(searchValue);
var regexFromString = new RegExp(searchString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
return string.replace(regexFromString, replaceValue);
}

View File

@ -1,14 +0,0 @@
import { sep as pathSep } from "path";
export function assertNoPnpmDlx() {
if (__dirname.includes(`${pathSep}pnpm${pathSep}dlx${pathSep}`)) {
console.log(
[
"Please don't use `pnpm dlx keycloakify`.",
"\nUse `npx keycloakify` or `pnpm exec keycloakify` instead since you want to use the",
"keycloakify version that is installed in your project and not the latest version on NPM."
].join(" ")
);
process.exit(1);
}
}

View File

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

View File

@ -0,0 +1,130 @@
import { exec as execCallback } from "child_process";
import { createHash } from "crypto";
import { mkdir, readFile, stat, writeFile } from "fs/promises";
import fetch, { type FetchOptions } from "make-fetch-happen";
import { dirname as pathDirname, join as pathJoin } from "path";
import { assert } from "tsafe";
import { promisify } from "util";
import { getProjectRoot } from "./getProjectRoot";
import { transformCodebase } from "./transformCodebase";
import { unzip } from "./unzip";
const exec = promisify(execCallback);
function hash(s: string) {
return createHash("sha256").update(s).digest("hex");
}
async function exists(path: string) {
try {
await stat(path);
return true;
} catch (error) {
if ((error as Error & { code: string }).code === "ENOENT") return false;
throw error;
}
}
function ensureArray<T>(arg0: T | T[]) {
return Array.isArray(arg0) ? arg0 : typeof arg0 === "undefined" ? [] : [arg0];
}
function ensureSingleOrNone<T>(arg0: T | T[]) {
if (!Array.isArray(arg0)) return arg0;
if (arg0.length === 0) return undefined;
if (arg0.length === 1) return arg0[0];
throw new Error("Illegal configuration, expected a single value but found multiple: " + arg0.map(String).join(", "));
}
type NPMConfig = Record<string, string | string[]>;
const npmConfigReducer = (cfg: NPMConfig, [key, value]: [string, string]) =>
key in cfg ? { ...cfg, [key]: [...ensureArray(cfg[key]), value] } : { ...cfg, [key]: value };
/**
* Get npm configuration as map
*/
async function getNmpConfig() {
return readNpmConfig().then(parseNpmConfig);
}
async function readNpmConfig() {
const { stdout } = await exec("npm config get", { encoding: "utf8" });
return stdout;
}
function parseNpmConfig(stdout: string) {
return stdout
.split("\n")
.filter(line => !line.startsWith(";"))
.map(line => line.trim())
.map(line => line.split("=", 2) as [string, string])
.reduce(npmConfigReducer, {} as NPMConfig);
}
function maybeBoolean(arg0: string | undefined) {
return typeof arg0 === "undefined" ? undefined : Boolean(arg0);
}
function chunks<T>(arr: T[], size: number = 2) {
return arr.map((_, i) => i % size == 0 && arr.slice(i, i + size)).filter(Boolean) as T[][];
}
async function readCafile(cafile: string) {
const cafileContent = await readFile(cafile, "utf-8");
return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(ca => ca.join("").replace(/^\n/, "").replace(/\n/g, "\\n"));
}
/**
* Get proxy and ssl configuration from npm config files. Note that we don't care about
* proxy config in env vars, because make-fetch-happen will do that for us.
*
* @returns proxy configuration
*/
async function getFetchOptions(): Promise<Pick<FetchOptions, "proxy" | "noProxy" | "strictSSL" | "ca" | "cert">> {
const cfg = await getNmpConfig();
const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]);
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
const strictSSL = maybeBoolean(ensureSingleOrNone(cfg["strict-ssl"]));
const cert = cfg["cert"];
const ca = ensureArray(cfg["ca"] ?? cfg["ca[]"]);
const cafile = ensureSingleOrNone(cfg["cafile"]);
if (typeof cafile !== "undefined" && cafile !== "null") ca.push(...(await readCafile(cafile)));
return { proxy, noProxy, strictSSL, cert, ca: ca.length === 0 ? undefined : ca };
}
export async function downloadAndUnzip(params: { url: string; destDirPath: string; pathOfDirToExtractInArchive?: string }) {
const { url, destDirPath, pathOfDirToExtractInArchive } = params;
const downloadHash = hash(JSON.stringify({ url })).substring(0, 15);
const projectRoot = getProjectRoot();
const cacheRoot = process.env.XDG_CACHE_HOME ?? pathJoin(projectRoot, "node_modules", ".cache");
const zipFilePath = pathJoin(cacheRoot, "keycloakify", "zip", `_${downloadHash}.zip`);
const extractDirPath = pathJoin(cacheRoot, "keycloakify", "unzip", `_${downloadHash}`);
if (!(await exists(zipFilePath))) {
const opts = await getFetchOptions();
const response = await fetch(url, opts);
await mkdir(pathDirname(zipFilePath), { "recursive": true });
/**
* The correct way to fix this is to upgrade node-fetch beyond 3.2.5
* (see https://github.com/node-fetch/node-fetch/issues/1295#issuecomment-1144061991.)
* Unfortunately, octokit (a dependency of keycloakify) also uses node-fetch, and
* does not support node-fetch 3.x. So we stick around with this band-aid until
* octokit upgrades.
*/
response.body?.setMaxListeners(Number.MAX_VALUE);
assert(typeof response.body !== "undefined" && response.body != null);
await writeFile(zipFilePath, response.body);
}
await unzip(zipFilePath, extractDirPath, pathOfDirToExtractInArchive);
transformCodebase({
"srcDirPath": extractDirPath,
"destDirPath": destDirPath
});
}

View File

@ -1,73 +0,0 @@
import { exec as execCallback } from "child_process";
import { readFile } from "fs/promises";
import { type FetchOptions } from "make-fetch-happen";
import { promisify } from "util";
function ensureArray<T>(arg0: T | T[]) {
return Array.isArray(arg0) ? arg0 : typeof arg0 === "undefined" ? [] : [arg0];
}
function ensureSingleOrNone<T>(arg0: T | T[]) {
if (!Array.isArray(arg0)) return arg0;
if (arg0.length === 0) return undefined;
if (arg0.length === 1) return arg0[0];
throw new Error("Illegal configuration, expected a single value but found multiple: " + arg0.map(String).join(", "));
}
type NPMConfig = Record<string, string | string[]>;
/**
* Get npm configuration as map
*/
async function getNmpConfig(params: { npmWorkspaceRootDirPath: string }) {
const { npmWorkspaceRootDirPath } = params;
const exec = promisify(execCallback);
const stdout = await exec("npm config get", { "encoding": "utf8", "cwd": npmWorkspaceRootDirPath }).then(({ stdout }) => stdout);
const npmConfigReducer = (cfg: NPMConfig, [key, value]: [string, string]) =>
key in cfg ? { ...cfg, [key]: [...ensureArray(cfg[key]), value] } : { ...cfg, [key]: value };
return stdout
.split("\n")
.filter(line => !line.startsWith(";"))
.map(line => line.trim())
.map(line => line.split("=", 2) as [string, string])
.reduce(npmConfigReducer, {} as NPMConfig);
}
export type ProxyFetchOptions = Pick<FetchOptions, "proxy" | "noProxy" | "strictSSL" | "cert" | "ca">;
export async function getProxyFetchOptions(params: { npmWorkspaceRootDirPath: string }): Promise<ProxyFetchOptions> {
const { npmWorkspaceRootDirPath } = params;
const cfg = await getNmpConfig({ npmWorkspaceRootDirPath });
const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]);
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
function maybeBoolean(arg0: string | undefined) {
return typeof arg0 === "undefined" ? undefined : Boolean(arg0);
}
const strictSSL = maybeBoolean(ensureSingleOrNone(cfg["strict-ssl"]));
const cert = cfg["cert"];
const ca = ensureArray(cfg["ca"] ?? cfg["ca[]"]);
const cafile = ensureSingleOrNone(cfg["cafile"]);
if (typeof cafile !== "undefined" && cafile !== "null") {
ca.push(
...(await (async () => {
function chunks<T>(arr: T[], size: number = 2) {
return arr.map((_, i) => i % size == 0 && arr.slice(i, i + size)).filter(Boolean) as T[][];
}
const cafileContent = await readFile(cafile, "utf-8");
return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(ca => ca.join("").replace(/^\n/, "").replace(/\n/g, "\\n"));
})())
);
}
return { proxy, noProxy, strictSSL, cert, "ca": ca.length === 0 ? undefined : ca };
}

View File

@ -1,11 +0,0 @@
import * as fs from "fs/promises";
export async function existsAsync(path: string) {
try {
await fs.stat(path);
return true;
} catch (error) {
if ((error as Error & { code: string }).code === "ENOENT") return false;
throw error;
}
}

View File

@ -1,43 +0,0 @@
import * as fs from "fs/promises";
import { join as pathJoin } from "path";
import { SemVer } from "./SemVer";
/**
* Polyfill of fs.rm(dirPath, { "recursive": true })
* For older version of Node
*/
export async function rm(dirPath: string, options: { recursive: true; force?: true }) {
if (SemVer.compare(SemVer.parse(process.version), SemVer.parse("14.14.0")) > 0) {
return fs.rm(dirPath, options);
}
const { force = true } = options;
if (force && !(await checkDirExists(dirPath))) {
return;
}
const removeDir_rec = async (dirPath: string) =>
Promise.all(
(await fs.readdir(dirPath)).map(async basename => {
const fileOrDirpath = pathJoin(dirPath, basename);
if ((await fs.lstat(fileOrDirpath)).isDirectory()) {
await removeDir_rec(fileOrDirpath);
} else {
await fs.unlink(fileOrDirpath);
}
})
);
await removeDir_rec(dirPath);
}
async function checkDirExists(dirPath: string) {
try {
await fs.access(dirPath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}

View File

@ -1,34 +0,0 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { SemVer } from "./SemVer";
/**
* Polyfill of fs.rmSync(dirPath, { "recursive": true })
* For older version of Node
*/
export function rmSync(dirPath: string, options: { recursive: true; force?: true }) {
if (SemVer.compare(SemVer.parse(process.version), SemVer.parse("14.14.0")) > 0) {
fs.rmSync(dirPath, options);
return;
}
const { force = true } = options;
if (force && !fs.existsSync(dirPath)) {
return;
}
const removeDir_rec = (dirPath: string) =>
fs.readdirSync(dirPath).forEach(basename => {
const fileOrDirpath = pathJoin(dirPath, basename);
if (fs.lstatSync(fileOrDirpath).isDirectory()) {
removeDir_rec(fileOrDirpath);
return;
} else {
fs.unlinkSync(fileOrDirpath);
}
});
removeDir_rec(dirPath);
}

View File

@ -1,17 +0,0 @@
import { isAbsolute as pathIsAbsolute, sep as pathSep, join as pathJoin } from "path";
export function getAbsoluteAndInOsFormatPath(params: { pathIsh: string; cwd: string }): string {
const { pathIsh, cwd } = params;
let pathOut = pathIsh;
pathOut = pathOut.replace(/\//g, pathSep);
pathOut = pathOut.endsWith(pathSep) ? pathOut.slice(0, -1) : pathOut;
if (!pathIsAbsolute(pathOut)) {
pathOut = pathJoin(cwd, pathOut);
}
return pathOut;
}

View File

@ -1,19 +1,19 @@
import * as fs from "fs";
import * as path from "path";
function getThisCodebaseRootDirPath_rec(dirPath: string): string {
function getProjectRootRec(dirPath: string): string {
if (fs.existsSync(path.join(dirPath, "package.json"))) {
return dirPath;
}
return getThisCodebaseRootDirPath_rec(path.join(dirPath, ".."));
return getProjectRootRec(path.join(dirPath, ".."));
}
let result: string | undefined = undefined;
export function getThisCodebaseRootDirPath(): string {
export function getProjectRoot(): string {
if (result !== undefined) {
return result;
}
return (result = getThisCodebaseRootDirPath_rec(__dirname));
return (result = getProjectRootRec(__dirname));
}

View File

@ -1,15 +1,13 @@
import { getThisCodebaseRootDirPath } from "./getThisCodebaseRootDirPath";
import { getProjectRoot } from "./getProjectRoot";
import { join as pathJoin } from "path";
import { constants } from "fs";
import { chmod, stat } from "fs/promises";
(async () => {
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
const { bin } = await import(pathJoin(thisCodebaseRootDirPath, "package.json"));
const { bin } = await import(pathJoin(getProjectRoot(), "package.json"));
const promises = Object.values<string>(bin).map(async scriptPath => {
const fullPath = pathJoin(thisCodebaseRootDirPath, scriptPath);
const fullPath = pathJoin(getProjectRoot(), scriptPath);
const oldMode = (await stat(fullPath)).mode;
const newMode = oldMode | constants.S_IXUSR | constants.S_IXGRP | constants.S_IXOTH;
await chmod(fullPath, newMode);

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

@ -0,0 +1,99 @@
import { dirname, relative, sep, join } from "path";
import { createWriteStream } from "fs";
import walk from "./walk";
import { ZipFile } from "yazl";
import { mkdir } from "fs/promises";
import trimIndent from "./trimIndent";
export type ZipEntry = { zipPath: string } & ({ fsPath: string } | { buffer: Buffer });
export type ZipEntryGenerator = AsyncGenerator<ZipEntry, void, unknown>;
type CommonJarArgs = {
groupId: string;
artifactId: string;
version: string;
};
export type JarStreamArgs = CommonJarArgs & {
asyncPathGeneratorFn(): ZipEntryGenerator;
};
export type JarArgs = CommonJarArgs & {
targetPath: string;
rootPath: string;
};
export async function jarStream({ groupId, artifactId, version, asyncPathGeneratorFn }: JarStreamArgs) {
const manifestPath = "META-INF/MANIFEST.MF";
const manifestData = Buffer.from(trimIndent`
Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Created-By: Keycloakify
Built-By: unknown
Build-Jdk: 19.0.0
`);
const pomPropsPath = `META-INF/maven/${groupId}/${artifactId}/pom.properties`;
const pomPropsData = Buffer.from(trimIndent`
# Generated by keycloakify
# ${new Date()}
artifactId=${artifactId}
groupId=${groupId}
version=${version}
`);
const zipFile = new ZipFile();
for await (const entry of asyncPathGeneratorFn()) {
if ("buffer" in entry) {
zipFile.addBuffer(entry.buffer, entry.zipPath);
} else if ("fsPath" in entry) {
if (entry.fsPath.endsWith(sep)) {
zipFile.addEmptyDirectory(entry.zipPath);
} else {
zipFile.addFile(entry.fsPath, entry.zipPath);
}
}
}
zipFile.addBuffer(manifestData, manifestPath);
zipFile.addBuffer(pomPropsData, pomPropsPath);
zipFile.end();
return zipFile;
}
/**
* Create a jar archive, using the resources found at `rootPath` (a directory) and write the
* archive to `targetPath` (a file). Use `groupId`, `artifactId` and `version` to define
* the contents of the pom.properties file which is going to be added to the archive.
* The root directory is expectedto have a conventional maven/gradle folder structure with a
* single `pom.xml` file at the root and a `src/main/resources` directory containing all
* application resources.
*/
export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) {
await mkdir(dirname(targetPath), { recursive: true });
const asyncPathGeneratorFn = async function* (): ZipEntryGenerator {
const resourcesPath = join(rootPath, "src", "main", "resources");
for await (const fsPath of walk(resourcesPath)) {
const zipPath = relative(resourcesPath, fsPath).split(sep).join("/");
yield { fsPath, zipPath };
}
yield {
fsPath: join(rootPath, "pom.xml"),
zipPath: `META-INF/maven/${groupId}/${artifactId}/pom.xml`
};
};
const zipFile = await jarStream({ groupId, artifactId, version, asyncPathGeneratorFn });
await new Promise<void>(async (resolve, reject) => {
zipFile.outputStream
.pipe(createWriteStream(targetPath, { encoding: "binary" }))
.on("close", () => resolve())
.on("error", e => reject(e));
});
}

View File

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

View File

@ -0,0 +1,6 @@
export function pathJoin(...path: string[]): string {
return path
.map((part, i) => (i === 0 ? part : part.replace(/^\/+/, "")))
.map((part, i) => (i === path.length - 1 ? part : part.replace(/\/+$/, "")))
.join("/");
}

View File

@ -1,12 +0,0 @@
import { getThisCodebaseRootDirPath } from "./getThisCodebaseRootDirPath";
import { assert } from "tsafe/assert";
import * as fs from "fs";
import { join as pathJoin } from "path";
export function readThisNpmProjectVersion(): string {
const version = JSON.parse(fs.readFileSync(pathJoin(getThisCodebaseRootDirPath(), "package.json")).toString("utf8"))["version"];
assert(typeof version === "string");
return version;
}

View File

@ -1,69 +1,46 @@
import * as fs from "fs";
import * as path from "path";
import { crawl } from "./crawl";
import { rmSync } from "../tools/fs.rmSync";
import { id } from "tsafe/id";
type TransformSourceCode = (params: { sourceCode: Buffer; filePath: string; fileRelativePath: string }) =>
type TransformSourceCode = (params: { sourceCode: Buffer; filePath: string }) =>
| {
modifiedSourceCode: Buffer;
newFileName?: string;
}
| undefined;
/**
* Apply a transformation function to every file of directory
* If source and destination are the same this function can be used to apply the transformation in place
* like filtering out some files or modifying them.
* */
/** Apply a transformation function to every file of directory */
export function transformCodebase(params: { srcDirPath: string; destDirPath: string; transformSourceCode?: TransformSourceCode }) {
const { srcDirPath, transformSourceCode } = params;
const {
srcDirPath,
destDirPath,
transformSourceCode = id<TransformSourceCode>(({ sourceCode }) => ({
"modifiedSourceCode": sourceCode
}))
} = params;
const isTargetSameAsSource = path.relative(srcDirPath, params.destDirPath) === "";
const destDirPath = isTargetSameAsSource ? path.join(srcDirPath, "..", "tmp_xOsPdkPsTdzPs34sOkHs") : params.destDirPath;
fs.mkdirSync(destDirPath, {
"recursive": true
});
for (const fileRelativePath of crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })) {
const filePath = path.join(srcDirPath, fileRelativePath);
const destFilePath = path.join(destDirPath, fileRelativePath);
// NOTE: Optimization, if we don't need to transform the file, just copy
// it using the lower level implementation.
if (transformSourceCode === undefined) {
fs.mkdirSync(path.dirname(destFilePath), {
"recursive": true
});
fs.copyFileSync(filePath, destFilePath);
continue;
}
for (const file_relative_path of crawl(srcDirPath)) {
const filePath = path.join(srcDirPath, file_relative_path);
const transformSourceCodeResult = transformSourceCode({
"sourceCode": fs.readFileSync(filePath),
filePath,
fileRelativePath
"filePath": path.join(srcDirPath, file_relative_path)
});
if (transformSourceCodeResult === undefined) {
continue;
}
fs.mkdirSync(path.dirname(destFilePath), {
fs.mkdirSync(path.dirname(path.join(destDirPath, file_relative_path)), {
"recursive": true
});
const { newFileName, modifiedSourceCode } = transformSourceCodeResult;
fs.writeFileSync(path.join(path.dirname(destFilePath), newFileName ?? path.basename(destFilePath)), modifiedSourceCode);
}
if (isTargetSameAsSource) {
rmSync(srcDirPath, { "recursive": true });
fs.renameSync(destDirPath, srcDirPath);
fs.writeFileSync(
path.join(path.dirname(path.join(destDirPath, file_relative_path)), newFileName ?? path.basename(file_relative_path)),
modifiedSourceCode
);
}
}

View File

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

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

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

View File

@ -1,44 +0,0 @@
import { assert } from "tsafe/assert";
/**
* WARNING: Internal use only!!
* THIS DOES NOT WORK IN KEYCLOAK! It's only for resolving mock assets.
* This is just a way to know what's the base url that works
* both in webpack and vite.
* You can see this as a polyfill that return `import.meta.env.BASE_URL` when in Vite
* and when in Webpack returns the base url in the same format as vite does meaning
* "/" if hosted at root or "/foo/" when hosted under a subpath (always start and ends with a "/").
*/
export const BASE_URL = (() => {
vite: {
let BASE_URL: string;
try {
// @ts-expect-error
BASE_URL = import.meta.env.BASE_URL;
assert(typeof BASE_URL === "string");
} catch {
break vite;
}
return BASE_URL;
}
webpack: {
let BASE_URL: string;
try {
// @ts-expect-error
BASE_URL = process.env.PUBLIC_URL;
assert(typeof BASE_URL === "string");
} catch {
break webpack;
}
return BASE_URL === "" ? "/" : `${BASE_URL}/`;
}
return "/";
})();

View File

@ -1 +0,0 @@
export const isStorybook = typeof window === "object" && Object.keys(window).find(key => key.startsWith("__STORYBOOK")) !== undefined;

View File

@ -36,10 +36,6 @@ export declare namespace keycloak_js {
}
/**
* @deprecated: This will be removed in the next major version.
* If you use this, please copy paste the code into your project.
* Better yet migrate away from keycloak-js and use https://docs.oidc-spa.dev instead.
*
* NOTE: This is just a slightly modified version of the default adapter in keycloak-js
* The goal here is just to be able to inject search param in url before keycloak redirect.
* Our use case for it is to pass over the login screen the states of useGlobalState

View File

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

View File

@ -12,8 +12,6 @@ const Error = lazy(() => import("keycloakify/login/pages/Error"));
const LoginResetPassword = lazy(() => import("keycloakify/login/pages/LoginResetPassword"));
const LoginVerifyEmail = lazy(() => import("keycloakify/login/pages/LoginVerifyEmail"));
const Terms = lazy(() => import("keycloakify/login/pages/Terms"));
const LoginDeviceVerifyUserCode = lazy(() => import("keycloakify/login/pages/LoginDeviceVerifyUserCode"));
const LoginOauthGrant = lazy(() => import("keycloakify/login/pages/LoginOauthGrant"));
const LoginOtp = lazy(() => import("keycloakify/login/pages/LoginOtp"));
const LoginPassword = lazy(() => import("keycloakify/login/pages/LoginPassword"));
const LoginUsername = lazy(() => import("keycloakify/login/pages/LoginUsername"));
@ -54,10 +52,6 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
return <LoginVerifyEmail kcContext={kcContext} {...rest} />;
case "terms.ftl":
return <Terms kcContext={kcContext} {...rest} />;
case "login-oauth2-device-verify-user-code.ftl":
return <LoginDeviceVerifyUserCode kcContext={kcContext} {...rest} />;
case "login-oauth-grant.ftl":
return <LoginOauthGrant kcContext={kcContext} {...rest} />;
case "login-otp.ftl":
return <LoginOtp kcContext={kcContext} {...rest} />;
case "login-username.ftl":

View File

@ -31,16 +31,15 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { isReady } = usePrepareTemplate({
"doFetchDefaultThemeResources": doUseDefaultCss,
"styles": [
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesCommonPath}/lib/zocial/zocial.css`,
`${url.resourcesPath}/css/login.css`
url,
"stylesCommon": [
"node_modules/patternfly/dist/css/patternfly.min.css",
"node_modules/patternfly/dist/css/patternfly-additions.min.css",
"lib/zocial/zocial.css"
],
"styles": ["css/login.css"],
"htmlClassName": getClassName("kcHtmlClass"),
"bodyClassName": getClassName("kcBodyClass"),
"htmlLangProperty": locale?.currentLanguageTag,
"documentTitle": i18n.msgStr("loginTitle", kcContext.realm.displayName)
"bodyClassName": undefined
});
if (!isReady) {

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
import { type ThemeType } from "keycloakify/bin/constants";
import type { LoginThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import type { MessageKey } from "../i18n/i18n";
@ -19,8 +18,6 @@ export type KcContext =
| KcContext.LoginResetPassword
| KcContext.LoginVerifyEmail
| KcContext.Terms
| KcContext.LoginDeviceVerifyUserCode
| KcContext.LoginOauthGrant
| KcContext.LoginOtp
| KcContext.LoginUsername
| KcContext.WebauthnAuthenticate
@ -40,7 +37,6 @@ export type KcContext =
export declare namespace KcContext {
export type Common = {
themeVersion: string;
keycloakifyVersion: string;
themeType: "login";
themeName: string;
@ -82,41 +78,14 @@ export declare namespace KcContext {
clientId: string;
name?: string;
description?: string;
attributes: Record<string, string>;
};
isAppInitiatedAction?: boolean;
isAppInitiatedAction: boolean;
messagesPerField: {
/**
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.
*
* @param fieldName to check for
* @param text to return
* @return text if message exists for given field, else undefined
*/
printIfExists: <T extends string>(fieldName: string, text: T) => T | undefined;
/**
* Check if exists error message for given fields
*
* @param fields
* @return boolean
*/
printIfExists: <T>(fieldName: string, x: T) => T | undefined;
existsError: (fieldName: string) => boolean;
/**
* Get message for given field.
*
* @param fieldName
* @return message text or empty string
*/
get: (fieldName: string) => string;
/**
* Check if message for given field exists
*
* @param field
* @return boolean
*/
exists: (fieldName: string) => boolean;
};
properties: Record<string, string | undefined>;
};
export type SamlPostForm = Common & {
@ -151,7 +120,7 @@ export declare namespace KcContext {
rememberMe?: string;
password?: string;
};
usernameHidden?: boolean;
usernameEditDisabled: boolean;
social: {
displayInfo: boolean;
providers?: {
@ -245,38 +214,6 @@ export declare namespace KcContext {
export type Terms = Common & {
pageId: "terms.ftl";
//NOTE: Optional because maybe it wasn't defined in older keycloak versions.
user?: {
id: string;
username: string;
attributes: Record<string, string[]>;
email: string;
emailVerified: boolean;
firstName?: string;
lastName?: string;
markedForEviction?: boolean;
};
};
export type LoginDeviceVerifyUserCode = Common & {
pageId: "login-oauth2-device-verify-user-code.ftl";
url: {
oauth2DeviceVerificationAction: string;
};
};
export type LoginOauthGrant = Common & {
pageId: "login-oauth-grant.ftl";
oauth: {
code: string;
client: string;
clientScopesRequested: {
consentScreenText: string;
}[];
};
url: {
oauthAction: string;
};
};
export type LoginOtp = Common & {

View File

@ -2,19 +2,20 @@ import type { KcContext, Attribute } from "./KcContext";
import { kcContextMocks, kcContextCommonMock } from "./kcContextMocks";
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import { deepAssign } from "keycloakify/tools/deepAssign";
import { isStorybook } from "keycloakify/lib/isStorybook";
import { id } from "tsafe/id";
import { exclude } from "tsafe/exclude";
import { assert } from "tsafe/assert";
import type { ExtendKcContext } from "./getKcContextFromWindow";
import { getKcContextFromWindow } from "./getKcContextFromWindow";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { pathBasename } from "keycloakify/tools/pathBasename";
import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { symToStr } from "tsafe/symToStr";
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
mockProperties?: Record<string, string>;
}) {
const { mockData, mockProperties } = params ?? {};
const { mockData } = params ?? {};
function getKcContext<PageId extends ExtendKcContext<KcContextExtension>["pageId"] | undefined = undefined>(params?: {
mockPageId?: PageId;
@ -31,13 +32,7 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
if (mockPageId !== undefined && realKcContext === undefined) {
//TODO maybe trow if no mock fo custom page
warn_that_mock_is_enbaled: {
if (isStorybook) {
break warn_that_mock_is_enbaled;
}
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
}
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
@ -142,13 +137,6 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
}
}
if (mockProperties !== undefined) {
deepAssign({
"target": kcContext.properties,
"source": mockProperties
});
}
return { kcContext };
}
@ -160,6 +148,12 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
return { "kcContext": undefined as any };
}
{
const { url } = realKcContext;
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(resourcesCommonDirPathRelativeToPublicDir));
}
return { "kcContext": realKcContext as any };
}

View File

@ -1,11 +1,11 @@
import type { KcContext } from "./KcContext";
import type { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey";
import { nameOfTheGlobal } from "keycloakify/bin/constants";
import { ftlValuesGlobalName } from "keycloakify/bin/keycloakify/ftlValuesGlobalName";
export type ExtendKcContext<KcContextExtension extends { pageId: string }> = [KcContextExtension] extends [never]
? KcContext
: AndByDiscriminatingKey<"pageId", KcContextExtension & KcContext.Common, KcContext>;
export function getKcContextFromWindow<KcContextExtension extends { pageId: string } = never>(): ExtendKcContext<KcContextExtension> | undefined {
return typeof window === "undefined" ? undefined : (window as any)[nameOfTheGlobal];
return typeof window === "undefined" ? undefined : (window as any)[ftlValuesGlobalName];
}

View File

@ -1,10 +1,12 @@
import "minimal-polyfills/Object.fromEntries";
import type { KcContext, Attribute } from "./KcContext";
import { resources_common, keycloak_resources } from "keycloakify/bin/constants";
import { resourcesCommonDirPathRelativeToPublicDir, resourcesDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { id } from "tsafe/id";
import { assert, type Equals } from "tsafe/assert";
import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
import { BASE_URL } from "keycloakify/lib/BASE_URL";
const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
const attributes: Attribute[] = [
{
@ -100,17 +102,14 @@ const attributes: Attribute[] = [
const attributesByName = Object.fromEntries(attributes.map(attribute => [attribute.name, attribute])) as any;
const resourcesPath = `${BASE_URL}${keycloak_resources}/login/resources`;
export const kcContextCommonMock: KcContext.Common = {
"themeVersion": "0.0.0",
"keycloakifyVersion": "0.0.0",
"themeType": "login",
"themeName": "my-theme-name",
"url": {
"loginAction": "#",
resourcesPath,
"resourcesCommonPath": `${resourcesPath}/${resources_common}`,
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
"loginRestartFlowUrl": "/auth/realms/myrealm/login-actions/restart?client_id=account&tab_id=HoAx28ja4xg",
"loginUrl": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg"
},
@ -232,139 +231,20 @@ export const kcContextCommonMock: KcContext.Common = {
"showTryAnotherWayLink": false
},
"client": {
"clientId": "myApp",
"attributes": {}
"clientId": "myApp"
},
"scripts": [],
"isAppInitiatedAction": false,
"properties": {
"kcLogoIdP-facebook": "fa fa-facebook",
"parent": "keycloak",
"kcAuthenticatorOTPClass": "fa fa-mobile list-view-pf-icon-lg",
"kcLogoIdP-bitbucket": "fa fa-bitbucket",
"kcAuthenticatorWebAuthnClass": "fa fa-key list-view-pf-icon-lg",
"kcWebAuthnDefaultIcon": "pficon pficon-key",
"kcLogoIdP-stackoverflow": "fa fa-stack-overflow",
"kcSelectAuthListItemClass": "pf-l-stack__item select-auth-box-parent pf-l-split",
"kcLogoIdP-microsoft": "fa fa-windows",
"kcLocaleItemClass": "pf-c-dropdown__menu-item",
"kcLoginOTPListItemHeaderClass": "pf-c-tile__header",
"kcLoginOTPListItemIconBodyClass": "pf-c-tile__icon",
"kcInputHelperTextAfterClass": "pf-c-form__helper-text pf-c-form__helper-text-after",
"kcFormClass": "form-horizontal",
"kcSelectAuthListClass": "pf-l-stack select-auth-container",
"kcInputClassRadioCheckboxLabelDisabled": "pf-m-disabled",
"kcSelectAuthListItemIconClass": "pf-l-split__item select-auth-box-icon",
"kcRecoveryCodesWarning": "kc-recovery-codes-warning",
"kcFormSettingClass": "login-pf-settings",
"kcWebAuthnBLE": "fa fa-bluetooth-b",
"kcInputWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcSelectAuthListItemArrowIconClass": "fa fa-angle-right fa-lg",
"meta": "viewport==width=device-width,initial-scale=1",
"styles": "css/login.css css/tile.css",
"kcFeedbackAreaClass": "col-md-12",
"kcLogoIdP-google": "fa fa-google",
"kcCheckLabelClass": "pf-c-check__label",
"kcSelectAuthListItemFillClass": "pf-l-split__item pf-m-fill",
"kcAuthenticatorDefaultClass": "fa fa-list list-view-pf-icon-lg",
"kcLogoIdP-gitlab": "fa fa-gitlab",
"kcFormAreaClass": "col-sm-10 col-sm-offset-1 col-md-8 col-md-offset-2 col-lg-8 col-lg-offset-2",
"kcFormButtonsClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcInputClassRadioLabel": "pf-c-radio__label",
"kcAuthenticatorWebAuthnPasswordlessClass": "fa fa-key list-view-pf-icon-lg",
"kcSelectAuthListItemHeadingClass": "pf-l-stack__item select-auth-box-headline pf-c-title",
"kcInfoAreaClass": "col-xs-12 col-sm-4 col-md-4 col-lg-5 details",
"kcLogoLink": "http://www.keycloak.org",
"kcContainerClass": "container-fluid",
"kcSelectAuthListItemTitle": "select-auth-box-paragraph",
"kcHtmlClass": "login-pf",
"kcLoginOTPListItemTitleClass": "pf-c-tile__title",
"locales": "ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN",
"serviceTitle": "CodeGouv",
"kcLogoIdP-openshift-v4": "pf-icon pf-icon-openshift",
"kcWebAuthnUnknownIcon": "pficon pficon-key unknown-transport-class",
"kcFormSocialAccountNameClass": "kc-social-provider-name",
"kcLogoIdP-openshift-v3": "pf-icon pf-icon-openshift",
"kcLoginOTPListInputClass": "pf-c-tile__input",
"kcWebAuthnUSB": "fa fa-usb",
"kcInputClassRadio": "pf-c-radio",
"kcWebAuthnKeyIcon": "pficon pficon-key",
"kcFeedbackInfoIcon": "fa fa-fw fa-info-circle",
"kcCommonLogoIdP": "kc-social-provider-logo kc-social-gray",
"stylesCommon":
"web_modules/@patternfly/react-core/dist/styles/base.css web_modules/@patternfly/react-core/dist/styles/app.css node_modules/patternfly/dist/css/patternfly.min.css node_modules/patternfly/dist/css/patternfly-additions.min.css lib/pficon/pficon.css",
"kcRecoveryCodesActions": "kc-recovery-codes-actions",
"kcFormGroupHeader": "pf-c-form__group",
"kcFormSocialAccountSectionClass": "kc-social-section kc-social-gray",
"kcLogoIdP-instagram": "fa fa-instagram",
"kcAlertClass": "pf-c-alert pf-m-inline",
"kcHeaderClass": "login-pf-page-header",
"kcLabelWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcFormSocialAccountLinkClass": "pf-c-login__main-footer-links-item-link",
"kcLocaleMainClass": "pf-c-dropdown",
"kcTextareaClass": "form-control",
"kcButtonBlockClass": "pf-m-block",
"kcButtonClass": "pf-c-button",
"kcWebAuthnNFC": "fa fa-wifi",
"kcLocaleClass": "col-xs-12 col-sm-1",
"kcInputClassCheckboxInput": "pf-c-check__input",
"kcFeedbackErrorIcon": "fa fa-fw fa-exclamation-circle",
"kcInputLargeClass": "input-lg",
"kcInputErrorMessageClass": "pf-c-form__helper-text pf-m-error required kc-feedback-text",
"kcRecoveryCodesList": "kc-recovery-codes-list",
"kcFormSocialAccountListClass": "pf-c-login__main-footer-links kc-social-links",
"kcAlertTitleClass": "pf-c-alert__title kc-feedback-text",
"kcAuthenticatorPasswordClass": "fa fa-unlock list-view-pf-icon-lg",
"kcCheckInputClass": "pf-c-check__input",
"kcLogoIdP-linkedin": "fa fa-linkedin",
"kcLogoIdP-twitter": "fa fa-twitter",
"kcFeedbackWarningIcon": "fa fa-fw fa-exclamation-triangle",
"kcResetFlowIcon": "pficon pficon-arrow fa",
"kcSelectAuthListItemIconPropertyClass": "fa-2x select-auth-box-icon-properties",
"kcFeedbackSuccessIcon": "fa fa-fw fa-check-circle",
"kcLoginOTPListClass": "pf-c-tile",
"kcSrOnlyClass": "sr-only",
"kcFormSocialAccountListGridClass": "pf-l-grid kc-social-grid",
"kcButtonDefaultClass": "btn-default",
"kcFormGroupErrorClass": "has-error",
"kcSelectAuthListItemDescriptionClass": "pf-l-stack__item select-auth-box-desc",
"kcSelectAuthListItemBodyClass": "pf-l-split__item pf-l-stack",
"import": "common/keycloak",
"kcWebAuthnInternal": "pficon pficon-key",
"kcSelectAuthListItemArrowClass": "pf-l-split__item select-auth-box-arrow",
"kcCheckClass": "pf-c-check",
"kcContentClass": "col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3",
"kcLogoClass": "login-pf-brand",
"kcLoginOTPListItemIconClass": "fa fa-mobile",
"kcLoginClass": "login-pf-page",
"kcSignUpClass": "login-pf-signup",
"kcButtonLargeClass": "btn-lg",
"kcFormCardClass": "card-pf",
"kcLocaleListClass": "pf-c-dropdown__menu pf-m-align-right",
"kcInputClass": "pf-c-form-control",
"kcFormGroupClass": "form-group",
"kcLogoIdP-paypal": "fa fa-paypal",
"kcInputClassCheckbox": "pf-c-check",
"kcRecoveryCodesConfirmation": "kc-recovery-codes-confirmation",
"kcInputClassRadioInput": "pf-c-radio__input",
"kcFormSocialAccountListButtonClass": "pf-c-button pf-m-control pf-m-block kc-social-item kc-social-gray",
"kcInputClassCheckboxLabel": "pf-c-check__label",
"kcFormOptionsClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
"kcFormHeaderClass": "login-pf-header",
"kcFormSocialAccountGridItem": "pf-l-grid__item",
"kcButtonPrimaryClass": "pf-m-primary",
"kcInputHelperTextBeforeClass": "pf-c-form__helper-text pf-c-form__helper-text-before",
"kcLogoIdP-github": "fa fa-github",
"kcLabelClass": "pf-c-form__label pf-c-form__label-text"
}
"message": {
"type": "success",
"summary": "This is a test message"
},
"isAppInitiatedAction": false
};
const loginUrl = {
...kcContextCommonMock.url,
"loginResetCredentialsUrl": "/auth/realms/myrealm/login-actions/reset-credentials?client_id=account&tab_id=HoAx28ja4xg",
"registrationUrl": "/auth/realms/myrealm/login-actions/registration?client_id=account&tab_id=HoAx28ja4xg",
"oauth2DeviceVerificationAction": "/auth/realms/myrealm/device",
"oauthAction": "/auth/realms/myrealm/login-actions/consent?client_id=account&tab_id=HoAx28ja4xg"
"registrationUrl": "/auth/realms/myrealm/login-actions/registration?client_id=account&tab_id=HoAx28ja4xg"
};
export const kcContextMocks = [
@ -384,7 +264,7 @@ export const kcContextMocks = [
"social": {
"displayInfo": true
},
"usernameHidden": false,
"usernameEditDisabled": false,
"login": {},
"registrationDisabled": false
}),
@ -433,8 +313,7 @@ export const kcContextMocks = [
"actionUri": "#",
"client": {
"clientId": "myApp",
"baseUrl": "#",
"attributes": {}
"baseUrl": "#"
}
}),
id<KcContext.Error>({
@ -442,8 +321,7 @@ export const kcContextMocks = [
"pageId": "error.ftl",
"client": {
"clientId": "myApp",
"baseUrl": "#",
"attributes": {}
"baseUrl": "#"
},
"message": {
"type": "error",
@ -470,25 +348,6 @@ export const kcContextMocks = [
...kcContextCommonMock,
"pageId": "terms.ftl"
}),
id<KcContext.LoginDeviceVerifyUserCode>({
...kcContextCommonMock,
"pageId": "login-oauth2-device-verify-user-code.ftl",
url: loginUrl
}),
id<KcContext.LoginOauthGrant>({
...kcContextCommonMock,
"pageId": "login-oauth-grant.ftl",
oauth: {
code: "5-1N4CIzfi1aprIQjmylI-9e3spLCWW9i5d-GDcs-Sw",
clientScopesRequested: [
{ consentScreenText: "${profileScopeConsentText}" },
{ consentScreenText: "${rolesScopeConsentText}" },
{ consentScreenText: "${emailScopeConsentText}" }
],
client: "account"
},
url: loginUrl
}),
id<KcContext.LoginOtp>({
...kcContextCommonMock,
"pageId": "login-otp.ftl",
@ -617,8 +476,7 @@ export const kcContextMocks = [
},
"client": {
"clientId": "myApp",
"baseUrl": "#",
"attributes": {}
"baseUrl": "#"
},
"logoutConfirm": { "code": "123", skipLink: false }
}),

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