Compare commits

..

2 Commits

Author SHA1 Message Date
2e017a8d2d Bump version 2023-04-17 01:41:45 +02:00
86702d8a73 Avoid deprecating getKcContext 2023-04-17 01:41:30 +02:00
181 changed files with 3043 additions and 7583 deletions

View File

@ -131,106 +131,6 @@
"contributions": [
"code"
]
},
{
"login": "thosil",
"name": "Thomas Silvestre",
"avatar_url": "https://avatars.githubusercontent.com/u/1140574?v=4",
"profile": "https://www.gravitysoftware.be",
"contributions": [
"code"
]
},
{
"login": "satanshiro",
"name": "satanshiro",
"avatar_url": "https://avatars.githubusercontent.com/u/38865738?v=4",
"profile": "https://github.com/satanshiro",
"contributions": [
"code"
]
},
{
"login": "kpoelhekke",
"name": "Koen Poelhekke",
"avatar_url": "https://avatars.githubusercontent.com/u/1632377?v=4",
"profile": "https://poelhekke.dev",
"contributions": [
"code"
]
},
{
"login": "zavoloklom",
"name": "Sergey Kupletsky",
"avatar_url": "https://avatars.githubusercontent.com/u/4151869?v=4",
"profile": "https://github.com/zavoloklom",
"contributions": [
"test",
"code"
]
},
{
"login": "rome-user",
"name": "rome-user",
"avatar_url": "https://avatars.githubusercontent.com/u/114131048?v=4",
"profile": "https://github.com/rome-user",
"contributions": [
"code"
]
},
{
"login": "celinepelletier",
"name": "Céline Pelletier",
"avatar_url": "https://avatars.githubusercontent.com/u/82821620?v=4",
"profile": "https://github.com/celinepelletier",
"contributions": [
"code"
]
},
{
"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 +138,5 @@
"repoType": "github",
"repoHost": "https://github.com",
"projectName": "keycloakify",
"projectOwner": "keycloakify",
"commitType": "docs"
"projectOwner": "keycloakify"
}

View File

@ -34,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
@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
node-version: '16'
- uses: bahmutov/npm-install@v1
- run: yarn build-storybook -o ./build_storybook
- run: git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${{github.repository}}.git
@ -74,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

4
.gitignore vendored
View File

@ -53,9 +53,5 @@ jspm_packages
# VS Code devcontainers
.devcontainer
/.yarn
/.yarnrc.yml
/stories/assets/fonts/
/build_storybook/
/storybook-static/

View File

@ -8,8 +8,8 @@ node_modules/
/.vscode/
/src/login/i18n/baseMessages/
/src/account/i18n/baseMessages/
# Test Build Directories
/dist_test
/sample_react_project/
/sample_custom_react_project/
/keycloakify_starter_test/
/.storybook/static/keycloak-resources/
/keycloakify_starter_test/

View File

@ -3,9 +3,8 @@ import React from "react";
import { DocsContainer as BaseContainer } from "@storybook/addon-docs";
import { useDarkMode } from "storybook-dark-mode";
import { darkTheme, lightTheme } from "./customTheme";
import "./static/fonts/WorkSans/font.css";
export function DocsContainer({ children, context }) {
export const DocsContainer = ({ children, context }) => {
const isStorybookUiDark = useDarkMode();
const theme = isStorybookUiDark ? darkTheme : lightTheme;
@ -16,7 +15,7 @@ export function DocsContainer({ children, context }) {
<>
<style>{`
body {
padding: 0 !important;
padding: 0 !important,
background-color: ${backgroundColor};
}
@ -58,19 +57,4 @@ export function DocsContainer({ children, context }) {
</BaseContainer>
</>
);
}
export function CanvasContainer({ children }) {
return (
<>
<style>{`
body {
padding: 0 !important;
}
`}</style>
{children}
</>
);
}
};

View File

@ -1,6 +1,8 @@
module.exports = {
"stories": [
"../stories/**/*.stories.@(ts|tsx|mdx)"
"../stories/*.stories.mdx",
"../stories/*.stories.@(ts|tsx)",
"../stories/**/*.stories.@(ts|tsx)"
],
"addons": [
"@storybook/addon-links",

View File

@ -1,6 +0,0 @@
import { addons } from '@storybook/addons';
addons.setConfig({
"selectedPanel": 'storybook/a11y/panel',
"showPanel": false,
});

View File

@ -1,5 +1,5 @@
import { darkTheme, lightTheme } from "./customTheme";
import { DocsContainer, CanvasContainer } from "./Containers";
import { DocsContainer } from "./DocsContainer";
export const parameters = {
"actions": { "argTypesRegex": "^on[A-Z].*" },
@ -17,12 +17,6 @@ export const parameters = {
"docs": {
"container": DocsContainer
},
"controls": {
"disable": true,
},
"actions": {
"disable": true
},
"viewport": {
"viewports": {
"1440p": {
@ -74,7 +68,7 @@ export const parameters = {
"height": "844px",
},
},
"iphone5se": {
"iphone5se":{
"name": "Iphone 5/SE",
"styles": {
"width": "320px",
@ -103,23 +97,16 @@ export const parameters = {
},
};
export const decorators = [
(Story) => (
<CanvasContainer>
<Story />
</CanvasContainer>
),
];
const { getHardCodedWeight } = (() => {
const orderedPagesPrefix = [
"Introduction",
"login/login.ftl",
"login/register-user-profile.ftl",
"login/register.ftl",
"login/terms.ftl",
"login/error.ftl",
//"components/Header",
//"components/Footer",
"components/Alert",
"components/Tabs",
"components/Stepper",
"components/Button",
];
function getHardCodedWeight(kind) {

View File

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

157
README.md
View File

@ -14,61 +14,49 @@
<a href="https://github.com/garronej/keycloakify/blob/main/LICENSE">
<img src="https://img.shields.io/npm/l/keycloakify">
</a>
<a href="https://github.com/keycloakify/keycloakify/blob/729503fe31a155a823f46dd66ad4ff34ca274e0a/tsconfig.json#L14">
<img src="https://camo.githubusercontent.com/0f9fcc0ac1b8617ad4989364f60f78b2d6b32985ad6a508f215f14d8f897b8d3/68747470733a2f2f62616467656e2e6e65742f62616467652f547970655363726970742f7374726963742532302546302539462539322541412f626c7565">
</a>
<a href="https://github.com/thomasdarimont/awesome-keycloak">
<img src="https://awesome.re/mentioned-badge.svg"/>
</a>
<a href="https://discord.gg/kYFZG7fQmn">
<img src="https://img.shields.io/discord/1097708346976505977"/>
</a>
<p align="center">
<a href="https://www.keycloakify.dev">Home</a>
-
<a href="https://docs.keycloakify.dev">Documentation</a>
-
<a href="https://storybook.keycloakify.dev">Storybook</a>
<a href="https://storybook.keycloakify.dev/storybook">Storybook</a>
-
<a href="https://github.com/codegouvfr/keycloakify-starter">Starter project</a>
</p>
</p>
<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">
<i>Ultimately this build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
</p>
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** [and up](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)!
## 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.
We are exclusively sponsored by [Cloud IAM](https://www.cloud-iam.com), a French company offering Keycloak as a service.
Their dedicated support helps us continue the development and maintenance of this project.
[Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github) provides the following services:
[Cloud IAM](https://www.cloud-iam.com/) provides the following services:
- Simplify and secure your Keycloak Identity and Access Management. Keycloak as a Service.
- Perfectly configured and optimized Keycloak IAM, ready in seconds.
- Custom theme building for your brand using Keycloakify.
<div align="center">
![Logo Dark](https://user-images.githubusercontent.com/6702424/234135797-c84d0a90-0526-43e5-a186-70cbebdeb278.png#gh-dark-mode-only)
</div>
<div align="center">
![Logo Light](https://user-images.githubusercontent.com/6702424/234135799-68684c33-4ec5-48d4-8763-0f3922c86643.png#gh-light-mode-only)
</div>
<p align="center">
<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>
<a href="https://www.cloud-iam.com/">
<img src="https://user-images.githubusercontent.com/6702424/232165752-17134e68-4a55-4d6e-8672-e9132ecac5d5.svg" alt="Cloud IAM Logo" width="200"/>
</a>
<br/>
<i>Use promo code <code>keycloakify</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://www.cloud-iam.com/), for your support!
## Contributors ✨
@ -95,23 +83,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lazToum"><img src="https://avatars.githubusercontent.com/u/4764837?v=4?s=100" width="100px;" alt="Lazaros Toumanidis"/><br /><sub><b>Lazaros Toumanidis</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=lazToum" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/marcmrf"><img src="https://avatars.githubusercontent.com/u/9928519?v=4?s=100" width="100px;" alt="Marc"/><br /><sub><b>Marc</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=marcmrf" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://kasir-barati.github.io"><img src="https://avatars.githubusercontent.com/u/73785723?v=4?s=100" width="100px;" alt="Kasir Barati"/><br /><sub><b>Kasir Barati</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kasir-barati" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/asashay"><img src="https://avatars.githubusercontent.com/u/10714670?v=4?s=100" width="100px;" alt="Alex Oliynyk"/><br /><sub><b>Alex Oliynyk</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=asashay" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://www.gravitysoftware.be"><img src="https://avatars.githubusercontent.com/u/1140574?v=4?s=100" width="100px;" alt="Thomas Silvestre"/><br /><sub><b>Thomas Silvestre</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=thosil" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/satanshiro"><img src="https://avatars.githubusercontent.com/u/38865738?v=4?s=100" width="100px;" alt="satanshiro"/><br /><sub><b>satanshiro</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=satanshiro" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://poelhekke.dev"><img src="https://avatars.githubusercontent.com/u/1632377?v=4?s=100" width="100px;" alt="Koen Poelhekke"/><br /><sub><b>Koen Poelhekke</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kpoelhekke" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zavoloklom"><img src="https://avatars.githubusercontent.com/u/4151869?v=4?s=100" width="100px;" alt="Sergey Kupletsky"/><br /><sub><b>Sergey Kupletsky</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=zavoloklom" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=zavoloklom" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rome-user"><img src="https://avatars.githubusercontent.com/u/114131048?v=4?s=100" width="100px;" alt="rome-user"/><br /><sub><b>rome-user</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=rome-user" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/celinepelletier"><img src="https://avatars.githubusercontent.com/u/82821620?v=4?s=100" width="100px;" alt="Céline Pelletier"/><br /><sub><b>Céline Pelletier</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=celinepelletier" title="Code">💻</a></td>
<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>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/asashay"><img src="https://avatars.githubusercontent.com/u/10714670?v=4?s=100" width="100px;" alt="Alex Oliynyk"/><br /><sub><b>Alex Oliynyk</b></sub></a><br /> <a href="https://github.com/keycloakify/keycloakify/commits?author=asashay" title="Code">💻</a></td>
</tr>
</tbody>
</table>
@ -121,72 +93,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- ALL-CONTRIBUTORS-LIST:END -->
# Changelog highlights
## 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).
## 7.9
- Separate script for copying the default theme static assets to the public directory.
Theses assets are only needed for testing your theme locally in Storybook or with a `mockPageId`.
You are now expected to have a `"prepare": "copy-keycloak-resources-to-public",` in your package.json scripts.
This script will create `public/keycloak-assets` when you run `yarn install` (If you are using another package manager
like `pnpm` makes sure that `"prepare"` is actually ran.)
[See the updated starter](https://github.com/keycloakify/keycloakify-starter/blob/94532fcf10bf8b19e0873be8575fd28a8958a806/package.json#L11). `public/keycloak-assets` shouldn't be tracked by GIT and is automatically ignored.
## 7.7
## 7.7
- Better storybook support, see [the starter project](https://github.com/keycloakify/keycloakify-starter).
@ -270,79 +179,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.5.1",
"version": "7.7.1",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
@ -9,11 +9,12 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"prepare": "yarn generate-i18n-messages",
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc -p src/vite-plugin && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/ && cp -r src dist/",
"prepare": "yarn generate-i18n-messages && yarn copy-fonts",
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src/tsconfig.json && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/",
"build:watch": "tsc -p src/tsconfig.json && (concurrently \"tsc -p src/tsconfig.json -w\" \"tsc-alias -p src/tsconfig.json\")",
"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,17 +24,16 @@
"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"
"tsc-watch": "tsc -p src/bin -w & tsc -p src/lib -w",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook",
"copy-fonts": "copyfiles -u 2 .storybook/static/fonts/**/* stories/assets"
},
"bin": {
"copy-keycloak-resources-to-public": "dist/bin/copy-keycloak-resources-to-public.js",
"download-builtin-keycloak-theme": "dist/bin/download-builtin-keycloak-theme.js",
"eject-keycloak-page": "dist/bin/eject-keycloak-page.js",
"keycloakify": "dist/bin/keycloakify/index.js",
"initialize-email-theme": "dist/bin/initialize-email-theme.js",
"keycloakify": "dist/bin/keycloakify/index.js"
"download-builtin-keycloak-theme": "dist/bin/download-builtin-keycloak-theme.js",
"eject-keycloak-page": "dist/bin/eject-keycloak-page.js"
},
"lint-staged": {
"*.{ts,tsx,json,md}": [
@ -69,7 +69,6 @@
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@emotion/react": "^11.10.6",
"@storybook/addon-a11y": "^6.5.16",
"@storybook/addon-actions": "^6.5.13",
"@storybook/addon-essentials": "^6.5.13",
@ -79,20 +78,17 @@
"@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",
"concurrently": "^7.6.0",
"copyfiles": "^2.4.1",
"eslint-plugin-storybook": "^0.6.7",
"husky": "^4.3.8",
"lint-staged": "^11.0.0",
"powerhooks": "^0.26.7",
"prettier": "^2.3.0",
"properties-parser": "^0.3.1",
"react": "^18.2.0",
@ -102,30 +98,25 @@
"storybook-dark-mode": "^1.1.2",
"ts-node": "^10.9.1",
"tsc-alias": "^1.8.3",
"tss-react": "^4.8.2",
"typescript": "^4.9.1-beta",
"typescript": "^5.0.4",
"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",
"@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,8 @@ 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 { getCliOptions } from "../src/bin/tools/cliOptions";
import { getLogger } from "../src/bin/tools/logger";
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
@ -12,30 +13,20 @@ import { getLogger } from "../src/bin/tools/logger";
//@ts-ignore
const propertiesParser = require("properties-parser");
const isSilent = true;
const { isSilent } = getCliOptions(process.argv.slice(2));
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

@ -17,12 +17,10 @@ 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"),
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"))
});
@ -62,7 +60,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div>
</li>
)}
{referrer?.url && (
{referrer?.url !== undefined && (
<li>
<a href={referrer.url} id="referrer">
{msg("backTo", referrer.name)}

View File

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

View File

@ -211,9 +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",
"newPasswordSameAsOld": "New password must be different from the old one",
"passwordConfirmNotMatch": "Password confirmation does not match"
"notAValidOption": "Not a valid option"
},
"fr": {
/* spell-checker: disable */
@ -225,9 +223,7 @@ const keycloakifyExtraMessages = {
"logoutConfirmTitle": "Déconnexion",
"logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
"doLogout": "Se déconnecter",
"newPasswordSameAsOld": "Le nouveau mot de passe doit être différent de l'ancien",
"passwordConfirmNotMatch": "La confirmation du mot de passe ne correspond pas"
"doLogout": "Se déconnecter"
/* spell-checker: enable */
}
};

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,16 +1,12 @@
import type { AccountThemePageId } 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;
export declare namespace KcContext {
export type Common = {
themeVersion: string;
keycloakifyVersion: string;
themeType: "account";
themeName: string;
locale?: {
supported: {
url: string;
@ -30,8 +26,6 @@ export declare namespace KcContext {
resourceUrl: string;
resourcesCommonPath: string;
resourcesPath: string;
/** @deprecated, not present in recent keycloak version apparently, use kcContext.referrer instead */
referrerURI?: string;
getLogoutUrl: () => string;
};
features: {
@ -44,44 +38,18 @@ export declare namespace KcContext {
internationalizationEnabled: boolean;
userManagedAccessAllowed: boolean;
};
// Present only if redirected to account page with ?referrer=xxx&referrer_uri=http...
message?: {
type: "success" | "warning" | "error" | "info";
summary: string;
};
referrer?: {
url: string; // The url of the App
name: string; // Client id
url?: string;
name: string;
};
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: {
@ -103,6 +71,7 @@ export declare namespace KcContext {
export type Account = Common & {
pageId: "account.ftl";
url: {
referrerURI: string;
accountUrl: string;
};
realm: {
@ -113,15 +82,4 @@ export declare namespace KcContext {
};
}
{
type Got = KcContext["pageId"];
type Expected = AccountThemePageId;
type OnlyInGot = Exclude<Got, Expected>;
type OnlyInExpected = Exclude<Expected, Got>;
assert<Equals<OnlyInGot, never>>();
assert<Equals<OnlyInExpected, never>>();
}
assert<KcContext["themeType"] extends ThemeType ? true : false>();
assert<Equals<KcContext["pageId"], AccountThemePageId>>();

View File

@ -1,10 +1,14 @@
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 { mockTestingResourcesCommonPath } from "keycloakify/bin/mockTestingResourcesPath";
import { symToStr } from "tsafe/symToStr";
import { kcContextMocks, kcContextCommonMock } from "keycloakify/account/kcContext/kcContextMocks";
import { id } from "tsafe/id";
import { accountThemePageIds } from "keycloakify/bin/keycloakify/generateFtl/pageId";
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
@ -26,13 +30,13 @@ 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}.`,
`If assets are missing make sure you have built your Keycloak theme at least once.`
].join(" "),
"background: red; color: yellow; font-size: medium"
);
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
@ -89,10 +93,16 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
return { "kcContext": undefined as any };
}
if (realKcContext.themeType !== "account") {
if (id<readonly string[]>(accountThemePageIds).indexOf(realKcContext.pageId) < 0 && !("account" in realKcContext)) {
return { "kcContext": undefined as any };
}
{
const { url } = realKcContext;
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(mockTestingResourcesCommonPath));
}
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,19 +1,16 @@
import "minimal-polyfills/Object.fromEntries";
import { resources_common, keycloak_resources } from "keycloakify/bin/constants";
import { mockTestingResourcesCommonPath, mockTestingResourcesPath } 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, mockTestingResourcesPath),
"resourcesCommonPath": pathJoin(PUBLIC_URL, mockTestingResourcesCommonPath),
"resourceUrl": "#",
"accountUrl": "#",
"applicationsUrl": "#",
@ -133,6 +130,10 @@ export const kcContextCommonMock: KcContext.Common = {
],
"currentLanguageTag": "en"
},
"message": {
"type": "success",
"summary": "This is a test message"
},
"features": {
"authorization": true,
"identityFederation": true,

View File

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

View File

@ -4,7 +4,7 @@ import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function Account(props: PageProps<Extract<KcContext, { pageId: "account.ftl" }>, I18n>) {
export default function LogoutConfirm(props: PageProps<Extract<KcContext, { pageId: "account.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
@ -15,7 +15,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
}
});
const { url, realm, messagesPerField, stateChecker, account, referrer } = kcContext;
const { url, realm, messagesPerField, stateChecker, account } = kcContext;
const { msg } = i18n;
@ -51,7 +51,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
id="username"
name="username"
disabled={!realm.editUsernameAllowed}
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,14 +92,14 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
</div>
<div className="col-sm-10 col-md-10">
<input type="text" className="form-control" id="lastName" name="lastName" defaultValue={account.lastName ?? ""} />
<input type="text" className="form-control" id="lastName" name="lastName" value={account.lastName ?? ""} />
</div>
</div>
<div className="form-group">
<div id="kc-form-buttons" className="col-md-offset-2 col-md-10 submit">
<div>
{referrer !== undefined && <a href={referrer?.url}>{msg("backToApplication")}</a>}
{url.referrerURI !== undefined && <a href={url.referrerURI}>${msg("backToApplication")}</a>}
<button
type="submit"
className={clsx(

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

View File

@ -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,112 +0,0 @@
#!/usr/bin/env node
import { downloadKeycloakStaticResources, type BuildOptionsLike } 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 * as fs from "fs";
import { rmSync } from "./tools/fs.rmSync";
export async function copyKeycloakResourcesToPublic(params: { processArgv: string[] }) {
const { processArgv } = params;
const buildOptions = readBuildOptions({ processArgv });
const destDirPath = pathJoin(buildOptions.publicDirPath, keycloak_resources);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
const { keycloakifyBuildinfoRaw } = generateKeycloakifyBuildinfoRaw({
destDirPath,
"keycloakifyVersion": readThisNpmProjectVersion(),
buildOptions
});
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;
}
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
});
}
fs.writeFileSync(
pathJoin(destDirPath, "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(buildOptions.publicDirPath, keycloak_resources, ".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();
}

View File

@ -1,259 +1,48 @@
#!/usr/bin/env node
import { join as pathJoin } from "path";
import { downloadAndUnzip } from "./downloadAndUnzip";
import { downloadAndUnzip } from "./tools/downloadAndUnzip";
import { promptKeycloakVersion } from "./promptKeycloakVersion";
import { getCliOptions } from "./tools/cliOptions";
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"
});
}
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", "pficon"];
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")
);
}
{
const totpFtlFilePath = pathJoin(destDirPath, "base", "account", "totp.ftl");
fs.writeFileSync(
totpFtlFilePath,
Buffer.from(
fs
.readFileSync(totpFtlFilePath)
.toString("utf8")
.replace(
[
" <#list totp.policy.supportedApplications as app>",
" <li>${app}</li>",
" </#list>"
].join("\n"),
[
" <#if totp.policy.supportedApplications?has_content>",
" <#list totp.policy.supportedApplications as app>",
" <li>${app}</li>",
" </#list>",
" </#if>"
].join("\n")
),
"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({
"processArgv": process.argv.slice(2)
});
const logger = getLogger({ "isSilent": buildOptions.isSilent });
const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
const { keycloakVersion } = await promptKeycloakVersion();
const destDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources", "theme");
const destDirPath = pathJoin(
readBuildOptions({
"isSilent": true,
"isExternalAssetsCliParamProvided": false,
"projectDirPath": process.cwd()
}).keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme"
);
logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`);
await downloadBuiltinKeycloakTheme({
keycloakVersion,
destDirPath,
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,24 @@ 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 * as fs from "fs";
import { getCliOptions } from "./tools/cliOptions";
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({
"processArgv": process.argv.slice(2)
const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({
"projectDirPath": process.cwd()
});
const logger = getLogger({ "isSilent": buildOptions.isSilent });
if (emailThemeSrcDirPath === undefined) {
logger.warn("Couldn't locate your theme source directory");
const { themeSrcDirPath } = getThemeSrcDirPath({
"reactAppRootDirPath": buildOptions.reactAppRootDirPath
});
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
process.exit(-1);
}
if (fs.existsSync(emailThemeSrcDirPath)) {
logger.warn(`There is already a ${pathRelative(process.cwd(), emailThemeSrcDirPath)} directory in your project. Aborting.`);
@ -36,7 +36,7 @@ export async function main() {
await downloadBuiltinKeycloakTheme({
keycloakVersion,
"destDirPath": builtinKeycloakThemeTmpDirPath,
buildOptions
isSilent
});
transformCodebase({
@ -52,7 +52,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,226 @@
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";
/** Consolidated build option gathered form CLI arguments and config in package.json */
export type BuildOptions = BuildOptions.Standalone | BuildOptions.ExternalAssets;
export namespace BuildOptions {
export type Common = {
isSilent: boolean;
themeVersion: string;
themeName: string;
extraLoginPages: string[] | undefined;
extraAccountPages: string[] | undefined;
extraThemeProperties?: string[];
groupId: string;
artifactId: string;
bundler: Bundler;
keycloakVersionDefaultAssets: string;
/** Directory of your built react project. Defaults to {cwd}/build */
reactAppBuildDirPath: string;
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
keycloakifyBuildDirPath: string;
customUserAttributes: string[];
};
export type Standalone = Common & {
isStandalone: true;
urlPathname: string | undefined;
};
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
export namespace ExternalAssets {
export type CommonExternalAssets = Common & {
isStandalone: false;
};
export type SameDomain = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: true;
};
export type DifferentDomains = CommonExternalAssets & {
areAppAndKeycloakServerSharingSameDomain: false;
urlOrigin: string;
urlPathname: string | undefined;
};
}
}
export function readBuildOptions(params: { projectDirPath: string; isExternalAssetsCliParamProvided: boolean; isSilent: boolean }): BuildOptions {
const { projectDirPath, isExternalAssetsCliParamProvided, isSilent } = params;
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 } =
keycloakify ?? {};
const themeName =
keycloakify.themeName ??
name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-");
return {
themeName,
"bundler": (() => {
const { KEYCLOAKIFY_BUNDLER } = process.env;
assert(
typeGuard<Bundler | undefined>(
KEYCLOAKIFY_BUNDLER,
[undefined, ...id<readonly string[]>(bundlers)].includes(KEYCLOAKIFY_BUNDLER)
),
`${symToStr({ KEYCLOAKIFY_BUNDLER })} should be one of ${bundlers.join(", ")}`
);
return KEYCLOAKIFY_BUNDLER ?? bundler ?? "keycloakify";
})(),
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? artifactId ?? `${themeName}-keycloak-theme`,
"groupId": (() => {
const fallbackGroupId = `${themeName}.keycloak`;
return (
process.env.KEYCLOAKIFY_GROUP_ID ??
groupId ??
(!homepage
? fallbackGroupId
: urlParse(homepage)
.host?.replace(/:[0-9]+$/, "")
?.split(".")
.reverse()
.join(".") ?? fallbackGroupId) + ".keycloak"
);
})(),
"themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? process.env.KEYCLOAKIFY_VERSION ?? version ?? "0.0.0",
"extraLoginPages": [...(extraPages ?? []), ...(extraLoginPages ?? [])],
extraAccountPages,
extraThemeProperties,
isSilent,
"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,190 +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 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: cwd });
} 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.existsError('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.existsError('${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>
}
};
@ -404,18 +121,8 @@
out["keycloakifyVersion"] = "KEYCLOAKIFY_VERSION_xEdKd3xEdr";
out["themeVersion"] = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx";
out["themeType"] = "KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr";
out["themeName"] = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer";
out["pageId"] = "${pageId}";
try {
out["url"]["resourcesCommonPath"] = out["url"]["resourcesPath"] + "/" + "RESOURCES_COMMON_cLsLsMrtDkpVv";
} catch(error) {
}
return out;
})()
@ -462,16 +169,10 @@
<#-- https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
key == "loginAction" &&
are_same_path(path, ["url"]) &&
["saml-post-form.ftl", "error.ftl", "info.ftl", "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"]?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,32 +188,22 @@
"error.ftl" == pageId &&
are_same_path(path, ["realm"]) &&
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
) || (
"applications.ftl" == pageId &&
are_same_path(path, ["applications", "applications", "*", "client", "realm"])
) || (
"applications.ftl" == pageId &&
"masterAdminClient" == key
)
>
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#continue>
</#if>
<#-- 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>
@ -645,17 +336,6 @@
</#if>
<#local isDate = "">
<#attempt>
<#local isDate = object?is_date_like>
<#recover>
<#return "ABORT: Can't test if it's a date">
</#attempt>
<#if isDate>
<#return '"' + object?datetime?iso_utc + '"'>
</#if>
<#attempt>
<#return '"' + object?js_string + '"'>;
<#recover>

View File

@ -1,55 +1,82 @@
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 = {
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 } = 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 +98,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 +127,12 @@ 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_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion),
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
"<#if scripts??>",
" <#list scripts as script>",
@ -119,34 +145,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",
@ -23,8 +21,7 @@ export const loginThemePageIds = [
"update-user-profile.ftl",
"idp-review-user-profile.ftl",
"update-email.ftl",
"select-authenticator.ftl",
"saml-post-form.ftl"
"select-authenticator.ftl"
] as const;
export const accountThemePageIds = ["password.ftl", "account.ftl"] as const;

View File

@ -0,0 +1,88 @@
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;
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, 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": [
{
"name": themeName,
"types": [...themeTypes, ...(doBundlesEmailTemplate ? ["email"] : [])]
}
]
},
null,
2
),
"utf8"
)
);
}
return {
"jarFilePath": pathJoin(keycloakThemeBuildingDirPath, "target", `${artifactId}-${themeVersion}.jar`)
};
}

View File

@ -0,0 +1,241 @@
import { transformCodebase } from "../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin, basename as pathBasename } from "path";
import { replaceImportsFromStaticInJsCode } from "./replacers/replaceImportsFromStaticInJsCode";
import { replaceImportsInCssCode } from "./replacers/replaceImportsInCssCode";
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds, themeTypes, type ThemeType } from "./generateFtl";
import { downloadBuiltinKeycloakTheme } from "../download-builtin-keycloak-theme";
import { mockTestingResourcesCommonPath, mockTestingResourcesPath, mockTestingSubDirOfPublicDirBasename } from "../mockTestingResourcesPath";
import { isInside } from "../tools/isInside";
import type { BuildOptions } from "./BuildOptions";
import { assert } from "tsafe/assert";
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;
};
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 generateKeycloakThemeResources(params: {
reactAppBuildDirPath: string;
keycloakThemeBuildingDirPath: string;
emailThemeSrcDirPath: string | undefined;
keycloakVersion: string;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
}): Promise<{ doBundlesEmailTemplate: boolean }> {
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, emailThemeSrcDirPath, keycloakVersion, buildOptions, keycloakifyVersion } = params;
const getThemeDirPath = (themeType: ThemeType | "email") =>
pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType);
let allCssGlobalsToDefine: Record<string, string> = {};
let generateFtlFilesCode_glob: ReturnType<typeof generateFtlFilesCodeFactory>["generateFtlFilesCode"] | undefined = undefined;
for (const themeType of themeTypes) {
const themeDirPath = getThemeDirPath(themeType);
copy_app_resources_to_theme_path: {
const isFirstPass = themeType.indexOf(themeType) === 0;
if (!isFirstPass && !buildOptions.isStandalone) {
break copy_app_resources_to_theme_path;
}
transformCodebase({
"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/
if (
buildOptions.isStandalone &&
isInside({
"dirPath": pathJoin(reactAppBuildDirPath, mockTestingSubDirOfPublicDirBasename),
filePath
})
) {
return undefined;
}
if (/\.css?$/i.test(filePath)) {
if (!buildOptions.isStandalone) {
return undefined;
}
const { cssGlobalsToDefine, fixedCssCode } = replaceImportsInCssCode({
"cssCode": sourceCode.toString("utf8")
});
register_css_variables: {
if (!isFirstPass) {
break register_css_variables;
}
allCssGlobalsToDefine = {
...allCssGlobalsToDefine,
...cssGlobalsToDefine
};
}
return { "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8") };
}
if (/\.js?$/i.test(filePath)) {
if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) {
return undefined;
}
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": sourceCode.toString("utf8"),
buildOptions
});
return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
}
return buildOptions.isStandalone ? { "modifiedSourceCode": sourceCode } : undefined;
}
});
}
const generateFtlFilesCode = (() => {
if (generateFtlFilesCode_glob !== undefined) {
return generateFtlFilesCode_glob;
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
"cssGlobalsToDefine": allCssGlobalsToDefine,
buildOptions,
keycloakifyVersion
});
return generateFtlFilesCode;
})();
[
...(() => {
switch (themeType) {
case "login":
return loginThemePageIds;
case "account":
return accountThemePageIds;
}
})(),
...((() => {
switch (themeType) {
case "login":
return buildOptions.extraLoginPages;
case "account":
return buildOptions.extraAccountPages;
}
})() ?? [])
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.mkdirSync(themeDirPath, { "recursive": true });
fs.writeFileSync(pathJoin(themeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
});
{
const tmpDirPath = pathJoin(themeDirPath, "..", "tmp_xxKdLpdIdLd");
await downloadBuiltinKeycloakTheme({
keycloakVersion,
"destDirPath": tmpDirPath,
isSilent: buildOptions.isSilent
});
const themeResourcesDirPath = pathJoin(themeDirPath, "resources");
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", themeType, "resources"),
"destDirPath": themeResourcesDirPath
});
const reactAppPublicDirPath = pathJoin(reactAppBuildDirPath, "..", "public");
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"),
"destDirPath": pathJoin(themeResourcesDirPath, pathBasename(mockTestingResourcesCommonPath))
});
transformCodebase({
"srcDirPath": themeResourcesDirPath,
"destDirPath": pathJoin(reactAppPublicDirPath, mockTestingResourcesPath)
});
const keycloakResourcesWithinPublicDirPath = pathJoin(reactAppPublicDirPath, mockTestingSubDirOfPublicDirBasename);
fs.writeFileSync(
pathJoin(keycloakResourcesWithinPublicDirPath, "README.txt"),
Buffer.from(
["This is just a test folder that helps develop", "the login and register page without having to run a Keycloak container"].join(
" "
)
)
);
fs.writeFileSync(pathJoin(keycloakResourcesWithinPublicDirPath, ".gitignore"), Buffer.from("*", "utf8"));
fs.rmSync(tmpDirPath, { recursive: true, force: true });
}
fs.writeFileSync(
pathJoin(themeDirPath, "theme.properties"),
Buffer.from(["parent=keycloak", ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8")
);
}
let doBundlesEmailTemplate: boolean;
email: {
if (emailThemeSrcDirPath === undefined) {
doBundlesEmailTemplate = false;
break email;
}
doBundlesEmailTemplate = true;
transformCodebase({
"srcDirPath": emailThemeSrcDirPath,
"destDirPath": getThemeDirPath("email")
});
}
return { doBundlesEmailTemplate };
}

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,11 @@
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;
};
{
@ -19,43 +19,39 @@ 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;
export function generateStartKeycloakTestingContainer(params: {
keycloakVersion: string;
keycloakThemeBuildingDirPath: string;
buildOptions: BuildOptionsLike;
}) {
const {
keycloakThemeBuildingDirPath,
keycloakVersion,
buildOptions: { themeName }
} = params;
const themeRelativeDirPath = pathJoin("src", "main", "resources", "theme");
const themeDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, themeRelativeDirPath);
const keycloakThemePath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace(/\\/g, "/");
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 \\",
` -v "${keycloakThemePath}":"/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 +0,0 @@
import { transformCodebase } from "../../tools/transformCodebase";
import { join as pathJoin } from "path";
import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme";
import { resources_common, type ThemeType } from "../../constants";
import { BuildOptions } from "../buildOptions";
import { assert } from "tsafe/assert";
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;
const tmpDirPath = pathJoin(
themeDirPath,
`tmp_suLeKsxId_${crypto.createHash("sha256").update(`${themeType}-${keycloakVersion}`).digest("hex").slice(0, 8)}`
);
await downloadBuiltinKeycloakTheme({
keycloakVersion,
"destDirPath": tmpDirPath,
buildOptions
});
const resourcesPath = pathJoin(themeDirPath, themeType, "resources");
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", themeType, "resources"),
"destDirPath": resourcesPath
});
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"),
"destDirPath": pathJoin(resourcesPath, resources_common)
});
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,331 +0,0 @@
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 { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds } from "../generateFtl";
import {
type ThemeType,
lastKeycloakVersionWithAccountV1,
keycloak_resources,
retrocompatPostfix,
accountV1ThemeName,
basenameOfTheKeycloakifyResourcesDir
} from "../../constants";
import { isInside } from "../../tools/isInside";
import type { BuildOptions } from "../buildOptions";
import { assert, type Equals } 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;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function generateTheme(params: {
themeName: string;
themeSrcDirPath: string;
keycloakifySrcDirPath: string;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
}): Promise<void> {
const { themeName, themeSrcDirPath, keycloakifySrcDirPath, 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 cssGlobalsToDefine: Record<string, string> = {};
const implementedThemeTypes: Record<ThemeType | "email", boolean> = {
"login": false,
"account": false,
"email": false
};
for (const themeType of ["login", "account"] as const) {
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
continue;
}
implementedThemeTypes[themeType] = true;
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;
}
transformCodebase({
"srcDirPath": buildOptions.reactAppBuildDirPath,
destDirPath,
"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 (
isInside({
"dirPath": pathJoin(buildOptions.reactAppBuildDirPath, keycloak_resources),
filePath
})
) {
return undefined;
}
if (/\.css?$/i.test(filePath)) {
const { cssGlobalsToDefine: cssGlobalsToDefineForThisFile, fixedCssCode } = replaceImportsInCssCode({
"cssCode": sourceCode.toString("utf8")
});
Object.entries(cssGlobalsToDefineForThisFile).forEach(([key, value]) => {
cssGlobalsToDefine[key] = value;
});
return { "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8") };
}
if (/\.js?$/i.test(filePath)) {
const { fixedJsCode } = replaceImportsInJsCode({
"jsCode": sourceCode.toString("utf8"),
buildOptions
});
return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
}
return { "modifiedSourceCode": sourceCode };
}
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
"indexHtmlCode": fs.readFileSync(pathJoin(buildOptions.reactAppBuildDirPath, "index.html")).toString("utf8"),
cssGlobalsToDefine,
buildOptions,
keycloakifyVersion,
themeType,
"fieldNames": readFieldNameUsage({
keycloakifySrcDirPath,
themeSrcDirPath,
themeType
})
});
[
...(() => {
switch (themeType) {
case "login":
return loginThemePageIds;
case "account":
return accountThemePageIds;
}
})(),
...readExtraPagesNames({
themeType,
themeSrcDirPath
})
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.mkdirSync(themeTypeDirPath, { "recursive": true });
fs.writeFileSync(pathJoin(themeTypeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
});
generateMessageProperties({
themeSrcDirPath,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), { "recursive": true });
const propertiesFilePath = pathJoin(messagesDirPath, `messages_${languageTag}.properties`);
fs.writeFileSync(propertiesFilePath, Buffer.from(propertiesFileSource, "utf8"));
});
await downloadKeycloakStaticResources({
"keycloakVersion": (() => {
switch (themeType) {
case "account":
return lastKeycloakVersionWithAccountV1;
case "login":
return buildOptions.loginThemeResourcesFromKeycloakVersion;
}
})(),
"themeDirPath": pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildOptions
});
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"
)
);
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");
if (!fs.existsSync(emailThemeSrcDirPath)) {
break email;
}
implementedThemeTypes.email = true;
transformCodebase({
"srcDirPath": emailThemeSrcDirPath,
"destDirPath": getThemeTypeDirPath({ "themeType": "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"));
}
}

View File

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

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,120 +1,147 @@
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 { generateKeycloakThemeResources } from "./generateKeycloakThemeResources";
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 { getCliOptions } from "../tools/cliOptions";
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() {
const buildOptions = readBuildOptions({
"processArgv": process.argv.slice(2)
});
const logger = getLogger({ "isSilent": buildOptions.isSilent });
const { isSilent, hasExternalAssets } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
logger.log("🔏 Building the keycloak theme...⌚");
const { themeSrcDirPath } = getThemeSrcDirPath({ "reactAppRootDirPath": buildOptions.reactAppRootDirPath });
const projectDirPath = process.cwd();
for (const themeName of buildOptions.themeNames) {
await generateTheme({
themeName,
themeSrcDirPath,
"keycloakifySrcDirPath": pathJoin(getThisCodebaseRootDirPath(), "src"),
"keycloakifyVersion": readThisNpmProjectVersion(),
buildOptions
});
}
const buildOptions = readBuildOptions({
projectDirPath,
"isExternalAssetsCliParamProvided": hasExternalAssets,
"isSilent": isSilent
});
{
const { pomFileCode } = generatePom({ buildOptions });
const { doBundlesEmailTemplate } = await generateKeycloakThemeResources({
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
"emailThemeSrcDirPath": (() => {
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({ projectDirPath });
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8"));
}
if (emailThemeSrcDirPath === undefined || !fs.existsSync(emailThemeSrcDirPath)) {
return;
}
const containerKeycloakVersion = "23.0.6";
return emailThemeSrcDirPath;
})(),
"reactAppBuildDirPath": buildOptions.reactAppBuildDirPath,
buildOptions,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
"keycloakifyVersion": (() => {
const version = JSON.parse(fs.readFileSync(pathJoin(getProjectRoot(), "package.json")).toString("utf8"))["version"];
const jarFilePath = pathJoin(buildOptions.keycloakifyBuildDirPath, "target", `${buildOptions.artifactId}-${buildOptions.themeVersion}.jar`);
assert(typeof version === "string");
generateStartKeycloakTestingContainer({
"keycloakVersion": containerKeycloakVersion,
jarFilePath,
return version;
})()
});
const { jarFilePath } = generateJavaStackFiles({
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
doBundlesEmailTemplate,
buildOptions
});
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, ".gitignore"), Buffer.from("*", "utf8"));
child_process.execSync("npx vite", {
"cwd": buildOptions.reactAppRootDirPath,
"env": {
...process.env,
[keycloakifyBuildOptionsForPostPostBuildScriptEnvName]: JSON.stringify(buildOptions)
}
});
create_jar: {
if (!buildOptions.doCreateJar) {
break create_jar;
}
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"
)
);
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": pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources"),
"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);
}
// We want, however, to test in a container running the latest Keycloak version
const containerKeycloakVersion = "20.0.1";
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)`,
`- 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]}`,
` Save (button at the bottom of the page)`,
`- Create a realm: myrealm`,
`- Enable registration: Realm settings -> Login tab -> User registration: on`,
`- Enable the Account theme: Realm settings -> Themes tab -> Account theme, select ${buildOptions.themeName} `,
`- Create a client id myclient`,
` Root URL: https://www.keycloak.org/app/`,
` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
` Web origins: *`,
` Login Theme: ${buildOptions.themeName}`,
` Save (button at the bottom of the page)`,
``,
`- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`,
`- Got to 👉 http://localhost:8080/realms/myrealm/account 👈 to see your account theme`,

View File

@ -0,0 +1,64 @@
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;
};
};
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()
})
.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 mockTestingSubDirOfPublicDirBasename = "keycloak_static";
export const mockTestingResourcesPath = pathJoin(mockTestingSubDirOfPublicDirBasename, "resources");
export const mockTestingResourcesCommonPath = pathJoin(mockTestingResourcesPath, "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,15 +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": 10,
"doIgnoreBeta": true,
"owner": "keycloak",
"repo": "keycloak"
}).then(arr => arr.map(({ tag }) => tag))),
lastKeycloakVersionWithAccountV1,
"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

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

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,87 @@
import { exec as execCallback } from "child_process";
import { createHash } from "crypto";
import { mkdir, 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;
}
}
/**
* Get npm configuration as map
*/
async function getNmpConfig(): Promise<Record<string, string>> {
const { stdout } = await exec("npm config get", { encoding: "utf8" });
return stdout
.split("\n")
.filter(line => !line.startsWith(";"))
.map(line => line.trim())
.map(line => line.split("=", 2))
.reduce((cfg, [key, value]) => ({ ...cfg, [key]: value }), {});
}
/**
* Get proxy configuration from npm config files. Note that we don't care about
* proxy config in env vars, because make-fetch-happen will do that for us.
*
* @returns proxy configuration
*/
async function getNpmProxyConfig(): Promise<Pick<FetchOptions, "proxy" | "noProxy">> {
const cfg = await getNmpConfig();
const proxy = cfg["https-proxy"] ?? cfg["proxy"];
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
return { proxy, noProxy };
}
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 proxyOpts = await getNpmProxyConfig();
const response = await fetch(url, proxyOpts);
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);

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

@ -0,0 +1,87 @@
import { dirname, relative, sep } 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 && !entry.fsPath.endsWith(sep)) {
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.
*/
export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) {
await mkdir(dirname(targetPath), { recursive: true });
const asyncPathGeneratorFn = async function* (): ZipEntryGenerator {
for await (const fsPath of walk(rootPath)) {
const zipPath = relative(rootPath, fsPath).split(sep).join("/");
yield { fsPath, zipPath };
}
};
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

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

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}/`;
}
throw new Error("Bundler not supported");
})();

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,15 +1,21 @@
import { useReducer, useEffect } from "react";
import { headInsert } from "keycloakify/tools/headInsert";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { clsx } from "keycloakify/tools/clsx";
export function usePrepareTemplate(params: {
doFetchDefaultThemeResources: boolean;
stylesCommon?: string[];
styles?: string[];
scripts?: string[];
url: {
resourcesCommonPath: string;
resourcesPath: string;
};
htmlClassName: string | undefined;
bodyClassName: string | undefined;
}) {
const { doFetchDefaultThemeResources, styles = [], scripts = [], htmlClassName, bodyClassName } = params;
const { doFetchDefaultThemeResources, stylesCommon, styles, url, scripts, htmlClassName, bodyClassName } = params;
const [isReady, setReady] = useReducer(() => true, !doFetchDefaultThemeResources);
@ -20,41 +26,36 @@ export function usePrepareTemplate(params: {
let isUnmounted = false;
const removeArray: (() => void)[] = [];
(async () => {
for (const style of [...styles].reverse()) {
const { prLoaded, remove } = headInsert({
"type": "css",
"position": "prepend",
"href": style
});
removeArray.push(remove);
// TODO: Find a way to do that in parallel (without breaking the order)
await prLoaded;
if (isUnmounted) {
return;
}
Promise.all(
[
...(stylesCommon ?? []).map(relativePath => pathJoin(url.resourcesCommonPath, relativePath)),
...(styles ?? []).map(relativePath => pathJoin(url.resourcesPath, relativePath))
]
.reverse()
.map(href =>
headInsert({
"type": "css",
href,
"position": "prepend"
})
)
).then(() => {
if (isUnmounted) {
return;
}
setReady();
})();
scripts.forEach(src => {
const { remove } = headInsert({
"type": "javascript",
src
});
removeArray.push(remove);
});
(scripts ?? []).forEach(relativePath =>
headInsert({
"type": "javascript",
"src": pathJoin(url.resourcesPath, relativePath)
})
);
return () => {
isUnmounted = true;
removeArray.forEach(remove => 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"));
@ -29,7 +27,6 @@ const UpdateUserProfile = lazy(() => import("keycloakify/login/pages/UpdateUserP
const IdpReviewUserProfile = lazy(() => import("keycloakify/login/pages/IdpReviewUserProfile"));
const UpdateEmail = lazy(() => import("keycloakify/login/pages/UpdateEmail"));
const SelectAuthenticator = lazy(() => import("keycloakify/login/pages/SelectAuthenticator"));
const SamlPostForm = lazy(() => import("keycloakify/login/pages/SamlPostForm"));
export default function Fallback(props: PageProps<KcContext, I18n>) {
const { kcContext, ...rest } = props;
@ -54,10 +51,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":
@ -88,8 +81,6 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
return <UpdateEmail kcContext={kcContext} {...rest} />;
case "select-authenticator.ftl":
return <SelectAuthenticator kcContext={kcContext} {...rest} />;
case "saml-post-form.ftl":
return <SamlPostForm kcContext={kcContext} {...rest} />;
}
assert<Equals<typeof kcContext, never>>(false);
})()}

View File

@ -31,22 +31,21 @@ 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")
"bodyClassName": undefined
});
if (!isReady) {
return null;
}
document.title = i18n.msgStr("loginTitle", kcContext.realm.displayName);
return (
<div className={getClassName("kcLoginClass")}>
<div id="kc-header" className={getClassName("kcHeaderClass")}>

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";

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